@cosmicdrift/kumiko-bundled-features 1.0.0 → 2.0.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 +41 -7
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +200 -0
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +147 -0
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +43 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +35 -0
- package/src/admin-shell/constants.ts +11 -0
- package/src/admin-shell/feature.ts +135 -0
- package/src/admin-shell/i18n.ts +36 -0
- package/src/admin-shell/index.ts +17 -0
- package/src/admin-shell/overview-allowlist.ts +37 -0
- package/src/admin-shell/web/client-plugin.tsx +26 -0
- package/src/admin-shell/web/i18n.ts +16 -0
- package/src/admin-shell/web/index.ts +2 -0
- package/src/admin-shell/web/overview-layout.tsx +69 -0
- package/src/admin-shell/web/overview-query.ts +15 -0
- package/src/admin-shell/web/platform-overview-screen.tsx +90 -0
- package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
- package/src/agent-tools/index.ts +9 -0
- package/src/agent-tools/tool-catalog.ts +130 -0
- package/src/agent-tools/tool-dispatch.ts +82 -0
- package/src/agent-tools/types.ts +38 -0
- package/src/audit/__tests__/audit-screens.boot.test.ts +51 -0
- package/src/audit/__tests__/audit-security.integration.test.ts +201 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -25
- package/src/audit/constants.ts +9 -2
- package/src/audit/feature.ts +33 -1
- package/src/audit/handlers/details.query.ts +47 -0
- package/src/audit/handlers/list.query.ts +3 -2
- package/src/audit/i18n.ts +31 -0
- package/src/audit/web/audit-log-detail-screen.tsx +131 -0
- package/src/audit/web/audit-log-screen.tsx +250 -0
- package/src/audit/web/client-plugin.tsx +22 -0
- package/src/audit/web/i18n.ts +12 -0
- package/src/audit/web/index.ts +5 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +4 -4
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +4 -4
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +10 -11
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +33 -6
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +9 -6
- package/src/auth-email-password/__tests__/feature-options.test.ts +33 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +35 -1
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +19 -6
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +264 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +20 -4
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +91 -0
- package/src/auth-email-password/constants.ts +25 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +59 -11
- package/src/auth-email-password/handlers/change-password.write.ts +14 -13
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +34 -105
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +12 -5
- package/src/auth-email-password/handlers/invite-accept.write.ts +9 -2
- package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +12 -4
- package/src/auth-email-password/handlers/login.write.ts +86 -16
- package/src/auth-email-password/handlers/logout.write.ts +4 -2
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +3 -2
- package/src/auth-email-password/handlers/self-registration-status.query.ts +23 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +19 -11
- package/src/auth-email-password/handlers/signup-request.write.ts +22 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +4 -2
- package/src/auth-email-password/i18n.ts +52 -0
- package/src/auth-email-password/index.ts +13 -2
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/seeding.ts +55 -27
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +542 -0
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +44 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +194 -6
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +64 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +129 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +149 -5
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +137 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +134 -0
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +93 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +29 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +96 -18
- package/src/auth-email-password/web/auth-form-logic.ts +30 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +22 -20
- package/src/auth-email-password/web/auth-gate.tsx +140 -19
- package/src/auth-email-password/web/client-plugin.ts +24 -2
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +6 -6
- package/src/auth-email-password/web/index.ts +16 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +5 -5
- package/src/auth-email-password/web/login-screen.tsx +78 -22
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +15 -18
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-email-password/web/signup-complete-screen.tsx +13 -15
- package/src/auth-email-password/web/signup-screen.tsx +8 -10
- package/src/auth-email-password/web/use-shell-user.ts +5 -2
- package/src/auth-email-password/web/user-menu.tsx +3 -1
- package/src/auth-email-password/web/verify-email-screen.tsx +10 -14
- package/src/auth-foundation/__tests__/feature.test.ts +46 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +77 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +160 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +150 -0
- package/src/auth-foundation/boot-checks.ts +150 -0
- package/src/auth-foundation/feature.ts +157 -0
- package/src/auth-foundation/index.ts +37 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +62 -0
- package/src/auth-foundation/types.ts +170 -0
- package/src/auth-mfa/__tests__/base32.test.ts +28 -0
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +265 -0
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +321 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +322 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +237 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +39 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +113 -0
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +139 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +247 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +169 -0
- package/src/auth-mfa/__tests__/totp.test.ts +131 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +319 -0
- package/src/auth-mfa/base32.ts +51 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +45 -0
- package/src/auth-mfa/db/queries.ts +45 -0
- package/src/auth-mfa/errors.ts +66 -0
- package/src/auth-mfa/feature.ts +170 -0
- package/src/auth-mfa/handlers/disable.write.ts +67 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +200 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +106 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +234 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +77 -0
- package/src/auth-mfa/handlers/status.query.ts +23 -0
- package/src/auth-mfa/handlers/verify.write.ts +179 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +19 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-setup-token.ts +96 -0
- package/src/auth-mfa/mfa-status-checker.ts +84 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/otpauth-uri.ts +21 -0
- package/src/auth-mfa/recovery-codes.ts +54 -0
- package/src/auth-mfa/schema/user-mfa.ts +60 -0
- package/src/auth-mfa/testing.ts +5 -0
- package/src/auth-mfa/totp.ts +67 -0
- package/src/auth-mfa/verify-factor.ts +58 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +265 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +182 -0
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +379 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +104 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +136 -0
- package/src/auth-mfa/web/index.ts +27 -0
- package/src/auth-mfa/web/mfa-client.ts +184 -0
- package/src/auth-mfa/web/mfa-disable-dialog.tsx +69 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +228 -0
- package/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +53 -0
- package/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +68 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +267 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +117 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +12 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +134 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +93 -0
- package/src/billing-foundation/entities.ts +28 -4
- package/src/billing-foundation/events.ts +6 -2
- package/src/billing-foundation/feature.ts +9 -1
- package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
- package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
- package/src/billing-foundation/handlers/process-event.write.ts +39 -3
- package/src/billing-foundation/tenant-destroy-hook.ts +46 -0
- package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
- package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
- package/src/cap-counter/__tests__/entity-ddl.test.ts +15 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +8 -5
- package/src/cap-counter/constants.ts +2 -0
- package/src/cap-counter/enforce-cap.ts +15 -18
- package/src/cap-counter/entity.ts +3 -3
- package/src/cap-counter/feature.ts +28 -1
- package/src/cap-counter/i18n.ts +12 -0
- package/src/cap-counter/with-cap-enforcement.ts +2 -2
- package/src/channel-email/__tests__/pii-guard.test.ts +56 -0
- package/src/channel-email/email-channel.ts +2 -1
- package/src/channel-email/index.ts +1 -0
- package/src/channel-email/pii-guard.ts +39 -0
- package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
- package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
- package/src/compliance-profiles/constants.ts +15 -0
- package/src/compliance-profiles/feature.ts +22 -1
- package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
- package/src/compliance-profiles/i18n.ts +6 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/web/client-plugin.tsx +22 -0
- package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
- package/src/compliance-profiles/web/i18n.ts +25 -0
- package/src/compliance-profiles/web/index.ts +6 -0
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +22 -0
- package/src/config/__tests__/cascade.integration.test.ts +35 -0
- package/src/config/__tests__/config.integration.test.ts +71 -10
- package/src/config/__tests__/deserialize-value.test.ts +5 -8
- package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
- package/src/config/__tests__/pii-encrypted.integration.test.ts +179 -0
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +234 -0
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +243 -0
- package/src/config/__tests__/write-helpers.test.ts +23 -0
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/feature.ts +15 -8
- package/src/config/handlers/cascade.query.ts +2 -1
- package/src/config/handlers/reencrypt.job.ts +209 -0
- package/src/config/handlers/set.write.ts +55 -3
- package/src/config/handlers/values.query.ts +2 -1
- package/src/config/i18n.ts +9 -0
- package/src/config/index.ts +5 -4
- package/src/config/resolver.ts +75 -61
- package/src/config/table.ts +1 -1
- package/src/config/write-helpers.ts +22 -0
- package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +148 -0
- package/src/crypto-shredding/constants.ts +5 -0
- package/src/crypto-shredding/feature.ts +18 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +91 -0
- package/src/crypto-shredding/index.ts +11 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +92 -53
- package/src/custom-fields/__tests__/field-access.integration.test.ts +25 -0
- package/src/custom-fields/__tests__/parse-serialized-field.test.ts +11 -3
- package/src/custom-fields/__tests__/retention.integration.test.ts +40 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +26 -79
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/db/queries/user-data-rights.ts +0 -20
- package/src/custom-fields/entity.ts +1 -1
- package/src/custom-fields/events.ts +4 -3
- package/src/custom-fields/feature.ts +8 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/handlers/set-custom-field.write.ts +3 -44
- package/src/custom-fields/handlers/update-tenant-field.write.ts +0 -17
- package/src/custom-fields/lib/field-access.ts +8 -1
- package/src/custom-fields/lib/parse-serialized-field.ts +12 -4
- package/src/custom-fields/lib/value-schema.ts +1 -1
- package/src/custom-fields/run-retention.ts +9 -1
- package/src/custom-fields/schemas.ts +9 -9
- package/src/custom-fields/wire-for-entity.ts +110 -78
- package/src/custom-fields/wire-user-data-rights.ts +5 -54
- package/src/data-retention/__tests__/parse-override.test.ts +14 -13
- package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +145 -8
- package/src/data-retention/feature.ts +3 -3
- package/src/data-retention/run-retention-cleanup.ts +102 -19
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +171 -0
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
- package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
- package/src/delivery/constants.ts +13 -51
- package/src/delivery/feature.ts +29 -5
- package/src/delivery/handlers/log.query.ts +28 -7
- package/src/delivery/i18n.ts +6 -0
- package/src/delivery/index.ts +6 -1
- package/src/delivery/public-names.ts +36 -0
- package/src/delivery/tables.ts +3 -3
- package/src/delivery/web/client-plugin.tsx +20 -0
- package/src/delivery/web/delivery-log-screen.tsx +103 -0
- package/src/delivery/web/i18n.ts +25 -0
- package/src/delivery/web/index.ts +3 -0
- package/src/document-ingest-foundation/__tests__/feature.integration.test.ts +147 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +105 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +43 -0
- package/src/document-ingest-foundation/events.ts +25 -0
- package/src/document-ingest-foundation/feature.ts +126 -0
- package/src/document-ingest-foundation/index.ts +12 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +304 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +7 -6
- package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
- package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
- package/src/feature-toggles/compose-tier-resolver.ts +134 -0
- package/src/feature-toggles/constants.ts +14 -0
- package/src/feature-toggles/feature.ts +23 -3
- package/src/feature-toggles/global-feature-state-table.ts +20 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +1 -1
- package/src/feature-toggles/i18n.ts +6 -0
- package/src/feature-toggles/index.ts +8 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- package/src/feature-toggles/web/client-plugin.tsx +22 -0
- package/src/feature-toggles/web/i18n.ts +29 -0
- package/src/feature-toggles/web/index.ts +6 -0
- package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
- package/src/file-foundation/__tests__/feature.test.ts +5 -5
- package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
- package/src/file-foundation/feature.ts +10 -11
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -2
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/folders/__tests__/folders.integration.test.ts +3 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +43 -2
- package/src/folders/web/__tests__/tree.test.ts +8 -0
- package/src/folders/web/folder-manager.tsx +2 -3
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders/web/tree.ts +13 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
- package/src/folders-user-data/index.ts +4 -4
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +307 -0
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +169 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +644 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +111 -0
- package/src/inbound-mail-foundation/__tests__/retention.integration.test.ts +237 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +433 -0
- package/src/inbound-mail-foundation/aggregate-id.ts +40 -0
- package/src/inbound-mail-foundation/connect-routes.ts +230 -0
- package/src/inbound-mail-foundation/constants.ts +80 -0
- package/src/inbound-mail-foundation/db/queries/inbound-projections.ts +38 -0
- package/src/inbound-mail-foundation/entities.ts +172 -0
- package/src/inbound-mail-foundation/events.ts +147 -0
- package/src/inbound-mail-foundation/feature.ts +243 -0
- package/src/inbound-mail-foundation/handlers/account-state.ts +23 -0
- package/src/inbound-mail-foundation/handlers/connect-account.write.ts +112 -0
- package/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +69 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +333 -0
- package/src/inbound-mail-foundation/handlers/list-accounts.query.ts +44 -0
- package/src/inbound-mail-foundation/handlers/list-messages.query.ts +58 -0
- package/src/inbound-mail-foundation/handlers/scope-visibility.ts +14 -0
- package/src/inbound-mail-foundation/handlers/update-account.write.ts +78 -0
- package/src/inbound-mail-foundation/index.ts +97 -0
- package/src/inbound-mail-foundation/oauth-state.ts +103 -0
- package/src/inbound-mail-foundation/projection.ts +152 -0
- package/src/inbound-mail-foundation/provider-factory.ts +53 -0
- package/src/inbound-mail-foundation/retention-sweep.ts +168 -0
- package/src/inbound-mail-foundation/tenant-destroy-hook.ts +93 -0
- package/src/inbound-mail-foundation/types.ts +250 -0
- package/src/inbound-mail-foundation/watch-supervisor.ts +482 -0
- package/src/inbound-provider-imap/__tests__/feature.test.ts +115 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +302 -0
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +206 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +358 -0
- package/src/inbound-provider-imap/credential-document.ts +51 -0
- package/src/inbound-provider-imap/feature.ts +283 -0
- package/src/inbound-provider-imap/imap-client.ts +158 -0
- package/src/inbound-provider-imap/index.ts +17 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +52 -0
- package/src/inbound-provider-inmemory/feature.ts +154 -0
- package/src/inbound-provider-inmemory/index.ts +12 -0
- package/src/jobs/__tests__/entity-ddl.test.ts +15 -0
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
- package/src/jobs/__tests__/jobs-events.integration.test.ts +37 -1
- package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +120 -0
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
- package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +169 -0
- package/src/jobs/constants.ts +8 -1
- package/src/jobs/feature.ts +52 -8
- package/src/jobs/handlers/detail.query.ts +6 -0
- package/src/jobs/handlers/list.query.ts +8 -1
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/jobs/handlers/reindex-entity.job.ts +49 -0
- package/src/jobs/i18n.ts +37 -0
- package/src/jobs/job-instant.ts +9 -0
- package/src/jobs/job-run-table.ts +4 -4
- package/src/jobs/web/client-plugin.tsx +22 -0
- package/src/jobs/web/i18n.ts +12 -0
- package/src/jobs/web/index.ts +4 -0
- package/src/jobs/web/job-run-detail-screen.tsx +180 -0
- package/src/jobs/web/job-runs-screen.tsx +146 -0
- package/src/ledger/__tests__/ledger.integration.test.ts +8 -3
- package/src/ledger/handlers/confirm-schedule-period.write.ts +17 -4
- package/src/ledger/recurring.ts +17 -8
- package/src/ledger/web/index.ts +3 -2
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +55 -0
- package/src/legal-pages/feature.ts +24 -31
- package/src/legal-pages/index.ts +1 -0
- package/src/mail-foundation/__tests__/feature.test.ts +5 -6
- package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +58 -0
- package/src/mail-foundation/feature.ts +26 -24
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +23 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +65 -0
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/managed-pages/__tests__/i18n-surface.boot.test.ts +30 -0
- package/src/managed-pages/__tests__/managed-pages.integration.test.ts +137 -0
- package/src/managed-pages/feature.ts +45 -47
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/managed-pages/handlers/by-tenant-published.query.ts +52 -0
- package/src/managed-pages/i18n.ts +66 -0
- package/src/managed-pages/screens/page-screens.ts +5 -1
- package/src/managed-pages/web/client-plugin.tsx +22 -0
- package/src/managed-pages/web/i18n.ts +24 -0
- package/src/managed-pages/web/index.ts +3 -0
- package/src/page-render/__tests__/layout.test.ts +63 -0
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/branding.ts +4 -3
- package/src/page-render/index.ts +1 -1
- package/src/page-render/layout.ts +18 -3
- package/src/page-render/markdown.ts +1 -9
- package/src/page-render/web.ts +13 -0
- package/src/personal-access-tokens/__tests__/feature.test.ts +42 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +239 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +105 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
- package/src/personal-access-tokens/handlers/create.write.ts +47 -0
- package/src/personal-access-tokens/handlers/list.query.ts +50 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/i18n.ts +5 -0
- package/src/personal-access-tokens/index.ts +31 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +63 -0
- package/src/personal-access-tokens/scopes.ts +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +265 -0
- package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
- package/src/renderer-simple/__tests__/resolve-variables.test.ts +37 -0
- package/src/renderer-simple/resolve-variables.ts +3 -1
- package/src/secrets/__tests__/rotate.integration.test.ts +115 -18
- package/src/secrets/handlers/rotate.job.ts +77 -98
- package/src/secrets/secrets-context.ts +7 -38
- package/src/secrets/table.ts +7 -8
- package/src/seo/__tests__/llms-txt.test.ts +38 -0
- package/src/seo/__tests__/robots-txt.test.ts +18 -0
- package/src/seo/__tests__/schema-builders.test.ts +57 -0
- package/src/seo/__tests__/seo.integration.test.ts +350 -0
- package/src/seo/__tests__/sitemap.test.ts +38 -0
- package/src/seo/constants.ts +60 -0
- package/src/seo/feature.ts +316 -0
- package/src/seo/handlers/seo-config.query.ts +35 -0
- package/src/seo/index.ts +19 -0
- package/src/seo/llms-txt.ts +33 -0
- package/src/seo/robots-txt.ts +13 -0
- package/src/seo/schema-builders.ts +57 -0
- package/src/seo/sitemap.ts +33 -0
- package/src/sessions/__tests__/auto-revoke-binding.test.ts +71 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +11 -7
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +18 -7
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +55 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +272 -20
- package/src/sessions/__tests__/test-helpers.ts +16 -3
- package/src/sessions/constants.ts +5 -0
- package/src/sessions/db/queries/cleanup.ts +2 -2
- package/src/sessions/feature.ts +175 -26
- package/src/sessions/handlers/detail.query.ts +36 -0
- package/src/sessions/handlers/list.query.ts +14 -9
- package/src/sessions/handlers/mine.query.ts +13 -8
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/i18n.ts +23 -0
- package/src/sessions/index.ts +9 -2
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +99 -14
- package/src/sessions/session-checker-fail-open.test.ts +77 -0
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/chunked-entity-migration.ts +89 -0
- package/src/shared/decrypt-stored-pii.ts +26 -0
- package/src/shared/encrypt-for-direct-write.ts +23 -0
- package/src/shared/entities-of.ts +17 -0
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/{auth-email-password/__tests__ → shared}/identity-v3-hash.test.ts +2 -2
- package/src/shared/index.ts +16 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/shared/password-hashing.test.ts +35 -0
- package/src/{auth-email-password → shared}/password-hashing.ts +11 -0
- package/src/shared/system-query.ts +5 -0
- package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
- package/src/subscription-mollie/__tests__/feature.test.ts +97 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +127 -1
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +15 -5
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/feature.ts +2 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tags/__tests__/schemas.test.ts +34 -0
- package/src/tags/__tests__/tags.integration.test.ts +12 -1
- package/src/tags/entity.ts +6 -1
- package/src/tags/feature.ts +2 -0
- package/src/tags/i18n.ts +5 -0
- package/src/tags/schemas.ts +9 -2
- package/src/tags/web/__tests__/tag-manager.test.tsx +333 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +47 -1
- package/src/tags/web/tag-manager.tsx +1 -1
- package/src/tags/web/tag-section.tsx +6 -3
- package/src/template-resolver/testing.ts +2 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/__tests__/members-screens.boot.test.ts +51 -0
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/__tests__/tenant-security.integration.test.ts +310 -0
- package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
- package/src/tenant/constants.ts +5 -0
- package/src/tenant/feature.ts +19 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
- package/src/tenant/handlers/invitations.query.ts +21 -4
- package/src/tenant/handlers/members.query.ts +41 -6
- package/src/tenant/i18n.ts +28 -0
- package/src/tenant/index.ts +7 -2
- package/src/tenant/invitation-table.ts +3 -2
- package/src/tenant/schema/tenant.ts +27 -2
- package/src/tenant/screens.ts +3 -2
- package/src/tenant/seeding.ts +136 -3
- package/src/tenant/web/client-plugin.tsx +20 -0
- package/src/tenant/web/i18n.ts +43 -0
- package/src/tenant/web/index.ts +4 -0
- package/src/tenant/web/members-screen.tsx +270 -0
- package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +471 -0
- package/src/tenant-lifecycle/boot-checks.ts +45 -0
- package/src/tenant-lifecycle/constants.ts +56 -0
- package/src/tenant-lifecycle/events.ts +46 -0
- package/src/tenant-lifecycle/feature.ts +112 -0
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +79 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
- package/src/tenant-lifecycle/index.ts +7 -0
- package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
- package/src/tenant-lifecycle/lifecycle-gate.ts +65 -0
- package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
- package/src/tenant-lifecycle/stages.ts +206 -0
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/text-content/web/__tests__/editor-read-only.test.tsx +33 -6
- package/src/text-content/web/__tests__/group-blocks.test.ts +28 -3
- package/src/text-content/web/client-plugin.tsx +126 -104
- package/src/tier-engine/__tests__/auto-default-tier.integration.test.ts +1 -1
- package/src/tier-engine/__tests__/resolver.integration.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
- package/src/tier-engine/__tests__/trial-config-validation.test.ts +30 -0
- package/src/tier-engine/feature.ts +13 -6
- package/src/tier-engine/i18n.ts +8 -0
- package/src/tier-engine/web/tier-admin-screen.tsx +26 -19
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/__tests__/user.integration.test.ts +26 -0
- package/src/user/constants.ts +1 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/feature.ts +3 -0
- package/src/user/handlers/find-for-auth.query.ts +8 -6
- package/src/user/i18n.ts +20 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/user.ts +6 -2
- package/src/user/screens.ts +3 -15
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +21 -1
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +145 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +67 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +170 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +169 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +101 -33
- package/src/user-data-rights/__tests__/email-templates.test.ts +15 -0
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +154 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +41 -62
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +110 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +14 -0
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +19 -2
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +25 -14
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +172 -19
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +257 -26
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +339 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +77 -1
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +70 -0
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/email-templates.ts +6 -6
- package/src/user-data-rights/feature.ts +162 -43
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/deletion-grace-period.ts +4 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +26 -18
- package/src/user-data-rights/handlers/download-by-token.query.ts +23 -15
- package/src/user-data-rights/handlers/lift-restriction.write.ts +44 -19
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/request-deletion-by-email.write.ts +3 -1
- package/src/user-data-rights/handlers/restrict-account.write.ts +58 -7
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/i18n.ts +35 -32
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-export-jobs.ts +55 -19
- package/src/user-data-rights/run-forget-cleanup.ts +112 -7
- package/src/user-data-rights/run-user-export.ts +70 -4
- package/src/user-data-rights/schema/download-token.ts +5 -3
- package/src/user-data-rights/schema/export-job.ts +2 -0
- package/src/user-data-rights/screens.ts +2 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/client-plugin.tsx +6 -1
- package/src/user-data-rights/web/i18n.ts +30 -10
- package/src/user-data-rights/web/privacy-center-screen.tsx +5 -3
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +455 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +33 -9
- package/src/user-data-rights-defaults/feature.ts +78 -1
- package/src/user-data-rights-defaults/hooks/api-token.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/config-value.userdata-hook.ts +51 -0
- package/src/user-data-rights-defaults/hooks/delivery-attempt.userdata-hook.ts +37 -0
- package/src/user-data-rights-defaults/hooks/feature-mounted.ts +10 -0
- package/src/user-data-rights-defaults/hooks/in-app-message.userdata-hook.ts +38 -0
- package/src/user-data-rights-defaults/hooks/job-run.userdata-hook.ts +35 -0
- package/src/user-data-rights-defaults/hooks/notification-preference.userdata-hook.ts +54 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +112 -0
- package/src/user-data-rights-defaults/hooks/user-session.userdata-hook.ts +42 -0
- package/src/user-data-rights-defaults/index.ts +26 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +7 -1
- package/src/user-profile/__tests__/profile-screen.test.tsx +5 -0
- package/src/user-profile/feature.ts +4 -1
- package/src/user-profile/handlers/change-email.write.ts +15 -10
- package/src/user-profile/web/profile-screen.tsx +15 -3
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/feature-toggles/db/queries/toggle-state.ts +0 -25
- package/src/legal-pages/security-headers.ts +0 -1
- package/src/sessions/schema/index.ts +0 -5
- package/src/tenant/schema/index.ts +0 -5
- package/src/user/schema/index.ts +0 -5
- /package/src/{auth-email-password → shared}/identity-v3-hash.ts +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// HMAC-signed token minted by the login handler once the password check
|
|
2
|
+
// succeeds but a second factor is still owed. Carries just enough to look
|
|
3
|
+
// the user back up at `/auth/mfa/verify` — no secret material (unlike
|
|
4
|
+
// mfa-setup-token.ts, this token is never shown to the user, only round-
|
|
5
|
+
// tripped by the client between the two login requests).
|
|
6
|
+
//
|
|
7
|
+
// Format: <base64url(JSON body)>.<hmac-base64url> — same shape as
|
|
8
|
+
// mfa-setup-token.ts, distinct HMAC domain-separation string so a setup
|
|
9
|
+
// token can never be replayed as a challenge token or vice versa.
|
|
10
|
+
//
|
|
11
|
+
// Replay protection: burnToken()/unburnToken() from shared/token-burn-store
|
|
12
|
+
// (purpose "mfa-challenge") make the token single-use on a SUCCESSFUL
|
|
13
|
+
// verify. Brute-force protection is a SEPARATE mechanism — see
|
|
14
|
+
// mfa-verify-attempts.ts — because burn-on-success alone doesn't cap wrong
|
|
15
|
+
// guesses against a still-valid token.
|
|
16
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { Temporal } from "temporal-polyfill";
|
|
19
|
+
|
|
20
|
+
export type MfaChallengePayload = {
|
|
21
|
+
readonly userId: string;
|
|
22
|
+
readonly tenantId: TenantId;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type EncodedBody = MfaChallengePayload & { readonly expiresAtMs: number };
|
|
26
|
+
|
|
27
|
+
function sign(input: string, secret: string): string {
|
|
28
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function signMfaChallengeToken(
|
|
32
|
+
payload: MfaChallengePayload,
|
|
33
|
+
ttlMinutes: number,
|
|
34
|
+
secret: string,
|
|
35
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
39
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
40
|
+
const signature = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
41
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type VerifyMfaChallengeResult =
|
|
45
|
+
| { readonly ok: true; readonly payload: MfaChallengePayload; readonly expiresAtMs: number }
|
|
46
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
47
|
+
|
|
48
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
49
|
+
if (typeof value !== "object" || value === null) return false;
|
|
50
|
+
const v = value as Record<string, unknown>;
|
|
51
|
+
return (
|
|
52
|
+
typeof v["userId"] === "string" &&
|
|
53
|
+
typeof v["tenantId"] === "string" &&
|
|
54
|
+
typeof v["expiresAtMs"] === "number"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function verifyMfaChallengeToken(
|
|
59
|
+
token: string,
|
|
60
|
+
secret: string,
|
|
61
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
62
|
+
): VerifyMfaChallengeResult {
|
|
63
|
+
const parts = token.split(".");
|
|
64
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
65
|
+
const [bodyB64, providedSig] = parts;
|
|
66
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
67
|
+
|
|
68
|
+
const expected = sign(`mfa-challenge:${bodyB64}`, secret);
|
|
69
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
70
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
71
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
72
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
73
|
+
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, reason: "malformed" };
|
|
79
|
+
}
|
|
80
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
84
|
+
) {
|
|
85
|
+
return { ok: false, reason: "expired" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
89
|
+
return { ok: true, payload, expiresAtMs };
|
|
90
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// HMAC-signed token minted by login.write.ts when enforcement policy blocks
|
|
2
|
+
// an unenrolled user (mfa-setup-required) — proves the password check
|
|
3
|
+
// already succeeded, so a later pre-auth enable-start variant (#1231) can
|
|
4
|
+
// let the user enroll without a full session. No secret material (unlike
|
|
5
|
+
// mfa-setup-token.ts) — just enough to look the user back up.
|
|
6
|
+
//
|
|
7
|
+
// Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
|
|
8
|
+
// signing string so this can never be replayed as a login challenge or
|
|
9
|
+
// vice versa. Reuses challengeTokenSecret rather than adding a third
|
|
10
|
+
// feature secret: both tokens assert the identical trust boundary
|
|
11
|
+
// ("password already verified, short-lived follow-up allowed").
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { Temporal } from "temporal-polyfill";
|
|
15
|
+
|
|
16
|
+
export type MfaPreauthSetupPayload = {
|
|
17
|
+
readonly userId: string;
|
|
18
|
+
readonly tenantId: TenantId;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EncodedBody = MfaPreauthSetupPayload & { readonly expiresAtMs: number };
|
|
22
|
+
|
|
23
|
+
function sign(input: string, secret: string): string {
|
|
24
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function signMfaPreauthSetupToken(
|
|
28
|
+
payload: MfaPreauthSetupPayload,
|
|
29
|
+
ttlMinutes: number,
|
|
30
|
+
secret: string,
|
|
31
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
34
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
+
const signature = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
37
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type VerifyMfaPreauthSetupResult =
|
|
41
|
+
| { readonly ok: true; readonly payload: MfaPreauthSetupPayload; readonly expiresAtMs: number }
|
|
42
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
43
|
+
|
|
44
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
45
|
+
if (typeof value !== "object" || value === null) return false;
|
|
46
|
+
const v = value as Record<string, unknown>;
|
|
47
|
+
return (
|
|
48
|
+
typeof v["userId"] === "string" &&
|
|
49
|
+
typeof v["tenantId"] === "string" &&
|
|
50
|
+
typeof v["expiresAtMs"] === "number"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyMfaPreauthSetupToken(
|
|
55
|
+
token: string,
|
|
56
|
+
secret: string,
|
|
57
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
58
|
+
): VerifyMfaPreauthSetupResult {
|
|
59
|
+
const parts = token.split(".");
|
|
60
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
61
|
+
const [bodyB64, providedSig] = parts;
|
|
62
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
63
|
+
|
|
64
|
+
const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
65
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
66
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
67
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
68
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
69
|
+
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: "malformed" };
|
|
75
|
+
}
|
|
76
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
80
|
+
) {
|
|
81
|
+
return { ok: false, reason: "expired" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
85
|
+
return { ok: true, payload, expiresAtMs };
|
|
86
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// HMAC-signed token carrying the pending MFA setup state between
|
|
2
|
+
// `mfa.enable.start` and `mfa.enable.confirm` — no DB row exists until
|
|
3
|
+
// confirm succeeds, so an abandoned setup leaves no trace. The token embeds
|
|
4
|
+
// the TOTP secret and recovery-code hashes; this is not a confidentiality
|
|
5
|
+
// leak because the client already displayed the same secret as a QR code in
|
|
6
|
+
// the same response — the token exists to avoid a second round-trip to
|
|
7
|
+
// regenerate it, not to hide it.
|
|
8
|
+
//
|
|
9
|
+
// Format: <base64url(JSON body)>.<hmac-base64url>. Distinct from
|
|
10
|
+
// signed-token.ts (userId-only payload) because setup needs to carry
|
|
11
|
+
// generated secret material, not just re-derive it from a lookup.
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { Temporal } from "temporal-polyfill";
|
|
14
|
+
|
|
15
|
+
export type MfaSetupPayload = {
|
|
16
|
+
readonly userId: string;
|
|
17
|
+
readonly totpSecretBase32: string;
|
|
18
|
+
readonly recoveryCodeHashes: readonly string[];
|
|
19
|
+
// Set only by enable-start-preauth.write.ts — the session-authed
|
|
20
|
+
// enable-start.write.ts has no tenantId to carry (event.user.tenantId
|
|
21
|
+
// already covers that path). The pre-auth confirm handler has no session
|
|
22
|
+
// to fall back on, so this is the only source of which tenant to scope to.
|
|
23
|
+
readonly tenantId?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type EncodedBody = MfaSetupPayload & { readonly expiresAtMs: number };
|
|
27
|
+
|
|
28
|
+
function sign(input: string, secret: string): string {
|
|
29
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function signMfaSetupToken(
|
|
33
|
+
payload: MfaSetupPayload,
|
|
34
|
+
ttlMinutes: number,
|
|
35
|
+
secret: string,
|
|
36
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
37
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
38
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
39
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
40
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
41
|
+
const signature = sign(`mfa-setup:${bodyB64}`, secret);
|
|
42
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type VerifyMfaSetupResult =
|
|
46
|
+
| { readonly ok: true; readonly payload: MfaSetupPayload; readonly expiresAtMs: number }
|
|
47
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
48
|
+
|
|
49
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
50
|
+
if (typeof value !== "object" || value === null) return false;
|
|
51
|
+
const v = value as Record<string, unknown>;
|
|
52
|
+
return (
|
|
53
|
+
typeof v["userId"] === "string" &&
|
|
54
|
+
typeof v["totpSecretBase32"] === "string" &&
|
|
55
|
+
Array.isArray(v["recoveryCodeHashes"]) &&
|
|
56
|
+
v["recoveryCodeHashes"].every((h) => typeof h === "string") &&
|
|
57
|
+
typeof v["expiresAtMs"] === "number" &&
|
|
58
|
+
(v["tenantId"] === undefined || typeof v["tenantId"] === "string")
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function verifyMfaSetupToken(
|
|
63
|
+
token: string,
|
|
64
|
+
secret: string,
|
|
65
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
66
|
+
): VerifyMfaSetupResult {
|
|
67
|
+
const parts = token.split(".");
|
|
68
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
69
|
+
const [bodyB64, providedSig] = parts;
|
|
70
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
71
|
+
|
|
72
|
+
const expected = sign(`mfa-setup:${bodyB64}`, secret);
|
|
73
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
74
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
75
|
+
// Length check before timingSafeEqual — a length mismatch throws inside
|
|
76
|
+
// timingSafeEqual, and that throw is itself a timing signal.
|
|
77
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
78
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
79
|
+
|
|
80
|
+
let parsed: unknown;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: false, reason: "malformed" };
|
|
85
|
+
}
|
|
86
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
90
|
+
) {
|
|
91
|
+
return { ok: false, reason: "expired" };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
95
|
+
return { ok: true, payload, expiresAtMs };
|
|
96
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
type HandlerContext,
|
|
5
|
+
type SessionUser,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { type MfaRequiredPolicy, mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config";
|
|
9
|
+
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES } from "./constants";
|
|
10
|
+
import { findUserMfaRow } from "./db/queries";
|
|
11
|
+
import { signMfaChallengeToken } from "./mfa-challenge-token";
|
|
12
|
+
import { signMfaPreauthSetupToken } from "./mfa-preauth-setup-token";
|
|
13
|
+
|
|
14
|
+
export type MfaStatusCheckResult =
|
|
15
|
+
| { readonly required: false }
|
|
16
|
+
| { readonly required: true; readonly challengeToken: string }
|
|
17
|
+
// Enforcement policy demands MFA for this user, but they haven't enrolled
|
|
18
|
+
// yet. Distinct from `required: true` (which means "enrolled, prove it
|
|
19
|
+
// now") — see config.ts's ponytail note on why this hard-blocks login
|
|
20
|
+
// with no in-band recovery until PR3's enrollment-during-login UI ships.
|
|
21
|
+
| { readonly setupRequired: true; readonly preauthSetupToken: string };
|
|
22
|
+
|
|
23
|
+
// Consumed by auth-email-password's login.write.ts via `mfaStatusChecker`
|
|
24
|
+
// (a generic callback type declared THERE, not here — auth-email-password
|
|
25
|
+
// must not import auth-mfa's config, only the shape).
|
|
26
|
+
export type MfaStatusChecker = (
|
|
27
|
+
ctx: HandlerContext,
|
|
28
|
+
userId: string,
|
|
29
|
+
tenantId: TenantId,
|
|
30
|
+
// Merged global+tenant roles — needed to evaluate the "admins" policy
|
|
31
|
+
// value. Must be the MERGED set (see login.write.ts caller): a
|
|
32
|
+
// SystemAdmin whose admin-ness lives only in global roles would be
|
|
33
|
+
// missed if the caller passed tenant-membership roles alone.
|
|
34
|
+
roles: readonly string[],
|
|
35
|
+
) => Promise<MfaStatusCheckResult>;
|
|
36
|
+
|
|
37
|
+
export function createMfaStatusChecker(opts: {
|
|
38
|
+
readonly challengeTokenSecret: string;
|
|
39
|
+
}): MfaStatusChecker {
|
|
40
|
+
return async (ctx, userId, tenantId, roles) => {
|
|
41
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
42
|
+
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
43
|
+
const row = await findUserMfaRow(scopedDb, scopedUser);
|
|
44
|
+
|
|
45
|
+
if (row) {
|
|
46
|
+
// Enrolled — always enforce regardless of policy. The user opted in
|
|
47
|
+
// themselves; policy only governs whether enrollment is MANDATORY
|
|
48
|
+
// for those who haven't.
|
|
49
|
+
const { token } = signMfaChallengeToken(
|
|
50
|
+
{ userId, tenantId },
|
|
51
|
+
MFA_CHALLENGE_TOKEN_TTL_MINUTES,
|
|
52
|
+
opts.challengeTokenSecret,
|
|
53
|
+
);
|
|
54
|
+
return { required: true, challengeToken: token };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ctx.config is bound to the CALLING user's tenant (GUEST_USER for
|
|
58
|
+
// login) — wrong tenant here. ctx.configResolver takes tenantId
|
|
59
|
+
// explicitly, which is what a pre-session check against the LOGGING-IN
|
|
60
|
+
// user's tenant needs.
|
|
61
|
+
// @cast-boundary engine-payload — "select" config values are validated
|
|
62
|
+
// against the declared `options` at write-time by the config feature.
|
|
63
|
+
const policy = ((await ctx.configResolver?.get(
|
|
64
|
+
mfaRequiredConfigHandle.name,
|
|
65
|
+
mfaRequiredConfigKey(),
|
|
66
|
+
tenantId,
|
|
67
|
+
userId,
|
|
68
|
+
scopedDb,
|
|
69
|
+
)) ?? "optional") as MfaRequiredPolicy | "optional";
|
|
70
|
+
if (policy === "optional") return { required: false };
|
|
71
|
+
const buildSetupToken = (): string =>
|
|
72
|
+
signMfaPreauthSetupToken(
|
|
73
|
+
{ userId, tenantId },
|
|
74
|
+
MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES,
|
|
75
|
+
opts.challengeTokenSecret,
|
|
76
|
+
).token;
|
|
77
|
+
if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
|
|
78
|
+
// policy === "admins"
|
|
79
|
+
const isAdmin = roles.some((role) => access.admin.includes(role));
|
|
80
|
+
return isAdmin
|
|
81
|
+
? { setupRequired: true, preauthSetupToken: buildSetupToken() }
|
|
82
|
+
: { required: false };
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Redis-backed brute-force cap for `/auth/mfa/verify`, keyed by userId (NOT
|
|
2
|
+
// by challenge token). Modeled on auth-email-password/lockout-store.ts.
|
|
3
|
+
//
|
|
4
|
+
// Why keyed by userId, not challengeToken: a challenge token is reissued on
|
|
5
|
+
// every successful password login (see mfa-challenge-token.ts) — a fresh
|
|
6
|
+
// token would reset a token-keyed counter to zero, letting an attacker who
|
|
7
|
+
// already has the password just re-login repeatedly to get unlimited TOTP
|
|
8
|
+
// guesses. A 6-digit code with a ±1-step window has 3 valid values out of
|
|
9
|
+
// 10^6; without a cap that survives across challenge reissuance, online
|
|
10
|
+
// brute-force is practical. The counter's lifetime crosses token
|
|
11
|
+
// boundaries on purpose.
|
|
12
|
+
//
|
|
13
|
+
// This is INDEPENDENT of the AuthRoutesConfig.mfaVerifyRateLimit on the
|
|
14
|
+
// framework route (IP-scoped abuse protection for the endpoint itself) —
|
|
15
|
+
// both are needed, neither substitutes for the other.
|
|
16
|
+
|
|
17
|
+
import type Redis from "ioredis";
|
|
18
|
+
|
|
19
|
+
export type MfaVerifyLockoutState = {
|
|
20
|
+
readonly failureCount: number;
|
|
21
|
+
readonly lockedUntil: number | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const COUNT_KEY_PREFIX = "kumiko:auth:mfa-verify:count:";
|
|
25
|
+
const UNTIL_KEY_PREFIX = "kumiko:auth:mfa-verify:until:";
|
|
26
|
+
|
|
27
|
+
function countKey(userId: string): string {
|
|
28
|
+
return `${COUNT_KEY_PREFIX}${userId}`;
|
|
29
|
+
}
|
|
30
|
+
function untilKey(userId: string): string {
|
|
31
|
+
return `${UNTIL_KEY_PREFIX}${userId}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function getMfaVerifyLockoutState(
|
|
35
|
+
redis: Redis,
|
|
36
|
+
userId: string,
|
|
37
|
+
): Promise<MfaVerifyLockoutState | null> {
|
|
38
|
+
const [countRaw, untilRaw] = await redis.mget(countKey(userId), untilKey(userId));
|
|
39
|
+
if (countRaw === null) return null;
|
|
40
|
+
const failureCount = Number(countRaw);
|
|
41
|
+
if (!Number.isFinite(failureCount)) return null;
|
|
42
|
+
const lockedUntil = untilRaw !== null ? Number(untilRaw) : null;
|
|
43
|
+
return {
|
|
44
|
+
failureCount,
|
|
45
|
+
lockedUntil: lockedUntil !== null && Number.isFinite(lockedUntil) ? lockedUntil : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Race-free: INCR is atomic, NX on the until-key means only the attempt
|
|
50
|
+
// that first crosses the threshold anchors the lock window — see
|
|
51
|
+
// lockout-store.ts's recordFailedAttempt for the identical reasoning.
|
|
52
|
+
export async function recordFailedMfaVerifyAttempt(
|
|
53
|
+
redis: Redis,
|
|
54
|
+
userId: string,
|
|
55
|
+
maxAttempts: number,
|
|
56
|
+
lockoutMinutes: number,
|
|
57
|
+
): Promise<MfaVerifyLockoutState> {
|
|
58
|
+
const lockDurationMs = lockoutMinutes * 60 * 1000;
|
|
59
|
+
const ttlSec = Math.max(lockoutMinutes * 60, 24 * 3600);
|
|
60
|
+
|
|
61
|
+
const count = await redis.incr(countKey(userId));
|
|
62
|
+
if (count === 1) {
|
|
63
|
+
await redis.expire(countKey(userId), ttlSec);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let lockedUntil: number | null = null;
|
|
67
|
+
if (count >= maxAttempts) {
|
|
68
|
+
const computedUntil = Date.now() + lockDurationMs;
|
|
69
|
+
const setOk = await redis.set(
|
|
70
|
+
untilKey(userId),
|
|
71
|
+
String(computedUntil),
|
|
72
|
+
"PX",
|
|
73
|
+
lockDurationMs,
|
|
74
|
+
"NX",
|
|
75
|
+
);
|
|
76
|
+
if (setOk === "OK") {
|
|
77
|
+
lockedUntil = computedUntil;
|
|
78
|
+
} else {
|
|
79
|
+
const existing = await redis.get(untilKey(userId));
|
|
80
|
+
lockedUntil = existing !== null ? Number(existing) : null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { failureCount: count, lockedUntil };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Called on a successful verify. The only path that resets the streak.
|
|
88
|
+
export async function clearMfaVerifyAttempts(redis: Redis, userId: string): Promise<void> {
|
|
89
|
+
await redis.del(countKey(userId), untilKey(userId));
|
|
90
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { base32Encode } from "./base32";
|
|
2
|
+
|
|
3
|
+
// otpauth:// URI per Google Authenticator's key-uri-format (the de facto
|
|
4
|
+
// standard every authenticator app implements). The client renders this as
|
|
5
|
+
// a QR code (or lets the user type the secret manually) — the server never
|
|
6
|
+
// touches QR rendering.
|
|
7
|
+
export function buildOtpauthUri(opts: {
|
|
8
|
+
readonly issuer: string;
|
|
9
|
+
readonly accountLabel: string;
|
|
10
|
+
readonly secret: Buffer;
|
|
11
|
+
}): string {
|
|
12
|
+
const label = encodeURIComponent(`${opts.issuer}:${opts.accountLabel}`);
|
|
13
|
+
const params = new URLSearchParams({
|
|
14
|
+
secret: base32Encode(opts.secret),
|
|
15
|
+
issuer: opts.issuer,
|
|
16
|
+
algorithm: "SHA1",
|
|
17
|
+
digits: "6",
|
|
18
|
+
period: "30",
|
|
19
|
+
});
|
|
20
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
import { hashPassword, verifyPassword } from "../shared";
|
|
3
|
+
|
|
4
|
+
const RECOVERY_CODE_COUNT = 8;
|
|
5
|
+
// No ambiguous chars (0/O, 1/I) — these get read aloud or copy-typed from a
|
|
6
|
+
// screen during account-recovery, a context where a misread costs a support
|
|
7
|
+
// ticket, not just a retry.
|
|
8
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
9
|
+
|
|
10
|
+
// Recovery codes get read aloud or copy-typed from a screen — a user retyping
|
|
11
|
+
// "abcd1234" lowercase or without the dash must still match. Applied on both
|
|
12
|
+
// sides (hash-time here strips the group separator before hashing, keeping
|
|
13
|
+
// hash and verify identical if generation ever changes) so a formatting
|
|
14
|
+
// slip never surfaces as "wrong code" for an otherwise-correct code.
|
|
15
|
+
function normalizeRecoveryCode(code: string): string {
|
|
16
|
+
return code.toUpperCase().replace(/[^A-Z2-9]/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function randomCodeGroup(): string {
|
|
20
|
+
let out = "";
|
|
21
|
+
for (let i = 0; i < 4; i++) {
|
|
22
|
+
out += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function generateRecoveryCode(): string {
|
|
28
|
+
return `${randomCodeGroup()}-${randomCodeGroup()}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): string[] {
|
|
32
|
+
return Array.from({ length: count }, generateRecoveryCode);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Reuses the same argon2id hashing as passwords — recovery codes are
|
|
36
|
+
// bearer-secrets of comparable sensitivity (whoever has one can log in).
|
|
37
|
+
export async function hashRecoveryCodes(codes: readonly string[]): Promise<string[]> {
|
|
38
|
+
return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Sequential (not Promise.all) — recovery-code lists are ≤8 entries, and a
|
|
42
|
+
// user typing a wrong code should not pay 8x argon2 cost when the first
|
|
43
|
+
// hash already matches.
|
|
44
|
+
export async function findMatchingRecoveryCodeIndex(
|
|
45
|
+
code: string,
|
|
46
|
+
hashes: readonly string[],
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const normalized = normalizeRecoveryCode(code);
|
|
49
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
50
|
+
const hash = hashes[i];
|
|
51
|
+
if (hash !== undefined && (await verifyPassword(hash, normalized))) return i;
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
createEntity,
|
|
4
|
+
createTextField,
|
|
5
|
+
createTimestampField,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// One row per (userId, tenantId) — presence of a row IS "MFA enabled" for
|
|
9
|
+
// that user. Event-sourced (not unmanagedTable): enable/disable/regenerate
|
|
10
|
+
// are rare, auditable state changes, not a hot-path lookup like sessions —
|
|
11
|
+
// mfa.enabled/disabled fall out of the event stream instead of being
|
|
12
|
+
// manually emitted.
|
|
13
|
+
export const userMfaEntity = createEntity({
|
|
14
|
+
table: "read_user_mfa",
|
|
15
|
+
fields: {
|
|
16
|
+
// FK to the user row, not content — PII-naming heuristic would flag
|
|
17
|
+
// "userId" as content; it's a pseudonymous reference, matching the same
|
|
18
|
+
// annotation `config`'s userId column uses.
|
|
19
|
+
userId: createTextField({ required: true, allowPlaintext: "pseudonymous-fk" }),
|
|
20
|
+
// Envelope-encrypted via the same MasterKeyProvider as secrets/config
|
|
21
|
+
// (entity-field-encryption.ts) — no manual wrapDek/unwrapDek needed.
|
|
22
|
+
// userOwned: crypto-shredding this field is exactly "revoke this
|
|
23
|
+
// user's 2FA on GDPR forget", which is the correct behavior anyway.
|
|
24
|
+
totpSecret: createTextField({
|
|
25
|
+
required: true,
|
|
26
|
+
encrypted: true,
|
|
27
|
+
userOwned: { ownerField: "userId" },
|
|
28
|
+
}),
|
|
29
|
+
// JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
|
|
30
|
+
// recovery codes) — both encryption layers (entity-field envelope +
|
|
31
|
+
// per-subject crypto-shred, same as totpSecret above) require a string
|
|
32
|
+
// value, so callers JSON.stringify on write and JSON.parse on read
|
|
33
|
+
// (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
|
|
34
|
+
// A redeemed code's hash is removed from the array (see disable/regen
|
|
35
|
+
// handlers), so array length also IS the remaining-codes count.
|
|
36
|
+
// No jsonb->text migration: zero-legacy/single-user framework repo, no
|
|
37
|
+
// production user_mfa rows exist to backfill (delete-and-re-enroll is
|
|
38
|
+
// the upgrade path for anyone who hit the old jsonb shape locally).
|
|
39
|
+
recoveryCodes: createTextField({
|
|
40
|
+
required: true,
|
|
41
|
+
encrypted: true,
|
|
42
|
+
userOwned: { ownerField: "userId" },
|
|
43
|
+
}),
|
|
44
|
+
enabledAt: createTimestampField({ required: true }),
|
|
45
|
+
lastUsedAt: createTimestampField(),
|
|
46
|
+
},
|
|
47
|
+
indexes: [{ unique: true, columns: ["userId", "tenantId"], name: "read_user_mfa_user_unique" }],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const userMfaTable = buildEntityTable("user-mfa", userMfaEntity);
|
|
51
|
+
|
|
52
|
+
// Both encryption layers on `recoveryCodes` (entity-field envelope +
|
|
53
|
+
// per-subject crypto-shred) require a string value — every writer
|
|
54
|
+
// stringifies the same `{ hashes }` shape, so it lives here once instead
|
|
55
|
+
// of at each of the three write handlers. Matching read-side helper is
|
|
56
|
+
// `parseJsonOrThrow` in db/queries.ts (parses the full row, not just this
|
|
57
|
+
// field, so it isn't a symmetric pair of this one function).
|
|
58
|
+
export function encodeRecoveryCodes(hashes: readonly string[]): string {
|
|
59
|
+
return JSON.stringify({ hashes });
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Test-only helper — not part of the runtime surface (a real client never
|
|
2
|
+
// derives a code from the raw secret; the authenticator app does that).
|
|
3
|
+
// Kept out of the main "./auth-mfa" barrel so consumers don't see a
|
|
4
|
+
// server-side "valid code now" generator in autocomplete.
|
|
5
|
+
export { currentTotpCode } from "./totp";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// RFC 6238 TOTP on top of node:crypto — no otplib/speakeasy dependency. The
|
|
2
|
+
// algorithm is ~20 lines of HMAC-SHA1 + dynamic truncation; pulling in a
|
|
3
|
+
// package for it would be the opposite of lazy.
|
|
4
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const STEP_SECONDS = 30;
|
|
7
|
+
const DIGITS = 6;
|
|
8
|
+
const WINDOW = 1; // accept ±1 step (±30s) of clock drift, per the spec's guidance
|
|
9
|
+
|
|
10
|
+
export function generateTotpSecret(): Buffer {
|
|
11
|
+
return randomBytes(20); // 160 bits — RFC 4226's recommended HOTP secret length
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hotp(secret: Buffer, counter: number): string {
|
|
15
|
+
const counterBuf = Buffer.alloc(8);
|
|
16
|
+
counterBuf.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
|
|
17
|
+
counterBuf.writeUInt32BE(counter % 2 ** 32, 4);
|
|
18
|
+
|
|
19
|
+
const hmac = createHmac("sha1", secret).update(counterBuf).digest();
|
|
20
|
+
const offset = (hmac[hmac.length - 1] ?? 0) & 0x0f;
|
|
21
|
+
const truncated =
|
|
22
|
+
(((hmac[offset] ?? 0) & 0x7f) << 24) |
|
|
23
|
+
(((hmac[offset + 1] ?? 0) & 0xff) << 16) |
|
|
24
|
+
(((hmac[offset + 2] ?? 0) & 0xff) << 8) |
|
|
25
|
+
((hmac[offset + 3] ?? 0) & 0xff);
|
|
26
|
+
|
|
27
|
+
return String(truncated % 10 ** DIGITS).padStart(DIGITS, "0");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Exposed for callers that need to derive "the code right now" — the
|
|
31
|
+
// enable-confirm/verify UI flows show a live-updating code client-side
|
|
32
|
+
// (via their own TOTP lib or a countdown), and tests need a code to submit
|
|
33
|
+
// without hand-rolling the HMAC math again.
|
|
34
|
+
export function currentTotpCode(secret: Buffer, nowMs: number = Date.now()): string {
|
|
35
|
+
return totpAt(secret, Math.floor(nowMs / 1000));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function totpAt(secret: Buffer, epochSeconds: number): string {
|
|
39
|
+
return hotp(secret, Math.floor(epochSeconds / STEP_SECONDS));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Timing-safe across the whole ±WINDOW check, not just the final compare —
|
|
43
|
+
// a TOTP code is the same sensitivity class as a password, brute-forceable
|
|
44
|
+
// over a public /auth/mfa/verify endpoint without it.
|
|
45
|
+
//
|
|
46
|
+
// Returns the matched HOTP counter (not just true) so callers can burn it —
|
|
47
|
+
// a code accepted once must not verify again within its ±WINDOW validity,
|
|
48
|
+
// per RFC 6238 §5.2. Returns `false` on no match, matching the old boolean
|
|
49
|
+
// contract for callers (like enable-confirm) that don't need replay-burn.
|
|
50
|
+
export function verifyTotp(
|
|
51
|
+
secret: Buffer,
|
|
52
|
+
code: string,
|
|
53
|
+
nowMs: number = Date.now(),
|
|
54
|
+
): number | false {
|
|
55
|
+
if (!/^\d{6}$/.test(code)) return false;
|
|
56
|
+
const codeBuf = Buffer.from(code);
|
|
57
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
58
|
+
let matchedCounter: number | false = false;
|
|
59
|
+
for (let w = -WINDOW; w <= WINDOW; w++) {
|
|
60
|
+
const stepSeconds = nowSeconds + w * STEP_SECONDS;
|
|
61
|
+
const candidate = Buffer.from(totpAt(secret, stepSeconds));
|
|
62
|
+
if (timingSafeEqual(candidate, codeBuf)) {
|
|
63
|
+
matchedCounter = Math.floor(stepSeconds / STEP_SECONDS);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return matchedCounter;
|
|
67
|
+
}
|