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