@cosmicdrift/kumiko-samples 0.404.1 → 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 (93) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +10 -10
  3. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  4. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
  5. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
  6. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  7. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
  8. package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
  9. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
  10. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
  11. package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
  12. package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
  13. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  14. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
  15. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
  16. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
  17. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
  18. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
  19. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
  21. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
  22. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
  23. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
  24. package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
  25. package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
  26. package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
  27. package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
  28. package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
  29. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  30. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
  31. package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
  32. package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
  33. package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
  34. package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
  35. package/packages/bundled-features/src/folders/feature.ts +2 -8
  36. package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
  37. package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
  38. package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
  39. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
  40. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
  41. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
  42. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
  43. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
  44. package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
  45. package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
  46. package/packages/bundled-features/src/ledger/feature.ts +2 -7
  47. package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
  48. package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
  49. package/packages/bundled-features/src/notes-history/feature.ts +7 -8
  50. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
  51. package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
  52. package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
  53. package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
  54. package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
  55. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
  56. package/packages/bundled-features/src/shared/changes.json +6 -0
  57. package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
  58. package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
  59. package/packages/bundled-features/src/shared/index.ts +6 -0
  60. package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
  61. package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
  62. package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
  63. package/packages/bundled-features/src/shared/signed-token.ts +4 -14
  64. package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
  65. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
  66. package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
  67. package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
  68. package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
  69. package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
  70. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
  71. package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
  72. package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
  73. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
  74. package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
  75. package/packages/bundled-features/src/tags/feature.ts +2 -8
  76. package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
  77. package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
  78. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
  79. package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
  80. package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
  81. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
  82. package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
  83. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  84. package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
  85. package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
  86. package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
  87. package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
  88. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
  89. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
  90. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
  91. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
  92. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
  93. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-samples",
3
- "version": "0.404.1",
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.1",
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>",
@@ -167,15 +167,15 @@
167
167
  "@cosmicdrift/kumiko-types": "workspace:*",
168
168
  "@mollie/api-client": "^4.6.0",
169
169
  "@node-rs/argon2": "^2.2.2",
170
- "@tiptap/core": "^3.32.1",
171
- "@tiptap/extension-link": "^3.32.1",
172
- "@tiptap/extension-list": "^3.32.1",
173
- "@tiptap/extension-table": "^3.32.1",
174
- "@tiptap/html": "^3.32.1",
175
- "@tiptap/extensions": "^3.32.1",
176
- "@tiptap/pm": "^3.32.1",
177
- "@tiptap/react": "^3.32.1",
178
- "@tiptap/starter-kit": "^3.32.1",
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
179
  "@types/mailparser": "^3.9.0",
180
180
  "@types/qrcode": "^1.5.6",
181
181
  "clsx": "^2.1.1",
@@ -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;
@@ -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 {
@@ -1,23 +1,21 @@
1
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
- import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
3
+ import {
4
+ createHmacTokenCodec,
5
+ type HmacTokenVerifyResult,
6
+ isUserTenantPayload,
7
+ } from "./hmac-token-codec.ts";
5
8
 
6
9
  export type MfaChallengePayload = {
7
10
  readonly userId: string;
8
11
  readonly tenantId: TenantId;
9
12
  };
10
13
 
11
- function isPayload(value: unknown): value is MfaChallengePayload {
12
- if (!isPlainObject(value)) return false;
13
- return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
14
- }
15
-
16
14
  // Domain-separated from mfa-preauth-setup (see hmac-token-codec). Single-use is
17
15
  // enforced by the consumer via burnToken (purpose "mfa-challenge"); this codec
18
16
  // only signs and verifies. Brute-forcing the code behind a valid token is
19
17
  // limited separately in mfa-verify-attempts.ts.
20
- const codec = createHmacTokenCodec("mfa-challenge", isPayload);
18
+ const codec = createHmacTokenCodec("mfa-challenge", isUserTenantPayload);
21
19
 
22
20
  export function signMfaChallengeToken(
23
21
  payload: MfaChallengePayload,
@@ -1,22 +1,20 @@
1
1
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
3
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
- import { createHmacTokenCodec, type HmacTokenVerifyResult } from "./hmac-token-codec.ts";
3
+ import {
4
+ createHmacTokenCodec,
5
+ type HmacTokenVerifyResult,
6
+ isUserTenantPayload,
7
+ } from "./hmac-token-codec.ts";
5
8
 
6
9
  export type MfaPreauthSetupPayload = {
7
10
  readonly userId: string;
8
11
  readonly tenantId: TenantId;
9
12
  };
10
13
 
11
- function isPayload(value: unknown): value is MfaPreauthSetupPayload {
12
- if (!isPlainObject(value)) return false;
13
- return typeof value["userId"] === "string" && typeof value["tenantId"] === "string";
14
- }
15
-
16
14
  // Domain "mfa-preauth-setup" must never equal the challenge domain: a setup
17
15
  // token has to fail verification as a challenge token and vice versa. The
18
16
  // challenge token secret is reused deliberately; the domain carries the separation.
19
- const codec = createHmacTokenCodec("mfa-preauth-setup", isPayload);
17
+ const codec = createHmacTokenCodec("mfa-preauth-setup", isUserTenantPayload);
20
18
 
21
19
  export function signMfaPreauthSetupToken(
22
20
  payload: MfaPreauthSetupPayload,