@cosmicdrift/kumiko-samples 0.352.0 → 0.354.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/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/public-names.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +17 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +4 -2
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +19 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +0 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requestContext } from "@cosmicdrift/kumiko-framework/api";
|
|
2
2
|
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
3
|
+
import { runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
4
|
import {
|
|
4
5
|
computeBlindIndex,
|
|
5
6
|
configuredBlindIndexKey,
|
|
@@ -31,8 +32,10 @@ import {
|
|
|
31
32
|
type WriteFailure,
|
|
32
33
|
writeFailure,
|
|
33
34
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
34
|
-
import {
|
|
35
|
-
|
|
35
|
+
import {
|
|
36
|
+
appendDomainEventCore,
|
|
37
|
+
assertIrreversibleOperationAllowed,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
36
39
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
37
40
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
38
41
|
import * as z from "zod";
|
|
@@ -46,6 +49,7 @@ import {
|
|
|
46
49
|
} from "../../user-data-rights/index.js";
|
|
47
50
|
import {
|
|
48
51
|
CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
52
|
+
CRYPTO_SHREDDING_FEATURE_NAME,
|
|
49
53
|
RECORD_ENTITY_NOT_REGISTERED,
|
|
50
54
|
SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
51
55
|
SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
@@ -65,6 +69,9 @@ export const subjectForgottenSchema = z.object({
|
|
|
65
69
|
subjectKey: z.string().min(1),
|
|
66
70
|
reason: z.string().min(10),
|
|
67
71
|
forgottenBy: z.string().min(1),
|
|
72
|
+
// Error code or name of a failed derived-data sweep, never its message (which
|
|
73
|
+
// can name the subject). Present only when the handler failed after the erase.
|
|
74
|
+
cleanupError: z.string().min(1).optional(),
|
|
68
75
|
});
|
|
69
76
|
|
|
70
77
|
export const subjectForgetDeniedSchema = z.object({
|
|
@@ -234,16 +241,8 @@ async function appendDenialAuditEvent(
|
|
|
234
241
|
}),
|
|
235
242
|
);
|
|
236
243
|
}
|
|
237
|
-
const eventDef = ctx.registry.getEvent(SUBJECT_FORGET_DENIED_EVENT_NAME);
|
|
238
|
-
if (!eventDef) {
|
|
239
|
-
return writeFailure(
|
|
240
|
-
new InternalError({
|
|
241
|
-
message: `[crypto-shredding] event "${SUBJECT_FORGET_DENIED_EVENT_NAME}" is not registered.`,
|
|
242
|
-
}),
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
244
|
const blindIndexKey = configuredBlindIndexKey();
|
|
246
|
-
const payload =
|
|
245
|
+
const payload: z.infer<typeof subjectForgetDeniedSchema> = {
|
|
247
246
|
// The denial lands in the REQUESTING actor's own tenant-scoped stream —
|
|
248
247
|
// it must never materialise the foreign subject's identifiers there. A
|
|
249
248
|
// plaintext subjectKey/aggregateId would survive as a permanent record
|
|
@@ -260,31 +259,132 @@ async function appendDenialAuditEvent(
|
|
|
260
259
|
forgottenBy: event.user.id,
|
|
261
260
|
actorTenantId: event.user.tenantId,
|
|
262
261
|
denial: denialCode,
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
expectedVersion: 0,
|
|
275
|
-
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
276
|
-
eventVersion: eventDef.version,
|
|
277
|
-
payload,
|
|
278
|
-
metadata: {
|
|
279
|
-
userId: event.user.id,
|
|
280
|
-
...(reqCtx?.requestId ? { requestId: reqCtx.requestId } : {}),
|
|
281
|
-
...(reqCtx?.correlationId ? { correlationId: reqCtx.correlationId } : {}),
|
|
282
|
-
...(reqCtx?.causationId ? { causationId: reqCtx.causationId } : {}),
|
|
262
|
+
};
|
|
263
|
+
await appendDomainEventCore(
|
|
264
|
+
{
|
|
265
|
+
registry: ctx.registry,
|
|
266
|
+
db: denialAuditRunner,
|
|
267
|
+
// MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw
|
|
268
|
+
// bypasses TenantDb's scoping, so this is the only guard against a cross-tenant denial event (fw#2452).
|
|
269
|
+
tenantId: event.user.tenantId,
|
|
270
|
+
userId: String(event.user.id),
|
|
271
|
+
callSiteLabel: "forget-subject denial audit",
|
|
272
|
+
callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
|
|
283
273
|
},
|
|
284
|
-
|
|
274
|
+
{
|
|
275
|
+
// Fresh stream per denial: no version to read, nothing to conflict with.
|
|
276
|
+
aggregateId: generateId(),
|
|
277
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
278
|
+
type: SUBJECT_FORGET_DENIED_EVENT_NAME,
|
|
279
|
+
payload,
|
|
280
|
+
},
|
|
281
|
+
);
|
|
285
282
|
return null;
|
|
286
283
|
}
|
|
287
284
|
|
|
285
|
+
// Name or driver code of the error, never its message: a message can carry the
|
|
286
|
+
// subject key or row values and would land in a permanent audit event.
|
|
287
|
+
function sweepErrorLabel(err: unknown): string {
|
|
288
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
289
|
+
const code = err.code;
|
|
290
|
+
if (typeof code === "string" && code.length > 0) return code;
|
|
291
|
+
}
|
|
292
|
+
return err instanceof Error && err.name.length > 0 ? err.name : "UnknownError";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The key is already erased when this runs, so a failing sweep must not skip the
|
|
296
|
+
// audit event: the failure is returned as a label for the event instead of thrown.
|
|
297
|
+
// The savepoint keeps a database error from aborting the handler transaction.
|
|
298
|
+
async function sweepDerivedSubjectData(
|
|
299
|
+
ctx: HandlerContext,
|
|
300
|
+
subject: SubjectId,
|
|
301
|
+
subjectKey: string,
|
|
302
|
+
crossTenantRunner: DbRunner,
|
|
303
|
+
): Promise<string | undefined> {
|
|
304
|
+
try {
|
|
305
|
+
await runInSavepointIfSupported(crossTenantRunner, async (crossTenantSubjectRunner) => {
|
|
306
|
+
// Blind-index sweep (#818): nulls bidx columns now so the deterministic
|
|
307
|
+
// HMAC doesn't stay equality-matchable; raw because the ciphertext prefix addresses the subject across tenants.
|
|
308
|
+
await nullBlindIndexesForSubject(crossTenantSubjectRunner, ctx.registry.features, subjectKey);
|
|
309
|
+
|
|
310
|
+
// Derived search index still holds plaintext (#1610) — purge next to the
|
|
311
|
+
// blind-index sweep. No adapter → no-op (apps without search).
|
|
312
|
+
if (ctx.searchAdapter) {
|
|
313
|
+
await purgeSearchDocumentsForSubject(
|
|
314
|
+
crossTenantSubjectRunner,
|
|
315
|
+
ctx.registry.features,
|
|
316
|
+
ctx.searchAdapter,
|
|
317
|
+
subjectKey,
|
|
318
|
+
subject,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return undefined;
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return sweepErrorLabel(err);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Graceful fail: email-subscribers and other non-user entities may use
|
|
329
|
+
// user-style subject keys without having an actual user row — the key erase is
|
|
330
|
+
// the important part for GDPR compliance; the lifecycle update is best-effort for
|
|
331
|
+
// real users. The savepoint confines the failure so the handler transaction
|
|
332
|
+
// stays usable for the audit event.
|
|
333
|
+
async function closeUserLoginDoorBestEffort(
|
|
334
|
+
ctx: HandlerContext,
|
|
335
|
+
userId: string,
|
|
336
|
+
lifecycleRunner: DbRunner,
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
try {
|
|
339
|
+
await runInSavepointIfSupported(lifecycleRunner, async (userLifecycleRunner) => {
|
|
340
|
+
await updateUserLifecycle(userLifecycleRunner, userId, { status: USER_STATUS.Deleted });
|
|
341
|
+
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
342
|
+
await revokeAllPatTokensForUser(userLifecycleRunner, userId);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
// skip: no user row for this subject key (e.g. email subscribers); key erase and sweeps already ran.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Through the outside-transaction db when available, so the audit event commits
|
|
351
|
+
// even though the handler may fail afterwards (sweep error) and roll back its own
|
|
352
|
+
// transaction. Without that source the event stays in the handler transaction.
|
|
353
|
+
async function appendSubjectForgotten(
|
|
354
|
+
ctx: HandlerContext,
|
|
355
|
+
event: WriteEvent<z.infer<typeof forgetSubjectSchema>>,
|
|
356
|
+
aggregateId: string,
|
|
357
|
+
payload: z.infer<typeof subjectForgottenSchema>,
|
|
358
|
+
outsideRunner: DbRunner | undefined,
|
|
359
|
+
): Promise<void> {
|
|
360
|
+
if (!outsideRunner) {
|
|
361
|
+
await ctx.unsafeAppendEvent({
|
|
362
|
+
aggregateId,
|
|
363
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
364
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
365
|
+
payload,
|
|
366
|
+
});
|
|
367
|
+
} else {
|
|
368
|
+
await appendDomainEventCore(
|
|
369
|
+
{
|
|
370
|
+
registry: ctx.registry,
|
|
371
|
+
db: outsideRunner,
|
|
372
|
+
// MUST be event.user.tenantId, never SYSTEM_TENANT_ID — unsafeRaw bypasses TenantDb's scoping (fw#2452).
|
|
373
|
+
tenantId: event.user.tenantId,
|
|
374
|
+
userId: String(event.user.id),
|
|
375
|
+
callSiteLabel: "forget-subject audit",
|
|
376
|
+
callerFeature: CRYPTO_SHREDDING_FEATURE_NAME,
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
aggregateId,
|
|
380
|
+
aggregateType: CRYPTO_SHREDDING_AGGREGATE_TYPE,
|
|
381
|
+
type: SUBJECT_FORGOTTEN_EVENT_NAME,
|
|
382
|
+
payload,
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
288
388
|
// Manual crypto-shred for a DPO / platform operator: erases the subject's
|
|
289
389
|
// DEK immediately (all its PII ciphertext becomes unreadable, reads render
|
|
290
390
|
// "[[erased]]") and appends the audit event. Forget is final — the adapter
|
|
@@ -306,7 +406,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
306
406
|
escapeHatch: {
|
|
307
407
|
grants: ["unsafeRaw"],
|
|
308
408
|
reason:
|
|
309
|
-
"denial audit
|
|
409
|
+
"the denial and SUBJECT_FORGOTTEN audit appends name the caller's own tenant stream on the outside-transaction db; " +
|
|
310
410
|
"the tenant-scope and retention checks run against the subject's tenant, not necessarily the caller's; " +
|
|
311
411
|
"the blind-index sweep and search purge address the subject across tenants; the user " +
|
|
312
412
|
"lifecycle update and PAT revoke run on the SYSTEM user stream.",
|
|
@@ -377,22 +477,12 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
377
477
|
eraseReason: event.payload.reason,
|
|
378
478
|
});
|
|
379
479
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// blind-index sweep. No adapter → no-op (apps without search).
|
|
387
|
-
if (ctx.searchAdapter) {
|
|
388
|
-
await purgeSearchDocumentsForSubject(
|
|
389
|
-
crossTenantSubjectRunner,
|
|
390
|
-
ctx.registry.features,
|
|
391
|
-
ctx.searchAdapter,
|
|
392
|
-
subjectKey,
|
|
393
|
-
subject,
|
|
394
|
-
);
|
|
395
|
-
}
|
|
480
|
+
const cleanupError = await sweepDerivedSubjectData(
|
|
481
|
+
ctx,
|
|
482
|
+
subject,
|
|
483
|
+
subjectKey,
|
|
484
|
+
ctx.db.unsafeRaw(),
|
|
485
|
+
);
|
|
396
486
|
|
|
397
487
|
// User subject: close the login door. DEK-erase makes the passwordHash
|
|
398
488
|
// ciphertext unreadable, but status + PATs are standalone credentials —
|
|
@@ -409,33 +499,28 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
409
499
|
// crash recovery is safe. User-feature guard: without the user feature
|
|
410
500
|
// read_users doesn't exist (crypto-only stack). Tenant subjects have no
|
|
411
501
|
// credentials.
|
|
412
|
-
// Graceful fail: email-subscribers and other non-user entities may use
|
|
413
|
-
// user-style subject keys without having an actual user row — the key
|
|
414
|
-
// erase above is the important part for GDPR compliance; the lifecycle
|
|
415
|
-
// update is best-effort for real users.
|
|
416
502
|
if (raw.kind === "user" && ctx.registry.features.has("user")) {
|
|
417
|
-
|
|
418
|
-
const userLifecycleRunner = ctx.db.unsafeRaw();
|
|
419
|
-
await updateUserLifecycle(userLifecycleRunner, raw.userId, { status: USER_STATUS.Deleted });
|
|
420
|
-
if (ctx.registry.features.has("personal-access-tokens")) {
|
|
421
|
-
await revokeAllPatTokensForUser(userLifecycleRunner, raw.userId);
|
|
422
|
-
}
|
|
423
|
-
} catch {
|
|
424
|
-
// User row may not exist (e.g. email subscribers with user-style
|
|
425
|
-
// subject keys). Key erase + blind-index sweep above already ran.
|
|
426
|
-
}
|
|
503
|
+
await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
|
|
427
504
|
}
|
|
428
505
|
|
|
429
|
-
await
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
506
|
+
await appendSubjectForgotten(
|
|
507
|
+
ctx,
|
|
508
|
+
event,
|
|
509
|
+
subjectAggregateId(raw),
|
|
510
|
+
{
|
|
434
511
|
subjectKey,
|
|
435
512
|
reason: event.payload.reason,
|
|
436
513
|
forgottenBy: event.user.id,
|
|
514
|
+
...(cleanupError !== undefined && { cleanupError }),
|
|
437
515
|
},
|
|
438
|
-
|
|
516
|
+
ctx.dbOutsideTransaction?.unsafeRaw(),
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
if (cleanupError !== undefined) {
|
|
520
|
+
throw new InternalError({
|
|
521
|
+
message: `[crypto-shredding] forget-subject erased the key but a derived-data sweep failed (${cleanupError}); retry to finish the cleanup.`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
439
524
|
|
|
440
525
|
return { isSuccess: true as const, data: { subjectKey } };
|
|
441
526
|
},
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "POST /resubscribe requires the undo token from the POST /unsubscribe response",
|
|
6
|
+
"migration": "Clients that called the resubscribe route with the unsubscribe token must send the undo token instead: read it from the hidden token field of the page POST /unsubscribe returns (aud kumiko:resubscribe, valid for 1 hour). The unsubscribe token is rejected on /resubscribe with 400, and the undo token is rejected on /unsubscribe."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.352.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -1,23 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { notificationAddressOptOutsTable } from "../../tables.js";
|
|
5
|
+
|
|
6
|
+
export type AddressOptOutKey = string;
|
|
7
|
+
|
|
8
|
+
export function addressOptOutKey(addressHash: string, channel: string): AddressOptOutKey {
|
|
9
|
+
return `${addressHash}|${channel}`;
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
// Exact match only — unlike notification-preferences there is no wildcard
|
|
6
13
|
// ("*") semantics for address opt-outs, the token that creates a row always
|
|
7
|
-
// carries one concrete notificationType/channel pair.
|
|
8
|
-
|
|
14
|
+
// carries one concrete notificationType/channel pair. One query covers every
|
|
15
|
+
// recipient of a notify() call.
|
|
16
|
+
export async function selectOptedOutAddresses(
|
|
9
17
|
db: DbConnection,
|
|
10
18
|
tenantId: TenantId,
|
|
11
|
-
addressHash: string,
|
|
12
19
|
notificationType: string,
|
|
13
|
-
|
|
14
|
-
): Promise<
|
|
15
|
-
|
|
20
|
+
addressHashes: readonly string[],
|
|
21
|
+
): Promise<ReadonlySet<AddressOptOutKey>> {
|
|
22
|
+
if (addressHashes.length === 0) return new Set();
|
|
23
|
+
const rows = await selectMany<{ readonly addressHash: string; readonly channel: string }>(
|
|
16
24
|
db,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
LIMIT 1`,
|
|
20
|
-
[tenantId, addressHash, notificationType, channel],
|
|
25
|
+
notificationAddressOptOutsTable,
|
|
26
|
+
{ tenantId, notificationType, addressHash: { in: addressHashes } },
|
|
21
27
|
);
|
|
22
|
-
return rows.
|
|
28
|
+
return new Set(rows.map((row) => addressOptOutKey(row.addressHash, row.channel)));
|
|
23
29
|
}
|
|
@@ -22,7 +22,11 @@ import { hashUnsubscribeAddress } from "./address-opt-out.js";
|
|
|
22
22
|
import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
|
|
23
23
|
import { buildChannelContext } from "./channel-context.js";
|
|
24
24
|
import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryPriorityRank } from "./constants.js";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type AddressOptOutKey,
|
|
27
|
+
addressOptOutKey,
|
|
28
|
+
selectOptedOutAddresses,
|
|
29
|
+
} from "./db/queries/address-opt-outs.js";
|
|
26
30
|
import { selectNotificationPreferences } from "./db/queries/preferences.js";
|
|
27
31
|
import {
|
|
28
32
|
type ChannelContext,
|
|
@@ -411,16 +415,74 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
411
415
|
|
|
412
416
|
// Shared by deliverToUser (resolved account address) and deliverDirect (a
|
|
413
417
|
// route address with no user account) — same suppression rule either way:
|
|
414
|
-
// no blind-index key configured means no hash, so nothing to look up.
|
|
415
|
-
|
|
418
|
+
// no blind-index key configured means no hash, so nothing to look up. One
|
|
419
|
+
// query per notify() call, not per recipient and channel.
|
|
420
|
+
async function loadSuppressedAddresses(
|
|
421
|
+
tenantId: TenantId,
|
|
422
|
+
notificationType: string,
|
|
423
|
+
priority: NotifyPriority,
|
|
424
|
+
addresses: Iterable<string>,
|
|
425
|
+
): Promise<ReadonlySet<AddressOptOutKey>> {
|
|
426
|
+
// Critical priority skips address opt-outs, same rule as user preferences.
|
|
427
|
+
if (priority === "critical") return new Set();
|
|
428
|
+
const addressHashes = new Set<string>();
|
|
429
|
+
for (const address of addresses) {
|
|
430
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
431
|
+
if (addressHash !== undefined) addressHashes.add(addressHash);
|
|
432
|
+
}
|
|
433
|
+
return selectOptedOutAddresses(db, tenantId, notificationType, [...addressHashes]);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function isAddressSuppressed(
|
|
437
|
+
suppressed: ReadonlySet<AddressOptOutKey>,
|
|
416
438
|
address: string,
|
|
439
|
+
channelName: string,
|
|
440
|
+
): boolean {
|
|
441
|
+
const addressHash = hashUnsubscribeAddress(address);
|
|
442
|
+
return addressHash !== undefined && suppressed.has(addressOptOutKey(addressHash, channelName));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
|
|
446
|
+
|
|
447
|
+
async function isChannelDeliverable(
|
|
448
|
+
userId: string,
|
|
417
449
|
tenantId: TenantId,
|
|
418
450
|
notificationType: string,
|
|
419
451
|
channelName: string,
|
|
420
452
|
): Promise<boolean> {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
453
|
+
if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
|
|
454
|
+
return isChannelEnabled(userId, tenantId, notificationType, channelName);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Resolves every user's channel addresses up front so the opt-out lookup can
|
|
458
|
+
// cover them all at once. A resolve that throws stays out of the map: the
|
|
459
|
+
// delivery loop resolves it again and reports channel_error as before.
|
|
460
|
+
// Channels the kill switch or the user's preferences disable are never resolved
|
|
461
|
+
// here: resolve may have side effects and deliverToUser skips those channels.
|
|
462
|
+
async function prefetchUserAddresses(
|
|
463
|
+
userIds: readonly string[],
|
|
464
|
+
tenantId: TenantId,
|
|
465
|
+
notificationType: string,
|
|
466
|
+
): Promise<Map<string, string | null>> {
|
|
467
|
+
const addresses = new Map<string, string | null>();
|
|
468
|
+
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
469
|
+
for (const userId of userIds) {
|
|
470
|
+
for (const channel of channels) {
|
|
471
|
+
if (!channel.resolve) continue;
|
|
472
|
+
try {
|
|
473
|
+
if (!(await isChannelDeliverable(userId, tenantId, notificationType, channel.name))) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
addresses.set(
|
|
477
|
+
recipientChannelKey(userId, channel.name),
|
|
478
|
+
await channel.resolve(userId, channelCtx),
|
|
479
|
+
);
|
|
480
|
+
} catch {
|
|
481
|
+
// skip: re-resolved (and logged) by deliverToUser
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return addresses;
|
|
424
486
|
}
|
|
425
487
|
|
|
426
488
|
// Check if user has disabled this notification+channel combo.
|
|
@@ -466,6 +528,8 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
466
528
|
tenantId: TenantId,
|
|
467
529
|
priority: NotifyPriority,
|
|
468
530
|
run: NotifyRun,
|
|
531
|
+
prefetchedAddresses: ReadonlyMap<string, string | null>,
|
|
532
|
+
suppressed: ReadonlySet<AddressOptOutKey>,
|
|
469
533
|
): Promise<void> {
|
|
470
534
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
471
535
|
|
|
@@ -530,7 +594,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
530
594
|
}
|
|
531
595
|
|
|
532
596
|
try {
|
|
533
|
-
const
|
|
597
|
+
const prefetchKey = recipientChannelKey(userId, channel.name);
|
|
598
|
+
const address = prefetchedAddresses.has(prefetchKey)
|
|
599
|
+
? prefetchedAddresses.get(prefetchKey)
|
|
600
|
+
: await channel.resolve(userId, channelCtx);
|
|
534
601
|
if (!address) {
|
|
535
602
|
await logDelivery(run, {
|
|
536
603
|
tenantId,
|
|
@@ -545,10 +612,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
545
612
|
continue;
|
|
546
613
|
}
|
|
547
614
|
|
|
548
|
-
if (
|
|
549
|
-
priority !== "critical" &&
|
|
550
|
-
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
551
|
-
) {
|
|
615
|
+
if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
|
|
552
616
|
await logDelivery(run, {
|
|
553
617
|
tenantId,
|
|
554
618
|
notificationType,
|
|
@@ -602,6 +666,12 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
602
666
|
run: NotifyRun,
|
|
603
667
|
): Promise<void> {
|
|
604
668
|
const channelCtx = buildChannelContext(db, registry, sseBroker, tenantId, secrets);
|
|
669
|
+
const suppressed = await loadSuppressedAddresses(
|
|
670
|
+
tenantId,
|
|
671
|
+
notificationType,
|
|
672
|
+
priority,
|
|
673
|
+
Object.values(route),
|
|
674
|
+
);
|
|
605
675
|
|
|
606
676
|
// Direct routing skips preferences (no user account) but NOT rate limit
|
|
607
677
|
// — direct sends can still be abused (webhook replays, test harnesses).
|
|
@@ -612,10 +682,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
612
682
|
|
|
613
683
|
// Address opt-out (critical priority skips it, same rule as user
|
|
614
684
|
// preferences).
|
|
615
|
-
if (
|
|
616
|
-
priority !== "critical" &&
|
|
617
|
-
(await isAddressSuppressed(address, tenantId, notificationType, channel.name))
|
|
618
|
-
) {
|
|
685
|
+
if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
|
|
619
686
|
await logDelivery(run, {
|
|
620
687
|
tenantId,
|
|
621
688
|
notificationType,
|
|
@@ -729,8 +796,28 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
729
796
|
userIds = to;
|
|
730
797
|
}
|
|
731
798
|
|
|
799
|
+
const prefetchedAddresses =
|
|
800
|
+
priority === "critical"
|
|
801
|
+
? new Map<string, string | null>()
|
|
802
|
+
: await prefetchUserAddresses(userIds, tenantId, notificationType);
|
|
803
|
+
const suppressed = await loadSuppressedAddresses(
|
|
804
|
+
tenantId,
|
|
805
|
+
notificationType,
|
|
806
|
+
priority,
|
|
807
|
+
[...prefetchedAddresses.values()].filter((address): address is string => !!address),
|
|
808
|
+
);
|
|
809
|
+
|
|
732
810
|
for (const userId of userIds) {
|
|
733
|
-
await deliverToUser(
|
|
811
|
+
await deliverToUser(
|
|
812
|
+
userId,
|
|
813
|
+
notificationType,
|
|
814
|
+
data,
|
|
815
|
+
tenantId,
|
|
816
|
+
priority,
|
|
817
|
+
run,
|
|
818
|
+
prefetchedAddresses,
|
|
819
|
+
suppressed,
|
|
820
|
+
);
|
|
734
821
|
}
|
|
735
822
|
}
|
|
736
823
|
return { deliveries: run.deliveries };
|
|
@@ -11,7 +11,7 @@ export const DeliveryHandlers = {
|
|
|
11
11
|
resubscribeUser: "delivery:write:resubscribe-user",
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
|
-
//
|
|
14
|
+
// Stable path: links signed against it keep working. The unsubscribe route is
|
|
15
15
|
// mounted at this exact path via `extraRoutes: [...createUnsubscribeRoutes(...)]`.
|
|
16
16
|
export const DELIVERY_UNSUBSCRIBE_PATH = "/api/delivery/unsubscribe" as const;
|
|
17
17
|
|