@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.287.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/package.json +12 -9
- package/src/auth-email-password/signed-token.ts +4 -86
- package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
- package/src/derivatives-sharp/changes.json +8 -1
- package/src/derivatives-sharp/render.ts +172 -5
- package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +9 -1
- package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
- package/src/shared/changes.json +8 -1
- package/src/shared/index.ts +12 -0
- package/src/shared/row-bound-grant.test.ts +294 -0
- package/src/shared/row-bound-grant.ts +115 -0
- package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
- package/src/shared/signed-token.ts +101 -0
- package/src/tenant/seeding.ts +4 -0
- package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
- package/src/user-data-rights/changes.json +12 -0
- package/src/user-data-rights/deletion-token.ts +41 -47
- package/src/user-data-rights/feature.ts +27 -0
- package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
- package/src/user-data-rights/run-export-jobs.ts +69 -1
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { cachedResponse, computeRevisionEtag } from "@cosmicdrift/kumiko-framework/api";
|
|
10
10
|
import {
|
|
11
11
|
defineFeature,
|
|
12
|
+
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
12
13
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
13
14
|
EXT_DERIVATIVE_RENDERER,
|
|
14
15
|
type FeatureDefinition,
|
|
@@ -78,7 +79,7 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
78
79
|
|
|
79
80
|
return defineFeature(FEATURE_NAME, (r) => {
|
|
80
81
|
r.describe(
|
|
81
|
-
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it.",
|
|
82
|
+
"Declares the `derivativeRenderer` extension point. `ctx.derivatives.variant(fileRefId, spec, name)` derives a variant of a tracked FileRef the first time it's requested and reuses the stored result afterwards (derive-on-first-use, keyed by a hash of the spec). Mount at least one `derivatives-*` renderer feature alongside this one — without a registered renderer for the FileRef's MIME type, every `variant(...)` call throws. Also declares the `derivativePublicPredicate` extension point (`r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, '<entityType>', { isPublic })`) and, when `createFileDerivativesFeature({resolveApexTenant})` is passed a host-resolver, mounts an anonymous `GET {basePath}/:fileRefId/:variant` route that serves any variant name the FileRef's field declared in its `variants` for a FileRef whose entityType has a registered predicate returning true — default-deny (404) otherwise, same as an unknown FileRef or an undeclared variant name. The route's only rate-limit (`per: \"ip\"`) trusts the first `x-forwarded-for` hop — deployers must ensure their ingress overwrites rather than appends to that header, or the throttle is bypassable by rotating it. Also declares the `derivativeOverlayResolver` extension point (`r.useExtension(EXT_DERIVATIVE_OVERLAY_RESOLVER, '<entityType>', { resolve })`), used to turn a variant's `overlays[].dataToken` into the actual QR payload for that FileRef's entityType before the variant is rendered — a variant declaring a `qr` overlay throws at request-time if no resolver is registered for the FileRef's entityType.",
|
|
82
83
|
);
|
|
83
84
|
r.uiHints({
|
|
84
85
|
displayLabel: "File Derivatives",
|
|
@@ -108,6 +109,13 @@ export function createFileDerivativesFeature(opts: FileDerivativesOptions = {}):
|
|
|
108
109
|
// entityType.
|
|
109
110
|
},
|
|
110
111
|
});
|
|
112
|
+
r.extendsRegistrar(EXT_DERIVATIVE_OVERLAY_RESOLVER, {
|
|
113
|
+
onRegister: () => {
|
|
114
|
+
// No side-effects at register-time — resolution happens in
|
|
115
|
+
// variant(), keyed by the FileRef's entityType, before the spec
|
|
116
|
+
// hash is computed.
|
|
117
|
+
},
|
|
118
|
+
});
|
|
111
119
|
|
|
112
120
|
// Registered ONLY when resolveApexTenant is supplied — r.queryHandler
|
|
113
121
|
// registers publicVariantQuery into the feature's dispatch table as a
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Row-bound grants end-to-end over real /api/write calls without a session:
|
|
2
|
+
// the shape a consumer builds on (anonymous caller created a row, now performs
|
|
3
|
+
// exactly one narrow further write on it). The unit tests fake the anchor
|
|
4
|
+
// store; this one uses a real conditional UPDATE against Postgres, which is
|
|
5
|
+
// the only way to show that `commitAnchor` can actually be atomic and that two
|
|
6
|
+
// simultaneous redemptions of the same grant leave exactly one winner.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { executeRawQuery } from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
12
|
+
import { setupTestStack, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { redeemRowBoundGrant, signRowBoundGrant } from "../row-bound-grant";
|
|
15
|
+
|
|
16
|
+
const TABLE = "row_bound_grant_demo";
|
|
17
|
+
const SECRET = "row-bound-grant-integration-secret";
|
|
18
|
+
const PURPOSE = "demo-enrich";
|
|
19
|
+
const ENRICH = "grantdemo:write:enrich";
|
|
20
|
+
const ROW_ID = "11111111-1111-4111-8111-111111111111";
|
|
21
|
+
const ANCHOR = "22222222-2222-4222-8222-222222222222";
|
|
22
|
+
|
|
23
|
+
// Two /api/write calls fired together still run to completion one after the
|
|
24
|
+
// other, so a plain Promise.all would never open the window a conditional
|
|
25
|
+
// UPDATE exists for. This holds every redeemer between reading the anchor and
|
|
26
|
+
// spending it until `expected` of them have read it — the interleaving itself,
|
|
27
|
+
// deterministic instead of a sleep. The timeout keeps a serialising stack from
|
|
28
|
+
// hanging the suite: it fails the assertion instead.
|
|
29
|
+
function createRaceGate(expected: number, timeoutMs = 2_000) {
|
|
30
|
+
let arrived = 0;
|
|
31
|
+
let open: () => void = () => {};
|
|
32
|
+
const opened = new Promise<void>((resolve) => {
|
|
33
|
+
open = resolve;
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
async wait(): Promise<void> {
|
|
37
|
+
arrived += 1;
|
|
38
|
+
if (arrived >= expected) open();
|
|
39
|
+
await Promise.race([opened, Bun.sleep(timeoutMs)]);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let raceGate: { wait: () => Promise<void> } | null = null;
|
|
45
|
+
|
|
46
|
+
const RAW_REASON =
|
|
47
|
+
"the grant holder has no session, so the anchor spend is a conditional UPDATE " +
|
|
48
|
+
"outside the entity write map";
|
|
49
|
+
|
|
50
|
+
const grantDemoFeature = defineFeature("grantdemo", (r) => {
|
|
51
|
+
r.writeHandler(
|
|
52
|
+
"enrich",
|
|
53
|
+
z.object({ token: z.string().min(1), note: z.string().min(1) }),
|
|
54
|
+
async (event, ctx) => {
|
|
55
|
+
const db = ctx.db.unsafeRaw(RAW_REASON);
|
|
56
|
+
const redeemed = await redeemRowBoundGrant({
|
|
57
|
+
token: event.payload.token,
|
|
58
|
+
purpose: PURPOSE,
|
|
59
|
+
secret: SECRET,
|
|
60
|
+
loadAnchor: async (subject) => {
|
|
61
|
+
const rows = await executeRawQuery<{ anchor: string | null }>(
|
|
62
|
+
db,
|
|
63
|
+
`SELECT anchor FROM ${TABLE} WHERE id = $1`,
|
|
64
|
+
[subject],
|
|
65
|
+
);
|
|
66
|
+
await raceGate?.wait();
|
|
67
|
+
return rows[0]?.anchor ?? null;
|
|
68
|
+
},
|
|
69
|
+
commitAnchor: async (subject, expected) => {
|
|
70
|
+
const rows = await executeRawQuery<{ id: string }>(
|
|
71
|
+
db,
|
|
72
|
+
`UPDATE ${TABLE} SET anchor = NULL, note = $3 WHERE id = $1 AND anchor = $2 RETURNING id`,
|
|
73
|
+
[subject, expected, event.payload.note],
|
|
74
|
+
);
|
|
75
|
+
return rows.length === 1;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (!redeemed.ok) return writeFailure(new UnprocessableError("invalid_or_expired_grant"));
|
|
79
|
+
return { isSuccess: true as const, data: { id: redeemed.subject } };
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
access: { roles: ["anonymous"] },
|
|
83
|
+
escapeHatch: {
|
|
84
|
+
reason:
|
|
85
|
+
"the row is written by an anonymous grant holder through raw SQL, so the write " +
|
|
86
|
+
"cannot go through the entity write map of a tenant-scoped user",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stack: TestStack;
|
|
93
|
+
|
|
94
|
+
function grantFor(anchor: string): string {
|
|
95
|
+
return signRowBoundGrant({
|
|
96
|
+
subject: ROW_ID,
|
|
97
|
+
purpose: PURPOSE,
|
|
98
|
+
anchor,
|
|
99
|
+
ttlMinutes: 30,
|
|
100
|
+
secret: SECRET,
|
|
101
|
+
}).token;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function enrich(token: string, note: string) {
|
|
105
|
+
return stack.http.raw("POST", "/api/write", { type: ENRICH, payload: { token, note } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readRow(): Promise<{ anchor: string | null; note: string | null } | undefined> {
|
|
109
|
+
const rows = await executeRawQuery<{ anchor: string | null; note: string | null }>(
|
|
110
|
+
stack.db,
|
|
111
|
+
`SELECT anchor, note FROM ${TABLE} WHERE id = $1`,
|
|
112
|
+
[ROW_ID],
|
|
113
|
+
);
|
|
114
|
+
return rows[0];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
stack = await setupTestStack({
|
|
119
|
+
features: [grantDemoFeature],
|
|
120
|
+
anonymousAccess: { defaultTenantId: testTenantId(1) },
|
|
121
|
+
});
|
|
122
|
+
await executeRawQuery(
|
|
123
|
+
stack.db,
|
|
124
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (id uuid PRIMARY KEY, anchor uuid, note text)`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterAll(async () => {
|
|
129
|
+
await stack.cleanup();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
raceGate = null;
|
|
134
|
+
await executeRawQuery(stack.db, `DELETE FROM ${TABLE}`);
|
|
135
|
+
await executeRawQuery(stack.db, `INSERT INTO ${TABLE} (id, anchor, note) VALUES ($1, $2, NULL)`, [
|
|
136
|
+
ROW_ID,
|
|
137
|
+
ANCHOR,
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("row-bound grant over anonymous HTTP", () => {
|
|
142
|
+
test("a valid grant performs the one write and spends the anchor", async () => {
|
|
143
|
+
const res = await enrich(grantFor(ANCHOR), "first");
|
|
144
|
+
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(await readRow()).toEqual({ anchor: null, note: "first" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("replaying the same grant fails and leaves the row alone", async () => {
|
|
150
|
+
const token = grantFor(ANCHOR);
|
|
151
|
+
expect((await enrich(token, "first")).status).toBe(200);
|
|
152
|
+
|
|
153
|
+
const replay = await enrich(token, "second");
|
|
154
|
+
|
|
155
|
+
expect(replay.status).toBe(422);
|
|
156
|
+
expect(await readRow()).toEqual({ anchor: null, note: "first" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("two simultaneous redemptions of one grant leave exactly one winner", async () => {
|
|
160
|
+
const token = grantFor(ANCHOR);
|
|
161
|
+
raceGate = createRaceGate(2);
|
|
162
|
+
|
|
163
|
+
const results = await Promise.all([enrich(token, "a"), enrich(token, "b")]);
|
|
164
|
+
|
|
165
|
+
expect(results.map((r) => r.status).sort()).toEqual([200, 422]);
|
|
166
|
+
const row = await readRow();
|
|
167
|
+
expect(row?.anchor).toBeNull();
|
|
168
|
+
expect(["a", "b"]).toContain(row?.note ?? "");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a grant for a superseded anchor fails without spending the live one", async () => {
|
|
172
|
+
const res = await enrich(grantFor("33333333-3333-4333-8333-333333333333"), "stale");
|
|
173
|
+
|
|
174
|
+
expect(res.status).toBe(422);
|
|
175
|
+
expect(await readRow()).toEqual({ anchor: ANCHOR, note: null });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a garbage token is a 422, not a 500 from the row lookup", async () => {
|
|
179
|
+
const res = await enrich("not.a.token", "junk");
|
|
180
|
+
|
|
181
|
+
expect(res.status).toBe(422);
|
|
182
|
+
expect(await readRow()).toEqual({ anchor: ANCHOR, note: null });
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/shared/changes.json
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.287.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "row-bound grants: one short-lived, single-use capability for anonymous writes on one row",
|
|
6
|
+
"detail": "`signed-token.ts` moves from `auth-email-password/` to `shared/` — the mechanism\nwas never email/password specific (user-data-rights already used it). The old\npath re-exports it, so no importer breaks. New subpaths:\n`./shared/signed-token` and `./shared/row-bound-grant`.\n`shared/row-bound-grant.ts` is the new piece. It folds the row's *current*\nanchor (a request id, a status, a version — anything that moves on when the row\nis consumed) into the HMAC purpose on both mint and redeem, so a replayed token\nstops working the moment the row moves on. Single-use semantics without a burn\nkey and without Redis. Minting and redeeming share one purpose-building\nfunction, because an unanchored purpose silently degrades into a bearer token\nvalid for the whole TTL.\nAnchoring alone only closes the replay window once the row has moved on, so\nspending the anchor is part of the primitive, not homework for the caller:\n`redeemRowBoundGrant` takes a mandatory `commitAnchor(subject, expectedAnchor)`\nthat must move the row on atomically (a conditional UPDATE) and report whether\nthis caller won. It runs strictly after verification, so nobody can invalidate a\nrow by naming it with a junk token, and the loser of a race gets the same\nrejection as a forged grant. Skipping it is possible but has to be declared with\na reason (`{ unsafeSkip: { reason } }`), the way the framework handles\n`escapeHatch` — an undeclared skip is how a single-use grant quietly becomes a\nbearer token for the length of its TTL.\nEvery rejection returns a bare `{ ok: false }` with no reason, so a caller\ncannot accidentally turn an anonymous endpoint into a row-existence oracle.\n`user-data-rights`' deletion token now runs on the helper and its hand-rolled\n`peekDeletionTokenUserId` is gone. The tokens stay byte-compatible — a test\npins the wire format against the pre-refactor formula. It declares an\n`unsafeSkip` for the spend: `pendingDeletionRequestId` may only change through\n`updateUserLifecycle`, so a conditional UPDATE there would lose the field on a\nprojection rebuild. Behaviour of that flow is unchanged.\nNote for callers: the subject is not secret. `signToken` puts it in the token\nbody in the clear, so a grant on a row exposes that row's id to whoever holds\nthe link. Where the id itself must stay hidden, use an opaque handle\n(`./shared/single-use-token-store`) instead."
|
|
7
|
+
}
|
|
8
|
+
]
|
package/src/shared/index.ts
CHANGED
|
@@ -20,9 +20,21 @@ export { createLockoutCounter, type LockoutCounterState } from "./lockout-counte
|
|
|
20
20
|
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
21
21
|
export { joinRowParentIsVisible, parentRowIsVisible } from "./parent-visibility";
|
|
22
22
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
23
|
+
export {
|
|
24
|
+
type RowBoundGrantResult,
|
|
25
|
+
redeemRowBoundGrant,
|
|
26
|
+
signRowBoundGrant,
|
|
27
|
+
} from "./row-bound-grant";
|
|
23
28
|
export { sessionField } from "./session-field";
|
|
24
29
|
export { sessionLocaleField } from "./session-locale-field";
|
|
25
30
|
export { sessionTimezoneField } from "./session-timezone-field";
|
|
31
|
+
export {
|
|
32
|
+
peekTokenSubject,
|
|
33
|
+
signToken,
|
|
34
|
+
TokenPurpose,
|
|
35
|
+
type VerifyResult,
|
|
36
|
+
verifyToken,
|
|
37
|
+
} from "./signed-token";
|
|
26
38
|
export { createSingleUseTokenStore } from "./single-use-token-store";
|
|
27
39
|
export type { SystemQueryFn } from "./system-query";
|
|
28
40
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
|
+
import { redeemRowBoundGrant, signRowBoundGrant } from "./row-bound-grant";
|
|
4
|
+
|
|
5
|
+
const SECRET = "test-secret-value";
|
|
6
|
+
const PURPOSE = "waitlist-enrich";
|
|
7
|
+
const SUBJECT = "row-42";
|
|
8
|
+
const NOW = Temporal.Instant.fromEpochMilliseconds(1_700_000_000_000);
|
|
9
|
+
|
|
10
|
+
function grantFor(anchor: string, ttlMinutes = 30): string {
|
|
11
|
+
return signRowBoundGrant({
|
|
12
|
+
subject: SUBJECT,
|
|
13
|
+
purpose: PURPOSE,
|
|
14
|
+
anchor,
|
|
15
|
+
ttlMinutes,
|
|
16
|
+
secret: SECRET,
|
|
17
|
+
now: NOW,
|
|
18
|
+
}).token;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function anchorIs(anchor: string | null): (subject: string) => Promise<string | null> {
|
|
22
|
+
return async () => anchor;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SKIP_COMMIT = { unsafeSkip: { reason: "test covers verification only" } } as const;
|
|
26
|
+
|
|
27
|
+
// Stands in for a conditional UPDATE ... WHERE anchor = expected: the first
|
|
28
|
+
// caller to spend the live anchor wins, everyone after it gets false.
|
|
29
|
+
function spendableAnchor(initial: string) {
|
|
30
|
+
let current: string | null = initial;
|
|
31
|
+
return {
|
|
32
|
+
load: async () => current,
|
|
33
|
+
commit: async (_subject: string, expected: string) => {
|
|
34
|
+
if (current !== expected) return false;
|
|
35
|
+
current = null;
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("redeemRowBoundGrant", () => {
|
|
42
|
+
test("accepts a grant whose row still carries the anchor it was minted for", async () => {
|
|
43
|
+
const result = await redeemRowBoundGrant({
|
|
44
|
+
token: grantFor("pending"),
|
|
45
|
+
purpose: PURPOSE,
|
|
46
|
+
secret: SECRET,
|
|
47
|
+
loadAnchor: anchorIs("pending"),
|
|
48
|
+
commitAnchor: SKIP_COMMIT,
|
|
49
|
+
now: NOW,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.ok).toBe(true);
|
|
53
|
+
expect(result.ok && result.subject).toBe(SUBJECT);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("passes the token's subject to loadAnchor so the row is looked up by it", async () => {
|
|
57
|
+
const seen: string[] = [];
|
|
58
|
+
|
|
59
|
+
await redeemRowBoundGrant({
|
|
60
|
+
token: grantFor("pending"),
|
|
61
|
+
purpose: PURPOSE,
|
|
62
|
+
secret: SECRET,
|
|
63
|
+
loadAnchor: async (subject) => {
|
|
64
|
+
seen.push(subject);
|
|
65
|
+
return "pending";
|
|
66
|
+
},
|
|
67
|
+
commitAnchor: SKIP_COMMIT,
|
|
68
|
+
now: NOW,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(seen).toEqual([SUBJECT]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("rejects a replayed grant once the row moved on to another anchor", async () => {
|
|
75
|
+
const token = grantFor("pending");
|
|
76
|
+
|
|
77
|
+
const first = await redeemRowBoundGrant({
|
|
78
|
+
token,
|
|
79
|
+
purpose: PURPOSE,
|
|
80
|
+
secret: SECRET,
|
|
81
|
+
loadAnchor: anchorIs("pending"),
|
|
82
|
+
commitAnchor: SKIP_COMMIT,
|
|
83
|
+
now: NOW,
|
|
84
|
+
});
|
|
85
|
+
const replayed = await redeemRowBoundGrant({
|
|
86
|
+
token,
|
|
87
|
+
purpose: PURPOSE,
|
|
88
|
+
secret: SECRET,
|
|
89
|
+
loadAnchor: anchorIs("enriched"),
|
|
90
|
+
commitAnchor: SKIP_COMMIT,
|
|
91
|
+
now: NOW,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(first.ok).toBe(true);
|
|
95
|
+
expect(replayed.ok).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("rejects when the row has no anchor at all", async () => {
|
|
99
|
+
const result = await redeemRowBoundGrant({
|
|
100
|
+
token: grantFor("pending"),
|
|
101
|
+
purpose: PURPOSE,
|
|
102
|
+
secret: SECRET,
|
|
103
|
+
loadAnchor: anchorIs(null),
|
|
104
|
+
commitAnchor: SKIP_COMMIT,
|
|
105
|
+
now: NOW,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.ok).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("rejects instead of surfacing a throwing row lookup", async () => {
|
|
112
|
+
const result = await redeemRowBoundGrant({
|
|
113
|
+
token: grantFor("pending"),
|
|
114
|
+
purpose: PURPOSE,
|
|
115
|
+
secret: SECRET,
|
|
116
|
+
loadAnchor: async () => {
|
|
117
|
+
throw new Error("invalid input syntax for type uuid");
|
|
118
|
+
},
|
|
119
|
+
commitAnchor: SKIP_COMMIT,
|
|
120
|
+
now: NOW,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects an expired grant", async () => {
|
|
127
|
+
const result = await redeemRowBoundGrant({
|
|
128
|
+
token: grantFor("pending", 30),
|
|
129
|
+
purpose: PURPOSE,
|
|
130
|
+
secret: SECRET,
|
|
131
|
+
loadAnchor: anchorIs("pending"),
|
|
132
|
+
commitAnchor: SKIP_COMMIT,
|
|
133
|
+
now: NOW.add({ minutes: 31 }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.ok).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("rejects a grant redeemed against a different purpose", async () => {
|
|
140
|
+
const result = await redeemRowBoundGrant({
|
|
141
|
+
token: grantFor("pending"),
|
|
142
|
+
purpose: "waitlist-delete",
|
|
143
|
+
secret: SECRET,
|
|
144
|
+
loadAnchor: anchorIs("pending"),
|
|
145
|
+
commitAnchor: SKIP_COMMIT,
|
|
146
|
+
now: NOW,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result.ok).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("rejects a tampered signature", async () => {
|
|
153
|
+
const [subject, expiresAt] = grantFor("pending").split(".");
|
|
154
|
+
|
|
155
|
+
const result = await redeemRowBoundGrant({
|
|
156
|
+
token: `${subject}.${expiresAt}.YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY`,
|
|
157
|
+
purpose: PURPOSE,
|
|
158
|
+
secret: SECRET,
|
|
159
|
+
loadAnchor: anchorIs("pending"),
|
|
160
|
+
commitAnchor: SKIP_COMMIT,
|
|
161
|
+
now: NOW,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(result.ok).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("rejects a grant minted with a different secret", async () => {
|
|
168
|
+
const foreign = signRowBoundGrant({
|
|
169
|
+
subject: SUBJECT,
|
|
170
|
+
purpose: PURPOSE,
|
|
171
|
+
anchor: "pending",
|
|
172
|
+
ttlMinutes: 30,
|
|
173
|
+
secret: "someone-elses-secret",
|
|
174
|
+
now: NOW,
|
|
175
|
+
}).token;
|
|
176
|
+
|
|
177
|
+
const result = await redeemRowBoundGrant({
|
|
178
|
+
token: foreign,
|
|
179
|
+
purpose: PURPOSE,
|
|
180
|
+
secret: SECRET,
|
|
181
|
+
loadAnchor: anchorIs("pending"),
|
|
182
|
+
commitAnchor: SKIP_COMMIT,
|
|
183
|
+
now: NOW,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.ok).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("rejects without touching the row when no secret is configured", async () => {
|
|
190
|
+
let looked = false;
|
|
191
|
+
|
|
192
|
+
const result = await redeemRowBoundGrant({
|
|
193
|
+
token: grantFor("pending"),
|
|
194
|
+
purpose: PURPOSE,
|
|
195
|
+
secret: undefined,
|
|
196
|
+
loadAnchor: async () => {
|
|
197
|
+
looked = true;
|
|
198
|
+
return "pending";
|
|
199
|
+
},
|
|
200
|
+
commitAnchor: SKIP_COMMIT,
|
|
201
|
+
now: NOW,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result.ok).toBe(false);
|
|
205
|
+
expect(looked).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("rejects a malformed token without touching the row", async () => {
|
|
209
|
+
let looked = false;
|
|
210
|
+
|
|
211
|
+
const result = await redeemRowBoundGrant({
|
|
212
|
+
token: "not-a-token",
|
|
213
|
+
purpose: PURPOSE,
|
|
214
|
+
secret: SECRET,
|
|
215
|
+
loadAnchor: async () => {
|
|
216
|
+
looked = true;
|
|
217
|
+
return "pending";
|
|
218
|
+
},
|
|
219
|
+
commitAnchor: SKIP_COMMIT,
|
|
220
|
+
now: NOW,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(result.ok).toBe(false);
|
|
224
|
+
expect(looked).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("only one of two simultaneous redemptions of the same grant wins", async () => {
|
|
228
|
+
const row = spendableAnchor("pending");
|
|
229
|
+
const token = grantFor("pending");
|
|
230
|
+
const redeem = () =>
|
|
231
|
+
redeemRowBoundGrant({
|
|
232
|
+
token,
|
|
233
|
+
purpose: PURPOSE,
|
|
234
|
+
secret: SECRET,
|
|
235
|
+
loadAnchor: row.load,
|
|
236
|
+
commitAnchor: row.commit,
|
|
237
|
+
now: NOW,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const [first, second] = await Promise.all([redeem(), redeem()]);
|
|
241
|
+
|
|
242
|
+
expect([first.ok, second.ok].filter(Boolean)).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("does not spend the anchor for a token that fails verification", async () => {
|
|
246
|
+
const row = spendableAnchor("pending");
|
|
247
|
+
|
|
248
|
+
const rejected = await redeemRowBoundGrant({
|
|
249
|
+
token: grantFor("some-other-anchor"),
|
|
250
|
+
purpose: PURPOSE,
|
|
251
|
+
secret: SECRET,
|
|
252
|
+
loadAnchor: row.load,
|
|
253
|
+
commitAnchor: row.commit,
|
|
254
|
+
now: NOW,
|
|
255
|
+
});
|
|
256
|
+
const legitimate = await redeemRowBoundGrant({
|
|
257
|
+
token: grantFor("pending"),
|
|
258
|
+
purpose: PURPOSE,
|
|
259
|
+
secret: SECRET,
|
|
260
|
+
loadAnchor: row.load,
|
|
261
|
+
commitAnchor: row.commit,
|
|
262
|
+
now: NOW,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(rejected.ok).toBe(false);
|
|
266
|
+
expect(legitimate.ok).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("refuses an unsafeSkip without a reason", async () => {
|
|
270
|
+
const redeem = redeemRowBoundGrant({
|
|
271
|
+
token: grantFor("pending"),
|
|
272
|
+
purpose: PURPOSE,
|
|
273
|
+
secret: SECRET,
|
|
274
|
+
loadAnchor: anchorIs("pending"),
|
|
275
|
+
commitAnchor: { unsafeSkip: { reason: " " } },
|
|
276
|
+
now: NOW,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(redeem).rejects.toThrow(/non-empty reason/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("rejects when the anchor can no longer be spent", async () => {
|
|
283
|
+
const result = await redeemRowBoundGrant({
|
|
284
|
+
token: grantFor("pending"),
|
|
285
|
+
purpose: PURPOSE,
|
|
286
|
+
secret: SECRET,
|
|
287
|
+
loadAnchor: anchorIs("pending"),
|
|
288
|
+
commitAnchor: async () => false,
|
|
289
|
+
now: NOW,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(result.ok).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// A short-lived grant that lets a caller without a session perform exactly
|
|
2
|
+
// one named operation on exactly one row — the shape behind
|
|
3
|
+
// user-data-rights' confirm-deletion-by-token and any "you just created this
|
|
4
|
+
// row, now enrich it" flow.
|
|
5
|
+
//
|
|
6
|
+
// The row's own anchor (a value that moves on when the row is consumed: a
|
|
7
|
+
// request id, a status, a version) is mixed INTO the HMAC purpose rather
|
|
8
|
+
// than carried in the token. Verification recomputes it from the row's
|
|
9
|
+
// CURRENT anchor, so a replayed token dies the moment the row moves on —
|
|
10
|
+
// single-use semantics without a burn key or Redis. Minting and redeeming
|
|
11
|
+
// share one purpose-building function so the two can't drift apart; that
|
|
12
|
+
// coupling is the whole point of this module, since an unanchored purpose
|
|
13
|
+
// silently degrades to a bearer token valid for the full TTL.
|
|
14
|
+
//
|
|
15
|
+
// Anchoring alone closes the replay window only AFTER the row has moved on.
|
|
16
|
+
// Two requests redeeming the same grant simultaneously both read the live
|
|
17
|
+
// anchor and both verify, so spending it is a separate, mandatory step:
|
|
18
|
+
// `commitAnchor` must move the row on atomically (a conditional update on
|
|
19
|
+
// the anchor) and report whether this caller was the one who did. Skipping
|
|
20
|
+
// it is possible but has to be declared with a reason, the way the framework
|
|
21
|
+
// handles escapeHatch — an undeclared skip is how a single-use grant quietly
|
|
22
|
+
// becomes a bearer token for the length of its TTL. Put the actual write into
|
|
23
|
+
// the same statement that spends the anchor: a write issued after `ok: true`
|
|
24
|
+
// can lose its work to a crash while the grant is already burned.
|
|
25
|
+
//
|
|
26
|
+
// The subject is NOT secret: signToken puts it in the token body in the
|
|
27
|
+
// clear. A grant on a row therefore exposes that row's id to whoever holds
|
|
28
|
+
// the link. Where the id itself must stay hidden, use an opaque handle
|
|
29
|
+
// (single-use-token-store) instead.
|
|
30
|
+
|
|
31
|
+
import type { Temporal } from "temporal-polyfill";
|
|
32
|
+
import { peekTokenSubject, signToken, verifyToken } from "./signed-token";
|
|
33
|
+
|
|
34
|
+
export type RowBoundGrantResult =
|
|
35
|
+
| { readonly ok: true; readonly subject: string; readonly expiresAtMs: number }
|
|
36
|
+
| { readonly ok: false };
|
|
37
|
+
|
|
38
|
+
// Atomically spends the anchor: must return true only for the single caller
|
|
39
|
+
// that moved the row on from `expectedAnchor`, false for everyone else. A
|
|
40
|
+
// plain "read, check, then write" is not enough — two requests redeeming the
|
|
41
|
+
// same grant at once both read the live anchor and both pass.
|
|
42
|
+
export type AnchorCommit =
|
|
43
|
+
| ((subject: string, expectedAnchor: string) => Promise<boolean>)
|
|
44
|
+
| { readonly unsafeSkip: { readonly reason: string } };
|
|
45
|
+
|
|
46
|
+
const FAILED: RowBoundGrantResult = { ok: false };
|
|
47
|
+
|
|
48
|
+
function anchoredPurpose(purpose: string, anchor: string): string {
|
|
49
|
+
return `${purpose}:${anchor}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function signRowBoundGrant(args: {
|
|
53
|
+
readonly subject: string;
|
|
54
|
+
readonly purpose: string;
|
|
55
|
+
readonly anchor: string;
|
|
56
|
+
readonly ttlMinutes: number;
|
|
57
|
+
readonly secret: string;
|
|
58
|
+
readonly now?: Temporal.Instant;
|
|
59
|
+
}): { readonly token: string; readonly expiresAt: Temporal.Instant } {
|
|
60
|
+
return signToken(
|
|
61
|
+
args.subject,
|
|
62
|
+
anchoredPurpose(args.purpose, args.anchor),
|
|
63
|
+
args.ttlMinutes,
|
|
64
|
+
args.secret,
|
|
65
|
+
args.now,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Every rejection returns the same bare `{ ok: false }` — no reason, by
|
|
70
|
+
// design. A caller that could tell "bad signature" from "no such row" would
|
|
71
|
+
// hand an attacker a row-existence oracle on an endpoint that is open to
|
|
72
|
+
// anonymous callers.
|
|
73
|
+
export async function redeemRowBoundGrant(args: {
|
|
74
|
+
readonly token: string;
|
|
75
|
+
readonly purpose: string;
|
|
76
|
+
readonly secret: string | undefined;
|
|
77
|
+
readonly loadAnchor: (subject: string) => Promise<string | null>;
|
|
78
|
+
readonly commitAnchor: AnchorCommit;
|
|
79
|
+
readonly now?: Temporal.Instant;
|
|
80
|
+
}): Promise<RowBoundGrantResult> {
|
|
81
|
+
if (!args.secret) return FAILED;
|
|
82
|
+
|
|
83
|
+
const subject = peekTokenSubject(args.token);
|
|
84
|
+
if (!subject) return FAILED;
|
|
85
|
+
|
|
86
|
+
// The subject is unverified attacker input at this point, so a lookup that
|
|
87
|
+
// throws on it (e.g. a non-uuid value against a uuid column) must not
|
|
88
|
+
// surface as a 500.
|
|
89
|
+
let anchor: string | null;
|
|
90
|
+
try {
|
|
91
|
+
anchor = await args.loadAnchor(subject);
|
|
92
|
+
} catch {
|
|
93
|
+
return FAILED;
|
|
94
|
+
}
|
|
95
|
+
if (!anchor) return FAILED;
|
|
96
|
+
|
|
97
|
+
const verified = verifyToken(
|
|
98
|
+
args.token,
|
|
99
|
+
anchoredPurpose(args.purpose, anchor),
|
|
100
|
+
args.secret,
|
|
101
|
+
args.now,
|
|
102
|
+
);
|
|
103
|
+
if (!verified.ok) return FAILED;
|
|
104
|
+
|
|
105
|
+
// Spending happens after verification, never before: a caller that burned
|
|
106
|
+
// the anchor on an unverified token could invalidate any row it can name.
|
|
107
|
+
if (typeof args.commitAnchor === "function") {
|
|
108
|
+
const spent = await args.commitAnchor(subject, anchor);
|
|
109
|
+
if (!spent) return FAILED;
|
|
110
|
+
} else if (!args.commitAnchor.unsafeSkip.reason.trim()) {
|
|
111
|
+
throw new Error("row-bound grant: unsafeSkip needs a non-empty reason");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { ok: true, subject, expiresAtMs: verified.expiresAtMs };
|
|
115
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { Temporal } from "temporal-polyfill";
|
|
3
|
-
import { signToken, TokenPurpose, verifyToken } from "
|
|
3
|
+
import { signToken, TokenPurpose, verifyToken } from "./signed-token";
|
|
4
4
|
|
|
5
5
|
const SECRET = "test-hmac-secret-32-bytes-minimum!!";
|
|
6
6
|
const USER_ID = "11111111-1111-4111-8111-111111111111";
|