@haverstack/server 0.8.0 → 0.9.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.
- package/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +70 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changeStreams.d.ts +17 -0
- package/dist/lib/changeStreams.d.ts.map +1 -0
- package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
- package/dist/lib/changeStreams.js.map +1 -0
- package/dist/lib/frameGate.d.ts +26 -0
- package/dist/lib/frameGate.d.ts.map +1 -0
- package/dist/lib/frameGate.js +44 -0
- package/dist/lib/frameGate.js.map +1 -0
- package/dist/lib/json.d.ts +10 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +27 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/lib/nonceStore.d.ts +50 -0
- package/dist/lib/nonceStore.d.ts.map +1 -0
- package/dist/lib/nonceStore.js +96 -0
- package/dist/lib/nonceStore.js.map +1 -0
- package/dist/lib/queryLimit.d.ts +4 -0
- package/dist/lib/queryLimit.d.ts.map +1 -0
- package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
- package/dist/lib/queryLimit.js.map +1 -0
- package/dist/lib/queryWorker/pool.d.ts +60 -0
- package/dist/lib/queryWorker/pool.d.ts.map +1 -0
- package/dist/lib/queryWorker/pool.js +278 -0
- package/dist/lib/queryWorker/pool.js.map +1 -0
- package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
- package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
- package/dist/lib/queryWorker/protocol.js +2 -0
- package/dist/lib/queryWorker/protocol.js.map +1 -0
- package/dist/lib/queryWorker/worker.d.ts +2 -0
- package/dist/lib/queryWorker/worker.d.ts.map +1 -0
- package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
- package/dist/lib/queryWorker/worker.js.map +1 -0
- package/dist/lib/resumeBuffer.d.ts +143 -0
- package/dist/lib/resumeBuffer.d.ts.map +1 -0
- package/dist/lib/resumeBuffer.js +216 -0
- package/dist/lib/resumeBuffer.js.map +1 -0
- package/dist/lib/resumeCursor.d.ts +16 -0
- package/dist/lib/resumeCursor.d.ts.map +1 -0
- package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
- package/dist/lib/resumeCursor.js.map +1 -0
- package/dist/lib/tokenSweep.d.ts +17 -0
- package/dist/lib/tokenSweep.d.ts.map +1 -0
- package/dist/lib/tokenSweep.js +40 -0
- package/dist/lib/tokenSweep.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/middleware/auth.d.ts +25 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +74 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/errors.d.ts +17 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +64 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/routes/attachments.d.ts +5 -0
- package/dist/routes/attachments.d.ts.map +1 -0
- package/dist/routes/attachments.js +104 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +110 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/changes.d.ts +22 -0
- package/dist/routes/changes.d.ts.map +1 -0
- package/dist/routes/changes.js +233 -0
- package/dist/routes/changes.js.map +1 -0
- package/dist/routes/entity.d.ts +5 -0
- package/dist/routes/entity.d.ts.map +1 -0
- package/dist/routes/entity.js +30 -0
- package/dist/routes/entity.js.map +1 -0
- package/dist/routes/health.d.ts +4 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +7 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/records.d.ts +5 -0
- package/dist/routes/records.d.ts.map +1 -0
- package/dist/routes/records.js +231 -0
- package/dist/routes/records.js.map +1 -0
- package/dist/routes/tokens.d.ts +5 -0
- package/dist/routes/tokens.d.ts.map +1 -0
- package/dist/routes/tokens.js +65 -0
- package/dist/routes/tokens.js.map +1 -0
- package/dist/routes/types.d.ts +5 -0
- package/dist/routes/types.d.ts.map +1 -0
- package/dist/routes/types.js +46 -0
- package/dist/routes/types.js.map +1 -0
- package/dist/routes/wellknown.d.ts +6 -0
- package/dist/routes/wellknown.d.ts.map +1 -0
- package/dist/routes/wellknown.js +45 -0
- package/dist/routes/wellknown.js.map +1 -0
- package/dist/shutdown.d.ts +23 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +32 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/stack.d.ts +20 -0
- package/dist/stack.d.ts.map +1 -0
- package/dist/stack.js +50 -0
- package/dist/stack.js.map +1 -0
- package/dist/testing.d.ts +49 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +130 -0
- package/dist/testing.js.map +1 -0
- package/{src/types.ts → dist/types.d.ts} +5 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
- package/dist/wireError.d.ts.map +1 -0
- package/dist/wireError.js +13 -0
- package/dist/wireError.js.map +1 -0
- package/package.json +37 -12
- package/.claude/settings.local.json +0 -22
- package/.dockerignore +0 -10
- package/.env.example +0 -90
- package/.github/pull_request_template.md +0 -23
- package/.github/workflows/ci.yml +0 -55
- package/.github/workflows/release.yml +0 -99
- package/.prettierignore +0 -3
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -91
- package/CONTRIBUTING.md +0 -234
- package/Dockerfile +0 -30
- package/SECURITY.md +0 -54
- package/docs/api.md +0 -277
- package/docs/deployment.md +0 -212
- package/eslint.config.mjs +0 -18
- package/pnpm-workspace.yaml +0 -13
- package/src/app.ts +0 -86
- package/src/config.ts +0 -176
- package/src/index.ts +0 -54
- package/src/lib/frameGate.ts +0 -42
- package/src/lib/json.ts +0 -27
- package/src/lib/nonceStore.ts +0 -103
- package/src/lib/queryWorker/pool.ts +0 -324
- package/src/lib/resumeBuffer.ts +0 -268
- package/src/lib/tokenSweep.ts +0 -43
- package/src/middleware/auth.ts +0 -77
- package/src/middleware/errors.ts +0 -73
- package/src/routes/attachments.ts +0 -126
- package/src/routes/auth.ts +0 -137
- package/src/routes/changes.ts +0 -279
- package/src/routes/entity.ts +0 -33
- package/src/routes/health.ts +0 -8
- package/src/routes/records.ts +0 -259
- package/src/routes/tokens.ts +0 -85
- package/src/routes/types.ts +0 -57
- package/src/routes/wellknown.ts +0 -55
- package/src/shutdown.ts +0 -56
- package/src/stack.ts +0 -96
- package/tests/app.test.ts +0 -86
- package/tests/changeFeedClient.ts +0 -156
- package/tests/config.test.ts +0 -112
- package/tests/conformance.test.ts +0 -2322
- package/tests/fixtures/blockingWorker.ts +0 -27
- package/tests/fixtures/brokenWorker.ts +0 -7
- package/tests/lib/changeFeedClient.test.ts +0 -126
- package/tests/lib/frameGate.test.ts +0 -93
- package/tests/lib/queryLimit.test.ts +0 -33
- package/tests/lib/queryWorker.test.ts +0 -60
- package/tests/lib/queryWorkerPool.test.ts +0 -129
- package/tests/lib/resumeBuffer.test.ts +0 -341
- package/tests/lib/resumeCursor.test.ts +0 -46
- package/tests/middleware/errors.test.ts +0 -176
- package/tests/routes/associations.test.ts +0 -170
- package/tests/routes/attachments.test.ts +0 -621
- package/tests/routes/auth.test.ts +0 -448
- package/tests/routes/changes.test.ts +0 -567
- package/tests/routes/contentQuery.test.ts +0 -68
- package/tests/routes/entity.test.ts +0 -170
- package/tests/routes/groups.test.ts +0 -69
- package/tests/routes/records.test.ts +0 -1965
- package/tests/routes/relatedToQuery.test.ts +0 -182
- package/tests/routes/tokens.test.ts +0 -157
- package/tests/routes/tombstones.test.ts +0 -93
- package/tests/routes/types.test.ts +0 -129
- package/tests/routes/versions.test.ts +0 -177
- package/tests/routes/wellknown.test.ts +0 -71
- package/tests/setup.ts +0 -154
- package/tests/shutdown.test.ts +0 -121
- package/tests/stack.test.ts +0 -198
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -19
- 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
|
-
});
|
package/src/lib/frameGate.ts
DELETED
|
@@ -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
|
-
}
|
package/src/lib/nonceStore.ts
DELETED
|
@@ -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
|
-
}
|