@haverstack/server 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.dockerignore +10 -0
  3. package/.env.example +90 -0
  4. package/.github/pull_request_template.md +23 -0
  5. package/.github/workflows/ci.yml +55 -0
  6. package/.github/workflows/release.yml +99 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc +7 -0
  9. package/AGENTS.md +91 -0
  10. package/CONTRIBUTING.md +234 -0
  11. package/Dockerfile +30 -0
  12. package/LICENSE +121 -0
  13. package/README.md +107 -0
  14. package/SECURITY.md +54 -0
  15. package/docs/api.md +277 -0
  16. package/docs/deployment.md +212 -0
  17. package/eslint.config.mjs +18 -0
  18. package/package.json +42 -0
  19. package/pnpm-workspace.yaml +13 -0
  20. package/src/app.ts +86 -0
  21. package/src/config.ts +176 -0
  22. package/src/index.ts +54 -0
  23. package/src/lib/changeStreams.ts +23 -0
  24. package/src/lib/frameGate.ts +42 -0
  25. package/src/lib/json.ts +27 -0
  26. package/src/lib/nonceStore.ts +103 -0
  27. package/src/lib/queryLimit.ts +11 -0
  28. package/src/lib/queryWorker/pool.ts +324 -0
  29. package/src/lib/queryWorker/protocol.ts +39 -0
  30. package/src/lib/queryWorker/worker.ts +51 -0
  31. package/src/lib/resumeBuffer.ts +268 -0
  32. package/src/lib/resumeCursor.ts +48 -0
  33. package/src/lib/tokenSweep.ts +43 -0
  34. package/src/middleware/auth.ts +77 -0
  35. package/src/middleware/errors.ts +73 -0
  36. package/src/routes/attachments.ts +126 -0
  37. package/src/routes/auth.ts +137 -0
  38. package/src/routes/changes.ts +279 -0
  39. package/src/routes/entity.ts +33 -0
  40. package/src/routes/health.ts +8 -0
  41. package/src/routes/records.ts +259 -0
  42. package/src/routes/tokens.ts +85 -0
  43. package/src/routes/types.ts +57 -0
  44. package/src/routes/wellknown.ts +55 -0
  45. package/src/shutdown.ts +56 -0
  46. package/src/stack.ts +96 -0
  47. package/src/types.ts +9 -0
  48. package/src/wireError.ts +21 -0
  49. package/tests/app.test.ts +86 -0
  50. package/tests/changeFeedClient.ts +156 -0
  51. package/tests/config.test.ts +112 -0
  52. package/tests/conformance.test.ts +2322 -0
  53. package/tests/fixtures/blockingWorker.ts +27 -0
  54. package/tests/fixtures/brokenWorker.ts +7 -0
  55. package/tests/lib/changeFeedClient.test.ts +126 -0
  56. package/tests/lib/frameGate.test.ts +93 -0
  57. package/tests/lib/queryLimit.test.ts +33 -0
  58. package/tests/lib/queryWorker.test.ts +60 -0
  59. package/tests/lib/queryWorkerPool.test.ts +129 -0
  60. package/tests/lib/resumeBuffer.test.ts +341 -0
  61. package/tests/lib/resumeCursor.test.ts +46 -0
  62. package/tests/middleware/errors.test.ts +176 -0
  63. package/tests/routes/associations.test.ts +170 -0
  64. package/tests/routes/attachments.test.ts +621 -0
  65. package/tests/routes/auth.test.ts +448 -0
  66. package/tests/routes/changes.test.ts +567 -0
  67. package/tests/routes/contentQuery.test.ts +68 -0
  68. package/tests/routes/entity.test.ts +170 -0
  69. package/tests/routes/groups.test.ts +69 -0
  70. package/tests/routes/records.test.ts +1965 -0
  71. package/tests/routes/relatedToQuery.test.ts +182 -0
  72. package/tests/routes/tokens.test.ts +157 -0
  73. package/tests/routes/tombstones.test.ts +93 -0
  74. package/tests/routes/types.test.ts +129 -0
  75. package/tests/routes/versions.test.ts +177 -0
  76. package/tests/routes/wellknown.test.ts +71 -0
  77. package/tests/setup.ts +154 -0
  78. package/tests/shutdown.test.ts +121 -0
  79. package/tests/stack.test.ts +198 -0
  80. package/tsconfig.build.json +9 -0
  81. package/tsconfig.json +19 -0
  82. package/vitest.config.ts +12 -0
@@ -0,0 +1,57 @@
1
+ import { Hono } from 'hono';
2
+ import type { AppEnv } from '../types.js';
3
+ import type { StackContext } from '../stack.js';
4
+ import { requireOwner } from '../middleware/auth.js';
5
+ import { readJson } from '../lib/json.js';
6
+ import { serializeType } from '@haverstack/wire-types';
7
+ import {
8
+ hashSchema,
9
+ StackQueryError,
10
+ StackNotFoundError,
11
+ StackValidationError,
12
+ } from '@haverstack/core';
13
+ import type { TypeSchema } from '@haverstack/core';
14
+
15
+ export function typeRoutes(ctx: StackContext): Hono<AppEnv> {
16
+ const app = new Hono<AppEnv>();
17
+ const { adapter, stack } = ctx;
18
+
19
+ app.get('/', async (c) => {
20
+ const types = await adapter.listTypes();
21
+ return c.json(types.map(serializeType));
22
+ });
23
+
24
+ app.get('/:id', async (c) => {
25
+ const id = decodeURIComponent(c.req.param('id'));
26
+ const type = await adapter.getType(id);
27
+ if (!type) throw new StackNotFoundError('Type not found');
28
+ return c.json(serializeType(type));
29
+ });
30
+
31
+ app.post('/', requireOwner(stack.ownerEntityId), async (c) => {
32
+ const body = await readJson<Record<string, unknown>>(c);
33
+ if (!body.id || typeof body.id !== 'string') throw new StackQueryError('id is required');
34
+ if (!body.name || typeof body.name !== 'string') throw new StackQueryError('name is required');
35
+ if (!body.schema || typeof body.schema !== 'object')
36
+ throw new StackQueryError('schema is required');
37
+ if (!body.schemaHash || typeof body.schemaHash !== 'string')
38
+ throw new StackQueryError('schemaHash is required');
39
+
40
+ const computedHash = await hashSchema(body.schema as TypeSchema);
41
+ if (body.schemaHash !== computedHash)
42
+ throw new StackValidationError([
43
+ { path: 'schemaHash', message: 'schemaHash does not match schema' },
44
+ ]);
45
+
46
+ // stack.defineType() (not adapter.saveType() directly) is what runs the
47
+ // schema-drift check: redefining an existing typeId with anything
48
+ // beyond additive evolution throws StackSchemaDriftError, which
49
+ // adapter.saveType() alone has no way to enforce — it's a raw write.
50
+ const type = await stack.defineType(body.id, body.name, body.schema as TypeSchema, {
51
+ ...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}),
52
+ });
53
+ return c.json(serializeType(type), 201);
54
+ });
55
+
56
+ return app;
57
+ }
@@ -0,0 +1,55 @@
1
+ import { Hono } from 'hono';
2
+ import {
3
+ WIRE_PROTOCOL_VERSION,
4
+ AUTH_METHOD_DID_CHALLENGE,
5
+ CHANGE_TRANSPORT_SSE,
6
+ } from '@haverstack/wire-types';
7
+ import type { DiscoveryResponse } from '@haverstack/wire-types';
8
+ import type { AppEnv } from '../types.js';
9
+ import type { StackContext } from '../stack.js';
10
+ import type { Config } from '../config.js';
11
+
12
+ export function wellknownRoutes(ctx: StackContext, config: Config): Hono<AppEnv> {
13
+ const app = new Hono<AppEnv>();
14
+
15
+ app.get('/stack', (c) => {
16
+ const body: DiscoveryResponse = {
17
+ version: WIRE_PROTOCOL_VERSION,
18
+ entityId: ctx.stack.ownerEntityId,
19
+ timezone: ctx.stack.timezone,
20
+ // The server's own request-size ceilings are authoritative here,
21
+ // overriding the adapter's `null` — nothing at the storage layer
22
+ // imposes a limit, but this server enforces both. See
23
+ // docs/spec/wire-format.md § Discovery.
24
+ capabilities: {
25
+ ...ctx.stack.features,
26
+ limits: {
27
+ attachmentBytes: config.maxAttachmentBytes,
28
+ contentBytes: config.maxContentBytes,
29
+ },
30
+ },
31
+ // The DID challenge-response handshake is always implemented by this
32
+ // server, so it's always advertised — a client holding a DID
33
+ // credential learns at open() that there's a handshake to perform,
34
+ // rather than discovering it as a 404 partway through one.
35
+ auth: { methods: [AUTH_METHOD_DID_CHALLENGE] },
36
+ // Top-level rather than inside `capabilities`, which carries only
37
+ // what the `...ctx.stack.features` spread brings. An object rather
38
+ // than a boolean for the same reason `auth` is: the surface grows
39
+ // entries — another transport, batched frames — not more booleans.
40
+ //
41
+ // Both flags are literals with no override: GET /changes does resume
42
+ // and does honor `?include=record`, and a client acts on this answer
43
+ // without asking again. An override could only ever make discovery
44
+ // lie about the route beside it.
45
+ changes: {
46
+ transports: [CHANGE_TRANSPORT_SSE],
47
+ resume: true,
48
+ records: true,
49
+ },
50
+ };
51
+ return c.json(body);
52
+ });
53
+
54
+ return app;
55
+ }
@@ -0,0 +1,56 @@
1
+ import type { Logger } from 'pino';
2
+ import type { StackContext } from './stack.js';
3
+
4
+ /**
5
+ * The subset of node-server's return value shutdown() needs. `close()`
6
+ * mirrors Node's http.Server: it stops accepting new connections and waits
7
+ * for open ones to end before invoking the callback. `closeAllConnections()`
8
+ * (Node >=18.2) destroys every open connection immediately, which is what
9
+ * makes a pending close() callback fire once the grace period expires.
10
+ * Optional because node-server's `ServerType` also covers Http2Server, whose
11
+ * @types/node surface omits it even though the app only ever serves HTTP/1.
12
+ */
13
+ export type ShutdownServer = {
14
+ close(callback: () => void): void;
15
+ closeAllConnections?(): void;
16
+ };
17
+
18
+ /**
19
+ * Builds the shutdown sequence: stop the server (bounded by `timeoutMs` so a
20
+ * client holding a keep-alive connection open can't block it indefinitely),
21
+ * then release the stack's resources. Does not call process.exit — callers
22
+ * decide the exit code so this stays unit-testable.
23
+ */
24
+ export function createShutdownHandler(
25
+ server: ShutdownServer,
26
+ ctx: StackContext,
27
+ logger: Logger,
28
+ timeoutMs: number,
29
+ ): (signal: string) => Promise<void> {
30
+ return async (signal: string) => {
31
+ logger.info({ signal }, 'Shutting down');
32
+
33
+ // A change-feed stream never drains on its own, so server.close() below
34
+ // would wait out the full grace period every time. See
35
+ // src/lib/changeStreams.ts.
36
+ ctx.changeStreams.closeAll();
37
+
38
+ await new Promise<void>((resolve) => {
39
+ const timer = setTimeout(() => {
40
+ logger.warn({ timeoutMs }, 'Shutdown grace period exceeded; forcing connections closed');
41
+ server.closeAllConnections?.();
42
+ }, timeoutMs);
43
+ server.close(() => {
44
+ clearTimeout(timer);
45
+ resolve();
46
+ });
47
+ });
48
+
49
+ await ctx.queryWorker.close();
50
+ await ctx.stack.flush();
51
+ await ctx.stack.close();
52
+ await ctx.tokens.close();
53
+ ctx.nonces.close();
54
+ logger.info('Clean shutdown complete');
55
+ };
56
+ }
package/src/stack.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local';
2
+ import {
3
+ ARTICLE,
4
+ BOOKMARK,
5
+ CONTACT,
6
+ NOTE,
7
+ PAGE,
8
+ PHOTO,
9
+ PLACE,
10
+ TASK,
11
+ defineCommonsTypes,
12
+ } from '@haverstack/commons';
13
+ import { Stack } from '@haverstack/core';
14
+ import type { StackTokenStore } from '@haverstack/core/wire';
15
+ import type { Logger } from 'pino';
16
+ import type { Config } from './config.js';
17
+ import { AuthNonceStore, defaultNonceStorePath } from './lib/nonceStore.js';
18
+ import { QueryWorkerPool } from './lib/queryWorker/pool.js';
19
+ import { ChangeStreamRegistry } from './lib/changeStreams.js';
20
+
21
+ export type StackContext = {
22
+ adapter: LocalAdapter;
23
+ stack: Stack;
24
+ // Typed as the interface, not NativeTokenStore, so routes and middleware
25
+ // bind to the contract. close() is the one lifecycle hook every
26
+ // implementation needs and the core interface doesn't declare.
27
+ tokens: StackTokenStore & { close(): Promise<void> };
28
+ // DID challenge-response nonces — see AuthNonceStore. Also kept outside
29
+ // the portable stack file, in its own sibling store beside the tokens.
30
+ nonces: AuthNonceStore;
31
+ // Runs ScopedStack.query() off the request thread with a per-request
32
+ // deadline. See docs/spec/wire-format.md § Bounding query cost and
33
+ // src/lib/queryWorker/pool.ts.
34
+ queryWorker: QueryWorkerPool;
35
+ // Open GET /changes SSE connections, so shutdown can end them promptly
36
+ // rather than waiting out the drain timeout. See src/lib/changeStreams.ts.
37
+ changeStreams: ChangeStreamRegistry;
38
+ };
39
+
40
+ export async function initStack(config: Config, logger: Logger): Promise<StackContext> {
41
+ // openOrInitialize() decides between open and create without a TOCTOU
42
+ // gap. entityId goes in as a lazy provider so openOrInitialize()'s own
43
+ // owner-mismatch check, which throws, never runs on the open path —
44
+ // ENTITY_ID divergence is a warning below, not a failure — and so a
45
+ // missing ENTITY_ID is an error only when creating a database.
46
+ const adapter = await LocalAdapter.openOrInitialize({
47
+ path: config.dbPath,
48
+ entityId: () => {
49
+ if (!config.entityId) {
50
+ throw new Error(
51
+ 'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)',
52
+ );
53
+ }
54
+ return config.entityId;
55
+ },
56
+ timezone: config.timezone,
57
+ });
58
+
59
+ if (config.entityId && config.entityId !== adapter.ownerEntityId) {
60
+ logger.warn(
61
+ { configuredEntityId: config.entityId, storedOwnerEntityId: adapter.ownerEntityId },
62
+ "ENTITY_ID does not match the stack's stored owner; the configured value is ignored",
63
+ );
64
+ }
65
+
66
+ const stack = await Stack.create(
67
+ adapter,
68
+ config.ownerName
69
+ ? { ownerProfile: { name: config.ownerName, handle: config.ownerHandle ?? undefined } }
70
+ : undefined,
71
+ );
72
+
73
+ // Opt-in (SEED_COMMONS_TYPES). defineType() is idempotent — an identical
74
+ // schema is a no-op preserving createdAt — so this is safe every boot.
75
+ if (config.seedCommonsTypes) {
76
+ await defineCommonsTypes(stack, [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO]);
77
+ }
78
+
79
+ // Composed as a separate part rather than sniffed off the adapter: auth
80
+ // material lives in its own file beside stack.db (not inside the
81
+ // portable stack export) per docs/spec/wire-format.md § Authentication.
82
+ const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(config.dbPath) });
83
+ const nonces = AuthNonceStore.open(defaultNonceStorePath(config.dbPath));
84
+
85
+ // Spawned only once the adapter has opened and seeded stack.db: each
86
+ // worker opens its own connection to that same file, and idempotent
87
+ // seeding makes the race survivable rather than worth running.
88
+ const queryWorker = new QueryWorkerPool({
89
+ init: { dbPath: config.dbPath },
90
+ poolSize: config.queryWorkerPoolSize,
91
+ queueLimit: config.queryQueueLimit,
92
+ logger,
93
+ });
94
+
95
+ return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() };
96
+ }
package/src/types.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { TokenSession } from '@haverstack/core';
2
+
3
+ /** Hono context variable map shared across all route files. */
4
+ export type AppEnv = {
5
+ Variables: {
6
+ auth: TokenSession | null;
7
+ requestId: string;
8
+ };
9
+ };
@@ -0,0 +1,21 @@
1
+ import type { Context } from 'hono';
2
+ import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
+ import type { AppEnv } from './types.js';
4
+
5
+ /**
6
+ * `{ error: { code, message } }` responses for failures outside the core
7
+ * `StackError` taxonomy — thrown core errors instead flow through
8
+ * `errorMiddleware` via `serializeError()`. `code` is a server-local
9
+ * extension for these (not `WireErrorCode`): `unauthorized` for a missing
10
+ * or invalid bearer token has no core class, per
11
+ * docs/spec/wire-format.md § Error responses ("no verified DID behind the
12
+ * request at all" predates any Stack operation).
13
+ */
14
+ export function wireError(
15
+ c: Context<AppEnv>,
16
+ status: ContentfulStatusCode,
17
+ code: string,
18
+ message: string,
19
+ ): Response {
20
+ return c.json({ error: { code, message } }, status);
21
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { createApp } from '../src/app.js';
3
+ import { buildTestApp, testConfig, logger, req, TEST_TOKEN, type TestApp } from './setup.js';
4
+
5
+ const NOTE_TYPE_ID = 'com.example.test/note@1';
6
+
7
+ describe('request body size limit', () => {
8
+ let t: TestApp;
9
+ beforeEach(async () => {
10
+ t = await buildTestApp();
11
+ await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
12
+ body: { kind: 'text' as const, required: false as const },
13
+ });
14
+ });
15
+ afterEach(async () => {
16
+ await t.cleanup();
17
+ });
18
+
19
+ it('returns a conforming 413 for an oversized POST /records body', async () => {
20
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 100 }, logger);
21
+ const { status, data } = await req(smallApp, 'POST', '/records', {
22
+ token: TEST_TOKEN,
23
+ body: { typeId: NOTE_TYPE_ID, content: { body: 'x'.repeat(500) } },
24
+ });
25
+ expect(status).toBe(413);
26
+ expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
27
+ });
28
+
29
+ it('returns a conforming 413 for an oversized PATCH body', async () => {
30
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 5000 }, logger);
31
+ const created = await req(smallApp, 'POST', '/records', {
32
+ token: TEST_TOKEN,
33
+ body: { typeId: NOTE_TYPE_ID, content: { body: 'short' } },
34
+ });
35
+ const id = (created.data as { id: string }).id;
36
+
37
+ const { status, data } = await req(smallApp, 'PATCH', `/records/${id}`, {
38
+ token: TEST_TOKEN,
39
+ body: { contentPatch: { body: 'x'.repeat(6000) } },
40
+ });
41
+ expect(status).toBe(413);
42
+ expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
43
+ });
44
+
45
+ it('succeeds for a body just under the limit', async () => {
46
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 500 }, logger);
47
+ const { status } = await req(smallApp, 'POST', '/records', {
48
+ token: TEST_TOKEN,
49
+ body: { typeId: NOTE_TYPE_ID, content: { body: 'well under the limit' } },
50
+ });
51
+ expect(status).toBe(200);
52
+ });
53
+
54
+ it('fires before JSON parsing, not after', async () => {
55
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
56
+ const res = await smallApp.request('/records', {
57
+ method: 'POST',
58
+ headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' },
59
+ // Not valid JSON at all — if the limit ran after parsing, this would
60
+ // surface as a parse/bad_request failure instead of a clean 413.
61
+ body: 'not valid json, and also over the 10-byte limit',
62
+ });
63
+ expect(res.status).toBe(413);
64
+ const body = (await res.json()) as { error: { code: string } };
65
+ expect(body.error.code).toBe('payload_too_large');
66
+ });
67
+
68
+ it('applies to routes outside the original per-prefix list (e.g. POST /attachments/gc)', async () => {
69
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
70
+ const { status } = await req(smallApp, 'POST', '/attachments/gc', {
71
+ token: TEST_TOKEN,
72
+ body: { graceMs: 999999, dryRun: true },
73
+ });
74
+ expect(status).toBe(413);
75
+ });
76
+
77
+ it('excludes POST /attachments so its own, larger ceiling still applies', async () => {
78
+ const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
79
+ const res = await smallApp.request('/attachments', {
80
+ method: 'POST',
81
+ headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
82
+ body: 'x'.repeat(1000), // well over the 10-byte content limit, under maxAttachmentBytes
83
+ });
84
+ expect(res.status).toBe(200);
85
+ });
86
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * A client-side reader for the change feed's SSE wire format
3
+ * (docs/spec/wire-format.md § Change feed). Hono's own `hono/streaming`
4
+ * ships an SSE *writer* (`streamSSE`) but nothing to consume one — that
5
+ * half is what this module provides, so tests/conformance.test.ts can
6
+ * drive changeFeedFixtures: open a connection, decode frames as they
7
+ * arrive, dispatch the fixture's `activity` mutations while it is still
8
+ * open, and collect what the connection produces.
9
+ */
10
+ import type { Hono } from 'hono';
11
+ import type { AppEnv } from '../src/app.js';
12
+
13
+ export type DecodedFrame = {
14
+ /**
15
+ * SSE `id:`, when the wire line was present on *this* frame — not the
16
+ * persisted "last event ID" a spec-compliant EventSource client carries
17
+ * forward across events with no id of their own. changeFeedFixtures pins
18
+ * the literal per-frame wire content (a `reset` frame carries no id even
19
+ * immediately after a `record` frame that did), so this decoder resets
20
+ * per frame rather than inheriting.
21
+ */
22
+ id?: string;
23
+ event: string;
24
+ data: unknown;
25
+ };
26
+
27
+ /** Incremental SSE decoder: feed it raw chunks of the stream's text as they arrive. */
28
+ export class SSEDecoder {
29
+ private buffer = '';
30
+ private eventName: string | undefined;
31
+ private id: string | undefined;
32
+ private dataLines: string[] = [];
33
+ private sawField = false;
34
+
35
+ /** Decode one chunk, returning any frames it completed. */
36
+ push(text: string): DecodedFrame[] {
37
+ this.buffer += text;
38
+ const frames: DecodedFrame[] = [];
39
+ let newlineIndex: number;
40
+ while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
41
+ const rawLine = this.buffer.slice(0, newlineIndex);
42
+ this.buffer = this.buffer.slice(newlineIndex + 1);
43
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
44
+ if (line === '') {
45
+ const frame = this.dispatch();
46
+ if (frame) frames.push(frame);
47
+ continue;
48
+ }
49
+ if (line.startsWith(':')) continue; // comment line — a keepalive
50
+ const colonIndex = line.indexOf(':');
51
+ const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
52
+ let value = colonIndex === -1 ? '' : line.slice(colonIndex + 1);
53
+ if (value.startsWith(' ')) value = value.slice(1);
54
+ this.sawField = true;
55
+ if (field === 'id') this.id = value;
56
+ else if (field === 'event') this.eventName = value;
57
+ else if (field === 'data') this.dataLines.push(value);
58
+ // 'retry' and any other field: not needed here, ignored.
59
+ }
60
+ return frames;
61
+ }
62
+
63
+ private dispatch(): DecodedFrame | undefined {
64
+ if (!this.sawField) return undefined; // a blank line with nothing since the last dispatch
65
+ const event = this.eventName ?? 'message';
66
+ const id = this.id;
67
+ const dataText = this.dataLines.join('\n');
68
+ this.eventName = undefined;
69
+ this.id = undefined;
70
+ this.dataLines = [];
71
+ this.sawField = false;
72
+ let data: unknown = dataText;
73
+ if (dataText) {
74
+ try {
75
+ data = JSON.parse(dataText);
76
+ } catch {
77
+ // Leave as raw text — a fixture asserting on parsed JSON fails loudly.
78
+ }
79
+ }
80
+ return { ...(id !== undefined && { id }), event, data };
81
+ }
82
+ }
83
+
84
+ export type ChangeFeedConnection = {
85
+ status: number;
86
+ /** Frames decoded so far, in arrival order. Mutated in place as more arrive. */
87
+ frames: DecodedFrame[];
88
+ /** Resolves with the first `count` frames once they've arrived, or rejects after timeoutMs. */
89
+ waitForFrames(count: number, timeoutMs?: number): Promise<DecodedFrame[]>;
90
+ /** Cancels the underlying reader and stops decoding. */
91
+ close(): Promise<void>;
92
+ };
93
+
94
+ export type OpenChangeFeedOpts = {
95
+ token?: string;
96
+ /** Additional headers, e.g. Last-Event-ID. */
97
+ headers?: Record<string, string>;
98
+ };
99
+
100
+ /**
101
+ * Opens an SSE connection against a Hono test app. `app.request()` resolves
102
+ * as soon as the response's headers are sent — the body stays open and
103
+ * readable while the server keeps writing — so a mutation dispatched via
104
+ * the ordinary `req()` helper while a connection from this function is
105
+ * still open reaches the same in-process stack and can produce frames on
106
+ * it, exactly as changeFeedFixtures' `activity` expects.
107
+ */
108
+ export async function openChangeFeed(
109
+ app: Hono<AppEnv>,
110
+ path: string,
111
+ opts: OpenChangeFeedOpts = {},
112
+ ): Promise<ChangeFeedConnection> {
113
+ const headers: Record<string, string> = { Accept: 'text/event-stream' };
114
+ if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
115
+ Object.assign(headers, opts.headers);
116
+
117
+ const res = await app.request(path, { headers });
118
+ const reader = res.body?.getReader();
119
+ const decoder = new SSEDecoder();
120
+ const textDecoder = new TextDecoder();
121
+ const frames: DecodedFrame[] = [];
122
+ let closed = false;
123
+
124
+ const pump = (async () => {
125
+ if (!reader) return;
126
+ while (!closed) {
127
+ const { done, value } = await reader.read();
128
+ if (done) return;
129
+ frames.push(...decoder.push(textDecoder.decode(value, { stream: true })));
130
+ }
131
+ })();
132
+ // A rejected pump would otherwise surface as an unhandled rejection the
133
+ // moment close() cancels the reader mid-read.
134
+ pump.catch(() => {});
135
+
136
+ async function waitForFrames(count: number, timeoutMs = 2000): Promise<DecodedFrame[]> {
137
+ const start = Date.now();
138
+ while (frames.length < count) {
139
+ if (Date.now() - start > timeoutMs) {
140
+ throw new Error(
141
+ `Timed out after ${timeoutMs}ms waiting for ${count} frame(s); got ${frames.length}: ${JSON.stringify(frames)}`,
142
+ );
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 10));
145
+ }
146
+ return frames.slice(0, count);
147
+ }
148
+
149
+ async function close(): Promise<void> {
150
+ closed = true;
151
+ if (reader) await reader.cancel().catch(() => {});
152
+ await pump;
153
+ }
154
+
155
+ return { status: res.status, frames, waitForFrames, close };
156
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { loadConfig } from '../src/config.js';
3
+
4
+ const REQUIRED_ENV = {
5
+ DB_PATH: './stack.db',
6
+ OWNER_TOKEN: 'test-owner-token',
7
+ BASE_URL: 'https://stack.example.com',
8
+ };
9
+
10
+ describe('loadConfig', () => {
11
+ const savedEnv = { ...process.env };
12
+
13
+ beforeEach(() => {
14
+ for (const key of Object.keys(process.env)) delete process.env[key];
15
+ Object.assign(process.env, REQUIRED_ENV);
16
+ });
17
+
18
+ afterEach(() => {
19
+ for (const key of Object.keys(process.env)) delete process.env[key];
20
+ Object.assign(process.env, savedEnv);
21
+ });
22
+
23
+ it('accepts a well-formed DID as ENTITY_ID', () => {
24
+ process.env['ENTITY_ID'] = 'did:key:z6MkfooBar';
25
+ const config = loadConfig();
26
+ expect(config.entityId).toBe('did:key:z6MkfooBar');
27
+ });
28
+
29
+ it('rejects an ENTITY_ID that is not a DID', () => {
30
+ process.env['ENTITY_ID'] = 'not-a-did';
31
+ expect(() => loadConfig()).toThrow(/must be a DID/);
32
+ });
33
+
34
+ it('allows ENTITY_ID to be unset', () => {
35
+ const config = loadConfig();
36
+ expect(config.entityId).toBeNull();
37
+ });
38
+
39
+ it('reads OWNER_NAME and OWNER_HANDLE when set', () => {
40
+ process.env['OWNER_NAME'] = 'Jane';
41
+ process.env['OWNER_HANDLE'] = '@jane';
42
+ const config = loadConfig();
43
+ expect(config.ownerName).toBe('Jane');
44
+ expect(config.ownerHandle).toBe('@jane');
45
+ });
46
+
47
+ it('defaults OWNER_NAME and OWNER_HANDLE to null', () => {
48
+ const config = loadConfig();
49
+ expect(config.ownerName).toBeNull();
50
+ expect(config.ownerHandle).toBeNull();
51
+ });
52
+
53
+ it('leaves timezone undefined when TIMEZONE is unset', () => {
54
+ const config = loadConfig();
55
+ expect(config.timezone).toBeUndefined();
56
+ });
57
+
58
+ it('reads TIMEZONE when set', () => {
59
+ process.env['TIMEZONE'] = 'America/New_York';
60
+ const config = loadConfig();
61
+ expect(config.timezone).toBe('America/New_York');
62
+ });
63
+
64
+ it('defaults maxContentBytes to 1 MB', () => {
65
+ const config = loadConfig();
66
+ expect(config.maxContentBytes).toBe(1 * 1024 * 1024);
67
+ });
68
+
69
+ it('reads MAX_CONTENT_BYTES when set', () => {
70
+ process.env['MAX_CONTENT_BYTES'] = '2048';
71
+ const config = loadConfig();
72
+ expect(config.maxContentBytes).toBe(2048);
73
+ });
74
+
75
+ it('rejects an invalid MAX_CONTENT_BYTES', () => {
76
+ process.env['MAX_CONTENT_BYTES'] = 'not-a-number';
77
+ expect(() => loadConfig()).toThrow(/Invalid MAX_CONTENT_BYTES/);
78
+ });
79
+
80
+ it('refuses to start when BASE_URL is unset', () => {
81
+ delete process.env['BASE_URL'];
82
+ expect(() => loadConfig()).toThrow(/Missing required environment variable: BASE_URL/);
83
+ });
84
+
85
+ it('rejects a BASE_URL with no origin to bind a signature to', () => {
86
+ process.env['BASE_URL'] = 'not-a-url';
87
+ expect(() => loadConfig()).toThrow(/BASE_URL must be an absolute URL/);
88
+ });
89
+
90
+ it('derives authOrigin from BASE_URL', () => {
91
+ process.env['BASE_URL'] = 'https://stack.example.com/some/path';
92
+ const config = loadConfig();
93
+ expect(config.baseUrl).toBe('https://stack.example.com/some/path');
94
+ expect(config.authOrigin).toBe('https://stack.example.com');
95
+ });
96
+
97
+ it('defaults shutdownTimeoutMs to 10s', () => {
98
+ const config = loadConfig();
99
+ expect(config.shutdownTimeoutMs).toBe(10_000);
100
+ });
101
+
102
+ it('reads SHUTDOWN_TIMEOUT_MS when set', () => {
103
+ process.env['SHUTDOWN_TIMEOUT_MS'] = '5000';
104
+ const config = loadConfig();
105
+ expect(config.shutdownTimeoutMs).toBe(5000);
106
+ });
107
+
108
+ it('rejects an invalid SHUTDOWN_TIMEOUT_MS', () => {
109
+ process.env['SHUTDOWN_TIMEOUT_MS'] = 'not-a-number';
110
+ expect(() => loadConfig()).toThrow(/Invalid SHUTDOWN_TIMEOUT_MS/);
111
+ });
112
+ });