@cosmicdrift/kumiko-samples 0.351.0 → 0.353.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.
Files changed (114) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +2 -2
  3. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +15 -2
  4. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  5. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  6. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  7. package/packages/bundled-features/src/audit/changes.json +6 -0
  8. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  9. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +2 -3
  10. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  11. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +3 -4
  12. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  13. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  14. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +95 -54
  15. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +247 -0
  16. package/packages/bundled-features/src/cap-counter/changes.json +28 -0
  17. package/packages/bundled-features/src/cap-counter/constants.ts +8 -0
  18. package/packages/bundled-features/src/cap-counter/feature.ts +7 -0
  19. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +5 -0
  20. package/packages/bundled-features/src/cap-counter/index.ts +9 -0
  21. package/packages/bundled-features/src/cap-counter/payload-field.ts +10 -0
  22. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +51 -0
  23. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +3 -0
  24. package/packages/bundled-features/src/cap-counter/tables.ts +53 -0
  25. package/packages/bundled-features/src/cap-counter/value-cap-guard.ts +70 -0
  26. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +63 -70
  27. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  28. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  29. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  30. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  31. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  33. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  34. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  35. package/packages/bundled-features/src/delivery/attempt-log-retention.ts +40 -0
  36. package/packages/bundled-features/src/delivery/attempt-log.ts +26 -3
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/constants.ts +1 -0
  39. package/packages/bundled-features/src/delivery/events.ts +3 -0
  40. package/packages/bundled-features/src/delivery/feature.ts +50 -2
  41. package/packages/bundled-features/src/delivery/index.ts +6 -0
  42. package/packages/bundled-features/src/delivery/mask-recipient-address.ts +40 -0
  43. package/packages/bundled-features/src/delivery/public-names.ts +6 -1
  44. package/packages/bundled-features/src/delivery/types.ts +12 -2
  45. package/packages/bundled-features/src/derivatives-sharp/changes.json +6 -0
  46. package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
  47. package/packages/bundled-features/src/folders/feature.ts +2 -12
  48. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  49. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  50. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  51. package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
  52. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  53. package/packages/bundled-features/src/ledger/entity.ts +4 -0
  54. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  55. package/packages/bundled-features/src/legal-pages/README.md +3 -3
  56. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  57. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  58. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  59. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  60. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  61. package/packages/bundled-features/src/renderer-foundation/types.ts +1 -1
  62. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  63. package/packages/bundled-features/src/renderer-simple/feature.ts +4 -6
  64. package/packages/bundled-features/src/renderer-simple/index.ts +1 -0
  65. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +138 -0
  66. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +33 -4
  67. package/packages/bundled-features/src/seo/changes.json +5 -0
  68. package/packages/bundled-features/src/seo/feature.ts +23 -7
  69. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  70. package/packages/bundled-features/src/shared/index.ts +2 -1
  71. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  72. package/packages/bundled-features/src/tags/feature.ts +2 -12
  73. package/packages/bundled-features/src/tenant/changes.json +7 -0
  74. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  75. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  76. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  77. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  78. package/packages/bundled-features/src/tenant/index.ts +1 -0
  79. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  80. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  81. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  82. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  83. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  85. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
  86. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  87. package/packages/bundled-features/src/tier-engine/index.ts +7 -0
  88. package/packages/bundled-features/src/tier-engine/tier-option-gate.ts +148 -0
  89. package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
  90. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  91. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  92. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  93. package/packages/bundled-features/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +36 -6
  94. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  95. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
  96. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
  97. package/samples/apps/marketing-demo/src/app/seed-data.ts +2 -2
  98. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  99. package/samples/apps/styleguide/src/app/shell.tsx +1 -1
  100. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +9 -3
  101. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  102. package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
  103. package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
  104. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  105. package/samples/apps/ui-walkthrough/src/app/assistant-action.tsx +6 -5
  106. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -2
  107. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  108. package/samples/recipes/files-post-processing/package.json +1 -1
  109. package/samples/recipes/public-share-token/README.md +4 -5
  110. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  111. package/samples/recipes/security-baseline/README.md +6 -0
  112. package/samples/recipes/seed-migration/README.md +13 -1
  113. package/samples/recipes/tenant-settings/README.md +1 -1
  114. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -14,6 +14,7 @@ import {
14
14
  VersionConflictError,
15
15
  } from "@cosmicdrift/kumiko-framework/event-store";
16
16
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
17
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
17
18
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
18
19
  import { tenantEntity, tenantTable } from "../tenant/index.js";
19
20
  import {
@@ -222,6 +223,7 @@ export async function runNextDestructionStage(args: {
222
223
  readonly tenantId: TenantId;
223
224
  readonly log?: (message: string) => void;
224
225
  readonly fileProviderResolver?: FileProviderResolver;
226
+ readonly searchAdapter?: SearchAdapter;
225
227
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
226
228
  readonly actor?: string;
227
229
  }): Promise<{ readonly done: boolean; readonly error?: string; readonly halted?: boolean }> {
@@ -246,6 +248,7 @@ export async function runNextDestructionStage(args: {
246
248
  tenantId: args.tenantId,
247
249
  log: args.log,
248
250
  fileProviderResolver: args.fileProviderResolver,
251
+ searchAdapter: args.searchAdapter,
249
252
  escapeHatchAuditSink: args.escapeHatchAuditSink,
250
253
  actor: args.actor,
251
254
  };
@@ -334,6 +337,7 @@ export async function runTenantDestructionSweep(args: {
334
337
  readonly now?: Temporal.Instant;
335
338
  readonly log?: (message: string) => void;
336
339
  readonly fileProviderResolver?: FileProviderResolver;
340
+ readonly searchAdapter?: SearchAdapter;
337
341
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
338
342
  readonly actor?: string;
339
343
  }): Promise<{ readonly triggered: number; readonly advanced: number }> {
@@ -380,6 +384,7 @@ export async function runTenantDestructionSweep(args: {
380
384
  tenantId: row.id as TenantId,
381
385
  log: args.log,
382
386
  fileProviderResolver: args.fileProviderResolver,
387
+ searchAdapter: args.searchAdapter,
383
388
  escapeHatchAuditSink: args.escapeHatchAuditSink,
384
389
  actor: args.actor,
385
390
  });
@@ -1,5 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import {
3
+ configuredPiiSubjectKms,
4
+ type SubjectId,
5
+ subjectIdToKey,
6
+ } from "@cosmicdrift/kumiko-framework/crypto";
3
7
  import {
4
8
  createEventStoreExecutor,
5
9
  createTenantDb,
@@ -26,6 +30,10 @@ import {
26
30
  createEscapeHatchReporter,
27
31
  UNATTRIBUTED_ACTOR,
28
32
  } from "@cosmicdrift/kumiko-framework/pipeline";
33
+ import {
34
+ purgeSearchDocumentsForSubject,
35
+ type SearchAdapter,
36
+ } from "@cosmicdrift/kumiko-framework/search";
29
37
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
30
38
  import {
31
39
  tenantEntity,
@@ -45,6 +53,9 @@ export type DestructionStageCtx = {
45
53
  // "destroyTenant" hooks) — undefined when no file-provider is wired,
46
54
  // which those hooks must treat as "nothing to clean up", not an error.
47
55
  readonly fileProviderResolver?: FileProviderResolver;
56
+ // Derived search docs of the tenant record (its name) live in the writer's
57
+ // index, not the destroyed tenant's, so the subject-keys stage purges them.
58
+ readonly searchAdapter?: SearchAdapter;
48
59
  // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
49
60
  // systemUser.id); runTenantDataHooks uses them to attribute+audit any
50
61
  // EXT_TENANT_DATA usage's declared escapeHatch.
@@ -123,8 +134,10 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
123
134
  const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
124
135
  tenantId: ctx.tenantId,
125
136
  });
137
+ const tenantRecordSubject: SubjectId = { kind: "record", entity: "tenant", id: ctx.tenantId };
126
138
  const subjects: SubjectId[] = [
127
139
  { kind: "tenant", tenantId: ctx.tenantId },
140
+ tenantRecordSubject,
128
141
  ...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
129
142
  ];
130
143
  for (const subject of subjects) {
@@ -133,6 +146,15 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
133
146
  eraseReason: "tenant-destroy stage subject-keys",
134
147
  });
135
148
  }
149
+ if (ctx.searchAdapter) {
150
+ await purgeSearchDocumentsForSubject(
151
+ ctx.db,
152
+ ctx.registry.features,
153
+ ctx.searchAdapter,
154
+ subjectIdToKey(tenantRecordSubject),
155
+ tenantRecordSubject,
156
+ );
157
+ }
136
158
  }
137
159
 
138
160
  async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "createTierOptionGate builds the availability rows and gates select options by tier on write",
6
+ "detail": "withTierOptionGate rejects a disallowed option with the lowest tier that allows it and lets an unchanged stored value pass on update."
7
+ },
2
8
  {
3
9
  "version": "0.278.0",
4
10
  "type": "breaking",
@@ -29,6 +29,13 @@ export {
29
29
  createTierEngineFeature,
30
30
  tierEngineFeature,
31
31
  } from "./feature.js";
32
+ export {
33
+ createTierOptionGate,
34
+ type TierOptionAvailability,
35
+ type TierOptionGate,
36
+ type TierOptionGateDeps,
37
+ type TierOptionSpec,
38
+ } from "./tier-option-gate.js";
32
39
  export {
33
40
  createTierResolver,
34
41
  type TierResolver,
@@ -0,0 +1,148 @@
1
+ // Tier-gated options of a select field: one spec drives the availability query the
2
+ // renderer shows (disabled option + "from <tier>" hint) and the write-path check that
3
+ // actually enforces it, so the two cannot drift apart.
4
+
5
+ import type { EntityTableMeta, SchemaTable, TenantDb } from "@cosmicdrift/kumiko-framework/db";
6
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
7
+ import {
8
+ UnprocessableError,
9
+ type WriteFailure,
10
+ writeFailure,
11
+ } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { isPlainObject } from "@cosmicdrift/kumiko-framework/utils";
13
+ import {
14
+ type CapGuardContext,
15
+ type CapLimitContext,
16
+ readPayloadField,
17
+ } from "../cap-counter/index.js";
18
+
19
+ export type TierOptionSpec<TCaps> = {
20
+ readonly field: string;
21
+ readonly options: readonly string[];
22
+ readonly isOptionAllowed: (option: string, caps: TCaps) => boolean;
23
+ readonly code: string;
24
+ readonly i18nKey: string;
25
+ /** Lets an update that resends the unchanged stored value of a no longer allowed
26
+ * option pass (edit forms submit the whole row). Without it the stored value is not looked up. */
27
+ readonly table?: SchemaTable | EntityTableMeta;
28
+ };
29
+
30
+ export type TierOptionAvailability = {
31
+ readonly value: string;
32
+ readonly disabled: boolean;
33
+ readonly hint?: string;
34
+ };
35
+
36
+ export type TierOptionGateDeps<TTier extends string, TCaps> = {
37
+ /** Ascending: the first tier that allows an option is the one named as required. */
38
+ readonly tierOrder: readonly TTier[];
39
+ readonly capsForTier: (tier: TTier, context: CapLimitContext) => TCaps | Promise<TCaps>;
40
+ readonly resolveTier: (db: TenantDb) => Promise<TTier>;
41
+ };
42
+
43
+ export type TierOptionGate<TTier extends string, TCaps> = {
44
+ readonly optionAvailability: (
45
+ db: TenantDb,
46
+ context: CapLimitContext,
47
+ spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
48
+ hintFor: (requiredTier: TTier) => string,
49
+ ) => Promise<readonly TierOptionAvailability[]>;
50
+ /** `recordId` is the id of the edited row; with `spec.table` it enables the unchanged-value pass. */
51
+ readonly checkTierOption: (
52
+ ctx: CapGuardContext,
53
+ value: unknown,
54
+ spec: TierOptionSpec<TCaps>,
55
+ recordId?: string,
56
+ ) => Promise<WriteFailure | null>;
57
+ readonly withTierOptionGate: (
58
+ handler: WriteHandlerDef,
59
+ spec: TierOptionSpec<TCaps>,
60
+ ) => WriteHandlerDef;
61
+ };
62
+
63
+ function limitContextOf(ctx: CapGuardContext): CapLimitContext {
64
+ return ctx.config ? { config: ctx.config } : {};
65
+ }
66
+
67
+ function recordIdOf(payload: unknown): string | undefined {
68
+ return isPlainObject(payload) && typeof payload["id"] === "string" ? payload["id"] : undefined;
69
+ }
70
+
71
+ export function createTierOptionGate<TTier extends string, TCaps>(
72
+ deps: TierOptionGateDeps<TTier, TCaps>,
73
+ ): TierOptionGate<TTier, TCaps> {
74
+ async function capsPerTier(
75
+ context: CapLimitContext,
76
+ ): Promise<readonly { readonly tier: TTier; readonly caps: TCaps }[]> {
77
+ return Promise.all(
78
+ deps.tierOrder.map(async (tier) => ({ tier, caps: await deps.capsForTier(tier, context) })),
79
+ );
80
+ }
81
+
82
+ async function optionAvailability(
83
+ db: TenantDb,
84
+ context: CapLimitContext,
85
+ spec: Pick<TierOptionSpec<TCaps>, "options" | "isOptionAllowed">,
86
+ hintFor: (requiredTier: TTier) => string,
87
+ ): Promise<readonly TierOptionAvailability[]> {
88
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(db), context);
89
+ const tiers = await capsPerTier(context);
90
+ return spec.options.map((value) => {
91
+ if (spec.isOptionAllowed(value, currentCaps)) return { value, disabled: false };
92
+ const required = tiers.find(({ caps }) => spec.isOptionAllowed(value, caps));
93
+ return required === undefined
94
+ ? { value, disabled: true }
95
+ : { value, disabled: true, hint: hintFor(required.tier) };
96
+ });
97
+ }
98
+
99
+ async function storedValueMatches(
100
+ ctx: CapGuardContext,
101
+ spec: TierOptionSpec<TCaps>,
102
+ value: string,
103
+ recordId: string | undefined,
104
+ ): Promise<boolean> {
105
+ if (spec.table === undefined || recordId === undefined) return false;
106
+ const stored = await ctx.db.fetchOne<Record<string, unknown>>(spec.table, { id: recordId });
107
+ return stored?.[spec.field] === value;
108
+ }
109
+
110
+ async function checkTierOption(
111
+ ctx: CapGuardContext,
112
+ value: unknown,
113
+ spec: TierOptionSpec<TCaps>,
114
+ recordId?: string,
115
+ ): Promise<WriteFailure | null> {
116
+ // skip: an absent or non-string value is the schema's business, not a tier violation.
117
+ if (typeof value !== "string") return null;
118
+ const context = limitContextOf(ctx);
119
+ const currentCaps = await deps.capsForTier(await deps.resolveTier(ctx.db), context);
120
+ if (spec.isOptionAllowed(value, currentCaps)) return null;
121
+ if (await storedValueMatches(ctx, spec, value, recordId)) return null;
122
+ const required = (await capsPerTier(context)).find(({ caps }) =>
123
+ spec.isOptionAllowed(value, caps),
124
+ );
125
+ return writeFailure(
126
+ new UnprocessableError(spec.code, {
127
+ i18nKey: spec.i18nKey,
128
+ details: { field: spec.field, value, requiredTier: required?.tier },
129
+ }),
130
+ );
131
+ }
132
+
133
+ function withTierOptionGate(
134
+ handler: WriteHandlerDef,
135
+ spec: TierOptionSpec<TCaps>,
136
+ ): WriteHandlerDef {
137
+ return {
138
+ ...handler,
139
+ handler: async (event, ctx) => {
140
+ const value = readPayloadField(event.payload, spec.field);
141
+ const failure = await checkTierOption(ctx, value, spec, recordIdOf(event.payload));
142
+ return failure ?? handler.handler(event, ctx);
143
+ },
144
+ };
145
+ }
146
+
147
+ return { optionAvailability, checkTierOption, withTierOptionGate };
148
+ }
@@ -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 selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } })
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) => {
@@ -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.`,
@@ -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 (#3024) — the caller (confirm-deletion-by-token) folds the
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
@@ -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 (#3024): commitDeletion carries
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 (#3024): two simultaneous confirms of the same token both read
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
@@ -1,5 +1,12 @@
1
1
  import type { UserDataDeleteHook, UserDataExportHook } from "@cosmicdrift/kumiko-framework/engine";
2
- import { deliveryAttemptsTable } from "../../delivery/index.js";
2
+ import {
3
+ appendEventInTenantDb,
4
+ getStreamVersionInTenantDb,
5
+ } from "@cosmicdrift/kumiko-framework/event-store";
6
+ import {
7
+ DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
8
+ deliveryAttemptsTable,
9
+ } from "../../delivery/index.js";
3
10
  import { featureMounted } from "./feature-mounted.js";
4
11
 
5
12
  // userData-Hooks for delivery's attempt log (deferred from #797, closed by
@@ -28,9 +35,32 @@ export const deliveryAttemptExportHook: UserDataExportHook = async (ctx) => {
28
35
  };
29
36
  };
30
37
 
31
- export const deliveryAttemptDeleteHook: UserDataDeleteHook = async () => {
32
- // Deliberate no-op: erasure runs via crypto-shredding — the forget pipeline
33
- // erases the recipient's DEK, which makes recipientAddress unreadable in
34
- // BOTH the append-only events and the projected rows (#799). A read-side
35
- // UPDATE here would be wiped on the next projection rebuild anyway.
38
+ // Both strategies erase the address. The DEK shredding of a forget covers KMS deployments; the
39
+ // erase event also covers plaintext mode, and unlike a read-side UPDATE it survives a projection
40
+ // rebuild. Rows without an address are skipped, which makes a second run a no-op.
41
+ export const deliveryAttemptDeleteHook: UserDataDeleteHook = async (ctx) => {
42
+ // skip: delivery not mounted — its table doesn't exist, nothing to erase.
43
+ if (!featureMounted(ctx, "delivery")) return;
44
+ const rows = await ctx.db.selectMany<{ id: string; recipientAddress: string | null }>(
45
+ deliveryAttemptsTable,
46
+ {
47
+ tenantId: ctx.tenantId,
48
+ recipientId: ctx.userId,
49
+ },
50
+ );
51
+ for (const row of rows) {
52
+ if (row.recipientAddress === null) continue;
53
+ await appendEventInTenantDb(
54
+ ctx.db,
55
+ {
56
+ aggregateType: "deliveryAttempt",
57
+ aggregateId: row.id,
58
+ expectedVersion: await getStreamVersionInTenantDb(ctx.db, row.id),
59
+ type: DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
60
+ payload: {},
61
+ metadata: { userId: "system" },
62
+ },
63
+ { registry: ctx.registry },
64
+ );
65
+ }
36
66
  };
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "workflow.run-failed carries the real stepIndex when a run suspends in an unsupported step"
6
+ },
2
7
  {
3
8
  "version": "0.340.0",
4
9
  "type": "improvement",
@@ -23,7 +23,11 @@ import {
23
23
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
24
24
  import { workflowRunAggregateId } from "./aggregate-id.js";
25
25
  import { registerEventWakeup } from "./event-subscriber.js";
26
- import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner.js";
26
+ import {
27
+ startAndRunWorkflow,
28
+ type WorkflowRunFailedPayload,
29
+ WorkflowSuspensionUnsupportedError,
30
+ } from "./runner.js";
27
31
  import { registerWorkflow } from "./workflow-registry.js";
28
32
 
29
33
  const log = createFallbackLogger("workflow-runner");
@@ -98,15 +102,19 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
98
102
  handlerCtx: ctx as never,
99
103
  });
100
104
  } catch (error) {
105
+ // Only the suspension error knows its step; a throw inside a step
106
+ // carries no index, so it still reports 0.
107
+ const stepIndex =
108
+ error instanceof WorkflowSuspensionUnsupportedError ? error.stepIndex : 0;
101
109
  log.warn("workflow run failed", {
102
110
  runId,
103
111
  workflowName: workflow.name,
104
- stepIndex: 0,
112
+ stepIndex,
105
113
  error: String(error),
106
114
  });
107
115
  const failedPayload: WorkflowRunFailedPayload = {
108
116
  workflowName: workflow.name,
109
- stepIndex: 0,
117
+ stepIndex,
110
118
  error: describeWorkflowStepError(error),
111
119
  };
112
120
  await ctx.unsafeAppendEvent({
@@ -92,6 +92,11 @@ function checkQ7Fingerprint(
92
92
  storedFingerprint: string | null,
93
93
  ): WorkflowRunFailedPayload | null {
94
94
  const currentFingerprint = computeDefinitionFingerprint(workflow);
95
+ if (storedFingerprint === null) {
96
+ log.warn(
97
+ `Run ${runId} of workflow "${workflowName}" has no stored definition fingerprint — resuming against the current definition unchecked.`,
98
+ );
99
+ }
95
100
  const fingerprintChanged = storedFingerprint !== null && currentFingerprint !== storedFingerprint;
96
101
  if (!fingerprintChanged) {
97
102
  return null;
@@ -306,7 +306,7 @@ export const ASSET_TEMPLATES: ReadonlyArray<AssetTemplate> = [
306
306
  // treffen ohne Sonderfall. Wird nur für status==="lent" konsultiert.
307
307
  export const OWNERS: ReadonlyArray<string> = [
308
308
  "",
309
- "Marc Frost",
309
+ "Alex Morgan",
310
310
  "Anna Weber",
311
311
  "Lars Bergmann",
312
312
  "Sina Klein",
@@ -435,7 +435,7 @@ export const REPORTERS: ReadonlyArray<string> = [
435
435
  // unzugewiesen damit die Liste nicht zu uniform aussieht.
436
436
  export const ASSIGNEES: ReadonlyArray<string> = [
437
437
  "",
438
- "Marc Frost",
438
+ "Alex Morgan",
439
439
  "IT-Team",
440
440
  "Sysadmin",
441
441
  "Helpdesk-L1",
@@ -53,7 +53,6 @@ export const itemEditScreen: EntityEditScreenDefinition = {
53
53
  type: "entityEdit",
54
54
  entity: "item",
55
55
  layout: {
56
- width: "3xl",
57
56
  sections: [
58
57
  {
59
58
  title: "Basics",
@@ -58,7 +58,7 @@ export function AppShell({
58
58
  ],
59
59
  ])
60
60
  }
61
- sidebarFooter={<SidebarUser name="Marc Frost" email="marc@cosmicdrift.dev" />}
61
+ sidebarFooter={<SidebarUser name="Alex Morgan" email="alex@example.com" />}
62
62
  >
63
63
  {activeTarget !== undefined ? <EditorPanel resolvers={resolvers} /> : children}
64
64
  </DefaultAppShell>
@@ -606,20 +606,26 @@ export function Gallery(): ReactNode {
606
606
  <div className="flex flex-col gap-5 px-6 py-6">
607
607
  <div className="flex items-center gap-4">
608
608
  <div className="bg-muted text-foreground flex size-16 items-center justify-center rounded-full text-lg font-semibold">
609
- MF
609
+ AM
610
610
  </div>
611
611
  <Button variant="secondary">Change avatar</Button>
612
612
  </div>
613
613
  <div className="grid gap-4 sm:grid-cols-2">
614
614
  <Field id="pf-name" label="Full name">
615
- <Input kind="text" id="pf-name" name="pf-name" value="Marc Frost" onChange={noop} />
615
+ <Input
616
+ kind="text"
617
+ id="pf-name"
618
+ name="pf-name"
619
+ value="Alex Morgan"
620
+ onChange={noop}
621
+ />
616
622
  </Field>
617
623
  <Field id="pf-email" label="Email">
618
624
  <Input
619
625
  kind="text"
620
626
  id="pf-email"
621
627
  name="pf-email"
622
- value="marc@cosmicdrift.dev"
628
+ value="alex@example.com"
623
629
  onChange={noop}
624
630
  />
625
631
  </Field>
@@ -0,0 +1,47 @@
1
+ // Server-side locale helpers for the dashboard demo queries. Not shared with
2
+ // renderer-web's format.ts: server code must not import renderer-web.
3
+
4
+ import { WIDGETS_I18N } from "./i18n";
5
+
6
+ type WidgetsI18nKey = keyof typeof WIDGETS_I18N;
7
+ type DemoCurrency = "EUR" | "USD";
8
+
9
+ const FALLBACK_LOCALE = "en";
10
+
11
+ // Intl throws RangeError on malformed tags.
12
+ function usableLocale(locale: string): string {
13
+ try {
14
+ return Intl.getCanonicalLocales(locale)[0] ?? FALLBACK_LOCALE;
15
+ } catch {
16
+ return FALLBACK_LOCALE;
17
+ }
18
+ }
19
+
20
+ // The bundle only carries de + en: any "de" tag ("de-AT") picks de, everything else en.
21
+ export function demoText(key: WidgetsI18nKey, locale: string): string {
22
+ const entry = WIDGETS_I18N[key];
23
+ const primarySubtag = usableLocale(locale).split("-")[0];
24
+ return primarySubtag === "de" ? entry.de : entry.en;
25
+ }
26
+
27
+ export function demoMoney(amount: number, currency: DemoCurrency, locale: string): string {
28
+ return new Intl.NumberFormat(usableLocale(locale), {
29
+ style: "currency",
30
+ currency,
31
+ maximumFractionDigits: 0,
32
+ }).format(amount);
33
+ }
34
+
35
+ export function demoPercent(fraction: number, locale: string): string {
36
+ return new Intl.NumberFormat(usableLocale(locale), {
37
+ style: "percent",
38
+ maximumFractionDigits: 0,
39
+ }).format(fraction);
40
+ }
41
+
42
+ export function demoMonthYear(year: number, month: number, locale: string): string {
43
+ return Temporal.PlainDate.from({ year, month, day: 1 }).toLocaleString(usableLocale(locale), {
44
+ month: "short",
45
+ year: "numeric",
46
+ });
47
+ }