@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,188 @@
|
|
|
1
|
+
# WebAuthn Plugin
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `webauthn` plugin adds passkey and WebAuthn support to Fortress, enabling passwordless authentication using platform authenticators (Touch ID, Face ID, Windows Hello) and security keys. It is built on [@simplewebauthn/server](https://simplewebauthn.dev/).
|
|
6
|
+
|
|
7
|
+
The plugin supports both registration (adding a passkey to an existing account) and authentication (signing in with a passkey). In passwordless mode, successful authentication returns JWT tokens directly.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Import the `webauthn` factory and pass it in the `plugins` array when creating a Fortress instance:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createFortress } from '@bajustone/fortress';
|
|
15
|
+
import { webauthn } from '@bajustone/fortress/plugins/webauthn';
|
|
16
|
+
|
|
17
|
+
const fortress = createFortress({
|
|
18
|
+
jwt: { key: 'your-secret-at-least-32-bytes!!' },
|
|
19
|
+
database: adapter,
|
|
20
|
+
plugins: [
|
|
21
|
+
webauthn({
|
|
22
|
+
rpName: 'My App',
|
|
23
|
+
rpID: 'example.com',
|
|
24
|
+
origin: 'https://example.com',
|
|
25
|
+
}),
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Once registered, methods are available at `fortress.plugins['webauthn']` with full type safety.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
| Option | Type | Default | Required | Description |
|
|
35
|
+
|---|---|---|---|---|
|
|
36
|
+
| `rpName` | `string` | -- | Yes | Human-readable relying party name shown to users (e.g., "My App"). |
|
|
37
|
+
| `rpID` | `string` | -- | Yes | Relying party identifier, typically the domain (e.g., "example.com"). |
|
|
38
|
+
| `origin` | `string \| string[]` | -- | Yes | Expected origin(s) for credentials (e.g., "https://example.com"). |
|
|
39
|
+
| `attestation` | `'none' \| 'indirect' \| 'direct' \| 'enterprise'` | `'none'` | No | Attestation preference. |
|
|
40
|
+
| `authenticatorSelection.authenticatorAttachment` | `'platform' \| 'cross-platform'` | -- | No | Restrict to platform or roaming authenticators. |
|
|
41
|
+
| `authenticatorSelection.residentKey` | `'discouraged' \| 'preferred' \| 'required'` | `'preferred'` | No | Resident key (discoverable credential) preference. |
|
|
42
|
+
| `authenticatorSelection.userVerification` | `'discouraged' \| 'preferred' \| 'required'` | `'preferred'` | No | User verification preference. |
|
|
43
|
+
| `timeout` | `number` | `60000` (60s) | No | Timeout for WebAuthn ceremonies in milliseconds. |
|
|
44
|
+
| `challengeTTLSeconds` | `number` | `300` (5 min) | No | How long a challenge remains valid, in seconds. |
|
|
45
|
+
| `supportPasswordless` | `boolean` | `true` | No | When `true`, `verifyAuthentication` returns `AuthResult` for direct passwordless login. When `false`, the plugin registers a pre-token `postAuthGate` and challenges password logins. |
|
|
46
|
+
|
|
47
|
+
## HTTP Routes
|
|
48
|
+
|
|
49
|
+
The plugin defines four routes that are auto-mounted via `mountFortress`:
|
|
50
|
+
|
|
51
|
+
| Method | Path | Auth Required | Description |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| POST | `/webauthn/register/options` | Yes (bearer) | Generate registration options for a new passkey against the authenticated caller. Body: `{}`. |
|
|
54
|
+
| POST | `/webauthn/register/verify` | Yes (bearer) | Verify the registration response and store the credential for the authenticated caller. Body: `{ response }`. |
|
|
55
|
+
| POST | `/webauthn/authenticate/options` | No | Generate authentication options. Optionally pass `userId` for non-discoverable flow. |
|
|
56
|
+
| POST | `/webauthn/authenticate/verify` | No | Verify the authentication assertion and return tokens (if passwordless). |
|
|
57
|
+
|
|
58
|
+
## API Reference
|
|
59
|
+
|
|
60
|
+
| Method | Signature | Returns |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `generateRegistrationOptions` | `(input: Record<string, unknown>, ctx: PluginRouteContext)` | `Promise<{ options: PublicKeyCredentialCreationOptionsJSON }>` |
|
|
63
|
+
| `verifyRegistration` | `(input: { response: RegistrationResponseJSON }, ctx: PluginRouteContext)` | `Promise<{ verified, credentialId, credentialDeviceType, credentialBackedUp }>` |
|
|
64
|
+
| `generateAuthenticationOptions` | `(input: { userId?: string })` | `Promise<{ options: PublicKeyCredentialRequestOptionsJSON }>` |
|
|
65
|
+
| `completeAuthentication` | `(continuationToken, response, meta?)` | `Promise<AuthResult>` |
|
|
66
|
+
| `verifyAuthentication` | `(input: { response: AuthenticationResponseJSON }, meta?)` | `Promise<AuthResult>` |
|
|
67
|
+
|
|
68
|
+
> **Registration methods require `PluginRouteContext`.** The target user is always the verified caller (`ctx.userId`) — there is no body-supplied `userId` field. When a browser hits `POST /webauthn/register/options` or `/webauthn/register/verify`, `fortress.handleRequest` constructs the `ctx` automatically from the bearer token. Programmatic callers must build one by hand (see below).
|
|
69
|
+
|
|
70
|
+
### generateRegistrationOptions
|
|
71
|
+
|
|
72
|
+
Generates options for `navigator.credentials.create()`. Existing credentials for the authenticated caller are included in `excludeCredentials` to prevent duplicate registration:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// Programmatic (server-side, no HTTP request)
|
|
76
|
+
const { options } = await fortress.plugins['webauthn'].generateRegistrationOptions(
|
|
77
|
+
{},
|
|
78
|
+
{ userId, request: new Request('http://localhost') },
|
|
79
|
+
);
|
|
80
|
+
// Pass options to the browser: navigator.credentials.create({ publicKey: options })
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Throws `Unauthorized` if `ctx.userId` is missing, or `NotFound` if the user does not exist.
|
|
84
|
+
|
|
85
|
+
### verifyRegistration
|
|
86
|
+
|
|
87
|
+
Verifies the authenticator's response and stores the new credential against the authenticated caller:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const result = await fortress.plugins['webauthn'].verifyRegistration(
|
|
91
|
+
{ response: registrationResponseFromBrowser },
|
|
92
|
+
{ userId, request: new Request('http://localhost') },
|
|
93
|
+
);
|
|
94
|
+
// result.verified, result.credentialId, result.credentialDeviceType, result.credentialBackedUp
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Throws:
|
|
98
|
+
- `Unauthorized` -- `ctx.userId` is missing.
|
|
99
|
+
- `BadRequest` -- No pending challenge, challenge expired, or verification failed.
|
|
100
|
+
|
|
101
|
+
### generateAuthenticationOptions
|
|
102
|
+
|
|
103
|
+
Generates options for `navigator.credentials.get()`. Pass `userId` to restrict to that user's credentials, or omit for discoverable credential (passkey) flow:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// Discoverable flow (user selects passkey)
|
|
107
|
+
const { options } = await fortress.plugins['webauthn'].generateAuthenticationOptions({});
|
|
108
|
+
|
|
109
|
+
// User-specific flow
|
|
110
|
+
const { options } = await fortress.plugins['webauthn'].generateAuthenticationOptions({ userId });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### verifyAuthentication
|
|
114
|
+
|
|
115
|
+
Verifies the authentication assertion. In passwordless mode, returns the unified auth result and issues a full session:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const result = await fortress.plugins['webauthn'].verifyAuthentication({
|
|
119
|
+
response: authenticationResponseFromBrowser,
|
|
120
|
+
});
|
|
121
|
+
if (result.status === 'success') {
|
|
122
|
+
// result.user, result.method === 'webauthn', result.accessToken, result.refreshToken
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
For second-factor mode, pass the pending login's continuation token and browser assertion to `completeAuthentication(continuationToken, response, meta)`. Both paths validate the authenticator counter to detect credential cloning (skipped for synced passkeys where both counters are 0), and both rerun remaining gates before token issuance.
|
|
127
|
+
|
|
128
|
+
Throws:
|
|
129
|
+
- `Unauthorized` -- Unknown credential or verification failed.
|
|
130
|
+
- `BadRequest` -- No pending challenge or challenge expired.
|
|
131
|
+
|
|
132
|
+
## Example
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { createFortress } from '@bajustone/fortress';
|
|
136
|
+
import { webauthn } from '@bajustone/fortress/plugins/webauthn';
|
|
137
|
+
|
|
138
|
+
const fortress = createFortress({
|
|
139
|
+
jwt: { key: 'your-secret-at-least-32-bytes!!' },
|
|
140
|
+
database: adapter,
|
|
141
|
+
plugins: [
|
|
142
|
+
webauthn({
|
|
143
|
+
rpName: 'My App',
|
|
144
|
+
rpID: 'myapp.com',
|
|
145
|
+
origin: 'https://myapp.com',
|
|
146
|
+
supportPasswordless: true,
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- Registration (browser + server) ---
|
|
152
|
+
// Most apps let the browser hit POST /webauthn/register/options and
|
|
153
|
+
// /webauthn/register/verify directly — the dispatcher constructs the
|
|
154
|
+
// PluginRouteContext from the bearer token so the passkey is always
|
|
155
|
+
// registered against the verified caller.
|
|
156
|
+
//
|
|
157
|
+
// Calling programmatically (e.g. from a custom Hono route or CLI tool)
|
|
158
|
+
// requires building a PluginRouteContext by hand:
|
|
159
|
+
|
|
160
|
+
// Server: generate options
|
|
161
|
+
const { options } = await fortress.plugins['webauthn'].generateRegistrationOptions(
|
|
162
|
+
{},
|
|
163
|
+
{ userId, request: new Request('http://localhost') },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Browser: create credential
|
|
167
|
+
// const credential = await navigator.credentials.create({ publicKey: options });
|
|
168
|
+
|
|
169
|
+
// Server: verify and store
|
|
170
|
+
const result = await fortress.plugins['webauthn'].verifyRegistration(
|
|
171
|
+
{ response: credentialResponseFromBrowser },
|
|
172
|
+
{ userId, request: new Request('http://localhost') },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// --- Authentication (browser + server) ---
|
|
176
|
+
|
|
177
|
+
// Server: generate options
|
|
178
|
+
const { options: authOptions } = await fortress.plugins['webauthn'].generateAuthenticationOptions({});
|
|
179
|
+
|
|
180
|
+
// Browser: get assertion
|
|
181
|
+
// const assertion = await navigator.credentials.get({ publicKey: authOptions });
|
|
182
|
+
|
|
183
|
+
// Server: verify and get tokens
|
|
184
|
+
const authResult = await fortress.plugins['webauthn'].verifyAuthentication({
|
|
185
|
+
response: assertionFromBrowser,
|
|
186
|
+
});
|
|
187
|
+
// authResult.accessToken -- use this to authenticate the user
|
|
188
|
+
```
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Webhook Plugin
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The `webhook` plugin delivers events to consumer-registered HTTPS endpoints following the [Standard Webhooks](https://www.standardwebhooks.com) specification (HMAC-SHA256 signing, `webhook-id`/`webhook-timestamp`/`webhook-signature` headers).
|
|
6
|
+
|
|
7
|
+
Highlights:
|
|
8
|
+
|
|
9
|
+
- **Custom events** — declare and `emit()` your own events through the same path the built-in auth events use.
|
|
10
|
+
- **Bring-Your-Own-Queue (BYOQ)** — delivery is always queued; plug in any backend (in-memory, database, BullMQ, SQS, Cloudflare Queues, …) via a small interface. Defaults to a dev-only in-memory queue.
|
|
11
|
+
- **SSRF-safe delivery** — webhook URLs are consumer-supplied, so delivery resolves + validates the target, blocks private/loopback/link-local/CGNAT/NAT64 addresses, and **pins the connection to the resolved IP** (closing the DNS-rebinding window).
|
|
12
|
+
- **Resilience** — failure classification, jittered exponential backoff, a per-endpoint circuit breaker, and DLQ/alert callbacks.
|
|
13
|
+
- **Per-delivery idempotency** — a stable `webhook-id` so receivers can dedup retries, plus an `idempotencyKey` so `emit()` is safe to call more than once.
|
|
14
|
+
|
|
15
|
+
Built-in auth events are dispatched automatically via lifecycle hooks; custom events you `emit()` yourself.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createFortress } from '@bajustone/fortress';
|
|
21
|
+
import { webhook } from '@bajustone/fortress/plugins/webhook';
|
|
22
|
+
|
|
23
|
+
const fortress = createFortress({
|
|
24
|
+
jwt: { key: 'your-secret-at-least-32-bytes!!' },
|
|
25
|
+
database: adapter,
|
|
26
|
+
plugins: [webhook()], // defaults: all built-in events, in-memory queue
|
|
27
|
+
});
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Methods are available at `fortress.plugins.webhook`. The plugin is method-only — it ships **no** HTTP routes (mount your own management endpoints, as the example app does).
|
|
31
|
+
|
|
32
|
+
## Events
|
|
33
|
+
|
|
34
|
+
Built-in and custom events share one declaration shape (`WebhookEventDeclaration`) and one emit path. The built-ins:
|
|
35
|
+
|
|
36
|
+
| Event | Source hook |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `auth.login.success` | `afterLogin` |
|
|
39
|
+
| `auth.login.failure` | `onLoginFailure` |
|
|
40
|
+
| `auth.logout` | `beforeLogout` |
|
|
41
|
+
| `auth.user.registered` | `afterRegister` |
|
|
42
|
+
| `auth.token.refreshed` | `afterTokenRefresh` |
|
|
43
|
+
|
|
44
|
+
Pass `events` to declare custom events (and optionally exclude built-ins). Each event may carry a Standard Schema validator (`schema`) — the payload is validated at `emit()` time.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { builtinEvents, webhook } from '@bajustone/fortress/plugins/webhook';
|
|
48
|
+
import { obj, str } from '@bajustone/fortress';
|
|
49
|
+
|
|
50
|
+
webhook({
|
|
51
|
+
events: [
|
|
52
|
+
...builtinEvents({ exclude: ['auth.logout'] }), // keep the built-ins you want
|
|
53
|
+
{ name: 'order.paid', schema: obj({ orderId: str(), amount: str() }, 'orderId', 'amount') },
|
|
54
|
+
{ name: 'order.refunded' },
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Emit a custom event from your code:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
await fortress.plugins.webhook.emit('order.paid', { orderId: 'o_123', amount: '49.00' });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`emit()` throws a `WebhookEmitError` (with `code: 'unknown_event' | 'invalid_payload' | 'payload_too_large'`) so you can branch on the failure.
|
|
66
|
+
|
|
67
|
+
## Queue (Bring-Your-Own-Queue)
|
|
68
|
+
|
|
69
|
+
Delivery is always queued. Bundled queues (zero extra deps):
|
|
70
|
+
|
|
71
|
+
- **`inMemoryQueue()`** — the **default**. `setTimeout`-driven, single process. **Dev-only**: scheduled retries live in in-process timers, so a restart loses every pending retry until the next process runs its startup recovery sweep. Jobs are processed sequentially.
|
|
72
|
+
- **`databaseQueue({ pollMs })`** — the crash-safe bundled option. Polls `webhook_delivery` for due rows; the table itself is the transactional outbox, so it survives restarts. Single-worker (run one poller per deployment, or use a real broker for multi-worker).
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { databaseQueue, webhook } from '@bajustone/fortress/plugins/webhook';
|
|
76
|
+
|
|
77
|
+
webhook({ queue: databaseQueue({ pollMs: 10_000 }) });
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Implement `WebhookQueue` to use any external broker. Set `handlesRetries: true` and `delivery.retry: 'queue'` to let the broker own retries (the plugin re-throws on failure so the broker re-delivers).
|
|
81
|
+
|
|
82
|
+
The queue worker starts automatically the first time the plugin methods are accessed; call `await fortress.plugins.webhook.stop()` on shutdown to tear down timers/pollers.
|
|
83
|
+
|
|
84
|
+
## Configuration
|
|
85
|
+
|
|
86
|
+
All fields on `WebhookConfig` are optional:
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `events` | `WebhookEventDeclaration[]` | `builtinEvents()` | The event registry (built-in + custom). |
|
|
91
|
+
| `queue` | `WebhookQueue` | `inMemoryQueue()` | Delivery queue backend. |
|
|
92
|
+
| `maxRetries` | `number` | `5` | Attempts before a delivery is marked `failed`. |
|
|
93
|
+
| `maxPayloadBytes` | `number` | `262144` (256 KB) | `emit()` rejects larger payloads. |
|
|
94
|
+
| `delivery.timeoutMs` | `number` | `10000` | Per-attempt request timeout. |
|
|
95
|
+
| `delivery.retry` | `'pluginScheduled' \| 'inProcess' \| 'queue'` | `'pluginScheduled'` | Who owns retries (see below). |
|
|
96
|
+
| `delivery.permanentStatuses` | `number[]` | `[404, 410, 421]` | Statuses that permanently deactivate an endpoint. |
|
|
97
|
+
| `delivery.maxConsecutiveFailures` | `number` | `15` | Circuit breaker — deactivate after this many consecutive failures. |
|
|
98
|
+
| `delivery.onDeliveryFailed` | `(d: WebhookDelivery) => void \| Promise<void>` | — | Terminal-failure hook (the DLQ/alert seam). |
|
|
99
|
+
| `delivery.onEndpointDeactivated` | `(e: WebhookEndpoint, reason: WebhookDeactivatedReason) => void \| Promise<void>` | — | Fired on auto-deactivation. |
|
|
100
|
+
| `delivery.fetch` | `FetchFn` | `ssrfSafeFetch()` | Override the transport (custom transport or tests). **Bypasses the SSRF guard** — only override when you control the targets. |
|
|
101
|
+
|
|
102
|
+
### Retry modes
|
|
103
|
+
|
|
104
|
+
| Mode | Behavior |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `'pluginScheduled'` (default) | One attempt per job. On a retriable failure the plugin schedules `nextRetryAt` with jittered backoff and re-enqueues. Works with any queue. |
|
|
107
|
+
| `'inProcess'` | The fetcher transport retries within a single attempt (POST opted in). The queue sees one job per logical delivery. |
|
|
108
|
+
| `'queue'` | The plugin re-throws on a retriable failure so the queue re-delivers. Requires a queue with `handlesRetries: true`. |
|
|
109
|
+
|
|
110
|
+
### Failure classification (plugin-scheduled)
|
|
111
|
+
|
|
112
|
+
| Result | Action |
|
|
113
|
+
|---|---|
|
|
114
|
+
| 2xx | success (resets the failure counter) |
|
|
115
|
+
| `404 / 410 / 421` | `failed` + **deactivate** the endpoint (`permanent_<status>`) |
|
|
116
|
+
| `408 / 425 / 429` | retry (transient) |
|
|
117
|
+
| other `4xx` | `failed`, **no retry** (permanent client error) |
|
|
118
|
+
| `5xx` / network / timeout | retry with jittered exponential backoff |
|
|
119
|
+
|
|
120
|
+
Backoff ladder (jittered ±25%): **5s → 5min → 30min → 2h → 5h**. Independently, the circuit breaker deactivates an endpoint after `maxConsecutiveFailures` consecutive failures (`reason: 'too_many_failures'`).
|
|
121
|
+
|
|
122
|
+
## Endpoint management
|
|
123
|
+
|
|
124
|
+
| Method | Signature |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `emit` | `(name: string, payload: object, opts?: { idempotencyKey?: string }) => Promise<void>` |
|
|
127
|
+
| `registerEndpoint` | `(url: string, events: string[], opts?: { secret?: string }) => Promise<WebhookEndpoint>` |
|
|
128
|
+
| `updateEndpoint` | `(id: string, patch: { url?, events?, isActive? }) => Promise<RedactedWebhookEndpoint \| null>` |
|
|
129
|
+
| `rotateSecret` | `(id: string) => Promise<{ id: string; secret: string }>` |
|
|
130
|
+
| `listEndpoints` | `() => Promise<RedactedWebhookEndpoint[]>` |
|
|
131
|
+
| `removeEndpoint` | `(id: string) => Promise<void>` |
|
|
132
|
+
| `listEventTypes` | `() => { name: string; description?: string }[]` |
|
|
133
|
+
| `stop` | `() => Promise<void>` |
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const wh = fortress.plugins.webhook;
|
|
137
|
+
|
|
138
|
+
// A CSPRNG secret is generated when omitted — returned ONCE on the result.
|
|
139
|
+
const endpoint = await wh.registerEndpoint('https://hooks.myapp.com/auth', ['auth.login.success', 'order.paid']);
|
|
140
|
+
console.log(endpoint.secret); // whsec_… — store it now; it is never returned again
|
|
141
|
+
|
|
142
|
+
await wh.updateEndpoint(endpoint.id, { events: ['order.paid'], isActive: true });
|
|
143
|
+
const { secret } = await wh.rotateSecret(endpoint.id); // new secret, returned once
|
|
144
|
+
|
|
145
|
+
const endpoints = await wh.listEndpoints(); // secret REDACTED
|
|
146
|
+
await wh.removeEndpoint(endpoint.id);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> **Secrets are returned only at `registerEndpoint` and `rotateSecret`.** `listEndpoints()` and `updateEndpoint()` omit the `secret` field.
|
|
150
|
+
|
|
151
|
+
## Signing
|
|
152
|
+
|
|
153
|
+
Each attempt sends:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
webhook-id: msg_<deliveryId> # STABLE across retries — use it as your idempotency key
|
|
157
|
+
webhook-timestamp: 1234567890 # changes per attempt
|
|
158
|
+
webhook-signature: v1,<base64-hmac-sha256>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The signature is `HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{body}")`. Because `webhook-id` is stable per delivery, a receiver that dedups on it ignores retries automatically.
|
|
162
|
+
|
|
163
|
+
## Idempotency
|
|
164
|
+
|
|
165
|
+
Pass `emit(name, payload, { idempotencyKey })` to make a logical event safe to emit more than once: a second `emit` with the same `(endpoint, idempotencyKey)` is a no-op, enforced by a unique index on `webhook_delivery (endpoint_id, idempotency_key)` (so it holds even under concurrent emits).
|
|
166
|
+
|
|
167
|
+
## Types
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
type WebhookDeactivatedReason = `permanent_${number}` | 'too_many_failures';
|
|
171
|
+
type WebhookErrorKind = 'http' | 'network';
|
|
172
|
+
|
|
173
|
+
interface WebhookEndpoint {
|
|
174
|
+
id: string;
|
|
175
|
+
url: string;
|
|
176
|
+
events: string; // JSON array of event names
|
|
177
|
+
secret: string; // redacted in listEndpoints()/updateEndpoint()
|
|
178
|
+
isActive: boolean;
|
|
179
|
+
deactivatedReason: WebhookDeactivatedReason | null;
|
|
180
|
+
consecutiveFailures: number;
|
|
181
|
+
createdAt: Date;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface WebhookDelivery {
|
|
185
|
+
id: string;
|
|
186
|
+
endpointId: string;
|
|
187
|
+
eventType: string;
|
|
188
|
+
payload: string; // JSON
|
|
189
|
+
status: 'pending' | 'success' | 'failed';
|
|
190
|
+
attempts: number;
|
|
191
|
+
idempotencyKey: string | null;
|
|
192
|
+
lastAttemptAt: Date | null;
|
|
193
|
+
nextRetryAt: Date | null;
|
|
194
|
+
responseStatus: number | null;
|
|
195
|
+
responseBody: string | null; // first ~2 KB, for debugging
|
|
196
|
+
errorKind: WebhookErrorKind | null;
|
|
197
|
+
createdAt: Date;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Security
|
|
202
|
+
|
|
203
|
+
- **SSRF.** Built-in delivery resolves the host, rejects non-https and private/loopback/link-local/CGNAT targets (IPv4, IPv6, `::ffff:`-mapped, and `64:ff9b::/96` NAT64 forms), and pins the connection to the validated IP. Overriding `delivery.fetch` bypasses this guard — only do so for targets you control. `assertSafeWebhookUrl(url)` is exported if a custom transport wants to reuse the guard.
|
|
204
|
+
- **Secrets.** Generated with a CSPRNG, returned only at creation/rotation, and redacted from list/update. Rotate with `rotateSecret`.
|
|
205
|
+
- **Payloads.** Signed but **not encrypted** — never put secrets/PII in a webhook payload you wouldn't want a receiver (or a misconfigured endpoint) to see.
|
|
206
|
+
|
|
207
|
+
## Example
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { createFortress, obj, str } from '@bajustone/fortress';
|
|
211
|
+
import { builtinEvents, databaseQueue, webhook } from '@bajustone/fortress/plugins/webhook';
|
|
212
|
+
|
|
213
|
+
const fortress = createFortress({
|
|
214
|
+
jwt: { key: 'your-secret-at-least-32-bytes!!' },
|
|
215
|
+
database: adapter,
|
|
216
|
+
plugins: [
|
|
217
|
+
webhook({
|
|
218
|
+
events: [
|
|
219
|
+
...builtinEvents(),
|
|
220
|
+
{ name: 'order.paid', schema: obj({ orderId: str() }, 'orderId') },
|
|
221
|
+
],
|
|
222
|
+
queue: databaseQueue({ pollMs: 10_000 }), // crash-safe
|
|
223
|
+
delivery: {
|
|
224
|
+
maxConsecutiveFailures: 10,
|
|
225
|
+
onEndpointDeactivated: (e, reason) => log.warn('webhook endpoint disabled', { id: e.id, reason }),
|
|
226
|
+
onDeliveryFailed: d => deadLetter.push(d),
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const wh = fortress.plugins.webhook;
|
|
233
|
+
|
|
234
|
+
// Built-in auth events deliver automatically on login/register/etc.
|
|
235
|
+
await wh.registerEndpoint('https://hooks.myapp.com/auth', ['auth.login.success', 'order.paid']);
|
|
236
|
+
|
|
237
|
+
// Emit a custom event
|
|
238
|
+
await fortress.plugins.webhook.emit('order.paid', { orderId: 'o_123' }, { idempotencyKey: 'o_123' });
|
|
239
|
+
|
|
240
|
+
// On shutdown
|
|
241
|
+
await wh.stop();
|
|
242
|
+
```
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Policy as code
|
|
2
|
+
|
|
3
|
+
Declare roles, permissions, groups, and service accounts in JSON, then reconcile the file against the database with `diffPolicy()` and `applyPolicyPlan()`.
|
|
4
|
+
|
|
5
|
+
## File format
|
|
6
|
+
|
|
7
|
+
Default file: `fortress.policy.json` in the working directory.
|
|
8
|
+
Environment-specific override:
|
|
9
|
+
`fortress.policy.<env>.json`, picked when `FORTRESS_ENV` is set (the env
|
|
10
|
+
file fully **replaces** the base — no implicit merging).
|
|
11
|
+
|
|
12
|
+
See [`examples/policy/fortress.policy.json`](../examples/policy/fortress.policy.json)
|
|
13
|
+
for a complete example. Minimum shape:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"resources": [
|
|
18
|
+
{ "name": "article", "actions": ["create", "read", "update", "delete"] }
|
|
19
|
+
],
|
|
20
|
+
"roles": [
|
|
21
|
+
{
|
|
22
|
+
"name": "editor",
|
|
23
|
+
"description": "Authors and edits articles",
|
|
24
|
+
"permissions": [
|
|
25
|
+
{ "resource": "article", "action": "create" },
|
|
26
|
+
{ "resource": "article", "action": "update" }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"groups": [
|
|
31
|
+
{ "name": "engineering", "description": "Engineering team" }
|
|
32
|
+
],
|
|
33
|
+
"serviceAccounts": [
|
|
34
|
+
{ "name": "ci-bot", "displayName": "CI", "isActive": true, "roles": ["editor"] }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### What's covered
|
|
40
|
+
|
|
41
|
+
| Entity | Declared keys | Reconciled by `applyPolicyPlan` |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Resources | `name`, `actions[]`, `description` | Creates missing resources; adds missing actions to existing resources |
|
|
44
|
+
| Roles | `name`, `description`, `permissions[]` | Creates missing roles; updates description; adds + removes permissions |
|
|
45
|
+
| Groups | `name`, `description` | Creates missing groups; updates description |
|
|
46
|
+
| Service accounts | `name`, `displayName`, `description`, `isActive`, `roles[]` | Creates missing SAs; updates fields; binds + unbinds roles |
|
|
47
|
+
|
|
48
|
+
### What's intentionally NOT covered
|
|
49
|
+
|
|
50
|
+
- **OAuth clients.** They have secrets — manage via the admin endpoints.
|
|
51
|
+
- **User accounts / user → group memberships.** User data, not policy.
|
|
52
|
+
Provision users via signup or admin endpoints; manage memberships via
|
|
53
|
+
`fortress.iam.addUserToGroup(...)`.
|
|
54
|
+
- **Per-tenant role bindings.** Manage via the admin endpoints; the
|
|
55
|
+
global policy file is the wrong fit for per-tenant scale.
|
|
56
|
+
- **Resource deletions / action removals.** Dropping a resource cascades
|
|
57
|
+
to permissions and role bindings; require explicit operator action.
|
|
58
|
+
|
|
59
|
+
## Diff + apply workflow
|
|
60
|
+
|
|
61
|
+
The same three-step loop you'd use for migrations or manifests:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { createFortress, loadPolicy, diffPolicy, applyPolicyPlan } from '@bajustone/fortress';
|
|
65
|
+
import config from './fortress.config';
|
|
66
|
+
|
|
67
|
+
const fortress = createFortress(config);
|
|
68
|
+
const { policy } = await loadPolicy(); // reads fortress.policy.json (or env override)
|
|
69
|
+
const plan = await diffPolicy(policy, fortress.iam);
|
|
70
|
+
if (!plan.inSync) {
|
|
71
|
+
console.log('Plan:');
|
|
72
|
+
for (const op of plan.ops) console.log(' -', op.description);
|
|
73
|
+
|
|
74
|
+
const result = await applyPolicyPlan(plan, fortress.iam);
|
|
75
|
+
if (result.errors.length) {
|
|
76
|
+
console.error('Some ops failed:', result.errors);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Pruning (deletes)
|
|
83
|
+
|
|
84
|
+
`diffPolicy(policy, iam, { prune: true })` emits `delete-role`,
|
|
85
|
+
`delete-group`, `delete-service-account`, and `unbind-service-account-role`
|
|
86
|
+
ops for entities present in the database but absent from the policy
|
|
87
|
+
file. System roles (`isSystem === true`) are never deleted.
|
|
88
|
+
|
|
89
|
+
Run the un-pruned diff first to review, then add `prune: true` when
|
|
90
|
+
the diff matches your intent. An empty policy is rejected under prune by default because it would delete all managed IAM state; the destructive operation requires explicit `{ prune: true, allowEmptyPrune: true }` acknowledgement.
|
|
91
|
+
|
|
92
|
+
### Resource ops apply without filesystem access
|
|
93
|
+
|
|
94
|
+
`applyPolicyPlan` reconciles resources/actions, roles, groups, service accounts, and their global role bindings directly through `IamService`. It never writes a temporary file, so it works in workerd/Deno-no-fs runtimes. The compatibility `applyResourceOps(plan, iam, legacyFilePath)` helper now ignores the retained path argument and applies only resource operations through the same in-memory database path.
|
|
95
|
+
|
|
96
|
+
## CLI
|
|
97
|
+
|
|
98
|
+
The CLI exposes one offline command (no DB required) plus three
|
|
99
|
+
how-to printers because the diff/apply/check commands need your
|
|
100
|
+
configured `Fortress` instance:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
fortress policy:summary [--file <path>] [--env <name>] # offline; prints declared counts
|
|
104
|
+
fortress policy:diff # prints code snippet for in-app usage
|
|
105
|
+
fortress policy:apply # prints code snippet
|
|
106
|
+
fortress policy:check # prints code snippet
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`fortress policy:summary` is useful in CI to assert the file parses
|
|
110
|
+
and to print a quick inventory; the actual diff/apply runs from a
|
|
111
|
+
small script in your repo that knows your DB connection.
|
|
112
|
+
|
|
113
|
+
## CI gate
|
|
114
|
+
|
|
115
|
+
Add a `policy:check` step to your CI workflow that runs against an
|
|
116
|
+
ephemeral DB seeded from production policy:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// scripts/policy-check.ts
|
|
120
|
+
import { createFortress, loadPolicy, diffPolicy } from '@bajustone/fortress';
|
|
121
|
+
import { createTestAdapter } from '@bajustone/fortress/testing';
|
|
122
|
+
import { applyPolicyPlan } from '@bajustone/fortress';
|
|
123
|
+
|
|
124
|
+
const fortress = createFortress({
|
|
125
|
+
database: createTestAdapter(),
|
|
126
|
+
jwt: { key: process.env.FORTRESS_JWT_SECRET! },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { policy } = await loadPolicy();
|
|
130
|
+
// Bootstrap from the policy itself so the diff result reflects only the
|
|
131
|
+
// drift between two consecutive policy revisions.
|
|
132
|
+
await applyPolicyPlan(await diffPolicy(policy, fortress.iam), fortress.iam);
|
|
133
|
+
|
|
134
|
+
const after = await diffPolicy(policy, fortress.iam);
|
|
135
|
+
if (!after.inSync) {
|
|
136
|
+
console.error('Policy reconciliation is not converging:', after.ops);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
console.log(`Policy in sync (${policy.roles?.length ?? 0} role(s), ${policy.serviceAccounts?.length ?? 0} service account(s)).`);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Run it as a step in `.github/workflows/fortress-ci.yml` alongside
|
|
143
|
+
`fortress manifest:check` and `fortress migrate:check`.
|
|
144
|
+
|
|
145
|
+
## API reference
|
|
146
|
+
|
|
147
|
+
| Export | Kind | Purpose |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| `loadPolicy(options?)` | async function | Read, validate, and parse the policy file (env-aware) |
|
|
150
|
+
| `resolvePolicyPath(options?)` | async function | Resolve the file path that `loadPolicy` would use |
|
|
151
|
+
| `diffPolicy(policy, iam, options?)` | function | Compute the plan to reconcile live IAM with `policy` |
|
|
152
|
+
| `applyPolicyPlan(plan, iam)` | function | Apply every op in the plan; returns `applied` / `errors` |
|
|
153
|
+
| `applyResourceOps(plan, iam, legacyFilePath)` | function | Compatibility helper; applies resource-only ops directly without file I/O |
|
|
154
|
+
| `PolicyDocument`, `PolicyRole`, ... | types | Type-only exports for callers that read the file themselves |
|
|
155
|
+
|
|
156
|
+
All exported from the package root.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Route security manifest
|
|
2
|
+
|
|
3
|
+
Fortress builds a canonical route-security manifest from endpoint metadata. The manifest is derived from the same `EndpointDefinition` records used by `fortress.handleRequest()`, adapter mounting, and OpenAPI generation.
|
|
4
|
+
|
|
5
|
+
## Runtime API
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const manifest = fortress.manifest;
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Each entry contains:
|
|
12
|
+
|
|
13
|
+
- `method`, `path`, `handler` — the HTTP route and dispatcher handler.
|
|
14
|
+
- `plugin` — `auth`, `iam`, a registered plugin name, or `null` when the origin cannot be inferred.
|
|
15
|
+
- `classification` — one of:
|
|
16
|
+
- `public` — `meta.security` includes `none`.
|
|
17
|
+
- `authenticated` — bearer/basic/API-key route without an IAM permission.
|
|
18
|
+
- `rbac` — route has `meta.permission` and requires IAM authorization.
|
|
19
|
+
- `oauth-protocol` — route has `meta.bearerKind: 'oauth'` and self-authenticates as an OAuth protocol endpoint.
|
|
20
|
+
- `default-deny` — no usable security metadata; the request pipeline denies it.
|
|
21
|
+
- `permission`, `security`, `bearerKind` — direct endpoint security metadata.
|
|
22
|
+
- `csrfApplicable` — unsafe method and not skipped by `config.csrf`.
|
|
23
|
+
- `rateLimited` — route matches plugin rate-limit middleware, or a rate-limit hook protects the auth gate.
|
|
24
|
+
- `mounted` — `true` for routes present in the active `fortress.endpoints` union.
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
fortress manifest --out route-manifest.json
|
|
30
|
+
fortress manifest:check
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The CLI currently emits/checks the core auth + IAM manifest. For app/plugin-aware checks, call the programmatic API against your configured `fortress` instance:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { detectRouteManifestDrift, hasRouteManifestDrift } from '@bajustone/fortress';
|
|
37
|
+
|
|
38
|
+
const drift = detectRouteManifestDrift(fortress, { openapi: yourGeneratedOpenApiSpec });
|
|
39
|
+
if (hasRouteManifestDrift(drift)) {
|
|
40
|
+
throw new Error(JSON.stringify(drift, null, 2));
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Adapter behavior
|
|
45
|
+
|
|
46
|
+
Hono, Express, and SvelteKit adapters use `fortress.manifest` to decide which paths are Fortress-managed before delegating to `fortress.handleRequest()`. This keeps route interception, OpenAPI, and security drift checks aligned with the same endpoint metadata.
|