feat: add local model gateway and console tabs
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import type { Context, Next } from 'hono'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import { isIP } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export type SocketBindings = { incoming?: IncomingMessage }
|
||||
|
||||
export async function localAdmin<E extends { Bindings: SocketBindings }>(c: Context<E>, next: Next) {
|
||||
const address = c.env?.incoming?.socket?.remoteAddress?.toLowerCase()
|
||||
const ipv4 = address?.startsWith('::ffff:') ? address.slice(7) : address
|
||||
const loopback = address === '::1' || (ipv4 !== undefined && isIP(ipv4) === 4 && ipv4.startsWith('127.'))
|
||||
const forwarded = [...c.req.raw.headers.keys()].some(key => key === 'forwarded' || key.startsWith('x-forwarded-') || key === 'x-real-ip')
|
||||
const origin = c.req.header('origin')
|
||||
const allowedOrigin = !origin || origin === new URL(c.req.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin)
|
||||
if (!loopback || forwarded || !allowedOrigin) {
|
||||
return c.json({ error: { code: 'ADMIN_ACCESS_DENIED', message: 'Direct loopback administration required.' }, requestId: randomUUID() }, 403)
|
||||
}
|
||||
await next()
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { app } from './app.js'
|
||||
import { createApp } from './app.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
|
||||
const app = withLocalSocket(createApp())
|
||||
|
||||
describe('ShareServer prototype API', () => {
|
||||
it('reports prototype readiness without claiming production readiness', async () => {
|
||||
|
||||
+21
-2
@@ -5,11 +5,19 @@ import { secureHeaders } from 'hono/secure-headers'
|
||||
import { prototypeSnapshot } from '../../shared/prototype-data.js'
|
||||
import { sharingRoutes } from './sharing-routes.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { gatewayRoutes } from './gateway-routes.js'
|
||||
import { localAdmin, type SocketBindings } from './admin-boundary.js'
|
||||
|
||||
export function createApp(store = new SharingStore()) {
|
||||
const app = new Hono()
|
||||
export function createApp(store = new SharingStore(), gateway = new GatewayStore()) {
|
||||
const app = new Hono<{ Bindings: SocketBindings }>()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (c.req.path.startsWith('/api/v1/gateway/') || (c.req.method === 'GET' && ['/api/v1/public/health', '/api/v1/public/ready'].includes(c.req.path))) await next()
|
||||
else return localAdmin(c, next)
|
||||
})
|
||||
app.use('/api', localAdmin)
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
@@ -37,6 +45,16 @@ export function createApp(store = new SharingStore()) {
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false,
|
||||
gateway: {
|
||||
mode: 'local-openai-compatible',
|
||||
authentication: 'scoped-bearer-key',
|
||||
persistence: 'local-json',
|
||||
credentials: 'aes-256-gcm-and-legacy-environment-references',
|
||||
writerScope: 'single-process-single-store-per-data-directory',
|
||||
networkBoundary: 'direct-loopback-admin-authenticated-inference',
|
||||
organizationAclEnforced: false,
|
||||
storageChecked: false
|
||||
},
|
||||
sharing: {
|
||||
mode: 'local-metadata-only',
|
||||
authentication: 'none',
|
||||
@@ -83,6 +101,7 @@ export function createApp(store = new SharingStore()) {
|
||||
})
|
||||
|
||||
app.route('/api/v1/sharing', sharingRoutes(store))
|
||||
app.route('/api/v1/gateway', gatewayRoutes(gateway))
|
||||
|
||||
app.all('/api/*', (context) => context.json({
|
||||
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
import { readFile, realpath } from 'node:fs/promises'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
|
||||
export type EncryptedCredential = { nonce: string; tag: string; ciphertext: string }
|
||||
|
||||
export function outsideDirectory(directory: string, file: string) {
|
||||
const path = relative(resolve(directory), resolve(file))
|
||||
return path !== '' && (path === '..' || path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(path))
|
||||
}
|
||||
|
||||
async function masterKey(directory: string): Promise<Buffer> {
|
||||
let value = process.env.SHARESERVER_MASTER_KEY
|
||||
const file = process.env.SHARESERVER_MASTER_KEY_FILE
|
||||
if (value && file) throw new Error('Ambiguous master key configuration')
|
||||
if (file) {
|
||||
const actualFile = await realpath(file)
|
||||
const actualDirectory = await realpath(directory).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
return resolve(directory)
|
||||
})
|
||||
if (!outsideDirectory(directory, file) || !outsideDirectory(actualDirectory, actualFile)) throw new Error('Master key must be outside data directory')
|
||||
value = (await readFile(actualFile, 'utf8')).trim()
|
||||
}
|
||||
if (!value || !/^[0-9a-fA-F]{64}$/.test(value)) throw new Error('Master key unavailable')
|
||||
return Buffer.from(value, 'hex')
|
||||
}
|
||||
|
||||
export async function encryptCredential(directory: string, id: string, secret: string): Promise<EncryptedCredential> {
|
||||
const nonce = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', await masterKey(directory), nonce)
|
||||
cipher.setAAD(Buffer.from(id))
|
||||
const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()])
|
||||
return { nonce: nonce.toString('hex'), tag: cipher.getAuthTag().toString('hex'), ciphertext: ciphertext.toString('hex') }
|
||||
}
|
||||
|
||||
export async function decryptCredential(directory: string, id: string, value: EncryptedCredential): Promise<string> {
|
||||
const decipher = createDecipheriv('aes-256-gcm', await masterKey(directory), Buffer.from(value.nonce, 'hex'))
|
||||
decipher.setAAD(Buffer.from(id))
|
||||
decipher.setAuthTag(Buffer.from(value.tag, 'hex'))
|
||||
return Buffer.concat([decipher.update(Buffer.from(value.ciphertext, 'hex')), decipher.final()]).toString('utf8')
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { socketBinding } from './test-socket.js'
|
||||
|
||||
const prefix = '/api/v1/gateway'
|
||||
const upstreamInput = { name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1/', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialAction: 'replace', apiKey: 'provider-secret' }
|
||||
const post = (value: unknown, key?: string): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) }, body: JSON.stringify(value) })
|
||||
const exec = promisify(execFile)
|
||||
|
||||
describe('B1 gateway security and persistence contracts', () => {
|
||||
let directory: string
|
||||
let store: GatewayStore
|
||||
let app: ReturnType<typeof createApp>
|
||||
const chat = { model: 'public-model', messages: [{ role: 'user', content: 'hello' }] }
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'b1-gateway-'))
|
||||
store = new GatewayStore(join(directory, 'data'))
|
||||
app = createApp(new SharingStore(join(directory, 'data')), store)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '')
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '')
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
async function configured() {
|
||||
const upstream = await store.upstream(upstreamInput)
|
||||
const model = await store.model({ alias: 'public-model', upstreamId: upstream.id, remoteModel: 'remote-model', enabled: true, displayName: 'Friendly model' })
|
||||
const created = await store.createAccessKey({ name: 'Desktop', modelIds: [model.id] })
|
||||
return { upstream, model, ...created }
|
||||
}
|
||||
function mockCompletion() {
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ choices: [{ message: { role: 'assistant', content: 'answer' } }] }))
|
||||
}
|
||||
it('encrypts with fresh nonces, projects no secrets, and keeps/replaces/clears credentials atomically', async () => {
|
||||
const { upstream } = await configured()
|
||||
const filename = join(directory, 'data', 'gateway.json')
|
||||
const original = JSON.parse(await readFile(filename, 'utf8'))
|
||||
expect(original.upstreams[0].credential).toMatchObject({ nonce: expect.stringMatching(/^[a-f0-9]{24}$/), tag: expect.stringMatching(/^[a-f0-9]{32}$/) })
|
||||
expect(JSON.stringify(original)).not.toContain('provider-secret')
|
||||
expect(upstream).toEqual({ id: upstream.id, name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true })
|
||||
const { apiKey, ...input } = upstreamInput
|
||||
expect(apiKey).toBe('provider-secret')
|
||||
await store.upstream({ ...input, credentialAction: 'keep' }, upstream.id)
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential).toEqual(original.upstreams[0].credential)
|
||||
expect(() => store.upstream({ ...input, credentialAction: 'replace', apiKey: '' }, upstream.id)).toThrow()
|
||||
await store.upstream(upstreamInput, upstream.id)
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential.nonce).not.toBe(original.upstreams[0].credential.nonce)
|
||||
expect(await new GatewayStore(join(directory, 'data')).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
await store.upstream({ ...input, credentialAction: 'clear' }, upstream.id)
|
||||
expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(false)
|
||||
await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_NOT_CONFIGURED' })
|
||||
await store.upstream({ ...input, authentication: 'none', credentialAction: 'keep' }, upstream.id)
|
||||
expect(await store.headers(upstream.id)).toEqual({ 'content-type': 'application/json' })
|
||||
})
|
||||
it('fails on missing/wrong master keys and tampered ciphertext without replacing existing secrets', async () => {
|
||||
const { upstream, key } = await configured()
|
||||
const filename = join(directory, 'data', 'gateway.json')
|
||||
const saved = await readFile(filename, 'utf8')
|
||||
const fetch = mockCompletion()
|
||||
for (const master of ['', '34'.repeat(32), 'invalid']) {
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', master)
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post(chat, key))
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'CREDENTIAL_UNAVAILABLE' } })
|
||||
expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(true)
|
||||
await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
expect(JSON.parse(await readFile(filename, 'utf8')).upstreams).toEqual(JSON.parse(saved).upstreams)
|
||||
}
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
const corrupted = JSON.parse(saved)
|
||||
corrupted.upstreams[0].credential.tag = '00'.repeat(16)
|
||||
await writeFile(filename, JSON.stringify(corrupted))
|
||||
await expect(new GatewayStore(join(directory, 'data')).headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
expect(await readFile(filename, 'utf8')).toBe(JSON.stringify(corrupted))
|
||||
})
|
||||
it('reads legacy environment records and long aliases, then migrates explicitly without changing IDs', async () => {
|
||||
const upstreamId = randomUUID(), modelId = randomUUID()
|
||||
const alias = 'a'.repeat(180)
|
||||
await mkdir(join(directory, 'data'))
|
||||
await writeFile(join(directory, 'data', 'gateway.json'), JSON.stringify({ version: 1,
|
||||
upstreams: [{ id: upstreamId, name: 'Old', baseUrl: upstreamInput.baseUrl, apiKeyEnv: 'B1_LEGACY_KEY', enabled: true }],
|
||||
models: [{ id: modelId, alias, upstreamId, remoteModel: 'remote', enabled: true }], audits: [] }))
|
||||
vi.stubEnv('B1_LEGACY_KEY', 'legacy-secret')
|
||||
const snapshot = await store.snapshot()
|
||||
expect(JSON.stringify(snapshot)).not.toContain('B1_LEGACY_KEY')
|
||||
const { key } = await store.createAccessKey({ name: 'Migrated', modelIds: [modelId] })
|
||||
const fetch = mockCompletion()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: alias }, key))).status).toBe(200)
|
||||
expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ authorization: 'Bearer legacy-secret' })
|
||||
await store.model({ alias, upstreamId, remoteModel: 'remote', enabled: false }, modelId)
|
||||
await expect(store.model({ alias: 'b'.repeat(129), upstreamId, remoteModel: 'remote', enabled: true })).rejects.toMatchObject({ status: 400 })
|
||||
await store.upstream(upstreamInput, upstreamId)
|
||||
expect((await new GatewayStore(join(directory, 'data')).snapshot()).models[0].id).toBe(modelId)
|
||||
expect(await store.headers(upstreamId)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
const saved = await readFile(join(directory, 'data', 'gateway.json'), 'utf8')
|
||||
expect(saved).not.toContain('apiKeyEnv')
|
||||
expect(saved).not.toContain('legacy-secret')
|
||||
})
|
||||
it('returns keys only at creation, enforces stable model scopes, and preserves revocation across restarts', async () => {
|
||||
const { upstream, model, key, accessKey } = await configured()
|
||||
const fetch = mockCompletion()
|
||||
const second = await store.model({ alias: 'other-model', upstreamId: upstream.id, remoteModel: 'other', enabled: true })
|
||||
for (const invalid of [undefined, 'bad-key']) {
|
||||
expect((await app.request(`${prefix}/v1/models`, { headers: invalid ? { authorization: `Bearer ${invalid}` } : {} })).status).toBe(401)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, invalid))).status).toBe(401)
|
||||
}
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: second.alias }, key))).status).toBe(403)
|
||||
const list = await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })
|
||||
expect(await list.json()).toMatchObject({ data: [{ id: 'public-model' }] })
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(200)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ accessKeyId: accessKey.id, inputTokens: null, outputTokens: null })
|
||||
const response = await app.request(`${prefix}/access-keys`, post({ name: 'Web-created', modelIds: [model.id] }), socketBinding())
|
||||
const created = await response.json()
|
||||
expect(Object.keys(created).sort()).toEqual(['accessKey', 'key'])
|
||||
expect(Object.keys(created.accessKey).sort()).toEqual(['createdAt', 'id', 'modelIds', 'name', 'revokedAt'])
|
||||
const publicList = await (await app.request(`${prefix}/access-keys`, undefined, socketBinding())).json()
|
||||
expect(publicList.accessKeys).toHaveLength(2)
|
||||
expect(JSON.stringify(publicList)).not.toContain(created.key)
|
||||
expect(await readFile(join(directory, 'data', 'gateway.json'), 'utf8')).not.toContain(key)
|
||||
const revoked = await (await app.request(`${prefix}/access-keys/${accessKey.id}/revoke`, { method: 'POST' }, socketBinding())).json()
|
||||
expect(revoked.revokedAt).toEqual(expect.any(String))
|
||||
const restarted = new GatewayStore(join(directory, 'data'))
|
||||
expect(await restarted.revokeAccessKey(accessKey.id)).toEqual(revoked)
|
||||
await expect(restarted.authenticate(`Bearer ${key}`)).rejects.toMatchObject({ status: 401 })
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(401)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
for (const modelIds of [[], ['public-model'], [model.id, model.id], [randomUUID()]]) {
|
||||
expect((await app.request(`${prefix}/access-keys`, post({ name: 'Invalid', modelIds }), socketBinding())).status).toBe(400)
|
||||
}
|
||||
})
|
||||
it('denies admin requests without an actual loopback binding, including legacy and prototype APIs', async () => {
|
||||
const { key } = await configured()
|
||||
for (const path of [`${prefix}/config`, `${prefix}/access-keys`, `${prefix}/test`, '/api/v1/sharing/devices', '/api/v1/web/prototype/snapshot', '/api/v1/web/prototype/actions']) {
|
||||
const init = path.endsWith('/test') || path.endsWith('/actions') ? post(chat, key) : { headers: { authorization: `Bearer ${key}` } }
|
||||
for (const binding of [undefined, socketBinding('192.168.1.8'), socketBinding('::ffff:192.168.1.8')]) {
|
||||
expect((await app.request(path, init, binding)).status).toBe(403)
|
||||
}
|
||||
for (const headers of [{ 'x-forwarded-for': '127.0.0.1' }, { forwarded: 'for=127.0.0.1' }, { 'x-real-ip': '127.0.0.1' }]) {
|
||||
expect((await app.request(path, { ...init, headers: { ...init.headers, ...headers } }, socketBinding())).status).toBe(403)
|
||||
}
|
||||
}
|
||||
for (const address of ['127.0.0.1', '127.0.0.2', '::1', '::ffff:127.0.0.1']) {
|
||||
expect((await app.request(`${prefix}/config`, undefined, socketBinding(address))).status).toBe(200)
|
||||
}
|
||||
mockCompletion()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key), socketBinding('192.168.1.8'))).status).toBe(200)
|
||||
})
|
||||
it('uses the same forwarding and auditing for local nonstream tests without bypassing inference authentication', async () => {
|
||||
const { upstream } = await configured()
|
||||
const fetch = mockCompletion()
|
||||
const toolMessages = [{ role: 'assistant', tool_calls: [{ id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'call', content: 'result' }]
|
||||
const response = await app.request(`${prefix}/test`, post({ ...chat, messages: toolMessages }), socketBinding())
|
||||
expect(response.status).toBe(200)
|
||||
expect(JSON.parse(fetch.mock.calls[0][1]?.body as string)).toMatchObject({ model: 'remote-model', messages: toolMessages })
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: null, upstreamId: upstream.id })
|
||||
expect((await app.request(`${prefix}/test`, post({ ...chat, stream: true }), socketBinding())).status).toBe(400)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat), socketBinding())).status).toBe(401)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('uses only the configured gateway URL and omits unavailable models from client discovery', async () => {
|
||||
const { upstream, key } = await configured()
|
||||
expect((await store.snapshot()).gatewayBaseUrl).toBeNull()
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', 'https://gateway.example/service/api/v1/gateway/v1/')
|
||||
const response = await app.request(`${prefix}/config`, { headers: { host: 'attacker.example' } }, socketBinding())
|
||||
expect((await response.json()).gatewayBaseUrl).toBe('https://gateway.example/service/api/v1/gateway/v1')
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [] })
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32))
|
||||
await store.upstream({ ...upstreamInput, enabled: false }, upstream.id)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(403)
|
||||
})
|
||||
it('preserves mapping and credentials after failed discovery and sends no auth to unauthenticated upstreams', async () => {
|
||||
const { upstream, model } = await configured()
|
||||
const before = await store.snapshot()
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('provider-secret sensitive failure', { status: 401 }))
|
||||
const response = await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' }, socketBinding())
|
||||
expect(response.status).toBe(502)
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'UPSTREAM_HTTP_ERROR', message: 'Upstream returned HTTP 401.' } })
|
||||
expect(await store.snapshot()).toEqual(before)
|
||||
await store.upstream({ name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'none', enabled: true, credentialAction: 'keep' }, upstream.id)
|
||||
fetch.mockResolvedValueOnce(Response.json({ choices: [] }))
|
||||
expect((await app.request(`${prefix}/test`, post(chat), socketBinding())).status).toBe(200)
|
||||
expect(fetch.mock.calls[1][1]?.headers).toEqual({ 'content-type': 'application/json' })
|
||||
const renamed = await store.model({ alias: 'renamed', upstreamId: upstream.id, remoteModel: model.remoteModel, enabled: true }, model.id)
|
||||
expect(renamed.id).toBe(model.id)
|
||||
const { key } = await store.createAccessKey({ name: 'Renamed client', modelIds: [model.id] })
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [{ id: 'renamed' }] })
|
||||
})
|
||||
it('initializes an external master key explicitly, refuses overwrite/data paths, and supports file-backed credentials', async () => {
|
||||
const file = join(directory, 'master.key')
|
||||
const data = join(directory, 'data')
|
||||
await mkdir(data)
|
||||
const command = resolve('scripts/init-master-key.mjs')
|
||||
const env = { ...process.env, SHARESERVER_DATA_DIR: data }
|
||||
const result = await exec(process.execPath, [command, file], { env })
|
||||
const key = (await readFile(file, 'utf8')).trim()
|
||||
expect(key).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(result.stdout).not.toContain(key)
|
||||
await expect(exec(process.execPath, [command, file], { env })).rejects.toMatchObject({ code: 1 })
|
||||
await expect(exec(process.execPath, [command, join(data, 'bad.key')], { env })).rejects.toMatchObject({ code: 1 })
|
||||
expect((await readFile(file, 'utf8')).trim()).toBe(key)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', '')
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', file)
|
||||
const upstream = await store.upstream(upstreamInput)
|
||||
expect(await new GatewayStore(data).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' })
|
||||
await writeFile(join(data, 'bad.key'), `${key}\n`)
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', join(data, 'bad.key'))
|
||||
await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Hono } from 'hono'
|
||||
import type { GatewayAudit } from '../../shared/gateway.js'
|
||||
import { GatewayError, GatewayStore, record } from './gateway-store.js'
|
||||
import { localAdmin, type SocketBindings } from './admin-boundary.js'
|
||||
|
||||
const REQUEST_LIMIT = 1024 * 1024
|
||||
const RESPONSE_LIMIT = 16 * 1024 * 1024
|
||||
const EVENT_LIMIT = 1024 * 1024
|
||||
const CHAT_FIELDS = new Set(('model messages stream stream_options temperature top_p n stop max_tokens max_completion_tokens presence_penalty frequency_penalty logit_bias logprobs top_logprobs seed tools tool_choice parallel_tool_calls functions function_call response_format reasoning_effort verbosity user').split(' '))
|
||||
async function readBounded(body: ReadableStream<Uint8Array> | null, limit: number, signal: AbortSignal): Promise<string> {
|
||||
if (!body) throw new GatewayError(400, 'EMPTY_BODY')
|
||||
const reader = body.getReader()
|
||||
const abort = () => { void reader.cancel().catch(() => undefined) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
let size = 0
|
||||
let result = ''
|
||||
const decoder = new TextDecoder()
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT')
|
||||
const { done, value } = await reader.read()
|
||||
if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT')
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
if (size > limit) throw new GatewayError(413, 'BODY_TOO_LARGE')
|
||||
result += decoder.decode(value, { stream: true })
|
||||
}
|
||||
return result + decoder.decode()
|
||||
} finally { signal.removeEventListener('abort', abort); await reader.cancel().catch(() => undefined); reader.releaseLock() }
|
||||
}
|
||||
function json(text: string): Record<string, unknown> {
|
||||
try { return record(JSON.parse(text)) } catch { throw new GatewayError(400, 'INVALID_JSON') }
|
||||
}
|
||||
function originAllowed(request: Request) {
|
||||
const origin = request.headers.get('origin')
|
||||
return !origin || origin === new URL(request.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin)
|
||||
}
|
||||
function usage(value: Record<string, unknown>, audit: GatewayAudit) {
|
||||
if (value.usage && typeof value.usage === 'object') {
|
||||
const u = value.usage as Record<string, unknown>
|
||||
if (Number.isSafeInteger(u.prompt_tokens) && Number(u.prompt_tokens) >= 0) audit.inputTokens = Number(u.prompt_tokens)
|
||||
if (Number.isSafeInteger(u.completion_tokens) && Number(u.completion_tokens) >= 0) audit.outputTokens = Number(u.completion_tokens)
|
||||
}
|
||||
}
|
||||
|
||||
export function gatewayRoutes(store: GatewayStore, timeoutMs = 120_000) {
|
||||
const app = new Hono<{ Bindings: SocketBindings; Variables: { requestId: string } }>()
|
||||
app.onError((error, c) => {
|
||||
const safe = error instanceof GatewayError ? error : new GatewayError(500, 'GATEWAY_FAILURE')
|
||||
return c.json({ error: { code: safe.code, message: safe.message }, requestId: c.get('requestId') ?? randomUUID() }, safe.status)
|
||||
})
|
||||
app.use('*', async (c, next) => {
|
||||
const inference = (c.req.method === 'POST' && c.req.path === '/api/v1/gateway/v1/chat/completions') || (c.req.method === 'GET' && c.req.path === '/api/v1/gateway/v1/models')
|
||||
if (inference) await next()
|
||||
else return localAdmin(c, next)
|
||||
})
|
||||
const body = async (request: Request) => {
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED')
|
||||
return json(await readBounded(request.body, REQUEST_LIMIT, AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)])))
|
||||
}
|
||||
app.get('/config', async c => c.json(await store.snapshot()))
|
||||
app.post('/upstreams', async c => c.json(await store.upstream(await body(c.req.raw))))
|
||||
app.put('/upstreams/:id', async c => c.json(await store.upstream(await body(c.req.raw), c.req.param('id'))))
|
||||
app.post('/models', async c => c.json(await store.model(await body(c.req.raw))))
|
||||
app.put('/models/:id', async c => c.json(await store.model(await body(c.req.raw), c.req.param('id'))))
|
||||
app.get('/access-keys', async c => c.json(await store.accessKeys()))
|
||||
app.post('/access-keys', async c => c.json(await store.createAccessKey(await body(c.req.raw))))
|
||||
app.post('/access-keys/:id/revoke', async c => c.json(await store.revokeAccessKey(c.req.param('id'))))
|
||||
app.post('/upstreams/:id/discover', async c => {
|
||||
const upstream = (await store.snapshot()).upstreams.find(u => u.id === c.req.param('id'))
|
||||
if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED')
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)])
|
||||
const headers = await store.headers(upstream.id)
|
||||
try {
|
||||
const response = await fetch(`${upstream.baseUrl}/models`, { headers, redirect: 'error', signal })
|
||||
if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) }
|
||||
const data = json(await readBounded(response.body, RESPONSE_LIMIT, signal)).data
|
||||
if (!Array.isArray(data) || data.some(m => !m || typeof m.id !== 'string' || m.id.length > 200)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
return c.json({ models: [...new Set(data.map(m => m.id as string))] })
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayError && error.code === 'UPSTREAM_HTTP_ERROR') throw error
|
||||
throw new GatewayError(signal.aborted ? 504 : 502, signal.aborted ? 'REQUEST_TIMEOUT' : 'UPSTREAM_FAILURE')
|
||||
}
|
||||
})
|
||||
app.get('/v1/models', async c => {
|
||||
const key = await store.authenticate(c.req.header('authorization'))
|
||||
const snapshot = await store.snapshot()
|
||||
const available = []
|
||||
for (const model of snapshot.models) {
|
||||
if (!key.modelIds.includes(model.id) || !model.enabled || !snapshot.upstreams.some(u => u.id === model.upstreamId && u.enabled)) continue
|
||||
try { await store.headers(model.upstreamId) }
|
||||
catch (error) { if (error instanceof GatewayError && ['CREDENTIAL_UNAVAILABLE', 'CREDENTIAL_NOT_CONFIGURED', 'UPSTREAM_DISABLED'].includes(error.code)) continue; throw error }
|
||||
available.push({ id: model.alias, object: 'model', created: 0, owned_by: 'local-gateway' })
|
||||
}
|
||||
return c.json({ object: 'list', data: available })
|
||||
})
|
||||
app.on('POST', ['/v1/chat/completions', '/test'], async c => {
|
||||
const started = Date.now()
|
||||
const audit: GatewayAudit = { id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false, status: 'failed', httpStatus: 500, durationMs: 0, inputTokens: null, outputTokens: null, errorCode: null }
|
||||
c.set('requestId', audit.id)
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
let cancelled = false
|
||||
let finished = false
|
||||
const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs)
|
||||
const cancel = () => { cancelled = true; controller.abort() }
|
||||
c.req.raw.signal.addEventListener('abort', cancel, { once: true })
|
||||
if (c.req.raw.signal.aborted) cancel()
|
||||
const finish = async (status: GatewayAudit['status'], httpStatus: number, code: string | null) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
clearTimeout(timer)
|
||||
c.req.raw.signal.removeEventListener('abort', cancel)
|
||||
Object.assign(audit, { status, httpStatus, errorCode: code, durationMs: Date.now() - started })
|
||||
await store.audit(audit)
|
||||
}
|
||||
const failure = (error: unknown) => cancelled ? new GatewayError(400, 'CLIENT_CANCELLED') : timedOut ? new GatewayError(504, 'REQUEST_TIMEOUT') : error instanceof GatewayError ? error : new GatewayError(502, 'UPSTREAM_FAILURE')
|
||||
try {
|
||||
const key = c.req.path === '/api/v1/gateway/test' ? null : await store.authenticate(c.req.header('authorization'))
|
||||
audit.accessKeyId = key?.id ?? null
|
||||
if (!originAllowed(c.req.raw)) throw new GatewayError(403, 'ORIGIN_DENIED')
|
||||
if (!c.req.header('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED')
|
||||
const input = json(await readBounded(c.req.raw.body, REQUEST_LIMIT, controller.signal))
|
||||
audit.stream = input.stream === true
|
||||
if (c.req.path === '/api/v1/gateway/test' && input.stream === true) throw new GatewayError(400, 'TEST_NON_STREAMING_ONLY')
|
||||
if (Object.keys(input).some(key => !CHAT_FIELDS.has(key))) throw new GatewayError(400, 'UNSUPPORTED_FIELD', 'Unsupported chat field or routing override.')
|
||||
if (typeof input.model !== 'string' || input.model.length > 200 || !Array.isArray(input.messages) || !input.messages.length ||
|
||||
input.messages.some(m => !m || typeof m !== 'object' || !['system', 'developer', 'user', 'assistant', 'tool', 'function'].includes(m.role)) ||
|
||||
(input.stream !== undefined && typeof input.stream !== 'boolean')) throw new GatewayError(400, 'INVALID_CHAT_REQUEST')
|
||||
const snapshot = await store.snapshot()
|
||||
const model = snapshot.models.find(m => m.alias === input.model)
|
||||
// Only configured aliases are persisted, never arbitrary user text.
|
||||
if (key && (!model || !key.modelIds.includes(model.id))) throw new GatewayError(403, 'MODEL_NOT_AUTHORIZED')
|
||||
if (!model) throw new GatewayError(404, 'MODEL_NOT_FOUND')
|
||||
audit.model = model.alias
|
||||
audit.upstreamId = model.upstreamId
|
||||
const upstream = snapshot.upstreams.find(u => u.id === model.upstreamId)!
|
||||
if (!model.enabled || !upstream.enabled) throw new GatewayError(403, 'MODEL_DISABLED')
|
||||
const response = await fetch(`${upstream.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers: await store.headers(upstream.id), body: JSON.stringify({ ...input, model: model.remoteModel }), redirect: 'error', signal: controller.signal
|
||||
})
|
||||
if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) }
|
||||
if (!input.stream) {
|
||||
let output: Record<string, unknown>
|
||||
try { output = json(await readBounded(response.body, RESPONSE_LIMIT, controller.signal)) }
|
||||
catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') }
|
||||
if (output.error || !Array.isArray(output.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
usage(output, audit)
|
||||
output.model = model.alias
|
||||
await finish('success', 200, null)
|
||||
return c.json(output)
|
||||
}
|
||||
if (!response.headers.get('content-type')?.includes('text/event-stream') || !response.body) {
|
||||
await response.body?.cancel()
|
||||
throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
}
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ''
|
||||
let bytes = 0
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(output) {
|
||||
const abortStream = () => {
|
||||
if (finished) return
|
||||
void reader.cancel().catch(() => undefined)
|
||||
void finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : 504, cancelled ? 'CLIENT_CANCELLED' : 'REQUEST_TIMEOUT')
|
||||
.then(() => output.error(new Error(cancelled ? 'Client cancelled.' : 'Gateway request timed out.')))
|
||||
.catch(() => output.error(new Error('Gateway storage failed.')))
|
||||
}
|
||||
controller.signal.addEventListener('abort', abortStream, { once: true })
|
||||
if (controller.signal.aborted) abortStream()
|
||||
},
|
||||
async pull(output) {
|
||||
if (finished) return
|
||||
try {
|
||||
while (true) {
|
||||
const boundary = /\r?\n\r?\n/.exec(buffer)
|
||||
if (boundary) {
|
||||
const event = buffer.slice(0, boundary.index)
|
||||
buffer = buffer.slice(boundary.index + boundary[0].length)
|
||||
if (event.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
const data = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n')
|
||||
if (!data) continue
|
||||
if (data === '[DONE]') {
|
||||
await finish('success', 200, null)
|
||||
output.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
output.close()
|
||||
controller.abort()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
let chunk: Record<string, unknown>
|
||||
try { chunk = json(data) } catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') }
|
||||
if (chunk.error || !Array.isArray(chunk.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE')
|
||||
usage(chunk, audit)
|
||||
chunk.model = model.alias
|
||||
output.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
|
||||
return
|
||||
}
|
||||
if (buffer.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
const next = await reader.read()
|
||||
if (finished) return
|
||||
if (next.done) throw new GatewayError(502, 'INCOMPLETE_STREAM')
|
||||
bytes += next.value.byteLength
|
||||
if (bytes > RESPONSE_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE')
|
||||
buffer += decoder.decode(next.value, { stream: true })
|
||||
}
|
||||
} catch (error) {
|
||||
if (finished) {
|
||||
output.error(new Error('Gateway stream failed.'))
|
||||
controller.abort()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
const safe = failure(error)
|
||||
await reader.cancel().catch(() => undefined)
|
||||
try {
|
||||
await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code)
|
||||
if (!cancelled) { output.enqueue(encoder.encode(`data: ${JSON.stringify({ error: { code: safe.code, message: 'Gateway stream failed.' }, requestId: audit.id })}\n\n`)); output.close() }
|
||||
} catch { output.error(new Error('Gateway stream failed.')) }
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
async cancel() {
|
||||
try { await finish('cancelled', 499, 'CLIENT_CANCELLED') }
|
||||
finally {
|
||||
cancel()
|
||||
await reader.cancel().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' } })
|
||||
} catch (error) {
|
||||
const safe = failure(error)
|
||||
controller.abort()
|
||||
await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code)
|
||||
throw safe
|
||||
}
|
||||
})
|
||||
app.all('*', c => c.json({ error: { code: 'NOT_FOUND', message: 'API route not found.' }, requestId: randomUUID() }, 404))
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { GatewayAccessKey, GatewayAudit, GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../shared/gateway.js'
|
||||
import { decryptCredential, encryptCredential, type EncryptedCredential } from './gateway-credentials.js'
|
||||
|
||||
export class GatewayError extends Error {
|
||||
constructor(public status: 400 | 401 | 403 | 404 | 409 | 413 | 415 | 500 | 502 | 503 | 504, public code: string, message = 'Gateway request failed.') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new GatewayError(400, 'INVALID_INPUT')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function exact(value: unknown, keys: string[]) {
|
||||
const item = record(value)
|
||||
if (Object.keys(item).length !== keys.length || keys.some(key => !Object.hasOwn(item, key))) throw new GatewayError(400, 'INVALID_FIELDS')
|
||||
return item
|
||||
}
|
||||
function text(value: unknown, max = 200): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > max || Array.from(value).some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) throw new GatewayError(400, 'INVALID_TEXT')
|
||||
return value
|
||||
}
|
||||
function enabled(value: unknown): boolean {
|
||||
if (typeof value !== 'boolean') throw new GatewayError(400, 'INVALID_ENABLED')
|
||||
return value
|
||||
}
|
||||
function uuid(value: unknown): string {
|
||||
const id = text(value)
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new GatewayError(400, 'INVALID_ID')
|
||||
return id
|
||||
}
|
||||
export function upstreamInput(value: unknown): GatewayUpstreamInput {
|
||||
const item = record(value)
|
||||
exact(item, ['name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credentialAction', ...(Object.hasOwn(item, 'apiKey') ? ['apiKey'] : [])])
|
||||
if (item.protocol !== 'openai-chat-completions' || !['api-key', 'none'].includes(String(item.authentication)) || !['keep', 'replace', 'clear'].includes(String(item.credentialAction))) throw new GatewayError(400, 'INVALID_UPSTREAM')
|
||||
if ((item.credentialAction !== 'replace' && Object.hasOwn(item, 'apiKey')) || (item.authentication === 'none' && item.credentialAction === 'replace')) throw new GatewayError(400, 'INVALID_CREDENTIAL_ACTION')
|
||||
return {
|
||||
name: text(item.name), baseUrl: baseUrl(item.baseUrl), protocol: item.protocol,
|
||||
authentication: item.authentication as GatewayUpstreamInput['authentication'], enabled: enabled(item.enabled),
|
||||
credentialAction: item.credentialAction as GatewayUpstreamInput['credentialAction'],
|
||||
...(item.credentialAction === 'replace' ? { apiKey: text(item.apiKey, 16384) } : {})
|
||||
}
|
||||
}
|
||||
function baseUrl(value: unknown): string {
|
||||
let url: URL
|
||||
const raw = text(value, 2048)
|
||||
try { url = new URL(raw) } catch { throw new GatewayError(400, 'INVALID_BASE_URL') }
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || raw.includes('?') || raw.includes('#')) {
|
||||
throw new GatewayError(400, 'INVALID_BASE_URL', 'Use HTTP or HTTPS without credentials, query or fragment.')
|
||||
}
|
||||
return url.href.replace(/\/+$/, '')
|
||||
}
|
||||
export function modelInput(value: unknown, legacy = false): GatewayModelInput {
|
||||
const item = record(value)
|
||||
exact(item, ['alias', 'upstreamId', 'remoteModel', 'enabled', ...(Object.hasOwn(item, 'displayName') ? ['displayName'] : [])])
|
||||
const alias = text(item.alias, legacy ? 200 : 128)
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(alias)) throw new GatewayError(400, 'INVALID_ALIAS')
|
||||
return { alias, upstreamId: uuid(item.upstreamId), remoteModel: text(item.remoteModel), enabled: enabled(item.enabled), ...(item.displayName !== undefined ? { displayName: text(item.displayName) } : {}) }
|
||||
}
|
||||
type StoredUpstream = Omit<GatewayUpstream, 'credentialConfigured'> & { credential: EncryptedCredential | null; apiKeyEnv?: string }
|
||||
type StoredAccessKey = GatewayAccessKey & { digest: string }
|
||||
type State = { version: 2; upstreams: StoredUpstream[]; models: GatewayModel[]; audits: GatewayAudit[]; accessKeys: StoredAccessKey[] }
|
||||
function publicUpstream(u: StoredUpstream): GatewayUpstream {
|
||||
return { id: u.id, name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled,
|
||||
credentialConfigured: Boolean(u.credential || (u.apiKeyEnv && process.env[u.apiKeyEnv]?.trim())) }
|
||||
}
|
||||
function publicAccessKey(k: StoredAccessKey): GatewayAccessKey {
|
||||
return { id: k.id, name: k.name, modelIds: [...k.modelIds], createdAt: k.createdAt, revokedAt: k.revokedAt }
|
||||
}
|
||||
function timestamp(value: unknown): string {
|
||||
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) throw new Error('Invalid timestamp')
|
||||
return value
|
||||
}
|
||||
function parseState(value: unknown): State {
|
||||
const item = record(value)
|
||||
exact(item, ['version', 'upstreams', 'models', 'audits', ...(item.version === 2 ? ['accessKeys'] : [])])
|
||||
if ((item.version !== 1 && item.version !== 2) || !Array.isArray(item.upstreams) || !Array.isArray(item.models) || !Array.isArray(item.audits) || item.audits.length > 200) throw new Error('Invalid state')
|
||||
const upstreams = item.upstreams.map(value => {
|
||||
const u = record(value)
|
||||
if (item.version === 1) {
|
||||
exact(u, ['id', 'name', 'baseUrl', 'apiKeyEnv', 'enabled'])
|
||||
if (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv)) throw new Error('Invalid legacy credential')
|
||||
return { id: uuid(u.id), name: text(u.name), baseUrl: baseUrl(u.baseUrl), enabled: enabled(u.enabled), protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKeyEnv: u.apiKeyEnv, credential: null }
|
||||
}
|
||||
exact(u, ['id', 'name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credential', ...(Object.hasOwn(u, 'apiKeyEnv') ? ['apiKeyEnv'] : [])])
|
||||
const input = upstreamInput({ name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled, credentialAction: 'keep' })
|
||||
if (u.apiKeyEnv !== undefined && (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv))) throw new Error('Invalid credential reference')
|
||||
if (u.credential !== null) {
|
||||
const cipher = exact(u.credential, ['nonce', 'tag', 'ciphertext'])
|
||||
if (typeof cipher.nonce !== 'string' || !/^[a-f0-9]{24}$/.test(cipher.nonce) || typeof cipher.tag !== 'string' || !/^[a-f0-9]{32}$/.test(cipher.tag) || typeof cipher.ciphertext !== 'string' || !/^(?:[a-f0-9]{2}){1,65536}$/.test(cipher.ciphertext)) throw new Error('Invalid ciphertext')
|
||||
}
|
||||
if (u.credential && u.apiKeyEnv) throw new Error('Ambiguous credential')
|
||||
return { id: uuid(u.id), name: input.name, baseUrl: input.baseUrl, protocol: input.protocol, authentication: input.authentication, enabled: input.enabled, credential: u.credential as EncryptedCredential | null, ...(u.apiKeyEnv ? { apiKeyEnv: u.apiKeyEnv as string } : {}) }
|
||||
})
|
||||
const models = item.models.map(value => {
|
||||
const { id, ...input } = record(value)
|
||||
return { id: uuid(id), ...modelInput(input, true) }
|
||||
})
|
||||
const audits = item.audits.map(value => {
|
||||
const audit: Record<string, unknown> = { ...record(value), ...(item.version === 1 ? { accessKeyId: null } : {}) }
|
||||
exact(audit, ['id', 'time', 'model', 'upstreamId', 'accessKeyId', 'stream', 'status', 'httpStatus', 'durationMs', 'inputTokens', 'outputTokens', 'errorCode'])
|
||||
uuid(audit.id)
|
||||
if (audit.accessKeyId !== null) uuid(audit.accessKeyId)
|
||||
if (typeof audit.time !== 'string' || !Number.isFinite(Date.parse(audit.time)) || new Date(audit.time).toISOString() !== audit.time ||
|
||||
typeof audit.model !== 'string' || audit.model.length > 200 || (audit.upstreamId !== null && !upstreams.some(u => u.id === audit.upstreamId)) ||
|
||||
typeof audit.stream !== 'boolean' || !['success', 'failed', 'cancelled'].includes(String(audit.status)) ||
|
||||
!Number.isInteger(audit.httpStatus) || Number(audit.httpStatus) < 100 || Number(audit.httpStatus) > 599 ||
|
||||
!Number.isFinite(audit.durationMs) || Number(audit.durationMs) < 0 ||
|
||||
[audit.inputTokens, audit.outputTokens].some(n => n !== null && (!Number.isSafeInteger(n) || Number(n) < 0)) ||
|
||||
(audit.errorCode !== null && (typeof audit.errorCode !== 'string' || !/^[A-Z_]{1,64}$/.test(audit.errorCode)))) throw new Error('Invalid audit')
|
||||
return audit as GatewayAudit
|
||||
})
|
||||
if (new Set(upstreams.map(u => u.id)).size !== upstreams.length || new Set(models.map(m => m.id)).size !== models.length ||
|
||||
new Set(models.map(m => m.alias)).size !== models.length || new Set(audits.map(a => a.id)).size !== audits.length ||
|
||||
models.some(m => !upstreams.some(u => u.id === m.upstreamId))) throw new Error('Invalid references')
|
||||
if (item.version === 2 && !Array.isArray(item.accessKeys)) throw new Error('Invalid access keys')
|
||||
const accessKeys = ((item.accessKeys ?? []) as unknown[]).map(value => {
|
||||
const k = exact(value, ['id', 'name', 'modelIds', 'createdAt', 'revokedAt', 'digest'])
|
||||
if (!Array.isArray(k.modelIds) || !k.modelIds.length || k.modelIds.some(id => !models.some(m => m.id === id)) || new Set(k.modelIds).size !== k.modelIds.length || typeof k.digest !== 'string' || !/^[a-f0-9]{64}$/.test(k.digest)) throw new Error('Invalid access key')
|
||||
return { id: uuid(k.id), name: text(k.name), modelIds: k.modelIds as string[], createdAt: timestamp(k.createdAt), revokedAt: k.revokedAt === null ? null : timestamp(k.revokedAt), digest: k.digest }
|
||||
})
|
||||
if (new Set(accessKeys.map(k => k.id)).size !== accessKeys.length || new Set(accessKeys.map(k => k.digest)).size !== accessKeys.length || audits.some(a => a.accessKeyId !== null && !accessKeys.some(k => k.id === a.accessKeyId))) throw new Error('Invalid access key references')
|
||||
return { version: 2, upstreams, models, audits, accessKeys }
|
||||
}
|
||||
|
||||
// One process and one store per data directory; publish memory only after atomic replacement.
|
||||
export class GatewayStore {
|
||||
private state?: State
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private readonly filename: string
|
||||
constructor(private readonly directory = process.env.SHARESERVER_DATA_DIR ?? './data') {
|
||||
this.filename = resolve(directory, 'gateway.json')
|
||||
}
|
||||
private run<T>(operation: (state: State) => T | Promise<T>, write = false): Promise<T> {
|
||||
const result = this.queue.then(async () => {
|
||||
if (!this.state) {
|
||||
try { this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8'))) }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE')
|
||||
this.state = { version: 2, upstreams: [], models: [], audits: [], accessKeys: [] }
|
||||
}
|
||||
}
|
||||
const next = structuredClone(this.state)
|
||||
const value = await operation(next)
|
||||
if (write) {
|
||||
const temporary = `${this.filename}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const file = await open(temporary, 'wx', 0o600)
|
||||
try { await file.writeFile(JSON.stringify(next) + '\n'); await file.sync() } finally { await file.close() }
|
||||
await rename(temporary, this.filename)
|
||||
this.state = next
|
||||
} catch { throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE') }
|
||||
finally { await rm(temporary, { force: true }).catch(() => undefined) }
|
||||
}
|
||||
return value
|
||||
})
|
||||
this.queue = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
snapshot(): Promise<GatewaySnapshot> {
|
||||
return this.run(state => {
|
||||
let gatewayBaseUrl: string | null = null
|
||||
if (process.env.SHARESERVER_GATEWAY_BASE_URL) {
|
||||
try { gatewayBaseUrl = baseUrl(process.env.SHARESERVER_GATEWAY_BASE_URL) }
|
||||
catch { throw new GatewayError(503, 'INVALID_GATEWAY_BASE_URL') }
|
||||
}
|
||||
return { models: state.models, audits: state.audits, upstreams: state.upstreams.map(publicUpstream), gatewayBaseUrl }
|
||||
})
|
||||
}
|
||||
upstream(value: unknown, id?: string) {
|
||||
const input = upstreamInput(value)
|
||||
return this.run(async state => {
|
||||
const index = id === undefined ? -1 : state.upstreams.findIndex(u => u.id === id)
|
||||
if (id !== undefined && index < 0) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
const { credentialAction, apiKey, ...fields } = input
|
||||
const previous = state.upstreams[index]
|
||||
const upstream: StoredUpstream = { ...fields, id: id ?? randomUUID(), credential: previous?.credential ?? null, ...(previous?.apiKeyEnv ? { apiKeyEnv: previous.apiKeyEnv } : {}) }
|
||||
if (credentialAction === 'replace') {
|
||||
try {
|
||||
// A changed deployment key must not silently create a mixed-key store.
|
||||
for (const existing of state.upstreams) {
|
||||
if (existing.credential) await decryptCredential(this.directory, existing.id, existing.credential)
|
||||
}
|
||||
upstream.credential = await encryptCredential(this.directory, upstream.id, apiKey!)
|
||||
}
|
||||
catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') }
|
||||
delete upstream.apiKeyEnv
|
||||
} else if (credentialAction === 'clear') {
|
||||
upstream.credential = null
|
||||
delete upstream.apiKeyEnv
|
||||
}
|
||||
if (index < 0) state.upstreams.push(upstream); else state.upstreams[index] = upstream
|
||||
return publicUpstream(upstream)
|
||||
}, true)
|
||||
}
|
||||
model(value: unknown, id?: string) {
|
||||
return this.run(state => {
|
||||
const index = id === undefined ? -1 : state.models.findIndex(m => m.id === id)
|
||||
if (id !== undefined && index < 0) throw new GatewayError(404, 'MODEL_NOT_FOUND')
|
||||
const input = modelInput(value, index >= 0 && record(value).alias === state.models[index].alias)
|
||||
if (!state.upstreams.some(u => u.id === input.upstreamId)) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (state.models.some(m => m.alias === input.alias && m.id !== id)) throw new GatewayError(409, 'ALIAS_EXISTS')
|
||||
const model = { ...input, id: id ?? randomUUID() }
|
||||
if (index < 0) state.models.push(model); else state.models[index] = model
|
||||
return model
|
||||
}, true)
|
||||
}
|
||||
headers(id: string): Promise<Record<string, string>> {
|
||||
return this.run(async (state): Promise<Record<string, string>> => {
|
||||
const upstream = state.upstreams.find(u => u.id === id)
|
||||
if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND')
|
||||
if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED')
|
||||
if (upstream.authentication === 'none') return { 'content-type': 'application/json' }
|
||||
let key: string | undefined
|
||||
try { key = upstream.credential ? await decryptCredential(this.directory, id, upstream.credential) : upstream.apiKeyEnv ? process.env[upstream.apiKeyEnv]?.trim() : undefined }
|
||||
catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') }
|
||||
if (!key) throw new GatewayError(503, 'CREDENTIAL_NOT_CONFIGURED')
|
||||
return { 'content-type': 'application/json', authorization: `Bearer ${key}` }
|
||||
})
|
||||
}
|
||||
accessKeys() {
|
||||
return this.run(state => ({ accessKeys: state.accessKeys.map(publicAccessKey) }))
|
||||
}
|
||||
createAccessKey(value: unknown) {
|
||||
const input = exact(value, ['name', 'modelIds'])
|
||||
const name = text(input.name)
|
||||
return this.run(state => {
|
||||
if (!Array.isArray(input.modelIds) || !input.modelIds.length || input.modelIds.some(id => typeof id !== 'string' || !state.models.some(m => m.id === id)) || new Set(input.modelIds).size !== input.modelIds.length) throw new GatewayError(400, 'INVALID_MODEL_SCOPE')
|
||||
const key = `ss_${randomBytes(32).toString('base64url')}`
|
||||
const accessKey: StoredAccessKey = { id: randomUUID(), name, modelIds: [...input.modelIds] as string[], createdAt: new Date().toISOString(), revokedAt: null, digest: createHash('sha256').update(key).digest('hex') }
|
||||
state.accessKeys.push(accessKey)
|
||||
return { accessKey: publicAccessKey(accessKey), key }
|
||||
}, true)
|
||||
}
|
||||
revokeAccessKey(id: string) {
|
||||
return this.run(state => {
|
||||
const key = state.accessKeys.find(k => k.id === id)
|
||||
if (!key) throw new GatewayError(404, 'ACCESS_KEY_NOT_FOUND')
|
||||
key.revokedAt ??= new Date().toISOString()
|
||||
return publicAccessKey(key)
|
||||
}, true)
|
||||
}
|
||||
authenticate(authorization?: string) {
|
||||
if (!authorization || !/^Bearer ss_[A-Za-z0-9_-]{43}$/i.test(authorization)) throw new GatewayError(401, 'INVALID_ACCESS_KEY')
|
||||
const digest = createHash('sha256').update(authorization.slice(7)).digest()
|
||||
return this.run(state => {
|
||||
const key = state.accessKeys.find(k => timingSafeEqual(Buffer.from(k.digest, 'hex'), digest))
|
||||
if (!key || key.revokedAt) throw new GatewayError(401, 'INVALID_ACCESS_KEY')
|
||||
return publicAccessKey(key)
|
||||
})
|
||||
}
|
||||
audit(audit: GatewayAudit) {
|
||||
return this.run(state => { state.audits = [audit, ...state.audits].slice(0, 200) }, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment node
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { gatewayRoutes } from './gateway-routes.js'
|
||||
import { GatewayStore } from './gateway-store.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
|
||||
let accessKey: string
|
||||
const post = (body: unknown, extra: RequestInit = {}): RequestInit => ({ method: 'POST', body: JSON.stringify(body), ...extra, headers: { 'content-type': 'application/json', authorization: `Bearer ${accessKey}`, ...extra.headers } })
|
||||
const prefix = '/api/v1/gateway'
|
||||
const chat = { model: 'local', messages: [{ role: 'user', content: 'private prompt' }] }
|
||||
describe('local model gateway', () => {
|
||||
let directory: string
|
||||
let store: GatewayStore
|
||||
let app: ReturnType<typeof createApp>
|
||||
let server: Server
|
||||
let baseUrl: string
|
||||
let calls: { path: string; authorization?: string; body: Record<string, unknown> }[]
|
||||
let handler: (req: IncomingMessage, res: ServerResponse) => void
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'gateway-test-'))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY', 'ab'.repeat(32))
|
||||
vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '')
|
||||
vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '')
|
||||
accessKey = ''
|
||||
store = new GatewayStore(directory)
|
||||
app = withLocalSocket(createApp(new SharingStore(directory), store))
|
||||
calls = []
|
||||
handler = (req, res) => {
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify(req.url === '/v1/models' ? { data: [{ id: 'remote' }] } : {
|
||||
id: 'completion', model: 'remote', choices: [{ message: { role: 'assistant', content: 'private output', tool_calls: [{ id: 'tool', type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }], usage: { prompt_tokens: 7, completion_tokens: 4 }
|
||||
}))
|
||||
}
|
||||
server = createServer(async (req, res) => {
|
||||
let body = ''
|
||||
for await (const chunk of req) body += chunk
|
||||
calls.push({ path: req.url!, authorization: req.headers.authorization, body: body ? JSON.parse(body) : {} })
|
||||
handler(req, res)
|
||||
})
|
||||
server.listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
const address = server.address() as { port: number }
|
||||
baseUrl = `http://127.0.0.1:${address.port}/v1`
|
||||
process.env.GATEWAY_TEST_API_KEY = 'test-only-secret'
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
delete process.env.GATEWAY_TEST_API_KEY
|
||||
delete process.env.GATEWAY_TEST_BASE_URL
|
||||
delete process.env.GATEWAY_TEST_MODEL
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
async function configure() {
|
||||
const input = { name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'api-key', credentialAction: 'replace', apiKey: 'test-only-secret', enabled: true }
|
||||
const upstream = await (await app.request(`${prefix}/upstreams`, post(input))).json()
|
||||
const model = await (await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'remote', enabled: true }))).json()
|
||||
accessKey = (await store.createAccessKey({ name: 'Test client', modelIds: [model.id] })).key
|
||||
return { upstream, model, input }
|
||||
}
|
||||
it('starts empty; persists managed credentials and full updates across restart', async () => {
|
||||
expect(await (await app.request(`${prefix}/config`)).json()).toEqual({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null })
|
||||
const { input } = await configure()
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.upstreams).toHaveLength(1)
|
||||
expect(snapshot.models).toHaveLength(1)
|
||||
const u = snapshot.upstreams[0]
|
||||
expect(u.id).toMatch(/^[a-f0-9-]{36}$/)
|
||||
const updated = await app.request(`${prefix}/upstreams/${u.id}`, post({ ...input, name: 'Updated', enabled: false }, { method: 'PUT' }))
|
||||
expect(updated.status).toBe(200)
|
||||
expect((await new GatewayStore(directory).snapshot()).upstreams[0].enabled).toBe(false)
|
||||
expect(await readFile(join(directory, 'gateway.json'), 'utf8')).not.toContain('test-only-secret')
|
||||
expect((await app.request(`${prefix}/import-env`, post({ prefix: 'GATEWAY_TEST' }))).status).toBe(404)
|
||||
})
|
||||
it('fails closed on malformed persisted data without overwriting it', async () => {
|
||||
for (const data of ['{broken', JSON.stringify({ version: 1, upstreams: [], models: [], audits: [{ prompt: 'bad' }] })]) {
|
||||
await writeFile(join(directory, 'gateway.json'), data)
|
||||
const broken = withLocalSocket(new Hono().route(prefix, gatewayRoutes(new GatewayStore(directory))))
|
||||
expect((await broken.request(`${prefix}/config`)).status).toBe(503)
|
||||
expect((await broken.request(`${prefix}/upstreams`, post({ name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'none', credentialAction: 'clear', enabled: true }))).status).toBe(503)
|
||||
expect(await readFile(join(directory, 'gateway.json'), 'utf8')).toBe(data)
|
||||
}
|
||||
})
|
||||
it('discovers real models, maps aliases, preserves tools and records only metadata', async () => {
|
||||
const { upstream } = await configure()
|
||||
expect(await (await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' })).json()).toEqual({ models: ['remote'] })
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [{ id: 'local' }] })
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }] }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ model: 'local', choices: [{ message: { tool_calls: [{ id: 'tool' }] } }] })
|
||||
expect(calls[1]).toMatchObject({ authorization: 'Bearer test-only-secret', body: { model: 'remote', tools: [{ type: 'function' }] } })
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.audits[0]).toMatchObject({ status: 'success', inputTokens: 7, outputTokens: 4 })
|
||||
const persisted = await readFile(join(directory, 'gateway.json'), 'utf8')
|
||||
for (const secret of ['private prompt', 'private output', 'caller-secret', 'test-only-secret']) expect(persisted).not.toContain(secret)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('test-only-secret')
|
||||
})
|
||||
it('rejects disabled routes, unknown fields, hostile origins and missing runtime credentials', async () => {
|
||||
const { model, upstream, input } = await configure()
|
||||
expect((await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'other', enabled: true }))).status).toBe(409)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, base_url: 'https://evil.example' }))).status).toBe(400)
|
||||
const denied = await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'https://evil.example' } }))
|
||||
expect(denied.status).toBe(403)
|
||||
expect(await denied.json()).toMatchObject({ error: { code: 'ORIGIN_DENIED' }, requestId: (await store.snapshot()).audits[0].id })
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', httpStatus: 403, errorCode: 'ORIGIN_DENIED' })
|
||||
expect((await app.request(`${prefix}/upstreams`, post(input, { headers: { 'content-type': 'application/json', origin: 'null' } }))).status).toBe(403)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'http://localhost:5174' } }))).status).toBe(200)
|
||||
const { id, ...modelInput } = model
|
||||
expect((await app.request(`${prefix}/models/${id}`, post({ ...modelInput, enabled: false }, { method: 'PUT' }))).status).toBe(200)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(403)
|
||||
expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [] })
|
||||
await app.request(`${prefix}/models/${id}`, post(modelInput, { method: 'PUT' }))
|
||||
delete process.env.SHARESERVER_MASTER_KEY
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(503)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', inputTokens: null, outputTokens: null })
|
||||
})
|
||||
it('validates URLs and sanitizes upstream HTTP errors and redirects', async () => {
|
||||
const { input } = await configure()
|
||||
for (const url of ['https://user:secret@example.com', `${baseUrl}?key=secret`, `${baseUrl}#fragment`, 'file:///tmp/test']) {
|
||||
expect((await app.request(`${prefix}/upstreams`, post({ ...input, baseUrl: url }))).status).toBe(400)
|
||||
}
|
||||
for (const status of [401, 302]) {
|
||||
handler = (_req, res) => { res.writeHead(status, { location: `${baseUrl}/leak` }); res.end('test-only-secret upstream-sensitive-error') }
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post(chat))
|
||||
expect(response.status).toBe(502)
|
||||
const error = await response.text()
|
||||
expect(error).not.toContain('test-only-secret')
|
||||
expect(error).not.toContain(baseUrl)
|
||||
expect(error).not.toContain('upstream-sensitive-error')
|
||||
}
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
it('streams fragmented SSE tool calls and usage through completion', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","function":{"name":"lookup","arguments":"{}"}}]}}]}\r')
|
||||
setTimeout(() => res.end('\n\r\ndata: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n'), 10)
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true, stream_options: { include_usage: true } }))
|
||||
const text = await response.text()
|
||||
expect(text).toContain('tool_calls')
|
||||
expect(text).toContain('lookup')
|
||||
expect(text).toContain('data: [DONE]')
|
||||
expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'success', stream: true, inputTokens: 3, outputTokens: 2 })
|
||||
})
|
||||
it('cancels upstream on downstream cancellation and persists cancellation', async () => {
|
||||
await configure()
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
await reader.read()
|
||||
await reader.cancel()
|
||||
await closure
|
||||
expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'cancelled', httpStatus: 499, inputTokens: null, outputTokens: null })
|
||||
})
|
||||
it('allows an already started stream to finish after key revocation but rejects subsequent calls', async () => {
|
||||
await configure()
|
||||
let complete!: () => void
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"first"}}]}\n\n')
|
||||
complete = () => res.end('data: [DONE]\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain('first')
|
||||
const issued = await store.authenticate(`Bearer ${accessKey}`)
|
||||
await store.revokeAccessKey(issued.id)
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(401)
|
||||
complete()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain('[DONE]')
|
||||
expect((await reader.read()).done).toBe(true)
|
||||
expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: issued.id })
|
||||
})
|
||||
it('bounds requests and detects truncated streams and timeouts', async () => {
|
||||
await configure()
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, messages: [{ role: 'user', content: 'x'.repeat(1024 * 1024) }] }))).status).toBe(413)
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end('data: {"choices":[]}\n\n') }
|
||||
const truncated = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(await truncated.text()).toContain('INCOMPLETE_STREAM')
|
||||
expect((await store.snapshot()).audits[0].status).toBe('failed')
|
||||
handler = () => undefined
|
||||
const short = new Hono().route(prefix, gatewayRoutes(store, 50))
|
||||
expect((await short.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(504)
|
||||
expect((await store.snapshot()).audits[0].errorCode).toBe('REQUEST_TIMEOUT')
|
||||
})
|
||||
it('bounds upstream responses and streams, without persisting response data', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [], extra: 'x'.repeat(16 * 1024 * 1024) })) }
|
||||
expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(502)
|
||||
handler = (_req, res) => { res.setHeader('content-type', 'text/event-stream'); res.end(`data: ${'x'.repeat(1024 * 1024 + 1)}`) }
|
||||
const stream = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(await stream.text()).toContain('STREAM_TOO_LARGE')
|
||||
expect((await store.snapshot()).audits.every(a => a.status === 'failed')).toBe(true)
|
||||
})
|
||||
it('persists only the newest 200 audits under concurrent writes', async () => {
|
||||
await Promise.all(Array.from({ length: 205 }, (_, i) => store.audit({
|
||||
id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false,
|
||||
status: 'failed', httpStatus: 404, durationMs: i, inputTokens: null, outputTokens: null, errorCode: 'MODEL_NOT_FOUND'
|
||||
})))
|
||||
const snapshot = await new GatewayStore(directory).snapshot()
|
||||
expect(snapshot.audits).toHaveLength(200)
|
||||
expect(snapshot.audits[0].durationMs).toBe(204)
|
||||
expect(snapshot.audits[199].durationMs).toBe(5)
|
||||
})
|
||||
it('propagates a real HTTP client disconnect to the upstream', async () => {
|
||||
await configure()
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n')
|
||||
}
|
||||
const gateway = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' })
|
||||
await once(gateway, 'listening')
|
||||
try {
|
||||
const address = gateway.address() as { port: number }
|
||||
const controller = new AbortController()
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal }))
|
||||
await response.body!.getReader().read()
|
||||
controller.abort()
|
||||
await closure
|
||||
await expect.poll(async () => (await store.snapshot()).audits[0]?.status).toBe('cancelled')
|
||||
} finally {
|
||||
(gateway as Server).closeAllConnections()
|
||||
await new Promise<void>(resolve => gateway.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
it('times out a stalled SSE stream even without downstream reads', async () => {
|
||||
await configure()
|
||||
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write('data: {"choices":[]}\n\n') }
|
||||
const short = new Hono().route(prefix, gatewayRoutes(store, 100))
|
||||
const response = await short.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
await expect.poll(async () => (await store.snapshot()).audits[0]?.errorCode).toBe('REQUEST_TIMEOUT')
|
||||
await expect(response.text()).rejects.toThrow('timed out')
|
||||
})
|
||||
it('errors the stream and closes upstream when persisting a successful stream audit fails', async () => {
|
||||
await configure()
|
||||
// A directory at the destination makes the real atomic rename fail on all platforms.
|
||||
await rm(join(directory, 'gateway.json'))
|
||||
await mkdir(join(directory, 'gateway.json'))
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.text()).rejects.toThrow('Gateway stream failed.')
|
||||
await closure
|
||||
expect((await store.snapshot()).audits).toEqual([])
|
||||
})
|
||||
it('still closes upstream when persisting cancellation fails', async () => {
|
||||
await configure()
|
||||
await rm(join(directory, 'gateway.json'))
|
||||
await mkdir(join(directory, 'gateway.json'))
|
||||
let closed!: () => void
|
||||
const closure = new Promise<void>(resolve => { closed = resolve })
|
||||
handler = (_req, res) => {
|
||||
res.on('close', closed)
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.write('data: {"choices":[]}\n\n')
|
||||
}
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }))
|
||||
const reader = response.body!.getReader()
|
||||
await reader.read()
|
||||
await expect(reader.cancel()).rejects.toMatchObject({ code: 'GATEWAY_STORAGE_UNAVAILABLE' })
|
||||
await closure
|
||||
})
|
||||
it('handles abort after upstream headers but before stream startup, including EOF', async () => {
|
||||
await configure()
|
||||
const controller = new AbortController()
|
||||
handler = (_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
res.end('data: [DONE]\n\n')
|
||||
}
|
||||
const originalFetch = globalThis.fetch
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (...args) => {
|
||||
const response = await originalFetch(...args)
|
||||
controller.abort()
|
||||
return response
|
||||
})
|
||||
const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal }))
|
||||
await expect(response.text()).rejects.toThrow('Client cancelled.')
|
||||
expect((await new GatewayStore(directory).snapshot()).audits).toMatchObject([
|
||||
{ status: 'cancelled', httpStatus: 499, errorCode: 'CLIENT_CANCELLED', inputTokens: null, outputTokens: null }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -7,9 +7,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createApp } from './app.js'
|
||||
import { createApp as createUnboundApp } from './app.js'
|
||||
import { withLocalSocket } from './test-socket.js'
|
||||
import { SharingStore } from './sharing-store.js'
|
||||
|
||||
const createApp = (...args: Parameters<typeof createUnboundApp>) => withLocalSocket(createUnboundApp(...args))
|
||||
|
||||
const device = { id: 'desktop-1', name: 'Local desktop', platform: 'win32', appVersion: '1.0.0' }
|
||||
const publication = {
|
||||
id: 'pub-1', deviceId: device.id, name: 'Local knowledge', kind: 'knowledge',
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Hono } from 'hono'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
import type { SocketBindings } from './admin-boundary.js'
|
||||
|
||||
export const socketBinding = (remoteAddress = '127.0.0.1'): SocketBindings => ({ incoming: { socket: { remoteAddress } } as IncomingMessage })
|
||||
|
||||
// Inject the same binding read from IncomingMessage in production, never a header.
|
||||
export function withLocalSocket<T extends { request: Hono['request'] }>(app: T): T {
|
||||
const request = app.request.bind(app)
|
||||
app.request = (input, init, env, executionCtx) => request(input, init, env ?? socketBinding(), executionCtx)
|
||||
return app
|
||||
}
|
||||
Reference in New Issue
Block a user