@beignet/core 0.0.3 → 0.0.5

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 (360) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +792 -50
  3. package/dist/application/index.d.ts +28 -2
  4. package/dist/application/index.d.ts.map +1 -1
  5. package/dist/application/index.js +140 -12
  6. package/dist/application/index.js.map +1 -1
  7. package/dist/client/client.d.ts +2 -2
  8. package/dist/client/client.d.ts.map +1 -1
  9. package/dist/client/client.js +136 -48
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/client/error-messages.d.ts +14 -0
  12. package/dist/client/error-messages.d.ts.map +1 -0
  13. package/dist/client/error-messages.js +23 -0
  14. package/dist/client/error-messages.js.map +1 -0
  15. package/dist/client/index.d.ts +8 -4
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +6 -2
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/types.d.ts +35 -5
  20. package/dist/client/types.d.ts.map +1 -1
  21. package/dist/client-only.d.ts +8 -0
  22. package/dist/client-only.d.ts.map +1 -0
  23. package/dist/client-only.js +8 -0
  24. package/dist/client-only.js.map +1 -0
  25. package/dist/config/index.d.ts +5 -5
  26. package/dist/config/index.d.ts.map +1 -1
  27. package/dist/config/index.js +2 -2
  28. package/dist/config/index.js.map +1 -1
  29. package/dist/contracts/catalog-errors.d.ts +27 -0
  30. package/dist/contracts/catalog-errors.d.ts.map +1 -0
  31. package/dist/contracts/catalog-errors.js +69 -0
  32. package/dist/contracts/catalog-errors.js.map +1 -0
  33. package/dist/contracts/contract-builder.d.ts +15 -12
  34. package/dist/contracts/contract-builder.d.ts.map +1 -1
  35. package/dist/contracts/contract-builder.js +15 -41
  36. package/dist/contracts/contract-builder.js.map +1 -1
  37. package/dist/contracts/contract-group.d.ts +11 -8
  38. package/dist/contracts/contract-group.d.ts.map +1 -1
  39. package/dist/contracts/contract-group.js +13 -40
  40. package/dist/contracts/contract-group.js.map +1 -1
  41. package/dist/contracts/contract-like.d.ts +1 -1
  42. package/dist/contracts/contract-like.d.ts.map +1 -1
  43. package/dist/contracts/index.d.ts +13 -9
  44. package/dist/contracts/index.d.ts.map +1 -1
  45. package/dist/contracts/index.js +9 -5
  46. package/dist/contracts/index.js.map +1 -1
  47. package/dist/contracts/openapi-meta.d.ts +48 -0
  48. package/dist/contracts/openapi-meta.d.ts.map +1 -1
  49. package/dist/contracts/openapi-meta.js +3 -0
  50. package/dist/contracts/openapi-meta.js.map +1 -1
  51. package/dist/contracts/path-template.d.ts +1 -1
  52. package/dist/contracts/path-template.js +2 -2
  53. package/dist/contracts/path-template.js.map +1 -1
  54. package/dist/contracts/schema-shape.d.ts +37 -0
  55. package/dist/contracts/schema-shape.d.ts.map +1 -0
  56. package/dist/contracts/schema-shape.js +61 -0
  57. package/dist/contracts/schema-shape.js.map +1 -0
  58. package/dist/contracts/success-status.d.ts +32 -0
  59. package/dist/contracts/success-status.d.ts.map +1 -0
  60. package/dist/contracts/success-status.js +18 -0
  61. package/dist/contracts/success-status.js.map +1 -0
  62. package/dist/contracts/types.d.ts +25 -5
  63. package/dist/contracts/types.d.ts.map +1 -1
  64. package/dist/contracts/types.js.map +1 -1
  65. package/dist/contracts/utils.d.ts +1 -1
  66. package/dist/contracts/utils.d.ts.map +1 -1
  67. package/dist/contracts/utils.js +1 -1
  68. package/dist/contracts/utils.js.map +1 -1
  69. package/dist/domain/events.d.ts +1 -1
  70. package/dist/domain/events.d.ts.map +1 -1
  71. package/dist/domain/events.js +1 -1
  72. package/dist/domain/events.js.map +1 -1
  73. package/dist/domain/index.d.ts +3 -3
  74. package/dist/domain/index.d.ts.map +1 -1
  75. package/dist/domain/index.js +3 -3
  76. package/dist/domain/index.js.map +1 -1
  77. package/dist/errors/catalog.d.ts +9 -1
  78. package/dist/errors/catalog.d.ts.map +1 -1
  79. package/dist/errors/catalog.js +7 -1
  80. package/dist/errors/catalog.js.map +1 -1
  81. package/dist/errors/http.d.ts +10 -0
  82. package/dist/errors/http.d.ts.map +1 -1
  83. package/dist/errors/http.js +11 -1
  84. package/dist/errors/http.js.map +1 -1
  85. package/dist/errors/index.d.ts +4 -4
  86. package/dist/errors/index.d.ts.map +1 -1
  87. package/dist/errors/index.js +4 -4
  88. package/dist/errors/index.js.map +1 -1
  89. package/dist/errors/response.d.ts +4 -1
  90. package/dist/errors/response.d.ts.map +1 -1
  91. package/dist/errors/response.js.map +1 -1
  92. package/dist/events/index.d.ts +10 -12
  93. package/dist/events/index.d.ts.map +1 -1
  94. package/dist/events/index.js +10 -10
  95. package/dist/events/index.js.map +1 -1
  96. package/dist/idempotency/index.d.ts +5 -3
  97. package/dist/idempotency/index.d.ts.map +1 -1
  98. package/dist/idempotency/index.js.map +1 -1
  99. package/dist/jobs/index.d.ts +12 -14
  100. package/dist/jobs/index.d.ts.map +1 -1
  101. package/dist/jobs/index.js +13 -13
  102. package/dist/jobs/index.js.map +1 -1
  103. package/dist/notifications/index.d.ts +14 -16
  104. package/dist/notifications/index.d.ts.map +1 -1
  105. package/dist/notifications/index.js +14 -14
  106. package/dist/notifications/index.js.map +1 -1
  107. package/dist/openapi/index.d.ts +8 -3
  108. package/dist/openapi/index.d.ts.map +1 -1
  109. package/dist/openapi/index.js +41 -29
  110. package/dist/openapi/index.js.map +1 -1
  111. package/dist/openapi/schema-introspector.d.ts +37 -0
  112. package/dist/openapi/schema-introspector.d.ts.map +1 -1
  113. package/dist/openapi/schema-introspector.js +23 -17
  114. package/dist/openapi/schema-introspector.js.map +1 -1
  115. package/dist/outbox/index.d.ts +15 -6
  116. package/dist/outbox/index.d.ts.map +1 -1
  117. package/dist/outbox/index.js +60 -16
  118. package/dist/outbox/index.js.map +1 -1
  119. package/dist/ports/audit.d.ts +56 -10
  120. package/dist/ports/audit.d.ts.map +1 -1
  121. package/dist/ports/audit.js +71 -3
  122. package/dist/ports/audit.js.map +1 -1
  123. package/dist/ports/auth.d.ts +92 -0
  124. package/dist/ports/auth.d.ts.map +1 -1
  125. package/dist/ports/auth.js +92 -0
  126. package/dist/ports/auth.js.map +1 -1
  127. package/dist/ports/events.d.ts +2 -2
  128. package/dist/ports/events.d.ts.map +1 -1
  129. package/dist/ports/index.d.ts +62 -33
  130. package/dist/ports/index.d.ts.map +1 -1
  131. package/dist/ports/index.js +28 -34
  132. package/dist/ports/index.js.map +1 -1
  133. package/dist/ports/policy.d.ts +32 -3
  134. package/dist/ports/policy.d.ts.map +1 -1
  135. package/dist/ports/policy.js +13 -2
  136. package/dist/ports/policy.js.map +1 -1
  137. package/dist/ports/testing.d.ts +1030 -2
  138. package/dist/ports/testing.d.ts.map +1 -1
  139. package/dist/ports/testing.js +1031 -1
  140. package/dist/ports/testing.js.map +1 -1
  141. package/dist/ports/unbound.d.ts +21 -0
  142. package/dist/ports/unbound.d.ts.map +1 -0
  143. package/dist/ports/unbound.js +57 -0
  144. package/dist/ports/unbound.js.map +1 -0
  145. package/dist/ports/unit-of-work.d.ts +1 -1
  146. package/dist/ports/unit-of-work.d.ts.map +1 -1
  147. package/dist/ports/unit-of-work.js +1 -1
  148. package/dist/ports/unit-of-work.js.map +1 -1
  149. package/dist/providers/index.d.ts +3 -2
  150. package/dist/providers/index.d.ts.map +1 -1
  151. package/dist/providers/index.js +3 -2
  152. package/dist/providers/index.js.map +1 -1
  153. package/dist/providers/instrumentation.d.ts +45 -4
  154. package/dist/providers/instrumentation.d.ts.map +1 -1
  155. package/dist/providers/instrumentation.js +25 -6
  156. package/dist/providers/instrumentation.js.map +1 -1
  157. package/dist/providers/metadata.d.ts +39 -0
  158. package/dist/providers/metadata.d.ts.map +1 -0
  159. package/dist/providers/metadata.js +169 -0
  160. package/dist/providers/metadata.js.map +1 -0
  161. package/dist/providers/provider.d.ts +114 -9
  162. package/dist/providers/provider.d.ts.map +1 -1
  163. package/dist/providers/provider.js +3 -20
  164. package/dist/providers/provider.js.map +1 -1
  165. package/dist/schedules/index.d.ts +94 -13
  166. package/dist/schedules/index.d.ts.map +1 -1
  167. package/dist/schedules/index.js +66 -12
  168. package/dist/schedules/index.js.map +1 -1
  169. package/dist/server/audit-context.d.ts +29 -0
  170. package/dist/server/audit-context.d.ts.map +1 -0
  171. package/dist/server/audit-context.js +44 -0
  172. package/dist/server/audit-context.js.map +1 -0
  173. package/dist/server/context.d.ts +141 -0
  174. package/dist/server/context.d.ts.map +1 -0
  175. package/dist/server/context.js +39 -0
  176. package/dist/server/context.js.map +1 -0
  177. package/dist/server/contract-like.d.ts +1 -1
  178. package/dist/server/contract-like.d.ts.map +1 -1
  179. package/dist/server/contract-like.js +1 -1
  180. package/dist/server/contract-like.js.map +1 -1
  181. package/dist/server/health.d.ts +2 -2
  182. package/dist/server/health.d.ts.map +1 -1
  183. package/dist/server/hooks/auth.d.ts +49 -10
  184. package/dist/server/hooks/auth.d.ts.map +1 -1
  185. package/dist/server/hooks/auth.js +77 -37
  186. package/dist/server/hooks/auth.js.map +1 -1
  187. package/dist/server/hooks/cors.d.ts +1 -1
  188. package/dist/server/hooks/cors.d.ts.map +1 -1
  189. package/dist/server/hooks/errors.d.ts +2 -2
  190. package/dist/server/hooks/errors.d.ts.map +1 -1
  191. package/dist/server/hooks/errors.js +2 -2
  192. package/dist/server/hooks/errors.js.map +1 -1
  193. package/dist/server/hooks/idempotency.d.ts +78 -0
  194. package/dist/server/hooks/idempotency.d.ts.map +1 -0
  195. package/dist/server/hooks/idempotency.js +154 -0
  196. package/dist/server/hooks/idempotency.js.map +1 -0
  197. package/dist/server/hooks/index.d.ts +8 -7
  198. package/dist/server/hooks/index.d.ts.map +1 -1
  199. package/dist/server/hooks/index.js +6 -5
  200. package/dist/server/hooks/index.js.map +1 -1
  201. package/dist/server/hooks/logging.d.ts +2 -2
  202. package/dist/server/hooks/logging.d.ts.map +1 -1
  203. package/dist/server/hooks/logging.js +1 -1
  204. package/dist/server/hooks/logging.js.map +1 -1
  205. package/dist/server/hooks/rate-limit.d.ts +25 -7
  206. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  207. package/dist/server/hooks/rate-limit.js +47 -12
  208. package/dist/server/hooks/rate-limit.js.map +1 -1
  209. package/dist/server/hooks.d.ts +1 -1
  210. package/dist/server/hooks.d.ts.map +1 -1
  211. package/dist/server/hooks.js +1 -1
  212. package/dist/server/hooks.js.map +1 -1
  213. package/dist/server/http.d.ts +61 -35
  214. package/dist/server/http.d.ts.map +1 -1
  215. package/dist/server/http.js +1 -20
  216. package/dist/server/http.js.map +1 -1
  217. package/dist/server/index.d.ts +36 -12
  218. package/dist/server/index.d.ts.map +1 -1
  219. package/dist/server/index.js +24 -8
  220. package/dist/server/index.js.map +1 -1
  221. package/dist/server/instrumentation.d.ts +108 -0
  222. package/dist/server/instrumentation.d.ts.map +1 -0
  223. package/dist/server/instrumentation.js +297 -0
  224. package/dist/server/instrumentation.js.map +1 -0
  225. package/dist/server/openapi.d.ts +3 -3
  226. package/dist/server/openapi.d.ts.map +1 -1
  227. package/dist/server/openapi.js +1 -1
  228. package/dist/server/openapi.js.map +1 -1
  229. package/dist/server/providers/index.d.ts +3 -3
  230. package/dist/server/providers/index.d.ts.map +1 -1
  231. package/dist/server/providers/index.js +3 -3
  232. package/dist/server/providers/index.js.map +1 -1
  233. package/dist/server/providers/loadProviderConfig.d.ts +2 -2
  234. package/dist/server/providers/loadProviderConfig.d.ts.map +1 -1
  235. package/dist/server/providers/loadProviderConfig.js +2 -2
  236. package/dist/server/providers/loadProviderConfig.js.map +1 -1
  237. package/dist/server/request-context.d.ts +67 -0
  238. package/dist/server/request-context.d.ts.map +1 -0
  239. package/dist/server/request-context.js +79 -0
  240. package/dist/server/request-context.js.map +1 -0
  241. package/dist/server/server-context.d.ts +38 -0
  242. package/dist/server/server-context.d.ts.map +1 -0
  243. package/dist/server/server-context.js +38 -0
  244. package/dist/server/server-context.js.map +1 -0
  245. package/dist/server/server.d.ts +105 -33
  246. package/dist/server/server.d.ts.map +1 -1
  247. package/dist/server/server.js +434 -118
  248. package/dist/server/server.js.map +1 -1
  249. package/dist/server/types.d.ts +2 -2
  250. package/dist/server/types.d.ts.map +1 -1
  251. package/dist/server/types.js +2 -2
  252. package/dist/server/types.js.map +1 -1
  253. package/dist/server/use-case-route.d.ts +263 -0
  254. package/dist/server/use-case-route.d.ts.map +1 -0
  255. package/dist/server/use-case-route.js +77 -0
  256. package/dist/server/use-case-route.js.map +1 -0
  257. package/dist/server-only.d.ts +8 -0
  258. package/dist/server-only.d.ts.map +1 -0
  259. package/dist/server-only.js +8 -0
  260. package/dist/server-only.js.map +1 -0
  261. package/dist/tasks/index.d.ts +139 -0
  262. package/dist/tasks/index.d.ts.map +1 -0
  263. package/dist/tasks/index.js +98 -0
  264. package/dist/tasks/index.js.map +1 -0
  265. package/dist/testing/index.d.ts +607 -5
  266. package/dist/testing/index.d.ts.map +1 -1
  267. package/dist/testing/index.js +426 -4
  268. package/dist/testing/index.js.map +1 -1
  269. package/dist/tracing/index.d.ts +89 -0
  270. package/dist/tracing/index.d.ts.map +1 -0
  271. package/dist/tracing/index.js +101 -0
  272. package/dist/tracing/index.js.map +1 -0
  273. package/dist/uploads/client.d.ts +1 -1
  274. package/dist/uploads/client.d.ts.map +1 -1
  275. package/dist/uploads/index.d.ts +2 -2
  276. package/dist/uploads/index.d.ts.map +1 -1
  277. package/dist/uploads/index.js +1 -1
  278. package/dist/uploads/index.js.map +1 -1
  279. package/package.json +24 -2
  280. package/src/application/index.ts +193 -10
  281. package/src/client/client.ts +148 -150
  282. package/src/client/error-messages.ts +35 -0
  283. package/src/client/index.ts +12 -4
  284. package/src/client/types.ts +44 -5
  285. package/src/client-only.ts +7 -0
  286. package/src/config/index.ts +6 -6
  287. package/src/contracts/catalog-errors.ts +115 -0
  288. package/src/contracts/contract-builder.ts +39 -76
  289. package/src/contracts/contract-group.ts +33 -68
  290. package/src/contracts/contract-like.ts +1 -1
  291. package/src/contracts/index.ts +24 -11
  292. package/src/contracts/openapi-meta.ts +55 -0
  293. package/src/contracts/path-template.ts +2 -2
  294. package/src/contracts/schema-shape.ts +75 -0
  295. package/src/contracts/success-status.ts +68 -0
  296. package/src/contracts/types.ts +32 -5
  297. package/src/contracts/utils.ts +5 -2
  298. package/src/domain/events.ts +6 -2
  299. package/src/domain/index.ts +3 -3
  300. package/src/errors/catalog.ts +9 -1
  301. package/src/errors/http.ts +11 -1
  302. package/src/errors/index.ts +4 -4
  303. package/src/errors/response.ts +4 -1
  304. package/src/events/index.ts +12 -26
  305. package/src/idempotency/index.ts +5 -3
  306. package/src/jobs/index.ts +14 -24
  307. package/src/notifications/index.ts +17 -27
  308. package/src/openapi/index.ts +73 -38
  309. package/src/openapi/schema-introspector.ts +68 -17
  310. package/src/outbox/index.ts +84 -19
  311. package/src/ports/audit.ts +120 -11
  312. package/src/ports/auth.ts +132 -0
  313. package/src/ports/events.ts +2 -2
  314. package/src/ports/index.ts +104 -35
  315. package/src/ports/policy.ts +50 -3
  316. package/src/ports/testing.ts +2220 -33
  317. package/src/ports/unbound.ts +64 -0
  318. package/src/ports/unit-of-work.ts +6 -2
  319. package/src/providers/index.ts +16 -3
  320. package/src/providers/instrumentation.ts +86 -7
  321. package/src/providers/metadata.ts +234 -0
  322. package/src/providers/provider.ts +168 -9
  323. package/src/schedules/index.ts +173 -23
  324. package/src/server/audit-context.ts +45 -0
  325. package/src/server/context.ts +224 -0
  326. package/src/server/contract-like.ts +1 -1
  327. package/src/server/health.ts +2 -2
  328. package/src/server/hooks/auth.ts +141 -51
  329. package/src/server/hooks/cors.ts +1 -1
  330. package/src/server/hooks/errors.ts +7 -4
  331. package/src/server/hooks/idempotency.ts +263 -0
  332. package/src/server/hooks/index.ts +14 -7
  333. package/src/server/hooks/logging.ts +3 -3
  334. package/src/server/hooks/rate-limit.ts +85 -17
  335. package/src/server/hooks.ts +1 -1
  336. package/src/server/http.ts +78 -51
  337. package/src/server/index.ts +62 -12
  338. package/src/server/instrumentation.ts +470 -0
  339. package/src/server/openapi.ts +4 -4
  340. package/src/server/providers/index.ts +6 -3
  341. package/src/server/providers/loadProviderConfig.ts +4 -4
  342. package/src/server/request-context.ts +116 -0
  343. package/src/server/server-context.ts +44 -0
  344. package/src/server/server.ts +886 -238
  345. package/src/server/types.ts +2 -2
  346. package/src/server/use-case-route.ts +430 -0
  347. package/src/server-only.ts +7 -0
  348. package/src/tasks/index.ts +275 -0
  349. package/src/testing/index.ts +1142 -6
  350. package/src/tracing/index.ts +176 -0
  351. package/src/uploads/client.ts +1 -1
  352. package/src/uploads/index.ts +7 -3
  353. package/dist/ports/mailer.d.ts +0 -6
  354. package/dist/ports/mailer.d.ts.map +0 -1
  355. package/dist/ports/mailer.js +0 -2
  356. package/dist/ports/mailer.js.map +0 -1
  357. package/dist/ports/schedules.d.ts +0 -9
  358. package/dist/ports/schedules.d.ts.map +0 -1
  359. package/dist/ports/schedules.js +0 -2
  360. package/dist/ports/schedules.js.map +0 -1
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Idempotency hooks for @beignet/core/server
3
+ */
4
+
5
+ import { AppError, httpErrors } from "../../errors/index.js";
6
+ import {
7
+ createIdempotencyFingerprint,
8
+ IdempotencyConflictError,
9
+ IdempotencyInProgressError,
10
+ type IdempotencyMeta,
11
+ type IdempotencyPort,
12
+ type IdempotencyScope,
13
+ } from "../../idempotency/index.js";
14
+ import type { ActivityActor, ActivityTenant } from "../../ports/index.js";
15
+ import type {
16
+ HttpRequestLike,
17
+ HttpResponseLike,
18
+ ServerHook,
19
+ } from "../types.js";
20
+
21
+ /**
22
+ * Ports required by idempotency hooks.
23
+ */
24
+ export type IdempotencyPorts = {
25
+ idempotency: IdempotencyPort;
26
+ };
27
+
28
+ /**
29
+ * Minimal context shape required for actor- and tenant-scoped idempotency.
30
+ */
31
+ export type CtxWithIdempotency = {
32
+ ports: IdempotencyPorts;
33
+ actor?: ActivityActor;
34
+ tenant?: ActivityTenant;
35
+ };
36
+
37
+ /**
38
+ * Options for `createIdempotencyHooks(...)`.
39
+ */
40
+ export interface IdempotencyHooksOptions<Ctx> {
41
+ /**
42
+ * Build the idempotency namespace for a contract.
43
+ *
44
+ * Defaults to `http.<contract name>` so HTTP reservations never collide with
45
+ * use-case `runIdempotently(...)` namespaces.
46
+ */
47
+ namespace?: (args: { contract: { name: string } }) => string;
48
+ /**
49
+ * Build the idempotency scope after context exists.
50
+ *
51
+ * Defaults to a scope derived from `meta.scope`: `"global"` stays global,
52
+ * `"actor"` scopes by `ctx.actor?.id`, `"tenant"` scopes by `ctx.tenant?.id`,
53
+ * and `"actor-tenant"` scopes by both.
54
+ */
55
+ scope?: (args: {
56
+ ctx: Ctx;
57
+ req: HttpRequestLike;
58
+ meta: IdempotencyMeta;
59
+ }) => IdempotencyScope;
60
+ /**
61
+ * Build the fingerprint input from the parsed request.
62
+ *
63
+ * Defaults to `{ path, query, body }`.
64
+ */
65
+ fingerprintInput?: (args: {
66
+ path: unknown;
67
+ query: unknown;
68
+ body: unknown;
69
+ }) => unknown;
70
+ }
71
+
72
+ /**
73
+ * Header set on replayed responses.
74
+ */
75
+ const IDEMPOTENCY_REPLAYED_HEADER = "idempotency-replayed";
76
+
77
+ type PendingReservation = {
78
+ port: IdempotencyPort;
79
+ namespace: string;
80
+ key: string;
81
+ scope: IdempotencyScope;
82
+ fingerprint: string;
83
+ };
84
+
85
+ function defaultIdempotencyScope(
86
+ ctx: CtxWithIdempotency,
87
+ meta: IdempotencyMeta,
88
+ ): IdempotencyScope {
89
+ const mode = meta.scope ?? "global";
90
+
91
+ switch (mode) {
92
+ case "global":
93
+ return "global";
94
+ case "actor":
95
+ return { actorId: ctx.actor?.id };
96
+ case "tenant":
97
+ return { tenantId: ctx.tenant?.id };
98
+ case "actor-tenant":
99
+ return { actorId: ctx.actor?.id, tenantId: ctx.tenant?.id };
100
+ }
101
+ }
102
+
103
+ function isReplayableHttpResponse(value: unknown): value is HttpResponseLike {
104
+ if (typeof value !== "object" || value === null) return false;
105
+
106
+ const candidate = value as { status?: unknown; headers?: unknown };
107
+ if (typeof candidate.status !== "number") return false;
108
+
109
+ if (
110
+ candidate.headers !== undefined &&
111
+ (typeof candidate.headers !== "object" ||
112
+ candidate.headers === null ||
113
+ Array.isArray(candidate.headers))
114
+ ) {
115
+ return false;
116
+ }
117
+
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Create metadata-driven idempotency hooks.
123
+ *
124
+ * The hook reads `contract.metadata.idempotency` and enforces it with
125
+ * `ctx.ports.idempotency`. In `beforeHandle` it reserves the client key after
126
+ * request parsing, replays completed matching responses with an
127
+ * `idempotency-replayed: true` header, and rejects in-progress or conflicting
128
+ * keys with the framework `IdempotencyInProgress`/`IdempotencyConflict` catalog
129
+ * errors. In `beforeSend` it stores 2xx framework-neutral responses for replay
130
+ * and releases the reservation for errors, non-2xx responses, and native
131
+ * `Response` results, which are not replayable.
132
+ *
133
+ * Use `runIdempotently(...)` from `@beignet/core/idempotency` for non-HTTP
134
+ * workflows such as jobs, listeners, webhooks, and schedules.
135
+ *
136
+ * @param options - Optional namespace, scope, and fingerprint-input builders.
137
+ * @returns A server hook backed by `ctx.ports.idempotency`.
138
+ */
139
+ export function createIdempotencyHooks<Ctx extends CtxWithIdempotency>(
140
+ options: IdempotencyHooksOptions<Ctx> = {},
141
+ ): ServerHook<Ctx, IdempotencyPorts> {
142
+ const pending = new WeakMap<HttpRequestLike, PendingReservation>();
143
+
144
+ return {
145
+ name: "idempotency",
146
+ beforeHandle: async ({ ctx, contract, req, path, query, body }) => {
147
+ const meta = contract.metadata?.idempotency;
148
+ if (!meta) {
149
+ return undefined;
150
+ }
151
+
152
+ const header = (meta.header ?? "idempotency-key").toLowerCase();
153
+ const key = req.headers.get(header);
154
+
155
+ if (!key) {
156
+ if (meta.required) {
157
+ throw new AppError(
158
+ httpErrors.BadRequest,
159
+ {
160
+ contract: contract.name,
161
+ header,
162
+ },
163
+ `Missing required idempotency key header "${header}"`,
164
+ );
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ const namespace =
170
+ options.namespace?.({ contract: { name: contract.name } }) ??
171
+ `http.${contract.name}`;
172
+ const scope =
173
+ options.scope?.({ ctx, req, meta }) ??
174
+ defaultIdempotencyScope(ctx, meta);
175
+ const fingerprint = await createIdempotencyFingerprint(
176
+ options.fingerprintInput?.({ path, query, body }) ?? {
177
+ path,
178
+ query,
179
+ body,
180
+ },
181
+ );
182
+
183
+ const reservation = await ctx.ports.idempotency.reserve({
184
+ namespace,
185
+ key,
186
+ scope,
187
+ fingerprint,
188
+ ttlSec: meta.ttlSec,
189
+ });
190
+
191
+ switch (reservation.status) {
192
+ case "replay": {
193
+ if (!isReplayableHttpResponse(reservation.result)) {
194
+ throw new AppError(
195
+ httpErrors.InternalServerError,
196
+ { namespace, key },
197
+ `Stored idempotency result for "${namespace}" key "${key}" is not a replayable HTTP response`,
198
+ );
199
+ }
200
+
201
+ return {
202
+ status: reservation.result.status,
203
+ headers: {
204
+ ...(reservation.result.headers ?? {}),
205
+ [IDEMPOTENCY_REPLAYED_HEADER]: "true",
206
+ },
207
+ body: reservation.result.body,
208
+ };
209
+ }
210
+ case "inProgress": {
211
+ throw new IdempotencyInProgressError(reservation);
212
+ }
213
+ case "conflict": {
214
+ throw new IdempotencyConflictError(reservation);
215
+ }
216
+ case "reserved": {
217
+ pending.set(req, {
218
+ port: ctx.ports.idempotency,
219
+ namespace,
220
+ key,
221
+ scope,
222
+ fingerprint,
223
+ });
224
+ return undefined;
225
+ }
226
+ }
227
+ },
228
+ beforeSend: async ({ req, response, error, native }) => {
229
+ const reservation = pending.get(req);
230
+ if (!reservation) {
231
+ return undefined;
232
+ }
233
+ pending.delete(req);
234
+
235
+ const { port, namespace, key, scope, fingerprint } = reservation;
236
+
237
+ if (
238
+ !native &&
239
+ !error &&
240
+ response.status >= 200 &&
241
+ response.status < 300
242
+ ) {
243
+ await port.complete({
244
+ namespace,
245
+ key,
246
+ scope,
247
+ fingerprint,
248
+ result: {
249
+ status: response.status,
250
+ headers: response.headers,
251
+ body: response.body,
252
+ },
253
+ });
254
+ return undefined;
255
+ }
256
+
257
+ // Errors, non-2xx responses, and native `Response` results release the
258
+ // reservation. Streams are not replayable.
259
+ await port.fail({ namespace, key, scope, fingerprint, error });
260
+ return undefined;
261
+ },
262
+ };
263
+ }
@@ -2,35 +2,42 @@
2
2
  * Hook utilities for @beignet/core/server
3
3
  */
4
4
 
5
- import type { AnyPorts } from "../../ports";
6
- import type { ServerHook } from "../http";
5
+ import type { AnyPorts } from "../../ports/index.js";
6
+ import type { ServerHook } from "../http.js";
7
7
 
8
8
  export {
9
9
  type AuthHookArgs,
10
10
  type AuthHooksOptions,
11
11
  type AuthRouteHooks,
12
12
  createAuthHooks,
13
- } from "./auth";
13
+ } from "./auth.js";
14
14
  export {
15
15
  applyCorsHeaders,
16
16
  type CorsConfig,
17
17
  createCorsHooks,
18
- } from "./cors";
18
+ } from "./cors.js";
19
19
  export {
20
20
  defaultMapErrorToResponse,
21
21
  type ErrorMappingConfig,
22
22
  type ErrorMappingResult,
23
- } from "./errors";
23
+ } from "./errors.js";
24
+ export {
25
+ type CtxWithIdempotency,
26
+ createIdempotencyHooks,
27
+ type IdempotencyHooksOptions,
28
+ type IdempotencyPorts,
29
+ } from "./idempotency.js";
24
30
  export {
25
31
  createLoggingHooks,
26
32
  type Logger,
27
33
  type LoggingConfig,
28
- } from "./logging";
34
+ } from "./logging.js";
29
35
  export {
30
36
  type CtxWithRateLimit,
31
37
  createRateLimitHooks,
38
+ type RateLimitIpSource,
32
39
  type RateLimitOptions,
33
- } from "./rate-limit";
40
+ } from "./rate-limit.js";
34
41
 
35
42
  /**
36
43
  * Flatten hook arrays into a single hook list.
@@ -2,9 +2,9 @@
2
2
  * Logging hook utilities for @beignet/core/server
3
3
  */
4
4
 
5
- import type { HttpContractConfig } from "../../contracts";
6
- import type { HttpRequestLike, ServerHook } from "../types";
7
- import { getRequestIdFromContext } from "./utils";
5
+ import type { HttpContractConfig } from "../../contracts/index.js";
6
+ import type { HttpRequestLike, ServerHook } from "../types.js";
7
+ import { getRequestIdFromContext } from "./utils.js";
8
8
 
9
9
  /**
10
10
  * Minimal logger shape accepted by `createLoggingHooks(...)`.
@@ -2,10 +2,14 @@
2
2
  * Rate limit hooks for @beignet/core/server
3
3
  */
4
4
 
5
- import type { RateLimitScope } from "../../contracts";
6
- import { AppError, httpErrors } from "../../errors";
7
- import type { ActivityActor, RateLimitPort } from "../../ports";
8
- import type { HttpRequestLike, ServerHook } from "../types";
5
+ import type { RateLimitScope } from "../../contracts/index.js";
6
+ import { AppError, httpErrors } from "../../errors/index.js";
7
+ import type { ActivityActor, RateLimitPort } from "../../ports/index.js";
8
+ import {
9
+ createProviderInstrumentation,
10
+ type ProviderInstrumentationTarget,
11
+ } from "../../providers/index.js";
12
+ import type { HttpRequestLike, ServerHook } from "../types.js";
9
13
 
10
14
  /**
11
15
  * Ports required by rate-limit hooks.
@@ -24,6 +28,23 @@ export type CtxWithRateLimit = {
24
28
 
25
29
  type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
26
30
 
31
+ /**
32
+ * Strategy for resolving the client IP used by `ip`-scoped limits.
33
+ *
34
+ * - `"x-forwarded-for-last"` (default): the last `x-forwarded-for` entry.
35
+ * This is the address appended by the platform's trusted reverse proxy and
36
+ * cannot be chosen by the client.
37
+ * - `"x-forwarded-for-first"`: the first `x-forwarded-for` entry. This value
38
+ * is client-controlled, so only use it when a trusted edge normalizes the
39
+ * header before it reaches the app.
40
+ * - A function receives the raw request and returns the client IP, for
41
+ * platform-specific headers such as `cf-connecting-ip`.
42
+ */
43
+ export type RateLimitIpSource =
44
+ | "x-forwarded-for-last"
45
+ | "x-forwarded-for-first"
46
+ | ((req: HttpRequestLike) => string | undefined);
47
+
27
48
  /**
28
49
  * Options for `createRateLimitHooks(...)`.
29
50
  */
@@ -48,14 +69,38 @@ export interface RateLimitOptions<Ctx> {
48
69
  scope: EarlyRateLimitScope;
49
70
  }) => string;
50
71
  /**
51
- * Resolve a client IP from the raw request.
72
+ * Resolve the client IP for `ip`-scoped limits.
73
+ *
74
+ * Defaults to `"x-forwarded-for-last"`, the entry appended by the
75
+ * platform's trusted proxy. Pass a function for platform-specific headers.
52
76
  */
53
- getClientIp?: (req: HttpRequestLike) => string | undefined;
77
+ ipSource?: RateLimitIpSource;
54
78
  }
55
79
 
56
- function defaultGetClientIp(req: HttpRequestLike): string | undefined {
57
- const xfwd = req.headers.get("x-forwarded-for") ?? "";
58
- return xfwd.split(",")[0].trim() || undefined;
80
+ function parseForwardedFor(req: HttpRequestLike): string[] {
81
+ const header = req.headers.get("x-forwarded-for") ?? "";
82
+ return header
83
+ .split(",")
84
+ .map((entry) => entry.trim())
85
+ .filter(Boolean);
86
+ }
87
+
88
+ function resolveClientIp(
89
+ req: HttpRequestLike,
90
+ ipSource: RateLimitIpSource,
91
+ ): string | undefined {
92
+ if (typeof ipSource === "function") {
93
+ return ipSource(req) || undefined;
94
+ }
95
+
96
+ const entries = parseForwardedFor(req);
97
+ if (entries.length === 0) {
98
+ return undefined;
99
+ }
100
+
101
+ return ipSource === "x-forwarded-for-first"
102
+ ? entries[0]
103
+ : entries[entries.length - 1];
59
104
  }
60
105
 
61
106
  function emitUserKey(userId: string): string {
@@ -104,7 +149,7 @@ function defaultEarlyRateLimitKey(
104
149
  }
105
150
 
106
151
  async function enforceRateLimit(
107
- rateLimit: RateLimitPort,
152
+ ports: RateLimitPorts,
108
153
  args: {
109
154
  key: string;
110
155
  limit: number;
@@ -112,7 +157,7 @@ async function enforceRateLimit(
112
157
  scope: RateLimitScope;
113
158
  },
114
159
  ): Promise<void> {
115
- const result = await rateLimit.hit({
160
+ const result = await ports.rateLimit.hit({
116
161
  key: args.key,
117
162
  limit: args.limit,
118
163
  windowSec: args.windowSec,
@@ -122,10 +167,30 @@ async function enforceRateLimit(
122
167
  return;
123
168
  }
124
169
 
170
+ // App ports may carry an `instrumentation` or `devtools` sink alongside the
171
+ // typed rate-limit port; the helper resolves them at runtime.
172
+ const instrumentation = createProviderInstrumentation(
173
+ ports as ProviderInstrumentationTarget,
174
+ {
175
+ providerName: "rate-limit",
176
+ watcher: "rateLimit",
177
+ },
178
+ );
179
+ instrumentation.custom({
180
+ name: "rateLimit.denied",
181
+ label: "Rate limit denied",
182
+ summary: `Rate limit denied for ${args.key}`,
183
+ details: {
184
+ key: args.key,
185
+ scope: args.scope,
186
+ limit: args.limit,
187
+ windowSec: args.windowSec,
188
+ },
189
+ });
190
+
125
191
  throw new AppError(
126
192
  httpErrors.TooManyRequests,
127
193
  {
128
- key: args.key,
129
194
  scope: args.scope,
130
195
  retryAfterSeconds: result.retryAfterSeconds,
131
196
  resetAt: result.resetAt?.toISOString() ?? null,
@@ -140,15 +205,18 @@ async function enforceRateLimit(
140
205
  * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run
141
206
  * in `onRequest` before context creation; user-scoped limits run in
142
207
  * `beforeHandle` after `ctx.actor` is available. Exceeded limits throw the
143
- * framework `TooManyRequests` app error.
208
+ * framework `TooManyRequests` app error with `scope`, `retryAfterSeconds`, and
209
+ * `resetAt` details. The bucket key is never sent to clients; denials emit a
210
+ * `rateLimit.denied` instrumentation event that carries the key for operators.
144
211
  *
145
- * @param options - Optional key builders and client-IP resolver.
212
+ * @param options - Optional key builders and client-IP source.
146
213
  * @returns A server hook backed by `ctx.ports.rateLimit`.
147
214
  */
148
215
  export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
149
216
  options: RateLimitOptions<Ctx> = {},
150
217
  ): ServerHook<Ctx, RateLimitPorts> {
151
- const getClientIp = options.getClientIp ?? defaultGetClientIp;
218
+ const ipSource = options.ipSource ?? "x-forwarded-for-last";
219
+ const getClientIp = (req: HttpRequestLike) => resolveClientIp(req, ipSource);
152
220
 
153
221
  return {
154
222
  name: "rate-limit",
@@ -167,7 +235,7 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
167
235
  options.earlyKey?.({ req, scope }) ??
168
236
  defaultEarlyRateLimitKey({ req, scope }, getClientIp);
169
237
 
170
- await enforceRateLimit(ports.rateLimit, {
238
+ await enforceRateLimit(ports, {
171
239
  key,
172
240
  limit: rlMeta.max,
173
241
  windowSec: rlMeta.windowSec,
@@ -191,7 +259,7 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
191
259
  options.key?.({ ctx, req, scope }) ??
192
260
  defaultRateLimitKey({ ctx, req, scope }, getClientIp);
193
261
 
194
- await enforceRateLimit(ctx.ports.rateLimit, {
262
+ await enforceRateLimit(ctx.ports, {
195
263
  key,
196
264
  limit: rlMeta.max,
197
265
  windowSec: rlMeta.windowSec,
@@ -1 +1 @@
1
- export * from "./hooks/index";
1
+ export * from "./hooks/index.js";
@@ -3,8 +3,8 @@ import type {
3
3
  InferHeaderSchemaOutput,
4
4
  InferOutput,
5
5
  StandardSchema,
6
- } from "../contracts";
7
- import type { AnyPorts } from "../ports";
6
+ } from "../contracts/index.js";
7
+ import type { AnyPorts } from "../ports/index.js";
8
8
 
9
9
  /**
10
10
  * Framework-neutral request shape consumed by Beignet server adapters.
@@ -84,6 +84,50 @@ export interface HttpResponseLike {
84
84
  */
85
85
  export type HttpResponse = HttpResponseLike | Response;
86
86
 
87
+ /**
88
+ * Framework-neutral Beignet API handler consumed by HTTP adapters.
89
+ */
90
+ export type HttpAdapterApiHandler = (
91
+ req: HttpRequestLike,
92
+ ) => Promise<HttpResponse>;
93
+
94
+ /**
95
+ * Native handler shape produced by an HTTP adapter.
96
+ */
97
+ export type HttpAdapterHandler<NativeRequest, NativeResponse> = (
98
+ req: NativeRequest,
99
+ ) => Promise<NativeResponse>;
100
+
101
+ /**
102
+ * Contract implemented by packages that adapt Beignet's framework-neutral
103
+ * server runtime to a platform HTTP API.
104
+ *
105
+ * Core owns request parsing, hooks, route matching, validation, error mapping,
106
+ * response ownership, and provider lifecycle. Adapters own only the conversion
107
+ * between the platform request/response types and Beignet's `HttpRequestLike`
108
+ * / `HttpResponse` boundary.
109
+ */
110
+ export interface HttpAdapter<NativeRequest, NativeResponse> {
111
+ /**
112
+ * Human-readable adapter name for diagnostics and documentation.
113
+ */
114
+ name: string;
115
+ /**
116
+ * Convert a platform request into Beignet's framework-neutral request shape.
117
+ */
118
+ toRequestLike(req: NativeRequest): HttpRequestLike;
119
+ /**
120
+ * Convert a Beignet response into the platform response type.
121
+ */
122
+ toNativeResponse(res: HttpResponse): NativeResponse | Promise<NativeResponse>;
123
+ /**
124
+ * Wrap a Beignet API handler in the platform's native handler shape.
125
+ */
126
+ createHandler(
127
+ handler: HttpAdapterApiHandler,
128
+ ): HttpAdapterHandler<NativeRequest, NativeResponse>;
129
+ }
130
+
87
131
  type InferSchemaOrFallback<
88
132
  T extends StandardSchema | null,
89
133
  Fallback,
@@ -130,7 +174,7 @@ export interface HandlerArgs<Ctx, C extends HttpContractConfig> {
130
174
  */
131
175
  req: HttpRequestLike;
132
176
  /**
133
- * Application context returned by `createContext`.
177
+ * Application context assembled by the server context blueprint.
134
178
  */
135
179
  ctx: Ctx;
136
180
  /**
@@ -183,10 +227,14 @@ export type RouteHookArgs<
183
227
  * Route hooks are for scoped policy and context enrichment such as
184
228
  * authentication, tenant resolution, feature gates, and idempotency. They add
185
229
  * fields to the handler context instead of replacing the app context.
230
+ *
231
+ * Hook additions must not include `gate`: the server re-attaches the gate
232
+ * declared by the context blueprint after every hook, so identity changes are
233
+ * picked up automatically.
186
234
  */
187
235
  export interface RouteHook<
188
236
  Ctx,
189
- AddedCtx extends object = Record<string, never>,
237
+ AddedCtx extends object & { gate?: never } = Record<string, never>,
190
238
  > {
191
239
  /**
192
240
  * Optional name used in diagnostics and devtools.
@@ -198,54 +246,24 @@ export interface RouteHook<
198
246
  resolve: (args: RouteHookArgs<Ctx>) => MaybePromise<AddedCtx | undefined>;
199
247
  }
200
248
 
201
- /**
202
- * Builder for route-scoped hooks.
203
- */
204
- export type RouteHookBuilder<Ctx> = {
205
- /**
206
- * Assign a diagnostic name to the hook.
207
- */
208
- name: (name: string) => RouteHookNamedBuilder<Ctx>;
209
- /**
210
- * Define the hook resolver.
211
- */
212
- resolve: <AddedCtx extends object>(
213
- resolve: RouteHook<Ctx, AddedCtx>["resolve"],
214
- ) => RouteHook<Ctx, AddedCtx>;
215
- };
249
+ type AddedCtxFromHook<Hook> =
250
+ Hook extends RouteHook<infer _Ctx, infer AddedCtx> ? AddedCtx : unknown;
216
251
 
217
- /**
218
- * Named builder for route-scoped hooks.
219
- */
220
- export type RouteHookNamedBuilder<Ctx> = {
221
- /**
222
- * Define the hook resolver.
223
- */
224
- resolve: <AddedCtx extends object>(
225
- resolve: RouteHook<Ctx, AddedCtx>["resolve"],
226
- ) => RouteHook<Ctx, AddedCtx>;
227
- };
252
+ type UnionToIntersection<Union> = (
253
+ Union extends unknown
254
+ ? (value: Union) => void
255
+ : never
256
+ ) extends (value: infer Intersection) => void
257
+ ? Intersection
258
+ : never;
228
259
 
229
260
  /**
230
- * Define a route-scoped hook.
231
- *
232
- * Route hooks enrich handler context for one route or route group. They should
233
- * throw application/framework errors for denials instead of returning HTTP
234
- * responses directly.
261
+ * Intersection of the context fields added by a route hook list.
235
262
  */
236
- export function defineRouteHook<Ctx>(): RouteHookBuilder<Ctx> {
237
- return {
238
- name: (name) => ({
239
- resolve: (resolve) => ({
240
- name,
241
- resolve,
242
- }),
243
- }),
244
- resolve: (resolve) => ({
245
- resolve,
246
- }),
247
- };
248
- }
263
+ export type AddedCtxFromHooks<Hooks extends readonly unknown[]> =
264
+ Hooks extends readonly []
265
+ ? unknown
266
+ : UnionToIntersection<AddedCtxFromHook<Hooks[number]>>;
249
267
 
250
268
  /**
251
269
  * Hook that runs after a route is matched but before request parsing and
@@ -294,10 +312,11 @@ export type BeforeHandleHook<
294
312
  }) => MaybePromise<BeforeHandleResult<Ctx>>;
295
313
 
296
314
  /**
297
- * Hook that runs before a framework-neutral response is returned.
315
+ * Hook that runs before the response is returned.
298
316
  *
299
317
  * Return a response to replace or decorate the outgoing response. Hooks run in
300
- * declaration order.
318
+ * declaration order. For native web `Response` results the hook receives a
319
+ * headers-only view and only header changes are applied.
301
320
  */
302
321
  export type BeforeSendHook<
303
322
  Ctx,
@@ -312,6 +331,13 @@ export type BeforeSendHook<
312
331
  body?: InferBody<C>;
313
332
  response: HttpResponseLike;
314
333
  error?: unknown;
334
+ /**
335
+ * True when the route returned a native web Response. The response argument
336
+ * is a headers-only view ({ status, headers }); the body is not readable and
337
+ * returned body/status changes are ignored. Header changes are merged onto
338
+ * the native Response.
339
+ */
340
+ native?: boolean;
315
341
  }) => MaybePromise<HttpResponseLike | undefined>;
316
342
 
317
343
  /**
@@ -395,7 +421,8 @@ export interface ServerHook<Ctx, Ports extends AnyPorts = AnyPorts> {
395
421
  */
396
422
  beforeHandle?: BeforeHandleHook<Ctx>;
397
423
  /**
398
- * Runs before a framework-neutral response is returned.
424
+ * Runs before the response is returned. Native web `Response` results get a
425
+ * headers-only view with `native: true`.
399
426
  */
400
427
  beforeSend?: BeforeSendHook<Ctx>;
401
428
  /**