@cosmicdrift/kumiko-bundled-features 0.286.0 → 0.288.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.
Files changed (28) hide show
  1. package/package.json +12 -9
  2. package/src/auth-email-password/signed-token.ts +4 -86
  3. package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +186 -0
  4. package/src/billing-foundation/changes.json +6 -0
  5. package/src/billing-foundation/subscription-tier-sync.ts +9 -4
  6. package/src/derivatives-sharp/__tests__/render.test.ts +246 -1
  7. package/src/derivatives-sharp/changes.json +8 -1
  8. package/src/derivatives-sharp/render.ts +172 -5
  9. package/src/file-derivatives/__tests__/public-variant-route.integration.test.ts +15 -1
  10. package/src/file-derivatives/changes.json +6 -0
  11. package/src/file-derivatives/feature.ts +9 -1
  12. package/src/shared/__tests__/row-bound-grant.integration.test.ts +184 -0
  13. package/src/shared/changes.json +8 -1
  14. package/src/shared/index.ts +12 -0
  15. package/src/shared/row-bound-grant.test.ts +294 -0
  16. package/src/shared/row-bound-grant.ts +115 -0
  17. package/src/{auth-email-password/__tests__ → shared}/signed-token.test.ts +1 -1
  18. package/src/shared/signed-token.ts +101 -0
  19. package/src/tenant/seeding.ts +4 -0
  20. package/src/user-data-rights/__tests__/deletion-token-compat.test.ts +35 -0
  21. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +90 -1
  22. package/src/user-data-rights/changes.json +12 -0
  23. package/src/user-data-rights/deletion-token.ts +41 -47
  24. package/src/user-data-rights/feature.ts +27 -0
  25. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +10 -20
  26. package/src/user-data-rights/run-export-jobs.ts +69 -1
  27. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +198 -1
  28. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +76 -6
@@ -5,9 +5,12 @@
5
5
  import type {
6
6
  BlurRegion,
7
7
  DerivativeRendererPlugin,
8
+ OverlayGravity,
9
+ ResolvedOverlayLayer,
8
10
  VariantFit,
9
11
  VariantSpec,
10
12
  } from "@cosmicdrift/kumiko-types/derivatives-types";
13
+ import QRCode from "qrcode";
11
14
  import type { OverlayOptions, ResizeOptions, Sharp } from "sharp";
12
15
  import sharp from "sharp";
13
16
 
@@ -25,6 +28,16 @@ const MAX_OUTPUT_EDGE = 8192;
25
28
  // unbounded region list is a CPU/RAM-multiplying DoS knob independent of
26
29
  // output size.
27
30
  const MAX_BLUR_REGIONS = 64;
31
+ // Every layer decodes and resizes separately before the composite — an
32
+ // unbounded list is the same CPU/RAM multiplier MAX_BLUR_REGIONS caps.
33
+ const MAX_OVERLAY_LAYERS = 8;
34
+ // Layer bytes ride inside the spec, which is hashed on every variant() call.
35
+ const MAX_OVERLAY_IMAGE_BYTES = 512 * 1024;
36
+ const MAX_QR_DATA_LENGTH = 1024;
37
+ // Below roughly this, a QR's modules land on too few pixels to survive
38
+ // camera capture — the AC claims phone-scannable, so an under-sized layer
39
+ // throws instead of caching a silently unusable image.
40
+ const MIN_QR_PIXEL_WIDTH = 160;
28
41
  // sequentialRead trades random pixel access for lower peak memory on
29
42
  // streamed formats — applied at every decode entry point below since none
30
43
  // of them need non-sequential access to the *original* bytes (extract runs
@@ -39,6 +52,18 @@ function clampSigma(sigma: number): number {
39
52
  return Math.min(MAX_SHARP_SIGMA, Math.max(MIN_SHARP_SIGMA, sigma));
40
53
  }
41
54
 
55
+ // Shared trust boundary for every image byte source this renderer decodes —
56
+ // the original upload (renderImage) and an overlay layer's bytes (applyOverlays)
57
+ // alike: a base64 string in a field declaration isn't more trustworthy than
58
+ // an upload just because it came from app code.
59
+ function assertNotSvg(format: string | undefined): void {
60
+ if (format === "svg") {
61
+ throw new Error(
62
+ "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
63
+ );
64
+ }
65
+ }
66
+
42
67
  function assertValidRegion(region: BlurRegion): void {
43
68
  const values = [region.x, region.y, region.width, region.height];
44
69
  if (values.some((v) => !Number.isFinite(v)) || region.width <= 0 || region.height <= 0) {
@@ -86,6 +111,102 @@ async function applyBlurRegions(
86
111
  return sharp(data, SHARP_INPUT_OPTIONS).composite(overlays).toBuffer();
87
112
  }
88
113
 
114
+ // Placement is anchored to the OUTPUT box (overlays run after resize, see
115
+ // applyOverlays), then clamped the same way applyBlurRegions clamps its
116
+ // region coordinates — a caller-declared marginPct can't push a layer
117
+ // off-canvas.
118
+ function overlayPosition(
119
+ gravity: OverlayGravity,
120
+ marginPx: number,
121
+ outputWidth: number,
122
+ outputHeight: number,
123
+ layerWidth: number,
124
+ layerHeight: number,
125
+ ): { left: number; top: number } {
126
+ let left: number;
127
+ let top: number;
128
+ switch (gravity) {
129
+ case "north-west":
130
+ left = marginPx;
131
+ top = marginPx;
132
+ break;
133
+ case "north-east":
134
+ left = outputWidth - layerWidth - marginPx;
135
+ top = marginPx;
136
+ break;
137
+ case "south-west":
138
+ left = marginPx;
139
+ top = outputHeight - layerHeight - marginPx;
140
+ break;
141
+ case "south-east":
142
+ left = outputWidth - layerWidth - marginPx;
143
+ top = outputHeight - layerHeight - marginPx;
144
+ break;
145
+ case "center":
146
+ left = (outputWidth - layerWidth) / 2;
147
+ top = (outputHeight - layerHeight) / 2;
148
+ break;
149
+ default:
150
+ throw new Error(`derivatives-sharp: unknown overlay gravity "${gravity satisfies never}".`);
151
+ }
152
+ const maxLeft = Math.max(0, outputWidth - layerWidth);
153
+ const maxTop = Math.max(0, outputHeight - layerHeight);
154
+ return {
155
+ left: Math.min(Math.max(Math.round(left), 0), maxLeft),
156
+ top: Math.min(Math.max(Math.round(top), 0), maxTop),
157
+ };
158
+ }
159
+
160
+ // Runs AFTER resize (see the AC in renderImage) so a layer sized as a
161
+ // fraction of the output is a fraction of what the caller actually gets,
162
+ // regardless of the source's own dimensions or `fit` crop.
163
+ async function applyOverlays(
164
+ pipeline: Sharp,
165
+ layers: readonly ResolvedOverlayLayer[],
166
+ ): Promise<Sharp> {
167
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
168
+
169
+ const composites: OverlayOptions[] = [];
170
+ for (const layer of layers) {
171
+ const layerBytes =
172
+ layer.kind === "qr"
173
+ ? await QRCode.toBuffer(layer.data, { margin: 1, errorCorrectionLevel: "M" })
174
+ : Buffer.from(layer.imageBase64, "base64");
175
+
176
+ const layerMeta = await sharp(layerBytes, SHARP_INPUT_OPTIONS).metadata();
177
+ assertNotSvg(layerMeta.format);
178
+
179
+ const targetWidth = Math.max(1, Math.round(info.width * layer.widthPct));
180
+ if (layer.kind === "qr" && targetWidth < MIN_QR_PIXEL_WIDTH) {
181
+ throw new Error(
182
+ `derivatives-sharp: qr overlay would render at ${targetWidth}px wide, below the ${MIN_QR_PIXEL_WIDTH}px minimum a camera can reliably scan.`,
183
+ );
184
+ }
185
+
186
+ // PNG (not the layer's own format) to keep any alpha through the resize.
187
+ // height caps the other axis too — sharp's composite() rejects an input
188
+ // larger than the base image, which a tall/square layer would otherwise
189
+ // hit once widthPct alone drives its width past the output's height.
190
+ const resized = await sharp(layerBytes, SHARP_INPUT_OPTIONS)
191
+ .resize({ width: targetWidth, height: info.height, fit: "inside" })
192
+ .png()
193
+ .toBuffer({ resolveWithObject: true });
194
+
195
+ const marginPx = Math.round(info.width * (layer.marginPct ?? 0));
196
+ const { left, top } = overlayPosition(
197
+ layer.gravity,
198
+ marginPx,
199
+ info.width,
200
+ info.height,
201
+ resized.info.width,
202
+ resized.info.height,
203
+ );
204
+ composites.push({ input: resized.data, left, top });
205
+ }
206
+
207
+ return sharp(data, SHARP_INPUT_OPTIONS).composite(composites);
208
+ }
209
+
89
210
  function buildResizeOptions(width: number, height: number, fit: VariantFit): ResizeOptions {
90
211
  const options: ResizeOptions = { width, height, fit };
91
212
  if (fit === "inside") {
@@ -153,6 +274,51 @@ function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string
153
274
  return encode(pipeline, quality);
154
275
  }
155
276
 
277
+ // Split out of assertRenderSpecBounds (not inlined there) purely to stay
278
+ // under the AST-guard complexity budget — see assertRenderSpecBounds's own
279
+ // comment. Still the single call site for every overlay bound.
280
+ function assertOverlayLayerBounds(layer: ResolvedOverlayLayer): void {
281
+ if (!Number.isFinite(layer.widthPct) || layer.widthPct <= 0 || layer.widthPct > 1) {
282
+ throw new Error(
283
+ `derivatives-sharp: overlay widthPct ${layer.widthPct} is out of range — must be a finite number in (0, 1].`,
284
+ );
285
+ }
286
+ if (layer.marginPct !== undefined && (layer.marginPct < 0 || layer.marginPct >= 0.5)) {
287
+ throw new Error(
288
+ `derivatives-sharp: overlay marginPct ${layer.marginPct} is out of range — must be within [0, 0.5).`,
289
+ );
290
+ }
291
+ if (layer.kind === "qr") {
292
+ if (layer.data.length === 0 || layer.data.length > MAX_QR_DATA_LENGTH) {
293
+ throw new Error(
294
+ `derivatives-sharp: qr overlay data length ${layer.data.length} is out of range — must be 1–${MAX_QR_DATA_LENGTH} characters.`,
295
+ );
296
+ }
297
+ // skip: qr layers have no imageBase64 field to validate; this return also
298
+ // narrows the union below to the image variant without a cast.
299
+ return;
300
+ }
301
+ const byteLength = Buffer.byteLength(layer.imageBase64, "base64");
302
+ if (byteLength > MAX_OVERLAY_IMAGE_BYTES) {
303
+ throw new Error(
304
+ `derivatives-sharp: overlay image is ${byteLength} bytes, exceeding the limit of ${MAX_OVERLAY_IMAGE_BYTES}.`,
305
+ );
306
+ }
307
+ }
308
+
309
+ function assertOverlayBounds(layers: readonly ResolvedOverlayLayer[] | undefined): void {
310
+ // skip: no overlays configured for this variant, nothing to validate
311
+ if (!layers) return;
312
+ if (layers.length > MAX_OVERLAY_LAYERS) {
313
+ throw new Error(
314
+ `derivatives-sharp: overlays has ${layers.length} entries, exceeding the limit of ${MAX_OVERLAY_LAYERS} — each layer decodes and resizes separately before the composite.`,
315
+ );
316
+ }
317
+ for (const layer of layers) {
318
+ assertOverlayLayerBounds(layer);
319
+ }
320
+ }
321
+
156
322
  // Input-validation for the DoS caps above — kept out of renderImage so the
157
323
  // complexity hotspot stays under the AST-guard budget.
158
324
  function assertRenderSpecBounds(spec: VariantSpec): void {
@@ -181,6 +347,7 @@ function assertRenderSpecBounds(spec: VariantSpec): void {
181
347
  );
182
348
  }
183
349
  }
350
+ assertOverlayBounds(spec.resolvedOverlays);
184
351
  }
185
352
 
186
353
  export const renderImage: DerivativeRendererPlugin["render"] = async (
@@ -194,11 +361,7 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
194
361
  // the SVG rejection from the actual bytes so a mislabeled upload can't
195
362
  // reach libvips as SVG.
196
363
  const sniffed = await sharp(input, SHARP_INPUT_OPTIONS).metadata();
197
- if (sniffed.format === "svg") {
198
- throw new Error(
199
- "derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
200
- );
201
- }
364
+ assertNotSvg(sniffed.format);
202
365
 
203
366
  assertRenderSpecBounds(spec);
204
367
 
@@ -225,6 +388,10 @@ export const renderImage: DerivativeRendererPlugin["render"] = async (
225
388
  pipeline = pipeline.blur(spec.blur);
226
389
  }
227
390
 
391
+ if (spec.resolvedOverlays && spec.resolvedOverlays.length > 0) {
392
+ pipeline = await applyOverlays(pipeline, spec.resolvedOverlays);
393
+ }
394
+
228
395
  pipeline = applyEncoder(pipeline, spec, normalizedSource);
229
396
 
230
397
  const buffer = await pipeline.toBuffer();
@@ -77,9 +77,23 @@ const widgetEntity = createEntity({
77
77
  },
78
78
  });
79
79
 
80
+ // Registered as a real entity (so upload's registry-hardening resolves it)
81
+ // but with no EXT_DERIVATIVE_PUBLIC_PREDICATE registration — the "no
82
+ // predicate registered for this entityType" default-deny case below needs
83
+ // an entity that upload can attach to, distinct from "the entityType string
84
+ // isn't registered at all" (which upload now rejects before the file ever
85
+ // reaches file_refs, see kumiko-framework#3005).
86
+ const noPredicateEntity = createEntity({
87
+ table: "public_variant_no_predicate",
88
+ fields: {
89
+ img: createImageField({ variants: { thumb: { maxEdge: 160, format: "webp" } } }),
90
+ },
91
+ });
92
+
80
93
  let predicateCalls = 0;
81
94
  const widgetPredicateFeature = defineFeature("publicvariantroutetest", (r) => {
82
95
  r.entity("widget", widgetEntity);
96
+ r.entity("nopredicate", noPredicateEntity);
83
97
  r.useExtension(EXT_DERIVATIVE_PUBLIC_PREDICATE, "widget", {
84
98
  isPublic: (args: DerivativePublicPredicateArgs) => {
85
99
  predicateCalls++;
@@ -161,7 +175,7 @@ async function uploadFile(
161
175
 
162
176
  describe("GET /media/:fileRefId/:variant (anonymous, default-deny)", () => {
163
177
  test("no predicate registered for the FileRef's entityType → 404", async () => {
164
- const fileId = await uploadFile(userA, { entityType: "unregistered-type", entityId: "x" });
178
+ const fileId = await uploadFile(userA, { entityType: "nopredicate", entityId: "x" });
165
179
 
166
180
  const res = await stack.app.request(`http://${HOST_A}/media/${fileId}/thumb`);
167
181
 
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.287.0",
4
+ "type": "improvement",
5
+ "title": "Add overlay stage (QR codes, badge images) to derived image variants",
6
+ "detail": "VariantSpec now supports declarative overlay layers (qr via a resolver-token, or a base64 image) composited onto derived image variants. QR values are never free text — only a dataToken resolved server-side via a new extension point, keeping the anonymous derivative route from becoming an open image generator."
7
+ },
2
8
  {
3
9
  "version": "0.194.0",
4
10
  "type": "breaking",
@@ -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
+ });
@@ -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
+ ]
@@ -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";