@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.
- package/dist/auth-email-password/handlers/login.write.js +1 -2
- 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/custom-fields/feature.js +3 -2
- 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/inbound-mail-foundation/feature.js +1 -1
- package/dist/jobs/db/queries/stale-run-sweep.js +22 -15
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history/handlers/add-note.write.js +2 -1
- package/dist/notes-history-user-data/hooks.js +21 -14
- package/dist/personal-access-tokens/revoke-for-user.js +12 -1
- package/dist/personal-access-tokens/schema/api-token.js +5 -5
- 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/subscription-mollie/plugin-methods.js +3 -5
- package/dist/subscription-stripe/plugin-methods.js +23 -5
- package/dist/tenant/handlers/member-directory.query.js +16 -3
- package/dist/tenant/handlers/tenant-directory.query.js +7 -1
- package/dist/tenant-handover/feature.js +1 -1
- package/dist/user/handlers/detail.query.d.ts +1 -0
- package/dist/user/schema/user.js +4 -3
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
- 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/subscription-stripe/changes.json +6 -0
- package/src/tenant/changes.json +6 -0
- 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)
|
|
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 {
|
|
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,
|
|
@@ -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
|
-
//
|
|
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 {
|
|
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
|
|
@@ -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
|
|
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),
|
|
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
|
}
|
|
@@ -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
|
|
75
|
-
"
|
|
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 —
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
46
|
-
//
|
|
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: "
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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";
|
|
@@ -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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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
|
|
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
|
|
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
|
-
...(!
|
|
57
|
+
...(!providerCustomerId && { customer_creation: "always" }),
|
|
52
58
|
}),
|
|
53
|
-
...(
|
|
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, {
|
|
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 =
|
|
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, {
|
|
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
|
|
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
|
};
|
package/dist/user/schema/user.js
CHANGED
|
@@ -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
|
|
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: "
|
|
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
|
-
//
|
|
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: "
|
|
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.
|
|
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: "
|
|
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.
|
|
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 {
|
|
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.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.
|
|
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.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.
|
|
989
|
-
"@cosmicdrift/kumiko-locale-es": "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",
|
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",
|
|
@@ -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",
|
package/src/tenant/changes.json
CHANGED
|
@@ -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",
|