@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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard Schema V1 interfaces — inlined from the spec.
|
|
3
|
+
* No `@standard-schema/spec` dependency needed.
|
|
4
|
+
*
|
|
5
|
+
* @see https://standardschema.dev
|
|
6
|
+
* @see https://github.com/standard-schema/standard-schema
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line ts/no-namespace
|
|
10
|
+
export declare namespace StandardSchemaV1 {
|
|
11
|
+
interface Props<Input = unknown, Output = Input> {
|
|
12
|
+
readonly version: 1;
|
|
13
|
+
readonly vendor: string;
|
|
14
|
+
readonly validate: (
|
|
15
|
+
value: unknown,
|
|
16
|
+
) => Result<Output> | Promise<Result<Output>>;
|
|
17
|
+
readonly types?: Types<Input, Output> | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Result<Output> = SuccessResult<Output> | FailureResult;
|
|
21
|
+
|
|
22
|
+
interface SuccessResult<Output> {
|
|
23
|
+
readonly value: Output;
|
|
24
|
+
readonly issues?: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FailureResult {
|
|
28
|
+
readonly issues: ReadonlyArray<Issue>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Issue {
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PathSegment {
|
|
37
|
+
readonly key: PropertyKey;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Types<Input = unknown, Output = Input> {
|
|
41
|
+
readonly input: Input;
|
|
42
|
+
readonly output: Output;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type InferInput<Schema extends StandardSchemaV1> = NonNullable<
|
|
46
|
+
Schema['~standard']['types']
|
|
47
|
+
>['input'];
|
|
48
|
+
|
|
49
|
+
type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
|
|
50
|
+
Schema['~standard']['types']
|
|
51
|
+
>['output'];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
55
|
+
readonly '~standard': StandardSchemaV1.Props<Input, Output>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// --- Identity ---
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A fortress user record as returned by the database adapter.
|
|
5
|
+
*
|
|
6
|
+
* `id` is always a string at the fortress API surface. Adapters backed by
|
|
7
|
+
* numeric primary keys are responsible for stringifying on read and parsing
|
|
8
|
+
* on write at the adapter boundary — consumers of fortress never see the
|
|
9
|
+
* underlying representation. This matches the JWT/OIDC `sub` wire shape
|
|
10
|
+
* (RFC 7519 §4.1.2) and unblocks UUID/ULID/snowflake-keyed adapters.
|
|
11
|
+
*/
|
|
12
|
+
export interface FortressUser {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
name: string;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
emailVerified?: boolean;
|
|
18
|
+
createdAt: Date;
|
|
19
|
+
updatedAt: Date;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Auth ---
|
|
23
|
+
|
|
24
|
+
/** JWT claims fortress signs into the access token, plus the optional impersonation actor (`act`) and per-deployment custom claims. */
|
|
25
|
+
export interface TokenClaims {
|
|
26
|
+
/** Subject identifier — string per RFC 7519 §4.1.2. */
|
|
27
|
+
sub: string;
|
|
28
|
+
/** Subject kind — defaults to 'USER' on legacy tokens that do not carry the claim. */
|
|
29
|
+
subjectType: SubjectType;
|
|
30
|
+
name: string;
|
|
31
|
+
groups: string[];
|
|
32
|
+
iss: string;
|
|
33
|
+
/** Optional JWT audience, when configured for this deployment. */
|
|
34
|
+
aud?: string | string[];
|
|
35
|
+
iat: number;
|
|
36
|
+
exp: number;
|
|
37
|
+
act?: { sub: string; subjectType?: SubjectType }; // RFC 8693 actor claim for impersonation
|
|
38
|
+
customClaims?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A short-lived access token paired with its longer-lived refresh token. */
|
|
42
|
+
export interface AuthTokenPair {
|
|
43
|
+
accessToken: string;
|
|
44
|
+
refreshToken: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The credential method that completed authentication. Carried on a successful result. */
|
|
48
|
+
export type AuthMethod = 'password' | 'refresh' | 'two-factor' | 'webauthn' | 'magic-link' | 'impersonation';
|
|
49
|
+
|
|
50
|
+
/** Why a sign-in is pending an additional step before tokens are issued. */
|
|
51
|
+
export type PendingReason = 'two-factor' | 'webauthn' | 'email-verification' | 'magic-link';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The challenge a pending sign-in must satisfy before tokens are issued.
|
|
55
|
+
* `reason` tells the client which step to run; the single-use `continuationToken`
|
|
56
|
+
* is presented back to `auth.completePendingAuth` to finish the flow.
|
|
57
|
+
*/
|
|
58
|
+
export interface AuthChallenge {
|
|
59
|
+
reason: PendingReason;
|
|
60
|
+
continuationToken: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Successful sign-in / refresh result — both tokens are issued. */
|
|
64
|
+
export interface AuthSuccess {
|
|
65
|
+
status: 'success';
|
|
66
|
+
user: FortressUser;
|
|
67
|
+
method: AuthMethod;
|
|
68
|
+
accessToken: string;
|
|
69
|
+
refreshToken: string;
|
|
70
|
+
pluginData?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Impersonation sign-in result — only an access token is issued (refresh is suppressed for safety). */
|
|
74
|
+
export interface AuthImpersonation {
|
|
75
|
+
status: 'impersonation';
|
|
76
|
+
user: FortressUser;
|
|
77
|
+
accessToken: string;
|
|
78
|
+
refreshToken: null;
|
|
79
|
+
pluginData?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pending sign-in result — the user must complete an additional step before tokens are issued. */
|
|
83
|
+
export interface AuthPending {
|
|
84
|
+
status: 'pending';
|
|
85
|
+
user: FortressUser;
|
|
86
|
+
pending: AuthChallenge;
|
|
87
|
+
pluginData?: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Discriminated union of every possible auth flow outcome. */
|
|
91
|
+
export type AuthResult = AuthSuccess | AuthImpersonation | AuthPending;
|
|
92
|
+
|
|
93
|
+
/** Narrow an {@link AuthResult} to the successful (tokens-issued) variant. */
|
|
94
|
+
export function isSuccess(result: AuthResult): result is AuthSuccess {
|
|
95
|
+
return result.status === 'success';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Narrow an {@link AuthResult} to the pending (additional-step-required) variant. */
|
|
99
|
+
export function isPending(result: AuthResult): result is AuthPending {
|
|
100
|
+
return result.status === 'pending';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Narrow an {@link AuthResult} to the impersonation variant. */
|
|
104
|
+
export function isImpersonation(result: AuthResult): result is AuthImpersonation {
|
|
105
|
+
return result.status === 'impersonation';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Assert an {@link AuthResult} is successful, throwing otherwise. Use when a
|
|
110
|
+
* caller has already ruled out the pending/impersonation variants and wants the
|
|
111
|
+
* token fields without a manual narrow.
|
|
112
|
+
*/
|
|
113
|
+
export function assertSuccess(result: AuthResult): asserts result is AuthSuccess {
|
|
114
|
+
if (result.status !== 'success')
|
|
115
|
+
throw new Error(`Expected a successful auth result, but got status '${result.status}'`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Per-request metadata fortress threads through hooks for audit logging, lockout, and trusted-device tracking. */
|
|
119
|
+
export interface RequestMeta {
|
|
120
|
+
ipAddress?: string;
|
|
121
|
+
userAgent?: string;
|
|
122
|
+
deviceName?: string;
|
|
123
|
+
/** Raw server-issued trusted-device secret from the host-owned cookie. */
|
|
124
|
+
trustedDeviceToken?: string;
|
|
125
|
+
/** Explicitly request enrollment of this browser/device after a successful factor. */
|
|
126
|
+
rememberDevice?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A persisted refresh-token session as exposed to session-management UIs. */
|
|
130
|
+
export interface SessionInfo {
|
|
131
|
+
id: string;
|
|
132
|
+
ipAddress: string | null;
|
|
133
|
+
userAgent: string | null;
|
|
134
|
+
deviceName: string | null;
|
|
135
|
+
createdAt: Date;
|
|
136
|
+
lastActiveAt: Date | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Input shape accepted by `fortress.createUser` and the admin user-creation endpoint. */
|
|
140
|
+
export interface CreateUserInput {
|
|
141
|
+
email: string;
|
|
142
|
+
name: string;
|
|
143
|
+
password?: string;
|
|
144
|
+
isActive?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- IAM ---
|
|
148
|
+
|
|
149
|
+
/** The kind of subject a role or permission can be bound to. */
|
|
150
|
+
export type SubjectType = 'USER' | 'GROUP' | 'SERVICE_ACCOUNT';
|
|
151
|
+
|
|
152
|
+
/** A subject identity — a discriminated (type, id) pair used by IAM and the auth pipeline. */
|
|
153
|
+
export interface Subject {
|
|
154
|
+
type: SubjectType;
|
|
155
|
+
id: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A persisted IAM permission — a (resource, action) pair with optional conditions and an allow/deny effect. */
|
|
159
|
+
export interface Permission {
|
|
160
|
+
id: string;
|
|
161
|
+
resource: string;
|
|
162
|
+
action: string;
|
|
163
|
+
effect: 'ALLOW' | 'DENY';
|
|
164
|
+
conditions?: PermissionCondition[];
|
|
165
|
+
description?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Input shape for creating a new {@link Permission}. */
|
|
169
|
+
export interface PermissionInput {
|
|
170
|
+
resource: string;
|
|
171
|
+
action: string;
|
|
172
|
+
effect?: 'ALLOW' | 'DENY';
|
|
173
|
+
conditions?: PermissionCondition[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Reference to a value resolved from the {@link PermissionContext} at evaluation time. */
|
|
177
|
+
export interface ConditionRef {
|
|
178
|
+
ref: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A literal or {@link ConditionRef}-based value used by a {@link PermissionCondition}. */
|
|
182
|
+
export type ConditionValue = string | string[] | ConditionRef | ConditionRef[];
|
|
183
|
+
|
|
184
|
+
/** A single ABAC-style condition guarding a {@link Permission}. */
|
|
185
|
+
export interface PermissionCondition {
|
|
186
|
+
field: string;
|
|
187
|
+
operator: 'eq' | 'neq' | 'in' | 'startsWith';
|
|
188
|
+
value: ConditionValue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Context object passed into the permission evaluator at check time. */
|
|
192
|
+
export interface PermissionContext {
|
|
193
|
+
resource?: Record<string, unknown>;
|
|
194
|
+
request?: Record<string, unknown>;
|
|
195
|
+
user?: Record<string, unknown>;
|
|
196
|
+
tenantId?: string;
|
|
197
|
+
/**
|
|
198
|
+
* Optional narrowing scopes from the credential used for this request
|
|
199
|
+
* (for example an API key). `null`/`undefined` means unscoped/full
|
|
200
|
+
* subject permissions; an empty array means no permissions.
|
|
201
|
+
*/
|
|
202
|
+
credentialScopes?: string[] | null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A persisted IAM role grouping a set of {@link Permission}s. */
|
|
206
|
+
export interface Role {
|
|
207
|
+
id: string;
|
|
208
|
+
name: string;
|
|
209
|
+
description?: string | null;
|
|
210
|
+
isSystem?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Binding of a {@link Role} to a subject (user, group, or service account), optionally scoped to a tenant. */
|
|
214
|
+
export interface RoleBinding {
|
|
215
|
+
id: string;
|
|
216
|
+
roleId: string;
|
|
217
|
+
subjectType: SubjectType;
|
|
218
|
+
subjectId: string;
|
|
219
|
+
tenantId?: string | null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The kind of identifier a user can sign in with. */
|
|
223
|
+
export type LoginIdentifierType = 'email' | 'phone' | 'username';
|
|
224
|
+
|
|
225
|
+
/** A persisted login identifier (e.g. email, phone, username) attached to a user. */
|
|
226
|
+
export interface LoginIdentifier {
|
|
227
|
+
id: string;
|
|
228
|
+
userId: string;
|
|
229
|
+
type: LoginIdentifierType;
|
|
230
|
+
value: string;
|
|
231
|
+
/** Reserved for future multi-tenant support. Not yet stored in the database. */
|
|
232
|
+
tenantId?: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A persisted IAM group used to grant permissions to many users at once. */
|
|
236
|
+
export interface Group {
|
|
237
|
+
id: string;
|
|
238
|
+
name: string;
|
|
239
|
+
description?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A persisted IAM service account — a non-human principal that can hold roles and direct permissions. */
|
|
243
|
+
export interface ServiceAccount {
|
|
244
|
+
id: string;
|
|
245
|
+
name: string;
|
|
246
|
+
displayName: string | null;
|
|
247
|
+
description: string | null;
|
|
248
|
+
isActive: boolean;
|
|
249
|
+
createdAt: Date;
|
|
250
|
+
updatedAt: Date;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Input shape for creating a new {@link ServiceAccount}. `name` is the immutable machine identifier. */
|
|
254
|
+
export interface CreateServiceAccountInput {
|
|
255
|
+
name: string;
|
|
256
|
+
displayName?: string;
|
|
257
|
+
description?: string;
|
|
258
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { JSONSchema } from './json-schema';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { FortressError } from './errors';
|
|
4
|
+
import { iamEndpoints } from './iam/iam-endpoints';
|
|
5
|
+
import { int, num, obj, str } from './schema-builder';
|
|
6
|
+
import { coerceBySchema, validateRequest } from './validation';
|
|
7
|
+
|
|
8
|
+
describe('coerceBySchema', () => {
|
|
9
|
+
it('coerces only lossless canonical decimal URL numerics', () => {
|
|
10
|
+
const schema = obj({ integer: int(), number: num() });
|
|
11
|
+
expect(coerceBySchema(schema, { integer: '-12', number: '0.5' })).toEqual({
|
|
12
|
+
integer: -12,
|
|
13
|
+
number: 0.5,
|
|
14
|
+
});
|
|
15
|
+
for (const value of [
|
|
16
|
+
'',
|
|
17
|
+
'0x10',
|
|
18
|
+
'1e3',
|
|
19
|
+
'+1',
|
|
20
|
+
'.5',
|
|
21
|
+
'01',
|
|
22
|
+
'1.0',
|
|
23
|
+
'-0',
|
|
24
|
+
'9007199254740992',
|
|
25
|
+
'0.10000000000000001',
|
|
26
|
+
'Infinity',
|
|
27
|
+
]) {
|
|
28
|
+
expect(coerceBySchema(schema, { integer: value, number: value })).toEqual({
|
|
29
|
+
integer: value,
|
|
30
|
+
number: value,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('leaves unsafe integers uncoerced so validation rejects them', async () => {
|
|
36
|
+
const paramsSchema = obj({ id: int() }, 'id');
|
|
37
|
+
const params = coerceBySchema(paramsSchema, { id: '9007199254740992' });
|
|
38
|
+
|
|
39
|
+
expect(params).toEqual({ id: '9007199254740992' });
|
|
40
|
+
await expect(
|
|
41
|
+
validateRequest({ params: paramsSchema, paramsSchema }, { params }),
|
|
42
|
+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', statusCode: 422 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('coerces numeric enum and const properties without an explicit type', async () => {
|
|
46
|
+
const schema = {
|
|
47
|
+
type: 'object' as const,
|
|
48
|
+
properties: {
|
|
49
|
+
mode: { enum: [1, 2] },
|
|
50
|
+
version: { const: 3 },
|
|
51
|
+
},
|
|
52
|
+
required: ['mode', 'version'],
|
|
53
|
+
};
|
|
54
|
+
const params = coerceBySchema(schema, { mode: '2', version: '3' });
|
|
55
|
+
|
|
56
|
+
expect(params).toEqual({ mode: 2, version: 3 });
|
|
57
|
+
await expect(validateRequest({ params: schema }, { params })).resolves.toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('validateRequest', () => {
|
|
62
|
+
it('no-op when input is undefined', async () => {
|
|
63
|
+
await expect(validateRequest(undefined, {})).resolves.toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('no-op when no schemas are set', async () => {
|
|
67
|
+
await expect(validateRequest({}, {})).resolves.toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('validates hand-authored JSON Schemas and aggregates locations', async () => {
|
|
71
|
+
const input = {
|
|
72
|
+
body: { type: 'object' as const, properties: { name: { type: 'string' as const } }, required: ['name'] },
|
|
73
|
+
query: { type: 'object' as const, properties: { page: { type: 'integer' as const } }, required: ['page'] },
|
|
74
|
+
params: { type: 'object' as const, properties: { id: { type: 'integer' as const } }, required: ['id'] },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await validateRequest(input, { body: {}, query: {}, params: {} });
|
|
79
|
+
expect.unreachable();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
expect(err).toMatchObject({ code: 'VALIDATION_ERROR', statusCode: 422 });
|
|
83
|
+
const details = (err as FortressError).details as Array<{ location: string }>;
|
|
84
|
+
expect(details.map(issue => issue.location)).toEqual(['body', 'query', 'params']);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('preserves definitions when validating a raw JSON Schema ref', async () => {
|
|
89
|
+
const body = {
|
|
90
|
+
$ref: '#/$defs/AdminBody',
|
|
91
|
+
$defs: {
|
|
92
|
+
AdminBody: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: { name: { type: 'string' } },
|
|
95
|
+
required: ['name'],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
} as unknown as JSONSchema;
|
|
99
|
+
|
|
100
|
+
await expect(validateRequest({ body }, { body: { name: 'admin' } })).resolves.toBeUndefined();
|
|
101
|
+
await expect(
|
|
102
|
+
validateRequest({ body }, { body: {} }),
|
|
103
|
+
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', statusCode: 422 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('enforces component refs through a real IAM endpoint body', async () => {
|
|
107
|
+
const input = iamEndpoints.createRole.input;
|
|
108
|
+
await expect(validateRequest(input, {
|
|
109
|
+
body: { name: 'editor', permissions: [{ resource: 'posts', action: 'read' }] },
|
|
110
|
+
})).resolves.toBeUndefined();
|
|
111
|
+
await expect(validateRequest(input, {
|
|
112
|
+
body: { name: 'editor', permissions: [{ resource: 'posts' }] },
|
|
113
|
+
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR', statusCode: 422 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('validates body via bodySchema', async () => {
|
|
117
|
+
const body = obj({ name: str() }, 'name');
|
|
118
|
+
const input = {
|
|
119
|
+
body: body as any,
|
|
120
|
+
bodySchema: body,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await expect(validateRequest(input, { body: { name: 'Alice' } })).resolves.toBeUndefined();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await validateRequest(input, { body: {} });
|
|
127
|
+
expect.unreachable();
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
expect(err).toBeInstanceOf(FortressError);
|
|
131
|
+
expect((err as FortressError).code).toBe('VALIDATION_ERROR');
|
|
132
|
+
expect((err as FortressError).statusCode).toBe(422);
|
|
133
|
+
expect((err as FortressError).details).toBeDefined();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('validates query via querySchema', async () => {
|
|
138
|
+
const query = obj({ q: str() }, 'q');
|
|
139
|
+
const input = {
|
|
140
|
+
query: query as any,
|
|
141
|
+
querySchema: query,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
await expect(validateRequest(input, { query: { q: 'hello' } })).resolves.toBeUndefined();
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await validateRequest(input, { query: {} });
|
|
148
|
+
expect.unreachable();
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
expect((err as FortressError).code).toBe('VALIDATION_ERROR');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('validates params via paramsSchema', async () => {
|
|
156
|
+
const params = obj({ id: int() }, 'id');
|
|
157
|
+
// Note: int() does not coerce strings 2014 a string id rejects.
|
|
158
|
+
const input = {
|
|
159
|
+
params: params as any,
|
|
160
|
+
paramsSchema: params,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await expect(validateRequest(input, { params: { id: 42 } })).resolves.toBeUndefined();
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await validateRequest(input, { params: {} });
|
|
167
|
+
expect.unreachable();
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
expect((err as FortressError).code).toBe('VALIDATION_ERROR');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('collects errors from multiple inputs', async () => {
|
|
175
|
+
const body = obj({ name: str() }, 'name');
|
|
176
|
+
const query = obj({ q: str() }, 'q');
|
|
177
|
+
const input = {
|
|
178
|
+
body: body as any,
|
|
179
|
+
bodySchema: body,
|
|
180
|
+
query: query as any,
|
|
181
|
+
querySchema: query,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await validateRequest(input, { body: {}, query: {} });
|
|
186
|
+
expect.unreachable();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
const details = (err as FortressError).details as any[];
|
|
190
|
+
expect(details.length).toBe(2);
|
|
191
|
+
expect(details[0].location).toBe('body');
|
|
192
|
+
expect(details[1].location).toBe('query');
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime request validation using Standard Schema.
|
|
3
|
+
*
|
|
4
|
+
* Validates body/query/params against schemas stored in EndpointInput.
|
|
5
|
+
* Works with any Standard Schema provider (fortress, Zod, Valibot, ArkType).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EndpointInput } from './endpoint';
|
|
9
|
+
import type { JSONSchema } from './json-schema';
|
|
10
|
+
import type { StandardSchemaV1 } from './standard-schema';
|
|
11
|
+
import { fromJSONSchema } from '@bajustone/fetcher/openapi';
|
|
12
|
+
import { Errors } from './errors';
|
|
13
|
+
|
|
14
|
+
// Keep URL numerics deliberately narrower than Number(): no exponent, sign, or
|
|
15
|
+
// hexadecimal forms. The spelling must round-trip exactly so precision loss,
|
|
16
|
+
// leading zeroes, and other non-canonical values remain validation failures.
|
|
17
|
+
const DECIMAL_INTEGER_RE = /^-?\d+$/;
|
|
18
|
+
const DECIMAL_NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
19
|
+
const rawSchemaValidators = new WeakMap<object, StandardSchemaV1>();
|
|
20
|
+
|
|
21
|
+
function standardSchemaFor(schema: StandardSchemaV1 | JSONSchema): StandardSchemaV1 {
|
|
22
|
+
if ('~standard' in schema)
|
|
23
|
+
return schema as StandardSchemaV1;
|
|
24
|
+
let compiled = rawSchemaValidators.get(schema as object);
|
|
25
|
+
if (!compiled) {
|
|
26
|
+
compiled = fromJSONSchema(schema as object) as unknown as StandardSchemaV1;
|
|
27
|
+
rawSchemaValidators.set(schema as object, compiled);
|
|
28
|
+
}
|
|
29
|
+
return compiled;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Coerce URL-sourced query/params values to the types declared in a JSON
|
|
34
|
+
* Schema before runtime validation. HTTP path and query parameters arrive
|
|
35
|
+
* as strings from URL parsing, but endpoint schemas often declare them as
|
|
36
|
+
* `integer` / `number` / `boolean` for the purposes of OpenAPI docs and
|
|
37
|
+
* type inference. Without coercion, every `:id` / `?limit=10` would fail
|
|
38
|
+
* strict JSON Schema validation.
|
|
39
|
+
*
|
|
40
|
+
* Body data is _not_ run through this — JSON body values already arrive
|
|
41
|
+
* with their native types.
|
|
42
|
+
*
|
|
43
|
+
* Unknown or unparseable values pass through unchanged; the downstream
|
|
44
|
+
* validator produces the actual error if they don't match. Only the four
|
|
45
|
+
* primitive types (`integer`, `number`, `boolean`, `string`) are coerced
|
|
46
|
+
* — nested objects/arrays in query strings are out of scope.
|
|
47
|
+
*/
|
|
48
|
+
export function coerceBySchema(
|
|
49
|
+
schema: JSONSchema | undefined,
|
|
50
|
+
data: Record<string, unknown> | undefined,
|
|
51
|
+
): Record<string, unknown> | undefined {
|
|
52
|
+
if (!schema || !data || schema.type !== 'object' || !schema.properties)
|
|
53
|
+
return data;
|
|
54
|
+
|
|
55
|
+
const out: Record<string, unknown> = { ...data };
|
|
56
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
57
|
+
const value = out[key];
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
continue;
|
|
60
|
+
const coerced = coerceScalar(propSchema as JSONSchema, value);
|
|
61
|
+
if (coerced !== undefined)
|
|
62
|
+
out[key] = coerced;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function coerceScalar(schema: JSONSchema, value: string): unknown {
|
|
68
|
+
// `enums()` and `literal()` intentionally omit `type`; infer numeric URL
|
|
69
|
+
// parameters from their numeric constraints so `/items?id=1` can satisfy
|
|
70
|
+
// `{ enum: [1, 2] }` and `{ const: 1 }`.
|
|
71
|
+
const type = schema.type ?? numericConstraintType(schema);
|
|
72
|
+
switch (type) {
|
|
73
|
+
case 'integer': {
|
|
74
|
+
if (!DECIMAL_INTEGER_RE.test(value))
|
|
75
|
+
return undefined;
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (Number.isFinite(n) && Number.isSafeInteger(n) && String(n) === value)
|
|
78
|
+
return n;
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
case 'number': {
|
|
82
|
+
if (!DECIMAL_NUMBER_RE.test(value))
|
|
83
|
+
return undefined;
|
|
84
|
+
const n = Number(value);
|
|
85
|
+
if (Number.isFinite(n) && (!Number.isInteger(n) || Number.isSafeInteger(n)) && String(n) === value)
|
|
86
|
+
return n;
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
case 'boolean': {
|
|
90
|
+
if (value === 'true')
|
|
91
|
+
return true;
|
|
92
|
+
if (value === 'false')
|
|
93
|
+
return false;
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
// Leave strings (and anything else) alone.
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function numericConstraintType(schema: JSONSchema): 'integer' | 'number' | undefined {
|
|
103
|
+
if (typeof schema.const === 'number')
|
|
104
|
+
return Number.isInteger(schema.const) ? 'integer' : 'number';
|
|
105
|
+
if (schema.enum && schema.enum.length > 0 && schema.enum.every(value => typeof value === 'number'))
|
|
106
|
+
return schema.enum.every(value => Number.isInteger(value as number)) ? 'integer' : 'number';
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate request data against endpoint input schemas.
|
|
112
|
+
* Throws FortressError('VALIDATION_ERROR') on failure.
|
|
113
|
+
*
|
|
114
|
+
* Uses `~standard.validate()` from whichever schema is attached
|
|
115
|
+
* (fortress built-in or external Standard Schema).
|
|
116
|
+
*/
|
|
117
|
+
export async function validateRequest(
|
|
118
|
+
input: EndpointInput | undefined,
|
|
119
|
+
data: { body?: unknown; query?: unknown; params?: unknown },
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
if (!input)
|
|
122
|
+
return;
|
|
123
|
+
|
|
124
|
+
const allIssues: Array<{ path?: unknown; message: string; location: string }> = [];
|
|
125
|
+
|
|
126
|
+
if (input.bodySchema || input.body) {
|
|
127
|
+
const issues = await validateSchema(input.bodySchema ?? input.body!, data.body, 'body');
|
|
128
|
+
allIssues.push(...issues);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (input.querySchema || input.query) {
|
|
132
|
+
const issues = await validateSchema(input.querySchema ?? input.query!, data.query, 'query');
|
|
133
|
+
allIssues.push(...issues);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (input.paramsSchema || input.params) {
|
|
137
|
+
const issues = await validateSchema(input.paramsSchema ?? input.params!, data.params, 'params');
|
|
138
|
+
allIssues.push(...issues);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (allIssues.length > 0) {
|
|
142
|
+
throw Errors.validationError(allIssues);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function validateSchema(
|
|
147
|
+
schema: StandardSchemaV1 | JSONSchema,
|
|
148
|
+
data: unknown,
|
|
149
|
+
location: string,
|
|
150
|
+
): Promise<Array<{ path?: unknown; message: string; location: string }>> {
|
|
151
|
+
const result = await standardSchemaFor(schema)['~standard'].validate(data);
|
|
152
|
+
if (result.issues) {
|
|
153
|
+
return result.issues.map(issue => ({
|
|
154
|
+
path: issue.path,
|
|
155
|
+
message: issue.message,
|
|
156
|
+
location,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate a single value against a Standard Schema and return the parsed
|
|
164
|
+
* value, or throw `Errors.validationError` on failure.
|
|
165
|
+
*
|
|
166
|
+
* Used by the per-handler request-extraction helpers (`vBody`/`vParam`/
|
|
167
|
+
* `vQuery`) in each framework adapter. Unlike {@link validateRequest}, which
|
|
168
|
+
* aggregates issues across body+query+params, this validates one location
|
|
169
|
+
* at a time — appropriate for per-handler call sites where a single helper
|
|
170
|
+
* call corresponds to a single piece of request data.
|
|
171
|
+
*
|
|
172
|
+
* The thrown `FortressError` is byte-identical to what `validateRequest`
|
|
173
|
+
* produces (HTTP 422, `VALIDATION_ERROR`, `details: issues[]`), so adapter
|
|
174
|
+
* error handlers format both consistently.
|
|
175
|
+
*/
|
|
176
|
+
export async function validateValue<T extends StandardSchemaV1>(
|
|
177
|
+
schema: T,
|
|
178
|
+
data: unknown,
|
|
179
|
+
location: 'body' | 'query' | 'params',
|
|
180
|
+
): Promise<StandardSchemaV1.InferOutput<T>> {
|
|
181
|
+
const result = await schema['~standard'].validate(data);
|
|
182
|
+
if (result.issues) {
|
|
183
|
+
throw Errors.validationError(
|
|
184
|
+
result.issues.map(issue => ({
|
|
185
|
+
path: issue.path,
|
|
186
|
+
message: issue.message,
|
|
187
|
+
location,
|
|
188
|
+
})),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return result.value as StandardSchemaV1.InferOutput<T>;
|
|
192
|
+
}
|