mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { handleMcpApi } from './api/mcp.js'
|
||||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||||
import { handleDoctorApi } from './api/doctor.js'
|
import { handleDoctorApi } from './api/doctor.js'
|
||||||
|
import { handleH5AccessApi } from './api/h5-access.js'
|
||||||
|
|
||||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
|||||||
case 'doctor':
|
case 'doctor':
|
||||||
return handleDoctorApi(req, url, segments)
|
return handleDoctorApi(req, url, segments)
|
||||||
|
|
||||||
|
case 'h5-access':
|
||||||
|
return handleH5AccessApi(req, url, segments)
|
||||||
|
|
||||||
case 'filesystem':
|
case 'filesystem':
|
||||||
return handleFilesystemRoute(url.pathname, url)
|
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