@cosmicdrift/kumiko-samples 0.353.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 +11 -5
- 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/tool-dispatch.ts +1 -1
- 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 +5 -0
- 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/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 +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- 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/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/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 +72 -23
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- 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 +1 -1
- 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/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 +13 -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/mail-transport-smtp/feature.ts +4 -15
- 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/personal-access-tokens/handlers/create.write.ts +1 -0
- 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/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- 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/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 +12 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- 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/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- 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 +13 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
- 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 +3 -0
- 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/changes.json +6 -0
- 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/event-trigger.ts +72 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -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/styleguide/src/features/widgets/feature.ts +83 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
- 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/seed-migration/README.md +0 -2
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
isTenantResourceExtensionHooks,
|
|
23
23
|
type Registry,
|
|
24
24
|
type TenantDataHookCtx,
|
|
25
|
+
type TenantDestroyHookResult,
|
|
25
26
|
type TenantId,
|
|
26
27
|
type TenantResourceExtensionName,
|
|
27
28
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -61,12 +62,18 @@ export type DestructionStageCtx = {
|
|
|
61
62
|
// EXT_TENANT_DATA usage's declared escapeHatch.
|
|
62
63
|
readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
|
|
63
64
|
readonly actor?: string;
|
|
65
|
+
// Epoch ms the stage must hand back by returning { done: false }.
|
|
66
|
+
readonly deadlineAt: number;
|
|
64
67
|
};
|
|
65
68
|
|
|
69
|
+
export type StageRunOutcome = { readonly done: boolean; readonly processed: number };
|
|
70
|
+
|
|
71
|
+
const STAGE_DONE: StageRunOutcome = { done: true, processed: 0 };
|
|
72
|
+
|
|
66
73
|
export type DestructionStage = {
|
|
67
74
|
readonly name: TenantDestructionStageName;
|
|
68
75
|
readonly maxAttempts: number;
|
|
69
|
-
readonly run: (ctx: DestructionStageCtx) => Promise<
|
|
76
|
+
readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
|
|
70
77
|
};
|
|
71
78
|
|
|
72
79
|
const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
|
|
@@ -78,24 +85,44 @@ const tenantMembershipCrud = createEventStoreExecutor(
|
|
|
78
85
|
},
|
|
79
86
|
);
|
|
80
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
|
+
|
|
81
105
|
async function runExtensionDestroyHooks(
|
|
82
106
|
registry: Registry,
|
|
83
107
|
extensionName: TenantResourceExtensionName,
|
|
84
108
|
ctx: DestructionStageCtx,
|
|
85
|
-
): Promise<
|
|
109
|
+
): Promise<StageRunOutcome> {
|
|
86
110
|
const usages = registry.getExtensionUsages(extensionName);
|
|
111
|
+
const total = newOutcomeAccumulator();
|
|
87
112
|
for (const usage of usages) {
|
|
88
113
|
if (!isTenantResourceExtensionHooks(usage.options)) {
|
|
89
114
|
throw new Error(
|
|
90
115
|
`${extensionName} registration for "${usage.entityName}" has no destroy function`,
|
|
91
116
|
);
|
|
92
117
|
}
|
|
93
|
-
await usage.options.destroyTenant(ctx.tenantId, ctx);
|
|
118
|
+
total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
|
|
94
119
|
}
|
|
120
|
+
return total.outcome();
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<
|
|
123
|
+
async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
98
124
|
const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
|
|
125
|
+
const total = newOutcomeAccumulator();
|
|
99
126
|
for (const usage of usages) {
|
|
100
127
|
if (!isTenantDataExtensionHooks(usage.options)) {
|
|
101
128
|
throw new Error(
|
|
@@ -117,19 +144,21 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
|
|
|
117
144
|
}),
|
|
118
145
|
registry: ctx.registry,
|
|
119
146
|
tenantId: ctx.tenantId,
|
|
147
|
+
deadlineAt: ctx.deadlineAt,
|
|
120
148
|
fileProviderResolver: ctx.fileProviderResolver,
|
|
121
149
|
log: ctx.log,
|
|
122
150
|
};
|
|
123
|
-
await destroy(hookCtx);
|
|
151
|
+
total.add(await destroy(hookCtx));
|
|
124
152
|
}
|
|
153
|
+
return total.outcome();
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<
|
|
156
|
+
async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
128
157
|
const kms = configuredPiiSubjectKms();
|
|
129
158
|
if (!kms) {
|
|
130
159
|
ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
|
|
131
160
|
// skip: KMS optional — apps without crypto-shredding still run other destroy stages
|
|
132
|
-
return;
|
|
161
|
+
return STAGE_DONE;
|
|
133
162
|
}
|
|
134
163
|
const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
|
|
135
164
|
tenantId: ctx.tenantId,
|
|
@@ -155,15 +184,17 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
|
|
|
155
184
|
tenantRecordSubject,
|
|
156
185
|
);
|
|
157
186
|
}
|
|
187
|
+
return STAGE_DONE;
|
|
158
188
|
}
|
|
159
189
|
|
|
160
|
-
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<
|
|
190
|
+
async function purgeTenantCache(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
161
191
|
// ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
|
|
162
192
|
// then this stage is a documented no-op (no cache layer in test stack).
|
|
163
193
|
ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
|
|
194
|
+
return STAGE_DONE;
|
|
164
195
|
}
|
|
165
196
|
|
|
166
|
-
async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<
|
|
197
|
+
async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
|
|
167
198
|
const now = getTemporal().Now.instant();
|
|
168
199
|
const user = createSystemUser(ctx.tenantId);
|
|
169
200
|
const db = createTenantDb(ctx.db, ctx.tenantId, "system");
|
|
@@ -202,6 +233,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
|
|
|
202
233
|
{ skipOptimisticLock: true },
|
|
203
234
|
);
|
|
204
235
|
invalidateTenantLifecycleGate(ctx.tenantId);
|
|
236
|
+
return STAGE_DONE;
|
|
205
237
|
}
|
|
206
238
|
|
|
207
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),
|
|
@@ -120,6 +120,9 @@ export const listQuery: QueryHandlerDef = {
|
|
|
120
120
|
return result;
|
|
121
121
|
}
|
|
122
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;
|
|
123
126
|
const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
|
|
124
127
|
return { ...envelope, rows };
|
|
125
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
|
|
@@ -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",
|
|
@@ -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,13 +12,20 @@
|
|
|
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";
|
|
@@ -32,6 +39,60 @@ import { registerWorkflow } from "./workflow-registry.js";
|
|
|
32
39
|
|
|
33
40
|
const log = createFallbackLogger("workflow-runner");
|
|
34
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
|
+
|
|
35
96
|
export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
|
|
36
97
|
// Populate the workflow-registry unconditionally, before the event-trigger
|
|
37
98
|
// guard below — resume-run (framework#2513 Phase 2) looks workflows up by
|
|
@@ -47,6 +108,8 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
47
108
|
// need a scheduler, not an MSP, so there is nothing to register here.
|
|
48
109
|
if (workflow.trigger.kind !== "event") return;
|
|
49
110
|
|
|
111
|
+
assertEventTriggerStepsSupported(workflow);
|
|
112
|
+
|
|
50
113
|
const eventType = workflow.trigger.eventType;
|
|
51
114
|
|
|
52
115
|
r.multiStreamProjection({
|
|
@@ -60,12 +123,6 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
60
123
|
// closure does not carry that narrowing, and re-narrowing here keeps
|
|
61
124
|
// `trigger.filter` below typed without a cast.
|
|
62
125
|
if (workflow.trigger.kind !== "event") return;
|
|
63
|
-
if (workflow.trigger.filter) {
|
|
64
|
-
const matches = workflow.trigger.filter(event as never);
|
|
65
|
-
// skip: the workflow's own trigger filter rejected this event — not
|
|
66
|
-
// this run's concern, so no run is started and nothing is recorded.
|
|
67
|
-
if (!matches) return;
|
|
68
|
-
}
|
|
69
126
|
|
|
70
127
|
// @cast-boundary msp-to-write-event — the MSP delivers a StoredEvent
|
|
71
128
|
// (event-store shape); the workflow runner expects a WriteEvent
|
|
@@ -73,6 +130,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
73
130
|
// overlap exactly — the missing `.user` field is acceptable because
|
|
74
131
|
// workflow triggers run system-level, not user-scoped.
|
|
75
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;
|
|
76
136
|
let idempotencyKey: string | undefined;
|
|
77
137
|
if (typeof workflow.idempotencyKey === "function") {
|
|
78
138
|
idempotencyKey = workflow.idempotencyKey(triggerEvent);
|
|
@@ -95,17 +155,15 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
|
|
|
95
155
|
version: event.version,
|
|
96
156
|
},
|
|
97
157
|
...(idempotencyKey && { idempotencyKey }),
|
|
98
|
-
|
|
99
|
-
// exposes unsafeAppendEvent/loadAggregate, a subset of
|
|
100
|
-
// HandlerContext. The step vocabulary this runner drives (wait,
|
|
101
|
-
// compute, return) only ever calls unsafeAppendEvent on it.
|
|
102
|
-
handlerCtx: ctx as never,
|
|
158
|
+
handlerCtx: ctx,
|
|
103
159
|
});
|
|
104
160
|
} catch (error) {
|
|
105
|
-
//
|
|
106
|
-
// carries no index, so it still reports 0.
|
|
161
|
+
// Failures outside the step loop (run-started append) have no step yet, so 0.
|
|
107
162
|
const stepIndex =
|
|
108
|
-
error instanceof
|
|
163
|
+
error instanceof WorkflowStepError ||
|
|
164
|
+
error instanceof WorkflowSuspensionUnsupportedError
|
|
165
|
+
? error.stepIndex
|
|
166
|
+
: 0;
|
|
109
167
|
log.warn("workflow run failed", {
|
|
110
168
|
runId,
|
|
111
169
|
workflowName: workflow.name,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
WORKFLOW_RUN_STARTED_TYPE,
|
|
49
49
|
WORKFLOW_WAITING_FOR_EVENT_TYPE,
|
|
50
50
|
type WorkflowDefinition,
|
|
51
|
+
WorkflowStepError,
|
|
51
52
|
type WriteEvent,
|
|
52
53
|
type WriteHandlerDef,
|
|
53
54
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -392,9 +393,13 @@ async function runResumedPipeline(ctx: HandlerContext, input: ResumedPipelineInp
|
|
|
392
393
|
return { isSuccess: true as const, data: { outcome: "completed" as const } };
|
|
393
394
|
} catch (error) {
|
|
394
395
|
log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
|
|
396
|
+
// The input stepIndex is the suspended step; the failure happened at the step the resumed pass reached.
|
|
395
397
|
const failedPayload: WorkflowRunFailedPayload = {
|
|
396
398
|
workflowName,
|
|
397
|
-
stepIndex
|
|
399
|
+
stepIndex:
|
|
400
|
+
error instanceof WorkflowStepError || error instanceof WorkflowSuspensionUnsupportedError
|
|
401
|
+
? error.stepIndex
|
|
402
|
+
: stepIndex,
|
|
398
403
|
error: describeWorkflowStepError(error),
|
|
399
404
|
};
|
|
400
405
|
await appendRunFailed(ctx, runId, failedPayload);
|
|
@@ -75,6 +75,8 @@ export type WorkflowRunFailedPayload = {
|
|
|
75
75
|
readonly reason?: string;
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
export type WorkflowRunContext = Pick<HandlerContext, "unsafeAppendEvent">;
|
|
79
|
+
|
|
78
80
|
export class WorkflowSuspensionUnsupportedError extends Error {
|
|
79
81
|
constructor(
|
|
80
82
|
readonly workflowName: string,
|
|
@@ -102,7 +104,7 @@ export async function startAndRunWorkflow(args: {
|
|
|
102
104
|
readonly triggerEvent: WriteEvent;
|
|
103
105
|
readonly triggerEventRef: TriggerEventRef;
|
|
104
106
|
readonly idempotencyKey?: string;
|
|
105
|
-
readonly handlerCtx:
|
|
107
|
+
readonly handlerCtx: WorkflowRunContext;
|
|
106
108
|
}): Promise<{ readonly outcome: "completed" | "suspended" }> {
|
|
107
109
|
const fingerprint = computeDefinitionFingerprint(args.workflow);
|
|
108
110
|
|
|
@@ -123,10 +125,14 @@ export async function startAndRunWorkflow(args: {
|
|
|
123
125
|
|
|
124
126
|
const steps = buildPipelineSteps(args.workflow.pipelineDef, args.triggerEvent);
|
|
125
127
|
|
|
128
|
+
// @cast-boundary msp-to-handler-ctx — event-trigger hands over the narrower
|
|
129
|
+
// MultiStreamApplyContext (no query/write/writeAs). The step vocabulary this
|
|
130
|
+
// runner drives (wait, compute, return) only ever calls unsafeAppendEvent.
|
|
131
|
+
const stepCtx = args.handlerCtx as unknown as HandlerContext;
|
|
126
132
|
const outcome = await runStepList(
|
|
127
133
|
steps,
|
|
128
134
|
args.triggerEvent,
|
|
129
|
-
|
|
135
|
+
stepCtx,
|
|
130
136
|
{},
|
|
131
137
|
{},
|
|
132
138
|
{
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// hängt hier die Handler dran und ruft r.screen/r.nav.
|
|
9
9
|
|
|
10
10
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
11
12
|
import { itemsTranslations } from "./i18n";
|
|
12
13
|
import {
|
|
13
14
|
itemActiveScreen,
|
|
@@ -29,7 +30,7 @@ const open = {
|
|
|
29
30
|
|
|
30
31
|
// The boot validator checks server-registered keys, while the client bundle is
|
|
31
32
|
// locale-first — flip it rather than maintaining the same strings twice.
|
|
32
|
-
function keyFirst(byLocale:
|
|
33
|
+
function keyFirst(byLocale: TranslationsByLocale): Record<string, Record<string, string>> {
|
|
33
34
|
const out: Record<string, Record<string, string>> = {};
|
|
34
35
|
for (const [locale, entries] of Object.entries(byLocale)) {
|
|
35
36
|
for (const [key, value] of Object.entries(entries)) {
|