@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.404.1",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.404.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -165,38 +165,38 @@
|
|
|
165
165
|
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
166
166
|
"@cosmicdrift/kumiko-renderer": "workspace:*",
|
|
167
167
|
"@cosmicdrift/kumiko-types": "workspace:*",
|
|
168
|
-
"@mollie/api-client": "^4.
|
|
169
|
-
"@node-rs/argon2": "^2.
|
|
170
|
-
"@tiptap/core": "^3.
|
|
171
|
-
"@tiptap/extension-link": "^3.
|
|
172
|
-
"@tiptap/extension-list": "^3.
|
|
173
|
-
"@tiptap/extension-table": "^3.
|
|
174
|
-
"@tiptap/html": "^3.
|
|
175
|
-
"@tiptap/extensions": "^3.
|
|
176
|
-
"@tiptap/pm": "^3.
|
|
177
|
-
"@tiptap/react": "^3.
|
|
178
|
-
"@tiptap/starter-kit": "^3.
|
|
179
|
-
"@types/mailparser": "^3.
|
|
180
|
-
"@types/qrcode": "^1.5.
|
|
168
|
+
"@mollie/api-client": "^4.6.0",
|
|
169
|
+
"@node-rs/argon2": "^2.2.2",
|
|
170
|
+
"@tiptap/core": "^3.32.1",
|
|
171
|
+
"@tiptap/extension-link": "^3.32.1",
|
|
172
|
+
"@tiptap/extension-list": "^3.32.1",
|
|
173
|
+
"@tiptap/extension-table": "^3.32.1",
|
|
174
|
+
"@tiptap/html": "^3.32.1",
|
|
175
|
+
"@tiptap/extensions": "^3.32.1",
|
|
176
|
+
"@tiptap/pm": "^3.32.1",
|
|
177
|
+
"@tiptap/react": "^3.32.1",
|
|
178
|
+
"@tiptap/starter-kit": "^3.32.1",
|
|
179
|
+
"@types/mailparser": "^3.9.0",
|
|
180
|
+
"@types/qrcode": "^1.5.6",
|
|
181
181
|
"clsx": "^2.1.1",
|
|
182
182
|
"happy-dom": "^21.0.4",
|
|
183
183
|
"hono": "^4.13.14",
|
|
184
184
|
"imapflow": "^1.7.8",
|
|
185
|
-
"ioredis": "^5.
|
|
186
|
-
"jose": "^6.2.
|
|
187
|
-
"lucide-react": "^1.
|
|
188
|
-
"mailparser": "^3.
|
|
189
|
-
"marked": "^18.
|
|
190
|
-
"nodemailer": "^10.
|
|
185
|
+
"ioredis": "^5.11.1",
|
|
186
|
+
"jose": "^6.2.14",
|
|
187
|
+
"lucide-react": "^1.65.0",
|
|
188
|
+
"mailparser": "^3.14.1",
|
|
189
|
+
"marked": "^18.1.2",
|
|
190
|
+
"nodemailer": "^10.3.1",
|
|
191
191
|
"pdf-lib": "^1.17.1",
|
|
192
192
|
"qrcode": "^1.5.4",
|
|
193
|
-
"react": "^19.
|
|
194
|
-
"react-dom": "^19.
|
|
193
|
+
"react": "^19.3.1",
|
|
194
|
+
"react-dom": "^19.3.1",
|
|
195
195
|
"sharp": "^0.35.5",
|
|
196
196
|
"stripe": "^22.6.2",
|
|
197
|
-
"tailwind-merge": "^3.
|
|
197
|
+
"tailwind-merge": "^3.7.0",
|
|
198
198
|
"temporal-polyfill": "^0.3.2",
|
|
199
|
-
"uuid": "^14.0.
|
|
199
|
+
"uuid": "^14.0.3",
|
|
200
200
|
"zod": "^4.6.7"
|
|
201
201
|
},
|
|
202
202
|
"publishConfig": {
|
|
@@ -764,7 +764,7 @@
|
|
|
764
764
|
"@cosmicdrift/kumiko-locale-it": "workspace:*",
|
|
765
765
|
"@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
|
|
766
766
|
"jsqr": "^1.4.0",
|
|
767
|
-
"unpdf": "^1.
|
|
767
|
+
"unpdf": "^1.9.1",
|
|
768
768
|
"@cosmicdrift/kumiko-testing": "workspace:*"
|
|
769
769
|
},
|
|
770
770
|
"peerDependencies": {
|
|
@@ -15,17 +15,19 @@ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sess
|
|
|
15
15
|
|
|
16
16
|
const features = [
|
|
17
17
|
createSessionsFeature(),
|
|
18
|
-
createAuthMfaFeature({
|
|
18
|
+
createAuthMfaFeature({
|
|
19
|
+
/* secrets, issuer */
|
|
20
|
+
}),
|
|
19
21
|
createAccountSecurityFeature(),
|
|
20
22
|
];
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
## Options
|
|
24
26
|
|
|
25
|
-
| Option
|
|
26
|
-
|
|
|
27
|
-
| `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }`
|
|
28
|
-
| `nav`
|
|
27
|
+
| Option | Default | Effect |
|
|
28
|
+
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }` |
|
|
30
|
+
| `nav` | none | `{ order?, parent?, icon? }` (icon default `"user"`) adds a sidebar entry. Without it the screen is dormant and apps link it from the user menu |
|
|
29
31
|
|
|
30
32
|
The MFA panels switch on `auth-mfa:query:status` (`enabled`), exported as
|
|
31
33
|
`accountSecurityMfaStatusVisibility`.
|
|
@@ -77,7 +77,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
77
77
|
|
|
78
78
|
r.job({
|
|
79
79
|
name: "escape-hatch-retention",
|
|
80
|
-
trigger: {
|
|
80
|
+
trigger: { maintenance: true },
|
|
81
81
|
concurrency: "skip",
|
|
82
82
|
// pruneEvents needs a transaction-capable connection, which only unsafeRaw() hands a job.
|
|
83
83
|
escapeHatch: {
|
|
@@ -73,6 +73,7 @@ export function createLoginRoute(
|
|
|
73
73
|
const MfaVerifyComponent = opts.mfaVerifyScreen;
|
|
74
74
|
const MfaSetupComponent = opts.mfaSetupScreen;
|
|
75
75
|
|
|
76
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory (module scope or makeAuthGate); the route takes no props, so opts are bound here
|
|
76
77
|
function LoginRoute(): ReactNode {
|
|
77
78
|
const { status, refresh, bootstrapFailure, logout } = useSession();
|
|
78
79
|
const { onAuthenticated } = opts;
|
|
@@ -212,6 +213,7 @@ function resolveLoginRedirectOrNull(
|
|
|
212
213
|
try {
|
|
213
214
|
return resolveLoginRedirect(loginUrl, locale, location);
|
|
214
215
|
} catch (error) {
|
|
216
|
+
// biome-ignore lint/suspicious/noConsole: the browser console is the only place a broken loginUrl builder shows up, the visitor just sees the built-in login
|
|
215
217
|
console.error("Auth gate: loginUrl could not be resolved, showing the built-in login", error);
|
|
216
218
|
return null;
|
|
217
219
|
}
|
|
@@ -223,6 +225,7 @@ export function makeAuthGate(opts: AuthGateOptions = {}): ComponentType<{
|
|
|
223
225
|
const { loginUrl } = opts;
|
|
224
226
|
if (typeof loginUrl === "string") assertNavigableUrl(loginUrl, "loginUrl");
|
|
225
227
|
const LoginRoute = createLoginRoute(opts);
|
|
228
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory (makeSessionAuthGate or an app's mount function); gates only receive children, so opts are bound here
|
|
226
229
|
function AuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
227
230
|
const { status } = useSession();
|
|
228
231
|
const locale = useLocale().locale();
|
|
@@ -255,6 +258,7 @@ export function makeSessionAuthGate(opts: SessionAuthGateOptions = {}): Componen
|
|
|
255
258
|
const { postLogoutUrl } = opts;
|
|
256
259
|
if (postLogoutUrl !== undefined) assertNavigableUrl(postLogoutUrl, "postLogoutUrl");
|
|
257
260
|
const AuthGate = makeAuthGate(opts);
|
|
261
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per emailPasswordClient(); gates only receive children, so opts are bound here
|
|
258
262
|
function SessionAuthGate({ children }: { readonly children: ReactNode }): ReactNode {
|
|
259
263
|
return (
|
|
260
264
|
<SessionProvider postLogoutUrl={postLogoutUrl}>
|
|
@@ -71,7 +71,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
71
71
|
// retention rules are configured but never enforced.
|
|
72
72
|
r.job({
|
|
73
73
|
name: "retention-cleanup",
|
|
74
|
-
trigger: {
|
|
74
|
+
trigger: { maintenance: true },
|
|
75
75
|
perTenant: true,
|
|
76
76
|
concurrency: "skip",
|
|
77
77
|
escapeHatch: {
|
|
@@ -197,7 +197,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
197
197
|
if (attemptLogRetentionDays !== undefined) {
|
|
198
198
|
r.job({
|
|
199
199
|
name: DeliveryJobNames.attemptLogRetention,
|
|
200
|
-
trigger: {
|
|
200
|
+
trigger: { maintenance: true },
|
|
201
201
|
concurrency: "skip",
|
|
202
202
|
escapeHatch: { reason: ATTEMPT_LOG_RETENTION_ESCAPE_HATCH_REASON },
|
|
203
203
|
handler: async (_payload, ctx) => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Documents recipe has a README and gen-sample-index rejects a primarySample without one",
|
|
6
|
+
"detail": "feature-reference linked to /en/samples/recipes-documents/, a page the docs site never generated because the recipe had no README.md. The README is added, and gen-sample-index aborts with the feature, the sample and the fix when a primarySample from sample-index.overrides.json has no README.md."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Feature-toggle Redis signal follows KUMIKO_REDIS_PREFIX",
|
|
6
|
+
"detail": "createRedisToggleSyncSignal takes an optional channelSubscribePattern (default from KUMIKO_REDIS_PREFIX) and ignores messages from other channels, which a prefix-wide subscription delivers."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.365.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createRedisPubSubSignal,
|
|
5
5
|
type RedisClientOptions,
|
|
6
6
|
redisChannelPrefixFromEnv,
|
|
7
|
+
redisNamespaceFromEnv,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/redis";
|
|
8
9
|
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
9
10
|
import type { ToggleSyncSignal } from "./toggle-runtime.ts";
|
|
@@ -36,11 +37,12 @@ export function createRedisToggleSyncSignal(
|
|
|
36
37
|
redisUrl: string,
|
|
37
38
|
clientOptions?: RedisClientOptions,
|
|
38
39
|
channelPrefix: string = redisChannelPrefixFromEnv(),
|
|
40
|
+
channelSubscribePattern: string | undefined = redisNamespaceFromEnv().channelSubscribePattern,
|
|
39
41
|
): RedisToggleSyncSignal {
|
|
40
42
|
const toggleSyncChannel = `${channelPrefix}${TOGGLE_SYNC_CHANNEL_NAME}`;
|
|
41
43
|
const signal = createRedisPubSubSignal({
|
|
42
44
|
redisUrl,
|
|
43
|
-
channelPattern: toggleSyncChannel,
|
|
45
|
+
channelPattern: channelSubscribePattern ?? toggleSyncChannel,
|
|
44
46
|
label: "feature-toggles",
|
|
45
47
|
...(clientOptions ? { clientOptions } : {}),
|
|
46
48
|
});
|
|
@@ -50,7 +52,9 @@ export function createRedisToggleSyncSignal(
|
|
|
50
52
|
signal.publish(toggleSyncChannel, { featureName, enabled });
|
|
51
53
|
},
|
|
52
54
|
onMessage(listener) {
|
|
53
|
-
signal.onMessage((
|
|
55
|
+
signal.onMessage((channel, payload) => {
|
|
56
|
+
// skip: a wide subscribe pattern also delivers other features' channels
|
|
57
|
+
if (channel !== toggleSyncChannel) return;
|
|
54
58
|
if (!isTogglePayload(payload)) {
|
|
55
59
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback — no ctx.obs.log in a raw PubSubSignal handler.
|
|
56
60
|
console.error(
|
|
@@ -43,7 +43,7 @@ function registerFormDraft(r: FeatureRegistrar<typeof FORM_DRAFT_FEATURE_NAME>):
|
|
|
43
43
|
r.config({ keys: { retentionDays: formDraftRetentionDaysConfig } });
|
|
44
44
|
r.job({
|
|
45
45
|
name: "cleanup",
|
|
46
|
-
trigger: {
|
|
46
|
+
trigger: { maintenance: true },
|
|
47
47
|
concurrency: "skip",
|
|
48
48
|
escapeHatch: { reason: CLEANUP_ESCAPE_HATCH_REASON },
|
|
49
49
|
handler: (payload, ctx) =>
|
|
@@ -55,16 +55,16 @@ the app sets it.
|
|
|
55
55
|
|
|
56
56
|
## Options
|
|
57
57
|
|
|
58
|
-
| Option
|
|
59
|
-
|
|
60
|
-
| `tenantBy`
|
|
61
|
-
| `baseDomain`
|
|
62
|
-
| `apex`
|
|
63
|
-
| `tenant`
|
|
64
|
-
| `unknownTenant`
|
|
65
|
-
| `hosts`
|
|
66
|
-
| `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none
|
|
67
|
-
| `locales`
|
|
58
|
+
| Option | Meaning |
|
|
59
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
60
|
+
| `tenantBy` | `"host"` (`<key>.<baseDomain>`) or `"path"` with `tenantPathPrefix` (`/t/<key>/...` on the apex) |
|
|
61
|
+
| `baseDomain` | Apex hostname, lowercase, no port. `www.<baseDomain>` counts as apex |
|
|
62
|
+
| `apex` | `{ file, csp?, localeFiles? }` for the apex |
|
|
63
|
+
| `tenant` | `{ file, csp? }` for a known tenant and for the bare path prefix |
|
|
64
|
+
| `unknownTenant` | `{ file, csp? }` served with 404; omit for a bare 404 |
|
|
65
|
+
| `hosts` | Further fixed hostnames with their own surface, checked first |
|
|
66
|
+
| `apexAnonymousTenantId` | Tenant for anonymous API calls on the apex (e.g. `SYSTEM_TENANT_ID` for public signup); omit and the apex has none |
|
|
67
|
+
| `locales` | `{ default, prefixed }`: `/<default>/...` answers 301 to the unprefixed path, `/<prefixed>/...` serves `apex.localeFiles[locale]` |
|
|
68
68
|
|
|
69
69
|
A `csp` replaces runProdApp's default CSP for that surface; without it the
|
|
70
70
|
default applies. Options are validated when the feature or dispatch is built.
|
|
@@ -107,6 +107,7 @@ export async function appendThreadRollup(
|
|
|
107
107
|
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
108
108
|
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
109
109
|
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
110
|
+
// biome-ignore lint/complexity/useMathMinMax: ISO strings compare lexicographically; Math.max would coerce them to NaN
|
|
110
111
|
const newLastAt = receivedAtIso > previousLastAt ? receivedAtIso : previousLastAt;
|
|
111
112
|
const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
|
|
112
113
|
tenantId,
|
|
@@ -18,7 +18,7 @@ export function registerInboundMailRetentionJob(r: FeatureRegistrar<"inbound-mai
|
|
|
18
18
|
"inbound-mail retention runs raw deletes; every statement filters by this run's tenantId";
|
|
19
19
|
r.job({
|
|
20
20
|
name: "inbound-mail-retention",
|
|
21
|
-
trigger: {
|
|
21
|
+
trigger: { maintenance: true },
|
|
22
22
|
perTenant: true,
|
|
23
23
|
concurrency: "skip",
|
|
24
24
|
escapeHatch: { reason: INBOUND_MAIL_RETENTION_REASON },
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.404.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Cleanup jobs run once a day in the maintenance window",
|
|
6
|
+
"detail": "jobs (stale-run-sweep, hourly before, and retention-cleanup), user-data-rights (export and forget), data-retention, audit escape-hatch-retention, inbound-mail retention, delivery attempt-log-retention and form-draft cleanup use trigger { maintenance: true } instead of 03:00 to 03:30 and hourly crons. Without KUMIKO_MAINTENANCE_WINDOW they all run at 03:00 UTC. A stuck job run is now marked failed at most a day after it crosses the stale timeout."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.394.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -43,7 +43,7 @@ export type JobsFeatureOptions = {
|
|
|
43
43
|
// store_job_run_logs before the daily retention-cleanup job deletes it. Also the age after
|
|
44
44
|
// which a tenant-visible "queued" row counts as lost and is dropped by the stale-run sweep.
|
|
45
45
|
readonly retentionDays?: number;
|
|
46
|
-
// How long a run can sit at status "running" before the
|
|
46
|
+
// How long a run can sit at status "running" before the daily
|
|
47
47
|
// stale-run-sweep job marks it "failed" — a process kill mid-run
|
|
48
48
|
// never fires onJobComplete/onJobFailed, so nothing else ever revisits
|
|
49
49
|
// it). Must clear any legitimately long-running job (reindexEntity,
|
|
@@ -120,18 +120,17 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
|
|
|
120
120
|
// stores — nothing else purges them.
|
|
121
121
|
r.job(
|
|
122
122
|
"retention-cleanup",
|
|
123
|
-
{ trigger: {
|
|
123
|
+
{ trigger: { maintenance: true }, concurrency: "skip" },
|
|
124
124
|
createRetentionCleanupJob(retentionDays),
|
|
125
125
|
);
|
|
126
126
|
|
|
127
127
|
// Sweeps store_job_runs for rows stuck at status "running" past the
|
|
128
|
-
// timeout.
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// doesn't matter.
|
|
128
|
+
// timeout. Runs in the maintenance window: the timeout is
|
|
129
|
+
// hours, so a daily pass finds a stuck run at most a day late and a sleeping
|
|
130
|
+
// app is not woken every hour for it.
|
|
132
131
|
r.job(
|
|
133
132
|
"stale-run-sweep",
|
|
134
|
-
{ trigger: {
|
|
133
|
+
{ trigger: { maintenance: true }, concurrency: "skip" },
|
|
135
134
|
createStaleRunSweepJob(staleRunTimeoutHours, retentionDays),
|
|
136
135
|
);
|
|
137
136
|
|
|
@@ -25,7 +25,7 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
25
25
|
runProdApp({
|
|
26
26
|
envSchema, // composeEnvSchema({ core: frameworkCoreEnvSchema, features })
|
|
27
27
|
features: [
|
|
28
|
-
createTemplateResolverFeature(),
|
|
28
|
+
createTemplateResolverFeature(), // legal-pages requires template-resolver
|
|
29
29
|
createLegalPagesFeature(),
|
|
30
30
|
/* ... */
|
|
31
31
|
],
|
|
@@ -60,12 +60,12 @@ See [template-resolver/README.md](../template-resolver/README.md).
|
|
|
60
60
|
|
|
61
61
|
**Default** (DACH: DE required, EN optional):
|
|
62
62
|
|
|
63
|
-
| Path
|
|
64
|
-
|
|
65
|
-
| `GET /legal/impressum`
|
|
66
|
-
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung"
|
|
67
|
-
| `GET /legal/imprint`
|
|
68
|
-
| `GET /legal/privacy`
|
|
63
|
+
| Path | Slug + locale | Title fallback (when block empty) |
|
|
64
|
+
| ------------------------ | ---------------- | --------------------------------- |
|
|
65
|
+
| `GET /legal/impressum` | `imprint` / `de` | "Impressum" |
|
|
66
|
+
| `GET /legal/datenschutz` | `privacy` / `de` | "Datenschutzerklärung" |
|
|
67
|
+
| `GET /legal/imprint` | `imprint` / `en` | "Imprint" |
|
|
68
|
+
| `GET /legal/privacy` | `privacy` / `en` | "Privacy Policy" |
|
|
69
69
|
|
|
70
70
|
Response:
|
|
71
71
|
- `200 text/html` — block exists + has content. Cache header `public, max-age=300`.
|
|
@@ -112,11 +112,19 @@ that needs an additional page (terms/AGB), passes its own lists:
|
|
|
112
112
|
createLegalPagesFeature({
|
|
113
113
|
routes: [
|
|
114
114
|
{ path: "/legal/aviso-legal", slug: "imprint", lang: "es", titleFallback: "Aviso legal" },
|
|
115
|
-
{
|
|
115
|
+
{
|
|
116
|
+
path: "/legal/privacidad",
|
|
117
|
+
slug: "privacy",
|
|
118
|
+
lang: "es",
|
|
119
|
+
titleFallback: "Política de privacidad",
|
|
120
|
+
},
|
|
116
121
|
{ path: "/legal/terminos", slug: "terms", lang: "es", titleFallback: "Términos y condiciones" },
|
|
117
122
|
],
|
|
118
|
-
requiredBlocks: [
|
|
119
|
-
}
|
|
123
|
+
requiredBlocks: [
|
|
124
|
+
{ slug: "imprint", lang: "es" },
|
|
125
|
+
{ slug: "privacy", lang: "es" },
|
|
126
|
+
],
|
|
127
|
+
});
|
|
120
128
|
```
|
|
121
129
|
|
|
122
130
|
`slug` is free-form — it only has to match the `slug` you seed the
|
|
@@ -162,10 +170,10 @@ starts and checks whether the required blocks exist in SYSTEM_TENANT.
|
|
|
162
170
|
A throw rejects the boot, so the process never reports ready.
|
|
163
171
|
**Default** (DACH):
|
|
164
172
|
|
|
165
|
-
| Slug + locale
|
|
166
|
-
|
|
173
|
+
| Slug + locale | What happens when missing |
|
|
174
|
+
| ---------------- | ----------------------------------------------------------------------------------------- |
|
|
167
175
|
| `imprint` / `de` | **Production:** `throw new Error(...)` blocks app start. **Dev:** `ctx.obs.log.warn(...)` |
|
|
168
|
-
| `privacy` / `de` | as above
|
|
176
|
+
| `privacy` / `de` | as above |
|
|
169
177
|
|
|
170
178
|
EN versions are **not** boot-fail-relevant by default (`LEGAL_OPTIONAL_BLOCKS`).
|
|
171
179
|
Routes return `404` if an EN block is missing.
|
|
@@ -21,9 +21,7 @@ const features = [
|
|
|
21
21
|
const app = createKumikoApp({
|
|
22
22
|
features,
|
|
23
23
|
extraContext: ({ registry }) => ({
|
|
24
|
-
rendererFoundation: createRendererFoundationApi(
|
|
25
|
-
collectRendererPlugins(registry),
|
|
26
|
-
),
|
|
24
|
+
rendererFoundation: createRendererFoundationApi(collectRendererPlugins(registry)),
|
|
27
25
|
}),
|
|
28
26
|
});
|
|
29
27
|
```
|
|
@@ -39,7 +37,11 @@ async function sendMail(ctx, tenantId) {
|
|
|
39
37
|
const result = await renderer.render(
|
|
40
38
|
{
|
|
41
39
|
kind: "mail",
|
|
42
|
-
payload: {
|
|
40
|
+
payload: {
|
|
41
|
+
slug: "billing:notify:invoice-ready",
|
|
42
|
+
locale: "de",
|
|
43
|
+
variables: { name: "Frau Schmidt" },
|
|
44
|
+
},
|
|
43
45
|
},
|
|
44
46
|
{ db: ctx.db, registry: ctx.registry, tenantId, config: ctx.config },
|
|
45
47
|
);
|
|
@@ -11,7 +11,11 @@ const { pdfBytes, pageCount } = await renderTablePdf(
|
|
|
11
11
|
{
|
|
12
12
|
title: "Prüfprotokoll",
|
|
13
13
|
meta: [{ label: "Anlage", value: "Kessel 3" }],
|
|
14
|
-
columns: [
|
|
14
|
+
columns: [
|
|
15
|
+
{ header: "Datum" },
|
|
16
|
+
{ header: "Prüfer", weight: 2 },
|
|
17
|
+
{ header: "Kosten", align: "right" },
|
|
18
|
+
],
|
|
15
19
|
rows: [["03.02.2026", "Müller", "120,00 €"]],
|
|
16
20
|
footerText: "Kumiko GmbH",
|
|
17
21
|
pageLabel: "Seite {page} von {pages}",
|
|
@@ -44,13 +44,13 @@ async function someHandler(ctx) {
|
|
|
44
44
|
|
|
45
45
|
## Kinds, format, and meta
|
|
46
46
|
|
|
47
|
-
| kind
|
|
48
|
-
|
|
49
|
-
| `mail`
|
|
50
|
-
| `mail-layout`
|
|
51
|
-
| `pdf-layout`
|
|
52
|
-
| `document-pdf`
|
|
53
|
-
| remaining kinds | arbitrary
|
|
47
|
+
| kind | contentFormat | meta |
|
|
48
|
+
| --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `mail` | `html` (document schema, rebuilt server-side) | Required: `subject`; optional `previewText`, `layoutSlug`, `category` (`transactional`/`marketing`), `senderName`, `replyTo` |
|
|
50
|
+
| `mail-layout` | `mjml` | empty (`{}`) or missing |
|
|
51
|
+
| `pdf-layout` | `html` | empty (`{}`) or missing |
|
|
52
|
+
| `document-pdf` | `html` | missing |
|
|
53
|
+
| remaining kinds | arbitrary | missing |
|
|
54
54
|
|
|
55
55
|
`meta` is stored as JSON in the `meta` column and is validated on write per kind against
|
|
56
56
|
`template-meta.ts` (errors as field issues on `meta.*`). Subject and preview text allow only simple
|
|
@@ -105,22 +105,22 @@ await seedSystemTemplate(db, {
|
|
|
105
105
|
|
|
106
106
|
## Admin workflows (write handlers + queries)
|
|
107
107
|
|
|
108
|
-
| Handler
|
|
109
|
-
|
|
110
|
-
| `TemplateResolverHandlers.upsertSystem`
|
|
111
|
-
| `TemplateResolverHandlers.upsertTenant`
|
|
112
|
-
| `TemplateResolverHandlers.publish`
|
|
113
|
-
| `TemplateResolverHandlers.archive`
|
|
114
|
-
| `TemplateResolverHandlers.publishSystem`
|
|
115
|
-
| `TemplateResolverHandlers.archiveSystem`
|
|
116
|
-
| `TemplateResolverHandlers.resetToSystem`
|
|
117
|
-
| `TemplateResolverQueries.findById`
|
|
118
|
-
| `TemplateResolverQueries.list`
|
|
119
|
-
| `TemplateResolverQueries.usage`
|
|
120
|
-
| `TemplateResolverQueries.overview`
|
|
121
|
-
| `TemplateResolverQueries.layoutSource`
|
|
122
|
-
| `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin
|
|
123
|
-
| `TemplateResolverQueries.diffToSystem`
|
|
108
|
+
| Handler | QN | Who | What |
|
|
109
|
+
| ----------------------------------------- | ------------------------------------------ | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `TemplateResolverHandlers.upsertSystem` | `template-resolver:write:upsert-system` | SystemAdmin | Creates or updates system default templates (`SYSTEM_TENANT_ID`, scope='system', status='active') |
|
|
111
|
+
| `TemplateResolverHandlers.upsertTenant` | `template-resolver:write:upsert-tenant` | TenantAdmin (own tenant) + SystemAdmin via `tenantIdOverride` | Creates or updates tenant overrides (scope='tenant'), default status='draft' |
|
|
112
|
+
| `TemplateResolverHandlers.publish` | `template-resolver:write:publish` | TenantAdmin (own tenant) | Sets status='active' |
|
|
113
|
+
| `TemplateResolverHandlers.archive` | `template-resolver:write:archive` | TenantAdmin (own tenant) | Sets status='archived' (the resolver ignores it afterward) |
|
|
114
|
+
| `TemplateResolverHandlers.publishSystem` | `template-resolver:write:publish-system` | SystemAdmin | Sets status='active' on a system default, with the same mail validation as `publish` |
|
|
115
|
+
| `TemplateResolverHandlers.archiveSystem` | `template-resolver:write:archive-system` | SystemAdmin | Sets status='archived' on a system default; tenants without an override fall through |
|
|
116
|
+
| `TemplateResolverHandlers.resetToSystem` | `template-resolver:write:reset-to-system` | TenantAdmin (own tenant) | Archives the own override so that the active system template applies again; without an active system template, `no_system_template` |
|
|
117
|
+
| `TemplateResolverQueries.findById` | `template-resolver:query:find-by-id` | TenantAdmin + User (own tenant, system templates visible) | Raw lookup for the edit UI |
|
|
118
|
+
| `TemplateResolverQueries.list` | `template-resolver:query:list` | same | Filters by kind/locale/status, optionally includeSystem |
|
|
119
|
+
| `TemplateResolverQueries.usage` | `template-resolver:query:usage` | TenantAdmin | Which notifications use a mail template or mail layout, from the registry |
|
|
120
|
+
| `TemplateResolverQueries.overview` | `template-resolver:query:overview` | TenantAdmin + SystemAdmin | One row per slug and type, mail, PDF, layouts, with origin, languages, status, version, layout, and usage; searchable, filterable, sortable, and pageable |
|
|
121
|
+
| `TemplateResolverQueries.layoutSource` | `template-resolver:query:layout-source` | TenantAdmin + SystemAdmin | Own and system version of a layout, header/signature/footer regions of the effective HTML version, storage locale; MJML source only for SystemAdmin |
|
|
122
|
+
| `TemplateResolverQueries.tenantOverrides` | `template-resolver:query:tenant-overrides` | SystemAdmin | Number of active/draft overrides of a system template and total active tenants, numbers only |
|
|
123
|
+
| `TemplateResolverQueries.diffToSystem` | `template-resolver:query:diff-to-system` | TenantAdmin (own tenant) | Block and meta comparison of a tenant override with the system template |
|
|
124
124
|
|
|
125
125
|
**SystemAdmin cross-tenant access for publish/archive/findById:** deferred (Task 8), `upsertTenant` already has `tenantIdOverride`; publish/archive only need this when a system admin panel curates templates belonging to other tenants. `ctx.db` is tenant-scoped, foreign IDs result in NotFound without an override.
|
|
126
126
|
|
|
@@ -166,12 +166,12 @@ The harness checks `TemplateNotFoundError` propagation, locale fallback, and, wh
|
|
|
166
166
|
Marketing, FAQ, and legal texts use the same store, but a separate write path: `set.write` carries
|
|
167
167
|
`title` + `folder` (content tree) and has no draft status, saving publishes.
|
|
168
168
|
|
|
169
|
-
| Handler
|
|
170
|
-
|
|
171
|
-
| `TemplateResolverHandlers.set`
|
|
172
|
-
| `TemplateResolverQueries.bySlug`
|
|
173
|
-
| `TemplateResolverQueries.byTenant`
|
|
174
|
-
| `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared
|
|
169
|
+
| Handler | QN | Who | What |
|
|
170
|
+
| -------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
171
|
+
| `TemplateResolverHandlers.set` | `template-resolver:write:set` | TenantAdmin + SystemAdmin (via `tenantIdOverride`, also on `SYSTEM_TENANT_ID`) | Upserts one resource per `(tenantId, slug, kind, locale)` |
|
|
172
|
+
| `TemplateResolverQueries.bySlug` | `template-resolver:query:by-slug` | anonymous + User + Admins | One text block, the public read for landing/legal pages |
|
|
173
|
+
| `TemplateResolverQueries.byTenant` | `template-resolver:query:by-tenant` | anonymous + User + Admins | All text blocks of a tenant for the content tree |
|
|
174
|
+
| `<collection>-list` / `-item` / `-set` | `template-resolver:query:<id>-list` … | as declared | Per content collection, see below |
|
|
175
175
|
|
|
176
176
|
The two public queries are hardcoded to `kind: "text-block"` and accept **no** kind parameter. Mail
|
|
177
177
|
templates and AI prompts are stored in the same table and use the handlers of their collection. This
|
|
@@ -248,8 +248,9 @@ export function withParsedDocument<T>(html: string, read: (body: ScanElement) =>
|
|
|
248
248
|
);
|
|
249
249
|
return read(parsed.body);
|
|
250
250
|
} finally {
|
|
251
|
-
window.
|
|
252
|
-
window.happyDOM.
|
|
251
|
+
// The window is never reused, so this sync API need not await the teardown promises.
|
|
252
|
+
void window.happyDOM.abort();
|
|
253
|
+
void window.happyDOM.close();
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
|
package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx
CHANGED
|
@@ -17,6 +17,7 @@ const SLUG_INVALID_ISSUE: FieldIssue = {
|
|
|
17
17
|
export function createCollectionEntryCreateForm(
|
|
18
18
|
tenantId: string | undefined,
|
|
19
19
|
): (props: { readonly target: TargetRef; readonly onClose: () => void }) => ReactNode {
|
|
20
|
+
// biome-ignore lint/correctness/noComponentHookFactories: setup-time factory, called once per templateResolverClient(); the resolver slot only passes target/onClose, so tenantId is bound here
|
|
20
21
|
return function BoundCollectionEntryCreateForm(props) {
|
|
21
22
|
return <CollectionEntryCreateForm {...props} tenantId={tenantId} />;
|
|
22
23
|
};
|
|
@@ -92,6 +92,7 @@ const tenantMembershipCrud = createEventStoreExecutor(
|
|
|
92
92
|
// earlier hook that keeps reporting done:false can use up the budget, so later chunking
|
|
93
93
|
// hooks time out with nothing processed until it finishes.
|
|
94
94
|
function newOutcomeAccumulator(): {
|
|
95
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: takes the awaited result of a destroy hook, whose public type allows void
|
|
95
96
|
add: (result: TenantDestroyHookResult | void) => void;
|
|
96
97
|
outcome: () => StageRunOutcome;
|
|
97
98
|
} {
|