@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,789 @@
|
|
|
1
|
+
// src/plugins/webhook/index.ts
|
|
2
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
3
|
+
|
|
4
|
+
// src/plugins/webhook/builtin-events.ts
|
|
5
|
+
var BUILTIN = [
|
|
6
|
+
{ name: "auth.login.success", source: "afterLogin", description: "A user authenticated successfully." },
|
|
7
|
+
{ name: "auth.login.failure", source: "onLoginFailure", description: "A login attempt failed." },
|
|
8
|
+
{ name: "auth.logout", source: "beforeLogout", description: "A user logged out." },
|
|
9
|
+
{ name: "auth.user.registered", source: "afterRegister", description: "A new user was registered." },
|
|
10
|
+
{ name: "auth.token.refreshed", source: "afterTokenRefresh", description: "An access token was refreshed." }
|
|
11
|
+
];
|
|
12
|
+
var BUILTIN_EVENT_NAMES = new Set(BUILTIN.map((e) => e.name));
|
|
13
|
+
function builtinEvents(opts = {}) {
|
|
14
|
+
const all = BUILTIN.map((e) => ({ ...e }));
|
|
15
|
+
return opts.exclude ? all.filter((e) => !opts.exclude.includes(e.name)) : all;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/plugins/webhook/delivery.ts
|
|
19
|
+
import { Buffer } from "buffer";
|
|
20
|
+
import { request as httpsRequest } from "https";
|
|
21
|
+
import { createFetch } from "@bajustone/fetcher";
|
|
22
|
+
|
|
23
|
+
// src/plugins/webhook/ssrf.ts
|
|
24
|
+
import { lookup } from "dns/promises";
|
|
25
|
+
import { isIP } from "net";
|
|
26
|
+
function stripIpv6Brackets(host) {
|
|
27
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
28
|
+
}
|
|
29
|
+
function parseIpv4(address) {
|
|
30
|
+
const parts = address.split(".");
|
|
31
|
+
if (parts.length !== 4)
|
|
32
|
+
return null;
|
|
33
|
+
const octets = parts.map((part) => Number(part));
|
|
34
|
+
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
|
|
35
|
+
return null;
|
|
36
|
+
return octets;
|
|
37
|
+
}
|
|
38
|
+
function extractMappedIpv4(address) {
|
|
39
|
+
const lower = stripIpv6Brackets(address).toLowerCase();
|
|
40
|
+
if (!lower.startsWith("::ffff:"))
|
|
41
|
+
return null;
|
|
42
|
+
const tail = lower.slice("::ffff:".length);
|
|
43
|
+
if (parseIpv4(tail))
|
|
44
|
+
return tail;
|
|
45
|
+
const words = tail.split(":");
|
|
46
|
+
if (words.length !== 2)
|
|
47
|
+
return null;
|
|
48
|
+
const hi = Number.parseInt(words[0], 16);
|
|
49
|
+
const lo = Number.parseInt(words[1], 16);
|
|
50
|
+
if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi < 0 || hi > 65535 || lo < 0 || lo > 65535)
|
|
51
|
+
return null;
|
|
52
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
53
|
+
}
|
|
54
|
+
function isPrivateIpv4(address) {
|
|
55
|
+
const octets = parseIpv4(address);
|
|
56
|
+
if (!octets)
|
|
57
|
+
return false;
|
|
58
|
+
const [a, b] = octets;
|
|
59
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a >= 224;
|
|
60
|
+
}
|
|
61
|
+
function ipv6Groups(address) {
|
|
62
|
+
let host = stripIpv6Brackets(address).toLowerCase();
|
|
63
|
+
const zone = host.indexOf("%");
|
|
64
|
+
if (zone !== -1)
|
|
65
|
+
host = host.slice(0, zone);
|
|
66
|
+
if (!host.includes(":"))
|
|
67
|
+
return null;
|
|
68
|
+
if (host.includes(".")) {
|
|
69
|
+
const lastColon = host.lastIndexOf(":");
|
|
70
|
+
const v4 = parseIpv4(host.slice(lastColon + 1));
|
|
71
|
+
if (!v4)
|
|
72
|
+
return null;
|
|
73
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
74
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
75
|
+
host = `${host.slice(0, lastColon + 1)}${hi}:${lo}`;
|
|
76
|
+
}
|
|
77
|
+
const halves = host.split("::");
|
|
78
|
+
if (halves.length > 2)
|
|
79
|
+
return null;
|
|
80
|
+
const head = halves[0] === "" ? [] : halves[0].split(":");
|
|
81
|
+
const tail = halves.length === 2 ? halves[1] === "" ? [] : halves[1].split(":") : null;
|
|
82
|
+
let parts;
|
|
83
|
+
if (tail === null) {
|
|
84
|
+
parts = head;
|
|
85
|
+
} else {
|
|
86
|
+
const missing = 8 - head.length - tail.length;
|
|
87
|
+
if (missing < 1)
|
|
88
|
+
return null;
|
|
89
|
+
parts = [...head, ...Array.from({ length: missing }).fill("0"), ...tail];
|
|
90
|
+
}
|
|
91
|
+
if (parts.length !== 8)
|
|
92
|
+
return null;
|
|
93
|
+
const groups = parts.map((part) => Number.parseInt(part, 16));
|
|
94
|
+
if (groups.some((g) => !Number.isInteger(g) || g < 0 || g > 65535))
|
|
95
|
+
return null;
|
|
96
|
+
return groups;
|
|
97
|
+
}
|
|
98
|
+
function extractNat64Ipv4(address) {
|
|
99
|
+
const groups = ipv6Groups(address);
|
|
100
|
+
if (!groups)
|
|
101
|
+
return null;
|
|
102
|
+
if (groups[0] === 100 && groups[1] === 65435 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 0) {
|
|
103
|
+
const g6 = groups[6];
|
|
104
|
+
const g7 = groups[7];
|
|
105
|
+
return `${g6 >> 8 & 255}.${g6 & 255}.${g7 >> 8 & 255}.${g7 & 255}`;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
function isPrivateIp(address) {
|
|
110
|
+
const host = stripIpv6Brackets(address).toLowerCase();
|
|
111
|
+
const mapped = extractMappedIpv4(host);
|
|
112
|
+
if (mapped)
|
|
113
|
+
return isPrivateIpv4(mapped);
|
|
114
|
+
const nat64 = extractNat64Ipv4(host);
|
|
115
|
+
if (nat64)
|
|
116
|
+
return isPrivateIpv4(nat64);
|
|
117
|
+
if (isPrivateIpv4(host))
|
|
118
|
+
return true;
|
|
119
|
+
if (host === "::" || host === "::1")
|
|
120
|
+
return true;
|
|
121
|
+
return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:") || host.startsWith("ff");
|
|
122
|
+
}
|
|
123
|
+
async function resolveSafeWebhookTarget(url) {
|
|
124
|
+
const parsed = new URL(url);
|
|
125
|
+
if (parsed.protocol !== "https:")
|
|
126
|
+
throw new Error("Webhook URL must use https");
|
|
127
|
+
const host = stripIpv6Brackets(parsed.hostname.toLowerCase());
|
|
128
|
+
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local"))
|
|
129
|
+
throw new Error("Webhook URL host is not allowed");
|
|
130
|
+
const literalFamily = isIP(host);
|
|
131
|
+
const records = literalFamily === 0 ? await lookup(host, { all: true, verbatim: true }) : [{ address: host, family: literalFamily }];
|
|
132
|
+
if (records.length === 0)
|
|
133
|
+
throw new Error("Webhook URL host did not resolve");
|
|
134
|
+
if (records.some((record) => isPrivateIp(record.address)))
|
|
135
|
+
throw new Error("Webhook URL resolves to a private address");
|
|
136
|
+
const selected = records[0];
|
|
137
|
+
return { url: parsed, address: selected.address, family: selected.family };
|
|
138
|
+
}
|
|
139
|
+
async function assertSafeWebhookUrl(url) {
|
|
140
|
+
await resolveSafeWebhookTarget(url);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/plugins/webhook/delivery.ts
|
|
144
|
+
function ssrfSafeFetch() {
|
|
145
|
+
return async (req) => {
|
|
146
|
+
const target = await resolveSafeWebhookTarget(req.url);
|
|
147
|
+
const body = req.method === "GET" || req.method === "HEAD" ? void 0 : await req.text();
|
|
148
|
+
const headers = {};
|
|
149
|
+
req.headers.forEach((value, key) => {
|
|
150
|
+
headers[key] = value;
|
|
151
|
+
});
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const clientReq = httpsRequest(
|
|
154
|
+
{
|
|
155
|
+
protocol: target.url.protocol,
|
|
156
|
+
hostname: target.url.hostname,
|
|
157
|
+
port: target.url.port || 443,
|
|
158
|
+
path: `${target.url.pathname}${target.url.search}`,
|
|
159
|
+
method: req.method,
|
|
160
|
+
headers,
|
|
161
|
+
// Pin the connection to the exact validated IP (DNS-rebind defense).
|
|
162
|
+
lookup: (_hostname, _options, cb) => cb(null, target.address, target.family)
|
|
163
|
+
},
|
|
164
|
+
(res) => {
|
|
165
|
+
const chunks = [];
|
|
166
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
167
|
+
res.on("end", () => {
|
|
168
|
+
const resHeaders = new Headers();
|
|
169
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
170
|
+
if (typeof value === "string")
|
|
171
|
+
resHeaders.set(key, value);
|
|
172
|
+
else if (Array.isArray(value))
|
|
173
|
+
resHeaders.set(key, value.join(", "));
|
|
174
|
+
}
|
|
175
|
+
const status = res.statusCode && res.statusCode >= 200 ? res.statusCode : 502;
|
|
176
|
+
resolve(new Response(Buffer.concat(chunks), { status, headers: resHeaders }));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
if (req.signal) {
|
|
181
|
+
if (req.signal.aborted)
|
|
182
|
+
clientReq.destroy(new Error("aborted"));
|
|
183
|
+
else
|
|
184
|
+
req.signal.addEventListener("abort", () => clientReq.destroy(new Error("aborted")), { once: true });
|
|
185
|
+
}
|
|
186
|
+
clientReq.on("error", reject);
|
|
187
|
+
clientReq.end(body);
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function createDeliveryClient(opts) {
|
|
192
|
+
return createFetch({
|
|
193
|
+
baseUrl: "",
|
|
194
|
+
fetch: opts.fetch ?? ssrfSafeFetch(),
|
|
195
|
+
timeout: opts.timeoutMs ?? 1e4,
|
|
196
|
+
...opts.retryMode === "inProcess" ? {
|
|
197
|
+
retry: {
|
|
198
|
+
attempts: 5,
|
|
199
|
+
methods: ["POST"],
|
|
200
|
+
backoff: 5e3,
|
|
201
|
+
factor: 2,
|
|
202
|
+
maxBackoff: 5 * 6e4
|
|
203
|
+
}
|
|
204
|
+
} : {}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function safeReadBody(response) {
|
|
208
|
+
try {
|
|
209
|
+
const text = await response.text();
|
|
210
|
+
return text.slice(0, 2048);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function deliver(client, url, body, headers) {
|
|
216
|
+
const response = await client.post(url, {
|
|
217
|
+
body,
|
|
218
|
+
headers: { "Content-Type": "application/json", ...headers }
|
|
219
|
+
});
|
|
220
|
+
if (response.ok)
|
|
221
|
+
return { ok: true, status: response.status };
|
|
222
|
+
const transport = response.status === 0;
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
kind: transport ? "network" : "http",
|
|
226
|
+
status: transport ? null : response.status,
|
|
227
|
+
responseBody: transport ? null : await safeReadBody(response),
|
|
228
|
+
message: transport ? "Transport error (network, timeout, or blocked target)" : `HTTP ${response.status}`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/plugins/webhook/hooks.ts
|
|
233
|
+
function bindBuiltinHooks(registry, emit) {
|
|
234
|
+
const sources = registry.sources();
|
|
235
|
+
const hooks = {};
|
|
236
|
+
const loginName = sources.get("afterLogin");
|
|
237
|
+
if (loginName) {
|
|
238
|
+
hooks.afterLogin = async (ctx, result) => {
|
|
239
|
+
void emit(ctx.db, loginName, {
|
|
240
|
+
event: loginName,
|
|
241
|
+
userId: result.user.id,
|
|
242
|
+
email: result.user.email,
|
|
243
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
244
|
+
ip: ctx.meta?.ipAddress ?? null
|
|
245
|
+
}).catch(() => {
|
|
246
|
+
});
|
|
247
|
+
return result;
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const failureName = sources.get("onLoginFailure");
|
|
251
|
+
if (failureName) {
|
|
252
|
+
hooks.onLoginFailure = async (ctx) => {
|
|
253
|
+
void emit(ctx.db, failureName, {
|
|
254
|
+
event: failureName,
|
|
255
|
+
identifier: ctx.identifier,
|
|
256
|
+
error: ctx.error.message,
|
|
257
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
258
|
+
}).catch(() => {
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const logoutName = sources.get("beforeLogout");
|
|
263
|
+
if (logoutName) {
|
|
264
|
+
hooks.beforeLogout = async (ctx) => {
|
|
265
|
+
void emit(ctx.db, logoutName, {
|
|
266
|
+
event: logoutName,
|
|
267
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
268
|
+
ip: ctx.meta?.ipAddress ?? null
|
|
269
|
+
}).catch(() => {
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const registerName = sources.get("afterRegister");
|
|
274
|
+
if (registerName) {
|
|
275
|
+
hooks.afterRegister = async (ctx, user) => {
|
|
276
|
+
void emit(ctx.db, registerName, {
|
|
277
|
+
event: registerName,
|
|
278
|
+
userId: user.id,
|
|
279
|
+
email: user.email,
|
|
280
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
281
|
+
ip: ctx.meta?.ipAddress ?? null
|
|
282
|
+
}).catch(() => {
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const refreshName = sources.get("afterTokenRefresh");
|
|
287
|
+
if (refreshName) {
|
|
288
|
+
hooks.afterTokenRefresh = async (ctx, result) => {
|
|
289
|
+
void emit(ctx.db, refreshName, {
|
|
290
|
+
event: refreshName,
|
|
291
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
292
|
+
ip: ctx.meta?.ipAddress ?? null
|
|
293
|
+
}).catch(() => {
|
|
294
|
+
});
|
|
295
|
+
return result;
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return hooks;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/plugins/webhook/queue/in-memory.ts
|
|
302
|
+
function inMemoryQueue() {
|
|
303
|
+
let handler = null;
|
|
304
|
+
const timers = /* @__PURE__ */ new Set();
|
|
305
|
+
let chain = Promise.resolve();
|
|
306
|
+
const enqueue = async (job) => {
|
|
307
|
+
const delay = job.scheduledFor ? Math.max(0, job.scheduledFor.getTime() - Date.now()) : 0;
|
|
308
|
+
const dispatch = () => {
|
|
309
|
+
const current = handler;
|
|
310
|
+
if (!current)
|
|
311
|
+
return;
|
|
312
|
+
chain = chain.then(() => current(job)).catch(() => {
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
if (delay === 0) {
|
|
316
|
+
queueMicrotask(dispatch);
|
|
317
|
+
} else {
|
|
318
|
+
const timer = setTimeout(() => {
|
|
319
|
+
timers.delete(timer);
|
|
320
|
+
dispatch();
|
|
321
|
+
}, delay);
|
|
322
|
+
timers.add(timer);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
enqueue,
|
|
327
|
+
async start(h, ctx) {
|
|
328
|
+
handler = h;
|
|
329
|
+
const pending = await ctx.db.findMany({
|
|
330
|
+
model: "webhook_delivery",
|
|
331
|
+
where: [{ field: "status", operator: "=", value: "pending" }]
|
|
332
|
+
});
|
|
333
|
+
for (const delivery of pending) {
|
|
334
|
+
void enqueue({
|
|
335
|
+
deliveryId: delivery.id,
|
|
336
|
+
endpointId: delivery.endpointId,
|
|
337
|
+
eventType: delivery.eventType,
|
|
338
|
+
attempt: delivery.attempts + 1,
|
|
339
|
+
scheduledFor: delivery.nextRetryAt ?? void 0
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return async () => {
|
|
343
|
+
for (const timer of timers)
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
timers.clear();
|
|
346
|
+
handler = null;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/plugins/webhook/registry.ts
|
|
353
|
+
var VALID_SOURCES = /* @__PURE__ */ new Set([
|
|
354
|
+
"afterLogin",
|
|
355
|
+
"onLoginFailure",
|
|
356
|
+
"beforeLogout",
|
|
357
|
+
"afterRegister",
|
|
358
|
+
"afterTokenRefresh"
|
|
359
|
+
]);
|
|
360
|
+
function assertEventRegistry(events) {
|
|
361
|
+
const seen = /* @__PURE__ */ new Set();
|
|
362
|
+
for (const event of events) {
|
|
363
|
+
if (typeof event.name !== "string" || event.name.length === 0)
|
|
364
|
+
throw new TypeError("Webhook event declaration requires a non-empty string `name`");
|
|
365
|
+
if (seen.has(event.name))
|
|
366
|
+
throw new Error(`Duplicate webhook event name: ${event.name}`);
|
|
367
|
+
seen.add(event.name);
|
|
368
|
+
if (event.source !== void 0 && !VALID_SOURCES.has(event.source))
|
|
369
|
+
throw new Error(`Webhook event '${event.name}' has an unknown source: ${event.source}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function createEventRegistry(events) {
|
|
373
|
+
assertEventRegistry(events);
|
|
374
|
+
const ordered = [...events];
|
|
375
|
+
const byName = new Map(ordered.map((e) => [e.name, e]));
|
|
376
|
+
const sources = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const event of ordered) {
|
|
378
|
+
if (event.source && !sources.has(event.source))
|
|
379
|
+
sources.set(event.source, event.name);
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
list: () => ordered.map((e) => ({ ...e })),
|
|
383
|
+
names: () => ordered.map((e) => e.name),
|
|
384
|
+
get: (name) => byName.get(name),
|
|
385
|
+
has: (name) => byName.has(name),
|
|
386
|
+
sources: () => new Map(sources)
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// src/plugins/webhook/signing.ts
|
|
391
|
+
async function signPayload(secret, webhookId, timestamp, body) {
|
|
392
|
+
const content = `${webhookId}.${timestamp}.${body}`;
|
|
393
|
+
const key = await crypto.subtle.importKey(
|
|
394
|
+
"raw",
|
|
395
|
+
new TextEncoder().encode(secret),
|
|
396
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
397
|
+
false,
|
|
398
|
+
["sign"]
|
|
399
|
+
);
|
|
400
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content));
|
|
401
|
+
return btoa(String.fromCharCode(...new Uint8Array(sig)));
|
|
402
|
+
}
|
|
403
|
+
async function signatureHeaders(secret, webhookId, timestamp, body) {
|
|
404
|
+
const signature = await signPayload(secret, webhookId, timestamp, body);
|
|
405
|
+
return {
|
|
406
|
+
"webhook-id": webhookId,
|
|
407
|
+
"webhook-timestamp": String(timestamp),
|
|
408
|
+
"webhook-signature": `v1,${signature}`
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/plugins/webhook/types.ts
|
|
413
|
+
var WebhookEmitError = class extends Error {
|
|
414
|
+
code;
|
|
415
|
+
constructor(code, message) {
|
|
416
|
+
super(message);
|
|
417
|
+
this.name = "WebhookEmitError";
|
|
418
|
+
this.code = code;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// src/plugins/webhook/queue/database.ts
|
|
423
|
+
function databaseQueue(opts = {}) {
|
|
424
|
+
const pollMs = opts.pollMs ?? 1e4;
|
|
425
|
+
return {
|
|
426
|
+
async enqueue() {
|
|
427
|
+
},
|
|
428
|
+
async start(handler, ctx) {
|
|
429
|
+
let stopped = false;
|
|
430
|
+
let timer = null;
|
|
431
|
+
const poll = async () => {
|
|
432
|
+
if (stopped)
|
|
433
|
+
return;
|
|
434
|
+
try {
|
|
435
|
+
const due = await ctx.db.findMany({
|
|
436
|
+
model: "webhook_delivery",
|
|
437
|
+
where: [
|
|
438
|
+
{ field: "status", operator: "=", value: "pending" },
|
|
439
|
+
{ field: "nextRetryAt", operator: "lte", value: /* @__PURE__ */ new Date() }
|
|
440
|
+
],
|
|
441
|
+
sortBy: { field: "nextRetryAt", direction: "asc" }
|
|
442
|
+
});
|
|
443
|
+
for (const delivery of due) {
|
|
444
|
+
if (stopped)
|
|
445
|
+
break;
|
|
446
|
+
await handler({
|
|
447
|
+
deliveryId: delivery.id,
|
|
448
|
+
endpointId: delivery.endpointId,
|
|
449
|
+
eventType: delivery.eventType,
|
|
450
|
+
attempt: delivery.attempts + 1,
|
|
451
|
+
scheduledFor: delivery.nextRetryAt ?? void 0
|
|
452
|
+
}).catch(() => {
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
}
|
|
457
|
+
if (!stopped)
|
|
458
|
+
timer = setTimeout(() => void poll(), pollMs);
|
|
459
|
+
};
|
|
460
|
+
timer = setTimeout(() => void poll(), 0);
|
|
461
|
+
return async () => {
|
|
462
|
+
stopped = true;
|
|
463
|
+
if (timer)
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/plugins/webhook/index.ts
|
|
471
|
+
var RETRY_INTERVALS_MS = [5e3, 5 * 6e4, 30 * 6e4, 2 * 60 * 6e4, 5 * 60 * 6e4];
|
|
472
|
+
var TRANSIENT_4XX = /* @__PURE__ */ new Set([408, 425, 429]);
|
|
473
|
+
function withJitter(ms) {
|
|
474
|
+
return Math.max(0, Math.round(ms * (1 + (Math.random() - 0.5) * 0.5)));
|
|
475
|
+
}
|
|
476
|
+
function generateSecret() {
|
|
477
|
+
const bytes = new Uint8Array(32);
|
|
478
|
+
crypto.getRandomValues(bytes);
|
|
479
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
480
|
+
return `whsec_${hex}`;
|
|
481
|
+
}
|
|
482
|
+
function idWhere(id) {
|
|
483
|
+
return [{ field: "id", operator: "=", value: id }];
|
|
484
|
+
}
|
|
485
|
+
function redact(endpoint) {
|
|
486
|
+
const { secret: _secret, ...rest } = endpoint;
|
|
487
|
+
return rest;
|
|
488
|
+
}
|
|
489
|
+
function webhook(config = {}) {
|
|
490
|
+
const registry = createEventRegistry(config.events ?? builtinEvents());
|
|
491
|
+
const queue = config.queue ?? inMemoryQueue();
|
|
492
|
+
const retryMode = config.delivery?.retry ?? "pluginScheduled";
|
|
493
|
+
const maxRetries = config.maxRetries ?? 5;
|
|
494
|
+
const maxPayloadBytes = config.maxPayloadBytes ?? 256 * 1024;
|
|
495
|
+
const timeoutMs = config.delivery?.timeoutMs ?? 1e4;
|
|
496
|
+
const permanentStatuses = new Set(config.delivery?.permanentStatuses ?? [404, 410, 421]);
|
|
497
|
+
const maxConsecutiveFailures = config.delivery?.maxConsecutiveFailures ?? 15;
|
|
498
|
+
const onDeliveryFailed = config.delivery?.onDeliveryFailed;
|
|
499
|
+
const onEndpointDeactivated = config.delivery?.onEndpointDeactivated;
|
|
500
|
+
if (retryMode === "queue" && !queue.handlesRetries) {
|
|
501
|
+
throw new Error('delivery.retry: "queue" requires a queue with handlesRetries: true');
|
|
502
|
+
}
|
|
503
|
+
const client = createDeliveryClient({ timeoutMs, retryMode, fetch: config.delivery?.fetch });
|
|
504
|
+
const deliverFn = (url, body, headers) => deliver(client, url, body, headers);
|
|
505
|
+
function classify(result) {
|
|
506
|
+
if (!result.ok && result.kind === "http" && result.status !== null) {
|
|
507
|
+
if (permanentStatuses.has(result.status))
|
|
508
|
+
return "deactivate";
|
|
509
|
+
if (TRANSIENT_4XX.has(result.status))
|
|
510
|
+
return "retry";
|
|
511
|
+
if (result.status >= 400 && result.status < 500)
|
|
512
|
+
return "permanent_fail";
|
|
513
|
+
return "retry";
|
|
514
|
+
}
|
|
515
|
+
return "retry";
|
|
516
|
+
}
|
|
517
|
+
async function deactivateEndpoint(db, endpoint, reason) {
|
|
518
|
+
if (!endpoint.isActive)
|
|
519
|
+
return;
|
|
520
|
+
await db.update({ model: "webhook_endpoint", where: idWhere(endpoint.id), data: { isActive: false, deactivatedReason: reason } });
|
|
521
|
+
if (onEndpointDeactivated)
|
|
522
|
+
await onEndpointDeactivated({ ...endpoint, isActive: false, deactivatedReason: reason }, reason);
|
|
523
|
+
}
|
|
524
|
+
async function processJob(db, job) {
|
|
525
|
+
const delivery = await db.findOne({ model: "webhook_delivery", where: idWhere(job.deliveryId) });
|
|
526
|
+
if (!delivery || delivery.status !== "pending")
|
|
527
|
+
return;
|
|
528
|
+
const endpoint = await db.findOne({ model: "webhook_endpoint", where: idWhere(delivery.endpointId) });
|
|
529
|
+
if (!endpoint || !endpoint.isActive)
|
|
530
|
+
return;
|
|
531
|
+
const webhookId = `msg_${delivery.id}`;
|
|
532
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
533
|
+
const headers = await signatureHeaders(endpoint.secret, webhookId, timestamp, delivery.payload);
|
|
534
|
+
const result = await deliverFn(endpoint.url, delivery.payload, headers);
|
|
535
|
+
const now = /* @__PURE__ */ new Date();
|
|
536
|
+
const attempts = delivery.attempts + 1;
|
|
537
|
+
if (result.ok) {
|
|
538
|
+
await db.update({
|
|
539
|
+
model: "webhook_delivery",
|
|
540
|
+
where: idWhere(delivery.id),
|
|
541
|
+
data: { status: "success", attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus: result.status, errorKind: null }
|
|
542
|
+
});
|
|
543
|
+
if (endpoint.consecutiveFailures > 0)
|
|
544
|
+
await db.update({ model: "webhook_endpoint", where: idWhere(endpoint.id), data: { consecutiveFailures: 0 } });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const consecutiveFailures = endpoint.consecutiveFailures + 1;
|
|
548
|
+
await db.update({ model: "webhook_endpoint", where: idWhere(endpoint.id), data: { consecutiveFailures } });
|
|
549
|
+
const responseStatus = result.status;
|
|
550
|
+
const responseBody = result.responseBody;
|
|
551
|
+
const errorKind = result.kind;
|
|
552
|
+
const action = classify(result);
|
|
553
|
+
const exhausted = attempts >= maxRetries;
|
|
554
|
+
const breakerTripped = consecutiveFailures >= maxConsecutiveFailures;
|
|
555
|
+
const willRetry = action === "retry" && !exhausted && retryMode !== "inProcess";
|
|
556
|
+
const markFailed = async () => {
|
|
557
|
+
await db.update({
|
|
558
|
+
model: "webhook_delivery",
|
|
559
|
+
where: idWhere(delivery.id),
|
|
560
|
+
data: { status: "failed", attempts, lastAttemptAt: now, nextRetryAt: null, responseStatus, responseBody, errorKind }
|
|
561
|
+
});
|
|
562
|
+
if (onDeliveryFailed) {
|
|
563
|
+
const row = await db.findOne({ model: "webhook_delivery", where: idWhere(delivery.id) });
|
|
564
|
+
if (row)
|
|
565
|
+
await onDeliveryFailed(row);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
if (action === "deactivate") {
|
|
569
|
+
await markFailed();
|
|
570
|
+
await deactivateEndpoint(db, endpoint, `permanent_${responseStatus}`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!willRetry || breakerTripped) {
|
|
574
|
+
await markFailed();
|
|
575
|
+
if (breakerTripped)
|
|
576
|
+
await deactivateEndpoint(db, endpoint, "too_many_failures");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (retryMode === "queue") {
|
|
580
|
+
await db.update({
|
|
581
|
+
model: "webhook_delivery",
|
|
582
|
+
where: idWhere(delivery.id),
|
|
583
|
+
data: { status: "pending", attempts, lastAttemptAt: now, responseStatus, responseBody, errorKind }
|
|
584
|
+
});
|
|
585
|
+
throw new Error(`Webhook delivery failed (${errorKind}${responseStatus !== null ? ` ${responseStatus}` : ""}); queue will retry`);
|
|
586
|
+
}
|
|
587
|
+
const retryMs = withJitter(RETRY_INTERVALS_MS[Math.min(attempts - 1, RETRY_INTERVALS_MS.length - 1)]);
|
|
588
|
+
const nextRetryAt = new Date(Date.now() + retryMs);
|
|
589
|
+
await db.update({
|
|
590
|
+
model: "webhook_delivery",
|
|
591
|
+
where: idWhere(delivery.id),
|
|
592
|
+
data: { status: "pending", attempts, lastAttemptAt: now, nextRetryAt, responseStatus, responseBody, errorKind }
|
|
593
|
+
});
|
|
594
|
+
await queue.enqueue({
|
|
595
|
+
deliveryId: delivery.id,
|
|
596
|
+
endpointId: endpoint.id,
|
|
597
|
+
eventType: delivery.eventType,
|
|
598
|
+
attempt: attempts + 1,
|
|
599
|
+
scheduledFor: nextRetryAt
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
async function emitEvent(db, eventName, payload, opts) {
|
|
603
|
+
const declaration = registry.get(eventName);
|
|
604
|
+
if (!declaration)
|
|
605
|
+
throw new WebhookEmitError("unknown_event", `Unknown webhook event: ${eventName}`);
|
|
606
|
+
if (declaration.schema) {
|
|
607
|
+
const result = await declaration.schema["~standard"].validate(payload);
|
|
608
|
+
if (result.issues)
|
|
609
|
+
throw new WebhookEmitError("invalid_payload", `Invalid payload for webhook event '${eventName}'`);
|
|
610
|
+
}
|
|
611
|
+
const payloadJson = JSON.stringify(payload);
|
|
612
|
+
if (Buffer2.byteLength(payloadJson, "utf8") > maxPayloadBytes)
|
|
613
|
+
throw new WebhookEmitError("payload_too_large", `Webhook payload for '${eventName}' exceeds ${maxPayloadBytes} bytes`);
|
|
614
|
+
const endpoints = await db.findMany({ model: "webhook_endpoint", where: [{ field: "isActive", operator: "=", value: true }] });
|
|
615
|
+
const now = /* @__PURE__ */ new Date();
|
|
616
|
+
for (const endpoint of endpoints) {
|
|
617
|
+
let names;
|
|
618
|
+
try {
|
|
619
|
+
names = JSON.parse(endpoint.events);
|
|
620
|
+
} catch {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (!names.includes(eventName))
|
|
624
|
+
continue;
|
|
625
|
+
if (opts?.idempotencyKey) {
|
|
626
|
+
const existing = await db.findOne({
|
|
627
|
+
model: "webhook_delivery",
|
|
628
|
+
where: [
|
|
629
|
+
{ field: "endpointId", operator: "=", value: endpoint.id },
|
|
630
|
+
{ field: "idempotencyKey", operator: "=", value: opts.idempotencyKey }
|
|
631
|
+
]
|
|
632
|
+
});
|
|
633
|
+
if (existing)
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
let delivery;
|
|
637
|
+
try {
|
|
638
|
+
delivery = await db.create({
|
|
639
|
+
model: "webhook_delivery",
|
|
640
|
+
data: {
|
|
641
|
+
endpointId: endpoint.id,
|
|
642
|
+
eventType: eventName,
|
|
643
|
+
payload: payloadJson,
|
|
644
|
+
status: "pending",
|
|
645
|
+
attempts: 0,
|
|
646
|
+
idempotencyKey: opts?.idempotencyKey ?? null,
|
|
647
|
+
nextRetryAt: now
|
|
648
|
+
// due immediately (the database queue selects on this)
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
} catch (err) {
|
|
652
|
+
if (opts?.idempotencyKey) {
|
|
653
|
+
const winner = await db.findOne({
|
|
654
|
+
model: "webhook_delivery",
|
|
655
|
+
where: [
|
|
656
|
+
{ field: "endpointId", operator: "=", value: endpoint.id },
|
|
657
|
+
{ field: "idempotencyKey", operator: "=", value: opts.idempotencyKey }
|
|
658
|
+
]
|
|
659
|
+
});
|
|
660
|
+
if (winner)
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
throw err;
|
|
664
|
+
}
|
|
665
|
+
await queue.enqueue({ deliveryId: delivery.id, endpointId: endpoint.id, eventType: eventName, attempt: 1, scheduledFor: now });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
let queueStarted = false;
|
|
669
|
+
let queueTeardown = null;
|
|
670
|
+
function ensureQueueStarted(db) {
|
|
671
|
+
if (queueStarted || !queue.start)
|
|
672
|
+
return;
|
|
673
|
+
queueStarted = true;
|
|
674
|
+
void queue.start((job) => processJob(db, job), { db }).then((teardown) => {
|
|
675
|
+
queueTeardown = teardown;
|
|
676
|
+
}).catch(() => {
|
|
677
|
+
queueStarted = false;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
name: "webhook",
|
|
682
|
+
models: [
|
|
683
|
+
{
|
|
684
|
+
name: "webhook_endpoint",
|
|
685
|
+
fields: {
|
|
686
|
+
id: { type: "string", required: true },
|
|
687
|
+
url: { type: "string", required: true },
|
|
688
|
+
events: { type: "string", required: true },
|
|
689
|
+
secret: { type: "string", required: true },
|
|
690
|
+
isActive: { type: "boolean", required: true },
|
|
691
|
+
deactivatedReason: { type: "string" },
|
|
692
|
+
consecutiveFailures: { type: "number", required: true },
|
|
693
|
+
createdAt: { type: "date", required: true }
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: "webhook_delivery",
|
|
698
|
+
fields: {
|
|
699
|
+
id: { type: "string", required: true },
|
|
700
|
+
endpointId: { type: "string", required: true, references: { model: "webhook_endpoint", field: "id" } },
|
|
701
|
+
eventType: { type: "string", required: true },
|
|
702
|
+
payload: { type: "string", required: true },
|
|
703
|
+
status: { type: "string", required: true },
|
|
704
|
+
attempts: { type: "number", required: true },
|
|
705
|
+
idempotencyKey: { type: "string" },
|
|
706
|
+
lastAttemptAt: { type: "date" },
|
|
707
|
+
nextRetryAt: { type: "date" },
|
|
708
|
+
responseStatus: { type: "number" },
|
|
709
|
+
responseBody: { type: "string" },
|
|
710
|
+
errorKind: { type: "string" },
|
|
711
|
+
createdAt: { type: "date", required: true }
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
],
|
|
715
|
+
hooks: bindBuiltinHooks(registry, emitEvent),
|
|
716
|
+
methods: (ctx) => {
|
|
717
|
+
ensureQueueStarted(ctx.db);
|
|
718
|
+
return {
|
|
719
|
+
/** Emit an event — validates against the declared schema and the payload cap, then queues delivery to every subscribed endpoint. */
|
|
720
|
+
emit(eventName, payload, opts) {
|
|
721
|
+
return emitEvent(ctx.db, eventName, payload, opts);
|
|
722
|
+
},
|
|
723
|
+
/** Register a delivery endpoint. A CSPRNG `secret` is generated when omitted and returned once on the result. */
|
|
724
|
+
async registerEndpoint(url, events, opts) {
|
|
725
|
+
const secret = opts?.secret ?? generateSecret();
|
|
726
|
+
return ctx.db.create({
|
|
727
|
+
model: "webhook_endpoint",
|
|
728
|
+
data: { url, events: JSON.stringify(events), secret, isActive: true, consecutiveFailures: 0 }
|
|
729
|
+
});
|
|
730
|
+
},
|
|
731
|
+
/** Patch an endpoint's url / events / active flag. Re-activating clears the deactivation reason and resets the failure counter. Secret is redacted. */
|
|
732
|
+
async updateEndpoint(id, patch) {
|
|
733
|
+
const data = {};
|
|
734
|
+
if (patch.url !== void 0)
|
|
735
|
+
data.url = patch.url;
|
|
736
|
+
if (patch.events !== void 0)
|
|
737
|
+
data.events = JSON.stringify(patch.events);
|
|
738
|
+
if (patch.isActive !== void 0) {
|
|
739
|
+
data.isActive = patch.isActive;
|
|
740
|
+
if (patch.isActive) {
|
|
741
|
+
data.deactivatedReason = null;
|
|
742
|
+
data.consecutiveFailures = 0;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const updated = await ctx.db.update({ model: "webhook_endpoint", where: idWhere(id), data });
|
|
746
|
+
return updated ? redact(updated) : null;
|
|
747
|
+
},
|
|
748
|
+
/** Rotate an endpoint's signing secret. Returns the new secret once. */
|
|
749
|
+
async rotateSecret(id) {
|
|
750
|
+
const secret = generateSecret();
|
|
751
|
+
const updated = await ctx.db.update({ model: "webhook_endpoint", where: idWhere(id), data: { secret } });
|
|
752
|
+
if (!updated)
|
|
753
|
+
throw new Error(`Webhook endpoint not found: ${id}`);
|
|
754
|
+
return { id, secret };
|
|
755
|
+
},
|
|
756
|
+
/** List all endpoints with `secret` redacted. */
|
|
757
|
+
async listEndpoints() {
|
|
758
|
+
const rows = await ctx.db.findMany({ model: "webhook_endpoint" });
|
|
759
|
+
return rows.map(redact);
|
|
760
|
+
},
|
|
761
|
+
/** Delete an endpoint and its delivery rows. */
|
|
762
|
+
async removeEndpoint(id) {
|
|
763
|
+
await ctx.db.delete({ model: "webhook_delivery", where: [{ field: "endpointId", operator: "=", value: id }] });
|
|
764
|
+
await ctx.db.delete({ model: "webhook_endpoint", where: idWhere(id) });
|
|
765
|
+
},
|
|
766
|
+
/** The registered event types (name + description). */
|
|
767
|
+
listEventTypes() {
|
|
768
|
+
return registry.list().map((e) => ({ name: e.name, description: e.description }));
|
|
769
|
+
},
|
|
770
|
+
/** Stop the queue worker (timers/poller) — call on shutdown. */
|
|
771
|
+
async stop() {
|
|
772
|
+
if (queueTeardown) {
|
|
773
|
+
await queueTeardown();
|
|
774
|
+
queueTeardown = null;
|
|
775
|
+
}
|
|
776
|
+
queueStarted = false;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
export {
|
|
783
|
+
WebhookEmitError,
|
|
784
|
+
assertSafeWebhookUrl,
|
|
785
|
+
builtinEvents,
|
|
786
|
+
databaseQueue,
|
|
787
|
+
inMemoryQueue,
|
|
788
|
+
webhook
|
|
789
|
+
};
|