@cosmicdrift/kumiko-framework 0.391.0 → 0.392.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 (47) hide show
  1. package/agents/examples/apex-landing.md +1 -1
  2. package/agents/examples/apex-surface-auth.md +35 -35
  3. package/agents/examples/compliance-profiles-demo.md +4 -4
  4. package/agents/examples/crypto-shredding-hr.md +1 -1
  5. package/agents/examples/designer-demo.md +6 -6
  6. package/agents/examples/e2e-generator.md +18 -18
  7. package/agents/examples/encrypted-tenant-config.md +31 -31
  8. package/agents/examples/files-post-processing.md +1 -1
  9. package/agents/examples/inbound-mail-triage.md +1 -1
  10. package/agents/examples/lane-routing.md +5 -5
  11. package/agents/examples/legal-pages.md +36 -36
  12. package/agents/examples/record-detail-layout.md +41 -8
  13. package/agents/examples/seed-migration.md +44 -44
  14. package/agents/examples/user-data-rights.md +21 -21
  15. package/agents/examples/user-profile.md +5 -5
  16. package/agents/examples/wizard-form.md +1 -1
  17. package/agents/features.md +3 -3
  18. package/dist/db/entity-column-meta.d.ts +3 -0
  19. package/dist/db/entity-column-meta.js +113 -0
  20. package/dist/db/entity-table-indexes.d.ts +7 -0
  21. package/dist/db/entity-table-indexes.js +124 -0
  22. package/dist/db/entity-table-meta.js +1 -153
  23. package/dist/db/migrate-change-render.d.ts +40 -0
  24. package/dist/db/migrate-change-render.js +89 -0
  25. package/dist/db/migrate-generator.d.ts +5 -37
  26. package/dist/db/migrate-generator.js +99 -171
  27. package/dist/db/row-level-security-boot-check.js +4 -4
  28. package/dist/db/table-builder.d.ts +1 -1
  29. package/dist/db/table-builder.js +42 -133
  30. package/dist/engine/boot-validator/query-refs.js +22 -9
  31. package/dist/engine/boot-validator/screens-actions.js +11 -8
  32. package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
  33. package/dist/engine/build-app-schema.js +5 -0
  34. package/dist/engine/extensions/user-data.js +0 -2
  35. package/dist/engine/project-query-live-entities.d.ts +2 -0
  36. package/dist/engine/project-query-live-entities.js +11 -0
  37. package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
  38. package/dist/env/index.d.ts +1 -0
  39. package/dist/env/index.js +1 -0
  40. package/dist/env/required-env.d.ts +2 -0
  41. package/dist/env/required-env.js +9 -0
  42. package/dist/i18n/required-surface-keys.js +4 -0
  43. package/dist/migrations/kumiko-drift.js +4 -4
  44. package/dist/ui-types/app-schema.d.ts +1 -0
  45. package/dist/ui-types/index.d.ts +1 -1
  46. package/package.json +3 -3
  47. package/src/changes.json +42 -0
@@ -53,7 +53,7 @@ bun run screenshot # → screenshots/landing.png + lightbox.png
53
53
  import { expect, boundaryGuardedTest as test } from "@cosmicdrift/kumiko-testing/e2e";
54
54
  import { renderLanding, SAMPLE_PLANS } from "../src/feature";
55
55
 
56
- test("header keeps the page's horizontal padding below 1120px (#2038)", async ({ page }) => {
56
+ test("header keeps the page's horizontal padding below 1120px", async ({ page }) => {
57
57
  await page.setViewportSize({ width: 390, height: 844 });
58
58
  await page.setContent(renderLanding({ plans: SAMPLE_PLANS }), {
59
59
  waitUntil: "domcontentloaded",
@@ -127,9 +127,9 @@ Proves end-to-end over real `/api/write` without auth:
127
127
  ### src/__tests__/feature.integration.test.ts
128
128
 
129
129
  ```ts
130
- // Beweist: composeApexAccountApp() bootet (volle Account-Komposition inkl.
131
- // auth + user-data-rights) UND der anonyme email-verifizierte Deletion-Flow
132
- // läuft end-to-end durch /api/write OHNE Auth (anonymousAccess).
130
+ // Proves: composeApexAccountApp() boots (full account composition incl.
131
+ // auth + user-data-rights) AND the anonymous email-verified deletion flow
132
+ // runs end to end through /api/write WITHOUT auth (anonymousAccess).
133
133
 
134
134
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
135
135
  import {
@@ -224,7 +224,7 @@ describe("apex-surface-auth recipe", () => {
224
224
  expect(rows[0]?.status).toBe(USER_STATUS.DeletionRequested);
225
225
  });
226
226
 
227
- test("enumeration-safe: unbekannte Email → success, keine Mail", async () => {
227
+ test("enumeration-safe: unknown email → success, no email", async () => {
228
228
  const res = await stack.http.raw("POST", "/api/write", {
229
229
  type: REQUEST_BY_EMAIL,
230
230
  payload: { email: "nobody@example.com" },
@@ -238,21 +238,21 @@ describe("apex-surface-auth recipe", () => {
238
238
  ### src/feature.ts
239
239
 
240
240
  ```ts
241
- // apex-surface-auth Recipe — der evidente Weg für öffentlichen Apex-Content.
241
+ // apex-surface-auth recipe — the evident way for public apex content.
242
242
  //
243
- // Eine Kumiko-App hat eine öffentliche Apex-Präsenz (Landing/Login) UND eine
244
- // schema-getriebene Admin-UI. Die Admin-UI mountet via createKumikoApp (volles
245
- // Schema). Die Apex mountet via createPublicSurface — schema-LOS, anonym
246
- // erreichbar, kein Admin-Nav/Topologie-Leak. Beide teilen Locale + Primitives.
243
+ // A Kumiko app has a public apex presence (landing/login) AND a
244
+ // schema-driven admin UI. The admin UI mounts via createKumikoApp (full
245
+ // schema). The apex mounts via createPublicSurface — schema-LESS, anonymously
246
+ // reachable, no admin nav/topology leak. Both share locale + primitives.
247
247
  //
248
- // Dieses Recipe zeigt:
249
- // 1. Server: die Feature-Komposition für die 4 Account-Flows + den anonymen,
250
- // email-verifizierten Deletion-Flow (Lockout-sicher).
251
- // 2. Client (Kommentar unten + README): wie createPublicSurface + AuthShell
252
- // die Screens in der Apex-Chrome mounten.
248
+ // This recipe shows:
249
+ // 1. Server: the feature composition for the 4 account flows + the anonymous,
250
+ // email-verified deletion flow (lockout-safe).
251
+ // 2. Client (comment below + README): how createPublicSurface + AuthShell
252
+ // mount the screens in the apex chrome.
253
253
  //
254
- // CLIENT-WIRING (apex.tsx der App — renderer/web, hier nur als Referenz, da
255
- // Recipes keine Browser-Deps ziehen):
254
+ // CLIENT-WIRING (the app's apex.tsx — renderer/web, only a reference here, since
255
+ // recipes pull in no browser deps):
256
256
  //
257
257
  // import {
258
258
  // ForgotPasswordScreen, SignupScreen, createLoginRoute,
@@ -264,8 +264,8 @@ describe("apex-surface-auth recipe", () => {
264
264
  // } from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
265
265
  // import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
266
266
  //
267
- // // AuthShell: die Auth-Card rendert in der Marketing-Chrome statt
268
- // // Fullscreen. Default (ohne Provider) bleibt der Fullscreen-Wrapper.
267
+ // // AuthShell: the auth card renders inside the marketing chrome instead of
268
+ // // fullscreen. Default (without a provider) stays the fullscreen wrapper.
269
269
  // const shell = ({ children }) => (
270
270
  // <MarketingChrome>
271
271
  // <AuthShellProvider shell={(card) => <div className="py-12 flex justify-center">{card}</div>}>
@@ -274,12 +274,12 @@ describe("apex-surface-auth recipe", () => {
274
274
  // </MarketingChrome>
275
275
  // );
276
276
  //
277
- // // createLoginRoute — NICHT LoginScreen direkt rendern: die Route
278
- // // braucht die Challenge-Swap-Logik für einen zweiten Faktor. Ohne die
279
- // // (z.B. raw <LoginScreen />) bleibt ein MFA-enrolter User beim Login
280
- // // hängen, sobald die App irgendwann auth-mfa mountet. Mountet die App
281
- // // auth-mfa, `mfaVerifyScreen: MfaVerifyScreen` (aus
282
- // // "@cosmicdrift/kumiko-bundled-features/auth-mfa/web") ergänzen.
277
+ // // createLoginRoute — do NOT render LoginScreen directly: the route
278
+ // // needs the challenge-swap logic for a second factor. Without it
279
+ // // (e.g. a raw <LoginScreen />) an MFA-enrolled user gets stuck at login
280
+ // // as soon as the app mounts auth-mfa at some point. If the app mounts
281
+ // // auth-mfa, add `mfaVerifyScreen: MfaVerifyScreen` (from
282
+ // // "@cosmicdrift/kumiko-bundled-features/auth-mfa/web").
283
283
  // const LoginRoute = createLoginRoute({ loginScreenProps: { signupHref: "/signup" } });
284
284
  //
285
285
  // createPublicSurface({
@@ -296,8 +296,8 @@ describe("apex-surface-auth recipe", () => {
296
296
  // });
297
297
  //
298
298
  //
299
- // SERVER-WIRING: die App aktiviert `anonymousAccess` (defaultTenantId = der
300
- // Apex-Host-Tenant), damit /api/write die anonymen Deletion-Handler erreicht:
299
+ // SERVER-WIRING: the app enables `anonymousAccess` (defaultTenantId = the
300
+ // apex host tenant) so /api/write reaches the anonymous deletion handlers:
301
301
  //
302
302
  // runProdApp({ features: composeApexAccountApp({...}), envSchema,
303
303
  // anonymousAccess: { defaultTenantId: APEX_TENANT_ID } })
@@ -318,20 +318,20 @@ import {
318
318
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
319
319
 
320
320
  export type ApexAccountAppOptions = {
321
- /** HMAC-Secret für das Deletion-Verify-Token. */
321
+ /** HMAC secret for the deletion verify token. */
322
322
  readonly deletionTokenSecret: string;
323
- /** Apex-Route des Confirm-Screens; der Handler hängt das Token als
324
- * URL-Fragment an (`#token=`). */
323
+ /** Apex route of the confirm screen; the handler appends the token as a
324
+ * URL fragment (`#token=`). */
325
325
  readonly deletionVerifyUrl: string;
326
- /** Versand des Verify-Magic-Links. MUSS non-blocking sein (enqueue) — ein
327
- * synchroner Send würde ein Timing-Oracle für Account-Enumeration öffnen. */
326
+ /** Sending the verify magic link. MUST be non-blocking (enqueue) — a
327
+ * synchronous send would open a timing oracle for account enumeration. */
328
328
  readonly sendDeletionVerificationEmail: SendDeletionVerificationEmailFn;
329
329
  };
330
330
 
331
- // Volle Feature-Komposition für die öffentlichen Account-Flows. Die
332
- // user-data-rights-Options aktivieren den anonymen Deletion-Flow; die übrigen
333
- // Features liefern Login/Register/PW (auth-email-password) + die
334
- // Require-Kette (user-data-rights → data-retention + compliance-profiles +
331
+ // Full feature composition for the public account flows. The
332
+ // user-data-rights options enable the anonymous deletion flow; the remaining
333
+ // features provide login/register/PW (auth-email-password) + the
334
+ // require chain (user-data-rights → data-retention + compliance-profiles +
335
335
  // sessions).
336
336
  export function composeApexAccountApp(opts: ApexAccountAppOptions): FeatureDefinition[] {
337
337
  return [
@@ -168,7 +168,7 @@ describe("compliance-profiles-demo :: cross-tenant isolation", () => {
168
168
  expect(result.profile.tenantDestroyGracePeriod).toEqual({ days: 30 });
169
169
  });
170
170
 
171
- test("Tenant B (Schweiz) → swiss-dsg: EDÖB + DE/FR/IT/EN-Sprachen", async () => {
171
+ test("Tenant B (Switzerland) → swiss-dsg: Swiss data protection authority + DE/FR/IT/EN languages", async () => {
172
172
  const result = await stack.http.queryOk<{
173
173
  profile: {
174
174
  key: string;
@@ -206,7 +206,7 @@ describe("compliance-profiles-demo :: cross-tenant isolation", () => {
206
206
  expect(result.profile.subProcessor.worksCouncilApprovalRequired).toBe(true);
207
207
  });
208
208
 
209
- test("Profile-Wechsel auf demselben Tenant zeigt sofort neues Verhalten", async () => {
209
+ test("Profile switch on the same tenant immediately shows new behavior", async () => {
210
210
  // Eigener Tenant D damit der Test reihenfolge-unabhaengig laeuft
211
211
  // (Tenant A bleibt auf eu-dsgvo fuer die anderen Tests).
212
212
  const tenantDAdmin = createTestUser({
@@ -233,7 +233,7 @@ describe("compliance-profiles-demo :: cross-tenant isolation", () => {
233
233
  expect(back.profile.breach.authorityContact).toBe("BlnBDI Berlin");
234
234
  });
235
235
 
236
- test("Tenant-Override merged auf base-profile, andere Felder bleiben", async () => {
236
+ test("Tenant override merges into base profile; other fields remain", async () => {
237
237
  // Eigener Tenant E damit der Override-State Tenant B nicht
238
238
  // ueberschreibt (reihenfolge-unabhaengig).
239
239
  const tenantEAdmin = createTestUser({
@@ -267,7 +267,7 @@ describe("compliance-profiles-demo :: cross-tenant isolation", () => {
267
267
  // Andere userRights aus swiss-dsg bleiben
268
268
  expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
269
269
  expect(result.profile.userRights.restrictionAllowed).toBe(true);
270
- // Sprachen aus swiss-dsg unverändert
270
+ // Languages from swiss-dsg unchanged
271
271
  expect(result.profile.notifications.languages).toEqual(["de", "fr", "it", "en"]);
272
272
  });
273
273
  });
@@ -246,7 +246,7 @@ describe("crypto-shredding-hr", () => {
246
246
  });
247
247
  });
248
248
 
249
- describe("crypto-shredding-hr — blind-index lookups (#818)", () => {
249
+ describe("crypto-shredding-hr — blind-index lookups", () => {
250
250
  const employeeTable = buildEntityTable("employee", employeeEntity);
251
251
 
252
252
  test("equality lookup by email works on the encrypted column — until the key is erased", async () => {
@@ -29,12 +29,12 @@ No dedicated automated tests in this folder yet — use it as a composition samp
29
29
 
30
30
  ```ts
31
31
  // kumiko-feature-version: 1
32
- // Demo-Feature für den Designer-Skeleton (C5).
33
- // Enthält bewusst Patterns aus den 3 hand-rollten View-Kategorien:
34
- // - entity → Tabelle der Fields
35
- // - writeHandler → Header + Code-Blocks (schema + handler)
36
- // - nav → ID/Label/Screen-Card
37
- // Plus optionalRequires + metric als JSON-Dump-Fallback.
32
+ // Demo feature for the designer skeleton (C5).
33
+ // Deliberately contains patterns from the 3 hand-rolled view categories:
34
+ // - entity → table of fields
35
+ // - writeHandler → header + code blocks (schema + handler)
36
+ // - nav → ID/label/screen card
37
+ // Plus optionalRequires + metric as a JSON dump fallback.
38
38
 
39
39
  import { createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
40
40
  import { z } from "zod";
@@ -31,12 +31,12 @@ bun test
31
31
  ### src/__tests__/feature.test.ts
32
32
 
33
33
  ```ts
34
- // E2E-Generator Sample — Unit-Test.
34
+ // E2E generator sample — unit test.
35
35
  //
36
- // Zweck: zeigt einen realistischen Generator-Run (shop-Feature mit allen
37
- // unterstützten + ein paar bewusst unsupported Feldtypen). Der Snapshot
38
- // dokumentiert das Ergebnis; jeder Feature-Autor der den Generator zum
39
- // ersten Mal anfasst kann hier reinschauen statt in die Implementation.
36
+ // Purpose: shows a realistic generator run (shop feature with all
37
+ // supported + a few deliberately unsupported field types). The snapshot
38
+ // documents the result; any feature author touching the generator for the
39
+ // first time can look here instead of at the implementation.
40
40
 
41
41
  import { describe, expect, test } from "bun:test";
42
42
  import {
@@ -54,11 +54,11 @@ const shop = createShopFeature();
54
54
  const registry = createRegistry([shop]);
55
55
 
56
56
  describe("e2e-generator sample — shop feature", () => {
57
- test("validateBoot akzeptiert das Feature", () => {
57
+ test("validateBoot accepts the feature", () => {
58
58
  expect(() => validateBoot([shop])).not.toThrow();
59
59
  });
60
60
 
61
- test("generateE2ESpec liefert alle vier Kinds für das List+Edit-Paar", () => {
61
+ test("generateE2ESpec returns all four kinds for the List+Edit pair", () => {
62
62
  const specs = generateE2ESpec(registry);
63
63
  const kinds = specs.map((s) => s.kind).sort();
64
64
  expect(kinds).toEqual([
@@ -69,7 +69,7 @@ describe("e2e-generator sample — shop feature", () => {
69
69
  ]);
70
70
  });
71
71
 
72
- test("list-has-fixture-row referenziert den Create-Handler + nutzt name als Identifying", () => {
72
+ test("list-has-fixture-row references the Create handler + uses name as the identifying field", () => {
73
73
  const specs = generateE2ESpec(registry);
74
74
  const row = specs.find((s) => s.kind === "list-has-fixture-row");
75
75
  if (row?.kind !== "list-has-fixture-row") throw new Error("unreachable");
@@ -84,14 +84,14 @@ describe("e2e-generator sample — shop feature", () => {
84
84
  expect(row.fixture["listPrice"]).toEqual({ amount: 1, currency: "EUR" });
85
85
  });
86
86
 
87
- test("edit-validates-required listet nur echte required-Felder (name, price)", () => {
87
+ test("edit-validates-required lists only actual required fields (name, price)", () => {
88
88
  const specs = generateE2ESpec(registry);
89
89
  const validates = specs.find((s) => s.kind === "edit-validates-required");
90
90
  if (validates?.kind !== "edit-validates-required") throw new Error("unreachable");
91
91
  expect(validates.requiredFields).toEqual(["name", "price"]);
92
92
  });
93
93
 
94
- test("edit-save-persists mapped Feldtypen auf die richtigen Playwright-Interaktionen", () => {
94
+ test("edit-save-persists maps field types to the correct Playwright interactions", () => {
95
95
  const specs = generateE2ESpec(registry);
96
96
  const save = specs.find((s) => s.kind === "edit-save-persists");
97
97
  if (save?.kind !== "edit-save-persists") throw new Error("unreachable");
@@ -104,17 +104,17 @@ describe("e2e-generator sample — shop feature", () => {
104
104
  // sonst zerschellt Playwright am ersten Dropdown.
105
105
  expect(opsByField.get("status")).toBe("select");
106
106
  expect(opsByField.get("featured")).toBe("check");
107
- // money (listPrice) wird im Form bewusst übersprungen — komplexes
108
- // Widget (Betrag + Währung), das der Generator nicht generisch
109
- // automatisieren kann. API-Seed nutzt es trotzdem (siehe Test oben).
107
+ // money (listPrice) is deliberately skipped in the form — a complex
108
+ // widget (amount + currency) that the generator cannot automate
109
+ // generically. The API seed uses it anyway (see test above).
110
110
  expect(opsByField.has("listPrice")).toBe(false);
111
111
  });
112
112
 
113
- test("Output ist JSON-serialisierbar (Prozess-Grenze zu Playwright-Worker)", () => {
114
- // Das Sample-Feature muss sich rundtrippen lassen: TestSpec → JSON.stringify →
115
- // JSON.parse. Im produktiven Setup schreibt ein bun-Subprozess (globalSetup)
116
- // die JSON auf Platte, der Playwright-Worker liest sie — kein runtime-Import
117
- // vom Framework im Test-Prozess (würde mit Playwrights expect kollidieren).
113
+ test("Output is JSON-serializable (process boundary to Playwright worker)", () => {
114
+ // The sample feature must round-trip: TestSpec → JSON.stringify →
115
+ // JSON.parse. In the production setup a bun subprocess (globalSetup) writes
116
+ // the JSON to disk, the Playwright worker reads it — no runtime import
117
+ // of the framework in the test process (would collide with Playwright's expect).
118
118
  const specs = generateE2ESpec(registry);
119
119
  const roundtripped = JSON.parse(JSON.stringify(specs));
120
120
  expect(roundtripped).toEqual(specs);
@@ -143,9 +143,9 @@ decrypt fallback until the job has run once, then delete it.
143
143
  ### src/__tests__/feature.integration.test.ts
144
144
 
145
145
  ```ts
146
- // End-to-End: Tenant-Admin setzt Stripe-API-Key → DB hat ciphertext →
147
- // charge-handler liest entschlüsselt → caller sieht charge-id, NIE den
148
- // Key. Plus: zwei Tenants haben getrennte Keys.
146
+ // End to end: tenant admin sets the Stripe API key → DB has ciphertext →
147
+ // the charge handler reads it decrypted → caller sees the charge id, NEVER the
148
+ // key. Plus: two tenants have separate keys.
149
149
 
150
150
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
151
151
  import { randomBytes } from "node:crypto";
@@ -200,7 +200,7 @@ beforeEach(() => {
200
200
  });
201
201
 
202
202
  describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
203
- test("DB hält ciphertext, NICHT plaintext", async () => {
203
+ test("DB stores ciphertext, NOT plaintext", async () => {
204
204
  const plaintextKey = "sk_live_super_secret_stripe_key_12345";
205
205
  await stack.http.writeOk(
206
206
  "config:write:set",
@@ -220,7 +220,7 @@ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
220
220
  expect(stored).not.toContain("super_secret");
221
221
  });
222
222
 
223
- test("config:query:values maskt encrypted-key mit ••••••", async () => {
223
+ test("config:query:values masks encrypted key with ••••••", async () => {
224
224
  await stack.http.writeOk(
225
225
  "config:write:set",
226
226
  { key: stripeApiKeyHandle.name, value: "sk_live_xyz", scope: "tenant" },
@@ -234,8 +234,8 @@ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
234
234
  expect(cfg[stripeApiKeyHandle.name]?.value).toBe("••••••");
235
235
  });
236
236
 
237
- test("charge-handler liest den entschlüsselten Key + nutzt ihn", async () => {
238
- // Charge ohne gesetzten Key → schlägt mit klarer Error fehl
237
+ test("charge-handler reads the decrypted key + uses it", async () => {
238
+ // Charge without a set key → fails with a clear error
239
239
  const noKey = await stack.http.write(
240
240
  "billing:write:charge",
241
241
  { amount: 1000, customerRef: "cust_42" },
@@ -257,16 +257,16 @@ describe("encrypted tenant-config: per-tenant Stripe-API-key", () => {
257
257
  expect(ok.chargeId).toContain("cust_42");
258
258
  });
259
259
 
260
- test("Tenant-Isolation: Tenant-A's Key leakt NICHT zu Tenant-B", async () => {
260
+ test("Tenant isolation: Tenant-A's key does not leak to Tenant-B", async () => {
261
261
  await stack.http.writeOk(
262
262
  "config:write:set",
263
263
  { key: stripeApiKeyHandle.name, value: "sk_live_acme_only", scope: "tenant" },
264
264
  acmeAdmin,
265
265
  );
266
266
 
267
- // Tenant-B macht charge OHNE eigenen Key → fehlschlägt (sieht
268
- // den acme-Key NICHT). Beweis: per-tenant config-row, nicht globale
269
- // Variable.
267
+ // Tenant B does a charge WITHOUT its own key → fails (does NOT see
268
+ // the acme key). Proof: per-tenant config row, not a global
269
+ // variable.
270
270
  const res = await stack.http.write(
271
271
  "billing:write:charge",
272
272
  { amount: 500, customerRef: "globex_cust" },
@@ -310,17 +310,17 @@ describe("encrypted tenant-config: mask derives the configEdit screen", () => {
310
310
  ### src/feature.ts
311
311
 
312
312
  ```ts
313
- // Encrypted per-tenant config — Stripe-API-key pattern.
313
+ // Encrypted per-tenant config — Stripe API key pattern.
314
314
  //
315
- // Minimal-Showcase: ein dummy "billing"-feature mit einem per-tenant
316
- // Stripe-API-Key. Der `mask`-Eintrag lässt den configEdit-Screen +
317
- // Settings-Hub-Nav automatisch entstehen (kein handgeschriebenes
318
- // r.screen/r.nav mehr). charge-handler liest den Key über ctx.config
319
- // (entschlüsselt automatisch).
315
+ // Minimal showcase: a dummy "billing" feature with a per-tenant
316
+ // Stripe API key. The `mask` entry makes the configEdit screen +
317
+ // settings hub nav appear automatically (no more hand-written
318
+ // r.screen/r.nav). The charge handler reads the key via ctx.config
319
+ // (decrypted automatically).
320
320
  //
321
- // Production: nicht aufrufen — der echte Stripe-Call ist hier ein Mock.
322
- // Pattern in produktiven Apps: Strip-API-Key in tenantBillingConfig.key,
323
- // charge-handler lädt key + ruft tatsächlich Stripe.
321
+ // Production: do not call — the real Stripe call is a mock here.
322
+ // Pattern in productive apps: Stripe API key in tenantBillingConfig.key,
323
+ // the charge handler loads the key + actually calls Stripe.
324
324
 
325
325
  import {
326
326
  access,
@@ -352,8 +352,8 @@ const stripeApiKeyHandle: ConfigKeyHandle<"text"> = {
352
352
  type: "text",
353
353
  };
354
354
 
355
- // Charge-Handler — nutzt den entschlüsselten API-Key. Caller sieht NUR
356
- // die charge-id zurück, der Key bleibt server-side.
355
+ // Charge handler — uses the decrypted API key. The caller sees ONLY
356
+ // the charge id back, the key stays server-side.
357
357
  const chargeHandler = defineWriteHandler({
358
358
  name: "charge",
359
359
  schema: z.object({
@@ -378,10 +378,10 @@ const chargeHandler = defineWriteHandler({
378
378
  );
379
379
  }
380
380
 
381
- // Mock: real impl würde fetch("https://api.stripe.com/v1/charges",
382
- // { headers: { Authorization: `Bearer ${apiKey}` }, ... }) ausführen.
383
- // Wichtig: apiKey verlässt den server NICHT — kein log, kein
384
- // response-field, kein error-detail.
381
+ // Mock: a real impl would execute fetch("https://api.stripe.com/v1/charges",
382
+ // { headers: { Authorization: `Bearer ${apiKey}` }, ... }).
383
+ // Important: apiKey does NOT leave the server — no log, no
384
+ // response field, no error detail.
385
385
  const chargeId = `ch_${Date.now()}_${event.payload.customerRef}`;
386
386
 
387
387
  return {
@@ -395,10 +395,10 @@ export const billingFeature = defineFeature(FEATURE, (r) => {
395
395
  r.requires({ features: ["config"] });
396
396
  r.config("stripe-api-key", stripeApiKeyDef);
397
397
  r.writeHandler(chargeHandler);
398
- // Kein r.screen/r.nav: der `mask`-Eintrag auf dem Key lässt
399
- // buildConfigFeatureSchema den configEdit-Screen (••••••-maskiert,
400
- // config:write:set verschlüsselt vor dem write) + den Settings-Hub-Nav
401
- // automatisch ableiten.
398
+ // No r.screen/r.nav: the `mask` entry on the key lets
399
+ // buildConfigFeatureSchema derive the configEdit screen (••••••-masked,
400
+ // config:write:set encrypts before the write) + the settings hub nav
401
+ // automatically.
402
402
 
403
403
  r.translations({
404
404
  keys: {
@@ -408,6 +408,6 @@ export const billingFeature = defineFeature(FEATURE, (r) => {
408
408
  });
409
409
  });
410
410
 
411
- // Re-exports damit tests die handles ohne re-typing nutzen können.
411
+ // Re-exports so tests can use the handles without re-typing.
412
412
  export { stripeApiKeyHandle };
413
413
  ```
@@ -225,7 +225,7 @@ describe("entry point 3 — public route, default-deny", () => {
225
225
  expect(res.headers.get("Content-Type")).toBe("image/webp");
226
226
  });
227
227
 
228
- test("publishing tenant A's photo does not publish tenant B's photo (#1983/1)", async () => {
228
+ test("publishing tenant A's photo does not publish tenant B's photo", async () => {
229
229
  const photoIdA = await createPhoto(user);
230
230
  const photoIdB = await createPhoto(userB);
231
231
  const fileIdA = await uploadPhoto(
@@ -232,7 +232,7 @@ describe("inbound-mail triage sample", () => {
232
232
  data: { duplicate: boolean };
233
233
  };
234
234
  expect(replayBody.data.duplicate).toBe(true);
235
- // Trigger-Nachlauf abwarten, dann Idempotenz prüfen.
235
+ // Wait for the trigger follow-up, then check idempotency.
236
236
  await new Promise((resolve) => setTimeout(resolve, 500));
237
237
  expect(triageInbox.size).toBe(1);
238
238
  } finally {
@@ -164,11 +164,11 @@ import { z } from "zod";
164
164
  export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
165
165
  export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
166
166
 
167
- // Entity-Dekl sichert den qualified-name-Prefix "orders:write:order:create"
168
- // für den Write-Handler unten und damit den Job-Trigger auf diesem Event.
169
- // Persistenz passiert im Custom-Handler nicht (id ist hardcoded für den
170
- // Sample-Test) — in einer echten App würde ein entityWriteHandler in die DB
171
- // schreiben.
167
+ // The entity declaration secures the qualified-name prefix "orders:write:order:create"
168
+ // for the write handler below and thereby the job trigger on this event.
169
+ // Persistence does not happen in the custom handler (id is hardcoded for the
170
+ // sample test) — in a real app an entityWriteHandler would write to the
171
+ // DB.
172
172
  const orderEntity = createEntity({
173
173
  table: "read_orders",
174
174
  fields: {
@@ -267,7 +267,7 @@ beforeAll(async () => {
267
267
  await unsafeCreateEntityTable(db, templateResourceEntity);
268
268
  await createEventsTable(db);
269
269
 
270
- // Seed Pflicht-Blocks für SYSTEM_TENANT (DE Impressum + DSE)
270
+ // Seed required blocks for SYSTEM_TENANT (DE imprint + privacy policy)
271
271
  await seedTextBlock(db, {
272
272
  tenantId: SYSTEM_TENANT_ID,
273
273
  slug: "imprint",
@@ -313,9 +313,9 @@ afterAll(async () => {
313
313
  });
314
314
 
315
315
  describe("legal-pages sample", () => {
316
- test("alle LEGAL_ROUTES sind über stack.app erreichbar", async () => {
317
- // Smoke-Test: jede Route gibt entweder 200 (gerendert) oder 404
318
- // (Block fehlt). Nie 5xx — die Server-Pipeline läuft sauber durch.
316
+ test("all LEGAL_ROUTES are reachable via stack.app", async () => {
317
+ // Smoke test: every route returns either 200 (rendered) or 404
318
+ // (block missing). Never 5xx — the server pipeline runs through cleanly.
319
319
  for (const route of LEGAL_ROUTES) {
320
320
  const res = await stack.app.request(route.path);
321
321
  expect([200, 404]).toContain(res.status);
@@ -337,21 +337,21 @@ describe("legal-pages sample", () => {
337
337
  expect(privacyBody).toContain("Datenschutzerklärung");
338
338
  });
339
339
 
340
- test("EN-Variante 404 (nicht geseedet) zeigt hilfreiche Meldung", async () => {
340
+ test("The EN variant 404 (not seeded) shows a helpful message", async () => {
341
341
  const res = await stack.app.request("/legal/imprint");
342
342
  expect(res.status).toBe(404);
343
343
  const body = await res.text();
344
344
  expect(body).toContain("Tenant-Admin");
345
345
  });
346
346
 
347
- test("Markdown-Headings werden zu HTML-h2-Tags", async () => {
347
+ test("Markdown headings are converted to HTML h2 tags", async () => {
348
348
  const res = await stack.app.request("/legal/impressum");
349
349
  const body = await res.text();
350
350
  expect(body).toContain("<h2>Angaben gemäß § 5 TMG</h2>");
351
351
  expect(body).toContain("<strong>Alex Morgan</strong>");
352
352
  });
353
353
 
354
- test("Cache-Header zeigt 60s public-cache", async () => {
354
+ test("Cache header indicates 60s public caching", async () => {
355
355
  const res = await stack.app.request("/legal/impressum");
356
356
  expect(res.headers.get("cache-control")).toBe("public, max-age=60, must-revalidate");
357
357
  });
@@ -378,40 +378,40 @@ describe("legal-pages sample", () => {
378
378
  ### src/feature.ts
379
379
 
380
380
  ```ts
381
- // Legal-Pages Sample
381
+ // Legal-pages sample
382
382
  //
383
- // DACH-Apps (DE/AT/CH) sind verpflichtet ein Impressum (TMG/DDG §5) und
384
- // eine Datenschutzerklärung (DSGVO Art. 13) öffentlich zugänglich zu
385
- // haben. Das ist 1) für jede App identisch und 2) nervig manuell pro
386
- // App neu zu basteln.
383
+ // DACH apps (DE/AT/CH) are required to make an imprint (TMG/DDG §5) and
384
+ // a privacy policy (GDPR Art. 13) publicly accessible.
385
+ // That is 1) identical for every app and 2) annoying to build
386
+ // by hand per app.
387
387
  //
388
- // Lösung: zwei opt-in bundled-features kombinieren:
388
+ // Solution: combine two opt-in bundled features:
389
389
  //
390
- // - `template-resolver` — der Content-Store (Entity `template-resource`,
391
- // kind `text-block` mit slug+locale+title+content, scoped per Tenant).
392
- // Auch nutzbar für FAQ, About, ToS, Marketing-Snippets — nicht
393
- // legal-spezifisch.
390
+ // - `template-resolver` — the content store (entity `template-resource`,
391
+ // kind `text-block` with slug+locale+title+content, scoped per tenant).
392
+ // Also usable for FAQ, About, ToS, marketing snippets — not
393
+ // legal-specific.
394
394
  //
395
- // - `legal-pages` — opt-in-Wrapper darauf, der vier feste Public-
396
- // Routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
397
- // `/legal/privacy`) registriert und Markdown→HTML rendered. Plus
398
- // Boot-Check der in Production hart fehlt wenn die DE-Pflicht-Blocks
399
- // fehlen.
395
+ // - `legal-pages` — opt-in wrapper on top of it that registers four fixed public
396
+ // routes (`/legal/impressum`, `/legal/datenschutz`, `/legal/imprint`,
397
+ // `/legal/privacy`) and renders Markdown→HTML. Plus a
398
+ // boot check that fails hard in production when the DE required blocks
399
+ // are missing.
400
400
  //
401
- // Tenant-Modell: 1 App = X Tenants = 1 Impressum. Alle Subdomains
402
- // teilen sich die SYSTEM_TENANT_ID-Version. Wer pro-Tenant-Impressums
403
- // braucht, muss sein eigenes Routing davorsetzen oder die by-slug-
404
- // query mit tenant-specific tenantId nutzen.
401
+ // Tenant model: 1 app = X tenants = 1 imprint. All subdomains
402
+ // share the SYSTEM_TENANT_ID version. If you need per-tenant imprints,
403
+ // you have to put your own routing in front or use the by-slug
404
+ // query with a tenant-specific tenantId.
405
405
  //
406
- // Voraussetzungen für Production:
407
- // - `anonymousAccess` muss in runProdApp/runDevApp konfiguriert sein
408
- // (defaultTenantId = SYSTEM_TENANT_ID), sonst antworten die
409
- // legal-pages-Routes mit 503
410
- // - `extraContext.templateResolver = createTemplateResolverApi(db, registry)` muss
411
- // gewired sein, sonst wirft der Boot-Check mit Wiring-Hinweis
412
- // - Beim ersten Boot müssen die TextBlocks geseedet sein —
413
- // template-resolver/seeding `seedTextBlock` oder via API
414
- // `template-resolver:write:set` mit TenantAdmin-Token
406
+ // Prerequisites for production:
407
+ // - `anonymousAccess` must be configured in runProdApp/runDevApp
408
+ // (defaultTenantId = SYSTEM_TENANT_ID), otherwise the
409
+ // legal-pages routes answer with 503
410
+ // - `extraContext.templateResolver = createTemplateResolverApi(db, registry)` must be
411
+ // wired, otherwise the boot check throws with a wiring hint
412
+ // - On first boot the TextBlocks must be seeded —
413
+ // template-resolver/seeding `seedTextBlock` or via API
414
+ // `template-resolver:write:set` with a TenantAdmin token
415
415
 
416
416
  import {
417
417
  createLegalPagesFeature,
@@ -437,6 +437,6 @@ export const legalPagesFeature = createLegalPagesFeature({
437
437
  }),
438
438
  });
439
439
 
440
- // Re-exports für Tests + andere Demos
440
+ // Re-exports for tests + other demos
441
441
  export { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, LEGAL_TERMS_ROUTES };
442
442
  ```