@bajustone/fortress 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1011 -0
- package/LICENSE +21 -0
- package/README.md +760 -0
- package/SECURITY.md +197 -0
- package/bin/fortress.ts +667 -0
- package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
- package/dist/auth-service-BkzZkWfH.d.ts +307 -0
- package/dist/config-B2366s_G.d.ts +665 -0
- package/dist/config-GtlVt6ZD.d.cts +665 -0
- package/dist/convert-routes-BLCQT57f.d.ts +72 -0
- package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
- package/dist/crypto.cjs +61 -0
- package/dist/crypto.d.cts +36 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/crypto.js +35 -0
- package/dist/drift-BT1wtMDJ.d.cts +22 -0
- package/dist/drift-k9LiYpRP.d.ts +22 -0
- package/dist/drizzle/pg.cjs +481 -0
- package/dist/drizzle/pg.d.cts +15 -0
- package/dist/drizzle/pg.d.ts +15 -0
- package/dist/drizzle/pg.js +454 -0
- package/dist/drizzle.cjs +1442 -0
- package/dist/drizzle.d.cts +85 -0
- package/dist/drizzle.d.ts +85 -0
- package/dist/drizzle.js +1411 -0
- package/dist/express.cjs +1357 -0
- package/dist/express.d.cts +333 -0
- package/dist/express.d.ts +333 -0
- package/dist/express.js +1314 -0
- package/dist/fetcher.cjs +77 -0
- package/dist/fetcher.d.cts +7 -0
- package/dist/fetcher.d.ts +7 -0
- package/dist/fetcher.js +41 -0
- package/dist/fortress-BmSvFfqK.d.cts +1089 -0
- package/dist/fortress-uA-_cheR.d.ts +1089 -0
- package/dist/hono.cjs +1530 -0
- package/dist/hono.d.cts +514 -0
- package/dist/hono.d.ts +514 -0
- package/dist/hono.js +1483 -0
- package/dist/index-CxEkfCA0.d.cts +77 -0
- package/dist/index-CxEkfCA0.d.ts +77 -0
- package/dist/index-D054pcb-.d.cts +146 -0
- package/dist/index-jAMbbUAY.d.ts +146 -0
- package/dist/index.cjs +9046 -0
- package/dist/index.d.cts +717 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +8935 -0
- package/dist/jwt.cjs +255 -0
- package/dist/jwt.d.cts +90 -0
- package/dist/jwt.d.ts +90 -0
- package/dist/jwt.js +227 -0
- package/dist/otel.cjs +108 -0
- package/dist/otel.d.cts +62 -0
- package/dist/otel.d.ts +62 -0
- package/dist/otel.js +73 -0
- package/dist/plugins/account-lockout.cjs +322 -0
- package/dist/plugins/account-lockout.d.cts +42 -0
- package/dist/plugins/account-lockout.d.ts +42 -0
- package/dist/plugins/account-lockout.js +295 -0
- package/dist/plugins/admin.cjs +2378 -0
- package/dist/plugins/admin.d.cts +72 -0
- package/dist/plugins/admin.d.ts +72 -0
- package/dist/plugins/admin.js +2351 -0
- package/dist/plugins/api-key.cjs +792 -0
- package/dist/plugins/api-key.d.cts +121 -0
- package/dist/plugins/api-key.d.ts +121 -0
- package/dist/plugins/api-key.js +765 -0
- package/dist/plugins/audit-log.cjs +493 -0
- package/dist/plugins/audit-log.d.cts +86 -0
- package/dist/plugins/audit-log.d.ts +86 -0
- package/dist/plugins/audit-log.js +468 -0
- package/dist/plugins/data-isolation.cjs +211 -0
- package/dist/plugins/data-isolation.d.cts +49 -0
- package/dist/plugins/data-isolation.d.ts +49 -0
- package/dist/plugins/data-isolation.js +186 -0
- package/dist/plugins/email-verification.cjs +273 -0
- package/dist/plugins/email-verification.d.cts +44 -0
- package/dist/plugins/email-verification.d.ts +44 -0
- package/dist/plugins/email-verification.js +246 -0
- package/dist/plugins/magic-link.cjs +231 -0
- package/dist/plugins/magic-link.d.cts +39 -0
- package/dist/plugins/magic-link.d.ts +39 -0
- package/dist/plugins/magic-link.js +204 -0
- package/dist/plugins/oauth.cjs +1696 -0
- package/dist/plugins/oauth.d.cts +406 -0
- package/dist/plugins/oauth.d.ts +406 -0
- package/dist/plugins/oauth.js +1669 -0
- package/dist/plugins/openapi.cjs +1185 -0
- package/dist/plugins/openapi.d.cts +6 -0
- package/dist/plugins/openapi.d.ts +6 -0
- package/dist/plugins/openapi.js +1158 -0
- package/dist/plugins/rate-limit/express.cjs +55 -0
- package/dist/plugins/rate-limit/express.d.cts +64 -0
- package/dist/plugins/rate-limit/express.d.ts +64 -0
- package/dist/plugins/rate-limit/express.js +30 -0
- package/dist/plugins/rate-limit/hono.cjs +51 -0
- package/dist/plugins/rate-limit/hono.d.cts +59 -0
- package/dist/plugins/rate-limit/hono.d.ts +59 -0
- package/dist/plugins/rate-limit/hono.js +26 -0
- package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
- package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
- package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
- package/dist/plugins/rate-limit/sveltekit.js +24 -0
- package/dist/plugins/rate-limit.cjs +354 -0
- package/dist/plugins/rate-limit.d.cts +140 -0
- package/dist/plugins/rate-limit.d.ts +140 -0
- package/dist/plugins/rate-limit.js +325 -0
- package/dist/plugins/social-login.cjs +905 -0
- package/dist/plugins/social-login.d.cts +114 -0
- package/dist/plugins/social-login.d.ts +114 -0
- package/dist/plugins/social-login.js +880 -0
- package/dist/plugins/tenancy.cjs +780 -0
- package/dist/plugins/tenancy.d.cts +108 -0
- package/dist/plugins/tenancy.d.ts +108 -0
- package/dist/plugins/tenancy.js +753 -0
- package/dist/plugins/two-factor.cjs +555 -0
- package/dist/plugins/two-factor.d.cts +67 -0
- package/dist/plugins/two-factor.d.ts +67 -0
- package/dist/plugins/two-factor.js +526 -0
- package/dist/plugins/webauthn.cjs +786 -0
- package/dist/plugins/webauthn.d.cts +72 -0
- package/dist/plugins/webauthn.d.ts +72 -0
- package/dist/plugins/webauthn.js +766 -0
- package/dist/plugins/webhook.cjs +819 -0
- package/dist/plugins/webhook.d.cts +254 -0
- package/dist/plugins/webhook.d.ts +254 -0
- package/dist/plugins/webhook.js +789 -0
- package/dist/protect-D1v_0upK.d.cts +123 -0
- package/dist/protect-DsxV_-dJ.d.ts +123 -0
- package/dist/sveltekit.cjs +1258 -0
- package/dist/sveltekit.d.cts +420 -0
- package/dist/sveltekit.d.ts +420 -0
- package/dist/sveltekit.js +1207 -0
- package/dist/testing.cjs +4294 -0
- package/dist/testing.d.cts +197 -0
- package/dist/testing.d.ts +197 -0
- package/dist/testing.js +4264 -0
- package/dist/types-et0R8tsC.d.cts +217 -0
- package/dist/types-et0R8tsC.d.ts +217 -0
- package/docs/adapter-typed-helpers.md +192 -0
- package/docs/admin-recipes.md +227 -0
- package/docs/architecture.md +434 -0
- package/docs/ci/github-actions.yml +59 -0
- package/docs/ci.md +109 -0
- package/docs/compatibility.md +115 -0
- package/docs/deployment.md +305 -0
- package/docs/hardening.md +118 -0
- package/docs/host-owned-routes.md +154 -0
- package/docs/migrations/0001-schema-version.md +54 -0
- package/docs/migrations/0002-initial-schema.md +84 -0
- package/docs/migrations/upgrade-guide.md +160 -0
- package/docs/observability.md +394 -0
- package/docs/plugins/account-lockout.md +131 -0
- package/docs/plugins/admin.md +402 -0
- package/docs/plugins/api-key.md +327 -0
- package/docs/plugins/audit-log.md +261 -0
- package/docs/plugins/data-isolation.md +186 -0
- package/docs/plugins/email-verification.md +121 -0
- package/docs/plugins/magic-link.md +123 -0
- package/docs/plugins/oauth.md +544 -0
- package/docs/plugins/openapi.md +250 -0
- package/docs/plugins/rate-limit.md +223 -0
- package/docs/plugins/social-login.md +337 -0
- package/docs/plugins/tenancy.md +122 -0
- package/docs/plugins/two-factor.md +191 -0
- package/docs/plugins/webauthn.md +188 -0
- package/docs/plugins/webhook.md +242 -0
- package/docs/policy-as-code.md +156 -0
- package/docs/route-manifest.md +46 -0
- package/docs/security.md +261 -0
- package/docs/threat-model.md +161 -0
- package/examples/README.md +119 -0
- package/examples/express-app/index.ts +747 -0
- package/examples/hono-app/docker-compose.yml +14 -0
- package/examples/hono-app/index.ts +1009 -0
- package/examples/policy/fortress.policy.json +47 -0
- package/examples/sveltekit-app/README.md +125 -0
- package/examples/sveltekit-app/src/app.d.ts +16 -0
- package/examples/sveltekit-app/src/hooks.server.ts +23 -0
- package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
- package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
- package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
- package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
- package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
- package/migrations/pg/0001_schema_version.down.sql +2 -0
- package/migrations/pg/0001_schema_version.sql +8 -0
- package/migrations/pg/0002_initial_schema.down.sql +36 -0
- package/migrations/pg/0002_initial_schema.sql +376 -0
- package/migrations/pg/0003_auth_continuation.down.sql +6 -0
- package/migrations/pg/0003_auth_continuation.sql +30 -0
- package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/pg/0004_tenant_default_unique.sql +14 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
- package/migrations/pg/0006_canonical_email.down.sql +3 -0
- package/migrations/pg/0006_canonical_email.sql +8 -0
- package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.sql +8 -0
- package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
- package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
- package/migrations/sqlite/0001_schema_version.down.sql +2 -0
- package/migrations/sqlite/0001_schema_version.sql +8 -0
- package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
- package/migrations/sqlite/0002_initial_schema.sql +376 -0
- package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
- package/migrations/sqlite/0003_auth_continuation.sql +45 -0
- package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
- package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
- package/migrations/sqlite/0006_canonical_email.sql +8 -0
- package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
- package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
- package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
- package/package.json +398 -0
- package/src/adapters/database/index.ts +63 -0
- package/src/adapters/database/types.ts +22 -0
- package/src/changelog-script.test.ts +21 -0
- package/src/cli.test.ts +79 -0
- package/src/core/auth/auth-admin.test.ts +247 -0
- package/src/core/auth/auth-endpoints.ts +558 -0
- package/src/core/auth/auth-observer.test.ts +191 -0
- package/src/core/auth/auth-service.ts +1464 -0
- package/src/core/auth/email.test.ts +17 -0
- package/src/core/auth/email.ts +11 -0
- package/src/core/auth/hooks.test.ts +251 -0
- package/src/core/auth/impersonation.test.ts +179 -0
- package/src/core/auth/jwt.test.ts +184 -0
- package/src/core/auth/jwt.ts +239 -0
- package/src/core/auth/login-timing.test.ts +77 -0
- package/src/core/auth/password-policy.test.ts +253 -0
- package/src/core/auth/password-policy.ts +220 -0
- package/src/core/auth/password.test.ts +42 -0
- package/src/core/auth/password.ts +62 -0
- package/src/core/auth/post-auth-gate.test.ts +258 -0
- package/src/core/auth/post-auth-gate.ts +228 -0
- package/src/core/auth/refresh-token.test.ts +86 -0
- package/src/core/auth/refresh-token.ts +105 -0
- package/src/core/auth/timing-safe.ts +23 -0
- package/src/core/config.ts +212 -0
- package/src/core/endpoint.ts +149 -0
- package/src/core/errors.ts +199 -0
- package/src/core/fetcher-interop.test.ts +106 -0
- package/src/core/fortress.test.ts +354 -0
- package/src/core/fortress.ts +550 -0
- package/src/core/http/call.test.ts +148 -0
- package/src/core/http/call.ts +149 -0
- package/src/core/http/cookie-serialize.test.ts +198 -0
- package/src/core/http/cookie-serialize.ts +107 -0
- package/src/core/http/csrf.test.ts +140 -0
- package/src/core/http/csrf.ts +173 -0
- package/src/core/http/dispatch.ts +652 -0
- package/src/core/http/error-response.test.ts +69 -0
- package/src/core/http/error-response.ts +93 -0
- package/src/core/http/fortress-rbac.test.ts +43 -0
- package/src/core/http/fortress-rbac.ts +127 -0
- package/src/core/http/handle-request.test.ts +441 -0
- package/src/core/http/handle-request.ts +354 -0
- package/src/core/http/match.test.ts +122 -0
- package/src/core/http/match.ts +126 -0
- package/src/core/http/outbound.test.ts +34 -0
- package/src/core/http/outbound.ts +105 -0
- package/src/core/http/plugin-middleware.ts +67 -0
- package/src/core/http/principal.test.ts +345 -0
- package/src/core/http/principal.ts +86 -0
- package/src/core/http/protect.test.ts +220 -0
- package/src/core/http/protect.ts +394 -0
- package/src/core/http/token-extraction.test.ts +59 -0
- package/src/core/http/token-extraction.ts +53 -0
- package/src/core/iam/explain.test.ts +177 -0
- package/src/core/iam/explain.ts +302 -0
- package/src/core/iam/iam-endpoints.ts +779 -0
- package/src/core/iam/iam-service.test.ts +829 -0
- package/src/core/iam/iam-service.ts +1137 -0
- package/src/core/iam/permission-cache.test.ts +115 -0
- package/src/core/iam/permission-cache.ts +71 -0
- package/src/core/iam/permission-check-observer.test.ts +90 -0
- package/src/core/iam/permission-evaluator.test.ts +291 -0
- package/src/core/iam/permission-evaluator.ts +198 -0
- package/src/core/iam/permission-sync.test.ts +166 -0
- package/src/core/iam/permission-sync.ts +192 -0
- package/src/core/iam/resource-sync.test.ts +70 -0
- package/src/core/iam/resource-sync.ts +182 -0
- package/src/core/iam/service-account-http.test.ts +529 -0
- package/src/core/iam/service-account.test.ts +282 -0
- package/src/core/internal-adapter.test.ts +389 -0
- package/src/core/internal-adapter.ts +435 -0
- package/src/core/json-schema.ts +50 -0
- package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
- package/src/core/manifest/drift.ts +103 -0
- package/src/core/manifest/route-manifest.test.ts +142 -0
- package/src/core/manifest/route-manifest.ts +154 -0
- package/src/core/migrate.test.ts +72 -0
- package/src/core/migrations/engine.test.ts +308 -0
- package/src/core/migrations/engine.ts +548 -0
- package/src/core/migrations/migrations.ts +1771 -0
- package/src/core/migrations/pg-migration.integration-test.ts +353 -0
- package/src/core/migrations/upgrade-fixture.test.ts +378 -0
- package/src/core/observability/db-instrumentation.ts +205 -0
- package/src/core/observability/listener-list.test.ts +124 -0
- package/src/core/observability/listener-list.ts +73 -0
- package/src/core/observability/logger.test.ts +43 -0
- package/src/core/observability/logger.ts +49 -0
- package/src/core/observability/spans.test.ts +193 -0
- package/src/core/observability/types.ts +80 -0
- package/src/core/openapi-drift.test.ts +41 -0
- package/src/core/openapi.ts +47 -0
- package/src/core/plugin-methods-map.ts +75 -0
- package/src/core/plugin-runner.test.ts +233 -0
- package/src/core/plugin-runner.ts +276 -0
- package/src/core/plugin.ts +210 -0
- package/src/core/policy/apply.ts +272 -0
- package/src/core/policy/diff.ts +288 -0
- package/src/core/policy/loader.test.ts +63 -0
- package/src/core/policy/loader.ts +214 -0
- package/src/core/policy/policy.test.ts +232 -0
- package/src/core/policy/types.ts +105 -0
- package/src/core/schema-builder.test.ts +660 -0
- package/src/core/schema-builder.ts +881 -0
- package/src/core/standard-schema.ts +56 -0
- package/src/core/types.ts +258 -0
- package/src/core/validation.test.ts +195 -0
- package/src/core/validation.ts +192 -0
- package/src/drizzle/adapter.test.ts +425 -0
- package/src/drizzle/adapter.ts +474 -0
- package/src/drizzle/index.ts +31 -0
- package/src/drizzle/pg/adapter.integration-test.ts +456 -0
- package/src/drizzle/pg/index.ts +13 -0
- package/src/drizzle/pg/pg.integration-test.ts +1539 -0
- package/src/drizzle/pg/schema.ts +539 -0
- package/src/drizzle/pg-error-map.test.ts +218 -0
- package/src/drizzle/pg-error-map.ts +151 -0
- package/src/drizzle/schema.ts +544 -0
- package/src/drizzle/sqlite-serialization.test.ts +106 -0
- package/src/express/express-api-key-user-routes.test.ts +241 -0
- package/src/express/express.test.ts +442 -0
- package/src/express/handle.ts +191 -0
- package/src/express/index.ts +62 -0
- package/src/express/middleware.ts +551 -0
- package/src/express/protect.ts +154 -0
- package/src/express/validated.test.ts +86 -0
- package/src/express/validated.ts +99 -0
- package/src/fetcher/index.ts +81 -0
- package/src/hono/convert-routes.test.ts +220 -0
- package/src/hono/convert-routes.ts +196 -0
- package/src/hono/converters.test.ts +50 -0
- package/src/hono/converters.ts +43 -0
- package/src/hono/handle.ts +79 -0
- package/src/hono/helpers.ts +2 -0
- package/src/hono/hono-api-key-user-routes.test.ts +225 -0
- package/src/hono/hono-handlers.test.ts +861 -0
- package/src/hono/hono.test.ts +454 -0
- package/src/hono/index.ts +101 -0
- package/src/hono/middleware/auth.ts +236 -0
- package/src/hono/middleware/auth.types.test.ts +94 -0
- package/src/hono/middleware/csrf.test.ts +127 -0
- package/src/hono/middleware/csrf.ts +68 -0
- package/src/hono/middleware/error-handler.ts +12 -0
- package/src/hono/middleware/plugin-middleware.ts +35 -0
- package/src/hono/middleware/rbac.ts +131 -0
- package/src/hono/middleware/security-headers.test.ts +88 -0
- package/src/hono/middleware/security-headers.ts +68 -0
- package/src/hono/openapi.ts +237 -0
- package/src/hono/protect.ts +87 -0
- package/src/hono/validated.test.ts +123 -0
- package/src/hono/validated.ts +62 -0
- package/src/index.ts +309 -0
- package/src/integration.test.ts +718 -0
- package/src/internal/changelog.ts +56 -0
- package/src/otel/index.ts +158 -0
- package/src/otel/otel-types.test.ts +35 -0
- package/src/otel/otel.test.ts +235 -0
- package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
- package/src/plugins/account-lockout/index.ts +267 -0
- package/src/plugins/admin/admin-api-key.test.ts +438 -0
- package/src/plugins/admin/index.ts +1612 -0
- package/src/plugins/api-key/api-key.test.ts +684 -0
- package/src/plugins/api-key/core.ts +336 -0
- package/src/plugins/api-key/index.ts +315 -0
- package/src/plugins/audit-log/audit-log.test.ts +749 -0
- package/src/plugins/audit-log/index.ts +680 -0
- package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
- package/src/plugins/data-isolation/index.ts +157 -0
- package/src/plugins/email-verification/email-verification.test.ts +199 -0
- package/src/plugins/email-verification/index.ts +190 -0
- package/src/plugins/magic-link/index.ts +129 -0
- package/src/plugins/magic-link/magic-link.test.ts +156 -0
- package/src/plugins/oauth/id-token.ts +86 -0
- package/src/plugins/oauth/index.ts +2145 -0
- package/src/plugins/oauth/jwks.test.ts +32 -0
- package/src/plugins/oauth/jwks.ts +182 -0
- package/src/plugins/oauth/oauth.test.ts +2220 -0
- package/src/plugins/oauth/pkce.ts +58 -0
- package/src/plugins/openapi/index.ts +298 -0
- package/src/plugins/openapi/openapi.test.ts +425 -0
- package/src/plugins/openapi/spec-builder.ts +287 -0
- package/src/plugins/rate-limit/express.test.ts +89 -0
- package/src/plugins/rate-limit/express.ts +86 -0
- package/src/plugins/rate-limit/hono.test.ts +60 -0
- package/src/plugins/rate-limit/hono.ts +74 -0
- package/src/plugins/rate-limit/index.ts +383 -0
- package/src/plugins/rate-limit/memory-store.ts +61 -0
- package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
- package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
- package/src/plugins/rate-limit/sveltekit.ts +66 -0
- package/src/plugins/social-login/index.ts +715 -0
- package/src/plugins/social-login/providers/apple.ts +34 -0
- package/src/plugins/social-login/providers/discord.ts +26 -0
- package/src/plugins/social-login/providers/github.ts +54 -0
- package/src/plugins/social-login/providers/google.ts +23 -0
- package/src/plugins/social-login/providers/index.ts +22 -0
- package/src/plugins/social-login/providers/microsoft.ts +35 -0
- package/src/plugins/social-login/providers/oidc.ts +37 -0
- package/src/plugins/social-login/providers/providers.test.ts +211 -0
- package/src/plugins/social-login/social-login.test.ts +675 -0
- package/src/plugins/social-login/types.ts +80 -0
- package/src/plugins/tenancy/index.ts +544 -0
- package/src/plugins/tenancy/tenancy.test.ts +323 -0
- package/src/plugins/two-factor/index.ts +569 -0
- package/src/plugins/two-factor/two-factor.test.ts +235 -0
- package/src/plugins/webauthn/index.ts +554 -0
- package/src/plugins/webauthn/webauthn.test.ts +472 -0
- package/src/plugins/webhook/builtin-events.ts +29 -0
- package/src/plugins/webhook/delivery.test.ts +51 -0
- package/src/plugins/webhook/delivery.ts +154 -0
- package/src/plugins/webhook/hooks.ts +89 -0
- package/src/plugins/webhook/index.ts +445 -0
- package/src/plugins/webhook/queue/database.ts +67 -0
- package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
- package/src/plugins/webhook/queue/in-memory.ts +71 -0
- package/src/plugins/webhook/queue/types.ts +51 -0
- package/src/plugins/webhook/registry.test.ts +48 -0
- package/src/plugins/webhook/registry.ts +64 -0
- package/src/plugins/webhook/signing.ts +45 -0
- package/src/plugins/webhook/ssrf.ts +198 -0
- package/src/plugins/webhook/types.ts +138 -0
- package/src/plugins/webhook/webhook.test.ts +324 -0
- package/src/sveltekit/actions.ts +233 -0
- package/src/sveltekit/catch-all.ts +43 -0
- package/src/sveltekit/cookies.ts +136 -0
- package/src/sveltekit/handle.ts +356 -0
- package/src/sveltekit/helpers.ts +69 -0
- package/src/sveltekit/index.ts +74 -0
- package/src/sveltekit/protect.ts +80 -0
- package/src/sveltekit/sveltekit-types.test.ts +31 -0
- package/src/sveltekit/sveltekit.test.ts +799 -0
- package/src/sveltekit/types.ts +134 -0
- package/src/sveltekit/validated.test.ts +117 -0
- package/src/sveltekit/validated.ts +78 -0
- package/src/testing/adapter-conformance.test.ts +408 -0
- package/src/testing/checks.test.ts +124 -0
- package/src/testing/checks.ts +304 -0
- package/src/testing/index.ts +107 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { PluginContext } from '../../core/plugin';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createTestAdapter } from '../../testing';
|
|
4
|
+
import { dataIsolation } from './index';
|
|
5
|
+
|
|
6
|
+
describe('data-isolation plugin', () => {
|
|
7
|
+
it('generates scope rules for matching models', async () => {
|
|
8
|
+
const db = createTestAdapter();
|
|
9
|
+
const plugin = dataIsolation({
|
|
10
|
+
scopes: [{
|
|
11
|
+
name: 'site',
|
|
12
|
+
field: 'siteId',
|
|
13
|
+
models: ['sale', 'inventory'],
|
|
14
|
+
resolveValue: async () => 3,
|
|
15
|
+
}],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
19
|
+
const rules = await plugin.scopeRules!('1', 'sale', ctx);
|
|
20
|
+
|
|
21
|
+
expect(rules).not.toBeNull();
|
|
22
|
+
expect(rules!.filters).toEqual([{ field: 'siteId', operator: '=', value: 3 }]);
|
|
23
|
+
expect(rules!.defaults).toEqual({ siteId: 3 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null for non-matching models', async () => {
|
|
27
|
+
const db = createTestAdapter();
|
|
28
|
+
const plugin = dataIsolation({
|
|
29
|
+
scopes: [{
|
|
30
|
+
name: 'site',
|
|
31
|
+
field: 'siteId',
|
|
32
|
+
models: ['sale'],
|
|
33
|
+
resolveValue: async () => 3,
|
|
34
|
+
}],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
38
|
+
const rules = await plugin.scopeRules!('1', 'user', ctx);
|
|
39
|
+
|
|
40
|
+
expect(rules).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('stacks multiple scopes for same model', async () => {
|
|
44
|
+
const db = createTestAdapter();
|
|
45
|
+
const plugin = dataIsolation({
|
|
46
|
+
scopes: [
|
|
47
|
+
{ name: 'org', field: 'orgId', models: ['sale'], resolveValue: async () => 7 },
|
|
48
|
+
{ name: 'site', field: 'siteId', models: ['sale'], resolveValue: async () => 3 },
|
|
49
|
+
],
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
53
|
+
const rules = await plugin.scopeRules!('1', 'sale', ctx);
|
|
54
|
+
|
|
55
|
+
expect(rules!.filters).toHaveLength(2);
|
|
56
|
+
expect(rules!.defaults).toEqual({ orgId: 7, siteId: 3 });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('supports wildcard * model matching', async () => {
|
|
60
|
+
const db = createTestAdapter();
|
|
61
|
+
const plugin = dataIsolation({
|
|
62
|
+
scopes: [{
|
|
63
|
+
name: 'tenant',
|
|
64
|
+
field: 'tenantId',
|
|
65
|
+
models: ['*'],
|
|
66
|
+
resolveValue: async () => 1,
|
|
67
|
+
}],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
71
|
+
const rules = await plugin.scopeRules!('1', 'anything', ctx);
|
|
72
|
+
|
|
73
|
+
expect(rules).not.toBeNull();
|
|
74
|
+
expect(rules!.filters[0].value).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it.each([null, undefined])('denies by default when an applicable scope resolves to %s', async (value) => {
|
|
78
|
+
const db = createTestAdapter();
|
|
79
|
+
const plugin = dataIsolation({
|
|
80
|
+
scopes: [{
|
|
81
|
+
name: 'site',
|
|
82
|
+
field: 'siteId',
|
|
83
|
+
models: ['sale'],
|
|
84
|
+
resolveValue: async () => value,
|
|
85
|
+
}],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
89
|
+
|
|
90
|
+
await expect(plugin.scopeRules!('1', 'sale', ctx)).rejects.toMatchObject({
|
|
91
|
+
code: 'FORBIDDEN',
|
|
92
|
+
message: 'Data isolation scope \'site\' could not be resolved',
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('supports explicit unsafe legacy skip mode for unresolved scopes', async () => {
|
|
97
|
+
const db = createTestAdapter();
|
|
98
|
+
const plugin = dataIsolation({
|
|
99
|
+
unresolvedScope: 'skip',
|
|
100
|
+
scopes: [{
|
|
101
|
+
name: 'site',
|
|
102
|
+
field: 'siteId',
|
|
103
|
+
models: ['sale'],
|
|
104
|
+
resolveValue: async () => null,
|
|
105
|
+
}],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
109
|
+
|
|
110
|
+
await expect(plugin.scopeRules!('1', 'sale', ctx)).resolves.toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('bypass methods', () => {
|
|
114
|
+
it('withoutScope bypasses a specific scope', async () => {
|
|
115
|
+
const db = createTestAdapter();
|
|
116
|
+
const plugin = dataIsolation({
|
|
117
|
+
scopes: [
|
|
118
|
+
{ name: 'org', field: 'orgId', models: ['sale'], resolveValue: async () => 7 },
|
|
119
|
+
{ name: 'site', field: 'siteId', models: ['sale'], resolveValue: async () => 3 },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
124
|
+
const methods = plugin.methods!(ctx) as { withoutScope: <T>(name: string, fn: () => Promise<T>) => Promise<T> };
|
|
125
|
+
|
|
126
|
+
const rules = await methods.withoutScope('site', async () => {
|
|
127
|
+
return plugin.scopeRules!('1', 'sale', ctx);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(rules!.filters).toHaveLength(1);
|
|
131
|
+
expect(rules!.filters[0].field).toBe('orgId');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('withoutScope explicitly bypasses an unresolved named scope', async () => {
|
|
135
|
+
const db = createTestAdapter();
|
|
136
|
+
const plugin = dataIsolation({
|
|
137
|
+
scopes: [{ name: 'site', field: 'siteId', models: ['sale'], resolveValue: async () => null }],
|
|
138
|
+
});
|
|
139
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
140
|
+
const methods = plugin.methods!(ctx) as { withoutScope: <T>(name: string, fn: () => Promise<T>) => Promise<T> };
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
methods.withoutScope('site', () => plugin.scopeRules!('1', 'sale', ctx)),
|
|
144
|
+
).resolves.toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('unscoped bypasses all scopes', async () => {
|
|
148
|
+
const db = createTestAdapter();
|
|
149
|
+
const plugin = dataIsolation({
|
|
150
|
+
scopes: [
|
|
151
|
+
{ name: 'org', field: 'orgId', models: ['sale'], resolveValue: async () => 7 },
|
|
152
|
+
{ name: 'site', field: 'siteId', models: ['sale'], resolveValue: async () => 3 },
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
157
|
+
const methods = plugin.methods!(ctx) as { unscoped: <T>(fn: () => Promise<T>) => Promise<T> };
|
|
158
|
+
|
|
159
|
+
const rules = await methods.unscoped(async () => {
|
|
160
|
+
return plugin.scopeRules!('1', 'sale', ctx);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(rules).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// H4 regression: pre-fix, `unscoped()` set a module-level flag so a
|
|
167
|
+
// concurrent request would see no isolation while the first request
|
|
168
|
+
// was still awaiting inside the bypass window.
|
|
169
|
+
it('unscoped does not leak across concurrent async flows (H4)', async () => {
|
|
170
|
+
const db = createTestAdapter();
|
|
171
|
+
const plugin = dataIsolation({
|
|
172
|
+
scopes: [
|
|
173
|
+
{ name: 'org', field: 'orgId', models: ['sale'], resolveValue: async () => 7 },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
const ctx: PluginContext = { db, config: { jwt: { key: 'x'.repeat(32) }, database: db } };
|
|
177
|
+
const methods = plugin.methods!(ctx) as { unscoped: <T>(fn: () => Promise<T>) => Promise<T> };
|
|
178
|
+
|
|
179
|
+
// Flow A holds an unscoped window open across an await.
|
|
180
|
+
// Flow B asks for scope rules in the middle.
|
|
181
|
+
const flowB = (async (): Promise<unknown> => {
|
|
182
|
+
// Microtask-delay so flow A is already "inside" unscoped().
|
|
183
|
+
await Promise.resolve();
|
|
184
|
+
return plugin.scopeRules!('1', 'sale', ctx);
|
|
185
|
+
})();
|
|
186
|
+
const flowA = methods.unscoped(async () => {
|
|
187
|
+
await new Promise(r => setTimeout(r, 5));
|
|
188
|
+
return 'a-done';
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const [flowAResult, flowBResult] = await Promise.all([flowA, flowB]);
|
|
192
|
+
expect(flowAResult).toBe('a-done');
|
|
193
|
+
// Flow B is in its own async context — still scoped.
|
|
194
|
+
expect((flowBResult as { filters: { field: string }[] }).filters[0].field).toBe('orgId');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-level data isolation plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Lets you scope read/write access to a database row by per-user assignments,
|
|
5
|
+
* for example "user can only see rows where `org_id` equals their assigned
|
|
6
|
+
* org". Works with any database adapter via the core `scopeRules` capability.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ScopeRule } from '../../adapters/database/types';
|
|
12
|
+
import type { FortressPlugin, PluginContext } from '../../core/plugin';
|
|
13
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
14
|
+
import { Errors } from '../../core/errors';
|
|
15
|
+
|
|
16
|
+
export interface DataIsolationScope {
|
|
17
|
+
/** Scope name for identification and bypass control */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Column name that holds the scoping value in the target tables */
|
|
20
|
+
field: string;
|
|
21
|
+
/** Which models (tables) this scope applies to. Use ['*'] for all models. */
|
|
22
|
+
models: string[];
|
|
23
|
+
/** Resolve the current user's value for this scope */
|
|
24
|
+
resolveValue: (userId: string, ctx: PluginContext) => Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DataIsolationConfig {
|
|
28
|
+
scopes: DataIsolationScope[];
|
|
29
|
+
/**
|
|
30
|
+
* Behavior when an applicable scope resolves to `null` or `undefined`.
|
|
31
|
+
* Defaults to `'deny'`. `'skip'` is an unsafe legacy compatibility mode
|
|
32
|
+
* that removes that scope's row isolation for the operation.
|
|
33
|
+
*/
|
|
34
|
+
unresolvedScope?: 'deny' | 'skip';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-request bypass state. Stored in `AsyncLocalStorage` so concurrent
|
|
39
|
+
* requests don't trample each other's `unscoped()` / `withoutScope()`
|
|
40
|
+
* windows. The pre-fix implementation used module-level globals — one
|
|
41
|
+
* request's `unscoped()` await would silently disable isolation for every
|
|
42
|
+
* other in-flight request handling the same async tick (H4).
|
|
43
|
+
*
|
|
44
|
+
* Runtime support: `AsyncLocalStorage` is Node core and is implemented by
|
|
45
|
+
* Bun, Deno, Cloudflare Workers (with the `nodejs_compat` flag and a
|
|
46
|
+
* compatibility date of 2024-09-23 or later), and Vercel Edge. This plugin
|
|
47
|
+
* uses only the universally-supported subset — `run()` and `getStore()`,
|
|
48
|
+
* never `enterWith()`/`disable()` — and wraps native async callbacks, so the
|
|
49
|
+
* workerd "thenable context-loss" caveat does not apply. The only hot-path
|
|
50
|
+
* call is the cheap `getStore()` read in `scopeRules`. This is also the
|
|
51
|
+
* direction of the TC39 AsyncContext proposal (Stage 2), so the approach is
|
|
52
|
+
* standards-track rather than a Node-specific hack.
|
|
53
|
+
*
|
|
54
|
+
* The dependency is scoped to this module — importing the main
|
|
55
|
+
* `@bajustone/fortress` entry only pulls in plugin *types*, not
|
|
56
|
+
* `node:async_hooks`, so it loads only when this plugin is actually used.
|
|
57
|
+
* On a runtime that genuinely lacks `node:async_hooks`, importing the
|
|
58
|
+
* plugin throws at import time.
|
|
59
|
+
*/
|
|
60
|
+
interface BypassState {
|
|
61
|
+
all: boolean;
|
|
62
|
+
scopes: Set<string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bypassStore = new AsyncLocalStorage<BypassState>();
|
|
66
|
+
|
|
67
|
+
function currentBypassState(): BypassState | undefined {
|
|
68
|
+
return bypassStore.getStore();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface DataIsolationMethods {
|
|
72
|
+
withoutScope: <T>(scopeName: string, fn: () => Promise<T>) => Promise<T>;
|
|
73
|
+
unscoped: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Data isolation plugin factory. Returns a {@link FortressPlugin} that scopes
|
|
77
|
+
* read and write access to a database row by per-user scope assignments,
|
|
78
|
+
* enforcing isolation through the core `scopeRules` capability.
|
|
79
|
+
*/
|
|
80
|
+
export function dataIsolation(config: DataIsolationConfig): FortressPlugin & { readonly name: 'data-isolation' } {
|
|
81
|
+
return {
|
|
82
|
+
name: 'data-isolation',
|
|
83
|
+
|
|
84
|
+
models: [{
|
|
85
|
+
name: 'user_scope_assignment',
|
|
86
|
+
fields: {
|
|
87
|
+
id: { type: 'number', required: true },
|
|
88
|
+
userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
|
|
89
|
+
scopeName: { type: 'string', required: true },
|
|
90
|
+
scopeValue: { type: 'string', required: true },
|
|
91
|
+
createdAt: { type: 'date', required: true },
|
|
92
|
+
},
|
|
93
|
+
}],
|
|
94
|
+
|
|
95
|
+
async scopeRules(userId: string, model: string, ctx: PluginContext): Promise<ScopeRule | null> {
|
|
96
|
+
const state = currentBypassState();
|
|
97
|
+
if (state?.all)
|
|
98
|
+
return null;
|
|
99
|
+
|
|
100
|
+
const filters: ScopeRule['filters'] = [];
|
|
101
|
+
const defaults: ScopeRule['defaults'] = {};
|
|
102
|
+
|
|
103
|
+
for (const scope of config.scopes) {
|
|
104
|
+
if (state?.scopes.has(scope.name))
|
|
105
|
+
continue;
|
|
106
|
+
|
|
107
|
+
// Check if this scope applies to the queried model
|
|
108
|
+
const applies = scope.models.includes('*') || scope.models.includes(model);
|
|
109
|
+
if (!applies)
|
|
110
|
+
continue;
|
|
111
|
+
|
|
112
|
+
const value = await scope.resolveValue(userId, ctx);
|
|
113
|
+
if (value === undefined || value === null) {
|
|
114
|
+
if (config.unresolvedScope === 'skip')
|
|
115
|
+
continue;
|
|
116
|
+
throw Errors.forbidden(`Data isolation scope '${scope.name}' could not be resolved`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
filters.push({ field: scope.field, operator: '=', value });
|
|
120
|
+
defaults[scope.field] = value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return filters.length > 0 ? { filters, defaults } : null;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
methods: () => ({
|
|
127
|
+
/**
|
|
128
|
+
* Execute a callback with a specific scope bypassed.
|
|
129
|
+
* Queries within the callback will not have the named scope filter applied.
|
|
130
|
+
*
|
|
131
|
+
* The bypass is async-context-local: it applies only to async work
|
|
132
|
+
* spawned inside `fn` and never leaks across concurrent requests.
|
|
133
|
+
*/
|
|
134
|
+
async withoutScope<T>(scopeName: string, fn: () => Promise<T>): Promise<T> {
|
|
135
|
+
const existing = currentBypassState();
|
|
136
|
+
const nextScopes = new Set(existing?.scopes ?? []);
|
|
137
|
+
nextScopes.add(scopeName);
|
|
138
|
+
const next: BypassState = { all: existing?.all ?? false, scopes: nextScopes };
|
|
139
|
+
return bypassStore.run(next, fn);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Execute a callback with all scopes bypassed.
|
|
144
|
+
* Use with caution \u2014 no row-level isolation is applied.
|
|
145
|
+
*
|
|
146
|
+
* The bypass is async-context-local. Calls outside the callback (and
|
|
147
|
+
* concurrent requests running on different async contexts) are
|
|
148
|
+
* unaffected.
|
|
149
|
+
*/
|
|
150
|
+
async unscoped<T>(fn: () => Promise<T>): Promise<T> {
|
|
151
|
+
const existing = currentBypassState();
|
|
152
|
+
const next: BypassState = { all: true, scopes: new Set(existing?.scopes ?? []) };
|
|
153
|
+
return bypassStore.run(next, fn);
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { Fortress } from '../../core/fortress';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createFortress } from '../../core/fortress';
|
|
4
|
+
import { createTestAdapter } from '../../testing';
|
|
5
|
+
import { emailVerification } from './index';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'email-verify-test-secret-32chars!';
|
|
8
|
+
|
|
9
|
+
describe('email-verification plugin', () => {
|
|
10
|
+
let fortress: Fortress<any>;
|
|
11
|
+
let capturedToken: string | null;
|
|
12
|
+
let capturedEmail: string | null;
|
|
13
|
+
const onSend = vi.fn(async (email: string, token: string, _userId: string) => {
|
|
14
|
+
capturedEmail = email;
|
|
15
|
+
capturedToken = token;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
capturedToken = null;
|
|
20
|
+
capturedEmail = null;
|
|
21
|
+
onSend.mockClear();
|
|
22
|
+
|
|
23
|
+
fortress = createFortress({
|
|
24
|
+
jwt: { key: SECRET },
|
|
25
|
+
database: createTestAdapter(),
|
|
26
|
+
plugins: [emailVerification({ onSendVerification: onSend })],
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('afterRegister hook', () => {
|
|
31
|
+
it('calls onSendVerification when user is created', async () => {
|
|
32
|
+
await fortress.auth.createUser({
|
|
33
|
+
email: 'alice@example.com',
|
|
34
|
+
name: 'Alice',
|
|
35
|
+
password: 'password-123456',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(onSend).toHaveBeenCalledOnce();
|
|
39
|
+
expect(capturedEmail).toBe('alice@example.com');
|
|
40
|
+
expect(capturedToken).toBeTruthy();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('verify method', () => {
|
|
45
|
+
it('verifies a valid token', async () => {
|
|
46
|
+
await fortress.auth.createUser({
|
|
47
|
+
email: 'alice@example.com',
|
|
48
|
+
name: 'Alice',
|
|
49
|
+
password: 'password-123456',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await (fortress.plugins['email-verification'].verify as (token: string) => Promise<{ userId: string; email: string }>)(capturedToken!);
|
|
53
|
+
|
|
54
|
+
expect(result.email).toBe('alice@example.com');
|
|
55
|
+
expect(result.userId).toBeDefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects already-used token', async () => {
|
|
59
|
+
await fortress.auth.createUser({
|
|
60
|
+
email: 'bob@example.com',
|
|
61
|
+
name: 'Bob',
|
|
62
|
+
password: 'password-123456',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const verify = fortress.plugins['email-verification'].verify as (token: string) => Promise<unknown>;
|
|
66
|
+
await verify(capturedToken!);
|
|
67
|
+
|
|
68
|
+
await expect(verify(capturedToken!)).rejects.toThrow('Invalid or expired verification token');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects invalid token', async () => {
|
|
72
|
+
const verify = fortress.plugins['email-verification'].verify as (token: string) => Promise<unknown>;
|
|
73
|
+
await expect(verify('bogus-token')).rejects.toThrow('Invalid or expired verification token');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects expired token', async () => {
|
|
77
|
+
// Create with negative expiry to guarantee token is already expired
|
|
78
|
+
const shortFortress = createFortress({
|
|
79
|
+
jwt: { key: SECRET },
|
|
80
|
+
database: createTestAdapter(),
|
|
81
|
+
plugins: [emailVerification({ tokenExpirySeconds: -1, onSendVerification: onSend })],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await shortFortress.auth.createUser({
|
|
85
|
+
email: 'carol@example.com',
|
|
86
|
+
name: 'Carol',
|
|
87
|
+
password: 'password-123456',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const verify = shortFortress.plugins['email-verification'].verify as (token: string) => Promise<unknown>;
|
|
91
|
+
await expect(verify(capturedToken!)).rejects.toThrow('Invalid or expired verification token');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('post-auth gate', () => {
|
|
96
|
+
it('holds unverified login and completes after token verification', async () => {
|
|
97
|
+
await fortress.auth.createUser({
|
|
98
|
+
email: 'dave@example.com',
|
|
99
|
+
name: 'Dave',
|
|
100
|
+
password: 'password-123456',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await fortress.auth.login('dave@example.com', 'password-123456');
|
|
104
|
+
expect(result.status).toBe('pending');
|
|
105
|
+
expect(result.pluginData?.requiresEmailVerification).toBe(true);
|
|
106
|
+
expect(await fortress.config.database.count({ model: 'refresh_token' })).toBe(0);
|
|
107
|
+
if (result.status !== 'pending' || !result.pending)
|
|
108
|
+
throw new Error('Expected email-verification continuation');
|
|
109
|
+
|
|
110
|
+
const complete = fortress.plugins['email-verification'].completeVerification as (
|
|
111
|
+
continuationToken: string,
|
|
112
|
+
verificationToken: string,
|
|
113
|
+
) => Promise<unknown>;
|
|
114
|
+
await expect(complete(result.pending.continuationToken, capturedToken!)).resolves.toMatchObject({
|
|
115
|
+
status: 'success',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('allows verified user login', async () => {
|
|
120
|
+
await fortress.auth.createUser({
|
|
121
|
+
email: 'eve@example.com',
|
|
122
|
+
name: 'Eve',
|
|
123
|
+
password: 'password-123456',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Verify email first
|
|
127
|
+
const verify = fortress.plugins['email-verification'].verify as (token: string) => Promise<unknown>;
|
|
128
|
+
await verify(capturedToken!);
|
|
129
|
+
|
|
130
|
+
// Login should succeed
|
|
131
|
+
const result = await fortress.auth.login('eve@example.com', 'password-123456');
|
|
132
|
+
if (result.status !== 'success')
|
|
133
|
+
throw new Error('Expected successful login');
|
|
134
|
+
expect(result.accessToken).toBeTruthy();
|
|
135
|
+
expect(result.user.email).toBe('eve@example.com');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('sendVerification method', () => {
|
|
140
|
+
it('generates a new token for existing user', async () => {
|
|
141
|
+
const user = await fortress.auth.createUser({
|
|
142
|
+
email: 'frank@example.com',
|
|
143
|
+
name: 'Frank',
|
|
144
|
+
password: 'password-123456',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
onSend.mockClear();
|
|
148
|
+
const sendVerification = fortress.plugins['email-verification'].sendVerification as (userId: string) => Promise<{ sent: true }>;
|
|
149
|
+
const result = await sendVerification(user.id);
|
|
150
|
+
|
|
151
|
+
expect(result).toEqual({ sent: true });
|
|
152
|
+
expect(onSend).toHaveBeenCalledOnce();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('adopts a changed email only after its token is verified', async () => {
|
|
156
|
+
const user = await fortress.auth.createUser({
|
|
157
|
+
email: 'old@example.com',
|
|
158
|
+
name: 'Changed',
|
|
159
|
+
password: 'password-123456',
|
|
160
|
+
});
|
|
161
|
+
onSend.mockClear();
|
|
162
|
+
const methods = fortress.plugins['email-verification'];
|
|
163
|
+
await methods.sendVerification(user.id, 'new@example.com');
|
|
164
|
+
const token = onSend.mock.calls[0]![1];
|
|
165
|
+
|
|
166
|
+
const findUser = () => fortress.config.database.findOne<{ email: string; emailVerified: boolean }>({
|
|
167
|
+
model: 'user',
|
|
168
|
+
where: [{ field: 'id', operator: '=', value: user.id }],
|
|
169
|
+
});
|
|
170
|
+
expect((await findUser())?.email).toBe('old@example.com');
|
|
171
|
+
await methods.verify(token);
|
|
172
|
+
expect(await findUser()).toMatchObject({
|
|
173
|
+
email: 'new@example.com',
|
|
174
|
+
emailVerified: true,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('requireVerification: false', () => {
|
|
180
|
+
it('allows login without verification', async () => {
|
|
181
|
+
const noRequireFortress = createFortress({
|
|
182
|
+
jwt: { key: SECRET },
|
|
183
|
+
database: createTestAdapter(),
|
|
184
|
+
plugins: [emailVerification({ requireVerification: false })],
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await noRequireFortress.auth.createUser({
|
|
188
|
+
email: 'grace@example.com',
|
|
189
|
+
name: 'Grace',
|
|
190
|
+
password: 'password-123456',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const result = await noRequireFortress.auth.login('grace@example.com', 'password-123456');
|
|
194
|
+
if (result.status !== 'success')
|
|
195
|
+
throw new Error('Expected successful login');
|
|
196
|
+
expect(result.accessToken).toBeTruthy();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|