@cosmicdrift/kumiko-bundled-features 0.174.0 → 0.174.1

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 (36) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/login-gates.test.ts +12 -6
  3. package/src/auth-email-password/seeding.ts +4 -2
  4. package/src/auth-mfa/__tests__/token-secrets.test.ts +19 -0
  5. package/src/auth-mfa/handlers/reencrypt.job.ts +11 -20
  6. package/src/auth-mfa/token-secrets.ts +16 -14
  7. package/src/billing-foundation/subscription-tier-sync.ts +8 -1
  8. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +98 -0
  9. package/src/cap-counter/stock-cap-guard.ts +1 -1
  10. package/src/config/handlers/reencrypt.job.ts +7 -18
  11. package/src/delivery/delivery-service.ts +1 -1
  12. package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +47 -11
  13. package/src/document-ingest-foundation/__tests__/feature.test.ts +6 -2
  14. package/src/document-ingest-foundation/events.ts +15 -0
  15. package/src/document-ingest-foundation/feature.ts +27 -2
  16. package/src/inbound-mail-foundation/watch-supervisor.ts +11 -2
  17. package/src/jobs/handlers/catalog.query.ts +1 -4
  18. package/src/jobs/handlers/trigger.write.ts +2 -1
  19. package/src/jobs/is-manual-trigger.ts +5 -0
  20. package/src/managed-pages/screens/branding-screen.ts +1 -1
  21. package/src/presets/dsgvo-self-service.ts +10 -10
  22. package/src/sessions/handlers/revoke-all-for-user.write.ts +10 -1
  23. package/src/shared/classify-stored-envelope.ts +21 -0
  24. package/src/shared/index.ts +4 -0
  25. package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +9 -0
  26. package/src/tenant-settings/tenant-defaults.ts +3 -0
  27. package/src/tier-engine/compose-app.ts +2 -13
  28. package/src/user/__tests__/seed-testing.integration.test.ts +25 -0
  29. package/src/user/seeding.ts +25 -1
  30. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +1 -1
  31. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +2 -2
  32. package/src/user-data-rights/__tests__/request-deletion-url.test.ts +11 -8
  33. package/src/user-data-rights/feature.ts +3 -2
  34. package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +8 -6
  35. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +5 -5
  36. package/src/user-data-rights/web/confirm-deletion-screen.tsx +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.174.0",
3
+ "version": "0.174.1",
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>",
@@ -122,12 +122,12 @@
122
122
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
123
123
  },
124
124
  "dependencies": {
125
- "@cosmicdrift/kumiko-dispatcher-live": "0.174.0",
126
- "@cosmicdrift/kumiko-framework": "0.174.0",
127
- "@cosmicdrift/kumiko-headless": "0.174.0",
128
- "@cosmicdrift/kumiko-renderer": "0.174.0",
129
- "@cosmicdrift/kumiko-renderer-web": "0.174.0",
130
- "@cosmicdrift/kumiko-types": "0.174.0",
125
+ "@cosmicdrift/kumiko-dispatcher-live": "0.174.1",
126
+ "@cosmicdrift/kumiko-framework": "0.174.1",
127
+ "@cosmicdrift/kumiko-headless": "0.174.1",
128
+ "@cosmicdrift/kumiko-renderer": "0.174.1",
129
+ "@cosmicdrift/kumiko-renderer-web": "0.174.1",
130
+ "@cosmicdrift/kumiko-types": "0.174.1",
131
131
  "@mollie/api-client": "^4.5.0",
132
132
  "imapflow": "^1.3.3",
133
133
  "mailparser": "^3.9.8",
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { USER_STATUS } from "../../user";
4
4
  import type { AuthUserRow } from "../auth-user-row";
5
+ import { accountRestricted, emailNotVerified, invalidCredentials } from "../errors";
5
6
  import {
6
7
  gateBuildSession,
7
8
  gateEnforceAccountStatus,
@@ -21,7 +22,7 @@ describe("login.write gates (fw#1284)", () => {
21
22
  test("gateEnforceEmailVerified rejects when strict and unverified", () => {
22
23
  const g = gateEnforceEmailVerified(row({ emailVerified: false }), true);
23
24
  expect(g.ok).toBe(false);
24
- if (!g.ok) expect(g.result.isSuccess).toBe(false);
25
+ if (!g.ok) expect(g.result).toEqual(emailNotVerified());
25
26
  });
26
27
 
27
28
  test("gateEnforceEmailVerified passes when not strict", () => {
@@ -31,16 +32,21 @@ describe("login.write gates (fw#1284)", () => {
31
32
  test("gateEnforceAccountStatus rejects restricted", () => {
32
33
  const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Restricted }));
33
34
  expect(g.ok).toBe(false);
35
+ if (!g.ok) expect(g.result).toEqual(accountRestricted());
34
36
  });
35
37
 
36
38
  test("gateEnforceAccountStatus rejects deletion_requested as invalid_creds shape", () => {
37
39
  const g = gateEnforceAccountStatus(row({ status: USER_STATUS.DeletionRequested }));
38
40
  expect(g.ok).toBe(false);
39
- if (!g.ok) {
40
- expect(g.result.isSuccess).toBe(false);
41
- // anti-enumeration — same family as invalid credentials
42
- expect(g.result).toMatchObject({ isSuccess: false });
43
- }
41
+ // anti-enumeration — same payload as invalid credentials, not a
42
+ // distinguishable "account deleted" error.
43
+ if (!g.ok) expect(g.result).toEqual(invalidCredentials());
44
+ });
45
+
46
+ test("gateEnforceAccountStatus rejects deleted as invalid_creds shape", () => {
47
+ const g = gateEnforceAccountStatus(row({ status: USER_STATUS.Deleted }));
48
+ expect(g.ok).toBe(false);
49
+ if (!g.ok) expect(g.result).toEqual(invalidCredentials());
44
50
  });
45
51
 
46
52
  test("gateEnforceAccountStatus passes active", () => {
@@ -190,8 +190,10 @@ export type SeedAdminOptions = {
190
190
  * membership-Rollen. Typischer use-case: `["SystemAdmin"]` für
191
191
  * einen Plattform-Operator. Default: leer. */
192
192
  readonly globalRoles?: readonly string[];
193
- /** Initial-emailVerified-FlagDefault false (unverified),
194
- * gleicher Mechanismus wie seedUserWithPassword.emailVerified. */
193
+ /** Initial emailVerified state defaults to false (unverified). Same
194
+ * mechanism as seedUserWithPassword.emailVerified. Re-running the seed
195
+ * against an already-seeded row reconciles this flag too (true → sets
196
+ * it via a real `.updated` event; never flips true → false). */
195
197
  readonly emailVerified?: boolean;
196
198
  readonly by?: SessionUser;
197
199
  };
@@ -30,6 +30,25 @@ describe("resolveMfaTokenSecrets", () => {
30
30
  );
31
31
  });
32
32
 
33
+ test("an empty-string override falls back to derivation instead of an empty key", () => {
34
+ const resolved = resolveMfaTokenSecrets(JWT_SECRET, {
35
+ setupTokenSecret: "",
36
+ challengeTokenSecret: "",
37
+ });
38
+
39
+ expect(resolved).toEqual(resolveMfaTokenSecrets(JWT_SECRET));
40
+ expect(resolved.setupTokenSecret).not.toBe("");
41
+ });
42
+
43
+ test("a whitespace-only override falls back to derivation too", () => {
44
+ const resolved = resolveMfaTokenSecrets(JWT_SECRET, {
45
+ setupTokenSecret: " ",
46
+ challengeTokenSecret: "\t",
47
+ });
48
+
49
+ expect(resolved).toEqual(resolveMfaTokenSecrets(JWT_SECRET));
50
+ });
51
+
33
52
  test("rotating the master rotates both", () => {
34
53
  const before = resolveMfaTokenSecrets(JWT_SECRET);
35
54
  const after = resolveMfaTokenSecrets(`${JWT_SECRET}-rotated`);
@@ -38,8 +38,12 @@ import {
38
38
  } from "@cosmicdrift/kumiko-framework/db";
39
39
  import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
40
40
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
41
- import { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
42
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
41
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
42
+ import {
43
+ type ChunkedMigrationStopReason,
44
+ classifyStoredEnvelope,
45
+ runChunkedMigration,
46
+ } from "../../shared";
43
47
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
44
48
 
45
49
  const DEFAULT_BATCH_SIZE = 100;
@@ -66,22 +70,6 @@ export type MfaReencryptJobResult = {
66
70
  readonly stoppedReason: ChunkedMigrationStopReason;
67
71
  };
68
72
 
69
- type EnvelopeClassification = "rotate" | "current" | "unrecognized";
70
-
71
- // After PII peel (when configured), values must be current-cipher envelopes.
72
- // Non-JSON / non-envelope is not a supported legacy decrypt path — fail the
73
- // row instead of treating it as "needs rotate" (#1541, mirrors config #1513).
74
- function classifyEnvelope(value: string, targetVersion: number): EnvelopeClassification {
75
- let parsed: unknown;
76
- try {
77
- parsed = JSON.parse(value);
78
- } catch {
79
- return "unrecognized";
80
- }
81
- if (!isStoredEnvelope(parsed)) return "unrecognized";
82
- return parsed.kekVersion === targetVersion ? "current" : "rotate";
83
- }
84
-
85
73
  export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
86
74
  const payload = rawPayload as MfaReencryptJobPayload; // @cast-boundary engine-payload
87
75
  const maybeCipher = configuredEntityFieldEncryption();
@@ -166,8 +154,11 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
166
154
  envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
167
155
  }
168
156
 
169
- const totpClass = classifyEnvelope(envelopeValues.totpSecret, targetVersion);
170
- const recoveryClass = classifyEnvelope(envelopeValues.recoveryCodes, targetVersion);
157
+ // After PII peel (when configured), values must be current-cipher
158
+ // envelopes non-JSON/non-envelope is not a supported legacy decrypt
159
+ // path (#1541, mirrors config #1513).
160
+ const totpClass = classifyStoredEnvelope(envelopeValues.totpSecret, targetVersion);
161
+ const recoveryClass = classifyStoredEnvelope(envelopeValues.recoveryCodes, targetVersion);
171
162
  if (totpClass === "unrecognized" || recoveryClass === "unrecognized") {
172
163
  ctx.log?.warn?.(
173
164
  `[auth-mfa:reencrypt] row ${row.id} has a field that is not a current-cipher envelope, not re-encryptable`,
@@ -10,19 +10,19 @@ export type ResolvedMfaTokenSecrets = {
10
10
  readonly challengeTokenSecret: string;
11
11
  };
12
12
 
13
- // The two HKDF purposes auth-mfa needs. They live here rather than at each
14
- // call site because an app typically resolves them twice — once in the prod
15
- // entrypoint against a validated JWT_SECRET, once in the dev server against
16
- // its fallback — and a purpose string that drifts between those two files
17
- // invalidates every token issued by the other.
18
- //
19
- // Setup and challenge stay separate on purpose: a setup token proves "this
20
- // user is enrolling a factor", a challenge token proves "this user passed
21
- // step one of login". Sharing one key would let the first be replayed as the
22
- // second.
13
+ // Shared here (not per call site) so the purpose string can't drift between
14
+ // prod/dev entrypoints. Setup and challenge stay separate: sharing one key
15
+ // would let a setup token be replayed as a login-challenge token.
23
16
  const SETUP_TOKEN_PURPOSE = "mfa-setup-token-v1";
24
17
  const CHALLENGE_TOKEN_PURPOSE = "mfa-challenge-token-v1";
25
18
 
19
+ // A blank or whitespace-only override is treated as absent — an empty env
20
+ // var (MFA_SETUP_TOKEN_SECRET="") must fall back to derivation, not sign
21
+ // tokens with an empty/near-empty HMAC key (fw#1623).
22
+ function resolveSecret(override: string | undefined, fallback: () => string): string {
23
+ return override !== undefined && override.trim() !== "" ? override : fallback();
24
+ }
25
+
26
26
  /** Derives both MFA token secrets from the app's master secret. Pass explicit
27
27
  * overrides only when a deployment needs its own key for one of them —
28
28
  * otherwise deriving keeps a single env var authoritative. */
@@ -31,9 +31,11 @@ export function resolveMfaTokenSecrets(
31
31
  overrides: MfaTokenSecretOverrides = {},
32
32
  ): ResolvedMfaTokenSecrets {
33
33
  return {
34
- setupTokenSecret:
35
- overrides.setupTokenSecret ?? derivePurposeSecret(masterSecret, SETUP_TOKEN_PURPOSE),
36
- challengeTokenSecret:
37
- overrides.challengeTokenSecret ?? derivePurposeSecret(masterSecret, CHALLENGE_TOKEN_PURPOSE),
34
+ setupTokenSecret: resolveSecret(overrides.setupTokenSecret, () =>
35
+ derivePurposeSecret(masterSecret, SETUP_TOKEN_PURPOSE),
36
+ ),
37
+ challengeTokenSecret: resolveSecret(overrides.challengeTokenSecret, () =>
38
+ derivePurposeSecret(masterSecret, CHALLENGE_TOKEN_PURPOSE),
39
+ ),
38
40
  };
39
41
  }
@@ -116,9 +116,16 @@ export function createSubscriptionTierSync<TTier extends string>(
116
116
  tenantId: targetTenantId,
117
117
  });
118
118
  if (!result.isSuccess) return result;
119
+ // The primary write already committed — a webhook caller (Stripe/
120
+ // PayPal) that sees isSuccess:false here retries the whole event,
121
+ // re-running an already-succeeded side effect. Log the tier-sync
122
+ // failure instead of masking the primary write's success.
119
123
  const syncError = await syncTierFromSubscription(targetTenantId);
120
124
  if (syncError) {
121
- return { isSuccess: false, error: syncError };
125
+ // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
126
+ console.warn(
127
+ `[subscription-tier-sync] tier sync failed for tenant ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
128
+ );
122
129
  }
123
130
  return result;
124
131
  },
@@ -0,0 +1,98 @@
1
+ // Regression test for the tenant-scope override bug: checkStockCap used to
2
+ // spread `spec.where` AFTER the injected `tenantId`, so a `where` object
3
+ // carrying its own `tenantId` key silently overrode the caller's real tenant
4
+ // and the count ran against the wrong tenant's rows.
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
9
+ import { createEntityExecutor, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ setupTestStack,
13
+ type TestStack,
14
+ testTenantId,
15
+ unsafeCreateEntityTable,
16
+ } from "@cosmicdrift/kumiko-framework/stack";
17
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { capCounterEntity } from "../entity";
19
+ import { createStockCapGuard } from "../stock-cap-guard";
20
+
21
+ const { executor, table: capCounterTable } = createEntityExecutor("cap-counter", capCounterEntity);
22
+
23
+ let stack: TestStack;
24
+ let db: DbConnection;
25
+
26
+ beforeAll(async () => {
27
+ stack = await setupTestStack({ features: [] });
28
+ db = stack.db;
29
+ await unsafeCreateEntityTable(db, capCounterEntity, "cap-counter");
30
+ await createEventsTable(db);
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await stack.cleanup();
35
+ });
36
+
37
+ beforeEach(async () => {
38
+ await resetTestTables(db, [capCounterTable, eventsTable]);
39
+ });
40
+
41
+ function seedUser(tenantId: ReturnType<typeof testTenantId>): SessionUser {
42
+ return { id: "seed-user", tenantId, roles: ["SystemAdmin"] };
43
+ }
44
+
45
+ async function seedCounterRow(tenantId: ReturnType<typeof testTenantId>): Promise<void> {
46
+ const result = await executor.create(
47
+ { tenantId, capName: "x", value: 0, periodStart: "2026-07-01T00:00:00Z" },
48
+ seedUser(tenantId),
49
+ createTenantDb(db, tenantId),
50
+ );
51
+ if (!result.isSuccess) throw new Error(`seed failed: ${JSON.stringify(result)}`);
52
+ }
53
+
54
+ describe("checkStockCap tenant scoping", () => {
55
+ test("spec.where cannot override the caller's real tenantId", async () => {
56
+ const realTenant = testTenantId(1);
57
+ const otherTenant = testTenantId(2);
58
+
59
+ // 5 rows live under otherTenant — realTenant has none.
60
+ for (let i = 0; i < 5; i++) {
61
+ await seedCounterRow(otherTenant);
62
+ }
63
+
64
+ const guard = createStockCapGuard(async () => ({}));
65
+ const failure = await guard.checkStockCap(db, realTenant, {
66
+ table: capCounterTable,
67
+ limit: () => 1,
68
+ // A where object that (accidentally or maliciously) carries its own
69
+ // tenantId must NOT be able to redirect the count to another tenant.
70
+ where: { tenantId: otherTenant, capName: "x" },
71
+ code: "cap-exceeded",
72
+ i18nKey: "cap.exceeded",
73
+ field: "capName",
74
+ });
75
+
76
+ // realTenant has zero matching rows — must not see otherTenant's 5.
77
+ expect(failure).toBeNull();
78
+ });
79
+
80
+ test("still enforces the cap for the caller's own tenant", async () => {
81
+ const tenant = testTenantId(1);
82
+ for (let i = 0; i < 2; i++) {
83
+ await seedCounterRow(tenant);
84
+ }
85
+
86
+ const guard = createStockCapGuard(async () => ({}));
87
+ const failure = await guard.checkStockCap(db, tenant, {
88
+ table: capCounterTable,
89
+ limit: () => 1,
90
+ where: { capName: "x" },
91
+ code: "cap-exceeded",
92
+ i18nKey: "cap.exceeded",
93
+ field: "capName",
94
+ });
95
+
96
+ expect(failure).not.toBeNull();
97
+ });
98
+ });
@@ -39,7 +39,7 @@ export function createStockCapGuard<TCaps>(
39
39
  spec: StockCapSpec<TCaps>,
40
40
  ): Promise<WriteFailure | null> {
41
41
  const caps = await resolveTierCaps(db, tenantId);
42
- const current = await countWhere(db, spec.table, { tenantId, ...spec.where });
42
+ const current = await countWhere(db, spec.table, { ...spec.where, tenantId });
43
43
  const { state, limit } = enforceStockCap({
44
44
  current,
45
45
  limit: spec.limit(caps),
@@ -25,8 +25,12 @@ import {
25
25
  } from "@cosmicdrift/kumiko-framework/db";
26
26
  import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
27
27
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
28
- import { type EnvelopeCipher, isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
29
- import { type ChunkedMigrationStopReason, runChunkedMigration } from "../../shared";
28
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
29
+ import {
30
+ type ChunkedMigrationStopReason,
31
+ classifyStoredEnvelope,
32
+ runChunkedMigration,
33
+ } from "../../shared";
30
34
  import { configValueEntity, configValuesTable } from "../table";
31
35
 
32
36
  const DEFAULT_MAX_FAILURES = 10;
@@ -49,21 +53,6 @@ export type ReencryptJobResult = {
49
53
  readonly stoppedReason: ChunkedMigrationStopReason;
50
54
  };
51
55
 
52
- type RowClassification = "rotate" | "current" | "unrecognized";
53
-
54
- function classifyRow(value: string, targetVersion: number): RowClassification {
55
- let parsed: unknown;
56
- try {
57
- parsed = JSON.parse(value);
58
- } catch {
59
- // not JSON at all — no supported format decrypts this; let
60
- // cipher.decrypt reject it and count the row as failed.
61
- return "unrecognized";
62
- }
63
- if (!isStoredEnvelope(parsed)) return "unrecognized";
64
- return parsed.kekVersion === targetVersion ? "current" : "rotate";
65
- }
66
-
67
56
  export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
68
57
  const payload = rawPayload as ReencryptJobPayload; // @cast-boundary engine-payload
69
58
  const maybeCipher = ctx.configEncryption;
@@ -148,7 +137,7 @@ export const reencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void>
148
137
 
149
138
  async function migrateRow(row: ConfigRow): Promise<"migrated" | "skipped" | "failed"> {
150
139
  if (row.value === null || row.value === undefined) return "skipped";
151
- const classification = classifyRow(row.value, targetVersion);
140
+ const classification = classifyStoredEnvelope(row.value, targetVersion);
152
141
  if (classification === "current") {
153
142
  alreadyCurrent++;
154
143
  return "skipped";
@@ -488,7 +488,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
488
488
  tenantId,
489
489
  notificationType,
490
490
  channel: "*",
491
- recipientId: null,
491
+ recipientId: options.recipientId ?? null,
492
492
  recipientAddress: null,
493
493
  status: "skipped",
494
494
  error: "duplicate_idempotency_key",
@@ -89,6 +89,14 @@ async function loadIngestRequestedEvents(): Promise<{ payload: Record<string, un
89
89
  return rows as { payload: Record<string, unknown> }[];
90
90
  }
91
91
 
92
+ async function loadIngestSkippedEvents(): Promise<{ payload: Record<string, unknown> }[]> {
93
+ const rows = await asRawClient(stack.db).unsafe(
94
+ `SELECT payload FROM kumiko_events WHERE type = $1`,
95
+ ["document-ingest-foundation:event:document-ingest-skipped"],
96
+ );
97
+ return rows as { payload: Record<string, unknown> }[];
98
+ }
99
+
92
100
  describe("fileRef.created → documentIngest.requested", () => {
93
101
  test("PDF upload requests ingest with the fileRef pointer, no binary", async () => {
94
102
  const { id, storageKey } = await uploadFile("invoice.pdf", pdfBytes, "application/pdf");
@@ -97,12 +105,13 @@ describe("fileRef.created → documentIngest.requested", () => {
97
105
 
98
106
  const rows = await loadIngestRequestedEvents();
99
107
  expect(rows).toHaveLength(1);
100
- const payload = rows[0]?.payload;
101
- expect(payload?.["fileRefId"]).toBe(id);
102
- expect(payload?.["storageKey"]).toBe(storageKey);
103
- expect(payload?.["mimeType"]).toBe("application/pdf");
104
- expect(payload?.["data"]).toBeUndefined();
105
- expect(payload?.["binary"]).toBeUndefined();
108
+ expect(rows[0]?.payload).toEqual({
109
+ fileRefId: id,
110
+ storageKey,
111
+ fileName: "invoice.pdf",
112
+ mimeType: "application/pdf",
113
+ size: pdfBytes.length,
114
+ });
106
115
  });
107
116
 
108
117
  test("image/png upload also requests ingest (Phase-1 mime allowlist)", async () => {
@@ -113,19 +122,32 @@ describe("fileRef.created → documentIngest.requested", () => {
113
122
  expect(await loadIngestRequestedEvents()).toHaveLength(1);
114
123
  });
115
124
 
116
- test("unsupported mime type is skipped — no ingest requested", async () => {
117
- await uploadFile("notes.txt", textBytes, "text/plain");
125
+ test("unsupported mime type is skipped — no ingest requested, skip is observable", async () => {
126
+ const { id, storageKey } = await uploadFile("notes.txt", textBytes, "text/plain");
118
127
 
119
128
  await stack.eventDispatcher?.runOnce();
120
129
 
121
130
  expect(await loadIngestRequestedEvents()).toHaveLength(0);
131
+ const skipped = await loadIngestSkippedEvents();
132
+ expect(skipped).toHaveLength(1);
133
+ // mimeType not pinned exactly: the upload route/File API append a
134
+ // charset suffix ("text/plain;charset=utf-8") that isn't this MSP's
135
+ // concern — the allowlist-miss is.
136
+ expect(skipped[0]?.payload).toMatchObject({
137
+ fileRefId: id,
138
+ storageKey,
139
+ fileName: "notes.txt",
140
+ reason: "unsupported-mime-type",
141
+ });
142
+ expect(String(skipped[0]?.payload["mimeType"])).toStartWith("text/plain");
122
143
  });
123
144
 
124
- test("oversized file is skipped before the mime check — no ingest requested", async () => {
145
+ test("oversized file is skipped before the mime check — no ingest requested, skip is observable", async () => {
125
146
  // Real uploads can't exceed file-routes.ts' 10mb unconstrained-upload
126
147
  // default, well below this feature's 25mb domain cap — so an oversized
127
148
  // fileRef.created is inserted directly (bypassing the upload route) to
128
149
  // prove the MSP's own size-check fires independently of it.
150
+ const oversizedSize = 26 * 1024 * 1024;
129
151
  await asRawClient(stack.db).unsafe(
130
152
  `
131
153
  INSERT INTO kumiko_events
@@ -142,9 +164,13 @@ describe("fileRef.created → documentIngest.requested", () => {
142
164
  storageKey: "huge.pdf",
143
165
  fileName: "huge.pdf",
144
166
  mimeType: "application/pdf",
145
- size: 26 * 1024 * 1024,
167
+ size: oversizedSize,
146
168
  }),
147
- "{}",
169
+ // MSP-apply's ctx.unsafeAppendEvent stamps the new event's actor from
170
+ // the TRIGGERING event's metadata.userId (server.ts) — an empty
171
+ // metadata object here leaves it undefined and the skip-event insert
172
+ // rejects with "UNDEFINED_VALUE".
173
+ JSON.stringify({ userId: admin.id }),
148
174
  admin.id,
149
175
  ],
150
176
  );
@@ -152,5 +178,15 @@ describe("fileRef.created → documentIngest.requested", () => {
152
178
  await stack.eventDispatcher?.runOnce();
153
179
 
154
180
  expect(await loadIngestRequestedEvents()).toHaveLength(0);
181
+ const skipped = await loadIngestSkippedEvents();
182
+ expect(skipped).toHaveLength(1);
183
+ expect(skipped[0]?.payload).toEqual({
184
+ fileRefId: "00000000-0000-4000-8000-0000000000ff",
185
+ storageKey: "huge.pdf",
186
+ fileName: "huge.pdf",
187
+ mimeType: "application/pdf",
188
+ size: oversizedSize,
189
+ reason: "file-too-large",
190
+ });
155
191
  });
156
192
  });
@@ -7,7 +7,7 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { documentExtractEntity } from "../entity";
10
- import { DOCUMENT_INGEST_REQUESTED_EVENT_QN } from "../events";
10
+ import { DOCUMENT_INGEST_REQUESTED_EVENT_QN, DOCUMENT_INGEST_SKIPPED_EVENT_QN } from "../events";
11
11
  import { documentIngestFoundationFeature } from "../feature";
12
12
 
13
13
  describe("documentIngestFoundationFeature — shape", () => {
@@ -64,9 +64,10 @@ describe("documentIngestFoundationFeature — shape", () => {
64
64
  expect(Object.keys(documentIngestFoundationFeature.queryHandlers)).toHaveLength(0);
65
65
  });
66
66
 
67
- test("registers the documentIngest.requested event under the exported QN", () => {
67
+ test("registers the documentIngest.requested and documentIngest.skipped events under their exported QNs", () => {
68
68
  expect(Object.keys(documentIngestFoundationFeature.events)).toEqual([
69
69
  "documentIngest.requested",
70
+ "documentIngest.skipped",
70
71
  ]);
71
72
  // The registry qualifies short → QN via qn(toKebab(feature), "event",
72
73
  // toKebab(short)) — pin the hand-written DOCUMENT_INGEST_REQUESTED_EVENT_QN
@@ -77,6 +78,9 @@ describe("documentIngestFoundationFeature — shape", () => {
77
78
  expect(documentIngestFoundationFeature.events["documentIngest.requested"]?.name).toBe(
78
79
  DOCUMENT_INGEST_REQUESTED_EVENT_QN,
79
80
  );
81
+ expect(documentIngestFoundationFeature.events["documentIngest.skipped"]?.name).toBe(
82
+ DOCUMENT_INGEST_SKIPPED_EVENT_QN,
83
+ );
80
84
  });
81
85
 
82
86
  test("registers the fileRef.created MSP", () => {
@@ -23,3 +23,18 @@ export const documentIngestRequestedPayloadSchema = z.object({
23
23
  size: z.number().int().min(0),
24
24
  });
25
25
  export type DocumentIngestRequestedPayload = z.infer<typeof documentIngestRequestedPayloadSchema>;
26
+
27
+ export const DOCUMENT_INGEST_SKIPPED_EVENT_SHORT = "documentIngest.skipped" as const;
28
+
29
+ export const DOCUMENT_INGEST_SKIPPED_EVENT_QN =
30
+ "document-ingest-foundation:event:document-ingest-skipped" as const;
31
+
32
+ export const documentIngestSkippedPayloadSchema = z.object({
33
+ fileRefId: z.string().min(1),
34
+ storageKey: z.string().min(1),
35
+ fileName: z.string().min(1),
36
+ mimeType: z.string().min(1),
37
+ size: z.number().int().min(0),
38
+ reason: z.enum(["file-too-large", "unsupported-mime-type"]),
39
+ });
40
+ export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
@@ -24,7 +24,10 @@ import {
24
24
  DOCUMENT_INGEST_AGGREGATE_TYPE,
25
25
  DOCUMENT_INGEST_REQUESTED_EVENT_QN,
26
26
  DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
27
+ DOCUMENT_INGEST_SKIPPED_EVENT_QN,
28
+ DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
27
29
  documentIngestRequestedPayloadSchema,
30
+ documentIngestSkippedPayloadSchema,
28
31
  } from "./events";
29
32
  import { documentExtractTenantDestroyHook } from "./tenant-destroy-hook";
30
33
 
@@ -100,6 +103,7 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
100
103
  );
101
104
 
102
105
  r.defineEvent(DOCUMENT_INGEST_REQUESTED_EVENT_SHORT, documentIngestRequestedPayloadSchema);
106
+ r.defineEvent(DOCUMENT_INGEST_SKIPPED_EVENT_SHORT, documentIngestSkippedPayloadSchema);
103
107
 
104
108
  r.multiStreamProjection({
105
109
  name: "request-ingest",
@@ -112,10 +116,31 @@ export const documentIngestFoundationFeature = defineFeature(FEATURE_NAME, (r) =
112
116
  if (!parsed.success) return;
113
117
  const payload = parsed.data;
114
118
 
119
+ const skip = (reason: "file-too-large" | "unsupported-mime-type") =>
120
+ ctx.unsafeAppendEvent({
121
+ aggregateId: event.aggregateId,
122
+ aggregateType: DOCUMENT_INGEST_AGGREGATE_TYPE,
123
+ type: DOCUMENT_INGEST_SKIPPED_EVENT_QN,
124
+ payload: {
125
+ fileRefId: event.aggregateId,
126
+ storageKey: payload.storageKey,
127
+ fileName: payload.fileName,
128
+ mimeType: payload.mimeType,
129
+ size: payload.size,
130
+ reason,
131
+ },
132
+ });
133
+
115
134
  // skip: over the fixed cap — no ingest requested, upload itself already succeeded
116
- if (payload.size > MAX_FILE_BYTES) return;
135
+ if (payload.size > MAX_FILE_BYTES) {
136
+ await skip("file-too-large");
137
+ return;
138
+ }
117
139
  // skip: outside the Phase-1 mime allowlist — no ingest requested
118
- if (!ALLOWED_MIME_TYPES.has(payload.mimeType)) return;
140
+ if (!ALLOWED_MIME_TYPES.has(payload.mimeType)) {
141
+ await skip("unsupported-mime-type");
142
+ return;
143
+ }
119
144
 
120
145
  await ctx.unsafeAppendEvent({
121
146
  aggregateId: event.aggregateId,
@@ -408,8 +408,17 @@ export function createInboundMailSupervisor(
408
408
  state.stop = stop;
409
409
  state.backoffMs = backoffInitialMs;
410
410
  // Await — fire-and-forget raced with a later auth_error mark under
411
- // try-first waitFor (isWatching true before projection settled).
412
- await markAccount(account, { watchState: "watching" }, "watch_supervisor");
411
+ // try-first waitFor (isWatching true before projection settled). Own
412
+ // try/catch: a projection-write hiccup here is not a sync failure —
413
+ // the watch itself is healthy, so it must not trigger handleSyncError's
414
+ // backoff/restart/auth_error handling below.
415
+ try {
416
+ await markAccount(account, { watchState: "watching" }, "watch_supervisor");
417
+ } catch (err) {
418
+ log(
419
+ `inbound-mail: markAccount(watching) for account ${account.id} failed: ${err instanceof Error ? err.message : String(err)}`,
420
+ );
421
+ }
413
422
  } catch (err) {
414
423
  const keepRunning = await handleSyncError(account, err);
415
424
  if (keepRunning) scheduleRestart(err);
@@ -1,5 +1,6 @@
1
1
  import { defineQueryHandler, type JobDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { z } from "zod";
3
+ import { isManualTrigger } from "../is-manual-trigger";
3
4
 
4
5
  export type ManualJobCatalogEntry = {
5
6
  readonly jobName: string;
@@ -8,10 +9,6 @@ export type ManualJobCatalogEntry = {
8
9
  readonly payloadSchema: Record<string, unknown> | null;
9
10
  };
10
11
 
11
- function isManualTrigger(trigger: JobDefinition["trigger"]): boolean {
12
- return "manual" in trigger && trigger.manual === true;
13
- }
14
-
15
12
  function payloadSchemaJson(job: JobDefinition): Record<string, unknown> | null {
16
13
  if (job.schema === undefined) return null;
17
14
  try {
@@ -9,6 +9,7 @@ import {
9
9
  import type { JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
10
10
  import { z } from "zod";
11
11
  import { JobErrors } from "../constants";
12
+ import { isManualTrigger } from "../is-manual-trigger";
12
13
 
13
14
  export const triggerWrite = defineWriteHandler({
14
15
  name: "trigger",
@@ -31,7 +32,7 @@ export const triggerWrite = defineWriteHandler({
31
32
  );
32
33
  }
33
34
 
34
- if (!("manual" in jobDef.trigger) || jobDef.trigger.manual !== true) {
35
+ if (!isManualTrigger(jobDef.trigger)) {
35
36
  return writeFailure(
36
37
  new UnprocessableError(JobErrors.notManual, {
37
38
  i18nKey: "jobs.errors.notManual",
@@ -0,0 +1,5 @@
1
+ import type { JobDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ export function isManualTrigger(trigger: JobDefinition["trigger"]): boolean {
4
+ return "manual" in trigger && trigger.manual === true;
5
+ }
@@ -42,7 +42,7 @@ export function createBrandingSettingsScreen(opts: {
42
42
  description: createTextField({
43
43
  maxLength: 500,
44
44
  multiline: { rows: 3 },
45
- allowPlaintext: "tenant branding copy, business content not personal data",
45
+ allowPlaintext: "is-business-data",
46
46
  }),
47
47
  siteUrl: createTextField({ maxLength: 2000, format: "url" }),
48
48
  accentColor: createTextField({ maxLength: 9 }),
@@ -6,19 +6,19 @@ import { createUserDataRightsFeature, type UserDataRightsOptions } from "../user
6
6
  import { createUserProfileFeature } from "../user-profile";
7
7
 
8
8
  export type DsgvoSelfServiceOptions = {
9
- /** Durchgereicht an createUserDataRightsFeature — Export-/Deletion-Mail-
10
- * Callbacks + Apex-Deletion-HMAC. Default {} (no-op Mail-Side). */
9
+ /** Passed through to createUserDataRightsFeature — export/deletion mail
10
+ * callbacks + Apex deletion HMAC. Default {} (no-op mail side). */
11
11
  readonly userDataRights?: UserDataRightsOptions;
12
12
  };
13
13
 
14
- // DSGVO- + Account-Self-Service-Kette, die jede Kumiko-SaaS-App mountet
15
- // (Privacy-Center, Account-Löschung Art. 17, Export Art. 20, Sessions).
16
- // Order is load-bearing (Require-Order): user-data-rights braucht
17
- // data-retention + compliance-profiles + sessions, user-profile braucht
18
- // user-data-rights. Genau diese Order stand bisher in jeder App handkopiert
19
- // mit Erklär-Kommentar. text-content + legal-pages bleiben bewusst draußen
20
- // legal-pages hat ein app-spezifisches wrapLayout, text-content ist
21
- // standalone Foundation; beide spreaded die App selbst dazu.
14
+ // DSGVO + account self-service chain every Kumiko SaaS app mounts (privacy
15
+ // center, account deletion Art. 17, export Art. 20, sessions). Order is
16
+ // load-bearing (require-order): user-data-rights needs data-retention +
17
+ // compliance-profiles + sessions, user-profile needs user-data-rights. This
18
+ // exact order used to be hand-copied into every app with an explainer
19
+ // comment. text-content + legal-pages stay out deliberately legal-pages
20
+ // has an app-specific wrapLayout, text-content is a standalone foundation;
21
+ // both are spread in by the app itself.
22
22
  export function dsgvoSelfServiceFeatures(opts: DsgvoSelfServiceOptions = {}): FeatureDefinition[] {
23
23
  return [
24
24
  createDataRetentionFeature(),
@@ -1,6 +1,7 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
3
  import { access, defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
4
5
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
5
6
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
6
7
  import { Temporal } from "temporal-polyfill";
@@ -55,6 +56,14 @@ export const revokeAllForUserWrite = defineWriteHandler({
55
56
  // parse explicitly before the raw append so a shape drift fails loudly
56
57
  // here instead of landing unvalidated on the events table.
57
58
  if (updated.length > 0) {
59
+ const eventDef = ctx.registry.getEvent(SESSION_REVOKED_EVENT_QN);
60
+ if (!eventDef) {
61
+ return writeFailure(
62
+ new UnprocessableError("session_revoked_event_not_registered", {
63
+ details: { eventQn: SESSION_REVOKED_EVENT_QN },
64
+ }),
65
+ );
66
+ }
58
67
  const payload = sessionRevokedSchema.parse({
59
68
  userId: event.payload.userId,
60
69
  sessionIds: updated.map((row) => row.id),
@@ -66,7 +75,7 @@ export const revokeAllForUserWrite = defineWriteHandler({
66
75
  tenantId: SYSTEM_TENANT_ID,
67
76
  expectedVersion: 0,
68
77
  type: SESSION_REVOKED_EVENT_QN,
69
- eventVersion: ctx.registry.getEvent(SESSION_REVOKED_EVENT_QN)?.version ?? 1,
78
+ eventVersion: eventDef.version,
70
79
  payload,
71
80
  metadata: {
72
81
  userId: event.user.id,
@@ -0,0 +1,21 @@
1
+ import { isStoredEnvelope } from "@cosmicdrift/kumiko-framework/secrets";
2
+
3
+ export type StoredEnvelopeClassification = "rotate" | "current" | "unrecognized";
4
+
5
+ // Shared by every KEK-rotation job (config, auth-mfa, ...): a row whose value
6
+ // isn't a current-cipher envelope (malformed JSON, or any pre-envelope
7
+ // format) is never a supported re-encrypt input — classify it "unrecognized"
8
+ // so callers fail it loudly instead of handing it to cipher.decrypt.
9
+ export function classifyStoredEnvelope(
10
+ value: string,
11
+ targetVersion: number,
12
+ ): StoredEnvelopeClassification {
13
+ let parsed: unknown;
14
+ try {
15
+ parsed = JSON.parse(value);
16
+ } catch {
17
+ return "unrecognized";
18
+ }
19
+ if (!isStoredEnvelope(parsed)) return "unrecognized";
20
+ return parsed.kekVersion === targetVersion ? "current" : "rotate";
21
+ }
@@ -5,6 +5,10 @@ export {
5
5
  type MigrationRowOutcome,
6
6
  runChunkedMigration,
7
7
  } from "./chunked-entity-migration";
8
+ export {
9
+ classifyStoredEnvelope,
10
+ type StoredEnvelopeClassification,
11
+ } from "./classify-stored-envelope";
8
12
  export { decryptStoredPii } from "./decrypt-stored-pii";
9
13
  export { encryptForDirectWrite } from "./encrypt-for-direct-write";
10
14
  export { entitiesOf } from "./entities-of";
@@ -47,6 +47,15 @@ test("defineCreateWithTenantDefaults throws on an unknown currency field", () =>
47
47
  ).toThrow(/unknown field "notARealField"/);
48
48
  });
49
49
 
50
+ test("defineCreateWithTenantDefaults throws on an unknown locale field", () => {
51
+ expect(() =>
52
+ defineCreateWithTenantDefaults("invoice", invoiceEntity, {
53
+ access: ACCESS,
54
+ localeField: "languge",
55
+ }),
56
+ ).toThrow(/unknown field "languge"/);
57
+ });
58
+
50
59
  describe("without tenant-settings mount", () => {
51
60
  const invoiceFeature = defineFeature("invoice", (r) => {
52
61
  r.entity("invoice", invoiceEntity);
@@ -42,6 +42,9 @@ export function defineCreateWithTenantDefaults(
42
42
  const isRequired = "required" in def && def.required === true;
43
43
  relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
44
44
  }
45
+ if (options.localeField && !entity.fields[options.localeField]) {
46
+ throw new Error(`defineCreateWithTenantDefaults: unknown field "${options.localeField}"`);
47
+ }
45
48
  const baseSchema = buildInsertSchema(entity);
46
49
  const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
47
50
 
@@ -85,19 +85,8 @@ export type ComposedApp<TCaps extends Readonly<Record<string, unknown>>> = {
85
85
  * is safer than silently mounting the wrong feature-set and hoping nobody
86
86
  * notices when "Pro" turns out to mean "Free".
87
87
  *
88
- * **No production callers, and that is not rot.** This is the BOOT-TIME half
89
- * of tier composition which features get mounted at all. The RUNTIME half,
90
- * which features a given tenant may see out of everything mounted, is
91
- * `createTierEngineFeature`'s `resolver(tenantId) => ReadonlySet<string>`
92
- * (feature.ts). Both read the same tierMap at opposite ends. Apps use the
93
- * resolver today; nobody composes per-tenant feature sets at boot yet, so an
94
- * app that only needs caps merges them locally rather than constructing a
95
- * featureRegistry it has no other use for (kumiko-studio's
96
- * `resolvePlatformCaps`, studio#154/1 → PR #162 — a deliberate, typed copy of
97
- * step 4, not a workaround). Whether the boot-time half ever gets a caller is
98
- * a product question: kumiko-studio's BYO pivot (Sprint 8a Phase 3a)
99
- * differentiates tiers by CAPS, not by feature set. Delete this only once no
100
- * app plans to differentiate by feature set — the engine is app-agnostic.
88
+ * **No production callers, and that is not rot** see
89
+ * docs/reference/tier-composition-boot-vs-runtime.md for why.
101
90
  */
102
91
  export function composeApp<TCaps extends Readonly<Record<string, unknown>>>(
103
92
  input: ComposeAppInput<TCaps>,
@@ -104,6 +104,31 @@ describe("seedUser", () => {
104
104
  expect(row?.["passwordHash"]).toBeNull();
105
105
  });
106
106
 
107
+ test("emailVerified:true reconciled auf einem bereits geseedeten User (#1687)", async () => {
108
+ const first = await seedUser(stack.db, {
109
+ email: "frank@example.com",
110
+ displayName: "Frank",
111
+ });
112
+ const [rowBefore] = await selectMany(stack.db, userTable, { id: first.id });
113
+ expect(rowBefore?.["emailVerified"]).not.toBe(true);
114
+
115
+ const second = await seedUser(stack.db, {
116
+ email: "frank@example.com",
117
+ displayName: "Frank",
118
+ emailVerified: true,
119
+ });
120
+ expect(second.id).toBe(first.id);
121
+
122
+ const [rowAfter] = await selectMany(stack.db, userTable, { id: first.id });
123
+ expect(rowAfter?.["emailVerified"]).toBe(true);
124
+
125
+ const updated = await selectMany(stack.db, eventsTable, {
126
+ aggregateType: "user",
127
+ type: "user.updated",
128
+ });
129
+ expect(updated).toHaveLength(1);
130
+ });
131
+
107
132
  test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
108
133
  const { id: userId } = await seedUser(stack.db, {
109
134
  email: "eve@example.com",
@@ -69,7 +69,31 @@ export async function seedUser(
69
69
  const existing = await fetchOne(db, userTable, { email: options.email });
70
70
  // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
71
71
  // liefert die Projection-Row als Record<string, unknown>.
72
- if (existing) return { id: existing["id"] as string };
72
+ if (existing) {
73
+ const id = existing["id"] as string;
74
+ // Reconcile emailVerified on an already-seeded row: a persistent dev DB
75
+ // (or a re-run bootstrap) can carry a User seeded before this flag
76
+ // existed or before it flipped to true — without this, "seed with
77
+ // emailVerified: true" only ever takes effect on the very first insert
78
+ // and the flag silently does nothing on every re-run after (#1687).
79
+ // Goes through the executor (a real `.updated` event), never a direct
80
+ // write.
81
+ if (options.emailVerified === true && existing["emailVerified"] !== true) {
82
+ const result = await userExecutor.update(
83
+ { id, version: existing["version"] as number, changes: { emailVerified: true } },
84
+ by,
85
+ tdb,
86
+ );
87
+ // version_conflict = a concurrent write already changed the row —
88
+ // fine for a seed helper, don't fail the whole seed run over it.
89
+ if (!result.isSuccess && result.error.code !== "version_conflict") {
90
+ throw new Error(
91
+ `seedUser emailVerified reconcile failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
92
+ );
93
+ }
94
+ }
95
+ return { id };
96
+ }
73
97
 
74
98
  const result = await userExecutor.create(
75
99
  {
@@ -103,7 +103,7 @@ afterEach(() => {
103
103
 
104
104
  function tokenFromLastVerifyCall(): string {
105
105
  const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
106
- return url.searchParams.get("token") ?? "";
106
+ return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
107
107
  }
108
108
 
109
109
  describe("anonymous deletion flow with active KMS", () => {
@@ -95,7 +95,7 @@ async function seedAlice(status: string = USER_STATUS.Active, email: string = AL
95
95
 
96
96
  function tokenFromLastVerifyCall(): string {
97
97
  const url = new URL(verifyCalls[0]?.verifyUrl ?? "");
98
- return url.searchParams.get("token") ?? "";
98
+ return new URLSearchParams(url.hash.slice(1)).get("token") ?? "";
99
99
  }
100
100
 
101
101
  async function statusOf(): Promise<string | undefined> {
@@ -118,7 +118,7 @@ describe("anonymous deletion flow", () => {
118
118
 
119
119
  expect(verifyCalls).toHaveLength(1);
120
120
  expect(verifyCalls[0]?.email).toBe(ALICE_EMAIL);
121
- expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}?token=`)).toBe(true);
121
+ expect(verifyCalls[0]?.verifyUrl.startsWith(`${VERIFY_URL}#token=`)).toBe(true);
122
122
  expect(tokenFromLastVerifyCall().length).toBeGreaterThan(0);
123
123
  // Status noch NICHT geflipt — erst confirm startet die Grace-Period.
124
124
  expect(await statusOf()).toBe(USER_STATUS.Active);
@@ -2,20 +2,23 @@ import { describe, expect, test } from "bun:test";
2
2
  import { buildDeletionVerifyUrl } from "../handlers/request-deletion-by-email.write";
3
3
 
4
4
  describe("buildDeletionVerifyUrl", () => {
5
- test("appends ?token to a plain base URL", () => {
6
- expect(buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123")).toBe(
7
- "https://app.example.com/delete/confirm?token=tok-123",
8
- );
5
+ // fw#1554: the token goes in the URL fragment, not a query param —
6
+ // fragments never leave the browser, so they never land in proxy/access
7
+ // logs (unlike ?token=, which does).
8
+ test("puts the token in the URL fragment, not a query param", () => {
9
+ const url = buildDeletionVerifyUrl("https://app.example.com/delete/confirm", "tok-123");
10
+ expect(url).toBe("https://app.example.com/delete/confirm#token=tok-123");
11
+ expect(new URL(url).search).toBe("");
9
12
  });
10
13
 
11
- test("appends &token when the base already carries query params (not a second ?)", () => {
14
+ test("preserves an existing query param only the token is a fragment", () => {
12
15
  const url = buildDeletionVerifyUrl("https://app.example.com/confirm?lang=de", "tok-123");
13
- expect(url).toBe("https://app.example.com/confirm?lang=de&token=tok-123");
14
- expect(url.match(/\?/g)).toHaveLength(1);
16
+ expect(url).toBe("https://app.example.com/confirm?lang=de#token=tok-123");
15
17
  });
16
18
 
17
19
  test("URL-encodes a token with reserved characters", () => {
18
20
  const url = new URL(buildDeletionVerifyUrl("https://app.example.com/c", "a b&c=d"));
19
- expect(url.searchParams.get("token")).toBe("a b&c=d");
21
+ const params = new URLSearchParams(url.hash.slice(1));
22
+ expect(params.get("token")).toBe("a b&c=d");
20
23
  });
21
24
  });
@@ -112,8 +112,9 @@ export type UserDataRightsOptions = {
112
112
  * by-token weist generisch ab). */
113
113
  readonly deletionTokenSecret?: string;
114
114
  /** Basis-URL des Apex-Confirm-Screens, z.B.
115
- * "https://app.example.com/delete-account/confirm". Der Handler hängt
116
- * `?token=<token>` an. Required wenn deletionTokenSecret gesetzt. */
115
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt das
116
+ * Token als URL-Fragment an (`#token=<token>`, fw#1554). Required wenn
117
+ * deletionTokenSecret gesetzt. */
117
118
  readonly deletionVerifyUrl?: string;
118
119
  /** Versand des Verify-Magic-Links (Schritt 1 des anonymen Flows).
119
120
  * Best-effort, app-author-wired. MUSS non-blocking sein (enqueue, z.B.
@@ -30,18 +30,20 @@ export type RequestDeletionByEmailOptions = {
30
30
  * deaktiviert (Handler antwortet still mit success, kein Link). */
31
31
  readonly deletionTokenSecret?: string;
32
32
  /** Basis-URL des Apex-Confirm-Screens, z.B.
33
- * "https://app.example.com/delete-account/confirm". Der Handler hängt
34
- * `?token=<token>` an. Ohne URL kein Link. */
33
+ * "https://app.example.com/delete-account/confirm". Der Handler hängt das
34
+ * Token als URL-Fragment an (`#token=<token>`, fw#1554). Ohne URL kein Link. */
35
35
  readonly deletionVerifyUrl?: string;
36
36
  readonly sendDeletionVerificationEmail?: SendDeletionVerificationEmailFn;
37
37
  };
38
38
 
39
- // URL-safe append: handles a base URL that already carries query params
40
- // (`?lang=de` `?lang=de&token=…`) instead of producing an invalid
41
- // `?lang=de?token=…`. searchParams.set encodes the token.
39
+ // Token goes in the URL fragment, not a query param fragments never leave
40
+ // the browser (not sent to the server, so they never land in proxy/access
41
+ // logs, unlike `?token=`). Same convention as the export-download link
42
+ // (feature.ts, issue #1271). Preserves any existing query params on `base`
43
+ // (`?lang=de` stays a query param; only the token is a fragment).
42
44
  export function buildDeletionVerifyUrl(base: string, token: string): string {
43
45
  const url = new URL(base);
44
- url.searchParams.set("token", token);
46
+ url.hash = `token=${encodeURIComponent(token)}`;
45
47
  return url.toString();
46
48
  }
47
49
 
@@ -79,15 +79,15 @@ describe("RequestAccountDeletionScreen", () => {
79
79
  });
80
80
 
81
81
  describe("ConfirmAccountDeletionScreen", () => {
82
- test("ohne ?token → missingToken, kein Confirm-Button", () => {
82
+ test("ohne #token → missingToken, kein Confirm-Button", () => {
83
83
  window.history.replaceState({}, "", "/delete-account/confirm");
84
84
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
85
85
  expect(ui.getByText(/Kein Token/)).toBeTruthy();
86
86
  expect(ui.queryByRole("button")).toBeNull();
87
87
  });
88
88
 
89
- test("mit ?token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
90
- window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
89
+ test("mit #token → Confirm dispatcht confirm-deletion-by-token + Success", async () => {
90
+ window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
91
91
  const calls: WriteCall[] = [];
92
92
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, calls));
93
93
  fireEvent.click(ui.getByRole("button"));
@@ -98,7 +98,7 @@ describe("ConfirmAccountDeletionScreen", () => {
98
98
  });
99
99
 
100
100
  test("write-Failure → invalidToken-Banner, kein Success", async () => {
101
- window.history.replaceState({}, "", "/delete-account/confirm?token=bad");
101
+ window.history.replaceState({}, "", "/delete-account/confirm#token=bad");
102
102
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(false, []));
103
103
  fireEvent.click(ui.getByRole("button"));
104
104
  await waitFor(() => expect(ui.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
@@ -106,7 +106,7 @@ describe("ConfirmAccountDeletionScreen", () => {
106
106
  });
107
107
 
108
108
  test("write wirft → generischer Error-Banner, NICHT invalidToken", async () => {
109
- window.history.replaceState({}, "", "/delete-account/confirm?token=tok-123");
109
+ window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
110
110
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeThrowingDispatcher());
111
111
  fireEvent.click(ui.getByRole("button"));
112
112
  await waitFor(() => expect(ui.getByText(/schief gegangen/)).toBeTruthy());
@@ -1,6 +1,7 @@
1
1
  // @runtime client
2
2
  // ConfirmAccountDeletionScreen — anonymer Apex-Screen Schritt 2. Liest das
3
- // `?token` aus der Verify-Link-URL und dispatcht beim Bestätigen
3
+ // Token aus dem URL-Fragment (`#token=…`, nie an den Server gesendet — landet
4
+ // so nicht in Proxy-/Access-Logs, fw#1554) und dispatcht beim Bestätigen
4
5
  // user-data-rights:write:confirm-deletion-by-token → startet die Grace-Period.
5
6
  //
6
7
  // App mountet den Screen unter der deletionVerifyUrl-Route (z.B.
@@ -15,7 +16,7 @@ type Phase = "idle" | "submitting" | "success" | "missing" | "invalid" | "error"
15
16
 
16
17
  function readToken(): string {
17
18
  if (typeof window === "undefined") return "";
18
- return new URLSearchParams(window.location.search).get("token") ?? "";
19
+ return new URLSearchParams(window.location.hash.slice(1)).get("token") ?? "";
19
20
  }
20
21
 
21
22
  export type ConfirmAccountDeletionScreenProps = {