@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,515 @@
1
+ import { z } from "zod";
2
+ import { environmentIdSchema, organizationIdSchema, projectIdSchema, } from "./identifiers.js";
3
+ import { timestampSchema } from "./time.js";
4
+ /**
5
+ * Request and collection mechanics: paging, optimistic concurrency, and
6
+ * idempotent writes.
7
+ */
8
+ /* -------------------------------------------------------------------------- */
9
+ /* Pagination */
10
+ /* -------------------------------------------------------------------------- */
11
+ /**
12
+ * Cursor paging only. Offsets drift when rows are inserted mid-scan, and a
13
+ * scheduling book changes constantly. The cursor is opaque: clients pass back
14
+ * exactly what they were given.
15
+ */
16
+ export const PAGE_SIZE = { default: 25, max: 100, min: 1 };
17
+ export const pageRequestSchema = z.object({
18
+ cursor: z.string().min(1).optional(),
19
+ limit: z
20
+ .number()
21
+ .int()
22
+ .min(PAGE_SIZE.min)
23
+ .max(PAGE_SIZE.max)
24
+ .default(PAGE_SIZE.default),
25
+ });
26
+ /**
27
+ * A page of results.
28
+ *
29
+ * `freshness` is deliberately part of the contract rather than an
30
+ * implementation detail: cross-partition listings are served from a projection
31
+ * that may lag, and a client that cannot tell fresh from stale will present
32
+ * stale data as fact. `authoritative` means the answer came from the owning
33
+ * partition; `projected` means it may lag and carries the age.
34
+ */
35
+ export const freshnessSchema = z.discriminatedUnion("kind", [
36
+ z.object({ kind: z.literal("authoritative") }),
37
+ z.object({
38
+ asOf: timestampSchema,
39
+ kind: z.literal("projected"),
40
+ lagSeconds: z.number().int().nonnegative(),
41
+ }),
42
+ ]);
43
+ /**
44
+ * The `projected` arm alone, for a response that is never authoritative.
45
+ *
46
+ * **A comment saying "always projected" is a claim; this is a check.** The
47
+ * forecast's own schema documented that arm as the only one it ever sends and
48
+ * then admitted both, so every reader had to handle a case the service cannot
49
+ * produce — and the two most useful fields, the age and the instant it was
50
+ * taken, were unreachable without narrowing that no consumer could justify.
51
+ * Naming the narrower shape moves the claim into the type.
52
+ */
53
+ export const projectedFreshnessSchema = z.object({
54
+ asOf: timestampSchema,
55
+ kind: z.literal("projected"),
56
+ lagSeconds: z.number().int().nonnegative(),
57
+ });
58
+ export function pageSchema(item) {
59
+ return z.object({
60
+ data: z.array(item),
61
+ freshness: freshnessSchema,
62
+ /** Absent when there is no further page. */
63
+ nextCursor: z.string().min(1).optional(),
64
+ });
65
+ }
66
+ /**
67
+ * A 32-bit FNV-1a hash of the authority tuple, hex encoded.
68
+ *
69
+ * This is **not** an authorization control and must never be read as one. The
70
+ * listing a cursor is presented to is scoped by the verified principal, so a
71
+ * forged or stolen cursor can at most reposition a walk inside the thief's own
72
+ * project — it cannot name another one. What the fingerprint buys is a clear
73
+ * refusal instead of a confusing empty page when a cursor is carried across
74
+ * projects or environments, which is why a non-cryptographic hash is enough and
75
+ * why a collision costs nothing.
76
+ */
77
+ function authorityFingerprint(authority) {
78
+ const material = [
79
+ authority.organization,
80
+ authority.project,
81
+ authority.environment,
82
+ authority.environmentKind,
83
+ ].join("\u0000");
84
+ let hash = 0x811c9dc5;
85
+ for (let index = 0; index < material.length; index += 1) {
86
+ hash ^= material.charCodeAt(index);
87
+ // The FNV prime 0x01000193, applied as shifts so the arithmetic stays
88
+ // inside 32 bits: 2^24 + 2^8 + 2^7 + 2^4 + 2^1 + 2^0. The 2^24 term is easy
89
+ // to leave out and hard to notice — without it this multiplies by 403, and
90
+ // a hash that disperses acceptably on this input shape looks fine while no
91
+ // longer being the algorithm its own name promises.
92
+ hash =
93
+ (hash +
94
+ (hash << 1) +
95
+ (hash << 4) +
96
+ (hash << 7) +
97
+ (hash << 8) +
98
+ (hash << 24)) >>>
99
+ 0;
100
+ }
101
+ return hash.toString(16).padStart(8, "0");
102
+ }
103
+ function base64UrlEncode(value) {
104
+ return btoa(value)
105
+ .replace(/\+/gu, "-")
106
+ .replace(/\//gu, "_")
107
+ .replace(/=+$/u, "");
108
+ }
109
+ /** Undefined for anything that is not base64url at all. */
110
+ function base64UrlDecode(value) {
111
+ if (!/^[A-Za-z0-9_-]+$/u.test(value)) {
112
+ return undefined;
113
+ }
114
+ // The padding is restored for readers rather than for `atob`, which
115
+ // implements WHATWG forgiving-base64 and accepts an unpadded value already.
116
+ // Stated so nobody later reads this line as load-bearing: what actually
117
+ // rejects a malformed cursor is the character check above and the throw
118
+ // below, which fires for a length that cannot be base64 at all.
119
+ const padded = value
120
+ .replace(/-/gu, "+")
121
+ .replace(/_/gu, "/")
122
+ .padEnd(Math.ceil(value.length / 4) * 4, "=");
123
+ try {
124
+ return atob(padded);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ /**
131
+ * The cursor a client is handed for the next page.
132
+ *
133
+ * Opaque by construction: it is a position and a tenancy fingerprint, and
134
+ * `docs/contracts/v1-conventions.md` says clients pass back exactly what they
135
+ * were given. It is deliberately *not* signed. A signature would imply the value
136
+ * carries authority, and it carries none — every listing is scoped by the
137
+ * principal before a cursor is even looked at.
138
+ */
139
+ export function encodeCursor(authority, position) {
140
+ return base64UrlEncode(JSON.stringify({
141
+ a: position.createdAtMs,
142
+ i: position.id,
143
+ k: authorityFingerprint(authority),
144
+ }));
145
+ }
146
+ /**
147
+ * The position a cursor names, or nothing.
148
+ *
149
+ * Nothing covers every way a cursor can fail to be one this listing issued:
150
+ * not base64url, not JSON, the wrong shape, or minted under a different
151
+ * authority. They collapse into one answer on purpose — telling a caller *how*
152
+ * their cursor is wrong tells them what a valid one looks like, and the caller
153
+ * never constructs one anyway.
154
+ */
155
+ export function decodeCursor(authority, cursor) {
156
+ const decoded = base64UrlDecode(cursor);
157
+ if (decoded === undefined) {
158
+ return undefined;
159
+ }
160
+ let parsed;
161
+ try {
162
+ parsed = JSON.parse(decoded);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ const shape = z
168
+ .object({
169
+ a: z.number().int().nonnegative(),
170
+ i: z.string().min(1),
171
+ k: z.string(),
172
+ })
173
+ .safeParse(parsed);
174
+ if (!shape.success || shape.data.k !== authorityFingerprint(authority)) {
175
+ return undefined;
176
+ }
177
+ return { createdAtMs: shape.data.a, id: shape.data.i };
178
+ }
179
+ /** Query values arrive as strings; only a whole number may become one. */
180
+ function asNumberIfInteger(value) {
181
+ return /^-?\d+$/u.test(value) ? Number(value) : value;
182
+ }
183
+ /**
184
+ * The page parameters, and which of them are numeric — derived from
185
+ * `pageRequestSchema` rather than written out again.
186
+ *
187
+ * Every projection reads the parameter list out of this same schema's JSON
188
+ * Schema. A hand-written list here agrees with it exactly until somebody adds a
189
+ * field: the document would publish it and the generated client would send it,
190
+ * while the service silently ignored it. That is the drift the operation
191
+ * registry exists to prevent, one line below the schema it prevents it for.
192
+ *
193
+ * Numeric-ness has to be derived too rather than assumed from the name. Coercing
194
+ * every all-digit value would turn a cursor that happened to be digits into a
195
+ * number, and a cursor is opaque precisely so its encoding can change.
196
+ *
197
+ * **Only numeric coercion is derived.** A future page parameter that is neither
198
+ * a string nor a number — a boolean, say — would arrive here as the text
199
+ * `"true"` and be refused by its own schema. That is the safe direction and it
200
+ * is still a trap, so whoever adds one adds its coercion beside this list.
201
+ *
202
+ * Computed once: `toJSONSchema` is not cheap enough to run per request.
203
+ */
204
+ const PAGE_PARAMETERS = Object.entries(z.toJSONSchema(pageRequestSchema, {
205
+ io: "input",
206
+ unrepresentable: "any",
207
+ }).properties ?? {}).map(([name, schema]) => [
208
+ name,
209
+ schema.type === "integer" || schema.type === "number",
210
+ ]);
211
+ /**
212
+ * A page request, read from a URL's query string.
213
+ *
214
+ * Lives here rather than in a route so REST and MCP cannot disagree about what
215
+ * `limit=0`, `limit=abc` or a repeated parameter mean — the bounds and the
216
+ * default come from `pageRequestSchema` and are stated once.
217
+ *
218
+ * A **repeated** parameter is refused rather than resolved by taking the first
219
+ * or the last. `?limit=5&limit=500` is a caller who does not agree with
220
+ * themselves, and silently picking one makes their bug look like ours.
221
+ *
222
+ * Unknown parameters are ignored. A client written against a later version of
223
+ * this contract must not start failing against an earlier deployment, and that
224
+ * is the direction compatibility has to hold in.
225
+ */
226
+ export function parsePageRequest(query) {
227
+ const errors = [];
228
+ const raw = {};
229
+ for (const [field, numeric] of PAGE_PARAMETERS) {
230
+ const values = query.getAll(field);
231
+ if (values.length > 1) {
232
+ errors.push({
233
+ pointer: `/${field}`,
234
+ reason: "must be given at most once",
235
+ });
236
+ continue;
237
+ }
238
+ const value = values[0];
239
+ if (value === undefined) {
240
+ continue;
241
+ }
242
+ raw[field] = numeric ? asNumberIfInteger(value) : value;
243
+ }
244
+ if (errors.length > 0) {
245
+ return { errors, ok: false };
246
+ }
247
+ const parsed = pageRequestSchema.safeParse(raw);
248
+ if (parsed.success) {
249
+ return { ok: true, page: parsed.data };
250
+ }
251
+ return {
252
+ errors: parsed.error.issues.map((issue) => ({
253
+ pointer: `/${issue.path.join("/")}`,
254
+ reason: issue.message,
255
+ })),
256
+ ok: false,
257
+ };
258
+ }
259
+ /* -------------------------------------------------------------------------- */
260
+ /* Optimistic concurrency */
261
+ /* -------------------------------------------------------------------------- */
262
+ /**
263
+ * Every mutable resource carries a monotonically increasing `version`, surfaced
264
+ * as a strong ETag. A conditional write that loses the race is refused with
265
+ * `version_conflict` rather than silently overwriting — a cancel racing a
266
+ * replace must not resolve by luck.
267
+ */
268
+ export const versionSchema = z.number().int().positive();
269
+ /**
270
+ * A strong entity tag over a version: a quoted decimal, and nothing else.
271
+ *
272
+ * Written once and read by both `fromETag` and `etagSchema`, so what the
273
+ * document publishes as an `If-Match` value and what a route will actually
274
+ * accept cannot disagree. Weak tags (`W/"3"`) and the wildcard (`*`) are
275
+ * deliberately not matched — a weak comparison is defined to ignore exactly the
276
+ * differences a lost update consists of, and `*` asks a question about
277
+ * existence rather than about version.
278
+ *
279
+ * **No leading zero.** RFC 9110 defines strong comparison as byte-for-byte, so
280
+ * `"01"` is simply not the tag `"1"` — and this pattern read `\d+` until
281
+ * W2-02's tests found that `fromETag('"01"')` answered `1`, honouring a
282
+ * precondition against a version whose tag the service never issued. `toETag`
283
+ * cannot produce that spelling, so every one that arrives was hand-made.
284
+ *
285
+ * The digit ceiling only keeps the input bounded; `fromETag` below applies the
286
+ * rule that matters, which is that the value survives being a `Number`. Past
287
+ * 2^53 it does not: `"9007199254740993"` parses to 9007199254740992 and would
288
+ * match a *different* version, which is worse than being refused.
289
+ */
290
+ const ETAG_PATTERN = /^"([1-9][0-9]{0,18})"$/u;
291
+ const ETAG_MESSAGE = 'must be a strong entity tag, as returned by ETag: "7"';
292
+ export function toETag(version) {
293
+ return `"${version}"`;
294
+ }
295
+ /** Parses a strong ETag, returning undefined for anything malformed or weak. */
296
+ export function fromETag(etag) {
297
+ const match = ETAG_PATTERN.exec(etag.trim());
298
+ if (match === null) {
299
+ return undefined;
300
+ }
301
+ const parsed = Number(match[1]);
302
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
303
+ }
304
+ /**
305
+ * The same rule as a schema, and deliberately the *same* rule: the refinement
306
+ * calls the parser rather than restating what it accepts, so a value the
307
+ * document says is valid cannot be one the service then refuses.
308
+ *
309
+ * The `regex` is kept beside the refinement because only the regex survives
310
+ * JSON Schema conversion — the published document carries the pattern, and the
311
+ * refinement is the extra strictness the OpenAPI preamble already discloses
312
+ * that a described schema does not capture.
313
+ */
314
+ export const etagSchema = z
315
+ .string()
316
+ .regex(ETAG_PATTERN, ETAG_MESSAGE)
317
+ .refine((value) => fromETag(value) !== undefined, ETAG_MESSAGE);
318
+ /* -------------------------------------------------------------------------- */
319
+ /* Idempotency */
320
+ /* -------------------------------------------------------------------------- */
321
+ /**
322
+ * Idempotency keys make an unsafe write safe to retry.
323
+ *
324
+ * The key is scoped to the authority tuple and the operation, so two projects
325
+ * may use the same key without colliding. A replay of the *same* request
326
+ * returns the original result with `replayed: true`. Reusing a key with a
327
+ * *different* request body is a `idempotency_key_reused` conflict, never a
328
+ * silent second write — that distinction is the whole point, and the legacy
329
+ * scheduler had no equivalent.
330
+ */
331
+ export const idempotencyKeySchema = z
332
+ .string()
333
+ .min(16, "must be at least 16 characters so keys do not collide by accident")
334
+ .max(255)
335
+ .regex(/^[\x20-\x7e]+$/u, "must be printable ASCII");
336
+ /**
337
+ * The scope a key was used in. Carried on the receipt rather than assumed,
338
+ * because an implementation that keys its receipt store on the idempotency key
339
+ * alone would return one tenant's result to another whenever both happened to
340
+ * send the same key. The scope is part of the record so that mistake cannot be
341
+ * made silently.
342
+ */
343
+ export const idempotencyScopeSchema = z.object({
344
+ environment: environmentIdSchema,
345
+ operation: z.string().min(1),
346
+ organization: organizationIdSchema,
347
+ project: projectIdSchema,
348
+ });
349
+ export const idempotencyReceiptSchema = z.object({
350
+ /**
351
+ * Hash of the canonical request the key was first used with. Canonical
352
+ * means: the operation name, then the request body serialized with object
353
+ * keys sorted lexicographically and no insignificant whitespace. Every
354
+ * adapter must compute it the same way or REST and MCP will disagree about
355
+ * whether two requests are the same.
356
+ */
357
+ requestFingerprint: z.string().min(1),
358
+ /** True when this response replays an earlier identical request. */
359
+ replayed: z.boolean(),
360
+ scope: idempotencyScopeSchema,
361
+ storedAt: timestampSchema,
362
+ });
363
+ /**
364
+ * The canonical form of a request, as the receipt's `requestFingerprint`
365
+ * describes it: the operation name, then the body with object keys sorted and
366
+ * no insignificant whitespace.
367
+ *
368
+ * Here rather than in each adapter because the receipt schema says every
369
+ * adapter must compute it the same way, and a rule stated in a doc comment and
370
+ * implemented twice is a rule that holds until the second implementation. REST
371
+ * and MCP send the same create differently — one as a JSON body, one as tool
372
+ * arguments — and this is what makes "the same request" mean the same thing to
373
+ * both.
374
+ *
375
+ * Keys are ordered by UTF-16 code unit rather than by `localeCompare`, which is
376
+ * locale-dependent: a canonical form that depends on the machine's locale is
377
+ * not canonical. `undefined`-valued keys are dropped, because `JSON.stringify`
378
+ * drops them too and a caller who omitted a field and one who set it to
379
+ * `undefined` sent the same bytes.
380
+ */
381
+ export function canonicalRequest(operation, body) {
382
+ return `${operation}\n${canonicalJson(body)}`;
383
+ }
384
+ function canonicalJson(value) {
385
+ if (value === null || typeof value !== "object") {
386
+ // `undefined` at the root serializes to nothing, which would make an absent
387
+ // body and an empty one the same fingerprint. They are the same request, so
388
+ // that is correct — but stated, because it looks like an oversight.
389
+ return JSON.stringify(value) ?? "null";
390
+ }
391
+ if (Array.isArray(value)) {
392
+ return `[${value.map((item) => canonicalJson(item)).join(",")}]`;
393
+ }
394
+ const fields = Object.entries(value)
395
+ .filter(([, field]) => field !== undefined)
396
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
397
+ .map(([name, field]) => `${JSON.stringify(name)}:${canonicalJson(field)}`);
398
+ return `{${fields.join(",")}}`;
399
+ }
400
+ /**
401
+ * The fingerprint stored on a receipt: SHA-256 of the canonical request, hex.
402
+ *
403
+ * Hashed rather than stored whole because a body may carry a payload up to
404
+ * `SCHEDULING_BOUNDS.payloadBytes`, and a receipt store that kept every request
405
+ * verbatim would cost the same as the schedules it protects. A cryptographic
406
+ * digest rather than the cheap hash `encodeCursor` uses: a collision there
407
+ * costs a confusing refusal, and a collision here would replay one request's
408
+ * result for a different request, which is the exact failure the receipt exists
409
+ * to prevent.
410
+ *
411
+ * Web Crypto, for the reason `identifiers.ts` gives for `getRandomValues`: it
412
+ * is a standard rather than a Cloudflare extension, so one implementation
413
+ * serves every runtime this package is read from.
414
+ */
415
+ export async function requestFingerprint(operation, body) {
416
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalRequest(operation, body)));
417
+ return [...new Uint8Array(digest)]
418
+ .map((byte) => byte.toString(16).padStart(2, "0"))
419
+ .join("");
420
+ }
421
+ function sameScope(a, b) {
422
+ return (a.environment === b.environment &&
423
+ a.operation === b.operation &&
424
+ a.organization === b.organization &&
425
+ a.project === b.project);
426
+ }
427
+ /**
428
+ * Decides how a write carrying an idempotency key must be treated. Pure, so
429
+ * both the API and the MCP adapter reach the same answer.
430
+ *
431
+ * A receipt from a different scope is not this caller's key at all — it is
432
+ * accepted as a first use, never replayed and never a conflict.
433
+ */
434
+ export function classifyIdempotency(incoming, stored) {
435
+ if (stored === undefined || !sameScope(stored.scope, incoming.scope)) {
436
+ return { kind: "accepted" };
437
+ }
438
+ return stored.requestFingerprint === incoming.fingerprint
439
+ ? { kind: "replayed" }
440
+ : { kind: "conflict" };
441
+ }
442
+ /* -------------------------------------------------------------------------- */
443
+ /* Headers on the wire */
444
+ /* -------------------------------------------------------------------------- */
445
+ /**
446
+ * The headers the mechanics above are carried in, as object schemas whose
447
+ * properties are the header names.
448
+ *
449
+ * The same shape `OperationDefinition.query` uses, for the same reason: a
450
+ * header a route reads and the published document does not carry is drift, and
451
+ * the way to prevent it is for both to derive from one schema. Names are
452
+ * standard where a standard exists — `If-Match` and `ETag` are RFC 9110's, and
453
+ * a conditional write invented a private spelling of them would be a
454
+ * conditional write no HTTP cache, proxy or client library understands.
455
+ *
456
+ * `Idempotency-Key` has no RFC yet; the name is the one the payments industry
457
+ * settled on and the one the IETF draft uses, which is what an integrator will
458
+ * already have a client for.
459
+ */
460
+ export const CONCURRENCY_HEADERS = {
461
+ etag: "ETag",
462
+ idempotencyKey: "Idempotency-Key",
463
+ ifMatch: "If-Match",
464
+ replayed: "Idempotency-Replayed",
465
+ };
466
+ export const idempotencyRequestHeaderSchema = z.object({
467
+ [CONCURRENCY_HEADERS.idempotencyKey]: idempotencyKeySchema.optional(),
468
+ });
469
+ /**
470
+ * A write for which idempotency is mandatory, rather than an optional
471
+ * protection a caller may opt into.
472
+ *
473
+ * Redelivery uses this shape because retrying an ambiguous redelivery request
474
+ * without the same key could send a second callback. Keeping it distinct from
475
+ * `idempotencyRequestHeaderSchema` lets schedule creation retain its published
476
+ * optional behavior without weakening the redelivery promise.
477
+ */
478
+ export const requiredIdempotencyRequestHeaderSchema = z.object({
479
+ [CONCURRENCY_HEADERS.idempotencyKey]: idempotencyKeySchema,
480
+ });
481
+ export const conditionalRequestHeaderSchema = z.object({
482
+ [CONCURRENCY_HEADERS.ifMatch]: etagSchema.optional(),
483
+ });
484
+ /**
485
+ * Required rather than optional, deliberately: an operation that declares this
486
+ * promises the header on every success, and a client that has to test for its
487
+ * presence before making a conditional write will write the untested branch
488
+ * wrong.
489
+ */
490
+ export const versionResponseHeaderSchema = z.object({
491
+ [CONCURRENCY_HEADERS.etag]: etagSchema,
492
+ });
493
+ /**
494
+ * What a replayed write answers with, on top of the version.
495
+ *
496
+ * `replayed: true` is a promise `docs/contracts/v1-conventions.md` and
497
+ * `idempotencyReceiptSchema` have both made since W0-02 — the receipt's own
498
+ * field is documented as "true when **this response** replays an earlier
499
+ * identical request" — and until W2-02's review nothing carried it on the wire.
500
+ * A caller could not tell a replay from a first create, which makes the
501
+ * difference between "my retry worked" and "I created a second one" unanswerable
502
+ * from the response.
503
+ *
504
+ * Present only on a replay rather than `false` on every create: an absent
505
+ * header is the ordinary case, and a client that has to read a header to learn
506
+ * nothing happened is a client that will not read it.
507
+ */
508
+ export const replayedResponseHeaderSchema = z.object({
509
+ [CONCURRENCY_HEADERS.etag]: etagSchema,
510
+ [CONCURRENCY_HEADERS.replayed]: z.literal("true").optional(),
511
+ });
512
+ /** Replay marker for an idempotent create whose result has no ETag. */
513
+ export const idempotencyReplayResponseHeaderSchema = z.object({
514
+ [CONCURRENCY_HEADERS.replayed]: z.literal("true").optional(),
515
+ });