@cosmicdrift/kumiko-bundled-features 0.24.0 → 0.25.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/package.json +1 -1
- package/src/__tests__/env-schemas.test.ts +53 -11
- package/src/auth-email-password/__tests__/auth.integration.test.ts +37 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +32 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +31 -0
- package/src/auth-email-password/handlers/change-password.write.ts +12 -2
- package/src/auth-email-password/handlers/confirm-token-flow.ts +17 -2
- package/src/compliance-profiles/__tests__/parse-override.test.ts +53 -0
- package/src/compliance-profiles/_internal/parse-override.ts +8 -7
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
- package/src/custom-fields/__tests__/cross-tenant-set-write.integration.test.ts +178 -0
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
- package/src/custom-fields/__tests__/drift.test.ts +43 -0
- package/src/custom-fields/__tests__/field-access.integration.test.ts +59 -0
- package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
- package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
- package/src/custom-fields/__tests__/value-schema.test.ts +54 -0
- package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
- package/src/custom-fields/constants.ts +8 -7
- package/src/custom-fields/db/queries/projection.ts +19 -7
- package/src/custom-fields/db/queries/retention.ts +20 -6
- package/src/custom-fields/executor.ts +10 -0
- package/src/custom-fields/feature.ts +32 -39
- package/src/custom-fields/handlers/clear-custom-field.write.ts +8 -1
- package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
- package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
- package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -1
- package/src/custom-fields/lib/field-access.ts +9 -4
- package/src/custom-fields/lib/field-definition-row.ts +33 -0
- package/src/custom-fields/lib/value-schema.ts +14 -2
- package/src/custom-fields/run-retention.ts +6 -5
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
- package/src/custom-fields/web/client-plugin.tsx +2 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
- package/src/custom-fields/web/i18n.ts +30 -0
- package/src/custom-fields/wire-for-entity.ts +9 -2
- package/src/custom-fields/wire-user-data-rights.ts +9 -0
- package/src/feature-toggles/handlers/set.write.ts +13 -8
- package/src/secrets/feature.ts +4 -11
- package/src/subscription-stripe/feature.ts +2 -2
- package/src/template-resolver/handlers/list.query.ts +12 -10
- package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
- package/src/tenant/seeding.ts +3 -3
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +11 -11
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
- package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
- package/src/user-data-rights/run-forget-cleanup.ts +77 -36
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
|
@@ -104,8 +104,16 @@ export interface RunForgetCleanupResult {
|
|
|
104
104
|
interface HookEntry {
|
|
105
105
|
readonly entityName: string;
|
|
106
106
|
readonly deleteHook: UserDataDeleteHook;
|
|
107
|
+
/** Lower runs first. Owner-column-preserving redaction declares a negative
|
|
108
|
+
* order so it precedes owner-nulling hooks on the same entity (see sort below). */
|
|
109
|
+
readonly order: number;
|
|
107
110
|
}
|
|
108
111
|
|
|
112
|
+
// EXT_USER_DATA delete-hooks default here; a hook that redacts data keyed on an
|
|
113
|
+
// owner column it doesn't own must register a lower order so it runs BEFORE any
|
|
114
|
+
// hook that nulls that column. See custom-fields wire-user-data-rights.ts.
|
|
115
|
+
const HOOK_ORDER_DEFAULT = 0;
|
|
116
|
+
|
|
109
117
|
export async function runForgetCleanup(
|
|
110
118
|
args: RunForgetCleanupArgs,
|
|
111
119
|
): Promise<RunForgetCleanupResult> {
|
|
@@ -127,10 +135,18 @@ export async function runForgetCleanup(
|
|
|
127
135
|
const usages = registry.getExtensionUsages(EXT_USER_DATA);
|
|
128
136
|
const hookEntries: HookEntry[] = usages
|
|
129
137
|
.map((u): HookEntry | null => {
|
|
130
|
-
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook }; // @cast-boundary engine-payload
|
|
131
|
-
|
|
138
|
+
const opts = (u.options ?? {}) as { delete?: UserDataDeleteHook; order?: number }; // @cast-boundary engine-payload
|
|
139
|
+
if (!opts.delete) return null;
|
|
140
|
+
const order = typeof opts.order === "number" ? opts.order : HOOK_ORDER_DEFAULT;
|
|
141
|
+
return { entityName: u.entityName, deleteHook: opts.delete, order };
|
|
132
142
|
})
|
|
133
|
-
.filter((x): x is HookEntry => x !== null)
|
|
143
|
+
.filter((x): x is HookEntry => x !== null)
|
|
144
|
+
// Order ascending. Array.sort is ES2019-stable, so equal orders keep
|
|
145
|
+
// registration order; correctness here needs only distinct orders, not
|
|
146
|
+
// stability. Guarantees owner-preserving redaction (negative order) runs
|
|
147
|
+
// before owner-nulling hooks on the same entity, independent of feature
|
|
148
|
+
// registration order.
|
|
149
|
+
.sort((a, b) => a.order - b.order);
|
|
134
150
|
|
|
135
151
|
const errors: ForgetCleanupError[] = [];
|
|
136
152
|
const processedUserIds: string[] = [];
|
|
@@ -233,46 +249,40 @@ async function processUser(args: {
|
|
|
233
249
|
memberships.length > 0 ? memberships.map((m) => m.tenantId) : [SYSTEM_TENANT_ID_FOR_ORPHANS];
|
|
234
250
|
|
|
235
251
|
// Per-User-Sub-Tx: hooks + status-flip atomar. Bei Hook-Throw rollt
|
|
236
|
-
// nur dieser User zurueck, andere User bleiben commit-fest.
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// `DbConnection | DbTx`, beide haben `.transaction()`, aber TS kann
|
|
242
|
-
// die Signaturen ueber die Union nicht unifizieren (PgDatabase vs
|
|
243
|
-
// PgTransaction haben unterschiedliche Generics). Cast macht das
|
|
244
|
-
// Strukturelle explizit, kein Hack.
|
|
252
|
+
// nur dieser User zurueck, andere User bleiben commit-fest. Die Sub-Tx
|
|
253
|
+
// nestet korrekt: eine Top-Level-Connection oeffnet sie via `.begin`
|
|
254
|
+
// (BEGIN), eine TransactionSql — der Fall im Dispatcher, wo jeder
|
|
255
|
+
// writeHandler bereits IN der Outer-Tx laeuft — via `.savepoint`
|
|
256
|
+
// (SAVEPOINT). Siehe runInSubTransaction.
|
|
245
257
|
let txSucceeded = false;
|
|
246
258
|
let currentTenantId: TenantId | null = null;
|
|
247
259
|
let currentEntityName: string | null = null;
|
|
248
260
|
try {
|
|
249
|
-
await (db
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
261
|
+
await runInSubTransaction(db, async (tx) => {
|
|
262
|
+
for (const tenantId of tenantList) {
|
|
263
|
+
currentTenantId = tenantId;
|
|
264
|
+
for (const entry of hookEntries) {
|
|
265
|
+
currentEntityName = entry.entityName;
|
|
266
|
+
const policy = await resolveRetentionPolicyForTenant({
|
|
267
|
+
db: tx,
|
|
268
|
+
registry,
|
|
269
|
+
tenantId,
|
|
270
|
+
entityName: entry.entityName,
|
|
271
|
+
});
|
|
272
|
+
const strategy = policyToStrategy(policy.policy?.strategy ?? null);
|
|
262
273
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
274
|
+
hookCallsAttempted++;
|
|
275
|
+
await entry.deleteHook({ db: tx, tenantId, userId }, strategy);
|
|
266
276
|
}
|
|
277
|
+
}
|
|
267
278
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
);
|
|
279
|
+
// Status-Flip in derselben Sub-Tx. Falls einer der Hooks oben
|
|
280
|
+
// geworfen hat, kommen wir hier nicht an — die Tx rollback'd
|
|
281
|
+
// alles, der User bleibt im DeletionRequested-Status, naechster
|
|
282
|
+
// Run retried.
|
|
283
|
+
await updateMany(tx, userTable, { status: USER_STATUS.Deleted }, { id: userId });
|
|
284
|
+
txSucceeded = true;
|
|
285
|
+
});
|
|
276
286
|
} catch (e) {
|
|
277
287
|
// currentTenantId/currentEntityName tracken den Failing-Hook —
|
|
278
288
|
// Operator sieht "Hook fileRef in Tenant A failed for user X" statt
|
|
@@ -294,6 +304,37 @@ async function processUser(args: {
|
|
|
294
304
|
};
|
|
295
305
|
}
|
|
296
306
|
|
|
307
|
+
// Per-user sub-transaction, nesting-aware across both db shapes:
|
|
308
|
+
// - top-level connection (Bun.SQL / postgres-js Sql) → `.begin` (BEGIN)
|
|
309
|
+
// - TransactionSql (inside the dispatcher's outer tx, where every
|
|
310
|
+
// writeHandler already runs) → `.savepoint` (SAVEPOINT)
|
|
311
|
+
// A TransactionSql has no `.begin`, so the previous unconditional `.begin`
|
|
312
|
+
// threw "is not a function" on every user when invoked through the dispatcher
|
|
313
|
+
// (the cron path) → zero deletions in production, while direct-connection tests
|
|
314
|
+
// stayed green. Selecting the available method makes the sub-tx work in both
|
|
315
|
+
// contexts; on throw the savepoint rolls back just this user (others survive).
|
|
316
|
+
async function runInSubTransaction(
|
|
317
|
+
db: DbRunner,
|
|
318
|
+
fn: (tx: DbRunner) => Promise<void>,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
// `db` is already the raw runner (the handler passes ctx.db.raw, the tests a
|
|
321
|
+
// top-level connection) — cast to read the transaction surface directly,
|
|
322
|
+
// without asRawClient (a test-only escape hatch). A top-level connection
|
|
323
|
+
// exposes `.begin`; a TransactionSql only `.savepoint`. They are mutually
|
|
324
|
+
// exclusive, so prefer whichever is present.
|
|
325
|
+
const runner = db as {
|
|
326
|
+
begin?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
327
|
+
savepoint?: (f: (tx: DbRunner) => Promise<void>) => Promise<void>;
|
|
328
|
+
};
|
|
329
|
+
const open = runner.begin ?? runner.savepoint;
|
|
330
|
+
if (!open) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"runForgetCleanup: db exposes neither .begin nor .savepoint — cannot open a per-user sub-transaction",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await open.call(runner, fn);
|
|
336
|
+
}
|
|
337
|
+
|
|
297
338
|
// Pseudo-Tenant fuer User ohne aktive Memberships. RFC4122-konforme
|
|
298
339
|
// Null-UUID. Tenant-scoped Hooks finden hier nichts (no-op),
|
|
299
340
|
// tenant-agnostische Hooks (z.B. user) operieren auf der globalen
|
|
@@ -11,15 +11,21 @@ import { type FileStorageProvider, fileRefsTable } from "@cosmicdrift/kumiko-fra
|
|
|
11
11
|
//
|
|
12
12
|
// Delete-Hook entfernt FileRef-Zeile via factory
|
|
13
13
|
// `createFileRefDeleteHook(storageProvider)`:
|
|
14
|
-
// "delete": storageProvider.delete() pro File
|
|
14
|
+
// "delete": storageProvider.delete() pro File + Row hard-delete
|
|
15
15
|
// "anonymize": insertedById=null, Row + binary bleiben (FK-Refs
|
|
16
16
|
// koennen weiter zeigen; Personenbezug raus)
|
|
17
17
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
18
|
+
// Delete-Pfad ist FAIL-CLOSED, sobald ein Provider gewired ist: schlaegt ein
|
|
19
|
+
// binary-delete fehl, wirft der Hook
|
|
20
|
+
// NACH dem Loop — die per-User-Sub-Tx von runForgetCleanup rollt zurueck, der
|
|
21
|
+
// User bleibt DeletionRequested, der naechste Run retried (storageProvider.delete
|
|
22
|
+
// ist idempotent, schon-geloeschte Keys sind no-op). Den Fehler zu schlucken und
|
|
23
|
+
// die Row trotzdem hard-zu-loeschen wuerde Art.-17-Erasure als "done" markieren
|
|
24
|
+
// waehrend die Bytes auf Disk bleiben — eine falsche Compliance-Aussage. Das
|
|
25
|
+
// "KEIN globaler Rollback" der Sprint-2-Atomicity-Decision bleibt gewahrt: nur
|
|
26
|
+
// DIESE User-Sub-Tx rollt zurueck (= der Retry-Mechanismus), andere User des
|
|
27
|
+
// Laufs committen. Der anonymize-Pfad behaelt Row+binary bewusst, hat also
|
|
28
|
+
// nichts zu schlucken.
|
|
23
29
|
//
|
|
24
30
|
// `storageProvider` ist optional. App-Author wired es beim
|
|
25
31
|
// Feature-Mount rein (`createUserDataRightsDefaultsFeature({
|
|
@@ -92,6 +98,7 @@ export function createFileRefDeleteHook(
|
|
|
92
98
|
tenantId: ctx.tenantId,
|
|
93
99
|
insertedById: ctx.userId,
|
|
94
100
|
});
|
|
101
|
+
const failedKeys: string[] = [];
|
|
95
102
|
for (const row of rows) {
|
|
96
103
|
const key = (row as Record<string, unknown>)["storageKey"]; // @cast-boundary db-row
|
|
97
104
|
if (typeof key !== "string" || key.length === 0) continue;
|
|
@@ -102,8 +109,16 @@ export function createFileRefDeleteHook(
|
|
|
102
109
|
console.warn(
|
|
103
110
|
`[user-data-rights-defaults:fileRef] storage delete failed key=${key} err=${err instanceof Error ? err.message : String(err)}`,
|
|
104
111
|
);
|
|
112
|
+
failedKeys.push(key);
|
|
105
113
|
}
|
|
106
114
|
}
|
|
115
|
+
// Fail-closed: abort before the row hard-delete so the sub-tx rolls back
|
|
116
|
+
// and the next forget run retries (delete is idempotent → converges).
|
|
117
|
+
if (failedKeys.length > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`[user-data-rights-defaults:fileRef] ${failedKeys.length} binary delete(s) failed — aborting forget so the rows are retried next run (keys: ${failedKeys.join(", ")})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
107
122
|
} else if (!missingStorageWarned) {
|
|
108
123
|
missingStorageWarned = true;
|
|
109
124
|
// biome-ignore lint/suspicious/noConsole: misconfiguration visibility — disk-leak in forget-flow
|