@cosmicdrift/kumiko-samples 0.364.0 → 0.365.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.
Files changed (148) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/changes.json +7 -0
  33. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  34. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  35. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  36. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  37. package/packages/bundled-features/src/delivery/changes.json +12 -0
  38. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  39. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  40. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  41. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  42. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  43. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  44. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  45. package/packages/bundled-features/src/jobs/changes.json +12 -0
  46. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  47. package/packages/bundled-features/src/metrics/changes.json +6 -0
  48. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  49. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  50. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  51. package/packages/bundled-features/src/secrets/changes.json +6 -0
  52. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  53. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  54. package/packages/bundled-features/src/shared/index.ts +8 -0
  55. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  56. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  57. package/packages/bundled-features/src/step-dispatcher/changes.json +8 -1
  58. package/packages/bundled-features/src/step-dispatcher/constants.ts +22 -0
  59. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +236 -0
  60. package/packages/bundled-features/src/step-dispatcher/feature.ts +65 -118
  61. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +40 -14
  62. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  63. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  64. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  65. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  66. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  67. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  68. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  69. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  70. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  71. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  72. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  73. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  74. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  75. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  76. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  77. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  78. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  79. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  80. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  81. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  82. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  83. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  84. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  85. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  86. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  87. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  88. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  89. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  90. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  91. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  92. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  93. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  94. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  95. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  96. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  97. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  98. package/packages/bundled-features/src/tenant/changes.json +6 -0
  99. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  100. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  101. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  102. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  103. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  104. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +9 -3
  105. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  106. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  107. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  108. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  109. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  110. package/packages/bundled-features/src/user/constants.ts +1 -0
  111. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  112. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  113. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  114. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  115. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  116. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  117. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  118. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  119. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  120. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  121. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  122. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  123. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  124. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  125. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  126. package/samples/apps/use-all-bundled/README.md +3 -13
  127. package/samples/apps/use-all-bundled/feature-manifest.json +4 -4
  128. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  129. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  130. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  131. package/samples/recipes/auth-mfa/README.md +2 -1
  132. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  133. package/samples/recipes/custom-handlers/README.md +4 -3
  134. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  135. package/samples/recipes/dashboard-stats/README.md +38 -0
  136. package/samples/recipes/dashboard-stats/package.json +8 -0
  137. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  138. package/samples/recipes/legal-pages/README.md +2 -2
  139. package/samples/recipes/managed-pages/README.md +2 -2
  140. package/samples/recipes/pdf-audit-export/README.md +2 -1
  141. package/samples/recipes/pdf-audit-export/package.json +1 -1
  142. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  143. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  144. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  145. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  146. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  147. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  148. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,11 +1,9 @@
1
- import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
3
2
  import {
4
3
  createSystemUser,
5
4
  defineWriteHandler,
6
5
  type HandlerContext,
7
6
  parseTenantId,
8
- type TenantId,
9
7
  } from "@cosmicdrift/kumiko-framework/engine";
10
8
  import {
11
9
  InternalError,
@@ -18,13 +16,12 @@ import {
18
16
  cancelPendingInvitation,
19
17
  INVITATION_STATUS,
20
18
  tenantInvitationsTable,
21
- tenantMembershipsTable,
22
19
  } from "../../tenant/index.js";
23
- import { userTable } from "../../user/index.js";
24
20
  import { WAITLIST_STATUS, WaitlistErrors } from "../constants.js";
25
21
  import { isOpenWaitlistStatus } from "../entity.js";
26
22
  import {
27
23
  adminAccess,
24
+ isTenantMember,
28
25
  normalizeEmail,
29
26
  platformActor,
30
27
  waitlistDb,
@@ -34,18 +31,6 @@ import { WaitlistIdSchema } from "../payloads.js";
34
31
 
35
32
  export type WaitlistRejectData = { readonly kind: "rejected"; readonly id: string };
36
33
 
37
- // An admin re-invite can flip an accepted invitation back to pending, so the
38
- // invitation status alone cannot prove the person holds no access.
39
- async function isTenantMember(db: TenantDb, email: string, tenantId: TenantId): Promise<boolean> {
40
- const user = await fetchOne<{ id: string }>(db, userTable, {
41
- email: normalizeEmail(email),
42
- isDeleted: false,
43
- });
44
- if (!user) return false;
45
- const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
46
- return membership !== undefined && membership !== null;
47
- }
48
-
49
34
  // An invited entry already has a live invitation link; rejecting must kill it,
50
35
  // otherwise the rejected person can still sign up. Once accepted they hold
51
36
  // access, which only tenant member removal may revoke.
@@ -36,10 +36,13 @@ const SUBMIT_ROLES = access.roles(
36
36
  "TenantAdmin",
37
37
  );
38
38
 
39
+ const OPEN_STATUSES = [WAITLIST_STATUS.Pending, WAITLIST_STATUS.Invited];
40
+
39
41
  export type WaitlistSubmitData = { readonly kind: "submitted" };
40
42
 
41
- // Identical for new, duplicate and honeypot submits: the response must not
42
- // reveal whether the email is already on the list.
43
+ // Identical body for new, duplicate and honeypot submits so the response does not
44
+ // state whether the email is already on the list. Timing still differs: only the
45
+ // new-entry path writes and sends mail.
43
46
  const SUBMITTED: WriteResult<WaitlistSubmitData> = {
44
47
  isSuccess: true,
45
48
  data: { kind: "submitted" },
@@ -60,13 +63,32 @@ function isHoneypotFilled(payload: WaitlistSubmitInput): boolean {
60
63
 
61
64
  async function hasOpenEntryForEmail(email: string, ctx: HandlerContext): Promise<boolean> {
62
65
  const found = await waitlistExecutor.list(
63
- { filter: { field: "email", op: "eq", value: email }, limit: 50 },
66
+ {
67
+ filter: { field: "email", op: "eq", value: email },
68
+ filters: [{ field: "status", op: "in", value: OPEN_STATUSES }],
69
+ limit: 1,
70
+ },
64
71
  platformActor(),
65
72
  waitlistDb(ctx),
66
73
  );
67
- return found.rows.some(
68
- (row) => row["status"] === WAITLIST_STATUS.Pending || row["status"] === WAITLIST_STATUS.Invited,
69
- );
74
+ return found.rows.length > 0;
75
+ }
76
+
77
+ // The resolver usually reads config; run it outside the handler transaction's
78
+ // critical section so a failing statement cannot abort the transaction that holds
79
+ // the entry, and a slow resolver does not extend the per-email advisory lock.
80
+ async function resolveAdminRecipient(
81
+ opts: WaitlistOptions,
82
+ ctx: HandlerContext,
83
+ ): Promise<string | null> {
84
+ try {
85
+ return (await opts.notifyRecipient?.(ctx)) || null;
86
+ } catch (err) {
87
+ ctx.log?.warn("[waitlist:submit] admin notice recipient resolution failed", {
88
+ error: err instanceof Error ? err.message : String(err),
89
+ });
90
+ return null;
91
+ }
70
92
  }
71
93
 
72
94
  async function sendBestEffort(
@@ -87,14 +109,16 @@ async function sendBestEffort(
87
109
  }
88
110
 
89
111
  export function createSubmitHandler(opts: WaitlistOptions) {
112
+ const emailRateLimits = opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS;
90
113
  return defineWriteHandler<"submit", typeof WaitlistSubmitSchema, WaitlistSubmitData>({
91
114
  name: "submit",
92
115
  schema: WaitlistSubmitSchema,
93
116
  access: { roles: SUBMIT_ROLES, personalData: "public-intake" },
94
117
  rateLimit: opts.rateLimit ?? DEFAULT_SUBMIT_RATE_LIMIT,
95
- additionalRateLimits: opts.emailRateLimits ?? DEFAULT_SUBMIT_EMAIL_RATE_LIMITS,
118
+ // An empty array means "disabled"; boot validation rejects an empty additionalRateLimits.
119
+ ...(emailRateLimits.length > 0 && { additionalRateLimits: emailRateLimits }),
96
120
  description:
97
- "Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Always reports success, whether the address is new or already on the list.",
121
+ "Public waitlist sign-up: registers an interest entry and sends the submitter a confirmation mail. Reports success whether the address is new or already on the list; the response body is identical, response timing can still differ.",
98
122
  escapeHatch: { grants: ["unsafeRaw"], reason: SUBMIT_LOCK_REASON },
99
123
  handler: async (event, ctx) => {
100
124
  const payload = event.payload;
@@ -106,6 +130,7 @@ export function createSubmitHandler(opts: WaitlistOptions) {
106
130
  message: "waitlist:submit requires ctx.systemDb — is r.systemScope() set?",
107
131
  });
108
132
  }
133
+ const adminRecipient = await resolveAdminRecipient(opts, ctx);
109
134
  await acquireNamespacedAdvisoryLock(
110
135
  ctx.systemDb.unsafeRaw(SUBMIT_LOCK_REASON),
111
136
  SUBMIT_LOCK_NAMESPACE,
@@ -140,7 +165,6 @@ export function createSubmitHandler(opts: WaitlistOptions) {
140
165
  );
141
166
 
142
167
  await sendBestEffort(ctx, "admin notice", async (notify) => {
143
- const adminRecipient = await opts.notifyRecipient?.(ctx);
144
168
  // skip: no admin recipient configured
145
169
  if (!adminRecipient) return;
146
170
  await notify(WAITLIST_NOTIFICATION_TYPES.adminNotice, {
@@ -1,6 +1,15 @@
1
1
  type LocalizedString = { readonly en: string; readonly de: string };
2
2
 
3
3
  export const WAITLIST_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "waitlist:feature.label": { en: "Waitlist", de: "Warteliste" },
5
+ "waitlist:notify.confirmation.label": {
6
+ en: "Waitlist confirmation",
7
+ de: "Bestätigung der Warteliste",
8
+ },
9
+ "waitlist:notify.admin-notice.label": {
10
+ en: "New waitlist entry",
11
+ de: "Neuer Eintrag auf der Warteliste",
12
+ },
4
13
  "screen:waitlist-list.title": { en: "Waitlist", de: "Warteliste" },
5
14
  "waitlist.nav.waitlist": { en: "Waitlist", de: "Warteliste" },
6
15
  "waitlist:entity:waitlistEntry:field:name": { en: "Name", de: "Name" },
@@ -1,3 +1,4 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
3
  import {
3
4
  access,
@@ -5,8 +6,11 @@ import {
5
6
  type HandlerContext,
6
7
  type SessionUser,
7
8
  SYSTEM_TENANT_ID,
9
+ type TenantId,
8
10
  } from "@cosmicdrift/kumiko-framework/engine";
9
11
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { tenantMembershipsTable } from "../tenant/index.js";
13
+ import { userTable } from "../user/index.js";
10
14
  import { waitlistEntryEntity, waitlistEntryTable } from "./entity.js";
11
15
 
12
16
  export const WAITLIST_CROSS_TENANT_REASON = "waitlist is platform-wide (systemScope)";
@@ -34,3 +38,19 @@ export function waitlistDb(ctx: HandlerContext): TenantDb {
34
38
  export function platformActor(): SessionUser {
35
39
  return createSystemUser(SYSTEM_TENANT_ID);
36
40
  }
41
+
42
+ // An admin re-invite can flip an accepted invitation back to pending, so the
43
+ // invitation status alone cannot prove the person holds no access.
44
+ export async function isTenantMember(
45
+ db: TenantDb,
46
+ email: string,
47
+ tenantId: TenantId,
48
+ ): Promise<boolean> {
49
+ const user = await fetchOne<{ id: string }>(db, userTable, {
50
+ email: normalizeEmail(email),
51
+ isDeleted: false,
52
+ });
53
+ if (!user) return false;
54
+ const membership = await fetchOne(db, tenantMembershipsTable, { userId: user.id, tenantId });
55
+ return membership !== undefined && membership !== null;
56
+ }
@@ -32,7 +32,7 @@ import { workflowRunPendingTableMeta } from "./tables.js";
32
32
 
33
33
  // Kebab-case matching the "./workflow-runner" export subpath — every other
34
34
  // bundled feature's runtime name follows this convention, and
35
- // FEATURE_IMPORT_REGISTRY (use-all-bundled) keys off it.
35
+ // use-all-bundled's check-coverage matches exports to mounted features by it.
36
36
  const FEATURE_NAME = "workflow-runner";
37
37
 
38
38
  export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
@@ -35,7 +35,20 @@ export const APP_FEATURES = [
35
35
  createDocumentDesignerFeature(),
36
36
  // Mounted individually: composeRendererStack() would add a second template-resolver.
37
37
  createRendererFoundationFeature(),
38
- createRendererMailHtmlFeature(),
38
+ createRendererMailHtmlFeature({
39
+ footer: ({ locale }) =>
40
+ locale.startsWith("de")
41
+ ? {
42
+ text: "Gesendet über das Nordlicht Mieterportal",
43
+ imprint: { label: "Impressum", url: "https://nordlicht-immobilien.de/impressum" },
44
+ privacy: { label: "Datenschutz", url: "https://nordlicht-immobilien.de/datenschutz" },
45
+ }
46
+ : {
47
+ text: "Sent via the Nordlicht tenant portal",
48
+ imprint: { label: "Imprint", url: "https://nordlicht-immobilien.de/imprint" },
49
+ privacy: { label: "Privacy", url: "https://nordlicht-immobilien.de/privacy" },
50
+ },
51
+ }),
39
52
  // The PDF preview of the template editor prints through the puppeteer-service
40
53
  // (PUPPETEER_SERVICE_URL / PUPPETEER_AUTH_TOKEN); the e2e config starts one.
41
54
  createRendererPuppeteerClientFeature(),
@@ -33,24 +33,14 @@ Exits 0 on success; any feature-wiring fault throws and exits 1.
33
33
 
34
34
  ## Coverage status
35
35
 
36
- 36/39 bundled-feature exports mounted, 3 held-back. M0.1 reduced this from 10. Held-back nach M0.1:
37
-
38
- - `auth-email-password` — auto-mounted via `composeFeatures(authOptions)` (no need to list)
39
- - `files-provider-s3` — utility helpers (`createS3Provider`), kein `defineFeature`
40
- - `foundation-shared` — pure utilities (`requireDefined`, `requireNonEmpty`), kein `defineFeature`
41
-
42
- The 3 remaining are utilities or auto-mounted — they cannot be added to `APP_FEATURES` by design.
36
+ Every bundled-feature export is mounted except the held-back ones in `EXPECTED_HELD_BACK` (`scripts/check-coverage.ts`): utilities without `defineFeature` and scaffold-only features. They cannot be added to `APP_FEATURES` by design.
43
37
 
44
38
  ## Maintenance
45
39
 
46
40
  When you add a new feature-export to `@cosmicdrift/kumiko-bundled-features` you MUST either:
47
41
 
48
42
  1. Mount it in `src/run-config.ts`. `kumiko/schema.ts` picks up entities via
49
- `composeFeatures(APP_FEATURES)` — then regenerate if schema changed:
50
- ```sh
51
- cd samples/apps/use-all-bundled
52
- bun ../../bin/kumiko.ts schema generate feature-coverage
53
- ```
43
+ `composeFeatures(APP_FEATURES)`; run `kumiko-schema generate` if the schema changed.
54
44
  2. Or, if the feature is not yet smoke-ready, add it to `EXPECTED_HELD_BACK` in `scripts/check-coverage.ts` with a one-line reason.
55
45
 
56
- CI fails otherwise via `Coverage lint (M5)`.
46
+ CI fails otherwise via `Coverage lint`.
@@ -2122,7 +2122,7 @@
2122
2122
  },
2123
2123
  {
2124
2124
  "name": "renderer-mail-html",
2125
- "description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code `DEFAULT_MAIL_LAYOUT_MJML` as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
2125
+ "description": "A `renderer-foundation` plugin that handles `RenderRequest { kind: \"mail\" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: \"mail\"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: \"mail-layout\"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.",
2126
2126
  "toggleableDefault": null,
2127
2127
  "requires": [
2128
2128
  "renderer-foundation",
@@ -2170,7 +2170,7 @@
2170
2170
  },
2171
2171
  {
2172
2172
  "name": "renderer-puppeteer-client",
2173
- "description": "A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.",
2173
+ "description": "A `renderer-foundation` plugin that prints documents to PDF through a separately deployed `puppeteer-service`. A `document-pdf` request names a stored template (`{ slug, locale, variables }`): the plugin resolves the template and its `pdf-layout` (header, signature and footer regions), embeds every image as a data URI, turns repeating table header rows into a real table head and sends the finished HTML to the service, which performs no network fetches itself. The page format, margins, file name pattern and title come from the template meta. A write handler renders with `renderPdfBeforeTransaction(ctx, { slug, locale, variables })` as its `beforeTransaction` phase, so the print service call holds no database connection, and stores the result with `storeRenderedPdf(ctx, event.prepared)` in the handler: a file plus a `rendered-pdf` record that keeps the template and layout version, so a stored document can always be traced to the text it was created from. The `puppeteer-status` query tells a TenantAdmin whether the service answers; while it does not, rendering fails with a `puppeteer_unavailable` error (HTTP 503) and no PDF is created. `image-snapshot` requests still take ready HTML in `payload.content`, which must embed all resources as data: URIs because the service fetches no http(s) resources. Configure the endpoint and token with `PUPPETEER_SERVICE_URL` and `PUPPETEER_AUTH_TOKEN`; the client retries 5xx and network errors with exponential backoff (default 3 retries), 4xx fails at once. The feature adds the `rendered_pdfs` table, so apps that mount it need a schema migration.",
2174
2174
  "toggleableDefault": null,
2175
2175
  "requires": [
2176
2176
  "renderer-foundation"
@@ -2391,7 +2391,7 @@
2391
2391
  },
2392
2392
  {
2393
2393
  "name": "step-dispatcher",
2394
- "description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
2394
+ "description": "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. An `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event enqueues one `step-dispatcher:job:deliver` BullMQ job per dispatch (Redis carries only the stream id and tenant, never the request payload). The job performs the actual HTTP or mail delivery and appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream, so the outcome is recorded in the event log without a separate status table. Transient failures (network error, timeout, 5xx, 408, 429, any mail adapter failure) are retried 3 times with exponential backoff; the outcome is recorded after the final attempt and the payload key is erased only then. Permanent failures (invalid url, blocked host, other 4xx, unavailable auth secret) are recorded immediately. At most 2 deliveries per tenant run at once (`maxPerTenant` with `onLimit: \"defer\"`), so a slow receiver of one tenant does not delay other tenants. Requires a job runner (Redis) and a worker consuming the job's default `worker` lane; without a job runner the projection fails loudly. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across retries and redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.",
2395
2395
  "toggleableDefault": null,
2396
2396
  "requires": [
2397
2397
  "secrets"
@@ -3209,7 +3209,7 @@
3209
3209
  },
3210
3210
  {
3211
3211
  "name": "waitlist",
3212
- "description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response never reveals whether an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
3212
+ "description": "Public waitlist: an anonymous, rate-limited `submit` handler collects name, email and optional company, portfolio and message (a honeypot field and per-email dedupe keep bots and repeat submits out, and the response body is identical whether or not an address was already listed), and mails the submitter a confirmation. SystemAdmins review entries in a list screen and invite or reject them; an invite creates the entrant's own tenant (or joins one shared tenant) and mails an accept link with the configured membership role, never a global role. Entries are platform-wide, their personal data is crypto-shredding-ready, and the EXT_USER_DATA hooks export and erase entries matching a user's email.",
3213
3213
  "toggleableDefault": null,
3214
3214
  "requires": [
3215
3215
  "tenant",
@@ -1,18 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  // biome-ignore-all lint/suspicious/noConsole: CLI-Script, console ist Feature.
3
3
  //
4
- // C1 M5 coverage lint. Diffs bundled-features-Exports gegen den
5
- // FEATURE_IMPORT_REGISTRY in samples/apps/use-all-bundled/schema/
6
- // generate.ts. Fängt zwei Drifts:
4
+ // Coverage lint. Diffs die Top-Level-Exports von bundled-features gegen die Features, die
5
+ // use-all-bundled mountet (FEATURES aus kumiko/schema.ts: APP_FEATURES plus die von
6
+ // composeFeatures ergänzten Auth-/Signup-Features). Fängt drei Drifts:
7
7
  //
8
- // 1. Ein neuer feature-export ohne Aufnahme in use-all-bundled
9
- // → unreferenced exports
10
- // 2. Ein removed feature-export der noch im Registry steht
11
- // → stale registry
12
- //
13
- // Held-back features für M0.1 (subscription-stripe, channel-email, …)
14
- // stehen in EXPECTED_HELD_BACK. Sobald sie mit stub-options gemountet
15
- // werden, müssen sie aus dieser Liste raus.
8
+ // 1. Ein Export, der weder gemountet ist noch in EXPECTED_HELD_BACK steht
9
+ // 2. Ein EXPECTED_HELD_BACK-Eintrag, der kein Export mehr ist
10
+ // 3. Ein EXPECTED_HELD_BACK-Eintrag, der inzwischen gemountet ist (stale)
16
11
  //
17
12
  // Usage:
18
13
  // bun samples/apps/use-all-bundled/scripts/check-coverage.ts
@@ -20,6 +15,7 @@
20
15
 
21
16
  import { readFileSync } from "node:fs";
22
17
  import { resolve } from "node:path";
18
+ import { FEATURES } from "../kumiko/schema";
23
19
 
24
20
  const ROOT = resolve(import.meta.dir, "..");
25
21
  const FRAMEWORK_ROOT = resolve(ROOT, "..", "..", "..");
@@ -27,33 +23,13 @@ const FRAMEWORK_ROOT = resolve(ROOT, "..", "..", "..");
27
23
  // Held-back exports: existieren in bundled-features, sind aber nicht
28
24
  // als runtime-feature in use-all-bundled mountbar.
29
25
  //
30
- // Nach M0.1 nur noch utilities + auto-mounted:
31
- // - utilities: kein r.defineFeature (foundation-shared, files-provider-s3
32
- // sind pure helpers).
33
- // - auto-mounted: kommt via composeFeatures(includeBundled:true) automatisch
34
- // (auth-email-password) — wäre doppelt-mount wenn explizit gelistet.
26
+ // Nur Utilities ohne defineFeature (foundation-shared, files-provider-s3 sind pure helpers)
27
+ // und scaffold-only Features. Gemountete Features gehören nicht hierher.
35
28
  const EXPECTED_HELD_BACK = new Set([
36
- "auth-email-password", // auto-mounted via composeFeatures(authOptions)
37
- "agent-tools", // utility (buildToolCatalog: Registry -> ToolDefinition[]), kein defineFeature
38
29
  "files-provider-s3", // utility (createS3Provider helpers), kein defineFeature
39
30
  "foundation-shared", // utilities (requireDefined/requireNonEmpty), kein feature
40
31
  "page-render", // shared HTML render/cache helpers (legal/managed-pages), kein defineFeature
41
32
  "presets", // feature-bundle helpers (dsgvoSelfServiceFeatures, securityBaselineFeatures), kein defineFeature
42
- "auth-foundation", // scaffold-only (#1368): bootCheck wirft ohne gemountete auth-provider-*, die es noch nicht gibt
43
- ]);
44
-
45
- // Registry-only: mounted features that are NOT top-level package exports
46
- // (companion features living under another export path). schema-check still
47
- // needs them in FEATURE_IMPORT_REGISTRY; coverage must not flag them stale.
48
- const EXPECTED_REGISTRY_ONLY = new Set([
49
- "auth-self-registration", // from ./auth-email-password, auto-mounted with signup (#1521)
50
- "collection-labels", // app-local (src/app/), nav labels for this app's content collections
51
- "cap-overview-labels", // app-local (src/app/), labels for this app's example cap-overview caps
52
- "metrics-system", // from ./metrics, companion of createMetricsFeature (platform-wide scope)
53
- "locale-de", // @cosmicdrift/kumiko-locale-de, not a bundled-features export
54
- "renderer-mail-html", // @cosmicdrift/kumiko-renderer-mail-html, auto-mounted with auth by composeFeatures
55
- "renderer-puppeteer-client", // @cosmicdrift/kumiko-renderer-puppeteer-client, not a bundled-features export
56
- "document-designer", // @cosmicdrift/kumiko-document-designer, not a bundled-features export
57
33
  ]);
58
34
 
59
35
  // Sub-paths in bundled-features's package.json exports (./tenant/seeding,
@@ -87,120 +63,60 @@ function readBundledExports(): Set<string> {
87
63
  return out;
88
64
  }
89
65
 
90
- function extractRegistryBlock(src: string): string {
91
- // Anchor auf den FEATURE_IMPORT_REGISTRY-Object-Body. Ohne diesen Anchor
92
- // würde die kind-Regex jedes `<ident>: { kind: "factory"|"named" }` im File
93
- // matchen (z.B. eine künftige Hilfsvariable) → false positive/negative.
94
- const startMatch = /FEATURE_IMPORT_REGISTRY[^=]*=\s*\{/.exec(src);
95
- if (!startMatch) {
96
- throw new Error("check-coverage: FEATURE_IMPORT_REGISTRY block not found in generate.ts");
97
- }
98
- const bodyStart = startMatch.index + startMatch[0].length;
99
- // Top-level-Close ist das erste `};` am Zeilenanfang nach bodyStart.
100
- const closeMatch = /\n};/.exec(src.slice(bodyStart));
101
- if (!closeMatch) {
102
- throw new Error("check-coverage: FEATURE_IMPORT_REGISTRY closing `};` not found");
103
- }
104
- return src.slice(bodyStart, bodyStart + closeMatch.index);
105
- }
106
-
107
- function readRegistryFeatures(): Set<string> {
108
- const generateSrc = readFileSync(resolve(ROOT, "schema", "generate.ts"), "utf-8");
109
- const registryBlock = extractRegistryBlock(generateSrc);
110
- // Match object-keys mit Discriminator `kind: "factory" | "named"`.
111
- // Property-Keys können quoted ("billing-foundation") oder unquoted
112
- // (config, user) sein — beides ist gültig wenn kebab-segment-free.
113
- const re = /(?:"([a-z][a-z0-9-]*)"|([a-z][a-z0-9]*)):\s*\{\s*kind:\s*"(factory|named)"/g;
114
- const out = new Set<string>();
115
- for (const m of registryBlock.matchAll(re)) {
116
- const name = m[1] ?? m[2];
117
- if (name) out.add(name);
118
- }
119
- return out;
120
- }
121
-
122
66
  const exports = readBundledExports();
123
- const registry = readRegistryFeatures();
124
-
125
- const unreferenced: string[] = [];
126
- const stale: string[] = [];
127
- // Held-back-Einträge, die kein bundled-Export mehr sind → stale Konfig.
128
- // Ohne diese Prüfung hätte der Drift-Guard in seiner eigenen Konfig einen
129
- // Blind-Spot: ein umbenanntes/entferntes Feature bliebe still in der Liste.
130
- const staleHeldBack: string[] = [];
131
-
132
- for (const name of exports) {
133
- if (registry.has(name)) continue;
134
- if (EXPECTED_HELD_BACK.has(name)) continue;
135
- unreferenced.push(name);
136
- }
137
-
138
- for (const name of registry) {
139
- if (!exports.has(name) && !EXPECTED_REGISTRY_ONLY.has(name)) stale.push(name);
140
- }
67
+ const mounted = new Set<string>(FEATURES.map((feature) => feature.name));
141
68
 
142
- for (const name of EXPECTED_HELD_BACK) {
143
- if (!exports.has(name)) staleHeldBack.push(name);
144
- }
69
+ const unreferenced = [...exports].filter(
70
+ (name) => !mounted.has(name) && !EXPECTED_HELD_BACK.has(name),
71
+ );
72
+ // Ohne diese Prüfungen hätte der Guard in seiner eigenen Konfig einen Blind-Spot: ein
73
+ // umbenanntes oder entferntes Feature bliebe still in der Liste, ein gemountetes ebenso.
74
+ const staleHeldBack = [...EXPECTED_HELD_BACK].filter((name) => !exports.has(name));
75
+ const mountedHeldBack = [...EXPECTED_HELD_BACK].filter((name) => mounted.has(name));
145
76
 
146
77
  let ok = true;
147
78
 
148
79
  if (unreferenced.length > 0) {
149
80
  console.error(
150
- `\n❌ ${unreferenced.length} bundled-feature export(s) NICHT in use-all-bundled/schema/generate.ts:`,
81
+ `\n❌ ${unreferenced.length} bundled-feature export(s) nicht in use-all-bundled gemountet:`,
151
82
  );
152
83
  for (const name of unreferenced.sort()) {
153
84
  console.error(` - ${name}`);
154
85
  }
155
86
  console.error(
156
- "\n Action: in src/run-config.ts + schema/generate.ts FEATURE_IMPORT_REGISTRY aufnehmen,",
157
- );
158
- console.error(
159
- " oder (wenn mount-options noch fehlen) zu EXPECTED_HELD_BACK in dieser Datei hinzufügen.",
87
+ "\n Action: in src/run-config.ts mounten, oder (wenn mount-options noch fehlen) zu EXPECTED_HELD_BACK in dieser Datei hinzufügen.",
160
88
  );
161
89
  ok = false;
162
90
  }
163
91
 
164
- if (stale.length > 0) {
92
+ if (staleHeldBack.length > 0) {
165
93
  console.error(
166
- `\n❌ ${stale.length} FEATURE_IMPORT_REGISTRY entry/entries ohne Match in bundled-features exports:`,
94
+ `\n❌ ${staleHeldBack.length} EXPECTED_HELD_BACK entry/entries ohne Match in bundled-features exports:`,
167
95
  );
168
- for (const name of stale.sort()) {
96
+ for (const name of staleHeldBack.sort()) {
169
97
  console.error(` - ${name}`);
170
98
  }
171
99
  console.error(
172
- "\n Action: aus schema/generate.ts FEATURE_IMPORT_REGISTRY entfernen, ggf. App-eigenes Feature.",
100
+ "\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen (Feature wurde umbenannt/entfernt).",
173
101
  );
174
102
  ok = false;
175
103
  }
176
104
 
177
- if (staleHeldBack.length > 0) {
105
+ if (mountedHeldBack.length > 0) {
178
106
  console.error(
179
- `\n❌ ${staleHeldBack.length} EXPECTED_HELD_BACK entry/entries ohne Match in bundled-features exports:`,
107
+ `\n❌ ${mountedHeldBack.length} EXPECTED_HELD_BACK entry/entries sind gemountet (stale):`,
180
108
  );
181
- for (const name of staleHeldBack.sort()) {
109
+ for (const name of mountedHeldBack.sort()) {
182
110
  console.error(` - ${name}`);
183
111
  }
184
- console.error(
185
- "\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen (Feature wurde umbenannt/entfernt).",
186
- );
112
+ console.error("\n Action: aus EXPECTED_HELD_BACK in dieser Datei entfernen.");
187
113
  ok = false;
188
114
  }
189
115
 
190
- if (ok) {
191
- // Disjunkte Zahlen: `mountable` = exports ohne held-back; `coveredCount`
192
- // zählt nur Registry-Einträge, die ein echter mountbarer Export sind
193
- // (auth-email-password steht im Registry für schema-check-Konsistenz,
194
- // ist aber held-back — würde sonst die Ratio >1 verfälschen).
195
- const mountable = exports.size - EXPECTED_HELD_BACK.size;
196
- let coveredCount = 0;
197
- for (const name of registry) {
198
- if (exports.has(name) && !EXPECTED_HELD_BACK.has(name)) coveredCount++;
199
- }
200
- console.log(
201
- `✓ Coverage: ${coveredCount}/${mountable} mountable bundled-features im Registry, ${EXPECTED_HELD_BACK.size} held-back (M0.1).`,
202
- );
203
- process.exit(0);
204
- } else {
205
- process.exit(1);
206
- }
116
+ if (!ok) process.exit(1);
117
+
118
+ const mountable = exports.size - EXPECTED_HELD_BACK.size;
119
+ const mountedExports = [...exports].filter((name) => mounted.has(name)).length;
120
+ console.log(
121
+ `✓ Coverage: ${mountedExports}/${mountable} mountable bundled-features gemountet, ${EXPECTED_HELD_BACK.size} held-back.`,
122
+ );
@@ -6,7 +6,7 @@
6
6
  // proving the "extend any object" promise on real, bootable screens.
7
7
  //
8
8
  // Deliberately NOT in src/run-config.ts APP_FEATURES: that set is bundled-only
9
- // (schema/generate.ts FEATURE_IMPORT_REGISTRY + check-coverage gate it). Like
9
+ // (check-coverage compares it against the bundled-features exports). Like
10
10
  // appScreensFeature, this app-owned feature is mounted only in the dev server
11
11
  // (src/app/dev-app-options.ts), where runDevApp dev-pushes its table into the ephemeral
12
12
  // DB. Nothing here ships to prod / the schema pipeline.
@@ -442,6 +442,3 @@ export const AUTH_COMPOSE_OPTIONS = {
442
442
  tokenTtlMinutes: 60,
443
443
  },
444
444
  } as const;
445
-
446
- /** schema-check: auth-self-registration is mounted when signup is set. */
447
- export const HAS_SIGNUP = true;
@@ -10,7 +10,7 @@ The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthE
10
10
 
11
11
  1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
12
12
  2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
13
- 3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
13
+ 3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify, switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate } })` (the gate is required with `mfaVerifyHandler`, so a tenant switch asks for the second factor too) mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
14
14
  4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
15
15
 
16
16
  ## What to copy into your app
@@ -43,6 +43,7 @@ const server = buildServer({
43
43
  membershipQuery: "tenant:query:memberships",
44
44
  loginHandler: "auth-email-password:[REDACTED:API key param]",
45
45
  mfaVerifyHandler: AuthMfaHandlers.verify,
46
+ switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
46
47
  sessionCreator: sessionCallbacks.sessionCreator,
47
48
  sessionRevoker: sessionCallbacks.sessionRevoker,
48
49
  sessionChecker: sessionCallbacks.sessionChecker,
@@ -37,6 +37,7 @@
37
37
  // membershipQuery: "tenant:query:memberships",
38
38
  // loginHandler: "auth-email-password:[REDACTED:API key param]",
39
39
  // mfaVerifyHandler: AuthMfaHandlers.verify,
40
+ // switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
40
41
  // sessionCreator: sessionCallbacks.sessionCreator,
41
42
  // sessionRevoker: sessionCallbacks.sessionRevoker,
42
43
  // sessionChecker: sessionCallbacks.sessionChecker,
@@ -22,9 +22,10 @@ logic is yours.
22
22
  - **`skipOptimisticLock` for last-writer-wins** — `reset` deliberately
23
23
  bypasses the version check because the operation is admin-driven and
24
24
  resetting twice is semantically the same as resetting once.
25
- - **Custom queries** — `counter:active` filters the projection in
26
- TypeScript after the executor's list returns. Useful when the filter is
27
- computed (above-threshold) rather than indexed.
25
+ - **Custom queries** — `counter:active` passes a server-side `filter`
26
+ (`eq`/`ne`/`lt`/`gt`/`lte`/`gte`/`in`) and `totalCount` to the executor's
27
+ list. Never filter a page in TypeScript: list is paginated, so rows after
28
+ the first page would be missed.
28
29
  - **`failNotFound` helper** — typed error response for the 404 case
29
30
  without a hand-built `KumikoError` subclass.
30
31
 
@@ -1,5 +1,5 @@
1
1
  // Custom Handlers Sample
2
- // Shows: writeHandler with business logic, queryHandler with custom filtering,
2
+ // Shows: writeHandler with business logic, queryHandler with a server-side filter,
3
3
  // handler that modifies payload before DB write.
4
4
 
5
5
  import {
@@ -12,6 +12,9 @@ import {
12
12
  import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
13
13
  import { z } from "zod";
14
14
 
15
+ // detail() returns an untyped row; parse the two fields the increment needs once.
16
+ const counterVersionRowSchema = z.object({ count: z.number(), version: z.number() });
17
+
15
18
  export const counterEntity = createEntity({
16
19
  table: "read_sample_counters",
17
20
  fields: {
@@ -48,13 +51,14 @@ export const counterFeature = defineFeature("counters", (r) => {
48
51
  return failNotFound("counter", event.payload.id);
49
52
  }
50
53
 
51
- const newCount = (current["count"] as number) + event.payload.amount;
54
+ const { count, version } = counterVersionRowSchema.parse(current);
55
+ const newCount = count + event.payload.amount;
52
56
  // Read-modify-write on counter: use the version we just read so two
53
57
  // concurrent increments don't clobber each other's changes.
54
58
  return counterExecutor.update(
55
59
  {
56
60
  id: event.payload.id,
57
- version: current["version"] as number,
61
+ version,
58
62
  changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
59
63
  },
60
64
  event.user,
@@ -80,17 +84,22 @@ export const counterFeature = defineFeature("counters", (r) => {
80
84
  { access: { roles: ["Admin"] } },
81
85
  );
82
86
 
83
- // Custom query: only counters above a threshold
87
+ // Custom query: only counters above a threshold. list() returns one page
88
+ // (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
89
+ // page 1; filter in the query instead. totalCount counts over the whole WHERE.
84
90
  r.queryHandler(
85
91
  "counter:active",
86
- z.object({ minCount: z.number().default(1) }),
87
- async (query, ctx) => {
88
- const all = await counterExecutor.list({}, query.user, ctx.db);
89
- return {
90
- ...all,
91
- rows: all.rows.filter((r) => (r["count"] as number) >= query.payload.minCount),
92
- };
93
- },
92
+ z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
93
+ async (query, ctx) =>
94
+ counterExecutor.list(
95
+ {
96
+ filter: { field: "count", op: "gte", value: query.payload.minCount },
97
+ totalCount: true,
98
+ cursor: query.payload.cursor,
99
+ },
100
+ query.user,
101
+ ctx.db,
102
+ ),
94
103
  {
95
104
  access: {
96
105
  openToAll: {