@cosmicdrift/kumiko-samples 0.403.0 → 0.404.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
- package/packages/bundled-features/src/data-retention/feature.ts +1 -1
- package/packages/bundled-features/src/delivery/feature.ts +1 -1
- package/packages/bundled-features/src/documents/changes.json +8 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +6 -2
- package/packages/bundled-features/src/form-draft/feature.ts +1 -1
- package/packages/bundled-features/src/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/feature.ts +6 -7
- package/packages/bundled-features/src/legal-pages/README.md +21 -13
- package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- package/packages/bundled-features/src/template-resolver/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- package/packages/bundled-features/src/user-data-rights/feature.ts +3 -3
- package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/lane-routing/README.md +1 -0
- package/samples/recipes/lane-routing/src/feature.ts +8 -0
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
|
@@ -28,11 +28,15 @@ A typical app has two mounts sharing locale and primitives:
|
|
|
28
28
|
|
|
29
29
|
```tsx illustration
|
|
30
30
|
import {
|
|
31
|
-
ForgotPasswordScreen,
|
|
32
|
-
|
|
31
|
+
ForgotPasswordScreen,
|
|
32
|
+
SignupScreen,
|
|
33
|
+
createLoginRoute,
|
|
34
|
+
AuthShellProvider,
|
|
35
|
+
emailPasswordClient,
|
|
33
36
|
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
|
|
34
37
|
import {
|
|
35
|
-
RequestAccountDeletionScreen,
|
|
38
|
+
RequestAccountDeletionScreen,
|
|
39
|
+
ConfirmAccountDeletionScreen,
|
|
36
40
|
} from "@cosmicdrift/kumiko-bundled-features/user-data-rights/web";
|
|
37
41
|
import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
|
|
38
42
|
|
|
@@ -43,7 +47,7 @@ import { createPublicSurface } from "@cosmicdrift/kumiko-renderer/web";
|
|
|
43
47
|
const LoginRoute = createLoginRoute({ loginScreenProps: { signupHref: "/signup" } });
|
|
44
48
|
|
|
45
49
|
createPublicSurface({
|
|
46
|
-
clientFeatures: [emailPasswordClient()],
|
|
50
|
+
clientFeatures: [emailPasswordClient()], // SessionProvider + i18n
|
|
47
51
|
shell: ({ children }) => (
|
|
48
52
|
<MarketingChrome>
|
|
49
53
|
<AuthShellProvider shell={(card) => <div className="py-12 flex justify-center">{card}</div>}>
|
|
@@ -52,10 +56,10 @@ createPublicSurface({
|
|
|
52
56
|
</MarketingChrome>
|
|
53
57
|
),
|
|
54
58
|
routes: [
|
|
55
|
-
{ path: "/login",
|
|
56
|
-
{ path: "/signup",
|
|
59
|
+
{ path: "/login", component: <LoginRoute /> },
|
|
60
|
+
{ path: "/signup", component: <SignupScreen loginHref="/login" /> },
|
|
57
61
|
{ path: "/forgot-password", component: <ForgotPasswordScreen loginHref="/login" /> },
|
|
58
|
-
{ path: "/delete-account",
|
|
62
|
+
{ path: "/delete-account", component: <RequestAccountDeletionScreen /> },
|
|
59
63
|
{ path: "/delete-account/confirm", component: <ConfirmAccountDeletionScreen /> },
|
|
60
64
|
],
|
|
61
65
|
fallback: <LoginRoute />,
|
|
@@ -90,8 +94,8 @@ runProdApp({
|
|
|
90
94
|
|
|
91
95
|
GDPR Art. 17 applies when the user **cannot log in** (lockout). The flow is
|
|
92
96
|
email-verified (magic link), not login-gated. The HMAC token carries `userId`
|
|
93
|
-
|
|
94
|
-
token endpoints. Second confirm is idempotent (`user_not_in_active_state`).
|
|
97
|
+
- expiry (no DB table); purpose `"deletion-request"` blocks replay on other
|
|
98
|
+
token endpoints. Second confirm is idempotent (`user_not_in_active_state`).
|
|
95
99
|
|
|
96
100
|
## Tests
|
|
97
101
|
|
|
@@ -22,9 +22,7 @@ const mfaFeature = createAuthMfaFeature({
|
|
|
22
22
|
issuer: "Your App",
|
|
23
23
|
});
|
|
24
24
|
const sessionCallbacks = createSessionCallbacks({ db });
|
|
25
|
-
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
26
|
-
sessionCallbacks.sessionRevokeAllOthers,
|
|
27
|
-
);
|
|
25
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
|
|
28
26
|
|
|
29
27
|
const server = buildServer({
|
|
30
28
|
registry: buildRegistry([
|
|
@@ -37,7 +35,7 @@ const server = buildServer({
|
|
|
37
35
|
mfaFeature,
|
|
38
36
|
// ... other features
|
|
39
37
|
]),
|
|
40
|
-
context: { db
|
|
38
|
+
context: { db /* ... */ },
|
|
41
39
|
jwtSecret: process.env.JWT_SECRET,
|
|
42
40
|
auth: {
|
|
43
41
|
membershipQuery: "tenant:query:memberships",
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
"@cosmicdrift/kumiko-bundled-features": "workspace:*",
|
|
7
7
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
8
8
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
9
|
-
"react": "^19.
|
|
9
|
+
"react": "^19.3.1"
|
|
10
10
|
},
|
|
11
11
|
"devDependencies": {
|
|
12
12
|
"@cosmicdrift/kumiko-testing": "workspace:*",
|
|
13
|
-
"@types/react": "^19.
|
|
13
|
+
"@types/react": "^19.3.0"
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -12,7 +12,7 @@ never mounted, and nothing caught it — until now.
|
|
|
12
12
|
ctx exposing every mounted feature. Throw to fail the boot with a clear,
|
|
13
13
|
feature-prefixed message.
|
|
14
14
|
- **Conditional invariants `r.requires` can't express** — the check only
|
|
15
|
-
fails when
|
|
15
|
+
fails when _this_ feature's own shape demands it (has a PII field), not
|
|
16
16
|
unconditionally. A plain `r.requires({ features: ["user-data-hook"] })` would fail even
|
|
17
17
|
for a prompt-store variant with no PII fields at all.
|
|
18
18
|
- **`validateBoot`** — the framework's boot-time validator, run directly
|
|
@@ -38,7 +38,7 @@ user-data-hook → companion feature the check requires when PII is present
|
|
|
38
38
|
|
|
39
39
|
## When to reach for it
|
|
40
40
|
|
|
41
|
-
You're writing a feature whose validity depends on
|
|
41
|
+
You're writing a feature whose validity depends on _another_ feature being
|
|
42
42
|
mounted, but only under a condition your feature alone can evaluate (e.g.
|
|
43
43
|
"only if I have PII fields", "only if I expose more than N screens"). If the
|
|
44
44
|
requirement is unconditional, `r.requires`/`r.optionalRequires` is simpler
|
|
@@ -8,11 +8,11 @@ branches in your domain handlers.
|
|
|
8
8
|
How a multi-tenant platform uses `compliance-profiles` to deliver different
|
|
9
9
|
regulatory behaviour per tenant:
|
|
10
10
|
|
|
11
|
-
| Tenant
|
|
12
|
-
|
|
13
|
-
| **A** (DACH)
|
|
14
|
-
| **B** (Switzerland) | `swiss-dsg`
|
|
15
|
-
| **C** (DACH-HR)
|
|
11
|
+
| Tenant | Profile | Supervisory authority | Languages | Tenant-destroy grace |
|
|
12
|
+
| ------------------- | ----------------- | --------------------- | ----------------- | --------------------- |
|
|
13
|
+
| **A** (DACH) | `eu-dsgvo` | BlnBDI Berlin | de / en | 30 days |
|
|
14
|
+
| **B** (Switzerland) | `swiss-dsg` | EDÖB Bern | de / fr / it / en | 30 days |
|
|
15
|
+
| **C** (DACH-HR) | `de-hr-dsgvo-hgb` | State DPA | de | 60 days (HR override) |
|
|
16
16
|
|
|
17
17
|
Plus tenant B with an override (`gracePeriod: { days: 90 }`) extending
|
|
18
18
|
user-rights grace without losing other profile fields — deep-merge on the
|
|
@@ -28,14 +28,14 @@ the option.
|
|
|
28
28
|
|
|
29
29
|
## Boot requirements
|
|
30
30
|
|
|
31
|
-
| Stack
|
|
32
|
-
|
|
|
33
|
-
| `composeMailStack`
|
|
34
|
-
| `composeFileStack` without providers
|
|
35
|
-
| `composeGdprStack`
|
|
36
|
-
| `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail`
|
|
37
|
-
| `composeSecurityStack`
|
|
38
|
-
| `composeSaasStack({ billing: "stripe" })`
|
|
31
|
+
| Stack | Boot option | Also satisfied by |
|
|
32
|
+
| ----------------------------------------------------------------- | ---------------------------------- | ------------------------------- |
|
|
33
|
+
| `composeMailStack` | `mail` | a mounted `channel-email` |
|
|
34
|
+
| `composeFileStack` without providers | `fileStorage` | a mounted file provider |
|
|
35
|
+
| `composeGdprStack` | `kmsMode` | `kms` or `allowPlaintextPii` |
|
|
36
|
+
| `composeGdprStack({ selfService })`, `composeUserDataRightsStack` | `mail` | a mounted `channel-email` |
|
|
37
|
+
| `composeSecurityStack` | `kmsMode`, `rateLimit` not `false` | `kms` or `allowPlaintextPii` |
|
|
38
|
+
| `composeSaasStack({ billing: "stripe" })` | `billing` (warning only) | a mounted subscription provider |
|
|
39
39
|
|
|
40
40
|
A missing option fails the boot with:
|
|
41
41
|
|
|
@@ -68,6 +68,7 @@ The integration test pins three guarantees:
|
|
|
68
68
|
```bash
|
|
69
69
|
bun kumiko test integration samples/cross-feature-events
|
|
70
70
|
```
|
|
71
|
+
|
|
71
72
|
4. **Catch-up job** — `catch-up-job.integration.test.ts` places orders
|
|
72
73
|
before the first dispatcher pass and checks that the `catchUp` job
|
|
73
74
|
invoices each of them exactly once.
|
|
@@ -7,7 +7,7 @@ unreadable forever, and reads render the `[[erased]]` sentinel.
|
|
|
7
7
|
|
|
8
8
|
The recipe ships a mini HR feature: an `employee` entity whose name and email
|
|
9
9
|
are the employee's own PII, and an `hr-comment` entity whose body is
|
|
10
|
-
encrypted under the key of the employee the comment is
|
|
10
|
+
encrypted under the key of the employee the comment is _about_ — so a
|
|
11
11
|
manager's comment dies with the employee's key, not the manager's.
|
|
12
12
|
|
|
13
13
|
## What it shows
|
|
@@ -41,7 +41,7 @@ manager's comment dies with the employee's key, not the manager's.
|
|
|
41
41
|
than an alphabetically paginated one — not a plaintext sort column beside
|
|
42
42
|
the encrypted one, which would survive the key erase and quietly undo the
|
|
43
43
|
shredding.
|
|
44
|
-
- **Forget = `kms.eraseKey(subject)`** — afterwards detail
|
|
44
|
+
- **Forget = `kms.eraseKey(subject)`** — afterwards detail _and_ list render
|
|
45
45
|
`[[erased]]` for every protected field while the stored ciphertext bytes
|
|
46
46
|
stay untouched.
|
|
47
47
|
|
|
@@ -62,7 +62,7 @@ deletion grace period.
|
|
|
62
62
|
|
|
63
63
|
1. Create an employee → the executor creates a subject key on first insert
|
|
64
64
|
and stores `displayName`/`email` as ciphertext.
|
|
65
|
-
2. A comment about the employee is encrypted under the
|
|
65
|
+
2. A comment about the employee is encrypted under the _employee's_ key via
|
|
66
66
|
`userOwned: { ownerField: "employeeId" }`.
|
|
67
67
|
3. `kms.eraseKey({ kind: "user", userId })` — idempotent, tombstone stays
|
|
68
68
|
for the audit trail.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Let a tenant add their own fields to your entity at runtime — without
|
|
4
4
|
writing a migration, without rebuilding your handler, without a code
|
|
5
5
|
change. The recipe wires a `property` entity into the `custom-fields`
|
|
6
|
-
bundle and drives a full
|
|
6
|
+
bundle and drives a full _define → set → read_ roundtrip.
|
|
7
7
|
|
|
8
8
|
The result: the tenant defines `internalNumber: text`, sets it on a
|
|
9
9
|
property, and reads it back flattened onto the row — looking exactly
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Documents
|
|
2
|
+
|
|
3
|
+
A document register on top of existing records: captured files with a category, linked to records, visible only to whoever may read those records.
|
|
4
|
+
|
|
5
|
+
## What it shows
|
|
6
|
+
|
|
7
|
+
- `createDocumentsFeature` (`@cosmicdrift/kumiko-bundled-features/documents`) with `categories`, `linkTargets` and `access` roles.
|
|
8
|
+
- A `capture` rule turns a file field into a document source without the entity knowing about the documents feature: every saved `contract.file` is filed as a document of category `contract`, titled from the row, and linked to the contract's property.
|
|
9
|
+
- Visibility follows the linked records: a document is visible to users who may read the linked records and the contract that owns the file. The `documents:query:for-record` query returns the documents of one record; a property reader sees none for the property because they may not read the contract that owns the file.
|
|
10
|
+
|
|
11
|
+
## Source
|
|
12
|
+
|
|
13
|
+
Feature entry point: `src/feature.ts`.
|
|
14
|
+
|
|
15
|
+
```ts file=<rootDir>/_samples/recipes-documents/src/feature.ts
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Tests
|
|
19
|
+
|
|
20
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
cd samples/recipes/documents
|
|
24
|
+
bun test
|
|
25
|
+
```
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
7
7
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
8
|
-
"react": "^19.
|
|
8
|
+
"react": "^19.3.1"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@cosmicdrift/kumiko-testing": "workspace:*",
|
|
12
|
-
"@types/react": "^19.
|
|
12
|
+
"@types/react": "^19.3.0"
|
|
13
13
|
}
|
|
14
14
|
}
|
|
@@ -10,10 +10,10 @@ the platform operator being able to read the plaintext from the DB.
|
|
|
10
10
|
import { access, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
|
|
12
12
|
createTenantConfig("text", {
|
|
13
|
-
encrypted: true,
|
|
14
|
-
write: access.admin,
|
|
15
|
-
read: access.admin,
|
|
16
|
-
mask: { title: "billing.stripe-api-key", order: 1 },
|
|
13
|
+
encrypted: true, // ← ciphertext in the DB
|
|
14
|
+
write: access.admin, // ← the tenant admin writes their own
|
|
15
|
+
read: access.admin, // ← nobody else sees it
|
|
16
|
+
mask: { title: "billing.stripe-api-key", order: 1 }, // ← derives the edit screen + nav
|
|
17
17
|
});
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -39,10 +39,10 @@ i18n key of the field label, `mask.order` its position.
|
|
|
39
39
|
|
|
40
40
|
## Vs. `r.secret` / `samples/recipes/secrets-demo`
|
|
41
41
|
|
|
42
|
-
| Pattern
|
|
43
|
-
|
|
44
|
-
| **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers).
|
|
45
|
-
| **`encrypted: true` config key** (this recipe)
|
|
42
|
+
| Pattern | Scope | Use case |
|
|
43
|
+
| -------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| **`r.secret`** (envelope encryption, KEK rotation) | App-global | Platform-owned secrets (e.g. the master Stripe key that bills ALL customers). |
|
|
45
|
+
| **`encrypted: true` config key** (this recipe) | Per-tenant | Customer-owned secrets. Customer sets, rotates, and never sees another customer's. Same envelope encryption + KEK keyring under the hood. |
|
|
46
46
|
|
|
47
47
|
Combinable: the platform uses `r.secret` for its internal secrets,
|
|
48
48
|
and in parallel customers have their tenant-owned `encrypted: true`
|
|
@@ -21,16 +21,16 @@ instead of keeping a hand-written copy of every feature's variables.
|
|
|
21
21
|
|
|
22
22
|
## Blocks
|
|
23
23
|
|
|
24
|
-
| Block
|
|
25
|
-
|
|
|
26
|
-
| `kmsEnvSchema`
|
|
27
|
-
| `smtpEnvSchema`
|
|
28
|
-
| `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3`
|
|
29
|
-
| `stripeEnvSchema({ prices })`
|
|
30
|
-
| `paddleEnvSchema({ prices })`
|
|
31
|
-
| `mfaTokenSecretsEnvSchema`
|
|
32
|
-
| `buildInfoEnvSchema`, `demoAdminEnvSchema`
|
|
33
|
-
| `prometheusMetricsEnvSchema`
|
|
24
|
+
| Block | Where it lives | Registered by |
|
|
25
|
+
| ---------------------------------------------- | ------------------------- | ----------------------------------------------------- |
|
|
26
|
+
| `kmsEnvSchema` | `crypto-shredding` | `createCryptoShreddingFeature()` |
|
|
27
|
+
| `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
|
|
28
|
+
| `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
|
|
29
|
+
| `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
|
|
30
|
+
| `paddleEnvSchema({ prices })` | `subscription-paddle` | app (`extend`); the feature registers the bridge keys |
|
|
31
|
+
| `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
|
|
32
|
+
| `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
|
|
33
|
+
| `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
|
|
34
34
|
|
|
35
35
|
## When to reach for it
|
|
36
36
|
|
|
@@ -14,15 +14,15 @@ Every Kumiko error class in a real handler context. A single feature `orders-lit
|
|
|
14
14
|
|
|
15
15
|
## The classes — when do I use which?
|
|
16
16
|
|
|
17
|
-
| Class
|
|
18
|
-
|
|
19
|
-
| `ValidationError`
|
|
20
|
-
| `AccessDeniedError`
|
|
21
|
-
| `NotFoundError`
|
|
22
|
-
| `ConflictError`
|
|
23
|
-
| `VersionConflictError` | 409
|
|
24
|
-
| `UnprocessableError`
|
|
25
|
-
| `InternalError`
|
|
17
|
+
| Class | HTTP | Use in handler |
|
|
18
|
+
| ---------------------- | ---- | ------------------------------------------------------------------------------------- |
|
|
19
|
+
| `ValidationError` | 400 | Automatic from Zod. Never throw manually, only for validation-hook errors. |
|
|
20
|
+
| `AccessDeniedError` | 403 | "You're not allowed" — ownership, role check, field lock. |
|
|
21
|
+
| `NotFoundError` | 404 | Entity doesn't exist. Automatic via `failNotFound(entity, id)`. |
|
|
22
|
+
| `ConflictError` | 409 | State collision without a version (e.g. "paid orders can't be cancelled"). |
|
|
23
|
+
| `VersionConflictError` | 409 | Optimistic lock — comes out of CrudExecutor automatically. You never throw it. |
|
|
24
|
+
| `UnprocessableError` | 422 | Business rule violated. The reason string describes what. |
|
|
25
|
+
| `InternalError` | 500 | You **don't throw it yourself**. The framework wraps unexpected throws automatically. |
|
|
26
26
|
|
|
27
27
|
## Convenience helpers
|
|
28
28
|
|
|
@@ -6,15 +6,15 @@ blocks Kumiko provides.
|
|
|
6
6
|
|
|
7
7
|
## What the sample shows
|
|
8
8
|
|
|
9
|
-
| API
|
|
10
|
-
|
|
11
|
-
| `r.defineEvent(name, schema, { version, migrations })`
|
|
12
|
-
| `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream
|
|
13
|
-
| `r.projection(...)`
|
|
14
|
-
| `r.multiStreamProjection(...)`
|
|
15
|
-
| `ctx.events.load(id, { asOf })`
|
|
16
|
-
| `ctx.events.archive(id, { aggregateType })`
|
|
17
|
-
| `ctx.dispatch.queryProjection(name)`
|
|
9
|
+
| API | Purpose |
|
|
10
|
+
| ------------------------------------------------------------------ | ------------------------------------------------- |
|
|
11
|
+
| `r.defineEvent(name, schema, { version, migrations })` | Event schema with version number + upcaster chain |
|
|
12
|
+
| `ctx.events.append({ aggregateId, aggregateType, type, payload })` | Domain event on aggregate stream |
|
|
13
|
+
| `r.projection(...)` | Single-stream read model, inline in the write TX |
|
|
14
|
+
| `r.multiStreamProjection(...)` | Cross-aggregate read model, async via dispatcher |
|
|
15
|
+
| `ctx.events.load(id, { asOf })` | Live aggregation with point-in-time |
|
|
16
|
+
| `ctx.events.archive(id, { aggregateType })` | Archive a stream (Marten `ArchiveStream`) |
|
|
17
|
+
| `ctx.dispatch.queryProjection(name)` | Tenant-scoped read-model query |
|
|
18
18
|
|
|
19
19
|
## Domain
|
|
20
20
|
|
|
@@ -5,6 +5,7 @@ Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
|
|
|
5
5
|
## What it shows
|
|
6
6
|
|
|
7
7
|
- `r.job({ runIn: "worker" })` after an HTTP write
|
|
8
|
+
- `trigger: { maintenance: true }` for a daily housekeeping job in the app's maintenance window (`KUMIKO_MAINTENANCE_WINDOW`)
|
|
8
9
|
- End-to-end verification via `createAllInOneEntrypoint`
|
|
9
10
|
|
|
10
11
|
## Source
|
|
@@ -23,6 +23,7 @@ import { z } from "zod";
|
|
|
23
23
|
// opaque to the framework.
|
|
24
24
|
export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
|
|
25
25
|
export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
|
|
26
|
+
export const pruneRuns: number[] = [];
|
|
26
27
|
|
|
27
28
|
// The entity declaration secures the qualified-name prefix "orders:write:order:create"
|
|
28
29
|
// for the write handler below and thereby the job trigger on this event.
|
|
@@ -100,5 +101,12 @@ export function createLaneRoutingFeature(): FeatureDefinition {
|
|
|
100
101
|
});
|
|
101
102
|
},
|
|
102
103
|
);
|
|
104
|
+
|
|
105
|
+
// Housekeeping: `maintenance` runs once a day in the app's maintenance window
|
|
106
|
+
// (KUMIKO_MAINTENANCE_WINDOW, UTC, default 03:00) instead of a cron time of its own, so a
|
|
107
|
+
// sleeping app wakes once a day for all its cleanup jobs.
|
|
108
|
+
r.job("prune-old-receipts", { trigger: { maintenance: true }, runIn: "worker" }, async () => {
|
|
109
|
+
pruneRuns.push(Date.now());
|
|
110
|
+
});
|
|
103
111
|
});
|
|
104
112
|
}
|
|
@@ -38,12 +38,12 @@ invoicingFlow …`):
|
|
|
38
38
|
Both recipes use the same primitive — accounts + balanced transactions — but
|
|
39
39
|
record different things:
|
|
40
40
|
|
|
41
|
-
|
|
|
42
|
-
|
|
43
|
-
| Accounts
|
|
44
|
-
| A transaction is…
|
|
45
|
-
| Revenue recognised | n/a
|
|
46
|
-
| Reports surfaced
|
|
41
|
+
| | [ledger-banking](../ledger-banking/) | ledger-invoicing |
|
|
42
|
+
| ------------------ | ------------------------------------ | ----------------------------------------- |
|
|
43
|
+
| Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
|
|
44
|
+
| A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
|
|
45
|
+
| Revenue recognised | n/a | at invoice time (accrual) |
|
|
46
|
+
| Reports surfaced | balances | balances + P&L |
|
|
47
47
|
|
|
48
48
|
## Run
|
|
49
49
|
|
|
@@ -162,12 +162,12 @@ page in `createLegalPagesLayout({ siteName })`.
|
|
|
162
162
|
|
|
163
163
|
After the seed these URLs are immediately live:
|
|
164
164
|
|
|
165
|
-
| URL
|
|
166
|
-
|
|
167
|
-
| `https://your-app.example/legal/impressum`
|
|
168
|
-
| `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE)
|
|
169
|
-
| `https://your-app.example/legal/imprint`
|
|
170
|
-
| `https://your-app.example/legal/privacy`
|
|
165
|
+
| URL | Content |
|
|
166
|
+
| -------------------------------------------- | ------------------------------ |
|
|
167
|
+
| `https://your-app.example/legal/impressum` | Impressum (DE) |
|
|
168
|
+
| `https://your-app.example/legal/datenschutz` | Datenschutzerklärung (DE) |
|
|
169
|
+
| `https://your-app.example/legal/imprint` | Imprint (EN, if seeded) |
|
|
170
|
+
| `https://your-app.example/legal/privacy` | Privacy Policy (EN, if seeded) |
|
|
171
171
|
|
|
172
172
|
→ Footer links are set per app (legal-pages does not ship a footer
|
|
173
173
|
component — deliberately, every app has its own layout).
|
|
@@ -195,22 +195,22 @@ ACL: `["TenantAdmin", "SystemAdmin"]`. Cache header `public, max-age=300`
|
|
|
195
195
|
When the required blocks (`imprint/de` + `privacy/de`) are missing
|
|
196
196
|
from SYSTEM_TENANT:
|
|
197
197
|
|
|
198
|
-
| Mode
|
|
199
|
-
|
|
198
|
+
| Mode | Behavior |
|
|
199
|
+
| --------------------- | ----------------------------------------------------------- |
|
|
200
200
|
| `NODE_ENV=production` | App boot throws an error with a slug list — container exits |
|
|
201
|
-
| otherwise (dev/test)
|
|
201
|
+
| otherwise (dev/test) | `console.warn` with a slug list, app starts anyway |
|
|
202
202
|
|
|
203
203
|
→ Safety net: no production deploy without populated legal pages.
|
|
204
204
|
|
|
205
205
|
## Troubleshooting
|
|
206
206
|
|
|
207
|
-
| Symptom
|
|
208
|
-
|
|
209
|
-
| Route returns `503 legal page unavailable`
|
|
210
|
-
| Boot check throws `ctx.templateResolver missing`
|
|
211
|
-
| Route returns `404 not configured`
|
|
212
|
-
| Multi-tenant app: tenant subdomain shows an empty page
|
|
213
|
-
| `<script>` tags in a Markdown body land 1:1 in the HTML | Deliberately accepted right now — see [legal-pages/README.md XSS section](../../../packages/bundled-features/src/legal-pages/README.md#xss--currently-not-secured-by-design) | DOMPurify is a Phase 2 once a multi-author setup arrives
|
|
207
|
+
| Symptom | Cause | Fix |
|
|
208
|
+
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
209
|
+
| Route returns `503 legal page unavailable` | `anonymousAccess` not configured in `runProdApp` | Set `anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID }` |
|
|
210
|
+
| Boot check throws `ctx.templateResolver missing` | `extraContext.templateResolver` not wired | Set `extraContext: ({ db, registry }) => ({ templateResolver: createTemplateResolverApi(db, registry) })` |
|
|
211
|
+
| Route returns `404 not configured` | Required block doesn't exist or has `content=null` | `seedTextBlock` with a content string |
|
|
212
|
+
| Multi-tenant app: tenant subdomain shows an empty page | (Bug regression?) Routes should ALWAYS show SYSTEM_TENANT texts | The `legal-pages.integration.test.ts` test "SYSTEM_TENANT routing" covers this — should be green |
|
|
213
|
+
| `<script>` tags in a Markdown body land 1:1 in the HTML | Deliberately accepted right now — see [legal-pages/README.md XSS section](../../../packages/bundled-features/src/legal-pages/README.md#xss--currently-not-secured-by-design) | DOMPurify is a Phase 2 once a multi-author setup arrives |
|
|
214
214
|
|
|
215
215
|
## Cross-refs
|
|
216
216
|
|
|
@@ -6,15 +6,19 @@ backings and two scopes, without a hand-written screen, nav, or env-wiring map.
|
|
|
6
6
|
|
|
7
7
|
Two keys, deliberately different:
|
|
8
8
|
|
|
9
|
-
| Key
|
|
10
|
-
|
|
9
|
+
| Key | Scope | Backing | Story |
|
|
10
|
+
| ----------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
11
11
|
| `payment-api-key` | `system` | `secrets` | Platform-owned secret (one Stripe key bills all tenants). Stored envelope-encrypted in the secrets store, masked in every query, revealed only for the owning feature's `ctx.config` read. |
|
|
12
|
-
| `smtp-host`
|
|
12
|
+
| `smtp-host` | `tenant` | config | Platform default that a tenant admin overrides. Cascade resolves `tenant-row → system-row → default`; one tenant's override never leaks to another. |
|
|
13
13
|
|
|
14
14
|
## Pattern
|
|
15
15
|
|
|
16
16
|
```ts illustration
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
access,
|
|
19
|
+
createSystemConfig,
|
|
20
|
+
createTenantConfig,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
22
|
|
|
19
23
|
r.config({
|
|
20
24
|
keys: {
|
|
@@ -48,7 +52,7 @@ r.config({
|
|
|
48
52
|
valid for `scope: "system"` — secrets are flat per `(tenant, key)` with no
|
|
49
53
|
cascade, so a tenant override would be incoherent and is rejected at boot.
|
|
50
54
|
- **`env: "SMTP_HOST"`** — at boot (`runProdApp`) the env var seeds the key's
|
|
51
|
-
default as an app-override, below any `system-row`. The env var
|
|
55
|
+
default as an app-override, below any `system-row`. The env var _is_ the
|
|
52
56
|
default; no manual `AppConfigOverrides` map, no re-typing in the admin UI.
|
|
53
57
|
- **`mask`** — the key surfaces automatically in the self-populating settings
|
|
54
58
|
hub: `buildConfigFeatureSchema` derives a `configEdit` screen + a child nav
|
|
@@ -86,14 +90,14 @@ the config backing only needs the `ctx.services.secrets` context shown above.
|
|
|
86
90
|
|
|
87
91
|
## Vs. the neighbouring recipes
|
|
88
92
|
|
|
89
|
-
| Pattern
|
|
90
|
-
|
|
91
|
-
| **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant
|
|
92
|
-
| **`backing: "secrets"` config key** (this recipe)
|
|
93
|
-
| **`r.secret`** (`secrets-demo`)
|
|
93
|
+
| Pattern | Storage | Cascade | Use case |
|
|
94
|
+
| ------------------------------------------------------------ | ------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
|
|
95
|
+
| **`encrypted: true` config key** (`encrypted-tenant-config`) | `config_values`, AES ciphertext | system → tenant | Per-tenant customer secret, no KEK rotation/audit. |
|
|
96
|
+
| **`backing: "secrets"` config key** (this recipe) | secrets envelope, system tenant | none (system-only) | Platform-owned secret needing rotation + audit-on-read, declared as a config key. |
|
|
97
|
+
| **`r.secret`** (`secrets-demo`) | secrets envelope | none | Same storage, but addressed directly — no config key, no settings-hub entry. |
|
|
94
98
|
|
|
95
99
|
`backing: "secrets"` is the bridge: you get the secrets store's guarantees
|
|
96
|
-
|
|
100
|
+
_and_ the config feature's declarative surface (masking, settings hub, the same
|
|
97
101
|
`config:write:set` write path).
|
|
98
102
|
|
|
99
103
|
## Flow
|
|
@@ -139,14 +139,14 @@ exact same helpers.
|
|
|
139
139
|
|
|
140
140
|
## Security posture
|
|
141
141
|
|
|
142
|
-
| Threat
|
|
143
|
-
|
|
144
|
-
| `<script>`/raw HTML in a page body
|
|
145
|
-
| Cross-tenant content on a shared CDN
|
|
146
|
-
| Draft leak to anonymous visitors
|
|
142
|
+
| Threat | Mitigation |
|
|
143
|
+
| --------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
144
|
+
| `<script>`/raw HTML in a page body | `renderSafeMarkdown` escapes raw HTML (Markdown-only) |
|
|
145
|
+
| Cross-tenant content on a shared CDN | `Vary: Host` + per-tenant `resolveApexTenant` |
|
|
146
|
+
| Draft leak to anonymous visitors | published-only; drafts → 404 |
|
|
147
147
|
| `@import`/`url()`/`expression()`/`</style>` in custom CSS | allowlist-by-construction sanitizer (rebuilt from validated tokens) |
|
|
148
|
-
| Scope-escape / overlay onto host chrome
|
|
149
|
-
| Untrusted tenant abusing CSS
|
|
148
|
+
| Scope-escape / overlay onto host chrome | scope-prefix + `position`/`isolation`/`overflow` containment |
|
|
149
|
+
| Untrusted tenant abusing CSS | tier-gate (`managed-pages-css`) + `allowCustomCss` opt-in |
|
|
150
150
|
|
|
151
151
|
Custom-CSS sanitization for untrusted tenants is **best-effort defense-in-depth**:
|
|
152
152
|
a tenant can still restyle its **own** page area within the clip. Keep it
|
|
@@ -7,18 +7,18 @@ behaves end-to-end against the worktree's framework source.
|
|
|
7
7
|
|
|
8
8
|
## What's inside
|
|
9
9
|
|
|
10
|
-
| Step
|
|
11
|
-
|
|
12
|
-
| `r.step.aggregate.create`
|
|
13
|
-
| `r.step.aggregate.update`
|
|
14
|
-
| `r.step.aggregate.appendEvent`
|
|
15
|
-
| `r.step.read.findOne`
|
|
16
|
-
| `r.step.read.findMany`
|
|
17
|
-
| `r.step.compute`
|
|
18
|
-
| `r.step.branch`
|
|
19
|
-
| `r.step.forEach`
|
|
20
|
-
| `r.step.return`
|
|
21
|
-
| `r.step.unsafeProjectionUpsert` | `product:adjust-stock`
|
|
10
|
+
| Step | Where it shows up |
|
|
11
|
+
| ------------------------------- | ------------------------------------------------------------------------------ |
|
|
12
|
+
| `r.step.aggregate.create` | `product:create` |
|
|
13
|
+
| `r.step.aggregate.update` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
|
|
14
|
+
| `r.step.aggregate.appendEvent` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
|
|
15
|
+
| `r.step.read.findOne` | `product:rename`, `product:adjust-stock`, `product:bulk-adjust` |
|
|
16
|
+
| `r.step.read.findMany` | `report:archive-low-stock-products` |
|
|
17
|
+
| `r.step.compute` | `product:adjust-stock`, `product:bulk-adjust` |
|
|
18
|
+
| `r.step.branch` | `product:rename`, `product:adjust-stock` |
|
|
19
|
+
| `r.step.forEach` | `product:bulk-adjust`, `report:archive-low-stock-products` |
|
|
20
|
+
| `r.step.return` | every handler |
|
|
21
|
+
| `r.step.unsafeProjectionUpsert` | `product:adjust-stock` |
|
|
22
22
|
| `r.step.unsafeProjectionDelete` | `product:adjust-stock`, `product:archive`, `report:archive-low-stock-products` |
|
|
23
23
|
|
|
24
24
|
## Domain
|
|
@@ -84,7 +84,7 @@ engine APIs land in a published release.
|
|
|
84
84
|
in the pure event-sourcing shape — it appends a domain event
|
|
85
85
|
(`product-stock-adjusted`) and lets the inline
|
|
86
86
|
`product-stock-counter` projection update `currentStock`. The
|
|
87
|
-
alternative (calling `aggregate.update`
|
|
87
|
+
alternative (calling `aggregate.update` _and_ `appendEvent` in
|
|
88
88
|
one handler) inflates the stream-version mid-handler and trips
|
|
89
89
|
optimistic-locking on the next call. The other handlers
|
|
90
90
|
(`rename`, `bulk-adjust`) use `aggregate.update` directly
|
|
@@ -20,11 +20,11 @@ the URL carries a share token).
|
|
|
20
20
|
|
|
21
21
|
## Handlers
|
|
22
22
|
|
|
23
|
-
| QN
|
|
24
|
-
|
|
23
|
+
| QN | Access |
|
|
24
|
+
| -------------------------------------- | --------------------------- |
|
|
25
25
|
| `public-share:write:share-link:create` | authenticated (`openToAll`) |
|
|
26
|
-
| `public-share:write:share-link:revoke` | owner
|
|
27
|
-
| `public-share:query:share-by-token`
|
|
26
|
+
| `public-share:write:share-link:revoke` | owner |
|
|
27
|
+
| `public-share:query:share-by-token` | anonymous + authenticated |
|
|
28
28
|
|
|
29
29
|
## Run tests
|
|
30
30
|
|