pipework 0.251.0 → 0.252.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 +11 -0
- package/dist/core/config/load.d.ts +2 -0
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +1 -0
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +1 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +2 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +1 -0
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/surface/types.d.ts +1 -0
- package/dist/core/surface/types.d.ts.map +1 -1
- package/dist/data/db/namespace.d.ts +3 -0
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +3 -0
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/pool.d.ts.map +1 -1
- package/dist/data/db/pool.js +6 -1
- package/dist/data/db/pool.js.map +1 -1
- package/dist/data/db/query-label.d.ts +5 -0
- package/dist/data/db/query-label.d.ts.map +1 -0
- package/dist/data/db/query-label.js +11 -0
- package/dist/data/db/query-label.js.map +1 -0
- package/dist/data/db/query-watch.d.ts +21 -0
- package/dist/data/db/query-watch.d.ts.map +1 -0
- package/dist/data/db/query-watch.js +90 -0
- package/dist/data/db/query-watch.js.map +1 -0
- package/dist/data/query/postgres-js/driver.js +1 -1
- package/dist/data/query/postgres-js/driver.js.map +1 -1
- package/dist/data/query/postgres-js/session.d.ts +5 -1
- package/dist/data/query/postgres-js/session.d.ts.map +1 -1
- package/dist/data/query/postgres-js/session.js +16 -11
- package/dist/data/query/postgres-js/session.js.map +1 -1
- package/dist/data/query/utils.d.ts +2 -0
- package/dist/data/query/utils.d.ts.map +1 -1
- package/dist/data/query/utils.js.map +1 -1
- package/dist/infra/credits/schema.d.ts +1 -1
- package/dist/test/plugin.d.ts.map +1 -1
- package/dist/test/plugin.js +10 -4
- package/dist/test/plugin.js.map +1 -1
- package/package.json +4 -3
- package/src/async/jobs/abortable-delay.ts +18 -0
- package/src/async/jobs/claims.ts +52 -0
- package/src/async/jobs/cron.ts +179 -0
- package/src/async/jobs/errors.ts +56 -0
- package/src/async/jobs/exclusion.ts +117 -0
- package/src/async/jobs/execute.ts +219 -0
- package/src/async/jobs/index.ts +10 -0
- package/src/async/jobs/listener.ts +135 -0
- package/src/async/jobs/namespace.ts +20 -0
- package/src/async/jobs/queue-door.ts +44 -0
- package/src/async/jobs/queue.ts +1012 -0
- package/src/async/jobs/run-script.ts +67 -0
- package/src/async/jobs/scheduler.ts +218 -0
- package/src/async/jobs/schedules-table.ts +37 -0
- package/src/async/jobs/serialize-error.ts +48 -0
- package/src/async/jobs/table.ts +191 -0
- package/src/async/pipeline/define.ts +53 -0
- package/src/async/pipeline/errors.ts +35 -0
- package/src/async/pipeline/execute.ts +53 -0
- package/src/async/pipeline/index.ts +13 -0
- package/src/async/pipeline/namespace.ts +12 -0
- package/src/async/pipeline/resume.ts +67 -0
- package/src/async/pipeline/run-steps.ts +98 -0
- package/src/async/pipeline/types.ts +57 -0
- package/src/async/state-machine/define.ts +64 -0
- package/src/async/state-machine/errors.ts +29 -0
- package/src/async/state-machine/index.ts +3 -0
- package/src/async/state-machine/namespace.ts +12 -0
- package/src/async/state-machine/transition.ts +90 -0
- package/src/async/state-machine/types.ts +37 -0
- package/src/async/steps/errors.ts +31 -0
- package/src/async/steps/graph.ts +1214 -0
- package/src/async/steps/hash.ts +40 -0
- package/src/async/steps/index.ts +7 -0
- package/src/async/steps/namespace.ts +7 -0
- package/src/async/steps/table.ts +97 -0
- package/src/async/steps/types.ts +318 -0
- package/src/audit/chain/index.ts +9 -0
- package/src/audit/chain/install.ts +113 -0
- package/src/audit/chain/retire.ts +52 -0
- package/src/audit/chain/verify.ts +257 -0
- package/src/audit/query/cursor.ts +37 -0
- package/src/audit/query/index.ts +9 -0
- package/src/audit/query/query.ts +223 -0
- package/src/audit/schema/audit-record-fields.ts +68 -0
- package/src/audit/schema/audit-record.ts +23 -0
- package/src/auth/auth/audit.ts +31 -0
- package/src/auth/auth/chain.ts +84 -0
- package/src/auth/auth/cookie.ts +136 -0
- package/src/auth/auth/email-links.ts +311 -0
- package/src/auth/auth/guard.ts +128 -0
- package/src/auth/auth/index.ts +18 -0
- package/src/auth/auth/multi-org.ts +337 -0
- package/src/auth/auth/namespace.ts +34 -0
- package/src/auth/auth/parse-expiry.ts +13 -0
- package/src/auth/auth/passwords.ts +110 -0
- package/src/auth/auth/schema.ts +110 -0
- package/src/auth/auth/sessions.ts +565 -0
- package/src/auth/auth/sign-in.ts +156 -0
- package/src/auth/auth/signing-keys.ts +69 -0
- package/src/auth/auth/types.ts +26 -0
- package/src/auth/auth/uuid.ts +10 -0
- package/src/auth/rbac/cache.ts +55 -0
- package/src/auth/rbac/check.ts +30 -0
- package/src/auth/rbac/create.ts +35 -0
- package/src/auth/rbac/index.ts +5 -0
- package/src/auth/rbac/middleware.ts +20 -0
- package/src/auth/rbac/namespace.ts +15 -0
- package/src/auth/rbac/resolve.ts +31 -0
- package/src/auth/rbac/schema.sql +31 -0
- package/src/auth/rbac/seed.ts +67 -0
- package/src/auth/rbac/tables.ts +32 -0
- package/src/auth/rbac/types.ts +65 -0
- package/src/auth/tenant/extract.ts +33 -0
- package/src/auth/tenant/in-tenant.ts +49 -0
- package/src/auth/tenant/index.ts +7 -0
- package/src/auth/tenant/namespace.ts +23 -0
- package/src/auth/tenant/propagate.ts +20 -0
- package/src/auth/tenant/rls.ts +120 -0
- package/src/auth/tenant/scope.ts +33 -0
- package/src/auth/tenant/scoped-db.ts +581 -0
- package/src/auth/tenant/types.ts +14 -0
- package/src/auth/tenant/validate.ts +27 -0
- package/src/cli/commands/add.ts +158 -0
- package/src/cli/commands/alerts.ts +98 -0
- package/src/cli/commands/check.ts +1418 -0
- package/src/cli/commands/codemod.ts +78 -0
- package/src/cli/commands/db.ts +356 -0
- package/src/cli/commands/dev.ts +101 -0
- package/src/cli/commands/generate.ts +75 -0
- package/src/cli/commands/init.ts +36 -0
- package/src/cli/commands/install.ts +28 -0
- package/src/cli/commands/lint.ts +62 -0
- package/src/cli/commands/migrate.ts +132 -0
- package/src/cli/commands/openapi.ts +63 -0
- package/src/cli/commands/remove.ts +72 -0
- package/src/cli/commands/run.ts +50 -0
- package/src/cli/commands/scope.ts +54 -0
- package/src/cli/commands/serve.ts +68 -0
- package/src/cli/commands/shard.ts +31 -0
- package/src/cli/commands/shared/config-pipework-version.ts +28 -0
- package/src/cli/commands/shared/find-config.ts +22 -0
- package/src/cli/commands/shared/load-config.ts +35 -0
- package/src/cli/commands/shared/pnpm-install.ts +95 -0
- package/src/cli/commands/shared/report-removed.ts +8 -0
- package/src/cli/commands/sync.ts +32 -0
- package/src/cli/commands/test.ts +307 -0
- package/src/cli/commands/upgrade.ts +48 -0
- package/src/cli/dev-bootstrap.ts +24 -0
- package/src/cli/errors.ts +10 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/output.ts +17 -0
- package/src/cli/port.ts +15 -0
- package/src/cli/surface-kinds.ts +20 -0
- package/src/client/index.ts +4 -0
- package/src/client/poll.ts +130 -0
- package/src/client/report-fault.ts +119 -0
- package/src/core/config/app-pool.ts +19 -0
- package/src/core/config/aws-env.ts +29 -0
- package/src/core/config/base-dir.ts +24 -0
- package/src/core/config/discover.ts +64 -0
- package/src/core/config/env-resolve.ts +87 -0
- package/src/core/config/env-types.ts +116 -0
- package/src/core/config/env.ts +55 -0
- package/src/core/config/errors.ts +9 -0
- package/src/core/config/import-config.ts +40 -0
- package/src/core/config/index.ts +8 -0
- package/src/core/config/load.ts +417 -0
- package/src/core/config/namespace.ts +14 -0
- package/src/core/config/resolve.ts +195 -0
- package/src/core/config/schema.ts +488 -0
- package/src/core/config/statement-observer.ts +48 -0
- package/src/core/config/ts-inline-hooks.ts +75 -0
- package/src/core/config/ts-register.ts +4 -0
- package/src/core/config/ts-resolve-hooks.ts +101 -0
- package/src/core/errors/base.ts +49 -0
- package/src/core/errors/http.ts +146 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/namespace.ts +31 -0
- package/src/core/lifecycle/drain.ts +33 -0
- package/src/core/lifecycle/health.ts +72 -0
- package/src/core/lifecycle/index.ts +4 -0
- package/src/core/lifecycle/namespace.ts +7 -0
- package/src/core/lifecycle/startup.ts +102 -0
- package/src/core/lifecycle/types.ts +36 -0
- package/src/core/logging/context.ts +60 -0
- package/src/core/logging/error-chain.ts +68 -0
- package/src/core/logging/index.ts +5 -0
- package/src/core/logging/logger.ts +65 -0
- package/src/core/logging/namespace.ts +16 -0
- package/src/core/logging/proxy.ts +22 -0
- package/src/core/logging/redact-match.ts +24 -0
- package/src/core/logging/types.ts +47 -0
- package/src/core/pipework.ts +566 -0
- package/src/core/runtime/broker.ts +69 -0
- package/src/core/surface/http.ts +133 -0
- package/src/core/surface/index.ts +11 -0
- package/src/core/surface/job-timeout.ts +40 -0
- package/src/core/surface/namespace.ts +13 -0
- package/src/core/surface/script.ts +33 -0
- package/src/core/surface/start.ts +142 -0
- package/src/core/surface/types.ts +225 -0
- package/src/core/surface/validate.ts +88 -0
- package/src/core/surface/worker.ts +528 -0
- package/src/core/validation/connectivity.ts +49 -0
- package/src/core/validation/domain.ts +99 -0
- package/src/core/validation/index.ts +5 -0
- package/src/core/validation/queue-tables.ts +312 -0
- package/src/core/validation/rls-role.ts +138 -0
- package/src/core/validation/startup.ts +51 -0
- package/src/core/validation/surfaces.ts +65 -0
- package/src/core/validation/types.ts +25 -0
- package/src/data/db/advisory-lock.ts +55 -0
- package/src/data/db/aggregates.ts +172 -0
- package/src/data/db/array-elements.ts +111 -0
- package/src/data/db/bulk-set.ts +53 -0
- package/src/data/db/col/index.ts +14 -0
- package/src/data/db/composed.ts +53 -0
- package/src/data/db/db.ts +276 -0
- package/src/data/db/excluded.ts +16 -0
- package/src/data/db/expressions.ts +473 -0
- package/src/data/db/filter.ts +90 -0
- package/src/data/db/function-source.ts +114 -0
- package/src/data/db/identifiers.ts +16 -0
- package/src/data/db/idx/index.ts +9 -0
- package/src/data/db/in-list.ts +49 -0
- package/src/data/db/index.ts +9 -0
- package/src/data/db/insert-split.ts +137 -0
- package/src/data/db/instrumentation.ts +82 -0
- package/src/data/db/maintenance.ts +28 -0
- package/src/data/db/namespace.ts +107 -0
- package/src/data/db/owner-labels.ts +9 -0
- package/src/data/db/pool.ts +187 -0
- package/src/data/db/query-label.ts +13 -0
- package/src/data/db/query-watch.ts +116 -0
- package/src/data/db/raw-client-warning.ts +37 -0
- package/src/data/db/readonly.ts +23 -0
- package/src/data/db/reservation.ts +151 -0
- package/src/data/db/schema-features.ts +14 -0
- package/src/data/db/serializable.ts +83 -0
- package/src/data/db/sql.ts +1 -0
- package/src/data/db/timeouts.ts +76 -0
- package/src/data/db/types.ts +18 -0
- package/src/data/db/unbound.ts +33 -0
- package/src/data/db/values.ts +29 -0
- package/src/data/domain/brand.ts +15 -0
- package/src/data/domain/build-factory.ts +193 -0
- package/src/data/domain/build-schema.ts +161 -0
- package/src/data/domain/build-table.ts +355 -0
- package/src/data/domain/define.ts +239 -0
- package/src/data/domain/external-registry.ts +34 -0
- package/src/data/domain/external.ts +77 -0
- package/src/data/domain/field.ts +279 -0
- package/src/data/domain/index.ts +13 -0
- package/src/data/domain/naming.ts +6 -0
- package/src/data/domain/projections.ts +57 -0
- package/src/data/domain/trace-safe-projection.ts +31 -0
- package/src/data/domain/traits.ts +108 -0
- package/src/data/domain/types.ts +289 -0
- package/src/data/jsonb/composed.ts +18 -0
- package/src/data/jsonb/index.ts +1 -0
- package/src/data/jsonb/namespace.ts +30 -0
- package/src/data/jsonb/query.ts +86 -0
- package/src/data/migrate/append-only-generate.ts +47 -0
- package/src/data/migrate/applied.ts +47 -0
- package/src/data/migrate/apply.ts +309 -0
- package/src/data/migrate/check.ts +119 -0
- package/src/data/migrate/diff.ts +357 -0
- package/src/data/migrate/door-generate.ts +130 -0
- package/src/data/migrate/framework-rls.ts +58 -0
- package/src/data/migrate/generate.ts +201 -0
- package/src/data/migrate/history.ts +215 -0
- package/src/data/migrate/index.ts +9 -0
- package/src/data/migrate/internal-definitions.ts +175 -0
- package/src/data/migrate/post-process.ts +73 -0
- package/src/data/migrate/pragma.ts +73 -0
- package/src/data/migrate/provision-framework.ts +147 -0
- package/src/data/migrate/rls-generate.ts +61 -0
- package/src/data/migrate/run.ts +124 -0
- package/src/data/migrate/safety.ts +167 -0
- package/src/data/migrate/snapshot.ts +368 -0
- package/src/data/migrate/sql-emit.ts +392 -0
- package/src/data/migrate/statements.ts +312 -0
- package/src/data/query/alias.ts +126 -0
- package/src/data/query/batch.ts +8 -0
- package/src/data/query/cache/core/cache.ts +78 -0
- package/src/data/query/cache/core/index.ts +1 -0
- package/src/data/query/cache/core/types.ts +29 -0
- package/src/data/query/cache/readme.md +230 -0
- package/src/data/query/casing.ts +76 -0
- package/src/data/query/collation.ts +11 -0
- package/src/data/query/column-builder.ts +370 -0
- package/src/data/query/column.ts +150 -0
- package/src/data/query/entity.ts +42 -0
- package/src/data/query/errors.ts +33 -0
- package/src/data/query/index.ts +14 -0
- package/src/data/query/logger.ts +47 -0
- package/src/data/query/operations.ts +45 -0
- package/src/data/query/pg-core/alias.ts +12 -0
- package/src/data/query/pg-core/checks.ts +32 -0
- package/src/data/query/pg-core/columns/all.ts +71 -0
- package/src/data/query/pg-core/columns/bigint.ts +110 -0
- package/src/data/query/pg-core/columns/bigserial.ts +128 -0
- package/src/data/query/pg-core/columns/boolean.ts +43 -0
- package/src/data/query/pg-core/columns/char.ts +85 -0
- package/src/data/query/pg-core/columns/cidr.ts +43 -0
- package/src/data/query/pg-core/columns/common.ts +366 -0
- package/src/data/query/pg-core/columns/custom.ts +232 -0
- package/src/data/query/pg-core/columns/date.common.ts +15 -0
- package/src/data/query/pg-core/columns/date.ts +112 -0
- package/src/data/query/pg-core/columns/double-precision.ts +55 -0
- package/src/data/query/pg-core/columns/enum.ts +201 -0
- package/src/data/query/pg-core/columns/index.ts +35 -0
- package/src/data/query/pg-core/columns/inet.ts +43 -0
- package/src/data/query/pg-core/columns/int.common.ts +57 -0
- package/src/data/query/pg-core/columns/integer.ts +53 -0
- package/src/data/query/pg-core/columns/interval.ts +83 -0
- package/src/data/query/pg-core/columns/json.ts +58 -0
- package/src/data/query/pg-core/columns/jsonb.ts +56 -0
- package/src/data/query/pg-core/columns/line.ts +117 -0
- package/src/data/query/pg-core/columns/macaddr.ts +43 -0
- package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
- package/src/data/query/pg-core/columns/numeric.ts +227 -0
- package/src/data/query/pg-core/columns/point.ts +127 -0
- package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
- package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
- package/src/data/query/pg-core/columns/real.ts +58 -0
- package/src/data/query/pg-core/columns/serial.ts +55 -0
- package/src/data/query/pg-core/columns/smallint.ts +53 -0
- package/src/data/query/pg-core/columns/smallserial.ts +60 -0
- package/src/data/query/pg-core/columns/text.ts +67 -0
- package/src/data/query/pg-core/columns/time.ts +72 -0
- package/src/data/query/pg-core/columns/timestamp.ts +163 -0
- package/src/data/query/pg-core/columns/uuid.ts +51 -0
- package/src/data/query/pg-core/columns/varchar.ts +89 -0
- package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
- package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
- package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
- package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
- package/src/data/query/pg-core/db.ts +674 -0
- package/src/data/query/pg-core/dialect.ts +1404 -0
- package/src/data/query/pg-core/expressions.ts +25 -0
- package/src/data/query/pg-core/foreign-keys.ts +130 -0
- package/src/data/query/pg-core/index.ts +21 -0
- package/src/data/query/pg-core/indexes.ts +276 -0
- package/src/data/query/pg-core/policies.ts +56 -0
- package/src/data/query/pg-core/primary-keys.ts +62 -0
- package/src/data/query/pg-core/query-builders/count.ts +86 -0
- package/src/data/query/pg-core/query-builders/delete.ts +293 -0
- package/src/data/query/pg-core/query-builders/index.ts +7 -0
- package/src/data/query/pg-core/query-builders/insert.ts +452 -0
- package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
- package/src/data/query/pg-core/query-builders/query.ts +157 -0
- package/src/data/query/pg-core/query-builders/raw.ts +49 -0
- package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
- package/src/data/query/pg-core/query-builders/select.ts +1346 -0
- package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
- package/src/data/query/pg-core/query-builders/update.ts +626 -0
- package/src/data/query/pg-core/roles.ts +41 -0
- package/src/data/query/pg-core/schema.ts +72 -0
- package/src/data/query/pg-core/sequence.ts +41 -0
- package/src/data/query/pg-core/session.ts +292 -0
- package/src/data/query/pg-core/subquery.ts +46 -0
- package/src/data/query/pg-core/table.ts +252 -0
- package/src/data/query/pg-core/unique-constraint.ts +73 -0
- package/src/data/query/pg-core/utils/array.ts +95 -0
- package/src/data/query/pg-core/utils/index.ts +1 -0
- package/src/data/query/pg-core/utils.ts +104 -0
- package/src/data/query/pg-core/view-base.ts +14 -0
- package/src/data/query/pg-core/view-common.ts +1 -0
- package/src/data/query/pg-core/view.ts +435 -0
- package/src/data/query/pg-core/with-builder.ts +41 -0
- package/src/data/query/postgres-js/README.md +46 -0
- package/src/data/query/postgres-js/array-parser.ts +45 -0
- package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
- package/src/data/query/postgres-js/driver.ts +163 -0
- package/src/data/query/postgres-js/index.ts +2 -0
- package/src/data/query/postgres-js/session.ts +234 -0
- package/src/data/query/primary-key.ts +11 -0
- package/src/data/query/query-builders/query-builder.ts +19 -0
- package/src/data/query/query-builders/select.types.ts +175 -0
- package/src/data/query/query-promise.ts +35 -0
- package/src/data/query/relations.ts +725 -0
- package/src/data/query/runnable-query.ts +12 -0
- package/src/data/query/selection-proxy.ts +121 -0
- package/src/data/query/session.ts +8 -0
- package/src/data/query/sql/expressions/conditions.ts +741 -0
- package/src/data/query/sql/expressions/index.ts +2 -0
- package/src/data/query/sql/expressions/select.ts +45 -0
- package/src/data/query/sql/functions/aggregate.ts +129 -0
- package/src/data/query/sql/functions/index.ts +2 -0
- package/src/data/query/sql/functions/vector.ts +178 -0
- package/src/data/query/sql/index.ts +3 -0
- package/src/data/query/sql/sql.ts +794 -0
- package/src/data/query/subquery.ts +53 -0
- package/src/data/query/table.ts +208 -0
- package/src/data/query/table.utils.ts +2 -0
- package/src/data/query/tracing-utils.ts +3 -0
- package/src/data/query/tracing.ts +11 -0
- package/src/data/query/utils.ts +357 -0
- package/src/data/query/version.ts +4 -0
- package/src/data/query/view-common.ts +1 -0
- package/src/data/schema/branded.ts +13 -0
- package/src/data/schema/generated/coerce.ts +5 -0
- package/src/data/schema/generated/combinator.ts +6 -0
- package/src/data/schema/generated/encoding.ts +5 -0
- package/src/data/schema/generated/error.ts +6 -0
- package/src/data/schema/generated/format.ts +5 -0
- package/src/data/schema/generated/type-constructor.ts +10 -0
- package/src/data/schema/generated/type.ts +6 -0
- package/src/data/schema/index.ts +6 -0
- package/src/data/schema/json-schema.ts +33 -0
- package/src/data/schema/namespace.ts +55 -0
- package/src/data/schema/parse.ts +39 -0
- package/src/data/schema/types.ts +39 -0
- package/src/data/temporal/append-only.ts +134 -0
- package/src/data/temporal/close.ts +27 -0
- package/src/data/temporal/columns.ts +10 -0
- package/src/data/temporal/definition-queries.ts +70 -0
- package/src/data/temporal/errors.ts +10 -0
- package/src/data/temporal/get-current.ts +22 -0
- package/src/data/temporal/index.ts +9 -0
- package/src/data/temporal/namespace.ts +36 -0
- package/src/data/temporal/query.ts +25 -0
- package/src/data/temporal/resolve.ts +17 -0
- package/src/data/temporal/revise.ts +67 -0
- package/src/data/temporal/types.ts +22 -0
- package/src/data/valve/artifact.ts +33 -0
- package/src/data/valve/contract-diff.ts +21 -0
- package/src/data/valve/cross.ts +57 -0
- package/src/data/valve/crossing-ledger.ts +50 -0
- package/src/data/valve/decision.ts +44 -0
- package/src/data/valve/durable-crossing.ts +52 -0
- package/src/data/valve/execute-crossing.ts +77 -0
- package/src/data/valve/faucet.ts +56 -0
- package/src/data/valve/gate.ts +114 -0
- package/src/data/valve/graph.ts +77 -0
- package/src/data/valve/index.ts +53 -0
- package/src/data/valve/namespace.ts +57 -0
- package/src/data/valve/registry.ts +50 -0
- package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
- package/src/data/valve/schema/valve-crossing.ts +9 -0
- package/src/data/valve/supply.ts +154 -0
- package/src/data/valve/types.ts +143 -0
- package/src/data/vector/columns.ts +27 -0
- package/src/data/vector/index.ts +4 -0
- package/src/data/vector/namespace.ts +33 -0
- package/src/data/vector/query.ts +85 -0
- package/src/data/vector/validate.ts +16 -0
- package/src/data/write/audit/actor.ts +15 -0
- package/src/data/write/audit/audit-columns.ts +18 -0
- package/src/data/write/audit/audit-select.ts +111 -0
- package/src/data/write/audit/bound-transaction.ts +14 -0
- package/src/data/write/audit/cte-compile.ts +32 -0
- package/src/data/write/audit/decode-returning.ts +47 -0
- package/src/data/write/audit/final-select.ts +43 -0
- package/src/data/write/audit/infra-audit.ts +36 -0
- package/src/data/write/audit/primary-key.ts +65 -0
- package/src/data/write/audit/projection.ts +41 -0
- package/src/data/write/audit/read-touches.ts +86 -0
- package/src/data/write/audit/touch-match.ts +78 -0
- package/src/data/write/audit/verb-delete.ts +62 -0
- package/src/data/write/audit/verb-insert.ts +36 -0
- package/src/data/write/audit/verb-select.ts +47 -0
- package/src/data/write/audit/verb-update.ts +60 -0
- package/src/data/write/audit/verb-upsert.ts +104 -0
- package/src/data/write/conflict.ts +43 -0
- package/src/data/write/delete.ts +38 -0
- package/src/data/write/execute.ts +243 -0
- package/src/data/write/index.ts +11 -0
- package/src/data/write/insert.ts +63 -0
- package/src/data/write/state.ts +43 -0
- package/src/data/write/update.ts +54 -0
- package/src/data/write/upsert.ts +58 -0
- package/src/data/write/values.ts +19 -0
- package/src/index.ts +230 -0
- package/src/infra/access-links/code.ts +70 -0
- package/src/infra/access-links/handle.ts +892 -0
- package/src/infra/access-links/index.ts +9 -0
- package/src/infra/access-links/namespace.ts +30 -0
- package/src/infra/access-links/person-check.ts +177 -0
- package/src/infra/access-links/schema.ts +71 -0
- package/src/infra/alerts/async-sources.ts +231 -0
- package/src/infra/alerts/audit.ts +36 -0
- package/src/infra/alerts/beat.ts +123 -0
- package/src/infra/alerts/client-faults.ts +111 -0
- package/src/infra/alerts/faults.ts +156 -0
- package/src/infra/alerts/handle.ts +778 -0
- package/src/infra/alerts/http.ts +192 -0
- package/src/infra/alerts/index.ts +12 -0
- package/src/infra/alerts/infra-sources.ts +180 -0
- package/src/infra/alerts/namespace.ts +81 -0
- package/src/infra/alerts/redact.ts +21 -0
- package/src/infra/alerts/request-faults.ts +41 -0
- package/src/infra/alerts/schema.ts +124 -0
- package/src/infra/alerts/sinks.ts +125 -0
- package/src/infra/alerts/source-options.ts +26 -0
- package/src/infra/alerts/sources.ts +503 -0
- package/src/infra/alerts/subsystem-sources.ts +137 -0
- package/src/infra/audit/emit.ts +87 -0
- package/src/infra/audit/index.ts +2 -0
- package/src/infra/audit/namespace.ts +22 -0
- package/src/infra/audit/schema.sql +35 -0
- package/src/infra/audit/types.ts +18 -0
- package/src/infra/aws/credentials.ts +147 -0
- package/src/infra/aws/sigv4.ts +140 -0
- package/src/infra/cache/create.ts +58 -0
- package/src/infra/cache/entry.ts +81 -0
- package/src/infra/cache/index.ts +4 -0
- package/src/infra/cache/namespace.ts +10 -0
- package/src/infra/cache/tenant.ts +93 -0
- package/src/infra/cache/types.ts +24 -0
- package/src/infra/crawl/audit.ts +35 -0
- package/src/infra/crawl/errors.ts +25 -0
- package/src/infra/crawl/handle.ts +693 -0
- package/src/infra/crawl/index.ts +9 -0
- package/src/infra/crawl/links.ts +160 -0
- package/src/infra/crawl/namespace.ts +36 -0
- package/src/infra/crawl/robots.ts +100 -0
- package/src/infra/crawl/schema.ts +89 -0
- package/src/infra/credit-line/handle.ts +883 -0
- package/src/infra/credit-line/index.ts +6 -0
- package/src/infra/credit-line/namespace.ts +7 -0
- package/src/infra/credit-line/schema.ts +96 -0
- package/src/infra/credits/handle.ts +706 -0
- package/src/infra/credits/index.ts +5 -0
- package/src/infra/credits/namespace.ts +7 -0
- package/src/infra/credits/schema.ts +83 -0
- package/src/infra/email/audit.ts +36 -0
- package/src/infra/email/inbound.ts +622 -0
- package/src/infra/email/index.ts +11 -0
- package/src/infra/email/mail.ts +517 -0
- package/src/infra/email/mime.ts +447 -0
- package/src/infra/email/namespace.ts +22 -0
- package/src/infra/email/providers.ts +111 -0
- package/src/infra/email/schema.ts +96 -0
- package/src/infra/email/sns.ts +200 -0
- package/src/infra/email/unsubscribe.ts +44 -0
- package/src/infra/fetch/address.ts +88 -0
- package/src/infra/fetch/connect-retry.ts +87 -0
- package/src/infra/fetch/handle.ts +329 -0
- package/src/infra/fetch/index.ts +7 -0
- package/src/infra/fetch/namespace.ts +70 -0
- package/src/infra/idempotency/index.ts +3 -0
- package/src/infra/idempotency/middleware.ts +87 -0
- package/src/infra/idempotency/store.ts +92 -0
- package/src/infra/idempotency/types.ts +30 -0
- package/src/infra/join-requests/handle.ts +312 -0
- package/src/infra/join-requests/index.ts +2 -0
- package/src/infra/join-requests/namespace.ts +7 -0
- package/src/infra/join-requests/schema.ts +33 -0
- package/src/infra/outbox/core.ts +165 -0
- package/src/infra/quota/errors.ts +32 -0
- package/src/infra/quota/handle.ts +215 -0
- package/src/infra/quota/index.ts +5 -0
- package/src/infra/quota/namespace.ts +7 -0
- package/src/infra/quota/schema.ts +15 -0
- package/src/infra/storage/backend.ts +34 -0
- package/src/infra/storage/handle.ts +513 -0
- package/src/infra/storage/index.ts +9 -0
- package/src/infra/storage/local.ts +104 -0
- package/src/infra/storage/namespace.ts +13 -0
- package/src/infra/storage/s3.ts +257 -0
- package/src/infra/storage/schema.ts +24 -0
- package/src/infra/support/handle.ts +854 -0
- package/src/infra/support/http.ts +229 -0
- package/src/infra/support/index.ts +7 -0
- package/src/infra/support/namespace.ts +12 -0
- package/src/infra/support/schema.ts +96 -0
- package/src/infra/webhook/inbound.ts +45 -0
- package/src/infra/webhook/index.ts +5 -0
- package/src/infra/webhook/namespace.ts +24 -0
- package/src/infra/webhook/outbound.ts +315 -0
- package/src/infra/webhook/sign.ts +32 -0
- package/src/infra/webhook/verify.ts +82 -0
- package/src/invariants/assert.ts +26 -0
- package/src/invariants/index.ts +1 -0
- package/src/lint/config.ts +56 -0
- package/src/lint/doctrine.ts +163 -0
- package/src/lint/include.ts +97 -0
- package/src/lint/index.ts +3 -0
- package/src/lint/preset.ts +10 -0
- package/src/lint/resolver.ts +218 -0
- package/src/lint/rules/no-reexports.ts +46 -0
- package/src/request/behavior/audited.ts +85 -0
- package/src/request/behavior/cached.ts +62 -0
- package/src/request/behavior/compose.ts +28 -0
- package/src/request/behavior/index.ts +5 -0
- package/src/request/behavior/namespace.ts +16 -0
- package/src/request/behavior/types.ts +26 -0
- package/src/request/behavior/versioned.ts +54 -0
- package/src/request/context/create.ts +103 -0
- package/src/request/context/guard.ts +49 -0
- package/src/request/context/index.ts +4 -0
- package/src/request/context/run-in-transaction.ts +200 -0
- package/src/request/context/store.ts +28 -0
- package/src/request/context/types.ts +50 -0
- package/src/request/di/builder.ts +535 -0
- package/src/request/di/index.ts +8 -0
- package/src/request/di/resolve.ts +271 -0
- package/src/request/di/types.ts +262 -0
- package/src/request/fitting/namespace.ts +7 -0
- package/src/request/fixture/batch.ts +71 -0
- package/src/request/fixture/builder.ts +26 -0
- package/src/request/fixture/errors.ts +10 -0
- package/src/request/fixture/index.ts +8 -0
- package/src/request/fixture/namespace.ts +23 -0
- package/src/request/fixture/paginate.ts +44 -0
- package/src/request/fixture/to-handlers.ts +260 -0
- package/src/request/fixture/types.ts +79 -0
- package/src/request/flow/act-as.ts +78 -0
- package/src/request/flow/namespace.ts +17 -0
- package/src/request/http/error-step.ts +42 -0
- package/src/request/http/file.ts +141 -0
- package/src/request/http/index.ts +4 -0
- package/src/request/http/middleware.ts +141 -0
- package/src/request/http/namespace.ts +5 -0
- package/src/request/http/rate-limit-classes.ts +170 -0
- package/src/request/http/response.ts +82 -0
- package/src/request/http/server.ts +916 -0
- package/src/request/http/sniff.ts +261 -0
- package/src/request/http/types.ts +250 -0
- package/src/request/http/upload.ts +284 -0
- package/src/request/internal/text.ts +6 -0
- package/src/request/openapi/generate.ts +205 -0
- package/src/request/openapi/index.ts +2 -0
- package/src/request/openapi/namespace.ts +7 -0
- package/src/test/auth.ts +13 -0
- package/src/test/auto-setup.ts +45 -0
- package/src/test/context.ts +91 -0
- package/src/test/crawl-fixture.ts +157 -0
- package/src/test/discover-fork.ts +17 -0
- package/src/test/fingerprint.ts +135 -0
- package/src/test/globals.d.ts +7 -0
- package/src/test/index.ts +19 -0
- package/src/test/namespace.ts +87 -0
- package/src/test/plugin.ts +189 -0
- package/src/test/progress.ts +185 -0
- package/src/test/reap-after-run.ts +28 -0
- package/src/test/reap.ts +286 -0
- package/src/test/reset.ts +253 -0
- package/src/test/savepoints.ts +172 -0
- package/src/test/setup.ts +1422 -0
- package/src/test/system-channel.ts +50 -0
- package/src/test/vitest-plugin.ts +142 -0
- package/src/test/vitest-workspace.ts +507 -0
- package/src/test/vitest.ts +333 -0
- package/src/test/watch.ts +105 -0
- package/src/trace/capture/bounded-serializer.ts +146 -0
- package/src/trace/cardinality/analyze.ts +173 -0
- package/src/trace/cardinality/branch-context.ts +214 -0
- package/src/trace/cardinality/check.ts +33 -0
- package/src/trace/cardinality/classify-edge.ts +64 -0
- package/src/trace/cardinality/structural-max.ts +107 -0
- package/src/trace/check/coverage-check.ts +188 -0
- package/src/trace/check/flow-check.ts +103 -0
- package/src/trace/check/flow-step-site.ts +234 -0
- package/src/trace/check/io-pattern.ts +116 -0
- package/src/trace/codemod/add-cardinality.ts +152 -0
- package/src/trace/codemod/run.ts +80 -0
- package/src/trace/entry/child-trace.ts +21 -0
- package/src/trace/entry/close-trace.ts +87 -0
- package/src/trace/entry/finalize-trace.ts +28 -0
- package/src/trace/entry/open-trace.ts +138 -0
- package/src/trace/entry/parent-trace.ts +20 -0
- package/src/trace/entry/parse-traceparent.ts +39 -0
- package/src/trace/entry/process-start.ts +38 -0
- package/src/trace/entry/trace-context.ts +26 -0
- package/src/trace/entry/tracing-enabled.ts +10 -0
- package/src/trace/external/record.ts +195 -0
- package/src/trace/external/report.ts +66 -0
- package/src/trace/namespace.ts +19 -0
- package/src/trace/partition/day.ts +47 -0
- package/src/trace/partition/index.ts +14 -0
- package/src/trace/partition/partition-name.ts +23 -0
- package/src/trace/partition/plan.ts +82 -0
- package/src/trace/partition/schedule.ts +26 -0
- package/src/trace/partition/startup-hook.ts +39 -0
- package/src/trace/partition/sync.ts +154 -0
- package/src/trace/partition/tables.ts +53 -0
- package/src/trace/read/tree.ts +83 -0
- package/src/trace/retain/copy-on-error.ts +76 -0
- package/src/trace/runtime/expire-stale-traces.ts +54 -0
- package/src/trace/runtime/flow-step.ts +147 -0
- package/src/trace/runtime/flush-steps.ts +31 -0
- package/src/trace/runtime/pending-writes.ts +54 -0
- package/src/trace/runtime/step-buffer.ts +298 -0
- package/src/trace/runtime/step-error.ts +21 -0
- package/src/trace/runtime/step-meta.ts +55 -0
- package/src/trace/runtime/thenable.ts +12 -0
- package/src/trace/runtime/truncate-trace.ts +62 -0
- package/src/trace/schema/trace-fields.ts +52 -0
- package/src/trace/schema/trace-retained.ts +22 -0
- package/src/trace/schema/trace-step-fields.ts +44 -0
- package/src/trace/schema/trace-step-retained.ts +25 -0
- package/src/trace/schema/trace-step.ts +18 -0
- package/src/trace/schema/trace.ts +12 -0
- package/src/trace/transformer/apply.ts +79 -0
- package/src/trace/transformer/build-meta-literal.ts +30 -0
- package/src/trace/transformer/find-flow-step-sites.ts +85 -0
- package/src/trace/transformer/inject-flow-step-import.ts +39 -0
- package/src/trace/transformer/transform-error.ts +14 -0
- package/src/trace/transformer/transform-flow-steps.ts +123 -0
- package/src/trace/transformer/wrap-function-declaration.ts +62 -0
- package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
- package/src/workspace/affected.ts +110 -0
- package/src/workspace/changed-files.ts +163 -0
- package/src/workspace/check-deps.ts +213 -0
- package/src/workspace/checksum.ts +20 -0
- package/src/workspace/dependency-graph.ts +67 -0
- package/src/workspace/detect-pipework-copies.ts +93 -0
- package/src/workspace/discover.ts +206 -0
- package/src/workspace/generate.ts +401 -0
- package/src/workspace/index.ts +7 -0
- package/src/workspace/module-imports-pipework.ts +39 -0
- package/src/workspace/module-package-name.ts +12 -0
- package/src/workspace/scrub-git-env.ts +23 -0
- package/src/workspace/source-paths.ts +36 -0
- package/src/workspace/sync.ts +182 -0
- package/dist/REFERENCE.md +0 -1477
- package/dist/infra/access-links/rate-limit.d.ts +0 -10
- package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
- package/dist/infra/access-links/rate-limit.js +0 -28
- package/dist/infra/access-links/rate-limit.js.map +0 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AuthStrategy, AuthRequest, AuthContext } from './types.js'
|
|
2
|
+
import { UnauthorizedError, isPipeworkError } from '../../core/errors/index.js'
|
|
3
|
+
|
|
4
|
+
/** Post-authentication hook — enrich the resolved auth with additional data (roles, permissions, etc.). */
|
|
5
|
+
export interface AuthEnricher<TAuth> {
|
|
6
|
+
readonly name: string
|
|
7
|
+
enrich(auth: TAuth): Promise<TAuth>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Resolves tenant ID from auth context — called after authentication in the chain. */
|
|
11
|
+
export interface TenantResolver<TAuth> {
|
|
12
|
+
resolve(auth: TAuth): string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Configuration for the auth chain — strategies, enrichers, tenant resolver. */
|
|
16
|
+
export interface AuthChainConfig<TAuth> {
|
|
17
|
+
readonly strategies: readonly AuthStrategy<TAuth>[]
|
|
18
|
+
readonly enrichers?: readonly AuthEnricher<TAuth>[]
|
|
19
|
+
readonly tenantResolver?: TenantResolver<TAuth>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Result of running the auth chain — resolved auth and tenant (both nullable for public routes). */
|
|
23
|
+
export interface AuthChainResult<TAuth> {
|
|
24
|
+
readonly auth: TAuth | null
|
|
25
|
+
readonly tenant: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Executes the auth chain: tries strategies in order, enriches, resolves tenant. */
|
|
29
|
+
export async function runAuthChain<TAuth>(
|
|
30
|
+
config: AuthChainConfig<TAuth>,
|
|
31
|
+
request: AuthRequest,
|
|
32
|
+
options: { optional: boolean },
|
|
33
|
+
context: AuthContext,
|
|
34
|
+
): Promise<AuthChainResult<TAuth>> {
|
|
35
|
+
let extracted: TAuth | null = null
|
|
36
|
+
let matchedStrategy: AuthStrategy<TAuth> | null = null
|
|
37
|
+
|
|
38
|
+
for (const strategy of config.strategies) {
|
|
39
|
+
extracted = await strategy.extract(request, context)
|
|
40
|
+
if (extracted !== null) {
|
|
41
|
+
matchedStrategy = strategy
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (extracted === null) {
|
|
47
|
+
if (options.optional) return { auth: null, tenant: null }
|
|
48
|
+
throw new UnauthorizedError()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let auth: TAuth
|
|
52
|
+
try {
|
|
53
|
+
auth = await matchedStrategy!.verify(extracted, context)
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// A PipeworkError from verify reaches the caller unchanged: UNAUTHORIZED is a refused credential
|
|
56
|
+
// (signed out on an optional route), FORBIDDEN and any other code (a 5xx from the strategy's own
|
|
57
|
+
// configuration) are not. Only a plain Error, such as a third-party strategy's, becomes a 401
|
|
58
|
+
// 'Authentication failed' without its detail.
|
|
59
|
+
if (isPipeworkError(error) && error.code === 'UNAUTHORIZED' && options.optional) return { auth: null, tenant: null }
|
|
60
|
+
if (isPipeworkError(error)) throw error
|
|
61
|
+
throw new UnauthorizedError('Authentication failed', {
|
|
62
|
+
strategy: matchedStrategy!.name,
|
|
63
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (config.enrichers) {
|
|
68
|
+
for (const enricher of config.enrichers) {
|
|
69
|
+
auth = await enricher.enrich(auth)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tenant = config.tenantResolver ? config.tenantResolver.resolve(auth) : extractTenant(auth)
|
|
74
|
+
|
|
75
|
+
return { auth, tenant }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractTenant(auth: unknown): string | null {
|
|
79
|
+
if (auth !== null && typeof auth === 'object' && 'tenantId' in auth) {
|
|
80
|
+
const tenantId = (auth as { tenantId: unknown }).tenantId
|
|
81
|
+
if (typeof tenantId === 'string') return tenantId
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { CrossSiteRequestError } from '../../core/errors/index.js'
|
|
2
|
+
|
|
3
|
+
/** Cookie configuration for token delivery — name, domain, path, secure, sameSite, httpOnly. */
|
|
4
|
+
export interface CookieConfig {
|
|
5
|
+
readonly name?: string
|
|
6
|
+
readonly secure?: 'auto' | boolean
|
|
7
|
+
readonly sameSite?: 'strict' | 'lax' | 'none'
|
|
8
|
+
readonly path?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** CookieConfig with all defaults resolved — every field is required. */
|
|
12
|
+
export interface ResolvedCookieConfig {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly secure: boolean
|
|
15
|
+
readonly sameSite: string
|
|
16
|
+
readonly path: string
|
|
17
|
+
readonly httpOnly: true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolves a partial CookieConfig into a ResolvedCookieConfig with secure defaults. */
|
|
21
|
+
export function resolveCookieConfig(
|
|
22
|
+
cookie: CookieConfig,
|
|
23
|
+
env: string,
|
|
24
|
+
defaults: { readonly name: string; readonly sameSite: 'strict' | 'lax' | 'none' } = { name: '__pipework_refresh', sameSite: 'strict' },
|
|
25
|
+
): ResolvedCookieConfig {
|
|
26
|
+
const secure = cookie.secure === 'auto' || cookie.secure === undefined
|
|
27
|
+
? env === 'production'
|
|
28
|
+
: cookie.secure
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
name: cookie.name ?? defaults.name,
|
|
32
|
+
secure,
|
|
33
|
+
sameSite: cookie.sameSite ?? defaults.sameSite,
|
|
34
|
+
path: cookie.path ?? '/',
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Builds a Set-Cookie header string from name, value, and resolved config. */
|
|
40
|
+
export function buildSetCookieHeader(resolved: ResolvedCookieConfig, value: string, maxAge: number): string {
|
|
41
|
+
const parts = [
|
|
42
|
+
`${resolved.name}=${encodeURIComponent(value)}`,
|
|
43
|
+
`HttpOnly`,
|
|
44
|
+
`Path=${resolved.path}`,
|
|
45
|
+
`Max-Age=${maxAge}`,
|
|
46
|
+
`SameSite=${capitalize(resolved.sameSite)}`,
|
|
47
|
+
]
|
|
48
|
+
if (resolved.secure) parts.push('Secure')
|
|
49
|
+
return parts.join('; ')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Builds a Set-Cookie header that expires (clears) a cookie. */
|
|
53
|
+
export function buildClearCookieHeader(resolved: ResolvedCookieConfig): string {
|
|
54
|
+
const parts = [
|
|
55
|
+
`${resolved.name}=`,
|
|
56
|
+
`HttpOnly`,
|
|
57
|
+
`Path=${resolved.path}`,
|
|
58
|
+
`Max-Age=0`,
|
|
59
|
+
`SameSite=${capitalize(resolved.sameSite)}`,
|
|
60
|
+
]
|
|
61
|
+
if (resolved.secure) parts.push('Secure')
|
|
62
|
+
return parts.join('; ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Parses a Cookie header string into a Record of key-value pairs. */
|
|
66
|
+
export function parseCookieHeader(header: string | undefined, name: string): string | undefined {
|
|
67
|
+
if (header === undefined) return undefined
|
|
68
|
+
for (const pair of header.split(';')) {
|
|
69
|
+
const trimmed = pair.trim()
|
|
70
|
+
const eqIdx = trimmed.indexOf('=')
|
|
71
|
+
if (eqIdx === -1) continue
|
|
72
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
73
|
+
if (key === name) {
|
|
74
|
+
return decodeURIComponent(trimmed.slice(eqIdx + 1).trim())
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parses every name=value pair of a Cookie header. */
|
|
81
|
+
export function parseCookies(header: string | undefined): Record<string, string> {
|
|
82
|
+
const out: Record<string, string> = {}
|
|
83
|
+
if (header === undefined) return out
|
|
84
|
+
for (const pair of header.split(';')) {
|
|
85
|
+
const trimmed = pair.trim()
|
|
86
|
+
const eqIdx = trimmed.indexOf('=')
|
|
87
|
+
if (eqIdx === -1) continue
|
|
88
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
89
|
+
if (key in out) continue
|
|
90
|
+
try {
|
|
91
|
+
out[key] = decodeURIComponent(trimmed.slice(eqIdx + 1).trim())
|
|
92
|
+
} catch {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function capitalize(s: string): string {
|
|
100
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Refuses a cookie-authenticated request a browser says came from another site (403 CROSS_SITE_REQUEST).
|
|
107
|
+
* A write (any method but GET/HEAD/OPTIONS, or none) needs `Sec-Fetch-Site` same-origin/none or a trusted
|
|
108
|
+
* or same-host `Origin`. A read also passes as a top-level navigation, or with neither `Sec-Fetch-Site`
|
|
109
|
+
* nor `Origin` (a non-browser client); `crossSiteReads: 'allow'` skips the check for reads.
|
|
110
|
+
*/
|
|
111
|
+
export function assertSameSiteRequest(
|
|
112
|
+
request: { readonly headers: Readonly<Record<string, string | undefined>>; readonly method?: string | undefined },
|
|
113
|
+
trusted: readonly string[],
|
|
114
|
+
crossSiteReads: 'refuse' | 'allow' = 'refuse',
|
|
115
|
+
): void {
|
|
116
|
+
const read = request.method !== undefined && SAFE_METHODS.has(request.method.toUpperCase())
|
|
117
|
+
if (read && crossSiteReads === 'allow') return
|
|
118
|
+
const site = request.headers['sec-fetch-site']
|
|
119
|
+
if (site === 'same-origin' || site === 'none') return
|
|
120
|
+
const origin = request.headers['origin']
|
|
121
|
+
if (read) {
|
|
122
|
+
if (request.headers['sec-fetch-mode'] === 'navigate') return
|
|
123
|
+
if (site === undefined && origin === undefined) return
|
|
124
|
+
}
|
|
125
|
+
// same-site, cross-site, any other value, or no header: only the Origin can vouch.
|
|
126
|
+
if (origin !== undefined) {
|
|
127
|
+
if (trusted.includes(origin)) return
|
|
128
|
+
try {
|
|
129
|
+
const host = request.headers['host']
|
|
130
|
+
if (host !== undefined && new URL(origin).host === host.toLowerCase()) return
|
|
131
|
+
} catch {
|
|
132
|
+
// an unparseable Origin (such as "null") is refused below
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new CrossSiteRequestError()
|
|
136
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'
|
|
2
|
+
import { sql } from '../../data/db/sql.js'
|
|
3
|
+
import type { DB } from '../../data/db/index.js'
|
|
4
|
+
import { pipeSystem } from '../../data/db/db.js'
|
|
5
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
6
|
+
import { __flowStep } from '../../trace/runtime/flow-step.js'
|
|
7
|
+
import type { Mail } from '../../infra/email/mail.js'
|
|
8
|
+
import type { Passwords } from './passwords.js'
|
|
9
|
+
import type { Sessions } from './sessions.js'
|
|
10
|
+
import { auditUnauthenticated } from './audit.js'
|
|
11
|
+
import { refuseScopedHandle } from '../tenant/scoped-db.js'
|
|
12
|
+
|
|
13
|
+
export type EmailLinkPurpose = 'reset' | 'verify'
|
|
14
|
+
|
|
15
|
+
export interface EmailLinkPurposeConfig {
|
|
16
|
+
/** The name of the app's mail template; it reads `data.url`. */
|
|
17
|
+
readonly template: string
|
|
18
|
+
readonly expiresInSeconds: number
|
|
19
|
+
/** The link the person opens, built from the token. */
|
|
20
|
+
readonly url: (token: string) => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RequestLimit {
|
|
24
|
+
readonly max: number
|
|
25
|
+
readonly windowSeconds: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EmailLinksConfig {
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any app's templates
|
|
30
|
+
readonly mail: Mail<any>
|
|
31
|
+
readonly passwords: Passwords
|
|
32
|
+
/** When set, a completed reset revokes the user's refresh tokens and bumps their auth version. */
|
|
33
|
+
readonly sessions?: Pick<Sessions, 'revokeAll' | 'bumpVersion'>
|
|
34
|
+
/** Keys the token. At least 32 bytes; without it a copy of the database cannot make a token. */
|
|
35
|
+
readonly secret: string
|
|
36
|
+
/** Finds the user for an address (given trimmed, NFC and lower-cased), or null. Runs in the request's transaction. */
|
|
37
|
+
readonly lookup: (db: DB, address: string) => Promise<{ userId: string } | null>
|
|
38
|
+
readonly purposes: { readonly reset?: EmailLinkPurposeConfig; readonly verify?: EmailLinkPurposeConfig }
|
|
39
|
+
/** Defaults: 5 per address and 20 per client within 3600 seconds. */
|
|
40
|
+
readonly limits?: { readonly perAddress?: RequestLimit; readonly perClient?: RequestLimit }
|
|
41
|
+
/** Every `request` takes at least this long. Default 250. */
|
|
42
|
+
readonly minResponseMs?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The app's data for a message: an object, or a function called for a known, within-limit address in the request's transaction. */
|
|
46
|
+
export type EmailLinkData =
|
|
47
|
+
| Readonly<Record<string, unknown>>
|
|
48
|
+
| ((db: DB, user: { userId: string; address: string }) => Promise<Record<string, unknown>>)
|
|
49
|
+
|
|
50
|
+
export interface EmailLinkRequestInput {
|
|
51
|
+
readonly purpose: EmailLinkPurpose
|
|
52
|
+
readonly address: string
|
|
53
|
+
readonly client?: string
|
|
54
|
+
/** Stored with the message and given to its template beside `url`, which is reserved for the link. Not secret. */
|
|
55
|
+
readonly data?: EmailLinkData
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type EmailLinkRequestResult =
|
|
59
|
+
| { ok: true; limited: false }
|
|
60
|
+
| { ok: true; limited: true; retryAfterSeconds: number }
|
|
61
|
+
|
|
62
|
+
export type EmailLinkState = 'active' | 'used' | 'expired' | 'ended' | 'unknown'
|
|
63
|
+
export type EmailLinkRefusal = 'unknown' | 'used' | 'expired' | 'ended' | 'wrong-purpose'
|
|
64
|
+
|
|
65
|
+
export interface EmailLinkCheck {
|
|
66
|
+
state: EmailLinkState
|
|
67
|
+
purpose?: EmailLinkPurpose
|
|
68
|
+
userId?: string
|
|
69
|
+
/** When the link stops working (the row's `expires_at`); absent only for an unknown token. */
|
|
70
|
+
expiresAt?: Date
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A link `liveFor` found: usable now (not used, not ended, not expired). */
|
|
74
|
+
export interface LiveEmailLink {
|
|
75
|
+
readonly id: string
|
|
76
|
+
/** The token `check` and `use` take. */
|
|
77
|
+
readonly token: string
|
|
78
|
+
/** The link the person opens, as the purpose builds it from the token. */
|
|
79
|
+
readonly url: string
|
|
80
|
+
readonly expiresAt: Date
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface EmailLinks {
|
|
84
|
+
/**
|
|
85
|
+
* Asks for a link. Answers `{ ok: true, limited: false }` for a known address and an unknown one alike, and
|
|
86
|
+
* `{ ok: true, limited: true, retryAfterSeconds }` once over a limit (the longer wait of the limits exceeded),
|
|
87
|
+
* again whether or not the address exists; it takes at least `minResponseMs` for each. Only a known,
|
|
88
|
+
* within-limit address gets a message. `data` (an object, or a function called only for that address) goes
|
|
89
|
+
* with the message; a `url` key in it throws.
|
|
90
|
+
* Runs in `db` (the caller's transaction): a rollback drops the link and the message. The limit counts
|
|
91
|
+
* are kept on `pipe.system()`, so they survive a rollback. Needs an active Flow.
|
|
92
|
+
*/
|
|
93
|
+
request(db: DB, input: EmailLinkRequestInput): Promise<EmailLinkRequestResult>
|
|
94
|
+
/** For tests and support tools: the address's live links of `purpose` (given in any case), oldest first, with the token each was mailed under. */
|
|
95
|
+
liveFor(db: DB, address: string, purpose: EmailLinkPurpose): Promise<readonly LiveEmailLink[]>
|
|
96
|
+
/** The state of a link, without using it. */
|
|
97
|
+
check(db: DB, token: string): Promise<EmailLinkCheck>
|
|
98
|
+
/** Uses a link of `purpose` in one atomic statement: of two concurrent uses exactly one succeeds. */
|
|
99
|
+
use(db: DB, token: string, purpose: EmailLinkPurpose): Promise<
|
|
100
|
+
{ ok: true; userId: string; address: string } | { ok: false; reason: EmailLinkRefusal }>
|
|
101
|
+
/**
|
|
102
|
+
* Uses a reset link, hashes the new password, calls `store(db, userId, hash)` (the app keeps the hash),
|
|
103
|
+
* ends the user's other reset links and, when `sessions` is configured, revokes all their refresh tokens
|
|
104
|
+
* and bumps their auth version. A link that cannot be used changes nothing. The caller starts the new session.
|
|
105
|
+
*/
|
|
106
|
+
completeReset(db: DB, token: string, newPassword: string, options: {
|
|
107
|
+
store: (db: DB, userId: string, passwordHash: string) => Promise<void>
|
|
108
|
+
}): Promise<{ ok: true; userId: string } | { ok: false; reason: EmailLinkRefusal }>
|
|
109
|
+
/** Ends the user's outstanding reset links (call it when a password changes). */
|
|
110
|
+
endResets(db: DB, userId: string): Promise<void>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const LATE_NAME = 'pipework.emailLink'
|
|
114
|
+
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex')
|
|
115
|
+
const normalize = (address: string): string => address.normalize('NFC').trim().toLowerCase()
|
|
116
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Creates emailed single-use links that prove control of an address: password reset and address
|
|
120
|
+
* verification. Needs blueprint `emailLinks: {}` (tables `pipework_email_links`, `pipework_email_link_requests`)
|
|
121
|
+
* and `email: {}`; the app's mail module must run `mail.jobs`.
|
|
122
|
+
*
|
|
123
|
+
* The token is `base64url(HMAC-SHA256(secret, id || nonce))`. The link table holds only its sha256, and the
|
|
124
|
+
* stored message holds only a reference: the url is computed when the message is delivered, so no copy of the
|
|
125
|
+
* database holds a token or can make one without `secret`.
|
|
126
|
+
*
|
|
127
|
+
* Requesting, using and completing a reset are flow steps (`pipework/auth:email-link-request`,
|
|
128
|
+
* `pipework/auth:email-link-use`) and are audited (`PasswordReset:requested`, `PasswordReset:completed`,
|
|
129
|
+
* `PasswordReset:link-used`, `EmailAddress:verification-requested`, `EmailAddress:verified`; no tenant), never with the token,
|
|
130
|
+
* the password or an address that matched no user.
|
|
131
|
+
*/
|
|
132
|
+
export function createEmailLinks(config: EmailLinksConfig): EmailLinks {
|
|
133
|
+
if (Buffer.byteLength(config.secret, 'utf8') < 32) {
|
|
134
|
+
throw new Error('[pipework] auth.emailLinks: "secret" must be at least 32 bytes.')
|
|
135
|
+
}
|
|
136
|
+
const perAddress = config.limits?.perAddress ?? { max: 5, windowSeconds: 3600 }
|
|
137
|
+
const perClient = config.limits?.perClient ?? { max: 20, windowSeconds: 3600 }
|
|
138
|
+
const minResponseMs = config.minResponseMs ?? 250
|
|
139
|
+
|
|
140
|
+
const tokenFor = (id: string, nonce: string): string =>
|
|
141
|
+
createHmac('sha256', config.secret).update(id).update('\0').update(nonce).digest('base64url')
|
|
142
|
+
|
|
143
|
+
function purposeConfig(purpose: EmailLinkPurpose): EmailLinkPurposeConfig {
|
|
144
|
+
const found = config.purposes[purpose]
|
|
145
|
+
if (found === undefined) throw new Error(`[pipework] auth.emailLinks: no "${purpose}" purpose is configured.`)
|
|
146
|
+
return found
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
config.mail.registerLateData(LATE_NAME, async (db, ref) => {
|
|
150
|
+
const row = (await db.execute(sql`SELECT purpose, nonce FROM pipework_email_links WHERE id = ${ref}::uuid`))[0]
|
|
151
|
+
if (row === undefined) throw new Error('[pipework] auth.emailLinks: the link behind a message no longer exists.')
|
|
152
|
+
return { url: purposeConfig(row['purpose'] as EmailLinkPurpose).url(tokenFor(ref, row['nonce'] as string)) }
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
function assertNoUrl(data: Readonly<Record<string, unknown>>): void {
|
|
156
|
+
if (Object.hasOwn(data, 'url')) {
|
|
157
|
+
throw new Error('[pipework] auth.emailLinks: request "data" may not have a "url" key; "url" is the link, added when the message is delivered.')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Counts the request against a key and says whether it is over the limit. Atomic per key.
|
|
162
|
+
async function countRequest(db: DB, key: string, limit: RequestLimit): Promise<{ over: boolean; retryAfterSeconds: number }> {
|
|
163
|
+
const rows = await db.execute(sql`
|
|
164
|
+
INSERT INTO pipework_email_link_requests AS r (key, count, window_start)
|
|
165
|
+
VALUES (${key}, 1, now())
|
|
166
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
167
|
+
count = CASE WHEN r.window_start < now() - ${limit.windowSeconds}::int * interval '1 second' THEN 1 ELSE r.count + 1 END,
|
|
168
|
+
window_start = CASE WHEN r.window_start < now() - ${limit.windowSeconds}::int * interval '1 second' THEN now() ELSE r.window_start END
|
|
169
|
+
RETURNING count,
|
|
170
|
+
ceil(extract(epoch FROM (window_start + ${limit.windowSeconds}::int * interval '1 second' - now())))::int AS wait`)
|
|
171
|
+
return { over: Number(rows[0]!['count']) > limit.max, retryAfterSeconds: Math.max(1, Number(rows[0]!['wait'])) }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const requestStep = __flowStep({ ref: 'pipework/auth:email-link-request' }, async (
|
|
175
|
+
db: DB, input: EmailLinkRequestInput,
|
|
176
|
+
): Promise<EmailLinkRequestResult> => {
|
|
177
|
+
const purposeCfg = purposeConfig(input.purpose)
|
|
178
|
+
const system = pipeSystem()
|
|
179
|
+
const address = normalize(input.address)
|
|
180
|
+
const counts = [await countRequest(system, 'address:' + sha256(address), perAddress)]
|
|
181
|
+
if (input.client !== undefined && input.client !== '') counts.push(await countRequest(system, 'client:' + input.client, perClient))
|
|
182
|
+
const exceeded = counts.filter(c => c.over)
|
|
183
|
+
const verbs = input.purpose === 'reset'
|
|
184
|
+
? { entity: 'PasswordReset', verb: 'requested' }
|
|
185
|
+
: { entity: 'EmailAddress', verb: 'verification-requested' }
|
|
186
|
+
const client = input.client ?? null
|
|
187
|
+
if (exceeded.length > 0) {
|
|
188
|
+
getBaseLogger().warn({ event: 'email link refused', purpose: input.purpose, client }, 'email link request over its limit')
|
|
189
|
+
await auditUnauthenticated(system, verbs.entity, verbs.verb, null, { purpose: input.purpose, client, outcome: 'limited' })
|
|
190
|
+
return { ok: true, limited: true, retryAfterSeconds: Math.max(...exceeded.map(c => c.retryAfterSeconds)) }
|
|
191
|
+
}
|
|
192
|
+
const found = await config.lookup(db, address)
|
|
193
|
+
if (found === null) {
|
|
194
|
+
await auditUnauthenticated(system, verbs.entity, verbs.verb, null, { purpose: input.purpose, client, outcome: 'unknown' })
|
|
195
|
+
return { ok: true, limited: false }
|
|
196
|
+
}
|
|
197
|
+
const data = typeof input.data === 'function'
|
|
198
|
+
? await input.data(db, { userId: found.userId, address })
|
|
199
|
+
: (input.data ?? {})
|
|
200
|
+
assertNoUrl(data)
|
|
201
|
+
const id = randomUUID()
|
|
202
|
+
const nonce = randomBytes(16).toString('base64url')
|
|
203
|
+
const tokenHash = sha256(tokenFor(id, nonce))
|
|
204
|
+
await db.execute(sql`
|
|
205
|
+
UPDATE pipework_email_links SET ended_at = now()
|
|
206
|
+
WHERE user_id = ${found.userId} AND purpose = ${input.purpose}
|
|
207
|
+
AND used_at IS NULL AND ended_at IS NULL AND expires_at > now()`)
|
|
208
|
+
await db.execute(sql`
|
|
209
|
+
INSERT INTO pipework_email_links (id, purpose, user_id, address, token_hash, nonce, created_at, expires_at)
|
|
210
|
+
VALUES (${id}::uuid, ${input.purpose}, ${found.userId}, ${address}, ${tokenHash}, ${nonce}, clock_timestamp(),
|
|
211
|
+
now() + ${purposeCfg.expiresInSeconds}::int * interval '1 second')`)
|
|
212
|
+
await config.mail.send(db, {
|
|
213
|
+
to: address, template: purposeCfg.template, data, key: `pipework.emailLink:${id}`,
|
|
214
|
+
lateData: { name: LATE_NAME, ref: id },
|
|
215
|
+
})
|
|
216
|
+
await auditUnauthenticated(system, verbs.entity, verbs.verb, found.userId, { purpose: input.purpose, client, outcome: 'sent' })
|
|
217
|
+
return { ok: true, limited: false }
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
async function request(db: DB, input: EmailLinkRequestInput): Promise<EmailLinkRequestResult> {
|
|
221
|
+
if (typeof input.data === 'object') assertNoUrl(input.data)
|
|
222
|
+
const started = performance.now()
|
|
223
|
+
let result: EmailLinkRequestResult
|
|
224
|
+
try {
|
|
225
|
+
result = await requestStep(db, input)
|
|
226
|
+
} finally {
|
|
227
|
+
for (let wait = minResponseMs - (performance.now() - started); wait > 0; wait = minResponseMs - (performance.now() - started)) {
|
|
228
|
+
await sleep(Math.ceil(wait))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function check(db: DB, token: string): Promise<EmailLinkCheck> {
|
|
235
|
+
const row = (await db.execute(sql`
|
|
236
|
+
SELECT purpose, user_id, expires_at, used_at IS NOT NULL AS used, ended_at IS NOT NULL AS ended, expires_at <= now() AS expired
|
|
237
|
+
FROM pipework_email_links WHERE token_hash = ${sha256(token)}`))[0]
|
|
238
|
+
if (row === undefined) return { state: 'unknown' }
|
|
239
|
+
const state: EmailLinkState = row['used'] === true ? 'used' : row['ended'] === true ? 'ended' : row['expired'] === true ? 'expired' : 'active'
|
|
240
|
+
return { state, purpose: row['purpose'] as EmailLinkPurpose, userId: row['user_id'] as string, expiresAt: new Date(row['expires_at'] as Date | string) }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function liveFor(db: DB, address: string, purpose: EmailLinkPurpose): Promise<readonly LiveEmailLink[]> {
|
|
244
|
+
const purposeCfg = purposeConfig(purpose)
|
|
245
|
+
const rows = await db.execute(sql`
|
|
246
|
+
SELECT id::text, nonce, expires_at FROM pipework_email_links
|
|
247
|
+
WHERE address = ${normalize(address)} AND purpose = ${purpose}
|
|
248
|
+
AND used_at IS NULL AND ended_at IS NULL AND expires_at > now()
|
|
249
|
+
ORDER BY created_at, id`)
|
|
250
|
+
return rows.map((row) => {
|
|
251
|
+
const token = tokenFor(row['id'] as string, row['nonce'] as string)
|
|
252
|
+
return { id: row['id'] as string, token, url: purposeCfg.url(token), expiresAt: new Date(row['expires_at'] as Date | string) }
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function consume(db: DB, token: string, purpose: EmailLinkPurpose):
|
|
257
|
+
Promise<{ ok: true; userId: string; address: string } | { ok: false; reason: EmailLinkRefusal }> {
|
|
258
|
+
const hash = sha256(token)
|
|
259
|
+
const rows = await db.execute(sql`
|
|
260
|
+
UPDATE pipework_email_links SET used_at = now()
|
|
261
|
+
WHERE token_hash = ${hash} AND purpose = ${purpose}
|
|
262
|
+
AND used_at IS NULL AND ended_at IS NULL AND expires_at > now()
|
|
263
|
+
RETURNING user_id, address`)
|
|
264
|
+
if (rows.length > 0) return { ok: true, userId: rows[0]!['user_id'] as string, address: rows[0]!['address'] as string }
|
|
265
|
+
const seen = await check(db, token)
|
|
266
|
+
if (seen.state === 'unknown') return { ok: false, reason: 'unknown' }
|
|
267
|
+
if (seen.purpose !== purpose) return { ok: false, reason: 'wrong-purpose' }
|
|
268
|
+
return { ok: false, reason: seen.state === 'active' ? 'used' : seen.state }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const useStep = __flowStep({ ref: 'pipework/auth:email-link-use' }, async (db: DB, token: string, purpose: EmailLinkPurpose) => {
|
|
272
|
+
const result = await consume(db, token, purpose)
|
|
273
|
+
if (result.ok) {
|
|
274
|
+
const [entity, verb] = purpose === 'verify' ? ['EmailAddress', 'verified'] : ['PasswordReset', 'link-used']
|
|
275
|
+
await auditUnauthenticated(pipeSystem(), entity!, verb!, result.userId, { purpose })
|
|
276
|
+
}
|
|
277
|
+
return result
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
async function endResets(db: DB, userId: string): Promise<void> {
|
|
281
|
+
await db.execute(sql`
|
|
282
|
+
UPDATE pipework_email_links SET ended_at = now()
|
|
283
|
+
WHERE user_id = ${userId} AND purpose = 'reset' AND used_at IS NULL AND ended_at IS NULL`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const completeStep = __flowStep({ ref: 'pipework/auth:email-link-use' }, async (
|
|
287
|
+
db: DB, token: string, newPassword: string,
|
|
288
|
+
options: { store: (db: DB, userId: string, passwordHash: string) => Promise<void> },
|
|
289
|
+
): Promise<{ ok: true; userId: string } | { ok: false; reason: EmailLinkRefusal }> => {
|
|
290
|
+
const used = await consume(db, token, 'reset')
|
|
291
|
+
if (!used.ok) return used
|
|
292
|
+
const hash = await config.passwords.hash(newPassword)
|
|
293
|
+
await options.store(db, used.userId, hash)
|
|
294
|
+
await endResets(db, used.userId)
|
|
295
|
+
if (config.sessions !== undefined) {
|
|
296
|
+
await config.sessions.revokeAll(db, used.userId)
|
|
297
|
+
await config.sessions.bumpVersion(db, used.userId)
|
|
298
|
+
}
|
|
299
|
+
await auditUnauthenticated(pipeSystem(), 'PasswordReset', 'completed', used.userId, { purpose: 'reset' })
|
|
300
|
+
return { ok: true, userId: used.userId }
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
return refuseScopedHandle({
|
|
304
|
+
request,
|
|
305
|
+
check,
|
|
306
|
+
liveFor,
|
|
307
|
+
use: (db, token, purpose) => useStep(db, token, purpose),
|
|
308
|
+
completeReset: (db, token, newPassword, options) => completeStep(db, token, newPassword, options),
|
|
309
|
+
endResets,
|
|
310
|
+
}, 'emailLinks', 'email links belong to a user and act before any tenant is chosen')
|
|
311
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { sql } from '../../data/db/sql.js'
|
|
3
|
+
import type { DB } from '../../data/db/index.js'
|
|
4
|
+
import { pipeSystem } from '../../data/db/db.js'
|
|
5
|
+
import { auditUnauthenticated } from './audit.js'
|
|
6
|
+
import { getBaseLogger } from '../../core/logging/index.js'
|
|
7
|
+
|
|
8
|
+
export interface GuardConfig {
|
|
9
|
+
/** Names the guard; part of every stored key, so two guards never share a count. */
|
|
10
|
+
readonly name: string
|
|
11
|
+
/** Wrong tries allowed per key set within the window. */
|
|
12
|
+
readonly threshold: number
|
|
13
|
+
/** Seconds from the first counted try after which the count restarts. */
|
|
14
|
+
readonly windowSeconds: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type GuardKeys = Readonly<Record<string, string>>
|
|
18
|
+
|
|
19
|
+
export type GuardTake =
|
|
20
|
+
| { readonly allowed: true; readonly remaining: number }
|
|
21
|
+
| { readonly allowed: false; readonly retryAfterSeconds: number }
|
|
22
|
+
|
|
23
|
+
export type GuardStatus = { readonly remaining: number; readonly retryAfterSeconds: number | null }
|
|
24
|
+
|
|
25
|
+
export interface Guard {
|
|
26
|
+
/** Counts a try before the secret is checked; refuses once the threshold is reached. */
|
|
27
|
+
take(keys: GuardKeys): Promise<GuardTake>
|
|
28
|
+
/** Forgets the count for these keys; call on a right answer. */
|
|
29
|
+
reset(keys: GuardKeys): Promise<void>
|
|
30
|
+
/** Reads the count without counting a try. */
|
|
31
|
+
peek(keys: GuardKeys): Promise<GuardStatus>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Creates a guard on wrong guesses at a secret the app checks itself. `keys`
|
|
36
|
+
* together form one counter (`{ document, user }` counts per pair). Call `take`
|
|
37
|
+
* before checking the secret: it counts the try atomically in
|
|
38
|
+
* `pipework_guard_attempts` (blueprint `guards: {}`), so a parallel burst cannot
|
|
39
|
+
* exceed `threshold` per `windowSeconds`, and refuses without counting once the
|
|
40
|
+
* threshold is reached. Call `reset` on a right answer. A wrong try needs no
|
|
41
|
+
* call. Needs an active Flow; statements run on `pipe.system()`, outside the
|
|
42
|
+
* request transaction, so a route that rolls back on a wrong try keeps the count.
|
|
43
|
+
* Route a refusal to 429 with `throw new TooManyRequestsError(take.retryAfterSeconds)`.
|
|
44
|
+
* Refusals and resets are logged and audited (entity `Guard`), never with the raw keys.
|
|
45
|
+
*/
|
|
46
|
+
export function createGuard(config: GuardConfig): Guard {
|
|
47
|
+
const { name, threshold, windowSeconds } = config
|
|
48
|
+
if (typeof name !== 'string' || name === '') throw new Error('createGuard: name must be a non-empty string')
|
|
49
|
+
if (!Number.isInteger(threshold) || threshold < 1) throw new Error('createGuard: threshold must be a positive integer')
|
|
50
|
+
if (!Number.isInteger(windowSeconds) || windowSeconds < 1) throw new Error('createGuard: windowSeconds must be a positive integer')
|
|
51
|
+
|
|
52
|
+
function keyFor(keys: GuardKeys): string {
|
|
53
|
+
if (typeof keys !== 'object' || (keys as unknown) === null) throw new Error('guard keys must be a non-empty object')
|
|
54
|
+
const entries = Object.entries(keys).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
55
|
+
if (entries.length === 0) throw new Error('guard keys must be a non-empty object')
|
|
56
|
+
for (const [k, v] of entries) {
|
|
57
|
+
if (typeof v !== 'string' || v === '') throw new Error(`guard keys.${k} must be a non-empty string`)
|
|
58
|
+
}
|
|
59
|
+
return name + ':' + createHash('sha256').update(JSON.stringify(entries)).digest('hex')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const expired = sql`a.first_failure_at < now() - ${windowSeconds}::int * interval '1 second'`
|
|
63
|
+
|
|
64
|
+
// One statement counts the try. The update applies only when the window has
|
|
65
|
+
// passed or the count is under the threshold; otherwise no row comes back.
|
|
66
|
+
async function count(db: DB, key: string): Promise<number | null> {
|
|
67
|
+
const rows = await db.execute(sql`
|
|
68
|
+
INSERT INTO pipework_guard_attempts AS a (key, failures, first_failure_at, last_failure_at, locked_until)
|
|
69
|
+
VALUES (${key}, 1, now(), now(),
|
|
70
|
+
CASE WHEN 1 >= ${threshold}::int THEN now() + ${windowSeconds}::int * interval '1 second' END)
|
|
71
|
+
ON CONFLICT (key) DO UPDATE SET
|
|
72
|
+
failures = CASE WHEN ${expired} THEN 1 ELSE a.failures + 1 END,
|
|
73
|
+
first_failure_at = CASE WHEN ${expired} THEN now() ELSE a.first_failure_at END,
|
|
74
|
+
last_failure_at = now(),
|
|
75
|
+
locked_until = CASE
|
|
76
|
+
WHEN (CASE WHEN ${expired} THEN 1 ELSE a.failures + 1 END) >= ${threshold}::int
|
|
77
|
+
THEN (CASE WHEN ${expired} THEN now() ELSE a.first_failure_at END) + ${windowSeconds}::int * interval '1 second'
|
|
78
|
+
END
|
|
79
|
+
WHERE ${expired} OR a.failures < ${threshold}::int
|
|
80
|
+
RETURNING failures`)
|
|
81
|
+
return rows.length > 0 ? Number(rows[0]!['failures']) : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function retryAfter(db: DB, key: string): Promise<number> {
|
|
85
|
+
const rows = await db.execute(sql`
|
|
86
|
+
SELECT greatest(ceil(extract(epoch FROM (first_failure_at + ${windowSeconds}::int * interval '1 second' - now())))::int, 1) AS retry
|
|
87
|
+
FROM pipework_guard_attempts WHERE key = ${key}`)
|
|
88
|
+
return Number(rows[0]?.['retry'] ?? 1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function take(keys: GuardKeys): Promise<GuardTake> {
|
|
92
|
+
const key = keyFor(keys)
|
|
93
|
+
const db = pipeSystem()
|
|
94
|
+
const failures = await count(db, key)
|
|
95
|
+
if (failures !== null) return { allowed: true, remaining: threshold - failures }
|
|
96
|
+
const retryAfterSeconds = await retryAfter(db, key)
|
|
97
|
+
getBaseLogger().warn({ event: 'guard refused', guard: name, key, retryAfterSeconds }, 'guard refused')
|
|
98
|
+
await auditUnauthenticated(db, 'Guard', 'refused', null, { guard: name, key, retryAfterSeconds })
|
|
99
|
+
return { allowed: false, retryAfterSeconds }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function reset(keys: GuardKeys): Promise<void> {
|
|
103
|
+
const key = keyFor(keys)
|
|
104
|
+
const db = pipeSystem()
|
|
105
|
+
const rows = await db.execute(sql`DELETE FROM pipework_guard_attempts WHERE key = ${key} RETURNING key`)
|
|
106
|
+
if (rows.length === 0) return
|
|
107
|
+
getBaseLogger().info({ event: 'guard reset', guard: name, key }, 'guard reset')
|
|
108
|
+
await auditUnauthenticated(db, 'Guard', 'reset', null, { guard: name, key, retryAfterSeconds: null })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function peek(keys: GuardKeys): Promise<GuardStatus> {
|
|
112
|
+
const key = keyFor(keys)
|
|
113
|
+
const rows = await pipeSystem().execute(sql`
|
|
114
|
+
SELECT failures,
|
|
115
|
+
first_failure_at < now() - ${windowSeconds}::int * interval '1 second' AS passed,
|
|
116
|
+
greatest(ceil(extract(epoch FROM (first_failure_at + ${windowSeconds}::int * interval '1 second' - now())))::int, 1) AS retry
|
|
117
|
+
FROM pipework_guard_attempts WHERE key = ${key}`)
|
|
118
|
+
const row = rows[0]
|
|
119
|
+
if (row === undefined || row['passed'] === true) return { remaining: threshold, retryAfterSeconds: null }
|
|
120
|
+
const failures = Number(row['failures'])
|
|
121
|
+
return {
|
|
122
|
+
remaining: Math.max(threshold - failures, 0),
|
|
123
|
+
retryAfterSeconds: failures >= threshold ? Number(row['retry']) : null,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { take, reset, peek }
|
|
128
|
+
}
|