@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.
- package/dist/auth-mfa/handlers/status.query.js +1 -0
- package/dist/auth-mfa-user-data/hooks.js +4 -2
- package/dist/cap-counter/book-cap-usage.js +5 -0
- package/dist/delivery/handlers/log.query.js +33 -24
- package/dist/feature-toggles/toggle-runtime.d.ts +1 -0
- package/dist/feature-toggles/toggle-runtime.js +16 -2
- package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
- package/dist/notes-history-user-data/hooks.js +21 -14
- package/dist/personal-access-tokens/revoke-for-user.js +12 -1
- package/dist/sessions/session-callbacks.js +86 -75
- package/dist/shared/assert-erased.d.ts +2 -1
- package/dist/shared/assert-erased.js +12 -4
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/user/handlers/detail.query.d.ts +1 -0
- package/dist/user-data-rights-defaults/hooks/config-value.userdata-hook.js +4 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -2
- package/dist/user-data-rights-defaults/hooks/notification-preference.userdata-hook.js +4 -2
- package/dist/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.js +4 -2
- package/package.json +9 -9
- package/src/cap-counter/changes.json +6 -0
- package/src/delivery/changes.json +6 -0
- package/src/feature-toggles/changes.json +5 -0
- package/src/jobs/changes.json +5 -0
- package/src/personal-access-tokens/changes.json +5 -0
- package/src/sessions/changes.json +6 -0
- package/src/user-data-rights-defaults/changes.json +5 -0
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
32
|
-
|
|
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 =
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 ===
|
|
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
|
|
34
|
-
this.
|
|
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),
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
|
|
37
|
-
|
|
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"
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
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";
|
package/dist/shared/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
487
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
488
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
489
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
490
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
491
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
989
|
-
"@cosmicdrift/kumiko-locale-es": "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",
|
package/src/jobs/changes.json
CHANGED
|
@@ -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",
|