@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +25 -25
- package/packages/bundled-features/src/account-security/README.md +7 -5
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -0
- package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +9 -1
- package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +10 -41
- package/packages/bundled-features/src/auth-email-password/web/tenant-label.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +2 -7
- package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +7 -12
- package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts +39 -0
- package/packages/bundled-features/src/auth-foundation/feature.ts +8 -21
- package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
- package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +17 -14
- package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +6 -8
- package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +16 -57
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +3 -13
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -5
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -8
- package/packages/bundled-features/src/billing-foundation/index.ts +8 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +19 -33
- package/packages/bundled-features/src/billing-foundation/provider-event-seen.ts +14 -0
- package/packages/bundled-features/src/billing-foundation/provider-shared.ts +61 -0
- package/packages/bundled-features/src/billing-foundation/tenant-destroy-hook.ts +8 -4
- package/packages/bundled-features/src/channel-email/feature.ts +1 -1
- package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +9 -15
- package/packages/bundled-features/src/delivery/delivery-service.ts +2 -10
- package/packages/bundled-features/src/documents/changes.json +8 -0
- package/packages/bundled-features/src/file-derivatives/feature.ts +9 -21
- package/packages/bundled-features/src/file-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +1 -6
- package/packages/bundled-features/src/folders/feature.ts +2 -8
- package/packages/bundled-features/src/folders/web/folder-manager.tsx +7 -32
- package/packages/bundled-features/src/folders/web/folder-section.tsx +5 -25
- package/packages/bundled-features/src/form-draft/handlers/save.write.ts +6 -16
- package/packages/bundled-features/src/host-routing/README.md +10 -10
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +2 -6
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +5 -13
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +9 -19
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -15
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +1 -14
- package/packages/bundled-features/src/jobs/job-run-logger.ts +37 -40
- package/packages/bundled-features/src/jobs/parse-subject.ts +15 -0
- package/packages/bundled-features/src/ledger/feature.ts +2 -7
- package/packages/bundled-features/src/legal-pages/README.md +21 -13
- package/packages/bundled-features/src/mail-foundation/feature.ts +3 -7
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +4 -12
- package/packages/bundled-features/src/notes-history/feature.ts +7 -8
- package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +1 -9
- package/packages/bundled-features/src/personal-access-tokens/hash.ts +2 -2
- package/packages/bundled-features/src/personal-access-tokens/parse-scope-names.ts +9 -0
- package/packages/bundled-features/src/personal-access-tokens/resolver.ts +1 -9
- package/packages/bundled-features/src/renderer-foundation/README.md +6 -4
- package/packages/bundled-features/src/renderer-foundation/feature.ts +3 -16
- package/packages/bundled-features/src/renderer-pdf/README.md +5 -1
- package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -19
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/collect-extension-options.ts +14 -0
- package/packages/bundled-features/src/shared/hmac-signature.ts +15 -0
- package/packages/bundled-features/src/shared/index.ts +6 -0
- package/packages/bundled-features/src/shared/is-unique-constraint-failure.ts +11 -0
- package/packages/bundled-features/src/shared/mail-locale-templates.ts +64 -0
- package/packages/bundled-features/src/shared/resolve-feature-access.ts +16 -0
- package/packages/bundled-features/src/shared/signed-token.ts +4 -14
- package/packages/bundled-features/src/shared/single-use-token-store.ts +2 -2
- package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +5 -8
- package/packages/bundled-features/src/shared/tenant-db-cache.ts +19 -0
- package/packages/bundled-features/src/shared/web/status-banner.tsx +20 -0
- package/packages/bundled-features/src/shared/web/use-dispatched-writes.ts +31 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +5 -17
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +7 -29
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +6 -17
- package/packages/bundled-features/src/subscription-stripe/feature.ts +5 -19
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -20
- package/packages/bundled-features/src/subscription-stripe/runtime.ts +3 -19
- package/packages/bundled-features/src/tags/feature.ts +2 -8
- package/packages/bundled-features/src/tags/web/tag-manager.tsx +5 -25
- package/packages/bundled-features/src/tags/web/tag-section.tsx +3 -18
- package/packages/bundled-features/src/template-resolver/README.md +29 -29
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -2
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +3 -10
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +1 -0
- package/packages/bundled-features/src/tenant/handlers/decrypt-member-users.ts +30 -0
- package/packages/bundled-features/src/tenant/handlers/members.query.ts +3 -23
- package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +8 -20
- package/packages/bundled-features/src/tenant-lifecycle/archive-and-delete-tenant-rows.ts +28 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/index.ts +5 -0
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +1 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +1 -1
- package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +36 -36
- package/packages/bundled-features/src/user-data-rights/README.md +33 -33
- package/packages/bundled-features/src/user-data-rights/build-hook-db.ts +40 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-export.write.ts +2 -19
- package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +11 -47
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -35
- package/packages/bundled-features/src/user-data-rights/run-user-export.ts +2 -31
- package/packages/bundled-features/src/user-data-rights/token-helpers.ts +3 -15
- package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +1 -18
- package/packages/bundled-features/src/user-data-rights/web/public-deletion-gate.tsx +1 -0
- package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +1 -18
- package/samples/apps/admin-console/README.md +5 -5
- package/samples/apps/admin-console/package.json +3 -3
- package/samples/apps/cap-billing-demo/README.md +15 -15
- package/samples/apps/config-demo/README.md +7 -7
- package/samples/apps/config-demo/package.json +2 -2
- package/samples/apps/marketing-demo/README.md +2 -2
- package/samples/apps/marketing-demo/package.json +2 -2
- package/samples/apps/showcase/package.json +3 -3
- package/samples/apps/styleguide/package.json +2 -2
- package/samples/apps/ui-walkthrough/README.md +8 -9
- package/samples/apps/ui-walkthrough/package.json +2 -2
- package/samples/apps/use-all-bundled/README.md +2 -2
- package/samples/apps/use-all-bundled/package.json +2 -2
- package/samples/apps/use-all-bundled/scripts/gen-sample-index.ts +18 -1
- package/samples/apps/user-data-rights-demo/README.md +12 -12
- package/samples/apps/workspaces/package.json +2 -2
- package/samples/recipes/apex-landing/README.md +3 -3
- package/samples/recipes/apex-landing/package.json +1 -1
- package/samples/recipes/apex-surface-auth/README.md +13 -9
- package/samples/recipes/auth-mfa/README.md +2 -4
- package/samples/recipes/auth-mfa/package.json +2 -2
- package/samples/recipes/boot-check/README.md +2 -2
- package/samples/recipes/compliance-profiles-demo/README.md +5 -5
- package/samples/recipes/compose-stacks/README.md +8 -8
- package/samples/recipes/cross-feature-events/README.md +1 -0
- package/samples/recipes/crypto-shredding-hr/README.md +3 -3
- package/samples/recipes/custom-fields-basic/README.md +1 -1
- package/samples/recipes/documents/README.md +25 -0
- package/samples/recipes/embedded-entity-form/package.json +2 -2
- package/samples/recipes/encrypted-tenant-config/README.md +8 -8
- package/samples/recipes/env-composition/README.md +10 -10
- package/samples/recipes/error-contract/README.md +9 -9
- package/samples/recipes/event-sourcing/README.md +9 -9
- package/samples/recipes/ledger-invoicing/README.md +6 -6
- package/samples/recipes/legal-pages/README.md +16 -16
- package/samples/recipes/managed-config/README.md +15 -11
- package/samples/recipes/managed-pages/README.md +7 -7
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pipeline-basics/README.md +13 -13
- package/samples/recipes/public-share-token/README.md +4 -4
- package/samples/recipes/record-detail-layout/package.json +3 -3
- package/samples/recipes/security-baseline/README.md +4 -1
- package/samples/recipes/session-revocation/README.md +1 -1
- package/samples/recipes/tenant-settings/README.md +4 -4
- package/samples/recipes/tier-admin/README.md +2 -2
- package/samples/recipes/user-data-rights/README.md +21 -19
- package/samples/recipes/wizard-form/package.json +3 -3
- package/samples/recipes/writeform-section/package.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.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.
|
|
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.
|
|
169
|
-
"@node-rs/argon2": "^2.
|
|
170
|
-
"@tiptap/core": "^3.
|
|
171
|
-
"@tiptap/extension-link": "^3.
|
|
172
|
-
"@tiptap/extension-list": "^3.
|
|
173
|
-
"@tiptap/extension-table": "^3.
|
|
174
|
-
"@tiptap/html": "^3.
|
|
175
|
-
"@tiptap/extensions": "^3.
|
|
176
|
-
"@tiptap/pm": "^3.
|
|
177
|
-
"@tiptap/react": "^3.
|
|
178
|
-
"@tiptap/starter-kit": "^3.
|
|
179
|
-
"@types/mailparser": "^3.
|
|
180
|
-
"@types/qrcode": "^1.5.
|
|
168
|
+
"@mollie/api-client": "^4.6.0",
|
|
169
|
+
"@node-rs/argon2": "^2.2.2",
|
|
170
|
+
"@tiptap/core": "^3.32.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.
|
|
186
|
-
"jose": "^6.2.
|
|
187
|
-
"lucide-react": "^1.
|
|
188
|
-
"mailparser": "^3.
|
|
189
|
-
"marked": "^18.
|
|
190
|
-
"nodemailer": "^10.
|
|
185
|
+
"ioredis": "^5.11.1",
|
|
186
|
+
"jose": "^6.2.14",
|
|
187
|
+
"lucide-react": "^1.65.0",
|
|
188
|
+
"mailparser": "^3.14.1",
|
|
189
|
+
"marked": "^18.1.2",
|
|
190
|
+
"nodemailer": "^10.3.1",
|
|
191
191
|
"pdf-lib": "^1.17.1",
|
|
192
192
|
"qrcode": "^1.5.4",
|
|
193
|
-
"react": "^19.
|
|
194
|
-
"react-dom": "^19.
|
|
193
|
+
"react": "^19.3.1",
|
|
194
|
+
"react-dom": "^19.3.1",
|
|
195
195
|
"sharp": "^0.35.5",
|
|
196
196
|
"stripe": "^22.6.2",
|
|
197
|
-
"tailwind-merge": "^3.
|
|
197
|
+
"tailwind-merge": "^3.7.0",
|
|
198
198
|
"temporal-polyfill": "^0.3.2",
|
|
199
|
-
"uuid": "^14.0.
|
|
199
|
+
"uuid": "^14.0.3",
|
|
200
200
|
"zod": "^4.6.7"
|
|
201
201
|
},
|
|
202
202
|
"publishConfig": {
|
|
@@ -764,7 +764,7 @@
|
|
|
764
764
|
"@cosmicdrift/kumiko-locale-it": "workspace:*",
|
|
765
765
|
"@cosmicdrift/kumiko-renderer-mail-html": "workspace:*",
|
|
766
766
|
"jsqr": "^1.4.0",
|
|
767
|
-
"unpdf": "^1.
|
|
767
|
+
"unpdf": "^1.9.1",
|
|
768
768
|
"@cosmicdrift/kumiko-testing": "workspace:*"
|
|
769
769
|
},
|
|
770
770
|
"peerDependencies": {
|
|
@@ -15,17 +15,19 @@ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sess
|
|
|
15
15
|
|
|
16
16
|
const features = [
|
|
17
17
|
createSessionsFeature(),
|
|
18
|
-
createAuthMfaFeature({
|
|
18
|
+
createAuthMfaFeature({
|
|
19
|
+
/* secrets, issuer */
|
|
20
|
+
}),
|
|
19
21
|
createAccountSecurityFeature(),
|
|
20
22
|
];
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
## Options
|
|
24
26
|
|
|
25
|
-
| Option
|
|
26
|
-
|
|
|
27
|
-
| `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }`
|
|
28
|
-
| `nav`
|
|
27
|
+
| Option | Default | Effect |
|
|
28
|
+
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
+
| `access` | `openToAll` | Who may open the dashboard, e.g. `{ roles: ["Admin", "Editor"] }` |
|
|
30
|
+
| `nav` | none | `{ order?, parent?, icon? }` (icon default `"user"`) adds a sidebar entry. Without it the screen is dormant and apps link it from the user menu |
|
|
29
31
|
|
|
30
32
|
The MFA panels switch on `auth-mfa:query:status` (`enabled`), exported as
|
|
31
33
|
`accountSecurityMfaStatusVisibility`.
|
|
@@ -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
|
|
66
|
-
ALL_AUTH_MAIL_KINDS
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
export
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
133
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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">{
|
|
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
|
|
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">
|
|
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(", ")}
|
package/packages/bundled-features/src/auth-email-password/web/use-redirect-authed-from-login.ts
ADDED
|
@@ -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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 {
|
|
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
|
-
|
|
18
|
-
|
|
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 =
|
|
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 =
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|