feat: add local sharing and containerized web/server layout
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
import { secureHeaders } from 'hono/secure-headers'
|
||||
import { prototypeSnapshot } from '../shared/prototype-data.js'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
contentSecurityPolicy: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"]
|
||||
},
|
||||
crossOriginEmbedderPolicy: false
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/health', (context) =>
|
||||
context.json({
|
||||
status: 'ok',
|
||||
service: 'shareserver',
|
||||
version: '0.1.0-prototype.1'
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/ready', (context) =>
|
||||
context.json({
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/web/prototype/snapshot', (context) =>
|
||||
context.json({
|
||||
...prototypeSnapshot,
|
||||
instance: {
|
||||
...prototypeSnapshot.instance,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.post('/api/v1/web/prototype/actions', async (context) => {
|
||||
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
|
||||
if (!body?.action || !body.targetId) {
|
||||
return context.json(
|
||||
{
|
||||
code: 'INVALID_PROTOTYPE_ACTION',
|
||||
message: '缺少原型操作或目标对象。',
|
||||
requestId: crypto.randomUUID()
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
return context.json({
|
||||
accepted: true,
|
||||
action: body.action,
|
||||
targetId: body.targetId,
|
||||
prototypeOnly: true,
|
||||
requestId: crypto.randomUUID()
|
||||
})
|
||||
})
|
||||
|
||||
app.use('*', serveStatic({ root: './dist' }))
|
||||
app.get('*', serveStatic({ path: './dist/index.html' }))
|
||||
|
||||
export { app }
|
||||
@@ -0,0 +1,101 @@
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { logger } from 'hono/logger'
|
||||
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'
|
||||
|
||||
export function createApp(store = new SharingStore()) {
|
||||
const app = new Hono()
|
||||
|
||||
app.use('*', logger())
|
||||
app.use(
|
||||
'*',
|
||||
secureHeaders({
|
||||
contentSecurityPolicy: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"]
|
||||
},
|
||||
crossOriginEmbedderPolicy: false
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/health', (context) =>
|
||||
context.json({
|
||||
status: 'ok',
|
||||
service: 'shareserver',
|
||||
version: '0.1.0-prototype.1'
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/public/ready', (context) =>
|
||||
context.json({
|
||||
status: 'ready',
|
||||
mode: 'interactive-prototype',
|
||||
productionReady: false,
|
||||
sharing: {
|
||||
mode: 'local-metadata-only',
|
||||
authentication: 'none',
|
||||
persistence: 'local-json',
|
||||
writerScope: 'single-process-single-store-per-data-directory',
|
||||
sourceMode: 'descriptive-only-no-content-sync',
|
||||
permissionsEnforced: false,
|
||||
networkBoundary: 'trusted-local-test-service',
|
||||
storageChecked: false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/api/v1/web/prototype/snapshot', (context) =>
|
||||
context.json({
|
||||
...prototypeSnapshot,
|
||||
instance: {
|
||||
...prototypeSnapshot.instance,
|
||||
generatedAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
app.post('/api/v1/web/prototype/actions', async (context) => {
|
||||
const body = await context.req.json<{ action?: string; targetId?: string }>().catch(() => null)
|
||||
if (!body?.action || !body.targetId) {
|
||||
return context.json(
|
||||
{
|
||||
code: 'INVALID_PROTOTYPE_ACTION',
|
||||
message: '缺少原型操作或目标对象。',
|
||||
requestId: crypto.randomUUID()
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
return context.json({
|
||||
accepted: true,
|
||||
action: body.action,
|
||||
targetId: body.targetId,
|
||||
prototypeOnly: true,
|
||||
requestId: crypto.randomUUID()
|
||||
})
|
||||
})
|
||||
|
||||
app.route('/api/v1/sharing', sharingRoutes(store))
|
||||
|
||||
app.all('/api/*', (context) => context.json({
|
||||
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
|
||||
}, 404))
|
||||
app.all('/api', (context) => context.json({
|
||||
code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID()
|
||||
}, 404))
|
||||
|
||||
app.use('*', serveStatic({ root: './dist/web' }))
|
||||
app.get('*', serveStatic({ path: './dist/web/index.html' }))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
const app = createApp()
|
||||
export { app }
|
||||
@@ -0,0 +1,42 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { SharingError, SharingStore, validateDevice, validateId, validatePublication } from './sharing-store.js'
|
||||
|
||||
async function body(context: Context): Promise<unknown> {
|
||||
if (context.req.header('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
||||
throw new SharingError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Use application/json.')
|
||||
}
|
||||
try {
|
||||
return await context.req.json()
|
||||
} catch {
|
||||
throw new SharingError(400, 'INVALID_JSON', 'Request body must be valid JSON.')
|
||||
}
|
||||
}
|
||||
|
||||
export function sharingRoutes(store: SharingStore) {
|
||||
const routes = new Hono()
|
||||
routes.onError((error, context) => {
|
||||
if (error instanceof SharingError) {
|
||||
return context.json({ code: error.code, message: error.message, requestId: randomUUID() }, error.status)
|
||||
}
|
||||
return context.json({ code: 'STORAGE_UNAVAILABLE', message: 'Sharing metadata storage is unavailable.', requestId: randomUUID() }, 503)
|
||||
})
|
||||
routes.use('*', bodyLimit({
|
||||
maxSize: 32 * 1024,
|
||||
onError: (context) => context.json({ code: 'PAYLOAD_TOO_LARGE', message: 'Request body exceeds 32768 bytes.', requestId: randomUUID() }, 413)
|
||||
}))
|
||||
routes.post('/devices/register', async (context) => context.json(await store.register(validateDevice(await body(context)))))
|
||||
routes.get('/devices', async (context) => context.json({ devices: await store.devices() }))
|
||||
routes.get('/publications', async (context) => {
|
||||
const queries = context.req.queries()
|
||||
if (Object.keys(queries).some((key) => key !== 'deviceId') || (queries.deviceId && queries.deviceId.length !== 1)) {
|
||||
throw new SharingError(400, 'INVALID_INPUT', 'Only one optional deviceId query parameter is supported.')
|
||||
}
|
||||
const deviceId = queries.deviceId ? validateId(queries.deviceId[0], 'deviceId') : undefined
|
||||
return context.json({ publications: await store.publications(deviceId) })
|
||||
})
|
||||
routes.post('/publications', async (context) => context.json(await store.publish(validatePublication(await body(context)))))
|
||||
routes.post('/publications/:id/revoke', async (context) => context.json(await store.revoke(validateId(context.req.param('id')))))
|
||||
return routes
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Device, DeviceInput, Publication, PublicationInput } from '../../shared/sharing.js'
|
||||
|
||||
export class SharingError extends Error {
|
||||
constructor(public status: 400 | 404 | 409 | 413 | 415, public code: string, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new SharingError(400, 'INVALID_INPUT', message)
|
||||
}
|
||||
|
||||
function object(value: unknown, keys: string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid('Expected a JSON object.')
|
||||
const record = value as Record<string, unknown>
|
||||
if (Object.keys(record).length !== keys.length || keys.some((key) => !Object.hasOwn(record, key))) {
|
||||
invalid(`Expected exactly these fields: ${keys.join(', ')}.`)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
function text(value: unknown, field: string, max: number, allowEmpty = false): string {
|
||||
if (typeof value !== 'string' || value.length > max || (!allowEmpty && !value.trim()) ||
|
||||
Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
return code === 127 || (code < 32 && ![9, 10, 13].includes(code))
|
||||
})) {
|
||||
invalid(`${field} must be ${allowEmpty ? '0' : '1'}-${max} characters without control characters.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function validateId(value: unknown, field = 'id'): string {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
|
||||
invalid(`${field} must be 1-128 ASCII letters, digits, dots, underscores or hyphens, starting with a letter or digit.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function validateDevice(value: unknown): DeviceInput {
|
||||
const input = object(value, ['id', 'name', 'platform', 'appVersion'])
|
||||
return {
|
||||
id: validateId(input.id),
|
||||
name: text(input.name, 'name', 200),
|
||||
platform: text(input.platform, 'platform', 64),
|
||||
appVersion: text(input.appVersion, 'appVersion', 64)
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePublication(value: unknown): PublicationInput {
|
||||
const input = object(value, ['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions'])
|
||||
if (input.kind !== 'capability' && input.kind !== 'knowledge') invalid('Invalid kind.')
|
||||
if (input.sourceMode !== 'device' && input.sourceMode !== 'server') invalid('Invalid sourceMode.')
|
||||
const permissions = object(input.permissions, ['search', 'read', 'download'])
|
||||
if (typeof permissions.search !== 'boolean' || typeof permissions.read !== 'boolean' ||
|
||||
typeof permissions.download !== 'boolean') invalid('Permissions must be booleans.')
|
||||
return {
|
||||
id: validateId(input.id),
|
||||
deviceId: validateId(input.deviceId, 'deviceId'),
|
||||
name: text(input.name, 'name', 200),
|
||||
kind: input.kind,
|
||||
description: text(input.description, 'description', 4000, true),
|
||||
sourceMode: input.sourceMode,
|
||||
permissions: { search: permissions.search, read: permissions.read, download: permissions.download }
|
||||
}
|
||||
}
|
||||
|
||||
interface State {
|
||||
version: 1
|
||||
devices: Device[]
|
||||
publications: Publication[]
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): string {
|
||||
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) ||
|
||||
new Date(value).toISOString() !== value) throw new Error('Invalid stored timestamp.')
|
||||
return value
|
||||
}
|
||||
|
||||
function parseState(value: unknown): State {
|
||||
const state = object(value, ['version', 'devices', 'publications'])
|
||||
if (state.version !== 1 || !Array.isArray(state.devices) || !Array.isArray(state.publications)) {
|
||||
throw new Error('Invalid metadata file.')
|
||||
}
|
||||
const devices = state.devices.map((value) => {
|
||||
const { registeredAt, updatedAt, ...input } = object(value, ['id', 'name', 'platform', 'appVersion', 'registeredAt', 'updatedAt'])
|
||||
return { ...validateDevice(input), registeredAt: timestamp(registeredAt), updatedAt: timestamp(updatedAt) }
|
||||
})
|
||||
const publications = state.publications.map((value): Publication => {
|
||||
const { status, createdAt, updatedAt, ...input } = object(value,
|
||||
['id', 'deviceId', 'name', 'kind', 'description', 'sourceMode', 'permissions', 'status', 'createdAt', 'updatedAt'])
|
||||
if (status !== 'published' && status !== 'revoked') throw new Error('Invalid stored status.')
|
||||
return { ...validatePublication(input), status, createdAt: timestamp(createdAt), updatedAt: timestamp(updatedAt) }
|
||||
})
|
||||
const ids = new Set(devices.map((device) => device.id))
|
||||
if (ids.size !== devices.length || new Set(publications.map((item) => item.id)).size !== publications.length ||
|
||||
publications.some((item) => !ids.has(item.deviceId))) throw new Error('Invalid metadata references.')
|
||||
return { version: 1, devices, publications }
|
||||
}
|
||||
|
||||
// One store per data directory in one process. The queue covers reads and mutations.
|
||||
export class SharingStore {
|
||||
private state?: State
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private readonly directory: string
|
||||
private readonly filename: string
|
||||
|
||||
constructor(directory = process.env.SHARESERVER_DATA_DIR ?? './data') {
|
||||
this.directory = resolve(directory)
|
||||
this.filename = resolve(this.directory, 'sharing.json')
|
||||
}
|
||||
|
||||
private run<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(operation)
|
||||
this.queue = result.catch(() => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
private async load(): Promise<State> {
|
||||
if (!this.state) {
|
||||
try {
|
||||
this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8')))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Stored corruption is a service failure, never a client validation error.
|
||||
throw new Error('Unable to load sharing metadata.', { cause: error })
|
||||
}
|
||||
this.state = { version: 1, devices: [], publications: [] }
|
||||
}
|
||||
}
|
||||
return this.state
|
||||
}
|
||||
|
||||
private async save(state: State): Promise<void> {
|
||||
await mkdir(this.directory, { recursive: true })
|
||||
const temporary = `${this.filename}.${randomUUID()}.tmp`
|
||||
try {
|
||||
const file = await open(temporary, 'wx', 0o600)
|
||||
try {
|
||||
await file.writeFile(JSON.stringify(state, null, 2) + '\n', 'utf8')
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
await rename(temporary, this.filename)
|
||||
this.state = state
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
devices(): Promise<Device[]> {
|
||||
return this.run(async () => structuredClone((await this.load()).devices))
|
||||
}
|
||||
|
||||
publications(deviceId?: string): Promise<Publication[]> {
|
||||
return this.run(async () => structuredClone((await this.load()).publications.filter(
|
||||
(item) => deviceId === undefined || item.deviceId === deviceId
|
||||
)))
|
||||
}
|
||||
|
||||
register(input: DeviceInput): Promise<Device> {
|
||||
return this.run(async () => {
|
||||
const state = structuredClone(await this.load())
|
||||
const index = state.devices.findIndex((item) => item.id === input.id)
|
||||
const existing = state.devices[index]
|
||||
if (existing && existing.name === input.name && existing.platform === input.platform &&
|
||||
existing.appVersion === input.appVersion) return existing
|
||||
const now = new Date().toISOString()
|
||||
const device = { ...input, registeredAt: existing?.registeredAt ?? now, updatedAt: now }
|
||||
if (index < 0) state.devices.push(device)
|
||||
else state.devices[index] = device
|
||||
await this.save(state)
|
||||
return structuredClone(device)
|
||||
})
|
||||
}
|
||||
|
||||
publish(input: PublicationInput): Promise<Publication> {
|
||||
return this.run(async () => {
|
||||
const state = structuredClone(await this.load())
|
||||
const existing = state.publications.find((item) => item.id === input.id)
|
||||
if (existing) {
|
||||
const original: PublicationInput = {
|
||||
id: existing.id, deviceId: existing.deviceId, name: existing.name, kind: existing.kind,
|
||||
description: existing.description, sourceMode: existing.sourceMode, permissions: existing.permissions
|
||||
}
|
||||
if (JSON.stringify(original) !== JSON.stringify(input)) {
|
||||
throw new SharingError(409, 'PUBLICATION_ID_CONFLICT', 'Publication ID already has different metadata.')
|
||||
}
|
||||
// Retrying creation must never republish a revoked record.
|
||||
return existing
|
||||
}
|
||||
if (!state.devices.some((item) => item.id === input.deviceId)) {
|
||||
throw new SharingError(404, 'DEVICE_NOT_FOUND', 'Register the device before publishing.')
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const publication: Publication = { ...input, status: 'published', createdAt: now, updatedAt: now }
|
||||
state.publications.push(publication)
|
||||
await this.save(state)
|
||||
return structuredClone(publication)
|
||||
})
|
||||
}
|
||||
|
||||
revoke(id: string): Promise<Publication> {
|
||||
return this.run(async () => {
|
||||
const state = structuredClone(await this.load())
|
||||
const publication = state.publications.find((item) => item.id === id)
|
||||
if (!publication) throw new SharingError(404, 'PUBLICATION_NOT_FOUND', 'Publication does not exist.')
|
||||
if (publication.status === 'revoked') return publication
|
||||
publication.status = 'revoked'
|
||||
publication.updatedAt = new Date().toISOString()
|
||||
await this.save(state)
|
||||
return structuredClone(publication)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
// @vitest-environment node
|
||||
import { serve } from '@hono/node-server'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from 'node:fs/promises'
|
||||
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 { SharingStore } from './sharing-store.js'
|
||||
|
||||
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',
|
||||
description: 'Metadata only', sourceMode: 'server', permissions: { search: true, read: true, download: false }
|
||||
}
|
||||
const prefix = '/api/v1/sharing'
|
||||
const post = (value: unknown): RequestInit => ({
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value)
|
||||
})
|
||||
|
||||
describe('persistent sharing API', () => {
|
||||
let directory: string
|
||||
let app: ReturnType<typeof createApp>
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'shareserver-test-'))
|
||||
app = createApp(new SharingStore(directory))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('upserts devices and preserves registration time and exact retry timestamps', async () => {
|
||||
const first = await app.request(`${prefix}/devices/register`, post(device))
|
||||
expect(first.status).toBe(200)
|
||||
const original = await first.json()
|
||||
expect(original).toEqual({ ...device, registeredAt: expect.any(String), updatedAt: expect.any(String) })
|
||||
expect(new Date(original.registeredAt).toISOString()).toBe(original.registeredAt)
|
||||
const retry = await app.request(`${prefix}/devices/register`, post(device))
|
||||
expect(await retry.json()).toEqual(original)
|
||||
const update = await app.request(`${prefix}/devices/register`, post({ ...device, name: 'Renamed', appVersion: '2' }))
|
||||
const updated = await update.json()
|
||||
expect(updated).toMatchObject({ name: 'Renamed', appVersion: '2', registeredAt: original.registeredAt })
|
||||
expect(updated.updatedAt >= original.updatedAt).toBe(true)
|
||||
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [updated] })
|
||||
})
|
||||
|
||||
it('handles publication retries, conflicts, filtering and idempotent revocation across restarts', async () => {
|
||||
await app.request(`${prefix}/devices/register`, post(device))
|
||||
const created = await app.request(`${prefix}/publications`, post(publication))
|
||||
expect(created.status).toBe(200)
|
||||
const original = await created.json()
|
||||
expect(original).toEqual({ ...publication, status: 'published', createdAt: expect.any(String), updatedAt: expect.any(String) })
|
||||
// JSON key order is not part of the retry contract.
|
||||
const retry = await app.request(`${prefix}/publications`, post({
|
||||
...publication, permissions: { download: false, read: true, search: true }
|
||||
}))
|
||||
expect(await retry.json()).toEqual(original)
|
||||
for (const change of [{ name: 'Changed' }, { deviceId: 'other' }, { permissions: { search: false, read: true, download: false } }]) {
|
||||
const conflict = await app.request(`${prefix}/publications`, post({ ...publication, ...change }))
|
||||
expect(conflict.status).toBe(409)
|
||||
expect(await conflict.json()).toMatchObject({ code: 'PUBLICATION_ID_CONFLICT' })
|
||||
}
|
||||
app = createApp(new SharingStore(directory))
|
||||
expect(await (await app.request(`${prefix}/publications?deviceId=${device.id}`)).json()).toEqual({ publications: [original] })
|
||||
expect(await (await app.request(`${prefix}/publications?deviceId=other`)).json()).toEqual({ publications: [] })
|
||||
const revoked = await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()
|
||||
expect(revoked).toMatchObject({ status: 'revoked', createdAt: original.createdAt })
|
||||
app = createApp(new SharingStore(directory))
|
||||
expect(await (await app.request(`${prefix}/publications/pub-1/revoke`, { method: 'POST' })).json()).toEqual(revoked)
|
||||
expect(await (await app.request(`${prefix}/publications`, post(publication))).json()).toEqual(revoked)
|
||||
expect(await (await app.request(`${prefix}/publications`)).json()).toEqual({ publications: [revoked] })
|
||||
expect(await readdir(directory)).toEqual(['sharing.json'])
|
||||
})
|
||||
|
||||
it('serializes concurrent mutations without losing records, including conflicting ID retries', async () => {
|
||||
const results = await Promise.all(Array.from({ length: 16 }, (_, index) =>
|
||||
app.request(`${prefix}/devices/register`, post({ ...device, id: `device-${index}` }))))
|
||||
expect(results.every((response) => response.status === 200)).toBe(true)
|
||||
const publishes = await Promise.all(['A', 'B'].map((name) => app.request(`${prefix}/publications`,
|
||||
post({ ...publication, deviceId: 'device-0', name }))))
|
||||
expect(publishes.map((response) => response.status).sort()).toEqual([200, 409])
|
||||
const restarted = createApp(new SharingStore(directory))
|
||||
const devices = await (await restarted.request(`${prefix}/devices`)).json()
|
||||
expect(devices.devices).toHaveLength(16)
|
||||
const publications = await (await restarted.request(`${prefix}/publications`)).json()
|
||||
expect(publications.publications).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns explicit missing-resource errors', async () => {
|
||||
for (const [path, init, code] of [
|
||||
[`${prefix}/publications`, post(publication), 'DEVICE_NOT_FOUND'],
|
||||
[`${prefix}/publications/missing/revoke`, { method: 'POST' }, 'PUBLICATION_NOT_FOUND']
|
||||
] as const) {
|
||||
const response = await app.request(path, init)
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toMatchObject({ code, requestId: expect.any(String) })
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
null, [], {}, { ...device, id: '../escape' }, { ...device, id: 'x'.repeat(129) },
|
||||
{ ...device, name: ' ' }, { ...device, name: 'x'.repeat(201) }, { ...device, name: 'bad\u0000name' },
|
||||
{ ...device, platform: 123 }, { ...device, appVersion: 'x'.repeat(65) }, { ...device, token: 'not-accepted' }
|
||||
])('rejects invalid device input %j', async (input) => {
|
||||
const response = await app.request(`${prefix}/devices/register`, post(input))
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
|
||||
expect(await readdir(directory)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ...publication, kind: 'file' }, { ...publication, sourceMode: 'cloud' },
|
||||
{ ...publication, description: 'x'.repeat(4001) }, { ...publication, deviceId: '' },
|
||||
{ ...publication, permissions: { search: 'true', read: true, download: false } },
|
||||
{ ...publication, permissions: { search: true, read: true } },
|
||||
{ ...publication, permissions: { ...publication.permissions, token: 'not-accepted' } },
|
||||
{ ...publication, status: 'published' }
|
||||
])('rejects invalid publication input %j', async (input) => {
|
||||
const response = await app.request(`${prefix}/publications`, post(input))
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toMatchObject({ code: 'INVALID_INPUT' })
|
||||
})
|
||||
|
||||
it('bounds requests and rejects malformed JSON, media types, and invalid filters', async () => {
|
||||
for (const [init, status, code] of [
|
||||
[{ method: 'POST', body: '{}' }, 415, 'UNSUPPORTED_MEDIA_TYPE'],
|
||||
[{ ...post({}), body: '{' }, 400, 'INVALID_JSON'],
|
||||
[post({ ...device, name: 'x'.repeat(32768) }), 413, 'PAYLOAD_TOO_LARGE']
|
||||
] as const) {
|
||||
const response = await app.request(`${prefix}/devices/register`, init)
|
||||
expect(response.status).toBe(status)
|
||||
expect(await response.json()).toMatchObject({ code })
|
||||
}
|
||||
for (const query of ['deviceId=', 'deviceId=a&deviceId=b', 'deviceId=..%2Fsecret', 'token=secret']) {
|
||||
const response = await app.request(`${prefix}/publications?${query}`)
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not overwrite corrupt storage or expose filesystem details', async () => {
|
||||
await writeFile(join(directory, 'sharing.json'), '{broken')
|
||||
const response = await app.request(`${prefix}/devices/register`, post(device))
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toMatchObject({ code: 'STORAGE_UNAVAILABLE' })
|
||||
expect(await readFile(join(directory, 'sharing.json'), 'utf8')).toBe('{broken')
|
||||
await writeFile(join(directory, 'sharing.json'), JSON.stringify({ version: 1, devices: [], publications: [{ ...publication }] }))
|
||||
expect((await app.request(`${prefix}/devices`)).status).toBe(503)
|
||||
})
|
||||
|
||||
it('does not commit failed writes in memory and recovers its mutation queue', async () => {
|
||||
// A directory at the final file path forces the atomic rename to fail.
|
||||
expect((await app.request(`${prefix}/devices`)).status).toBe(200)
|
||||
await mkdir(join(directory, 'sharing.json'))
|
||||
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(503)
|
||||
expect(await (await app.request(`${prefix}/devices`)).json()).toEqual({ devices: [] })
|
||||
expect(await readdir(directory)).toEqual(['sharing.json'])
|
||||
await rm(join(directory, 'sharing.json'), { recursive: true })
|
||||
expect((await app.request(`${prefix}/devices/register`, post(device))).status).toBe(200)
|
||||
})
|
||||
|
||||
it('uses JSON 404s for unknown API paths and unsupported methods', async () => {
|
||||
for (const path of ['/api', '/api/missing', `${prefix}/missing`, `${prefix}/devices/register`]) {
|
||||
const response = await app.request(path)
|
||||
expect(response.status).toBe(404)
|
||||
expect(response.headers.get('content-type')).toContain('application/json')
|
||||
expect(await response.json()).toMatchObject({ code: 'NOT_FOUND' })
|
||||
}
|
||||
})
|
||||
|
||||
it('serves real HTTP and reloads persisted state after listener replacement', async () => {
|
||||
async function listen(current: ReturnType<typeof createApp>) {
|
||||
const server = serve({ fetch: current.fetch, hostname: '127.0.0.1', port: 0 })
|
||||
await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing listener address')
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
}
|
||||
}
|
||||
let listener = await listen(app)
|
||||
try {
|
||||
const registered = await fetch(`${listener.url}${prefix}/devices/register`, post(device))
|
||||
expect(registered.status).toBe(200)
|
||||
await registered.json()
|
||||
const published = await fetch(`${listener.url}${prefix}/publications`, post(publication))
|
||||
expect(published.status).toBe(200)
|
||||
const record = await published.json()
|
||||
await listener.close()
|
||||
listener = await listen(createApp(new SharingStore(directory)))
|
||||
const listed = await fetch(`${listener.url}${prefix}/publications?deviceId=${device.id}`)
|
||||
expect(await listed.json()).toEqual({ publications: [record] })
|
||||
const revoked = await fetch(`${listener.url}${prefix}/publications/pub-1/revoke`, { method: 'POST' })
|
||||
expect(await revoked.json()).toMatchObject({ status: 'revoked' })
|
||||
const ready = await fetch(`${listener.url}/api/v1/public/ready`)
|
||||
expect(await ready.json()).toMatchObject({ productionReady: false, sharing: {
|
||||
authentication: 'none', sourceMode: 'descriptive-only-no-content-sync', permissionsEnforced: false
|
||||
} })
|
||||
const missing = await fetch(`${listener.url}/api/unknown`)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toMatchObject({ code: 'NOT_FOUND' })
|
||||
} finally {
|
||||
await listener.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists metadata across actual server process restarts using SHARESERVER_DATA_DIR', async () => {
|
||||
async function start() {
|
||||
const child = spawn(process.execPath, ['--import', 'tsx', fileURLToPath(new URL('./index.ts', import.meta.url))], {
|
||||
env: { ...process.env, SHARESERVER_DATA_DIR: directory, SHARESERVER_HOST: '127.0.0.1', SHARESERVER_PORT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
const exited = once(child, 'exit')
|
||||
const stop = async () => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill()
|
||||
await exited
|
||||
}
|
||||
try {
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
let output = ''
|
||||
const timer = setTimeout(() => reject(new Error('Server startup timed out')), 10000)
|
||||
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
||||
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Server exited during startup: ${output}`)) })
|
||||
child.stderr.on('data', (chunk) => { output += chunk.toString() })
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString()
|
||||
const address = output.match(/listening on (http:\/\/127\.0\.0\.1:\d+)/)
|
||||
if (address) { clearTimeout(timer); resolve(address[1]) }
|
||||
})
|
||||
})
|
||||
return { url, stop }
|
||||
} catch (error) {
|
||||
await stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const first = await start()
|
||||
let original: unknown
|
||||
try {
|
||||
const registered = await fetch(`${first.url}${prefix}/devices/register`, post(device))
|
||||
expect(registered.status).toBe(200)
|
||||
await registered.json()
|
||||
const response = await fetch(`${first.url}${prefix}/publications`, post(publication))
|
||||
expect(response.status).toBe(200)
|
||||
original = await response.json()
|
||||
} finally {
|
||||
await first.stop()
|
||||
}
|
||||
const second = await start()
|
||||
try {
|
||||
const response = await fetch(`${second.url}${prefix}/publications`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ publications: [original] })
|
||||
const retry = await fetch(`${second.url}${prefix}/publications`, post(publication))
|
||||
expect(await retry.json()).toEqual(original)
|
||||
} finally {
|
||||
await second.stop()
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "../dist/server",
|
||||
"rootDir": "..",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "../shared"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user