From f54710089a0e80e85a7efc8a95d55948f0acb25b Mon Sep 17 00:00:00 2001 From: Jen Garcia Date: Mon, 25 May 2026 12:35:46 -0400 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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