@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,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic HTTP entry point: `fortress.handleRequest(request)`.
|
|
3
|
+
*
|
|
4
|
+
* Composes route matching → plugin `before-auth` → token verification →
|
|
5
|
+
* plugin `after-auth` → fortress-RBAC default-deny → plugin `after-rbac` →
|
|
6
|
+
* validation → endpoint dispatch → cookie attachment → JSON response.
|
|
7
|
+
*
|
|
8
|
+
* Adapters (Hono / Express / SvelteKit) detect Fortress-managed paths and
|
|
9
|
+
* delegate to this function with the raw `Request`. The returned `Response`
|
|
10
|
+
* is web-standard and can be returned directly from any framework that
|
|
11
|
+
* supports `Response`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Fortress } from '../fortress';
|
|
15
|
+
import type { Subject, TokenClaims } from '../types';
|
|
16
|
+
import type { RouteEntry } from './match';
|
|
17
|
+
import { resolveCookieConfig } from '../config';
|
|
18
|
+
import { Errors, FortressError } from '../errors';
|
|
19
|
+
import { coerceBySchema, validateRequest } from '../validation';
|
|
20
|
+
import { clearAuthCookies, serializeAuthCookies } from './cookie-serialize';
|
|
21
|
+
import { enforceCsrf, resolveCsrfConfig } from './csrf';
|
|
22
|
+
import { dispatchEndpoint } from './dispatch';
|
|
23
|
+
import { errorToResponse, withCookies } from './error-response';
|
|
24
|
+
import {
|
|
25
|
+
enforceFortressPermission,
|
|
26
|
+
getPluginPathPrefixes,
|
|
27
|
+
isFortressPath,
|
|
28
|
+
} from './fortress-rbac';
|
|
29
|
+
import { buildRouteTable, matchRoute } from './match';
|
|
30
|
+
import { runPluginMiddleware } from './plugin-middleware';
|
|
31
|
+
import { tryPluginPrincipal } from './principal';
|
|
32
|
+
import { extractAccessToken } from './token-extraction';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the request handler closure for a Fortress instance. Called once
|
|
36
|
+
* during `createFortress` and exposed as `fortress.handleRequest`.
|
|
37
|
+
*
|
|
38
|
+
* The returned function is `async (request: Request) => Promise<Response>`.
|
|
39
|
+
* It owns the route table, the resolved cookie config, and the cached
|
|
40
|
+
* plugin path prefixes — building these once at startup avoids per-request
|
|
41
|
+
* recomputation.
|
|
42
|
+
*/
|
|
43
|
+
export function buildHandleRequest(
|
|
44
|
+
fortress: Fortress,
|
|
45
|
+
): (request: Request) => Promise<Response> {
|
|
46
|
+
const mountedRoutes = new Set(
|
|
47
|
+
fortress.manifest
|
|
48
|
+
.filter(route => route.mounted)
|
|
49
|
+
.map(route => `${route.method.toUpperCase()} ${route.path}`),
|
|
50
|
+
);
|
|
51
|
+
const routeTable = buildRouteTable(
|
|
52
|
+
fortress.endpoints.filter(endpoint => mountedRoutes.has(`${endpoint.method.toUpperCase()} ${endpoint.path}`)),
|
|
53
|
+
);
|
|
54
|
+
const cookieConfig = resolveCookieConfig(fortress.config.cookies);
|
|
55
|
+
// H5: pipeline CSRF check resolved once at startup so per-request cost
|
|
56
|
+
// is just header / cookie inspection.
|
|
57
|
+
const csrfConfig = resolveCsrfConfig(fortress.config.csrf);
|
|
58
|
+
const plugins = fortress.config.plugins ?? [];
|
|
59
|
+
const pluginPathPrefixes = getPluginPathPrefixes(plugins);
|
|
60
|
+
const startActiveSpan = fortress.telemetry.tracer.startActiveSpan?.bind(fortress.telemetry.tracer)
|
|
61
|
+
?? (async (name, attributes, callback) => callback(
|
|
62
|
+
fortress.telemetry.tracer.startSpan(name, attributes),
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
return async function handleRequest(request: Request): Promise<Response> {
|
|
66
|
+
// Keep the request span active for the entire async pipeline so database,
|
|
67
|
+
// IAM, and plugin spans inherit it through the configured telemetry
|
|
68
|
+
// provider. The no-op provider invokes this callback without allocation.
|
|
69
|
+
return startActiveSpan(
|
|
70
|
+
'fortress.handleRequest',
|
|
71
|
+
{ 'http.method': request.method },
|
|
72
|
+
async (span) => {
|
|
73
|
+
let response: Response | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(request.url);
|
|
76
|
+
const pathname = url.pathname;
|
|
77
|
+
|
|
78
|
+
// 1. Plugin before-auth middleware (rate limit, audit log, etc.)
|
|
79
|
+
await runPluginMiddleware(plugins, fortress.config, 'before-auth', { request });
|
|
80
|
+
|
|
81
|
+
// 1a. H5 — CSRF check. Runs before token verification so a malicious
|
|
82
|
+
// cross-site POST can't trigger any auth lookup work either. The
|
|
83
|
+
// check is a no-op for safe methods, bearer/API-key flows, and
|
|
84
|
+
// deployments that opt out.
|
|
85
|
+
enforceCsrf(request, pathname, csrfConfig, cookieConfig);
|
|
86
|
+
|
|
87
|
+
// 2. Match path + method to an endpoint
|
|
88
|
+
const matched = matchRoute(routeTable, request.method, pathname);
|
|
89
|
+
if (!matched) {
|
|
90
|
+
// Only return 404 for fortress-owned paths; otherwise the caller
|
|
91
|
+
// (an adapter) might want to fall through to its own router.
|
|
92
|
+
if (isFortressPath(pathname, pluginPathPrefixes)) {
|
|
93
|
+
throw Errors.notFound(`No endpoint matches ${request.method} ${pathname}`);
|
|
94
|
+
}
|
|
95
|
+
// Non-fortress path with no match — also a 404 from this entry point
|
|
96
|
+
throw Errors.notFound(`No endpoint matches ${request.method} ${pathname}`);
|
|
97
|
+
}
|
|
98
|
+
const { endpoint, params } = matched;
|
|
99
|
+
// Upgrade span attributes now that we know the matched route.
|
|
100
|
+
// `endpoint.path` is parameterized (e.g. `/iam/roles/:id`) so it's
|
|
101
|
+
// low-cardinality and safe to put on a span/metric attribute.
|
|
102
|
+
span.setAttribute('http.route', endpoint.path);
|
|
103
|
+
span.setAttribute('fortress.handler', endpoint.handler);
|
|
104
|
+
|
|
105
|
+
// 3. Principal resolution. Three paths, tried in order:
|
|
106
|
+
//
|
|
107
|
+
// 3a. Plugin-backed credential resolvers (api-key, future
|
|
108
|
+
// OAuth client_credentials, future mTLS). First plugin to
|
|
109
|
+
// return non-null wins — its subject + optional claims
|
|
110
|
+
// become the request principal.
|
|
111
|
+
// 3b. JWT bearer fallback. Used when no plugin resolves the
|
|
112
|
+
// request and the endpoint declared `security: 'bearer'`.
|
|
113
|
+
// 3c. Routes that declare `meta.bearerKind: 'oauth'` (the OAuth
|
|
114
|
+
// protocol endpoints — token, userinfo, introspect, revoke,
|
|
115
|
+
// authorize, jwks, discovery) self-parse their bearer
|
|
116
|
+
// (which is an OAuth access token, not a Fortress JWT) so
|
|
117
|
+
// we skip both the resolver chain and the JWT check here.
|
|
118
|
+
// Other `/oauth/*` routes — e.g. the consent-flow endpoints
|
|
119
|
+
// `/oauth/flows/:flowId{,/approve,/deny}` — default to
|
|
120
|
+
// `bearerKind: 'jwt'` and go through the normal auth
|
|
121
|
+
// pipeline so a host app SPA can call them with a Fortress
|
|
122
|
+
// JWT and have RBAC honoured.
|
|
123
|
+
let subject: Subject | undefined;
|
|
124
|
+
let userId: string | undefined;
|
|
125
|
+
let claims: TokenClaims | undefined;
|
|
126
|
+
let scopes: string[] | null | undefined;
|
|
127
|
+
const selfManagedBearer = endpoint.meta?.bearerKind === 'oauth';
|
|
128
|
+
|
|
129
|
+
if (!selfManagedBearer) {
|
|
130
|
+
const resolved = await tryPluginPrincipal(fortress, request);
|
|
131
|
+
if (resolved) {
|
|
132
|
+
subject = resolved.subject;
|
|
133
|
+
claims = resolved.claims;
|
|
134
|
+
scopes = resolved.scopes;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const requiresBearer = !selfManagedBearer
|
|
139
|
+
&& ((endpoint.meta?.security?.includes('bearer') ?? false) || !!endpoint.meta?.permission);
|
|
140
|
+
if (!subject && requiresBearer) {
|
|
141
|
+
const token = extractAccessToken(request, cookieConfig);
|
|
142
|
+
if (!token)
|
|
143
|
+
throw Errors.unauthorized('Missing access token');
|
|
144
|
+
try {
|
|
145
|
+
claims = await fortress.auth.verifyToken(token);
|
|
146
|
+
subject = { type: claims.subjectType, id: claims.sub };
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof FortressError)
|
|
150
|
+
throw err;
|
|
151
|
+
throw Errors.unauthorized('Invalid access token');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Convenience alias: downstream code that only needs `userId` (adapters,
|
|
156
|
+
// plugin middleware) still works for USER subjects. Non-user principals
|
|
157
|
+
// leave `userId` undefined, which is correct — they'd be hitting routes
|
|
158
|
+
// that RBAC-check via `subject` now.
|
|
159
|
+
if (subject?.type === 'USER')
|
|
160
|
+
userId = subject.id;
|
|
161
|
+
|
|
162
|
+
if (subject) {
|
|
163
|
+
span.setAttribute('fortress.subject_type', subject.type);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 4. Plugin after-auth middleware
|
|
167
|
+
await runPluginMiddleware(plugins, fortress.config, 'after-auth', {
|
|
168
|
+
request,
|
|
169
|
+
fortressSubject: subject,
|
|
170
|
+
fortressUserId: userId,
|
|
171
|
+
fortressClaims: claims,
|
|
172
|
+
fortressScopes: scopes,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 5. Fortress-managed default-deny RBAC. Routes flagged
|
|
176
|
+
// `bearerKind: 'oauth'` self-authenticate inside their handlers
|
|
177
|
+
// (the bearer is an OAuth token, not a JWT) so they're exempt
|
|
178
|
+
// from the IAM check too.
|
|
179
|
+
if (!selfManagedBearer) {
|
|
180
|
+
await enforceFortressPermission(endpoint, subject, {
|
|
181
|
+
checkPermission: (subj, resource, action, credentialScopes): Promise<boolean> =>
|
|
182
|
+
fortress.iam.checkPermission(subj, resource, action, { credentialScopes }),
|
|
183
|
+
}, scopes);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 6. Plugin after-rbac middleware
|
|
187
|
+
await runPluginMiddleware(plugins, fortress.config, 'after-rbac', {
|
|
188
|
+
request,
|
|
189
|
+
fortressSubject: subject,
|
|
190
|
+
fortressUserId: userId,
|
|
191
|
+
fortressClaims: claims,
|
|
192
|
+
fortressScopes: scopes,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// 7. Body parse + validation. Validation reads the body via clone()
|
|
196
|
+
// so dispatch can re-read it. We sniff the content-type to know
|
|
197
|
+
// whether validateRequest can parse JSON. Skipped for routes
|
|
198
|
+
// flagged `bearerKind: 'oauth'` — their bodies are
|
|
199
|
+
// `application/x-www-form-urlencoded` and the OAuth dispatcher
|
|
200
|
+
// does its own parsing/validation per RFC 6749.
|
|
201
|
+
if (!selfManagedBearer) {
|
|
202
|
+
let parsedBody: unknown;
|
|
203
|
+
if (
|
|
204
|
+
request.method !== 'GET'
|
|
205
|
+
&& request.method !== 'HEAD'
|
|
206
|
+
&& (request.headers.get('content-type') ?? '').includes('json')
|
|
207
|
+
) {
|
|
208
|
+
parsedBody = await request.clone().json().catch(() => undefined);
|
|
209
|
+
}
|
|
210
|
+
const query = Object.fromEntries(url.searchParams);
|
|
211
|
+
// URL-sourced data is always strings. Coerce query/params to the
|
|
212
|
+
// types declared in the endpoint's JSON Schema before validation
|
|
213
|
+
// so `:id`/`?limit=2` don't trip integer/number/boolean checks.
|
|
214
|
+
const coercedQuery = coerceBySchema(endpoint.input?.query, query);
|
|
215
|
+
const coercedParams = coerceBySchema(endpoint.input?.params, params);
|
|
216
|
+
await validateRequest(endpoint.input, { body: parsedBody, query: coercedQuery, params: coercedParams });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 8. Dispatch + serialize. Pass IP/UA from headers as RequestMeta so
|
|
220
|
+
// auth handlers can stamp refresh tokens with their origin.
|
|
221
|
+
const meta = {
|
|
222
|
+
ipAddress:
|
|
223
|
+
request.headers.get('x-forwarded-for')
|
|
224
|
+
?? request.headers.get('x-real-ip')
|
|
225
|
+
?? undefined,
|
|
226
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
227
|
+
};
|
|
228
|
+
const dispatched = await dispatchEndpoint(fortress, request, endpoint, params, {
|
|
229
|
+
subject,
|
|
230
|
+
userId,
|
|
231
|
+
claims,
|
|
232
|
+
scopes,
|
|
233
|
+
meta,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// 9. If the endpoint emitted an auth result, serialize cookies.
|
|
237
|
+
// Detected by checking the response body for accessToken/refreshToken
|
|
238
|
+
// fields. We avoid touching streamed/HTML responses.
|
|
239
|
+
const cookies = await maybeBuildAuthCookies(
|
|
240
|
+
endpoint.handler,
|
|
241
|
+
dispatched,
|
|
242
|
+
fortress,
|
|
243
|
+
request,
|
|
244
|
+
);
|
|
245
|
+
response = cookies.length > 0
|
|
246
|
+
? withCookies(cookies.response, cookies.setCookies)
|
|
247
|
+
: dispatched;
|
|
248
|
+
return response;
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
response = errorToResponse(err, fortress.logger);
|
|
252
|
+
span.recordException(err);
|
|
253
|
+
span.setStatus({
|
|
254
|
+
code: 'error',
|
|
255
|
+
message: err instanceof Error ? err.message : String(err),
|
|
256
|
+
});
|
|
257
|
+
return response;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
if (response) {
|
|
261
|
+
span.setAttribute('http.status_code', response.status);
|
|
262
|
+
if (response.status >= 200 && response.status < 400) {
|
|
263
|
+
span.setStatus({ code: 'ok' });
|
|
264
|
+
}
|
|
265
|
+
else if (response.status >= 400) {
|
|
266
|
+
// 4xx/5xx without an exception (e.g. a FortressError already
|
|
267
|
+
// serialized). Mark the span as error so traces highlight it.
|
|
268
|
+
span.setStatus({
|
|
269
|
+
code: 'error',
|
|
270
|
+
message: `HTTP ${response.status}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
span.end();
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Inspect the dispatched response and, if it's an auth-issuing endpoint
|
|
283
|
+
* (`login`, `refresh`, `register-and-login`, etc.), build the matching
|
|
284
|
+
* `Set-Cookie` headers using the resolved cookie config.
|
|
285
|
+
*
|
|
286
|
+
* Returns the (possibly clone-replaced) response so callers can use
|
|
287
|
+
* `withCookies` against the body that was actually consumed.
|
|
288
|
+
*/
|
|
289
|
+
async function maybeBuildAuthCookies(
|
|
290
|
+
handler: string,
|
|
291
|
+
response: Response,
|
|
292
|
+
fortress: Fortress,
|
|
293
|
+
_request: Request,
|
|
294
|
+
): Promise<{ setCookies: string[]; response: Response; length: number }> {
|
|
295
|
+
// Logout succeeds only after dispatch revokes the supplied refresh token;
|
|
296
|
+
// clear both browser credentials on that successful response.
|
|
297
|
+
if (handler === 'logout') {
|
|
298
|
+
if (response.status >= 400)
|
|
299
|
+
return { setCookies: [], response, length: 0 };
|
|
300
|
+
const setCookies = clearAuthCookies(resolveCookieConfig(fortress.config.cookies));
|
|
301
|
+
return { setCookies, response, length: setCookies.length };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Only inspect successful auth-related handlers.
|
|
305
|
+
const isAuthIssuing = handler === 'login'
|
|
306
|
+
|| handler === 'refresh'
|
|
307
|
+
|| handler === 'impersonate'
|
|
308
|
+
|| handler === 'verifyTwoFactor'
|
|
309
|
+
|| handler === 'verifyMagicLink';
|
|
310
|
+
if (!isAuthIssuing)
|
|
311
|
+
return { setCookies: [], response, length: 0 };
|
|
312
|
+
if (response.status >= 400)
|
|
313
|
+
return { setCookies: [], response, length: 0 };
|
|
314
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
315
|
+
if (!contentType.includes('json'))
|
|
316
|
+
return { setCookies: [], response, length: 0 };
|
|
317
|
+
|
|
318
|
+
// Read the JSON body once and reconstruct the response.
|
|
319
|
+
const bodyText = await response.clone().text();
|
|
320
|
+
let parsed: unknown;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(bodyText);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return { setCookies: [], response, length: 0 };
|
|
326
|
+
}
|
|
327
|
+
const obj = parsed as { accessToken?: unknown; refreshToken?: unknown };
|
|
328
|
+
if (typeof obj?.accessToken !== 'string') {
|
|
329
|
+
return { setCookies: [], response, length: 0 };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const cookieConfig = resolveCookieConfig(fortress.config.cookies);
|
|
333
|
+
const accessExpiry = fortress.config.jwt.accessTokenExpirySeconds ?? 900;
|
|
334
|
+
const refreshExpiry = fortress.config.jwt.refreshTokenExpirySeconds ?? 604_800;
|
|
335
|
+
const setCookies = serializeAuthCookies(
|
|
336
|
+
{
|
|
337
|
+
accessToken: obj.accessToken,
|
|
338
|
+
refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
|
|
339
|
+
},
|
|
340
|
+
cookieConfig,
|
|
341
|
+
{ access: accessExpiry, refresh: refreshExpiry },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Reconstruct the response from the consumed body so the caller can stream it.
|
|
345
|
+
const replaced = new Response(bodyText, {
|
|
346
|
+
status: response.status,
|
|
347
|
+
statusText: response.statusText,
|
|
348
|
+
headers: response.headers,
|
|
349
|
+
});
|
|
350
|
+
return { setCookies, response: replaced, length: setCookies.length };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Re-export for adapter use
|
|
354
|
+
export type { RouteEntry };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { EndpointDefinition } from '../endpoint';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { buildRouteTable, matchRoute } from './match';
|
|
4
|
+
|
|
5
|
+
function ep(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', path: string, handler: string): EndpointDefinition {
|
|
6
|
+
return {
|
|
7
|
+
method,
|
|
8
|
+
path,
|
|
9
|
+
handler,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('matchRoute', () => {
|
|
14
|
+
const table = buildRouteTable([
|
|
15
|
+
ep('GET', '/auth/me', 'me'),
|
|
16
|
+
ep('POST', '/auth/login', 'login'),
|
|
17
|
+
ep('GET', '/iam/roles', 'getRoles'),
|
|
18
|
+
ep('POST', '/iam/roles', 'createRole'),
|
|
19
|
+
ep('DELETE', '/iam/roles/:id', 'deleteRole'),
|
|
20
|
+
ep('GET', '/iam/users/:id/permissions', 'getUserPermissions'),
|
|
21
|
+
ep('DELETE', '/iam/groups/:id/users/:userId', 'removeUserFromGroup'),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
it('matches an exact static route', () => {
|
|
25
|
+
const m = matchRoute(table, 'GET', '/auth/me');
|
|
26
|
+
expect(m?.endpoint.handler).toBe('me');
|
|
27
|
+
expect(m?.params).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('respects HTTP method', () => {
|
|
31
|
+
expect(matchRoute(table, 'POST', '/auth/me')).toBeNull();
|
|
32
|
+
expect(matchRoute(table, 'GET', '/iam/roles')?.endpoint.handler).toBe('getRoles');
|
|
33
|
+
expect(matchRoute(table, 'POST', '/iam/roles')?.endpoint.handler).toBe('createRole');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('extracts a single path param', () => {
|
|
37
|
+
const m = matchRoute(table, 'DELETE', '/iam/roles/42');
|
|
38
|
+
expect(m?.endpoint.handler).toBe('deleteRole');
|
|
39
|
+
expect(m?.params).toEqual({ id: '42' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('extracts multiple path params', () => {
|
|
43
|
+
const m = matchRoute(table, 'DELETE', '/iam/groups/3/users/7');
|
|
44
|
+
expect(m?.endpoint.handler).toBe('removeUserFromGroup');
|
|
45
|
+
expect(m?.params).toEqual({ id: '3', userId: '7' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when segment counts differ', () => {
|
|
49
|
+
expect(matchRoute(table, 'GET', '/iam/roles/extra')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns null when no route matches', () => {
|
|
53
|
+
expect(matchRoute(table, 'GET', '/nope')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('case-insensitive on method', () => {
|
|
57
|
+
expect(matchRoute(table, 'get', '/auth/me')?.endpoint.handler).toBe('me');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('url-decodes path param values', () => {
|
|
61
|
+
const m = matchRoute(table, 'GET', '/iam/users/hello%20world/permissions');
|
|
62
|
+
expect(m?.params.id).toBe('hello world');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
[
|
|
67
|
+
[
|
|
68
|
+
ep('GET', '/users/:id', 'param'),
|
|
69
|
+
ep('GET', '/users/me', 'static'),
|
|
70
|
+
],
|
|
71
|
+
'static',
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
[
|
|
75
|
+
ep('GET', '/users/me', 'static'),
|
|
76
|
+
ep('GET', '/users/:id', 'param'),
|
|
77
|
+
],
|
|
78
|
+
'static',
|
|
79
|
+
],
|
|
80
|
+
] as const)('prefers /users/me over /users/:id regardless of registration order', (routes, handler) => {
|
|
81
|
+
const m = matchRoute(buildRouteTable(routes), 'GET', '/users/me');
|
|
82
|
+
expect(m?.endpoint.handler).toBe(handler);
|
|
83
|
+
expect(m?.params).toEqual({});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it.each([
|
|
87
|
+
[
|
|
88
|
+
[
|
|
89
|
+
ep('GET', '/users/*', 'wildcard'),
|
|
90
|
+
ep('GET', '/users/:id', 'param'),
|
|
91
|
+
ep('GET', '/users/me', 'static'),
|
|
92
|
+
],
|
|
93
|
+
'static',
|
|
94
|
+
'/users/me',
|
|
95
|
+
{},
|
|
96
|
+
],
|
|
97
|
+
[
|
|
98
|
+
[
|
|
99
|
+
ep('GET', '/users/me', 'static'),
|
|
100
|
+
ep('GET', '/users/*', 'wildcard'),
|
|
101
|
+
ep('GET', '/users/:id', 'param'),
|
|
102
|
+
],
|
|
103
|
+
'param',
|
|
104
|
+
'/users/alice',
|
|
105
|
+
{ id: 'alice' },
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
[
|
|
109
|
+
ep('GET', '/accounts/:id', 'param'),
|
|
110
|
+
ep('GET', '/users/me', 'static'),
|
|
111
|
+
ep('GET', '/users/*', 'wildcard'),
|
|
112
|
+
],
|
|
113
|
+
'wildcard',
|
|
114
|
+
'/users/alice',
|
|
115
|
+
{},
|
|
116
|
+
],
|
|
117
|
+
] as const)('uses static, param, then wildcard precedence deterministically', (routes, handler, path, params) => {
|
|
118
|
+
const m = matchRoute(buildRouteTable(routes), 'GET', path);
|
|
119
|
+
expect(m?.endpoint.handler).toBe(handler);
|
|
120
|
+
expect(m?.params).toEqual(params);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint route matching shared by dispatch and fortress-RBAC.
|
|
3
|
+
*
|
|
4
|
+
* Builds a path-segment table from {@link EndpointDefinition}s and matches
|
|
5
|
+
* incoming `(method, pathname)` pairs against it. Supports `:param` segments
|
|
6
|
+
* and returns extracted path parameters alongside the matched endpoint.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { EndpointDefinition } from '../endpoint';
|
|
10
|
+
|
|
11
|
+
/** Minimal route shape accepted by the matcher. */
|
|
12
|
+
export interface RouteLike {
|
|
13
|
+
method: string;
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Route table entry built from an {@link EndpointDefinition} or manifest entry. */
|
|
18
|
+
export interface RouteEntry<T extends RouteLike = EndpointDefinition> {
|
|
19
|
+
endpoint: T;
|
|
20
|
+
method: string;
|
|
21
|
+
segments: string[];
|
|
22
|
+
paramNames: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Higher values are more specific when two routes have the same shape. */
|
|
26
|
+
function segmentSpecificity(segment: string): number {
|
|
27
|
+
if (segment === '*')
|
|
28
|
+
return 0;
|
|
29
|
+
if (segment.startsWith(':'))
|
|
30
|
+
return 1;
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Successful match: the endpoint/route plus extracted path parameters. */
|
|
35
|
+
export interface RouteMatch<T extends RouteLike = EndpointDefinition> {
|
|
36
|
+
endpoint: T;
|
|
37
|
+
params: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize path separators so route and middleware matching share one form. */
|
|
41
|
+
export function canonicalizePath(pathname: string): string {
|
|
42
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
43
|
+
return segments.length === 0 ? '/' : `/${segments.join('/')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pre-build a route table from endpoint definitions. Adapters call this once
|
|
48
|
+
* at startup and hand the result to {@link matchRoute} on every request.
|
|
49
|
+
*/
|
|
50
|
+
export function buildRouteTable<T extends RouteLike>(endpoints: readonly T[]): RouteEntry<T>[] {
|
|
51
|
+
const table = endpoints.map((ep) => {
|
|
52
|
+
const segments = canonicalizePath(ep.path).split('/').filter(Boolean);
|
|
53
|
+
const paramNames: string[] = [];
|
|
54
|
+
for (const seg of segments) {
|
|
55
|
+
if (seg.startsWith(':'))
|
|
56
|
+
paramNames.push(seg.slice(1));
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
endpoint: ep,
|
|
60
|
+
method: ep.method.toUpperCase(),
|
|
61
|
+
segments,
|
|
62
|
+
paramNames,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Match specificity lexicographically so a static segment always wins over
|
|
67
|
+
// a parameter (and a parameter over a wildcard), independent of registration
|
|
68
|
+
// order. Stable sorting retains registration order for duplicate patterns.
|
|
69
|
+
return table
|
|
70
|
+
.map((route, index) => ({ route, index }))
|
|
71
|
+
.sort((a, b) => {
|
|
72
|
+
const length = Math.max(a.route.segments.length, b.route.segments.length);
|
|
73
|
+
for (let i = 0; i < length; i++) {
|
|
74
|
+
const difference = segmentSpecificity(b.route.segments[i] ?? '')
|
|
75
|
+
- segmentSpecificity(a.route.segments[i] ?? '');
|
|
76
|
+
if (difference !== 0)
|
|
77
|
+
return difference;
|
|
78
|
+
}
|
|
79
|
+
return a.index - b.index;
|
|
80
|
+
})
|
|
81
|
+
.map(({ route }) => route);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Find the endpoint that matches a `(method, pathname)` pair. Returns the
|
|
86
|
+
* matched endpoint and any extracted `:param` values, or `null` if nothing
|
|
87
|
+
* matches.
|
|
88
|
+
*
|
|
89
|
+
* Matching is segment-by-segment with literal segments compared verbatim,
|
|
90
|
+
* `:param` segments capturing into `params`, and `*` matching any one segment.
|
|
91
|
+
* Static segments take priority over params, which take priority over
|
|
92
|
+
* wildcards, independent of registration order.
|
|
93
|
+
*/
|
|
94
|
+
export function matchRoute<T extends RouteLike>(
|
|
95
|
+
table: RouteEntry<T>[],
|
|
96
|
+
method: string,
|
|
97
|
+
pathname: string,
|
|
98
|
+
): RouteMatch<T> | null {
|
|
99
|
+
const upperMethod = method.toUpperCase();
|
|
100
|
+
const pathSegments = canonicalizePath(pathname).split('/').filter(Boolean);
|
|
101
|
+
|
|
102
|
+
for (const route of table) {
|
|
103
|
+
if (route.method !== upperMethod)
|
|
104
|
+
continue;
|
|
105
|
+
if (route.segments.length !== pathSegments.length)
|
|
106
|
+
continue;
|
|
107
|
+
|
|
108
|
+
const params: Record<string, string> = {};
|
|
109
|
+
let matched = true;
|
|
110
|
+
for (let i = 0; i < route.segments.length; i++) {
|
|
111
|
+
const routeSeg = route.segments[i];
|
|
112
|
+
const pathSeg = pathSegments[i];
|
|
113
|
+
if (routeSeg.startsWith(':')) {
|
|
114
|
+
params[routeSeg.slice(1)] = decodeURIComponent(pathSeg);
|
|
115
|
+
}
|
|
116
|
+
else if (routeSeg !== '*' && routeSeg !== pathSeg) {
|
|
117
|
+
matched = false;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (matched)
|
|
122
|
+
return { endpoint: route.endpoint, params };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createOutboundClient } from './outbound';
|
|
3
|
+
|
|
4
|
+
describe('outbound full-exchange timeout', () => {
|
|
5
|
+
afterEach(() => vi.restoreAllMocks());
|
|
6
|
+
|
|
7
|
+
it('keeps the deadline active while the response body is consumed', async () => {
|
|
8
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
|
9
|
+
const request = input as Request;
|
|
10
|
+
const body = new ReadableStream<Uint8Array>({
|
|
11
|
+
start(controller) {
|
|
12
|
+
request.signal.addEventListener('abort', () => {
|
|
13
|
+
controller.error(request.signal.reason);
|
|
14
|
+
}, { once: true });
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
return new Response(body, { status: 200 });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const client = createOutboundClient(20);
|
|
21
|
+
const response = await client.get('https://slow.example.test/data');
|
|
22
|
+
|
|
23
|
+
await expect(response.text()).rejects.toThrow(/timed out/i);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('clears the deadline after a complete body is read', async () => {
|
|
27
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
|
|
28
|
+
const client = createOutboundClient(100);
|
|
29
|
+
|
|
30
|
+
const response = await client.get('https://example.test/data');
|
|
31
|
+
|
|
32
|
+
await expect(response.json()).resolves.toEqual({ ok: true });
|
|
33
|
+
});
|
|
34
|
+
});
|