@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,206 @@
1
+ /**
2
+ * The seam between the API Worker and a Scheduler Partition Durable Object.
3
+ *
4
+ * **This is not a public wire shape.** Nothing here is served to a customer,
5
+ * appears in OpenAPI, or is reachable from the internet — a Durable Object has
6
+ * no route, and only a Worker holding the namespace binding can address one.
7
+ * It lives in the contracts package for the single reason that two workspaces
8
+ * need to agree on it and this is the only package both `apps/api` and
9
+ * `apps/scheduler` may depend on. The alternative was each side writing the
10
+ * shape out separately, which is how `apps/scheduler/src/contract-runtime.ts`
11
+ * became a filed debt.
12
+ *
13
+ * It carries **no Zod and no runtime import of the contract barrel**. The
14
+ * addressing and routing functions below are a string join and a hash, and the
15
+ * rest is types, which are erased. That matters: `apps/scheduler` is the Worker
16
+ * every Durable Object cold start parses, and W2-08 measured the contract
17
+ * barrel's runtime exports at 3.4x its bundle (see
18
+ * `apps/scheduler/src/contract-runtime.ts`).
19
+ *
20
+ * **`@greatwait/contracts/partition` is a subpath export for that reason**,
21
+ * added by W2-05 when the scheduler needed the route function at runtime rather
22
+ * than only its types. Importing it through the barrel would have reached Zod
23
+ * and undone the whole of `contract-runtime.ts`. This is a narrow instance of
24
+ * what [FXO-1404](https://linear.app/flowxo/issue/FXO-1404) asks for in
25
+ * general — a Zod-free entry point — and it does not close that issue: the
26
+ * identifier, timestamp and problem helpers `contract-runtime.ts` copies still
27
+ * live in modules that do reach Zod.
28
+ */
29
+ /**
30
+ * The shard every partition had until W2-05, and the only one an unsharded
31
+ * deployment ever uses.
32
+ *
33
+ * Named rather than written as `"0"` at two call sites, because the API Worker
34
+ * and the partition must derive the same Durable Object name and a literal
35
+ * repeated across a workspace boundary is a literal that will eventually
36
+ * disagree with itself. `partitionShardFor` returns exactly this when the shard
37
+ * count is one, so every Durable Object name a single-shard deployment produces
38
+ * is byte-identical to the ones it produced before sharding existed.
39
+ */
40
+ export const SINGLE_SHARD = "0";
41
+ /**
42
+ * Projection intents handed over in one call.
43
+ *
44
+ * **Declared here because both sides of the seam enforce it, and a bound
45
+ * enforced twice from two workspaces is a bound that will eventually disagree
46
+ * with itself.** The API Worker asks for this many; the partition clamps what it
47
+ * hands over to it and *refuses* a settle carrying more. If the asking number
48
+ * ever exceeded the refusing one, every settle would be refused, no intent would
49
+ * ever be retired, the outbox would grow to `PARTITION_LIMITS.outboxRows` and
50
+ * creates would then start failing for bookkeeping — a customer-visible outage
51
+ * produced by two constants drifting apart. The test-gap audit named exactly
52
+ * that, and the fix is one declaration rather than a test comparing two.
53
+ *
54
+ * Thirty-two is one D1 batch's worth of work and a few kilobytes of response.
55
+ * The caller comes back for the rest, and `RetainedProjections.pending` is how
56
+ * it knows to.
57
+ */
58
+ export const PROJECTION_DRAIN_LIMIT = 32;
59
+ /**
60
+ * The most shards one project/environment may be spread across.
61
+ *
62
+ * Eight, and the number is borrowed rather than invented:
63
+ * `apps/scheduler/src/routing.ts` clamps `HOST_EXECUTOR_SHARDS` at eight, and a
64
+ * repository carrying two different bounds on two knobs of the same shape is a
65
+ * repository where somebody eventually applies the wrong one. The costs differ
66
+ * but they point the same way — an executor shard divides a host budget, and a
67
+ * partition shard divides a project's schedules across Durable Objects.
68
+ *
69
+ * A listing had to fan out across all of them until W2-06 served listings from
70
+ * D1, which was the sharpest reason for a bound and is now gone. What is left is
71
+ * still a real fan-out: a listing whose projection is stale re-confirms every
72
+ * partition it covers, and a repair after an outage hands over from each in
73
+ * turn. Eight is where that stops being a request.
74
+ *
75
+ * A value above this is clamped rather than refused, which is upstream's
76
+ * judgement in `executorShardCount` and holds for the same reason: an operator
77
+ * asking for sixteen still wants sharding, just bounded.
78
+ */
79
+ export const MAX_PARTITION_SHARDS = 8;
80
+ /**
81
+ * FNV-1a, 32-bit, with the standard constants.
82
+ *
83
+ * A second copy of `apps/scheduler/src/routing.ts`'s `fnv1a32`, and the
84
+ * duplication is deliberate rather than overlooked. That file is copied
85
+ * upstream source which W3-03 retenants, and this package may not depend on an
86
+ * application workspace in either direction. `partition.test.ts` holds the two
87
+ * equal over a vector set, which is the same arrangement
88
+ * `partition-contract-parity.test.ts` uses for the state vocabularies: the
89
+ * duplication is safe because a divergence fails a test rather than shipping.
90
+ */
91
+ function fnv1a32(input) {
92
+ let hash = 0x811c9dc5;
93
+ for (let index = 0; index < input.length; index++) {
94
+ hash ^= input.charCodeAt(index);
95
+ hash = Math.imul(hash, 0x01000193);
96
+ }
97
+ return hash >>> 0;
98
+ }
99
+ /**
100
+ * How many shards a deployment spreads one project/environment across.
101
+ *
102
+ * Reads the raw environment string rather than an `Env`, so both Workers parse
103
+ * it the same way without either of them naming the other's bindings. Missing,
104
+ * unparseable, non-integer and below-one all resolve to one — the unsharded
105
+ * default — because a partition count is the thing a *routing* decision is made
106
+ * from, and a routing decision made from a garbage value is worse than one made
107
+ * from the default.
108
+ *
109
+ * **Changing it moves where existing identifiers resolve, so it is retunable
110
+ * only under a precondition, and that is the most important thing to know about
111
+ * it.** A schedule's shard is derived from its identifier. Before W2-06 that was
112
+ * the only answer, so raising the count stranded every schedule already stored.
113
+ * Now the shard a schedule was actually stored in is *recorded* in the D1
114
+ * directory, and `resolveSchedule` falls back to it whenever the derived
115
+ * partition has nothing — so a schedule created under an older count keeps
116
+ * working, because its route was written down rather than recomputed.
117
+ *
118
+ * The precondition is that every schedule **has** such a route, which is exactly
119
+ * what `greatwait_projection_checkpoint` reporting `pending: 0` for every
120
+ * partition means. A schedule whose projection never landed has no recorded
121
+ * route and no derivable one, and it is unreachable until its partition is
122
+ * drained. `docs/operations/cloudflare-environments.md` carries the procedure
123
+ * and the two costs it does not remove — a keyed create's idempotency receipt is
124
+ * stranded for its 24-hour life, and detail reads of pre-change schedules cost
125
+ * an extra round trip for as long as they exist.
126
+ *
127
+ * It is still not `HOST_EXECUTOR_SHARDS`, which rolls `1 → 4 → 8` under load
128
+ * because an executor holds no durable state. This one holds all of it, and
129
+ * moving it is a planned operation rather than a dial.
130
+ */
131
+ export function partitionShardCount(raw) {
132
+ if (raw === undefined) {
133
+ return 1;
134
+ }
135
+ const count = Number(raw);
136
+ if (!Number.isInteger(count) || count < 1) {
137
+ return 1;
138
+ }
139
+ return Math.min(count, MAX_PARTITION_SHARDS);
140
+ }
141
+ /**
142
+ * The shard a schedule identifier belongs to.
143
+ *
144
+ * Architecture section 4's route function: the stable opaque schedule ID hashed
145
+ * into a project/environment shard, with the count an operational configuration
146
+ * rather than a public contract. Nothing a *client* sends reaches this — the
147
+ * identifier is minted by the API Worker — so a caller can neither choose their
148
+ * shard nor learn how many there are.
149
+ */
150
+ export function partitionShardFor(scheduleId, shardCount) {
151
+ return shardOf(`sch:${scheduleId}`, shardCount);
152
+ }
153
+ /**
154
+ * The shard a create carrying an idempotency key routes to.
155
+ *
156
+ * **A create is the one operation that cannot route by schedule identifier,
157
+ * because it is the operation that mints one.** Every other operation is handed
158
+ * an identifier and derives the shard from it; a create has to pick a partition
159
+ * first and name the schedule afterwards. Picking randomly would break
160
+ * idempotency the moment there is more than one shard: the retry that carries
161
+ * the same key would mint a different identifier, land in a different Durable
162
+ * Object, find no receipt there, and create a second schedule — the exact
163
+ * failure the receipt exists to prevent.
164
+ *
165
+ * So a keyed create routes on the key, and the identifier is then minted *into*
166
+ * that shard (`mintScheduleIdIn`), which keeps `partitionShardFor` the single
167
+ * answer for every later read. A create with no key routes on the identifier it
168
+ * mints, because there is nothing to be idempotent about.
169
+ *
170
+ * The `idk:` prefix separates the two domains, so an identifier and a key that
171
+ * happen to be the same string cannot be conflated into the same hash input.
172
+ * The key is customer input and it selects a shard **inside the caller's own
173
+ * authority tuple**: it cannot reach another organization, project or
174
+ * environment, because those are the other components of the Durable Object
175
+ * name and none of them comes from the request.
176
+ */
177
+ export function partitionShardForIdempotencyKey(key, shardCount) {
178
+ return shardOf(`idk:${key}`, shardCount);
179
+ }
180
+ function shardOf(input, shardCount) {
181
+ return shardCount <= 1 ? SINGLE_SHARD : String(fnv1a32(input) % shardCount);
182
+ }
183
+ /**
184
+ * The Durable Object instance name for one authority tuple and shard.
185
+ *
186
+ * Deriving the name from the authority is what makes a cross-tenant read reach
187
+ * a *different object*, so tenancy is enforced by addressing before any code
188
+ * runs. The partition's own bound-identity check is then defence against a
189
+ * defect in this function rather than the only thing standing between two
190
+ * tenants.
191
+ *
192
+ * `:` is safe as a separator because every component is either a prefixed
193
+ * identifier drawn from Crockford base32 or the fixed literal `test`/`live`,
194
+ * and none of those can contain it. Callers must validate the tuple before
195
+ * calling: this function makes a name, it does not police one.
196
+ */
197
+ export function partitionInstanceName(authority, shard) {
198
+ return [
199
+ authority.organization,
200
+ authority.project,
201
+ authority.environment,
202
+ authority.environmentKind,
203
+ "s",
204
+ shard,
205
+ ].join(":");
206
+ }
@@ -0,0 +1,93 @@
1
+ import { type PrincipalKind, type Scope } from "./authority.js";
2
+ import { type MembershipRole } from "./identity.js";
3
+ import { type OperationDefinition } from "./operations.js";
4
+ export declare const ROLE_SCOPE_CEILINGS: Readonly<Record<MembershipRole, readonly Scope[]>>;
5
+ /** Everything a role may hold, expanded so a write implies its read. */
6
+ export declare function ceilingForRole(role: MembershipRole): Set<Scope>;
7
+ /** Whether a role may ever hold a scope. */
8
+ export declare function roleAllows(role: MembershipRole, scope: Scope): boolean;
9
+ /**
10
+ * Narrow requested scopes to what a role may actually hold.
11
+ *
12
+ * The rule the project specification states as "OAuth scopes cannot exceed the
13
+ * granting member's current role". Expressed as an intersection rather than a
14
+ * validation, deliberately: a grant request naming a scope above the ceiling is
15
+ * *reduced*, not refused. Refusing would tell the caller which scopes exist above
16
+ * their role, and a partial grant is what the OAuth consent screen has to show
17
+ * anyway.
18
+ *
19
+ * Order follows `SCOPES`, so the result is stable regardless of how the request
20
+ * was ordered — a granted-scope list that reorders between requests looks like a
21
+ * change to anything comparing them.
22
+ */
23
+ export declare function capScopesToRole(requested: readonly Scope[], role: MembershipRole): readonly Scope[];
24
+ /**
25
+ * The scopes an OAuth client may ask a member to grant.
26
+ *
27
+ * **Derived from what the operations actually accept, not from `SCOPES`.** The
28
+ * authorization server advertises a `scopes_supported` list and the consent
29
+ * screen offers it; without this, a client could request — and a member could
30
+ * approve — a scope whose only operation refuses OAuth principals outright. The
31
+ * grant would look granted and buy nothing, which is the worst kind of consent:
32
+ * one that misrepresents what was agreed to.
33
+ *
34
+ * `projects:read` is the first scope this excludes. Listing an organization's
35
+ * projects is a signed-in member's question, and a token's authority already is
36
+ * one project.
37
+ */
38
+ export declare function grantableScopes(): readonly Scope[];
39
+ /**
40
+ * The scopes an API credential can actually use.
41
+ *
42
+ * **`grantableScopes`'s sibling, and it exists for the same reason.** That one
43
+ * stops a consent screen offering a scope whose only operation refuses OAuth
44
+ * principals — "a grant that looks granted and buys nothing, which is the worst
45
+ * kind of consent". A credential-issuing surface has the identical exposure: a
46
+ * key issued with a scope no credential-callable operation declares is a key
47
+ * that will be refused `authentication_required` on the only route it names,
48
+ * for ever, and nothing about the key says so.
49
+ *
50
+ * `projects:read` was the first such scope and `projects:write` is the second —
51
+ * both belong to operations that accept only a signed-in member, because a
52
+ * key's authority already *is* one project. Deriving the list rather than
53
+ * naming them is what keeps a third from arriving unnoticed.
54
+ */
55
+ export declare function credentialScopes(): readonly Scope[];
56
+ /** One row of the generated policy matrix. */
57
+ export interface PolicyMatrixRow {
58
+ /**
59
+ * Scopes only some calls to this operation need.
60
+ *
61
+ * Present on the row rather than folded into `scopes`, because folding them
62
+ * would say every caller needs them — and `roles` below would then report
63
+ * that a member cannot rotate a credential at all, which is false.
64
+ */
65
+ readonly conditionalScopes?: readonly Scope[];
66
+ readonly mutates: boolean;
67
+ readonly operation: string;
68
+ /** Which kinds of principal may perform it. Empty means none is needed. */
69
+ readonly principals: readonly PrincipalKind[];
70
+ /** Roles that may perform it, in `MEMBERSHIP_ROLES` order. */
71
+ readonly roles: readonly MembershipRole[];
72
+ /** Scopes the operation requires. Empty means no principal is needed. */
73
+ readonly scopes: readonly Scope[];
74
+ }
75
+ /**
76
+ * Whether a role can perform an operation.
77
+ *
78
+ * An operation requiring no scopes needs no principal at all, so every role can
79
+ * reach it — and so can an unauthenticated caller. That is stated here rather
80
+ * than left implicit because "requires no scope" and "is public" are the same
81
+ * sentence, and a reader should not have to infer it.
82
+ */
83
+ export declare function roleCanPerform(role: MembershipRole, operation: OperationDefinition): boolean;
84
+ /**
85
+ * The policy matrix, derived from the operation registry.
86
+ *
87
+ * Generated rather than written down. A hand-maintained matrix is a second
88
+ * statement of the same policy, and the two drift — which is precisely what the
89
+ * artifact pipeline exists to prevent. Adding an operation to `OPERATIONS`
90
+ * changes this output, and `check:contracts` fails until the committed artifact
91
+ * matches.
92
+ */
93
+ export declare function policyMatrix(): readonly PolicyMatrixRow[];
package/dist/policy.js ADDED
@@ -0,0 +1,180 @@
1
+ import { SCOPES, effectiveScopes, } from "./authority.js";
2
+ import { MEMBERSHIP_ROLES } from "./identity.js";
3
+ import { OPERATIONS } from "./operations.js";
4
+ /** The registry as its declared interface. See `grantableScopes` for why. */
5
+ function allOperations() {
6
+ return OPERATIONS;
7
+ }
8
+ /**
9
+ * One policy for every way in.
10
+ *
11
+ * A Clerk session, an API credential, and an OAuth grant all resolve to the same
12
+ * `Principal`, and every one of them is authorized by the rules here. That is the
13
+ * point of W1-03: the legacy scheduler let each entry point decide for itself, so
14
+ * whichever one was least careful became the effective policy.
15
+ *
16
+ * **Scope names are public.** Renaming one is a contract change, not a
17
+ * refactor — an API credential issued yesterday carries the old name, and a
18
+ * customer's code names it too. `SCOPES` is the frozen list; this module says who
19
+ * may hold which.
20
+ */
21
+ /**
22
+ * The most a human role may ever hold.
23
+ *
24
+ * A ceiling, not a grant. An API credential or OAuth grant is issued *within* a
25
+ * member's ceiling and is usually narrower; nothing anywhere may exceed it. That
26
+ * asymmetry is what stops a member minting a credential more powerful than
27
+ * themselves, which is the standard privilege-escalation route in a system with
28
+ * delegated credentials.
29
+ *
30
+ * `admin` holds everything, and is written as `SCOPES` rather than a copied list
31
+ * so a new scope cannot be introduced that admins silently lack.
32
+ *
33
+ * `member` is everything except `credentials:administer` — acting on a
34
+ * credential somebody else issued.
35
+ *
36
+ * **It was everything except credential management entirely, and the operator
37
+ * changed that on 2026-08-15.** The reason it changed is that the argument for
38
+ * the old line did not support it: the comment above justifies the *ceiling
39
+ * mechanism*, which stops a credential exceeding its issuer, and that holds
40
+ * whatever the ceiling contains. What withholding `credentials:write` actually
41
+ * bought was a developer who could already create live schedules and live
42
+ * destinations from the browser, and had to ask somebody else for the key that
43
+ * does the same thing by another route. This is a developer tool and a `member`
44
+ * is a developer.
45
+ *
46
+ * **What is left is the distinction that survives being asked "who does this
47
+ * protect".** Your own access is yours; somebody else's is not, and taking a
48
+ * colleague's key away — or rotating it, which retires it on a delay — belongs
49
+ * with the role that manages the organization. Inviting and removing people is
50
+ * Clerk's and this product does not do it, so this is the whole of what `admin`
51
+ * means here.
52
+ */
53
+ const MEMBER_SCOPES = SCOPES.filter((scope) => scope !== "credentials:administer");
54
+ export const ROLE_SCOPE_CEILINGS = {
55
+ admin: SCOPES,
56
+ member: MEMBER_SCOPES,
57
+ };
58
+ /** Everything a role may hold, expanded so a write implies its read. */
59
+ export function ceilingForRole(role) {
60
+ return effectiveScopes(ROLE_SCOPE_CEILINGS[role]);
61
+ }
62
+ /** Whether a role may ever hold a scope. */
63
+ export function roleAllows(role, scope) {
64
+ return ceilingForRole(role).has(scope);
65
+ }
66
+ /**
67
+ * Narrow requested scopes to what a role may actually hold.
68
+ *
69
+ * The rule the project specification states as "OAuth scopes cannot exceed the
70
+ * granting member's current role". Expressed as an intersection rather than a
71
+ * validation, deliberately: a grant request naming a scope above the ceiling is
72
+ * *reduced*, not refused. Refusing would tell the caller which scopes exist above
73
+ * their role, and a partial grant is what the OAuth consent screen has to show
74
+ * anyway.
75
+ *
76
+ * Order follows `SCOPES`, so the result is stable regardless of how the request
77
+ * was ordered — a granted-scope list that reorders between requests looks like a
78
+ * change to anything comparing them.
79
+ */
80
+ export function capScopesToRole(requested, role) {
81
+ const ceiling = ceilingForRole(role);
82
+ const asked = new Set(requested);
83
+ return SCOPES.filter((scope) => asked.has(scope) && ceiling.has(scope));
84
+ }
85
+ /**
86
+ * The scopes an OAuth client may ask a member to grant.
87
+ *
88
+ * **Derived from what the operations actually accept, not from `SCOPES`.** The
89
+ * authorization server advertises a `scopes_supported` list and the consent
90
+ * screen offers it; without this, a client could request — and a member could
91
+ * approve — a scope whose only operation refuses OAuth principals outright. The
92
+ * grant would look granted and buy nothing, which is the worst kind of consent:
93
+ * one that misrepresents what was agreed to.
94
+ *
95
+ * `projects:read` is the first scope this excludes. Listing an organization's
96
+ * projects is a signed-in member's question, and a token's authority already is
97
+ * one project.
98
+ */
99
+ export function grantableScopes() {
100
+ // `.some` rather than `.includes`: `OPERATIONS` is a literal array, so each
101
+ // entry's `principals` is its own tuple type and `includes` narrows its
102
+ // argument to that tuple's members — which is `never` across the union.
103
+ // **`operationsFor` rather than `OPERATIONS`**: the registry is a
104
+ // const-asserted tuple, so each entry is its own literal type and an optional
105
+ // field absent from one entry is absent from the union. Reading it as the
106
+ // declared interface is what lets an optional field be read at all.
107
+ const grantable = new Set(allOperations()
108
+ .filter((operation) => operation.principals.some((kind) => kind === "oauth"))
109
+ .flatMap((operation) => [
110
+ ...operation.scopes,
111
+ // **And the ones only some calls need.** A scope an operation can refuse
112
+ // for but never declares is a scope no grant can carry, so the refusal
113
+ // would be unreachable through OAuth — and `credentials:administer` is
114
+ // the only scope a member does not hold, which would have left every
115
+ // role-narrowing rule on the consent path with nothing to narrow.
116
+ ...(operation.conditionalScopes ?? []),
117
+ ]));
118
+ // `SCOPES` order, so the advertised list is stable regardless of registry order.
119
+ return SCOPES.filter((scope) => grantable.has(scope));
120
+ }
121
+ /**
122
+ * The scopes an API credential can actually use.
123
+ *
124
+ * **`grantableScopes`'s sibling, and it exists for the same reason.** That one
125
+ * stops a consent screen offering a scope whose only operation refuses OAuth
126
+ * principals — "a grant that looks granted and buys nothing, which is the worst
127
+ * kind of consent". A credential-issuing surface has the identical exposure: a
128
+ * key issued with a scope no credential-callable operation declares is a key
129
+ * that will be refused `authentication_required` on the only route it names,
130
+ * for ever, and nothing about the key says so.
131
+ *
132
+ * `projects:read` was the first such scope and `projects:write` is the second —
133
+ * both belong to operations that accept only a signed-in member, because a
134
+ * key's authority already *is* one project. Deriving the list rather than
135
+ * naming them is what keeps a third from arriving unnoticed.
136
+ */
137
+ export function credentialScopes() {
138
+ const usable = new Set(allOperations()
139
+ .filter((operation) => operation.principals.some((kind) => kind === "api-credential"))
140
+ .flatMap((operation) => [
141
+ ...operation.scopes,
142
+ ...(operation.conditionalScopes ?? []),
143
+ ]));
144
+ return SCOPES.filter((scope) => usable.has(scope));
145
+ }
146
+ /**
147
+ * Whether a role can perform an operation.
148
+ *
149
+ * An operation requiring no scopes needs no principal at all, so every role can
150
+ * reach it — and so can an unauthenticated caller. That is stated here rather
151
+ * than left implicit because "requires no scope" and "is public" are the same
152
+ * sentence, and a reader should not have to infer it.
153
+ */
154
+ export function roleCanPerform(role, operation) {
155
+ const ceiling = ceilingForRole(role);
156
+ return operation.scopes.every((scope) => ceiling.has(scope));
157
+ }
158
+ /**
159
+ * The policy matrix, derived from the operation registry.
160
+ *
161
+ * Generated rather than written down. A hand-maintained matrix is a second
162
+ * statement of the same policy, and the two drift — which is precisely what the
163
+ * artifact pipeline exists to prevent. Adding an operation to `OPERATIONS`
164
+ * changes this output, and `check:contracts` fails until the committed artifact
165
+ * matches.
166
+ */
167
+ export function policyMatrix() {
168
+ return [...allOperations()]
169
+ .sort((left, right) => left.id.localeCompare(right.id))
170
+ .map((operation) => ({
171
+ mutates: operation.mutates,
172
+ operation: operation.id,
173
+ ...(operation.conditionalScopes === undefined
174
+ ? {}
175
+ : { conditionalScopes: [...operation.conditionalScopes] }),
176
+ principals: [...operation.principals],
177
+ roles: MEMBERSHIP_ROLES.filter((role) => roleCanPerform(role, operation)),
178
+ scopes: [...operation.scopes],
179
+ }));
180
+ }
@@ -0,0 +1,158 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Refusals.
4
+ *
5
+ * Every non-2xx response is an RFC 9457 problem document. It carries a stable
6
+ * machine-readable `code`, a human `title`, and — where the caller can act on
7
+ * it — a `retry` hint and per-field `errors`. The legacy scheduler returned
8
+ * `{ "error": "invalid_cron" }` with no category, no correlation, and no way
9
+ * for a client to tell "retry this" from "never retry this"; every adapter and
10
+ * SDK invented its own interpretation.
11
+ *
12
+ * `code` is the contract. `title` and `detail` are prose and may change without
13
+ * being a breaking change; clients must never branch on them.
14
+ */
15
+ export declare const PROBLEM_CATEGORIES: readonly ["authentication", "authorization", "conflict", "invalid-request", "not-found", "rate-limited", "unavailable"];
16
+ export declare const problemCategorySchema: z.ZodEnum<{
17
+ authentication: "authentication";
18
+ authorization: "authorization";
19
+ conflict: "conflict";
20
+ "invalid-request": "invalid-request";
21
+ "not-found": "not-found";
22
+ "rate-limited": "rate-limited";
23
+ unavailable: "unavailable";
24
+ }>;
25
+ export type ProblemCategory = z.infer<typeof problemCategorySchema>;
26
+ /**
27
+ * The stable refusal catalog. Adding a code is additive; removing one, or
28
+ * changing the category or status of an existing one, is breaking.
29
+ */
30
+ export declare const PROBLEM_CODES: {
31
+ readonly authentication_required: {
32
+ readonly category: "authentication";
33
+ readonly status: 401;
34
+ };
35
+ readonly credential_expired: {
36
+ readonly category: "authentication";
37
+ readonly status: 401;
38
+ };
39
+ readonly credential_revoked: {
40
+ readonly category: "authentication";
41
+ readonly status: 401;
42
+ };
43
+ readonly endpoint_unverified: {
44
+ readonly category: "invalid-request";
45
+ readonly status: 422;
46
+ };
47
+ readonly idempotency_key_reused: {
48
+ readonly category: "conflict";
49
+ readonly status: 409;
50
+ };
51
+ readonly invalid_request: {
52
+ readonly category: "invalid-request";
53
+ readonly status: 400;
54
+ };
55
+ readonly not_found: {
56
+ readonly category: "not-found";
57
+ readonly status: 404;
58
+ };
59
+ readonly payload_too_large: {
60
+ readonly category: "invalid-request";
61
+ readonly status: 413;
62
+ };
63
+ readonly plan_limit_reached: {
64
+ readonly category: "conflict";
65
+ readonly status: 409;
66
+ };
67
+ readonly rate_limited: {
68
+ readonly category: "rate-limited";
69
+ readonly status: 429;
70
+ };
71
+ readonly scope_missing: {
72
+ readonly category: "authorization";
73
+ readonly status: 403;
74
+ };
75
+ readonly service_unavailable: {
76
+ readonly category: "unavailable";
77
+ readonly status: 503;
78
+ };
79
+ readonly state_conflict: {
80
+ readonly category: "conflict";
81
+ readonly status: 409;
82
+ };
83
+ readonly test_to_live_escalation: {
84
+ readonly category: "authorization";
85
+ readonly status: 403;
86
+ };
87
+ readonly version_conflict: {
88
+ readonly category: "conflict";
89
+ readonly status: 409;
90
+ };
91
+ };
92
+ export type ProblemCode = keyof typeof PROBLEM_CODES;
93
+ export declare const problemCodeSchema: z.ZodEnum<{
94
+ authentication_required: "authentication_required";
95
+ credential_expired: "credential_expired";
96
+ credential_revoked: "credential_revoked";
97
+ endpoint_unverified: "endpoint_unverified";
98
+ idempotency_key_reused: "idempotency_key_reused";
99
+ invalid_request: "invalid_request";
100
+ not_found: "not_found";
101
+ payload_too_large: "payload_too_large";
102
+ plan_limit_reached: "plan_limit_reached";
103
+ rate_limited: "rate_limited";
104
+ scope_missing: "scope_missing";
105
+ service_unavailable: "service_unavailable";
106
+ state_conflict: "state_conflict";
107
+ test_to_live_escalation: "test_to_live_escalation";
108
+ version_conflict: "version_conflict";
109
+ }>;
110
+ /** Whether repeating the identical request could succeed later. */
111
+ export declare function isRetryable(code: ProblemCode): boolean;
112
+ export declare const fieldErrorSchema: z.ZodObject<{
113
+ pointer: z.ZodString;
114
+ reason: z.ZodString;
115
+ }, z.core.$strip>;
116
+ export type FieldError = z.infer<typeof fieldErrorSchema>;
117
+ export declare const problemSchema: z.ZodObject<{
118
+ code: z.ZodEnum<{
119
+ authentication_required: "authentication_required";
120
+ credential_expired: "credential_expired";
121
+ credential_revoked: "credential_revoked";
122
+ endpoint_unverified: "endpoint_unverified";
123
+ idempotency_key_reused: "idempotency_key_reused";
124
+ invalid_request: "invalid_request";
125
+ not_found: "not_found";
126
+ payload_too_large: "payload_too_large";
127
+ plan_limit_reached: "plan_limit_reached";
128
+ rate_limited: "rate_limited";
129
+ scope_missing: "scope_missing";
130
+ service_unavailable: "service_unavailable";
131
+ state_conflict: "state_conflict";
132
+ test_to_live_escalation: "test_to_live_escalation";
133
+ version_conflict: "version_conflict";
134
+ }>;
135
+ detail: z.ZodOptional<z.ZodString>;
136
+ errors: z.ZodOptional<z.ZodArray<z.ZodObject<{
137
+ pointer: z.ZodString;
138
+ reason: z.ZodString;
139
+ }, z.core.$strip>>>;
140
+ retryAfterSeconds: z.ZodOptional<z.ZodNumber>;
141
+ status: z.ZodNumber;
142
+ title: z.ZodString;
143
+ traceId: z.ZodOptional<z.ZodString>;
144
+ type: z.ZodLiteral<"about:blank">;
145
+ }, z.core.$strip>;
146
+ export type Problem = z.infer<typeof problemSchema>;
147
+ export interface ProblemOptions {
148
+ readonly detail?: string;
149
+ readonly errors?: readonly FieldError[];
150
+ readonly retryAfterSeconds?: number;
151
+ readonly title: string;
152
+ readonly traceId?: string;
153
+ }
154
+ /**
155
+ * Builds a problem whose status and category cannot contradict its code, and
156
+ * which cannot carry a retry hint on a code that is not retryable.
157
+ */
158
+ export declare function problem(code: ProblemCode, options: ProblemOptions): Problem;