@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.
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +35 -35
- package/agents/examples/compliance-profiles-demo.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +1 -1
- package/agents/examples/designer-demo.md +6 -6
- package/agents/examples/e2e-generator.md +18 -18
- package/agents/examples/encrypted-tenant-config.md +31 -31
- package/agents/examples/files-post-processing.md +1 -1
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +5 -5
- package/agents/examples/legal-pages.md +36 -36
- package/agents/examples/record-detail-layout.md +41 -8
- package/agents/examples/seed-migration.md +44 -44
- package/agents/examples/user-data-rights.md +21 -21
- package/agents/examples/user-profile.md +5 -5
- package/agents/examples/wizard-form.md +1 -1
- package/agents/features.md +3 -3
- package/dist/db/entity-column-meta.d.ts +3 -0
- package/dist/db/entity-column-meta.js +113 -0
- package/dist/db/entity-table-indexes.d.ts +7 -0
- package/dist/db/entity-table-indexes.js +124 -0
- package/dist/db/entity-table-meta.js +1 -153
- package/dist/db/migrate-change-render.d.ts +40 -0
- package/dist/db/migrate-change-render.js +89 -0
- package/dist/db/migrate-generator.d.ts +5 -37
- package/dist/db/migrate-generator.js +99 -171
- package/dist/db/row-level-security-boot-check.js +4 -4
- package/dist/db/table-builder.d.ts +1 -1
- package/dist/db/table-builder.js +42 -133
- package/dist/engine/boot-validator/query-refs.js +22 -9
- package/dist/engine/boot-validator/screens-actions.js +11 -8
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +8 -0
- package/dist/engine/build-app-schema.js +5 -0
- package/dist/engine/extensions/user-data.js +0 -2
- package/dist/engine/project-query-live-entities.d.ts +2 -0
- package/dist/engine/project-query-live-entities.js +11 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +1 -1
- package/dist/env/index.d.ts +1 -0
- package/dist/env/index.js +1 -0
- package/dist/env/required-env.d.ts +2 -0
- package/dist/env/required-env.js +9 -0
- package/dist/i18n/required-surface-keys.js +4 -0
- package/dist/migrations/kumiko-drift.js +4 -4
- package/dist/ui-types/app-schema.d.ts +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +3 -3
- 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
|
|
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
|
-
//
|
|
131
|
-
// auth + user-data-rights)
|
|
132
|
-
//
|
|
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:
|
|
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
|
|
241
|
+
// apex-surface-auth recipe — the evident way for public apex content.
|
|
242
242
|
//
|
|
243
|
-
//
|
|
244
|
-
// schema-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
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
|
-
//
|
|
249
|
-
// 1. Server:
|
|
250
|
-
// email-
|
|
251
|
-
// 2. Client (
|
|
252
|
-
//
|
|
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
|
|
255
|
-
//
|
|
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:
|
|
268
|
-
// //
|
|
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 —
|
|
278
|
-
// //
|
|
279
|
-
// // (
|
|
280
|
-
// //
|
|
281
|
-
// // auth-mfa, `mfaVerifyScreen: MfaVerifyScreen` (
|
|
282
|
-
// // "@cosmicdrift/kumiko-bundled-features/auth-mfa/web")
|
|
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:
|
|
300
|
-
//
|
|
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
|
|
321
|
+
/** HMAC secret for the deletion verify token. */
|
|
322
322
|
readonly deletionTokenSecret: string;
|
|
323
|
-
/** Apex
|
|
324
|
-
* URL
|
|
323
|
+
/** Apex route of the confirm screen; the handler appends the token as a
|
|
324
|
+
* URL fragment (`#token=`). */
|
|
325
325
|
readonly deletionVerifyUrl: string;
|
|
326
|
-
/**
|
|
327
|
-
*
|
|
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
|
-
//
|
|
332
|
-
// user-data-rights
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
33
|
-
//
|
|
34
|
-
// - entity →
|
|
35
|
-
// - writeHandler →
|
|
36
|
-
// - nav → ID/
|
|
37
|
-
// Plus optionalRequires + metric
|
|
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
|
|
34
|
+
// E2E generator sample — unit test.
|
|
35
35
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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
|
|
57
|
+
test("validateBoot accepts the feature", () => {
|
|
58
58
|
expect(() => validateBoot([shop])).not.toThrow();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test("generateE2ESpec
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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
|
|
114
|
-
//
|
|
115
|
-
// JSON.parse.
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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
|
|
147
|
-
// charge
|
|
148
|
-
//
|
|
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
|
|
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
|
|
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
|
|
238
|
-
// Charge
|
|
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
|
|
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
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
|
313
|
+
// Encrypted per-tenant config — Stripe API key pattern.
|
|
314
314
|
//
|
|
315
|
-
// Minimal
|
|
316
|
-
// Stripe
|
|
317
|
-
//
|
|
318
|
-
// r.screen/r.nav
|
|
319
|
-
// (
|
|
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:
|
|
322
|
-
// Pattern in
|
|
323
|
-
// charge
|
|
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
|
|
356
|
-
//
|
|
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
|
|
382
|
-
// { headers: { Authorization: `Bearer ${apiKey}` }, ... })
|
|
383
|
-
//
|
|
384
|
-
// response
|
|
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
|
-
//
|
|
399
|
-
// buildConfigFeatureSchema
|
|
400
|
-
// config:write:set
|
|
401
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
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("
|
|
317
|
-
// Smoke
|
|
318
|
-
// (
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
381
|
+
// Legal-pages sample
|
|
382
382
|
//
|
|
383
|
-
// DACH
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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
|
-
//
|
|
388
|
+
// Solution: combine two opt-in bundled features:
|
|
389
389
|
//
|
|
390
|
-
// - `template-resolver` —
|
|
391
|
-
// kind `text-block`
|
|
392
|
-
//
|
|
393
|
-
// legal-
|
|
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
|
|
396
|
-
//
|
|
397
|
-
// `/legal/privacy`)
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
// query
|
|
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
|
-
//
|
|
407
|
-
// - `anonymousAccess`
|
|
408
|
-
// (defaultTenantId = SYSTEM_TENANT_ID),
|
|
409
|
-
// legal-pages
|
|
410
|
-
// - `extraContext.templateResolver = createTemplateResolverApi(db, registry)`
|
|
411
|
-
//
|
|
412
|
-
// -
|
|
413
|
-
// template-resolver/seeding `seedTextBlock`
|
|
414
|
-
// `template-resolver:write:set`
|
|
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
|
|
440
|
+
// Re-exports for tests + other demos
|
|
441
441
|
export { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, LEGAL_TERMS_ROUTES };
|
|
442
442
|
```
|