@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
package/dist/time.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ export declare const timestampSchema: z.ZodString;
3
+ export type Timestamp = z.infer<typeof timestampSchema>;
4
+ /** Formats an instant in the one representation the wire accepts. */
5
+ export declare function toTimestamp(instant: Date): Timestamp;
6
+ /**
7
+ * Durations are whole seconds, never strings. Bounds belong to the resource
8
+ * that uses them; this only fixes the representation.
9
+ */
10
+ export declare const durationSecondsSchema: z.ZodNumber;
11
+ export type DurationSeconds = z.infer<typeof durationSecondsSchema>;
12
+ /**
13
+ * An IANA timezone name, carried alongside a recurrence rather than encoded
14
+ * into an instant.
15
+ *
16
+ * Two independent requirements, and getting either wrong fires a schedule at
17
+ * the wrong hour:
18
+ *
19
+ * 1. **A region component is required.** This is what refuses ambiguous
20
+ * abbreviations without curating a list of the safe ones. `Intl` resolves
21
+ * `BST` to `Asia/Dhaka`, so a British caller meaning British Summer Time
22
+ * would be scheduled six hours away and every response would look correct.
23
+ * `CET`, `GMT`, `EST5EDT`, `Eire`, `Japan` and `Israel` go the same way.
24
+ * 2. **It must resolve to a zone this runtime knows.** That is what refuses
25
+ * fixed offsets: `+01:00` and `Etc/GMT+5` resolve to themselves and are not
26
+ * zones but constant offsets with no daylight-saving rules, so a schedule
27
+ * carrying one drifts from its owner's wall clock the day the clocks
28
+ * change. (`Etc/GMT+5` is also UTC−5, not UTC+5.)
29
+ *
30
+ * Resolution happens before the membership test, deliberately.
31
+ * `Intl.supportedValuesOf` returns CLDR-canonical names, and for a number of
32
+ * zones CLDR's canonical name is the *deprecated* IANA link: exact membership
33
+ * accepts `Asia/Calcutta` and refuses `Asia/Kolkata`, the name IANA has used
34
+ * since 1993 and the one every other language's timezone library emits. The
35
+ * same holds for `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `Asia/Yangon`,
36
+ * `America/Nuuk` and `America/Argentina/Buenos_Aires`. Resolving first accepts
37
+ * both spellings and is immune to CLDR later migrating to the IANA names.
38
+ */
39
+ /**
40
+ * The one spelling of a zone that this contract stores and returns, beside
41
+ * `toTimestamp` for instants. Throws on a name the runtime does not know.
42
+ */
43
+ export declare function toTimeZone(value: string): string;
44
+ export declare const timeZoneSchema: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
45
+ export type TimeZone = z.infer<typeof timeZoneSchema>;
package/dist/time.js ADDED
@@ -0,0 +1,128 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Time on the wire.
4
+ *
5
+ * Every instant is an RFC 3339 timestamp in UTC with millisecond precision and
6
+ * a literal `Z`. No local offsets, no bare dates, no epoch numbers. A schedule
7
+ * that fires at the wrong moment is the worst defect this product can have, so
8
+ * the wire format is deliberately narrow: one representation, no parsing
9
+ * ambiguity, and no timezone inference anywhere in a client.
10
+ *
11
+ * The legacy scheduler accepted any `Date`-parseable string and returned
12
+ * whatever `toISOString()` produced. Narrowing this is a deliberate break.
13
+ *
14
+ * IANA timezones appear only where a human genuinely means a wall clock — the
15
+ * `cron` recurrence — and are carried as a separate named field, never folded
16
+ * into the instant.
17
+ */
18
+ const RFC3339_UTC_MS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
19
+ export const timestampSchema = z
20
+ .string()
21
+ .regex(RFC3339_UTC_MS, "must be an RFC 3339 UTC timestamp with millisecond precision, e.g. 2026-07-28T15:00:00.000Z")
22
+ // A regex alone is not enough: JavaScript happily "parses" 2026-02-31 and
23
+ // silently rolls it forward to 3 March. Round-tripping through Date is the
24
+ // only check that rejects a well-shaped calendar impossibility, and a
25
+ // scheduler that accepts a date that does not exist will fire at a time the
26
+ // customer never asked for.
27
+ .refine((value) => {
28
+ const parsed = new Date(value);
29
+ // Guarded because checks still run after the pattern fails, and
30
+ // `toISOString` throws rather than returning NaN on an invalid Date.
31
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
32
+ }, { message: "must be a real instant on the calendar" });
33
+ /** Formats an instant in the one representation the wire accepts. */
34
+ export function toTimestamp(instant) {
35
+ if (Number.isNaN(instant.getTime())) {
36
+ throw new RangeError("cannot format an invalid Date");
37
+ }
38
+ return instant.toISOString().replace(/\.(\d{3})\d*Z$/u, ".$1Z");
39
+ }
40
+ /**
41
+ * Durations are whole seconds, never strings. Bounds belong to the resource
42
+ * that uses them; this only fixes the representation.
43
+ */
44
+ export const durationSecondsSchema = z
45
+ .number()
46
+ .int("must be a whole number of seconds")
47
+ .nonnegative("must not be negative");
48
+ let canonicalZones;
49
+ /**
50
+ * The zone identifiers this runtime recognizes, plus `UTC`.
51
+ *
52
+ * Built lazily so a runtime without `Intl.supportedValuesOf` fails when a
53
+ * timezone is validated rather than when the contracts package is imported,
54
+ * which would take every adapter down with it.
55
+ */
56
+ function zones() {
57
+ canonicalZones ??= new Set([...Intl.supportedValuesOf("timeZone"), "UTC"]);
58
+ return canonicalZones;
59
+ }
60
+ /**
61
+ * An IANA timezone name, carried alongside a recurrence rather than encoded
62
+ * into an instant.
63
+ *
64
+ * Two independent requirements, and getting either wrong fires a schedule at
65
+ * the wrong hour:
66
+ *
67
+ * 1. **A region component is required.** This is what refuses ambiguous
68
+ * abbreviations without curating a list of the safe ones. `Intl` resolves
69
+ * `BST` to `Asia/Dhaka`, so a British caller meaning British Summer Time
70
+ * would be scheduled six hours away and every response would look correct.
71
+ * `CET`, `GMT`, `EST5EDT`, `Eire`, `Japan` and `Israel` go the same way.
72
+ * 2. **It must resolve to a zone this runtime knows.** That is what refuses
73
+ * fixed offsets: `+01:00` and `Etc/GMT+5` resolve to themselves and are not
74
+ * zones but constant offsets with no daylight-saving rules, so a schedule
75
+ * carrying one drifts from its owner's wall clock the day the clocks
76
+ * change. (`Etc/GMT+5` is also UTC−5, not UTC+5.)
77
+ *
78
+ * Resolution happens before the membership test, deliberately.
79
+ * `Intl.supportedValuesOf` returns CLDR-canonical names, and for a number of
80
+ * zones CLDR's canonical name is the *deprecated* IANA link: exact membership
81
+ * accepts `Asia/Calcutta` and refuses `Asia/Kolkata`, the name IANA has used
82
+ * since 1993 and the one every other language's timezone library emits. The
83
+ * same holds for `Europe/Kyiv`, `Asia/Ho_Chi_Minh`, `Asia/Yangon`,
84
+ * `America/Nuuk` and `America/Argentina/Buenos_Aires`. Resolving first accepts
85
+ * both spellings and is immune to CLDR later migrating to the IANA names.
86
+ */
87
+ /**
88
+ * The one spelling of a zone that this contract stores and returns, beside
89
+ * `toTimestamp` for instants. Throws on a name the runtime does not know.
90
+ */
91
+ export function toTimeZone(value) {
92
+ if (value === "UTC") {
93
+ return "UTC";
94
+ }
95
+ return new Intl.DateTimeFormat("en", { timeZone: value }).resolvedOptions()
96
+ .timeZone;
97
+ }
98
+ export const timeZoneSchema = z
99
+ .string()
100
+ .min(1)
101
+ .refine((value) => {
102
+ if (value === "UTC") {
103
+ return true;
104
+ }
105
+ if (!value.includes("/")) {
106
+ return false;
107
+ }
108
+ try {
109
+ return zones().has(new Intl.DateTimeFormat("en", { timeZone: value }).resolvedOptions()
110
+ .timeZone);
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }, {
116
+ message: "must be an IANA timezone with a region, such as Europe/London or Asia/Kolkata, or UTC — not an abbreviation like BST or a fixed offset like +01:00",
117
+ })
118
+ // Canonicalized, for the same reason instants have exactly one spelling.
119
+ // `Intl` matches zone names case-insensitively and both spellings of a
120
+ // renamed zone are accepted, so without this `Europe/London`,
121
+ // `europe/london`, `Asia/Kolkata` and `Asia/Calcutta` would be four distinct
122
+ // contract values denoting two zones. Nothing fires at the wrong hour, but
123
+ // schedules meaning the same wall clock would compare unequal, a forecast
124
+ // breakdown keyed on timezone would split, and an edit changing only the
125
+ // spelling would bump the optimistic version and write a spurious audit
126
+ // diff. Deciding this now is free; after W2 persists the first raw string it
127
+ // is a breaking response change plus a data migration.
128
+ .transform(toTimeZone);
@@ -0,0 +1,121 @@
1
+ import { z } from "zod";
2
+ import type { FieldError } from "./problems.js";
3
+ /**
4
+ * What a customer is charged for, and nothing about what it cost to deliver.
5
+ *
6
+ * **One terminal occurrence is one billable unit, however many attempts it
7
+ * took.** That is the commercial rule the whole runtime is built around — a
8
+ * retry is our cost, not the customer's — and this surface exists so it is
9
+ * something a customer can see rather than something they are told. A schedule
10
+ * that failed four times and succeeded on the fifth appears here once; the
11
+ * five attempts are on the occurrence's own attempt history, which is the page
12
+ * that answers "what did it cost us".
13
+ *
14
+ * Days are UTC and half-open, aligned to midnight at both ends. A tenant-local
15
+ * boundary would make every bucket a different width for a different customer
16
+ * and could not be one index range; the capacity decision states the budget in
17
+ * UTC days for the same reason.
18
+ *
19
+ * A day with no billable occurrence is **absent** rather than zero. The window
20
+ * says which days the answer covers, so an absent day is unambiguous, and a
21
+ * quiet month is a short document rather than thirty-one zeroes.
22
+ */
23
+ export declare const USAGE_BOUNDS: {
24
+ /** Days a window covers when the caller names neither end. */
25
+ readonly defaultWindowDays: 31;
26
+ /**
27
+ * The widest window this contract will describe.
28
+ *
29
+ * A year, because usage is read by a person reconciling a bill and a bill is
30
+ * never older than that. It is a contract ceiling rather than a plan one: a
31
+ * plan may bring it closer and may never push it out.
32
+ */
33
+ readonly maxWindowDays: 366;
34
+ };
35
+ /** How a billable occurrence ended. The four terminal occurrence states. */
36
+ export declare const BILLABLE_TERMINAL_STATES: readonly ["cancelled", "skipped_stale", "succeeded", "terminal_failed"];
37
+ export declare const billableTerminalStateSchema: z.ZodEnum<{
38
+ succeeded: "succeeded";
39
+ cancelled: "cancelled";
40
+ terminal_failed: "terminal_failed";
41
+ skipped_stale: "skipped_stale";
42
+ }>;
43
+ export type BillableTerminalStateName = z.infer<typeof billableTerminalStateSchema>;
44
+ export declare const usageRequestSchema: z.ZodObject<{
45
+ from: z.ZodOptional<z.ZodString>;
46
+ to: z.ZodOptional<z.ZodString>;
47
+ }, z.core.$strict>;
48
+ export type UsageRequest = z.infer<typeof usageRequestSchema>;
49
+ export type UsageRequestParse = {
50
+ readonly errors: readonly FieldError[];
51
+ readonly ok: false;
52
+ } | {
53
+ readonly ok: true;
54
+ readonly request: UsageRequest;
55
+ };
56
+ /**
57
+ * Read a usage request off a URL.
58
+ *
59
+ * One reader, so the REST service and the published contract mock cannot
60
+ * disagree about `from=yesterday`. A repeated parameter is a refusal rather
61
+ * than a last-one-wins guess, for the reason the forecast reader gives: two
62
+ * different windows in one request is a caller bug, and answering one of them
63
+ * silently is how it stays a bug.
64
+ */
65
+ export declare function parseUsageRequest(query: URLSearchParams): UsageRequestParse;
66
+ /** What a caller is told when a usage window is refused rather than served. */
67
+ export declare const USAGE_WINDOW_REASONS: Readonly<Record<UsageWindowRefusal, string>>;
68
+ export declare const usageDaySchema: z.ZodObject<{
69
+ billableOccurrences: z.ZodNumber;
70
+ byTerminalState: z.ZodRecord<z.ZodEnum<{
71
+ succeeded: "succeeded";
72
+ cancelled: "cancelled";
73
+ terminal_failed: "terminal_failed";
74
+ skipped_stale: "skipped_stale";
75
+ }> & z.core.$partial, z.ZodNumber>;
76
+ start: z.ZodString;
77
+ }, z.core.$strip>;
78
+ export type UsageDayView = z.infer<typeof usageDaySchema>;
79
+ export declare const usageSchema: z.ZodObject<{
80
+ days: z.ZodArray<z.ZodObject<{
81
+ billableOccurrences: z.ZodNumber;
82
+ byTerminalState: z.ZodRecord<z.ZodEnum<{
83
+ succeeded: "succeeded";
84
+ cancelled: "cancelled";
85
+ terminal_failed: "terminal_failed";
86
+ skipped_stale: "skipped_stale";
87
+ }> & z.core.$partial, z.ZodNumber>;
88
+ start: z.ZodString;
89
+ }, z.core.$strip>>;
90
+ freshness: z.ZodObject<{
91
+ kind: z.ZodLiteral<"authoritative">;
92
+ }, z.core.$strip>;
93
+ totalBillableOccurrences: z.ZodNumber;
94
+ window: z.ZodObject<{
95
+ from: z.ZodString;
96
+ to: z.ZodString;
97
+ }, z.core.$strip>;
98
+ }, z.core.$strip>;
99
+ export type UsageView = z.infer<typeof usageSchema>;
100
+ export type UsageWindowRefusal = "window-inverted" | "window-too-wide" | "window-unreadable";
101
+ export type ResolvedUsageWindow = {
102
+ readonly ok: false;
103
+ readonly refusal: UsageWindowRefusal;
104
+ } | {
105
+ readonly fromMs: number;
106
+ readonly ok: true;
107
+ readonly toMs: number;
108
+ };
109
+ /** UTC midnight of the day an instant falls in. */
110
+ export declare function utcMidnightMs(instantMs: number): number;
111
+ /**
112
+ * Resolve the window a usage request names, aligned to UTC days.
113
+ *
114
+ * **Both ends are snapped to midnight, and that is not tidiness.** The answer
115
+ * is bucketed by UTC day; a window beginning at nine in the morning would put a
116
+ * partial day at the front of the list with no way for a reader to tell it from
117
+ * a quiet one. Snapping outward — the start of `from`'s day, the start of the
118
+ * day after `to`'s — means the window always covers whole days and never covers
119
+ * less than what was asked for.
120
+ */
121
+ export declare function resolveUsageWindow(request: UsageRequest, nowMs: number): ResolvedUsageWindow;
package/dist/usage.js ADDED
@@ -0,0 +1,200 @@
1
+ import { z } from "zod";
2
+ import { timestampSchema } from "./time.js";
3
+ /**
4
+ * What a customer is charged for, and nothing about what it cost to deliver.
5
+ *
6
+ * **One terminal occurrence is one billable unit, however many attempts it
7
+ * took.** That is the commercial rule the whole runtime is built around — a
8
+ * retry is our cost, not the customer's — and this surface exists so it is
9
+ * something a customer can see rather than something they are told. A schedule
10
+ * that failed four times and succeeded on the fifth appears here once; the
11
+ * five attempts are on the occurrence's own attempt history, which is the page
12
+ * that answers "what did it cost us".
13
+ *
14
+ * Days are UTC and half-open, aligned to midnight at both ends. A tenant-local
15
+ * boundary would make every bucket a different width for a different customer
16
+ * and could not be one index range; the capacity decision states the budget in
17
+ * UTC days for the same reason.
18
+ *
19
+ * A day with no billable occurrence is **absent** rather than zero. The window
20
+ * says which days the answer covers, so an absent day is unambiguous, and a
21
+ * quiet month is a short document rather than thirty-one zeroes.
22
+ */
23
+ export const USAGE_BOUNDS = {
24
+ /** Days a window covers when the caller names neither end. */
25
+ defaultWindowDays: 31,
26
+ /**
27
+ * The widest window this contract will describe.
28
+ *
29
+ * A year, because usage is read by a person reconciling a bill and a bill is
30
+ * never older than that. It is a contract ceiling rather than a plan one: a
31
+ * plan may bring it closer and may never push it out.
32
+ */
33
+ maxWindowDays: 366,
34
+ };
35
+ /** How a billable occurrence ended. The four terminal occurrence states. */
36
+ export const BILLABLE_TERMINAL_STATES = [
37
+ "cancelled",
38
+ "skipped_stale",
39
+ "succeeded",
40
+ "terminal_failed",
41
+ ];
42
+ export const billableTerminalStateSchema = z.enum(BILLABLE_TERMINAL_STATES);
43
+ export const usageRequestSchema = z
44
+ .object({
45
+ /** Inclusive start. Defaults to `defaultWindowDays` before the end. */
46
+ from: timestampSchema.optional(),
47
+ /** Exclusive end. Defaults to the start of tomorrow, UTC. */
48
+ to: timestampSchema.optional(),
49
+ })
50
+ .strict();
51
+ const USAGE_PARAMETERS = ["from", "to"];
52
+ /**
53
+ * Read a usage request off a URL.
54
+ *
55
+ * One reader, so the REST service and the published contract mock cannot
56
+ * disagree about `from=yesterday`. A repeated parameter is a refusal rather
57
+ * than a last-one-wins guess, for the reason the forecast reader gives: two
58
+ * different windows in one request is a caller bug, and answering one of them
59
+ * silently is how it stays a bug.
60
+ */
61
+ export function parseUsageRequest(query) {
62
+ const errors = [];
63
+ const raw = {};
64
+ for (const field of USAGE_PARAMETERS) {
65
+ const values = query.getAll(field);
66
+ if (values.length > 1) {
67
+ errors.push({
68
+ pointer: `/${field}`,
69
+ reason: "must be given at most once",
70
+ });
71
+ continue;
72
+ }
73
+ const value = values[0];
74
+ if (value === undefined)
75
+ continue;
76
+ raw[field] = value;
77
+ }
78
+ if (errors.length > 0)
79
+ return { errors, ok: false };
80
+ const parsed = usageRequestSchema.safeParse(raw);
81
+ if (parsed.success)
82
+ return { ok: true, request: parsed.data };
83
+ return {
84
+ errors: parsed.error.issues.map((issue) => ({
85
+ pointer: `/${issue.path.join("/")}`,
86
+ reason: issue.message,
87
+ })),
88
+ ok: false,
89
+ };
90
+ }
91
+ /** What a caller is told when a usage window is refused rather than served. */
92
+ export const USAGE_WINDOW_REASONS = {
93
+ "window-inverted": "must end after it begins",
94
+ "window-too-wide": `must cover at most ${String(USAGE_BOUNDS.maxWindowDays)} days`,
95
+ "window-unreadable": "must be an RFC 3339 instant",
96
+ };
97
+ export const usageDaySchema = z.object({
98
+ billableOccurrences: z.number().int().positive(),
99
+ /**
100
+ * How the day's billable occurrences ended.
101
+ *
102
+ * Sums to `billableOccurrences`, and a state with no occurrences is absent
103
+ * rather than zero — the same rule the day list follows, one level down.
104
+ */
105
+ byTerminalState: z.partialRecord(billableTerminalStateSchema, z.number().int().positive()),
106
+ /** UTC midnight of the day this bucket covers. */
107
+ start: timestampSchema,
108
+ });
109
+ export const usageSchema = z
110
+ .object({
111
+ /** Ascending by `start`. Days with no billable occurrence are absent. */
112
+ days: z.array(usageDaySchema),
113
+ /**
114
+ * Authoritative, because this is the committed model the settlement wrote.
115
+ *
116
+ * A usage fact is inserted in the same transaction that settles the
117
+ * occurrence, so there is no projection behind this to be stale — which is
118
+ * exactly why it is served from the authority and cannot be served from a
119
+ * summary.
120
+ */
121
+ freshness: z.object({ kind: z.literal("authoritative") }),
122
+ totalBillableOccurrences: z.number().int().nonnegative(),
123
+ window: z.object({ from: timestampSchema, to: timestampSchema }),
124
+ })
125
+ // **Three invariants this document promised in prose and nothing checked.**
126
+ // A reader is shown two numbers that must agree — the total and the days it
127
+ // is a total of — and a client has to choose one. Without these a service
128
+ // could publish a total that disagreed with its own breakdown and every
129
+ // consumer would be right to render either.
130
+ //
131
+ // They are refinements rather than schema shape because they are relations
132
+ // between fields, so they hold at the wire and not in the generated OpenAPI
133
+ // — which is where the route's own `usageSchema.parse` before serving earns
134
+ // its place.
135
+ .refine((usage) => usage.days.reduce((sum, day) => sum + day.billableOccurrences, 0) ===
136
+ usage.totalBillableOccurrences, {
137
+ message: "totalBillableOccurrences must be the sum of the days",
138
+ path: ["totalBillableOccurrences"],
139
+ })
140
+ .refine((usage) => usage.days.every((day) => Object.values(day.byTerminalState).reduce((sum, count) => sum + count, 0) === day.billableOccurrences), {
141
+ message: "byTerminalState must account for every billable occurrence",
142
+ path: ["days"],
143
+ })
144
+ .refine((usage) => usage.days.every((day) => {
145
+ const start = Date.parse(day.start);
146
+ return (start >= Date.parse(usage.window.from) &&
147
+ start < Date.parse(usage.window.to));
148
+ }), {
149
+ // Half-open at the end, which is the one boundary worth stating: a day
150
+ // whose midnight *is* `to` is the first day outside the answer.
151
+ message: "every day must begin inside the window the answer covers",
152
+ path: ["days"],
153
+ })
154
+ // Ascending, because the document says so and a client rendering a chart
155
+ // reads the array in order. An unsorted answer is not wrong twice — it is
156
+ // wrong once, in the place nobody looks.
157
+ .refine((usage) => usage.days.every((day, index) => index === 0 ||
158
+ Date.parse(day.start) > Date.parse(usage.days[index - 1].start)), {
159
+ message: "days must ascend by start and appear at most once",
160
+ path: ["days"],
161
+ });
162
+ const DAY_MS = 86_400_000;
163
+ /** UTC midnight of the day an instant falls in. */
164
+ export function utcMidnightMs(instantMs) {
165
+ const at = new Date(instantMs);
166
+ return Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate());
167
+ }
168
+ /**
169
+ * Resolve the window a usage request names, aligned to UTC days.
170
+ *
171
+ * **Both ends are snapped to midnight, and that is not tidiness.** The answer
172
+ * is bucketed by UTC day; a window beginning at nine in the morning would put a
173
+ * partial day at the front of the list with no way for a reader to tell it from
174
+ * a quiet one. Snapping outward — the start of `from`'s day, the start of the
175
+ * day after `to`'s — means the window always covers whole days and never covers
176
+ * less than what was asked for.
177
+ */
178
+ export function resolveUsageWindow(request, nowMs) {
179
+ const defaultToMs = utcMidnightMs(nowMs) + DAY_MS;
180
+ const requestedToMs = request.to === undefined ? defaultToMs : Date.parse(request.to);
181
+ if (!Number.isFinite(requestedToMs)) {
182
+ return { ok: false, refusal: "window-unreadable" };
183
+ }
184
+ const toMs = request.to === undefined
185
+ ? defaultToMs
186
+ : utcMidnightMs(requestedToMs) + DAY_MS;
187
+ const requestedFromMs = request.from === undefined
188
+ ? toMs - USAGE_BOUNDS.defaultWindowDays * DAY_MS
189
+ : Date.parse(request.from);
190
+ if (!Number.isFinite(requestedFromMs)) {
191
+ return { ok: false, refusal: "window-unreadable" };
192
+ }
193
+ const fromMs = utcMidnightMs(requestedFromMs);
194
+ if (fromMs >= toMs)
195
+ return { ok: false, refusal: "window-inverted" };
196
+ if (toMs - fromMs > USAGE_BOUNDS.maxWindowDays * DAY_MS) {
197
+ return { ok: false, refusal: "window-too-wide" };
198
+ }
199
+ return { fromMs, ok: true, toMs };
200
+ }
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Inbound webhook authenticity.
4
+ *
5
+ * Clerk delivers organization and membership changes through Svix, which signs
6
+ * `<svix-id>.<svix-timestamp>.<raw body>` with HMAC-SHA256 under a secret minted
7
+ * per endpoint. This module owns the encoding and the header vocabulary; the
8
+ * adapter owns the key material and the constant-time comparison, exactly as
9
+ * `signatures.ts` splits outbound callback signing.
10
+ *
11
+ * The split matters. Keeping the signed-byte construction in the contract layer
12
+ * means every verifier — the Worker, a test, a future replay tool — signs the
13
+ * same bytes, and the reasoning below is stated once instead of being
14
+ * rediscovered by whoever writes the second one.
15
+ */
16
+ export declare const SVIX_HEADERS: {
17
+ readonly id: "svix-id";
18
+ readonly signature: "svix-signature";
19
+ readonly timestamp: "svix-timestamp";
20
+ };
21
+ /**
22
+ * Where Clerk delivers organization and membership changes.
23
+ *
24
+ * Deliberately outside `/v1`. W1-07 enforces REST/MCP parity over every public
25
+ * `/v1` operation, and a webhook ingress is not a customer-facing operation —
26
+ * putting it there would either break that check or require an exception to it.
27
+ * Follows the internal-route convention set by `/__greatwait/version`.
28
+ *
29
+ * **Here rather than in the Worker that serves it, since W4-08.** The smoke
30
+ * asks a hosted stage whether this route is configured to accept a delivery,
31
+ * and a script cannot import the API application. A second spelling of an
32
+ * address Clerk is pointed at is one that can drift while both read as
33
+ * correct.
34
+ */
35
+ export declare const CLERK_WEBHOOK_PATH = "/__greatwait/webhooks/clerk";
36
+ /** Only `v1` exists. A new scheme is added alongside, never in place of. */
37
+ export declare const WEBHOOK_SIGNATURE_SCHEME: "v1";
38
+ /**
39
+ * How far Clerk's clock may differ from ours before a delivery is stale.
40
+ *
41
+ * Svix's own guidance is five minutes, and matching it is deliberate: a tighter
42
+ * window would reject legitimate deliveries during a retry burst, and a wider
43
+ * one lengthens the replay window for a captured request.
44
+ */
45
+ export declare const WEBHOOK_TOLERANCE_SECONDS = 300;
46
+ /**
47
+ * A Svix message id. Deliberately a restricted charset rather than
48
+ * `z.string().min(1)` — see `webhookSigningInput` for why the absence of `.`
49
+ * here is a security property and not a formatting preference.
50
+ */
51
+ export declare const svixIdSchema: z.ZodString;
52
+ /**
53
+ * Seconds since the Unix epoch, as digits. No sign, no decimal point, no
54
+ * exponent — `Number.parseInt` would happily accept `+1e9` and `12.5`, and both
55
+ * would reach the signed bytes in a form we did not intend.
56
+ */
57
+ export declare const svixTimestampSchema: z.ZodString;
58
+ /**
59
+ * One `v1,<base64>` pair. Svix sends a space-delimited list so an endpoint can
60
+ * be verified under both keys during rotation, which is why the verifier must
61
+ * accept a match against *any* entry rather than parsing a single value.
62
+ */
63
+ export declare const svixSignatureEntryPattern: RegExp;
64
+ export declare const svixSignatureHeaderSchema: z.ZodString;
65
+ export declare const svixHeadersSchema: z.ZodObject<{
66
+ "svix-id": z.ZodString;
67
+ "svix-signature": z.ZodString;
68
+ "svix-timestamp": z.ZodString;
69
+ }, z.core.$strip>;
70
+ /**
71
+ * Every `v1` signature offered in the header, base64 still encoded.
72
+ *
73
+ * Entries in other schemes are dropped rather than rejected: Svix may add a
74
+ * scheme before we support it, and a delivery that carries both `v1` and a
75
+ * future `v2` must still verify under `v1` instead of failing closed on the
76
+ * presence of something newer.
77
+ */
78
+ export declare function webhookSignatureCandidates(header: string): readonly string[];
79
+ /**
80
+ * The exact bytes Svix signs: `<id>.<timestamp>.` followed by the raw body,
81
+ * with no re-serialization anywhere in the path.
82
+ *
83
+ * Both prefix fields are validated rather than trusted, and — as with
84
+ * `signingInput` for outbound callbacks — that is a security property. The
85
+ * delimiter is `.`, so if either field could itself contain a `.` the encoding
86
+ * would not be injective: an attacker holding one legitimate signature could
87
+ * shift bytes across a delimiter, lengthening the id header while shortening
88
+ * the body, and still verify.
89
+ *
90
+ * The argument here is slightly different from the outbound one, which relies
91
+ * on both fields being fixed-width. `svixIdSchema` and `svixTimestampSchema`
92
+ * admit no `.` at all, so splitting the signed content on its first two dots
93
+ * recovers the id and the timestamp uniquely whatever their lengths. Variable
94
+ * width is fine; a dot would not be.
95
+ *
96
+ * **Throws** on malformed input. Use `tryWebhookSigningInput` when verifying
97
+ * headers off the wire, or a malformed-header probe becomes a 500.
98
+ */
99
+ export declare function webhookSigningInput(svixId: string, svixTimestamp: string, rawBody: Uint8Array): Uint8Array;
100
+ /**
101
+ * `webhookSigningInput` for untrusted input: `undefined` instead of a throw when
102
+ * the headers are malformed.
103
+ */
104
+ export declare function tryWebhookSigningInput(svixId: string, svixTimestamp: string, rawBody: Uint8Array): Uint8Array | undefined;
105
+ /**
106
+ * Whether a delivery's timestamp is close enough to now.
107
+ *
108
+ * Rejects the future as firmly as the past. A signature dated an hour ahead is
109
+ * not a clock-skew inconvenience — accepting it would hand an attacker who
110
+ * captures one request an hour of replay window, so the comparison is on
111
+ * absolute difference.
112
+ *
113
+ * Replay protection beyond this window belongs to the receiver, keyed on the
114
+ * Svix message id; freshness alone does not make a delivery unique.
115
+ */
116
+ export declare function isWebhookTimestampFresh(svixTimestamp: string, nowMs: number, toleranceSeconds?: number): boolean;