pipework 0.252.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.
Files changed (690) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/package.json +4 -3
  3. package/src/async/jobs/abortable-delay.ts +18 -0
  4. package/src/async/jobs/claims.ts +52 -0
  5. package/src/async/jobs/cron.ts +179 -0
  6. package/src/async/jobs/errors.ts +56 -0
  7. package/src/async/jobs/exclusion.ts +117 -0
  8. package/src/async/jobs/execute.ts +219 -0
  9. package/src/async/jobs/index.ts +10 -0
  10. package/src/async/jobs/listener.ts +135 -0
  11. package/src/async/jobs/namespace.ts +20 -0
  12. package/src/async/jobs/queue-door.ts +44 -0
  13. package/src/async/jobs/queue.ts +1012 -0
  14. package/src/async/jobs/run-script.ts +67 -0
  15. package/src/async/jobs/scheduler.ts +218 -0
  16. package/src/async/jobs/schedules-table.ts +37 -0
  17. package/src/async/jobs/serialize-error.ts +48 -0
  18. package/src/async/jobs/table.ts +191 -0
  19. package/src/async/pipeline/define.ts +53 -0
  20. package/src/async/pipeline/errors.ts +35 -0
  21. package/src/async/pipeline/execute.ts +53 -0
  22. package/src/async/pipeline/index.ts +13 -0
  23. package/src/async/pipeline/namespace.ts +12 -0
  24. package/src/async/pipeline/resume.ts +67 -0
  25. package/src/async/pipeline/run-steps.ts +98 -0
  26. package/src/async/pipeline/types.ts +57 -0
  27. package/src/async/state-machine/define.ts +64 -0
  28. package/src/async/state-machine/errors.ts +29 -0
  29. package/src/async/state-machine/index.ts +3 -0
  30. package/src/async/state-machine/namespace.ts +12 -0
  31. package/src/async/state-machine/transition.ts +90 -0
  32. package/src/async/state-machine/types.ts +37 -0
  33. package/src/async/steps/errors.ts +31 -0
  34. package/src/async/steps/graph.ts +1214 -0
  35. package/src/async/steps/hash.ts +40 -0
  36. package/src/async/steps/index.ts +7 -0
  37. package/src/async/steps/namespace.ts +7 -0
  38. package/src/async/steps/table.ts +97 -0
  39. package/src/async/steps/types.ts +318 -0
  40. package/src/audit/chain/index.ts +9 -0
  41. package/src/audit/chain/install.ts +113 -0
  42. package/src/audit/chain/retire.ts +52 -0
  43. package/src/audit/chain/verify.ts +257 -0
  44. package/src/audit/query/cursor.ts +37 -0
  45. package/src/audit/query/index.ts +9 -0
  46. package/src/audit/query/query.ts +223 -0
  47. package/src/audit/schema/audit-record-fields.ts +68 -0
  48. package/src/audit/schema/audit-record.ts +23 -0
  49. package/src/auth/auth/audit.ts +31 -0
  50. package/src/auth/auth/chain.ts +84 -0
  51. package/src/auth/auth/cookie.ts +136 -0
  52. package/src/auth/auth/email-links.ts +311 -0
  53. package/src/auth/auth/guard.ts +128 -0
  54. package/src/auth/auth/index.ts +18 -0
  55. package/src/auth/auth/multi-org.ts +337 -0
  56. package/src/auth/auth/namespace.ts +34 -0
  57. package/src/auth/auth/parse-expiry.ts +13 -0
  58. package/src/auth/auth/passwords.ts +110 -0
  59. package/src/auth/auth/schema.ts +110 -0
  60. package/src/auth/auth/sessions.ts +565 -0
  61. package/src/auth/auth/sign-in.ts +156 -0
  62. package/src/auth/auth/signing-keys.ts +69 -0
  63. package/src/auth/auth/types.ts +26 -0
  64. package/src/auth/auth/uuid.ts +10 -0
  65. package/src/auth/rbac/cache.ts +55 -0
  66. package/src/auth/rbac/check.ts +30 -0
  67. package/src/auth/rbac/create.ts +35 -0
  68. package/src/auth/rbac/index.ts +5 -0
  69. package/src/auth/rbac/middleware.ts +20 -0
  70. package/src/auth/rbac/namespace.ts +15 -0
  71. package/src/auth/rbac/resolve.ts +31 -0
  72. package/src/auth/rbac/schema.sql +31 -0
  73. package/src/auth/rbac/seed.ts +67 -0
  74. package/src/auth/rbac/tables.ts +32 -0
  75. package/src/auth/rbac/types.ts +65 -0
  76. package/src/auth/tenant/extract.ts +33 -0
  77. package/src/auth/tenant/in-tenant.ts +49 -0
  78. package/src/auth/tenant/index.ts +7 -0
  79. package/src/auth/tenant/namespace.ts +23 -0
  80. package/src/auth/tenant/propagate.ts +20 -0
  81. package/src/auth/tenant/rls.ts +120 -0
  82. package/src/auth/tenant/scope.ts +33 -0
  83. package/src/auth/tenant/scoped-db.ts +581 -0
  84. package/src/auth/tenant/types.ts +14 -0
  85. package/src/auth/tenant/validate.ts +27 -0
  86. package/src/cli/commands/add.ts +158 -0
  87. package/src/cli/commands/alerts.ts +98 -0
  88. package/src/cli/commands/check.ts +1418 -0
  89. package/src/cli/commands/codemod.ts +78 -0
  90. package/src/cli/commands/db.ts +356 -0
  91. package/src/cli/commands/dev.ts +101 -0
  92. package/src/cli/commands/generate.ts +75 -0
  93. package/src/cli/commands/init.ts +36 -0
  94. package/src/cli/commands/install.ts +28 -0
  95. package/src/cli/commands/lint.ts +62 -0
  96. package/src/cli/commands/migrate.ts +132 -0
  97. package/src/cli/commands/openapi.ts +63 -0
  98. package/src/cli/commands/remove.ts +72 -0
  99. package/src/cli/commands/run.ts +50 -0
  100. package/src/cli/commands/scope.ts +54 -0
  101. package/src/cli/commands/serve.ts +68 -0
  102. package/src/cli/commands/shard.ts +31 -0
  103. package/src/cli/commands/shared/config-pipework-version.ts +28 -0
  104. package/src/cli/commands/shared/find-config.ts +22 -0
  105. package/src/cli/commands/shared/load-config.ts +35 -0
  106. package/src/cli/commands/shared/pnpm-install.ts +95 -0
  107. package/src/cli/commands/shared/report-removed.ts +8 -0
  108. package/src/cli/commands/sync.ts +32 -0
  109. package/src/cli/commands/test.ts +307 -0
  110. package/src/cli/commands/upgrade.ts +48 -0
  111. package/src/cli/dev-bootstrap.ts +24 -0
  112. package/src/cli/errors.ts +10 -0
  113. package/src/cli/index.ts +303 -0
  114. package/src/cli/output.ts +17 -0
  115. package/src/cli/port.ts +15 -0
  116. package/src/cli/surface-kinds.ts +20 -0
  117. package/src/client/index.ts +4 -0
  118. package/src/client/poll.ts +130 -0
  119. package/src/client/report-fault.ts +119 -0
  120. package/src/core/config/app-pool.ts +19 -0
  121. package/src/core/config/aws-env.ts +29 -0
  122. package/src/core/config/base-dir.ts +24 -0
  123. package/src/core/config/discover.ts +64 -0
  124. package/src/core/config/env-resolve.ts +87 -0
  125. package/src/core/config/env-types.ts +116 -0
  126. package/src/core/config/env.ts +55 -0
  127. package/src/core/config/errors.ts +9 -0
  128. package/src/core/config/import-config.ts +40 -0
  129. package/src/core/config/index.ts +8 -0
  130. package/src/core/config/load.ts +417 -0
  131. package/src/core/config/namespace.ts +14 -0
  132. package/src/core/config/resolve.ts +195 -0
  133. package/src/core/config/schema.ts +488 -0
  134. package/src/core/config/statement-observer.ts +48 -0
  135. package/src/core/config/ts-inline-hooks.ts +75 -0
  136. package/src/core/config/ts-register.ts +4 -0
  137. package/src/core/config/ts-resolve-hooks.ts +101 -0
  138. package/src/core/errors/base.ts +49 -0
  139. package/src/core/errors/http.ts +146 -0
  140. package/src/core/errors/index.ts +2 -0
  141. package/src/core/errors/namespace.ts +31 -0
  142. package/src/core/lifecycle/drain.ts +33 -0
  143. package/src/core/lifecycle/health.ts +72 -0
  144. package/src/core/lifecycle/index.ts +4 -0
  145. package/src/core/lifecycle/namespace.ts +7 -0
  146. package/src/core/lifecycle/startup.ts +102 -0
  147. package/src/core/lifecycle/types.ts +36 -0
  148. package/src/core/logging/context.ts +60 -0
  149. package/src/core/logging/error-chain.ts +68 -0
  150. package/src/core/logging/index.ts +5 -0
  151. package/src/core/logging/logger.ts +65 -0
  152. package/src/core/logging/namespace.ts +16 -0
  153. package/src/core/logging/proxy.ts +22 -0
  154. package/src/core/logging/redact-match.ts +24 -0
  155. package/src/core/logging/types.ts +47 -0
  156. package/src/core/pipework.ts +566 -0
  157. package/src/core/runtime/broker.ts +69 -0
  158. package/src/core/surface/http.ts +133 -0
  159. package/src/core/surface/index.ts +11 -0
  160. package/src/core/surface/job-timeout.ts +40 -0
  161. package/src/core/surface/namespace.ts +13 -0
  162. package/src/core/surface/script.ts +33 -0
  163. package/src/core/surface/start.ts +142 -0
  164. package/src/core/surface/types.ts +225 -0
  165. package/src/core/surface/validate.ts +88 -0
  166. package/src/core/surface/worker.ts +528 -0
  167. package/src/core/validation/connectivity.ts +49 -0
  168. package/src/core/validation/domain.ts +99 -0
  169. package/src/core/validation/index.ts +5 -0
  170. package/src/core/validation/queue-tables.ts +312 -0
  171. package/src/core/validation/rls-role.ts +138 -0
  172. package/src/core/validation/startup.ts +51 -0
  173. package/src/core/validation/surfaces.ts +65 -0
  174. package/src/core/validation/types.ts +25 -0
  175. package/src/data/db/advisory-lock.ts +55 -0
  176. package/src/data/db/aggregates.ts +172 -0
  177. package/src/data/db/array-elements.ts +111 -0
  178. package/src/data/db/bulk-set.ts +53 -0
  179. package/src/data/db/col/index.ts +14 -0
  180. package/src/data/db/composed.ts +53 -0
  181. package/src/data/db/db.ts +276 -0
  182. package/src/data/db/excluded.ts +16 -0
  183. package/src/data/db/expressions.ts +473 -0
  184. package/src/data/db/filter.ts +90 -0
  185. package/src/data/db/function-source.ts +114 -0
  186. package/src/data/db/identifiers.ts +16 -0
  187. package/src/data/db/idx/index.ts +9 -0
  188. package/src/data/db/in-list.ts +49 -0
  189. package/src/data/db/index.ts +9 -0
  190. package/src/data/db/insert-split.ts +137 -0
  191. package/src/data/db/instrumentation.ts +82 -0
  192. package/src/data/db/maintenance.ts +28 -0
  193. package/src/data/db/namespace.ts +107 -0
  194. package/src/data/db/owner-labels.ts +9 -0
  195. package/src/data/db/pool.ts +187 -0
  196. package/src/data/db/query-label.ts +13 -0
  197. package/src/data/db/query-watch.ts +116 -0
  198. package/src/data/db/raw-client-warning.ts +37 -0
  199. package/src/data/db/readonly.ts +23 -0
  200. package/src/data/db/reservation.ts +151 -0
  201. package/src/data/db/schema-features.ts +14 -0
  202. package/src/data/db/serializable.ts +83 -0
  203. package/src/data/db/sql.ts +1 -0
  204. package/src/data/db/timeouts.ts +76 -0
  205. package/src/data/db/types.ts +18 -0
  206. package/src/data/db/unbound.ts +33 -0
  207. package/src/data/db/values.ts +29 -0
  208. package/src/data/domain/brand.ts +15 -0
  209. package/src/data/domain/build-factory.ts +193 -0
  210. package/src/data/domain/build-schema.ts +161 -0
  211. package/src/data/domain/build-table.ts +355 -0
  212. package/src/data/domain/define.ts +239 -0
  213. package/src/data/domain/external-registry.ts +34 -0
  214. package/src/data/domain/external.ts +77 -0
  215. package/src/data/domain/field.ts +279 -0
  216. package/src/data/domain/index.ts +13 -0
  217. package/src/data/domain/naming.ts +6 -0
  218. package/src/data/domain/projections.ts +57 -0
  219. package/src/data/domain/trace-safe-projection.ts +31 -0
  220. package/src/data/domain/traits.ts +108 -0
  221. package/src/data/domain/types.ts +289 -0
  222. package/src/data/jsonb/composed.ts +18 -0
  223. package/src/data/jsonb/index.ts +1 -0
  224. package/src/data/jsonb/namespace.ts +30 -0
  225. package/src/data/jsonb/query.ts +86 -0
  226. package/src/data/migrate/append-only-generate.ts +47 -0
  227. package/src/data/migrate/applied.ts +47 -0
  228. package/src/data/migrate/apply.ts +309 -0
  229. package/src/data/migrate/check.ts +119 -0
  230. package/src/data/migrate/diff.ts +357 -0
  231. package/src/data/migrate/door-generate.ts +130 -0
  232. package/src/data/migrate/framework-rls.ts +58 -0
  233. package/src/data/migrate/generate.ts +201 -0
  234. package/src/data/migrate/history.ts +215 -0
  235. package/src/data/migrate/index.ts +9 -0
  236. package/src/data/migrate/internal-definitions.ts +175 -0
  237. package/src/data/migrate/post-process.ts +73 -0
  238. package/src/data/migrate/pragma.ts +73 -0
  239. package/src/data/migrate/provision-framework.ts +147 -0
  240. package/src/data/migrate/rls-generate.ts +61 -0
  241. package/src/data/migrate/run.ts +124 -0
  242. package/src/data/migrate/safety.ts +167 -0
  243. package/src/data/migrate/snapshot.ts +368 -0
  244. package/src/data/migrate/sql-emit.ts +392 -0
  245. package/src/data/migrate/statements.ts +312 -0
  246. package/src/data/query/alias.ts +126 -0
  247. package/src/data/query/batch.ts +8 -0
  248. package/src/data/query/cache/core/cache.ts +78 -0
  249. package/src/data/query/cache/core/index.ts +1 -0
  250. package/src/data/query/cache/core/types.ts +29 -0
  251. package/src/data/query/cache/readme.md +230 -0
  252. package/src/data/query/casing.ts +76 -0
  253. package/src/data/query/collation.ts +11 -0
  254. package/src/data/query/column-builder.ts +370 -0
  255. package/src/data/query/column.ts +150 -0
  256. package/src/data/query/entity.ts +42 -0
  257. package/src/data/query/errors.ts +33 -0
  258. package/src/data/query/index.ts +14 -0
  259. package/src/data/query/logger.ts +47 -0
  260. package/src/data/query/operations.ts +45 -0
  261. package/src/data/query/pg-core/alias.ts +12 -0
  262. package/src/data/query/pg-core/checks.ts +32 -0
  263. package/src/data/query/pg-core/columns/all.ts +71 -0
  264. package/src/data/query/pg-core/columns/bigint.ts +110 -0
  265. package/src/data/query/pg-core/columns/bigserial.ts +128 -0
  266. package/src/data/query/pg-core/columns/boolean.ts +43 -0
  267. package/src/data/query/pg-core/columns/char.ts +85 -0
  268. package/src/data/query/pg-core/columns/cidr.ts +43 -0
  269. package/src/data/query/pg-core/columns/common.ts +366 -0
  270. package/src/data/query/pg-core/columns/custom.ts +232 -0
  271. package/src/data/query/pg-core/columns/date.common.ts +15 -0
  272. package/src/data/query/pg-core/columns/date.ts +112 -0
  273. package/src/data/query/pg-core/columns/double-precision.ts +55 -0
  274. package/src/data/query/pg-core/columns/enum.ts +201 -0
  275. package/src/data/query/pg-core/columns/index.ts +35 -0
  276. package/src/data/query/pg-core/columns/inet.ts +43 -0
  277. package/src/data/query/pg-core/columns/int.common.ts +57 -0
  278. package/src/data/query/pg-core/columns/integer.ts +53 -0
  279. package/src/data/query/pg-core/columns/interval.ts +83 -0
  280. package/src/data/query/pg-core/columns/json.ts +58 -0
  281. package/src/data/query/pg-core/columns/jsonb.ts +56 -0
  282. package/src/data/query/pg-core/columns/line.ts +117 -0
  283. package/src/data/query/pg-core/columns/macaddr.ts +43 -0
  284. package/src/data/query/pg-core/columns/macaddr8.ts +43 -0
  285. package/src/data/query/pg-core/columns/numeric.ts +227 -0
  286. package/src/data/query/pg-core/columns/point.ts +127 -0
  287. package/src/data/query/pg-core/columns/postgis_extension/geometry.ts +119 -0
  288. package/src/data/query/pg-core/columns/postgis_extension/utils.ts +47 -0
  289. package/src/data/query/pg-core/columns/real.ts +58 -0
  290. package/src/data/query/pg-core/columns/serial.ts +55 -0
  291. package/src/data/query/pg-core/columns/smallint.ts +53 -0
  292. package/src/data/query/pg-core/columns/smallserial.ts +60 -0
  293. package/src/data/query/pg-core/columns/text.ts +67 -0
  294. package/src/data/query/pg-core/columns/time.ts +72 -0
  295. package/src/data/query/pg-core/columns/timestamp.ts +163 -0
  296. package/src/data/query/pg-core/columns/uuid.ts +51 -0
  297. package/src/data/query/pg-core/columns/varchar.ts +89 -0
  298. package/src/data/query/pg-core/columns/vector_extension/bit.ts +68 -0
  299. package/src/data/query/pg-core/columns/vector_extension/halfvec.ts +80 -0
  300. package/src/data/query/pg-core/columns/vector_extension/sparsevec.ts +67 -0
  301. package/src/data/query/pg-core/columns/vector_extension/vector.ts +80 -0
  302. package/src/data/query/pg-core/db.ts +674 -0
  303. package/src/data/query/pg-core/dialect.ts +1404 -0
  304. package/src/data/query/pg-core/expressions.ts +25 -0
  305. package/src/data/query/pg-core/foreign-keys.ts +130 -0
  306. package/src/data/query/pg-core/index.ts +21 -0
  307. package/src/data/query/pg-core/indexes.ts +276 -0
  308. package/src/data/query/pg-core/policies.ts +56 -0
  309. package/src/data/query/pg-core/primary-keys.ts +62 -0
  310. package/src/data/query/pg-core/query-builders/count.ts +86 -0
  311. package/src/data/query/pg-core/query-builders/delete.ts +293 -0
  312. package/src/data/query/pg-core/query-builders/index.ts +7 -0
  313. package/src/data/query/pg-core/query-builders/insert.ts +452 -0
  314. package/src/data/query/pg-core/query-builders/query-builder.ts +123 -0
  315. package/src/data/query/pg-core/query-builders/query.ts +157 -0
  316. package/src/data/query/pg-core/query-builders/raw.ts +49 -0
  317. package/src/data/query/pg-core/query-builders/refresh-materialized-view.ts +108 -0
  318. package/src/data/query/pg-core/query-builders/select.ts +1346 -0
  319. package/src/data/query/pg-core/query-builders/select.types.ts +437 -0
  320. package/src/data/query/pg-core/query-builders/update.ts +626 -0
  321. package/src/data/query/pg-core/roles.ts +41 -0
  322. package/src/data/query/pg-core/schema.ts +72 -0
  323. package/src/data/query/pg-core/sequence.ts +41 -0
  324. package/src/data/query/pg-core/session.ts +292 -0
  325. package/src/data/query/pg-core/subquery.ts +46 -0
  326. package/src/data/query/pg-core/table.ts +252 -0
  327. package/src/data/query/pg-core/unique-constraint.ts +73 -0
  328. package/src/data/query/pg-core/utils/array.ts +95 -0
  329. package/src/data/query/pg-core/utils/index.ts +1 -0
  330. package/src/data/query/pg-core/utils.ts +104 -0
  331. package/src/data/query/pg-core/view-base.ts +14 -0
  332. package/src/data/query/pg-core/view-common.ts +1 -0
  333. package/src/data/query/pg-core/view.ts +435 -0
  334. package/src/data/query/pg-core/with-builder.ts +41 -0
  335. package/src/data/query/postgres-js/README.md +46 -0
  336. package/src/data/query/postgres-js/array-parser.ts +45 -0
  337. package/src/data/query/postgres-js/driver-error-boundary.ts +149 -0
  338. package/src/data/query/postgres-js/driver.ts +163 -0
  339. package/src/data/query/postgres-js/index.ts +2 -0
  340. package/src/data/query/postgres-js/session.ts +234 -0
  341. package/src/data/query/primary-key.ts +11 -0
  342. package/src/data/query/query-builders/query-builder.ts +19 -0
  343. package/src/data/query/query-builders/select.types.ts +175 -0
  344. package/src/data/query/query-promise.ts +35 -0
  345. package/src/data/query/relations.ts +725 -0
  346. package/src/data/query/runnable-query.ts +12 -0
  347. package/src/data/query/selection-proxy.ts +121 -0
  348. package/src/data/query/session.ts +8 -0
  349. package/src/data/query/sql/expressions/conditions.ts +741 -0
  350. package/src/data/query/sql/expressions/index.ts +2 -0
  351. package/src/data/query/sql/expressions/select.ts +45 -0
  352. package/src/data/query/sql/functions/aggregate.ts +129 -0
  353. package/src/data/query/sql/functions/index.ts +2 -0
  354. package/src/data/query/sql/functions/vector.ts +178 -0
  355. package/src/data/query/sql/index.ts +3 -0
  356. package/src/data/query/sql/sql.ts +794 -0
  357. package/src/data/query/subquery.ts +53 -0
  358. package/src/data/query/table.ts +208 -0
  359. package/src/data/query/table.utils.ts +2 -0
  360. package/src/data/query/tracing-utils.ts +3 -0
  361. package/src/data/query/tracing.ts +11 -0
  362. package/src/data/query/utils.ts +357 -0
  363. package/src/data/query/version.ts +4 -0
  364. package/src/data/query/view-common.ts +1 -0
  365. package/src/data/schema/branded.ts +13 -0
  366. package/src/data/schema/generated/coerce.ts +5 -0
  367. package/src/data/schema/generated/combinator.ts +6 -0
  368. package/src/data/schema/generated/encoding.ts +5 -0
  369. package/src/data/schema/generated/error.ts +6 -0
  370. package/src/data/schema/generated/format.ts +5 -0
  371. package/src/data/schema/generated/type-constructor.ts +10 -0
  372. package/src/data/schema/generated/type.ts +6 -0
  373. package/src/data/schema/index.ts +6 -0
  374. package/src/data/schema/json-schema.ts +33 -0
  375. package/src/data/schema/namespace.ts +55 -0
  376. package/src/data/schema/parse.ts +39 -0
  377. package/src/data/schema/types.ts +39 -0
  378. package/src/data/temporal/append-only.ts +134 -0
  379. package/src/data/temporal/close.ts +27 -0
  380. package/src/data/temporal/columns.ts +10 -0
  381. package/src/data/temporal/definition-queries.ts +70 -0
  382. package/src/data/temporal/errors.ts +10 -0
  383. package/src/data/temporal/get-current.ts +22 -0
  384. package/src/data/temporal/index.ts +9 -0
  385. package/src/data/temporal/namespace.ts +36 -0
  386. package/src/data/temporal/query.ts +25 -0
  387. package/src/data/temporal/resolve.ts +17 -0
  388. package/src/data/temporal/revise.ts +67 -0
  389. package/src/data/temporal/types.ts +22 -0
  390. package/src/data/valve/artifact.ts +33 -0
  391. package/src/data/valve/contract-diff.ts +21 -0
  392. package/src/data/valve/cross.ts +57 -0
  393. package/src/data/valve/crossing-ledger.ts +50 -0
  394. package/src/data/valve/decision.ts +44 -0
  395. package/src/data/valve/durable-crossing.ts +52 -0
  396. package/src/data/valve/execute-crossing.ts +77 -0
  397. package/src/data/valve/faucet.ts +56 -0
  398. package/src/data/valve/gate.ts +114 -0
  399. package/src/data/valve/graph.ts +77 -0
  400. package/src/data/valve/index.ts +53 -0
  401. package/src/data/valve/namespace.ts +57 -0
  402. package/src/data/valve/registry.ts +50 -0
  403. package/src/data/valve/schema/valve-crossing-fields.ts +23 -0
  404. package/src/data/valve/schema/valve-crossing.ts +9 -0
  405. package/src/data/valve/supply.ts +154 -0
  406. package/src/data/valve/types.ts +143 -0
  407. package/src/data/vector/columns.ts +27 -0
  408. package/src/data/vector/index.ts +4 -0
  409. package/src/data/vector/namespace.ts +33 -0
  410. package/src/data/vector/query.ts +85 -0
  411. package/src/data/vector/validate.ts +16 -0
  412. package/src/data/write/audit/actor.ts +15 -0
  413. package/src/data/write/audit/audit-columns.ts +18 -0
  414. package/src/data/write/audit/audit-select.ts +111 -0
  415. package/src/data/write/audit/bound-transaction.ts +14 -0
  416. package/src/data/write/audit/cte-compile.ts +32 -0
  417. package/src/data/write/audit/decode-returning.ts +47 -0
  418. package/src/data/write/audit/final-select.ts +43 -0
  419. package/src/data/write/audit/infra-audit.ts +36 -0
  420. package/src/data/write/audit/primary-key.ts +65 -0
  421. package/src/data/write/audit/projection.ts +41 -0
  422. package/src/data/write/audit/read-touches.ts +86 -0
  423. package/src/data/write/audit/touch-match.ts +78 -0
  424. package/src/data/write/audit/verb-delete.ts +62 -0
  425. package/src/data/write/audit/verb-insert.ts +36 -0
  426. package/src/data/write/audit/verb-select.ts +47 -0
  427. package/src/data/write/audit/verb-update.ts +60 -0
  428. package/src/data/write/audit/verb-upsert.ts +104 -0
  429. package/src/data/write/conflict.ts +43 -0
  430. package/src/data/write/delete.ts +38 -0
  431. package/src/data/write/execute.ts +243 -0
  432. package/src/data/write/index.ts +11 -0
  433. package/src/data/write/insert.ts +63 -0
  434. package/src/data/write/state.ts +43 -0
  435. package/src/data/write/update.ts +54 -0
  436. package/src/data/write/upsert.ts +58 -0
  437. package/src/data/write/values.ts +19 -0
  438. package/src/index.ts +230 -0
  439. package/src/infra/access-links/code.ts +70 -0
  440. package/src/infra/access-links/handle.ts +892 -0
  441. package/src/infra/access-links/index.ts +9 -0
  442. package/src/infra/access-links/namespace.ts +30 -0
  443. package/src/infra/access-links/person-check.ts +177 -0
  444. package/src/infra/access-links/schema.ts +71 -0
  445. package/src/infra/alerts/async-sources.ts +231 -0
  446. package/src/infra/alerts/audit.ts +36 -0
  447. package/src/infra/alerts/beat.ts +123 -0
  448. package/src/infra/alerts/client-faults.ts +111 -0
  449. package/src/infra/alerts/faults.ts +156 -0
  450. package/src/infra/alerts/handle.ts +778 -0
  451. package/src/infra/alerts/http.ts +192 -0
  452. package/src/infra/alerts/index.ts +12 -0
  453. package/src/infra/alerts/infra-sources.ts +180 -0
  454. package/src/infra/alerts/namespace.ts +81 -0
  455. package/src/infra/alerts/redact.ts +21 -0
  456. package/src/infra/alerts/request-faults.ts +41 -0
  457. package/src/infra/alerts/schema.ts +124 -0
  458. package/src/infra/alerts/sinks.ts +125 -0
  459. package/src/infra/alerts/source-options.ts +26 -0
  460. package/src/infra/alerts/sources.ts +503 -0
  461. package/src/infra/alerts/subsystem-sources.ts +137 -0
  462. package/src/infra/audit/emit.ts +87 -0
  463. package/src/infra/audit/index.ts +2 -0
  464. package/src/infra/audit/namespace.ts +22 -0
  465. package/src/infra/audit/schema.sql +35 -0
  466. package/src/infra/audit/types.ts +18 -0
  467. package/src/infra/aws/credentials.ts +147 -0
  468. package/src/infra/aws/sigv4.ts +140 -0
  469. package/src/infra/cache/create.ts +58 -0
  470. package/src/infra/cache/entry.ts +81 -0
  471. package/src/infra/cache/index.ts +4 -0
  472. package/src/infra/cache/namespace.ts +10 -0
  473. package/src/infra/cache/tenant.ts +93 -0
  474. package/src/infra/cache/types.ts +24 -0
  475. package/src/infra/crawl/audit.ts +35 -0
  476. package/src/infra/crawl/errors.ts +25 -0
  477. package/src/infra/crawl/handle.ts +693 -0
  478. package/src/infra/crawl/index.ts +9 -0
  479. package/src/infra/crawl/links.ts +160 -0
  480. package/src/infra/crawl/namespace.ts +36 -0
  481. package/src/infra/crawl/robots.ts +100 -0
  482. package/src/infra/crawl/schema.ts +89 -0
  483. package/src/infra/credit-line/handle.ts +883 -0
  484. package/src/infra/credit-line/index.ts +6 -0
  485. package/src/infra/credit-line/namespace.ts +7 -0
  486. package/src/infra/credit-line/schema.ts +96 -0
  487. package/src/infra/credits/handle.ts +706 -0
  488. package/src/infra/credits/index.ts +5 -0
  489. package/src/infra/credits/namespace.ts +7 -0
  490. package/src/infra/credits/schema.ts +83 -0
  491. package/src/infra/email/audit.ts +36 -0
  492. package/src/infra/email/inbound.ts +622 -0
  493. package/src/infra/email/index.ts +11 -0
  494. package/src/infra/email/mail.ts +517 -0
  495. package/src/infra/email/mime.ts +447 -0
  496. package/src/infra/email/namespace.ts +22 -0
  497. package/src/infra/email/providers.ts +111 -0
  498. package/src/infra/email/schema.ts +96 -0
  499. package/src/infra/email/sns.ts +200 -0
  500. package/src/infra/email/unsubscribe.ts +44 -0
  501. package/src/infra/fetch/address.ts +88 -0
  502. package/src/infra/fetch/connect-retry.ts +87 -0
  503. package/src/infra/fetch/handle.ts +329 -0
  504. package/src/infra/fetch/index.ts +7 -0
  505. package/src/infra/fetch/namespace.ts +70 -0
  506. package/src/infra/idempotency/index.ts +3 -0
  507. package/src/infra/idempotency/middleware.ts +87 -0
  508. package/src/infra/idempotency/store.ts +92 -0
  509. package/src/infra/idempotency/types.ts +30 -0
  510. package/src/infra/join-requests/handle.ts +312 -0
  511. package/src/infra/join-requests/index.ts +2 -0
  512. package/src/infra/join-requests/namespace.ts +7 -0
  513. package/src/infra/join-requests/schema.ts +33 -0
  514. package/src/infra/outbox/core.ts +165 -0
  515. package/src/infra/quota/errors.ts +32 -0
  516. package/src/infra/quota/handle.ts +215 -0
  517. package/src/infra/quota/index.ts +5 -0
  518. package/src/infra/quota/namespace.ts +7 -0
  519. package/src/infra/quota/schema.ts +15 -0
  520. package/src/infra/storage/backend.ts +34 -0
  521. package/src/infra/storage/handle.ts +513 -0
  522. package/src/infra/storage/index.ts +9 -0
  523. package/src/infra/storage/local.ts +104 -0
  524. package/src/infra/storage/namespace.ts +13 -0
  525. package/src/infra/storage/s3.ts +257 -0
  526. package/src/infra/storage/schema.ts +24 -0
  527. package/src/infra/support/handle.ts +854 -0
  528. package/src/infra/support/http.ts +229 -0
  529. package/src/infra/support/index.ts +7 -0
  530. package/src/infra/support/namespace.ts +12 -0
  531. package/src/infra/support/schema.ts +96 -0
  532. package/src/infra/webhook/inbound.ts +45 -0
  533. package/src/infra/webhook/index.ts +5 -0
  534. package/src/infra/webhook/namespace.ts +24 -0
  535. package/src/infra/webhook/outbound.ts +315 -0
  536. package/src/infra/webhook/sign.ts +32 -0
  537. package/src/infra/webhook/verify.ts +82 -0
  538. package/src/invariants/assert.ts +26 -0
  539. package/src/invariants/index.ts +1 -0
  540. package/src/lint/config.ts +56 -0
  541. package/src/lint/doctrine.ts +163 -0
  542. package/src/lint/include.ts +97 -0
  543. package/src/lint/index.ts +3 -0
  544. package/src/lint/preset.ts +10 -0
  545. package/src/lint/resolver.ts +218 -0
  546. package/src/lint/rules/no-reexports.ts +46 -0
  547. package/src/request/behavior/audited.ts +85 -0
  548. package/src/request/behavior/cached.ts +62 -0
  549. package/src/request/behavior/compose.ts +28 -0
  550. package/src/request/behavior/index.ts +5 -0
  551. package/src/request/behavior/namespace.ts +16 -0
  552. package/src/request/behavior/types.ts +26 -0
  553. package/src/request/behavior/versioned.ts +54 -0
  554. package/src/request/context/create.ts +103 -0
  555. package/src/request/context/guard.ts +49 -0
  556. package/src/request/context/index.ts +4 -0
  557. package/src/request/context/run-in-transaction.ts +200 -0
  558. package/src/request/context/store.ts +28 -0
  559. package/src/request/context/types.ts +50 -0
  560. package/src/request/di/builder.ts +535 -0
  561. package/src/request/di/index.ts +8 -0
  562. package/src/request/di/resolve.ts +271 -0
  563. package/src/request/di/types.ts +262 -0
  564. package/src/request/fitting/namespace.ts +7 -0
  565. package/src/request/fixture/batch.ts +71 -0
  566. package/src/request/fixture/builder.ts +26 -0
  567. package/src/request/fixture/errors.ts +10 -0
  568. package/src/request/fixture/index.ts +8 -0
  569. package/src/request/fixture/namespace.ts +23 -0
  570. package/src/request/fixture/paginate.ts +44 -0
  571. package/src/request/fixture/to-handlers.ts +260 -0
  572. package/src/request/fixture/types.ts +79 -0
  573. package/src/request/flow/act-as.ts +78 -0
  574. package/src/request/flow/namespace.ts +17 -0
  575. package/src/request/http/error-step.ts +42 -0
  576. package/src/request/http/file.ts +141 -0
  577. package/src/request/http/index.ts +4 -0
  578. package/src/request/http/middleware.ts +141 -0
  579. package/src/request/http/namespace.ts +5 -0
  580. package/src/request/http/rate-limit-classes.ts +170 -0
  581. package/src/request/http/response.ts +82 -0
  582. package/src/request/http/server.ts +916 -0
  583. package/src/request/http/sniff.ts +261 -0
  584. package/src/request/http/types.ts +250 -0
  585. package/src/request/http/upload.ts +284 -0
  586. package/src/request/internal/text.ts +6 -0
  587. package/src/request/openapi/generate.ts +205 -0
  588. package/src/request/openapi/index.ts +2 -0
  589. package/src/request/openapi/namespace.ts +7 -0
  590. package/src/test/auth.ts +13 -0
  591. package/src/test/auto-setup.ts +45 -0
  592. package/src/test/context.ts +91 -0
  593. package/src/test/crawl-fixture.ts +157 -0
  594. package/src/test/discover-fork.ts +17 -0
  595. package/src/test/fingerprint.ts +135 -0
  596. package/src/test/globals.d.ts +7 -0
  597. package/src/test/index.ts +19 -0
  598. package/src/test/namespace.ts +87 -0
  599. package/src/test/plugin.ts +189 -0
  600. package/src/test/progress.ts +185 -0
  601. package/src/test/reap-after-run.ts +28 -0
  602. package/src/test/reap.ts +286 -0
  603. package/src/test/reset.ts +253 -0
  604. package/src/test/savepoints.ts +172 -0
  605. package/src/test/setup.ts +1422 -0
  606. package/src/test/system-channel.ts +50 -0
  607. package/src/test/vitest-plugin.ts +142 -0
  608. package/src/test/vitest-workspace.ts +507 -0
  609. package/src/test/vitest.ts +333 -0
  610. package/src/test/watch.ts +105 -0
  611. package/src/trace/capture/bounded-serializer.ts +146 -0
  612. package/src/trace/cardinality/analyze.ts +173 -0
  613. package/src/trace/cardinality/branch-context.ts +214 -0
  614. package/src/trace/cardinality/check.ts +33 -0
  615. package/src/trace/cardinality/classify-edge.ts +64 -0
  616. package/src/trace/cardinality/structural-max.ts +107 -0
  617. package/src/trace/check/coverage-check.ts +188 -0
  618. package/src/trace/check/flow-check.ts +103 -0
  619. package/src/trace/check/flow-step-site.ts +234 -0
  620. package/src/trace/check/io-pattern.ts +116 -0
  621. package/src/trace/codemod/add-cardinality.ts +152 -0
  622. package/src/trace/codemod/run.ts +80 -0
  623. package/src/trace/entry/child-trace.ts +21 -0
  624. package/src/trace/entry/close-trace.ts +87 -0
  625. package/src/trace/entry/finalize-trace.ts +28 -0
  626. package/src/trace/entry/open-trace.ts +138 -0
  627. package/src/trace/entry/parent-trace.ts +20 -0
  628. package/src/trace/entry/parse-traceparent.ts +39 -0
  629. package/src/trace/entry/process-start.ts +38 -0
  630. package/src/trace/entry/trace-context.ts +26 -0
  631. package/src/trace/entry/tracing-enabled.ts +10 -0
  632. package/src/trace/external/record.ts +195 -0
  633. package/src/trace/external/report.ts +66 -0
  634. package/src/trace/namespace.ts +19 -0
  635. package/src/trace/partition/day.ts +47 -0
  636. package/src/trace/partition/index.ts +14 -0
  637. package/src/trace/partition/partition-name.ts +23 -0
  638. package/src/trace/partition/plan.ts +82 -0
  639. package/src/trace/partition/schedule.ts +26 -0
  640. package/src/trace/partition/startup-hook.ts +39 -0
  641. package/src/trace/partition/sync.ts +154 -0
  642. package/src/trace/partition/tables.ts +53 -0
  643. package/src/trace/read/tree.ts +83 -0
  644. package/src/trace/retain/copy-on-error.ts +76 -0
  645. package/src/trace/runtime/expire-stale-traces.ts +54 -0
  646. package/src/trace/runtime/flow-step.ts +147 -0
  647. package/src/trace/runtime/flush-steps.ts +31 -0
  648. package/src/trace/runtime/pending-writes.ts +54 -0
  649. package/src/trace/runtime/step-buffer.ts +298 -0
  650. package/src/trace/runtime/step-error.ts +21 -0
  651. package/src/trace/runtime/step-meta.ts +55 -0
  652. package/src/trace/runtime/thenable.ts +12 -0
  653. package/src/trace/runtime/truncate-trace.ts +62 -0
  654. package/src/trace/schema/trace-fields.ts +52 -0
  655. package/src/trace/schema/trace-retained.ts +22 -0
  656. package/src/trace/schema/trace-step-fields.ts +44 -0
  657. package/src/trace/schema/trace-step-retained.ts +25 -0
  658. package/src/trace/schema/trace-step.ts +18 -0
  659. package/src/trace/schema/trace.ts +12 -0
  660. package/src/trace/transformer/apply.ts +79 -0
  661. package/src/trace/transformer/build-meta-literal.ts +30 -0
  662. package/src/trace/transformer/find-flow-step-sites.ts +85 -0
  663. package/src/trace/transformer/inject-flow-step-import.ts +39 -0
  664. package/src/trace/transformer/transform-error.ts +14 -0
  665. package/src/trace/transformer/transform-flow-steps.ts +123 -0
  666. package/src/trace/transformer/wrap-function-declaration.ts +62 -0
  667. package/src/trace/transformer/wrap-variable-declaration.ts +67 -0
  668. package/src/workspace/affected.ts +110 -0
  669. package/src/workspace/changed-files.ts +163 -0
  670. package/src/workspace/check-deps.ts +213 -0
  671. package/src/workspace/checksum.ts +20 -0
  672. package/src/workspace/dependency-graph.ts +67 -0
  673. package/src/workspace/detect-pipework-copies.ts +93 -0
  674. package/src/workspace/discover.ts +206 -0
  675. package/src/workspace/generate.ts +401 -0
  676. package/src/workspace/index.ts +7 -0
  677. package/src/workspace/module-imports-pipework.ts +39 -0
  678. package/src/workspace/module-package-name.ts +12 -0
  679. package/src/workspace/scrub-git-env.ts +23 -0
  680. package/src/workspace/source-paths.ts +36 -0
  681. package/src/workspace/sync.ts +182 -0
  682. package/dist/REFERENCE.md +0 -1477
  683. package/dist/infra/access-links/rate-limit.d.ts +0 -10
  684. package/dist/infra/access-links/rate-limit.d.ts.map +0 -1
  685. package/dist/infra/access-links/rate-limit.js +0 -28
  686. package/dist/infra/access-links/rate-limit.js.map +0 -1
  687. package/dist/infra/email/sigv4.d.ts +0 -24
  688. package/dist/infra/email/sigv4.d.ts.map +0 -1
  689. package/dist/infra/email/sigv4.js +0 -55
  690. 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
+ }