@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,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume cursor codec. A cursor is opaque and base64url by wire contract
|
|
3
|
+
* (`isValidSeq()` in @haverstack/wire-types), but this server's own are
|
|
4
|
+
* self-describing: `base64url(bufferId + ":" + n)`. That makes a presented
|
|
5
|
+
* cursor's origin checkable — a reconnect naming a buffer this server
|
|
6
|
+
* doesn't hold (a different filter, a restart, a buffer past its retention
|
|
7
|
+
* window) is caught rather than resumed against the wrong stream.
|
|
8
|
+
*
|
|
9
|
+
* Real base64url encoding of the whole string is what satisfies the
|
|
10
|
+
* charset rule automatically — there's no separate escaping step for the
|
|
11
|
+
* ":" delimiter, since it never appears in the encoded output.
|
|
12
|
+
*/
|
|
13
|
+
import { base64urlEncode, base64urlDecode } from '@haverstack/core/wire';
|
|
14
|
+
|
|
15
|
+
export type DecodedCursor = {
|
|
16
|
+
bufferId: string;
|
|
17
|
+
n: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Mint a cursor naming one buffer's identity and position. */
|
|
21
|
+
export function encodeCursor(bufferId: string, n: number): string {
|
|
22
|
+
return base64urlEncode(new TextEncoder().encode(`${bufferId}:${n}`));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decode a presented cursor. Returns null for anything that doesn't parse
|
|
27
|
+
* as `bufferId + ":" + a non-negative integer` — never throws. A
|
|
28
|
+
* charset-valid cursor naming garbage is the same "cannot be honored" case
|
|
29
|
+
* an unrecognized buffer id is: answered with a `reset` frame, not a 400.
|
|
30
|
+
* (A charset-*invalid* cursor is refused before this is ever called — see
|
|
31
|
+
* `src/routes/changes.ts`.)
|
|
32
|
+
*/
|
|
33
|
+
export function decodeCursor(seq: string): DecodedCursor | null {
|
|
34
|
+
let text: string;
|
|
35
|
+
try {
|
|
36
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(base64urlDecode(seq));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const colonIndex = text.lastIndexOf(':');
|
|
41
|
+
if (colonIndex === -1) return null;
|
|
42
|
+
const bufferId = text.slice(0, colonIndex);
|
|
43
|
+
const nText = text.slice(colonIndex + 1);
|
|
44
|
+
if (!/^\d+$/.test(nText)) return null;
|
|
45
|
+
const n = Number(nText);
|
|
46
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
47
|
+
return { bufferId, n };
|
|
48
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Logger } from 'pino';
|
|
2
|
+
import type { StackTokenStore } from '@haverstack/core/wire';
|
|
3
|
+
|
|
4
|
+
// Once an hour at most. The sweep is O(n) in tokens ever issued — cheap
|
|
5
|
+
// relative to that interval, and never on the hot path twice in a row.
|
|
6
|
+
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Delete tokens past their `expiresAt`.
|
|
10
|
+
*
|
|
11
|
+
* `lookupToken()` already refuses an expired token, so this changes no
|
|
12
|
+
* authorization outcome — it reclaims rows, and nothing else does: the
|
|
13
|
+
* store deletes only on an explicit `revokeToken()`. Since `POST
|
|
14
|
+
* /auth/token` is unauthenticated, strangers drive the row count, and
|
|
15
|
+
* unbounded growth costs disk and slows every `listTokens()` caller.
|
|
16
|
+
*
|
|
17
|
+
* Returns a throttled sweep — call it freely; it runs at most once per
|
|
18
|
+
* interval and never rejects, since failing to reclaim rows must not fail
|
|
19
|
+
* the request that happened to trigger it.
|
|
20
|
+
*/
|
|
21
|
+
export function createExpiredTokenSweeper(
|
|
22
|
+
tokens: StackTokenStore,
|
|
23
|
+
logger: Logger,
|
|
24
|
+
): () => Promise<void> {
|
|
25
|
+
let lastSweptAt = 0;
|
|
26
|
+
return async () => {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
if (now - lastSweptAt < SWEEP_INTERVAL_MS) return;
|
|
29
|
+
// Claimed before the await, so two concurrent requests can't both start
|
|
30
|
+
// a sweep and race each other's revokeToken() calls.
|
|
31
|
+
lastSweptAt = now;
|
|
32
|
+
try {
|
|
33
|
+
const all = await tokens.listTokens();
|
|
34
|
+
const expired = all.filter((t) => t.expiresAt !== undefined && t.expiresAt.getTime() <= now);
|
|
35
|
+
for (const t of expired) await tokens.revokeToken(t.id);
|
|
36
|
+
if (expired.length > 0) {
|
|
37
|
+
logger.info({ revoked: expired.length }, 'Swept expired tokens');
|
|
38
|
+
}
|
|
39
|
+
} catch (err) {
|
|
40
|
+
logger.warn({ err }, 'Expired-token sweep failed');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { MiddlewareHandler } from 'hono';
|
|
3
|
+
import { StackPermissionError } from '@haverstack/core';
|
|
4
|
+
import type { TokenSession } from '@haverstack/core';
|
|
5
|
+
import type { AppEnv } from '../types.js';
|
|
6
|
+
import type { StackContext } from '../stack.js';
|
|
7
|
+
import { wireError } from '../wireError.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Constant-time string equality, for comparing a presented credential
|
|
11
|
+
* against a configured secret. Exported so every comparison of the owner
|
|
12
|
+
* token uses the same one — `GET /changes` re-derives "is this the owner"
|
|
13
|
+
* to decide whether to arm its session re-check.
|
|
14
|
+
*/
|
|
15
|
+
export function safeCompare(a: string, b: string): boolean {
|
|
16
|
+
const ba = Buffer.from(a);
|
|
17
|
+
const bb = Buffer.from(b);
|
|
18
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function authMiddleware(ownerToken: string, ctx: StackContext): MiddlewareHandler<AppEnv> {
|
|
22
|
+
const ownerEntityId = ctx.stack.ownerEntityId;
|
|
23
|
+
|
|
24
|
+
return async (c, next) => {
|
|
25
|
+
const header = c.req.header('Authorization');
|
|
26
|
+
if (header === undefined) {
|
|
27
|
+
c.set('auth', null);
|
|
28
|
+
} else if (!header.startsWith('Bearer ')) {
|
|
29
|
+
// A credential that isn't a bearer token (`Basic ...`, a missing
|
|
30
|
+
// space) is a 401, never a silent downgrade to anonymous.
|
|
31
|
+
return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
|
|
32
|
+
} else {
|
|
33
|
+
const token = header.slice(7);
|
|
34
|
+
if (safeCompare(token, ownerToken)) {
|
|
35
|
+
c.set('auth', { principalId: ownerEntityId, subjectId: ownerEntityId });
|
|
36
|
+
} else {
|
|
37
|
+
const session = await ctx.tokens.lookupToken(token);
|
|
38
|
+
// Unknown, expired, or revoked is distinct from "none offered",
|
|
39
|
+
// and must not read as anonymous on optional-auth routes.
|
|
40
|
+
if (!session) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
|
|
41
|
+
c.set('auth', session);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
await next();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function requireAuth(): MiddlewareHandler<AppEnv> {
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
if (!c.get('auth')) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
|
|
51
|
+
await next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a session is the stack owner acting alone: undelegated, and
|
|
57
|
+
* authenticated as the owner rather than merely delegated for it. Being the
|
|
58
|
+
* owner is never on its own sufficient under delegation — a delegated
|
|
59
|
+
* session with the owner as principal still fails this, matching
|
|
60
|
+
* `ScopedStack`'s own owner-only gates (e.g. hard delete). See
|
|
61
|
+
* docs/spec/access-control.md § Delegation.
|
|
62
|
+
*/
|
|
63
|
+
export function isOwnerActingAlone(auth: TokenSession | null, ownerEntityId: string): boolean {
|
|
64
|
+
return auth?.principalId === ownerEntityId && auth?.subjectId === ownerEntityId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Gates a route to the owner acting alone; see `isOwnerActingAlone()`. */
|
|
68
|
+
export function requireOwner(ownerEntityId: string): MiddlewareHandler<AppEnv> {
|
|
69
|
+
return async (c, next) => {
|
|
70
|
+
const auth = c.get('auth');
|
|
71
|
+
if (!auth) return wireError(c, 401, 'unauthorized', 'Missing or invalid bearer token');
|
|
72
|
+
if (!isOwnerActingAlone(auth, ownerEntityId)) {
|
|
73
|
+
throw new StackPermissionError('Owner access required');
|
|
74
|
+
}
|
|
75
|
+
await next();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ErrorHandler } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
import type { Logger } from 'pino';
|
|
4
|
+
import { StackPermissionError, StackNotFoundError, type Stack } from '@haverstack/core';
|
|
5
|
+
import type { AppEnv } from '../types.js';
|
|
6
|
+
import { serializeError } from '@haverstack/wire-types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Central error handler. `serializeError()` covers every core `StackError`
|
|
10
|
+
* (thrown directly by routes or surfaced from `Stack`/`ScopedStack`) with
|
|
11
|
+
* the wire-format's typed `{ error: { code, message } }` body. Anything
|
|
12
|
+
* else is an unanticipated bug: log it and return a bodyless 500 rather
|
|
13
|
+
* than guessing at a taxonomy it doesn't belong to.
|
|
14
|
+
*
|
|
15
|
+
* `stack` is the unscoped root Stack, used only to probe existence for the
|
|
16
|
+
* refusal log below — never to read content or bypass a permission check
|
|
17
|
+
* for the response itself.
|
|
18
|
+
*/
|
|
19
|
+
export function errorMiddleware(logger: Logger, stack: Stack): ErrorHandler<AppEnv> {
|
|
20
|
+
return async (err, c) => {
|
|
21
|
+
const wire = serializeError(err);
|
|
22
|
+
if (wire) {
|
|
23
|
+
// A denial with a resolved auth is a verified DID that lacked the
|
|
24
|
+
// grant, which docs/spec/identity.md asks be logged. Anonymous
|
|
25
|
+
// denials are bodyless 401s from wireError() and never reach here.
|
|
26
|
+
const auth = c.get('auth');
|
|
27
|
+
if (err instanceof StackPermissionError && auth) {
|
|
28
|
+
logger.warn(
|
|
29
|
+
{
|
|
30
|
+
requestId: c.get('requestId'),
|
|
31
|
+
principalId: auth.principalId,
|
|
32
|
+
subjectId: auth.subjectId,
|
|
33
|
+
},
|
|
34
|
+
'Denied a verified requester',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
// On the wire a refused record is indistinguishable from a missing
|
|
38
|
+
// one (docs/spec/wire-format.md § Server implementation checklist),
|
|
39
|
+
// but the operator is not the adversary: an unscoped probe, on a path
|
|
40
|
+
// already failing, recovers the distinction for the log alone. Debug
|
|
41
|
+
// rather than warn — this is the sharing graph, which the spec asks
|
|
42
|
+
// stay out of a general-purpose aggregator by default.
|
|
43
|
+
if (err instanceof StackNotFoundError && auth) {
|
|
44
|
+
const recordId = c.req.param('id');
|
|
45
|
+
if (recordId) {
|
|
46
|
+
const existing = await stack.get(recordId);
|
|
47
|
+
logger.debug(
|
|
48
|
+
{
|
|
49
|
+
requestId: c.get('requestId'),
|
|
50
|
+
principalId: auth.principalId,
|
|
51
|
+
subjectId: auth.subjectId,
|
|
52
|
+
recordId,
|
|
53
|
+
existed: existing !== null,
|
|
54
|
+
// Always 'read': ScopedStack collapses to StackNotFoundError
|
|
55
|
+
// exactly when canRead() is false, whatever verb was asked.
|
|
56
|
+
check: 'read',
|
|
57
|
+
},
|
|
58
|
+
'Refused a verified requester a 404',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// A 401 or 403 to an anonymous caller would confirm the record
|
|
63
|
+
// exists to someone who presented no credential, so a private record
|
|
64
|
+
// and a missing one answer alike. `Bearer` (RFC 6750 §3) — not the
|
|
65
|
+
// `did-challenge` exchange discovery advertises for obtaining a
|
|
66
|
+
// token — keeps the login prompt reachable without reopening that.
|
|
67
|
+
if (wire.status === 404 && !auth) c.header('WWW-Authenticate', 'Bearer');
|
|
68
|
+
return c.json(wire.body, wire.status as ContentfulStatusCode);
|
|
69
|
+
}
|
|
70
|
+
logger.error({ err, requestId: c.get('requestId') }, 'Unhandled request error');
|
|
71
|
+
return c.json({ error: { code: 'internal', message: 'Internal server error' } }, 500);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
3
|
+
import { StackPermissionError, StackPayloadTooLargeError } from '@haverstack/core';
|
|
4
|
+
import {
|
|
5
|
+
resolveAttachmentDownloadContentType,
|
|
6
|
+
firstRecordedAttachment,
|
|
7
|
+
parseUploadFilename,
|
|
8
|
+
NOSNIFF_HEADER_NAME,
|
|
9
|
+
NOSNIFF_HEADER_VALUE,
|
|
10
|
+
} from '@haverstack/core/wire';
|
|
11
|
+
import { serializeRecord } from '@haverstack/wire-types';
|
|
12
|
+
import type { AppEnv } from '../types.js';
|
|
13
|
+
import type { StackContext } from '../stack.js';
|
|
14
|
+
import { requireAuth, requireOwner } from '../middleware/auth.js';
|
|
15
|
+
|
|
16
|
+
export function attachmentRoutes(ctx: StackContext, maxAttachmentBytes: number): Hono<AppEnv> {
|
|
17
|
+
const app = new Hono<AppEnv>();
|
|
18
|
+
const { stack } = ctx;
|
|
19
|
+
const { ownerEntityId } = stack;
|
|
20
|
+
|
|
21
|
+
// Aborts while reading the body, the moment the byte count passes the
|
|
22
|
+
// limit — no full-body buffer for an oversized upload, chunked or not.
|
|
23
|
+
const attachmentBodyLimit = bodyLimit({
|
|
24
|
+
maxSize: maxAttachmentBytes,
|
|
25
|
+
onError: () => {
|
|
26
|
+
throw new StackPayloadTooLargeError(
|
|
27
|
+
`Attachment exceeds the ${maxAttachmentBytes}-byte limit`,
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// POST /attachments — stores the bytes and creates the _attachment@1
|
|
33
|
+
// metadata record in the same request, returning that record. Routed
|
|
34
|
+
// through ScopedStack.putAttachment() so the create-grant check on
|
|
35
|
+
// _attachment@1 runs before a single byte is written: an authenticated
|
|
36
|
+
// requester with no grant is refused, not merely denied a metadata
|
|
37
|
+
// record afterward. See docs/spec/wire-format.md § Upload.
|
|
38
|
+
app.post('/', attachmentBodyLimit, requireAuth(), async (c) => {
|
|
39
|
+
const auth = c.get('auth')!;
|
|
40
|
+
const mimeType = c.req.header('Content-Type') || 'application/octet-stream';
|
|
41
|
+
const filename = parseUploadFilename(c.req.header('Content-Disposition'));
|
|
42
|
+
const appId = c.req.query('appId') || undefined;
|
|
43
|
+
|
|
44
|
+
const data = new Uint8Array(await c.req.arrayBuffer());
|
|
45
|
+
const record = await stack.forSession(auth).putAttachment(data, mimeType, filename, appId);
|
|
46
|
+
return c.json(serializeRecord(record), 200);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// GET /attachments/:fileId — download
|
|
50
|
+
app.get('/:fileId', async (c) => {
|
|
51
|
+
const fileId = c.req.param('fileId');
|
|
52
|
+
const auth = c.get('auth');
|
|
53
|
+
|
|
54
|
+
let data: Uint8Array;
|
|
55
|
+
try {
|
|
56
|
+
data = await (auth ? stack.forSession(auth) : stack.asEntity(null)).getAttachment(fileId);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Anonymous and denied is a transport-auth failure, distinct from an
|
|
59
|
+
// authenticated requester lacking access; everything else belongs to
|
|
60
|
+
// errorMiddleware. ScopedStack.getAttachment() already throws alike
|
|
61
|
+
// for missing and forbidden, so this handler owes no anti-oracle
|
|
62
|
+
// logic of its own — only to leave that answer intact.
|
|
63
|
+
if (!auth && e instanceof StackPermissionError) {
|
|
64
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
65
|
+
}
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const metaRecords = await stack.getAttachmentRecords(fileId);
|
|
70
|
+
const firstRecord = firstRecordedAttachment(metaRecords);
|
|
71
|
+
const ownRecord = auth
|
|
72
|
+
? firstRecordedAttachment(metaRecords.filter((r) => r.entityId === auth.subjectId))
|
|
73
|
+
: undefined;
|
|
74
|
+
|
|
75
|
+
const contentTypeParam = c.req.query('contentType');
|
|
76
|
+
const filenameParam = c.req.query('filename');
|
|
77
|
+
|
|
78
|
+
const { contentType, forced } = resolveAttachmentDownloadContentType({
|
|
79
|
+
contentTypeParam,
|
|
80
|
+
filenameParam,
|
|
81
|
+
storedMimeType: firstRecord?.content.mimeType,
|
|
82
|
+
});
|
|
83
|
+
// When the dangerous-type policy overrides the candidate, the filename
|
|
84
|
+
// goes too: forcing Content-Type alone won't stop a browser sniffing
|
|
85
|
+
// the body back into the original type without both nosniff and a
|
|
86
|
+
// non-inline disposition.
|
|
87
|
+
const filename = forced
|
|
88
|
+
? undefined
|
|
89
|
+
: (filenameParam ?? ownRecord?.content.filename ?? firstRecord?.content.filename);
|
|
90
|
+
|
|
91
|
+
const disposition = filename
|
|
92
|
+
? `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
93
|
+
: 'attachment';
|
|
94
|
+
|
|
95
|
+
return c.newResponse(data as unknown as Uint8Array<ArrayBuffer>, 200, {
|
|
96
|
+
'Content-Type': contentType,
|
|
97
|
+
'Content-Length': String(data.byteLength),
|
|
98
|
+
'Content-Disposition': disposition,
|
|
99
|
+
[NOSNIFF_HEADER_NAME]: NOSNIFF_HEADER_VALUE,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// DELETE /attachments/:fileId
|
|
104
|
+
app.delete('/:fileId', requireOwner(ownerEntityId), async (c) => {
|
|
105
|
+
const fileId = c.req.param('fileId');
|
|
106
|
+
await stack.deleteAttachment(fileId);
|
|
107
|
+
return c.body(null, 204);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// POST /attachments/gc — sweep and delete orphaned attachment bytes.
|
|
111
|
+
// Owner-only, invoke-only (no built-in scheduling): dryRun makes a
|
|
112
|
+
// cron-from-outside workflow safe. Body is entirely optional — every
|
|
113
|
+
// field defaults inside ScopedStack.collectAttachmentGarbage().
|
|
114
|
+
app.post('/gc', requireOwner(ownerEntityId), async (c) => {
|
|
115
|
+
const auth = c.get('auth')!;
|
|
116
|
+
const raw = await c.req.text();
|
|
117
|
+
const body = raw ? (JSON.parse(raw) as { graceMs?: number; dryRun?: boolean }) : {};
|
|
118
|
+
const result = await stack.forSession(auth).collectAttachmentGarbage({
|
|
119
|
+
...(typeof body.graceMs === 'number' && { graceMs: body.graceMs }),
|
|
120
|
+
...(body.dryRun === true && { dryRun: true }),
|
|
121
|
+
});
|
|
122
|
+
return c.json(result, 200);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return app;
|
|
126
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
import { verifyAuthChallenge, base64urlDecode } from '@haverstack/core/wire';
|
|
4
|
+
import { isValidDidKey } from '@haverstack/core/did';
|
|
5
|
+
import { WIRE_AUTH_ERROR_STATUS } from '@haverstack/wire-types';
|
|
6
|
+
import type {
|
|
7
|
+
AuthChallengeRequest,
|
|
8
|
+
AuthChallengeResponse,
|
|
9
|
+
AuthTokenRequest,
|
|
10
|
+
AuthTokenResponse,
|
|
11
|
+
WireAuthErrorCode,
|
|
12
|
+
} from '@haverstack/wire-types';
|
|
13
|
+
import type { Context } from 'hono';
|
|
14
|
+
import type { Logger } from 'pino';
|
|
15
|
+
import type { AppEnv } from '../types.js';
|
|
16
|
+
import type { StackContext } from '../stack.js';
|
|
17
|
+
import { readJson } from '../lib/json.js';
|
|
18
|
+
import { createExpiredTokenSweeper } from '../lib/tokenSweep.js';
|
|
19
|
+
|
|
20
|
+
// The handshake never issues a delegated token: proving key possession
|
|
21
|
+
// proves the principal and nothing about whom it may act for. It gets a
|
|
22
|
+
// default TTL rather than the owner's choice of one, because a token
|
|
23
|
+
// anyone can mint for themselves owes a bounded lifetime.
|
|
24
|
+
const AUTH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// Stamped on every handshake-issued token. GET /tokens is the owner's only
|
|
27
|
+
// view of what exists, and without this a self-service token is
|
|
28
|
+
// indistinguishable from one the owner minted deliberately — so "revoke
|
|
29
|
+
// everything the handshake handed out" would be an unanswerable question.
|
|
30
|
+
const AUTH_TOKEN_LABEL = 'did-challenge';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `{ error: { code, message } }` for the four handshake-specific codes,
|
|
34
|
+
* deliberately outside WireErrorCode — no Stack operation has begun at
|
|
35
|
+
* this point, so none of these maps to a StackError. See
|
|
36
|
+
* docs/spec/wire-format.md § Authentication.
|
|
37
|
+
*/
|
|
38
|
+
function authError(c: Context<AppEnv>, code: WireAuthErrorCode, message: string): Response {
|
|
39
|
+
return c.json({ error: { code, message } }, WIRE_AUTH_ERROR_STATUS[code] as ContentfulStatusCode);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function authRoutes(ctx: StackContext, authOrigin: string, logger: Logger): Hono<AppEnv> {
|
|
43
|
+
const app = new Hono<AppEnv>();
|
|
44
|
+
const sweepExpiredTokens = createExpiredTokenSweeper(ctx.tokens, logger);
|
|
45
|
+
|
|
46
|
+
// POST /auth/challenge — issues a nonce bound to the requested DID.
|
|
47
|
+
// Unauthenticated: this is how a token is earned in the first place.
|
|
48
|
+
app.post('/challenge', async (c) => {
|
|
49
|
+
const body = await readJson<Partial<AuthChallengeRequest>>(c);
|
|
50
|
+
if (typeof body.did !== 'string' || !isValidDidKey(body.did)) {
|
|
51
|
+
return authError(c, 'invalid_did', 'Not a valid did:key');
|
|
52
|
+
}
|
|
53
|
+
const { nonce, expiresAt } = ctx.nonces.issue(body.did);
|
|
54
|
+
const response: AuthChallengeResponse = { nonce, expiresAt: expiresAt.toISOString() };
|
|
55
|
+
return c.json(response);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// POST /auth/token — redeems a signed nonce for a bearer token.
|
|
59
|
+
// Unauthenticated for the same reason.
|
|
60
|
+
app.post('/token', async (c) => {
|
|
61
|
+
const body = await readJson<Partial<AuthTokenRequest>>(c);
|
|
62
|
+
if (typeof body.did !== 'string' || !isValidDidKey(body.did)) {
|
|
63
|
+
return authError(c, 'invalid_did', 'Not a valid did:key');
|
|
64
|
+
}
|
|
65
|
+
// Checked ahead of the signature so a nonce issued to a different DID
|
|
66
|
+
// is refused as unknown_nonce even when the caller's own signature over
|
|
67
|
+
// it is perfectly valid — the nonce belongs to the DID it was issued
|
|
68
|
+
// for, not to whoever can produce a valid signature.
|
|
69
|
+
if (typeof body.nonce !== 'string') {
|
|
70
|
+
return authError(c, 'unknown_nonce', 'Unknown or already-used nonce');
|
|
71
|
+
}
|
|
72
|
+
if (typeof body.signature !== 'string') {
|
|
73
|
+
return authError(c, 'invalid_signature', 'Signature does not verify');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Spent on this call regardless of what happens next — single-use
|
|
77
|
+
// means a redemption attempt consumes the nonce, not merely a
|
|
78
|
+
// successful one.
|
|
79
|
+
const result = ctx.nonces.redeem(body.did, body.nonce);
|
|
80
|
+
if (result === 'unknown') return authError(c, 'unknown_nonce', 'Unknown or already-used nonce');
|
|
81
|
+
if (result === 'expired') return authError(c, 'expired_nonce', 'Challenge has expired');
|
|
82
|
+
|
|
83
|
+
let signatureBytes: Uint8Array;
|
|
84
|
+
try {
|
|
85
|
+
signatureBytes = base64urlDecode(body.signature);
|
|
86
|
+
} catch {
|
|
87
|
+
return authError(c, 'invalid_signature', 'Signature does not verify');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Built from our own configured public origin (authOrigin), never from
|
|
91
|
+
// a request header — Host/X-Forwarded-Host are client-controlled, and
|
|
92
|
+
// deriving the signed origin from either would let a client choose
|
|
93
|
+
// which origin it signs for, silently reopening the relay attack the
|
|
94
|
+
// origin binding exists to prevent.
|
|
95
|
+
const verified = await verifyAuthChallenge(
|
|
96
|
+
{ origin: authOrigin, did: body.did, nonce: body.nonce },
|
|
97
|
+
signatureBytes,
|
|
98
|
+
).catch(() => false);
|
|
99
|
+
if (!verified) return authError(c, 'invalid_signature', 'Signature does not verify');
|
|
100
|
+
|
|
101
|
+
// Reclaim expired rows before adding one. Throttled internally, so this
|
|
102
|
+
// is a no-op on all but the first handshake of each interval.
|
|
103
|
+
await sweepExpiredTokens();
|
|
104
|
+
|
|
105
|
+
// Undelegated by construction: principalId and subjectId are both the
|
|
106
|
+
// proven DID. Never let a client name its own subject.
|
|
107
|
+
const expiresAt = new Date(Date.now() + AUTH_TOKEN_TTL_MS);
|
|
108
|
+
const { token } = await ctx.tokens.createToken(body.did, {
|
|
109
|
+
expiresAt,
|
|
110
|
+
label: AUTH_TOKEN_LABEL,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Reported from the value just written rather than read back through
|
|
114
|
+
// listTokens(), an unpaginated scan of every token ever issued — on an
|
|
115
|
+
// unauthenticated route that makes each handshake cost more than the
|
|
116
|
+
// last. The store persists the Date unchanged, so a read-back could
|
|
117
|
+
// only return what is already here.
|
|
118
|
+
const response: AuthTokenResponse = {
|
|
119
|
+
token,
|
|
120
|
+
expiresAt: expiresAt.toISOString(),
|
|
121
|
+
principalId: body.did,
|
|
122
|
+
subjectId: body.did,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The one place a bearer token comes into existence without the owner
|
|
126
|
+
// doing anything, so it is the one place a log line is the only record
|
|
127
|
+
// that it happened. errorMiddleware already logs denied-but-verified
|
|
128
|
+
// requests; this is its counterpart on the granting side.
|
|
129
|
+
logger.info(
|
|
130
|
+
{ requestId: c.get('requestId'), did: body.did, expiresAt: expiresAt.toISOString() },
|
|
131
|
+
'Issued a token via the DID challenge-response handshake',
|
|
132
|
+
);
|
|
133
|
+
return c.json(response);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return app;
|
|
137
|
+
}
|