@cosmicdrift/kumiko-bundled-features 0.335.0 → 0.336.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -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
@@ -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
  }
@@ -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
@@ -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";
@@ -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
  };
@@ -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.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -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.0",
487
+ "@cosmicdrift/kumiko-framework": "0.336.0",
488
+ "@cosmicdrift/kumiko-headless": "0.336.0",
489
+ "@cosmicdrift/kumiko-renderer": "0.336.0",
490
+ "@cosmicdrift/kumiko-renderer-web": "0.336.0",
491
+ "@cosmicdrift/kumiko-types": "0.336.0",
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.0",
989
+ "@cosmicdrift/kumiko-locale-es": "0.336.0",
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,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",