@cosmicdrift/kumiko-bundled-features 0.387.0 → 0.389.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-tools/index.d.ts +1 -1
- package/dist/agent-tools/index.js +1 -1
- package/dist/agent-tools/tool-dispatch.d.ts +12 -7
- package/dist/agent-tools/tool-dispatch.js +42 -31
- package/dist/audit/feature.js +1 -0
- package/dist/audit/handlers/audit-events.js +1 -1
- package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
- package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
- package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
- package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
- package/dist/auth-email-password/magic-link-mail.js +7 -3
- package/dist/auth-email-password/signup-activation.d.ts +1 -0
- package/dist/auth-email-password/signup-activation.js +1 -0
- package/dist/auth-email-password/web/auth-client.d.ts +2 -2
- package/dist/auth-email-password/web/auth-client.js +8 -4
- package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
- package/dist/auth-email-password/web/auth-redirect.js +20 -25
- package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
- package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
- package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
- package/dist/auth-email-password/web/index.d.ts +1 -0
- package/dist/auth-email-password/web/index.js +1 -0
- package/dist/auth-email-password/web/login-screen.js +11 -6
- package/dist/auth-email-password/web/reset-password-screen.js +7 -1
- package/dist/auth-email-password/web/return-path.d.ts +4 -0
- package/dist/auth-email-password/web/return-path.js +34 -0
- package/dist/auth-email-password/web/session.js +6 -1
- package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
- package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
- package/dist/auth-email-password/web/signup-screen.js +8 -3
- package/dist/auth-mfa/config.d.ts +1 -1
- package/dist/auth-mfa/config.js +3 -7
- package/dist/auth-mfa/feature-exports.d.ts +2 -2
- package/dist/auth-mfa/feature.js +8 -0
- package/dist/auth-mfa/mfa-status-checker.js +5 -3
- package/dist/auth-mfa/web/i18n.js +5 -1
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
- package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
- package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
- package/dist/billing-foundation/checkout-core.d.ts +9 -14
- package/dist/billing-foundation/checkout-core.js +24 -39
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
- package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
- package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
- package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
- package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
- package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
- package/dist/billing-foundation/events.d.ts +1 -0
- package/dist/billing-foundation/events.js +1 -0
- package/dist/billing-foundation/feature.js +3 -1
- package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
- package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
- package/dist/billing-foundation/handlers/process-event.write.js +8 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
- package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
- package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
- package/dist/billing-foundation/i18n.js +22 -0
- package/dist/billing-foundation/index.d.ts +1 -1
- package/dist/billing-foundation/plan-catalog.d.ts +21 -5
- package/dist/billing-foundation/plan-catalog.js +25 -16
- package/dist/billing-foundation/plan-switch.d.ts +12 -0
- package/dist/billing-foundation/plan-switch.js +60 -0
- package/dist/billing-foundation/types.d.ts +57 -1
- package/dist/billing-foundation/types.js +9 -0
- package/dist/billing-foundation/validate-options.js +17 -0
- package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
- package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
- package/dist/billing-foundation/web/constants.d.ts +4 -0
- package/dist/billing-foundation/web/constants.js +4 -0
- package/dist/billing-foundation/web/plan-price.d.ts +15 -0
- package/dist/billing-foundation/web/plan-price.js +32 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
- package/dist/cap-counter/cap-reservation.js +20 -7
- package/dist/cap-counter/enforce-cap.js +2 -1
- package/dist/cap-counter/feature.js +1 -0
- package/dist/cap-counter/stock-cap-guard.js +7 -1
- package/dist/cap-overview/feature.js +1 -0
- package/dist/channel-email/feature.js +1 -1
- package/dist/config/handlers/cascade.query.d.ts +5 -0
- package/dist/config/handlers/cascade.query.js +4 -3
- package/dist/config/handlers/set.write.js +12 -2
- package/dist/config/handlers/values.query.d.ts +5 -0
- package/dist/config/handlers/values.query.js +4 -3
- package/dist/config/write-helpers.d.ts +1 -0
- package/dist/config/write-helpers.js +18 -9
- package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -11
- package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
- package/dist/delivery/channel-context.js +6 -3
- package/dist/delivery/db/queries/preferences.js +2 -2
- package/dist/delivery/delivery-service.js +22 -7
- package/dist/delivery/feature.js +1 -0
- package/dist/delivery/handlers/preferences.query.d.ts +1 -1
- package/dist/delivery/handlers/preferences.query.js +20 -1
- package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/system-channel-context.d.ts +15 -0
- package/dist/delivery/system-channel-context.js +5 -0
- package/dist/delivery/unsubscribe-link.d.ts +3 -1
- package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
- package/dist/delivery/unsubscribe-page-strings.js +1 -0
- package/dist/delivery/unsubscribe-page.d.ts +2 -0
- package/dist/delivery/unsubscribe-page.js +5 -0
- package/dist/delivery/unsubscribe.js +25 -7
- package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
- package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
- package/dist/document-ingest-foundation/events.d.ts +11 -0
- package/dist/document-ingest-foundation/events.js +11 -0
- package/dist/document-ingest-foundation/index.d.ts +2 -1
- package/dist/document-ingest-foundation/index.js +2 -1
- package/dist/feature-toggles/feature.js +1 -0
- package/dist/file-foundation/index.d.ts +1 -1
- package/dist/file-foundation/index.js +1 -1
- package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
- package/dist/file-foundation/read-file-ref-content.js +6 -1
- package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
- package/dist/form-draft/db/queries/cleanup.js +2 -2
- package/dist/form-draft/decrypt-draft.js +5 -1
- package/dist/host-routing/dispatch.js +11 -20
- package/dist/host-routing/validate-options.js +3 -0
- package/dist/inbound-mail-foundation/feature.js +1 -0
- package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
- package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
- package/dist/inbound-mail-foundation/i18n.js +0 -1
- package/dist/inbound-mail-foundation/screens.js +0 -3
- package/dist/inbound-mail-foundation/types.d.ts +4 -3
- package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
- package/dist/jobs/feature.d.ts +1 -0
- package/dist/jobs/feature.js +9 -4
- package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-failures.query.js +45 -41
- package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-runs.query.js +54 -50
- package/dist/jobs/job-run-logger.js +5 -4
- package/dist/ledger/web/index.js +1 -1
- package/dist/legal-pages/seed.d.ts +3 -1
- package/dist/legal-pages/seed.js +2 -2
- package/dist/mail-foundation/feature.d.ts +3 -3
- package/dist/mail-foundation/feature.js +3 -0
- package/dist/mail-transport-smtp/config-keys.js +1 -1
- package/dist/mail-transport-smtp/feature.js +6 -5
- package/dist/managed-pages/constants.d.ts +1 -0
- package/dist/managed-pages/constants.js +1 -0
- package/dist/managed-pages/feature.js +3 -1
- package/dist/managed-pages/index.d.ts +1 -0
- package/dist/managed-pages/index.js +1 -0
- package/dist/notes-history/handlers/add-note.write.js +1 -1
- package/dist/personal-access-tokens/feature.d.ts +1 -1
- package/dist/personal-access-tokens/feature.js +5 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
- package/dist/renderer-pdf/render-table-pdf.js +22 -10
- package/dist/secrets/write-gate.js +8 -2
- package/dist/sessions/feature.d.ts +2 -2
- package/dist/sessions/feature.js +5 -0
- package/dist/sessions/index.d.ts +1 -1
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/session-callbacks.d.ts +0 -2
- package/dist/sessions/session-callbacks.js +1 -3
- package/dist/shared/decrypt-stored-pii.d.ts +1 -0
- package/dist/shared/decrypt-stored-pii.js +6 -1
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/mail-template-body.d.ts +6 -2
- package/dist/shared/mail-template-body.js +7 -4
- package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
- package/dist/shared/purge-files-reaching-subject.js +19 -2
- package/dist/shared/run-in-sub-transaction.js +5 -5
- package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
- package/dist/step-dispatcher/feature.js +1 -0
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/subscription-dodo/client.d.ts +17 -0
- package/dist/subscription-dodo/client.js +31 -0
- package/dist/subscription-dodo/constants.d.ts +12 -0
- package/dist/subscription-dodo/constants.js +19 -0
- package/dist/subscription-dodo/feature.d.ts +37 -0
- package/dist/subscription-dodo/feature.js +144 -0
- package/dist/subscription-dodo/index.d.ts +2 -0
- package/dist/subscription-dodo/index.js +4 -0
- package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
- package/dist/subscription-dodo/plugin-methods.js +260 -0
- package/dist/subscription-dodo/runtime.d.ts +27 -0
- package/dist/subscription-dodo/runtime.js +89 -0
- package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
- package/dist/subscription-dodo/verify-webhook.js +189 -0
- package/dist/subscription-stripe/feature.js +1 -0
- package/dist/subscription-stripe/runtime.js +7 -6
- package/dist/tags/feature.js +1 -0
- package/dist/template-resolver/api.d.ts +3 -2
- package/dist/template-resolver/api.js +24 -2
- package/dist/template-resolver/compile-document-body.js +1 -0
- package/dist/template-resolver/document-ops.d.ts +5 -0
- package/dist/template-resolver/document-ops.js +18 -2
- package/dist/template-resolver/feature.js +4 -4
- package/dist/template-resolver/handlers/bundle-import.js +3 -3
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
- package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
- package/dist/template-resolver/handlers/document-base.js +3 -2
- package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
- package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
- package/dist/template-resolver/handlers/layout-source.query.js +26 -6
- package/dist/template-resolver/handlers/list.query.js +5 -6
- package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
- package/dist/template-resolver/handlers/overview.query.js +21 -10
- package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
- package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
- package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
- package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
- package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
- package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
- package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
- package/dist/template-resolver/handlers/usage.query.js +26 -16
- package/dist/template-resolver/handlers/validate.query.js +7 -2
- package/dist/template-resolver/seeding.d.ts +1 -0
- package/dist/template-resolver/seeding.js +31 -12
- package/dist/template-resolver/store-template-resource.d.ts +1 -0
- package/dist/template-resolver/store-template-resource.js +65 -10
- package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
- package/dist/template-resolver/template-delivery-policy.js +0 -6
- package/dist/template-resolver/template-meta.js +7 -4
- package/dist/template-resolver/template-preview.js +4 -3
- package/dist/template-resolver/web/constants.d.ts +2 -2
- package/dist/template-resolver/web/constants.js +3 -3
- package/dist/template-resolver/web/i18n.js +3 -0
- package/dist/template-resolver/web/index.d.ts +1 -0
- package/dist/template-resolver/web/index.js +1 -0
- package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
- package/dist/template-resolver/web/mail-layout-constants.js +7 -0
- package/dist/template-resolver/web/qualified-names.d.ts +1 -0
- package/dist/template-resolver/web/qualified-names.js +1 -0
- package/dist/tenant/feature.js +1 -0
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +10 -3
- package/dist/tenant/handlers/team-list.query.js +3 -3
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +8 -4
- package/dist/tenant-lifecycle/constants.d.ts +1 -0
- package/dist/tenant-lifecycle/constants.js +4 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tenant-lifecycle/stages.js +22 -1
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
- package/dist/tier-engine/web/constants.js +2 -2
- package/dist/user/feature.js +1 -0
- package/dist/user-data-rights/feature.js +1 -0
- package/dist/user-data-rights/index.d.ts +1 -1
- package/dist/user-data-rights/index.js +1 -1
- package/dist/user-data-rights/run-export-jobs.js +3 -4
- package/dist/user-data-rights/run-forget-cleanup.js +2 -1
- package/dist/user-data-rights/web/constants.d.ts +6 -0
- package/dist/user-data-rights/web/constants.js +6 -0
- package/dist/user-data-rights/web/index.d.ts +1 -0
- package/dist/user-data-rights/web/index.js +1 -0
- package/dist/waitlist/feature.js +1 -0
- package/dist/waitlist/handlers/export-purge.write.js +2 -1
- package/dist/waitlist/handlers/submit.write.js +14 -10
- package/dist/waitlist/import.d.ts +3 -1
- package/dist/waitlist/import.js +26 -2
- package/dist/waitlist/legacy-migrate.js +18 -7
- package/dist/waitlist/link-on-signup.js +18 -6
- package/dist/waitlist/options.d.ts +5 -1
- package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
- package/package.json +19 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/audit/changes.json +12 -0
- package/src/auth-email-password/changes.json +12 -0
- package/src/auth-mfa/changes.json +12 -0
- package/src/billing-foundation/changes.json +55 -0
- package/src/cap-counter/changes.json +30 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +18 -0
- package/src/crypto-shredding/changes.json +18 -0
- package/src/delivery/changes.json +42 -0
- package/src/document-ingest-foundation/changes.json +6 -0
- package/src/form-draft/changes.json +6 -0
- package/src/host-routing/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +18 -0
- package/src/jobs/changes.json +13 -0
- package/src/mail-foundation/changes.json +6 -0
- package/src/mail-transport-smtp/changes.json +6 -0
- package/src/managed-pages/changes.json +6 -0
- package/src/renderer-pdf/changes.json +6 -0
- package/src/secrets/changes.json +6 -0
- package/src/sessions/changes.json +7 -0
- package/src/step-dispatcher/changes.json +12 -0
- package/src/subscription-dodo/changes.json +14 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/template-resolver/changes.json +84 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +12 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/user-data-rights/changes.json +12 -0
- package/src/waitlist/changes.json +36 -0
- package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
- package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
|
@@ -40,6 +40,8 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
40
40
|
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
|
|
41
41
|
// Sent by the reference combobox while the user types.
|
|
42
42
|
search: z.string().trim().min(1).optional(),
|
|
43
|
+
// Resolves exactly these users; callers that know their ids must not depend on the cap.
|
|
44
|
+
ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
|
|
43
45
|
}),
|
|
44
46
|
access: { roles: access.admin },
|
|
45
47
|
description: "Lists users as id/label pairs for resolving user references to display names: the caller's own tenant members for an admin, every user for a SystemAdmin; carries no email or role data.",
|
|
@@ -50,21 +52,26 @@ export const memberDirectoryQuery = definePagedQueryHandler({
|
|
|
50
52
|
message: "tenant:query:member-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
|
-
const { limit, search } = query.payload;
|
|
55
|
+
const { limit, search, ids } = query.payload;
|
|
56
|
+
if (ids?.length === 0)
|
|
57
|
+
return { rows: [], nextCursor: null };
|
|
54
58
|
// Without a search the first `limit` rows are enough; with one, every
|
|
55
59
|
// candidate must be decrypted before the limit can apply.
|
|
56
60
|
const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
|
|
57
61
|
let users;
|
|
58
62
|
if (isSystemAdmin(query.user)) {
|
|
59
63
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as user:query:user:list did");
|
|
60
|
-
users = await selectMany(db, userTable, undefined, {
|
|
64
|
+
users = await selectMany(db, userTable, ids === undefined ? undefined : { id: ids }, {
|
|
61
65
|
limit: scanLimit,
|
|
62
66
|
orderBy: { col: "id", direction: "asc" },
|
|
63
67
|
});
|
|
64
68
|
}
|
|
65
69
|
else {
|
|
66
70
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
67
|
-
const
|
|
71
|
+
const requestedIds = ids === undefined ? undefined : new Set(ids);
|
|
72
|
+
const userIds = (await loadMemberUserIds(db, query.user.tenantId))
|
|
73
|
+
.filter((id) => requestedIds === undefined || requestedIds.has(id))
|
|
74
|
+
.slice(0, scanLimit);
|
|
68
75
|
users =
|
|
69
76
|
userIds.length > 0
|
|
70
77
|
? await selectMany(db, userTable, { id: [...userIds] }, {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { isUndefinedTable, selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, definePagedQueryHandler, MAX_LIST_LIMIT, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
-
import {
|
|
6
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
7
|
import * as z from "zod";
|
|
8
8
|
import { userSessionTable } from "../../sessions/index.js";
|
|
9
9
|
import { decryptStoredPii, mapWithConcurrency } from "../../shared/index.js";
|
|
@@ -110,7 +110,7 @@ export const teamListQuery = definePagedQueryHandler({
|
|
|
110
110
|
// ponytail: lean sample apps (admin-console) mount auth without
|
|
111
111
|
// sessions — store_user_sessions never gets pushed. Integration tests
|
|
112
112
|
// may seed the table without mounting the feature; prod mounts both.
|
|
113
|
-
if (
|
|
113
|
+
if (!isUndefinedTable(err))
|
|
114
114
|
throw err;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
export declare const tenantDirectoryQuery: import("@cosmicdrift/kumiko-framework/engine").PagedQueryHandlerDefinition<"tenantDirectory", z.ZodObject<{
|
|
3
3
|
limit: z.ZodDefault<z.ZodNumber>;
|
|
4
4
|
search: z.ZodOptional<z.ZodString>;
|
|
5
|
+
ids: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
5
6
|
}, z.core.$strip>, {
|
|
6
7
|
id: string;
|
|
7
8
|
label: string;
|
|
@@ -21,9 +21,11 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
21
21
|
limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT),
|
|
22
22
|
// Sent by the reference combobox while the user types.
|
|
23
23
|
search: z.string().trim().min(1).optional(),
|
|
24
|
+
// Resolves exactly these tenants; callers that know their ids must not depend on the cap.
|
|
25
|
+
ids: z.array(z.string().min(1)).max(MAX_LIST_LIMIT).optional(),
|
|
24
26
|
}),
|
|
25
27
|
access: { roles: access.admin },
|
|
26
|
-
description: "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin.",
|
|
28
|
+
description: "Lists tenants as id/label pairs for resolving tenant references to display names: the caller's own tenant for an admin, every tenant for a SystemAdmin. A search returns the first `limit` matches in tenant-key order (sorted by label afterwards), not the alphabetically best matches.",
|
|
27
29
|
agent: { expose: false },
|
|
28
30
|
handler: async (query, ctx) => {
|
|
29
31
|
if (!ctx.systemDb) {
|
|
@@ -31,12 +33,14 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
31
33
|
message: "tenant:query:tenant-directory requires ctx.systemDb — is r.systemScope() still set on the tenant feature?",
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
|
-
const { limit, search } = query.payload;
|
|
36
|
+
const { limit, search, ids } = query.payload;
|
|
37
|
+
if (ids?.length === 0)
|
|
38
|
+
return { rows: [], nextCursor: null };
|
|
35
39
|
const scanLimit = search === undefined ? limit : SEARCH_SCAN_CAP;
|
|
36
40
|
let tenants;
|
|
37
41
|
if (isSystemAdmin(query.user)) {
|
|
38
42
|
const db = ctx.systemDb.acknowledgeCrossTenant("SystemAdmin reference labels span every tenant, as tenant:query:tenant:list did");
|
|
39
|
-
tenants = await selectMany(db, tenantTable, undefined, {
|
|
43
|
+
tenants = await selectMany(db, tenantTable, ids === undefined ? undefined : { id: ids }, {
|
|
40
44
|
limit: scanLimit,
|
|
41
45
|
orderBy: [
|
|
42
46
|
{ col: "key", direction: "asc" },
|
|
@@ -47,7 +51,7 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
47
51
|
else {
|
|
48
52
|
const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
49
53
|
const row = await fetchOne(db, tenantTable, { id: query.user.tenantId });
|
|
50
|
-
tenants = row ? [row] : [];
|
|
54
|
+
tenants = row && (ids === undefined || ids.includes(query.user.tenantId)) ? [row] : [];
|
|
51
55
|
}
|
|
52
56
|
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
53
57
|
// `key`; label order and search happen in memory after decrypting.
|
|
@@ -27,3 +27,4 @@ export declare const TenantLifecycleHandlers: {
|
|
|
27
27
|
export declare const TENANT_DESTRUCTION_STAGES: readonly ["external-resources", "search-indices", "cache", "app-data", "subject-keys", "files", "infra-resources", "tenant-row"];
|
|
28
28
|
export type TenantDestructionStageName = (typeof TENANT_DESTRUCTION_STAGES)[number];
|
|
29
29
|
export declare const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30000;
|
|
30
|
+
export declare const DESTRUCTION_STAGE_MIN_SLICE_MS = 2000;
|
|
@@ -38,3 +38,7 @@ export const TENANT_DESTRUCTION_STAGES = [
|
|
|
38
38
|
// The sweep cron fires every minute with concurrency "skip", so a tick must
|
|
39
39
|
// finish well inside that window or the next one is skipped.
|
|
40
40
|
export const DESTRUCTION_STAGE_TICK_BUDGET_MS = 30_000;
|
|
41
|
+
// Lower bound of a tenant's share of the tick budget. Below it a chunking hook
|
|
42
|
+
// without a guaranteed first batch processes nothing, and a no-progress stage
|
|
43
|
+
// counts as a failed attempt, which would abandon the pipeline under load.
|
|
44
|
+
export const DESTRUCTION_STAGE_MIN_SLICE_MS = 2_000;
|
|
@@ -3,7 +3,7 @@ import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
3
3
|
import { append, loadAggregate, VersionConflictError, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
5
5
|
import { tenantEntity, tenantTable } from "../tenant/index.js";
|
|
6
|
-
import { DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
6
|
+
import { DESTRUCTION_STAGE_MIN_SLICE_MS, DESTRUCTION_STAGE_TICK_BUDGET_MS, TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_COMPLETED_EVENT_QN, TENANT_DESTRUCTION_FAILED_EVENT_QN, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_PROGRESSED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
7
7
|
import { invalidateTenantLifecycleGate, TEARDOWN_GATE_SETTLE_MS } from "./lifecycle-gate.js";
|
|
8
8
|
import { isDestructionPipelineComplete, pickNextStage, } from "./stages.js";
|
|
9
9
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
@@ -251,7 +251,7 @@ export async function runTenantDestructionSweep(args) {
|
|
|
251
251
|
// window and starve the ones behind it. Each slice starts when its tenant
|
|
252
252
|
// starts: anchoring to a shared tick deadline would hand tenants after an
|
|
253
253
|
// overrun a zero slice, and a no-progress stage counts as a failed attempt.
|
|
254
|
-
const sliceMs = DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length);
|
|
254
|
+
const sliceMs = Math.max(DESTRUCTION_STAGE_MIN_SLICE_MS, DESTRUCTION_STAGE_TICK_BUDGET_MS / Math.max(1, destroying.length));
|
|
255
255
|
for (const row of destroying) {
|
|
256
256
|
const tenantDeadlineAt = Date.now() + sliceMs;
|
|
257
257
|
try {
|
|
@@ -73,6 +73,27 @@ async function runTenantDataHooks(ctx) {
|
|
|
73
73
|
}
|
|
74
74
|
return total.outcome();
|
|
75
75
|
}
|
|
76
|
+
// A user key outlives the tenant while the user still belongs to another live tenant:
|
|
77
|
+
// erasing it would tombstone the key and break every later PII write for that user.
|
|
78
|
+
async function userIdsWithoutOtherLiveTenant(ctx, userIds) {
|
|
79
|
+
// skip: no members, nothing to look up.
|
|
80
|
+
if (userIds.length === 0)
|
|
81
|
+
return [];
|
|
82
|
+
const otherMemberships = (await selectMany(ctx.db, tenantMembershipsTable, {
|
|
83
|
+
userId: { in: [...userIds] },
|
|
84
|
+
})).filter((membership) => membership.tenantId !== ctx.tenantId);
|
|
85
|
+
// skip: every member belongs to this tenant only.
|
|
86
|
+
if (otherMemberships.length === 0)
|
|
87
|
+
return userIds;
|
|
88
|
+
const otherTenants = await selectMany(ctx.db, tenantTable, {
|
|
89
|
+
id: { in: [...new Set(otherMemberships.map((membership) => membership.tenantId))] },
|
|
90
|
+
});
|
|
91
|
+
const liveTenantIds = new Set(otherTenants.filter((tenant) => tenant.status !== "destroyed").map((tenant) => tenant.id));
|
|
92
|
+
const retainedUserIds = new Set(otherMemberships
|
|
93
|
+
.filter((membership) => liveTenantIds.has(membership.tenantId))
|
|
94
|
+
.map((membership) => membership.userId));
|
|
95
|
+
return userIds.filter((userId) => !retainedUserIds.has(userId));
|
|
96
|
+
}
|
|
76
97
|
async function eraseSubjectKeys(ctx) {
|
|
77
98
|
const kms = configuredPiiSubjectKms();
|
|
78
99
|
if (!kms) {
|
|
@@ -87,7 +108,7 @@ async function eraseSubjectKeys(ctx) {
|
|
|
87
108
|
const subjects = [
|
|
88
109
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
89
110
|
tenantRecordSubject,
|
|
90
|
-
...memberships.map((m) => ({ kind: "user", userId
|
|
111
|
+
...(await userIdsWithoutOtherLiveTenant(ctx, memberships.map((m) => m.userId))).map((userId) => ({ kind: "user", userId })),
|
|
91
112
|
];
|
|
92
113
|
for (const subject of subjects) {
|
|
93
114
|
await kms.eraseKey(subject, {
|
|
@@ -16,9 +16,8 @@ export declare function createSetTenantTierWrite(opts?: SetTenantTierOptions): i
|
|
|
16
16
|
tenantId: z.ZodString;
|
|
17
17
|
tier: z.ZodString;
|
|
18
18
|
source: z.ZodDefault<z.ZodEnum<{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
readonly default: "default";
|
|
19
|
+
billing: "billing";
|
|
20
|
+
manual: "manual";
|
|
22
21
|
}>>;
|
|
23
22
|
}, z.core.$strip>, {
|
|
24
23
|
tenantId: string;
|
|
@@ -38,7 +38,10 @@ export function createSetTenantTierWrite(opts = {}) {
|
|
|
38
38
|
.min(1)
|
|
39
39
|
.max(50)
|
|
40
40
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
41
|
-
|
|
41
|
+
// `default` is reserved for the signup default tier; the billing sync treats it like `billing`.
|
|
42
|
+
source: z
|
|
43
|
+
.enum([TierAssignmentSources.manual, TierAssignmentSources.billing])
|
|
44
|
+
.default(TierAssignmentSources.manual),
|
|
42
45
|
}),
|
|
43
46
|
access: { roles: ["SystemAdmin"] },
|
|
44
47
|
agent: { risk: "high" },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// category in the kumiko isolation model (runtime may import client, client
|
|
4
4
|
// may only import client) — otherwise the runtime isolation guard throws on
|
|
5
5
|
// the client imports. Same pattern as text-content/constants.
|
|
6
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
6
7
|
// Feature name
|
|
7
8
|
export const TIER_ENGINE_FEATURE = "tier-engine";
|
|
8
9
|
// Qualified write handler names (QN format: scope:type:name).
|
|
@@ -18,9 +19,8 @@ export const TierAssignmentSources = {
|
|
|
18
19
|
billing: "billing",
|
|
19
20
|
default: "default",
|
|
20
21
|
};
|
|
21
|
-
const TIER_ASSIGNMENT_SOURCE_VALUES = Object.values(TierAssignmentSources);
|
|
22
22
|
export function isTierAssignmentSource(value) {
|
|
23
|
-
return
|
|
23
|
+
return isOneOf(Object.values(TierAssignmentSources), value);
|
|
24
24
|
}
|
|
25
25
|
// Qualified query handler names.
|
|
26
26
|
export const TierEngineQueries = {
|
package/dist/user/feature.js
CHANGED
|
@@ -14,6 +14,7 @@ import { userEditScreen, userListScreen } from "./screens.js";
|
|
|
14
14
|
// Membership + tenant-specific roles live in the tenant feature.
|
|
15
15
|
export function createUserFeature() {
|
|
16
16
|
return defineFeature("user", (r) => {
|
|
17
|
+
r.adoptsBundledLists();
|
|
17
18
|
r.describe("Manages the cross-tenant user identity: the `read_users` table holds each user's email, `displayName`, global `roles`, `emailVerified` flag, and lifecycle `status` (active / restricted / deletionRequested / deleted). Because users exist above any individual tenant, the feature runs with `r.systemScope()` \u2014 membership and tenant-specific roles live in the `tenant` feature instead. Add this feature whenever your app needs a persistent, tenant-agnostic user record that auth and GDPR pipelines can reference.");
|
|
18
19
|
r.uiHints({
|
|
19
20
|
displayLabel: "User Identity",
|
|
@@ -51,6 +51,7 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
51
51
|
let warnedMissingMailDelivery = false;
|
|
52
52
|
const showDeletion = opts.privacyCenterShowDeletion ?? true;
|
|
53
53
|
return defineFeature("user-data-rights", (r) => {
|
|
54
|
+
r.adoptsBundledLists();
|
|
54
55
|
r.describe('Implements GDPR Art. 15 (access / `my-audit-log` query), Art. 17 (erasure / `request-deletion` + `cancel-deletion`, plus the anonymous email-verified `request-deletion-by-email` + `confirm-deletion-by-token` flow for lockout-safe self-service, + cron cleanup with grace period), Art. 18 (restriction / `restrict-account` + `lift-restriction`), and Art. 20 (portability / async `request-export` \u2192 ZIP via `file-foundation`, Magic-Link download) as first-class HTTP handlers and cron jobs. Each domain feature opts in by calling `r.useExtension(EXT_USER_DATA, "<entity>", { export, delete })` \u2014 the feature then orchestrates the export and forget pipelines across all registered hooks automatically. When `mail-foundation` and a `mail-transport-*` are mounted, it also sends the four GDPR notifications (export ready/failed, deletion requested/executed) itself with no app callback code, rendered in each recipient’s locale. Requires `user`, `data-retention`, `compliance-profiles`, and `sessions`.');
|
|
55
56
|
r.uiHints({
|
|
56
57
|
displayLabel: "User Data Rights \u00b7 GDPR",
|
|
@@ -11,4 +11,4 @@ export type { UserExportBundle } from "./run-user-export.ts";
|
|
|
11
11
|
export { runUserExport } from "./run-user-export.ts";
|
|
12
12
|
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.ts";
|
|
13
13
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, type ExportJobStatus, exportJobEntity, exportJobsTable, } from "./schema/export-job.ts";
|
|
14
|
-
export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.ts";
|
|
14
|
+
export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.ts";
|
|
@@ -15,4 +15,4 @@ export { policyToStrategy, runForgetCleanup } from "./run-forget-cleanup.js";
|
|
|
15
15
|
export { runUserExport } from "./run-user-export.js";
|
|
16
16
|
export { downloadAttemptEntity, downloadAttemptsTable } from "./schema/download-attempt.js";
|
|
17
17
|
export { ACTIVE_JOB_CONSTRAINT, EXPORT_JOB_STATUS, exportJobEntity, exportJobsTable, } from "./schema/export-job.js";
|
|
18
|
-
export { TENANT_MODEL_CONFIG_KEY } from "./web/constants.js";
|
|
18
|
+
export { TENANT_MODEL_CONFIG_KEY, USER_DATA_RIGHTS_FEATURE, UserDataRightsHandlers, UserDataRightsQueries, } from "./web/constants.js";
|
|
@@ -19,12 +19,11 @@
|
|
|
19
19
|
// expiresAt + exportStorageCleanupGraceHours, so expired ZIPs do not stay in
|
|
20
20
|
// storage indefinitely.
|
|
21
21
|
import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
22
|
-
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
|
|
23
22
|
import { createEventStoreExecutor, createTenantDb, fetchOne, selectMany, } from "@cosmicdrift/kumiko-framework/db";
|
|
24
23
|
import { createSystemUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
25
24
|
import { createZipStream, tenantExportPrefix, } from "@cosmicdrift/kumiko-framework/files";
|
|
26
25
|
import { resolveProfileForTenant } from "../compliance-profiles/index.js";
|
|
27
|
-
import {
|
|
26
|
+
import { decryptStoredUserEmail } from "../shared/index.js";
|
|
28
27
|
import { userTable } from "../user/index.js";
|
|
29
28
|
import { selectExportJobsForStorageCleanup } from "./db/queries/export-jobs.js";
|
|
30
29
|
import { runUserExport } from "./run-user-export.js";
|
|
@@ -539,8 +538,8 @@ async function lookupUserContact(db, userId) {
|
|
|
539
538
|
const row = (await fetchOne(db, userTable, { id: userId }));
|
|
540
539
|
if (!row?.email)
|
|
541
540
|
return null;
|
|
542
|
-
const email = await
|
|
543
|
-
if (email ===
|
|
541
|
+
const email = await decryptStoredUserEmail(row.email, "user-data-rights:export-ready");
|
|
542
|
+
if (email === null)
|
|
544
543
|
return null;
|
|
545
544
|
return { email, locale: row.locale ?? null };
|
|
546
545
|
}
|
|
@@ -274,7 +274,8 @@ async function processUser(args) {
|
|
|
274
274
|
// is what makes this crash-safe: eraseKey-throw → tx rollback → user
|
|
275
275
|
// stays DeletionRequested → next run retries; flip-throw after a
|
|
276
276
|
// successful erase → retry re-runs the idempotent hooks and eraseKey
|
|
277
|
-
// (
|
|
277
|
+
// (idempotent: a repeat erase keeps the original tombstone, an unknown
|
|
278
|
+
// subject gets one). Erasing after
|
|
278
279
|
// the flip instead would risk a permanent silent miss: user already
|
|
279
280
|
// Deleted, no run ever picks him up again, key stays live.
|
|
280
281
|
if (kms) {
|
|
@@ -6,13 +6,19 @@ export declare const UserDataRightsQueries: {
|
|
|
6
6
|
readonly exportStatus: "user-data-rights:query:export-status";
|
|
7
7
|
readonly myAuditLog: "user-data-rights:query:my-audit-log";
|
|
8
8
|
readonly downloadByJob: "user-data-rights:query:download-by-job";
|
|
9
|
+
readonly downloadByToken: "user-data-rights:query:download-by-token";
|
|
10
|
+
readonly listDownloadAttempts: "user-data-rights:query:list-download-attempts";
|
|
9
11
|
};
|
|
10
12
|
export declare const TENANT_MODEL_CONFIG_KEY: "user-data-rights:config:tenant-model";
|
|
11
13
|
export declare const UserDataRightsHandlers: {
|
|
12
14
|
readonly requestExport: "user-data-rights:write:request-export";
|
|
13
15
|
readonly requestDeletion: "user-data-rights:write:request-deletion";
|
|
16
|
+
readonly requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email";
|
|
17
|
+
readonly confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token";
|
|
14
18
|
readonly cancelDeletion: "user-data-rights:write:cancel-deletion";
|
|
15
19
|
readonly restrictAccount: "user-data-rights:write:restrict-account";
|
|
20
|
+
readonly liftRestriction: "user-data-rights:write:lift-restriction";
|
|
21
|
+
readonly runForgetCleanup: "user-data-rights:write:run-forget-cleanup";
|
|
16
22
|
};
|
|
17
23
|
export declare const USER_ME_QUERY: "user:query:user:me";
|
|
18
24
|
export declare const EXPORT_JOB_STATUS: {
|
|
@@ -21,6 +21,8 @@ export const UserDataRightsQueries = {
|
|
|
21
21
|
exportStatus: "user-data-rights:query:export-status",
|
|
22
22
|
myAuditLog: "user-data-rights:query:my-audit-log",
|
|
23
23
|
downloadByJob: "user-data-rights:query:download-by-job",
|
|
24
|
+
downloadByToken: "user-data-rights:query:download-by-token",
|
|
25
|
+
listDownloadAttempts: "user-data-rights:query:list-download-attempts",
|
|
24
26
|
};
|
|
25
27
|
// App-level config: tenant-occupancy model. "single-user" tells the forget
|
|
26
28
|
// pipeline that each tenant has exactly one user, so tenant-scoped contributors
|
|
@@ -31,8 +33,12 @@ export const TENANT_MODEL_CONFIG_KEY = "user-data-rights:config:tenant-model";
|
|
|
31
33
|
export const UserDataRightsHandlers = {
|
|
32
34
|
requestExport: "user-data-rights:write:request-export",
|
|
33
35
|
requestDeletion: "user-data-rights:write:request-deletion",
|
|
36
|
+
requestDeletionByEmail: "user-data-rights:write:request-deletion-by-email",
|
|
37
|
+
confirmDeletionByToken: "user-data-rights:write:confirm-deletion-by-token",
|
|
34
38
|
cancelDeletion: "user-data-rights:write:cancel-deletion",
|
|
35
39
|
restrictAccount: "user-data-rights:write:restrict-account",
|
|
40
|
+
liftRestriction: "user-data-rights:write:lift-restriction",
|
|
41
|
+
runForgetCleanup: "user-data-rights:write:run-forget-cleanup",
|
|
36
42
|
};
|
|
37
43
|
// Foreign QN: the lifecycle status (active / deletionRequested / restricted)
|
|
38
44
|
// comes from the user feature. Pinned locally instead of importing the user
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { type UserDataRightsClientOptions, userDataRightsClient } from "./client-plugin.tsx";
|
|
2
2
|
export type { ConfirmAccountDeletionScreenProps } from "./confirm-deletion-screen.tsx";
|
|
3
3
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.tsx";
|
|
4
|
+
export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.ts";
|
|
4
5
|
export { defaultTranslations } from "./default-translations.ts";
|
|
5
6
|
export { formatDate } from "./privacy-center-screen.tsx";
|
|
6
7
|
export { makePublicDeletionGate, type PublicDeletionRoutes } from "./public-deletion-gate.tsx";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// no React/DOM dependencies.
|
|
6
6
|
export { userDataRightsClient } from "./client-plugin.js";
|
|
7
7
|
export { ConfirmAccountDeletionScreen } from "./confirm-deletion-screen.js";
|
|
8
|
+
export { UserDataRightsHandlers, UserDataRightsQueries } from "./constants.js";
|
|
8
9
|
export { defaultTranslations } from "./default-translations.js";
|
|
9
10
|
export { formatDate } from "./privacy-center-screen.js";
|
|
10
11
|
export { makePublicDeletionGate } from "./public-deletion-gate.js";
|
package/dist/waitlist/feature.js
CHANGED
|
@@ -15,6 +15,7 @@ import { waitlistEntryDeleteHook, waitlistEntryExportHook } from "./user-data-ho
|
|
|
15
15
|
import { WAITLIST_EXPORTED_EVENT_SHORT, WAITLIST_FEATURE, WAITLIST_PURGED_EVENT_SHORT, } from "./web/constants.js";
|
|
16
16
|
export function createWaitlistFeature(opts = {}) {
|
|
17
17
|
return defineFeature(WAITLIST_FEATURE, (r) => {
|
|
18
|
+
r.adoptsBundledLists();
|
|
18
19
|
r.describe("Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.");
|
|
19
20
|
r.uiHints({ displayLabel: "Waitlist", category: "data", recommended: false });
|
|
20
21
|
r.systemScope();
|
|
@@ -54,8 +54,9 @@ export const purgeHandler = defineWriteHandler({
|
|
|
54
54
|
details: { confirmCount: event.payload.confirmCount, actualCount: rows.length },
|
|
55
55
|
}));
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
// Audit first: purgeWaitlistRows shreds KMS keys, which a later failure cannot roll back.
|
|
58
58
|
await appendAudit(ctx, WAITLIST_PURGED_EVENT_QN, rows.length);
|
|
59
|
+
await purgeWaitlistRows(db, rows, event.user.id);
|
|
59
60
|
return { isSuccess: true, data: { purged: rows.length } };
|
|
60
61
|
},
|
|
61
62
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
1
|
+
import { acquireNamespacedAdvisoryLock, createSystemDbView, createTenantDb, runInSavepointIfSupported, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -10,7 +10,7 @@ import { WAITLIST_NOTIFICATION_TYPES, WAITLIST_STATUS } from "../web/constants.j
|
|
|
10
10
|
// pg_advisory_xact_lock namespace (int4): 'wlst' as ASCII, disjoint from the
|
|
11
11
|
// framework's other fixed advisory-lock keys.
|
|
12
12
|
const SUBMIT_LOCK_NAMESPACE = 0x776c7374;
|
|
13
|
-
const SUBMIT_LOCK_REASON = "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check; TenantDb has
|
|
13
|
+
const SUBMIT_LOCK_REASON = "takes the per-email advisory lock so parallel submits cannot both pass the duplicate check and wraps the admin-recipient resolver in a savepoint so a failing statement cannot abort the submit transaction; TenantDb has neither API";
|
|
14
14
|
// Logged-in callers carry no anonymous role, so every signed-in role must be listed.
|
|
15
15
|
const SUBMIT_ROLES = access.roles(...access.anonymous, ...access.authenticated, "Member", "TenantAdmin");
|
|
16
16
|
const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
|
|
@@ -40,12 +40,17 @@ async function hasOpenEntryForEmail(email, ctx) {
|
|
|
40
40
|
}, platformActor(), waitlistDb(ctx));
|
|
41
41
|
return found.rows.length > 0;
|
|
42
42
|
}
|
|
43
|
-
// The resolver usually reads config
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
43
|
+
// The resolver usually reads config. It runs before the per-email advisory lock so a slow
|
|
44
|
+
// resolver does not extend the lock. Postgres aborts the whole transaction after a failed
|
|
45
|
+
// statement, even one whose JS error is caught here, so the resolver gets a ctx whose
|
|
46
|
+
// systemDb is bound to its own savepoint: a failing statement rolls back only that.
|
|
47
|
+
// Statements through any other ctx handle (ctx.query, ctx.write) stay in the handler transaction.
|
|
48
|
+
async function resolveAdminRecipient(opts, ctx, systemDb, tenantId) {
|
|
47
49
|
try {
|
|
48
|
-
return
|
|
50
|
+
return await runInSavepointIfSupported(systemDb.unsafeRaw(SUBMIT_LOCK_REASON), async (sp) => {
|
|
51
|
+
const savepointSystemDb = createSystemDbView(createTenantDb({ db: sp, tenantId, mode: "system" }));
|
|
52
|
+
return (await opts.notifyRecipient?.({ ...ctx, systemDb: savepointSystemDb })) || null;
|
|
53
|
+
});
|
|
49
54
|
}
|
|
50
55
|
catch (err) {
|
|
51
56
|
ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
|
|
@@ -88,7 +93,7 @@ export function createSubmitHandler(opts) {
|
|
|
88
93
|
message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
|
|
89
94
|
});
|
|
90
95
|
}
|
|
91
|
-
const adminRecipient = await resolveAdminRecipient(opts, ctx);
|
|
96
|
+
const adminRecipient = await resolveAdminRecipient(opts, ctx, ctx.systemDb, event.user.tenantId);
|
|
92
97
|
await acquireNamespacedAdvisoryLock(ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON), SUBMIT_LOCK_NAMESPACE, email);
|
|
93
98
|
if (await hasOpenEntryForEmail(email, ctx))
|
|
94
99
|
return SUBMITTED;
|
|
@@ -124,8 +129,7 @@ export function createSubmitHandler(opts) {
|
|
|
124
129
|
message: payload.message,
|
|
125
130
|
}),
|
|
126
131
|
priority: "normal",
|
|
127
|
-
|
|
128
|
-
locale: resolveWaitlistMailLocale(ctx.locale),
|
|
132
|
+
locale: resolveWaitlistMailLocale(opts.adminLocale),
|
|
129
133
|
});
|
|
130
134
|
});
|
|
131
135
|
return SUBMITTED;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
3
|
-
import type
|
|
3
|
+
import { type WaitlistStatus } from "./web/constants.ts";
|
|
4
4
|
export type WaitlistImportEntry = {
|
|
5
5
|
/** Stable identity of the source row (e.g. `offlot:<uuid>`); the new entry id derives from it. */
|
|
6
6
|
readonly sourceKey: string;
|
|
@@ -19,6 +19,8 @@ export type WaitlistImportEntry = {
|
|
|
19
19
|
};
|
|
20
20
|
export type WaitlistImportOutcome = "created" | "skipped";
|
|
21
21
|
export declare function waitlistImportId(sourceKey: string): string;
|
|
22
|
+
export declare function isWaitlistImportId(id: string): boolean;
|
|
23
|
+
export declare function waitlistImportEntryWithinLimits(entry: WaitlistImportEntry): boolean;
|
|
22
24
|
export declare function importWaitlistEntry(db: TenantDb, entry: WaitlistImportEntry, options?: {
|
|
23
25
|
readonly dryRun?: boolean;
|
|
24
26
|
}): Promise<WaitlistImportOutcome>;
|
package/dist/waitlist/import.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import { normalizeEmail, platformActor, waitlistExecutor } from "./lib.js";
|
|
4
|
+
import { WAITLIST_FIELD_LIMITS } from "./web/constants.js";
|
|
4
5
|
// RFC 4122 name-based UUID (v5 layout, fixed namespace) so a re-run finds the entry
|
|
5
6
|
// it created before without matching on encrypted personal data.
|
|
6
7
|
const IMPORT_NAMESPACE = "kumiko-waitlist-import";
|
|
@@ -13,13 +14,36 @@ export function waitlistImportId(sourceKey) {
|
|
|
13
14
|
const hex = bytes.subarray(0, 16).toString("hex");
|
|
14
15
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
15
16
|
}
|
|
17
|
+
// The version nibble marks ids minted by waitlistImportId; legacy row ids are random UUIDs.
|
|
18
|
+
export function isWaitlistImportId(id) {
|
|
19
|
+
return id[14] === "5";
|
|
20
|
+
}
|
|
21
|
+
// The live submit schema caps these fields; an imported value above the cap would be
|
|
22
|
+
// stored but could never pass a later update through the same payload schema.
|
|
23
|
+
export function waitlistImportEntryWithinLimits(entry) {
|
|
24
|
+
const limited = [
|
|
25
|
+
[entry.name, WAITLIST_FIELD_LIMITS.name],
|
|
26
|
+
[entry.email, WAITLIST_FIELD_LIMITS.email],
|
|
27
|
+
[entry.locale, WAITLIST_FIELD_LIMITS.locale],
|
|
28
|
+
[entry.company, WAITLIST_FIELD_LIMITS.company],
|
|
29
|
+
[entry.portfolio, WAITLIST_FIELD_LIMITS.portfolio],
|
|
30
|
+
[entry.message, WAITLIST_FIELD_LIMITS.message],
|
|
31
|
+
];
|
|
32
|
+
return limited.every(([value, max]) => value === undefined || value.length <= max);
|
|
33
|
+
}
|
|
16
34
|
function presentOnly(fields) {
|
|
17
35
|
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
18
36
|
}
|
|
19
|
-
// Writes through the entity executor, so events
|
|
20
|
-
//
|
|
37
|
+
// Writes through the entity executor, so events and projection match a live submit; subject
|
|
38
|
+
// encryption applies only when the caller configured a PII KMS (the CLI wires it from env
|
|
39
|
+
// and aborts without one). No mail, no rate limit, no duplicate-by-email
|
|
21
40
|
// check: the source row is the truth and the id makes the call idempotent.
|
|
22
41
|
export async function importWaitlistEntry(db, entry, options = {}) {
|
|
42
|
+
if (!waitlistImportEntryWithinLimits(entry)) {
|
|
43
|
+
throw new InternalError({
|
|
44
|
+
message: `waitlist import entry ${entry.sourceKey} exceeds a field length limit`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
23
47
|
const id = waitlistImportId(entry.sourceKey);
|
|
24
48
|
const existing = await waitlistExecutor.detail({ id }, platformActor(), db);
|
|
25
49
|
if (existing)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
2
|
-
import { countWhere, createTenantDb, selectMany, table, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { countWhere, createTenantDb, selectMany, table, tableExists, text as textColumn, timestamp, transaction, uuid, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { archiveStream, createArchivedStreamsTable, eventsTable, isStreamArchived, } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
-
import { importWaitlistEntry, waitlistImportId } from "./import.js";
|
|
6
|
+
import { importWaitlistEntry, isWaitlistImportId, waitlistImportEntryWithinLimits, waitlistImportId, } from "./import.js";
|
|
7
7
|
import { WAITLIST_STATUS } from "./web/constants.js";
|
|
8
|
+
const ARCHIVED_STREAMS_TABLE = "public.kumiko_archived_streams";
|
|
8
9
|
export const LEGACY_WAITLIST_SOURCES = ["offlot", "publicstatus"];
|
|
9
10
|
export function isLegacyWaitlistSource(value) {
|
|
10
11
|
return LEGACY_WAITLIST_SOURCES.some((known) => known === value);
|
|
@@ -152,10 +153,12 @@ async function readLegacyRows(db, from, sourceTable) {
|
|
|
152
153
|
// new id, so a rebuild would replay the old stream next to it and bring every migrated entry back
|
|
153
154
|
// twice; archived streams are excluded from replay. The offlot default source table is the entity
|
|
154
155
|
// table itself, so its row ids are live entries by design and no id check can tell a wrong table.
|
|
155
|
-
async function archiveLegacyStream(db, legacyId, importedId) {
|
|
156
|
+
async function archiveLegacyStream(db, legacyId, importedId, dryRun) {
|
|
156
157
|
if (legacyId === importedId)
|
|
157
158
|
return 0;
|
|
158
|
-
|
|
159
|
+
// A dry run never creates the archive table, so a missing one means nothing is archived yet.
|
|
160
|
+
const archiveTableReady = !dryRun || (await tableExists(db, ARCHIVED_STREAMS_TABLE));
|
|
161
|
+
if (archiveTableReady && (await isStreamArchived(db, SYSTEM_TENANT_ID, legacyId)))
|
|
159
162
|
return 0;
|
|
160
163
|
const legacyEvents = await countWhere(db, eventsTable, {
|
|
161
164
|
tenantId: SYSTEM_TENANT_ID,
|
|
@@ -164,6 +167,8 @@ async function archiveLegacyStream(db, legacyId, importedId) {
|
|
|
164
167
|
});
|
|
165
168
|
if (legacyEvents === 0)
|
|
166
169
|
return 0;
|
|
170
|
+
if (dryRun)
|
|
171
|
+
return 1;
|
|
167
172
|
await archiveStream(db, {
|
|
168
173
|
tenantId: SYSTEM_TENANT_ID,
|
|
169
174
|
aggregateId: legacyId,
|
|
@@ -185,10 +190,16 @@ export async function migrateLegacyWaitlist(db, options) {
|
|
|
185
190
|
if (!options.dryRun && options.from === "offlot")
|
|
186
191
|
await createArchivedStreamsTable(db);
|
|
187
192
|
for (const row of rows) {
|
|
193
|
+
// The offlot default source is the entity table: rows a previous run imported
|
|
194
|
+
// are already migrated entries and must neither be re-imported nor archived.
|
|
195
|
+
if (options.from === "offlot" && isWaitlistImportId(String(row["id"]))) {
|
|
196
|
+
skipped += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
188
199
|
const entry = options.from === "offlot"
|
|
189
200
|
? await mapOfflotRow(row)
|
|
190
201
|
: mapPublicstatusRow(row, options.defaultLocale);
|
|
191
|
-
if (!entry) {
|
|
202
|
+
if (!entry || !waitlistImportEntryWithinLimits(entry)) {
|
|
192
203
|
unreadable += 1;
|
|
193
204
|
continue;
|
|
194
205
|
}
|
|
@@ -200,8 +211,8 @@ export async function migrateLegacyWaitlist(db, options) {
|
|
|
200
211
|
else
|
|
201
212
|
skipped += 1;
|
|
202
213
|
});
|
|
203
|
-
if (
|
|
204
|
-
archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey));
|
|
214
|
+
if (options.from === "offlot") {
|
|
215
|
+
archivedStreams += await archiveLegacyStream(db, String(row["id"]), waitlistImportId(entry.sourceKey), options.dryRun);
|
|
205
216
|
}
|
|
206
217
|
}
|
|
207
218
|
return { read: rows.length, created, skipped, unreadable, archivedStreams };
|
|
@@ -59,11 +59,14 @@ export const linkWaitlistOnSignup = async (event, tx) => {
|
|
|
59
59
|
if (typeof user?.email !== "string")
|
|
60
60
|
return;
|
|
61
61
|
const email = normalizeEmail(await decryptStoredPii(user.email, "email", PII_DECRYPT_REASON));
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
const findInvited = async () => {
|
|
63
|
+
const found = await waitlistExecutor.list({
|
|
64
|
+
filter: { field: "email", op: "eq", value: email },
|
|
65
|
+
filters: [{ field: "status", op: "in", value: [WAITLIST_STATUS.Invited] }],
|
|
66
|
+
}, platformActor(), db);
|
|
67
|
+
return [...found.rows].sort(newestSubmittedFirst)[0];
|
|
68
|
+
};
|
|
69
|
+
const invited = await findInvited();
|
|
67
70
|
// skip: the signup email has no invited waitlist entry (open signup or stale invite).
|
|
68
71
|
if (!invited)
|
|
69
72
|
return;
|
|
@@ -71,7 +74,7 @@ export const linkWaitlistOnSignup = async (event, tx) => {
|
|
|
71
74
|
// skip: defensive guard on the row shape.
|
|
72
75
|
if (typeof id !== "string" || typeof version !== "number")
|
|
73
76
|
return;
|
|
74
|
-
await waitlistExecutor.update({
|
|
77
|
+
const updated = await waitlistExecutor.update({
|
|
75
78
|
id,
|
|
76
79
|
version,
|
|
77
80
|
changes: {
|
|
@@ -80,4 +83,13 @@ export const linkWaitlistOnSignup = async (event, tx) => {
|
|
|
80
83
|
provisionedAt: Temporal.Now.instant(),
|
|
81
84
|
},
|
|
82
85
|
}, platformActor(), db);
|
|
86
|
+
// skip: linked.
|
|
87
|
+
if (updated.isSuccess)
|
|
88
|
+
return;
|
|
89
|
+
// A concurrent admin change that already moved the entry out of `invited` makes the link
|
|
90
|
+
// moot; otherwise throw so the at-least-once consumer redelivers.
|
|
91
|
+
// skip: the entry left `invited` concurrently.
|
|
92
|
+
if ((await findInvited())?.id !== id)
|
|
93
|
+
return;
|
|
94
|
+
throw new Error(`waitlist:link-on-signup: could not link waitlist entry ${id}`);
|
|
83
95
|
};
|