@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,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook delivery over `@bajustone/fetcher`.
|
|
3
|
+
*
|
|
4
|
+
* fetcher is 100% native `fetch` and does **no** IP pinning of its own, so the
|
|
5
|
+
* SSRF guard must live *inside* the transport: {@link ssrfSafeFetch} is a
|
|
6
|
+
* fetcher `FetchFn` that resolves + validates the target and issues the request
|
|
7
|
+
* through `node:https` with a custom `lookup` that pins the connection to the
|
|
8
|
+
* resolved IP — closing the DNS-rebinding window. A plain fetch (or a
|
|
9
|
+
* pre-flight check followed by a normal fetch) would re-resolve DNS and reopen
|
|
10
|
+
* that window, so the guard MUST be the transport.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { FetchFn } from '@bajustone/fetcher';
|
|
16
|
+
import { Buffer } from 'node:buffer';
|
|
17
|
+
import { request as httpsRequest } from 'node:https';
|
|
18
|
+
import { createFetch } from '@bajustone/fetcher';
|
|
19
|
+
import { resolveSafeWebhookTarget } from './ssrf';
|
|
20
|
+
|
|
21
|
+
/** How retries are handled for a delivery client. See the webhook config docs. */
|
|
22
|
+
export type RetryMode = 'pluginScheduled' | 'inProcess' | 'queue';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A fetcher `FetchFn` that delivers through `node:https` with the SSRF guard
|
|
26
|
+
* and connect-time IP pinning applied. Resolution + validation + connection
|
|
27
|
+
* all use the same resolved IP, so there is no TOCTOU gap.
|
|
28
|
+
*/
|
|
29
|
+
export function ssrfSafeFetch(): FetchFn {
|
|
30
|
+
return async (req: Request): Promise<Response> => {
|
|
31
|
+
// Throws on non-https / private / unresolved — fetcher turns the throw
|
|
32
|
+
// into a `{ ok: false }` result via its never-throws contract.
|
|
33
|
+
const target = await resolveSafeWebhookTarget(req.url);
|
|
34
|
+
const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.text();
|
|
35
|
+
|
|
36
|
+
const headers: Record<string, string> = {};
|
|
37
|
+
req.headers.forEach((value, key) => {
|
|
38
|
+
headers[key] = value;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return new Promise<Response>((resolve, reject) => {
|
|
42
|
+
const clientReq = httpsRequest(
|
|
43
|
+
{
|
|
44
|
+
protocol: target.url.protocol,
|
|
45
|
+
hostname: target.url.hostname,
|
|
46
|
+
port: target.url.port || 443,
|
|
47
|
+
path: `${target.url.pathname}${target.url.search}`,
|
|
48
|
+
method: req.method,
|
|
49
|
+
headers,
|
|
50
|
+
// Pin the connection to the exact validated IP (DNS-rebind defense).
|
|
51
|
+
lookup: (_hostname, _options, cb) => cb(null, target.address, target.family),
|
|
52
|
+
},
|
|
53
|
+
(res) => {
|
|
54
|
+
const chunks: Buffer[] = [];
|
|
55
|
+
res.on('data', chunk => chunks.push(chunk as Buffer));
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
const resHeaders = new Headers();
|
|
58
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
59
|
+
if (typeof value === 'string')
|
|
60
|
+
resHeaders.set(key, value);
|
|
61
|
+
else if (Array.isArray(value))
|
|
62
|
+
resHeaders.set(key, value.join(', '));
|
|
63
|
+
}
|
|
64
|
+
const status = res.statusCode && res.statusCode >= 200 ? res.statusCode : 502;
|
|
65
|
+
resolve(new Response(Buffer.concat(chunks), { status, headers: resHeaders }));
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Honor the fetcher timeout middleware's AbortSignal.
|
|
71
|
+
if (req.signal) {
|
|
72
|
+
if (req.signal.aborted)
|
|
73
|
+
clientReq.destroy(new Error('aborted'));
|
|
74
|
+
else
|
|
75
|
+
req.signal.addEventListener('abort', () => clientReq.destroy(new Error('aborted')), { once: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clientReq.on('error', reject);
|
|
79
|
+
clientReq.end(body);
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a delivery client. Always wires the {@link ssrfSafeFetch} transport
|
|
86
|
+
* (never a plain fetch) and a per-attempt timeout. For `inProcess` retries it
|
|
87
|
+
* also configures fetcher's retry middleware — POST must be opted in
|
|
88
|
+
* explicitly (fetcher does not retry POST by default), which is safe because
|
|
89
|
+
* every delivery carries a stable `webhook-id` for receiver-side dedup.
|
|
90
|
+
*/
|
|
91
|
+
export function createDeliveryClient(opts: { timeoutMs?: number; retryMode: RetryMode; fetch?: FetchFn }): ReturnType<typeof createFetch> {
|
|
92
|
+
return createFetch({
|
|
93
|
+
baseUrl: '',
|
|
94
|
+
fetch: opts.fetch ?? ssrfSafeFetch(),
|
|
95
|
+
timeout: opts.timeoutMs ?? 10_000,
|
|
96
|
+
...(opts.retryMode === 'inProcess'
|
|
97
|
+
? {
|
|
98
|
+
retry: {
|
|
99
|
+
attempts: 5,
|
|
100
|
+
methods: ['POST'],
|
|
101
|
+
backoff: 5_000,
|
|
102
|
+
factor: 2,
|
|
103
|
+
maxBackoff: 5 * 60_000,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Outcome of a single delivery attempt (or one `inProcess` retry sequence). */
|
|
111
|
+
export type DeliveryResult
|
|
112
|
+
= | { ok: true; status: number }
|
|
113
|
+
| { ok: false; kind: 'http' | 'network'; status: number | null; responseBody: string | null; message: string };
|
|
114
|
+
|
|
115
|
+
async function safeReadBody(response: Response): Promise<string | null> {
|
|
116
|
+
try {
|
|
117
|
+
const text = await response.text();
|
|
118
|
+
return text.slice(0, 2048); // first ~2KB, for debugging
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Deliver `body` to `url` with `headers`. Never throws: fetcher's transport
|
|
127
|
+
* resolves network/timeout failures to a `Response` with `ok === false`
|
|
128
|
+
* (`status === 0`), so the result is always a classified {@link DeliveryResult}.
|
|
129
|
+
*/
|
|
130
|
+
export async function deliver(
|
|
131
|
+
client: ReturnType<typeof createDeliveryClient>,
|
|
132
|
+
url: string,
|
|
133
|
+
body: string,
|
|
134
|
+
headers: Record<string, string>,
|
|
135
|
+
): Promise<DeliveryResult> {
|
|
136
|
+
const response = await client.post(url, {
|
|
137
|
+
body,
|
|
138
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (response.ok)
|
|
142
|
+
return { ok: true, status: response.status };
|
|
143
|
+
|
|
144
|
+
// status === 0 marks a transport failure (network/timeout/SSRF-rejected);
|
|
145
|
+
// anything else is a real HTTP status from the receiver.
|
|
146
|
+
const transport = response.status === 0;
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
kind: transport ? 'network' : 'http',
|
|
150
|
+
status: transport ? null : response.status,
|
|
151
|
+
responseBody: transport ? null : await safeReadBody(response),
|
|
152
|
+
message: transport ? 'Transport error (network, timeout, or blocked target)' : `HTTP ${response.status}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds fortress's auth lifecycle hooks to the webhook `emit` path, one hook
|
|
3
|
+
* per declared built-in `source`. Built-in events fire-and-forget — login and
|
|
4
|
+
* registration are never blocked on webhook persistence/delivery; user code
|
|
5
|
+
* uses the awaitable `emit()` method for at-least-once semantics.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
11
|
+
import type { PluginHooks } from '../../core/plugin';
|
|
12
|
+
import type { EventRegistry } from './registry';
|
|
13
|
+
|
|
14
|
+
/** Persist + enqueue an event for delivery. */
|
|
15
|
+
export type EmitFn = (db: DatabaseAdapter, eventName: string, payload: Record<string, unknown>) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wire the built-in auth hooks to `emit`. Only sources present in the registry
|
|
19
|
+
* are bound — excluding a built-in via `builtinEvents({ exclude })` leaves its
|
|
20
|
+
* hook unbound (a no-op for that event).
|
|
21
|
+
*/
|
|
22
|
+
export function bindBuiltinHooks(registry: EventRegistry, emit: EmitFn): PluginHooks {
|
|
23
|
+
const sources = registry.sources();
|
|
24
|
+
const hooks: PluginHooks = {};
|
|
25
|
+
|
|
26
|
+
const loginName = sources.get('afterLogin');
|
|
27
|
+
if (loginName) {
|
|
28
|
+
hooks.afterLogin = async (ctx, result) => {
|
|
29
|
+
void emit(ctx.db, loginName, {
|
|
30
|
+
event: loginName,
|
|
31
|
+
userId: result.user.id,
|
|
32
|
+
email: result.user.email,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
ip: ctx.meta?.ipAddress ?? null,
|
|
35
|
+
}).catch(() => {});
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const failureName = sources.get('onLoginFailure');
|
|
41
|
+
if (failureName) {
|
|
42
|
+
hooks.onLoginFailure = async (ctx) => {
|
|
43
|
+
void emit(ctx.db, failureName, {
|
|
44
|
+
event: failureName,
|
|
45
|
+
identifier: ctx.identifier,
|
|
46
|
+
error: ctx.error.message,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
}).catch(() => {});
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const logoutName = sources.get('beforeLogout');
|
|
53
|
+
if (logoutName) {
|
|
54
|
+
hooks.beforeLogout = async (ctx) => {
|
|
55
|
+
void emit(ctx.db, logoutName, {
|
|
56
|
+
event: logoutName,
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
ip: ctx.meta?.ipAddress ?? null,
|
|
59
|
+
}).catch(() => {});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const registerName = sources.get('afterRegister');
|
|
64
|
+
if (registerName) {
|
|
65
|
+
hooks.afterRegister = async (ctx, user) => {
|
|
66
|
+
void emit(ctx.db, registerName, {
|
|
67
|
+
event: registerName,
|
|
68
|
+
userId: user.id,
|
|
69
|
+
email: user.email,
|
|
70
|
+
timestamp: new Date().toISOString(),
|
|
71
|
+
ip: ctx.meta?.ipAddress ?? null,
|
|
72
|
+
}).catch(() => {});
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const refreshName = sources.get('afterTokenRefresh');
|
|
77
|
+
if (refreshName) {
|
|
78
|
+
hooks.afterTokenRefresh = async (ctx, result) => {
|
|
79
|
+
void emit(ctx.db, refreshName, {
|
|
80
|
+
event: refreshName,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
ip: ctx.meta?.ipAddress ?? null,
|
|
83
|
+
}).catch(() => {});
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return hooks;
|
|
89
|
+
}
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook delivery plugin for fortress (v1).
|
|
3
|
+
*
|
|
4
|
+
* - **Custom events** — declare and `emit()` your own events through the same
|
|
5
|
+
* path the built-in auth events use.
|
|
6
|
+
* - **Bring-Your-Own-Queue** — delivery is always queued; plug in any backend
|
|
7
|
+
* via {@link import('./queue/types').WebhookQueue}. Defaults to the dev-only
|
|
8
|
+
* {@link inMemoryQueue}; use {@link databaseQueue} (crash-safe) or a broker in prod.
|
|
9
|
+
* - **Standard Webhooks** signing (HMAC-SHA256), with a stable per-delivery
|
|
10
|
+
* `webhook-id` for receiver-side idempotency.
|
|
11
|
+
* - **SSRF-safe delivery** over `@bajustone/fetcher` with connect-time IP pinning.
|
|
12
|
+
* - Failure classification, jittered backoff, circuit-breaker auto-deactivation,
|
|
13
|
+
* and DLQ observability hooks.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { DatabaseAdapter, WhereClause } from '../../adapters/database';
|
|
19
|
+
import type { FortressPlugin } from '../../core/plugin';
|
|
20
|
+
import type { DeliveryResult } from './delivery';
|
|
21
|
+
import type { WebhookQueueJob } from './queue/types';
|
|
22
|
+
import type {
|
|
23
|
+
EmitOptions,
|
|
24
|
+
RedactedWebhookEndpoint,
|
|
25
|
+
RegisterEndpointOptions,
|
|
26
|
+
UpdateEndpointPatch,
|
|
27
|
+
WebhookConfig,
|
|
28
|
+
WebhookDeactivatedReason,
|
|
29
|
+
WebhookDelivery,
|
|
30
|
+
WebhookEndpoint,
|
|
31
|
+
} from './types';
|
|
32
|
+
import { Buffer } from 'node:buffer';
|
|
33
|
+
import { builtinEvents } from './builtin-events';
|
|
34
|
+
import { createDeliveryClient, deliver } from './delivery';
|
|
35
|
+
import { bindBuiltinHooks } from './hooks';
|
|
36
|
+
import { inMemoryQueue } from './queue/in-memory';
|
|
37
|
+
import { createEventRegistry } from './registry';
|
|
38
|
+
import { signatureHeaders } from './signing';
|
|
39
|
+
import { WebhookEmitError } from './types';
|
|
40
|
+
|
|
41
|
+
export { builtinEvents } from './builtin-events';
|
|
42
|
+
export { databaseQueue } from './queue/database';
|
|
43
|
+
export { inMemoryQueue } from './queue/in-memory';
|
|
44
|
+
export type { WebhookQueue, WebhookQueueContext, WebhookQueueJob } from './queue/types';
|
|
45
|
+
export { assertSafeWebhookUrl } from './ssrf';
|
|
46
|
+
export type {
|
|
47
|
+
EmitOptions,
|
|
48
|
+
RedactedWebhookEndpoint,
|
|
49
|
+
RegisterEndpointOptions,
|
|
50
|
+
UpdateEndpointPatch,
|
|
51
|
+
WebhookConfig,
|
|
52
|
+
WebhookDeactivatedReason,
|
|
53
|
+
WebhookDelivery,
|
|
54
|
+
WebhookDeliveryConfig,
|
|
55
|
+
WebhookEmitErrorCode,
|
|
56
|
+
WebhookEndpoint,
|
|
57
|
+
WebhookErrorKind,
|
|
58
|
+
WebhookEventDeclaration,
|
|
59
|
+
} from './types';
|
|
60
|
+
export { WebhookEmitError } from './types';
|
|
61
|
+
|
|
62
|
+
/** Plugin-scheduled retry backoff ladder (ms); jittered ±25% per attempt. */
|
|
63
|
+
const RETRY_INTERVALS_MS = [5_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000, 5 * 60 * 60_000];
|
|
64
|
+
|
|
65
|
+
/** 4xx statuses that are transient and worth retrying (matches the fetcher transport's retry set). */
|
|
66
|
+
const TRANSIENT_4XX = new Set([408, 425, 429]);
|
|
67
|
+
|
|
68
|
+
function withJitter(ms: number): number {
|
|
69
|
+
// Full ±25% jitter so an endpoint recovering doesn't get a synchronized spike.
|
|
70
|
+
return Math.max(0, Math.round(ms * (1 + (Math.random() - 0.5) * 0.5)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generate a Standard-Webhooks-style signing secret (`whsec_<hex>`), CSPRNG-backed. */
|
|
74
|
+
function generateSecret(): string {
|
|
75
|
+
const bytes = new Uint8Array(32);
|
|
76
|
+
crypto.getRandomValues(bytes);
|
|
77
|
+
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
78
|
+
return `whsec_${hex}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function idWhere(id: string): WhereClause[] {
|
|
82
|
+
return [{ field: 'id', operator: '=', value: id }];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function redact(endpoint: WebhookEndpoint): RedactedWebhookEndpoint {
|
|
86
|
+
const { secret: _secret, ...rest } = endpoint;
|
|
87
|
+
return rest;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Webhook plugin factory. See the module docs for the full feature set.
|
|
92
|
+
*/
|
|
93
|
+
export function webhook(config: WebhookConfig = {}): FortressPlugin {
|
|
94
|
+
const registry = createEventRegistry(config.events ?? builtinEvents());
|
|
95
|
+
const queue = config.queue ?? inMemoryQueue();
|
|
96
|
+
const retryMode = config.delivery?.retry ?? 'pluginScheduled';
|
|
97
|
+
const maxRetries = config.maxRetries ?? 5;
|
|
98
|
+
const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
|
|
99
|
+
const timeoutMs = config.delivery?.timeoutMs ?? 10_000;
|
|
100
|
+
const permanentStatuses = new Set(config.delivery?.permanentStatuses ?? [404, 410, 421]);
|
|
101
|
+
const maxConsecutiveFailures = config.delivery?.maxConsecutiveFailures ?? 15;
|
|
102
|
+
const onDeliveryFailed = config.delivery?.onDeliveryFailed;
|
|
103
|
+
const onEndpointDeactivated = config.delivery?.onEndpointDeactivated;
|
|
104
|
+
|
|
105
|
+
if (retryMode === 'queue' && !queue.handlesRetries) {
|
|
106
|
+
throw new Error('delivery.retry: "queue" requires a queue with handlesRetries: true');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const client = createDeliveryClient({ timeoutMs, retryMode, fetch: config.delivery?.fetch });
|
|
110
|
+
const deliverFn = (url: string, body: string, headers: Record<string, string>): Promise<DeliveryResult> =>
|
|
111
|
+
deliver(client, url, body, headers);
|
|
112
|
+
|
|
113
|
+
// ── Failure classification ────────────────────────────────────────
|
|
114
|
+
function classify(result: DeliveryResult): 'deactivate' | 'permanent_fail' | 'retry' {
|
|
115
|
+
if (!result.ok && result.kind === 'http' && result.status !== null) {
|
|
116
|
+
if (permanentStatuses.has(result.status))
|
|
117
|
+
return 'deactivate';
|
|
118
|
+
if (TRANSIENT_4XX.has(result.status))
|
|
119
|
+
return 'retry'; // 408 / 425 / 429 are transient
|
|
120
|
+
if (result.status >= 400 && result.status < 500)
|
|
121
|
+
return 'permanent_fail'; // other 4xx — retrying just burns attempts
|
|
122
|
+
return 'retry'; // 5xx
|
|
123
|
+
}
|
|
124
|
+
return 'retry'; // network / timeout
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function deactivateEndpoint(db: DatabaseAdapter, endpoint: WebhookEndpoint, reason: WebhookDeactivatedReason): Promise<void> {
|
|
128
|
+
if (!endpoint.isActive)
|
|
129
|
+
return;
|
|
130
|
+
await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { isActive: false, deactivatedReason: reason } });
|
|
131
|
+
if (onEndpointDeactivated)
|
|
132
|
+
await onEndpointDeactivated({ ...endpoint, isActive: false, deactivatedReason: reason }, reason);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Delivery (one queue job = one attempt) ────────────────────────
|
|
136
|
+
async function processJob(db: DatabaseAdapter, job: WebhookQueueJob): Promise<void> {
|
|
137
|
+
const delivery = await db.findOne<WebhookDelivery>({ model: 'webhook_delivery', where: idWhere(job.deliveryId) });
|
|
138
|
+
if (!delivery || delivery.status !== 'pending')
|
|
139
|
+
return;
|
|
140
|
+
const endpoint = await db.findOne<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(delivery.endpointId) });
|
|
141
|
+
if (!endpoint || !endpoint.isActive)
|
|
142
|
+
return;
|
|
143
|
+
|
|
144
|
+
// Stable per-delivery id (receiver idempotency key); only the timestamp changes per attempt.
|
|
145
|
+
const webhookId = `msg_${delivery.id}`;
|
|
146
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
147
|
+
const headers = await signatureHeaders(endpoint.secret, webhookId, timestamp, delivery.payload);
|
|
148
|
+
|
|
149
|
+
const result = await deliverFn(endpoint.url, delivery.payload, headers);
|
|
150
|
+
const now = new Date();
|
|
151
|
+
const attempts = delivery.attempts + 1;
|
|
152
|
+
|
|
153
|
+
if (result.ok) {
|
|
154
|
+
await db.update({
|
|
155
|
+
model: 'webhook_delivery',
|
|
156
|
+
where: idWhere(delivery.id),
|
|
157
|
+
data: { status: 'success', attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus: result.status, errorKind: null },
|
|
158
|
+
});
|
|
159
|
+
if (endpoint.consecutiveFailures > 0)
|
|
160
|
+
await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { consecutiveFailures: 0 } });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Failure path.
|
|
165
|
+
const consecutiveFailures = endpoint.consecutiveFailures + 1;
|
|
166
|
+
await db.update({ model: 'webhook_endpoint', where: idWhere(endpoint.id), data: { consecutiveFailures } });
|
|
167
|
+
|
|
168
|
+
const responseStatus = result.status;
|
|
169
|
+
const responseBody = result.responseBody;
|
|
170
|
+
const errorKind = result.kind;
|
|
171
|
+
const action = classify(result);
|
|
172
|
+
const exhausted = attempts >= maxRetries;
|
|
173
|
+
const breakerTripped = consecutiveFailures >= maxConsecutiveFailures;
|
|
174
|
+
// `inProcess` mode already retried inside fetcher, so a failure here is terminal.
|
|
175
|
+
const willRetry = action === 'retry' && !exhausted && retryMode !== 'inProcess';
|
|
176
|
+
|
|
177
|
+
const markFailed = async (): Promise<void> => {
|
|
178
|
+
await db.update({
|
|
179
|
+
model: 'webhook_delivery',
|
|
180
|
+
where: idWhere(delivery.id),
|
|
181
|
+
data: { status: 'failed', attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus, responseBody, errorKind },
|
|
182
|
+
});
|
|
183
|
+
if (onDeliveryFailed) {
|
|
184
|
+
const row = await db.findOne<WebhookDelivery>({ model: 'webhook_delivery', where: idWhere(delivery.id) });
|
|
185
|
+
if (row)
|
|
186
|
+
await onDeliveryFailed(row);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Permanent status → fail + deactivate.
|
|
191
|
+
if (action === 'deactivate') {
|
|
192
|
+
await markFailed();
|
|
193
|
+
await deactivateEndpoint(db, endpoint, `permanent_${responseStatus}` as WebhookDeactivatedReason);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Terminal: no further attempt will run — non-retriable, retries exhausted,
|
|
198
|
+
// `inProcess` (fetcher already retried), or the circuit breaker has tripped.
|
|
199
|
+
// Mark failed FIRST so a tripped-breaker delivery never lingers as a zombie
|
|
200
|
+
// `pending` row that nothing re-attempts and `onDeliveryFailed` never sees.
|
|
201
|
+
if (!willRetry || breakerTripped) {
|
|
202
|
+
await markFailed();
|
|
203
|
+
if (breakerTripped)
|
|
204
|
+
await deactivateEndpoint(db, endpoint, 'too_many_failures');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Retry is warranted and the breaker has NOT tripped.
|
|
209
|
+
if (retryMode === 'queue') {
|
|
210
|
+
// The external queue owns retries: keep the row pending and re-throw.
|
|
211
|
+
await db.update({
|
|
212
|
+
model: 'webhook_delivery',
|
|
213
|
+
where: idWhere(delivery.id),
|
|
214
|
+
data: { status: 'pending', attempts, lastAttemptAt: now, responseStatus, responseBody, errorKind },
|
|
215
|
+
});
|
|
216
|
+
throw new Error(`Webhook delivery failed (${errorKind}${responseStatus !== null ? ` ${responseStatus}` : ''}); queue will retry`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// pluginScheduled: schedule the next attempt with jitter and re-enqueue.
|
|
220
|
+
const retryMs = withJitter(RETRY_INTERVALS_MS[Math.min(attempts - 1, RETRY_INTERVALS_MS.length - 1)]!);
|
|
221
|
+
const nextRetryAt = new Date(Date.now() + retryMs);
|
|
222
|
+
await db.update({
|
|
223
|
+
model: 'webhook_delivery',
|
|
224
|
+
where: idWhere(delivery.id),
|
|
225
|
+
data: { status: 'pending', attempts, lastAttemptAt: now, nextRetryAt, responseStatus, responseBody, errorKind },
|
|
226
|
+
});
|
|
227
|
+
await queue.enqueue({
|
|
228
|
+
deliveryId: delivery.id,
|
|
229
|
+
endpointId: endpoint.id,
|
|
230
|
+
eventType: delivery.eventType,
|
|
231
|
+
attempt: attempts + 1,
|
|
232
|
+
scheduledFor: nextRetryAt,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Emit (persist outbox row + enqueue) ───────────────────────────
|
|
237
|
+
async function emitEvent(db: DatabaseAdapter, eventName: string, payload: Record<string, unknown>, opts?: EmitOptions): Promise<void> {
|
|
238
|
+
const declaration = registry.get(eventName);
|
|
239
|
+
if (!declaration)
|
|
240
|
+
throw new WebhookEmitError('unknown_event', `Unknown webhook event: ${eventName}`);
|
|
241
|
+
|
|
242
|
+
if (declaration.schema) {
|
|
243
|
+
const result = await declaration.schema['~standard'].validate(payload);
|
|
244
|
+
if (result.issues)
|
|
245
|
+
throw new WebhookEmitError('invalid_payload', `Invalid payload for webhook event '${eventName}'`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const payloadJson = JSON.stringify(payload);
|
|
249
|
+
if (Buffer.byteLength(payloadJson, 'utf8') > maxPayloadBytes)
|
|
250
|
+
throw new WebhookEmitError('payload_too_large', `Webhook payload for '${eventName}' exceeds ${maxPayloadBytes} bytes`);
|
|
251
|
+
|
|
252
|
+
const endpoints = await db.findMany<WebhookEndpoint>({ model: 'webhook_endpoint', where: [{ field: 'isActive', operator: '=', value: true }] });
|
|
253
|
+
const now = new Date();
|
|
254
|
+
|
|
255
|
+
for (const endpoint of endpoints) {
|
|
256
|
+
let names: string[];
|
|
257
|
+
try {
|
|
258
|
+
names = JSON.parse(endpoint.events) as string[];
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!names.includes(eventName))
|
|
264
|
+
continue;
|
|
265
|
+
|
|
266
|
+
if (opts?.idempotencyKey) {
|
|
267
|
+
const existing = await db.findOne<WebhookDelivery>({
|
|
268
|
+
model: 'webhook_delivery',
|
|
269
|
+
where: [
|
|
270
|
+
{ field: 'endpointId', operator: '=', value: endpoint.id },
|
|
271
|
+
{ field: 'idempotencyKey', operator: '=', value: opts.idempotencyKey },
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
if (existing)
|
|
275
|
+
continue; // already emitted to this endpoint
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Outbox: persist the row first, then signal the queue. The findOne above
|
|
279
|
+
// is the fast path; the unique index on (endpoint_id, idempotency_key) is
|
|
280
|
+
// the race backstop — a concurrent emit with the same key loses the insert,
|
|
281
|
+
// and we treat that as "already emitted" rather than enqueuing a duplicate.
|
|
282
|
+
let delivery: WebhookDelivery;
|
|
283
|
+
try {
|
|
284
|
+
delivery = await db.create<WebhookDelivery>({
|
|
285
|
+
model: 'webhook_delivery',
|
|
286
|
+
data: {
|
|
287
|
+
endpointId: endpoint.id,
|
|
288
|
+
eventType: eventName,
|
|
289
|
+
payload: payloadJson,
|
|
290
|
+
status: 'pending',
|
|
291
|
+
attempts: 0,
|
|
292
|
+
idempotencyKey: opts?.idempotencyKey ?? null,
|
|
293
|
+
nextRetryAt: now, // due immediately (the database queue selects on this)
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
if (opts?.idempotencyKey) {
|
|
299
|
+
const winner = await db.findOne<WebhookDelivery>({
|
|
300
|
+
model: 'webhook_delivery',
|
|
301
|
+
where: [
|
|
302
|
+
{ field: 'endpointId', operator: '=', value: endpoint.id },
|
|
303
|
+
{ field: 'idempotencyKey', operator: '=', value: opts.idempotencyKey },
|
|
304
|
+
],
|
|
305
|
+
});
|
|
306
|
+
if (winner)
|
|
307
|
+
continue; // a concurrent emit with the same key won the insert
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
await queue.enqueue({ deliveryId: delivery.id, endpointId: endpoint.id, eventType: eventName, attempt: 1, scheduledFor: now });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Queue lifecycle ───────────────────────────────────────────────
|
|
316
|
+
let queueStarted = false;
|
|
317
|
+
let queueTeardown: (() => Promise<void>) | null = null;
|
|
318
|
+
function ensureQueueStarted(db: DatabaseAdapter): void {
|
|
319
|
+
if (queueStarted || !queue.start)
|
|
320
|
+
return;
|
|
321
|
+
queueStarted = true;
|
|
322
|
+
// start() sets its handler synchronously before its first await, so jobs
|
|
323
|
+
// enqueued right after this call are not dropped.
|
|
324
|
+
void queue.start(job => processJob(db, job), { db })
|
|
325
|
+
.then((teardown) => {
|
|
326
|
+
queueTeardown = teardown;
|
|
327
|
+
})
|
|
328
|
+
.catch(() => {
|
|
329
|
+
queueStarted = false;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
name: 'webhook',
|
|
335
|
+
|
|
336
|
+
models: [
|
|
337
|
+
{
|
|
338
|
+
name: 'webhook_endpoint',
|
|
339
|
+
fields: {
|
|
340
|
+
id: { type: 'string', required: true },
|
|
341
|
+
url: { type: 'string', required: true },
|
|
342
|
+
events: { type: 'string', required: true },
|
|
343
|
+
secret: { type: 'string', required: true },
|
|
344
|
+
isActive: { type: 'boolean', required: true },
|
|
345
|
+
deactivatedReason: { type: 'string' },
|
|
346
|
+
consecutiveFailures: { type: 'number', required: true },
|
|
347
|
+
createdAt: { type: 'date', required: true },
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: 'webhook_delivery',
|
|
352
|
+
fields: {
|
|
353
|
+
id: { type: 'string', required: true },
|
|
354
|
+
endpointId: { type: 'string', required: true, references: { model: 'webhook_endpoint', field: 'id' } },
|
|
355
|
+
eventType: { type: 'string', required: true },
|
|
356
|
+
payload: { type: 'string', required: true },
|
|
357
|
+
status: { type: 'string', required: true },
|
|
358
|
+
attempts: { type: 'number', required: true },
|
|
359
|
+
idempotencyKey: { type: 'string' },
|
|
360
|
+
lastAttemptAt: { type: 'date' },
|
|
361
|
+
nextRetryAt: { type: 'date' },
|
|
362
|
+
responseStatus: { type: 'number' },
|
|
363
|
+
responseBody: { type: 'string' },
|
|
364
|
+
errorKind: { type: 'string' },
|
|
365
|
+
createdAt: { type: 'date', required: true },
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
|
|
370
|
+
hooks: bindBuiltinHooks(registry, emitEvent),
|
|
371
|
+
|
|
372
|
+
methods: (ctx) => {
|
|
373
|
+
ensureQueueStarted(ctx.db);
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
/** Emit an event — validates against the declared schema and the payload cap, then queues delivery to every subscribed endpoint. */
|
|
377
|
+
emit(eventName: string, payload: Record<string, unknown>, opts?: EmitOptions): Promise<void> {
|
|
378
|
+
return emitEvent(ctx.db, eventName, payload, opts);
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/** Register a delivery endpoint. A CSPRNG `secret` is generated when omitted and returned once on the result. */
|
|
382
|
+
async registerEndpoint(url: string, events: string[], opts?: RegisterEndpointOptions): Promise<WebhookEndpoint> {
|
|
383
|
+
const secret = opts?.secret ?? generateSecret();
|
|
384
|
+
return ctx.db.create<WebhookEndpoint>({
|
|
385
|
+
model: 'webhook_endpoint',
|
|
386
|
+
data: { url, events: JSON.stringify(events), secret, isActive: true, consecutiveFailures: 0 },
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
/** Patch an endpoint's url / events / active flag. Re-activating clears the deactivation reason and resets the failure counter. Secret is redacted. */
|
|
391
|
+
async updateEndpoint(id: string, patch: UpdateEndpointPatch): Promise<RedactedWebhookEndpoint | null> {
|
|
392
|
+
const data: Record<string, unknown> = {};
|
|
393
|
+
if (patch.url !== undefined)
|
|
394
|
+
data.url = patch.url;
|
|
395
|
+
if (patch.events !== undefined)
|
|
396
|
+
data.events = JSON.stringify(patch.events);
|
|
397
|
+
if (patch.isActive !== undefined) {
|
|
398
|
+
data.isActive = patch.isActive;
|
|
399
|
+
if (patch.isActive) {
|
|
400
|
+
data.deactivatedReason = null;
|
|
401
|
+
data.consecutiveFailures = 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const updated = await ctx.db.update<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(id), data });
|
|
405
|
+
return updated ? redact(updated) : null;
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
/** Rotate an endpoint's signing secret. Returns the new secret once. */
|
|
409
|
+
async rotateSecret(id: string): Promise<{ id: string; secret: string }> {
|
|
410
|
+
const secret = generateSecret();
|
|
411
|
+
const updated = await ctx.db.update<WebhookEndpoint>({ model: 'webhook_endpoint', where: idWhere(id), data: { secret } });
|
|
412
|
+
if (!updated)
|
|
413
|
+
throw new Error(`Webhook endpoint not found: ${id}`);
|
|
414
|
+
return { id, secret };
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
/** List all endpoints with `secret` redacted. */
|
|
418
|
+
async listEndpoints(): Promise<RedactedWebhookEndpoint[]> {
|
|
419
|
+
const rows = await ctx.db.findMany<WebhookEndpoint>({ model: 'webhook_endpoint' });
|
|
420
|
+
return rows.map(redact);
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
/** Delete an endpoint and its delivery rows. */
|
|
424
|
+
async removeEndpoint(id: string): Promise<void> {
|
|
425
|
+
await ctx.db.delete({ model: 'webhook_delivery', where: [{ field: 'endpointId', operator: '=', value: id }] });
|
|
426
|
+
await ctx.db.delete({ model: 'webhook_endpoint', where: idWhere(id) });
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
/** The registered event types (name + description). */
|
|
430
|
+
listEventTypes(): { name: string; description?: string }[] {
|
|
431
|
+
return registry.list().map(e => ({ name: e.name, description: e.description }));
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
/** Stop the queue worker (timers/poller) — call on shutdown. */
|
|
435
|
+
async stop(): Promise<void> {
|
|
436
|
+
if (queueTeardown) {
|
|
437
|
+
await queueTeardown();
|
|
438
|
+
queueTeardown = null;
|
|
439
|
+
}
|
|
440
|
+
queueStarted = false;
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|