diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a627ade --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# 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= + +# Maximum upload size for attachments in bytes (default: 52428800 = 50 MB). +MAX_ATTACHMENT_BYTES=52428800 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/src/app.ts b/src/app.ts new file mode 100644 index 0000000..0da13fc --- /dev/null +++ b/src/app.ts @@ -0,0 +1,49 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +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'; +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 }; + +export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono { + const app = new Hono(); + + // 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: + config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()), + allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'], + allowHeaders: ['Authorization', 'Content-Type', 'Content-Disposition'], + exposeHeaders: ['X-Request-Id', 'Content-Disposition'], + }), + ); + app.use(errorMiddleware(logger)); + app.use(authMiddleware(config.tokens)); + + 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, config.maxAttachmentBytes)); + app.route('/entity', entityRoutes(ctx)); + + 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..8dc9664 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,77 @@ +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; + maxAttachmentBytes: number; +}; + +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, + maxAttachmentBytes: parseInt( + optional('MAX_ATTACHMENT_BYTES', String(50 * 1024 * 1024)), + 10, + ), + }; +} 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/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..f8b731a --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,26 @@ +import type { MiddlewareHandler } from 'hono'; +import type { TokenConfig } from '../config.js'; +import type { AppEnv } from '../types.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..32d02a1 --- /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 '../types.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); + } + }; +} diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts new file mode 100644 index 0000000..022ed14 --- /dev/null +++ b/src/routes/attachments.ts @@ -0,0 +1,103 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; + +export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + // POST /attachments — upload raw binary, Content-Type = MIME type + app.post('/', requireAuth(), async (c) => { + 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()); + 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); + }); + + // GET /attachments/:fileId — download + app.get('/:fileId', async (c) => { + const fileId = c.req.param('fileId'); + const auth = c.get('auth'); + + if (!auth) { + const accessible = await isAttachmentPublic(fileId, ctx); + if (!accessible) return c.json({ error: 'Unauthorized' }, 401); + } + + const meta = await adapter.getAttachmentMeta(fileId); + if (!meta) return c.json({ error: 'Attachment not found' }, 404); + + let data: Uint8Array; + try { + data = await adapter.getAttachment(fileId); + } catch { + return c.json({ error: 'Attachment not found' }, 404); + } + + const headers: Record = { + 'Content-Type': meta.mimeType, + 'Content-Length': String(meta.size), + }; + if (meta.filename) { + headers['Content-Disposition'] = `attachment; filename="${meta.filename}"`; + } + + return c.newResponse(data, 200, headers); + }); + + // DELETE /attachments/:fileId + app.delete('/:fileId', requireAuth(), async (c) => { + const fileId = c.req.param('fileId'); + const auth = c.get('auth')!; + if (!ownerEntityId || auth.entityId !== ownerEntityId) + return c.json({ error: 'Forbidden' }, 403); + + const meta = await adapter.getAttachmentMeta(fileId); + if (!meta) return c.json({ error: 'Attachment not found' }, 404); + + await adapter.deleteAttachment(fileId); + return c.body(null, 204); + }); + + return app; +} + +function parseFilename(disposition: string | undefined): string | undefined { + if (!disposition) return undefined; + const match = disposition.match(/filename="([^"]+)"/); + return match?.[1]; +} + +async function isAttachmentPublic(fileId: string, ctx: StackContext): Promise { + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + let cursor: string | undefined; + do { + const result = await adapter.queryRecords({ limit: 200, ...(cursor && { cursor }) }); + for (const record of result.records) { + const hasRef = record.associations?.some( + (a) => a.kind === 'attachment' && a.fileId === fileId, + ); + if (hasRef) { + const readable = await checkAccess(record, null, ownerEntityId, 'read', adapter); + if (readable) return true; + } + } + cursor = result.cursor ?? undefined; + } while (cursor); + return false; +} diff --git a/src/routes/entity.ts b/src/routes/entity.ts new file mode 100644 index 0000000..81f0209 --- /dev/null +++ b/src/routes/entity.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; +import { serializeRecord, parseDate } from '../lib/serialize.js'; + +export function entityRoutes(ctx: StackContext): Hono { + const app = new Hono(); + const { adapter, stack } = ctx; + const ownerEntityId = stack.ownerEntityId; + + app.get('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const record = await adapter.getRecord(ownerEntityId); + if (!record) return c.json({ error: 'Entity record not found' }, 404); + return c.json(serializeRecord(record)); + }); + + app.patch('/', requireAuth(), async (c) => { + if (!ownerEntityId) return c.json({ error: 'No owner entity configured' }, 404); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(ownerEntityId); + if (!existing) return c.json({ error: 'Entity record not found' }, 404); + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json>(); + + await adapter.saveVersion(ownerEntityId, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + + const updated = await adapter.updateRecord(ownerEntityId, { + ...(body.content !== undefined && { content: body.content as Record }), + ...(body.typeId !== undefined && { typeId: body.typeId as string }), + updatedAt: parseDate(body.updatedAt) ?? new Date(), + version: typeof body.version === 'number' ? body.version : existing.version + 1, + }); + + return c.json(serializeRecord(updated)); + }); + + return app; +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..218f216 --- /dev/null +++ b/src/routes/health.ts @@ -0,0 +1,8 @@ +import { Hono } from 'hono'; +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 new file mode 100644 index 0000000..b7a7f1f --- /dev/null +++ b/src/routes/records.ts @@ -0,0 +1,391 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.js'; +import type { StackContext } from '../stack.js'; +import { requireAuth } from '../middleware/auth.js'; +import { checkAccess } from '../lib/access.js'; +import { serializeRecord, serializeVersion } from '../lib/serialize.js'; +import type { + StackRecord, + StackQuery, + RecordFilter, + Association, + Permission, +} from '@haverstack/core'; + +// --------------------------------------------------------------------------- +// 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 search 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 label = getOne(url, 'relatedLabel'); + filter.relatedTo = { recordId: relatedTo, ...(label && { label }) }; + } + + 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 with content-field filters + // Registered before /:id patterns to avoid param capture on the literal "query" segment. + app.post('/query', requireAuth(), async (c) => { + const query = parseQueryBody(await c.req.json()); + const result = await adapter.queryRecords(query); + return c.json({ + records: result.records.map(serializeRecord), + cursor: result.cursor, + total: result.total, + }); + }); + + // GET /records — query by native fields via URL params + app.get('/', requireAuth(), async (c) => { + const query = parseQueryParams(new URL(c.req.url)); + const result = await adapter.queryRecords(query); + return c.json({ + records: result.records.map(serializeRecord), + cursor: result.cursor, + total: result.total, + }); + }); + + // 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); + 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 + 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 + app.patch('/:id', requireAuth(), async (c) => { + const id = c.req.param('id'); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + + const body = await c.req.json>(); + + // Snapshot current state before writing (server-side version history) + await adapter.saveVersion(id, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + + const updated = await adapter.updateRecord(id, { + ...(body.content !== undefined && { content: body.content as Record }), + ...(body.typeId !== undefined && { typeId: body.typeId as string }), + updatedAt: body.updatedAt ? new Date(body.updatedAt as string) : new Date(), + version: typeof body.version === 'number' ? body.version : existing.version + 1, + }); + + return c.json(serializeRecord(updated)); + }); + + // 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); + }); + + // ------------------------------------------------------------------ + // 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); + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + const auth = c.get('auth')!; + const record = await adapter.getRecord(id); + if (!record) return c.json({ error: 'Record not found' }, 404); + const canRead = await checkAccess(record, auth.entityId, ownerEntityId, 'read', adapter); + if (!canRead) return c.json({ error: 'Forbidden' }, 403); + const version = await adapter.getVersion(id, vNum); + if (!version) return c.json({ error: 'Version not found' }, 404); + return c.json(serializeVersion(version)); + }); + + // 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); + if (isNaN(vNum)) return c.json({ error: 'Invalid version number' }, 400); + const auth = c.get('auth')!; + const existing = await adapter.getRecord(id); + if (!existing) return c.json({ error: 'Record not found' }, 404); + const canWrite = await checkAccess(existing, auth.entityId, ownerEntityId, 'write', adapter); + if (!canWrite) return c.json({ error: 'Forbidden' }, 403); + const target = await adapter.getVersion(id, vNum); + if (!target) return c.json({ error: 'Version not found' }, 404); + // Snapshot current state before restoring + await adapter.saveVersion(id, { + version: existing.version, + content: existing.content, + updatedAt: existing.updatedAt, + ...(existing.entityId && { entityId: existing.entityId }), + }); + const restored = await adapter.updateRecord(id, { + content: target.content, + updatedAt: new Date(), + version: existing.version + 1, + }); + return c.json(serializeRecord(restored)); + }); + + return app; +} diff --git a/src/routes/types.ts b/src/routes/types.ts new file mode 100644 index 0000000..3f5caa3 --- /dev/null +++ b/src/routes/types.ts @@ -0,0 +1,52 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.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; + + app.get('/', requireAuth(), async (c) => { + const types = await adapter.listTypes(); + return c.json(types.map(serializeType)); + }); + + 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)); + }); + + 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..fc8afc3 --- /dev/null +++ b/src/routes/wellknown.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono'; +import type { AppEnv } from '../types.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; +} 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 }; +} 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; + }; +}; diff --git a/tests/routes/associations.test.ts b/tests/routes/associations.test.ts new file mode 100644 index 0000000..cb3cff1 --- /dev/null +++ b/tests/routes/associations.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, 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 adds a tag association', async () => { + const record = await seedRecord(); + const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + expect(status).toBe(204); + }); + + 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`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + expect((data as { associations: unknown[] }).associations).toHaveLength(2); + }); + + 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`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + const assocs = (data as { associations: Array<{ kind: string }> }).associations; + expect(assocs.every((a) => a.kind === 'tag')).toBe(true); + }); + + 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`, { + token: TEST_TOKEN, + body: { kind: 'tag', label: 'starred' }, + }); + expect(status).toBe(204); + const after = await t.ctx.adapter.getRecord(record.id); + expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy(); + }); +}); diff --git a/tests/routes/records.test.ts b/tests/routes/records.test.ts new file mode 100644 index 0000000..d625d6c --- /dev/null +++ b/tests/routes/records.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { generateId } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, TEST_ENTITY_ID, type TestApp } from '../setup.js'; + +const NOTE_TYPE_ID = 'com.example.test/note@1'; + +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 = {}) { + 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 id = generateId(); + const { status, data } = await req(t.app, 'POST', '/records', { + token: TEST_TOKEN, + body: { + id, + typeId: NOTE_TYPE_ID, + content: { body: 'Test note' }, + version: 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + entityId: TEST_ENTITY_ID, + }, + }); + expect(status).toBe(201); + const d = data as Record; + expect(d.id).toBe(id); + expect(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', { + token: TEST_TOKEN, + body: { typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, + }); + expect(status).toBe(400); + }); + + it('returns 401 without auth', async () => { + const { status } = await req(t.app, 'POST', '/records', { + body: { id: generateId(), typeId: NOTE_TYPE_ID, content: { body: 'x' }, version: 1 }, + }); + 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}`, { 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', { token: TEST_TOKEN }); + expect(status).toBe(404); + }); + }); + + describe('GET /records', () => { + 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', { 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 query param', async () => { + await seedRecord(t.ctx); + const { data } = await req(t.app, 'GET', `/records?typeId=${encodeURIComponent(NOTE_TYPE_ID)}`, { token: TEST_TOKEN }); + expect((data as { total: number }).total).toBe(1); + }); + }); + + describe('POST /records/query', () => { + it('accepts a full StackQuery body', async () => { + await seedRecord(t.ctx); + const { status, data } = await req(t.app, 'POST', '/records/query', { + token: TEST_TOKEN, + body: { + filter: { typeId: NOTE_TYPE_ID }, + sort: { field: 'createdAt', direction: 'desc' }, + limit: 10, + }, + }); + expect(status).toBe(200); + 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}`, { + token: TEST_TOKEN, + body: { 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 the previous version on update', async () => { + const record = await seedRecord(t.ctx); + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, + }); + 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}`, { token: TEST_TOKEN }); + 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`, { token: TEST_TOKEN }); + expect(status).toBe(204); + expect(await t.ctx.adapter.getRecord(record.id)).toBeNull(); + }); + }); +}); diff --git a/tests/routes/types.test.ts b/tests/routes/types.test.ts new file mode 100644 index 0000000..68fd3ae --- /dev/null +++ b/tests/routes/types.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { hashSchema } from '@haverstack/core'; +import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js'; + +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', { + token: TEST_TOKEN, + body: { + id: typeId, + baseId: 'com.example.test/item', + version: 1, + name: 'Item', + schema, + schemaHash, + createdAt: new Date().toISOString(), + }, + }); + expect(status).toBe(201); + expect((data as Record).id).toBe(typeId); + }); + + it('GET /types returns all registered types', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req(t.app, 'GET', '/types', { token: TEST_TOKEN }); + expect(status).toBe(200); + expect((data as unknown[]).length).toBeGreaterThanOrEqual(1); + }); + + it('GET /types/:id returns one type (URL-encoded)', async () => { + await t.ctx.stack.defineType(typeId, 'Item', schema); + const { status, data } = await req( + t.app, 'GET', `/types/${encodeURIComponent(typeId)}`, { token: TEST_TOKEN }, + ); + expect(status).toBe(200); + expect((data as Record).id).toBe(typeId); + }); + + it('GET /types/:id returns 404 for unknown type', async () => { + const { status } = await req(t.app, 'GET', '/types/unknown%40999', { token: TEST_TOKEN }); + expect(status).toBe(404); + }); +}); diff --git a/tests/routes/versions.test.ts b/tests/routes/versions.test.ts new file mode 100644 index 0000000..f88e784 --- /dev/null +++ b/tests/routes/versions.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildTestApp, req, TEST_TOKEN, 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 createAndPatch() { + const record = await t.ctx.stack.create(TYPE_ID, { body: 'v1' }); + await req(t.app, 'PATCH', `/records/${record.id}`, { + token: TEST_TOKEN, + body: { content: { body: 'v2' }, version: 2, updatedAt: new Date().toISOString() }, + }); + return record; + } + + 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); + expect((data as unknown[]).length).toBe(1); + }); + + 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 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 new file mode 100644 index 0000000..86baf0f --- /dev/null +++ b/tests/routes/wellknown.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +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 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); + 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..6877200 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,117 @@ +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { randomBytes } from 'node:crypto'; +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'; +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' }); + +/** + * 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 { + 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 { + 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(); + // 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; +}; + +/** + * Fire a request at the Hono test app and return status + parsed JSON body. + */ +export async function req( + app: Hono, + method: string, + path: string, + opts: ReqOpts = {}, +): Promise<{ status: number; data: unknown }> { + 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); + + const res = await app.request(path, { + method, + headers, + ...(opts.body !== undefined && { body: JSON.stringify(opts.body) }), + }); + + const text = await res.text(); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = text; + } + return { status: res.status, data }; +} 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'], + }, + }, +});