@cosmicdrift/kumiko-bundled-features 0.60.2 → 0.61.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.60.2",
3
+ "version": "0.61.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -111,6 +111,32 @@ describe("createTagsFeature access-options", () => {
111
111
  });
112
112
  });
113
113
 
114
+ describe("createTagsFeature toggleable-option (tier-gating)", () => {
115
+ test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
116
+ expect(createTagsFeature().toggleableDefault).toBeUndefined();
117
+ expect(createTagsFeature({ access: { openToAll: true } }).toggleableDefault).toBeUndefined();
118
+ });
119
+
120
+ test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
121
+ const feature = createTagsFeature({
122
+ access: { openToAll: true },
123
+ toggleable: { default: false },
124
+ });
125
+ expect(feature.toggleableDefault).toBe(false);
126
+ });
127
+
128
+ test("toggleable:{default:true} declares toggleable, enabled-by-default", () => {
129
+ expect(createTagsFeature({ toggleable: { default: true } }).toggleableDefault).toBe(true);
130
+ });
131
+
132
+ test("toggleable alone (no access/roles) builds a fresh, non-singleton feature", () => {
133
+ const feature = createTagsFeature({ toggleable: { default: false } });
134
+ expect(feature).not.toBe(createTagsFeature());
135
+ // access still defaults when only toggleable is set
136
+ expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
137
+ });
138
+ });
139
+
114
140
  describe("createTagPayloadSchema", () => {
115
141
  test("accepts name only", () => {
116
142
  expect(createTagPayloadSchema.safeParse({ name: "Kunde Müller" }).success).toBe(true);
@@ -28,11 +28,27 @@ import { createAssignTagHandler } from "./handlers/assign-tag.write";
28
28
  import { createCreateTagHandler } from "./handlers/create-tag.write";
29
29
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
30
30
 
31
- function registerTags(r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>, access: AccessRule): void {
31
+ // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
32
+ // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
33
+ // (handlers, queries, hooks) on/off per tenant — no host-side hook. `default`
34
+ // is the enablement when no toggle row / tier override exists. For a tier-gated
35
+ // feature use { default: false } (fail-closed) and list the feature name in the
36
+ // entitling tiers' TierMap; tenants below it get every tag path disabled.
37
+ type TagsToggleable = { readonly default: boolean };
38
+
39
+ function registerTags(
40
+ r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>,
41
+ access: AccessRule,
42
+ toggleable: TagsToggleable | undefined,
43
+ ): void {
32
44
  r.describe(
33
- "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }).",
45
+ "Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
34
46
  );
35
47
 
48
+ // Tier-gating is a framework concern, not a per-app hook: declaring the
49
+ // feature toggleable lets tier-engine/feature-toggles cut it per tenant.
50
+ if (toggleable !== undefined) r.toggleable(toggleable);
51
+
36
52
  r.entity("tag", tagEntity);
37
53
  r.entity("tag-assignment", tagAssignmentEntity);
38
54
 
@@ -45,7 +61,7 @@ function registerTags(r: FeatureRegistrar<typeof TAGS_FEATURE_NAME>, access: Acc
45
61
  }
46
62
 
47
63
  export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
48
- registerTags(r, DEFAULT_TAG_ACCESS),
64
+ registerTags(r, DEFAULT_TAG_ACCESS, undefined),
49
65
  );
50
66
 
51
67
  export type TagsFeatureOptions = {
@@ -56,6 +72,11 @@ export type TagsFeatureOptions = {
56
72
  readonly access?: AccessRule;
57
73
  /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
58
74
  readonly roles?: readonly string[];
75
+ /** Make the whole feature tier-gatable: declares r.toggleable so the
76
+ * tier-engine/feature-toggles can enable/disable every tag path per tenant.
77
+ * `default` applies when no toggle/tier override exists — use { default: false }
78
+ * for fail-closed tier-gating. Omit to keep tags always-on (default). */
79
+ readonly toggleable?: TagsToggleable;
59
80
  };
60
81
 
61
82
  function resolveAccess(opts: TagsFeatureOptions): AccessRule {
@@ -65,11 +86,11 @@ function resolveAccess(opts: TagsFeatureOptions): AccessRule {
65
86
  }
66
87
 
67
88
  // Backwards-compat / options wrapper. Without options returns the module-level
68
- // singleton (no rebuild). access/roles build a fresh feature-definition.
89
+ // singleton (no rebuild). access/roles/toggleable build a fresh feature-definition.
69
90
  export function createTagsFeature(opts: TagsFeatureOptions = {}): typeof tagsFeature {
70
- if (opts.access === undefined && opts.roles === undefined) {
91
+ if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
71
92
  return tagsFeature;
72
93
  }
73
94
  const access = resolveAccess(opts);
74
- return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access));
95
+ return defineFeature(TAGS_FEATURE_NAME, (r) => registerTags(r, access, opts.toggleable));
75
96
  }
@@ -141,6 +141,17 @@ export const userEntity = createEntity({
141
141
  gracePeriodEnd: createTimestampField({
142
142
  access: { write: access.privileged },
143
143
  }),
144
+
145
+ // Replay-Schutz für den anonymen email-Token-Deletion-Flow (#354/1).
146
+ // Gesetzt von request-deletion-by-email (eine UUID pro Mail-Antrag),
147
+ // genullt von cancel-deletion. confirm-deletion-by-token faltet diese ID
148
+ // in die HMAC-Purpose des Tokens — ein nach einem Cancel nachgespieltes
149
+ // (noch TTL-gültiges) Token verifiziert gegen die genullte/erneuerte ID
150
+ // nicht mehr. NULL solange kein email-Antrag offen ist.
151
+ pendingDeletionRequestId: createTextField({
152
+ maxLength: 36,
153
+ access: { write: access.privileged },
154
+ }),
144
155
  },
145
156
  });
146
157
 
@@ -31,6 +31,7 @@ import type { SendDeletionVerificationEmailFn } from "../handlers/request-deleti
31
31
 
32
32
  const REQUEST_BY_EMAIL = "user-data-rights:write:request-deletion-by-email";
33
33
  const CONFIRM_BY_TOKEN = "user-data-rights:write:confirm-deletion-by-token";
34
+ const CANCEL_DELETION = "user-data-rights:write:cancel-deletion";
34
35
  const DELETION_SECRET = "test-deletion-secret-0123456789abcdef";
35
36
  const VERIFY_URL = "https://app.example.test/delete-account/confirm";
36
37
 
@@ -166,8 +167,9 @@ describe("anonymous deletion flow", () => {
166
167
  expect(first.status).toBe(200);
167
168
  expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
168
169
 
169
- // Token ist bewusst replaybar (kein single-use); die Replay-Sicherheit kommt
170
- // allein aus dem Active-State-Guard zweites Confirm trifft non-active → 422.
170
+ // Pre-cancel-Replay: zweites Confirm trifft den noch-pending User
171
+ // (DeletionRequested) der Active-State-Guard schlägt zu → 422. (Den
172
+ // post-cancel-Replay deckt der requestId-Test darunter ab.)
171
173
  const second = await stack.http.raw("POST", "/api/write", {
172
174
  type: CONFIRM_BY_TOKEN,
173
175
  payload: { token },
@@ -187,6 +189,82 @@ describe("anonymous deletion flow", () => {
187
189
  expect(serialized).not.toContain(USER_STATUS.DeletionRequested);
188
190
  });
189
191
 
192
+ test("replay-after-cancel (#354/1): Token nach cancel-deletion re-armt NICHT → 422, bleibt Active", async () => {
193
+ await seedAlice();
194
+ await stack.http.raw("POST", "/api/write", {
195
+ type: REQUEST_BY_EMAIL,
196
+ payload: { email: ALICE_EMAIL },
197
+ });
198
+ const token = tokenFromLastVerifyCall();
199
+
200
+ // 1. Confirm armt die Grace-Period.
201
+ expect(
202
+ (await stack.http.raw("POST", "/api/write", { type: CONFIRM_BY_TOKEN, payload: { token } }))
203
+ .status,
204
+ ).toBe(200);
205
+ expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
206
+
207
+ // 2. User loggt sich (innerhalb der Grace) ein und bricht ab → Active,
208
+ // pendingDeletionRequestId genullt.
209
+ await stack.http.writeOk(CANCEL_DELETION, {}, aliceUser);
210
+ expect(await statusOf()).toBe(USER_STATUS.Active);
211
+
212
+ // 3. Dasselbe, noch TTL-gültige Token nachspielen → die genullte requestId
213
+ // lässt die HMAC-Purpose nicht mehr aufgehen → 422, kein re-arm.
214
+ const replay = await stack.http.raw("POST", "/api/write", {
215
+ type: CONFIRM_BY_TOKEN,
216
+ payload: { token },
217
+ });
218
+ expect(replay.status).toBe(422);
219
+ expect(await statusOf()).toBe(USER_STATUS.Active);
220
+ });
221
+
222
+ test("supersede (#354/1): altes Token re-armt NICHT, nachdem ein zweiter Antrag eine neue requestId setzt", async () => {
223
+ // Der diskriminierende Fall gegen einen presence-only-Check: nach cancel
224
+ // macht ein FRISCHER Antrag den marker wieder non-null (neue requestId).
225
+ // Ein presence-only-Guard würde das alte Token jetzt fälschlich akzeptieren;
226
+ // der requestId-Match lehnt es ab (token1 trägt R1, Row hält R2).
227
+ await seedAlice();
228
+
229
+ await stack.http.raw("POST", "/api/write", {
230
+ type: REQUEST_BY_EMAIL,
231
+ payload: { email: ALICE_EMAIL },
232
+ });
233
+ const token1 = tokenFromLastVerifyCall();
234
+
235
+ await stack.http.raw("POST", "/api/write", {
236
+ type: CONFIRM_BY_TOKEN,
237
+ payload: { token: token1 },
238
+ });
239
+ await stack.http.writeOk(CANCEL_DELETION, {}, aliceUser);
240
+ expect(await statusOf()).toBe(USER_STATUS.Active);
241
+
242
+ // Zweiter Antrag → neue requestId R2 auf der Row + token2.
243
+ verifyCalls.length = 0;
244
+ await stack.http.raw("POST", "/api/write", {
245
+ type: REQUEST_BY_EMAIL,
246
+ payload: { email: ALICE_EMAIL },
247
+ });
248
+ const token2 = tokenFromLastVerifyCall();
249
+ expect(token2).not.toBe(token1);
250
+
251
+ // Altes token1 (R1) gegen Row mit R2 → bad_signature → 422, kein re-arm.
252
+ const replayOld = await stack.http.raw("POST", "/api/write", {
253
+ type: CONFIRM_BY_TOKEN,
254
+ payload: { token: token1 },
255
+ });
256
+ expect(replayOld.status).toBe(422);
257
+ expect(await statusOf()).toBe(USER_STATUS.Active);
258
+
259
+ // Gegenprobe: das aktuelle token2 (R2) armt regulär.
260
+ const confirmNew = await stack.http.raw("POST", "/api/write", {
261
+ type: CONFIRM_BY_TOKEN,
262
+ payload: { token: token2 },
263
+ });
264
+ expect(confirmNew.status).toBe(200);
265
+ expect(await statusOf()).toBe(USER_STATUS.DeletionRequested);
266
+ });
267
+
190
268
  test("request-by-email für nicht-existente Email → success, KEINE Mail (enumeration-safe)", async () => {
191
269
  await seedAlice();
192
270
  const res = await stack.http.raw("POST", "/api/write", {
@@ -221,7 +299,19 @@ describe("anonymous deletion flow", () => {
221
299
 
222
300
  test("confirm mit falsch-signiertem Token → 422", async () => {
223
301
  await seedAlice();
224
- const { token } = signDeletionToken(aliceUser.id, 60, "the-wrong-secret-totally-different");
302
+ // Erst einen echten Antrag stellen, damit eine requestId auf der Row liegt
303
+ // — sonst greift schon der no-outstanding-request-Guard und der Bad-
304
+ // Signature-Pfad würde nie erreicht.
305
+ await stack.http.raw("POST", "/api/write", {
306
+ type: REQUEST_BY_EMAIL,
307
+ payload: { email: ALICE_EMAIL },
308
+ });
309
+ const { token } = signDeletionToken(
310
+ aliceUser.id,
311
+ "forged-request-id",
312
+ 60,
313
+ "the-wrong-secret-totally-different",
314
+ );
225
315
  const res = await stack.http.raw("POST", "/api/write", {
226
316
  type: CONFIRM_BY_TOKEN,
227
317
  payload: { token },
@@ -282,7 +372,7 @@ describe("anonymous deletion flow — not configured (kein Secret)", () => {
282
372
  });
283
373
 
284
374
  test("confirm ohne Secret → 422", async () => {
285
- const { token } = signDeletionToken(aliceUser.id, 60, DELETION_SECRET);
375
+ const { token } = signDeletionToken(aliceUser.id, "req-id", 60, DELETION_SECRET);
286
376
  const res = await bareStack.http.raw("POST", "/api/write", {
287
377
  type: CONFIRM_BY_TOKEN,
288
378
  payload: { token },
@@ -4,13 +4,13 @@
4
4
  // flow, so it reuses the same self-contained token mechanism (no DB row, no
5
5
  // Redis: the userId + expiry are baked into the signed token).
6
6
  //
7
- // The token is NOT single-use: replaying it on a still-pending (non-active)
8
- // user is a no-op (confirm hits non-active cannot_process_deletion). That
9
- // idempotency is only bounded though after a cancel-deletion the user is
10
- // active again and a still-valid token re-arms a second grace period. The
11
- // replay window is bounded by the TTL; the full fix (per-request requestId
12
- // bound into the token + the user row, nulled on cancel) is deferred as review
13
- // finding #354/1 (needs a shared user-entity migration).
7
+ // Replay-after-cancel (#354/1): the per-request `requestId` is folded INTO the
8
+ // HMAC purpose (`deletion-request:<requestId>`), not carried in the token body.
9
+ // The same id is stored on the user row when the request is minted and nulled
10
+ // on cancel. confirm recomputes the HMAC with the row's CURRENT id, so a token
11
+ // from a cancelled cycle (row id nulled) or a superseded one (row holds a newer
12
+ // id) fails verification the bounded-TTL replay window is closed without
13
+ // touching the shared signToken/verifyToken primitive.
14
14
 
15
15
  import type { Temporal } from "temporal-polyfill";
16
16
  import { signToken, verifyToken } from "../auth-email-password";
@@ -21,21 +21,42 @@ export type VerifyResult =
21
21
  | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
22
22
  | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
23
23
 
24
- // @wrapper-known semantic-alias
24
+ function deletionPurpose(requestId: string): string {
25
+ return `${DELETION_REQUEST_PURPOSE}:${requestId}`;
26
+ }
27
+
25
28
  export function signDeletionToken(
26
29
  userId: string,
30
+ requestId: string,
27
31
  ttlMinutes: number,
28
32
  secret: string,
29
33
  now?: Temporal.Instant,
30
34
  ): { token: string; expiresAt: Temporal.Instant } {
31
- return signToken(userId, DELETION_REQUEST_PURPOSE, ttlMinutes, secret, now);
35
+ return signToken(userId, deletionPurpose(requestId), ttlMinutes, secret, now);
32
36
  }
33
37
 
34
- // @wrapper-known semantic-alias
35
38
  export function verifyDeletionToken(
36
39
  token: string,
40
+ requestId: string,
37
41
  secret: string,
38
42
  now?: Temporal.Instant,
39
43
  ): VerifyResult {
40
- return verifyToken(token, DELETION_REQUEST_PURPOSE, secret, now);
44
+ return verifyToken(token, deletionPurpose(requestId), secret, now);
45
+ }
46
+
47
+ // Reads the userId from the token body WITHOUT verifying the HMAC — used only
48
+ // to look up the row's current requestId, which is itself an input to the
49
+ // verification below. The signature is still the gate; this peek never grants
50
+ // trust. Token format is `<userId>.<expiresAtMs>.<sig>`.
51
+ //
52
+ // Mirrors verifyToken's structural malformed-checks so an obviously-bogus token
53
+ // returns null here (→ generic reject) instead of reaching the DB lookup.
54
+ export function peekDeletionTokenUserId(token: string): string | null {
55
+ const parts = token.split(".");
56
+ if (parts.length !== 3) return null;
57
+ const [userId, expiresAtRaw, sig] = parts;
58
+ if (!userId || !expiresAtRaw || !sig) return null;
59
+ const expiresAtMs = Number(expiresAtRaw);
60
+ if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) return null;
61
+ return userId;
41
62
  }
@@ -67,6 +67,10 @@ export const cancelDeletionWrite = defineWriteHandler({
67
67
  {
68
68
  status: USER_STATUS.Active,
69
69
  gracePeriodEnd: null,
70
+ // #354/1: schließt das replay-after-cancel-Fenster — ein noch
71
+ // TTL-gültiges email-Token verifiziert gegen die genullte requestId
72
+ // nicht mehr und kann keine zweite Grace-Period armen.
73
+ pendingDeletionRequestId: null,
70
74
  },
71
75
  { id: event.user.id },
72
76
  );
@@ -1,8 +1,9 @@
1
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineWriteHandler, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
3
  import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
4
  import { z } from "zod";
4
- import { USER_STATUS } from "../../user";
5
- import { verifyDeletionToken } from "../deletion-token";
5
+ import { USER_STATUS, userTable } from "../../user";
6
+ import { peekDeletionTokenUserId, verifyDeletionToken } from "../deletion-token";
6
7
  import { startDeletionGracePeriod } from "./deletion-grace-period";
7
8
 
8
9
  export type ConfirmDeletionByTokenOptions = {
@@ -17,18 +18,35 @@ function invalidToken(): UnprocessableError {
17
18
  });
18
19
  }
19
20
 
21
+ // userId stammt aus dem noch-unverifizierten Token (Angreifer-Eingabe). Ein
22
+ // fehlgeschlagener Lookup — z.B. eine typfremde id auf einer int/uuid-Spalte —
23
+ // darf nicht als 500 durchschlagen; null behandelt der Caller wie "kein offener
24
+ // Antrag" (generischer 422). Die HMAC-Prüfung bleibt der eigentliche Gate.
25
+ async function readPendingDeletionRequestId(
26
+ ctx: HandlerContext,
27
+ userId: string,
28
+ ): Promise<string | null> {
29
+ try {
30
+ const row = await fetchOne<{ pendingDeletionRequestId: string | null }>(ctx.db.raw, userTable, {
31
+ id: userId,
32
+ });
33
+ return row?.["pendingDeletionRequestId"] ?? null;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
20
39
  // Anonymer Apex-Flow Schritt 2: Verify-Link-Target. Verifiziert das
21
40
  // HMAC-Token, extrahiert die userId und startet die Grace-Period über die
22
41
  // geteilte Logik.
23
42
  //
24
- // Idempotenz ist NUR bounded: ein zweites Confirm auf einen noch-pending
25
- // (DeletionRequested) User trifft non-active cannot_process_deletion. ABER
26
- // nach einem cancel-deletion (status Active, gracePeriodEnd null) ist der
27
- // User wieder aktiv; ein noch-gültiges Token (TTL aus request-deletion-by-email)
28
- // re-armt dann eine zweite Grace-Period (replay-after-cancel). Das Risiko ist
29
- // durch die Token-TTL begrenzt; der vollständige Fix (requestId pro Request im
30
- // Token + auf der User-Row, vom cancel genullt) ist als review-finding #354/1
31
- // deferred — er braucht eine Migration der geteilten user-Entity.
43
+ // Replay-Schutz (#354/1): die requestId der Row ist Teil des Verify-Keys. Wir
44
+ // lesen sie über die (unverifizierte, nur-Lookup) userId aus dem Token, lehnen
45
+ // einen fehlenden Eintrag ab und verifizieren das Token gegen die CURRENT
46
+ // requestId. Ein zweites Confirm auf einen noch-pending User trifft zudem
47
+ // non-active cannot_process_deletion. Nach einem cancel-deletion (status
48
+ // Active, pendingDeletionRequestId null) schlägt ein nachgespieltes Token an
49
+ // der genullten/erneuerten requestId fehl kein re-arm mehr.
32
50
  export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByTokenOptions = {}) {
33
51
  return defineWriteHandler({
34
52
  name: "confirm-deletion-by-token",
@@ -38,7 +56,22 @@ export function createConfirmDeletionByTokenHandler(opts: ConfirmDeletionByToken
38
56
  handler: async (event, ctx) => {
39
57
  if (!opts.deletionTokenSecret) return writeFailure(invalidToken());
40
58
 
41
- const verified = verifyDeletionToken(event.payload.token, opts.deletionTokenSecret);
59
+ const peekedUserId = peekDeletionTokenUserId(event.payload.token);
60
+ if (!peekedUserId) return writeFailure(invalidToken());
61
+
62
+ // Die requestId der Row ist Teil des Verify-Keys (HMAC-Purpose). Kein
63
+ // offener Antrag (null) → das Token gehört zu einem abgebrochenen Zyklus
64
+ // → Reject ohne weitere Signal-Preisgabe (gleicher generischer 422). Der
65
+ // peekedUserId ist unverifizierte Angreifer-Eingabe — ein Lookup-Fehler
66
+ // (z.B. typfremde id) wird zu demselben generischen 422, nie zu einem 500.
67
+ const requestId = await readPendingDeletionRequestId(ctx, peekedUserId);
68
+ if (!requestId) return writeFailure(invalidToken());
69
+
70
+ const verified = verifyDeletionToken(
71
+ event.payload.token,
72
+ requestId,
73
+ opts.deletionTokenSecret,
74
+ );
42
75
  if (!verified.ok) return writeFailure(invalidToken());
43
76
 
44
77
  const res = await startDeletionGracePeriod(ctx, verified.userId, event.user.tenantId);
@@ -1,4 +1,4 @@
1
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { z } from "zod";
4
4
  import { USER_STATUS, userTable } from "../../user";
@@ -75,8 +75,20 @@ export function createRequestDeletionByEmailHandler(opts: RequestDeletionByEmail
75
75
  return success;
76
76
  }
77
77
 
78
+ // Replay-Schutz (#354/1): pro Antrag eine frische requestId, die auf der
79
+ // user-Row landet und in die Token-HMAC-Purpose gefaltet wird. cancel
80
+ // nullt sie → ein nach Cancel nachgespieltes Token verifiziert nicht mehr.
81
+ const requestId = crypto.randomUUID();
82
+ await updateMany(
83
+ ctx.db.raw,
84
+ userTable,
85
+ { pendingDeletionRequestId: requestId },
86
+ { id: userRow["id"] },
87
+ );
88
+
78
89
  const { token, expiresAt } = signDeletionToken(
79
90
  userRow["id"],
91
+ requestId,
80
92
  DELETION_VERIFY_TTL_MINUTES,
81
93
  opts.deletionTokenSecret,
82
94
  );