@cosmicdrift/kumiko-bundled-features 0.335.0 → 0.336.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 (43) hide show
  1. package/dist/auth-email-password/handlers/login.write.js +1 -2
  2. package/dist/auth-mfa/handlers/status.query.js +1 -0
  3. package/dist/auth-mfa-user-data/hooks.js +4 -2
  4. package/dist/cap-counter/book-cap-usage.js +5 -0
  5. package/dist/custom-fields/feature.js +3 -2
  6. package/dist/delivery/handlers/log.query.js +33 -24
  7. package/dist/feature-toggles/toggle-runtime.d.ts +1 -0
  8. package/dist/feature-toggles/toggle-runtime.js +16 -2
  9. package/dist/inbound-mail-foundation/feature.js +1 -1
  10. package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
  11. package/dist/notes-history/feature.js +2 -2
  12. package/dist/notes-history/handlers/add-note.write.js +2 -1
  13. package/dist/notes-history-user-data/hooks.js +21 -14
  14. package/dist/personal-access-tokens/revoke-for-user.js +12 -1
  15. package/dist/personal-access-tokens/schema/api-token.js +5 -5
  16. package/dist/sessions/session-callbacks.js +86 -75
  17. package/dist/shared/assert-erased.d.ts +2 -1
  18. package/dist/shared/assert-erased.js +12 -4
  19. package/dist/shared/index.d.ts +1 -1
  20. package/dist/shared/index.js +1 -1
  21. package/dist/subscription-mollie/plugin-methods.js +3 -5
  22. package/dist/subscription-stripe/plugin-methods.js +23 -5
  23. package/dist/tenant/handlers/member-directory.query.js +16 -3
  24. package/dist/tenant/handlers/tenant-directory.query.js +7 -1
  25. package/dist/tenant-handover/feature.js +1 -1
  26. package/dist/user/handlers/detail.query.d.ts +1 -0
  27. package/dist/user/schema/user.js +4 -3
  28. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
  29. package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
  30. package/dist/user-data-rights-defaults/hooks/config-value.userdata-hook.js +4 -2
  31. package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -2
  32. package/dist/user-data-rights-defaults/hooks/notification-preference.userdata-hook.js +4 -2
  33. package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +4 -2
  34. package/package.json +9 -9
  35. package/src/cap-counter/changes.json +6 -0
  36. package/src/delivery/changes.json +6 -0
  37. package/src/feature-toggles/changes.json +5 -0
  38. package/src/jobs/changes.json +5 -0
  39. package/src/personal-access-tokens/changes.json +5 -0
  40. package/src/sessions/changes.json +6 -0
  41. package/src/subscription-stripe/changes.json +6 -0
  42. package/src/tenant/changes.json +6 -0
  43. package/src/user-data-rights-defaults/changes.json +5 -0
@@ -179,8 +179,7 @@ export function createLoginHandler(opts = {}) {
179
179
  const statusGate = gateEnforceAccountStatus(found);
180
180
  if (!statusGate.ok)
181
181
  return statusGate.result;
182
- // Still needed for candidate ORDER (preferred tenant first) — the actual
183
- // active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
182
+ // Still needed for candidate ORDER (preferred tenant first) in gateResolveMembership.
184
183
  const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
185
184
  userId: found.id,
186
185
  })); // @cast-boundary db-runner
@@ -12,6 +12,7 @@ import { userMfaTable } from "../schema/user-mfa.js";
12
12
  export const mfaStatusQuery = defineQueryHandler({
13
13
  name: "user-mfa:status",
14
14
  schema: z.object({}),
15
+ outputSchema: z.object({ enabled: z.boolean() }),
15
16
  access: {
16
17
  openToAll: {
17
18
  reason: "each signed-in user reads only their own MFA status; the query filters " +
@@ -9,7 +9,7 @@
9
9
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
10
10
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
11
11
  import { userMfaEntity, userMfaTable } from "../auth-mfa/index.js";
12
- import { assertErased } from "../shared/index.js";
12
+ import { collectErasureFailure, throwIfErasureFailed } from "../shared/index.js";
13
13
  const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
14
14
  entityName: "user-mfa",
15
15
  });
@@ -41,7 +41,9 @@ export const userMfaDeleteHook = async (ctx) => {
41
41
  if (rows.length === 0)
42
42
  return;
43
43
  const systemUser = createSystemUser(ctx.tenantId);
44
+ const failures = [];
44
45
  for (const row of rows) {
45
- assertErased(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id);
46
+ collectErasureFailure(await executor.forget({ id: row.id }, systemUser, ctx.db), "user-mfa", row.id, failures);
46
47
  }
48
+ throwIfErasureFailed(failures);
47
49
  };
@@ -84,6 +84,11 @@ export async function markCapSoftWarned(ctx, options) {
84
84
  if (!row) {
85
85
  throw new Error("cap-counter.markCapSoftWarned: row vanished between length-check and read");
86
86
  }
87
+ // A version-conflict retry lands here after a parallel warner already set the flag; a second
88
+ // write would only append a redundant event with a newer timestamp.
89
+ if (row["lastSoftWarnedAt"] !== null) {
90
+ return { isSuccess: true, data: row };
91
+ }
87
92
  const currentVersion = row["version"]; // @cast-boundary db-row
88
93
  return executor.update({
89
94
  id: aggregateId,
@@ -79,8 +79,9 @@ function registerCustomFields(r, variant) {
79
79
  // Event-types — qualified als "custom-fields:event:<short-name>".
80
80
  // Returned EventDefs liefern .name als compile-time literal-typed string,
81
81
  // den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
82
- // `value: z.unknown()` has no field-level schema to catalog under piiFields;
83
- // "no PII" is a naming convention (#972), not an enforced guarantee — see #2776.
82
+ // `value: z.unknown()` has no field-level schema to catalog under piiFields
83
+ // (event PII covers string fields only), so "no PII" is a convention here,
84
+ // not an enforced guarantee.
84
85
  const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
85
86
  piiFields: "none",
86
87
  });
@@ -1,5 +1,5 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
2
+ import { decodeKeysetCursor, encodeKeysetCursor } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { Temporal } from "temporal-polyfill";
@@ -28,15 +28,15 @@ function isDeliveryLogSortField(value) {
28
28
  // PHYSICAL column, not the display alias — row only has the DB field names
29
29
  // (e.g. notificationType), never the alias (e.g. "type").
30
30
  function encodeSortCursor(column, row) {
31
- if (column === "createdAt")
32
- return encodeCursor(row.createdAt.toString());
33
- return encodeCursor(String(row[column]));
31
+ const sortValue = column === "createdAt" ? row.createdAt.toString() : String(row[column]);
32
+ return encodeKeysetCursor(sortValue, row.id);
34
33
  }
34
+ // lastId is undefined for a legacy sort-value-only cursor still in flight.
35
35
  function decodeSortCursor(column, cursor) {
36
- const decoded = decodeCursor(cursor);
37
- if (column === "createdAt")
38
- return Temporal.Instant.from(decoded);
39
- return decoded;
36
+ const decoded = decodeKeysetCursor(cursor);
37
+ const raw = decoded.sortValue ?? decoded.id;
38
+ const sortValue = column === "createdAt" ? Temporal.Instant.from(raw) : raw;
39
+ return { sortValue, lastId: decoded.sortValue === undefined ? undefined : decoded.id };
40
40
  }
41
41
  export const logQuery = definePagedQueryHandler({
42
42
  name: "log",
@@ -70,23 +70,32 @@ export const logQuery = definePagedQueryHandler({
70
70
  const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
71
71
  // TenantAdmin/Admin stay strictly tenant-scoped; SystemAdmin sees every
72
72
  // tenant's attempts (platform waitlist confirmations live on SYSTEM_TENANT_ID).
73
- const where = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
74
- if (query.payload.cursor) {
75
- where[sortColumn] = {
76
- [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
77
- };
78
- }
79
- const rows = await selectMany(db, deliveryAttemptsTable, where, {
80
- // Tie-breaker on id keeps ordering stable across pages when the sort
81
- // column has duplicate values (e.g. many rows with the same status).
82
- orderBy: [
83
- { col: sortColumn, direction: sortDirection },
84
- { col: "id", direction: "asc" },
85
- ],
86
- limit: query.payload.limit,
87
- });
73
+ const baseWhere = isSystemAdmin ? {} : { tenantId: query.user.tenantId };
74
+ const limit = query.payload.limit;
75
+ const cursor = query.payload.cursor
76
+ ? decodeSortCursor(sortColumn, query.payload.cursor)
77
+ : undefined;
78
+ const beyondSortValue = sortDirection === "asc" ? "gt" : "lt";
79
+ // Keyset page = rows still tied on the cursor's sort value (id after the
80
+ // cursor's id), then rows strictly beyond it. Both halves are ordered the
81
+ // same way as the overall ORDER BY, so concatenation preserves it.
82
+ const tiedRows = cursor?.lastId !== undefined
83
+ ? await selectMany(db, deliveryAttemptsTable, { ...baseWhere, [sortColumn]: cursor.sortValue, id: { gt: cursor.lastId } }, { orderBy: [{ col: "id", direction: "asc" }], limit })
84
+ : [];
85
+ const beyondRows = tiedRows.length < limit
86
+ ? await selectMany(db, deliveryAttemptsTable, cursor
87
+ ? { ...baseWhere, [sortColumn]: { [beyondSortValue]: cursor.sortValue } }
88
+ : baseWhere, {
89
+ orderBy: [
90
+ { col: sortColumn, direction: sortDirection },
91
+ { col: "id", direction: "asc" },
92
+ ],
93
+ limit: limit - tiedRows.length,
94
+ })
95
+ : [];
96
+ const rows = [...tiedRows, ...beyondRows];
88
97
  const lastRow = rows[rows.length - 1];
89
- const nextCursor = rows.length === query.payload.limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
98
+ const nextCursor = rows.length === limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
90
99
  // recipientAddress is stored encrypted under the recipient's DEK (#799)
91
100
  // — decrypt for the admin log view; forgotten subjects show [[erased]].
92
101
  // The notificationType/recipientAddress → type/recipient rename happens
@@ -9,6 +9,7 @@ export declare class GlobalFeatureToggleRuntime {
9
9
  private readonly registry;
10
10
  private readonly syncSignal?;
11
11
  private snapshot;
12
+ private appliedDuringLoad;
12
13
  constructor(db: DbConnection, registry: Registry, syncSignal?: ToggleSyncSignal | undefined);
13
14
  initialize(): Promise<void>;
14
15
  refresh(): Promise<void>;
@@ -17,6 +17,9 @@ export class GlobalFeatureToggleRuntime {
17
17
  registry;
18
18
  syncSignal;
19
19
  snapshot = new Map();
20
+ // Flips applied while a snapshot load is in flight. The load's SELECT may have
21
+ // started before the flip committed, so its rows can be older than these.
22
+ appliedDuringLoad;
20
23
  constructor(db, registry, syncSignal) {
21
24
  this.db = db;
22
25
  this.registry = registry;
@@ -30,8 +33,18 @@ export class GlobalFeatureToggleRuntime {
30
33
  });
31
34
  }
32
35
  async initialize() {
33
- const rows = await selectMany(this.db, globalFeatureStateTable);
34
- this.snapshot = new Map(rows.map((r) => [r.featureName, r.enabled]));
36
+ const pendingFlips = new Map();
37
+ this.appliedDuringLoad = pendingFlips;
38
+ try {
39
+ const rows = await selectMany(this.db, globalFeatureStateTable);
40
+ this.snapshot = new Map([
41
+ ...rows.map((r) => [r.featureName, r.enabled]),
42
+ ...pendingFlips,
43
+ ]);
44
+ }
45
+ finally {
46
+ this.appliedDuringLoad = undefined;
47
+ }
35
48
  }
36
49
  // Re-read the full snapshot. Called from the set-handler after a
37
50
  // successful write — cheap point-update would be an optimisation but
@@ -46,6 +59,7 @@ export class GlobalFeatureToggleRuntime {
46
59
  // lock. Kept alongside refresh() so both options are explicit.
47
60
  apply(featureName, enabled) {
48
61
  this.snapshot.set(featureName, enabled);
62
+ this.appliedDuringLoad?.set(featureName, enabled);
49
63
  }
50
64
  // Called by the toggle-cache-sync MSP handler (feature.ts) — the one
51
65
  // process whose shared cursor won a given toggle-set event. With a
@@ -67,7 +67,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
67
67
  // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
68
68
  // "displayName" stays plaintext (often the mailbox owner's real name for
69
69
  // personal accounts); encrypting it would break the entity-projection read
70
- // path that carries it as `personal: false` (#2776).
70
+ // path that carries it as `personal: false`.
71
71
  r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
72
72
  piiFields: "none",
73
73
  });
@@ -12,30 +12,37 @@
12
12
  // the run becomes retriable via the existing jobs:write:retry gate
13
13
  // (retry.write.ts only allows retry from "failed").
14
14
  import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import { mapWithConcurrency } from "../../../shared/index.js";
15
16
  import { encryptFailureError } from "../../job-run-logger.js";
16
17
  import { jobRunsTable } from "../../job-run-table.js";
18
+ const KMS_POOL_CONCURRENCY = 4;
17
19
  export const STALE_JOB_RUN_ERROR = "job run exceeded the stale-run timeout without a completion signal (likely a crashed worker process)";
18
20
  export async function markStaleJobRunsFailed(db, timeoutHours) {
19
21
  const cutoff = Temporal.Now.instant().subtract({ hours: timeoutHours });
20
22
  const now = Temporal.Now.instant();
21
23
  // Fetch the matched rows first: `error` carries the same per-triggering-
22
24
  // user subject annotation as job-run-logger.ts's onJobFailed path
23
- // (personal: { of: "triggeredById" } on job-run-table.ts), and a batch
24
- // updateMany can't encrypt per-row — different matched runs can belong to
25
- // different users, each under their own DEK. One updateMany per row keeps
26
- // this crash-recovery sweep's write on the same encrypted footing as the
27
- // normal failure path instead of writing STALE_JOB_RUN_ERROR in the clear.
25
+ // (personal: { of: "triggeredById" } on job-run-table.ts), so it is encrypted
26
+ // under that subject's DEK. Runs are grouped by subject: one encrypt and one
27
+ // updateMany per subject instead of per run.
28
28
  const stale = await selectMany(db, jobRunsTable, {
29
29
  status: "running",
30
30
  startedAt: { lt: cutoff },
31
31
  });
32
+ const idsBySubject = new Map();
33
+ for (const run of stale) {
34
+ const ids = idsBySubject.get(run.triggeredById);
35
+ if (ids)
36
+ ids.push(run.id);
37
+ else
38
+ idsBySubject.set(run.triggeredById, [run.id]);
39
+ }
32
40
  // duration is deliberately left untouched: we don't know when the run
33
41
  // actually died, only that it crossed the timeout, so recording a
34
42
  // duration would misrepresent it as measured. detail-screen/list-screen
35
43
  // both already render a null duration as "—".
36
- let runsMarkedFailed = 0;
37
- for (const run of stale) {
38
- const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, run.triggeredById);
44
+ const updatedPerSubject = await mapWithConcurrency([...idsBySubject], KMS_POOL_CONCURRENCY, async ([triggeredById, ids]) => {
45
+ const encryptedError = await encryptFailureError(STALE_JOB_RUN_ERROR, triggeredById);
39
46
  const updated = await updateMany(db, jobRunsTable, {
40
47
  status: "failed",
41
48
  error: encryptedError,
@@ -43,12 +50,12 @@ export async function markStaleJobRunsFailed(db, timeoutHours) {
43
50
  modifiedAt: now,
44
51
  modifiedById: "system",
45
52
  },
46
- // Re-assert status: "running" here (not just id) — closes the race
47
- // window between the selectMany above and this write, where the run
48
- // could have completed/failed normally in between and must not be
49
- // clobbered back to "failed" with the stale-timeout message.
50
- { id: run.id, status: "running" });
51
- runsMarkedFailed += updated.length;
52
- }
53
+ // Re-assert status: "running" (not just id) — closes the race window
54
+ // between the selectMany above and this write, where a run could have
55
+ // completed/failed normally and must not be clobbered back to "failed".
56
+ { id: { in: ids }, status: "running" });
57
+ return updated.length;
58
+ });
59
+ const runsMarkedFailed = updatedPerSubject.reduce((sum, count) => sum + count, 0);
53
60
  return { runsMarkedFailed };
54
61
  }
@@ -71,8 +71,8 @@ export function createNotesHistoryFeature(opts = {}) {
71
71
  // of passing an empty array.
72
72
  if (opts.parents !== undefined && opts.parents.length === 0) {
73
73
  throw new Error("createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
74
- "an empty allowlist rejects every add-note call. Omit `parents` to keep " +
75
- "today's unrestricted behavior instead.");
74
+ "an empty allowlist rejects every add-note call. Omit `parents` to allow " +
75
+ "any registered entity the caller can read as a note parent.");
76
76
  }
77
77
  return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) => registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents));
78
78
  }
@@ -35,7 +35,8 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
35
35
  try {
36
36
  // read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
37
37
  // Bun.SQL poisons the whole tx after any error inside it, even one that's
38
- // caught — a bare try/catch here would take the note write down with it.
38
+ // caught — so the lookup runs in a savepoint where available, otherwise directly
39
+ // (pool statements are their own units).
39
40
  authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON), async (sp) => {
40
41
  const userRow = await fetchOne(sp, userTable, {
41
42
  id: event.user.id,
@@ -64,21 +64,28 @@ export const noteEntryDeleteHook = async (ctx) => {
64
64
  registry: ctx.registry,
65
65
  tenantId: ctx.tenantId,
66
66
  });
67
- for (const noteId of noteIds) {
68
- const note = await ctx.db.fetchOne(noteEntryTable, { id: noteId });
69
- // Defensive: append-only rows are never hard-deleted, so this shouldn't
70
- // happen — but a missing host means no retention policy to consult.
71
- if (!note)
72
- continue;
73
- const hostPolicy = await resolveRetentionPolicyForTenant({
74
- db: ctx.db,
75
- registry: ctx.registry,
76
- tenantId: ctx.tenantId,
77
- entityName: note.entityType,
78
- preloadedTenantPreset: tenantPreset,
79
- });
80
- if (policyToStrategy(hostPolicy.policy?.strategy ?? null) === "anonymize")
67
+ // Append-only rows are never hard-deleted, so every mentioned note should
68
+ // resolve; a missing one has no host and thus no retention policy to consult.
69
+ const notes = await ctx.db.selectMany(noteEntryTable, {
70
+ id: { in: [...noteIds] },
71
+ });
72
+ const strategyByEntityType = new Map();
73
+ for (const note of notes) {
74
+ let strategy = strategyByEntityType.get(note.entityType);
75
+ if (strategy === undefined) {
76
+ const hostPolicy = await resolveRetentionPolicyForTenant({
77
+ db: ctx.db,
78
+ registry: ctx.registry,
79
+ tenantId: ctx.tenantId,
80
+ entityName: note.entityType,
81
+ preloadedTenantPreset: tenantPreset,
82
+ });
83
+ strategy = policyToStrategy(hostPolicy.policy?.strategy ?? null);
84
+ strategyByEntityType.set(note.entityType, strategy);
85
+ }
86
+ if (strategy === "anonymize")
81
87
  continue;
88
+ const noteId = note.id;
82
89
  await kms.eraseKey({ kind: "record", entity: "note-entry", id: noteId }, {
83
90
  requestId: "notes-history-user-data:forget-mentioned-notes",
84
91
  userId: ctx.userId,
@@ -1,15 +1,26 @@
1
- import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { transaction, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
5
5
  import { Temporal } from "temporal-polyfill";
6
6
  import { PAT_REVOKED_AGGREGATE_TYPE, PAT_REVOKED_EVENT_QN, patRevokedSchema, } from "./pat-revoked-event.js";
7
7
  import { apiTokenTable } from "./schema/api-token.js";
8
+ // A pool has begin(); a tx/savepoint handle does not and is already atomic.
9
+ function canBeginTransaction(db) {
10
+ return typeof db.begin === "function"; // @cast-boundary driver-handle-probe
11
+ }
8
12
  // Cross-tenant revoke: password-change and MFA-enable/disable are account-
9
13
  // level security events, not scoped to one tenant. Mirrors sessions'
10
14
  // sessionMassRevoker (session-callbacks.ts) which passes the boot-time
11
15
  // DbConnection directly — not ctx.db, which would be tenant-scoped in a hook.
12
16
  export async function revokeAllPatTokensForUser(db, userId) {
17
+ // Update and event commit together: otherwise a failed append leaves the
18
+ // tokens revoked with no event, and a retry matches no live row.
19
+ return canBeginTransaction(db)
20
+ ? transaction(db, (tx) => revokeAndAppend(tx, userId))
21
+ : revokeAndAppend(db, userId);
22
+ }
23
+ async function revokeAndAppend(db, userId) {
13
24
  const updated = await updateMany(db, apiTokenTable, { revokedAt: Temporal.Now.instant() }, { userId, revokedAt: null });
14
25
  // Same reasoning as sessionMassRevoker's append — this raw
15
26
  // callback has no dispatcher ctx to call unsafeAppendEvent from, so it
@@ -42,27 +42,27 @@ export const apiTokenEntity = createEntity({
42
42
  // store ciphertext while the resolver keeps querying for the plaintext
43
43
  // hash — every bearer-token request would 401 once a KMS is active.
44
44
  // Fixing that needs a blind-index-backed lookup (like email's
45
- // find:"exact" companion column), a resolver.ts rewrite out of scope
46
- // here — see PR body "Offene Fragen".
45
+ // find:"exact" companion column), a resolver.ts rewrite, so this stays
46
+ // unencrypted until a blind-index lookup exists.
47
47
  tokenHash: createTextField({
48
48
  required: true,
49
49
  maxLength: 64,
50
50
  access: { write: access.privileged },
51
51
  personal: false,
52
- reason: "credential_hash_used_as_exact_lookup_key_2809",
52
+ reason: "credential_hash_used_as_exact_lookup_key",
53
53
  }),
54
54
  // Short displayable prefix ("your tokens" list) — not the secret itself,
55
55
  // but still identifies which of the user's tokens this row is. Kept
56
56
  // Klasse 1: list.query.ts returns `r.prefix` straight through with no
57
57
  // decrypt call (unlike `name`, which goes through decryptStoredPii) — a
58
58
  // subject annotation would leak ciphertext to the UI once a KMS is
59
- // active. See PR body "Offene Fragen".
59
+ // active.
60
60
  prefix: createTextField({
61
61
  required: true,
62
62
  maxLength: 16,
63
63
  access: { write: access.privileged },
64
64
  personal: false,
65
- reason: "credential_prefix_read_without_decrypt_2809",
65
+ reason: "credential_prefix_read_without_decrypt",
66
66
  }),
67
67
  // JSON-encoded string[] of granted scope names — mirrors the roles-column
68
68
  // convention (parseRoles-style text); the resolver JSON.parses it.
@@ -1,4 +1,4 @@
1
- import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { fetchOne, insertOne, transaction, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { buildSessionRoles, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { append } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import { generateId, parseRoles } from "@cosmicdrift/kumiko-framework/utils";
@@ -37,30 +37,35 @@ export function createSessionCallbacks(opts) {
37
37
  // original timestamp. Double-revoke races land here via logout +
38
38
  // switch-tenant on the same sid. (Password-change uses a different
39
39
  // callback — sessionMassRevoker — and isn't in scope for this guard.)
40
- const result = await updateMany(db, userSessionTable, { revokedAt: Temporal.Now.instant() }, { id: sid, revokedAt: null });
41
- // This is the ONLY caller of sessionRevoker — logout and tenant-switch
42
- // — so it's also the only source of streamScope: "revoked-sessions".
43
- // Without this append, a live SSE stream on this exact sid outlives its
44
- // own logout because the access-invalidation consumer never hears
45
- // about it — same gap sessionMassRevoker closed for
46
- // password-change.
47
- const revoked = result[0];
48
- if (revoked) {
49
- const payload = sessionRevokedSchema.parse({
50
- userId: revoked.userId,
51
- sessionIds: [sid],
52
- streamScope: "revoked-sessions",
53
- });
54
- await append(db, {
55
- aggregateId: generateId(),
56
- aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
57
- tenantId: SYSTEM_TENANT_ID,
58
- expectedVersion: 0,
59
- type: SESSION_REVOKED_EVENT_QN,
60
- payload,
61
- metadata: { userId: revoked.userId },
62
- });
63
- }
40
+ // One transaction: if the append failed after the update committed, a
41
+ // retried logout would match no live row (revokedAt IS NULL) and never
42
+ // emit the event, leaving the SSE stream of this sid open until JWT expiry.
43
+ await transaction(db, async (tx) => {
44
+ const result = await updateMany(tx, userSessionTable, { revokedAt: Temporal.Now.instant() }, { id: sid, revokedAt: null });
45
+ // This is the ONLY caller of sessionRevoker — logout and tenant-switch
46
+ // — so it's also the only source of streamScope: "revoked-sessions".
47
+ // Without this append, a live SSE stream on this exact sid outlives its
48
+ // own logout because the access-invalidation consumer never hears
49
+ // about it — same gap sessionMassRevoker closed for
50
+ // password-change.
51
+ const revoked = result[0];
52
+ if (revoked) {
53
+ const payload = sessionRevokedSchema.parse({
54
+ userId: revoked.userId,
55
+ sessionIds: [sid],
56
+ streamScope: "revoked-sessions",
57
+ });
58
+ await append(tx, {
59
+ aggregateId: generateId(),
60
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
61
+ tenantId: SYSTEM_TENANT_ID,
62
+ expectedVersion: 0,
63
+ type: SESSION_REVOKED_EVENT_QN,
64
+ payload,
65
+ metadata: { userId: revoked.userId },
66
+ });
67
+ }
68
+ });
64
69
  },
65
70
  // kumiko-lint-ignore complexity-budget session check + lastSeen refresh on hot path
66
71
  async sessionChecker(sid, expectedUserId) {
@@ -140,58 +145,64 @@ export function createSessionCallbacks(opts) {
140
145
  async sessionMassRevoker(userId) {
141
146
  // Count is accurate because we only touch live rows — a previously
142
147
  // revoked row stays in its state and isn't double-counted.
143
- const result = await updateMany(db, userSessionTable, { revokedAt: Temporal.Now.instant() }, { userId, revokedAt: null });
144
- // Lightweight append alongside the direct-write above, same pattern
145
- // as revoke.write.ts (#1559) — this callback is the password-change
146
- // auto-revoke path (sessions/feature.ts postSave hook) and has no
147
- // dispatcher ctx to call unsafeAppendEvent from, so it uses the raw
148
- // append() like revoke-all-for-user.write.ts does. Without this, the
149
- // access-invalidation consumer (#1560) never hears about a password
150
- // change and an already-open SSE stream survives it — the exact
151
- // "stale JWT mid-stream" gap this feature exists to close.
152
- if (result.length > 0) {
153
- const payload = sessionRevokedSchema.parse({
154
- userId,
155
- sessionIds: result.map((row) => row.id),
156
- });
157
- await append(db, {
158
- aggregateId: generateId(),
159
- aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
160
- tenantId: SYSTEM_TENANT_ID,
161
- expectedVersion: 0,
162
- type: SESSION_REVOKED_EVENT_QN,
163
- payload,
164
- metadata: { userId },
165
- });
166
- }
167
- return result.length;
148
+ // One transaction, same reason as sessionRevoker: a failed append must
149
+ // not leave sessions revoked without the event.
150
+ return transaction(db, async (tx) => {
151
+ const result = await updateMany(tx, userSessionTable, { revokedAt: Temporal.Now.instant() }, { userId, revokedAt: null });
152
+ // Lightweight append alongside the direct-write above, same pattern
153
+ // as revoke.write.ts (#1559) — this callback is the password-change
154
+ // auto-revoke path (sessions/feature.ts postSave hook) and has no
155
+ // dispatcher ctx to call unsafeAppendEvent from, so it uses the raw
156
+ // append() like revoke-all-for-user.write.ts does. Without this, the
157
+ // access-invalidation consumer (#1560) never hears about a password
158
+ // change and an already-open SSE stream survives it — the exact
159
+ // "stale JWT mid-stream" gap this feature exists to close.
160
+ if (result.length > 0) {
161
+ const payload = sessionRevokedSchema.parse({
162
+ userId,
163
+ sessionIds: result.map((row) => row.id),
164
+ });
165
+ await append(tx, {
166
+ aggregateId: generateId(),
167
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
168
+ tenantId: SYSTEM_TENANT_ID,
169
+ expectedVersion: 0,
170
+ type: SESSION_REVOKED_EVENT_QN,
171
+ payload,
172
+ metadata: { userId },
173
+ });
174
+ }
175
+ return result.length;
176
+ });
168
177
  },
169
178
  async sessionRevokeAllOthers(userId, currentSid) {
170
- const result = await updateMany(db, userSessionTable, { revokedAt: Temporal.Now.instant() }, currentSid
171
- ? { userId, revokedAt: null, id: { ne: currentSid } }
172
- : { userId, revokedAt: null });
173
- // Same reasoning as sessionMassRevoker above — this raw callback
174
- // (used by auth-mfa and other internal callers, distinct from the
175
- // user-facing revoke-all-others.write.ts handler which already
176
- // appends this event) needs its own append so callers reached
177
- // through here also cut open SSE streams.
178
- if (result.length > 0) {
179
- const payload = sessionRevokedSchema.parse({
180
- userId,
181
- sessionIds: result.map((row) => row.id),
182
- keptSessionId: currentSid,
183
- });
184
- await append(db, {
185
- aggregateId: generateId(),
186
- aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
187
- tenantId: SYSTEM_TENANT_ID,
188
- expectedVersion: 0,
189
- type: SESSION_REVOKED_EVENT_QN,
190
- payload,
191
- metadata: { userId },
192
- });
193
- }
194
- return result.length;
179
+ return transaction(db, async (tx) => {
180
+ const result = await updateMany(tx, userSessionTable, { revokedAt: Temporal.Now.instant() }, currentSid
181
+ ? { userId, revokedAt: null, id: { ne: currentSid } }
182
+ : { userId, revokedAt: null });
183
+ // Same reasoning as sessionMassRevoker above — this raw callback
184
+ // (used by auth-mfa and other internal callers, distinct from the
185
+ // user-facing revoke-all-others.write.ts handler which already
186
+ // appends this event) needs its own append so callers reached
187
+ // through here also cut open SSE streams.
188
+ if (result.length > 0) {
189
+ const payload = sessionRevokedSchema.parse({
190
+ userId,
191
+ sessionIds: result.map((row) => row.id),
192
+ keptSessionId: currentSid,
193
+ });
194
+ await append(tx, {
195
+ aggregateId: generateId(),
196
+ aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
197
+ tenantId: SYSTEM_TENANT_ID,
198
+ expectedVersion: 0,
199
+ type: SESSION_REVOKED_EVENT_QN,
200
+ payload,
201
+ metadata: { userId },
202
+ });
203
+ }
204
+ return result.length;
205
+ });
195
206
  },
196
207
  };
197
208
  }
@@ -1,2 +1,3 @@
1
1
  import type { WriteResult } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function assertErased(result: WriteResult<unknown>, entityName: string, id: string): void;
2
+ export declare function collectErasureFailure(result: WriteResult<unknown>, entityName: string, id: string, failures: string[]): void;
3
+ export declare function throwIfErasureFailed(failures: readonly string[]): void;
@@ -1,13 +1,21 @@
1
1
  // GDPR-forget hooks used to `await crud.forget(...)` and drop the result —
2
2
  // a silent ownership_denied (or any other failure) meant the row was never
3
- // actually erased, with nothing surfacing the miss. Callers must assert.
4
- export function assertErased(result, entityName, id) {
5
- // skip: the happy path — the assertion below only concerns failures.
3
+ // actually erased, with nothing surfacing the miss. Callers must collect the
4
+ // failure of every row (so one bad row does not leave the rest of the
5
+ // subject's rows untried) and throw once after the loop.
6
+ export function collectErasureFailure(result, entityName, id, failures) {
7
+ // skip: the happy path — the collection below only concerns failures.
6
8
  if (result.isSuccess)
7
9
  return;
8
10
  // skip: erasure is idempotent — a row that is already gone (e.g. a parallel
9
11
  // forget sweep) satisfies the erase request just as well as one this call removed.
10
12
  if (result.error.code === "not_found")
11
13
  return;
12
- throw new Error(`[user-data-rights] failed to erase ${entityName}/${id}: ${result.error.code} — ${result.error.message}`);
14
+ failures.push(`${entityName}/${id}: ${result.error.code} — ${result.error.message}`);
15
+ }
16
+ export function throwIfErasureFailed(failures) {
17
+ // skip: nothing failed, so there is nothing to report
18
+ if (failures.length === 0)
19
+ return;
20
+ throw new Error(`[user-data-rights] failed to erase ${failures.join("; ")}`);
13
21
  }
@@ -1,4 +1,4 @@
1
- export { assertErased } from "./assert-erased.js";
1
+ export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
2
2
  export { type ChunkedMigrationOptions, type ChunkedMigrationResult, type ChunkedMigrationStopReason, type MigrationRowOutcome, runChunkedMigration, } from "./chunked-entity-migration.js";
3
3
  export { classifyStoredEnvelope, type StoredEnvelopeClassification, } from "./classify-stored-envelope.js";
4
4
  export { decryptStoredPii } from "./decrypt-stored-pii.js";
@@ -1,4 +1,4 @@
1
- export { assertErased } from "./assert-erased.js";
1
+ export { collectErasureFailure, throwIfErasureFailed } from "./assert-erased.js";
2
2
  export { runChunkedMigration, } from "./chunked-entity-migration.js";
3
3
  export { classifyStoredEnvelope, } from "./classify-stored-envelope.js";
4
4
  export { decryptStoredPii } from "./decrypt-stored-pii.js";
@@ -32,11 +32,9 @@ export function createMollieCheckoutSession(client, priceToConfig, appWebhookUrl
32
32
  customerId = customer.id;
33
33
  }
34
34
  // mode "payment" (one-off top-up) → sequenceType.oneoff, no mandate
35
- // setup. verify-webhook.ts's ensureSubscriptionForMandate already
36
- // gates on sequenceType === "first" — a oneoff payment falls into
37
- // its "not our domain" branch and creates NO Mollie subscription.
38
- // Webhook resolution for oneoff payments is separate follow-up
39
- // work (offlot#109), not part of this contract.
35
+ // setup. Oneoff payments carry no subscriptionId and are not
36
+ // sequenceType "first", so verifyAndParseMollieWebhook drops them before
37
+ // any mandate/subscription handling.
40
38
  const sequenceType = options.mode === "payment" ? SequenceType.oneoff : SequenceType.first;
41
39
  // payments.create ist overloaded (Promise OR void mit callback);
42
40
  // explicit cast auf Promise<Payment>-overload.
@@ -1,5 +1,6 @@
1
1
  // Stripe-Plugin-Methoden für die POST-tenant-resolution-Phase:
2
- // createCheckoutSession, createPortalSession, cancelSubscription.
2
+ // createCheckoutSession, createPortalSession, cancelSubscription,
3
+ // retrieveSubscription, retrievePrices, planSwitchSession.
3
4
  //
4
5
  // Werden vom Plugin-build (feature.ts) als methods auf dem
5
6
  // SubscriptionProviderPlugin registriert. Anders als
@@ -8,7 +9,7 @@
8
9
  // runtime auf (api-key aus system-secrets, audited), statt aus einem
9
10
  // mount-time-Closure. Key-Rotation wirkt damit ohne Redeploy.
10
11
  //
11
- // **Type-Ableitung:** die options-shapes der drei methods werden
12
+ // **Type-Ableitung:** die options-shapes der methods werden
12
13
  // **direkt vom Plugin-Contract** abgeleitet (`Parameters<NonNullable
13
14
  // <SubscriptionProviderPlugin["...method"]>>[1]`). Wenn Foundation den
14
15
  // Contract erweitert (z.B. neuer optionaler Field), bemerkt der
@@ -18,6 +19,11 @@ import { KNOWN_RECURRING_INTERVALS, } from "@cosmicdrift/kumiko-bundled-features
18
19
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
19
20
  import Stripe from "stripe";
20
21
  import { isResourceMissingStripeError, mapStripeSubscriptionState } from "./verify-webhook.js";
22
+ function isUnknownCustomerStripeError(error) {
23
+ return (isResourceMissingStripeError(error) &&
24
+ error instanceof Stripe.errors.StripeInvalidRequestError &&
25
+ error.param === "customer");
26
+ }
21
27
  export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation = true } = {}) {
22
28
  return async (ctx, options) => {
23
29
  // #104-Invariante: ohne billing-live darf keine Stripe-Session
@@ -28,7 +34,7 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
28
34
  await runtime.assertBillingLive(ctx);
29
35
  const stripe = await runtime.clientForCtx(ctx);
30
36
  const mode = options.mode ?? "subscription";
31
- const session = await stripe.checkout.sessions.create({
37
+ const sessionParams = (providerCustomerId) => ({
32
38
  mode,
33
39
  line_items: [{ price: options.priceId, quantity: 1 }],
34
40
  success_url: options.successUrl,
@@ -48,10 +54,22 @@ export function createStripeCheckoutSession(runtime, { paymentInvoiceCreation =
48
54
  // Without a customer, Stripe's default "if_required" makes a guest
49
55
  // checkout when invoice_creation is off; the webhook then has no
50
56
  // customer id and drops the paid payment as ignored.
51
- ...(!options.providerCustomerId && { customer_creation: "always" }),
57
+ ...(!providerCustomerId && { customer_creation: "always" }),
52
58
  }),
53
- ...(options.providerCustomerId && { customer: options.providerCustomerId }),
59
+ ...(providerCustomerId && { customer: providerCustomerId }),
54
60
  });
61
+ let session;
62
+ try {
63
+ session = await stripe.checkout.sessions.create(sessionParams(options.providerCustomerId));
64
+ }
65
+ catch (error) {
66
+ if (!options.providerCustomerId || !isUnknownCustomerStripeError(error))
67
+ throw error;
68
+ // Stripe account switch (test -> live) or a customer deleted in the dashboard leaves a
69
+ // stored customer id Stripe no longer knows; checkout creates a fresh customer instead.
70
+ ctx.log?.warn("subscription-stripe: stored customer unknown to Stripe, retrying without it");
71
+ session = await stripe.checkout.sessions.create(sessionParams(undefined));
72
+ }
55
73
  if (!session.url) {
56
74
  // Defensive: Stripe returns a hosted url for both modes today —
57
75
  // guards against future API drift.
@@ -13,9 +13,14 @@ const KMS_POOL_CONCURRENCY = 4;
13
13
  // are decrypted and filtered in memory. The cap keeps a SystemAdmin's global
14
14
  // search from decrypting every user.
15
15
  const SEARCH_SCAN_CAP = 1000;
16
+ // Display names are encrypted, so id is the only stable SQL-side key; uuid text
17
+ // order equals the uuid column's byte order.
18
+ function compareIds(a, b) {
19
+ return a < b ? -1 : a > b ? 1 : 0;
20
+ }
16
21
  async function loadMemberUserIds(db, tenantId) {
17
22
  const memberships = await selectMany(db, tenantMembershipsTable, { tenantId });
18
- return [...new Set(memberships.map((row) => String(row["userId"])))];
23
+ return [...new Set(memberships.map((row) => String(row["userId"])))].sort(compareIds);
19
24
  }
20
25
  // Label source behind every `user:user` reference column (fw#3107). The
21
26
  // entity-convention lookup, user:query:user:list, is a SystemAdmin
@@ -52,12 +57,20 @@ export const memberDirectoryQuery = definePagedQueryHandler({
52
57
  let users;
53
58
  if (isSystemAdmin(query.user)) {
54
59
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
55
- users = await selectMany(db, userTable, undefined, { limit: scanLimit });
60
+ users = await selectMany(db, userTable, undefined, {
61
+ limit: scanLimit,
62
+ orderBy: { col: "id", direction: "asc" },
63
+ });
56
64
  }
57
65
  else {
58
66
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
59
67
  const userIds = (await loadMemberUserIds(db, query.user.tenantId)).slice(0, scanLimit);
60
- users = userIds.length > 0 ? await selectMany(db, userTable, { id: [...userIds] }) : [];
68
+ users =
69
+ userIds.length > 0
70
+ ? await selectMany(db, userTable, { id: [...userIds] }, {
71
+ orderBy: { col: "id", direction: "asc" },
72
+ })
73
+ : [];
61
74
  }
62
75
  const resolved = await mapWithConcurrency(users, KMS_POOL_CONCURRENCY, async (user) => {
63
76
  const id = String(user.id);
@@ -35,7 +35,13 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
35
35
  let tenants;
36
36
  if (isSystemAdmin(query.user)) {
37
37
  const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
38
- tenants = await selectMany(db, tenantTable, undefined, { limit: scanLimit });
38
+ tenants = await selectMany(db, tenantTable, undefined, {
39
+ limit: scanLimit,
40
+ orderBy: [
41
+ { col: "name", direction: "asc" },
42
+ { col: "id", direction: "asc" },
43
+ ],
44
+ });
39
45
  }
40
46
  else {
41
47
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
@@ -13,7 +13,7 @@ export function createTenantHandoverFeature(opts = {}) {
13
13
  r.describe("Try-before-signup ownership handover: claims the rows of a declared-transferable " +
14
14
  "entity graph (root plus its parentRef-linked children) from an anonymous/public " +
15
15
  "tenant into the caller's own tenant, legitimized by a row-bound grant the anonymous " +
16
- "flow minted. Idempotent, audited via a `<entityType>.tenantHandover` domain event.");
16
+ "flow minted. Idempotent, audited via a `tenant-handover:event:claimed` event on its own `tenant-handover-claim` aggregate.");
17
17
  r.uiHints({
18
18
  displayLabel: "Tenant Handover",
19
19
  category: "identity",
@@ -9,6 +9,7 @@ export declare const detailQuery: {
9
9
  agent?: import("@cosmicdrift/kumiko-types/handlers").AgentHandlerHints;
10
10
  rateLimit?: import("@cosmicdrift/kumiko-types/handlers").RateLimitDeclaration;
11
11
  outputSchema?: import("zod").ZodType;
12
+ liveEntities?: readonly string[];
12
13
  escapeHatch?: import("@cosmicdrift/kumiko-types/handlers").EscapeHatchDeclaration;
13
14
  handler: (query: Parameters<NonNullable<typeof baseDetail.handler>>[0], ctx: HandlerContext) => Promise<unknown>;
14
15
  };
@@ -72,12 +72,13 @@ export const userEntity = createEntity({
72
72
  // login-critical read stays raw, so verifyPassword would compare a
73
73
  // plaintext password against ciphertext and every login would fail once
74
74
  // a KMS is active. Already an irreversible one-way hash, access-gated to
75
- // privileged read/write — see PR body "Offene Fragen".
75
+ // privileged read/write. Needs a blind-index lookup before this can be
76
+ // subject-encrypted.
76
77
  passwordHash: createTextField({
77
78
  maxLength: 255,
78
79
  access: { read: access.privileged, write: access.privileged },
79
80
  personal: false,
80
- reason: "credential_hash_read_without_decrypt_2809",
81
+ reason: "credential_hash_read_without_decrypt",
81
82
  }),
82
83
  // Profile — user-editable. Real name in most apps → PII. Searchable via
83
84
  // the derived Meili index; events/projection stay ciphertext (#1610).
@@ -164,7 +165,7 @@ export const userEntity = createEntity({
164
165
  reason: "technical_reference",
165
166
  }),
166
167
  },
167
- // fw#2134 — email is `personal: "self", find: "exact"`, so the column holds
168
+ // email is `personal: "self", find: "fuzzy"` (lookupable), so the column holds
168
169
  // per-row ciphertext: a plain unique index on it can't catch duplicates.
169
170
  // `unique: true` over a lookupable column makes buildEntityTable /
170
171
  // deriveEntityTableMeta emit a second, partial unique index over the
@@ -49,10 +49,10 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
49
49
  schema: z.object({ token: z.string().min(1) }),
50
50
  access: { roles: ["anonymous", "Member", "User", "TenantAdmin", "SystemAdmin"] },
51
51
  escapeHatch: {
52
- reason: "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
52
+ reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
53
53
  "...) to compute the grace period end — the anonymous token holder has no read access " +
54
- "to that tenant-config projection. It also appends the user lifecycle event on the " +
55
- "SYSTEM_TENANT_ID user stream.",
54
+ "to that tenant-config projection. startDeletionGracePeriod also appends the user " +
55
+ "lifecycle event on the SYSTEM_TENANT_ID user stream.",
56
56
  },
57
57
  agent: { expose: false },
58
58
  rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
@@ -21,10 +21,10 @@ export function createRequestDeletionHandler(opts = {}) {
21
21
  },
22
22
  },
23
23
  escapeHatch: {
24
- reason: "startDeletionGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
24
+ reason: "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
25
25
  "...) to compute the grace period end — the calling user's own identity has no read " +
26
- "access to that tenant-config projection. It also appends the user lifecycle event on " +
27
- "the SYSTEM_TENANT_ID user stream.",
26
+ "access to that tenant-config projection. startDeletionGracePeriod also appends the " +
27
+ "user lifecycle event on the SYSTEM_TENANT_ID user stream.",
28
28
  },
29
29
  description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
30
30
  agent: { risk: "high" },
@@ -1,7 +1,7 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { configValueEntity, configValuesTable } from "../../config/index.js";
4
- import { assertErased } from "../../shared/index.js";
4
+ import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
5
5
  import { featureMounted } from "./feature-mounted.js";
6
6
  // userData-Hooks for config's USER-scoped rows (userId set). Tenant-/system-
7
7
  // scope rows carry no per-user subject and stay untouched. Event-sourced
@@ -37,10 +37,12 @@ export const configValueDeleteHook = async (ctx) => {
37
37
  tenantId: ctx.tenantId,
38
38
  userId: ctx.userId,
39
39
  });
40
+ const failures = [];
40
41
  for (const row of rows) {
41
42
  const id = row["id"]; // @cast-boundary db-row
42
43
  if (typeof id !== "string")
43
44
  continue;
44
- assertErased(await crud.forget({ id }, systemUser, ctx.db), "config-value", id);
45
+ collectErasureFailure(await crud.forget({ id }, systemUser, ctx.db), "config-value", id, failures);
45
46
  }
47
+ throwIfErasureFailed(failures);
46
48
  };
@@ -3,7 +3,7 @@ import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/de
3
3
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
5
5
  import { fileRefEntity, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
6
- import { assertErased } from "../../shared/index.js";
6
+ import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
7
7
  const ERROR_CONTEXT = "user-data-rights-defaults:fileRef";
8
8
  // Forget writes go through the executor (events), not deleteMany/updateMany:
9
9
  // a projection rebuild replays the events, so the erasure survives. Eventless
@@ -242,10 +242,12 @@ export const fileRefDeleteHook = async (ctx, strategy) => {
242
242
  // auto-verb, the erasure replays on rebuild (created → forgotten → row gone).
243
243
  // The old hard deleteMany was resurrected on rebuild; this closes that Art.17
244
244
  // hole without a direct write.
245
+ const failures = [];
245
246
  for (const row of personalRows) {
246
247
  const id = row["id"]; // @cast-boundary db-row
247
248
  if (typeof id !== "string")
248
249
  continue;
249
- assertErased(await crud.forget({ id }, systemUser, ctx.db), "fileRef", id);
250
+ collectErasureFailure(await crud.forget({ id }, systemUser, ctx.db), "fileRef", id, failures);
250
251
  }
252
+ throwIfErasureFailed(failures);
251
253
  };
@@ -1,7 +1,7 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { notificationPreferenceEntity, notificationPreferencesTable, } from "../../delivery/index.js";
4
- import { assertErased } from "../../shared/index.js";
4
+ import { collectErasureFailure, throwIfErasureFailed } from "../../shared/index.js";
5
5
  import { featureMounted } from "./feature-mounted.js";
6
6
  // userData-Hooks for delivery's notification-preference rows. Event-sourced
7
7
  // entity → forget goes through the executor so a rebuild replays the erasure.
@@ -40,10 +40,12 @@ export const notificationPreferenceDeleteHook = async (ctx) => {
40
40
  tenantId: ctx.tenantId,
41
41
  userId: ctx.userId,
42
42
  });
43
+ const failures = [];
43
44
  for (const row of rows) {
44
45
  const id = row["id"]; // @cast-boundary db-row
45
46
  if (typeof id !== "string")
46
47
  continue;
47
- assertErased(await crud.forget({ id }, systemUser, ctx.db), "notification-preference", id);
48
+ collectErasureFailure(await crud.forget({ id }, systemUser, ctx.db), "notification-preference", id, failures);
48
49
  }
50
+ throwIfErasureFailed(failures);
49
51
  };
@@ -1,6 +1,6 @@
1
1
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
- import { assertErased, decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
3
+ import { collectErasureFailure, decryptStoredPii, mapWithConcurrency, throwIfErasureFailed, } from "../../shared/index.js";
4
4
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/index.js";
5
5
  import { userTable } from "../../user/index.js";
6
6
  import { featureMounted } from "./feature-mounted.js";
@@ -78,6 +78,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
78
78
  if (!featureMounted(ctx, "tenant"))
79
79
  return;
80
80
  const systemUser = createSystemUser(ctx.tenantId);
81
+ const failures = [];
81
82
  const email = await resolveUserEmail(ctx);
82
83
  if (email) {
83
84
  const inviteeRows = await ctx.db.selectMany(tenantInvitationsTable, {
@@ -89,7 +90,7 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
89
90
  if (typeof id !== "string")
90
91
  continue;
91
92
  if (strategy === "delete") {
92
- assertErased(await crud.forget({ id }, systemUser, ctx.db), "tenant-invitation", id);
93
+ collectErasureFailure(await crud.forget({ id }, systemUser, ctx.db), "tenant-invitation", id, failures);
93
94
  }
94
95
  else {
95
96
  // Row-id in the pseudonym keeps the (tenantId, email) unique index
@@ -116,4 +117,5 @@ export const tenantInvitationDeleteHook = async (ctx, strategy) => {
116
117
  skipOptimisticLock: true,
117
118
  });
118
119
  }
120
+ throwIfErasureFailed(failures);
119
121
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.335.0",
3
+ "version": "0.336.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>",
@@ -483,12 +483,12 @@
483
483
  }
484
484
  },
485
485
  "dependencies": {
486
- "@cosmicdrift/kumiko-dispatcher-live": "0.335.0",
487
- "@cosmicdrift/kumiko-framework": "0.335.0",
488
- "@cosmicdrift/kumiko-headless": "0.335.0",
489
- "@cosmicdrift/kumiko-renderer": "0.335.0",
490
- "@cosmicdrift/kumiko-renderer-web": "0.335.0",
491
- "@cosmicdrift/kumiko-types": "0.335.0",
486
+ "@cosmicdrift/kumiko-dispatcher-live": "0.336.1",
487
+ "@cosmicdrift/kumiko-framework": "0.336.1",
488
+ "@cosmicdrift/kumiko-headless": "0.336.1",
489
+ "@cosmicdrift/kumiko-renderer": "0.336.1",
490
+ "@cosmicdrift/kumiko-renderer-web": "0.336.1",
491
+ "@cosmicdrift/kumiko-types": "0.336.1",
492
492
  "@mollie/api-client": "^4.5.0",
493
493
  "@node-rs/argon2": "^2.0.2",
494
494
  "@types/mailparser": "^3.4.6",
@@ -985,8 +985,8 @@
985
985
  ],
986
986
  "devDependencies": {
987
987
  "@testing-library/user-event": "^14.6.1",
988
- "@cosmicdrift/kumiko-locale-de": "0.335.0",
989
- "@cosmicdrift/kumiko-locale-es": "0.335.0",
988
+ "@cosmicdrift/kumiko-locale-de": "0.336.1",
989
+ "@cosmicdrift/kumiko-locale-es": "0.336.1",
990
990
  "jsqr": "^1.4.0"
991
991
  }
992
992
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "markCapSoftWarned skips the redundant write when the soft-warned flag is already set",
6
+ "detail": "`markCapSoftWarned` no longer appends a second update event when a parallel warner already set `lastSoftWarnedAt`; the version-conflict retry now returns success without writing."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "breaking",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "Delivery log paging no longer skips rows tied on the sort value",
6
+ "detail": "The delivery log query no longer skips rows at a page boundary when the sort column has duplicate values. A toggle flip received while the initial snapshot loads is no longer overwritten by the older DB read. The stale job-run sweep encrypts and updates once per triggering user instead of once per run."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "breaking",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "A toggle flip signalled during the initial load survives the snapshot read"
6
+ },
2
7
  {
3
8
  "version": "0.165.0",
4
9
  "type": "fix",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "improvement",
5
+ "title": "Stale job-run sweep batches its writes per triggering user"
6
+ },
2
7
  {
3
8
  "version": "0.335.0",
4
9
  "type": "fix",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "PAT revoke and its revoked-event commit atomically when called on a pool"
6
+ },
2
7
  {
3
8
  "version": "0.330.0",
4
9
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "Session revoke (single, mass, all-others) and its revoked-event commit atomically",
6
+ "detail": "Session logout, password-change/MFA mass revoke, revoke-all-others and PAT revoke now commit the revoke and its revoked-event in one transaction, so a failed append no longer leaves a revoked token whose SSE streams stay open. GDPR delete hooks now try every row of the subject and report all failures at once instead of stopping at the first. The notes mention-forget hook loads the mentioned notes with one query and resolves each host entity's retention policy once."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.336.1",
4
+ "type": "fix",
5
+ "title": "Checkout retries without a customer id Stripe no longer knows",
6
+ "detail": "Stripe checkout now retries once without the customer when the stored customer id is unknown to Stripe (for example after switching from a test to a live account), instead of failing with \"No such customer\"."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.336.1",
4
+ "type": "fix",
5
+ "title": "Member and tenant directory lookups return a stable order under a limit",
6
+ "detail": "`tenant:query:member-directory` and `tenant:query:tenant-directory` now sort their rows (tenants by name then id, members by id), so a `limit` always keeps the same rows instead of whichever the database returned first."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.336.0",
4
+ "type": "fix",
5
+ "title": "Delete hooks erase every row and report all failures together"
6
+ },
2
7
  {
3
8
  "version": "0.116.0",
4
9
  "type": "fix",