From f54710089a0e80e85a7efc8a95d55948f0acb25b Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:35:46 -0400 Subject: [PATCH 01/25] chore: project scaffold (package.json, tsconfig, tooling) --- .env.example | 30 ++++++++++++++++++++++++++++++ .gitignore | 8 ++++++++ .prettierignore | 3 +++ .prettierrc | 7 +++++++ eslint.config.mjs | 18 ++++++++++++++++++ package.json | 36 ++++++++++++++++++++++++++++++++++++ tsconfig.build.json | 9 +++++++++ tsconfig.json | 20 ++++++++++++++++++++ vitest.config.ts | 12 ++++++++++++ 9 files changed, 143 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c53fd5e --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Port to listen on (default: 3000) +PORT=3000 + +# Path to the SQLite database file. +# On first run (file does not exist), the database is initialized. +# On subsequent runs, the existing database is opened. +DB_PATH=./stack.db + +# Owner entity ID. Required only on first run when the DB is being initialized. +# Generate a stable ID before first launch and keep it consistent. +ENTITY_ID= + +# IANA timezone string. Used only on first run. Default: UTC +TIMEZONE=UTC + +# Bearer token to entity ID mapping. +# Format: token1:entityId1,token2:entityId2 +# Each token grants access as the specified entity. +# The entity whose ID matches ENTITY_ID is the stack owner and has full access. +AUTH_TOKENS= + +# Allowed CORS origins. Default: * (all origins) +# Use a comma-separated list to restrict: https://app.example.com,https://admin.example.com +CORS_ORIGINS=* + +# Canonical base URL of this server (optional). +# Used in responses that reference the server's own URL. +# Auto-detected from the request if not set. +# Example: https://stack.example.com +BASE_URL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5cc744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.db +*.db-wal +*.db-shm +attachments/ +.env +.env.local diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..29c69b2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2f5af49 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import prettierConfig from 'eslint-config-prettier'; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + { + ignores: ['dist/**', 'node_modules/**'], + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..098a013 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@haverstack/server", + "version": "0.1.0", + "description": "Reference server implementation for Haverstack", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/index.js", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "eslint src tests" + }, + "dependencies": { + "@haverstack/adapter-sqlite": "^0.1.0", + "@haverstack/core": "^0.1.0", + "@hono/node-server": "^1.13.7", + "hono": "^4.6.0", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "prettier": "^3.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vitest": "^2.0.0" + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..9ebb053 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4e1e0d1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..001cbcb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + }, + }, +}); From 9f29a2a9cd0a61bd157ea786b3d851d69fdb4776 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:36:13 -0400 Subject: [PATCH 02/25] feat: config, stack init, app factory, entry point --- src/app.ts | 51 ++++++++++++++++++++++++++++++++++++ src/config.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 46 ++++++++++++++++++++++++++++++++ src/stack.ts | 25 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/app.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/stack.ts diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..4e453e1 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,51 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { requestId } from 'hono/request-id'; +import type { Logger } from 'pino'; +import type { StackContext } from './stack.js'; +import type { Config } from './config.js'; +import { authMiddleware } from './middleware/auth.js'; +import { errorMiddleware } from './middleware/errors.js'; +import { wellknownRoutes } from './routes/wellknown.js'; +import { healthRoutes } from './routes/health.js'; +import { recordRoutes } from './routes/records.js'; +import { typeRoutes } from './routes/types.js'; +import { attachmentRoutes } from './routes/attachments.js'; +import { entityRoutes } from './routes/entity.js'; + +export type AppEnv = { + Variables: { + auth: { entityId: string } | null; + requestId: string; + }; +}; + +export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono { + const app = new Hono(); + + // Global middleware + app.use(requestId()); + app.use( + cors({ + origin: config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), + allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + allowHeaders: ['Authorization', 'Content-Type'], + exposeHeaders: ['X-Request-Id'], + }), + ); + app.use(errorMiddleware(logger)); + app.use(authMiddleware(config.tokens)); + + // Routes + app.route('/.well-known', wellknownRoutes(ctx)); + app.route('/health', healthRoutes()); + app.route('/records', recordRoutes(ctx)); + app.route('/types', typeRoutes(ctx)); + app.route('/attachments', attachmentRoutes(ctx)); + app.route('/entity', entityRoutes(ctx)); + + // 404 fallback + app.notFound((c) => c.json({ error: 'Not found' }, 404)); + + return app; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..8d8b5f0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs'; + +function required(name: string): string { + const val = process.env[name]; + if (!val) throw new Error(`Missing required environment variable: ${name}`); + return val; +} + +function optional(name: string, fallback: string): string { + return process.env[name] ?? fallback; +} + +export type TokenConfig = { + token: string; + entityId: string; +}; + +function parseTokens(raw: string): TokenConfig[] { + return raw.split(',').map((pair) => { + const i = pair.indexOf(':'); + if (i === -1) { + throw new Error( + `Invalid AUTH_TOKENS format. Expected comma-separated "token:entityId" pairs, got: "${pair}"`, + ); + } + const token = pair.slice(0, i).trim(); + const entityId = pair.slice(i + 1).trim(); + if (!token || !entityId) { + throw new Error(`Invalid AUTH_TOKENS entry "${pair}": both token and entityId are required`); + } + return { token, entityId }; + }); +} + +export type Config = { + port: number; + dbPath: string; + entityId: string | null; + timezone: string; + tokens: TokenConfig[]; + corsOrigins: string; + baseUrl: string | null; + isNewDb: boolean; +}; + +export function loadConfig(): Config { + const dbPath = required('DB_PATH'); + const isNewDb = !existsSync(dbPath); + + const entityId = process.env['ENTITY_ID'] ?? null; + const timezone = optional('TIMEZONE', 'UTC'); + + if (isNewDb && !entityId) { + throw new Error( + 'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)', + ); + } + + const rawTokens = required('AUTH_TOKENS'); + const tokens = parseTokens(rawTokens); + + return { + port: parseInt(optional('PORT', '3000'), 10), + dbPath, + entityId, + timezone, + tokens, + corsOrigins: optional('CORS_ORIGINS', '*'), + baseUrl: process.env['BASE_URL'] ?? null, + isNewDb, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e8b9f5e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,46 @@ +import { serve } from '@hono/node-server'; +import pino from 'pino'; +import { loadConfig } from './config.js'; +import { initStack } from './stack.js'; +import { createApp } from './app.js'; + +const logger = pino({ + level: process.env['LOG_LEVEL'] ?? 'info', + transport: + process.env['NODE_ENV'] !== 'production' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +async function main() { + const config = loadConfig(); + const ctx = await initStack(config); + const app = createApp(ctx, config, logger); + + logger.info( + { dbPath: config.dbPath, isNewDb: config.isNewDb }, + 'Stack initialized', + ); + + const server = serve({ fetch: app.fetch, port: config.port }, (info) => { + logger.info({ port: info.port }, 'Server listening'); + }); + + const shutdown = async (signal: string) => { + logger.info({ signal }, 'Shutting down'); + server.close(async () => { + await ctx.stack.flush(); + await ctx.stack.close(); + logger.info('Clean shutdown complete'); + process.exit(0); + }); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +main().catch((err) => { + logger.error({ err }, 'Fatal startup error'); + process.exit(1); +}); diff --git a/src/stack.ts b/src/stack.ts new file mode 100644 index 0000000..0361556 --- /dev/null +++ b/src/stack.ts @@ -0,0 +1,25 @@ +import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; +import { Stack } from '@haverstack/core'; +import type { Config } from './config.js'; + +export type StackContext = { + adapter: SQLiteAdapter; + stack: Stack; +}; + +export async function initStack(config: Config): Promise { + let adapter: SQLiteAdapter; + + if (config.isNewDb) { + adapter = await SQLiteAdapter.initialize({ + path: config.dbPath, + entityId: config.entityId!, + timezone: config.timezone, + }); + } else { + adapter = await SQLiteAdapter.open({ path: config.dbPath }); + } + + const stack = await Stack.create(adapter); + return { adapter, stack }; +} From a10a6fd439ff3baf9226f6a8fa565aa9f812fa30 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:36:40 -0400 Subject: [PATCH 03/25] feat: middleware (auth, errors) and lib (access control, serialization) --- src/lib/access.ts | 61 +++++++++++++++++++++++++++++ src/lib/serialize.ts | 83 ++++++++++++++++++++++++++++++++++++++++ src/middleware/auth.ts | 28 ++++++++++++++ src/middleware/errors.ts | 14 +++++++ 4 files changed, 186 insertions(+) create mode 100644 src/lib/access.ts create mode 100644 src/lib/serialize.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/middleware/errors.ts diff --git a/src/lib/access.ts b/src/lib/access.ts new file mode 100644 index 0000000..e2ebb8d --- /dev/null +++ b/src/lib/access.ts @@ -0,0 +1,61 @@ +import type { StackRecord, StackAdapter } from '@haverstack/core'; + +export type AccessMode = 'read' | 'write'; + +/** + * Check whether an entity has read or write access to a record. + * + * - No permissions (absent/empty): owner only. + * - public: anyone can read; write still requires an explicit grant. + * - entity: direct entityId match. + * - group: walk the _group record's relationship associations for membership. + */ +export async function checkAccess( + record: StackRecord, + requesterEntityId: string | null, + ownerEntityId: string | null, + mode: AccessMode, + adapter: StackAdapter, +): Promise { + // Owner always has full access. + if (requesterEntityId && requesterEntityId === ownerEntityId) return true; + + const perms = record.permissions; + + // No permissions = private. + if (!perms || perms.length === 0) return false; + + for (const p of perms) { + if (p.access === 'public' && mode === 'read') return true; + + if (p.access === 'entity' && p.entityId === requesterEntityId) { + if (mode === 'read' && p.read) return true; + if (mode === 'write' && p.write) return true; + } + + if (p.access === 'group' && requesterEntityId) { + const member = await isGroupMember(p.groupId, requesterEntityId, adapter); + if (member) { + if (mode === 'read' && p.read) return true; + if (mode === 'write' && p.write) return true; + } + } + } + + return false; +} + +async function isGroupMember( + groupRecordId: string, + entityId: string, + adapter: StackAdapter, +): Promise { + const group = await adapter.getRecord(groupRecordId); + if (!group) return false; + return (group.associations ?? []).some( + (a) => + a.kind === 'relationship' && + (a.label === 'member' || a.label === 'admin') && + a.recordId === entityId, + ); +} diff --git a/src/lib/serialize.ts b/src/lib/serialize.ts new file mode 100644 index 0000000..16a1076 --- /dev/null +++ b/src/lib/serialize.ts @@ -0,0 +1,83 @@ +import type { StackRecord, StackType, RecordVersion, Association, Permission } from '@haverstack/core'; + +export type WireRecord = { + id: string; + typeId: string; + createdAt: string; + updatedAt: string; + content: Record; + version: number; + parentId?: string; + entityId?: string; + appId?: string; + deletedAt?: string; + permissions?: Permission[]; + associations?: Association[]; +}; + +export type WireType = { + id: string; + baseId: string; + version: number; + name: string; + schema: Record; + schemaHash: string; + migratesFrom?: string; + createdAt: string; +}; + +export type WireVersion = { + version: number; + content: Record; + updatedAt: string; + entityId?: string; +}; + +export function serializeRecord(r: StackRecord): WireRecord { + const w: WireRecord = { + id: r.id, + typeId: r.typeId, + createdAt: r.createdAt.toISOString(), + updatedAt: r.updatedAt.toISOString(), + content: r.content, + version: r.version, + }; + if (r.parentId !== undefined) w.parentId = r.parentId; + if (r.entityId !== undefined) w.entityId = r.entityId; + if (r.appId !== undefined) w.appId = r.appId; + if (r.deletedAt !== undefined) w.deletedAt = r.deletedAt.toISOString(); + if (r.permissions !== undefined) w.permissions = r.permissions; + if (r.associations !== undefined) w.associations = r.associations; + return w; +} + +export function serializeType(t: StackType): WireType { + const w: WireType = { + id: t.id, + baseId: t.baseId, + version: t.version, + name: t.name, + schema: t.schema as Record, + schemaHash: t.schemaHash, + createdAt: t.createdAt.toISOString(), + }; + if (t.migratesFrom !== undefined) w.migratesFrom = t.migratesFrom; + return w; +} + +export function serializeVersion(v: RecordVersion): WireVersion { + const w: WireVersion = { + version: v.version, + content: v.content, + updatedAt: v.updatedAt.toISOString(), + }; + if (v.entityId !== undefined) w.entityId = v.entityId; + return w; +} + +/** Parse an ISO date string from a wire body, returns undefined if absent or invalid. */ +export function parseDate(val: unknown): Date | undefined { + if (typeof val !== 'string') return undefined; + const d = new Date(val); + return isNaN(d.getTime()) ? undefined : d; +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..42a5456 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,28 @@ +import type { MiddlewareHandler } from 'hono'; +import type { TokenConfig } from '../config.js'; +import type { AppEnv } from '../app.js'; + +export function authMiddleware(tokens: TokenConfig[]): MiddlewareHandler { + const tokenMap = new Map(tokens.map((t) => [t.token, t.entityId])); + + return async (c, next) => { + const header = c.req.header('Authorization'); + if (header?.startsWith('Bearer ')) { + const token = header.slice(7); + const entityId = tokenMap.get(token); + c.set('auth', entityId ? { entityId } : null); + } else { + c.set('auth', null); + } + await next(); + }; +} + +export function requireAuth(): MiddlewareHandler { + return async (c, next) => { + if (!c.get('auth')) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); + }; +} diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts new file mode 100644 index 0000000..693adc2 --- /dev/null +++ b/src/middleware/errors.ts @@ -0,0 +1,14 @@ +import type { MiddlewareHandler } from 'hono'; +import type { Logger } from 'pino'; +import type { AppEnv } from '../app.js'; + +export function errorMiddleware(logger: Logger): MiddlewareHandler { + return async (c, next) => { + try { + await next(); + } catch (err) { + logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); + return c.json({ error: 'Internal server error' }, 500); + } + }; +} From 8da8490fef234f422b0e85310155fc375af36868 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:37:31 -0400 Subject: [PATCH 04/25] feat: wellknown, health, types, entity routes --- src/routes/entity.ts | 52 +++++++++++++++++++++++++++++++++ src/routes/health.ts | 10 +++++++ src/routes/types.ts | 65 +++++++++++++++++++++++++++++++++++++++++ src/routes/wellknown.ts | 18 ++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 src/routes/entity.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/types.ts create mode 100644 src/routes/wellknown.ts diff --git a/src/routes/entity.ts b/src/routes/entity.ts new file mode 100644 index 0000000..10f767b --- /dev/null +++ b/src/routes/entity.ts @@ -0,0 +1,52 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../app.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; +import { serializeRecord, parseDate } from '../lib/serialize.js'; + +export function entityRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // Get the stack owner's entity record + app.get('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const record = await adapter.getRecord(ownerEntityId); + if (!record) return c.json({ error: 'Entity record not found' }, 404); + return c.json(serializeRecord(record)); + }); + + // Update the entity record's content + app.patch('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(ownerEntityId); + if (!existing) return c.json({ error: 'Entity record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json>(); + + // Snapshot current state before updating + await adapter.saveVersion(ownerEntityId, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + + const updated = await adapter.updateRecord(ownerEntityId, { + ...(body.content !== undefined && { content: body.content as Record }), + ...(body.typeId !== undefined && { typeId: body.typeId as string }), + updatedAt: parseDate(body.updatedAt) ?? new Date(), + version: typeof body.version === 'number' ? body.version : existing.version + 1, + }); + + return c.json(serializeRecord(updated)); + }); + + return app; +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..d656733 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,10 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../app.js'; + +export function healthRoutes(): Hono { + const app = new Hono(); + + app.get('/', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); + + return app; +} diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..7f7fea1 --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,65 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../app.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { serializeType } from '../lib/serialize.js'; +import type { StackType, TypeSchema } from '@haverstack/core'; + +export function typeRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter } = ctx; + + // List all types + app.get('/', requireAuth(), async (c) => { + const types = await adapter.listTypes(); + return c.json(types.map(serializeType)); + }); + + // Get one type (id is URL-encoded) + app.get('/:id', requireAuth(), async (c) => { + const id = decodeURIComponent(c.req.param('id')); + const type = await adapter.getType(id); + if (!type) return c.json({ error: 'Type not found' }, 404); + return c.json(serializeType(type)); + }); + + // Register or replace a type + app.post('/', requireAuth(), async (c) => { + const body = await c.req.json>(); + + if (!body.id || typeof body.id !== 'string') { + return c.json({ error: 'id is required' }, 400); + } + if (!body.baseId || typeof body.baseId !== 'string') { + return c.json({ error: 'baseId is required' }, 400); + } + if (typeof body.version !== 'number') { + return c.json({ error: 'version must be a number' }, 400); + } + if (!body.name || typeof body.name !== 'string') { + return c.json({ error: 'name is required' }, 400); + } + if (!body.schema || typeof body.schema !== 'object') { + return c.json({ error: 'schema is required' }, 400); + } + if (!body.schemaHash || typeof body.schemaHash !== 'string') { + return c.json({ error: 'schemaHash is required' }, 400); + } + + const type: StackType = { + id: body.id, + baseId: body.baseId, + version: body.version, + name: body.name, + schema: body.schema as TypeSchema, + schemaHash: body.schemaHash, + createdAt: body.createdAt ? new Date(body.createdAt as string) : new Date(), + ...(body.migratesFrom && { migratesFrom: body.migratesFrom as string }), + }; + + await adapter.saveType(type); + return c.json(serializeType(type), 201); + }); + + return app; +} diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts new file mode 100644 index 0000000..1b56474 --- /dev/null +++ b/src/routes/wellknown.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../app.js'; +import type { StackContext } from '../stack.js'; + +export function wellknownRoutes(ctx: StackContext): Hono { + const app = new Hono(); + + app.get('/stack', (c) => { + return c.json({ + version: '1.0', + entityId: ctx.stack.ownerEntityId ?? '', + timezone: ctx.stack.timezone, + capabilities: ctx.stack.capabilities, + }); + }); + + return app; +} From 43de7ee19c31e929656d7a983fa307451692f81f Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:38:43 -0400 Subject: [PATCH 05/25] feat: records route (CRUD, query, associations, permissions, versions) --- src/routes/records.ts | 440 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 src/routes/records.ts diff --git a/src/routes/records.ts b/src/routes/records.ts new file mode 100644 index 0000000..84e440c --- /dev/null +++ b/src/routes/records.ts @@ -0,0 +1,440 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../app.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; +import { serializeRecord, serializeVersion } from '../lib/serialize.js'; +import type { + StackRecord, + StackQuery, + RecordFilter, + Association, + Permission, +} from '@haverstack/core'; + +// --------------------------------------------------------------------------- +// Query parsing helpers +// --------------------------------------------------------------------------- + +function getAll(url: URL, key: string): string[] { + return url.searchParams.getAll(key); +} + +function getOne(url: URL, key: string): string | null { + return url.searchParams.get(key); +} + +/** Convert wire ISO strings back to Date objects inside a StackQuery body. */ +function parseQueryBody(raw: unknown): StackQuery { + if (!raw || typeof raw !== 'object') return {}; + const body = raw as Record; + const query: StackQuery = {}; + + if (body.filter) { + const f = body.filter as Record; + const filter: RecordFilter = {}; + + if (f.typeId !== undefined) filter.typeId = f.typeId as string | string[]; + if (f.parentId !== undefined) + filter.parentId = f.parentId === null ? null : (f.parentId as string); + if (f.appId !== undefined) filter.appId = f.appId as string | string[]; + if (f.entityId !== undefined) filter.entityId = f.entityId as string | string[]; + if (f.tags !== undefined) filter.tags = f.tags as string[]; + if (f.hasAttachment !== undefined) filter.hasAttachment = f.hasAttachment as string; + if (f.relatedTo !== undefined) + filter.relatedTo = f.relatedTo as { recordId: string; label?: string }; + if (f.content !== undefined) filter.content = f.content as Record; + if (f.search !== undefined) filter.search = f.search as string; + if (f.includeDeleted) filter.includeDeleted = true; + + if (f.createdAt) { + const r = f.createdAt as Record; + filter.createdAt = { + ...(r.before && { before: new Date(r.before) }), + ...(r.after && { after: new Date(r.after) }), + }; + } + if (f.updatedAt) { + const r = f.updatedAt as Record; + filter.updatedAt = { + ...(r.before && { before: new Date(r.before) }), + ...(r.after && { after: new Date(r.after) }), + }; + } + + query.filter = filter; + } + + if (body.sort) { + const s = body.sort as Record; + query.sort = { + field: s.field as 'createdAt' | 'updatedAt' | 'version', + ...(s.direction && { direction: s.direction as 'asc' | 'desc' }), + }; + } + + if (typeof body.limit === 'number') query.limit = body.limit; + if (typeof body.cursor === 'string') query.cursor = body.cursor; + + return query; +} + +/** Build a StackQuery from GET /records URL params. */ +function parseQueryParams(url: URL): StackQuery { + const filter: RecordFilter = {}; + + const typeIds = getAll(url, 'typeId'); + if (typeIds.length) filter.typeId = typeIds.length === 1 ? typeIds[0] : typeIds; + + const parentId = getOne(url, 'parentId'); + if (parentId !== null) filter.parentId = parentId === 'null' ? null : parentId; + + const appIds = getAll(url, 'appId'); + if (appIds.length) filter.appId = appIds.length === 1 ? appIds[0] : appIds; + + const entityIds = getAll(url, 'entityId'); + if (entityIds.length) filter.entityId = entityIds.length === 1 ? entityIds[0] : entityIds; + + const tags = getAll(url, 'tag'); + if (tags.length) filter.tags = tags; + + const hasAttachment = getOne(url, 'hasAttachment'); + if (hasAttachment) filter.hasAttachment = hasAttachment; + + const relatedTo = getOne(url, 'relatedTo'); + if (relatedTo) { + const relatedLabel = getOne(url, 'relatedLabel'); + filter.relatedTo = { recordId: relatedTo, ...(relatedLabel && { label: relatedLabel }) }; + } + + const search = getOne(url, 'search'); + if (search) filter.search = search; + + const createdBefore = getOne(url, 'createdBefore'); + const createdAfter = getOne(url, 'createdAfter'); + if (createdBefore || createdAfter) { + filter.createdAt = { + ...(createdBefore && { before: new Date(createdBefore) }), + ...(createdAfter && { after: new Date(createdAfter) }), + }; + } + + const updatedBefore = getOne(url, 'updatedBefore'); + const updatedAfter = getOne(url, 'updatedAfter'); + if (updatedBefore || updatedAfter) { + filter.updatedAt = { + ...(updatedBefore && { before: new Date(updatedBefore) }), + ...(updatedAfter && { after: new Date(updatedAfter) }), + }; + } + + if (getOne(url, 'includeDeleted') === 'true') filter.includeDeleted = true; + + const query: StackQuery = {}; + if (Object.keys(filter).length) query.filter = filter; + + const sort = getOne(url, 'sort') as 'createdAt' | 'updatedAt' | 'version' | null; + const direction = getOne(url, 'direction') as 'asc' | 'desc' | null; + if (sort) query.sort = { field: sort, ...(direction && { direction }) }; + + const limit = getOne(url, 'limit'); + if (limit) query.limit = parseInt(limit, 10); + + const cursor = getOne(url, 'cursor'); + if (cursor) query.cursor = cursor; + + return query; +} + +// --------------------------------------------------------------------------- +// Route factory +// --------------------------------------------------------------------------- + +export function recordRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // ------------------------------------------------------------------ + // POST /records/query — full query including content field filters + // Must be registered before /:id to avoid param capture + // ------------------------------------------------------------------ + app.post('/query', requireAuth(), async (c) => { + const body = await c.req.json(); + const query = parseQueryBody(body); + const result = await adapter.queryRecords(query); + return c.json({ + records: result.records.map(serializeRecord), + cursor: result.cursor, + total: result.total, + }); + }); + + // ------------------------------------------------------------------ + // GET /records — query by native fields via query params + // ------------------------------------------------------------------ + app.get('/', requireAuth(), async (c) => { + const url = new URL(c.req.url); + const query = parseQueryParams(url); + const result = await adapter.queryRecords(query); + return c.json({ + records: result.records.map(serializeRecord), + cursor: result.cursor, + total: result.total, + }); + }); + + // ------------------------------------------------------------------ + // POST /records — create a record + // ------------------------------------------------------------------ + app.post('/', requireAuth(), async (c) => { + const body = await c.req.json>(); + + if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); + if (!body.typeId || typeof body.typeId !== 'string') + return c.json({ error: 'typeId is required' }, 400); + if (!body.content || typeof body.content !== 'object') + return c.json({ error: 'content is required' }, 400); + + const now = new Date(); + const record: StackRecord = { + id: body.id, + typeId: body.typeId, + createdAt: body.createdAt ? new Date(body.createdAt as string) : now, + updatedAt: body.updatedAt ? new Date(body.updatedAt as string) : now, + content: body.content as Record, + version: typeof body.version === 'number' ? body.version : 1, + ...(body.parentId && { parentId: body.parentId as string }), + ...(body.entityId && { entityId: body.entityId as string }), + ...(body.appId && { appId: body.appId as string }), + ...(body.permissions && { permissions: body.permissions as Permission[] }), + ...(body.associations && { associations: body.associations as Association[] }), + }; + + const created = await adapter.createRecord(record); + return c.json(serializeRecord(created), 201); + }); + + // ------------------------------------------------------------------ + // GET /records/:id — get one record + // ------------------------------------------------------------------ + app.get('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + + return c.json(serializeRecord(record)); + }); + + // ------------------------------------------------------------------ + // PATCH /records/:id — update record (partial merge) + // ------------------------------------------------------------------ + app.patch('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json>(); + + // Snapshot current state before overwriting (server-side versioning) + await adapter.saveVersion(id, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + + const updated = await adapter.updateRecord(id, { + ...(body.content !== undefined && { content: body.content as Record }), + ...(body.typeId !== undefined && { typeId: body.typeId as string }), + updatedAt: body.updatedAt ? new Date(body.updatedAt as string) : new Date(), + version: typeof body.version === 'number' ? body.version : existing.version + 1, + }); + + return c.json(serializeRecord(updated)); + }); + + // ------------------------------------------------------------------ + // DELETE /records/:id — soft or hard delete + // ------------------------------------------------------------------ + app.delete('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; + await adapter.deleteRecord(id, { hard }); + return c.body(null, 204); + }); + + // ------------------------------------------------------------------ + // Permissions + // ------------------------------------------------------------------ + + app.get('/:id/permissions', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + + return c.json({ permissions: record.permissions ?? [] }); + }); + + app.put('/:id/permissions', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json<{ permissions: Permission[] }>(); + if (!Array.isArray(body.permissions)) { + return c.json({ error: 'permissions must be an array' }, 400); + } + + await adapter.updateRecord(id, { permissions: body.permissions }); + return c.json({ permissions: body.permissions }); + }); + + // ------------------------------------------------------------------ + // Associations + // ------------------------------------------------------------------ + + app.get('/:id/associations', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + + let assocs = record.associations ?? []; + + const kind = c.req.query('kind'); + if (kind) assocs = assocs.filter((a) => a.kind === kind); + + const label = c.req.query('label'); + if (label) assocs = assocs.filter((a) => a.label === label); + + return c.json({ associations: assocs }); + }); + + app.post('/:id/associations', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json(); + if (!body.kind || !body.label) { + return c.json({ error: 'kind and label are required' }, 400); + } + + await adapter.associate(id, body); + return c.body(null, 204); + }); + + app.delete('/:id/associations', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json(); + await adapter.dissociate(id, body); + return c.body(null, 204); + }); + + // ------------------------------------------------------------------ + // Versions + // ------------------------------------------------------------------ + + app.get('/:id/versions', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + + const versions = await adapter.getVersions(id); + return c.json(versions.map(serializeVersion)); + }); + + app.get('/:id/versions/:version', requireAuth(), async (c) => { + const id = c.req.param('id'); + const vNum = parseInt(c.req.param('version'), 10); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + const version = await adapter.getVersion(id, vNum); + if (!version) return c.json({ error: 'Version not found' }, 404); + return c.json(serializeVersion(version)); + }); + + // Restore a version — creates a new version, does not rewrite history + app.post('/:id/restore/:version', requireAuth(), async (c) => { + const id = c.req.param('id'); + const vNum = parseInt(c.req.param('version'), 10); + const auth = c.get('auth')!; + + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const target = await adapter.getVersion(id, vNum); + if (!target) return c.json({ error: 'Version not found' }, 404); + + // Snapshot current state before restoring + await adapter.saveVersion(id, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + + const restored = await adapter.updateRecord(id, { + content: target.content, + updatedAt: new Date(), + version: existing.version + 1, + }); + + return c.json(serializeRecord(restored)); + }); + + return app; +} From df2823e686b02cc8fbe3decf9af3eba9b5452e5b Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:39:42 -0400 Subject: [PATCH 06/25] feat: attachments route with file cleanup on delete --- src/routes/attachments.ts | 165 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/routes/attachments.ts diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts new file mode 100644 index 0000000..976613d --- /dev/null +++ b/src/routes/attachments.ts @@ -0,0 +1,165 @@ +import { Hono } from 'hono'; +import { readdirSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import type { AppEnv } from '../app.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; + +export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // Directory where the SQLite adapter stores attachment files. + // Mirrors the adapter's internal convention: attachments/ next to the .db file. + const attachmentsDir = join(dirname(dbPath), 'attachments'); + + // ---------------------------------------------------------------- + // POST /attachments — upload a file + // Body: raw binary, Content-Type: the file's MIME type + // ---------------------------------------------------------------- + app.post('/', requireAuth(), async (c) => { + const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; + const arrayBuffer = await c.req.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + const fileId = await adapter.putAttachment(data, mimeType); + return c.json({ fileId }, 201); + }); + + // ---------------------------------------------------------------- + // GET /attachments/:fileId — download a file + // ---------------------------------------------------------------- + app.get('/:fileId', async (c) => { + const fileId = c.req.param('fileId'); + const auth = c.get('auth'); + + if (!auth) { + // Unauthenticated: allow only if the attachment is referenced by a public record. + const accessible = await isAttachmentPublic(fileId, ctx); + if (!accessible) return c.json({ error: 'Unauthorized' }, 401); + } + // Authenticated: valid token = authorised entity; allow. + // Full per-record permission checking on individual attachment access is + // deferred until an attachment metadata endpoint exists in the adapter. + + let data: Uint8Array; + try { + data = await adapter.getAttachment(fileId); + } catch { + return c.json({ error: 'Attachment not found' }, 404); + } + + // Recover MIME type from the filename stored on disk + const mimeType = detectMimeType(attachmentsDir, fileId); + + return c.newResponse(data, 200, { + 'Content-Type': mimeType, + 'Content-Length': String(data.byteLength), + }); + }); + + // ---------------------------------------------------------------- + // DELETE /attachments/:fileId + // ---------------------------------------------------------------- + app.delete('/:fileId', requireAuth(), async (c) => { + const fileId = c.req.param('fileId'); + const auth = c.get('auth')!; + + // Only the owner can delete attachments directly. + if (auth.entityId !== ownerEntityId) { + return c.json({ error: 'Forbidden' }, 403); + } + + // Remove the DB row then the file on disk. + // The adapter only removes the row, so we clean up the file ourselves. + await adapter.deleteAttachment(fileId); + deleteAttachmentFile(attachmentsDir, fileId); + + return c.body(null, 204); + }); + + return app; +} + +/** + * Scan the attachments directory for a file matching fileId.{ext} and return + * its MIME type based on the extension, falling back to application/octet-stream. + * This mirrors the naming convention used by SQLiteAdapter.putAttachment(). + */ +function detectMimeType(attachmentsDir: string, fileId: string): string { + try { + const entries = readdirSync(attachmentsDir) as string[]; + const file = entries.find((f) => f.startsWith(fileId + '.')); + if (!file) return 'application/octet-stream'; + const ext = file.split('.').pop(); + return extToMime[ext ?? ''] ?? 'application/octet-stream'; + } catch { + return 'application/octet-stream'; + } +} + +/** + * Delete the attachment file from disk. The SQLiteAdapter removes the DB row + * but does not clean up the file, so we handle it here. + */ +function deleteAttachmentFile(attachmentsDir: string, fileId: string): void { + try { + const entries = readdirSync(attachmentsDir) as string[]; + for (const f of entries) { + if (f.startsWith(fileId + '.')) { + unlinkSync(join(attachmentsDir, f)); + break; + } + } + } catch { + // Best-effort; the row is already gone so this is non-fatal. + } +} + +/** + * Check whether an attachment is referenced by at least one publicly readable record. + * Used for unauthenticated attachment access. + */ +async function isAttachmentPublic( + fileId: string, + ctx: StackContext, +): Promise { + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + // Scan all records (personal stack; typically small) + let cursor: string | undefined; + do { + const result = await adapter.queryRecords({ limit: 200, ...(cursor && { cursor }) }); + for (const record of result.records) { + const hasRef = record.associations?.some( + (a) => a.kind === 'attachment' && a.fileId === fileId, + ); + if (hasRef) { + const readable = await checkAccess(record, null, ownerEntityId, 'read', adapter); + if (readable) return true; + } + } + cursor = result.cursor ?? undefined; + } while (cursor); + return false; +} + +const extToMime: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + pdf: 'application/pdf', + mp4: 'video/mp4', + mp3: 'audio/mpeg', + wav: 'audio/wav', + json: 'application/json', + txt: 'text/plain', + html: 'text/html', + css: 'text/css', + js: 'application/javascript', + bin: 'application/octet-stream', +}; From 7d41d3c16c77520e7f9f7c2822790f2327cab792 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:40:13 -0400 Subject: [PATCH 07/25] fix: pass dbPath to attachmentRoutes; fix ownerEntityId null guard --- src/app.ts | 8 +++----- src/routes/attachments.ts | 43 ++++++++------------------------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/src/app.ts b/src/app.ts index 4e453e1..2e67fbd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,11 +23,11 @@ export type AppEnv = { export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono { const app = new Hono(); - // Global middleware app.use(requestId()); app.use( cors({ - origin: config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), + origin: + config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], allowHeaders: ['Authorization', 'Content-Type'], exposeHeaders: ['X-Request-Id'], @@ -36,15 +36,13 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho app.use(errorMiddleware(logger)); app.use(authMiddleware(config.tokens)); - // Routes app.route('/.well-known', wellknownRoutes(ctx)); app.route('/health', healthRoutes()); app.route('/records', recordRoutes(ctx)); app.route('/types', typeRoutes(ctx)); - app.route('/attachments', attachmentRoutes(ctx)); + app.route('/attachments', attachmentRoutes(ctx, config.dbPath)); app.route('/entity', entityRoutes(ctx)); - // 404 fallback app.notFound((c) => c.json({ error: 'Not found' }, 404)); return app; diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 976613d..5fb5d2a 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -12,7 +12,7 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; - const arrayBuffer = await c.req.arrayBuffer(); - const data = new Uint8Array(arrayBuffer); + const data = new Uint8Array(await c.req.arrayBuffer()); const fileId = await adapter.putAttachment(data, mimeType); return c.json({ fileId }, 201); }); @@ -35,13 +34,10 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono f.startsWith(fileId + '.')); if (!file) return 'application/octet-stream'; - const ext = file.split('.').pop(); - return extToMime[ext ?? ''] ?? 'application/octet-stream'; + const ext = file.split('.').pop() ?? ''; + return extToMime[ext] ?? 'application/octet-stream'; } catch { return 'application/octet-stream'; } } -/** - * Delete the attachment file from disk. The SQLiteAdapter removes the DB row - * but does not clean up the file, so we handle it here. - */ function deleteAttachmentFile(attachmentsDir: string, fileId: string): void { try { const entries = readdirSync(attachmentsDir) as string[]; @@ -113,21 +96,13 @@ function deleteAttachmentFile(attachmentsDir: string, fileId: string): void { } } } catch { - // Best-effort; the row is already gone so this is non-fatal. + // Best-effort; row is already removed so this is non-fatal. } } -/** - * Check whether an attachment is referenced by at least one publicly readable record. - * Used for unauthenticated attachment access. - */ -async function isAttachmentPublic( - fileId: string, - ctx: StackContext, -): Promise { +async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - // Scan all records (personal stack; typically small) let cursor: string | undefined; do { const result = await adapter.queryRecords({ limit: 200, ...(cursor && { cursor }) }); From 83c2814cf160345b4708b2071f2e0e4fabdca153 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:41:39 -0400 Subject: [PATCH 08/25] test: integration tests for all route groups --- tests/routes/associations.test.ts | 63 ++++++++++++ tests/routes/records.test.ts | 159 ++++++++++++++++++++++++++++++ tests/routes/types.test.ts | 51 ++++++++++ tests/routes/versions.test.ts | 47 +++++++++ tests/routes/wellknown.test.ts | 23 +++++ tests/setup.ts | 102 +++++++++++++++++++ 6 files changed, 445 insertions(+) create mode 100644 tests/routes/associations.test.ts create mode 100644 tests/routes/records.test.ts create mode 100644 tests/routes/types.test.ts create mode 100644 tests/routes/versions.test.ts create mode 100644 tests/routes/wellknown.test.ts create mode 100644 tests/setup.ts diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts new file mode 100644 index 0000000..7fde773 --- /dev/null +++ b/tests/routes/associations.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; + +const TYPE_ID = 'com.example.test/post@1'; + +describe('Associations', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await t.ctx.stack.defineType(TYPE_ID, 'Post', { text: { kind: 'text' as const, required: true as const } }); + }); + afterEach(async () => { await t.cleanup(); }); + + async function seedRecord() { + return t.ctx.stack.create(TYPE_ID, { text: 'Hello' }); + } + + it('POST /records/:id/associations adds a tag', async () => { + const record = await seedRecord(); + const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + ...auth(), + ...json({ kind: 'tag', label: 'starred' }), + }); + expect(status).toBe(204); + }); + + it('GET /records/:id/associations returns all associations', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'archived' }); + + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/associations`, auth()); + expect(status).toBe(200); + const d = data as { associations: unknown[] }; + expect(d.associations).toHaveLength(2); + }); + + it('GET /records/:id/associations?kind=tag filters by kind', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/associations?kind=tag`, auth(), + ); + expect(status).toBe(200); + const d = data as { associations: Array<{ kind: string }> }; + expect(d.associations.every((a) => a.kind === 'tag')).toBe(true); + }); + + it('DELETE /records/:id/associations removes a tag', async () => { + const record = await seedRecord(); + await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); + + const { status } = await req(t.app, 'DELETE', `/records/${record.id}/associations`, { + ...auth(), + ...json({ kind: 'tag', label: 'starred' }), + }); + expect(status).toBe(204); + + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.associations?.find((a) => a.label === 'starred')).toBeUndefined(); + }); +}); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts new file mode 100644 index 0000000..f73f4e5 --- /dev/null +++ b/tests/routes/records.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, auth, json, TEST_ENTITY_ID, type TestApp } from '../setup.js'; +import type { StackType } from '@haverstack/core'; +import { hashSchema } from '@haverstack/core'; + +const NOTE_TYPE_ID = 'com.example.test/note@1'; + +async function seedType(ctx: TestApp['ctx']): Promise { + const schema = { title: { kind: 'string' as const }, body: { kind: 'text' as const, required: true as const } }; + return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', schema); +} + +async function seedRecord(ctx: TestApp['ctx'], overrides: Record = {}) { + return ctx.stack.create(NOTE_TYPE_ID, { body: 'Hello world', ...overrides }); +} + +describe('Records', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { await t.cleanup(); }); + + // ------------------------------------------------------------------ + describe('POST /records', () => { + it('creates a record', async () => { + const { generateId } = await import('@haverstack/core'); + const id = generateId(); + const { status, data } = await req(t.app, 'POST', '/records', { + ...auth(), + ...json({ + id, + typeId: NOTE_TYPE_ID, + content: { body: 'Test note' }, + version: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + entityId: TEST_ENTITY_ID, + }), + }); + expect(status).toBe(201); + const d = data as Record; + expect(d.id).toBe(id); + expect(d.typeId).toBe(NOTE_TYPE_ID); + expect((d.content as Record).body).toBe('Test note'); + }); + + it('returns 400 when id is missing', async () => { + const { status } = await req(t.app, 'POST', '/records', { + ...auth(), + ...json({ typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }), + }); + expect(status).toBe(400); + }); + + it('returns 401 without auth', async () => { + const { generateId } = await import('@haverstack/core'); + const { status } = await req(t.app, 'POST', '/records', { + ...json({ id: generateId(), typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }), + }); + expect(status).toBe(401); + }); + }); + + // ------------------------------------------------------------------ + describe('GET /records/:id', () => { + it('returns a record by id', async () => { + const record = await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, auth()); + expect(status).toBe(200); + expect((data as Record).id).toBe(record.id); + }); + + it('returns 404 for unknown id', async () => { + const { status } = await req(t.app, 'GET', '/records/nonexistent', auth()); + expect(status).toBe(404); + }); + }); + + // ------------------------------------------------------------------ + describe('GET /records', () => { + it('returns records list', async () => { + await seedRecord(t.ctx, { body: 'Note 1' }); + await seedRecord(t.ctx, { body: 'Note 2' }); + const { status, data } = await req(t.app, 'GET', '/records', auth()); + expect(status).toBe(200); + const d = data as { records: unknown[]; total: number }; + expect(d.total).toBe(2); + expect(d.records).toHaveLength(2); + }); + + it('filters by typeId', async () => { + await seedRecord(t.ctx); + const { data } = await req(t.app, 'GET', `/records?typeId=${NOTE_TYPE_ID}`, auth()); + const d = data as { total: number }; + expect(d.total).toBe(1); + }); + }); + + // ------------------------------------------------------------------ + describe('POST /records/query', () => { + it('accepts a StackQuery body', async () => { + await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'POST', '/records/query', { + ...auth(), + ...json({ filter: { typeId: NOTE_TYPE_ID }, sort: { field: 'createdAt', direction: 'desc' }, limit: 10 }), + }); + expect(status).toBe(200); + const d = data as { records: unknown[]; total: number }; + expect(d.total).toBe(1); + }); + }); + + // ------------------------------------------------------------------ + describe('PATCH /records/:id', () => { + it('updates record content', async () => { + const record = await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { + ...auth(), + ...json({ content: { body: 'Updated body' }, version: 2, updatedAt: new Date().toISOString() }), + }); + expect(status).toBe(200); + const d = data as Record; + expect((d.content as Record).body).toBe('Updated body'); + expect(d.version).toBe(2); + }); + + it('snapshots a version on update', async () => { + const record = await seedRecord(t.ctx); + await req(t.app, 'PATCH', `/records/${record.id}`, { + ...auth(), + ...json({ content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }), + }); + const versions = await t.ctx.adapter.getVersions(record.id); + expect(versions).toHaveLength(1); + expect(versions[0].version).toBe(1); + }); + }); + + // ------------------------------------------------------------------ + describe('DELETE /records/:id', () => { + it('soft-deletes by default', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, auth()); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.deletedAt).toBeDefined(); + }); + + it('hard-deletes with ?hard=true', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, auth()); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after).toBeNull(); + }); + }); +}); diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts new file mode 100644 index 0000000..b5babc6 --- /dev/null +++ b/tests/routes/types.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; +import { hashSchema } from '@haverstack/core'; + +describe('Types', () => { + let t: TestApp; + beforeEach(async () => { t = await buildTestApp(); }); + afterEach(async () => { await t.cleanup(); }); + + const typeId = 'com.example.test/item@1'; + const schema = { name: { kind: 'string' as const, required: true as const } }; + + it('POST /types registers a type', async () => { + const schemaHash = await hashSchema(schema); + const { status, data } = await req(t.app, 'POST', '/types', { + ...auth(), + ...json({ + id: typeId, + baseId: 'com.example.test/item', + version: 1, + name: 'Item', + schema, + schemaHash, + createdAt: new Date().toISOString(), + }), + }); + expect(status).toBe(201); + expect((data as Record).id).toBe(typeId); + }); + + it('GET /types returns registered types', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', '/types', auth()); + expect(status).toBe(200); + const types = data as unknown[]; + expect(types.length).toBeGreaterThanOrEqual(1); + }); + + it('GET /types/:id returns one type (URL-encoded)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const encoded = encodeURIComponent(typeId); + const { status, data } = await req(t.app, 'GET', `/types/${encoded}`, auth()); + expect(status).toBe(200); + expect((data as Record).id).toBe(typeId); + }); + + it('GET /types/:id returns 404 for unknown type', async () => { + const { status } = await req(t.app, 'GET', '/types/unknown%40999', auth()); + expect(status).toBe(404); + }); +}); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts new file mode 100644 index 0000000..b6a5fe3 --- /dev/null +++ b/tests/routes/versions.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; + +const TYPE_ID = 'com.example.test/doc@1'; + +describe('Versions', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await t.ctx.stack.defineType(TYPE_ID, 'Doc', { body: { kind: 'text' as const, required: true as const } }); + }); + afterEach(async () => { await t.cleanup(); }); + + async function createAndUpdate() { + const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); + // Update via HTTP so the server snapshots the version + await req(t.app, 'PATCH', `/records/${record.id}`, { + ...auth(), + ...json({ content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }), + }); + return record; + } + + it('GET /records/:id/versions returns version history', async () => { + const record = await createAndUpdate(); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions`, auth()); + expect(status).toBe(200); + const versions = data as unknown[]; + expect(versions).toHaveLength(1); + }); + + it('GET /records/:id/versions/:version returns a specific version', async () => { + const record = await createAndUpdate(); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions/1`, auth()); + expect(status).toBe(200); + expect((data as Record).version).toBe(1); + }); + + it('POST /records/:id/restore/:version restores a previous version', async () => { + const record = await createAndUpdate(); + const { status, data } = await req(t.app, 'POST', `/records/${record.id}/restore/1`, auth()); + expect(status).toBe(200); + const d = data as Record; + expect((d.content as Record).body).toBe('v1'); + expect(d.version).toBe(3); + }); +}); diff --git a/tests/routes/wellknown.test.ts b/tests/routes/wellknown.test.ts new file mode 100644 index 0000000..fda0079 --- /dev/null +++ b/tests/routes/wellknown.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, type TestApp } from '../setup.js'; + +describe('GET /.well-known/stack', () => { + let t: TestApp; + beforeEach(async () => { t = await buildTestApp(); }); + afterEach(async () => { await t.cleanup(); }); + + it('returns discovery document', async () => { + const { status, data } = await req(t.app, 'GET', '/.well-known/stack'); + expect(status).toBe(200); + const d = data as Record; + expect(d.version).toBe('1.0'); + expect(d.entityId).toBe('test-entity-id-00000001'); + expect(d.timezone).toBe('UTC'); + expect(d.capabilities).toBeDefined(); + }); + + it('does not require authentication', async () => { + const { status } = await req(t.app, 'GET', '/.well-known/stack'); + expect(status).toBe(200); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..2f80230 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,102 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { unlink, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; +import { Stack } from '@haverstack/core'; +import pino from 'pino'; +import { createApp } from '../src/app.js'; +import type { Config } from '../src/config.js'; +import type { StackContext } from '../src/stack.js'; +import type { Hono } from 'hono'; +import type { AppEnv } from '../src/app.js'; + +export const TEST_ENTITY_ID = 'test-entity-id-00000001'; +export const TEST_TOKEN = 'test-bearer-token'; +export const OTHER_TOKEN = 'other-bearer-token'; +export const OTHER_ENTITY_ID = 'other-entity-id-00000002'; + +export const logger = pino({ level: 'silent' }); + +export function tempDbPath(): string { + return join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}.db`); +} + +export async function createTestContext(dbPath: string): Promise { + const adapter = await SQLiteAdapter.initialize({ + path: dbPath, + entityId: TEST_ENTITY_ID, + timezone: 'UTC', + }); + const stack = await Stack.create(adapter); + return { adapter, stack }; +} + +export function testConfig(dbPath: string): Config { + return { + port: 3000, + dbPath, + entityId: TEST_ENTITY_ID, + timezone: 'UTC', + tokens: [ + { token: TEST_TOKEN, entityId: TEST_ENTITY_ID }, + { token: OTHER_TOKEN, entityId: OTHER_ENTITY_ID }, + ], + corsOrigins: '*', + baseUrl: null, + isNewDb: true, + }; +} + +export type TestApp = { + app: Hono; + ctx: StackContext; + dbPath: string; + cleanup: () => Promise; +}; + +export async function buildTestApp(): Promise { + const dbPath = tempDbPath(); + const ctx = await createTestContext(dbPath); + const config = testConfig(dbPath); + const app = createApp(ctx, config, logger); + + const cleanup = async () => { + await ctx.stack.close(); + if (existsSync(dbPath)) await unlink(dbPath); + const attachmentsDir = dbPath.replace(/\.db$/, '') + '-attachments'; + const dir = join(require('path').dirname(dbPath), 'attachments'); + try { await rm(dir, { recursive: true, force: true }); } catch { /* ok */ } + }; + + return { app, ctx, dbPath, cleanup }; +} + +export function auth(token = TEST_TOKEN): Record { + return { Authorization: `Bearer ${token}` }; +} + +export function json(body: unknown): { body: string; headers: Record } { + return { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }; +} + +export async function req( + app: Hono, + method: string, + path: string, + opts: { headers?: Record; body?: string } = {}, +) { + const res = await app.request(path, { + method, + headers: opts.headers ?? {}, + ...(opts.body !== undefined && { body: opts.body }), + }); + const text = await res.text(); + let data: unknown; + try { data = JSON.parse(text); } catch { data = text; } + return { status: res.status, data }; +} From 8340b663e17d60a90f16a632ad1cc7f50671618e Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:44:12 -0400 Subject: [PATCH 09/25] fix: test helper API (ESM-safe cleanup, unified req() opts signature) --- tests/routes/associations.test.ts | 41 ++++++++-------- tests/routes/records.test.ts | 78 ++++++++++++++----------------- tests/routes/types.test.ts | 22 ++++----- tests/routes/versions.test.ts | 41 +++++++++------- tests/routes/wellknown.test.ts | 6 +-- tests/setup.ts | 58 ++++++++++++++--------- 6 files changed, 130 insertions(+), 116 deletions(-) diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts index 7fde773..cb3cff1 100644 --- a/tests/routes/associations.test.ts +++ b/tests/routes/associations.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; const TYPE_ID = 'com.example.test/post@1'; @@ -7,7 +7,9 @@ describe('Associations', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(TYPE_ID, 'Post', { text: { kind: 'text' as const, required: true as const } }); + await t.ctx.stack.defineType(TYPE_ID, 'Post', { + text: { kind: 'text' as const, required: true as const }, + }); }); afterEach(async () => { await t.cleanup(); }); @@ -15,49 +17,46 @@ describe('Associations', () => { return t.ctx.stack.create(TYPE_ID, { text: 'Hello' }); } - it('POST /records/:id/associations adds a tag', async () => { + it('POST adds a tag association', async () => { const record = await seedRecord(); const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { - ...auth(), - ...json({ kind: 'tag', label: 'starred' }), + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, }); expect(status).toBe(204); }); - it('GET /records/:id/associations returns all associations', async () => { + it('GET returns all associations', async () => { const record = await seedRecord(); await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'archived' }); - - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/associations`, auth()); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/associations`, { token: TEST_TOKEN }, + ); expect(status).toBe(200); - const d = data as { associations: unknown[] }; - expect(d.associations).toHaveLength(2); + expect((data as { associations: unknown[] }).associations).toHaveLength(2); }); - it('GET /records/:id/associations?kind=tag filters by kind', async () => { + it('GET ?kind=tag filters by kind', async () => { const record = await seedRecord(); await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - const { status, data } = await req( - t.app, 'GET', `/records/${record.id}/associations?kind=tag`, auth(), + t.app, 'GET', `/records/${record.id}/associations?kind=tag`, { token: TEST_TOKEN }, ); expect(status).toBe(200); - const d = data as { associations: Array<{ kind: string }> }; - expect(d.associations.every((a) => a.kind === 'tag')).toBe(true); + const assocs = (data as { associations: Array<{ kind: string }> }).associations; + expect(assocs.every((a) => a.kind === 'tag')).toBe(true); }); - it('DELETE /records/:id/associations removes a tag', async () => { + it('DELETE removes an association', async () => { const record = await seedRecord(); await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' }); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}/associations`, { - ...auth(), - ...json({ kind: 'tag', label: 'starred' }), + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, }); expect(status).toBe(204); - const after = await t.ctx.adapter.getRecord(record.id); - expect(after?.associations?.find((a) => a.label === 'starred')).toBeUndefined(); + expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy(); }); }); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index f73f4e5..d625d6c 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, auth, json, TEST_ENTITY_ID, type TestApp } from '../setup.js'; -import type { StackType } from '@haverstack/core'; -import { hashSchema } from '@haverstack/core'; +import { generateId } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, type TestApp } from '../setup.js'; const NOTE_TYPE_ID = 'com.example.test/note@1'; -async function seedType(ctx: TestApp['ctx']): Promise { - const schema = { title: { kind: 'string' as const }, body: { kind: 'text' as const, required: true as const } }; - return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', schema); +async function seedType(ctx: TestApp['ctx']) { + return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { + title: { kind: 'string' as const }, + body: { kind: 'text' as const, required: true as const }, + }); } async function seedRecord(ctx: TestApp['ctx'], overrides: Record = {}) { @@ -22,14 +23,12 @@ describe('Records', () => { }); afterEach(async () => { await t.cleanup(); }); - // ------------------------------------------------------------------ describe('POST /records', () => { it('creates a record', async () => { - const { generateId } = await import('@haverstack/core'); const id = generateId(); const { status, data } = await req(t.app, 'POST', '/records', { - ...auth(), - ...json({ + token: TEST_TOKEN, + body: { id, typeId: NOTE_TYPE_ID, content: { body: 'Test note' }, @@ -37,7 +36,7 @@ describe('Records', () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), entityId: TEST_ENTITY_ID, - }), + }, }); expect(status).toBe(201); const d = data as Record; @@ -48,77 +47,74 @@ describe('Records', () => { it('returns 400 when id is missing', async () => { const { status } = await req(t.app, 'POST', '/records', { - ...auth(), - ...json({ typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }), + token: TEST_TOKEN, + body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, }); expect(status).toBe(400); }); it('returns 401 without auth', async () => { - const { generateId } = await import('@haverstack/core'); const { status } = await req(t.app, 'POST', '/records', { - ...json({ id: generateId(), typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }), + body: { id: generateId(), typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, }); expect(status).toBe(401); }); }); - // ------------------------------------------------------------------ describe('GET /records/:id', () => { it('returns a record by id', async () => { const record = await seedRecord(t.ctx); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, auth()); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}`, { token: TEST_TOKEN }); expect(status).toBe(200); expect((data as Record).id).toBe(record.id); }); it('returns 404 for unknown id', async () => { - const { status } = await req(t.app, 'GET', '/records/nonexistent', auth()); + const { status } = await req(t.app, 'GET', '/records/nonexistent', { token: TEST_TOKEN }); expect(status).toBe(404); }); }); - // ------------------------------------------------------------------ describe('GET /records', () => { - it('returns records list', async () => { + it('returns records list with total', async () => { await seedRecord(t.ctx, { body: 'Note 1' }); await seedRecord(t.ctx, { body: 'Note 2' }); - const { status, data } = await req(t.app, 'GET', '/records', auth()); + const { status, data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); expect(status).toBe(200); const d = data as { records: unknown[]; total: number }; expect(d.total).toBe(2); expect(d.records).toHaveLength(2); }); - it('filters by typeId', async () => { + it('filters by typeId query param', async () => { await seedRecord(t.ctx); - const { data } = await req(t.app, 'GET', `/records?typeId=${NOTE_TYPE_ID}`, auth()); - const d = data as { total: number }; - expect(d.total).toBe(1); + const { data } = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE_ID)}`, { token: TEST_TOKEN }); + expect((data as { total: number }).total).toBe(1); }); }); - // ------------------------------------------------------------------ describe('POST /records/query', () => { - it('accepts a StackQuery body', async () => { + it('accepts a full StackQuery body', async () => { await seedRecord(t.ctx); const { status, data } = await req(t.app, 'POST', '/records/query', { - ...auth(), - ...json({ filter: { typeId: NOTE_TYPE_ID }, sort: { field: 'createdAt', direction: 'desc' }, limit: 10 }), + token: TEST_TOKEN, + body: { + filter: { typeId: NOTE_TYPE_ID }, + sort: { field: 'createdAt', direction: 'desc' }, + limit: 10, + }, }); expect(status).toBe(200); - const d = data as { records: unknown[]; total: number }; - expect(d.total).toBe(1); + expect((data as { total: number }).total).toBe(1); }); }); - // ------------------------------------------------------------------ describe('PATCH /records/:id', () => { it('updates record content', async () => { const record = await seedRecord(t.ctx); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { - ...auth(), - ...json({ content: { body: 'Updated body' }, version: 2, updatedAt: new Date().toISOString() }), + token: TEST_TOKEN, + body: { content: { body: 'Updated body' }, version: 2, updatedAt: new Date().toISOString() }, }); expect(status).toBe(200); const d = data as Record; @@ -126,11 +122,11 @@ describe('Records', () => { expect(d.version).toBe(2); }); - it('snapshots a version on update', async () => { + it('snapshots the previous version on update', async () => { const record = await seedRecord(t.ctx); await req(t.app, 'PATCH', `/records/${record.id}`, { - ...auth(), - ...json({ content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }), + token: TEST_TOKEN, + body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, }); const versions = await t.ctx.adapter.getVersions(record.id); expect(versions).toHaveLength(1); @@ -138,11 +134,10 @@ describe('Records', () => { }); }); - // ------------------------------------------------------------------ describe('DELETE /records/:id', () => { it('soft-deletes by default', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, auth()); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token: TEST_TOKEN }); expect(status).toBe(204); const after = await t.ctx.adapter.getRecord(record.id); expect(after?.deletedAt).toBeDefined(); @@ -150,10 +145,9 @@ describe('Records', () => { it('hard-deletes with ?hard=true', async () => { const record = await seedRecord(t.ctx); - const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, auth()); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN }); expect(status).toBe(204); - const after = await t.ctx.adapter.getRecord(record.id); - expect(after).toBeNull(); + expect(await t.ctx.adapter.getRecord(record.id)).toBeNull(); }); }); }); diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts index b5babc6..68fd3ae 100644 --- a/tests/routes/types.test.ts +++ b/tests/routes/types.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; import { hashSchema } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; describe('Types', () => { let t: TestApp; @@ -13,8 +13,8 @@ describe('Types', () => { it('POST /types registers a type', async () => { const schemaHash = await hashSchema(schema); const { status, data } = await req(t.app, 'POST', '/types', { - ...auth(), - ...json({ + token: TEST_TOKEN, + body: { id: typeId, baseId: 'com.example.test/item', version: 1, @@ -22,30 +22,30 @@ describe('Types', () => { schema, schemaHash, createdAt: new Date().toISOString(), - }), + }, }); expect(status).toBe(201); expect((data as Record).id).toBe(typeId); }); - it('GET /types returns registered types', async () => { + it('GET /types returns all registered types', async () => { await t.ctx.stack.defineType(typeId, 'Item', schema); - const { status, data } = await req(t.app, 'GET', '/types', auth()); + const { status, data } = await req(t.app, 'GET', '/types', { token: TEST_TOKEN }); expect(status).toBe(200); - const types = data as unknown[]; - expect(types.length).toBeGreaterThanOrEqual(1); + expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); }); it('GET /types/:id returns one type (URL-encoded)', async () => { await t.ctx.stack.defineType(typeId, 'Item', schema); - const encoded = encodeURIComponent(typeId); - const { status, data } = await req(t.app, 'GET', `/types/${encoded}`, auth()); + const { status, data } = await req( + t.app, 'GET', `/types/${encodeURIComponent(typeId)}`, { token: TEST_TOKEN }, + ); expect(status).toBe(200); expect((data as Record).id).toBe(typeId); }); it('GET /types/:id returns 404 for unknown type', async () => { - const { status } = await req(t.app, 'GET', '/types/unknown%40999', auth()); + const { status } = await req(t.app, 'GET', '/types/unknown%40999', { token: TEST_TOKEN }); expect(status).toBe(404); }); }); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts index b6a5fe3..f88e784 100644 --- a/tests/routes/versions.test.ts +++ b/tests/routes/versions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, auth, json, type TestApp } from '../setup.js'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; const TYPE_ID = 'com.example.test/doc@1'; @@ -7,41 +7,48 @@ describe('Versions', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); - await t.ctx.stack.defineType(TYPE_ID, 'Doc', { body: { kind: 'text' as const, required: true as const } }); + await t.ctx.stack.defineType(TYPE_ID, 'Doc', { + body: { kind: 'text' as const, required: true as const }, + }); }); afterEach(async () => { await t.cleanup(); }); - async function createAndUpdate() { + async function createAndPatch() { const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); - // Update via HTTP so the server snapshots the version await req(t.app, 'PATCH', `/records/${record.id}`, { - ...auth(), - ...json({ content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }), + token: TEST_TOKEN, + body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, }); return record; } - it('GET /records/:id/versions returns version history', async () => { - const record = await createAndUpdate(); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions`, auth()); + it('GET /records/:id/versions returns history', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/versions`, { token: TEST_TOKEN }, + ); expect(status).toBe(200); - const versions = data as unknown[]; - expect(versions).toHaveLength(1); + expect((data as unknown[]).length).toBe(1); }); - it('GET /records/:id/versions/:version returns a specific version', async () => { - const record = await createAndUpdate(); - const { status, data } = await req(t.app, 'GET', `/records/${record.id}/versions/1`, auth()); + it('GET /records/:id/versions/:version returns one version', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'GET', `/records/${record.id}/versions/1`, { token: TEST_TOKEN }, + ); expect(status).toBe(200); expect((data as Record).version).toBe(1); }); - it('POST /records/:id/restore/:version restores a previous version', async () => { - const record = await createAndUpdate(); - const { status, data } = await req(t.app, 'POST', `/records/${record.id}/restore/1`, auth()); + it('POST /records/:id/restore/:version restores content without rewriting history', async () => { + const record = await createAndPatch(); + const { status, data } = await req( + t.app, 'POST', `/records/${record.id}/restore/1`, { token: TEST_TOKEN }, + ); expect(status).toBe(200); const d = data as Record; expect((d.content as Record).body).toBe('v1'); + // version 1 was snapshotted, then v2 was applied, then v2 was snapshotted for restore → new version is 3 expect(d.version).toBe(3); }); }); diff --git a/tests/routes/wellknown.test.ts b/tests/routes/wellknown.test.ts index fda0079..86baf0f 100644 --- a/tests/routes/wellknown.test.ts +++ b/tests/routes/wellknown.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { buildTestApp, req, type TestApp } from '../setup.js'; +import { buildTestApp, req, TEST_ENTITY_ID, type TestApp } from '../setup.js'; describe('GET /.well-known/stack', () => { let t: TestApp; beforeEach(async () => { t = await buildTestApp(); }); afterEach(async () => { await t.cleanup(); }); - it('returns discovery document', async () => { + it('returns the discovery document', async () => { const { status, data } = await req(t.app, 'GET', '/.well-known/stack'); expect(status).toBe(200); const d = data as Record; expect(d.version).toBe('1.0'); - expect(d.entityId).toBe('test-entity-id-00000001'); + expect(d.entityId).toBe(TEST_ENTITY_ID); expect(d.timezone).toBe('UTC'); expect(d.capabilities).toBeDefined(); }); diff --git a/tests/setup.ts b/tests/setup.ts index 2f80230..61af896 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { randomBytes } from 'node:crypto'; import { unlink, rm } from 'node:fs/promises'; import { existsSync } from 'node:fs'; @@ -65,38 +65,52 @@ export async function buildTestApp(): Promise { const cleanup = async () => { await ctx.stack.close(); if (existsSync(dbPath)) await unlink(dbPath); - const attachmentsDir = dbPath.replace(/\.db$/, '') + '-attachments'; - const dir = join(require('path').dirname(dbPath), 'attachments'); - try { await rm(dir, { recursive: true, force: true }); } catch { /* ok */ } + const attachmentsDir = join(dirname(dbPath), 'attachments'); + await rm(attachmentsDir, { recursive: true, force: true }).catch(() => {}); }; return { app, ctx, dbPath, cleanup }; } -export function auth(token = TEST_TOKEN): Record { - return { Authorization: `Bearer ${token}` }; -} - -export function json(body: unknown): { body: string; headers: Record } { - return { - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }; -} +export type ReqOpts = { + token?: string; + body?: unknown; + headers?: Record; + /** Set Content-Type to this MIME type and send body as raw string. Used for binary uploads. */ + rawBody?: { data: string; contentType: string }; +}; +/** + * Fire a request at the Hono test app and return the status code + parsed response. + * + * Pass `token` to add an Authorization header. + * Pass `body` to JSON-encode and send as application/json. + */ export async function req( app: Hono, method: string, path: string, - opts: { headers?: Record; body?: string } = {}, -) { - const res = await app.request(path, { - method, - headers: opts.headers ?? {}, - ...(opts.body !== undefined && { body: opts.body }), - }); + opts: ReqOpts = {}, +): Promise<{ status: number; data: unknown }> { + const headers: Record = { ...opts.headers }; + if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`; + + let body: BodyInit | undefined; + if (opts.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(opts.body); + } else if (opts.rawBody) { + headers['Content-Type'] = opts.rawBody.contentType; + body = opts.rawBody.data; + } + + const res = await app.request(path, { method, headers, body }); const text = await res.text(); let data: unknown; - try { data = JSON.parse(text); } catch { data = text; } + try { + data = JSON.parse(text); + } catch { + data = text; + } return { status: res.status, data }; } From 3d8a966d84cf9d6b423f099fb53607d30ccd68d8 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:47:33 -0400 Subject: [PATCH 10/25] refactor: extract AppEnv to types.ts, inline requestId to avoid hono/request-id version sensitivity --- src/app.ts | 16 +++---- src/middleware/auth.ts | 6 +-- src/middleware/errors.ts | 2 +- src/routes/attachments.ts | 26 +++--------- src/routes/entity.ts | 6 +-- src/routes/health.ts | 4 +- src/routes/records.ts | 87 +++++++++------------------------------ src/routes/types.ts | 27 ++++-------- src/routes/wellknown.ts | 2 +- src/types.ts | 7 ++++ 10 files changed, 52 insertions(+), 131 deletions(-) create mode 100644 src/types.ts diff --git a/src/app.ts b/src/app.ts index 2e67fbd..7b03b63 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,9 +1,9 @@ import { Hono } from 'hono'; import { cors } from 'hono/cors'; -import { requestId } from 'hono/request-id'; import type { Logger } from 'pino'; import type { StackContext } from './stack.js'; import type { Config } from './config.js'; +import type { AppEnv } from './types.js'; import { authMiddleware } from './middleware/auth.js'; import { errorMiddleware } from './middleware/errors.js'; import { wellknownRoutes } from './routes/wellknown.js'; @@ -13,17 +13,17 @@ import { typeRoutes } from './routes/types.js'; import { attachmentRoutes } from './routes/attachments.js'; import { entityRoutes } from './routes/entity.js'; -export type AppEnv = { - Variables: { - auth: { entityId: string } | null; - requestId: string; - }; -}; +export type { AppEnv }; export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono { const app = new Hono(); - app.use(requestId()); + // Assign a unique request ID to every request for log correlation. + app.use(async (c, next) => { + c.set('requestId', crypto.randomUUID()); + await next(); + }); + app.use( cors({ origin: diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 42a5456..f8b731a 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono'; import type { TokenConfig } from '../config.js'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; export function authMiddleware(tokens: TokenConfig[]): MiddlewareHandler { const tokenMap = new Map(tokens.map((t) => [t.token, t.entityId])); @@ -20,9 +20,7 @@ export function authMiddleware(tokens: TokenConfig[]): MiddlewareHandler export function requireAuth(): MiddlewareHandler { return async (c, next) => { - if (!c.get('auth')) { - return c.json({ error: 'Unauthorized' }, 401); - } + if (!c.get('auth')) return c.json({ error: 'Unauthorized' }, 401); await next(); }; } diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 693adc2..32d02a1 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono'; import type { Logger } from 'pino'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; export function errorMiddleware(logger: Logger): MiddlewareHandler { return async (c, next) => { diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 5fb5d2a..e7fd000 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { readdirSync, unlinkSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; import { checkAccess } from '../lib/access.js'; @@ -10,15 +10,9 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono(); const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - - // Directory where the SQLite adapter stores attachment files. - // Mirrors the adapter's convention: attachments/ next to the .db file. const attachmentsDir = join(dirname(dbPath), 'attachments'); - // ---------------------------------------------------------------- - // POST /attachments — upload a file - // Body: raw binary, Content-Type: the file's MIME type - // ---------------------------------------------------------------- + // POST /attachments — upload raw binary, Content-Type = MIME type app.post('/', requireAuth(), async (c) => { const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; const data = new Uint8Array(await c.req.arrayBuffer()); @@ -26,15 +20,12 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { const fileId = c.req.param('fileId'); const auth = c.get('auth'); if (!auth) { - // Unauthenticated: allow only if referenced by a public record. const accessible = await isAttachmentPublic(fileId, ctx); if (!accessible) return c.json({ error: 'Unauthorized' }, 401); } @@ -53,21 +44,14 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { const fileId = c.req.param('fileId'); const auth = c.get('auth')!; - - if (!ownerEntityId || auth.entityId !== ownerEntityId) { + if (!ownerEntityId || auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); - } - - // Adapter removes the DB row; we clean up the file ourselves. await adapter.deleteAttachment(fileId); deleteAttachmentFile(attachmentsDir, fileId); - return c.body(null, 204); }); @@ -96,7 +80,7 @@ function deleteAttachmentFile(attachmentsDir: string, fileId: string): void { } } } catch { - // Best-effort; row is already removed so this is non-fatal. + // Non-fatal — DB row is already removed. } } diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 10f767b..81f0209 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; import { checkAccess } from '../lib/access.js'; @@ -10,7 +10,6 @@ export function entityRoutes(ctx: StackContext): Hono { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - // Get the stack owner's entity record app.get('/', requireAuth(), async (c) => { if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); const record = await adapter.getRecord(ownerEntityId); @@ -18,19 +17,16 @@ export function entityRoutes(ctx: StackContext): Hono { return c.json(serializeRecord(record)); }); - // Update the entity record's content app.patch('/', requireAuth(), async (c) => { if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); const auth = c.get('auth')!; const existing = await adapter.getRecord(ownerEntityId); if (!existing) return c.json({ error: 'Entity record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const body = await c.req.json>(); - // Snapshot current state before updating await adapter.saveVersion(ownerEntityId, { version: existing.version, content: existing.content, diff --git a/src/routes/health.ts b/src/routes/health.ts index d656733..218f216 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,10 +1,8 @@ import { Hono } from 'hono'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; export function healthRoutes(): Hono { const app = new Hono(); - app.get('/', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() })); - return app; } diff --git a/src/routes/records.ts b/src/routes/records.ts index 84e440c..b7a7f1f 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; import { checkAccess } from '../lib/access.js'; @@ -79,7 +79,7 @@ function parseQueryBody(raw: unknown): StackQuery { return query; } -/** Build a StackQuery from GET /records URL params. */ +/** Build a StackQuery from GET /records URL search params. */ function parseQueryParams(url: URL): StackQuery { const filter: RecordFilter = {}; @@ -103,8 +103,8 @@ function parseQueryParams(url: URL): StackQuery { const relatedTo = getOne(url, 'relatedTo'); if (relatedTo) { - const relatedLabel = getOne(url, 'relatedLabel'); - filter.relatedTo = { recordId: relatedTo, ...(relatedLabel && { label: relatedLabel }) }; + const label = getOne(url, 'relatedLabel'); + filter.relatedTo = { recordId: relatedTo, ...(label && { label }) }; } const search = getOne(url, 'search'); @@ -155,13 +155,10 @@ export function recordRoutes(ctx: StackContext): Hono { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - // ------------------------------------------------------------------ - // POST /records/query — full query including content field filters - // Must be registered before /:id to avoid param capture - // ------------------------------------------------------------------ + // POST /records/query — full query with content-field filters + // Registered before /:id patterns to avoid param capture on the literal "query" segment. app.post('/query', requireAuth(), async (c) => { - const body = await c.req.json(); - const query = parseQueryBody(body); + const query = parseQueryBody(await c.req.json()); const result = await adapter.queryRecords(query); return c.json({ records: result.records.map(serializeRecord), @@ -170,12 +167,9 @@ export function recordRoutes(ctx: StackContext): Hono { }); }); - // ------------------------------------------------------------------ - // GET /records — query by native fields via query params - // ------------------------------------------------------------------ + // GET /records — query by native fields via URL params app.get('/', requireAuth(), async (c) => { - const url = new URL(c.req.url); - const query = parseQueryParams(url); + const query = parseQueryParams(new URL(c.req.url)); const result = await adapter.queryRecords(query); return c.json({ records: result.records.map(serializeRecord), @@ -184,12 +178,9 @@ export function recordRoutes(ctx: StackContext): Hono { }); }); - // ------------------------------------------------------------------ - // POST /records — create a record - // ------------------------------------------------------------------ + // POST /records — create app.post('/', requireAuth(), async (c) => { const body = await c.req.json>(); - if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); if (!body.typeId || typeof body.typeId !== 'string') return c.json({ error: 'typeId is required' }, 400); @@ -215,36 +206,29 @@ export function recordRoutes(ctx: StackContext): Hono { return c.json(serializeRecord(created), 201); }); - // ------------------------------------------------------------------ - // GET /records/:id — get one record - // ------------------------------------------------------------------ + // GET /records/:id app.get('/:id', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const record = await adapter.getRecord(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); if (!canRead) return c.json({ error: 'Forbidden' }, 403); - return c.json(serializeRecord(record)); }); - // ------------------------------------------------------------------ - // PATCH /records/:id — update record (partial merge) - // ------------------------------------------------------------------ + // PATCH /records/:id app.patch('/:id', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const body = await c.req.json>(); - // Snapshot current state before overwriting (server-side versioning) + // Snapshot current state before writing (server-side version history) await adapter.saveVersion(id, { version: existing.version, content: existing.content, @@ -262,18 +246,14 @@ export function recordRoutes(ctx: StackContext): Hono { return c.json(serializeRecord(updated)); }); - // ------------------------------------------------------------------ - // DELETE /records/:id — soft or hard delete - // ------------------------------------------------------------------ + // DELETE /records/:id (?hard=true for permanent) app.delete('/:id', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; await adapter.deleteRecord(id, { hard }); return c.body(null, 204); @@ -288,10 +268,8 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const record = await adapter.getRecord(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); if (!canRead) return c.json({ error: 'Forbidden' }, 403); - return c.json({ permissions: record.permissions ?? [] }); }); @@ -300,15 +278,11 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const body = await c.req.json<{ permissions: Permission[] }>(); - if (!Array.isArray(body.permissions)) { + if (!Array.isArray(body.permissions)) return c.json({ error: 'permissions must be an array' }, 400); - } - await adapter.updateRecord(id, { permissions: body.permissions }); return c.json({ permissions: body.permissions }); }); @@ -322,18 +296,13 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const record = await adapter.getRecord(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); if (!canRead) return c.json({ error: 'Forbidden' }, 403); - let assocs = record.associations ?? []; - const kind = c.req.query('kind'); if (kind) assocs = assocs.filter((a) => a.kind === kind); - const label = c.req.query('label'); if (label) assocs = assocs.filter((a) => a.label === label); - return c.json({ associations: assocs }); }); @@ -342,15 +311,10 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const body = await c.req.json(); - if (!body.kind || !body.label) { - return c.json({ error: 'kind and label are required' }, 400); - } - + if (!body.kind || !body.label) return c.json({ error: 'kind and label are required' }, 400); await adapter.associate(id, body); return c.body(null, 204); }); @@ -360,10 +324,8 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const body = await c.req.json(); await adapter.dissociate(id, body); return c.body(null, 204); @@ -378,10 +340,8 @@ export function recordRoutes(ctx: StackContext): Hono { const auth = c.get('auth')!; const record = await adapter.getRecord(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); if (!canRead) return c.json({ error: 'Forbidden' }, 403); - const versions = await adapter.getVersions(id); return c.json(versions.map(serializeVersion)); }); @@ -389,36 +349,29 @@ export function recordRoutes(ctx: StackContext): Hono { app.get('/:id/versions/:version', requireAuth(), async (c) => { const id = c.req.param('id'); const vNum = parseInt(c.req.param('version'), 10); + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); const auth = c.get('auth')!; const record = await adapter.getRecord(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); if (!canRead) return c.json({ error: 'Forbidden' }, 403); - - if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); const version = await adapter.getVersion(id, vNum); if (!version) return c.json({ error: 'Version not found' }, 404); return c.json(serializeVersion(version)); }); - // Restore a version — creates a new version, does not rewrite history + // POST /records/:id/restore/:version — creates new version, does not rewrite history app.post('/:id/restore/:version', requireAuth(), async (c) => { const id = c.req.param('id'); const vNum = parseInt(c.req.param('version'), 10); - const auth = c.get('auth')!; - if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); - + const auth = c.get('auth')!; const existing = await adapter.getRecord(id); if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const target = await adapter.getVersion(id, vNum); if (!target) return c.json({ error: 'Version not found' }, 404); - // Snapshot current state before restoring await adapter.saveVersion(id, { version: existing.version, @@ -426,13 +379,11 @@ export function recordRoutes(ctx: StackContext): Hono { updatedAt: existing.updatedAt, ...(existing.entityId && { entityId: existing.entityId }), }); - const restored = await adapter.updateRecord(id, { content: target.content, updatedAt: new Date(), version: existing.version + 1, }); - return c.json(serializeRecord(restored)); }); diff --git a/src/routes/types.ts b/src/routes/types.ts index 7f7fea1..3f5caa3 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; import { serializeType } from '../lib/serialize.js'; @@ -9,13 +9,11 @@ export function typeRoutes(ctx: StackContext): Hono { const app = new Hono(); const { adapter } = ctx; - // List all types app.get('/', requireAuth(), async (c) => { const types = await adapter.listTypes(); return c.json(types.map(serializeType)); }); - // Get one type (id is URL-encoded) app.get('/:id', requireAuth(), async (c) => { const id = decodeURIComponent(c.req.param('id')); const type = await adapter.getType(id); @@ -23,28 +21,17 @@ export function typeRoutes(ctx: StackContext): Hono { return c.json(serializeType(type)); }); - // Register or replace a type app.post('/', requireAuth(), async (c) => { const body = await c.req.json>(); - - if (!body.id || typeof body.id !== 'string') { - return c.json({ error: 'id is required' }, 400); - } - if (!body.baseId || typeof body.baseId !== 'string') { + if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); + if (!body.baseId || typeof body.baseId !== 'string') return c.json({ error: 'baseId is required' }, 400); - } - if (typeof body.version !== 'number') { - return c.json({ error: 'version must be a number' }, 400); - } - if (!body.name || typeof body.name !== 'string') { - return c.json({ error: 'name is required' }, 400); - } - if (!body.schema || typeof body.schema !== 'object') { + if (typeof body.version !== 'number') return c.json({ error: 'version must be a number' }, 400); + if (!body.name || typeof body.name !== 'string') return c.json({ error: 'name is required' }, 400); + if (!body.schema || typeof body.schema !== 'object') return c.json({ error: 'schema is required' }, 400); - } - if (!body.schemaHash || typeof body.schemaHash !== 'string') { + if (!body.schemaHash || typeof body.schemaHash !== 'string') return c.json({ error: 'schemaHash is required' }, 400); - } const type: StackType = { id: body.id, diff --git a/src/routes/wellknown.ts b/src/routes/wellknown.ts index 1b56474..fc8afc3 100644 --- a/src/routes/wellknown.ts +++ b/src/routes/wellknown.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AppEnv } from '../app.js'; +import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; export function wellknownRoutes(ctx: StackContext): Hono { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0e2ca0a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +/** Hono context variable map shared across all route files. */ +export type AppEnv = { + Variables: { + auth: { entityId: string } | null; + requestId: string; + }; +}; From 7a1353bd287ab3367b7c104cf856dcc954826543 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 14:04:41 -0400 Subject: [PATCH 11/25] fix: isolate each test in its own temp directory to prevent attachments/ collision --- tests/setup.ts | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/setup.ts b/tests/setup.ts index 61af896..6877200 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,8 +1,8 @@ import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { unlink, rm } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; import { SQLiteAdapter } from '@haverstack/adapter-sqlite'; import { Stack } from '@haverstack/core'; import pino from 'pino'; @@ -19,8 +19,14 @@ export const OTHER_ENTITY_ID = 'other-entity-id-00000002'; export const logger = pino({ level: 'silent' }); +/** + * Each test gets its own isolated temp directory so the SQLiteAdapter's + * sibling `attachments/` folder never collides between parallel test runs. + */ export function tempDbPath(): string { - return join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}.db`); + const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return join(dir, 'stack.db'); } export async function createTestContext(dbPath: string): Promise { @@ -64,27 +70,24 @@ export async function buildTestApp(): Promise { const cleanup = async () => { await ctx.stack.close(); - if (existsSync(dbPath)) await unlink(dbPath); - const attachmentsDir = join(dirname(dbPath), 'attachments'); - await rm(attachmentsDir, { recursive: true, force: true }).catch(() => {}); + // Remove the whole temp directory (includes the .db file and attachments/). + await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {}); }; return { app, ctx, dbPath, cleanup }; } export type ReqOpts = { + /** Adds Authorization: Bearer header. */ token?: string; + /** JSON-serialised as the request body with Content-Type: application/json. */ body?: unknown; + /** Additional headers merged after auth/content-type. */ headers?: Record; - /** Set Content-Type to this MIME type and send body as raw string. Used for binary uploads. */ - rawBody?: { data: string; contentType: string }; }; /** - * Fire a request at the Hono test app and return the status code + parsed response. - * - * Pass `token` to add an Authorization header. - * Pass `body` to JSON-encode and send as application/json. + * Fire a request at the Hono test app and return status + parsed JSON body. */ export async function req( app: Hono, @@ -92,19 +95,17 @@ export async function req( path: string, opts: ReqOpts = {}, ): Promise<{ status: number; data: unknown }> { - const headers: Record = { ...opts.headers }; + const headers: Record = {}; if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`; + if (opts.body !== undefined) headers['Content-Type'] = 'application/json'; + Object.assign(headers, opts.headers); - let body: BodyInit | undefined; - if (opts.body !== undefined) { - headers['Content-Type'] = 'application/json'; - body = JSON.stringify(opts.body); - } else if (opts.rawBody) { - headers['Content-Type'] = opts.rawBody.contentType; - body = opts.rawBody.data; - } + const res = await app.request(path, { + method, + headers, + ...(opts.body !== undefined && { body: JSON.stringify(opts.body) }), + }); - const res = await app.request(path, { method, headers, body }); const text = await res.text(); let data: unknown; try { From 794c5ab9e949b1f8376f8c6bfe6ce8923352c228 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 14:46:36 -0400 Subject: [PATCH 12/25] refactor(attachments): use adapter.getAttachmentMeta for MIME type and early 404 Replaces the brittle extension-scanning approach (detectMimeType + extToMime) with a direct DB query via the new optional StackAdapter.getAttachmentMeta method. When available this gives the exact MIME type stored at upload time and lets the GET handler return 404 before reading the binary payload. --- src/routes/attachments.ts | 46 ++++++++++++--------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index e7fd000..1f81314 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -30,6 +30,14 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono f.startsWith(fileId + '.')); - if (!file) return 'application/octet-stream'; - const ext = file.split('.').pop() ?? ''; - return extToMime[ext] ?? 'application/octet-stream'; - } catch { - return 'application/octet-stream'; - } -} - function deleteAttachmentFile(attachmentsDir: string, fileId: string): void { try { const entries = readdirSync(attachmentsDir) as string[]; @@ -103,22 +104,3 @@ async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - svg: 'image/svg+xml', - pdf: 'application/pdf', - mp4: 'video/mp4', - mp3: 'audio/mpeg', - wav: 'audio/wav', - json: 'application/json', - txt: 'text/plain', - html: 'text/html', - css: 'text/css', - js: 'application/javascript', - bin: 'application/octet-stream', -}; From 1f64dba26621630f451c22c5b4430c38925b0330 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Tue, 26 May 2026 08:00:26 -0400 Subject: [PATCH 13/25] refactor(attachments): drop adapter.getAttachmentMeta guards now that method is required --- src/routes/attachments.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 1f81314..4d3935e 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -30,13 +30,8 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono Date: Tue, 26 May 2026 08:20:13 -0400 Subject: [PATCH 14/25] feat(attachments): upload size limit, filename support, clean up file deletion - MAX_ATTACHMENT_BYTES env var (default 50 MB); 413 on oversized uploads - POST reads filename from Content-Disposition header and passes to adapter - GET sets Content-Disposition response header when filename is stored - GET uses meta.size for Content-Length instead of reading buffer length - DELETE delegates file removal to adapter.deleteAttachment (no more manual scan) - attachmentRoutes no longer needs dbPath; takes maxAttachmentBytes instead --- src/app.ts | 6 ++--- src/config.ts | 5 ++++ src/routes/attachments.ts | 48 +++++++++++++++++++++------------------ 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/app.ts b/src/app.ts index 7b03b63..0da13fc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,8 +29,8 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho origin: config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], - allowHeaders: ['Authorization', 'Content-Type'], - exposeHeaders: ['X-Request-Id'], + allowHeaders: ['Authorization', 'Content-Type', 'Content-Disposition'], + exposeHeaders: ['X-Request-Id', 'Content-Disposition'], }), ); app.use(errorMiddleware(logger)); @@ -40,7 +40,7 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho app.route('/health', healthRoutes()); app.route('/records', recordRoutes(ctx)); app.route('/types', typeRoutes(ctx)); - app.route('/attachments', attachmentRoutes(ctx, config.dbPath)); + app.route('/attachments', attachmentRoutes(ctx, config.maxAttachmentBytes)); app.route('/entity', entityRoutes(ctx)); app.notFound((c) => c.json({ error: 'Not found' }, 404)); diff --git a/src/config.ts b/src/config.ts index 8d8b5f0..8dc9664 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,7 @@ export type Config = { corsOrigins: string; baseUrl: string | null; isNewDb: boolean; + maxAttachmentBytes: number; }; export function loadConfig(): Config { @@ -68,5 +69,9 @@ export function loadConfig(): Config { corsOrigins: optional('CORS_ORIGINS', '*'), baseUrl: process.env['BASE_URL'] ?? null, isNewDb, + maxAttachmentBytes: parseInt( + optional('MAX_ATTACHMENT_BYTES', String(50 * 1024 * 1024)), + 10, + ), }; } diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 4d3935e..022ed14 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -1,22 +1,30 @@ import { Hono } from 'hono'; -import { readdirSync, unlinkSync } from 'node:fs'; -import { join, dirname } from 'node:path'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; import { checkAccess } from '../lib/access.js'; -export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { +export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { const app = new Hono(); const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - const attachmentsDir = join(dirname(dbPath), 'attachments'); // POST /attachments — upload raw binary, Content-Type = MIME type app.post('/', requireAuth(), async (c) => { - const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; + const contentLength = Number(c.req.header('Content-Length') ?? 0); + if (contentLength > maxAttachmentBytes) { + return c.json({ error: 'Attachment too large' }, 413); + } + const data = new Uint8Array(await c.req.arrayBuffer()); - const fileId = await adapter.putAttachment(data, mimeType); + if (data.byteLength > maxAttachmentBytes) { + return c.json({ error: 'Attachment too large' }, 413); + } + + const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; + const filename = parseFilename(c.req.header('Content-Disposition')); + + const fileId = await adapter.putAttachment(data, mimeType, filename); return c.json({ fileId }, 201); }); @@ -40,10 +48,15 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono = { 'Content-Type': meta.mimeType, - 'Content-Length': String(data.byteLength), - }); + 'Content-Length': String(meta.size), + }; + if (meta.filename) { + headers['Content-Disposition'] = `attachment; filename="${meta.filename}"`; + } + + return c.newResponse(data, 200, headers); }); // DELETE /attachments/:fileId @@ -57,25 +70,16 @@ export function attachmentRoutes(ctx: StackContext, dbPath: string): Hono { From 2bbbeaf21f555d576fc9ba02bfcb573b4085d20d Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Tue, 26 May 2026 08:40:33 -0400 Subject: [PATCH 15/25] docs: add MAX_ATTACHMENT_BYTES to .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index c53fd5e..a627ade 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,6 @@ CORS_ORIGINS=* # Auto-detected from the request if not set. # Example: https://stack.example.com BASE_URL= + +# Maximum upload size for attachments in bytes (default: 52428800 = 50 MB). +MAX_ATTACHMENT_BYTES=52428800 From 7ccb3c3f919bc3c66f73e7cfe1be7517c4599e73 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 15 Jun 2026 07:45:34 -0400 Subject: [PATCH 16/25] fix: replace O(n) isAttachmentPublic scan with attachmentFileId filter isAttachmentPublic previously fetched all records 200 at a time scanning for any with a matching attachment association. Now it uses the new attachmentFileId filter added to RecordFilter, which translates to an indexed EXISTS subquery on the SQLite side. Also threads attachmentFileId through parseQueryBody and parseQueryParams for GET/POST record queries. --- src/routes/attachments.ts | 22 ++++++++-------------- src/routes/records.ts | 4 ++++ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 022ed14..92de32c 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -85,19 +85,13 @@ function parseFilename(disposition: string | undefined): string | undefined { async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - let cursor: string | undefined; - do { - const result = await adapter.queryRecords({ limit: 200, ...(cursor && { cursor }) }); - for (const record of result.records) { - const hasRef = record.associations?.some( - (a) => a.kind === 'attachment' && a.fileId === fileId, - ); - if (hasRef) { - const readable = await checkAccess(record, null, ownerEntityId, 'read', adapter); - if (readable) return true; - } - } - cursor = result.cursor ?? undefined; - } while (cursor); + const result = await adapter.queryRecords({ + filter: { attachmentFileId: fileId }, + limit: 1, + }); + for (const record of result.records) { + const readable = await checkAccess(record, null, ownerEntityId, 'read', adapter); + if (readable) return true; + } return false; } diff --git a/src/routes/records.ts b/src/routes/records.ts index b7a7f1f..9d5b380 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -41,6 +41,7 @@ function parseQueryBody(raw: unknown): StackQuery { if (f.entityId !== undefined) filter.entityId = f.entityId as string | string[]; if (f.tags !== undefined) filter.tags = f.tags as string[]; if (f.hasAttachment !== undefined) filter.hasAttachment = f.hasAttachment as string; + if (f.attachmentFileId !== undefined) filter.attachmentFileId = f.attachmentFileId as string; if (f.relatedTo !== undefined) filter.relatedTo = f.relatedTo as { recordId: string; label?: string }; if (f.content !== undefined) filter.content = f.content as Record; @@ -101,6 +102,9 @@ function parseQueryParams(url: URL): StackQuery { const hasAttachment = getOne(url, 'hasAttachment'); if (hasAttachment) filter.hasAttachment = hasAttachment; + const attachmentFileId = getOne(url, 'attachmentFileId'); + if (attachmentFileId) filter.attachmentFileId = attachmentFileId; + const relatedTo = getOne(url, 'relatedTo'); if (relatedTo) { const label = getOne(url, 'relatedLabel'); From 2a404ac9c60162b165ab143c6b514fd29d64b0df Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 15 Jun 2026 22:05:11 -0400 Subject: [PATCH 17/25] feat: infer MIME type from filename extension when Content-Type is octet-stream When a client uploads without a specific Content-Type (or sends the generic application/octet-stream), fall back to the file extension from the Content-Disposition filename before giving up. Covers common image, document, text, video, audio, and archive types inline with no deps. --- src/routes/attachments.ts | 49 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 92de32c..44a7f65 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -21,8 +21,11 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): return c.json({ error: 'Attachment too large' }, 413); } - const mimeType = c.req.header('Content-Type') ?? 'application/octet-stream'; const filename = parseFilename(c.req.header('Content-Disposition')); + const mimeType = resolveMimeType( + c.req.header('Content-Type') ?? 'application/octet-stream', + filename, + ); const fileId = await adapter.putAttachment(data, mimeType, filename); return c.json({ fileId }, 201); @@ -82,6 +85,50 @@ function parseFilename(disposition: string | undefined): string | undefined { return match?.[1]; } +// Map of common file extensions to MIME types. Used to upgrade +// application/octet-stream when the client omits a specific Content-Type +// but provided a filename with a recognisable extension. +const EXTENSION_MIME: Record = { + // Images + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + // Documents + pdf: 'application/pdf', + // Text + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + html: 'text/html', + htm: 'text/html', + css: 'text/css', + js: 'text/javascript', + json: 'application/json', + xml: 'application/xml', + // Video + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + // Audio + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + m4a: 'audio/mp4', + // Archives + zip: 'application/zip', + gz: 'application/gzip', +}; + +function resolveMimeType(declared: string, filename: string | undefined): string { + if (declared !== 'application/octet-stream' || !filename) return declared; + const ext = filename.split('.').pop()?.toLowerCase(); + return (ext && EXTENSION_MIME[ext]) || declared; +} + async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; From 1fe595921acfbb7db77111133d21bc4afd841dd9 Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 15 Jun 2026 22:26:33 -0400 Subject: [PATCH 18/25] feat: replace static AUTH_TOKENS with DB-backed token management OWNER_TOKEN (env) replaces AUTH_TOKENS as the single bootstrap credential for the stack owner. All other tokens are issued, listed, and revoked via the new /tokens API (owner-only). Auth middleware checks the owner token first, then falls back to a hashed DB lookup via adapter.lookupToken(). --- .env.example | 9 +++--- src/app.ts | 4 ++- src/config.ts | 29 ++----------------- src/middleware/auth.ts | 14 +++++---- src/routes/tokens.ts | 65 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 38 deletions(-) create mode 100644 src/routes/tokens.ts diff --git a/.env.example b/.env.example index a627ade..ec0e537 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,10 @@ ENTITY_ID= # IANA timezone string. Used only on first run. Default: UTC TIMEZONE=UTC -# Bearer token to entity ID mapping. -# Format: token1:entityId1,token2:entityId2 -# Each token grants access as the specified entity. -# The entity whose ID matches ENTITY_ID is the stack owner and has full access. -AUTH_TOKENS= +# Master bearer token for the stack owner. Used to bootstrap the server and +# manage other tokens via POST /tokens. Keep this secret and treat it like a +# root password. All other tokens are issued via the API and stored in the DB. +OWNER_TOKEN= # Allowed CORS origins. Default: * (all origins) # Use a comma-separated list to restrict: https://app.example.com,https://admin.example.com diff --git a/src/app.ts b/src/app.ts index 0da13fc..95fe480 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { recordRoutes } from './routes/records.js'; import { typeRoutes } from './routes/types.js'; import { attachmentRoutes } from './routes/attachments.js'; import { entityRoutes } from './routes/entity.js'; +import { tokenRoutes } from './routes/tokens.js'; export type { AppEnv }; @@ -34,7 +35,7 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho }), ); app.use(errorMiddleware(logger)); - app.use(authMiddleware(config.tokens)); + app.use(authMiddleware(config.ownerToken, ctx)); app.route('/.well-known', wellknownRoutes(ctx)); app.route('/health', healthRoutes()); @@ -42,6 +43,7 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho app.route('/types', typeRoutes(ctx)); app.route('/attachments', attachmentRoutes(ctx, config.maxAttachmentBytes)); app.route('/entity', entityRoutes(ctx)); + app.route('/tokens', tokenRoutes(ctx)); app.notFound((c) => c.json({ error: 'Not found' }, 404)); diff --git a/src/config.ts b/src/config.ts index 8dc9664..5fcfc12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,34 +10,12 @@ function optional(name: string, fallback: string): string { return process.env[name] ?? fallback; } -export type TokenConfig = { - token: string; - entityId: string; -}; - -function parseTokens(raw: string): TokenConfig[] { - return raw.split(',').map((pair) => { - const i = pair.indexOf(':'); - if (i === -1) { - throw new Error( - `Invalid AUTH_TOKENS format. Expected comma-separated "token:entityId" pairs, got: "${pair}"`, - ); - } - const token = pair.slice(0, i).trim(); - const entityId = pair.slice(i + 1).trim(); - if (!token || !entityId) { - throw new Error(`Invalid AUTH_TOKENS entry "${pair}": both token and entityId are required`); - } - return { token, entityId }; - }); -} - export type Config = { port: number; dbPath: string; entityId: string | null; timezone: string; - tokens: TokenConfig[]; + ownerToken: string; corsOrigins: string; baseUrl: string | null; isNewDb: boolean; @@ -57,15 +35,12 @@ export function loadConfig(): Config { ); } - const rawTokens = required('AUTH_TOKENS'); - const tokens = parseTokens(rawTokens); - return { port: parseInt(optional('PORT', '3000'), 10), dbPath, entityId, timezone, - tokens, + ownerToken: required('OWNER_TOKEN'), corsOrigins: optional('CORS_ORIGINS', '*'), baseUrl: process.env['BASE_URL'] ?? null, isNewDb, diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index f8b731a..b069829 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -1,16 +1,20 @@ import type { MiddlewareHandler } from 'hono'; -import type { TokenConfig } from '../config.js'; import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; -export function authMiddleware(tokens: TokenConfig[]): MiddlewareHandler { - const tokenMap = new Map(tokens.map((t) => [t.token, t.entityId])); +export function authMiddleware(ownerToken: string, ctx: StackContext): MiddlewareHandler { + const ownerEntityId = ctx.stack.ownerEntityId; return async (c, next) => { const header = c.req.header('Authorization'); if (header?.startsWith('Bearer ')) { const token = header.slice(7); - const entityId = tokenMap.get(token); - c.set('auth', entityId ? { entityId } : null); + if (token === ownerToken) { + c.set('auth', { entityId: ownerEntityId }); + } else { + const result = await ctx.adapter.lookupToken(token); + c.set('auth', result ? { entityId: result.entityId } : null); + } } else { c.set('auth', null); } diff --git a/src/routes/tokens.ts b/src/routes/tokens.ts new file mode 100644 index 0000000..c716c5d --- /dev/null +++ b/src/routes/tokens.ts @@ -0,0 +1,65 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import type { TokenInfo } from '@haverstack/adapter-sqlite'; + +export function tokenRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // POST /tokens — issue a new token (owner only) + app.post('/', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json<{ entityId?: string; label?: string; expiresAt?: string }>(); + const entityId = body.entityId ?? ownerEntityId; + const expiresAt = body.expiresAt ? new Date(body.expiresAt) : undefined; + + const { id, token } = await adapter.createToken(entityId, { label: body.label, expiresAt }); + + return c.json( + { + id, + token, + entityId, + label: body.label ?? null, + createdAt: new Date().toISOString(), + expiresAt: expiresAt?.toISOString() ?? null, + }, + 201, + ); + }); + + // GET /tokens — list all DB-managed tokens; never returns token values + app.get('/', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + const tokens = await adapter.listTokens(); + return c.json({ tokens: tokens.map(serializeToken) }); + }); + + // DELETE /tokens/:id — revoke a token by its ID + app.delete('/:id', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== ownerEntityId) return c.json({ error: 'Forbidden' }, 403); + + await adapter.revokeToken(c.req.param('id')); + return c.body(null, 204); + }); + + return app; +} + +function serializeToken(t: TokenInfo) { + return { + id: t.id, + entityId: t.entityId, + label: t.label ?? null, + createdAt: t.createdAt.toISOString(), + expiresAt: t.expiresAt?.toISOString() ?? null, + }; +} From 19fa1913c77c008cc10dbb9ac3fabfe0b71fb409 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:44:26 +0000 Subject: [PATCH 19/25] fix: attachment access follows union of referencing records' read permissions GET /attachments/:fileId previously let any authenticated caller download any attachment, and only checked the first record referencing a fileId when evaluating anonymous access. Per spec, an attachment is accessible if ANY referencing record is accessible to the requester, regardless of auth state. The owner still gets unconditional access, including to files not yet referenced by any record. DELETE remains owner-only. Also fixes tests/setup.ts, which still referenced the static AUTH_TOKENS config shape removed when token issuance moved to OWNER_TOKEN + DB-backed tokens, causing the entire test suite to 500 on every authenticated request. --- src/routes/attachments.ts | 41 +++++--- tests/routes/attachments.test.ts | 166 +++++++++++++++++++++++++++++++ tests/setup.ts | 7 +- 3 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 tests/routes/attachments.test.ts diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index 44a7f65..c9560d2 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -36,10 +36,8 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): const fileId = c.req.param('fileId'); const auth = c.get('auth'); - if (!auth) { - const accessible = await isAttachmentPublic(fileId, ctx); - if (!accessible) return c.json({ error: 'Unauthorized' }, 401); - } + const accessible = await isAttachmentAccessible(fileId, auth?.entityId ?? null, ctx); + if (!accessible) return c.json({ error: 'Unauthorized' }, 401); const meta = await adapter.getAttachmentMeta(fileId); if (!meta) return c.json({ error: 'Attachment not found' }, 404); @@ -129,16 +127,33 @@ function resolveMimeType(declared: string, filename: string | undefined): string return (ext && EXTENSION_MIME[ext]) || declared; } -async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise { +/** + * An attachment is accessible if the requester can read at least one of the + * Records that reference it (per spec: permissions are governed by the + * referencing Record(s), not the attachment itself). Owners always pass, + * even for attachments not yet referenced by any Record (e.g. just uploaded). + */ +async function isAttachmentAccessible( + fileId: string, + requesterEntityId: string | null, + ctx: StackContext, +): Promise { const { adapter, stack } = ctx; const ownerEntityId = stack.ownerEntityId; - const result = await adapter.queryRecords({ - filter: { attachmentFileId: fileId }, - limit: 1, - }); - for (const record of result.records) { - const readable = await checkAccess(record, null, ownerEntityId, 'read', adapter); - if (readable) return true; - } + if (requesterEntityId && requesterEntityId === ownerEntityId) return true; + + let cursor: string | undefined; + do { + const result = await adapter.queryRecords({ + filter: { attachmentFileId: fileId }, + ...(cursor && { cursor }), + }); + for (const record of result.records) { + const readable = await checkAccess(record, requesterEntityId, ownerEntityId, 'read', adapter); + if (readable) return true; + } + cursor = result.cursor ?? undefined; + } while (cursor); + return false; } diff --git a/tests/routes/attachments.test.ts b/tests/routes/attachments.test.ts new file mode 100644 index 0000000..19ac8d4 --- /dev/null +++ b/tests/routes/attachments.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; + +const NOTE_TYPE_ID = 'com.example.test/note@1'; + +async function seedType(ctx: TestApp['ctx']) { + return ctx.stack.defineType(NOTE_TYPE_ID, 'Note', { + body: { kind: 'text' as const, required: true as const }, + }); +} + +async function putFile(ctx: TestApp['ctx'], content = 'hello') { + return ctx.adapter.putAttachment(new TextEncoder().encode(content), 'text/plain'); +} + +describe('GET /attachments/:fileId', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it('allows the owner to read an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { status, data } = await req(t.app, 'GET', `/attachments/${fileId}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(200); + expect(data).toBe('hello'); + }); + + it('rejects an anonymous request for an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(401); + }); + + it('rejects a non-owner authenticated request for an unattached file', async () => { + const fileId = await putFile(t.ctx); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(401); + }); + + it('allows anonymous access when the referencing record is public', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'public note' }, + { + permissions: [{ access: 'public' }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(200); + }); + + it('rejects anonymous access when the referencing record is private', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(401); + }); + + it('grants access if ANY referencing record is accessible, not just the first', async () => { + const fileId = await putFile(t.ctx); + // Private record references the file first... + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + // ...a second, public record also references it. + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'public note' }, + { + permissions: [{ access: 'public' }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`); + expect(status).toBe(200); + }); + + it('grants a non-owner entity access via an entity-scoped read grant', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'shared note' }, + { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(200); + }); + + it('rejects a non-owner entity without a matching read grant', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'private note' }, + { + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'GET', `/attachments/${fileId}`, { token }); + expect(status).toBe(401); + }); +}); + +describe('DELETE /attachments/:fileId', () => { + let t: TestApp; + beforeEach(async () => { + t = await buildTestApp(); + await seedType(t.ctx); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it('allows the owner to delete', async () => { + const fileId = await putFile(t.ctx); + const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { + token: TEST_TOKEN, + }); + expect(status).toBe(204); + }); + + it('rejects a non-owner entity even with a write grant on a referencing record', async () => { + const fileId = await putFile(t.ctx); + await t.ctx.stack.create( + NOTE_TYPE_ID, + { body: 'shared note' }, + { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + associations: [{ kind: 'attachment', label: 'file', fileId, mimeType: 'text/plain' }], + }, + ); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + + const { status } = await req(t.app, 'DELETE', `/attachments/${fileId}`, { token }); + expect(status).toBe(403); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 6877200..719db67 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -14,7 +14,6 @@ import type { AppEnv } from '../src/app.js'; export const TEST_ENTITY_ID = 'test-entity-id-00000001'; export const TEST_TOKEN = 'test-bearer-token'; -export const OTHER_TOKEN = 'other-bearer-token'; export const OTHER_ENTITY_ID = 'other-entity-id-00000002'; export const logger = pino({ level: 'silent' }); @@ -45,13 +44,11 @@ export function testConfig(dbPath: string): Config { dbPath, entityId: TEST_ENTITY_ID, timezone: 'UTC', - tokens: [ - { token: TEST_TOKEN, entityId: TEST_ENTITY_ID }, - { token: OTHER_TOKEN, entityId: OTHER_ENTITY_ID }, - ], + ownerToken: TEST_TOKEN, corsOrigins: '*', baseUrl: null, isNewDb: true, + maxAttachmentBytes: 50 * 1024 * 1024, }; } From 670c89c784ccccb2ad95627301de70c0965c0beb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:18:14 +0000 Subject: [PATCH 20/25] Adopt ScopedStack; fix anonymous reads, query filtering, PATCH merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up Stack.asEntity()/ScopedStack/StackPermissionError from @haverstack/core and uses them throughout. Permission bugs fixed: - Anonymous requesters could not read public records — all record routes required auth unconditionally. Read routes now call stack.asEntity(auth?.entityId ?? null) so public records are accessible without a token; StackPermissionError (→ 403 via app.onError) gates private ones. - GET /records and POST /records/query returned all records to any authenticated caller with no per-record access checks. Both now use ScopedStack.query(), which filters-then-refills across adapter pages so no record is silently skipped. total is always null on permission-scoped results (avoids leaking record cardinality). - PATCH /records/:id called adapter.updateRecord() directly, replacing the entire content object and skipping validation and version snapshotting. Now routes through Stack.update() (via ScopedStack), which applies RFC 7396 JSON Merge Patch, validates the merged result against the type schema, and snapshots the previous state to version history before writing. Other changes: - src/lib/access.ts deleted — logic now lives in @haverstack/core. - app.onError() used instead of a try/catch middleware wrapper; Hono catches handler errors in compose.js before they reach middleware catch blocks, so the middleware pattern was silently swallowing all permission errors as 500s. - entity.ts updated to use ScopedStack (was the last consumer of the deleted access.ts). - tsconfig.json: removed rootDir from the base config (it belongs only in tsconfig.build.json) — fixes a pre-existing typecheck failure when tests are included in the program. - Fixed pre-existing type errors exposed by the tsconfig fix: conditional spread with boolean (auth.ts, records.ts, types.ts) and Uint8Array variance (attachments.ts). --- package-lock.json | 2257 ++++++++++++++++++++++++++++++++++ package.json | 2 +- src/app.ts | 2 +- src/lib/access.ts | 61 - src/middleware/auth.ts | 2 +- src/middleware/errors.ts | 18 +- src/routes/attachments.ts | 28 +- src/routes/entity.ts | 40 +- src/routes/records.ts | 235 ++-- src/routes/types.ts | 2 +- tests/routes/records.test.ts | 95 +- tsconfig.json | 1 - 12 files changed, 2466 insertions(+), 277 deletions(-) create mode 100644 package-lock.json delete mode 100644 src/lib/access.ts diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5437b1d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2257 @@ +{ + "name": "@haverstack/server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@haverstack/server", + "version": "0.1.0", + "dependencies": { + "@haverstack/adapter-sqlite": "^0.1.0", + "@haverstack/core": "file:../core/packages/core", + "@hono/node-server": "^1.13.7", + "hono": "^4.6.0", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^10.0.0", + "prettier": "^3.0.0", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "typescript-eslint": "^8.0.0", + "vitest": "^2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "../core/packages/core": { + "name": "@haverstack/core", + "version": "0.1.0", + "license": "CC0-1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@haverstack/adapter-sqlite": { + "version": "0.1.0", + "license": "CC0-1.0", + "dependencies": { + "@haverstack/core": "0.1.0", + "sql.js": "^1.14.0" + } + }, + "node_modules/@haverstack/core": { + "resolved": "../core/packages/core", + "link": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.25", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql.js": { + "version": "1.14.1", + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index 098a013..647af1b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@haverstack/adapter-sqlite": "^0.1.0", - "@haverstack/core": "^0.1.0", + "@haverstack/core": "file:../core/packages/core", "@hono/node-server": "^1.13.7", "hono": "^4.6.0", "pino": "^9.5.0", diff --git a/src/app.ts b/src/app.ts index 95fe480..de16497 100644 --- a/src/app.ts +++ b/src/app.ts @@ -34,7 +34,7 @@ export function createApp(ctx: StackContext, config: Config, logger: Logger): Ho exposeHeaders: ['X-Request-Id', 'Content-Disposition'], }), ); - app.use(errorMiddleware(logger)); + app.onError(errorMiddleware(logger)); app.use(authMiddleware(config.ownerToken, ctx)); app.route('/.well-known', wellknownRoutes(ctx)); diff --git a/src/lib/access.ts b/src/lib/access.ts deleted file mode 100644 index e2ebb8d..0000000 --- a/src/lib/access.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { StackRecord, StackAdapter } from '@haverstack/core'; - -export type AccessMode = 'read' | 'write'; - -/** - * Check whether an entity has read or write access to a record. - * - * - No permissions (absent/empty): owner only. - * - public: anyone can read; write still requires an explicit grant. - * - entity: direct entityId match. - * - group: walk the _group record's relationship associations for membership. - */ -export async function checkAccess( - record: StackRecord, - requesterEntityId: string | null, - ownerEntityId: string | null, - mode: AccessMode, - adapter: StackAdapter, -): Promise { - // Owner always has full access. - if (requesterEntityId && requesterEntityId === ownerEntityId) return true; - - const perms = record.permissions; - - // No permissions = private. - if (!perms || perms.length === 0) return false; - - for (const p of perms) { - if (p.access === 'public' && mode === 'read') return true; - - if (p.access === 'entity' && p.entityId === requesterEntityId) { - if (mode === 'read' && p.read) return true; - if (mode === 'write' && p.write) return true; - } - - if (p.access === 'group' && requesterEntityId) { - const member = await isGroupMember(p.groupId, requesterEntityId, adapter); - if (member) { - if (mode === 'read' && p.read) return true; - if (mode === 'write' && p.write) return true; - } - } - } - - return false; -} - -async function isGroupMember( - groupRecordId: string, - entityId: string, - adapter: StackAdapter, -): Promise { - const group = await adapter.getRecord(groupRecordId); - if (!group) return false; - return (group.associations ?? []).some( - (a) => - a.kind === 'relationship' && - (a.label === 'member' || a.label === 'admin') && - a.recordId === entityId, - ); -} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index b069829..0c69445 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -10,7 +10,7 @@ export function authMiddleware(ownerToken: string, ctx: StackContext): Middlewar if (header?.startsWith('Bearer ')) { const token = header.slice(7); if (token === ownerToken) { - c.set('auth', { entityId: ownerEntityId }); + c.set('auth', ownerEntityId ? { entityId: ownerEntityId } : null); } else { const result = await ctx.adapter.lookupToken(token); c.set('auth', result ? { entityId: result.entityId } : null); diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 32d02a1..27a47d0 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -1,14 +1,14 @@ -import type { MiddlewareHandler } from 'hono'; +import type { ErrorHandler } from 'hono'; import type { Logger } from 'pino'; import type { AppEnv } from '../types.js'; +import { StackPermissionError, StackValidationError } from '@haverstack/core'; -export function errorMiddleware(logger: Logger): MiddlewareHandler { - return async (c, next) => { - try { - await next(); - } catch (err) { - logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); - return c.json({ error: 'Internal server error' }, 500); - } +export function errorMiddleware(logger: Logger): ErrorHandler { + return (err, c) => { + if (err instanceof StackPermissionError) return c.json({ error: 'Forbidden' }, 403); + if (err instanceof StackValidationError) + return c.json({ error: err.message, details: err.errors }, 400); + logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); + return c.json({ error: 'Internal server error' }, 500); }; } diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index c9560d2..c25ed50 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -2,7 +2,6 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; -import { checkAccess } from '../lib/access.js'; export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { const app = new Hono(); @@ -57,7 +56,7 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): headers['Content-Disposition'] = `attachment; filename="${meta.filename}"`; } - return c.newResponse(data, 200, headers); + return c.newResponse(data as unknown as Uint8Array, 200, headers); }); // DELETE /attachments/:fileId @@ -138,22 +137,13 @@ async function isAttachmentAccessible( requesterEntityId: string | null, ctx: StackContext, ): Promise { - const { adapter, stack } = ctx; - const ownerEntityId = stack.ownerEntityId; - if (requesterEntityId && requesterEntityId === ownerEntityId) return true; - - let cursor: string | undefined; - do { - const result = await adapter.queryRecords({ - filter: { attachmentFileId: fileId }, - ...(cursor && { cursor }), - }); - for (const record of result.records) { - const readable = await checkAccess(record, requesterEntityId, ownerEntityId, 'read', adapter); - if (readable) return true; - } - cursor = result.cursor ?? undefined; - } while (cursor); + const { stack } = ctx; + // Owner bypass: always accessible even if no record references it yet. + if (requesterEntityId && requesterEntityId === stack.ownerEntityId) return true; - return false; + const result = await stack.asEntity(requesterEntityId).query({ + filter: { attachmentFileId: fileId }, + limit: 1, + }); + return result.records.length > 0; } diff --git a/src/routes/entity.ts b/src/routes/entity.ts index 81f0209..b008be1 100644 --- a/src/routes/entity.ts +++ b/src/routes/entity.ts @@ -2,17 +2,17 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; -import { checkAccess } from '../lib/access.js'; -import { serializeRecord, parseDate } from '../lib/serialize.js'; +import { serializeRecord } from '../lib/serialize.js'; export function entityRoutes(ctx: StackContext): Hono { const app = new Hono(); - const { adapter, stack } = ctx; + const { stack } = ctx; const ownerEntityId = stack.ownerEntityId; app.get('/', requireAuth(), async (c) => { if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); - const record = await adapter.getRecord(ownerEntityId); + const auth = c.get('auth')!; + const record = await stack.asEntity(auth.entityId).get(ownerEntityId); if (!record) return c.json({ error: 'Entity record not found' }, 404); return c.json(serializeRecord(record)); }); @@ -20,28 +20,18 @@ export function entityRoutes(ctx: StackContext): Hono { app.patch('/', requireAuth(), async (c) => { if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); const auth = c.get('auth')!; - const existing = await adapter.getRecord(ownerEntityId); - if (!existing) return c.json({ error: 'Entity record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const body = await c.req.json>(); - - await adapter.saveVersion(ownerEntityId, { - version: existing.version, - content: existing.content, - updatedAt: existing.updatedAt, - ...(existing.entityId && { entityId: existing.entityId }), - }); - - const updated = await adapter.updateRecord(ownerEntityId, { - ...(body.content !== undefined && { content: body.content as Record }), - ...(body.typeId !== undefined && { typeId: body.typeId as string }), - updatedAt: parseDate(body.updatedAt) ?? new Date(), - version: typeof body.version === 'number' ? body.version : existing.version + 1, - }); - - return c.json(serializeRecord(updated)); + try { + const updated = await stack.asEntity(auth.entityId).update( + ownerEntityId, + (body.content ?? {}) as Record, + ); + return c.json(serializeRecord(updated)); + } catch (err) { + if (err instanceof Error && err.message.startsWith('Record not found')) + return c.json({ error: 'Entity record not found' }, 404); + throw err; + } }); return app; diff --git a/src/routes/records.ts b/src/routes/records.ts index 9d5b380..33f1547 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -2,15 +2,8 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; import { requireAuth } from '../middleware/auth.js'; -import { checkAccess } from '../lib/access.js'; import { serializeRecord, serializeVersion } from '../lib/serialize.js'; -import type { - StackRecord, - StackQuery, - RecordFilter, - Association, - Permission, -} from '@haverstack/core'; +import type { StackQuery, RecordFilter, Association, Permission, TypeId } from '@haverstack/core'; // --------------------------------------------------------------------------- // Query parsing helpers @@ -150,116 +143,95 @@ function parseQueryParams(url: URL): StackQuery { return query; } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** True when ScopedStack threw because the record doesn't exist (not a permission error). */ +function isRecordNotFound(err: unknown): boolean { + return err instanceof Error && err.message.startsWith('Record not found'); +} + // --------------------------------------------------------------------------- // Route factory // --------------------------------------------------------------------------- export function recordRoutes(ctx: StackContext): Hono { const app = new Hono(); - const { adapter, stack } = ctx; - const ownerEntityId = stack.ownerEntityId; + const { stack } = ctx; // POST /records/query — full query with content-field filters // Registered before /:id patterns to avoid param capture on the literal "query" segment. - app.post('/query', requireAuth(), async (c) => { + app.post('/query', async (c) => { + const auth = c.get('auth'); const query = parseQueryBody(await c.req.json()); - const result = await adapter.queryRecords(query); - return c.json({ - records: result.records.map(serializeRecord), - cursor: result.cursor, - total: result.total, - }); + const result = await stack.asEntity(auth?.entityId ?? null).query(query); + return c.json({ records: result.records.map(serializeRecord), cursor: result.cursor, total: result.total }); }); // GET /records — query by native fields via URL params - app.get('/', requireAuth(), async (c) => { + app.get('/', async (c) => { + const auth = c.get('auth'); const query = parseQueryParams(new URL(c.req.url)); - const result = await adapter.queryRecords(query); - return c.json({ - records: result.records.map(serializeRecord), - cursor: result.cursor, - total: result.total, - }); + const result = await stack.asEntity(auth?.entityId ?? null).query(query); + return c.json({ records: result.records.map(serializeRecord), cursor: result.cursor, total: result.total }); }); - // POST /records — create + // POST /records — create (server generates the ID) app.post('/', requireAuth(), async (c) => { const body = await c.req.json>(); - if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); if (!body.typeId || typeof body.typeId !== 'string') return c.json({ error: 'typeId is required' }, 400); if (!body.content || typeof body.content !== 'object') return c.json({ error: 'content is required' }, 400); - const now = new Date(); - const record: StackRecord = { - id: body.id, - typeId: body.typeId, - createdAt: body.createdAt ? new Date(body.createdAt as string) : now, - updatedAt: body.updatedAt ? new Date(body.updatedAt as string) : now, - content: body.content as Record, - version: typeof body.version === 'number' ? body.version : 1, - ...(body.parentId && { parentId: body.parentId as string }), - ...(body.entityId && { entityId: body.entityId as string }), - ...(body.appId && { appId: body.appId as string }), - ...(body.permissions && { permissions: body.permissions as Permission[] }), - ...(body.associations && { associations: body.associations as Association[] }), - }; - - const created = await adapter.createRecord(record); + const created = await stack.create(body.typeId as TypeId, body.content as Record, { + parentId: typeof body.parentId === 'string' ? body.parentId : undefined, + entityId: typeof body.entityId === 'string' ? body.entityId : undefined, + appId: typeof body.appId === 'string' ? body.appId : undefined, + permissions: Array.isArray(body.permissions) ? (body.permissions as Permission[]) : undefined, + associations: Array.isArray(body.associations) ? (body.associations as Association[]) : undefined, + }); return c.json(serializeRecord(created), 201); }); // GET /records/:id - app.get('/:id', requireAuth(), async (c) => { + app.get('/:id', async (c) => { const id = c.req.param('id'); - const auth = c.get('auth')!; - const record = await adapter.getRecord(id); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); - if (!canRead) return c.json({ error: 'Forbidden' }, 403); return c.json(serializeRecord(record)); }); - // PATCH /records/:id + // PATCH /records/:id — merges content patch (RFC 7396); null field values remove the field app.patch('/:id', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const body = await c.req.json>(); - - // Snapshot current state before writing (server-side version history) - await adapter.saveVersion(id, { - version: existing.version, - content: existing.content, - updatedAt: existing.updatedAt, - ...(existing.entityId && { entityId: existing.entityId }), - }); - - const updated = await adapter.updateRecord(id, { - ...(body.content !== undefined && { content: body.content as Record }), - ...(body.typeId !== undefined && { typeId: body.typeId as string }), - updatedAt: body.updatedAt ? new Date(body.updatedAt as string) : new Date(), - version: typeof body.version === 'number' ? body.version : existing.version + 1, - }); - - return c.json(serializeRecord(updated)); + try { + const updated = await stack.asEntity(auth.entityId).update( + id, + (body.content ?? {}) as Record, + ); + return c.json(serializeRecord(updated)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } }); // DELETE /records/:id (?hard=true for permanent) app.delete('/:id', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; - await adapter.deleteRecord(id, { hard }); + try { + await stack.asEntity(auth.entityId).delete(id, { hard }); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } return c.body(null, 204); }); @@ -267,27 +239,26 @@ export function recordRoutes(ctx: StackContext): Hono { // Permissions // ------------------------------------------------------------------ - app.get('/:id/permissions', requireAuth(), async (c) => { + app.get('/:id/permissions', async (c) => { const id = c.req.param('id'); - const auth = c.get('auth')!; - const record = await adapter.getRecord(id); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); - if (!canRead) return c.json({ error: 'Forbidden' }, 403); return c.json({ permissions: record.permissions ?? [] }); }); app.put('/:id/permissions', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const body = await c.req.json<{ permissions: Permission[] }>(); if (!Array.isArray(body.permissions)) return c.json({ error: 'permissions must be an array' }, 400); - await adapter.updateRecord(id, { permissions: body.permissions }); + try { + await stack.asEntity(auth.entityId).setPermissions(id, body.permissions); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } return c.json({ permissions: body.permissions }); }); @@ -295,13 +266,11 @@ export function recordRoutes(ctx: StackContext): Hono { // Associations // ------------------------------------------------------------------ - app.get('/:id/associations', requireAuth(), async (c) => { + app.get('/:id/associations', async (c) => { const id = c.req.param('id'); - const auth = c.get('auth')!; - const record = await adapter.getRecord(id); + const auth = c.get('auth'); + const record = await stack.asEntity(auth?.entityId ?? null).get(id); if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); - if (!canRead) return c.json({ error: 'Forbidden' }, 403); let assocs = record.associations ?? []; const kind = c.req.query('kind'); if (kind) assocs = assocs.filter((a) => a.kind === kind); @@ -313,25 +282,27 @@ export function recordRoutes(ctx: StackContext): Hono { app.post('/:id/associations', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const body = await c.req.json(); if (!body.kind || !body.label) return c.json({ error: 'kind and label are required' }, 400); - await adapter.associate(id, body); + try { + await stack.asEntity(auth.entityId).associate(id, body); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } return c.body(null, 204); }); app.delete('/:id/associations', requireAuth(), async (c) => { const id = c.req.param('id'); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); const body = await c.req.json(); - await adapter.dissociate(id, body); + try { + await stack.asEntity(auth.entityId).dissociate(id, body); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } return c.body(null, 204); }); @@ -339,29 +310,31 @@ export function recordRoutes(ctx: StackContext): Hono { // Versions // ------------------------------------------------------------------ - app.get('/:id/versions', requireAuth(), async (c) => { + app.get('/:id/versions', async (c) => { const id = c.req.param('id'); - const auth = c.get('auth')!; - const record = await adapter.getRecord(id); - if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); - if (!canRead) return c.json({ error: 'Forbidden' }, 403); - const versions = await adapter.getVersions(id); - return c.json(versions.map(serializeVersion)); + const auth = c.get('auth'); + try { + const versions = await stack.asEntity(auth?.entityId ?? null).getVersions(id); + return c.json(versions.map(serializeVersion)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } }); - app.get('/:id/versions/:version', requireAuth(), async (c) => { + app.get('/:id/versions/:version', async (c) => { const id = c.req.param('id'); const vNum = parseInt(c.req.param('version'), 10); if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); - const auth = c.get('auth')!; - const record = await adapter.getRecord(id); - if (!record) return c.json({ error: 'Record not found' }, 404); - const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); - if (!canRead) return c.json({ error: 'Forbidden' }, 403); - const version = await adapter.getVersion(id, vNum); - if (!version) return c.json({ error: 'Version not found' }, 404); - return c.json(serializeVersion(version)); + const auth = c.get('auth'); + try { + const version = await stack.asEntity(auth?.entityId ?? null).getVersion(id, vNum); + if (!version) return c.json({ error: 'Version not found' }, 404); + return c.json(serializeVersion(version)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + throw err; + } }); // POST /records/:id/restore/:version — creates new version, does not rewrite history @@ -370,25 +343,15 @@ export function recordRoutes(ctx: StackContext): Hono { const vNum = parseInt(c.req.param('version'), 10); if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); const auth = c.get('auth')!; - const existing = await adapter.getRecord(id); - if (!existing) return c.json({ error: 'Record not found' }, 404); - const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); - if (!canWrite) return c.json({ error: 'Forbidden' }, 403); - const target = await adapter.getVersion(id, vNum); - if (!target) return c.json({ error: 'Version not found' }, 404); - // Snapshot current state before restoring - await adapter.saveVersion(id, { - version: existing.version, - content: existing.content, - updatedAt: existing.updatedAt, - ...(existing.entityId && { entityId: existing.entityId }), - }); - const restored = await adapter.updateRecord(id, { - content: target.content, - updatedAt: new Date(), - version: existing.version + 1, - }); - return c.json(serializeRecord(restored)); + try { + const restored = await stack.asEntity(auth.entityId).restoreVersion(id, vNum); + return c.json(serializeRecord(restored)); + } catch (err) { + if (isRecordNotFound(err)) return c.json({ error: 'Record not found' }, 404); + if (err instanceof Error && err.message.startsWith('Version')) + return c.json({ error: 'Version not found' }, 404); + throw err; + } }); return app; diff --git a/src/routes/types.ts b/src/routes/types.ts index 3f5caa3..3a8cbeb 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -41,7 +41,7 @@ export function typeRoutes(ctx: StackContext): Hono { schema: body.schema as TypeSchema, schemaHash: body.schemaHash, createdAt: body.createdAt ? new Date(body.createdAt as string) : new Date(), - ...(body.migratesFrom && { migratesFrom: body.migratesFrom as string }), + ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}), }; await adapter.saveType(type); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index d625d6c..3c0dc2b 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { generateId } from '@haverstack/core'; -import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, type TestApp } from '../setup.js'; +import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; const NOTE_TYPE_ID = 'com.example.test/note@1'; @@ -24,38 +23,33 @@ describe('Records', () => { afterEach(async () => { await t.cleanup(); }); describe('POST /records', () => { - it('creates a record', async () => { - const id = generateId(); + it('creates a record and returns a server-generated id', async () => { const { status, data } = await req(t.app, 'POST', '/records', { token: TEST_TOKEN, body: { - id, typeId: NOTE_TYPE_ID, content: { body: 'Test note' }, - version: 1, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), entityId: TEST_ENTITY_ID, }, }); expect(status).toBe(201); const d = data as Record; - expect(d.id).toBe(id); + expect(typeof d.id).toBe('string'); expect(d.typeId).toBe(NOTE_TYPE_ID); expect((d.content as Record).body).toBe('Test note'); }); - it('returns 400 when id is missing', async () => { + it('returns 400 when typeId is missing', async () => { const { status } = await req(t.app, 'POST', '/records', { token: TEST_TOKEN, - body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, + body: { content: { body: 'x' } }, }); expect(status).toBe(400); }); it('returns 401 without auth', async () => { const { status } = await req(t.app, 'POST', '/records', { - body: { id: generateId(), typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, + body: { typeId: NOTE_TYPE_ID, content: { body: 'x' } }, }); expect(status).toBe(401); }); @@ -73,23 +67,64 @@ describe('Records', () => { const { status } = await req(t.app, 'GET', '/records/nonexistent', { token: TEST_TOKEN }); expect(status).toBe(404); }); + + it('anonymous gets 403 for a private record', async () => { + const record = await seedRecord(t.ctx); + const { status } = await req(t.app, 'GET', `/records/${record.id}`); + expect(status).toBe(403); + }); + + it('anonymous can read a public record', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'public content' }, { + permissions: [{ access: 'public' }], + }); + const { status, data } = await req(t.app, 'GET', `/records/${record.id}`); + expect(status).toBe(200); + expect((data as Record).id).toBe(record.id); + }); + + it('entity with a read grant can read the record', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared content' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); + expect(status).toBe(200); + }); + + it('entity without a grant gets 403', async () => { + const record = await seedRecord(t.ctx); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'GET', `/records/${record.id}`, { token }); + expect(status).toBe(403); + }); }); describe('GET /records', () => { - it('returns records list with total', async () => { + it('returns records list', async () => { await seedRecord(t.ctx, { body: 'Note 1' }); await seedRecord(t.ctx, { body: 'Note 2' }); const { status, data } = await req(t.app, 'GET', '/records', { token: TEST_TOKEN }); expect(status).toBe(200); - const d = data as { records: unknown[]; total: number }; - expect(d.total).toBe(2); + const d = data as { records: unknown[]; total: null }; + expect(d.total).toBeNull(); expect(d.records).toHaveLength(2); }); it('filters by typeId query param', async () => { await seedRecord(t.ctx); const { data } = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE_ID)}`, { token: TEST_TOKEN }); - expect((data as { total: number }).total).toBe(1); + expect((data as { records: unknown[] }).records).toHaveLength(1); + }); + + it('anonymous query returns only public records', async () => { + await seedRecord(t.ctx, { body: 'private' }); + await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'public' }, { permissions: [{ access: 'public' }] }); + const { data } = await req(t.app, 'GET', '/records'); + const d = data as { records: Array<{ content: { body: string } }>; total: null }; + expect(d.total).toBeNull(); + expect(d.records).toHaveLength(1); + expect(d.records[0].content.body).toBe('public'); }); }); @@ -105,20 +140,24 @@ describe('Records', () => { }, }); expect(status).toBe(200); - expect((data as { total: number }).total).toBe(1); + const d = data as { records: unknown[]; total: null }; + expect(d.records).toHaveLength(1); + expect(d.total).toBeNull(); }); }); describe('PATCH /records/:id', () => { - it('updates record content', async () => { - const record = await seedRecord(t.ctx); + it('merges content (does not overwrite unmentioned fields)', async () => { + const record = await seedRecord(t.ctx, { body: 'original', title: 'My title' }); const { status, data } = await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { content: { body: 'Updated body' }, version: 2, updatedAt: new Date().toISOString() }, + body: { content: { body: 'Updated body' } }, }); expect(status).toBe(200); const d = data as Record; - expect((d.content as Record).body).toBe('Updated body'); + const content = d.content as Record; + expect(content.body).toBe('Updated body'); + expect(content.title).toBe('My title'); expect(d.version).toBe(2); }); @@ -126,12 +165,24 @@ describe('Records', () => { const record = await seedRecord(t.ctx); await req(t.app, 'PATCH', `/records/${record.id}`, { token: TEST_TOKEN, - body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, + body: { content: { body: 'v2' } }, }); const versions = await t.ctx.adapter.getVersions(record.id); expect(versions).toHaveLength(1); expect(versions[0].version).toBe(1); }); + + it('returns 403 when requester lacks write access', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'hi' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: false }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'PATCH', `/records/${record.id}`, { + token, + body: { content: { body: 'hacked' } }, + }); + expect(status).toBe(403); + }); }); describe('DELETE /records/:id', () => { diff --git a/tsconfig.json b/tsconfig.json index 4e1e0d1..85e20d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,6 @@ "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", - "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, From 2ff9a9f1f6c832fa45c2f6b4b6e0250d13cd057f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 13:54:09 +0000 Subject: [PATCH 21/25] Use 422 for schema validation errors instead of 400 400 is for structurally malformed requests; 422 is for requests that are syntactically valid but fail semantic/schema rules, which is exactly what StackValidationError represents. --- src/middleware/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/middleware/errors.ts b/src/middleware/errors.ts index 27a47d0..d8522bf 100644 --- a/src/middleware/errors.ts +++ b/src/middleware/errors.ts @@ -7,7 +7,7 @@ export function errorMiddleware(logger: Logger): ErrorHandler { return (err, c) => { if (err instanceof StackPermissionError) return c.json({ error: 'Forbidden' }, 403); if (err instanceof StackValidationError) - return c.json({ error: err.message, details: err.errors }, 400); + return c.json({ error: err.message, details: err.errors }, 422); logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error'); return c.json({ error: 'Internal server error' }, 500); }; From 6ca2512a99d05fc913b4c0b8bc554a5dfe2ab70a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:01:19 +0000 Subject: [PATCH 22/25] Require auth on POST /records/query The full query endpoint supports expensive operations (full-text search, content-field filters) and POST responses are not cached by intermediaries, making it a higher-cost target for unauthenticated scrapers than GET /records. Easier to open later than to close. --- src/routes/records.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/records.ts b/src/routes/records.ts index 33f1547..72a1bc3 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -162,7 +162,7 @@ export function recordRoutes(ctx: StackContext): Hono { // POST /records/query — full query with content-field filters // Registered before /:id patterns to avoid param capture on the literal "query" segment. - app.post('/query', async (c) => { + app.post('/query', requireAuth(), async (c) => { const auth = c.get('auth'); const query = parseQueryBody(await c.req.json()); const result = await stack.asEntity(auth?.entityId ?? null).query(query); From 4431b524abd4e43aee844f63442de59a589c706a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:20:21 +0000 Subject: [PATCH 23/25] Restrict hard delete to stack owner Hard delete permanently destroys a record and its version history, which exceeds the scope of normal write access. Only the owner entity may issue ?hard=true; non-owners receive 403 regardless of their write grant. Co-Authored-By: Claude --- src/routes/records.ts | 2 ++ tests/routes/records.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/routes/records.ts b/src/routes/records.ts index 72a1bc3..16697bf 100644 --- a/src/routes/records.ts +++ b/src/routes/records.ts @@ -226,6 +226,8 @@ export function recordRoutes(ctx: StackContext): Hono { const id = c.req.param('id'); const auth = c.get('auth')!; const hard = new URL(c.req.url).searchParams.get('hard') === 'true'; + if (hard && auth.entityId !== stack.ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); try { await stack.asEntity(auth.entityId).delete(id, { hard }); } catch (err) { diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts index 3c0dc2b..cb4609e 100644 --- a/tests/routes/records.test.ts +++ b/tests/routes/records.test.ts @@ -194,11 +194,32 @@ describe('Records', () => { expect(after?.deletedAt).toBeDefined(); }); - it('hard-deletes with ?hard=true', async () => { + it('hard-deletes with ?hard=true (owner)', async () => { const record = await seedRecord(t.ctx); const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN }); expect(status).toBe(204); expect(await t.ctx.adapter.getRecord(record.id)).toBeNull(); }); + + it('non-owner with write access can soft-delete', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}`, { token }); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.deletedAt).toBeDefined(); + }); + + it('non-owner gets 403 on hard delete even with write access', async () => { + const record = await t.ctx.stack.create(NOTE_TYPE_ID, { body: 'shared' }, { + permissions: [{ access: 'entity', entityId: OTHER_ENTITY_ID, read: true, write: true }], + }); + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token }); + expect(status).toBe(403); + expect(await t.ctx.adapter.getRecord(record.id)).not.toBeNull(); + }); }); }); From 01ab8058ad5270996e60da016619ab9eaece1933 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:50:18 +0000 Subject: [PATCH 24/25] Make type reads public, restrict type creation to owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any client that can read a public record should be able to look up its schema — requiring auth on GET /types was inconsistent with anonymous record reads. Conversely, registering a new type is a structural stack-wide operation (equivalent to a schema migration), so POST /types is now owner-only to prevent namespace pollution and schema squatting. Co-Authored-By: Claude --- src/routes/types.ts | 10 +++-- tests/routes/types.test.ts | 84 +++++++++++++++++++++++--------------- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/routes/types.ts b/src/routes/types.ts index 3a8cbeb..9d6e013 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -7,14 +7,14 @@ import type { StackType, TypeSchema } from '@haverstack/core'; export function typeRoutes(ctx: StackContext): Hono { const app = new Hono(); - const { adapter } = ctx; + const { adapter, stack } = ctx; - app.get('/', requireAuth(), async (c) => { + app.get('/', async (c) => { const types = await adapter.listTypes(); return c.json(types.map(serializeType)); }); - app.get('/:id', requireAuth(), async (c) => { + app.get('/:id', async (c) => { const id = decodeURIComponent(c.req.param('id')); const type = await adapter.getType(id); if (!type) return c.json({ error: 'Type not found' }, 404); @@ -22,6 +22,10 @@ export function typeRoutes(ctx: StackContext): Hono { }); app.post('/', requireAuth(), async (c) => { + const auth = c.get('auth')!; + if (auth.entityId !== stack.ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); + const body = await c.req.json>(); if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); if (!body.baseId || typeof body.baseId !== 'string') diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts index 68fd3ae..079bc24 100644 --- a/tests/routes/types.test.ts +++ b/tests/routes/types.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hashSchema } from '@haverstack/core'; -import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; +import { buildTestApp, req, TEST_TOKEN, OTHER_ENTITY_ID, type TestApp } from '../setup.js'; describe('Types', () => { let t: TestApp; @@ -10,42 +10,62 @@ describe('Types', () => { const typeId = 'com.example.test/item@1'; const schema = { name: { kind: 'string' as const, required: true as const } }; - it('POST /types registers a type', async () => { - const schemaHash = await hashSchema(schema); - const { status, data } = await req(t.app, 'POST', '/types', { - token: TEST_TOKEN, - body: { - id: typeId, - baseId: 'com.example.test/item', - version: 1, - name: 'Item', - schema, - schemaHash, - createdAt: new Date().toISOString(), - }, + describe('POST /types', () => { + it('registers a type as owner', async () => { + const schemaHash = await hashSchema(schema); + const { status, data } = await req(t.app, 'POST', '/types', { + token: TEST_TOKEN, + body: { + id: typeId, + baseId: 'com.example.test/item', + version: 1, + name: 'Item', + schema, + schemaHash, + createdAt: new Date().toISOString(), + }, + }); + expect(status).toBe(201); + expect((data as Record).id).toBe(typeId); + }); + + it('returns 401 without auth', async () => { + const { status } = await req(t.app, 'POST', '/types', { + body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' }, + }); + expect(status).toBe(401); }); - expect(status).toBe(201); - expect((data as Record).id).toBe(typeId); - }); - it('GET /types returns all registered types', async () => { - await t.ctx.stack.defineType(typeId, 'Item', schema); - const { status, data } = await req(t.app, 'GET', '/types', { token: TEST_TOKEN }); - expect(status).toBe(200); - expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); + it('returns 403 for a non-owner entity', async () => { + const { token } = await t.ctx.adapter.createToken(OTHER_ENTITY_ID); + const { status } = await req(t.app, 'POST', '/types', { + token, + body: { id: typeId, baseId: 'x', version: 1, name: 'x', schema: {}, schemaHash: 'x' }, + }); + expect(status).toBe(403); + }); }); - it('GET /types/:id returns one type (URL-encoded)', async () => { - await t.ctx.stack.defineType(typeId, 'Item', schema); - const { status, data } = await req( - t.app, 'GET', `/types/${encodeURIComponent(typeId)}`, { token: TEST_TOKEN }, - ); - expect(status).toBe(200); - expect((data as Record).id).toBe(typeId); + describe('GET /types', () => { + it('returns all registered types (no auth required)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', '/types'); + expect(status).toBe(200); + expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); + }); }); - it('GET /types/:id returns 404 for unknown type', async () => { - const { status } = await req(t.app, 'GET', '/types/unknown%40999', { token: TEST_TOKEN }); - expect(status).toBe(404); + describe('GET /types/:id', () => { + it('returns one type (URL-encoded, no auth required)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', `/types/${encodeURIComponent(typeId)}`); + expect(status).toBe(200); + expect((data as Record).id).toBe(typeId); + }); + + it('returns 404 for unknown type', async () => { + const { status } = await req(t.app, 'GET', '/types/unknown%40999'); + expect(status).toBe(404); + }); }); }); From b6103158c9b2efd8401ba49e133290779cfe8390 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 15:58:16 +0000 Subject: [PATCH 25/25] Extract requireOwner middleware from inline owner checks Replaces the duplicated auth+owner guard pattern in POST /types and DELETE /attachments with a shared requireOwner(ownerEntityId) middleware that returns 401 for unauthenticated requests, 403 for non-owners, and 403 when no owner entity is configured. Co-Authored-By: Claude --- src/middleware/auth.ts | 10 ++++++++++ src/routes/attachments.ts | 7 ++----- src/routes/types.ts | 8 ++------ 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 0c69445..8c27e77 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -28,3 +28,13 @@ export function requireAuth(): MiddlewareHandler { await next(); }; } + +export function requireOwner(ownerEntityId: string | null): MiddlewareHandler { + return async (c, next) => { + const auth = c.get('auth'); + if (!auth) return c.json({ error: 'Unauthorized' }, 401); + if (!ownerEntityId || auth.entityId !== ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); + await next(); + }; +} diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts index c25ed50..79a4d64 100644 --- a/src/routes/attachments.ts +++ b/src/routes/attachments.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireAuth, requireOwner } from '../middleware/auth.js'; export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { const app = new Hono(); @@ -60,11 +60,8 @@ export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): }); // DELETE /attachments/:fileId - app.delete('/:fileId', requireAuth(), async (c) => { + app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => { const fileId = c.req.param('fileId'); - const auth = c.get('auth')!; - if (!ownerEntityId || auth.entityId !== ownerEntityId) - return c.json({ error: 'Forbidden' }, 403); const meta = await adapter.getAttachmentMeta(fileId); if (!meta) return c.json({ error: 'Attachment not found' }, 404); diff --git a/src/routes/types.ts b/src/routes/types.ts index 9d6e013..5074c4e 100644 --- a/src/routes/types.ts +++ b/src/routes/types.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import type { AppEnv } from '../types.js'; import type { StackContext } from '../stack.js'; -import { requireAuth } from '../middleware/auth.js'; +import { requireOwner } from '../middleware/auth.js'; import { serializeType } from '../lib/serialize.js'; import type { StackType, TypeSchema } from '@haverstack/core'; @@ -21,11 +21,7 @@ export function typeRoutes(ctx: StackContext): Hono { return c.json(serializeType(type)); }); - app.post('/', requireAuth(), async (c) => { - const auth = c.get('auth')!; - if (auth.entityId !== stack.ownerEntityId) - return c.json({ error: 'Forbidden' }, 403); - + app.post('/', requireOwner(stack.ownerEntityId), async (c) => { const body = await c.req.json>(); if (!body.id || typeof body.id !== 'string') return c.json({ error: 'id is required' }, 400); if (!body.baseId || typeof body.baseId !== 'string')