mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Add a durable H5 access control surface for remote browser setup
Persist H5 access state in cc-haha managed settings, expose a narrow server API for enable/disable/regenerate/verify flows, and keep token responses sanitized so the raw secret is only returned at generation time. Constraint: H5 config must live in ~/.claude/cc-haha/settings.json and preserve unknown fields Rejected: Store raw token for later display | violates hash-only persistence requirement Confidence: high Scope-risk: narrow Directive: Do not move H5 settings into ~/.claude/settings.json or expose tokenHash through the API Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts Not-tested: Full repo typecheck via tsc is blocked locally by missing bun-types and a TS 6 baseUrl deprecation in the current config
This commit is contained in:
parent
5f6547450f
commit
e357c978fe
170
src/server/__tests__/h5-access-api.test.ts
Normal file
170
src/server/__tests__/h5-access-api.test.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { handleApiRequest } from '../router.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
async function api(
|
||||
method: string,
|
||||
pathname: string,
|
||||
options: {
|
||||
body?: Record<string, unknown>
|
||||
bearerToken?: string
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const url = new URL(pathname, 'http://localhost:3456')
|
||||
const headers: Record<string, string> = {}
|
||||
|
||||
if (options.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (options.bearerToken) {
|
||||
headers.Authorization = `Bearer ${options.bearerToken}`
|
||||
}
|
||||
|
||||
return handleApiRequest(
|
||||
new Request(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
}),
|
||||
url,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-api-test-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('/api/h5-access', () => {
|
||||
test('GET returns sanitized disabled status by default', async () => {
|
||||
const response = await api('GET', '/api/h5-access')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('enable returns raw token once and status stays sanitized', async () => {
|
||||
const enableResponse = await api('POST', '/api/h5-access/enable')
|
||||
expect(enableResponse.status).toBe(200)
|
||||
|
||||
const enablePayload = await enableResponse.json() as {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
tokenPreview: string
|
||||
}
|
||||
token: string
|
||||
}
|
||||
|
||||
expect(enablePayload.settings.enabled).toBe(true)
|
||||
expect(enablePayload.token).toMatch(/^h5_/)
|
||||
|
||||
const statusResponse = await api('GET', '/api/h5-access')
|
||||
expect(statusResponse.status).toBe(200)
|
||||
const statusPayload = await statusResponse.json() as {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
tokenPreview: string | null
|
||||
}
|
||||
token?: string
|
||||
}
|
||||
|
||||
expect(statusPayload.settings.enabled).toBe(true)
|
||||
expect(statusPayload.settings.tokenPreview).toBe(enablePayload.settings.tokenPreview)
|
||||
expect(statusPayload.token).toBeUndefined()
|
||||
})
|
||||
|
||||
test('verify accepts a good bearer token and rejects missing or bad tokens', async () => {
|
||||
const enableResponse = await api('POST', '/api/h5-access/enable')
|
||||
const enablePayload = await enableResponse.json() as { token: string }
|
||||
|
||||
expect(
|
||||
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
|
||||
).toMatchObject({ status: 200 })
|
||||
expect(await api('POST', '/api/h5-access/verify')).toMatchObject({ status: 401 })
|
||||
expect(
|
||||
await api('POST', '/api/h5-access/verify', { bearerToken: 'bad-token' }),
|
||||
).toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
test('regenerate returns a new token and invalidates the previous one', async () => {
|
||||
const enableResponse = await api('POST', '/api/h5-access/enable')
|
||||
const enablePayload = await enableResponse.json() as { token: string }
|
||||
|
||||
const regenerateResponse = await api('POST', '/api/h5-access/regenerate')
|
||||
expect(regenerateResponse.status).toBe(200)
|
||||
const regeneratePayload = await regenerateResponse.json() as {
|
||||
settings: {
|
||||
enabled: boolean
|
||||
}
|
||||
token: string
|
||||
}
|
||||
|
||||
expect(regeneratePayload.settings.enabled).toBe(true)
|
||||
expect(regeneratePayload.token).toMatch(/^h5_/)
|
||||
expect(regeneratePayload.token).not.toBe(enablePayload.token)
|
||||
expect(
|
||||
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
|
||||
).toMatchObject({ status: 401 })
|
||||
expect(
|
||||
await api('POST', '/api/h5-access/verify', { bearerToken: regeneratePayload.token }),
|
||||
).toMatchObject({ status: 200 })
|
||||
})
|
||||
|
||||
test('disable clears access and causes the old token to fail verification', async () => {
|
||||
const enableResponse = await api('POST', '/api/h5-access/enable')
|
||||
const enablePayload = await enableResponse.json() as { token: string }
|
||||
|
||||
const disableResponse = await api('POST', '/api/h5-access/disable')
|
||||
expect(disableResponse.status).toBe(200)
|
||||
await expect(disableResponse.json()).resolves.toEqual({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
|
||||
).toMatchObject({ status: 401 })
|
||||
})
|
||||
|
||||
test('PUT updates sanitized settings', async () => {
|
||||
const response = await api('PUT', '/api/h5-access', {
|
||||
body: {
|
||||
allowedOrigins: ['https://example.com/path'],
|
||||
publicBaseUrl: 'https://public.example.com/app/',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: ['https://example.com'],
|
||||
publicBaseUrl: 'https://public.example.com/app',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
157
src/server/__tests__/h5-access-service.test.ts
Normal file
157
src/server/__tests__/h5-access-service.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { H5AccessService } from '../services/h5AccessService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
function getManagedSettingsPath(): string {
|
||||
return path.join(tmpDir, 'cc-haha', 'settings.json')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-service-test-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('H5AccessService', () => {
|
||||
test('defaults to disabled state with sanitized settings', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
await expect(service.getSettings()).resolves.toEqual({
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
})
|
||||
|
||||
await expect(service.validateToken('missing-token')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
test('enable generates a token and persists only hash plus preview', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
const result = await service.enable()
|
||||
const raw = await fs.readFile(getManagedSettingsPath(), 'utf-8')
|
||||
const saved = JSON.parse(raw) as {
|
||||
h5Access: {
|
||||
enabled: boolean
|
||||
tokenHash: string
|
||||
tokenPreview: string
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.token).toMatch(/^h5_[A-Za-z0-9_-]{43}$/)
|
||||
expect(result.settings).toEqual({
|
||||
enabled: true,
|
||||
tokenPreview: saved.h5Access.tokenPreview,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
})
|
||||
expect(saved.h5Access.enabled).toBe(true)
|
||||
expect(saved.h5Access.tokenHash).toHaveLength(64)
|
||||
expect(saved.h5Access.tokenPreview).toBe(
|
||||
`${result.token.slice(0, 7)}...${result.token.slice(-4)}`,
|
||||
)
|
||||
expect(raw).not.toContain(result.token)
|
||||
expect(await service.validateToken(result.token)).toBe(true)
|
||||
})
|
||||
|
||||
test('regenerateToken invalidates the previous token', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
const first = await service.enable()
|
||||
const second = await service.regenerateToken()
|
||||
|
||||
expect(second.token).toMatch(/^h5_/)
|
||||
expect(second.token).not.toBe(first.token)
|
||||
expect(await service.validateToken(first.token)).toBe(false)
|
||||
expect(await service.validateToken(second.token)).toBe(true)
|
||||
})
|
||||
|
||||
test('preserves unknown managed settings fields when updating h5Access', async () => {
|
||||
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
|
||||
await fs.writeFile(
|
||||
getManagedSettingsPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_MODEL: 'keep-me',
|
||||
},
|
||||
futureField: {
|
||||
keep: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const service = new H5AccessService()
|
||||
await service.enable()
|
||||
|
||||
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
|
||||
env: {
|
||||
ANTHROPIC_MODEL: string
|
||||
}
|
||||
futureField: {
|
||||
keep: boolean
|
||||
}
|
||||
h5Access: unknown
|
||||
}
|
||||
|
||||
expect(saved.env.ANTHROPIC_MODEL).toBe('keep-me')
|
||||
expect(saved.futureField).toEqual({ keep: true })
|
||||
expect(saved.h5Access).toBeDefined()
|
||||
})
|
||||
|
||||
test('updateSettings normalizes origins and rejects invalid ones', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
await expect(
|
||||
service.updateSettings({
|
||||
allowedOrigins: ['https://example.com/path', 'http://localhost:3000/foo'],
|
||||
publicBaseUrl: 'https://public.example.com/app/',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
|
||||
publicBaseUrl: 'https://public.example.com/app',
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.updateSettings({
|
||||
allowedOrigins: ['https://*.example.com'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
})
|
||||
})
|
||||
|
||||
test('isOriginAllowed requires enabled state and matches normalized origins', async () => {
|
||||
const service = new H5AccessService()
|
||||
|
||||
await service.updateSettings({
|
||||
allowedOrigins: ['https://example.com/path'],
|
||||
})
|
||||
|
||||
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
|
||||
|
||||
await service.enable()
|
||||
|
||||
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(true)
|
||||
await expect(service.isOriginAllowed('https://other.example.com')).resolves.toBe(false)
|
||||
await expect(service.isOriginAllowed('notaurl')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
96
src/server/api/h5-access.ts
Normal file
96
src/server/api/h5-access.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { H5AccessService } from '../services/h5AccessService.js'
|
||||
|
||||
const h5AccessService = new H5AccessService()
|
||||
|
||||
function methodNotAllowed(method: string, route: string): ApiError {
|
||||
return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
|
||||
function getBearerToken(req: Request): string | null {
|
||||
const authorization = req.headers.get('authorization')
|
||||
if (!authorization) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const body = await req.json()
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
return body as Record<string, unknown>
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error
|
||||
}
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleH5AccessApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const sub = segments[2]
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
if (req.method === 'GET') {
|
||||
return Response.json({ settings: await h5AccessService.getSettings() })
|
||||
}
|
||||
if (req.method === 'PUT') {
|
||||
const body = await parseJsonBody(req)
|
||||
const settings = await h5AccessService.updateSettings({
|
||||
allowedOrigins: body.allowedOrigins as string[] | undefined,
|
||||
publicBaseUrl: body.publicBaseUrl as string | null | undefined,
|
||||
})
|
||||
return Response.json({ settings })
|
||||
}
|
||||
throw methodNotAllowed(req.method, '/api/h5-access')
|
||||
|
||||
case 'enable':
|
||||
if (req.method !== 'POST') {
|
||||
throw methodNotAllowed(req.method, '/api/h5-access/enable')
|
||||
}
|
||||
return Response.json(await h5AccessService.enable())
|
||||
|
||||
case 'disable':
|
||||
if (req.method !== 'POST') {
|
||||
throw methodNotAllowed(req.method, '/api/h5-access/disable')
|
||||
}
|
||||
return Response.json({ settings: await h5AccessService.disable() })
|
||||
|
||||
case 'regenerate':
|
||||
if (req.method !== 'POST') {
|
||||
throw methodNotAllowed(req.method, '/api/h5-access/regenerate')
|
||||
}
|
||||
return Response.json(await h5AccessService.regenerateToken())
|
||||
|
||||
case 'verify': {
|
||||
if (req.method !== 'POST') {
|
||||
throw methodNotAllowed(req.method, '/api/h5-access/verify')
|
||||
}
|
||||
|
||||
const token = getBearerToken(req)
|
||||
const isValid = await h5AccessService.validateToken(token)
|
||||
if (!isValid) {
|
||||
throw new ApiError(401, 'Invalid or missing H5 access token', 'UNAUTHORIZED')
|
||||
}
|
||||
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown h5-access endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
import { handleDoctorApi } from './api/doctor.js'
|
||||
import { handleH5AccessApi } from './api/h5-access.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'doctor':
|
||||
return handleDoctorApi(req, url, segments)
|
||||
|
||||
case 'h5-access':
|
||||
return handleH5AccessApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
344
src/server/services/h5AccessService.ts
Normal file
344
src/server/services/h5AccessService.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||||
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
||||
|
||||
export type H5AccessSettings = {
|
||||
enabled: boolean
|
||||
tokenPreview: string | null
|
||||
allowedOrigins: string[]
|
||||
publicBaseUrl: string | null
|
||||
}
|
||||
|
||||
export type H5AccessEnableResult = {
|
||||
settings: H5AccessSettings
|
||||
token: string
|
||||
}
|
||||
|
||||
type StoredH5AccessSettings = H5AccessSettings & {
|
||||
tokenHash: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_STORED_SETTINGS: StoredH5AccessSettings = {
|
||||
enabled: false,
|
||||
tokenHash: null,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings {
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
tokenPreview: settings.tokenPreview,
|
||||
allowedOrigins: settings.allowedOrigins,
|
||||
publicBaseUrl: settings.publicBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex')
|
||||
}
|
||||
|
||||
function createToken(): string {
|
||||
return `h5_${randomBytes(32).toString('base64url')}`
|
||||
}
|
||||
|
||||
function createTokenPreview(token: string): string {
|
||||
return `${token.slice(0, 7)}...${token.slice(-4)}`
|
||||
}
|
||||
|
||||
function normalizeOriginInput(origin: string, fieldName = 'allowedOrigins'): string {
|
||||
if (origin.includes('*')) {
|
||||
throw ApiError.badRequest(`${fieldName} must not contain wildcard origins`)
|
||||
}
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(origin)
|
||||
} catch {
|
||||
throw ApiError.badRequest(`Invalid origin: ${origin}`)
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw ApiError.badRequest(`Invalid origin protocol: ${origin}`)
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw ApiError.badRequest(`Invalid origin credentials: ${origin}`)
|
||||
}
|
||||
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeAllowedOrigins(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) {
|
||||
throw ApiError.badRequest('allowedOrigins must be an array of strings')
|
||||
}
|
||||
|
||||
const normalized = input.map((origin) => {
|
||||
if (typeof origin !== 'string') {
|
||||
throw ApiError.badRequest('allowedOrigins must be an array of strings')
|
||||
}
|
||||
return normalizeOriginInput(origin)
|
||||
})
|
||||
|
||||
return [...new Set(normalized)]
|
||||
}
|
||||
|
||||
function normalizePublicBaseUrl(input: unknown): string | null {
|
||||
if (input === null || input === undefined || input === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof input !== 'string') {
|
||||
throw ApiError.badRequest('publicBaseUrl must be a string or null')
|
||||
}
|
||||
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(input)
|
||||
} catch {
|
||||
throw ApiError.badRequest(`Invalid publicBaseUrl: ${input}`)
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw ApiError.badRequest(`Invalid publicBaseUrl protocol: ${input}`)
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw ApiError.badRequest(`Invalid publicBaseUrl credentials: ${input}`)
|
||||
}
|
||||
|
||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '')
|
||||
return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}`
|
||||
}
|
||||
|
||||
function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
|
||||
if (!isRecord(value)) {
|
||||
return { ...DEFAULT_STORED_SETTINGS }
|
||||
}
|
||||
|
||||
const allowedOrigins = Array.isArray(value.allowedOrigins)
|
||||
? [...new Set(value.allowedOrigins.flatMap((origin) => {
|
||||
if (typeof origin !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
return [normalizeOriginInput(origin)]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}))]
|
||||
: []
|
||||
|
||||
let publicBaseUrl: string | null = null
|
||||
if (typeof value.publicBaseUrl === 'string') {
|
||||
try {
|
||||
publicBaseUrl = normalizePublicBaseUrl(value.publicBaseUrl)
|
||||
} catch {
|
||||
publicBaseUrl = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : null,
|
||||
tokenPreview: typeof value.tokenPreview === 'string' ? value.tokenPreview : null,
|
||||
allowedOrigins,
|
||||
publicBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export class H5AccessService {
|
||||
private static writeLocks = new Map<string, Promise<void>>()
|
||||
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private getSettingsPath(): string {
|
||||
return path.join(this.getConfigDir(), 'cc-haha', 'settings.json')
|
||||
}
|
||||
|
||||
private async withWriteLock<T>(
|
||||
filePath: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousWrite = H5AccessService.writeLocks.get(filePath) ?? Promise.resolve()
|
||||
const nextWrite = previousWrite.catch(() => {}).then(task)
|
||||
const trackedWrite = nextWrite.then(() => {}, () => {})
|
||||
|
||||
H5AccessService.writeLocks.set(filePath, trackedWrite)
|
||||
|
||||
try {
|
||||
return await nextWrite
|
||||
} finally {
|
||||
if (H5AccessService.writeLocks.get(filePath) === trackedWrite) {
|
||||
H5AccessService.writeLocks.delete(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async readManagedSettings(): Promise<Record<string, unknown>> {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
return readRecoverableJsonFile({
|
||||
filePath: this.getSettingsPath(),
|
||||
label: 'cc-haha managed settings',
|
||||
defaultValue: {},
|
||||
normalize: normalizeJsonObject,
|
||||
})
|
||||
}
|
||||
|
||||
private async writeManagedSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
const filePath = this.getSettingsPath()
|
||||
const dir = path.dirname(filePath)
|
||||
const contents = JSON.stringify(settings, null, 2) + '\n'
|
||||
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpFile, contents, 'utf-8')
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (error) {
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(`Failed to write settings.json: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async readStoredSettings(): Promise<{
|
||||
managedSettings: Record<string, unknown>
|
||||
h5Access: StoredH5AccessSettings
|
||||
}> {
|
||||
const managedSettings = await this.readManagedSettings()
|
||||
return {
|
||||
managedSettings,
|
||||
h5Access: normalizeStoredSettings(managedSettings.h5Access),
|
||||
}
|
||||
}
|
||||
|
||||
private async saveStoredSettings(
|
||||
managedSettings: Record<string, unknown>,
|
||||
h5Access: StoredH5AccessSettings,
|
||||
): Promise<void> {
|
||||
await this.writeManagedSettings({
|
||||
...managedSettings,
|
||||
h5Access,
|
||||
})
|
||||
}
|
||||
|
||||
private async setToken(
|
||||
managedSettings: Record<string, unknown>,
|
||||
current: StoredH5AccessSettings,
|
||||
): Promise<H5AccessEnableResult> {
|
||||
const token = createToken()
|
||||
const nextSettings: StoredH5AccessSettings = {
|
||||
...current,
|
||||
enabled: true,
|
||||
tokenHash: hashToken(token),
|
||||
tokenPreview: createTokenPreview(token),
|
||||
}
|
||||
|
||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
||||
|
||||
return {
|
||||
settings: toPublicSettings(nextSettings),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
async getSettings(): Promise<H5AccessSettings> {
|
||||
const { h5Access } = await this.readStoredSettings()
|
||||
return toPublicSettings(h5Access)
|
||||
}
|
||||
|
||||
async enable(): Promise<H5AccessEnableResult> {
|
||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
||||
return this.setToken(managedSettings, h5Access)
|
||||
})
|
||||
}
|
||||
|
||||
async disable(): Promise<H5AccessSettings> {
|
||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
||||
const nextSettings: StoredH5AccessSettings = {
|
||||
...h5Access,
|
||||
enabled: false,
|
||||
tokenHash: null,
|
||||
tokenPreview: null,
|
||||
}
|
||||
|
||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
||||
return toPublicSettings(nextSettings)
|
||||
})
|
||||
}
|
||||
|
||||
async regenerateToken(): Promise<H5AccessEnableResult> {
|
||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
||||
return this.setToken(managedSettings, h5Access)
|
||||
})
|
||||
}
|
||||
|
||||
async updateSettings(input: {
|
||||
allowedOrigins?: string[]
|
||||
publicBaseUrl?: string | null
|
||||
}): Promise<H5AccessSettings> {
|
||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
||||
const nextSettings: StoredH5AccessSettings = {
|
||||
...h5Access,
|
||||
allowedOrigins: input.allowedOrigins === undefined
|
||||
? h5Access.allowedOrigins
|
||||
: normalizeAllowedOrigins(input.allowedOrigins),
|
||||
publicBaseUrl: input.publicBaseUrl === undefined
|
||||
? h5Access.publicBaseUrl
|
||||
: normalizePublicBaseUrl(input.publicBaseUrl),
|
||||
}
|
||||
|
||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
||||
return toPublicSettings(nextSettings)
|
||||
})
|
||||
}
|
||||
|
||||
async validateToken(token: string | null | undefined): Promise<boolean> {
|
||||
if (!token) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { h5Access } = await this.readStoredSettings()
|
||||
if (!h5Access.enabled || !h5Access.tokenHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
return hashToken(token) === h5Access.tokenHash
|
||||
}
|
||||
|
||||
async isOriginAllowed(origin: string | null | undefined): Promise<boolean> {
|
||||
if (!origin) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { h5Access } = await this.readStoredSettings()
|
||||
if (!h5Access.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedOrigin = normalizeOriginInput(origin, 'origin')
|
||||
return h5Access.allowedOrigins.includes(normalizedOrigin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user