@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,80 @@
|
|
|
1
|
+
// --- Social Login Provider Types ---
|
|
2
|
+
|
|
3
|
+
/** Normalized user profile returned by all providers */
|
|
4
|
+
export interface ProviderProfile {
|
|
5
|
+
/** Provider's unique user ID */
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
/** Whether the provider has verified ownership of `email`. */
|
|
9
|
+
emailVerified: boolean;
|
|
10
|
+
name?: string;
|
|
11
|
+
displayName?: string;
|
|
12
|
+
avatar?: string;
|
|
13
|
+
/** Full raw response from the provider (for custom mapping) */
|
|
14
|
+
raw: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pre-configured provider definition with OAuth/OIDC endpoints and profile mapping */
|
|
18
|
+
export interface ProviderDefinition {
|
|
19
|
+
name: string;
|
|
20
|
+
/** OIDC discovery URL (.well-known/openid-configuration). Undefined for non-OIDC providers (e.g., GitHub). */
|
|
21
|
+
discoveryUrl?: string;
|
|
22
|
+
authorizationUrl: string;
|
|
23
|
+
tokenUrl: string;
|
|
24
|
+
/** User info endpoint. Undefined for Apple (profile comes from ID token only). */
|
|
25
|
+
userInfoUrl?: string;
|
|
26
|
+
/** Expected issuer for ID tokens. Resolved from discovery when omitted. */
|
|
27
|
+
issuer?: string;
|
|
28
|
+
/** JWKS URI for ID token verification. Resolved from discovery when omitted. */
|
|
29
|
+
jwksUri?: string;
|
|
30
|
+
defaultScopes: string[];
|
|
31
|
+
/** Maps the raw provider response to a normalized ProviderProfile */
|
|
32
|
+
mapProfile: (raw: Record<string, unknown>) => ProviderProfile;
|
|
33
|
+
/** Optional provider-specific profile fetcher (e.g. GitHub verified primary email). */
|
|
34
|
+
fetchProfile?: (accessToken: string) => Promise<Record<string, unknown>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** User-facing config for a single provider (passed to socialLogin plugin) */
|
|
38
|
+
export interface ProviderConfig {
|
|
39
|
+
name: string;
|
|
40
|
+
clientId: string;
|
|
41
|
+
clientSecret: string;
|
|
42
|
+
scopes?: string[];
|
|
43
|
+
/** Microsoft: tenant ID, 'common', or 'organizations' */
|
|
44
|
+
tenant?: string;
|
|
45
|
+
/** Restrict registration to specific email domains */
|
|
46
|
+
allowedDomains?: string[];
|
|
47
|
+
/** Generic OIDC: issuer URL for discovery */
|
|
48
|
+
issuer?: string;
|
|
49
|
+
/** Override discovered authorization endpoint for custom OIDC providers. */
|
|
50
|
+
authorizationUrl?: string;
|
|
51
|
+
/** Override discovered token endpoint for custom OIDC providers. */
|
|
52
|
+
tokenUrl?: string;
|
|
53
|
+
/** Override discovered userinfo endpoint for custom OIDC providers. */
|
|
54
|
+
userInfoUrl?: string;
|
|
55
|
+
/** Override discovered JWKS endpoint for custom OIDC providers. */
|
|
56
|
+
jwksUri?: string;
|
|
57
|
+
/** Apple Sign In: team ID used to generate the ES256 client-secret JWT. */
|
|
58
|
+
teamId?: string;
|
|
59
|
+
/** Apple Sign In: key ID used to generate the ES256 client-secret JWT. */
|
|
60
|
+
keyId?: string;
|
|
61
|
+
/** Apple Sign In: PKCS#8 private key used to generate the ES256 client-secret JWT. */
|
|
62
|
+
privateKey?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Plugin-level config for the social login plugin */
|
|
66
|
+
export interface SocialLoginConfig {
|
|
67
|
+
providers: ProviderConfig[];
|
|
68
|
+
/** Auto-create user on first social login (default: true) */
|
|
69
|
+
autoRegister?: boolean;
|
|
70
|
+
/** Link social identity to existing user by email (default: true) */
|
|
71
|
+
linkAccounts?: boolean;
|
|
72
|
+
/** Map provider profile fields to Fortress user fields */
|
|
73
|
+
mapProfile?: (provider: string, profile: ProviderProfile) => { email: string; name: string };
|
|
74
|
+
/** Called on first-ever login for a social user */
|
|
75
|
+
onFirstLogin?: (user: { id: string }, provider: string, profile: ProviderProfile) => Promise<void>;
|
|
76
|
+
/** Persist encrypted provider access/refresh tokens. Default: false (explicit opt-in). */
|
|
77
|
+
persistTokens?: boolean;
|
|
78
|
+
/** 32-byte AES-256-GCM key (raw bytes as base64/base64url/hex, or a UTF-8 string of at least 32 bytes). */
|
|
79
|
+
tokenEncryptionKey?: string;
|
|
80
|
+
}
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-per-tenant tenancy plugin for fortress (PostgreSQL only).
|
|
3
|
+
*
|
|
4
|
+
* Switches the active PostgreSQL `search_path` per request based on the
|
|
5
|
+
* resolved tenant, providing strong data isolation between tenants without
|
|
6
|
+
* touching application code. Requires a PostgreSQL-backed Drizzle adapter.
|
|
7
|
+
*
|
|
8
|
+
* The active tenant is read from the **verified** `tenantId` JWT claim (set by
|
|
9
|
+
* {@link enrichTokenClaims} from the user's default `tenant_user` membership),
|
|
10
|
+
* never from a client-supplied header — so a caller can only ever reach a
|
|
11
|
+
* tenant they belong to. Switching tenants requires {@link switchTenant} plus a
|
|
12
|
+
* token refresh.
|
|
13
|
+
*
|
|
14
|
+
* Isolation is enforced atomically: each database operation runs inside a
|
|
15
|
+
* transaction that first pins `search_path` via a bound `set_config(..., true)`
|
|
16
|
+
* call, so the schema selection and the query share one pooled connection and
|
|
17
|
+
* the path cannot leak or be discarded.
|
|
18
|
+
*
|
|
19
|
+
* HTTP endpoints are opt-in: pass `tenancy({ routes: true })` to mount the
|
|
20
|
+
* routes under `/tenancy/*`. The programmatic methods on
|
|
21
|
+
* `fortress.plugins.tenancy` are always available regardless of the flag.
|
|
22
|
+
*
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
27
|
+
import type { FortressPlugin, PluginContext, PluginRouteContext } from '../../core/plugin';
|
|
28
|
+
import { Errors } from '../../core/errors';
|
|
29
|
+
import { arr, bool, endpoint, id, obj, ref, str } from '../../core/schema-builder';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Callback invoked once, inside the creation transaction, after a tenant's
|
|
33
|
+
* schema is created — the hook for per-tenant table DDL / migrations.
|
|
34
|
+
*/
|
|
35
|
+
export type OnSchemaCreated = (
|
|
36
|
+
schemaName: string,
|
|
37
|
+
rawQuery: <T>(sql: string, params?: unknown[]) => Promise<T[]>,
|
|
38
|
+
) => Promise<void>;
|
|
39
|
+
|
|
40
|
+
export interface TenancyConfig {
|
|
41
|
+
/**
|
|
42
|
+
* Schema prefix for tenant schemas (default: 'tenant_'). Must match
|
|
43
|
+
* `^[a-z_][a-z0-9_]*$` — validated at factory time.
|
|
44
|
+
*/
|
|
45
|
+
schemaPrefix?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Mount HTTP routes under `/tenancy/*`. Default `false`. The programmatic
|
|
48
|
+
* methods on `fortress.plugins.tenancy` are always available; this flag only
|
|
49
|
+
* controls HTTP mounting.
|
|
50
|
+
*/
|
|
51
|
+
routes?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Run once inside the `createTenant` transaction, right after the tenant's
|
|
54
|
+
* PostgreSQL schema is created. Use it to create the tenant's business
|
|
55
|
+
* tables (or run a migration) against the new schema. The supplied
|
|
56
|
+
* `rawQuery` is bound to the same connection/transaction as the schema
|
|
57
|
+
* creation, so statements should reference the schema explicitly (e.g.
|
|
58
|
+
* `CREATE TABLE "${schemaName}".widgets (...)`).
|
|
59
|
+
*/
|
|
60
|
+
onSchemaCreated?: OnSchemaCreated;
|
|
61
|
+
/**
|
|
62
|
+
* When `true`, `deleteTenant` also issues `DROP SCHEMA IF EXISTS <schema>
|
|
63
|
+
* CASCADE`, destroying all tenant data. Default `false` — destructive drops
|
|
64
|
+
* must be opted into explicitly.
|
|
65
|
+
*/
|
|
66
|
+
dropSchemaOnDelete?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TenantRecord {
|
|
70
|
+
id: string;
|
|
71
|
+
name: string;
|
|
72
|
+
taxId: string;
|
|
73
|
+
description: string | null;
|
|
74
|
+
createdAt: Date;
|
|
75
|
+
updatedAt: Date;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface TenantUserRecord {
|
|
79
|
+
tenantId: string;
|
|
80
|
+
userId: string;
|
|
81
|
+
isDefault: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TenancyMethods {
|
|
85
|
+
createTenant: (
|
|
86
|
+
input: { name: string; taxId: string; description?: string },
|
|
87
|
+
routeCtx?: PluginRouteContext,
|
|
88
|
+
) => Promise<TenantRecord>;
|
|
89
|
+
deleteTenant: (
|
|
90
|
+
input: { id: string },
|
|
91
|
+
routeCtx?: PluginRouteContext,
|
|
92
|
+
) => Promise<{ ok: true }>;
|
|
93
|
+
addUserToTenant: (userId: string, tenantId: string) => Promise<void>;
|
|
94
|
+
getUserTenants: (userId: string) => Promise<TenantRecord[]>;
|
|
95
|
+
getMyTenants: (
|
|
96
|
+
input: { userId?: string },
|
|
97
|
+
routeCtx?: PluginRouteContext,
|
|
98
|
+
) => Promise<{ tenants: TenantRecord[] }>;
|
|
99
|
+
switchTenant: (
|
|
100
|
+
input: { taxId: string; userId?: string },
|
|
101
|
+
routeCtx?: PluginRouteContext,
|
|
102
|
+
) => Promise<{ ok: true }>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const SAFE_SCHEMA_PREFIX = /^[a-z_][a-z0-9_]*$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Subject-id strings that are safe to interpolate into a Postgres schema
|
|
109
|
+
* name. The tenant claim is verified-JWT-supplied but is *user-controlled*
|
|
110
|
+
* at sign-up time, so any character that isn't safe for an identifier must
|
|
111
|
+
* be rejected before {@link tenantSchemaName} concatenates it.
|
|
112
|
+
*/
|
|
113
|
+
const SAFE_TENANT_ID = /^[\w-]+$/;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate the configured schema prefix once, at factory time. The full
|
|
117
|
+
* schema name is `${prefix}${numericId}`, so a safe prefix guarantees a safe
|
|
118
|
+
* identifier — no per-value escaping is needed.
|
|
119
|
+
*/
|
|
120
|
+
function assertSafeSchemaPrefix(prefix: string): void {
|
|
121
|
+
if (!SAFE_SCHEMA_PREFIX.test(prefix))
|
|
122
|
+
throw Errors.badRequest(`Invalid tenancy schemaPrefix '${prefix}': must match ${SAFE_SCHEMA_PREFIX}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Routes ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
const errorRef = ref('ErrorResponse');
|
|
128
|
+
|
|
129
|
+
const tenantResponse = obj({
|
|
130
|
+
id: id('Tenant id'),
|
|
131
|
+
name: str('Tenant name'),
|
|
132
|
+
taxId: str('Unique tenant tax id / external code'),
|
|
133
|
+
description: str('Optional description'),
|
|
134
|
+
createdAt: str('ISO 8601 creation timestamp'),
|
|
135
|
+
updatedAt: str('ISO 8601 update timestamp'),
|
|
136
|
+
}, 'id', 'name', 'taxId');
|
|
137
|
+
|
|
138
|
+
const tenancyRoutes = {
|
|
139
|
+
createTenant: endpoint('POST', '/tenancy/tenants')
|
|
140
|
+
.summary('Create a tenant')
|
|
141
|
+
.description('Create a new tenant and its PostgreSQL schema. Requires the `fortress:manageTenants` permission.')
|
|
142
|
+
.tags('Tenancy')
|
|
143
|
+
.security('bearer')
|
|
144
|
+
.permission('fortress', 'manageTenants')
|
|
145
|
+
.body(obj({
|
|
146
|
+
name: str('Human-readable tenant name'),
|
|
147
|
+
taxId: str('Unique tenant tax id / external code'),
|
|
148
|
+
description: str('Optional description'),
|
|
149
|
+
}, 'name', 'taxId'))
|
|
150
|
+
.response(201, 'Tenant created', tenantResponse)
|
|
151
|
+
.response(401, 'Not authenticated', errorRef)
|
|
152
|
+
.response(403, 'Insufficient permissions', errorRef)
|
|
153
|
+
.response(409, 'taxId already exists', errorRef)
|
|
154
|
+
.handler('createTenant')
|
|
155
|
+
.build(),
|
|
156
|
+
|
|
157
|
+
deleteTenant: endpoint('DELETE', '/tenancy/tenants/:id')
|
|
158
|
+
.summary('Delete a tenant')
|
|
159
|
+
.description('Remove a tenant, its memberships, and (when `dropSchemaOnDelete` is enabled) its schema. Requires the `fortress:manageTenants` permission.')
|
|
160
|
+
.tags('Tenancy')
|
|
161
|
+
.security('bearer')
|
|
162
|
+
.permission('fortress', 'manageTenants')
|
|
163
|
+
.params(obj({ id: str('Tenant id') }, 'id'))
|
|
164
|
+
.response(200, 'Tenant deleted', obj({ ok: bool('Always true') }, 'ok'))
|
|
165
|
+
.response(401, 'Not authenticated', errorRef)
|
|
166
|
+
.response(403, 'Insufficient permissions', errorRef)
|
|
167
|
+
.response(404, 'Tenant not found', errorRef)
|
|
168
|
+
.handler('deleteTenant')
|
|
169
|
+
.build(),
|
|
170
|
+
|
|
171
|
+
getMyTenants: endpoint('GET', '/tenancy/tenants/mine')
|
|
172
|
+
.summary('List the caller\'s tenants')
|
|
173
|
+
.description('Return the tenants the authenticated caller belongs to.')
|
|
174
|
+
.tags('Tenancy')
|
|
175
|
+
.security('bearer')
|
|
176
|
+
.response(200, 'Tenants', obj({ tenants: arr(tenantResponse, 'Tenants the caller belongs to') }, 'tenants'))
|
|
177
|
+
.response(401, 'Not authenticated', errorRef)
|
|
178
|
+
.handler('getMyTenants')
|
|
179
|
+
.build(),
|
|
180
|
+
|
|
181
|
+
switchTenant: endpoint('POST', '/tenancy/switch')
|
|
182
|
+
.summary('Switch the caller\'s default tenant')
|
|
183
|
+
.description('Set the authenticated caller\'s default tenant. The new tenant takes effect on the next token refresh.')
|
|
184
|
+
.tags('Tenancy')
|
|
185
|
+
.security('bearer')
|
|
186
|
+
.body(obj({ taxId: str('Tax id of the tenant to switch to') }, 'taxId'))
|
|
187
|
+
.response(200, 'Switched', obj({ ok: bool('Always true') }, 'ok'))
|
|
188
|
+
.response(401, 'Not authenticated', errorRef)
|
|
189
|
+
.response(403, 'Caller does not belong to this tenant', errorRef)
|
|
190
|
+
.response(404, 'Tenant not found', errorRef)
|
|
191
|
+
.handler('switchTenant')
|
|
192
|
+
.build(),
|
|
193
|
+
} as const;
|
|
194
|
+
|
|
195
|
+
// ── Plugin Factory ──────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tenancy plugin factory (PostgreSQL only). Returns a {@link FortressPlugin}
|
|
199
|
+
* that switches the active PostgreSQL `search_path` per request based on the
|
|
200
|
+
* verified `tenantId` JWT claim, providing schema-level isolation between
|
|
201
|
+
* tenants. Pass `{ routes: true }` to mount the HTTP routes under `/tenancy/*`.
|
|
202
|
+
*/
|
|
203
|
+
export function tenancy(config: TenancyConfig = {}): FortressPlugin & { readonly name: 'tenancy' } {
|
|
204
|
+
const schemaPrefix = config.schemaPrefix ?? 'tenant_';
|
|
205
|
+
assertSafeSchemaPrefix(schemaPrefix);
|
|
206
|
+
const mountRoutes = config.routes === true;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Schema name for a tenant. `id` is numeric, so the result is always a
|
|
210
|
+
* valid, non-injectable identifier.
|
|
211
|
+
*/
|
|
212
|
+
const tenantSchemaName = (id: string): string => `${schemaPrefix}${id}`;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
name: 'tenancy',
|
|
216
|
+
|
|
217
|
+
models: [
|
|
218
|
+
{
|
|
219
|
+
name: 'tenant',
|
|
220
|
+
fields: {
|
|
221
|
+
id: { type: 'number', required: true },
|
|
222
|
+
name: { type: 'string', required: true },
|
|
223
|
+
taxId: { type: 'string', required: true, unique: true },
|
|
224
|
+
description: { type: 'string' },
|
|
225
|
+
createdAt: { type: 'date', required: true },
|
|
226
|
+
updatedAt: { type: 'date', required: true },
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'tenant_user',
|
|
231
|
+
fields: {
|
|
232
|
+
tenantId: { type: 'number', required: true, references: { model: 'tenant', field: 'id' } },
|
|
233
|
+
userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
|
|
234
|
+
isDefault: { type: 'boolean', required: true },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
|
|
239
|
+
...(mountRoutes ? { routes: tenancyRoutes } : {}),
|
|
240
|
+
|
|
241
|
+
async enrichTokenClaims(userId: string, ctx: PluginContext): Promise<Record<string, unknown>> {
|
|
242
|
+
// Find user's default tenant
|
|
243
|
+
const membership = await ctx.db.findOne<TenantUserRecord>({
|
|
244
|
+
model: 'tenant_user',
|
|
245
|
+
where: [
|
|
246
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
247
|
+
{ field: 'isDefault', operator: '=', value: true },
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!membership)
|
|
252
|
+
return {};
|
|
253
|
+
|
|
254
|
+
const tenant = await ctx.db.findOne<TenantRecord>({
|
|
255
|
+
model: 'tenant',
|
|
256
|
+
where: [{ field: 'id', operator: '=', value: membership.tenantId }],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
if (!tenant)
|
|
260
|
+
return {};
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
tenantId: tenant.id,
|
|
264
|
+
tenantCode: tenant.taxId,
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
wrapAdapter(adapter: DatabaseAdapter, requestContext: Record<string, unknown>): DatabaseAdapter {
|
|
269
|
+
const tenantId = requestContext.tenantId as string | undefined;
|
|
270
|
+
// Fail closed: no verified tenant claim ⇒ no schema switch. Business
|
|
271
|
+
// tables live only in tenant schemas, so they are simply not on the
|
|
272
|
+
// search path — there is no silent fallback to another tenant's data.
|
|
273
|
+
if (tenantId == null)
|
|
274
|
+
return adapter;
|
|
275
|
+
// Validate: alphanumeric + underscore + hyphen only. The id is
|
|
276
|
+
// interpolated into a Postgres schema name (via tenantSchemaName), so
|
|
277
|
+
// any character that isn't safe for an identifier MUST be rejected
|
|
278
|
+
// here — there is no second sanitization step downstream.
|
|
279
|
+
if (!SAFE_TENANT_ID.test(tenantId))
|
|
280
|
+
throw Errors.forbidden('Invalid tenant claim');
|
|
281
|
+
|
|
282
|
+
const isPg = adapter.dialect === 'pg' && !!adapter.rawQuery;
|
|
283
|
+
// For SQLite there is no schema-per-tenant model; pass through.
|
|
284
|
+
if (!isPg)
|
|
285
|
+
return adapter;
|
|
286
|
+
|
|
287
|
+
const schemaName = tenantSchemaName(tenantId);
|
|
288
|
+
|
|
289
|
+
// Pin search_path on the transaction's pinned connection. `?` is the
|
|
290
|
+
// adapter-canonical bound placeholder, and `set_config(..., true)` is transaction-local,
|
|
291
|
+
// so the path applies to exactly the operation that follows on the same
|
|
292
|
+
// connection.
|
|
293
|
+
const setPath = (tx: DatabaseAdapter): Promise<unknown> =>
|
|
294
|
+
tx.rawQuery!(`SELECT set_config('search_path', ?, true)`, [`${schemaName}, public`]);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
...adapter,
|
|
298
|
+
async create<T>(params: Parameters<DatabaseAdapter['create']>[0]): Promise<T> {
|
|
299
|
+
return adapter.transaction(async (tx) => {
|
|
300
|
+
await setPath(tx);
|
|
301
|
+
return tx.create<T>(params);
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
async findOne<T>(params: Parameters<DatabaseAdapter['findOne']>[0]): Promise<T | null> {
|
|
305
|
+
return adapter.transaction(async (tx) => {
|
|
306
|
+
await setPath(tx);
|
|
307
|
+
return tx.findOne<T>(params);
|
|
308
|
+
});
|
|
309
|
+
},
|
|
310
|
+
async findMany<T>(params: Parameters<DatabaseAdapter['findMany']>[0]): Promise<T[]> {
|
|
311
|
+
return adapter.transaction(async (tx) => {
|
|
312
|
+
await setPath(tx);
|
|
313
|
+
return tx.findMany<T>(params);
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
async update<T>(params: Parameters<DatabaseAdapter['update']>[0]): Promise<T | null> {
|
|
317
|
+
return adapter.transaction(async (tx) => {
|
|
318
|
+
await setPath(tx);
|
|
319
|
+
return tx.update<T>(params);
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
async delete(params: Parameters<DatabaseAdapter['delete']>[0]): Promise<void> {
|
|
323
|
+
return adapter.transaction(async (tx) => {
|
|
324
|
+
await setPath(tx);
|
|
325
|
+
return tx.delete(params);
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
async count(params: Parameters<DatabaseAdapter['count']>[0]): Promise<number> {
|
|
329
|
+
return adapter.transaction(async (tx) => {
|
|
330
|
+
await setPath(tx);
|
|
331
|
+
return tx.count(params);
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
async transaction<T>(fn: (tx: DatabaseAdapter) => Promise<T>): Promise<T> {
|
|
335
|
+
// Pin once for the whole transaction so every op inside the caller's
|
|
336
|
+
// callback runs against the tenant schema.
|
|
337
|
+
return adapter.transaction(async (tx) => {
|
|
338
|
+
await setPath(tx);
|
|
339
|
+
return fn(tx);
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
methods: (ctx) => {
|
|
346
|
+
const findTenantByTaxId = (taxId: string): Promise<TenantRecord | null> =>
|
|
347
|
+
ctx.db.findOne<TenantRecord>({
|
|
348
|
+
model: 'tenant',
|
|
349
|
+
where: [{ field: 'taxId', operator: '=', value: taxId }],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const listUserTenants = async (userId: string): Promise<TenantRecord[]> => {
|
|
353
|
+
const memberships = await ctx.db.findMany<TenantUserRecord>({
|
|
354
|
+
model: 'tenant_user',
|
|
355
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (memberships.length === 0)
|
|
359
|
+
return [];
|
|
360
|
+
|
|
361
|
+
const tenantIds = memberships.map(m => m.tenantId);
|
|
362
|
+
return ctx.db.findMany<TenantRecord>({
|
|
363
|
+
model: 'tenant',
|
|
364
|
+
where: [{ field: 'id', operator: 'in', value: tenantIds }],
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const requireUserId = (input: { userId?: string }, routeCtx?: PluginRouteContext): string => {
|
|
369
|
+
const userId = routeCtx?.userId ?? input.userId;
|
|
370
|
+
if (userId == null) {
|
|
371
|
+
if (routeCtx)
|
|
372
|
+
throw Errors.unauthorized('Not authenticated');
|
|
373
|
+
throw Errors.badRequest('userId is required for programmatic calls');
|
|
374
|
+
}
|
|
375
|
+
return userId;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const defaultTenantQueues = new Map<string, Promise<void>>();
|
|
379
|
+
const withDefaultTenantLock = async <T>(
|
|
380
|
+
userId: string,
|
|
381
|
+
operation: (tx: DatabaseAdapter) => Promise<T>,
|
|
382
|
+
): Promise<T> => {
|
|
383
|
+
const previous = defaultTenantQueues.get(userId) ?? Promise.resolve();
|
|
384
|
+
let release!: () => void;
|
|
385
|
+
const current = new Promise<void>((resolve) => {
|
|
386
|
+
release = resolve;
|
|
387
|
+
});
|
|
388
|
+
defaultTenantQueues.set(userId, current);
|
|
389
|
+
await previous;
|
|
390
|
+
try {
|
|
391
|
+
return await ctx.db.transaction(async (tx) => {
|
|
392
|
+
if (tx.dialect === 'pg' && tx.rawQuery) {
|
|
393
|
+
await tx.rawQuery('SELECT pg_advisory_xact_lock(hashtext(?))', [`fortress:tenant-default:${userId}`]);
|
|
394
|
+
}
|
|
395
|
+
return operation(tx);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
release();
|
|
400
|
+
if (defaultTenantQueues.get(userId) === current)
|
|
401
|
+
defaultTenantQueues.delete(userId);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
async createTenant(input: { name: string; taxId: string; description?: string }): Promise<TenantRecord> {
|
|
407
|
+
const existing = await findTenantByTaxId(input.taxId);
|
|
408
|
+
if (existing)
|
|
409
|
+
throw Errors.conflict(`Tenant with taxId '${input.taxId}' already exists`);
|
|
410
|
+
|
|
411
|
+
// Create the row and its schema atomically: PostgreSQL DDL is
|
|
412
|
+
// transactional, so a failed schema/DDL step rolls the tenant row back.
|
|
413
|
+
return ctx.db.transaction(async (tx) => {
|
|
414
|
+
const tenant = await tx.create<TenantRecord>({
|
|
415
|
+
model: 'tenant',
|
|
416
|
+
data: {
|
|
417
|
+
name: input.name,
|
|
418
|
+
taxId: input.taxId,
|
|
419
|
+
description: input.description ?? null,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (tx.dialect === 'pg' && tx.rawQuery) {
|
|
424
|
+
const schemaName = tenantSchemaName(tenant.id); // numeric id ⇒ safe
|
|
425
|
+
await tx.rawQuery(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
|
|
426
|
+
if (config.onSchemaCreated)
|
|
427
|
+
await config.onSchemaCreated(schemaName, tx.rawQuery.bind(tx));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return tenant;
|
|
431
|
+
});
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
async deleteTenant(input: { id: string }): Promise<{ ok: true }> {
|
|
435
|
+
const id = String(input.id ?? '');
|
|
436
|
+
if (!id)
|
|
437
|
+
throw Errors.badRequest('id is required');
|
|
438
|
+
|
|
439
|
+
const tenant = await ctx.db.findOne<TenantRecord>({
|
|
440
|
+
model: 'tenant',
|
|
441
|
+
where: [{ field: 'id', operator: '=', value: id }],
|
|
442
|
+
});
|
|
443
|
+
if (!tenant)
|
|
444
|
+
throw Errors.notFound(`Tenant '${id}' not found`);
|
|
445
|
+
|
|
446
|
+
await ctx.db.transaction(async (tx) => {
|
|
447
|
+
await tx.delete({
|
|
448
|
+
model: 'tenant_user',
|
|
449
|
+
where: [{ field: 'tenantId', operator: '=', value: id }],
|
|
450
|
+
});
|
|
451
|
+
await tx.delete({
|
|
452
|
+
model: 'tenant',
|
|
453
|
+
where: [{ field: 'id', operator: '=', value: id }],
|
|
454
|
+
});
|
|
455
|
+
if (config.dropSchemaOnDelete && tx.dialect === 'pg' && tx.rawQuery) {
|
|
456
|
+
const schemaName = tenantSchemaName(id); // numeric id ⇒ safe
|
|
457
|
+
await tx.rawQuery(`DROP SCHEMA IF EXISTS ${schemaName} CASCADE`);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return { ok: true };
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
async addUserToTenant(userId: string, tenantId: string): Promise<void> {
|
|
465
|
+
await withDefaultTenantLock(userId, async (tx) => {
|
|
466
|
+
const existing = await tx.findOne<TenantUserRecord>({
|
|
467
|
+
model: 'tenant_user',
|
|
468
|
+
where: [
|
|
469
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
470
|
+
{ field: 'tenantId', operator: '=', value: tenantId },
|
|
471
|
+
],
|
|
472
|
+
});
|
|
473
|
+
if (existing)
|
|
474
|
+
return;
|
|
475
|
+
|
|
476
|
+
const memberships = await tx.findMany<TenantUserRecord>({
|
|
477
|
+
model: 'tenant_user',
|
|
478
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
479
|
+
});
|
|
480
|
+
await tx.create({
|
|
481
|
+
model: 'tenant_user',
|
|
482
|
+
data: {
|
|
483
|
+
tenantId,
|
|
484
|
+
userId,
|
|
485
|
+
isDefault: memberships.length === 0,
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
async getUserTenants(userId: string): Promise<TenantRecord[]> {
|
|
492
|
+
return listUserTenants(userId);
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
async getMyTenants(input: { userId?: string }, routeCtx?: PluginRouteContext): Promise<{ tenants: TenantRecord[] }> {
|
|
496
|
+
return { tenants: await listUserTenants(requireUserId(input, routeCtx)) };
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
async switchTenant(input: { taxId: string; userId?: string }, routeCtx?: PluginRouteContext): Promise<{ ok: true }> {
|
|
500
|
+
const userId = requireUserId(input, routeCtx);
|
|
501
|
+
|
|
502
|
+
const tenant = await findTenantByTaxId(input.taxId);
|
|
503
|
+
if (!tenant)
|
|
504
|
+
throw Errors.notFound(`Tenant '${input.taxId}' not found`);
|
|
505
|
+
|
|
506
|
+
await withDefaultTenantLock(userId, async (tx) => {
|
|
507
|
+
const membership = await tx.findOne<TenantUserRecord>({
|
|
508
|
+
model: 'tenant_user',
|
|
509
|
+
where: [
|
|
510
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
511
|
+
{ field: 'tenantId', operator: '=', value: tenant.id },
|
|
512
|
+
],
|
|
513
|
+
});
|
|
514
|
+
if (!membership)
|
|
515
|
+
throw Errors.forbidden('User does not belong to this tenant');
|
|
516
|
+
|
|
517
|
+
// Partial unique indexes are checked during each row update on
|
|
518
|
+
// both SQLite and PostgreSQL, so a one-statement CASE flip can
|
|
519
|
+
// transiently collide when the target row is visited first. Two
|
|
520
|
+
// phases inside this serialized transaction avoid that hazard.
|
|
521
|
+
await tx.update({
|
|
522
|
+
model: 'tenant_user',
|
|
523
|
+
where: [
|
|
524
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
525
|
+
{ field: 'isDefault', operator: '=', value: true },
|
|
526
|
+
],
|
|
527
|
+
data: { isDefault: false },
|
|
528
|
+
});
|
|
529
|
+
await tx.update({
|
|
530
|
+
model: 'tenant_user',
|
|
531
|
+
where: [
|
|
532
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
533
|
+
{ field: 'tenantId', operator: '=', value: tenant.id },
|
|
534
|
+
],
|
|
535
|
+
data: { isDefault: true },
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return { ok: true };
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|