@haverstack/server 0.8.0 → 0.9.1

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 (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +108 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +42 -17
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
package/src/app.ts DELETED
@@ -1,86 +0,0 @@
1
- import { Hono } from 'hono';
2
- import { cors } from 'hono/cors';
3
- import { bodyLimit } from 'hono/body-limit';
4
- import { StackPayloadTooLargeError } from '@haverstack/core';
5
- import type { Logger } from 'pino';
6
- import type { StackContext } from './stack.js';
7
- import type { Config } from './config.js';
8
- import type { AppEnv } from './types.js';
9
- import { authMiddleware } from './middleware/auth.js';
10
- import { errorMiddleware } from './middleware/errors.js';
11
- import { wellknownRoutes } from './routes/wellknown.js';
12
- import { healthRoutes } from './routes/health.js';
13
- import { recordRoutes } from './routes/records.js';
14
- import { changeRoutes } from './routes/changes.js';
15
- import { typeRoutes } from './routes/types.js';
16
- import { attachmentRoutes } from './routes/attachments.js';
17
- import { entityRoutes } from './routes/entity.js';
18
- import { tokenRoutes } from './routes/tokens.js';
19
- import { authRoutes } from './routes/auth.js';
20
- import { wireError } from './wireError.js';
21
-
22
- export type { AppEnv };
23
-
24
- export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono<AppEnv> {
25
- const app = new Hono<AppEnv>();
26
-
27
- // Assign a unique request ID to every request and expose it on the response.
28
- app.use(async (c, next) => {
29
- const id = crypto.randomUUID();
30
- c.set('requestId', id);
31
- await next();
32
- c.header('X-Request-Id', id);
33
- });
34
-
35
- app.use(
36
- cors({
37
- origin:
38
- config.corsOrigins === '*'
39
- ? '*'
40
- : config.corsOrigins
41
- ? config.corsOrigins.split(',').map((s) => s.trim())
42
- : [],
43
- allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
44
- // Last-Event-ID: a browser resuming GET /changes sends it as a plain
45
- // header (fetch, not EventSource, per the feed's no-token-in-the-URL
46
- // rule). Omitted here, a cross-origin resume never reaches the route
47
- // that would answer it with `reset`.
48
- allowHeaders: ['Authorization', 'Content-Type', 'Content-Disposition', 'Last-Event-ID'],
49
- exposeHeaders: ['X-Request-Id', 'Content-Disposition'],
50
- }),
51
- );
52
- app.onError(errorMiddleware(logger, ctx.stack));
53
- app.use(authMiddleware(config.ownerToken, ctx));
54
-
55
- // Global rather than per-prefix, so a route added later can't end up with
56
- // no limit at all. POST /attachments is the one exception: it enforces its
57
- // own, larger ceiling, and running this one first would reject legitimate
58
- // uploads before that check ran. Thrown as a StackError so
59
- // errorMiddleware produces the conforming body, like any other failure.
60
- const jsonBodyLimit = bodyLimit({
61
- maxSize: config.maxContentBytes,
62
- onError: () => {
63
- throw new StackPayloadTooLargeError(
64
- `Request body exceeds the ${config.maxContentBytes}-byte limit`,
65
- );
66
- },
67
- });
68
- app.use('*', async (c, next) => {
69
- if (c.req.method === 'POST' && c.req.path === '/attachments') return next();
70
- return jsonBodyLimit(c, next);
71
- });
72
-
73
- app.route('/.well-known', wellknownRoutes(ctx, config));
74
- app.route('/health', healthRoutes());
75
- app.route('/records', recordRoutes(ctx, config.queryTimeoutMs));
76
- app.route('/changes', changeRoutes(ctx, config, logger));
77
- app.route('/types', typeRoutes(ctx));
78
- app.route('/attachments', attachmentRoutes(ctx, config.maxAttachmentBytes));
79
- app.route('/entity', entityRoutes(ctx));
80
- app.route('/tokens', tokenRoutes(ctx));
81
- app.route('/auth', authRoutes(ctx, config.authOrigin, logger));
82
-
83
- app.notFound((c) => wireError(c, 404, 'not_found', 'Not found'));
84
-
85
- return app;
86
- }
package/src/config.ts DELETED
@@ -1,176 +0,0 @@
1
- import { isValidDid } from '@haverstack/core/did';
2
- import { authOriginFromUrl } from '@haverstack/core/wire';
3
-
4
- const DEFAULT_MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024; // 50 MB
5
- const DEFAULT_MAX_CONTENT_BYTES = 1 * 1024 * 1024; // 1 MB
6
- const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
7
- const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
8
- const DEFAULT_QUERY_WORKER_POOL_SIZE = 2;
9
- // Each worker is a thread plus its own SQLite connection to the same file,
10
- // so an accidental extra zero here is expensive in a way the other limits
11
- // aren't. The ceiling is a sanity bound, not a tuning recommendation.
12
- const MAX_QUERY_WORKER_POOL_SIZE = 32;
13
- const DEFAULT_QUERY_QUEUE_LIMIT = 64;
14
- const MAX_QUERY_QUEUE_LIMIT = 10_000;
15
-
16
- function required(name: string): string {
17
- const val = process.env[name];
18
- if (!val) throw new Error(`Missing required environment variable: ${name}`);
19
- return val;
20
- }
21
-
22
- function optional(name: string, fallback: string): string {
23
- return process.env[name] ?? fallback;
24
- }
25
-
26
- export type Config = {
27
- port: number;
28
- dbPath: string;
29
- entityId: string | null;
30
- ownerName: string | null;
31
- ownerHandle: string | null;
32
- timezone: string | undefined;
33
- ownerToken: string;
34
- corsOrigins: string;
35
- baseUrl: string;
36
- authOrigin: string;
37
- maxAttachmentBytes: number;
38
- maxContentBytes: number;
39
- queryTimeoutMs: number;
40
- queryWorkerPoolSize: number;
41
- queryQueueLimit: number;
42
- seedCommonsTypes: boolean;
43
- shutdownTimeoutMs: number;
44
- };
45
-
46
- export function loadConfig(): Config {
47
- const dbPath = required('DB_PATH');
48
-
49
- const entityId = process.env['ENTITY_ID'] ?? null;
50
- if (entityId && !isValidDid(entityId)) {
51
- throw new Error(
52
- `ENTITY_ID must be a DID (e.g. "did:key:z6Mk..."), got: "${entityId}". ` +
53
- 'See docs/spec/identity.md for how to generate one.',
54
- );
55
- }
56
- const ownerName = process.env['OWNER_NAME'] ?? null;
57
- const ownerHandle = process.env['OWNER_HANDLE'] ?? null;
58
- // No default: an absent timezone stays undefined end to end rather than
59
- // asserting knowledge the stack was never actually given. See
60
- // docs/spec/wire-format.md § Discovery.
61
- const timezone = process.env['TIMEZONE'] ?? undefined;
62
-
63
- const port = parseInt(optional('PORT', '3000'), 10);
64
- if (isNaN(port) || port < 1 || port > 65535) {
65
- throw new Error(`Invalid PORT: ${process.env['PORT']}`);
66
- }
67
-
68
- const maxAttachmentBytes = parseInt(
69
- optional('MAX_ATTACHMENT_BYTES', String(DEFAULT_MAX_ATTACHMENT_BYTES)),
70
- 10,
71
- );
72
- if (isNaN(maxAttachmentBytes) || maxAttachmentBytes < 1) {
73
- throw new Error(`Invalid MAX_ATTACHMENT_BYTES: ${process.env['MAX_ATTACHMENT_BYTES']}`);
74
- }
75
-
76
- const maxContentBytes = parseInt(
77
- optional('MAX_CONTENT_BYTES', String(DEFAULT_MAX_CONTENT_BYTES)),
78
- 10,
79
- );
80
- if (isNaN(maxContentBytes) || maxContentBytes < 1) {
81
- throw new Error(`Invalid MAX_CONTENT_BYTES: ${process.env['MAX_CONTENT_BYTES']}`);
82
- }
83
-
84
- // Bounds a query *executing* on the worker pool, never its wait for a
85
- // slot — that is QUERY_QUEUE_LIMIT's job, so ordinary contention makes a
86
- // search slow rather than failed. See src/lib/queryWorker/pool.ts.
87
- const queryTimeoutMs = parseInt(
88
- optional('QUERY_TIMEOUT_MS', String(DEFAULT_QUERY_TIMEOUT_MS)),
89
- 10,
90
- );
91
- if (isNaN(queryTimeoutMs) || queryTimeoutMs < 1) {
92
- throw new Error(`Invalid QUERY_TIMEOUT_MS: ${process.env['QUERY_TIMEOUT_MS']}`);
93
- }
94
-
95
- const queryWorkerPoolSize = parseInt(
96
- optional('QUERY_WORKER_POOL_SIZE', String(DEFAULT_QUERY_WORKER_POOL_SIZE)),
97
- 10,
98
- );
99
- if (
100
- isNaN(queryWorkerPoolSize) ||
101
- queryWorkerPoolSize < 1 ||
102
- queryWorkerPoolSize > MAX_QUERY_WORKER_POOL_SIZE
103
- ) {
104
- throw new Error(
105
- `Invalid QUERY_WORKER_POOL_SIZE: ${process.env['QUERY_WORKER_POOL_SIZE']} ` +
106
- `(expected 1-${MAX_QUERY_WORKER_POOL_SIZE})`,
107
- );
108
- }
109
-
110
- // How many searches may wait for a worker before the pool sheds load
111
- // with 503 `timeout` instead of queueing further. This is what keeps
112
- // "slow under contention" from becoming an unbounded queue.
113
- const queryQueueLimit = parseInt(
114
- optional('QUERY_QUEUE_LIMIT', String(DEFAULT_QUERY_QUEUE_LIMIT)),
115
- 10,
116
- );
117
- if (isNaN(queryQueueLimit) || queryQueueLimit < 1 || queryQueueLimit > MAX_QUERY_QUEUE_LIMIT) {
118
- throw new Error(
119
- `Invalid QUERY_QUEUE_LIMIT: ${process.env['QUERY_QUEUE_LIMIT']} ` +
120
- `(expected 1-${MAX_QUERY_QUEUE_LIMIT})`,
121
- );
122
- }
123
-
124
- // How long shutdown() lets in-flight and keep-alive connections drain
125
- // before forcing them closed and finishing the sequence anyway.
126
- const shutdownTimeoutMs = parseInt(
127
- optional('SHUTDOWN_TIMEOUT_MS', String(DEFAULT_SHUTDOWN_TIMEOUT_MS)),
128
- 10,
129
- );
130
- if (isNaN(shutdownTimeoutMs) || shutdownTimeoutMs < 1) {
131
- throw new Error(`Invalid SHUTDOWN_TIMEOUT_MS: ${process.env['SHUTDOWN_TIMEOUT_MS']}`);
132
- }
133
-
134
- // Opt-in: @haverstack/commons is Draft status, so its definitions may
135
- // change in place, and a registered type shows up in GET /types and every
136
- // app's type cache whether or not that app wants it. See
137
- // docs/deployment.md § Schema Commons seeding.
138
- const seedCommonsTypes = optional('SEED_COMMONS_TYPES', 'false') === 'true';
139
-
140
- // Required rather than auto-detected: the handshake signs a payload
141
- // scoped to this server's public origin, and taking that from a
142
- // client-controlled header (Host, X-Forwarded-Host) would let a client
143
- // pick the origin it signs for, reopening the relay attack the binding
144
- // closes. See docs/spec/wire-format.md § Authentication.
145
- const baseUrl = required('BASE_URL');
146
- let authOrigin: string;
147
- try {
148
- authOrigin = authOriginFromUrl(baseUrl);
149
- } catch (err) {
150
- throw new Error(
151
- `BASE_URL must be an absolute URL with an origin (e.g. "https://stack.example.com"), got: "${baseUrl}". ` +
152
- (err instanceof Error ? err.message : String(err)),
153
- { cause: err },
154
- );
155
- }
156
-
157
- return {
158
- port,
159
- dbPath,
160
- entityId,
161
- ownerName,
162
- ownerHandle,
163
- timezone,
164
- ownerToken: required('OWNER_TOKEN'),
165
- corsOrigins: optional('CORS_ORIGINS', ''),
166
- baseUrl,
167
- authOrigin,
168
- maxAttachmentBytes,
169
- maxContentBytes,
170
- queryTimeoutMs,
171
- queryWorkerPoolSize,
172
- queryQueueLimit,
173
- seedCommonsTypes,
174
- shutdownTimeoutMs,
175
- };
176
- }
package/src/index.ts DELETED
@@ -1,54 +0,0 @@
1
- import { serve } from '@hono/node-server';
2
- import pino from 'pino';
3
- import { loadConfig } from './config.js';
4
- import { initStack, type StackContext } from './stack.js';
5
- import { createApp } from './app.js';
6
- import { createShutdownHandler } from './shutdown.js';
7
-
8
- const logger = pino({
9
- level: process.env['LOG_LEVEL'] ?? 'info',
10
- transport:
11
- process.env['NODE_ENV'] !== 'production'
12
- ? { target: 'pino-pretty', options: { colorize: true } }
13
- : undefined,
14
- });
15
-
16
- // Set once initStack() resolves, so the fatal-error handler below can flush
17
- // a crash that happens after startup. Undefined before then: nothing to
18
- // flush yet.
19
- let ctx: StackContext | undefined;
20
-
21
- async function main() {
22
- const config = loadConfig();
23
- ctx = await initStack(config, logger);
24
- const app = createApp(ctx, config, logger);
25
-
26
- logger.info({ dbPath: config.dbPath }, 'Stack initialized');
27
-
28
- const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
29
- logger.info({ port: info.port }, 'Server listening');
30
- });
31
-
32
- const shutdown = createShutdownHandler(server, ctx, logger, config.shutdownTimeoutMs);
33
- const onSignal = (signal: string) => {
34
- shutdown(signal)
35
- .then(() => process.exit(0))
36
- .catch((err) => {
37
- logger.error({ err }, 'Error during shutdown');
38
- process.exit(1);
39
- });
40
- };
41
-
42
- process.on('SIGTERM', () => onSignal('SIGTERM'));
43
- process.on('SIGINT', () => onSignal('SIGINT'));
44
- }
45
-
46
- main().catch(async (err) => {
47
- logger.error({ err }, 'Fatal startup error');
48
- if (ctx) {
49
- await ctx.stack.flush().catch((flushErr) => {
50
- logger.error({ err: flushErr }, 'Failed to flush during fatal-error shutdown');
51
- });
52
- }
53
- process.exit(1);
54
- });
@@ -1,42 +0,0 @@
1
- /**
2
- * Bounds a change-feed connection's in-flight frame count. Each accepted
3
- * send is paired with the write that delivers it; once `maxPending` sends
4
- * are outstanding at once — a client that isn't draining as fast as
5
- * changes arrive — the gate reports overflow instead of queuing further
6
- * sends without bound.
7
- *
8
- * Close on overflow, never drop a frame silently: a client that can't tell
9
- * it missed something can't repair it either, so dropping is worse than
10
- * disconnecting and letting it reconnect and reconcile.
11
- */
12
- export class FrameGate {
13
- private pending = 0;
14
- private overflowed = false;
15
-
16
- constructor(
17
- private readonly maxPending: number,
18
- private readonly onOverflow: () => void,
19
- ) {}
20
-
21
- /**
22
- * Accepts one send if there's room, invoking `write` and tracking it
23
- * until it settles. Returns whether it was accepted; a `false` return
24
- * means this call (and every one after it) triggered — or already
25
- * followed — overflow, and `onOverflow` has fired at most once.
26
- */
27
- send(write: () => Promise<unknown>): boolean {
28
- if (this.overflowed) return false;
29
- if (this.pending >= this.maxPending) {
30
- this.overflowed = true;
31
- this.onOverflow();
32
- return false;
33
- }
34
- this.pending++;
35
- write()
36
- .catch(() => {})
37
- .finally(() => {
38
- this.pending--;
39
- });
40
- return true;
41
- }
42
- }
package/src/lib/json.ts DELETED
@@ -1,27 +0,0 @@
1
- import type { Context } from 'hono';
2
- import { StackQueryError } from '@haverstack/core';
3
- import type { AppEnv } from '../types.js';
4
-
5
- /**
6
- * `c.req.json()` throws a bare `SyntaxError` on malformed input, which isn't
7
- * a `StackError` — left uncaught it falls through errorMiddleware's
8
- * catch-all as an unlabeled 500 instead of the 400 `bad_request` every other
9
- * structurally-invalid request gets (docs/spec/wire-format.md § 400 vs 422).
10
- */
11
- export async function readJson<T = unknown>(c: Context<AppEnv>): Promise<T> {
12
- let parsed: unknown;
13
- try {
14
- parsed = await c.req.json<T>();
15
- } catch (err) {
16
- if (err instanceof SyntaxError) throw new StackQueryError('Invalid JSON in request body');
17
- throw err;
18
- }
19
- // `null`, a bare string or a number parses fine, but every call site
20
- // indexes fields off the result, so a non-object reaches the handler and
21
- // throws a bare TypeError — another unlabeled 500, unauthenticated on the
22
- // /auth routes. Structurally invalid like malformed JSON, so a 400 too.
23
- if (parsed === null || typeof parsed !== 'object') {
24
- throw new StackQueryError('Request body must be a JSON object');
25
- }
26
- return parsed as T;
27
- }
@@ -1,103 +0,0 @@
1
- import { DatabaseSync } from 'node:sqlite';
2
- import { base64urlEncode } from '@haverstack/core/wire';
3
-
4
- // Matches the ~5-minute issued→expires gap in the handshake conformance
5
- // fixtures (@haverstack/conformance-fixtures' authChallengeFixtures).
6
- const NONCE_TTL_MS = 5 * 60 * 1000;
7
- // 18 raw bytes base64url-encodes to 24 characters — short, opaque, and
8
- // comfortably unguessable for a value that's live for minutes, not stored
9
- // long-term like a token.
10
- const NONCE_BYTES = 18;
11
-
12
- /** Conventional sibling path for the nonce store beside a stack's main .db file. */
13
- export const defaultNonceStorePath = (dbPath: string): string => `${dbPath}.nonces`;
14
-
15
- export type NonceRedeemResult = 'ok' | 'unknown' | 'expired';
16
-
17
- /**
18
- * Single-use, DID-bound nonces for the DID challenge–response handshake
19
- * (POST /auth/challenge, POST /auth/token). Core verifies signatures but
20
- * deliberately doesn't run a server, so nonce storage, single-use
21
- * enforcement, and DID binding are this server's own to implement — see
22
- * docs/spec/wire-format.md § Authentication.
23
- *
24
- * Kept beside the token store (its own sibling file) rather than in the
25
- * portable stack file or in memory, so a restart doesn't silently make
26
- * every in-flight handshake unrecoverable. node:sqlite, like
27
- * NativeTokenStore — no extra dependency.
28
- */
29
- export class AuthNonceStore {
30
- private constructor(private readonly db: DatabaseSync) {}
31
-
32
- static open(path: string): AuthNonceStore {
33
- const db = new DatabaseSync(path);
34
- db.exec('PRAGMA journal_mode = WAL;');
35
- db.exec(
36
- `CREATE TABLE IF NOT EXISTS nonces (
37
- nonce TEXT PRIMARY KEY,
38
- did TEXT NOT NULL,
39
- expiresAt INTEGER NOT NULL
40
- );`,
41
- );
42
- // sweepExpired() runs on every issue(), and POST /auth/challenge is
43
- // unauthenticated — without this the sweep is a full table scan whose
44
- // cost rises with the issue rate, so the endpoint would pay more the
45
- // harder it is hit.
46
- db.exec('CREATE INDEX IF NOT EXISTS idx_nonces_expiresAt ON nonces (expiresAt);');
47
- return new AuthNonceStore(db);
48
- }
49
-
50
- issue(did: string): { nonce: string; expiresAt: Date } {
51
- this.sweepExpired();
52
- const nonce = base64urlEncode(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
53
- const expiresAt = new Date(Date.now() + NONCE_TTL_MS);
54
- this.db
55
- .prepare('INSERT INTO nonces (nonce, did, expiresAt) VALUES (?, ?, ?)')
56
- .run(nonce, did, expiresAt.getTime());
57
- return { nonce, expiresAt };
58
- }
59
-
60
- /**
61
- * Checks and spends a nonce in one step — single-use means a redemption
62
- * consumes it regardless of outcome, not merely observes it. A missing
63
- * nonce and one bound to a different DID both report 'unknown': a server
64
- * MUST NOT let a caller distinguish never-issued from already-spent, or
65
- * spent-by-someone-else, since the difference only tells an attacker
66
- * something.
67
- *
68
- * One `DELETE ... RETURNING` rather than a SELECT then a DELETE, which
69
- * would be indivisible only for as long as nothing awaits between the two
70
- * statements — an invariant no reader can see. The `did` predicate sits in
71
- * the WHERE clause, so a mismatch deletes nothing.
72
- */
73
- redeem(did: string, nonce: string): NonceRedeemResult {
74
- const row = this.db
75
- .prepare('DELETE FROM nonces WHERE nonce = ? AND did = ? RETURNING expiresAt')
76
- .get(nonce, did) as { expiresAt: number } | undefined;
77
- if (!row) return 'unknown';
78
- if (Date.now() > row.expiresAt) return 'expired';
79
- return 'ok';
80
- }
81
-
82
- /**
83
- * Seeds a nonce with a caller-chosen value rather than a random one.
84
- * Production code never needs this — issue() is the only way a nonce is
85
- * meant to come into existence — but a conformance fixture carrying a
86
- * real signature is signed over one exact, fixed nonce value, so tests
87
- * need a way to get that value into the store directly. Named for what
88
- * it is so no production call site can reach for it without saying so.
89
- */
90
- seedForTesting(nonce: string, did: string, expiresAt: Date): void {
91
- this.db
92
- .prepare('INSERT OR REPLACE INTO nonces (nonce, did, expiresAt) VALUES (?, ?, ?)')
93
- .run(nonce, did, expiresAt.getTime());
94
- }
95
-
96
- private sweepExpired(): void {
97
- this.db.prepare('DELETE FROM nonces WHERE expiresAt < ?').run(Date.now());
98
- }
99
-
100
- close(): void {
101
- this.db.close();
102
- }
103
- }