@cosmicdrift/kumiko-bundled-features 0.334.0 → 0.335.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/admin-shell/overview-panels.js +2 -2
- package/dist/agent-tools/agent-doc-lint.d.ts +1 -0
- package/dist/agent-tools/agent-doc-lint.js +33 -3
- package/dist/agent-tools/agent-manifest.js +47 -6
- package/dist/agent-tools/filter-ops.d.ts +2 -0
- package/dist/agent-tools/filter-ops.js +4 -0
- package/dist/agent-tools/tool-catalog.js +1 -4
- package/dist/agent-tools/tool-dispatch.js +1 -4
- package/dist/audit/constants.d.ts +1 -1
- package/dist/audit/constants.js +1 -1
- package/dist/auth-email-password/handlers/change-password.write.js +2 -0
- package/dist/auth-email-password/handlers/invite-create.write.js +12 -2
- package/dist/auth-email-password/handlers/login.write.js +2 -0
- package/dist/auth-email-password/handlers/self-registration-status.query.js +1 -1
- package/dist/auth-email-password/i18n.js +2 -0
- package/dist/auth-email-password/web/auth-client.d.ts +1 -0
- package/dist/auth-email-password/web/auth-client.js +1 -1
- package/dist/auth-email-password/web/auth-gate.js +6 -8
- package/dist/auth-email-password/web/index.d.ts +1 -1
- package/dist/auth-email-password/web/index.js +1 -1
- package/dist/auth-email-password/web/invite-accept-screen.js +16 -12
- package/dist/auth-email-password/web/login-screen.js +1 -1
- package/dist/auth-email-password/web/session-bootstrap-error.d.ts +5 -1
- package/dist/auth-email-password/web/session-bootstrap-error.js +2 -2
- package/dist/auth-email-password/web/session.d.ts +4 -0
- package/dist/auth-email-password/web/session.js +15 -0
- package/dist/auth-mfa/feature.js +3 -2
- package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +2 -2
- package/dist/auth-mfa/handlers/verify.write.js +2 -0
- package/dist/auth-mfa/hmac-token-codec.js +3 -0
- package/dist/auth-mfa/mfa-challenge-token.js +4 -0
- package/dist/auth-mfa/mfa-preauth-setup-token.js +3 -0
- package/dist/auth-mfa/web/mfa-client.d.ts +1 -1
- package/dist/auth-mfa/web/mfa-client.js +4 -10
- package/dist/billing-foundation/billing-info-query.js +1 -1
- package/dist/billing-foundation/checkout-core.d.ts +5 -5
- package/dist/billing-foundation/checkout-core.js +10 -6
- package/dist/billing-foundation/handlers/switch-plan.write.js +5 -8
- package/dist/cap-counter/book-cap-usage.js +15 -5
- package/dist/cap-counter/enforce-cap.js +9 -3
- package/dist/cap-counter/feature.js +3 -2
- package/dist/cap-counter/i18n.d.ts +1 -0
- package/dist/cap-counter/i18n.js +6 -5
- package/dist/cap-overview/handlers/caps-usage.query.js +9 -16
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +41 -15
- package/dist/cap-overview/i18n.js +5 -0
- package/dist/cap-overview/web/cap-cards-panel.js +4 -1
- package/dist/cap-overview/web/cap-usage-bar.js +1 -1
- package/dist/channel-email/email-channel.js +12 -1
- package/dist/channel-in-app/tables.js +2 -6
- package/dist/compliance-profiles/handlers/sub-processors.query.js +1 -1
- package/dist/config/db/queries/resolver.js +3 -1
- package/dist/config/handlers/set.write.js +8 -4
- package/dist/config/web/i18n.js +1 -0
- package/dist/config/write-helpers.d.ts +1 -1
- package/dist/config/write-helpers.js +11 -5
- package/dist/crypto-shredding/handlers/forget-subject.write.js +15 -8
- package/dist/custom-fields/constants.js +1 -4
- package/dist/data-retention/resolve-for-tenant.d.ts +2 -3
- package/dist/data-retention/run-retention-cleanup.js +100 -44
- package/dist/delivery/types.d.ts +2 -1
- package/dist/delivery/types.js +7 -0
- package/dist/delivery/unsubscribe.js +5 -11
- package/dist/derivatives-sharp/render.js +27 -9
- package/dist/document-ingest-foundation/feature.js +5 -5
- package/dist/document-ingest-foundation/providers.d.ts +1 -0
- package/dist/document-ingest-foundation/providers.js +6 -0
- package/dist/document-ingest-foundation/write-document-extract.d.ts +2 -2
- package/dist/document-ingest-foundation/write-document-extract.js +10 -0
- package/dist/feature-toggles/global-feature-state-table.js +2 -7
- package/dist/feature-toggles/toggle-sync-signal.d.ts +2 -1
- package/dist/feature-toggles/toggle-sync-signal.js +3 -2
- package/dist/files-tenant-data/index.js +3 -2
- package/dist/folders/handlers/clear-folder.write.js +4 -11
- package/dist/folders/handlers/set-folder.write.js +4 -10
- package/dist/form-draft/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +7 -8
- package/dist/form-draft/handlers/get.query.js +7 -1
- package/dist/foundation-shared/host-allowlist.d.ts +4 -0
- package/dist/foundation-shared/host-allowlist.js +14 -0
- package/dist/foundation-shared/index.d.ts +1 -0
- package/dist/foundation-shared/index.js +1 -0
- package/dist/foundation-shared/mail-host-policy.d.ts +0 -2
- package/dist/foundation-shared/mail-host-policy.js +3 -13
- package/dist/inbound-mail-foundation/connect-routes.js +7 -5
- package/dist/inbound-mail-foundation/watch-supervisor.js +47 -43
- package/dist/jobs/handlers/list.query.d.ts +7 -2
- package/dist/jobs/handlers/list.query.js +9 -2
- package/dist/jobs/handlers/retry.write.js +3 -2
- package/dist/jobs/handlers/tenant-failures.query.js +3 -9
- package/dist/jobs/job-run-logger.js +18 -2
- package/dist/jobs/tenant-job-failure-table.js +2 -6
- package/dist/ledger/handlers/confirm-schedule-period.write.js +2 -2
- package/dist/ledger/handlers/reverse-transaction.write.js +2 -2
- package/dist/ledger/subject-reference.d.ts +4 -0
- package/dist/ledger/subject-reference.js +7 -0
- package/dist/legal-pages/feature.js +8 -1
- package/dist/managed-pages/handlers/branding.query.js +1 -1
- package/dist/managed-pages/handlers/by-slug.query.js +1 -1
- package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -1
- package/dist/metrics/compute.js +7 -1
- package/dist/metrics/window.js +5 -0
- package/dist/notes-history/handlers/add-note.write.js +4 -9
- package/dist/notes-history-user-data/hooks.js +8 -1
- package/dist/personal-access-tokens/constants.d.ts +1 -0
- package/dist/personal-access-tokens/constants.js +1 -0
- package/dist/personal-access-tokens/feature.js +2 -2
- package/dist/personal-access-tokens/handlers/create.write.js +2 -2
- package/dist/personal-access-tokens/handlers/revoke.write.js +2 -2
- package/dist/personal-access-tokens/i18n.js +12 -13
- package/dist/personal-access-tokens/schema/api-token.js +2 -1
- package/dist/personal-access-tokens/screens.js +5 -1
- package/dist/rate-limiting/feature.js +2 -0
- package/dist/rate-limiting/handlers/bucket-access.js +15 -7
- package/dist/rate-limiting/i18n.d.ts +10 -0
- package/dist/rate-limiting/i18n.js +10 -0
- package/dist/secrets/feature.js +3 -2
- package/dist/seo/handlers/seo-config.query.js +1 -1
- package/dist/sessions/constants.d.ts +1 -0
- package/dist/sessions/constants.js +1 -0
- package/dist/sessions/feature.js +5 -3
- package/dist/sessions/handlers/revoke-all-for-user.write.d.ts +1 -0
- package/dist/sessions/handlers/revoke-all-for-user.write.js +12 -2
- package/dist/sessions/handlers/revoke.write.js +2 -2
- package/dist/sessions/schema/user-session.js +2 -1
- package/dist/sessions/screens.js +5 -1
- package/dist/sessions/session-callbacks.js +2 -2
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/lockout-counter.js +1 -1
- package/dist/shared/parent-visibility.d.ts +5 -0
- package/dist/shared/parent-visibility.js +17 -1
- package/dist/shared/row-bound-grant.js +20 -9
- package/dist/shared/single-use-token-store.js +5 -13
- package/dist/step-dispatcher/webhook-runner.d.ts +0 -2
- package/dist/step-dispatcher/webhook-runner.js +18 -17
- package/dist/subscription-mollie/plugin-methods.d.ts +3 -2
- package/dist/subscription-mollie/plugin-methods.js +7 -1
- package/dist/subscription-mollie/verify-webhook.js +6 -0
- package/dist/subscription-stripe/plugin-methods.js +25 -6
- package/dist/subscription-stripe/verify-webhook.js +13 -6
- package/dist/tags/handlers/assign-tag.write.js +4 -10
- package/dist/tags/handlers/remove-tag.write.js +4 -11
- package/dist/template-resolver/api.d.ts +2 -1
- package/dist/template-resolver/api.js +1 -1
- package/dist/template-resolver/handlers/by-slug.query.d.ts +1 -1
- package/dist/template-resolver/handlers/by-slug.query.js +2 -2
- package/dist/template-resolver/handlers/by-tenant.query.d.ts +3 -2
- package/dist/template-resolver/handlers/by-tenant.query.js +2 -2
- package/dist/template-resolver/handlers/collection-item.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/collection-shared.d.ts +3 -2
- package/dist/template-resolver/handlers/collection-shared.js +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.d.ts +1 -1
- package/dist/template-resolver/handlers/find-by-id.query.js +1 -1
- package/dist/template-resolver/handlers/list.query.d.ts +1 -1
- package/dist/template-resolver/handlers/list.query.js +1 -1
- package/dist/template-resolver/seeding.js +41 -39
- package/dist/template-resolver/table.d.ts +8 -5
- package/dist/template-resolver/testing.js +2 -2
- package/dist/template-resolver/user-content-table.d.ts +2 -5
- package/dist/template-resolver/web/client-plugin.d.ts +1 -1
- package/dist/template-resolver-user-data/hooks.js +1 -1
- package/dist/tenant/cancel-pending-invitation.d.ts +8 -0
- package/dist/tenant/cancel-pending-invitation.js +22 -0
- package/dist/tenant/feature.js +4 -2
- package/dist/tenant/handlers/cancel-invitation.write.js +6 -21
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +17 -4
- package/dist/tenant/handlers/remove-member.write.js +9 -14
- package/dist/tenant/handlers/team-list.query.js +2 -2
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +13 -3
- package/dist/tenant/handlers/update-member-roles.write.d.ts +1 -1
- package/dist/tenant/handlers/update-member-roles.write.js +28 -12
- package/dist/tenant/i18n.js +4 -0
- package/dist/tenant/index.d.ts +1 -1
- package/dist/tenant/index.js +1 -1
- package/dist/tenant/last-tenant-admin.d.ts +1 -1
- package/dist/tenant/last-tenant-admin.js +5 -3
- package/dist/tenant/seeding.d.ts +4 -0
- package/dist/tenant/seeding.js +9 -2
- package/dist/tenant-lifecycle/boot-checks.d.ts +1 -0
- package/dist/tenant-lifecycle/boot-checks.js +27 -1
- package/dist/tenant-lifecycle/feature.js +2 -1
- package/dist/tenant-lifecycle/stages.js +2 -2
- package/dist/tier-engine/feature.js +3 -2
- package/dist/user/command-schemas.d.ts +2 -2
- package/dist/user/handlers/create.write.d.ts +1 -1
- package/dist/user/handlers/create.write.js +8 -2
- package/dist/user/handlers/update.write.d.ts +1 -1
- package/dist/user/handlers/update.write.js +8 -2
- package/dist/user-data-rights/db/queries/export-jobs.d.ts +2 -0
- package/dist/user-data-rights/feature.js +22 -9
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +4 -7
- package/dist/user-data-rights/handlers/deletion-grace-period.d.ts +3 -2
- package/dist/user-data-rights/handlers/deletion-grace-period.js +30 -8
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -6
- package/dist/user-data-rights/lib/update-user-lifecycle.js +7 -14
- package/dist/user-data-rights/run-export-jobs.d.ts +1 -0
- package/dist/user-data-rights/run-export-jobs.js +74 -41
- package/dist/user-data-rights/run-forget-cleanup.js +2 -2
- package/dist/user-data-rights/run-user-export.js +2 -2
- package/dist/user-data-rights-defaults/hooks/file-ref.userdata-hook.js +4 -1
- package/dist/user-profile/feature.js +6 -1
- package/dist/user-profile/handlers/change-email.write.js +2 -0
- package/dist/workflow-runner/db/queries/due-runs.js +6 -9
- package/dist/workflow-runner/event-subscriber.js +29 -9
- package/dist/workflow-runner/handlers/resume-run.write.js +8 -5
- package/dist/workflow-runner/pending-projection.js +8 -1
- package/package.json +11 -9
- package/src/agent-tools/changes.json +12 -0
- package/src/auth-email-password/changes.json +18 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +7 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +12 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +6 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/data-retention/changes.json +12 -0
- package/src/delivery/changes.json +7 -0
- package/src/derivatives-sharp/changes.json +11 -0
- package/src/document-ingest-foundation/changes.json +12 -0
- package/src/inbound-mail-foundation/changes.json +12 -0
- package/src/jobs/changes.json +13 -0
- package/src/legal-pages/changes.json +8 -1
- package/src/managed-pages/changes.json +5 -0
- package/src/metrics/changes.json +6 -0
- package/src/rate-limiting/changes.json +5 -0
- package/src/secrets/changes.json +7 -0
- package/src/sessions/changes.json +5 -0
- package/src/shared/changes.json +6 -0
- package/src/step-dispatcher/changes.json +5 -0
- package/src/subscription-mollie/changes.json +7 -0
- package/src/subscription-stripe/changes.json +11 -0
- package/src/template-resolver/changes.json +7 -0
- package/src/tenant/changes.json +29 -0
- package/src/user-data-rights/changes.json +22 -0
- package/src/workflow-runner/changes.json +6 -0
|
@@ -8,14 +8,16 @@ import { tenantMembershipsTable } from "./membership-table.js";
|
|
|
8
8
|
// (dispatcher batch wraps handlers in transaction — xact lock holds through update).
|
|
9
9
|
export const LAST_TENANT_ADMIN_LOCK_NAMESPACE = 0x7461646d; // 'tadm'
|
|
10
10
|
/** Refuse demoting/removing the last TenantAdmin for `tenantId`. */
|
|
11
|
-
export async function assertNotLastTenantAdmin(db, tenantId, userId) {
|
|
11
|
+
export async function assertNotLastTenantAdmin(db, tenantId, userId, action = "demote") {
|
|
12
12
|
await acquireNamespacedAdvisoryLock(db, LAST_TENANT_ADMIN_LOCK_NAMESPACE, tenantId);
|
|
13
13
|
const allMemberships = await selectMany(db, tenantMembershipsTable, { tenantId });
|
|
14
14
|
const adminCount = allMemberships.filter((m) => parseRoles(m["roles"]).includes("TenantAdmin")).length;
|
|
15
15
|
if (adminCount <= 1) {
|
|
16
16
|
return writeFailure(new ConflictError({
|
|
17
|
-
message:
|
|
18
|
-
i18nKey: "
|
|
17
|
+
message: `cannot ${action} the last tenant admin`,
|
|
18
|
+
i18nKey: action === "remove"
|
|
19
|
+
? "tenant.errors.cannotRemoveLastTenantAdmin"
|
|
20
|
+
: "tenant.errors.cannotDemoteLastTenantAdmin",
|
|
19
21
|
details: {
|
|
20
22
|
reason: TenantErrors.lastTenantAdmin,
|
|
21
23
|
userId,
|
package/dist/tenant/seeding.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ export declare function fireEntityPostSave(hooks: SeedTenantHooks | undefined, p
|
|
|
38
38
|
* a second call for the same `id` is a no-op (no update path). Same
|
|
39
39
|
* TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
|
|
40
40
|
* access-check and minus ConflictError-on-duplicate.
|
|
41
|
+
*
|
|
42
|
+
* `hooks` run only for the call that actually creates the tenant: a call that
|
|
43
|
+
* finds the tenant/stream already present (or loses a concurrent create race)
|
|
44
|
+
* returns without firing postSave.
|
|
41
45
|
*/
|
|
42
46
|
export declare function seedTenant(db: DbRunner, options: SeedTenantOptions, hooks?: SeedTenantHooks): Promise<{
|
|
43
47
|
id: TenantId;
|
package/dist/tenant/seeding.js
CHANGED
|
@@ -124,6 +124,10 @@ target) {
|
|
|
124
124
|
* a second call for the same `id` is a no-op (no update path). Same
|
|
125
125
|
* TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
|
|
126
126
|
* access-check and minus ConflictError-on-duplicate.
|
|
127
|
+
*
|
|
128
|
+
* `hooks` run only for the call that actually creates the tenant: a call that
|
|
129
|
+
* finds the tenant/stream already present (or loses a concurrent create race)
|
|
130
|
+
* returns without firing postSave.
|
|
127
131
|
*/
|
|
128
132
|
export async function seedTenant(db, options, hooks) {
|
|
129
133
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
@@ -146,8 +150,11 @@ export async function seedTenant(db, options, hooks) {
|
|
|
146
150
|
const result = await tenantExecutor.create({ id: options.id, key: options.key, name: options.name }, by, tdb);
|
|
147
151
|
if (!result.isSuccess) {
|
|
148
152
|
// Same idempotency case as the stream-version check above, only detected
|
|
149
|
-
// one instruction later: a
|
|
150
|
-
//
|
|
153
|
+
// one instruction later: a concurrent writer (e.g. a parallel seedTenant
|
|
154
|
+
// for the same id) created the stream between the version read above and
|
|
155
|
+
// this create; the executor confines the failed append to a savepoint, so
|
|
156
|
+
// the surrounding TX stays usable. Both paths return without firing
|
|
157
|
+
// postSave — hooks only run for the call that actually created the tenant.
|
|
151
158
|
if (result.error.code === "version_conflict")
|
|
152
159
|
return { id: options.id };
|
|
153
160
|
throw new Error(`seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`);
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
export declare function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void;
|
|
3
|
+
export declare function validateTenantDestroyHookShapes(features: readonly FeatureDefinition[]): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EXT_TENANT_DATA, } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { entitiesOf } from "../shared/index.js";
|
|
3
3
|
// V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
|
|
4
4
|
// (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
|
|
@@ -34,3 +34,29 @@ export function validateTenantDataHookCoverage(features) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
const TENANT_RESOURCE_EXTENSIONS = new Set([
|
|
38
|
+
EXT_STORAGE_PROVIDER,
|
|
39
|
+
EXT_SEARCH_ADAPTER,
|
|
40
|
+
EXT_EXTERNAL_RESOURCE,
|
|
41
|
+
EXT_INFRA_RESOURCE,
|
|
42
|
+
]);
|
|
43
|
+
// Malformed destroy registrations would otherwise only throw when a tenant
|
|
44
|
+
// destruction reaches the stage, possibly months after deploy, and stall the
|
|
45
|
+
// whole pipeline (stages.ts keeps its own throw as defense in depth). JS
|
|
46
|
+
// plugins and `as` casts slip past the type check.
|
|
47
|
+
export function validateTenantDestroyHookShapes(features) {
|
|
48
|
+
for (const feature of features) {
|
|
49
|
+
for (const usage of feature.extensionUsages) {
|
|
50
|
+
const isTenantData = usage.extensionName === EXT_TENANT_DATA;
|
|
51
|
+
if (!isTenantData && !TENANT_RESOURCE_EXTENSIONS.has(usage.extensionName))
|
|
52
|
+
continue;
|
|
53
|
+
const isValid = isTenantData
|
|
54
|
+
? isTenantDataExtensionHooks(usage.options)
|
|
55
|
+
: isTenantResourceExtensionHooks(usage.options);
|
|
56
|
+
if (isValid)
|
|
57
|
+
continue;
|
|
58
|
+
const requiredFn = isTenantData ? "destroy" : "destroyTenant";
|
|
59
|
+
throw new Error(`[kumiko:boot] ${usage.extensionName} registration for "${usage.entityName}" (feature "${feature.name}") has no ${requiredFn} function — tenant destroy would fail at this stage.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineFeature, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, EXT_TENANT_LIFECYCLE_STATUS, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { validateTenantDataHookCoverage } from "./boot-checks.js";
|
|
2
|
+
import { validateTenantDataHookCoverage, validateTenantDestroyHookShapes } from "./boot-checks.js";
|
|
3
3
|
import { DESTRUCTION_CANCELLED_EVENT_SHORT, DESTRUCTION_REQUESTED_EVENT_SHORT, TENANT_DESTRUCTION_COMPLETED_EVENT_SHORT, TENANT_DESTRUCTION_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_ABANDONED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_FAILED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_STARTED_EVENT_SHORT, TENANT_DESTRUCTION_STAGE_SUCCEEDED_EVENT_SHORT, TENANT_DESTRUCTION_STARTED_EVENT_SHORT, } from "./constants.js";
|
|
4
4
|
import { destructionCancelledSchema, destructionRequestedSchema, tenantDestructionCompletedSchema, tenantDestructionFailedSchema, tenantDestructionStageAbandonedSchema, tenantDestructionStageFailedSchema, tenantDestructionStageStartedSchema, tenantDestructionStageSucceededSchema, tenantDestructionStartedSchema, } from "./events.js";
|
|
5
5
|
import { cancelDestructionWrite } from "./handlers/cancel-destruction.write.js";
|
|
@@ -36,6 +36,7 @@ export function createTenantLifecycleFeature() {
|
|
|
36
36
|
// EXT_TENANT_DATA: its own mount is the trigger, matching the original
|
|
37
37
|
// guard's "tenant-lifecycle mounted" gate exactly.
|
|
38
38
|
r.bootCheck(({ features }) => validateTenantDataHookCoverage(features));
|
|
39
|
+
r.bootCheck(({ features }) => validateTenantDestroyHookShapes(features));
|
|
39
40
|
r.defineEvent(DESTRUCTION_REQUESTED_EVENT_SHORT, destructionRequestedSchema, {
|
|
40
41
|
piiFields: "none",
|
|
41
42
|
});
|
|
@@ -2,7 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { configuredPiiSubjectKms } from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
3
|
import { createEventStoreExecutor, createTenantDb, } from "@cosmicdrift/kumiko-framework/db";
|
|
4
4
|
import { createSystemUser, EXT_EXTERNAL_RESOURCE, EXT_INFRA_RESOURCE, EXT_SEARCH_ADAPTER, EXT_STORAGE_PROVIDER, EXT_TENANT_DATA, extensionUsageEscapeHatchReason, isTenantDataExtensionHooks, isTenantResourceExtensionHooks, } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
-
import { createEscapeHatchReporter } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
5
|
+
import { createEscapeHatchReporter, UNATTRIBUTED_ACTOR, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
6
6
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
7
7
|
import { tenantEntity, tenantMembershipEntity, tenantMembershipsTable, tenantTable, } from "../tenant/index.js";
|
|
8
8
|
import { invalidateTenantLifecycleGate } from "./lifecycle-gate.js";
|
|
@@ -30,7 +30,7 @@ async function runTenantDataHooks(ctx) {
|
|
|
30
30
|
const report = createEscapeHatchReporter({
|
|
31
31
|
handler: `${EXT_TENANT_DATA}:${usage.entityName}`,
|
|
32
32
|
tenantId: ctx.tenantId,
|
|
33
|
-
actor: ctx.actor ??
|
|
33
|
+
actor: ctx.actor ?? UNATTRIBUTED_ACTOR,
|
|
34
34
|
sink: ctx.escapeHatchAuditSink,
|
|
35
35
|
});
|
|
36
36
|
const hookCtx = {
|
|
@@ -129,11 +129,12 @@ export function createTierEngineFeature(opts = {}) {
|
|
|
129
129
|
// Standard-CRUD via Helper.
|
|
130
130
|
r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, {
|
|
131
131
|
...writeAccess,
|
|
132
|
-
|
|
132
|
+
// Bypasses the feature-gate refresh that set-tenant-tier performs.
|
|
133
|
+
agent: { expose: false },
|
|
133
134
|
}));
|
|
134
135
|
r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, {
|
|
135
136
|
...writeAccess,
|
|
136
|
-
|
|
137
|
+
agent: { expose: false },
|
|
137
138
|
}));
|
|
138
139
|
// Reads.
|
|
139
140
|
r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, {
|
|
@@ -3,7 +3,7 @@ export declare const UserCommandSchemas: {
|
|
|
3
3
|
email: import("zod").ZodEmail;
|
|
4
4
|
passwordHash: import("zod").ZodOptional<import("zod").ZodString>;
|
|
5
5
|
displayName: import("zod").ZodString;
|
|
6
|
-
locale: import("zod").ZodOptional<import("zod").ZodString
|
|
6
|
+
locale: import("zod").ZodOptional<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string, string>>>;
|
|
7
7
|
timezone: import("zod").ZodOptional<import("zod").ZodString>;
|
|
8
8
|
roles: import("zod").ZodOptional<import("zod").ZodUnion<readonly [import("zod").ZodArray<import("zod").ZodString>, import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string[], string>>]>>;
|
|
9
9
|
}, import("zod/v4/core").$strip>;
|
|
@@ -12,7 +12,7 @@ export declare const UserCommandSchemas: {
|
|
|
12
12
|
version: import("zod").ZodNumber;
|
|
13
13
|
changes: import("zod").ZodObject<{
|
|
14
14
|
displayName: import("zod").ZodOptional<import("zod").ZodString>;
|
|
15
|
-
locale: import("zod").ZodOptional<import("zod").ZodString
|
|
15
|
+
locale: import("zod").ZodOptional<import("zod").ZodPipe<import("zod").ZodString, import("zod").ZodTransform<string, string>>>;
|
|
16
16
|
timezone: import("zod").ZodOptional<import("zod").ZodString>;
|
|
17
17
|
email: import("zod").ZodOptional<import("zod").ZodEmail>;
|
|
18
18
|
passwordHash: import("zod").ZodOptional<import("zod").ZodString>;
|
|
@@ -3,7 +3,7 @@ export declare const createWrite: import("@cosmicdrift/kumiko-types/define-handl
|
|
|
3
3
|
email: z.ZodEmail;
|
|
4
4
|
passwordHash: z.ZodOptional<z.ZodString>;
|
|
5
5
|
displayName: z.ZodString;
|
|
6
|
-
locale: z.ZodOptional<z.ZodString
|
|
6
|
+
locale: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
7
7
|
timezone: z.ZodOptional<z.ZodString>;
|
|
8
8
|
roles: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodPipe<z.ZodString, z.ZodTransform<string[], string>>]>>;
|
|
9
9
|
}, z.core.$strip>, import("@cosmicdrift/kumiko-types/hooks").SaveContext, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
@@ -2,7 +2,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
|
2
2
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { defineWriteHandler, findForbiddenRoleAssignment, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { AccessDeniedError, ConflictError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
-
import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
5
|
+
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
6
6
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
7
7
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
8
8
|
import * as z from "zod";
|
|
@@ -28,7 +28,13 @@ export const createWrite = defineWriteHandler({
|
|
|
28
28
|
email: z.email(),
|
|
29
29
|
passwordHash: z.string().optional(),
|
|
30
30
|
displayName: z.string().min(1).max(100),
|
|
31
|
-
locale: z
|
|
31
|
+
locale: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(2)
|
|
34
|
+
.max(10)
|
|
35
|
+
.refine(isValidLocaleTag, "invalid locale tag")
|
|
36
|
+
.transform(canonicalizeLocaleTag)
|
|
37
|
+
.optional(),
|
|
32
38
|
timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
|
|
33
39
|
// Field-level write access (privileged) is defense-in-depth — create is
|
|
34
40
|
// already system/SystemAdmin-only.
|
|
@@ -4,7 +4,7 @@ export declare const updateWrite: import("@cosmicdrift/kumiko-types/define-handl
|
|
|
4
4
|
version: z.ZodNumber;
|
|
5
5
|
changes: z.ZodObject<{
|
|
6
6
|
displayName: z.ZodOptional<z.ZodString>;
|
|
7
|
-
locale: z.ZodOptional<z.ZodString
|
|
7
|
+
locale: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
|
|
8
8
|
timezone: z.ZodOptional<z.ZodString>;
|
|
9
9
|
email: z.ZodOptional<z.ZodEmail>;
|
|
10
10
|
passwordHash: z.ZodOptional<z.ZodString>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { access, defineWriteHandler, hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { AccessDeniedError, InternalError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
-
import { isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
4
|
+
import { canonicalizeLocaleTag, isValidLocaleTag } from "@cosmicdrift/kumiko-framework/i18n";
|
|
5
5
|
import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
6
6
|
import * as z from "zod";
|
|
7
7
|
import { UserErrors } from "../constants.js";
|
|
@@ -24,7 +24,13 @@ export const updateWrite = defineWriteHandler({
|
|
|
24
24
|
version: z.number(),
|
|
25
25
|
changes: z.object({
|
|
26
26
|
displayName: z.string().min(1).max(100).optional(),
|
|
27
|
-
locale: z
|
|
27
|
+
locale: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(2)
|
|
30
|
+
.max(10)
|
|
31
|
+
.refine(isValidLocaleTag, "invalid locale tag")
|
|
32
|
+
.transform(canonicalizeLocaleTag)
|
|
33
|
+
.optional(),
|
|
28
34
|
timezone: z.string().max(64).refine(isValidIanaTimeZone, "invalid IANA time zone").optional(),
|
|
29
35
|
email: z.email().optional(),
|
|
30
36
|
passwordHash: z.string().optional(),
|
|
@@ -8,5 +8,7 @@ export type ExportJobCleanupCandidate = {
|
|
|
8
8
|
readonly requestedFromTenantId: TenantId;
|
|
9
9
|
readonly downloadStorageKey: string | null;
|
|
10
10
|
readonly expiresAt: Temporal.Instant | null;
|
|
11
|
+
readonly completedAt: Temporal.Instant | null;
|
|
12
|
+
readonly startedAt: Temporal.Instant | null;
|
|
11
13
|
};
|
|
12
14
|
export declare function selectExportJobsForStorageCleanup(db: DbConnection, doneStatus: string, failedStatus: string): Promise<readonly ExportJobCleanupCandidate[]>;
|
|
@@ -25,7 +25,7 @@ import { isMailTransportAvailable, makeDefaultDeletionExecutedEmail, makeDefault
|
|
|
25
25
|
import { makeTenantMailTransportResolver } from "./lib/mail-transport-resolver.js";
|
|
26
26
|
import { resolveAppTenantModel } from "./lib/resolve-tenant-model.js";
|
|
27
27
|
import { makeTenantStorageProviderResolver } from "./lib/storage-provider-resolver.js";
|
|
28
|
-
import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, runExportJobs, } from "./run-export-jobs.js";
|
|
28
|
+
import { EXPORT_CLEANUP_BACKLOG_AGE_METRIC, EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC, runExportJobs, } from "./run-export-jobs.js";
|
|
29
29
|
import { runForgetCleanup, sanitizeReasonForLog, } from "./run-forget-cleanup.js";
|
|
30
30
|
import { downloadAttemptEntity } from "./schema/download-attempt.js";
|
|
31
31
|
import { exportDownloadTokenEntity } from "./schema/download-token.js";
|
|
@@ -156,12 +156,18 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
156
156
|
"userDataRights.privacyCenter.field.gracePeriodEnd": { en: "Scheduled deletion date" },
|
|
157
157
|
"userDataRights.privacyCenter.export.title": { en: "Export your data (Art. 20)" },
|
|
158
158
|
"userDataRights.privacyCenter.restriction.title": { en: "Restrict processing (Art. 18)" },
|
|
159
|
+
"userDataRights.privacyCenter.restriction.explainer": {
|
|
160
|
+
en: "Freeze your account: processing of your data is paused and you are signed out. Lifting the restriction afterwards is only possible via support.",
|
|
161
|
+
},
|
|
159
162
|
"userDataRights.privacyCenter.restriction.restrict": { en: "Restrict account" },
|
|
160
163
|
"userDataRights.privacyCenter.restriction.dialogTitle": { en: "Restrict your account?" },
|
|
161
164
|
"userDataRights.privacyCenter.restriction.dialogDescription": {
|
|
162
165
|
en: "You will be signed out immediately and cannot sign in again until support lifts the restriction.",
|
|
163
166
|
},
|
|
164
167
|
"userDataRights.privacyCenter.deletion.title": { en: "Delete account (Art. 17)" },
|
|
168
|
+
"userDataRights.privacyCenter.deletion.explainer": {
|
|
169
|
+
en: "Request deletion of your account. Until the grace period ends you can cancel the deletion.",
|
|
170
|
+
},
|
|
165
171
|
"userDataRights.privacyCenter.deletion.delete": { en: "Delete account" },
|
|
166
172
|
"userDataRights.privacyCenter.deletion.cancel": { en: "Cancel deletion" },
|
|
167
173
|
"userDataRights.privacyCenter.deletion.dialogTitle": { en: "Delete your account?" },
|
|
@@ -215,7 +221,7 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
215
221
|
// when `showDeletion` is false, and status covers restriction
|
|
216
222
|
// AND deletion state, so it can't hang off the optional section.
|
|
217
223
|
title: i18nKey("userDataRights.privacyCenter.restriction.title"),
|
|
218
|
-
description: "userDataRights.privacyCenter.restriction.explainer",
|
|
224
|
+
description: i18nKey("userDataRights.privacyCenter.restriction.explainer"),
|
|
219
225
|
fields: [
|
|
220
226
|
{
|
|
221
227
|
field: "status",
|
|
@@ -227,7 +233,7 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
227
233
|
? [
|
|
228
234
|
{
|
|
229
235
|
title: i18nKey("userDataRights.privacyCenter.deletion.title"),
|
|
230
|
-
description: "userDataRights.privacyCenter.deletion.explainer",
|
|
236
|
+
description: i18nKey("userDataRights.privacyCenter.deletion.explainer"),
|
|
231
237
|
// gracePeriodEnd is only meaningful once a deletion is
|
|
232
238
|
// actually pending — hide it instead of showing an empty
|
|
233
239
|
// date when status isn't deletionRequested.
|
|
@@ -269,6 +275,8 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
269
275
|
handler: UserDataRightsHandlers.cancelDeletion,
|
|
270
276
|
visible: { field: "status", eq: "deletionRequested" },
|
|
271
277
|
style: "secondary",
|
|
278
|
+
// Declared: the id's last segment ("deletion") would otherwise resolve to the destructive trash icon.
|
|
279
|
+
icon: "x",
|
|
272
280
|
},
|
|
273
281
|
]
|
|
274
282
|
: []),
|
|
@@ -372,15 +380,20 @@ export function createUserDataRightsFeature(opts = {}) {
|
|
|
372
380
|
// Surfaces a stalled export-cleanup cron: if the daily
|
|
373
381
|
// pass stops running, done-status export bundles keep sitting in
|
|
374
382
|
// storage past their TTL+grace with downloadStorageKey still set
|
|
375
|
-
// (unencrypted PII at rest).
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// Scope: Done-status bundles
|
|
379
|
-
//
|
|
383
|
+
// (unencrypted PII at rest). Gauges keep their last value in-process, so
|
|
384
|
+
// a dead cron does NOT make the backlog gauge absent; the last-run
|
|
385
|
+
// timestamp gauge below is the liveness signal.
|
|
386
|
+
// Scope: Done-status bundles past expiresAt+grace plus Failed-status
|
|
387
|
+
// orphans (no TTL, due from completedAt ?? startedAt; see storageCleanupPass).
|
|
380
388
|
r.metric(EXPORT_CLEANUP_BACKLOG_AGE_METRIC, {
|
|
381
389
|
type: "gauge",
|
|
382
390
|
unit: "seconds",
|
|
383
|
-
description: "Age in seconds of the oldest
|
|
391
|
+
description: "Age in seconds of the oldest export bundle (done: expiresAt+grace passed; failed: orphaned ZIP since completedAt or startedAt) whose downloadStorageKey is still set. 0 when the storage-cleanup pass has no backlog.",
|
|
392
|
+
});
|
|
393
|
+
r.metric(EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC, {
|
|
394
|
+
type: "gauge",
|
|
395
|
+
unit: "seconds",
|
|
396
|
+
description: "Unix timestamp of the last export storage-cleanup pass that completed. Alert on time() minus this value exceeding the cron interval plus a buffer; the backlog-age gauge alone stays at its last value when the cron stops.",
|
|
384
397
|
});
|
|
385
398
|
// S2.U3 Atom 3b — Worker fuer Async Export-Pipeline. Cron-getriggert.
|
|
386
399
|
const RUN_EXPORT_JOBS_REASON = "processes pending export jobs across every tenant";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { USER_STATUS, userTable } from "../../user/index.js";
|
|
5
5
|
import { redeemDeletionToken } from "../deletion-token.js";
|
|
6
|
-
import { startDeletionGracePeriod } from "./deletion-grace-period.js";
|
|
6
|
+
import { resolveGracePeriod, startDeletionGracePeriod } from "./deletion-grace-period.js";
|
|
7
7
|
// Generischer 422 für jeden Token-Fehlerpfad (malformed / bad_signature /
|
|
8
8
|
// expired / kein Secret) — kein Signal ob ein Token zu einem User gehört.
|
|
9
9
|
function invalidToken() {
|
|
@@ -55,11 +55,8 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
|
|
|
55
55
|
"SYSTEM_TENANT_ID user stream.",
|
|
56
56
|
},
|
|
57
57
|
agent: { expose: false },
|
|
58
|
-
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
58
|
+
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
59
59
|
handler: async (event, ctx) => {
|
|
60
|
-
// @cast-boundary engine-payload — queryAs returns unknown, narrowed to
|
|
61
|
-
// the compliance-profile shape.
|
|
62
|
-
const profile = (await ctx.queryAs(createSystemUser(event.user.tenantId), "compliance-profiles:query:for-tenant", {}));
|
|
63
60
|
let gracePeriodEndIso;
|
|
64
61
|
// The row's requestId is part of the verify key, so a token from a
|
|
65
62
|
// cancelled or superseded cycle fails. Every error path ends in the same
|
|
@@ -71,7 +68,7 @@ export function createConfirmDeletionByTokenHandler(opts = {}) {
|
|
|
71
68
|
secret: opts.deletionTokenSecret,
|
|
72
69
|
loadPendingRequestId: (userId) => readPendingDeletionRequestId(ctx, userId),
|
|
73
70
|
commitDeletion: async (userId, requestId) => {
|
|
74
|
-
const res = await startDeletionGracePeriod(ctx, userId,
|
|
71
|
+
const res = await startDeletionGracePeriod(ctx, userId, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"), { pendingDeletionRequestId: requestId });
|
|
75
72
|
if (!res.ok)
|
|
76
73
|
return false;
|
|
77
74
|
gracePeriodEndIso = res.gracePeriodEnd.toString();
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type DurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
2
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
import type
|
|
3
|
+
import { type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
5
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
6
6
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
7
|
+
export declare function resolveGracePeriod(ctx: HandlerContext, tenantId: string): Promise<DurationSpec>;
|
|
7
8
|
export type StartGracePeriodResult = {
|
|
8
9
|
readonly ok: true;
|
|
9
10
|
readonly gracePeriodEnd: Instant;
|
|
@@ -13,5 +14,5 @@ export type StartGracePeriodResult = {
|
|
|
13
14
|
readonly ok: false;
|
|
14
15
|
readonly error: UnprocessableError;
|
|
15
16
|
};
|
|
16
|
-
export declare function startDeletionGracePeriod(ctx: HandlerContext, userId: string,
|
|
17
|
+
export declare function startDeletionGracePeriod(ctx: HandlerContext, userId: string, resolveGracePeriodSpec: () => Promise<DurationSpec>, lifecycleRunner: DbRunner, additionalExpect?: Readonly<Record<string, string | number | boolean | null>>): Promise<StartGracePeriodResult>;
|
|
17
18
|
export {};
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import { addDurationSpec } from "@cosmicdrift/kumiko-framework/compliance";
|
|
1
|
+
import { addDurationSpec, } from "@cosmicdrift/kumiko-framework/compliance";
|
|
2
|
+
import { createSystemUser, declareEscapeHatch, } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
4
5
|
import { decryptStoredPii } from "../../shared/index.js";
|
|
5
6
|
import { USER_STATUS, userTable } from "../../user/index.js";
|
|
6
7
|
import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
|
|
8
|
+
// Single trust-boundary cast for the compliance-profile lookup: the query
|
|
9
|
+
// handler's declared return type is EffectiveComplianceProfile, but queryAs
|
|
10
|
+
// only hands back unknown. Needs the calling handler's escapeHatch (system
|
|
11
|
+
// queryAs).
|
|
12
|
+
export async function resolveGracePeriod(ctx, tenantId) {
|
|
13
|
+
declareEscapeHatch({
|
|
14
|
+
reason: "reads the tenant compliance profile via queryAs(SYSTEM, ...) to compute the grace period end; the calling user's own identity has no read access to that tenant-config projection",
|
|
15
|
+
});
|
|
16
|
+
const profile = (await ctx.queryAs(createSystemUser(tenantId), "compliance-profiles:query:for-tenant", {})); // @cast-boundary engine-payload
|
|
17
|
+
return profile.profile.userRights.gracePeriod;
|
|
18
|
+
}
|
|
7
19
|
// Flips an active user to DeletionRequested and sets gracePeriodEnd from the
|
|
8
20
|
// caller-supplied grace period. Shared between the authenticated
|
|
9
21
|
// request-deletion path (event.user) and the anonymous confirm-by-token path
|
|
@@ -13,7 +25,7 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
|
|
|
13
25
|
// ctx.db.global(userTable); only the grace period duration is tenant-configured.
|
|
14
26
|
// That read only supplies email/locale for the caller's notification — it is
|
|
15
27
|
// NOT the transition's guard. The guard is `expect: { status: Active }` on
|
|
16
|
-
// the lifecycle write itself
|
|
28
|
+
// the lifecycle write itself: the executor re-checks it against a
|
|
17
29
|
// fresh row right before writing, so a concurrent caller that already moved
|
|
18
30
|
// the user off Active is rejected even though this read saw Active.
|
|
19
31
|
//
|
|
@@ -31,11 +43,13 @@ import { updateUserLifecycle } from "../lib/update-user-lifecycle.js";
|
|
|
31
43
|
// true). No-op for the authenticated request-deletion path, which never set
|
|
32
44
|
// a pendingDeletionRequestId to begin with.
|
|
33
45
|
//
|
|
34
|
-
// `
|
|
35
|
-
// require an escalation (reading the tenant compliance profile,
|
|
36
|
-
// the SYSTEM_TENANT_ID user stream) that only the declaring
|
|
37
|
-
// escapeHatch covers.
|
|
38
|
-
|
|
46
|
+
// `resolveGracePeriodSpec` and `lifecycleRunner` are supplied by the caller:
|
|
47
|
+
// both require an escalation (reading the tenant compliance profile,
|
|
48
|
+
// appending to the SYSTEM_TENANT_ID user stream) that only the declaring
|
|
49
|
+
// handler's escapeHatch covers. The grace period is a lazy provider so the
|
|
50
|
+
// privileged compliance-profile query only runs for a user that is still
|
|
51
|
+
// Active, not for replayed tokens or already-requested deletions.
|
|
52
|
+
export async function startDeletionGracePeriod(ctx, userId, resolveGracePeriodSpec, lifecycleRunner, additionalExpect) {
|
|
39
53
|
const userRow = await ctx.db
|
|
40
54
|
.global(userTable)
|
|
41
55
|
.fetchOne({ id: userId });
|
|
@@ -47,8 +61,16 @@ export async function startDeletionGracePeriod(ctx, userId, gracePeriod, lifecyc
|
|
|
47
61
|
}),
|
|
48
62
|
};
|
|
49
63
|
}
|
|
64
|
+
if (userRow["status"] !== USER_STATUS.Active) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: new UnprocessableError("user_not_in_active_state", {
|
|
68
|
+
details: { currentStatus: userRow["status"] },
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
50
72
|
const T = getTemporal();
|
|
51
|
-
const gracePeriodEnd = addDurationSpec(T.Now.instant(),
|
|
73
|
+
const gracePeriodEnd = addDurationSpec(T.Now.instant(), await resolveGracePeriodSpec());
|
|
52
74
|
const { applied } = await updateUserLifecycle(lifecycleRunner, userId, { status: USER_STATUS.DeletionRequested, gracePeriodEnd, pendingDeletionRequestId: null }, { expect: { status: USER_STATUS.Active, ...additionalExpect } });
|
|
53
75
|
if (!applied) {
|
|
54
76
|
return {
|
|
@@ -56,7 +56,7 @@ export const downloadByTokenQuery = defineQueryHandler({
|
|
|
56
56
|
// 30 attempts/min/IP reicht fuer legitime User (mehrere Klicks bei
|
|
57
57
|
// Connection-Abbruch); blockiert automatisierte Probing-Loops.
|
|
58
58
|
// Memory `feedback_security_default_on`.
|
|
59
|
-
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
59
|
+
rateLimit: { per: "ip+handler", limit: 30, windowSeconds: 60 },
|
|
60
60
|
escapeHatch: {
|
|
61
61
|
reason: DOWNLOAD_BY_TOKEN_REASON,
|
|
62
62
|
},
|
|
@@ -34,7 +34,7 @@ export function createRequestDeletionByEmailHandler(opts = {}) {
|
|
|
34
34
|
// Mails a deletion link to any address the caller names.
|
|
35
35
|
agent: { risk: "high" },
|
|
36
36
|
// Defense-in-depth gegen Email-Probing auf dem anonymen Endpoint.
|
|
37
|
-
rateLimit: { per: "ip", limit: 10, windowSeconds: 60 },
|
|
37
|
+
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
38
38
|
escapeHatch: {
|
|
39
39
|
reason: APPEND_LIFECYCLE_EVENT_REASON,
|
|
40
40
|
},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { createTransportForTenant } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";
|
|
2
|
-
import {
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { USER_STATUS } from "../../user/index.js";
|
|
6
6
|
import { isMailTransportAvailable, makeDefaultDeletionRequestedEmail, } from "../lib/default-mailers.js";
|
|
7
|
-
import { startDeletionGracePeriod } from "./deletion-grace-period.js";
|
|
7
|
+
import { resolveGracePeriod, startDeletionGracePeriod } from "./deletion-grace-period.js";
|
|
8
8
|
// POST /api/user/request-deletion (S2.U5a) — DSGVO Art. 17 Forget-Antrag.
|
|
9
9
|
// Flippt status=Active → deletionRequested, setzt gracePeriodEnd aus dem
|
|
10
10
|
// Compliance-Profile (geteilte Logik: startDeletionGracePeriod). Account-
|
|
@@ -29,10 +29,7 @@ export function createRequestDeletionHandler(opts = {}) {
|
|
|
29
29
|
description: "Starts the GDPR Art. 17 deletion of the calling user's own account by arming the grace period from the tenant compliance profile and mailing a confirmation, after which only cancel-deletion can stop the erasure.",
|
|
30
30
|
agent: { risk: "high" },
|
|
31
31
|
handler: async (event, ctx) => {
|
|
32
|
-
|
|
33
|
-
// the compliance-profile shape.
|
|
34
|
-
const profile = (await ctx.queryAs(createSystemUser(event.user.tenantId), "compliance-profiles:query:for-tenant", {}));
|
|
35
|
-
const res = await startDeletionGracePeriod(ctx, event.user.id, profile.profile.userRights.gracePeriod, ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"));
|
|
32
|
+
const res = await startDeletionGracePeriod(ctx, event.user.id, () => resolveGracePeriod(ctx, event.user.tenantId), ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"));
|
|
36
33
|
if (!res.ok)
|
|
37
34
|
return writeFailure(res.error);
|
|
38
35
|
const { gracePeriodEnd, userEmail, userLocale } = res;
|
|
@@ -26,25 +26,18 @@ const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityNam
|
|
|
26
26
|
// longer held (precondition_failed) or a concurrent writer won the race on
|
|
27
27
|
// the same stream version (version_conflict) — both mean "someone else's
|
|
28
28
|
// transition already landed", the expected outcome for a caller that opted
|
|
29
|
-
// into `expect`. Any other failure, or a failure with no `expect` set
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// into `expect`. Any other failure, or a failure with no `expect` set, still
|
|
30
|
+
// throws — those callers never checked a return value, so silently dropping
|
|
31
|
+
// their write would be silent data loss.
|
|
32
32
|
export async function updateUserLifecycle(conn, userId, changes, options) {
|
|
33
33
|
// user ist systemStream (#497): der Executor-Choke-Point addressiert den
|
|
34
34
|
// Stream immer auf SYSTEM_TENANT_ID — ein Rescope auf die row-tenant_id
|
|
35
35
|
// waere wirkungslos. "system"-Mode, damit loadById auch Legacy-Rows findet,
|
|
36
36
|
// deren tenant_id noch vor dem #762-Backfill-Rebuild steht.
|
|
37
|
-
// skipOptimisticLock
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// round-trip — none of them read-then-hold a row version to pass as
|
|
42
|
-
// `payload.version`, so the executor's version gate would reject every
|
|
43
|
-
// call outright without it (`update()` requires `payload.version` unless
|
|
44
|
-
// skipOptimisticLock is set). #3024 replaces the safety that decision gave
|
|
45
|
-
// up with the purpose-built `expect:` precondition above, checked fresh at
|
|
46
|
-
// write time — the mechanism now genuinely matches the caller's shape
|
|
47
|
-
// (a business-field guard) instead of a repurposed version check.
|
|
37
|
+
// skipOptimisticLock: every caller is a server-side business transition
|
|
38
|
+
// (restrict/lift-restriction/grace-period/cancel/forget) that holds no row
|
|
39
|
+
// version to pass as `payload.version`, which update() requires otherwise.
|
|
40
|
+
// Callers needing a guard use `options.expect` instead.
|
|
48
41
|
const tenantDb = createTenantDb(conn, SYSTEM_TENANT_ID, "system");
|
|
49
42
|
const result = await userExecutor.update({ id: userId, changes }, createSystemUser(SYSTEM_TENANT_ID), tenantDb, { skipOptimisticLock: true, ...(options?.expect && { expect: options.expect }) });
|
|
50
43
|
if (result.isSuccess)
|
|
@@ -5,6 +5,7 @@ import type { MetricsHandle } from "@cosmicdrift/kumiko-framework/observability"
|
|
|
5
5
|
import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
6
6
|
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
7
7
|
export declare const EXPORT_CLEANUP_BACKLOG_AGE_METRIC = "export_cleanup_backlog_age";
|
|
8
|
+
export declare const EXPORT_CLEANUP_LAST_RUN_TIMESTAMP_METRIC = "export_cleanup_last_run_timestamp";
|
|
8
9
|
export declare const tokenCrud: import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor;
|
|
9
10
|
/**
|
|
10
11
|
* Notification-Callback fuer den done-Pfad. Pattern matched
|