@cosmicdrift/kumiko-samples 0.404.0 → 0.405.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +25 -25
  3. package/packages/bundled-features/src/account-security/README.md +7 -5
  4. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  5. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  6. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
  7. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  8. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  11. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  12. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  13. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  14. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  15. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  16. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  17. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  18. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  19. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  20. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  23. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  24. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  26. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  27. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  28. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  29. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  30. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  32. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  33. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  34. package/packages/bundled-features/src/documents/changes.json +8 -0
  35. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  36. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  37. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  38. package/packages/bundled-features/src/folders/feature.ts +2 -8
  39. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  40. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  41. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  42. package/packages/bundled-features/src/host-routing/README.md +10 -10
  43. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  44. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  46. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  47. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  48. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  49. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  50. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  51. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  52. package/packages/bundled-features/src/legal-pages/README.md +21 -13
  53. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  54. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  55. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  56. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  57. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  58. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  59. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  60. package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
  61. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  62. package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
  63. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  64. package/packages/bundled-features/src/shared/changes.json +6 -0
  65. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  66. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  67. package/packages/bundled-features/src/shared/index.ts +6 -0
  68. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  69. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  70. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  71. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  72. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  73. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  74. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  75. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  76. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  77. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  78. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  79. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  80. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  81. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  82. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  83. package/packages/bundled-features/src/tags/feature.ts +2 -8
  84. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  85. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  86. package/packages/bundled-features/src/template-resolver/README.md +29 -29
  87. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
  88. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  89. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
  90. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  91. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  92. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  93. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  94. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
  97. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  98. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
  99. package/packages/bundled-features/src/user-data-rights/README.md +33 -33
  100. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  101. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  102. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  103. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  104. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  105. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  106. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  107. package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
  108. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
  109. package/samples/apps/admin-console/README.md +5 -5
  110. package/samples/apps/admin-console/package.json +3 -3
  111. package/samples/apps/cap-billing-demo/README.md +15 -15
  112. package/samples/apps/config-demo/README.md +7 -7
  113. package/samples/apps/config-demo/package.json +2 -2
  114. package/samples/apps/marketing-demo/README.md +2 -2
  115. package/samples/apps/marketing-demo/package.json +2 -2
  116. package/samples/apps/showcase/package.json +3 -3
  117. package/samples/apps/styleguide/package.json +2 -2
  118. package/samples/apps/ui-walkthrough/README.md +8 -9
  119. package/samples/apps/ui-walkthrough/package.json +2 -2
  120. package/samples/apps/use-all-bundled/README.md +2 -2
  121. package/samples/apps/use-all-bundled/package.json +2 -2
  122. package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
  123. package/samples/apps/user-data-rights-demo/README.md +12 -12
  124. package/samples/apps/workspaces/package.json +2 -2
  125. package/samples/recipes/apex-landing/README.md +3 -3
  126. package/samples/recipes/apex-landing/package.json +1 -1
  127. package/samples/recipes/apex-surface-auth/README.md +13 -9
  128. package/samples/recipes/auth-mfa/README.md +2 -4
  129. package/samples/recipes/auth-mfa/package.json +2 -2
  130. package/samples/recipes/boot-check/README.md +2 -2
  131. package/samples/recipes/compliance-profiles-demo/README.md +5 -5
  132. package/samples/recipes/compose-stacks/README.md +8 -8
  133. package/samples/recipes/cross-feature-events/README.md +1 -0
  134. package/samples/recipes/crypto-shredding-hr/README.md +3 -3
  135. package/samples/recipes/custom-fields-basic/README.md +1 -1
  136. package/samples/recipes/documents/README.md +25 -0
  137. package/samples/recipes/embedded-entity-form/package.json +2 -2
  138. package/samples/recipes/encrypted-tenant-config/README.md +8 -8
  139. package/samples/recipes/env-composition/README.md +10 -10
  140. package/samples/recipes/error-contract/README.md +9 -9
  141. package/samples/recipes/event-sourcing/README.md +9 -9
  142. package/samples/recipes/ledger-invoicing/README.md +6 -6
  143. package/samples/recipes/legal-pages/README.md +16 -16
  144. package/samples/recipes/managed-config/README.md +15 -11
  145. package/samples/recipes/managed-pages/README.md +7 -7
  146. package/samples/recipes/pdf-audit-export/package.json +1 -1
  147. package/samples/recipes/pipeline-basics/README.md +13 -13
  148. package/samples/recipes/public-share-token/README.md +4 -4
  149. package/samples/recipes/record-detail-layout/package.json +3 -3
  150. package/samples/recipes/security-baseline/README.md +4 -1
  151. package/samples/recipes/session-revocation/README.md +1 -1
  152. package/samples/recipes/tenant-settings/README.md +4 -4
  153. package/samples/recipes/tier-admin/README.md +2 -2
  154. package/samples/recipes/user-data-rights/README.md +21 -19
  155. package/samples/recipes/wizard-form/package.json +3 -3
  156. 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.404.0",
3
+ "version": "0.405.0",
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.404.0",
3
+ "version": "0.405.0",
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.5.0",
169
- "@node-rs/argon2": "^2.0.2",
170
- "@tiptap/core": "^3.31.3",
171
- "@tiptap/extension-link": "^3.31.3",
172
- "@tiptap/extension-list": "^3.31.3",
173
- "@tiptap/extension-table": "^3.31.3",
174
- "@tiptap/html": "^3.31.3",
175
- "@tiptap/extensions": "^3.31.3",
176
- "@tiptap/pm": "^3.31.3",
177
- "@tiptap/react": "^3.31.3",
178
- "@tiptap/starter-kit": "^3.31.3",
179
- "@types/mailparser": "^3.4.6",
180
- "@types/qrcode": "^1.5.5",
168
+ "@mollie/api-client": "^4.6.0",
169
+ "@node-rs/argon2": "^2.2.2",
170
+ "@tiptap/core": "^3.32.2",
171
+ "@tiptap/extension-link": "^3.32.2",
172
+ "@tiptap/extension-list": "^3.32.2",
173
+ "@tiptap/extension-table": "^3.32.2",
174
+ "@tiptap/html": "^3.32.2",
175
+ "@tiptap/extensions": "^3.32.2",
176
+ "@tiptap/pm": "^3.32.2",
177
+ "@tiptap/react": "^3.32.2",
178
+ "@tiptap/starter-kit": "^3.32.2",
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.10.1",
186
- "jose": "^6.2.13",
187
- "lucide-react": "^1.14.0",
188
- "mailparser": "^3.12.4",
189
- "marked": "^18.0.3",
190
- "nodemailer": "^10.2.1",
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.2.6",
194
- "react-dom": "^19.2.6",
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.6.0",
197
+ "tailwind-merge": "^3.7.0",
198
198
  "temporal-polyfill": "^0.3.2",
199
- "uuid": "^14.0.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.8.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({ /* secrets, issuer */ }),
18
+ createAuthMfaFeature({
19
+ /* secrets, issuer */
20
+ }),
19
21
  createAccountSecurityFeature(),
20
22
  ];
21
23
  ```
22
24
 
23
25
  ## Options
24
26
 
25
- | Option | Default | Effect |
26
- | --- | --- | --- |
27
- | `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }` |
28
- | `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 |
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`.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "Login-path redirect hook and topbar without UserMenu",
6
+ "detail": "auth-email-password/web exports useRedirectAuthedFromLogin({ loginPath, target }), which replaces the URL with target while a signed-in user sits on the login path, also after Browser-Back, and isAuthedOnLoginPath. DefaultTopbarActions takes userMenu (default true) to hide the UserMenu and themeToggleTestId for the ThemeToggle button."
7
+ },
2
8
  {
3
9
  "version": "0.394.0",
4
10
  "type": "improvement",
@@ -1,17 +1,12 @@
1
1
  import {
2
- type NotificationLocalizedTemplate,
3
2
  QnTypes,
4
3
  qn,
5
4
  type TemplateVariables,
6
5
  templateVariable,
7
6
  } from "@cosmicdrift/kumiko-framework/engine";
8
- import {
9
- hasMailTranslationKey,
10
- listMailLocales,
11
- mailT,
12
- resolveMailLocale,
13
- } from "@cosmicdrift/kumiko-framework/i18n";
7
+ import { mailT } from "@cosmicdrift/kumiko-framework/i18n";
14
8
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
9
+ import { createMailLocaleTemplates } from "../shared/index.ts";
15
10
  import { AUTH_EMAIL_PASSWORD_FEATURE } from "./web/constants.ts";
16
11
 
17
12
  export const AUTH_MAIL_KINDS = {
@@ -62,18 +57,14 @@ const ALL_AUTH_MAIL_KINDS: readonly AuthMailKind[] = [
62
57
  "invite",
63
58
  ];
64
59
 
65
- const SUBJECT_KEY_BY_TYPE: ReadonlyMap<string, string> = new Map(
66
- ALL_AUTH_MAIL_KINDS.map((kind) => [AuthMailNotificationTypes[kind], subjectKey(kind)]),
67
- );
68
-
69
- // A registered locale whose bundle lacks the subject key has no template (see
70
- // authMailLocalizedTemplate); the renderer has no locale fallback, so the
71
- // sender must pick a locale that has one or the mail would not go out at all.
72
- export function authMailTemplateLocale(notificationType: string, requestedLocale: string): string {
73
- const locale = resolveMailLocale(requestedLocale);
74
- const key = SUBJECT_KEY_BY_TYPE.get(notificationType);
75
- return key !== undefined && hasMailTranslationKey(locale, key) ? locale : "en";
76
- }
60
+ const mailLocaleTemplates = createMailLocaleTemplates({
61
+ kinds: ALL_AUTH_MAIL_KINDS,
62
+ notificationTypes: AuthMailNotificationTypes,
63
+ subjectKey,
64
+ bodyFor: (kind, locale) => mailBody(locale, AUTH_MAIL_KINDS[kind].translationKey),
65
+ });
66
+
67
+ export const authMailTemplateLocale = mailLocaleTemplates.templateLocale;
77
68
 
78
69
  export const AUTH_MAIL_VARIABLES: TemplateVariables = {
79
70
  appName: templateVariable.text({ required: true }),
@@ -128,31 +119,4 @@ function mailBody(locale: string, translationKey: string): string {
128
119
  ].join("");
129
120
  }
130
121
 
131
- // Evaluated when the registry is built, after every locale feature has
132
- // registered its mail translations. English always ships; another locale only
133
- // gets a template when it has its own subject, so a missing translation never
134
- // silently becomes an English mail in that locale's slot.
135
- export function authMailLocalizedTemplate(kind: AuthMailKind): () => NotificationLocalizedTemplate {
136
- const { translationKey } = AUTH_MAIL_KINDS[kind];
137
- const kindSubjectKey = subjectKey(kind);
138
- return () => {
139
- const locales = [
140
- "en",
141
- ...listMailLocales().filter(
142
- (locale) => locale !== "en" && hasMailTranslationKey(locale, kindSubjectKey),
143
- ),
144
- ];
145
- return Object.fromEntries(
146
- locales.map((locale) => [
147
- locale,
148
- {
149
- body: mailBody(locale, translationKey),
150
- meta: {
151
- subject: mailT(locale, kindSubjectKey, { app: "{{appName}}" }),
152
- category: "transactional" as const,
153
- },
154
- },
155
- ]),
156
- );
157
- };
158
- }
122
+ export const authMailLocalizedTemplate = mailLocaleTemplates.localizedTemplate;
@@ -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}>
@@ -24,6 +24,11 @@ export type DefaultTopbarActionsProps = {
24
24
  readonly lightIcon?: ReactNode;
25
25
  /** Dark-Mode-Icon. Default: Unicode ☾. */
26
26
  readonly darkIcon?: ReactNode;
27
+ /** Render the UserMenu. Default true; false for apps whose account/logout
28
+ * lives elsewhere (for example a sidebar profile). */
29
+ readonly userMenu?: boolean;
30
+ /** data-testid forwarded to the ThemeToggle button. */
31
+ readonly themeToggleTestId?: string;
27
32
  };
28
33
 
29
34
  export function DefaultTopbarActions({
@@ -31,6 +36,8 @@ export function DefaultTopbarActions({
31
36
  extras,
32
37
  lightIcon,
33
38
  darkIcon,
39
+ userMenu = true,
40
+ themeToggleTestId,
34
41
  }: DefaultTopbarActionsProps = {}): ReactNode {
35
42
  return (
36
43
  <div className="flex items-center gap-2">
@@ -39,8 +46,9 @@ export function DefaultTopbarActions({
39
46
  <ThemeToggle
40
47
  {...(lightIcon !== undefined && { lightIcon })}
41
48
  {...(darkIcon !== undefined && { darkIcon })}
49
+ {...(themeToggleTestId !== undefined && { testId: themeToggleTestId })}
42
50
  />
43
- <UserMenu />
51
+ {userMenu && <UserMenu />}
44
52
  </div>
45
53
  );
46
54
  }
@@ -87,6 +87,11 @@ export type { TenantMenuItemsProps } from "./tenant-menu-items.tsx";
87
87
  export { TenantMenuItems } from "./tenant-menu-items.tsx";
88
88
  export type { TenantSwitcherProps } from "./tenant-switcher.tsx";
89
89
  export { TenantSwitcher } from "./tenant-switcher.tsx";
90
+ export type { UseRedirectAuthedFromLoginOptions } from "./use-redirect-authed-from-login.ts";
91
+ export {
92
+ isAuthedOnLoginPath,
93
+ useRedirectAuthedFromLogin,
94
+ } from "./use-redirect-authed-from-login.ts";
90
95
  export type { ShellUser } from "./use-shell-user.ts";
91
96
  export { useShellUser } from "./use-shell-user.ts";
92
97
  export type { UserMenuProps } from "./user-menu.tsx";
@@ -109,15 +109,15 @@ export function InviteAcceptScreen({
109
109
  );
110
110
  };
111
111
 
112
- const acceptLoggedIn = async (): Promise<void> => {
112
+ const postInviteAccept = async (path: string, body: Record<string, string>): Promise<void> => {
113
113
  setSubmitting(true);
114
114
  setError(null);
115
115
  // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
116
- const res = await fetch("/api/auth/invite-accept", {
116
+ const res = await fetch(path, {
117
117
  method: "POST",
118
118
  credentials: "same-origin",
119
119
  headers: { "Content-Type": "application/json", ...csrfHeader() },
120
- body: JSON.stringify({ token }),
120
+ body: JSON.stringify(body),
121
121
  });
122
122
  setSubmitting(false);
123
123
  if (res.ok) {
@@ -129,45 +129,14 @@ export function InviteAcceptScreen({
129
129
  setError(t("auth.errors.invalidInviteToken"));
130
130
  };
131
131
 
132
- const acceptAnonExisting = async (): Promise<void> => {
133
- setSubmitting(true);
134
- setError(null);
135
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
136
- const res = await fetch("/api/auth/invite-accept-with-login", {
137
- method: "POST",
138
- credentials: "same-origin",
139
- headers: { "Content-Type": "application/json", ...csrfHeader() },
140
- body: JSON.stringify({ token, email, password }),
141
- });
142
- setSubmitting(false);
143
- if (res.ok) {
144
- // @cast-boundary engine-payload — auth route JSON
145
- const data = (await res.json()) as InviteAcceptResponse;
146
- finishAccept(data);
147
- return;
148
- }
149
- setError(t("auth.errors.invalidInviteToken"));
150
- };
132
+ const acceptLoggedIn = (): Promise<void> =>
133
+ postInviteAccept("/api/auth/invite-accept", { token });
151
134
 
152
- const acceptAnonNew = async (): Promise<void> => {
153
- setSubmitting(true);
154
- setError(null);
155
- // kumiko-lint-ignore no-raw-hooks not yet converted to the framework hook set
156
- const res = await fetch("/api/auth/invite-signup-complete", {
157
- method: "POST",
158
- credentials: "same-origin",
159
- headers: { "Content-Type": "application/json", ...csrfHeader() },
160
- body: JSON.stringify({ token, password }),
161
- });
162
- setSubmitting(false);
163
- if (res.ok) {
164
- // @cast-boundary engine-payload — auth route JSON
165
- const data = (await res.json()) as InviteAcceptResponse;
166
- finishAccept(data);
167
- return;
168
- }
169
- setError(t("auth.errors.invalidInviteToken"));
170
- };
135
+ const acceptAnonExisting = (): Promise<void> =>
136
+ postInviteAccept("/api/auth/invite-accept-with-login", { token, email, password });
137
+
138
+ const acceptAnonNew = (): Promise<void> =>
139
+ postInviteAccept("/api/auth/invite-signup-complete", { token, password });
171
140
 
172
141
  const onSubmit = (e?: FormEvent): void => {
173
142
  e?.preventDefault();
@@ -0,0 +1,15 @@
1
+ import type { TenantSummary } from "./auth-client.ts";
2
+
3
+ // Label priority: app-provided resolver > server-provided name/key from
4
+ // /auth/tenants > UUID prefix. The prefix is only a last resort — it is
5
+ // indistinguishable for seeded tenants (00000000-…).
6
+ export function tenantLabel(
7
+ tenants: readonly TenantSummary[],
8
+ tenantId: string,
9
+ tenantName: ((tenantId: string) => string) | undefined,
10
+ ): string {
11
+ if (tenantName !== undefined) return tenantName(tenantId);
12
+ const membership = tenants.find((m) => m.tenantId === tenantId);
13
+ // || instead of ??: an empty name/key string must not be used as a label.
14
+ return membership?.name || membership?.key || tenantId.slice(0, 8);
15
+ }
@@ -9,6 +9,7 @@ import {
9
9
  } from "@cosmicdrift/kumiko-renderer/web";
10
10
  import type { ReactNode } from "react";
11
11
  import { useSession } from "./session.tsx";
12
+ import { tenantLabel } from "./tenant-label.ts";
12
13
 
13
14
  export type TenantMenuItemsProps = {
14
15
  readonly tenantName?: (tenantId: string) => string;
@@ -19,12 +20,6 @@ export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode
19
20
  const { user, tenants, activeTenantId, switchTenant } = useSession();
20
21
  if (user === null || tenants.length <= 1) return null;
21
22
 
22
- const nameOf = (tenantId: string): string => {
23
- if (tenantName !== undefined) return tenantName(tenantId);
24
- const membership = tenants.find((m) => m.tenantId === tenantId);
25
- return membership?.name || membership?.key || tenantId.slice(0, 8);
26
- };
27
-
28
23
  return (
29
24
  <>
30
25
  <DropdownMenuLabel>{t("auth.tenant.switcher.label")}</DropdownMenuLabel>
@@ -37,7 +32,7 @@ export function TenantMenuItems({ tenantName }: TenantMenuItemsProps): ReactNode
37
32
  if (membership.tenantId !== activeTenantId) void switchTenant(membership.tenantId);
38
33
  }}
39
34
  >
40
- <span className="truncate">{nameOf(membership.tenantId)}</span>
35
+ <span className="truncate">{tenantLabel(tenants, membership.tenantId, tenantName)}</span>
41
36
  </DropdownMenuCheckboxItem>
42
37
  ))}
43
38
  <DropdownMenuSeparator />
@@ -24,6 +24,7 @@ import {
24
24
  import { Building2, ChevronDown } from "lucide-react";
25
25
  import { type ReactNode, useCallback, useState } from "react";
26
26
  import { useSession } from "./session.tsx";
27
+ import { tenantLabel } from "./tenant-label.ts";
27
28
 
28
29
  export type TenantSwitcherProps = {
29
30
  /** Optional: provides a human-readable name for each tenant ID.
@@ -55,22 +56,14 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
55
56
  [activeTenantId, switchTenant],
56
57
  );
57
58
 
58
- // Label priority: app-provided resolver (prop) > server-provided
59
- // name/key from /auth/tenants > UUID prefix. The latter is only a
60
- // last resort — it is indistinguishable for seeded tenants (00000000-…).
61
- const nameOf = (tenantId: string): string => {
62
- if (tenantName !== undefined) return tenantName(tenantId);
63
- const membership = tenants.find((m) => m.tenantId === tenantId);
64
- // || instead of ??: an empty name/key string must not be used as a label.
65
- return membership?.name || membership?.key || tenantId.slice(0, 8);
66
- };
67
-
68
59
  // Rendering gate: no user → nothing; only one tenant → nothing as well
69
60
  // (single-tenant apps do not need a switcher).
70
61
  if (user === null || tenants.length <= 1) return null;
71
62
 
72
63
  const activeLabel =
73
- activeTenantId !== null ? nameOf(activeTenantId) : t("auth.tenant.switcher.none");
64
+ activeTenantId !== null
65
+ ? tenantLabel(tenants, activeTenantId, tenantName)
66
+ : t("auth.tenant.switcher.none");
74
67
 
75
68
  return (
76
69
  <DropdownMenu>
@@ -105,7 +98,9 @@ export function TenantSwitcher({ tenantName }: TenantSwitcherProps): ReactNode {
105
98
  }}
106
99
  >
107
100
  <div className="flex flex-col items-start min-w-0">
108
- <span className="truncate font-medium">{nameOf(membership.tenantId)}</span>
101
+ <span className="truncate font-medium">
102
+ {tenantLabel(tenants, membership.tenantId, tenantName)}
103
+ </span>
109
104
  {membership.roles.length > 0 && (
110
105
  <span className="text-xs text-muted-foreground truncate">
111
106
  {membership.roles.join(", ")}
@@ -0,0 +1,39 @@
1
+ import { type NavTarget, useNav } from "@cosmicdrift/kumiko-renderer";
2
+ import { useEffect, useRef } from "react";
3
+ import { useSession } from "./session.tsx";
4
+
5
+ export function isAuthedOnLoginPath(authed: boolean, pathname: string, loginPath: string): boolean {
6
+ return authed && pathname === loginPath;
7
+ }
8
+
9
+ export type UseRedirectAuthedFromLoginOptions = {
10
+ readonly loginPath: string;
11
+ readonly target: NavTarget;
12
+ };
13
+
14
+ // The SPA has no screen at the login path: after a login the URL stays there, and marketing
15
+ // links land there directly. replace, not navigate: a push would make Browser-Back from the
16
+ // target land on the login path again, which redirects forward again (history ping-pong).
17
+ export function useRedirectAuthedFromLogin({
18
+ loginPath,
19
+ target,
20
+ }: UseRedirectAuthedFromLoginOptions): void {
21
+ const session = useSession();
22
+ const nav = useNav();
23
+ const authed = session.user !== null;
24
+ // Callers pass an inline object; a ref keeps it out of the effect deps.
25
+ const targetRef = useRef(target);
26
+ targetRef.current = target;
27
+ useEffect(() => {
28
+ // skip: no browser location to inspect (native or server render)
29
+ if (typeof window === "undefined") return;
30
+ const redirectIfStranded = () => {
31
+ if (isAuthedOnLoginPath(authed, window.location.pathname, loginPath))
32
+ nav.replace(targetRef.current);
33
+ };
34
+ redirectIfStranded();
35
+ // Browser-Back to the login path changes neither authed nor nav, so the effect alone would not re-fire.
36
+ window.addEventListener("popstate", redirectIfStranded);
37
+ return () => window.removeEventListener("popstate", redirectIfStranded);
38
+ }, [authed, nav, loginPath]);
39
+ }
@@ -58,33 +58,20 @@ export function createAuthFoundationFeature() {
58
58
  recommended: false,
59
59
  });
60
60
 
61
- r.extendsRegistrar(EXT_TOKEN_VERIFIER, {
62
- onRegister: () => {
63
- // No side-effects at register-time — registry stores the usage,
64
- // resolveTokenVerifier looks it up at request-time.
65
- },
66
- });
61
+ // Registry stores the usage, resolveTokenVerifier looks it up at request-time.
62
+ r.extendsRegistrar(EXT_TOKEN_VERIFIER, {});
67
63
  r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
68
64
 
69
- r.extendsRegistrar(EXT_SESSION_STORE, {
70
- onRegister: () => {
71
- // No side-effects at register-time — resolveSessionStore at boot/request.
72
- },
73
- });
65
+ // resolveSessionStore looks the usage up at boot/request-time.
66
+ r.extendsRegistrar(EXT_SESSION_STORE, {});
74
67
  r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
75
68
 
76
- r.extendsRegistrar(EXT_TENANT_RESOLVER, {
77
- onRegister: () => {
78
- // Optional — zero providers is fine (single-tenant / header-cookie).
79
- },
80
- });
69
+ // Optional — zero providers is fine (single-tenant / header-cookie).
70
+ r.extendsRegistrar(EXT_TENANT_RESOLVER, {});
81
71
  r.bootCheck(({ features }) => validateTenantResolverMultiplicity(features));
82
72
 
83
- r.extendsRegistrar(EXT_TENANT_EXISTENCE, {
84
- onRegister: () => {
85
- // Optional — zero providers skips the existence check.
86
- },
87
- });
73
+ // Optional — zero providers skips the existence check.
74
+ r.extendsRegistrar(EXT_TENANT_EXISTENCE, {});
88
75
  r.bootCheck(({ features }) => validateTenantExistenceMultiplicity(features));
89
76
  },
90
77
  { dedupeOptions: {} },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.405.0",
4
+ "type": "improvement",
5
+ "title": "MFA setup token uses the shared HMAC token codec",
6
+ "detail": "signMfaSetupToken and verifyMfaSetupToken run on createHmacTokenCodec with the domain mfa-setup. Tokens issued before the change still verify, and new tokens are byte-identical for the same input. The challenge and preauth-setup tokens share one payload guard, and signed-token and the inbound-mail OAuth state use the same HMAC signature helper."
7
+ },
2
8
  {
3
9
  "version": "0.391.0",
4
10
  "type": "fix",
@@ -34,7 +34,6 @@ import {
34
34
  createTenantDb,
35
35
  type DbConnection,
36
36
  selectMany,
37
- type TenantDb,
38
37
  } from "@cosmicdrift/kumiko-framework/db";
39
38
  import type { JobContext, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
40
39
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -42,6 +41,7 @@ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
42
41
  import {
43
42
  type ChunkedMigrationStopReason,
44
43
  classifyStoredEnvelope,
44
+ createTenantDbCache,
45
45
  runChunkedMigration,
46
46
  } from "../../shared/index.ts";
47
47
  import { userMfaEntity, userMfaTable } from "../schema/user-mfa.ts";
@@ -105,20 +105,14 @@ export async function mfaReencryptJob(
105
105
  ? Date.now() + payload.maxDurationMs
106
106
  : Number.POSITIVE_INFINITY;
107
107
 
108
- const tdbCache = new Map<TenantId, TenantDb>();
109
- function tdbFor(tenantId: TenantId): TenantDb {
110
- let existing = tdbCache.get(tenantId);
111
- if (!existing) {
112
- existing = createTenantDb({
113
- db,
114
- tenantId,
115
- mode: "system",
116
- reason: "MFA key re-encryption job rewrites enrollments of every tenant",
117
- });
118
- tdbCache.set(tenantId, existing);
119
- }
120
- return existing;
121
- }
108
+ const tdbFor = createTenantDbCache((tenantId) =>
109
+ createTenantDb({
110
+ db,
111
+ tenantId,
112
+ mode: "system",
113
+ reason: "MFA key re-encryption job rewrites enrollments of every tenant",
114
+ }),
115
+ );
122
116
 
123
117
  type UserMfaRow = {
124
118
  id: string;
@@ -1,21 +1,26 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
3
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
-
5
- function hmacSign(input: string, secret: string): string {
6
- return createHmac("sha256", secret).update(input).digest("base64url");
7
- }
4
+ import { hmacSignatureMatches, signHmacSha256Base64Url } from "../shared/index.ts";
8
5
 
9
6
  export type HmacTokenVerifyResult<TPayload> =
10
7
  | { readonly ok: true; readonly payload: TPayload; readonly expiresAtMs: number }
11
8
  | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
12
9
 
10
+ export type UserTenantPayload = { readonly userId: string; readonly tenantId: TenantId };
11
+
12
+ export function isUserTenantPayload(value: unknown): value is UserTenantPayload {
13
+ if (!isPlainObject(value)) return false;
14
+ return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
15
+ }
16
+
13
17
  // `domain` is part of the signed input: it is the only thing that stops a token
14
18
  // minted for one MFA step from verifying as another (e.g. a preauth-setup token
15
19
  // replayed as a challenge token), since both share the same secret and payload shape.
16
- export function createHmacTokenCodec<
17
- TPayload extends { readonly userId: string; readonly tenantId: string },
18
- >(domain: string, isPayload: (value: unknown) => value is TPayload) {
20
+ export function createHmacTokenCodec<TPayload extends { readonly userId: string }>(
21
+ domain: string,
22
+ isPayload: (value: unknown) => value is TPayload,
23
+ ) {
19
24
  type EncodedBody = TPayload & { readonly expiresAtMs: number };
20
25
 
21
26
  function splitEncodedBody(
@@ -38,7 +43,7 @@ export function createHmacTokenCodec<
38
43
  const expiresAt = now.add({ minutes: ttlMinutes });
39
44
  const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
40
45
  const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
41
- const signature = hmacSign(`${domain}:${bodyB64}`, secret);
46
+ const signature = signHmacSha256Base64Url(`${domain}:${bodyB64}`, secret);
42
47
  return { token: `${bodyB64}.${signature}`, expiresAt };
43
48
  },
44
49
 
@@ -52,11 +57,9 @@ export function createHmacTokenCodec<
52
57
  const [bodyB64, providedSig] = parts;
53
58
  if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
54
59
 
55
- const expected = hmacSign(`${domain}:${bodyB64}`, secret);
56
- const expectedBuf = Buffer.from(expected, "base64url");
57
- const providedBuf = Buffer.from(providedSig, "base64url");
58
- if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
59
- if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
60
+ const expected = signHmacSha256Base64Url(`${domain}:${bodyB64}`, secret);
61
+ if (!hmacSignatureMatches(expected, providedSig))
62
+ return { ok: false, reason: "bad_signature" };
60
63
 
61
64
  let parsed: unknown;
62
65
  try {