@cosmicdrift/kumiko-samples 0.352.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 (68) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  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-overview/access.ts +2 -9
  15. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  16. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  17. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  18. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  19. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  20. package/packages/bundled-features/src/files-tenant-data/hooks.ts +3 -2
  21. package/packages/bundled-features/src/folders/feature.ts +2 -12
  22. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  23. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  24. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  25. package/packages/bundled-features/src/jobs/handlers/list.query.ts +44 -11
  26. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  27. package/packages/bundled-features/src/ledger/entity.ts +4 -0
  28. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  29. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  30. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  31. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  32. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  33. package/packages/bundled-features/src/seo/changes.json +5 -0
  34. package/packages/bundled-features/src/seo/feature.ts +23 -7
  35. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  36. package/packages/bundled-features/src/shared/index.ts +2 -1
  37. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  38. package/packages/bundled-features/src/tags/feature.ts +2 -12
  39. package/packages/bundled-features/src/tenant/changes.json +7 -0
  40. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  41. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  42. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  43. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  44. package/packages/bundled-features/src/tenant/index.ts +1 -0
  45. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  46. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  47. package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
  48. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  49. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +1 -0
  50. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  51. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +23 -1
  52. package/packages/bundled-features/src/user/handlers/list.query.ts +5 -2
  53. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  54. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  55. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  56. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  57. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +11 -3
  58. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +5 -0
  59. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  60. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  61. package/samples/apps/styleguide/src/features/widgets/feature.ts +48 -15
  62. package/samples/apps/styleguide/src/features/widgets/i18n.ts +28 -0
  63. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  64. package/samples/recipes/public-share-token/README.md +4 -5
  65. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  66. package/samples/recipes/security-baseline/README.md +6 -0
  67. package/samples/recipes/tenant-settings/README.md +1 -1
  68. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -268,7 +268,7 @@ export async function moveTransferGraph(args: {
268
268
  { entityName: rootEntityName, rowIds: [rootRowId] },
269
269
  ];
270
270
 
271
- for (let round = 0; round < MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
271
+ for (let round = 0; round <= MAX_TRANSFER_DEPTH && pending.length > 0; round++) {
272
272
  // Merged per type so an edge leaving a type runs once per round, not once
273
273
  // per edge that reached it.
274
274
  const discovered = new Map<string, string[]>();
@@ -330,25 +330,18 @@ export async function moveTransferGraph(args: {
330
330
  }
331
331
  }
332
332
 
333
- pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
334
- }
333
+ // The extra round past the limit is a probe: it only exists to find out
334
+ // whether rows still hang below the last allowed hop. Its moves are rolled
335
+ // back with the transaction when this throws.
336
+ const [strandedEntityName] = discovered.keys();
337
+ if (round === MAX_TRANSFER_DEPTH && strandedEntityName !== undefined) {
338
+ throw new UnprocessableError("transfer_graph_too_deep", {
339
+ i18nKey: "errors.tenantHandover.transferGraphTooDeep",
340
+ details: { entityName: strandedEntityName },
341
+ });
342
+ }
335
343
 
336
- // The depth limit is a safety net, not a licence to move part of a graph: if
337
- // rounds ran out while rows remain whose type still leads somewhere, the
338
- // declaration reaches further than the mover does, and returning quietly
339
- // would leave exactly the orphaned rows #3088 is about.
340
- //
341
- // Rounds count hops of both edge kinds, while the boot validator measures
342
- // reference chains between transferable entities only. A graph that fills the
343
- // limit with reference hops and then adds a parentRef child therefore boots
344
- // clean and fails here instead — the residual the validator cannot see, and
345
- // the reason this check has to exist rather than trusting boot alone.
346
- const stranded = pending.find(({ entityName }) => (adjacency.get(entityName) ?? []).length > 0);
347
- if (stranded !== undefined) {
348
- throw new UnprocessableError("transfer_graph_too_deep", {
349
- i18nKey: "errors.tenantHandover.transferGraphTooDeep",
350
- details: { entityName: stranded.entityName },
351
- });
344
+ pending = [...discovered].map(([entityName, rowIds]) => ({ entityName, rowIds }));
352
345
  }
353
346
 
354
347
  return movedCounts;
@@ -141,6 +141,7 @@ export function createTenantLifecycleFeature(): FeatureDefinition {
141
141
  now,
142
142
  log: (message) => ctx.log?.warn(message),
143
143
  fileProviderResolver: ctx._fileProviderResolver,
144
+ searchAdapter: ctx.searchAdapter,
144
145
  escapeHatchAuditSink: ctx._escapeHatchAuditSink,
145
146
  actor: ctx.systemUser.id,
146
147
  });
@@ -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> {
@@ -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,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;
@@ -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",
@@ -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
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
7
7
  import { z } from "zod";
8
+ import { demoMoney, demoMonthYear, demoPercent, demoText } from "./demo-locale";
8
9
  import { WIDGETS_I18N } from "./i18n";
9
10
 
10
11
  // Statische Demo-Zeitreihe (48 Punkte à 30 Minuten) — kein Date-API,
@@ -33,6 +34,13 @@ const INBOX_MESSAGES = Array.from({ length: 18 }, (_, i) => ({
33
34
  unread: i % 4 === 0,
34
35
  }));
35
36
 
37
+ function openAmount(amount: number, locale: string): string {
38
+ return demoText("widgets:dashboard:demo-amount-open", locale).replace(
39
+ "{amount}",
40
+ demoMoney(amount, "EUR", locale),
41
+ );
42
+ }
43
+
36
44
  export const widgetsFeature = defineFeature("widgets", (r) => {
37
45
  r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } });
38
46
  r.screen({
@@ -129,11 +137,14 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
129
137
  r.queryHandler(
130
138
  "metrics:portfolio-stat",
131
139
  z.object({ region: z.string().optional() }),
132
- async ({ payload: { region } }) => ({
133
- value: region === "us" ? "38.120 $" : region === "eu" ? "54.630 €" : "92.753 €",
134
- sub: "über 4 Konten",
140
+ async ({ payload: { region } }, ctx) => ({
141
+ value:
142
+ region === "us"
143
+ ? demoMoney(38120, "USD", ctx.locale)
144
+ : demoMoney(region === "eu" ? 54630 : 92753, "EUR", ctx.locale),
145
+ sub: demoText("widgets:catalog:portfolio-sub", ctx.locale),
135
146
  tone: "positive",
136
- delta: "12 %",
147
+ delta: demoPercent(0.12, ctx.locale),
137
148
  deltaDirection: "up",
138
149
  deltaTone: "positive",
139
150
  }),
@@ -150,7 +161,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
150
161
  r.queryHandler(
151
162
  "metrics:net-worth-assets",
152
163
  z.object({ region: z.string().optional() }),
153
- async () => ({ value: "120.000 €" }),
164
+ async (_query, ctx) => ({ value: demoMoney(120000, "EUR", ctx.locale) }),
154
165
  {
155
166
  access: {
156
167
  openToAll: {
@@ -164,7 +175,7 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
164
175
  r.queryHandler(
165
176
  "metrics:net-worth-debts",
166
177
  z.object({ region: z.string().optional() }),
167
- async () => ({ value: "65.370 €" }),
178
+ async (_query, ctx) => ({ value: demoMoney(65370, "EUR", ctx.locale) }),
168
179
  {
169
180
  access: {
170
181
  openToAll: {
@@ -196,10 +207,18 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
196
207
  r.queryHandler(
197
208
  "metrics:latest-items",
198
209
  z.object({}),
199
- async () => ({
210
+ async (_query, ctx) => ({
200
211
  rows: [
201
- { id: "i1", name: "API-Timeout eu-central", status: "resolved" },
202
- { id: "i2", name: "Zertifikat erneuert", status: "done" },
212
+ {
213
+ id: "i1",
214
+ name: demoText("widgets:dashboard:demo-incident-timeout", ctx.locale),
215
+ status: "resolved",
216
+ },
217
+ {
218
+ id: "i2",
219
+ name: demoText("widgets:dashboard:demo-incident-certificate", ctx.locale),
220
+ status: "done",
221
+ },
203
222
  ],
204
223
  nextCursor: null,
205
224
  }),
@@ -249,10 +268,16 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
249
268
  r.queryHandler(
250
269
  "metrics:upcoming-events",
251
270
  z.object({}),
252
- async () => ({
271
+ async (_query, ctx) => ({
253
272
  rows: [
254
- { primary: "Zinsanpassung Baudarlehen", trailing: "Aug 2026" },
255
- { primary: "Bausparvertrag zuteilungsreif", trailing: "Okt 2026" },
273
+ {
274
+ primary: demoText("widgets:dashboard:demo-event-rate-adjustment", ctx.locale),
275
+ trailing: demoMonthYear(2026, 8, ctx.locale),
276
+ },
277
+ {
278
+ primary: demoText("widgets:dashboard:demo-event-savings-contract", ctx.locale),
279
+ trailing: demoMonthYear(2026, 10, ctx.locale),
280
+ },
256
281
  ],
257
282
  }),
258
283
  {
@@ -268,10 +293,18 @@ export const widgetsFeature = defineFeature("widgets", (r) => {
268
293
  r.queryHandler(
269
294
  "metrics:goal-progress",
270
295
  z.object({}),
271
- async () => ({
296
+ async (_query, ctx) => ({
272
297
  rows: [
273
- { label: "Baudarlehen", value: "42.000 € offen", fraction: 0.71 },
274
- { label: "Autokredit", value: "3.200 € offen", fraction: 0.92 },
298
+ {
299
+ label: demoText("widgets:dashboard:demo-goal-mortgage", ctx.locale),
300
+ value: openAmount(42000, ctx.locale),
301
+ fraction: 0.71,
302
+ },
303
+ {
304
+ label: demoText("widgets:dashboard:demo-goal-car-loan", ctx.locale),
305
+ value: openAmount(3200, ctx.locale),
306
+ fraction: 0.92,
307
+ },
275
308
  ],
276
309
  }),
277
310
  {
@@ -27,6 +27,34 @@ export const WIDGETS_I18N = {
27
27
  "widgets:dashboard:filter-region": { de: "Region", en: "Region" },
28
28
  "widgets:dashboard:filter-region-eu": { de: "Europa", en: "Europe" },
29
29
  "widgets:dashboard:filter-region-us": { de: "USA", en: "USA" },
30
+ "widgets:dashboard:filter-echo-title": { de: "Custom-Panel", en: "Custom panel" },
31
+ "widgets:dashboard:filter-echo-filtered": {
32
+ de: "Gefiltert nach Region: {region}",
33
+ en: "Filtered by region: {region}",
34
+ },
35
+ "widgets:dashboard:filter-echo-unfiltered": {
36
+ de: "Ungefiltert (alle Regionen)",
37
+ en: "Unfiltered (all regions)",
38
+ },
39
+ "widgets:dashboard:demo-incident-timeout": {
40
+ de: "API-Timeout eu-central",
41
+ en: "API timeout eu-central",
42
+ },
43
+ "widgets:dashboard:demo-incident-certificate": {
44
+ de: "Zertifikat erneuert",
45
+ en: "Certificate renewed",
46
+ },
47
+ "widgets:dashboard:demo-event-rate-adjustment": {
48
+ de: "Zinsanpassung Baudarlehen",
49
+ en: "Mortgage rate adjustment",
50
+ },
51
+ "widgets:dashboard:demo-event-savings-contract": {
52
+ de: "Bausparvertrag zuteilungsreif",
53
+ en: "Building savings contract ready for allocation",
54
+ },
55
+ "widgets:dashboard:demo-goal-mortgage": { de: "Baudarlehen", en: "Mortgage" },
56
+ "widgets:dashboard:demo-goal-car-loan": { de: "Autokredit", en: "Car loan" },
57
+ "widgets:dashboard:demo-amount-open": { de: "{amount} offen", en: "{amount} outstanding" },
30
58
  "widgets:catalog:portfolio": { de: "Portfolio", en: "Portfolio" },
31
59
  "widgets:catalog:portfolio-sub": { de: "über 4 Konten", en: "across 4 accounts" },
32
60
  "widgets:catalog:remaining-debt": { de: "Restschuld", en: "Remaining debt" },
@@ -3,18 +3,19 @@
3
3
  // gewählten Screen-Filter-Wert über `filterParams` sieht (siehe
4
4
  // DashboardFilterDefinition in feature.ts).
5
5
 
6
- import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
6
+ import { type ExtensionSectionProps, useTranslation } from "@cosmicdrift/kumiko-renderer";
7
7
  import { SectionCard } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import type { ReactNode } from "react";
9
9
 
10
10
  export function DashboardFilterEcho({ filterParams }: ExtensionSectionProps): ReactNode {
11
+ const t = useTranslation();
11
12
  const region = filterParams?.["region"];
12
13
  return (
13
- <SectionCard title="Custom-Panel">
14
+ <SectionCard title={t("widgets:dashboard:filter-echo-title")}>
14
15
  <p className="text-sm text-muted-foreground">
15
16
  {typeof region === "string"
16
- ? `Gefiltert nach Region: ${region}`
17
- : "Ungefiltert (alle Regionen)"}
17
+ ? t("widgets:dashboard:filter-echo-filtered", { region })
18
+ : t("widgets:dashboard:filter-echo-unfiltered")}
18
19
  </p>
19
20
  </SectionCard>
20
21
  );
@@ -3,9 +3,8 @@
3
3
  Minimal pattern for **tokenized anonymous read surfaces**: authenticated users
4
4
  mint a link; visitors resolve it without login.
5
5
 
6
- Domain-agnostic — no credit/folder logic. Full consumer:
7
- [Cashcolt money-horse](https://github.com/CosmicDriftGameStudio/money-horse)
8
- (`share-by-token` + 5 layout templates).
6
+ Domain-agnostic — no credit/folder logic. A full consumer adds snapshots,
7
+ layout templates and tier gates on top.
9
8
 
10
9
  ## What it shows
11
10
 
@@ -16,8 +15,8 @@ Domain-agnostic — no credit/folder logic. Full consumer:
16
15
  - **`anonymousAccess`** on test stack (see integration test)
17
16
 
18
17
  Client wiring: [`recipes/apex-surface-auth`](../apex-surface-auth/) or a gate
19
- before `createKumikoApp` (money-horse uses `public-share-gate.tsx` like the public
20
- calculator).
18
+ before `createKumikoApp` (a small gate component that renders the public view when
19
+ the URL carries a share token).
21
20
 
22
21
  ## Handlers
23
22
 
@@ -3,7 +3,7 @@
3
3
  // Pattern: authenticated create → plain token show-once → anonymous
4
4
  // read-by-token with SHA-256 lookup. Revoke sets revokedAt via executor.
5
5
  //
6
- // See money-horse for credit/folder snapshots, layouts, tier gates.
6
+ // A full consumer adds domain snapshots, layouts and tier gates on top.
7
7
 
8
8
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
9
9
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -54,6 +54,12 @@ export const APP_FEATURES = [
54
54
  For an app that does not mount `dsgvoSelfServiceFeatures()`, use
55
55
  `securityBaselineFeatures()` with its default options, as this recipe does.
56
56
 
57
+ Dedupe keeps the first mounted instance. Late-bound state such as
58
+ `bindAutoRevokeOnPasswordChange` must be bound on the instance returned by
59
+ `composeFeatures`/`createApp`, not on a locally held handle to the dropped one:
60
+ the dropped instance's hooks never run and password changes silently stop
61
+ revoking sessions.
62
+
57
63
  A `createSessionsFeature({ ... })` mounted with different options than the
58
64
  preset's own no-arg instance still produces a clear boot error — dedupe only
59
65
  collapses provably interchangeable instances, never silently picks one:
@@ -20,7 +20,7 @@ import { defineCreateWithTenantDefaults } from "@cosmicdrift/kumiko-bundled-feat
20
20
  export const invoiceEntity = createEntity({
21
21
  table: "read_invoices",
22
22
  fields: {
23
- title: createTextField({ required: true }),
23
+ title: createTextField({ personal: false, reason: "is_business_data", required: true }),
24
24
  amount: createMoneyField({ required: true }),
25
25
  language: createSelectField({ options: ["en", "de", "fr"] as const }),
26
26
  },
@@ -1,11 +0,0 @@
1
- import type { OwnershipMap } from "@cosmicdrift/kumiko-framework/engine";
2
-
3
- // `where`-rules are read-path only (buildOwnershipClause). The write path
4
- // decides in memory against the concrete row, so a where-rule there can only
5
- // ever deny — boot validation rejects the shape (fw#2626). Feature factories
6
- // that accept an `ownership` option reject it earlier, at build time, so the
7
- // author gets the option name in the message instead of an entity scope.
8
- export function hasWhereRule(map: OwnershipMap | undefined): boolean {
9
- if (!map) return false;
10
- return Object.values(map).some((rule) => rule !== "all" && rule.kind === "where");
11
- }