@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.
- package/.claude/settings.local.json +22 -0
- package/.dockerignore +10 -0
- package/.env.example +90 -0
- package/.github/pull_request_template.md +23 -0
- package/.github/workflows/ci.yml +55 -0
- package/.github/workflows/release.yml +99 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +91 -0
- package/CONTRIBUTING.md +234 -0
- package/Dockerfile +30 -0
- package/LICENSE +121 -0
- package/README.md +107 -0
- package/SECURITY.md +54 -0
- package/docs/api.md +277 -0
- package/docs/deployment.md +212 -0
- package/eslint.config.mjs +18 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +13 -0
- package/src/app.ts +86 -0
- package/src/config.ts +176 -0
- package/src/index.ts +54 -0
- package/src/lib/changeStreams.ts +23 -0
- package/src/lib/frameGate.ts +42 -0
- package/src/lib/json.ts +27 -0
- package/src/lib/nonceStore.ts +103 -0
- package/src/lib/queryLimit.ts +11 -0
- package/src/lib/queryWorker/pool.ts +324 -0
- package/src/lib/queryWorker/protocol.ts +39 -0
- package/src/lib/queryWorker/worker.ts +51 -0
- package/src/lib/resumeBuffer.ts +268 -0
- package/src/lib/resumeCursor.ts +48 -0
- package/src/lib/tokenSweep.ts +43 -0
- package/src/middleware/auth.ts +77 -0
- package/src/middleware/errors.ts +73 -0
- package/src/routes/attachments.ts +126 -0
- package/src/routes/auth.ts +137 -0
- package/src/routes/changes.ts +279 -0
- package/src/routes/entity.ts +33 -0
- package/src/routes/health.ts +8 -0
- package/src/routes/records.ts +259 -0
- package/src/routes/tokens.ts +85 -0
- package/src/routes/types.ts +57 -0
- package/src/routes/wellknown.ts +55 -0
- package/src/shutdown.ts +56 -0
- package/src/stack.ts +96 -0
- package/src/types.ts +9 -0
- package/src/wireError.ts +21 -0
- package/tests/app.test.ts +86 -0
- package/tests/changeFeedClient.ts +156 -0
- package/tests/config.test.ts +112 -0
- package/tests/conformance.test.ts +2322 -0
- package/tests/fixtures/blockingWorker.ts +27 -0
- package/tests/fixtures/brokenWorker.ts +7 -0
- package/tests/lib/changeFeedClient.test.ts +126 -0
- package/tests/lib/frameGate.test.ts +93 -0
- package/tests/lib/queryLimit.test.ts +33 -0
- package/tests/lib/queryWorker.test.ts +60 -0
- package/tests/lib/queryWorkerPool.test.ts +129 -0
- package/tests/lib/resumeBuffer.test.ts +341 -0
- package/tests/lib/resumeCursor.test.ts +46 -0
- package/tests/middleware/errors.test.ts +176 -0
- package/tests/routes/associations.test.ts +170 -0
- package/tests/routes/attachments.test.ts +621 -0
- package/tests/routes/auth.test.ts +448 -0
- package/tests/routes/changes.test.ts +567 -0
- package/tests/routes/contentQuery.test.ts +68 -0
- package/tests/routes/entity.test.ts +170 -0
- package/tests/routes/groups.test.ts +69 -0
- package/tests/routes/records.test.ts +1965 -0
- package/tests/routes/relatedToQuery.test.ts +182 -0
- package/tests/routes/tokens.test.ts +157 -0
- package/tests/routes/tombstones.test.ts +93 -0
- package/tests/routes/types.test.ts +129 -0
- package/tests/routes/versions.test.ts +177 -0
- package/tests/routes/wellknown.test.ts +71 -0
- package/tests/setup.ts +154 -0
- package/tests/shutdown.test.ts +121 -0
- package/tests/stack.test.ts +198 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import { streamSSE } from 'hono/streaming';
|
|
4
|
+
import type { AppEnv } from '../types.js';
|
|
5
|
+
import type { StackContext } from '../stack.js';
|
|
6
|
+
import type { Config } from '../config.js';
|
|
7
|
+
import type { ScopedStack, TokenSession, RecordChange } from '@haverstack/core';
|
|
8
|
+
import { StackQueryError, StackPermissionError } from '@haverstack/core';
|
|
9
|
+
import { parseChangeParams } from '@haverstack/core/wire';
|
|
10
|
+
import { serializeChange, isValidSeq } from '@haverstack/wire-types';
|
|
11
|
+
import type { ChangeResetReason } from '@haverstack/wire-types';
|
|
12
|
+
import type { Logger } from 'pino';
|
|
13
|
+
import { safeCompare, isOwnerActingAlone } from '../middleware/auth.js';
|
|
14
|
+
import { FrameGate } from '../lib/frameGate.js';
|
|
15
|
+
import { decodeCursor } from '../lib/resumeCursor.js';
|
|
16
|
+
import { ResumeBufferRegistry, resumeBufferKey, type ResumeEntry } from '../lib/resumeBuffer.js';
|
|
17
|
+
|
|
18
|
+
// Keepalive cadence, and how often an authenticated session is re-checked
|
|
19
|
+
// against the token store — a stream's authority is fixed at connect, so
|
|
20
|
+
// without the re-check a revoked token keeps delivering for as long as the
|
|
21
|
+
// connection lives. Neither value reaches a client, so neither is an env var.
|
|
22
|
+
const DEFAULT_KEEPALIVE_MS = 15_000;
|
|
23
|
+
const DEFAULT_SESSION_CHECK_MS = 30_000;
|
|
24
|
+
// A client whose in-flight frame count crosses this is closed rather than
|
|
25
|
+
// left to queue: one that can't tell it missed a frame can't repair it
|
|
26
|
+
// either, so dropping silently is worse than disconnecting.
|
|
27
|
+
const DEFAULT_MAX_PENDING_FRAMES = 1000;
|
|
28
|
+
// Per-buffer ring depth and how long a buffer keeps being fed after its
|
|
29
|
+
// last connection disconnects — see src/lib/resumeBuffer.ts. Not part of
|
|
30
|
+
// the wire contract either: a client never learns these numbers, only
|
|
31
|
+
// their consequence (`overflow` or `cursor_expired`).
|
|
32
|
+
const DEFAULT_RESUME_BUFFER_DEPTH = 1000;
|
|
33
|
+
const DEFAULT_RESUME_RETENTION_MS = 5 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export type ChangeRouteOptions = {
|
|
36
|
+
keepaliveMs?: number;
|
|
37
|
+
sessionCheckMs?: number;
|
|
38
|
+
maxPendingFrames?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Whether a presented cursor is honored at all. Default true and never
|
|
41
|
+
* false in production, since discovery advertises resume unconditionally.
|
|
42
|
+
* It exists because `resume: false` is real, spec-defined behavior
|
|
43
|
+
* (`ready` with no `seq`, then `reset` with reason `not_supported`) that
|
|
44
|
+
* a conformance fixture needs a way to reach.
|
|
45
|
+
*/
|
|
46
|
+
resume?: boolean;
|
|
47
|
+
resumeBufferDepth?: number;
|
|
48
|
+
resumeRetentionMs?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Raw cursor text presented on this connection, if any — `Last-Event-ID`
|
|
53
|
+
* takes priority over `?since=` (a browser EventSource-style reconnect
|
|
54
|
+
* sends the header; `?since=` exists for a client whose transport can't
|
|
55
|
+
* set one). Not yet validated or decoded — see isValidSeq()/decodeCursor().
|
|
56
|
+
*/
|
|
57
|
+
function presentedCursorRaw(c: Context<AppEnv>, url: URL): string | undefined {
|
|
58
|
+
return c.req.header('Last-Event-ID') ?? url.searchParams.get('since') ?? undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function changeRoutes(
|
|
62
|
+
ctx: StackContext,
|
|
63
|
+
config: Config,
|
|
64
|
+
logger: Logger,
|
|
65
|
+
opts: ChangeRouteOptions = {},
|
|
66
|
+
): Hono<AppEnv> {
|
|
67
|
+
const app = new Hono<AppEnv>();
|
|
68
|
+
const keepaliveMs = opts.keepaliveMs ?? DEFAULT_KEEPALIVE_MS;
|
|
69
|
+
const sessionCheckMs = opts.sessionCheckMs ?? DEFAULT_SESSION_CHECK_MS;
|
|
70
|
+
const maxPendingFrames = opts.maxPendingFrames ?? DEFAULT_MAX_PENDING_FRAMES;
|
|
71
|
+
const resumeEnabled = opts.resume ?? true;
|
|
72
|
+
const resumeBuffers = new ResumeBufferRegistry({
|
|
73
|
+
depth: opts.resumeBufferDepth ?? DEFAULT_RESUME_BUFFER_DEPTH,
|
|
74
|
+
retentionMs: opts.resumeRetentionMs ?? DEFAULT_RESUME_RETENTION_MS,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Scope to a session if authenticated, else the anonymous (public-only) view. */
|
|
78
|
+
function scopeFor(auth: TokenSession | null): ScopedStack {
|
|
79
|
+
return auth ? ctx.stack.forSession(auth) : ctx.stack.asEntity(null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Single-process only: events exist only in the process owning the
|
|
83
|
+
// stack's storage (docs/spec/wire-format.md § Feed implementation
|
|
84
|
+
// checklist), and query workers only read. A second process subscribing
|
|
85
|
+
// to its own Stack over the same storage would see nothing and look fine
|
|
86
|
+
// in testing — worth knowing before scaling this horizontally.
|
|
87
|
+
app.get('/', async (c) => {
|
|
88
|
+
const url = new URL(c.req.url);
|
|
89
|
+
const auth = c.get('auth');
|
|
90
|
+
const { filter, includeRecords, includeUnlisted } = parseChangeParams(url);
|
|
91
|
+
const presentedRaw = presentedCursorRaw(c, url);
|
|
92
|
+
|
|
93
|
+
// Refused before the stream opens: a throw from subscribe() after
|
|
94
|
+
// streaming starts is caught below and merely closes the connection,
|
|
95
|
+
// silently, having already answered 200. includeUnlisted owes a real
|
|
96
|
+
// 403, the same one GET /records answers with.
|
|
97
|
+
if (includeUnlisted && !isOwnerActingAlone(auth, ctx.stack.ownerEntityId)) {
|
|
98
|
+
throw new StackPermissionError('includeUnlisted is owner-only');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A charset-invalid cursor is refused rather than treated as a cache
|
|
102
|
+
// miss: no conformant server could have minted it, so there is nothing
|
|
103
|
+
// to reconcile. isValidSeq() is the rule minted cursors meet on the way
|
|
104
|
+
// out, applied here on the way in.
|
|
105
|
+
if (resumeEnabled && presentedRaw !== undefined && !isValidSeq(presentedRaw)) {
|
|
106
|
+
throw new StackQueryError(`Invalid cursor: "${presentedRaw}"`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Never accepted: a bearer token is read only from the Authorization
|
|
110
|
+
// header (authMiddleware), never from a query param — the feed is
|
|
111
|
+
// consumed via fetch with a streaming body precisely so a client never
|
|
112
|
+
// needs to put a credential somewhere a browser might log or cache it.
|
|
113
|
+
const bearerToken = c.req.header('Authorization')?.slice('Bearer '.length);
|
|
114
|
+
|
|
115
|
+
return streamSSE(c, async (stream) => {
|
|
116
|
+
let resolveDone!: () => void;
|
|
117
|
+
const done = new Promise<void>((resolve) => {
|
|
118
|
+
resolveDone = resolve;
|
|
119
|
+
});
|
|
120
|
+
stream.onAbort(() => resolveDone());
|
|
121
|
+
const unregister = ctx.changeStreams.add(() => resolveDone());
|
|
122
|
+
|
|
123
|
+
const gate = new FrameGate(maxPendingFrames, () => resolveDone());
|
|
124
|
+
/** Returns false once the gate has tripped — this connection is closing. */
|
|
125
|
+
function send(event: string, data: unknown, id?: string): boolean {
|
|
126
|
+
return gate.send(() =>
|
|
127
|
+
stream.writeSSE({ event, data: JSON.stringify(data), ...(id !== undefined && { id }) }),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Assigned inside the try below; torn down in the finally whether the
|
|
132
|
+
// connection ended by abort, by a revoked session, or by a throw. A
|
|
133
|
+
// throw is caught rather than left to hono, which would answer it by
|
|
134
|
+
// writing the raw error message to the client as an `error` frame.
|
|
135
|
+
let unsubscribe: (() => void) | undefined;
|
|
136
|
+
let keepalive: NodeJS.Timeout | undefined;
|
|
137
|
+
let sessionCheck: NodeJS.Timeout | undefined;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
if (!resumeEnabled) {
|
|
141
|
+
// `resume: false` — a discovery-conformant server that mints no
|
|
142
|
+
// cursors at all, reachable only through WellknownRouteOptions'
|
|
143
|
+
// matching test toggle.
|
|
144
|
+
send('ready', {});
|
|
145
|
+
if (presentedRaw !== undefined) {
|
|
146
|
+
const reason: ChangeResetReason = 'not_supported';
|
|
147
|
+
send('reset', { reason });
|
|
148
|
+
}
|
|
149
|
+
unsubscribe = await scopeFor(auth).subscribe(
|
|
150
|
+
(change: RecordChange) => {
|
|
151
|
+
send('record', serializeChange(change));
|
|
152
|
+
},
|
|
153
|
+
{ filter, includeRecords, includeUnlisted },
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
const key = resumeBufferKey({
|
|
157
|
+
principalId: auth?.principalId ?? null,
|
|
158
|
+
subjectId: auth?.subjectId ?? null,
|
|
159
|
+
filter,
|
|
160
|
+
includeRecords,
|
|
161
|
+
includeUnlisted,
|
|
162
|
+
});
|
|
163
|
+
const scoped = scopeFor(auth);
|
|
164
|
+
const buffer = await resumeBuffers.acquire(key, (onChange) =>
|
|
165
|
+
scoped.subscribe(onChange, { filter, includeRecords, includeUnlisted }),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Attached before anything below awaits, so there is no gap
|
|
169
|
+
// between what the backlog snapshot covers and what the live
|
|
170
|
+
// buffer reports. `replaying` holds live arrivals until the
|
|
171
|
+
// permission-rechecking replay finishes, so a change appended
|
|
172
|
+
// mid-replay can't overtake an older, still-pending backlog frame.
|
|
173
|
+
let replaying = true;
|
|
174
|
+
const queued: ResumeEntry[] = [];
|
|
175
|
+
const detachLive = buffer.subscribeLive((entry) => {
|
|
176
|
+
if (replaying) queued.push(entry);
|
|
177
|
+
else send('record', entry.frame, entry.frame.seq);
|
|
178
|
+
});
|
|
179
|
+
// Registered before the first `await` below, so a subscription
|
|
180
|
+
// opened above is always released — including when that await
|
|
181
|
+
// throws. Everything after this point runs under the finally.
|
|
182
|
+
unsubscribe = () => {
|
|
183
|
+
detachLive();
|
|
184
|
+
resumeBuffers.release(key);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let resetReason: ChangeResetReason | undefined;
|
|
188
|
+
let backlog: ResumeEntry[] = [];
|
|
189
|
+
if (presentedRaw !== undefined) {
|
|
190
|
+
const decoded = decodeCursor(presentedRaw);
|
|
191
|
+
if (!decoded || decoded.bufferId !== buffer.id) {
|
|
192
|
+
// Unrecognized outright, or names a buffer instance that isn't
|
|
193
|
+
// this key's current one (evicted past its retention window,
|
|
194
|
+
// or minted for a filter this cursor doesn't actually match).
|
|
195
|
+
resetReason = 'cursor_expired';
|
|
196
|
+
} else {
|
|
197
|
+
const outcome = buffer.entriesAfter(decoded.n);
|
|
198
|
+
if (outcome.status === 'ok') backlog = outcome.entries;
|
|
199
|
+
else resetReason = outcome.status;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
send('ready', { seq: buffer.headCursor() });
|
|
204
|
+
|
|
205
|
+
if (resetReason) {
|
|
206
|
+
send('reset', { reason: resetReason });
|
|
207
|
+
} else {
|
|
208
|
+
for (const entry of backlog) {
|
|
209
|
+
// A purge frame isn't re-checkable: the mutation-time
|
|
210
|
+
// decision is the only one there will ever be
|
|
211
|
+
// (docs/spec/events.md). Everything else still has its record
|
|
212
|
+
// ID, so a grant revoked during the gap is caught here.
|
|
213
|
+
if (!entry.isPurge) {
|
|
214
|
+
const stillReadable = await scoped.get(entry.recordId);
|
|
215
|
+
if (!stillReadable) continue;
|
|
216
|
+
}
|
|
217
|
+
// Once the gate trips this connection is closing, so the
|
|
218
|
+
// remaining entries are permission checks nobody will read.
|
|
219
|
+
if (!send('record', entry.frame, entry.frame.seq)) break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
replaying = false;
|
|
224
|
+
for (const entry of queued) {
|
|
225
|
+
if (!send('record', entry.frame, entry.frame.seq)) break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Through the gate like any other write: a client not draining its
|
|
230
|
+
// keepalives isn't draining anything, and counting them is what
|
|
231
|
+
// keeps "in-flight frames are bounded" true of the whole stream
|
|
232
|
+
// rather than of `record` frames alone.
|
|
233
|
+
keepalive = setInterval(() => {
|
|
234
|
+
gate.send(() => stream.write(': keepalive\n\n'));
|
|
235
|
+
}, keepaliveMs);
|
|
236
|
+
|
|
237
|
+
// A revoked or expired token must stop delivering. The owner's
|
|
238
|
+
// static bearer token never expires (authMiddleware compares it
|
|
239
|
+
// directly, not via the token store), so only a minted session needs
|
|
240
|
+
// re-checking; an anonymous connection has no token to revoke.
|
|
241
|
+
const isOwnerToken =
|
|
242
|
+
bearerToken !== undefined && safeCompare(bearerToken, config.ownerToken);
|
|
243
|
+
sessionCheck =
|
|
244
|
+
auth && !isOwnerToken && bearerToken
|
|
245
|
+
? setInterval(() => {
|
|
246
|
+
void ctx.tokens.lookupToken(bearerToken).then(
|
|
247
|
+
(session) => {
|
|
248
|
+
if (!session) resolveDone();
|
|
249
|
+
},
|
|
250
|
+
// The token store is unreachable. Closing is the honest
|
|
251
|
+
// answer — the client reconnects and re-authenticates
|
|
252
|
+
// through the path that already handles a 401 — and it
|
|
253
|
+
// keeps a rejection here from going unhandled.
|
|
254
|
+
() => resolveDone(),
|
|
255
|
+
);
|
|
256
|
+
}, sessionCheckMs)
|
|
257
|
+
: undefined;
|
|
258
|
+
|
|
259
|
+
await done;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
// Hono answers an uncaught throw here by writing the raw error
|
|
262
|
+
// message to the client as an `error` frame. Catching keeps internal
|
|
263
|
+
// detail off a stream any anonymous caller can open, and a closed
|
|
264
|
+
// connection is a repair the client already knows how to make.
|
|
265
|
+
logger.error(
|
|
266
|
+
{ err, requestId: c.get('requestId') },
|
|
267
|
+
'Change feed connection ended with an error',
|
|
268
|
+
);
|
|
269
|
+
} finally {
|
|
270
|
+
if (keepalive) clearInterval(keepalive);
|
|
271
|
+
if (sessionCheck) clearInterval(sessionCheck);
|
|
272
|
+
unsubscribe?.();
|
|
273
|
+
unregister();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return app;
|
|
279
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { AppEnv } from '../types.js';
|
|
3
|
+
import type { StackContext } from '../stack.js';
|
|
4
|
+
import { requireAuth, requireOwner } from '../middleware/auth.js';
|
|
5
|
+
import { readJson } from '../lib/json.js';
|
|
6
|
+
import { serializeRecord } from '@haverstack/wire-types';
|
|
7
|
+
import { StackNotFoundError } from '@haverstack/core';
|
|
8
|
+
|
|
9
|
+
export function entityRoutes(ctx: StackContext): Hono<AppEnv> {
|
|
10
|
+
const app = new Hono<AppEnv>();
|
|
11
|
+
const { stack } = ctx;
|
|
12
|
+
const ownerEntityId = stack.ownerEntityId;
|
|
13
|
+
|
|
14
|
+
app.get('/', requireAuth(), async (c) => {
|
|
15
|
+
const auth = c.get('auth')!;
|
|
16
|
+
const record = await stack.forSession(auth).getOwnerEntity();
|
|
17
|
+
if (!record) throw new StackNotFoundError('Entity record not found');
|
|
18
|
+
return c.json(serializeRecord(record));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.patch('/', requireOwner(ownerEntityId), async (c) => {
|
|
22
|
+
const auth = c.get('auth')!;
|
|
23
|
+
const record = await stack.forSession(auth).getOwnerEntity();
|
|
24
|
+
if (!record) throw new StackNotFoundError('Entity record not found');
|
|
25
|
+
const body = await readJson<Record<string, unknown>>(c);
|
|
26
|
+
const updated = await stack
|
|
27
|
+
.forSession(auth)
|
|
28
|
+
.patchContent(record.id, (body.content ?? {}) as Record<string, unknown>);
|
|
29
|
+
return c.json(serializeRecord(updated));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return app;
|
|
33
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { AppEnv } from '../types.js';
|
|
3
|
+
import type { StackContext } from '../stack.js';
|
|
4
|
+
import type { ScopedStack, TokenSession } from '@haverstack/core';
|
|
5
|
+
import { requireAuth, requireOwner } from '../middleware/auth.js';
|
|
6
|
+
import { readJson } from '../lib/json.js';
|
|
7
|
+
import {
|
|
8
|
+
parseQueryBody,
|
|
9
|
+
parseQueryParams,
|
|
10
|
+
parsePositiveInt,
|
|
11
|
+
parseIfMatch,
|
|
12
|
+
createOptionsFromWireRecord,
|
|
13
|
+
changesFromWireBody,
|
|
14
|
+
} from '@haverstack/core/wire';
|
|
15
|
+
import { clampLimit } from '../lib/queryLimit.js';
|
|
16
|
+
import { serializeRecord, serializeVersion } from '@haverstack/wire-types';
|
|
17
|
+
import type { WireQueryResponse } from '@haverstack/wire-types';
|
|
18
|
+
import { StackValidationError, StackQueryError, StackNotFoundError } from '@haverstack/core';
|
|
19
|
+
import type { Association, TypeId } from '@haverstack/core';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Route factory
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
// Only the routes that can search a large index (POST /query, GET /) run
|
|
26
|
+
// on the query worker pool with a deadline; everything else calls the main
|
|
27
|
+
// thread's ScopedStack directly, being index-bound already. Virtualizing
|
|
28
|
+
// the whole ScopedStack API across the worker boundary would buy no bound
|
|
29
|
+
// the unbounded-cost case needs. See src/lib/queryWorker/pool.ts and
|
|
30
|
+
// docs/spec/wire-format.md § Bounding query cost.
|
|
31
|
+
export function recordRoutes(ctx: StackContext, queryTimeoutMs: number): Hono<AppEnv> {
|
|
32
|
+
const app = new Hono<AppEnv>();
|
|
33
|
+
const { stack, queryWorker } = ctx;
|
|
34
|
+
const ownerEntityId = stack.ownerEntityId;
|
|
35
|
+
|
|
36
|
+
/** Scope to a session if authenticated, else the anonymous view. */
|
|
37
|
+
function scopeFor(auth: TokenSession | null): ScopedStack {
|
|
38
|
+
return auth ? stack.forSession(auth) : stack.asEntity(null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// POST /records/query — full query with content-field filters. Optional
|
|
42
|
+
// auth, like GET /records: a superset of the same query surface, so an
|
|
43
|
+
// anonymous caller gets the same public-record subset either way.
|
|
44
|
+
// Registered ahead of the /:id patterns, which would otherwise capture
|
|
45
|
+
// the literal "query" segment.
|
|
46
|
+
app.post('/query', async (c) => {
|
|
47
|
+
const auth = c.get('auth');
|
|
48
|
+
const query = clampLimit(parseQueryBody(await readJson(c)));
|
|
49
|
+
const result = await queryWorker.query(auth, query, queryTimeoutMs);
|
|
50
|
+
const body: WireQueryResponse = {
|
|
51
|
+
records: result.records.map(serializeRecord),
|
|
52
|
+
cursor: result.cursor,
|
|
53
|
+
};
|
|
54
|
+
return c.json(body);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// GET /records — query by native fields via URL params
|
|
58
|
+
app.get('/', async (c) => {
|
|
59
|
+
const auth = c.get('auth');
|
|
60
|
+
const query = clampLimit(parseQueryParams(new URL(c.req.url)));
|
|
61
|
+
const result = await queryWorker.query(auth, query, queryTimeoutMs);
|
|
62
|
+
const body: WireQueryResponse = {
|
|
63
|
+
records: result.records.map(serializeRecord),
|
|
64
|
+
cursor: result.cursor,
|
|
65
|
+
};
|
|
66
|
+
return c.json(body);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// POST /records — a full record body, but version, entityId and
|
|
70
|
+
// principalId are stamped here and never trusted from it. Everything else
|
|
71
|
+
// a wire record body can carry — which fields are forwarded as-is, which
|
|
72
|
+
// are owner-acting-alone-only, and which reduce to a boolean rather than
|
|
73
|
+
// their literal value — is `createOptionsFromWireRecord()`'s disposition
|
|
74
|
+
// to make, not this route's: see `@haverstack/core/wire` and
|
|
75
|
+
// docs/spec/wire-format.md § Records.
|
|
76
|
+
app.post('/', requireAuth(), async (c) => {
|
|
77
|
+
const auth = c.get('auth')!;
|
|
78
|
+
const body = await readJson(c);
|
|
79
|
+
const { typeId, content, options } = createOptionsFromWireRecord(body, auth, ownerEntityId);
|
|
80
|
+
const created = await stack.forSession(auth).create(typeId, content, options);
|
|
81
|
+
return c.json(serializeRecord(created), 200);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// GET /records/:id
|
|
85
|
+
app.get('/:id', async (c) => {
|
|
86
|
+
const id = c.req.param('id');
|
|
87
|
+
const auth = c.get('auth');
|
|
88
|
+
const record = await scopeFor(auth).get(id);
|
|
89
|
+
if (!record) throw new StackNotFoundError('Record not found');
|
|
90
|
+
return c.json(serializeRecord(record));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// PATCH /records/:id — the body is a change set: an envelope naming any
|
|
94
|
+
// combination of `contentPatch`, `parentId`, `permissions`, `associations`
|
|
95
|
+
// and `unlisted`, applied as one atomic write that produces exactly one
|
|
96
|
+
// version. A conforming client sends { "contentPatch": { "title": "New" } },
|
|
97
|
+
// never the bare patch — content is one aspect among five here, so the
|
|
98
|
+
// envelope is what tells a `parentId` naming a container from a content
|
|
99
|
+
// field of that name. One If-Match fences the whole multi-aspect edit.
|
|
100
|
+
//
|
|
101
|
+
// `changesFromWireBody()` owns the entire read of that envelope: which
|
|
102
|
+
// keys it may carry, the 400 an unrecognized or wholly absent key earns,
|
|
103
|
+
// and the 422 a malformed value earns. Content keys stay Stack's to judge,
|
|
104
|
+
// so nothing here inspects the patch. See docs/spec/wire-format.md § Records.
|
|
105
|
+
//
|
|
106
|
+
// No route-level guard belongs here: ScopedStack owns the _attachment@1
|
|
107
|
+
// immutable-field and _grant@1 owner-only rules (docs/spec/attachments.md,
|
|
108
|
+
// docs/spec/access-control.md) and resolves each change-set key against its
|
|
109
|
+
// own gate, so a denial round-trips as the core error it is rather than a
|
|
110
|
+
// guess made before the permission check ran.
|
|
111
|
+
app.patch('/:id', requireAuth(), async (c) => {
|
|
112
|
+
const id = c.req.param('id');
|
|
113
|
+
const auth = c.get('auth')!;
|
|
114
|
+
const changes = changesFromWireBody(await readJson(c));
|
|
115
|
+
|
|
116
|
+
const updated = await stack
|
|
117
|
+
.forSession(auth)
|
|
118
|
+
.mutate(id, changes, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
119
|
+
return c.json(serializeRecord(updated));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// DELETE /records/:id (?hard=true for permanent). A soft delete bumps
|
|
123
|
+
// the version like any other mutation, so it answers with the record it
|
|
124
|
+
// produced (carrying deletedAt); a hard delete leaves nothing to answer
|
|
125
|
+
// with, so that one stays 204.
|
|
126
|
+
app.delete('/:id', requireAuth(), async (c) => {
|
|
127
|
+
const id = c.req.param('id');
|
|
128
|
+
const auth = c.get('auth')!;
|
|
129
|
+
const hard = new URL(c.req.url).searchParams.get('hard') === 'true';
|
|
130
|
+
const session = stack.forSession(auth);
|
|
131
|
+
|
|
132
|
+
await session.delete(id, { hard, ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
133
|
+
if (hard) return c.body(null, 204);
|
|
134
|
+
return c.json(serializeRecord((await session.get(id))!));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// POST /records/:id/undelete — reverses a soft delete; idempotent
|
|
138
|
+
app.post('/:id/undelete', requireAuth(), async (c) => {
|
|
139
|
+
const id = c.req.param('id');
|
|
140
|
+
const auth = c.get('auth')!;
|
|
141
|
+
const restored = await stack
|
|
142
|
+
.forSession(auth)
|
|
143
|
+
.undelete(id, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
144
|
+
return c.json(serializeRecord(restored));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------------------------
|
|
148
|
+
// Permissions
|
|
149
|
+
// ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
app.get('/:id/permissions', async (c) => {
|
|
152
|
+
const id = c.req.param('id');
|
|
153
|
+
const auth = c.get('auth');
|
|
154
|
+
const record = await scopeFor(auth).get(id);
|
|
155
|
+
if (!record) throw new StackNotFoundError('Record not found');
|
|
156
|
+
return c.json({ permissions: record.permissions ?? [] });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ------------------------------------------------------------------
|
|
160
|
+
// Associations
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
app.get('/:id/associations', async (c) => {
|
|
164
|
+
const id = c.req.param('id');
|
|
165
|
+
const auth = c.get('auth');
|
|
166
|
+
const record = await scopeFor(auth).get(id);
|
|
167
|
+
if (!record) throw new StackNotFoundError('Record not found');
|
|
168
|
+
let assocs = record.associations ?? [];
|
|
169
|
+
const kind = c.req.query('kind');
|
|
170
|
+
if (kind) assocs = assocs.filter((a) => a.kind === kind);
|
|
171
|
+
const label = c.req.query('label');
|
|
172
|
+
if (label) assocs = assocs.filter((a) => a.label === label);
|
|
173
|
+
return c.json({ associations: assocs });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
app.post('/:id/associations', requireAuth(), async (c) => {
|
|
177
|
+
const id = c.req.param('id');
|
|
178
|
+
const auth = c.get('auth')!;
|
|
179
|
+
const body = await readJson<Association>(c);
|
|
180
|
+
if (!body.kind || !body.label) throw new StackQueryError('kind and label are required');
|
|
181
|
+
const updated = await stack
|
|
182
|
+
.forSession(auth)
|
|
183
|
+
.associate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
184
|
+
return c.json(serializeRecord(updated));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// POST, not DELETE — a DELETE request body has no defined semantics
|
|
188
|
+
// (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways
|
|
189
|
+
// that drop or reject it.
|
|
190
|
+
app.post('/:id/associations/delete', requireAuth(), async (c) => {
|
|
191
|
+
const id = c.req.param('id');
|
|
192
|
+
const auth = c.get('auth')!;
|
|
193
|
+
const body = await readJson<Association>(c);
|
|
194
|
+
const updated = await stack
|
|
195
|
+
.forSession(auth)
|
|
196
|
+
.dissociate(id, body, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
197
|
+
return c.json(serializeRecord(updated));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ------------------------------------------------------------------
|
|
201
|
+
// Versions
|
|
202
|
+
// ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
app.get('/:id/versions', async (c) => {
|
|
205
|
+
const id = c.req.param('id');
|
|
206
|
+
const auth = c.get('auth');
|
|
207
|
+
const versions = await scopeFor(auth).getVersions(id);
|
|
208
|
+
return c.json(versions.map(serializeVersion));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
app.get('/:id/versions/:version', async (c) => {
|
|
212
|
+
const id = c.req.param('id');
|
|
213
|
+
const vNum = parsePositiveInt(c.req.param('version'), 'version number');
|
|
214
|
+
const auth = c.get('auth');
|
|
215
|
+
const version = await scopeFor(auth).getVersion(id, vNum);
|
|
216
|
+
if (!version) throw new StackNotFoundError('Version not found');
|
|
217
|
+
return c.json(serializeVersion(version));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// POST /records/:id/restore/:version — creates new version, does not rewrite history
|
|
221
|
+
app.post('/:id/restore/:version', requireAuth(), async (c) => {
|
|
222
|
+
const id = c.req.param('id');
|
|
223
|
+
const vNum = parsePositiveInt(c.req.param('version'), 'version number');
|
|
224
|
+
const auth = c.get('auth')!;
|
|
225
|
+
const restored = await stack
|
|
226
|
+
.forSession(auth)
|
|
227
|
+
.restoreVersion(id, vNum, { ifVersion: parseIfMatch(c.req.header('If-Match')) });
|
|
228
|
+
return c.json(serializeRecord(restored));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// POST /records/:id/migrate — the only way typeId changes after creation.
|
|
232
|
+
// The body carries the full post-migration content, computed client-side
|
|
233
|
+
// by the type's owning app and validated against toTypeId's schema by
|
|
234
|
+
// ScopedStack.commitMigration(). Owner acting alone only: replacing
|
|
235
|
+
// content and typeId wholesale would have to re-derive every gate
|
|
236
|
+
// create() and update() apply at both ends.
|
|
237
|
+
app.post('/:id/migrate', requireOwner(ownerEntityId), async (c) => {
|
|
238
|
+
const id = c.req.param('id');
|
|
239
|
+
const auth = c.get('auth')!;
|
|
240
|
+
const body = await readJson<Record<string, unknown>>(c);
|
|
241
|
+
if (!body.toTypeId || typeof body.toTypeId !== 'string')
|
|
242
|
+
throw new StackQueryError('toTypeId is required');
|
|
243
|
+
if (!body.content || typeof body.content !== 'object')
|
|
244
|
+
throw new StackQueryError('content is required');
|
|
245
|
+
if (!(await stack.getType(body.toTypeId as TypeId)))
|
|
246
|
+
throw new StackValidationError([
|
|
247
|
+
{ path: 'toTypeId', message: `Unknown type: "${body.toTypeId}"` },
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const migrated = await stack
|
|
251
|
+
.forSession(auth)
|
|
252
|
+
.commitMigration(id, body.toTypeId as TypeId, body.content as Record<string, unknown>, {
|
|
253
|
+
ifVersion: parseIfMatch(c.req.header('If-Match')),
|
|
254
|
+
});
|
|
255
|
+
return c.json(serializeRecord(migrated));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return app;
|
|
259
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { parseDate } from '@haverstack/wire-types';
|
|
7
|
+
import { StackValidationError } from '@haverstack/core';
|
|
8
|
+
import { isValidDid } from '@haverstack/core/did';
|
|
9
|
+
import type { TokenInfo } from '@haverstack/core/wire';
|
|
10
|
+
|
|
11
|
+
export function tokenRoutes(ctx: StackContext): Hono<AppEnv> {
|
|
12
|
+
const app = new Hono<AppEnv>();
|
|
13
|
+
const { tokens, stack } = ctx;
|
|
14
|
+
const ownerEntityId = stack.ownerEntityId;
|
|
15
|
+
|
|
16
|
+
// POST /tokens — issue a new token (owner only). `onBehalfOf` asserts a
|
|
17
|
+
// delegation out of band: the owner names the subject the issued
|
|
18
|
+
// principal acts for, per docs/spec/wire-format.md § The session a
|
|
19
|
+
// token names.
|
|
20
|
+
app.post('/', requireOwner(ownerEntityId), async (c) => {
|
|
21
|
+
const body = await readJson<{
|
|
22
|
+
entityId?: string;
|
|
23
|
+
onBehalfOf?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
}>(c);
|
|
27
|
+
if (body.entityId !== undefined && !isValidDid(body.entityId))
|
|
28
|
+
throw new StackValidationError([{ path: 'entityId', message: 'Must be a DID' }]);
|
|
29
|
+
if (body.onBehalfOf !== undefined && !isValidDid(body.onBehalfOf))
|
|
30
|
+
throw new StackValidationError([{ path: 'onBehalfOf', message: 'Must be a DID' }]);
|
|
31
|
+
|
|
32
|
+
const principalId = body.entityId ?? ownerEntityId;
|
|
33
|
+
const expiresAt = body.expiresAt ? parseDate(body.expiresAt) : undefined;
|
|
34
|
+
if (body.expiresAt && !expiresAt)
|
|
35
|
+
throw new StackValidationError([{ path: 'expiresAt', message: 'Invalid date' }]);
|
|
36
|
+
|
|
37
|
+
const { id, token } = await tokens.createToken(principalId, {
|
|
38
|
+
onBehalfOf: body.onBehalfOf,
|
|
39
|
+
label: body.label,
|
|
40
|
+
expiresAt,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Read the row back rather than fabricating createdAt here — the store
|
|
44
|
+
// is the source of truth, and GET /tokens must report the same value.
|
|
45
|
+
const stored = (await tokens.listTokens()).find((t) => t.id === id)!;
|
|
46
|
+
|
|
47
|
+
return c.json(
|
|
48
|
+
{
|
|
49
|
+
id,
|
|
50
|
+
token,
|
|
51
|
+
principalId,
|
|
52
|
+
subjectId: body.onBehalfOf ?? principalId,
|
|
53
|
+
label: stored.label ?? null,
|
|
54
|
+
createdAt: stored.createdAt.toISOString(),
|
|
55
|
+
expiresAt: stored.expiresAt?.toISOString() ?? null,
|
|
56
|
+
},
|
|
57
|
+
201,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// GET /tokens — list all DB-managed tokens; never returns token values
|
|
62
|
+
app.get('/', requireOwner(ownerEntityId), async (c) => {
|
|
63
|
+
const list = await tokens.listTokens();
|
|
64
|
+
return c.json({ tokens: list.map(serializeToken) });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// DELETE /tokens/:id — revoke a token by its ID
|
|
68
|
+
app.delete('/:id', requireOwner(ownerEntityId), async (c) => {
|
|
69
|
+
await tokens.revokeToken(c.req.param('id'));
|
|
70
|
+
return c.body(null, 204);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return app;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function serializeToken(t: TokenInfo) {
|
|
77
|
+
return {
|
|
78
|
+
id: t.id,
|
|
79
|
+
principalId: t.principalId,
|
|
80
|
+
subjectId: t.subjectId,
|
|
81
|
+
label: t.label ?? null,
|
|
82
|
+
createdAt: t.createdAt.toISOString(),
|
|
83
|
+
expiresAt: t.expiresAt?.toISOString() ?? null,
|
|
84
|
+
};
|
|
85
|
+
}
|