@cosmicdrift/kumiko-samples 0.352.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
- package/packages/bundled-features/src/agent-tools/changes.json +12 -0
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
- package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
- package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
- package/packages/bundled-features/src/cap-overview/access.ts +2 -9
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
- package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/public-names.ts +1 -1
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/feature.ts +2 -12
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/form-draft/changes.json +7 -1
- package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
- package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/i18n.ts +1 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +17 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/ledger/schemas.ts +8 -2
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/managed-pages/changes.json +6 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/notes-history/feature.ts +2 -12
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
- package/packages/bundled-features/src/seo/changes.json +5 -0
- package/packages/bundled-features/src/seo/feature.ts +23 -7
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +4 -2
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/tags/feature.ts +2 -12
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +19 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
- package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/index.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
- package/packages/bundled-features/src/tenant/screens.ts +1 -1
- package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
- package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
- package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
- package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/public-share-token/README.md +4 -5
- package/samples/recipes/public-share-token/src/feature.ts +1 -1
- package/samples/recipes/security-baseline/README.md +6 -0
- package/samples/recipes/seed-migration/README.md +0 -2
- package/samples/recipes/tenant-settings/README.md +1 -1
- package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
configuredPiiSubjectKms,
|
|
4
|
+
type SubjectId,
|
|
5
|
+
subjectIdToKey,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
3
7
|
import {
|
|
4
8
|
createEventStoreExecutor,
|
|
5
9
|
createTenantDb,
|
|
@@ -18,6 +22,7 @@ import {
|
|
|
18
22
|
isTenantResourceExtensionHooks,
|
|
19
23
|
type Registry,
|
|
20
24
|
type TenantDataHookCtx,
|
|
25
|
+
type TenantDestroyHookResult,
|
|
21
26
|
type TenantId,
|
|
22
27
|
type TenantResourceExtensionName,
|
|
23
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -26,6 +31,10 @@ import {
|
|
|
26
31
|
createEscapeHatchReporter,
|
|
27
32
|
UNATTRIBUTED_ACTOR,
|
|
28
33
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
34
|
+
import {
|
|
35
|
+
purgeSearchDocumentsForSubject,
|
|
36
|
+
type SearchAdapter,
|
|
37
|
+
} from "@cosmicdrift/kumiko-framework/search";
|
|
29
38
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
30
39
|
import {
|
|
31
40
|
tenantEntity,
|
|
@@ -45,17 +54,26 @@ export type DestructionStageCtx = {
|
|
|
45
54
|
// "destroyTenant" hooks) — undefined when no file-provider is wired,
|
|
46
55
|
// which those hooks must treat as "nothing to clean up", not an error.
|
|
47
56
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
57
|
+
// Derived search docs of the tenant record (its name) live in the writer's
|
|
58
|
+
// index, not the destroyed tenant's, so the subject-keys stage purges them.
|
|
59
|
+
readonly searchAdapter?: SearchAdapter;
|
|
48
60
|
// fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
|
|
49
61
|
// systemUser.id); runTenantDataHooks uses them to attribute+audit any
|
|
50
62
|
// EXT_TENANT_DATA usage's declared escapeHatch.
|
|
51
63
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
52
64
|
readonly actor?: string;
|
|
65
|
+
// Epoch ms the stage must hand back by returning { done: false }.
|
|
66
|
+
readonly deadlineAt: number;
|
|
53
67
|
};
|
|
54
68
|
|
|
69
|
+
export type StageRunOutcome = { readonly done: boolean; readonly processed: number };
|
|
70
|
+
|
|
71
|
+
const STAGE_DONE: StageRunOutcome = { done: true, processed: 0 };
|
|
72
|
+
|
|
55
73
|
export type DestructionStage = {
|
|
56
74
|
readonly name: TenantDestructionStageName;
|
|
57
75
|
readonly maxAttempts: number;
|
|
58
|
-
readonly run: (ctx: DestructionStageCtx) => Promise<
|
|
76
|
+
readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
|
|
59
77
|
};
|
|
60
78
|
|
|
61
79
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
@@ -67,24 +85,44 @@ const tenantMembershipCrud = createEventStoreExecutor(
|
|
|
67
85
|
},
|
|
68
86
|
);
|
|
69
87
|
|
|
88
|
+
// Every hook still runs each tick (they are idempotent), so a later hook is not
|
|
89
|
+
// starved by an earlier one that keeps reporting done:false.
|
|
90
|
+
function newOutcomeAccumulator(): {
|
|
91
|
+
add: (result: TenantDestroyHookResult | void) => void;
|
|
92
|
+
outcome: () => StageRunOutcome;
|
|
93
|
+
} {
|
|
94
|
+
let allDone = true;
|
|
95
|
+
let processed = 0;
|
|
96
|
+
return {
|
|
97
|
+
add: (result) => {
|
|
98
|
+
if (result?.done === false) allDone = false;
|
|
99
|
+
processed += result?.processed ?? 0;
|
|
100
|
+
},
|
|
101
|
+
outcome: () => ({ done: allDone, processed }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
70
105
|
async function runExtensionDestroyHooks(
|
|
71
106
|
registry: Registry,
|
|
72
107
|
extensionName: TenantResourceExtensionName,
|
|
73
108
|
ctx: DestructionStageCtx,
|
|
74
|
-
): Promise<
|
|
109
|
+
): Promise<StageRunOutcome> {
|
|
75
110
|
const usages = registry.getExtensionUsages(extensionName);
|
|
111
|
+
const total = newOutcomeAccumulator();
|
|
76
112
|
for (const usage of usages) {
|
|
77
113
|
if (!isTenantResourceExtensionHooks(usage.options)) {
|
|
78
114
|
throw new Error(
|
|
79
115
|
`${extensionName} registration for "${usage.entityName}" has no destroy function`,
|
|
80
116
|
);
|
|
81
117
|
}
|
|
82
|
-
await usage.options.destroyTenant(ctx.tenantId, ctx);
|
|
118
|
+
total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
|
|
83
119
|
}
|
|
120
|
+
return total.outcome();
|
|
84
121
|
}
|
|
85
122
|
|
|
86
|
-
async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<
|
|
123
|
+
async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
87
124
|
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
125
|
+
const total = newOutcomeAccumulator();
|
|
88
126
|
for (const usage of usages) {
|
|
89
127
|
if (!isTenantDataExtensionHooks(usage.options)) {
|
|
90
128
|
throw new Error(
|
|
@@ -106,25 +144,29 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
|
|
|
106
144
|
}),
|
|
107
145
|
registry: ctx.registry,
|
|
108
146
|
tenantId: ctx.tenantId,
|
|
147
|
+
deadlineAt: ctx.deadlineAt,
|
|
109
148
|
fileProviderResolver: ctx.fileProviderResolver,
|
|
110
149
|
log: ctx.log,
|
|
111
150
|
};
|
|
112
|
-
await destroy(hookCtx);
|
|
151
|
+
total.add(await destroy(hookCtx));
|
|
113
152
|
}
|
|
153
|
+
return total.outcome();
|
|
114
154
|
}
|
|
115
155
|
|
|
116
|
-
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<
|
|
156
|
+
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
117
157
|
const kms = configuredPiiSubjectKms();
|
|
118
158
|
if (!kms) {
|
|
119
159
|
ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
|
|
120
160
|
// skip: KMS optional — apps without crypto-shredding still run other destroy stages
|
|
121
|
-
return;
|
|
161
|
+
return STAGE_DONE;
|
|
122
162
|
}
|
|
123
163
|
const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
|
|
124
164
|
tenantId: ctx.tenantId,
|
|
125
165
|
});
|
|
166
|
+
const tenantRecordSubject: SubjectId = { kind: "record", entity: "tenant", id: ctx.tenantId };
|
|
126
167
|
const subjects: SubjectId[] = [
|
|
127
168
|
{ kind: "tenant", tenantId: ctx.tenantId },
|
|
169
|
+
tenantRecordSubject,
|
|
128
170
|
...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
|
|
129
171
|
];
|
|
130
172
|
for (const subject of subjects) {
|
|
@@ -133,15 +175,26 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
|
|
|
133
175
|
eraseReason: "tenant-destroy stage subject-keys",
|
|
134
176
|
});
|
|
135
177
|
}
|
|
178
|
+
if (ctx.searchAdapter) {
|
|
179
|
+
await purgeSearchDocumentsForSubject(
|
|
180
|
+
ctx.db,
|
|
181
|
+
ctx.registry.features,
|
|
182
|
+
ctx.searchAdapter,
|
|
183
|
+
subjectIdToKey(tenantRecordSubject),
|
|
184
|
+
tenantRecordSubject,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return STAGE_DONE;
|
|
136
188
|
}
|
|
137
189
|
|
|
138
|
-
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<
|
|
190
|
+
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
139
191
|
// ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
|
|
140
192
|
// then this stage is a documented no-op (no cache layer in test stack).
|
|
141
193
|
ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
|
|
194
|
+
return STAGE_DONE;
|
|
142
195
|
}
|
|
143
196
|
|
|
144
|
-
async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<
|
|
197
|
+
async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
145
198
|
const now = getTemporal().Now.instant();
|
|
146
199
|
const user = createSystemUser(ctx.tenantId);
|
|
147
200
|
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
@@ -180,6 +233,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
|
180
233
|
{ skipOptimisticLock: true },
|
|
181
234
|
);
|
|
182
235
|
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
236
|
+
return STAGE_DONE;
|
|
183
237
|
}
|
|
184
238
|
|
|
185
239
|
export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
|
|
5
|
+
import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
|
|
6
|
+
import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
7
|
+
import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
8
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
9
|
+
import { tenantTable } from "../tenant/index.js";
|
|
10
|
+
import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_STARTED_EVENT_QN } from "./constants.js";
|
|
11
|
+
import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
|
|
12
|
+
|
|
13
|
+
// Sidesteps the `request-destruction` write handler (needs user/auth/sessions
|
|
14
|
+
// features wired) by seeding the same "destroying" state it would produce.
|
|
15
|
+
export async function seedDestroyingTenant(db: DbConnection, tenantId: TenantId): Promise<void> {
|
|
16
|
+
const now = getTemporal().Now.instant();
|
|
17
|
+
await updateRows(
|
|
18
|
+
db,
|
|
19
|
+
tenantTable,
|
|
20
|
+
{ status: "destroying", destroyStartedAt: now },
|
|
21
|
+
{ id: tenantId },
|
|
22
|
+
);
|
|
23
|
+
await append(db, {
|
|
24
|
+
aggregateId: tenantId,
|
|
25
|
+
aggregateType: TENANT_AGGREGATE_TYPE,
|
|
26
|
+
tenantId,
|
|
27
|
+
expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
|
|
28
|
+
type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
|
|
29
|
+
payload: { startedAt: now.toString() },
|
|
30
|
+
metadata: { userId: "system", requestId: "test:destruction-started" },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function driveDestructionToCompletion(
|
|
35
|
+
stack: TestStack,
|
|
36
|
+
db: DbConnection,
|
|
37
|
+
tenantId: TenantId,
|
|
38
|
+
searchAdapter?: SearchAdapter,
|
|
39
|
+
): Promise<string> {
|
|
40
|
+
const farFuture = getTemporal()
|
|
41
|
+
.Now.instant()
|
|
42
|
+
.add({ hours: 24 * 3650 });
|
|
43
|
+
let status = "";
|
|
44
|
+
for (let i = 0; i < 20; i++) {
|
|
45
|
+
await runTenantDestructionSweep({
|
|
46
|
+
db: stack.db,
|
|
47
|
+
registry: stack.registry,
|
|
48
|
+
now: farFuture,
|
|
49
|
+
searchAdapter,
|
|
50
|
+
});
|
|
51
|
+
const rows = await selectMany(db, tenantTable, { id: tenantId });
|
|
52
|
+
status = String(rows[0]?.["status"]);
|
|
53
|
+
if (status === "destroyed" || status === "destroyFailed") break;
|
|
54
|
+
}
|
|
55
|
+
return status;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { runTenantDestructionSweep };
|
|
@@ -67,6 +67,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
|
|
|
67
67
|
.refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
|
|
68
68
|
}),
|
|
69
69
|
access: { roles: ["SystemAdmin"] },
|
|
70
|
+
agent: { risk: "high" },
|
|
70
71
|
escapeHatch: {
|
|
71
72
|
grants: ["unsafeRaw"],
|
|
72
73
|
reason: SET_TENANT_TIER_REASON,
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "user:write:user:update resolves agent risk high"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.354.0",
|
|
9
|
+
"type": "fix",
|
|
10
|
+
"title": "user:query:user:list skips the tenants label when the tenant feature is not mounted"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.354.0",
|
|
14
|
+
"type": "fix",
|
|
15
|
+
"title": "user:query:user:detail skips the tenants label when the tenant feature is not mounted"
|
|
16
|
+
},
|
|
2
17
|
{
|
|
3
18
|
"version": "0.340.0",
|
|
4
19
|
"type": "improvement",
|
|
@@ -25,6 +25,7 @@ export const detailQuery = {
|
|
|
25
25
|
) => {
|
|
26
26
|
const result = await baseDetail.handler?.(query, ctx);
|
|
27
27
|
if (result === null || typeof result !== "object") return result;
|
|
28
|
+
if (!ctx.registry.features.has("tenant")) return result;
|
|
28
29
|
const [enriched] = await attachTenantLabels(
|
|
29
30
|
[result as Record<string, unknown>],
|
|
30
31
|
dbForList(ctx),
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
9
|
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
|
|
10
10
|
// kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
|
|
11
|
-
import { tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
|
|
11
|
+
import { decryptTenantNames, tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
|
|
12
12
|
import { userEntity } from "../schema/user.js";
|
|
13
13
|
|
|
14
14
|
const baseList = defineEntityListHandler("user", userEntity, {
|
|
@@ -82,7 +82,10 @@ export async function attachTenantLabels(
|
|
|
82
82
|
];
|
|
83
83
|
const tenants =
|
|
84
84
|
tenantIds.length > 0
|
|
85
|
-
? await
|
|
85
|
+
? await decryptTenantNames(
|
|
86
|
+
await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } }),
|
|
87
|
+
"user:list",
|
|
88
|
+
)
|
|
86
89
|
: [];
|
|
87
90
|
const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
|
|
88
91
|
return rows.map((row) => {
|
|
@@ -117,6 +120,9 @@ export const listQuery: QueryHandlerDef = {
|
|
|
117
120
|
return result;
|
|
118
121
|
}
|
|
119
122
|
const envelope = result as { rows: readonly Record<string, unknown>[] };
|
|
123
|
+
// Without the tenant feature there are no membership tables to join; with
|
|
124
|
+
// it, a failing lookup must surface instead of rendering a silent empty column.
|
|
125
|
+
if (!ctx.registry.features.has("tenant")) return result;
|
|
120
126
|
const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
|
|
121
127
|
return { ...envelope, rows };
|
|
122
128
|
},
|
|
@@ -59,6 +59,7 @@ export const updateWrite = defineWriteHandler({
|
|
|
59
59
|
},
|
|
60
60
|
description:
|
|
61
61
|
"Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
|
|
62
|
+
agent: { risk: "high" },
|
|
62
63
|
escapeHatch: {
|
|
63
64
|
reason: SYSTEM_ADMIN_DEMOTION_COUNT_REASON,
|
|
64
65
|
},
|
|
@@ -125,6 +125,40 @@ export async function reconcileSeededUserEmailVerified(
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Re-writes `email` through the executor so the PII write pipeline recomputes
|
|
129
|
+
// `email_bidx`. Repairs rows whose blind index is NULL or stale (written before
|
|
130
|
+
// the key existed, key rotated, subject-key erased), which would otherwise stay
|
|
131
|
+
// invisible to every `fetchOne(userTable, { email })` lookup.
|
|
132
|
+
export async function rewriteSeededUserEmail(
|
|
133
|
+
db: DbConnection,
|
|
134
|
+
existingRow: Record<string, unknown>,
|
|
135
|
+
email: string,
|
|
136
|
+
by: SessionUser,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const id = existingRow["id"];
|
|
139
|
+
if (typeof id !== "string") {
|
|
140
|
+
throw new Error("rewriteSeededUserEmail: existing user row has no string id");
|
|
141
|
+
}
|
|
142
|
+
const tdb = createTenantDb(db, by.tenantId, "system");
|
|
143
|
+
const result = await userExecutor.update(
|
|
144
|
+
{ id, version: existingRow["version"] as number, changes: { email } },
|
|
145
|
+
by,
|
|
146
|
+
tdb,
|
|
147
|
+
);
|
|
148
|
+
// version_conflict: a concurrent write already changed the row — fine for a seed helper.
|
|
149
|
+
// unique_violation: a duplicate row already holds the blind index; the repair
|
|
150
|
+
// has to wait for a manual duplicate cleanup.
|
|
151
|
+
if (
|
|
152
|
+
!result.isSuccess &&
|
|
153
|
+
result.error.code !== "version_conflict" &&
|
|
154
|
+
result.error.code !== "unique_violation"
|
|
155
|
+
) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`rewriteSeededUserEmail failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
128
162
|
// Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
|
|
129
163
|
// Executor liefert ein Record<string, unknown> (die Projection-Row), in
|
|
130
164
|
// das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
|
|
@@ -24,6 +24,13 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
|
|
|
24
24
|
if (usage.extensionName !== EXT_USER_DATA) continue;
|
|
25
25
|
const hasExport = typeof usage.options?.["export"] === "function";
|
|
26
26
|
const hasDelete = typeof usage.options?.["delete"] === "function";
|
|
27
|
+
// The compile-time "at least one hook" type is bypassed by string-typed
|
|
28
|
+
// extension names and casts; without this an empty bag registers nothing.
|
|
29
|
+
if (!hasExport && !hasDelete) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
27
34
|
if (hasExport && !hasDelete) {
|
|
28
35
|
throw new Error(
|
|
29
36
|
`[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Row-bound grants (deletion token, tenant handover) refuse a colon in anchor or subject",
|
|
6
|
+
"migration": "signRowBoundGrant now throws when the anchor or subject contains \":\", and redeem returns FAILED for such a token. Use colon-free ids (uuids) for anchor and subject; a purpose may still contain a colon."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.338.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -38,7 +38,7 @@ export function redeemDeletionToken(args: {
|
|
|
38
38
|
readonly secret: string | undefined;
|
|
39
39
|
readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
|
|
40
40
|
// Spends the anchor AND performs the actual lifecycle transition in one
|
|
41
|
-
// atomic step
|
|
41
|
+
// atomic step — the caller (confirm-deletion-by-token) folds the
|
|
42
42
|
// Active→DeletionRequested write itself in here via `updateUserLifecycle`'s
|
|
43
43
|
// `expect: { status: Active, pendingDeletionRequestId }`, so a write issued
|
|
44
44
|
// after `ok: true` can't lose its work to a crash while the grant is
|
package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts
CHANGED
|
@@ -37,7 +37,7 @@ async function readPendingDeletionRequestId(
|
|
|
37
37
|
|
|
38
38
|
// Anonymous apex flow step 2: verify-link target. Verifies the HMAC token,
|
|
39
39
|
// extracts the userId, and flips the grace period through the shared logic —
|
|
40
|
-
// in ONE atomic step with the anchor spend
|
|
40
|
+
// in ONE atomic step with the anchor spend: commitDeletion carries
|
|
41
41
|
// the actual grace-period transition, so a crash after the spend can no
|
|
42
42
|
// longer lose the write step.
|
|
43
43
|
//
|
|
@@ -47,7 +47,7 @@ async function readPendingDeletionRequestId(
|
|
|
47
47
|
// a cancel-deletion (status → Active, pendingDeletionRequestId → null), a
|
|
48
48
|
// replayed token fails against the nulled/renewed requestId — no re-arm.
|
|
49
49
|
//
|
|
50
|
-
// Concurrency
|
|
50
|
+
// Concurrency: two simultaneous confirms of the same token both read
|
|
51
51
|
// the same requestId and both verify the HMAC — commitDeletion spends the
|
|
52
52
|
// anchor AND writes the transition atomically (expect: status===Active &&
|
|
53
53
|
// pendingDeletionRequestId===requestId), so exactly one wins. The loser gets
|
|
@@ -51,6 +51,7 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
|
|
|
51
51
|
},
|
|
52
52
|
},
|
|
53
53
|
escapeHatch: {
|
|
54
|
+
grants: ["systemIdentity", "unsafeRaw"],
|
|
54
55
|
reason:
|
|
55
56
|
"resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
|
|
56
57
|
"...) to compute the grace period end — the calling user's own identity has no read " +
|
|
@@ -35,16 +35,24 @@ export const downloadAttemptEntity = createEntity({
|
|
|
35
35
|
jobId: createTextField({ personal: false, reason: "technical_reference" }),
|
|
36
36
|
// User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
|
|
37
37
|
attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// is null-by-design for the anonymous token path, so a subject annotation
|
|
43
|
-
// (`personal: { of: "attemptedByUserId" }`) would throw
|
|
44
|
-
// SubjectResolutionError on every such row once a KMS is configured
|
|
38
|
+
// No subject annotation on this buildEntityTable-managed entity:
|
|
39
|
+
// attemptedByUserId is null-by-design for the anonymous token path, so
|
|
40
|
+
// `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
|
|
41
|
+
// on every such row once a KMS is configured
|
|
45
42
|
// (crypto/subject-resolver.ts resolveUserOwnedSubject).
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
// The job path knows the requester and an IP is personal data, so this is a
|
|
44
|
+
// deliberate waiver, not business data: a user forget does not shred these
|
|
45
|
+
// values, the 90d hardDelete below is what removes them.
|
|
46
|
+
ip: createTextField({
|
|
47
|
+
maxLength: 64,
|
|
48
|
+
personal: false,
|
|
49
|
+
reason: "security_audit_retention_bounded",
|
|
50
|
+
}),
|
|
51
|
+
userAgent: createTextField({
|
|
52
|
+
maxLength: 256,
|
|
53
|
+
personal: false,
|
|
54
|
+
reason: "security_audit_retention_bounded",
|
|
55
|
+
}),
|
|
48
56
|
attemptedAt: createTimestampField({ required: true }),
|
|
49
57
|
},
|
|
50
58
|
// 90d hardDelete: unbounded growth = disk-bomb genau gegen das System
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
access,
|
|
3
2
|
createSystemUser,
|
|
4
3
|
defineWriteHandler,
|
|
5
4
|
SYSTEM_TENANT_ID,
|
|
@@ -36,8 +35,15 @@ export const changeEmailWrite = defineWriteHandler({
|
|
|
36
35
|
currentPassword: z.string().min(1),
|
|
37
36
|
newEmail: z.email(),
|
|
38
37
|
}),
|
|
39
|
-
access: {
|
|
38
|
+
access: {
|
|
39
|
+
openToAll: {
|
|
40
|
+
reason:
|
|
41
|
+
"self-service for any signed-in user, including tenant members whose only role is " +
|
|
42
|
+
"TenantAdmin; the handler only touches event.user.id and re-checks the caller's password",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
40
45
|
escapeHatch: {
|
|
46
|
+
grants: ["systemIdentity"],
|
|
41
47
|
reason:
|
|
42
48
|
"Reads the caller's own passwordHash via ctx.queryAs(SYSTEM, user:findForAuth), checks " +
|
|
43
49
|
"email uniqueness the same way, and writes the new email via ctx.writeAs(SYSTEM, " +
|
|
@@ -12,22 +12,87 @@
|
|
|
12
12
|
import type {
|
|
13
13
|
FeatureRegistrar,
|
|
14
14
|
MultiStreamProjectionDefinition,
|
|
15
|
+
StepInstance,
|
|
15
16
|
WorkflowDefinition,
|
|
16
17
|
WriteEvent,
|
|
17
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import {
|
|
20
|
+
buildPipelineSteps,
|
|
21
|
+
createSystemUser,
|
|
19
22
|
describeWorkflowStepError,
|
|
23
|
+
getStep,
|
|
24
|
+
SYSTEM_TENANT_ID,
|
|
25
|
+
type TenantId,
|
|
20
26
|
WORKFLOW_AGGREGATE_TYPE,
|
|
21
27
|
WORKFLOW_RUN_FAILED_TYPE,
|
|
28
|
+
WorkflowStepError,
|
|
22
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
23
30
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
24
31
|
import { workflowRunAggregateId } from "./aggregate-id.js";
|
|
25
32
|
import { registerEventWakeup } from "./event-subscriber.js";
|
|
26
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
startAndRunWorkflow,
|
|
35
|
+
type WorkflowRunFailedPayload,
|
|
36
|
+
WorkflowSuspensionUnsupportedError,
|
|
37
|
+
} from "./runner.js";
|
|
27
38
|
import { registerWorkflow } from "./workflow-registry.js";
|
|
28
39
|
|
|
29
40
|
const log = createFallbackLogger("workflow-runner");
|
|
30
41
|
|
|
42
|
+
// The MSP apply context only offers unsafeAppendEvent/loadAggregate; these steps reach for
|
|
43
|
+
// ctx.db/write/writeAs and would crash on the first run instead of at registration.
|
|
44
|
+
const STEP_KINDS_NEEDING_HANDLER_CONTEXT: ReadonlySet<string> = new Set([
|
|
45
|
+
"aggregate.create",
|
|
46
|
+
"aggregate.update",
|
|
47
|
+
"callFeature",
|
|
48
|
+
"read.findOne",
|
|
49
|
+
"read.findMany",
|
|
50
|
+
"unsafeProjectionUpsert",
|
|
51
|
+
"unsafeProjectionDelete",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function* walkStepInstances(steps: readonly StepInstance[]): Generator<StepInstance, void, void> {
|
|
55
|
+
for (const step of steps) {
|
|
56
|
+
yield step;
|
|
57
|
+
const { args } = step;
|
|
58
|
+
if (typeof args !== "object" || args === null) continue;
|
|
59
|
+
for (const path of getStep(step.kind)?.subPaths ?? []) {
|
|
60
|
+
const nested: unknown = path in args ? Reflect.get(args, path) : undefined;
|
|
61
|
+
if (Array.isArray(nested)) yield* walkStepInstances(nested);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function assertEventTriggerStepsSupported(workflow: WorkflowDefinition): void {
|
|
67
|
+
let steps: readonly StepInstance[];
|
|
68
|
+
try {
|
|
69
|
+
// Same contract as the boot validator for handler pipelines: the closure must build its
|
|
70
|
+
// step list from the step builder alone, so an empty event is enough to enumerate kinds.
|
|
71
|
+
steps = buildPipelineSteps(workflow.pipelineDef, {
|
|
72
|
+
type: "workflow-registration-probe",
|
|
73
|
+
payload: {},
|
|
74
|
+
user: createSystemUser(SYSTEM_TENANT_ID as TenantId),
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Workflow "${workflow.name}" pipeline closure threw at registration: ${String(error)}. ` +
|
|
79
|
+
"Build the step list without reading event payload fields; read them inside step resolvers.",
|
|
80
|
+
{ cause: error },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const step of walkStepInstances(steps)) {
|
|
84
|
+
if (getStep(step.kind) === undefined) {
|
|
85
|
+
throw new Error(`Workflow "${workflow.name}" uses unknown step kind "${step.kind}"`);
|
|
86
|
+
}
|
|
87
|
+
if (STEP_KINDS_NEEDING_HANDLER_CONTEXT.has(step.kind)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Workflow "${workflow.name}" is event-triggered but uses step "${step.kind}", which needs a ` +
|
|
90
|
+
"handler context (db/write) the event-trigger projection does not provide.",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
31
96
|
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
32
97
|
// Populate the workflow-registry unconditionally, before the event-trigger
|
|
33
98
|
// guard below — resume-run (framework#2513 Phase 2) looks workflows up by
|
|
@@ -43,6 +108,8 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
43
108
|
// need a scheduler, not an MSP, so there is nothing to register here.
|
|
44
109
|
if (workflow.trigger.kind !== "event") return;
|
|
45
110
|
|
|
111
|
+
assertEventTriggerStepsSupported(workflow);
|
|
112
|
+
|
|
46
113
|
const eventType = workflow.trigger.eventType;
|
|
47
114
|
|
|
48
115
|
r.multiStreamProjection({
|
|
@@ -56,12 +123,6 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
56
123
|
// closure does not carry that narrowing, and re-narrowing here keeps
|
|
57
124
|
// `trigger.filter` below typed without a cast.
|
|
58
125
|
if (workflow.trigger.kind !== "event") return;
|
|
59
|
-
if (workflow.trigger.filter) {
|
|
60
|
-
const matches = workflow.trigger.filter(event as never);
|
|
61
|
-
// skip: the workflow's own trigger filter rejected this event — not
|
|
62
|
-
// this run's concern, so no run is started and nothing is recorded.
|
|
63
|
-
if (!matches) return;
|
|
64
|
-
}
|
|
65
126
|
|
|
66
127
|
// @cast-boundary msp-to-write-event — the MSP delivers a StoredEvent
|
|
67
128
|
// (event-store shape); the workflow runner expects a WriteEvent
|
|
@@ -69,6 +130,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
69
130
|
// overlap exactly — the missing `.user` field is acceptable because
|
|
70
131
|
// workflow triggers run system-level, not user-scoped.
|
|
71
132
|
const triggerEvent = event as unknown as WriteEvent;
|
|
133
|
+
// skip: the workflow's own trigger filter rejected this event — not this run's
|
|
134
|
+
// concern, so no run is started and nothing is recorded.
|
|
135
|
+
if (workflow.trigger.filter && !workflow.trigger.filter(triggerEvent)) return;
|
|
72
136
|
let idempotencyKey: string | undefined;
|
|
73
137
|
if (typeof workflow.idempotencyKey === "function") {
|
|
74
138
|
idempotencyKey = workflow.idempotencyKey(triggerEvent);
|
|
@@ -91,22 +155,24 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
91
155
|
version: event.version,
|
|
92
156
|
},
|
|
93
157
|
...(idempotencyKey && { idempotencyKey }),
|
|
94
|
-
|
|
95
|
-
// exposes unsafeAppendEvent/loadAggregate, a subset of
|
|
96
|
-
// HandlerContext. The step vocabulary this runner drives (wait,
|
|
97
|
-
// compute, return) only ever calls unsafeAppendEvent on it.
|
|
98
|
-
handlerCtx: ctx as never,
|
|
158
|
+
handlerCtx: ctx,
|
|
99
159
|
});
|
|
100
160
|
} catch (error) {
|
|
161
|
+
// Failures outside the step loop (run-started append) have no step yet, so 0.
|
|
162
|
+
const stepIndex =
|
|
163
|
+
error instanceof WorkflowStepError ||
|
|
164
|
+
error instanceof WorkflowSuspensionUnsupportedError
|
|
165
|
+
? error.stepIndex
|
|
166
|
+
: 0;
|
|
101
167
|
log.warn("workflow run failed", {
|
|
102
168
|
runId,
|
|
103
169
|
workflowName: workflow.name,
|
|
104
|
-
stepIndex
|
|
170
|
+
stepIndex,
|
|
105
171
|
error: String(error),
|
|
106
172
|
});
|
|
107
173
|
const failedPayload: WorkflowRunFailedPayload = {
|
|
108
174
|
workflowName: workflow.name,
|
|
109
|
-
stepIndex
|
|
175
|
+
stepIndex,
|
|
110
176
|
error: describeWorkflowStepError(error),
|
|
111
177
|
};
|
|
112
178
|
await ctx.unsafeAppendEvent({
|