@greatwait/contracts 0.1.0-alpha.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 (241) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -0
  3. package/dist/authority.d.ts +217 -0
  4. package/dist/authority.js +261 -0
  5. package/dist/callback-ownership.d.ts +58 -0
  6. package/dist/callback-ownership.js +71 -0
  7. package/dist/callback-safety.d.ts +38 -0
  8. package/dist/callback-safety.js +403 -0
  9. package/dist/compatibility.d.ts +82 -0
  10. package/dist/compatibility.js +115 -0
  11. package/dist/delivery.d.ts +401 -0
  12. package/dist/delivery.js +311 -0
  13. package/dist/endpoints.d.ts +305 -0
  14. package/dist/endpoints.js +179 -0
  15. package/dist/events.d.ts +62 -0
  16. package/dist/events.js +38 -0
  17. package/dist/first-value-journey.d.ts +305 -0
  18. package/dist/first-value-journey.js +466 -0
  19. package/dist/forecast.d.ts +443 -0
  20. package/dist/forecast.js +729 -0
  21. package/dist/identifiers.d.ts +109 -0
  22. package/dist/identifiers.js +131 -0
  23. package/dist/identity.d.ts +196 -0
  24. package/dist/identity.js +182 -0
  25. package/dist/index.d.ts +27 -0
  26. package/dist/index.js +27 -0
  27. package/dist/mcp.d.ts +51 -0
  28. package/dist/mcp.js +56 -0
  29. package/dist/oauth.d.ts +796 -0
  30. package/dist/oauth.js +708 -0
  31. package/dist/operations.d.ts +3985 -0
  32. package/dist/operations.js +1837 -0
  33. package/dist/partition.d.ts +771 -0
  34. package/dist/partition.js +206 -0
  35. package/dist/policy.d.ts +93 -0
  36. package/dist/policy.js +180 -0
  37. package/dist/problems.d.ts +158 -0
  38. package/dist/problems.js +104 -0
  39. package/dist/protocol.d.ts +308 -0
  40. package/dist/protocol.js +515 -0
  41. package/dist/scheduling-records.d.ts +395 -0
  42. package/dist/scheduling-records.js +284 -0
  43. package/dist/scheduling.d.ts +1098 -0
  44. package/dist/scheduling.js +1116 -0
  45. package/dist/secrets.d.ts +383 -0
  46. package/dist/secrets.js +272 -0
  47. package/dist/signatures.d.ts +704 -0
  48. package/dist/signatures.js +867 -0
  49. package/dist/tenancy.d.ts +178 -0
  50. package/dist/tenancy.js +145 -0
  51. package/dist/test-sink.d.ts +986 -0
  52. package/dist/test-sink.js +755 -0
  53. package/dist/time.d.ts +45 -0
  54. package/dist/time.js +128 -0
  55. package/dist/usage.d.ts +121 -0
  56. package/dist/usage.js +200 -0
  57. package/dist/webhooks.d.ts +116 -0
  58. package/dist/webhooks.js +161 -0
  59. package/dist/workspace.d.ts +92 -0
  60. package/dist/workspace.js +59 -0
  61. package/generated/v1/callback-delivery-signature.json +467 -0
  62. package/generated/v1/callback-endpoint-verification.json +59 -0
  63. package/generated/v1/callback-receiver.json +241 -0
  64. package/generated/v1/examples/attempts.list.response.json +24 -0
  65. package/generated/v1/examples/attempts.read.response.json +20 -0
  66. package/generated/v1/examples/credentials.create.request.json +5 -0
  67. package/generated/v1/examples/credentials.create.response.json +10 -0
  68. package/generated/v1/examples/credentials.list.response.json +17 -0
  69. package/generated/v1/examples/credentials.revoke.response.json +11 -0
  70. package/generated/v1/examples/credentials.rotate.request.json +3 -0
  71. package/generated/v1/examples/credentials.rotate.response.json +22 -0
  72. package/generated/v1/examples/endpoints.create.request.json +3 -0
  73. package/generated/v1/examples/endpoints.create.response.json +16 -0
  74. package/generated/v1/examples/endpoints.disable.response.json +15 -0
  75. package/generated/v1/examples/endpoints.list.response.json +21 -0
  76. package/generated/v1/examples/endpoints.read.response.json +14 -0
  77. package/generated/v1/examples/endpoints.rotateSigningKey.request.json +3 -0
  78. package/generated/v1/examples/endpoints.rotateSigningKey.response.json +23 -0
  79. package/generated/v1/examples/endpoints.update.request.json +3 -0
  80. package/generated/v1/examples/endpoints.update.response.json +13 -0
  81. package/generated/v1/examples/endpoints.verify.response.json +15 -0
  82. package/generated/v1/examples/forecast.read.response.json +28 -0
  83. package/generated/v1/examples/forecast.windows.response.json +21 -0
  84. package/generated/v1/examples/grants.list.response.json +17 -0
  85. package/generated/v1/examples/grants.revoke.response.json +16 -0
  86. package/generated/v1/examples/meta.scaffold.response.json +5 -0
  87. package/generated/v1/examples/meta.version.response.json +5 -0
  88. package/generated/v1/examples/occurrences.list.response.json +17 -0
  89. package/generated/v1/examples/occurrences.read.response.json +15 -0
  90. package/generated/v1/examples/occurrences.redeliver.response.json +16 -0
  91. package/generated/v1/examples/projects.create.request.json +3 -0
  92. package/generated/v1/examples/projects.create.response.json +5 -0
  93. package/generated/v1/examples/projects.list.response.json +9 -0
  94. package/generated/v1/examples/schedules.archive.response.json +25 -0
  95. package/generated/v1/examples/schedules.cancel.response.json +24 -0
  96. package/generated/v1/examples/schedules.create.request.json +9 -0
  97. package/generated/v1/examples/schedules.create.response.json +25 -0
  98. package/generated/v1/examples/schedules.list.response.json +23 -0
  99. package/generated/v1/examples/schedules.pause.response.json +23 -0
  100. package/generated/v1/examples/schedules.read.response.json +25 -0
  101. package/generated/v1/examples/schedules.replace.request.json +8 -0
  102. package/generated/v1/examples/schedules.replace.response.json +25 -0
  103. package/generated/v1/examples/schedules.resume.response.json +23 -0
  104. package/generated/v1/examples/sinkdeliveries.list.response.json +19 -0
  105. package/generated/v1/examples/sinkdeliveries.read.response.json +16 -0
  106. package/generated/v1/examples/testsinks.configure.request.json +3 -0
  107. package/generated/v1/examples/testsinks.configure.response.json +13 -0
  108. package/generated/v1/examples/testsinks.create.request.json +8 -0
  109. package/generated/v1/examples/testsinks.create.response.json +13 -0
  110. package/generated/v1/examples/testsinks.delete.response.json +13 -0
  111. package/generated/v1/examples/testsinks.list.response.json +20 -0
  112. package/generated/v1/examples/testsinks.read.response.json +14 -0
  113. package/generated/v1/examples/usage.read.response.json +20 -0
  114. package/generated/v1/first-value-journey.json +436 -0
  115. package/generated/v1/manifest.json +1037 -0
  116. package/generated/v1/openapi.json +23665 -0
  117. package/generated/v1/policy-matrix.json +305 -0
  118. package/generated/v1/schemas/attempts.list.query.json +16 -0
  119. package/generated/v1/schemas/attempts.list.response.json +175 -0
  120. package/generated/v1/schemas/attempts.read.response.json +168 -0
  121. package/generated/v1/schemas/credentials.create.request.json +39 -0
  122. package/generated/v1/schemas/credentials.create.response.json +88 -0
  123. package/generated/v1/schemas/credentials.list.query.json +16 -0
  124. package/generated/v1/schemas/credentials.list.response.json +124 -0
  125. package/generated/v1/schemas/credentials.revoke.response.json +84 -0
  126. package/generated/v1/schemas/credentials.rotate.request.json +11 -0
  127. package/generated/v1/schemas/credentials.rotate.response.json +171 -0
  128. package/generated/v1/schemas/endpoints.create.request.json +12 -0
  129. package/generated/v1/schemas/endpoints.create.response.json +115 -0
  130. package/generated/v1/schemas/endpoints.disable.response-headers.json +11 -0
  131. package/generated/v1/schemas/endpoints.disable.response.json +100 -0
  132. package/generated/v1/schemas/endpoints.list.query.json +16 -0
  133. package/generated/v1/schemas/endpoints.list.response.json +146 -0
  134. package/generated/v1/schemas/endpoints.read.response-headers.json +11 -0
  135. package/generated/v1/schemas/endpoints.read.response.json +100 -0
  136. package/generated/v1/schemas/endpoints.rotateSigningKey.request-headers.json +10 -0
  137. package/generated/v1/schemas/endpoints.rotateSigningKey.request.json +11 -0
  138. package/generated/v1/schemas/endpoints.rotateSigningKey.response-headers.json +11 -0
  139. package/generated/v1/schemas/endpoints.rotateSigningKey.response.json +118 -0
  140. package/generated/v1/schemas/endpoints.update.request-headers.json +10 -0
  141. package/generated/v1/schemas/endpoints.update.request.json +12 -0
  142. package/generated/v1/schemas/endpoints.update.response-headers.json +11 -0
  143. package/generated/v1/schemas/endpoints.update.response.json +100 -0
  144. package/generated/v1/schemas/endpoints.verify.request-headers.json +10 -0
  145. package/generated/v1/schemas/endpoints.verify.response-headers.json +11 -0
  146. package/generated/v1/schemas/endpoints.verify.response.json +100 -0
  147. package/generated/v1/schemas/forecast.read.query.json +26 -0
  148. package/generated/v1/schemas/forecast.read.response.json +100 -0
  149. package/generated/v1/schemas/forecast.windows.query.json +26 -0
  150. package/generated/v1/schemas/forecast.windows.response.json +94 -0
  151. package/generated/v1/schemas/grants.list.response.json +98 -0
  152. package/generated/v1/schemas/grants.revoke.response.json +95 -0
  153. package/generated/v1/schemas/meta.scaffold.response.json +36 -0
  154. package/generated/v1/schemas/meta.version.response.json +19 -0
  155. package/generated/v1/schemas/occurrences.list.query.json +16 -0
  156. package/generated/v1/schemas/occurrences.list.response.json +83 -0
  157. package/generated/v1/schemas/occurrences.read.response.json +76 -0
  158. package/generated/v1/schemas/occurrences.redeliver.request-headers.json +13 -0
  159. package/generated/v1/schemas/occurrences.redeliver.response-headers.json +10 -0
  160. package/generated/v1/schemas/occurrences.redeliver.response.json +168 -0
  161. package/generated/v1/schemas/projects.create.request.json +12 -0
  162. package/generated/v1/schemas/projects.create.response.json +25 -0
  163. package/generated/v1/schemas/projects.list.response.json +35 -0
  164. package/generated/v1/schemas/schedules.archive.request-headers.json +10 -0
  165. package/generated/v1/schemas/schedules.archive.response-headers.json +11 -0
  166. package/generated/v1/schemas/schedules.archive.response.json +189 -0
  167. package/generated/v1/schemas/schedules.cancel.request-headers.json +10 -0
  168. package/generated/v1/schemas/schedules.cancel.response-headers.json +11 -0
  169. package/generated/v1/schemas/schedules.cancel.response.json +189 -0
  170. package/generated/v1/schemas/schedules.create.request-headers.json +12 -0
  171. package/generated/v1/schemas/schedules.create.request.json +99 -0
  172. package/generated/v1/schemas/schedules.create.response-headers.json +15 -0
  173. package/generated/v1/schemas/schedules.create.response.json +189 -0
  174. package/generated/v1/schemas/schedules.list.query.json +16 -0
  175. package/generated/v1/schemas/schedules.list.response.json +170 -0
  176. package/generated/v1/schemas/schedules.pause.request-headers.json +10 -0
  177. package/generated/v1/schemas/schedules.pause.response-headers.json +11 -0
  178. package/generated/v1/schemas/schedules.pause.response.json +189 -0
  179. package/generated/v1/schemas/schedules.read.response-headers.json +11 -0
  180. package/generated/v1/schemas/schedules.read.response.json +189 -0
  181. package/generated/v1/schemas/schedules.replace.request-headers.json +10 -0
  182. package/generated/v1/schemas/schedules.replace.request.json +99 -0
  183. package/generated/v1/schemas/schedules.replace.response-headers.json +11 -0
  184. package/generated/v1/schemas/schedules.replace.response.json +189 -0
  185. package/generated/v1/schemas/schedules.resume.request-headers.json +10 -0
  186. package/generated/v1/schemas/schedules.resume.response-headers.json +11 -0
  187. package/generated/v1/schemas/schedules.resume.response.json +189 -0
  188. package/generated/v1/schemas/sinkdeliveries.list.query.json +16 -0
  189. package/generated/v1/schemas/sinkdeliveries.list.response.json +126 -0
  190. package/generated/v1/schemas/sinkdeliveries.read.response.json +98 -0
  191. package/generated/v1/schemas/testsinks.configure.request.json +66 -0
  192. package/generated/v1/schemas/testsinks.configure.response.json +115 -0
  193. package/generated/v1/schemas/testsinks.create.request.json +76 -0
  194. package/generated/v1/schemas/testsinks.create.response.json +115 -0
  195. package/generated/v1/schemas/testsinks.delete.response.json +115 -0
  196. package/generated/v1/schemas/testsinks.list.query.json +16 -0
  197. package/generated/v1/schemas/testsinks.list.response.json +161 -0
  198. package/generated/v1/schemas/testsinks.read.response.json +115 -0
  199. package/generated/v1/schemas/usage.read.query.json +15 -0
  200. package/generated/v1/schemas/usage.read.response.json +71 -0
  201. package/generated/v1/snippets/attempts.list.json +5 -0
  202. package/generated/v1/snippets/attempts.read.json +5 -0
  203. package/generated/v1/snippets/credentials.create.json +5 -0
  204. package/generated/v1/snippets/credentials.list.json +5 -0
  205. package/generated/v1/snippets/credentials.revoke.json +5 -0
  206. package/generated/v1/snippets/credentials.rotate.json +5 -0
  207. package/generated/v1/snippets/endpoints.create.json +5 -0
  208. package/generated/v1/snippets/endpoints.disable.json +5 -0
  209. package/generated/v1/snippets/endpoints.list.json +5 -0
  210. package/generated/v1/snippets/endpoints.read.json +5 -0
  211. package/generated/v1/snippets/endpoints.rotateSigningKey.json +5 -0
  212. package/generated/v1/snippets/endpoints.update.json +5 -0
  213. package/generated/v1/snippets/endpoints.verify.json +5 -0
  214. package/generated/v1/snippets/forecast.read.json +5 -0
  215. package/generated/v1/snippets/forecast.windows.json +5 -0
  216. package/generated/v1/snippets/grants.list.json +5 -0
  217. package/generated/v1/snippets/grants.revoke.json +5 -0
  218. package/generated/v1/snippets/meta.scaffold.json +5 -0
  219. package/generated/v1/snippets/meta.version.json +5 -0
  220. package/generated/v1/snippets/occurrences.list.json +5 -0
  221. package/generated/v1/snippets/occurrences.read.json +5 -0
  222. package/generated/v1/snippets/occurrences.redeliver.json +5 -0
  223. package/generated/v1/snippets/projects.create.json +5 -0
  224. package/generated/v1/snippets/projects.list.json +5 -0
  225. package/generated/v1/snippets/schedules.archive.json +5 -0
  226. package/generated/v1/snippets/schedules.cancel.json +5 -0
  227. package/generated/v1/snippets/schedules.create.json +5 -0
  228. package/generated/v1/snippets/schedules.list.json +5 -0
  229. package/generated/v1/snippets/schedules.pause.json +5 -0
  230. package/generated/v1/snippets/schedules.read.json +5 -0
  231. package/generated/v1/snippets/schedules.replace.json +5 -0
  232. package/generated/v1/snippets/schedules.resume.json +5 -0
  233. package/generated/v1/snippets/sinkdeliveries.list.json +5 -0
  234. package/generated/v1/snippets/sinkdeliveries.read.json +5 -0
  235. package/generated/v1/snippets/testsinks.configure.json +5 -0
  236. package/generated/v1/snippets/testsinks.create.json +5 -0
  237. package/generated/v1/snippets/testsinks.delete.json +5 -0
  238. package/generated/v1/snippets/testsinks.list.json +5 -0
  239. package/generated/v1/snippets/testsinks.read.json +5 -0
  240. package/generated/v1/snippets/usage.read.json +5 -0
  241. package/package.json +153 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Whether a receiver has to prove it owns a destination before Greatwait
3
+ * delivers anything there.
4
+ *
5
+ * **Zod-free and dependency-free on purpose.** Both the API Worker and the
6
+ * Scheduler Worker enforce this, and `apps/scheduler/src/contract-runtime.ts`
7
+ * records what reaching the contract barrel costs a Durable Object cold start.
8
+ * This module is an enumeration and two pure functions, so the subpath export
9
+ * `@greatwait/contracts/callback-ownership` costs the scheduler nothing.
10
+ *
11
+ * **Why the rule is stated once rather than twice.** Two sites resolve a
12
+ * destination for sending — `apps/api` when a schedule is created, and
13
+ * `apps/scheduler` at the moment of delivery — and they live in different
14
+ * workspaces. If each wrote its own status predicate they would eventually
15
+ * disagree, and the shape of that disagreement is a schedule the product
16
+ * accepts and then silently never delivers. `sendableStatuses` is the single
17
+ * statement both build their query from.
18
+ */
19
+ /**
20
+ * The answers this product understands, in the order a person would read them
21
+ * as increasing strictness.
22
+ *
23
+ * `not-required` does **not** mean verification is gone. A destination may
24
+ * still answer its ownership challenge, and doing so still records the instant
25
+ * it proved possession of the secret. It means nothing waits for that.
26
+ */
27
+ export const CALLBACK_OWNERSHIP_POLICIES = [
28
+ "not-required",
29
+ "required",
30
+ ];
31
+ /**
32
+ * What an unset variable means.
33
+ *
34
+ * Not required, decided on 2026-08-18 after the first invited customers walked
35
+ * the product: nobody writes an HMAC responder to try a scheduler, so requiring
36
+ * one before the first delivery cost the whole top of the funnel to buy a
37
+ * property that `evaluateCallbackUrl`, the delivery rate limits and the request
38
+ * signature already defend from other directions. See W4-09.
39
+ */
40
+ export const DEFAULT_CALLBACK_OWNERSHIP_POLICY = "not-required";
41
+ /**
42
+ * Read the policy a deployment declares, or refuse to run.
43
+ *
44
+ * **An unrecognised value throws rather than falling back.** The entire reason
45
+ * this is configuration is that turning the requirement on or off should be a
46
+ * visible decision; a typo, a rename, or a half-finished edit silently read as
47
+ * `not-required` would hand back exactly the off-switch the setting exists to
48
+ * make visible. Absent is a decision — it is the documented default — and
49
+ * `"requred"` is not.
50
+ */
51
+ export function readCallbackOwnershipPolicy(value) {
52
+ if (value === undefined || value === "") {
53
+ return DEFAULT_CALLBACK_OWNERSHIP_POLICY;
54
+ }
55
+ const known = CALLBACK_OWNERSHIP_POLICIES.find((policy) => policy === value);
56
+ if (known === undefined) {
57
+ throw new Error(`GREATWAIT_CALLBACK_OWNERSHIP is ${JSON.stringify(value)}, which this service does not understand. Known values: ${CALLBACK_OWNERSHIP_POLICIES.join(", ")}. Leave it unset for ${DEFAULT_CALLBACK_OWNERSHIP_POLICY}.`);
58
+ }
59
+ return known;
60
+ }
61
+ /**
62
+ * The destination statuses a send may resolve under a policy, as a query
63
+ * builds its `IN` list from.
64
+ *
65
+ * `disabled` is absent from both and always will be: disabling is terminal and
66
+ * is the one refusal this setting does not reach. A caller that widened this to
67
+ * include it would be delivering to a destination somebody turned off.
68
+ */
69
+ export function sendableStatuses(policy) {
70
+ return policy === "required" ? ["verified"] : ["pending", "verified"];
71
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Stable reasons a callback URL can fail the syntax/address policy.
3
+ *
4
+ * DNS is deliberately not represented here: names are resolved and every
5
+ * returned address is passed through `isPublicCallbackAddress` by the caller.
6
+ *
7
+ * **That caller is endpoint creation and verification, and since 2026-08-11 it
8
+ * is nowhere else.** This comment used to end "and immediately before a send",
9
+ * which FXO-1566 made false when it removed the send-time re-resolution. What
10
+ * runs at send time is `evaluateCallbackUrl` alone — a string check, which
11
+ * cannot see where a hostname points now. FXO-1575 measured what the platform
12
+ * does with the resulting window; see
13
+ * `docs/delivery/follow-ups/fxo-1575-what-global-fetch-strictly-public-blocks.md`.
14
+ */
15
+ export declare const CALLBACK_URL_REJECTION_REASONS: readonly ["invalid_url", "https_required", "credentials_forbidden", "fragment_forbidden", "port_forbidden", "hostname_forbidden", "address_forbidden"];
16
+ export type CallbackUrlRejectionReason = (typeof CALLBACK_URL_REJECTION_REASONS)[number];
17
+ /** 32 random bytes encoded without padding, after this visible namespace. */
18
+ export declare const CALLBACK_ENDPOINT_SECRET_PATTERN: RegExp;
19
+ type CallbackUrlEvaluation = {
20
+ readonly hostname: string;
21
+ readonly ok: true;
22
+ /** The WHATWG-canonical URL, including its path/query. */
23
+ readonly url: string;
24
+ } | {
25
+ readonly ok: false;
26
+ readonly reason: CallbackUrlRejectionReason;
27
+ };
28
+ /** Evaluate the URL-only half of the callback destination policy. */
29
+ export declare function evaluateCallbackUrl(value: string): CallbackUrlEvaluation;
30
+ /**
31
+ * Whether a literal address is globally reachable callback unicast space.
32
+ *
33
+ * The function accepts WHATWG IPv4 number forms because the URL parser accepts
34
+ * them. For example, `0x7f000001`, `0177.0.0.1`, and `2130706433` all classify
35
+ * as loopback rather than escaping the range checks through spelling alone.
36
+ */
37
+ export declare function isPublicCallbackAddress(address: string): boolean;
38
+ export {};
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Stable reasons a callback URL can fail the syntax/address policy.
3
+ *
4
+ * DNS is deliberately not represented here: names are resolved and every
5
+ * returned address is passed through `isPublicCallbackAddress` by the caller.
6
+ *
7
+ * **That caller is endpoint creation and verification, and since 2026-08-11 it
8
+ * is nowhere else.** This comment used to end "and immediately before a send",
9
+ * which FXO-1566 made false when it removed the send-time re-resolution. What
10
+ * runs at send time is `evaluateCallbackUrl` alone — a string check, which
11
+ * cannot see where a hostname points now. FXO-1575 measured what the platform
12
+ * does with the resulting window; see
13
+ * `docs/delivery/follow-ups/fxo-1575-what-global-fetch-strictly-public-blocks.md`.
14
+ */
15
+ export const CALLBACK_URL_REJECTION_REASONS = [
16
+ "invalid_url",
17
+ "https_required",
18
+ "credentials_forbidden",
19
+ "fragment_forbidden",
20
+ "port_forbidden",
21
+ "hostname_forbidden",
22
+ "address_forbidden",
23
+ ];
24
+ /** 32 random bytes encoded without padding, after this visible namespace. */
25
+ export const CALLBACK_ENDPOINT_SECRET_PATTERN = /^ptsk_(?:live|test)_[A-Za-z0-9_-]{43}$/u;
26
+ /**
27
+ * IANA IPv4 Special-Purpose Address Space entries that are not globally
28
+ * reachable, last updated 2025-10-09, plus multicast and one cloud-platform
29
+ * virtual address. The two globally reachable exceptions in 192.0.0.0/24 are
30
+ * handled before this table.
31
+ */
32
+ const FORBIDDEN_IPV4_CIDRS = [
33
+ ipv4Cidr("0.0.0.0", 8),
34
+ ipv4Cidr("10.0.0.0", 8),
35
+ ipv4Cidr("100.64.0.0", 10),
36
+ ipv4Cidr("127.0.0.0", 8),
37
+ ipv4Cidr("169.254.0.0", 16),
38
+ ipv4Cidr("172.16.0.0", 12),
39
+ ipv4Cidr("192.0.0.0", 24),
40
+ ipv4Cidr("192.0.2.0", 24),
41
+ ipv4Cidr("192.88.99.0", 24),
42
+ ipv4Cidr("192.168.0.0", 16),
43
+ ipv4Cidr("198.18.0.0", 15),
44
+ ipv4Cidr("198.51.100.0", 24),
45
+ ipv4Cidr("203.0.113.0", 24),
46
+ // IPv4 multicast is not unicast callback space.
47
+ ipv4Cidr("224.0.0.0", 4),
48
+ ipv4Cidr("240.0.0.0", 4),
49
+ // Azure's host virtual IP exposes platform services from inside a VM/VNet.
50
+ ipv4Cidr("168.63.129.16", 32),
51
+ ];
52
+ const GLOBALLY_REACHABLE_IPV4_EXCEPTIONS = new Set([
53
+ ipv4Number([192, 0, 0, 9]),
54
+ ipv4Number([192, 0, 0, 10]),
55
+ ]);
56
+ /**
57
+ * Fail-closed IPv6 public-unicast allowlist.
58
+ *
59
+ * This is the ALLOCATED portion of IANA's "IPv6 Global Unicast Address Space"
60
+ * registry, last updated 2025-10-10. The broad 2001::/23 IETF allocation is
61
+ * intentionally replaced by only its entries whose IANA special-purpose
62
+ * records say `Globally Reachable: True` (special registry last updated
63
+ * 2025-10-09). 2001:db8::/32 is subtracted below because it sits inside the
64
+ * allocated 2001:c00::/23 block. Unlisted and RESERVED space therefore fails
65
+ * closed when IANA changes the registry.
66
+ *
67
+ * Translation/mapping ranges (including 64:ff9b::/96, IPv4-compatible,
68
+ * IPv4-mapped, 6to4 and Teredo) are absent on purpose. That prevents an IPv4
69
+ * dotted or hexadecimal tail from smuggling a forbidden destination through
70
+ * a superficially IPv6 literal.
71
+ */
72
+ const PUBLIC_IPV6_CIDRS = [
73
+ // Globally reachable exceptions inside IANA's otherwise non-global /23.
74
+ ipv6Cidr("2001:1::1", 128),
75
+ ipv6Cidr("2001:1::2", 128),
76
+ ipv6Cidr("2001:1::3", 128),
77
+ ipv6Cidr("2001:3::", 32),
78
+ ipv6Cidr("2001:4:112::", 48),
79
+ ipv6Cidr("2001:20::", 28),
80
+ ipv6Cidr("2001:30::", 28),
81
+ // RIR/IANA allocations from the global-unicast registry.
82
+ ipv6Cidr("2001:200::", 23),
83
+ ipv6Cidr("2001:400::", 23),
84
+ ipv6Cidr("2001:600::", 23),
85
+ ipv6Cidr("2001:800::", 22),
86
+ ipv6Cidr("2001:c00::", 23),
87
+ ipv6Cidr("2001:e00::", 23),
88
+ ipv6Cidr("2001:1200::", 23),
89
+ ipv6Cidr("2001:1400::", 22),
90
+ ipv6Cidr("2001:1800::", 23),
91
+ ipv6Cidr("2001:1a00::", 23),
92
+ ipv6Cidr("2001:1c00::", 22),
93
+ ipv6Cidr("2001:2000::", 19),
94
+ ipv6Cidr("2001:4000::", 23),
95
+ ipv6Cidr("2001:4200::", 23),
96
+ ipv6Cidr("2001:4400::", 23),
97
+ ipv6Cidr("2001:4600::", 23),
98
+ ipv6Cidr("2001:4800::", 23),
99
+ ipv6Cidr("2001:4a00::", 23),
100
+ ipv6Cidr("2001:4c00::", 23),
101
+ ipv6Cidr("2001:5000::", 20),
102
+ ipv6Cidr("2001:8000::", 19),
103
+ ipv6Cidr("2001:a000::", 20),
104
+ ipv6Cidr("2001:b000::", 20),
105
+ ipv6Cidr("2003::", 18),
106
+ ipv6Cidr("2400::", 12),
107
+ ipv6Cidr("2410::", 12),
108
+ ipv6Cidr("2600::", 12),
109
+ ipv6Cidr("2610::", 23),
110
+ ipv6Cidr("2620::", 23),
111
+ ipv6Cidr("2630::", 12),
112
+ ipv6Cidr("2800::", 12),
113
+ ipv6Cidr("2a00::", 12),
114
+ ipv6Cidr("2a10::", 12),
115
+ ipv6Cidr("2c00::", 12),
116
+ ];
117
+ const FORBIDDEN_IPV6_CIDRS = [ipv6Cidr("2001:db8::", 32)];
118
+ const FORBIDDEN_NAME_SUFFIXES = [
119
+ "localhost",
120
+ "local",
121
+ "localdomain",
122
+ "internal",
123
+ "home.arpa",
124
+ ];
125
+ /**
126
+ * Runtime names are denied even when their DNS currently returns public IPs:
127
+ * allowing the product to call itself turns any future internal route into a
128
+ * customer-controlled reflection surface. Both custom-domain forms follow the
129
+ * deployable host vocabulary; workers.dev/pages.dev cover provider hostnames.
130
+ *
131
+ * **Two zones, because a rename does not retire a name.** W0-11 moved every
132
+ * address onto `greatwait.dev`, where a stage is `api.greatwait.dev` and
133
+ * `api-staging.greatwait.dev` — a shape the `flowxo.com` pattern does not
134
+ * match, so for as long as this recognised only the old form a customer could
135
+ * aim a callback at the product's own API and get exactly the reflection
136
+ * surface this rule exists to deny. The old form stays because the old names
137
+ * keep resolving until their custom domains are detached, and because a DNS
138
+ * record somebody forgets is precisely what this should still refuse.
139
+ */
140
+ const GREATWAIT_RUNTIME_NAME = /^(?:greatwait-(?:api|app|scheduler|contract-mock|postgres-runtime)(?:-[a-z0-9-]+)?\.flowxo\.com|(?:api|app|scheduler|contract-mock|postgres-runtime)(?:-[a-z0-9-]+)?\.greatwait\.dev)$/u;
141
+ const GREATWAIT_PROVIDER_NAME = /^greatwait-(?:api|app|scheduler|contract-mock|postgres-runtime)(?:-[a-z0-9-]+)?(?:\..+)?\.(?:workers|pages)\.dev$/u;
142
+ const GREATWAIT_WORKER_NAME = /^greatwait-(?:api|app|scheduler|contract-mock|postgres-runtime)(?:-[a-z0-9-]+)?$/u;
143
+ /** Evaluate the URL-only half of the callback destination policy. */
144
+ export function evaluateCallbackUrl(value) {
145
+ if (value.trim() !== value) {
146
+ return reject("invalid_url");
147
+ }
148
+ let parsed;
149
+ try {
150
+ parsed = new URL(value);
151
+ }
152
+ catch {
153
+ return reject("invalid_url");
154
+ }
155
+ if (parsed.protocol !== "https:") {
156
+ return reject("https_required");
157
+ }
158
+ // URL.username cannot distinguish `https://@host` after canonicalization,
159
+ // so inspect the raw authority as well as the parsed credential fields.
160
+ if (parsed.username.length > 0 ||
161
+ parsed.password.length > 0 ||
162
+ rawAuthorityContainsUserinfo(value)) {
163
+ return reject("credentials_forbidden");
164
+ }
165
+ // Likewise, URL.hash is empty for a trailing `#`; policy forbids the
166
+ // delimiter, not merely a non-empty fragment value.
167
+ if (value.includes("#")) {
168
+ return reject("fragment_forbidden");
169
+ }
170
+ // WHATWG removes the canonical default `:443`, so an empty parsed port means
171
+ // the effective port is 443 whether the caller wrote it or omitted it.
172
+ if (parsed.port !== "") {
173
+ return reject("port_forbidden");
174
+ }
175
+ if (rawHostnameHasEmptyLabel(value)) {
176
+ return reject("hostname_forbidden");
177
+ }
178
+ const hostname = unbracket(parsed.hostname).toLowerCase();
179
+ if (hostname.length === 0) {
180
+ return reject("invalid_url");
181
+ }
182
+ if (isIpv6Text(hostname) || isCanonicalIpv4(hostname)) {
183
+ if (!isPublicCallbackAddress(hostname)) {
184
+ return reject("address_forbidden");
185
+ }
186
+ }
187
+ else if (!isAllowedRegisteredName(hostname)) {
188
+ return reject("hostname_forbidden");
189
+ }
190
+ return { hostname, ok: true, url: parsed.href };
191
+ }
192
+ /**
193
+ * Whether a literal address is globally reachable callback unicast space.
194
+ *
195
+ * The function accepts WHATWG IPv4 number forms because the URL parser accepts
196
+ * them. For example, `0x7f000001`, `0177.0.0.1`, and `2130706433` all classify
197
+ * as loopback rather than escaping the range checks through spelling alone.
198
+ */
199
+ export function isPublicCallbackAddress(address) {
200
+ if (address.length === 0 || address.trim() !== address) {
201
+ return false;
202
+ }
203
+ const hasOpeningBracket = address.startsWith("[");
204
+ const hasClosingBracket = address.endsWith("]");
205
+ if (hasOpeningBracket !== hasClosingBracket) {
206
+ return false;
207
+ }
208
+ const bare = unbracket(address).toLowerCase();
209
+ if (hasOpeningBracket && !isIpv6Text(bare)) {
210
+ return false;
211
+ }
212
+ if (isIpv6Text(bare)) {
213
+ const parsed = parseIpv6(bare);
214
+ if (parsed === undefined) {
215
+ return false;
216
+ }
217
+ if (FORBIDDEN_IPV6_CIDRS.some((cidr) => ipv6InCidr(parsed, cidr))) {
218
+ return false;
219
+ }
220
+ return PUBLIC_IPV6_CIDRS.some((cidr) => ipv6InCidr(parsed, cidr));
221
+ }
222
+ const parsed = parseWhatwgIpv4(bare);
223
+ if (parsed === undefined) {
224
+ return false;
225
+ }
226
+ if (GLOBALLY_REACHABLE_IPV4_EXCEPTIONS.has(parsed)) {
227
+ return true;
228
+ }
229
+ return !FORBIDDEN_IPV4_CIDRS.some((cidr) => ipv4InCidr(parsed, cidr));
230
+ }
231
+ function reject(reason) {
232
+ return { ok: false, reason };
233
+ }
234
+ function rawAuthorityContainsUserinfo(value) {
235
+ return rawAuthority(value)?.includes("@") ?? false;
236
+ }
237
+ function rawHostnameHasEmptyLabel(value) {
238
+ const authority = rawAuthority(value);
239
+ if (authority === undefined) {
240
+ return false;
241
+ }
242
+ const withoutUserinfo = authority.slice(authority.lastIndexOf("@") + 1);
243
+ // Colons inside brackets are IPv6 separators, never DNS-label separators.
244
+ if (withoutUserinfo.startsWith("[")) {
245
+ return false;
246
+ }
247
+ const portStart = withoutUserinfo.lastIndexOf(":");
248
+ const rawHostname = portStart === -1 ? withoutUserinfo : withoutUserinfo.slice(0, portStart);
249
+ // WHATWG treats percent-encoded dots as label separators too.
250
+ const hostname = rawHostname.replace(/%2e/giu, ".");
251
+ return (hostname.startsWith(".") ||
252
+ hostname.endsWith(".") ||
253
+ hostname.includes(".."));
254
+ }
255
+ function rawAuthority(value) {
256
+ const schemeEnd = value.indexOf("://");
257
+ if (schemeEnd < 0) {
258
+ return undefined;
259
+ }
260
+ const authorityStart = schemeEnd + 3;
261
+ const possibleEnds = ["/", "?", "#"]
262
+ .map((delimiter) => value.indexOf(delimiter, authorityStart))
263
+ .filter((index) => index >= 0);
264
+ const authorityEnd = possibleEnds.length === 0 ? value.length : Math.min(...possibleEnds);
265
+ return value.slice(authorityStart, authorityEnd);
266
+ }
267
+ function isAllowedRegisteredName(hostname) {
268
+ if (hostname.startsWith(".") ||
269
+ hostname.endsWith(".") ||
270
+ hostname.includes("..") ||
271
+ hostname.length > 253) {
272
+ return false;
273
+ }
274
+ if (FORBIDDEN_NAME_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))) {
275
+ return false;
276
+ }
277
+ if (GREATWAIT_RUNTIME_NAME.test(hostname) ||
278
+ GREATWAIT_PROVIDER_NAME.test(hostname) ||
279
+ GREATWAIT_WORKER_NAME.test(hostname)) {
280
+ return false;
281
+ }
282
+ return hostname
283
+ .split(".")
284
+ .every((label) => label.length > 0 &&
285
+ label.length <= 63 &&
286
+ /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(label));
287
+ }
288
+ function unbracket(value) {
289
+ return value.startsWith("[") && value.endsWith("]")
290
+ ? value.slice(1, -1)
291
+ : value;
292
+ }
293
+ function isIpv6Text(value) {
294
+ return value.includes(":");
295
+ }
296
+ function isCanonicalIpv4(value) {
297
+ return /^\d+\.\d+\.\d+\.\d+$/u.test(value);
298
+ }
299
+ function parseWhatwgIpv4(value) {
300
+ if (value.startsWith(".") ||
301
+ value.endsWith(".") ||
302
+ value.includes("..") ||
303
+ ["/", "@", "?", "#", "[", "]"].some((char) => value.includes(char))) {
304
+ return undefined;
305
+ }
306
+ let hostname;
307
+ try {
308
+ hostname = new URL(`http://${value}/`).hostname;
309
+ }
310
+ catch {
311
+ return undefined;
312
+ }
313
+ const parts = hostname.split(".");
314
+ if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/u.test(part))) {
315
+ return undefined;
316
+ }
317
+ const octets = parts.map(Number);
318
+ if (octets.some((octet) => octet > 255)) {
319
+ return undefined;
320
+ }
321
+ return ipv4Number(octets);
322
+ }
323
+ function parseStrictIpv4(value) {
324
+ const parts = value.split(".");
325
+ if (parts.length !== 4 ||
326
+ parts.some((part) => !/^(?:0|[1-9]\d{0,2})$/u.test(part))) {
327
+ return undefined;
328
+ }
329
+ const octets = parts.map(Number);
330
+ return octets.some((octet) => octet > 255) ? undefined : octets;
331
+ }
332
+ function ipv4Number(octets) {
333
+ const [a = 0, b = 0, c = 0, d = 0] = octets;
334
+ return ((a * 256 + b) * 256 + c) * 256 + d;
335
+ }
336
+ function ipv4Cidr(address, prefix) {
337
+ const parsed = parseStrictIpv4(address);
338
+ if (parsed === undefined) {
339
+ throw new Error(`Invalid internal IPv4 CIDR address: ${address}`);
340
+ }
341
+ return { address: ipv4Number(parsed), prefix };
342
+ }
343
+ function ipv4InCidr(address, cidr) {
344
+ const blockSize = 2 ** (32 - cidr.prefix);
345
+ return (Math.floor(address / blockSize) === Math.floor(cidr.address / blockSize));
346
+ }
347
+ function parseIpv6(value) {
348
+ if (value.includes("%") || value.length === 0) {
349
+ return undefined;
350
+ }
351
+ let source = value;
352
+ if (source.includes(".")) {
353
+ const tailStart = source.lastIndexOf(":") + 1;
354
+ const ipv4 = parseStrictIpv4(source.slice(tailStart));
355
+ if (tailStart === 0 || ipv4 === undefined) {
356
+ return undefined;
357
+ }
358
+ const [a = 0, b = 0, c = 0, d = 0] = ipv4;
359
+ source = `${source.slice(0, tailStart)}${(a * 256 + b).toString(16)}:${(c * 256 +
360
+ d).toString(16)}`;
361
+ }
362
+ const compression = source.indexOf("::");
363
+ if (compression !== -1 && compression !== source.lastIndexOf("::")) {
364
+ return undefined;
365
+ }
366
+ let segments;
367
+ if (compression === -1) {
368
+ segments = source.split(":");
369
+ if (segments.length !== 8) {
370
+ return undefined;
371
+ }
372
+ }
373
+ else {
374
+ const left = source.slice(0, compression);
375
+ const right = source.slice(compression + 2);
376
+ const leftSegments = left.length === 0 ? [] : left.split(":");
377
+ const rightSegments = right.length === 0 ? [] : right.split(":");
378
+ const omitted = 8 - leftSegments.length - rightSegments.length;
379
+ if (omitted < 1) {
380
+ return undefined;
381
+ }
382
+ segments = [
383
+ ...leftSegments,
384
+ ...Array(omitted).fill("0"),
385
+ ...rightSegments,
386
+ ];
387
+ }
388
+ if (segments.some((segment) => !/^[0-9a-f]{1,4}$/u.test(segment))) {
389
+ return undefined;
390
+ }
391
+ return segments.reduce((result, segment) => (result << 16n) | BigInt(`0x${segment}`), 0n);
392
+ }
393
+ function ipv6Cidr(address, prefix) {
394
+ const parsed = parseIpv6(address);
395
+ if (parsed === undefined) {
396
+ throw new Error(`Invalid internal IPv6 CIDR address: ${address}`);
397
+ }
398
+ return { address: parsed, prefix };
399
+ }
400
+ function ipv6InCidr(address, cidr) {
401
+ const shift = BigInt(128 - cidr.prefix);
402
+ return address >> shift === cidr.address >> shift;
403
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * What may change, and what that costs.
4
+ *
5
+ * The contract version is dated and moves independently of any deployment. A
6
+ * release that ships twice a day must not imply the contract changed twice a
7
+ * day, and a contract change must be visible even when no code deployed.
8
+ */
9
+ export declare const CONTRACT_VERSION: "2026-08-22";
10
+ export declare const contractVersionSchema: z.ZodLiteral<"2026-08-22">;
11
+ /**
12
+ * Three kinds of change, and only the first is free.
13
+ *
14
+ * - `additive`: existing clients keep working untouched. New optional request
15
+ * field, new response field, new enum member a client may *send*, new
16
+ * endpoint, new scope, new problem code, new member of a vocabulary the
17
+ * artifact publishes open. A new member a client must *receive* from a closed
18
+ * list is breaking — it can break exhaustive parsing.
19
+ * - `breaking`: some conforming client stops working. Removing or renaming a
20
+ * field, making an optional request field required, narrowing a type,
21
+ * removing an enum member a client may send, changing a problem code's
22
+ * status or category, changing what is signed.
23
+ * - `deployment-only`: no wire effect at all. Internal refactor, added index,
24
+ * new log field, prose in `title` or `detail`. Prose is `internal`: `title`
25
+ * and `detail` are explicitly not part of the contract, so editing them is
26
+ * not a change to a response field.
27
+ *
28
+ * The distinction is enforceable rather than advisory: `classifyChange` is
29
+ * exercised by fixtures, so "we thought it was additive" is a testable claim.
30
+ */
31
+ export declare const CHANGE_KINDS: readonly ["additive", "breaking", "deployment-only"];
32
+ export declare const changeKindSchema: z.ZodEnum<{
33
+ additive: "additive";
34
+ breaking: "breaking";
35
+ "deployment-only": "deployment-only";
36
+ }>;
37
+ export type ChangeKind = z.infer<typeof changeKindSchema>;
38
+ /**
39
+ * Declared as runtime arrays, not bare type unions, so a test can iterate the
40
+ * product and prove every combination is classified.
41
+ *
42
+ * A hand-written table of cases cannot do that: a combination nobody thought
43
+ * of is untested, and a subject or operation added later is untested by
44
+ * default — in a function whose whole job is to tell a caller whether a change
45
+ * will break customers.
46
+ */
47
+ export declare const CHANGE_SUBJECTS: readonly ["endpoint", "enum-member", "internal", "problem-code", "request-field", "response-field", "scope", "signature-input"];
48
+ export type ChangeSubject = (typeof CHANGE_SUBJECTS)[number];
49
+ export declare const CHANGE_OPERATIONS: readonly ["changed", "added", "narrowed", "removed", "required", "widened"];
50
+ export type ChangeOperation = (typeof CHANGE_OPERATIONS)[number];
51
+ export interface ContractChange {
52
+ readonly operation: ChangeOperation;
53
+ /** Whether a client may send this, as opposed to only receiving it. */
54
+ readonly clientSupplied?: boolean;
55
+ /**
56
+ * Whether the published artifact describes this vocabulary as open.
57
+ *
58
+ * A received enum member is breaking because a client that generated a
59
+ * validator from a closed list rejects the first value that postdates its
60
+ * code. That is a fact about the *artifact*, not about the vocabulary, and
61
+ * two of ours are deliberately published without an `enum` — problem codes,
62
+ * and callback attempt diagnostics — so that growing them is genuinely free.
63
+ * Without this flag the classifier answers `breaking` for those too, which
64
+ * reads as a rule while being an artifact detail nobody can check from here.
65
+ *
66
+ * Set it only where the projection really opens the list. Claiming it for a
67
+ * closed vocabulary is how a breaking change ships as additive.
68
+ */
69
+ readonly openVocabulary?: boolean;
70
+ readonly subject: ChangeSubject;
71
+ }
72
+ /**
73
+ * Classifies a proposed change. Deliberately conservative: anything not
74
+ * demonstrably safe is `breaking`, because the cost of wrongly calling a
75
+ * breaking change additive is borne by customers.
76
+ */
77
+ export declare function classifyChange(change: ContractChange): ChangeKind;
78
+ export interface CompatibilityVerdict {
79
+ readonly kind: ChangeKind;
80
+ readonly requiresNewContractVersion: boolean;
81
+ }
82
+ export declare function assess(change: ContractChange): CompatibilityVerdict;