@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
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CALLBACK_COMPLETION_OUTCOMES, isCallbackRefusalStatus, } from "./delivery.js";
|
|
3
|
+
import { attemptIdSchema, callbackSigningKeyIdSchema, deliveryIdSchema, occurrenceIdSchema, scheduleIdSchema, } from "./identifiers.js";
|
|
4
|
+
import { timestampSchema } from "./time.js";
|
|
5
|
+
/**
|
|
6
|
+
* Callback authenticity.
|
|
7
|
+
*
|
|
8
|
+
* The signature covers the timestamp, the delivery id, and the exact raw
|
|
9
|
+
* request-body bytes. A receiver verifies before parsing, in constant time.
|
|
10
|
+
*
|
|
11
|
+
* This is the most consequential break from the legacy scheduler, which signed
|
|
12
|
+
* `scheduleId.owner.tenant.timestampMs` and never touched the body: a signature
|
|
13
|
+
* that does not cover the body authenticates only the envelope, so an attacker
|
|
14
|
+
* who can replay a captured header set may substitute any payload. W6-03
|
|
15
|
+
* migrates the autoscaler onto this contract through a separate v2 endpoint so
|
|
16
|
+
* the old and new verifiers never weaken each other.
|
|
17
|
+
*
|
|
18
|
+
* The `Callback-*` header namespace is intentionally independent of the display
|
|
19
|
+
* brand. Renaming the product must not force receivers to change verification
|
|
20
|
+
* code.
|
|
21
|
+
*/
|
|
22
|
+
export const CALLBACK_HEADERS = {
|
|
23
|
+
deliveryId: "Callback-Delivery-Id",
|
|
24
|
+
keyId: "Callback-Key-Id",
|
|
25
|
+
occurrenceId: "Callback-Occurrence-Id",
|
|
26
|
+
signature: "Callback-Signature",
|
|
27
|
+
timestamp: "Callback-Timestamp",
|
|
28
|
+
};
|
|
29
|
+
/** Only `v1` exists. A new scheme is added alongside, never in place of. */
|
|
30
|
+
export const SIGNATURE_SCHEME = "v1";
|
|
31
|
+
/** How far a receiver's clock may differ before a signature is stale. */
|
|
32
|
+
export const SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
33
|
+
/** Version of the complete delivery-authentication protocol artifact. */
|
|
34
|
+
export const CALLBACK_DELIVERY_PROTOCOL_VERSION = "2026-08-23";
|
|
35
|
+
/** The media type sent on every occurrence delivery. */
|
|
36
|
+
export const CALLBACK_CONTENT_TYPE = "application/json";
|
|
37
|
+
export const signatureHeaderSchema = z
|
|
38
|
+
.string()
|
|
39
|
+
.regex(/^v1=[0-9a-f]{64}$/u, "must be v1= followed by a lowercase hex SHA-256 HMAC");
|
|
40
|
+
/**
|
|
41
|
+
* The exact bytes that are signed: `<timestamp>.<delivery-id>.` followed by the
|
|
42
|
+
* raw body, with no re-serialization anywhere in the path. Re-encoding JSON
|
|
43
|
+
* before signing or verifying is the classic way to break this.
|
|
44
|
+
*
|
|
45
|
+
* Both prefix fields are validated here rather than trusted, and that is a
|
|
46
|
+
* security property, not tidiness. The delimiter `.` also occurs inside every
|
|
47
|
+
* contract timestamp, so without validation the encoding is not injective:
|
|
48
|
+
* `("2026-07-28T15:00:00", "000Z.dlv_…", body)` produces the same bytes as
|
|
49
|
+
* `("2026-07-28T15:00:00.000Z", "dlv_…", body)`, and an attacker holding one
|
|
50
|
+
* legitimate signature could move bytes across the delimiter — shortening the
|
|
51
|
+
* body and lengthening the delivery-id header — and still verify.
|
|
52
|
+
*
|
|
53
|
+
* Validating makes both fields fixed-width, so the prefix is a constant 56
|
|
54
|
+
* bytes and the encoding is injective. A verifier that computes the MAC before
|
|
55
|
+
* validating its headers is the natural implementation and would be
|
|
56
|
+
* vulnerable; making that impossible here means no receiver has to know.
|
|
57
|
+
*
|
|
58
|
+
* **Throws** on a malformed timestamp or delivery id. That is right on the
|
|
59
|
+
* signing side — refusing to sign something we cannot encode unambiguously —
|
|
60
|
+
* but a receiver verifying attacker-controlled headers wants a boolean, not an
|
|
61
|
+
* exception: use `trySigningInput`, or an uncaught throw turns a
|
|
62
|
+
* malformed-header probe into a 500.
|
|
63
|
+
*/
|
|
64
|
+
export function signingInput(timestamp, deliveryId, rawBody) {
|
|
65
|
+
const checkedTimestamp = timestampSchema.parse(timestamp);
|
|
66
|
+
const checkedDeliveryId = deliveryIdSchema.parse(deliveryId);
|
|
67
|
+
const prefix = `${checkedTimestamp}.${checkedDeliveryId}.`;
|
|
68
|
+
// Both fields are ASCII by construction, now that both have been validated, so
|
|
69
|
+
// UTF-8 encoding is byte-for-byte their code points and the prefix stays the
|
|
70
|
+
// constant 56 bytes the injectivity argument above depends on.
|
|
71
|
+
const prefixBytes = new TextEncoder().encode(prefix);
|
|
72
|
+
const input = new Uint8Array(prefixBytes.length + rawBody.length);
|
|
73
|
+
input.set(prefixBytes, 0);
|
|
74
|
+
input.set(rawBody, prefixBytes.length);
|
|
75
|
+
return input;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `signingInput` for untrusted input: returns `undefined` instead of throwing
|
|
79
|
+
* when the prefix fields are malformed, which is what a receiver verifying
|
|
80
|
+
* headers off the wire needs.
|
|
81
|
+
*/
|
|
82
|
+
export function trySigningInput(timestamp, deliveryId, rawBody) {
|
|
83
|
+
try {
|
|
84
|
+
return signingInput(timestamp, deliveryId, rawBody);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// Only a validation failure means "malformed headers". Anything else — an
|
|
88
|
+
// allocation failure on an oversized body, say — is a real fault and must
|
|
89
|
+
// not be reported to the caller as a bad signature.
|
|
90
|
+
if (error instanceof z.ZodError) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function formatSignature(hexMac) {
|
|
97
|
+
return `${SIGNATURE_SCHEME}=${hexMac}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Whether a signed timestamp is close enough to now. Replay protection beyond
|
|
101
|
+
* this window is the receiver's, keyed on the delivery id.
|
|
102
|
+
*/
|
|
103
|
+
export function isTimestampFresh(timestamp, nowMs, toleranceSeconds = SIGNATURE_TOLERANCE_SECONDS) {
|
|
104
|
+
// Validated with the contract schema, not `Date.parse`. A timestamp this
|
|
105
|
+
// service would never emit — `2026-07-28T15:00:00Z`, say — must not be
|
|
106
|
+
// treated as fresh just because it happens to parse.
|
|
107
|
+
if (!timestampSchema.safeParse(timestamp).success) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return Math.abs(nowMs - Date.parse(timestamp)) <= toleranceSeconds * 1000;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Why this delivery exists.
|
|
114
|
+
*
|
|
115
|
+
* `automatic` is everything the system decided for itself: a first attempt, and
|
|
116
|
+
* every retry after an ambiguous or failed one. Nobody asked for it, it may
|
|
117
|
+
* duplicate work already done, and a receiver must therefore treat it as at
|
|
118
|
+
* most one execution of the occurrence.
|
|
119
|
+
*
|
|
120
|
+
* `redelivery` is a person pressing the button, scoped by
|
|
121
|
+
* `occurrences:redeliver` and named against the retained attempt they chose to
|
|
122
|
+
* follow. It is a *request for the work to happen again*, and a receiver that
|
|
123
|
+
* suppresses it as a duplicate is answering a deliberate instruction with
|
|
124
|
+
* silence.
|
|
125
|
+
*
|
|
126
|
+
* The two were indistinguishable on the wire until this field existed, so the
|
|
127
|
+
* only rule safe against the first was applied to both. That is the defect
|
|
128
|
+
* W3-08 exists to remove, and the reason the field is inside the signed bytes
|
|
129
|
+
* rather than in a header: a receiver that would re-run work on the strength of
|
|
130
|
+
* an unauthenticated hint has handed anyone who can reach its endpoint a
|
|
131
|
+
* duplicate-execution button.
|
|
132
|
+
*/
|
|
133
|
+
export const callbackTriggerSchema = z.discriminatedUnion("kind", [
|
|
134
|
+
z.object({ kind: z.literal("automatic") }),
|
|
135
|
+
z.object({
|
|
136
|
+
kind: z.literal("redelivery"),
|
|
137
|
+
sourceAttemptId: attemptIdSchema,
|
|
138
|
+
}),
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* The envelope version this service emits.
|
|
142
|
+
*
|
|
143
|
+
* Dated and independent of the contract version. It moved on 2026-08-08 because
|
|
144
|
+
* the envelope gained `trigger`, and again on 2026-08-23 because `completion`
|
|
145
|
+
* stopped being optional: every delivery now carries a completion URL, a
|
|
146
|
+
* per-dispatch credential and an absolute deadline, whatever the schedule asked
|
|
147
|
+
* for. `assess` classifies both moves as `breaking` — see
|
|
148
|
+
* `CALLBACK_ENVELOPE_VERSION_CHANGE`.
|
|
149
|
+
*
|
|
150
|
+
* **A required field is why this is a version rather than an addition.** Under
|
|
151
|
+
* `2026-08-08` an absent `completion` means "this schedule did not ask for
|
|
152
|
+
* one"; under `2026-08-23` it means the envelope is malformed. A receiver that
|
|
153
|
+
* could not tell those apart would have to treat "no completion material" as
|
|
154
|
+
* both a normal delivery and a broken one.
|
|
155
|
+
*/
|
|
156
|
+
export const CALLBACK_ENVELOPE_VERSION = "2026-08-23";
|
|
157
|
+
/** The first published envelope version, which this service no longer emits. */
|
|
158
|
+
export const CALLBACK_ENVELOPE_VERSION_2026_07_01 = "2026-07-01";
|
|
159
|
+
/**
|
|
160
|
+
* The second published version, which this service no longer emits.
|
|
161
|
+
*
|
|
162
|
+
* Still accepted inbound, and its known-answer vectors are still published:
|
|
163
|
+
* a receiver deployed before the sender moved keeps verifying deliveries that
|
|
164
|
+
* were signed under it.
|
|
165
|
+
*/
|
|
166
|
+
export const CALLBACK_ENVELOPE_VERSION_2026_08_08 = "2026-08-08";
|
|
167
|
+
/**
|
|
168
|
+
* Every envelope version a receiver may be handed, oldest first.
|
|
169
|
+
*
|
|
170
|
+
* All are accepted on the way in and only the newest is sent, which is what
|
|
171
|
+
* makes a receiver deployable ahead of the sender. A receiver upgraded first
|
|
172
|
+
* keeps verifying the deliveries already in flight; one upgraded afterwards
|
|
173
|
+
* answers `invalid_envelope` to everything, because a version literal it has
|
|
174
|
+
* never heard of is exactly the case the field exists to catch.
|
|
175
|
+
*/
|
|
176
|
+
export const CALLBACK_ENVELOPE_VERSIONS = [
|
|
177
|
+
CALLBACK_ENVELOPE_VERSION_2026_07_01,
|
|
178
|
+
CALLBACK_ENVELOPE_VERSION_2026_08_08,
|
|
179
|
+
CALLBACK_ENVELOPE_VERSION,
|
|
180
|
+
];
|
|
181
|
+
const callbackEnvelopeShape = {
|
|
182
|
+
attempt: z.number().int().positive(),
|
|
183
|
+
/**
|
|
184
|
+
* The customer's payload, delivered verbatim. Deliberately any JSON value:
|
|
185
|
+
* the legacy scheduler accepted arrays and scalars, and narrowing to an
|
|
186
|
+
* object here would make existing payloads undeliverable after migration.
|
|
187
|
+
*/
|
|
188
|
+
data: z.unknown(),
|
|
189
|
+
occurrenceId: occurrenceIdSchema,
|
|
190
|
+
plannedFor: timestampSchema,
|
|
191
|
+
scheduleId: scheduleIdSchema,
|
|
192
|
+
type: z.literal("schedule.occurrence"),
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Where a `callback` receiver reports that it finished, and with what.
|
|
196
|
+
*
|
|
197
|
+
* **Present only on a delivery whose schedule asked for `completion:
|
|
198
|
+
* "callback"`**, which is what makes adding it to an existing envelope version
|
|
199
|
+
* safe rather than a break. A receiver sees this field for the first time on a
|
|
200
|
+
* delivery its own customer deliberately reconfigured; no receiver that has not
|
|
201
|
+
* opted in can be handed one. That is a different situation from `trigger`,
|
|
202
|
+
* which appeared on *every* delivery and moved the version to `2026-08-08` in
|
|
203
|
+
* consequence.
|
|
204
|
+
*
|
|
205
|
+
* The token authenticates the report and nothing else. It is minted per dispatch
|
|
206
|
+
* and names one attempt at one dispatch epoch, so a report replayed against a
|
|
207
|
+
* later attempt is refused by the same fence that refuses a late executor
|
|
208
|
+
* result.
|
|
209
|
+
*/
|
|
210
|
+
export const callbackCompletionSchema = z.object({
|
|
211
|
+
/** Bearer credential for the report. Send it, do not inspect it. */
|
|
212
|
+
token: z.string().min(1),
|
|
213
|
+
/** Absolute URL to POST to when the work is done. */
|
|
214
|
+
url: z.url(),
|
|
215
|
+
});
|
|
216
|
+
/**
|
|
217
|
+
* The completion material every `2026-08-23` delivery carries.
|
|
218
|
+
*
|
|
219
|
+
* The same URL and credential, plus the one thing a receiver could not
|
|
220
|
+
* previously know: **when Greatwait stops waiting.** Without it a receiver
|
|
221
|
+
* deciding whether to accept the work has to infer the deadline from the
|
|
222
|
+
* schedule's configured duration, which it cannot see, and a receiver that
|
|
223
|
+
* reports one second late looks identical to one that never reported at all.
|
|
224
|
+
*
|
|
225
|
+
* `deadline` is the schedule's own allowance measured from this attempt, in
|
|
226
|
+
* the wire's one timestamp representation. It is the maximum: a receiver may
|
|
227
|
+
* ask for an earlier instant in its acknowledgement and may not ask for a
|
|
228
|
+
* later one.
|
|
229
|
+
*/
|
|
230
|
+
export const callbackDeferredCompletionSchema = z.object({
|
|
231
|
+
/** When this attempt stops being waited for. Absolute, not a duration. */
|
|
232
|
+
deadline: timestampSchema,
|
|
233
|
+
/** Bearer credential for the report. Send it, do not inspect it. */
|
|
234
|
+
token: z.string().min(1),
|
|
235
|
+
/** Absolute URL to POST to when the work is done. */
|
|
236
|
+
url: z.url(),
|
|
237
|
+
});
|
|
238
|
+
/**
|
|
239
|
+
* The report body, whose vocabulary lives in the Zod-free `delivery` module so
|
|
240
|
+
* the scheduler's completion route can read it without importing Zod.
|
|
241
|
+
*/
|
|
242
|
+
export const callbackCompletionReportSchema = z.object({
|
|
243
|
+
outcome: z.enum(CALLBACK_COMPLETION_OUTCOMES).default("succeeded"),
|
|
244
|
+
});
|
|
245
|
+
/**
|
|
246
|
+
* The versioned JSON envelope delivered to a customer endpoint.
|
|
247
|
+
*
|
|
248
|
+
* A union rather than one object with an optional field, and the difference is
|
|
249
|
+
* the whole point of a version: under `2026-07-01` a missing `trigger` means
|
|
250
|
+
* "this sender cannot tell you", and under `2026-08-08` it is a malformed
|
|
251
|
+
* envelope. Collapsing the two would leave a receiver unable to know whether
|
|
252
|
+
* silence about a redelivery is an answer or an absence.
|
|
253
|
+
*/
|
|
254
|
+
export const callbackEnvelopeSchema = z.discriminatedUnion("version", [
|
|
255
|
+
z.object({
|
|
256
|
+
...callbackEnvelopeShape,
|
|
257
|
+
/** Envelope version, dated and independent of the contract version. */
|
|
258
|
+
version: z.literal(CALLBACK_ENVELOPE_VERSION_2026_07_01),
|
|
259
|
+
}),
|
|
260
|
+
z.object({
|
|
261
|
+
...callbackEnvelopeShape,
|
|
262
|
+
/**
|
|
263
|
+
* Where to report completion. Present only in `callback` mode, absent in
|
|
264
|
+
* every `response` delivery, which is every delivery this service sent
|
|
265
|
+
* before 2026-08-13.
|
|
266
|
+
*/
|
|
267
|
+
completion: callbackCompletionSchema.optional(),
|
|
268
|
+
/** Why this delivery exists. Required from `2026-08-08` onward. */
|
|
269
|
+
trigger: callbackTriggerSchema,
|
|
270
|
+
version: z.literal(CALLBACK_ENVELOPE_VERSION_2026_08_08),
|
|
271
|
+
}),
|
|
272
|
+
z.object({
|
|
273
|
+
...callbackEnvelopeShape,
|
|
274
|
+
/**
|
|
275
|
+
* Where to report completion, its credential, and the deadline.
|
|
276
|
+
*
|
|
277
|
+
* **Required, on every delivery.** That is the whole of this version: a
|
|
278
|
+
* receiver no longer has to have been configured for deferral to be
|
|
279
|
+
* allowed to defer, so the material cannot be conditional on a setting the
|
|
280
|
+
* receiver cannot see.
|
|
281
|
+
*/
|
|
282
|
+
completion: callbackDeferredCompletionSchema,
|
|
283
|
+
/** Why this delivery exists. Required from `2026-08-08` onward. */
|
|
284
|
+
trigger: callbackTriggerSchema,
|
|
285
|
+
version: z.literal(CALLBACK_ENVELOPE_VERSION),
|
|
286
|
+
}),
|
|
287
|
+
]);
|
|
288
|
+
/**
|
|
289
|
+
* What caused a delivery, for an envelope of any published version.
|
|
290
|
+
*
|
|
291
|
+
* A `2026-07-01` envelope carries no trigger and reads as `automatic`, which is
|
|
292
|
+
* the conservative answer rather than a neutral one: it keeps duplicate
|
|
293
|
+
* suppression in force for a sender that cannot say a redelivery was ordered.
|
|
294
|
+
* Every receiver-side decision goes through this function so that default lives
|
|
295
|
+
* in one place instead of being re-decided at each call site.
|
|
296
|
+
*/
|
|
297
|
+
export function callbackEnvelopeTrigger(envelope) {
|
|
298
|
+
return "trigger" in envelope ? envelope.trigger : { kind: "automatic" };
|
|
299
|
+
}
|
|
300
|
+
export function callbackRun(envelope) {
|
|
301
|
+
return callbackEnvelopeTrigger(envelope).kind === "redelivery"
|
|
302
|
+
? { kind: "redelivery", ordinal: envelope.attempt }
|
|
303
|
+
: { kind: "automatic" };
|
|
304
|
+
}
|
|
305
|
+
const textEncoder = new TextEncoder();
|
|
306
|
+
function isJsonValue(value, ancestors) {
|
|
307
|
+
if (value === null ||
|
|
308
|
+
typeof value === "string" ||
|
|
309
|
+
typeof value === "boolean") {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (typeof value === "number") {
|
|
313
|
+
return Number.isFinite(value);
|
|
314
|
+
}
|
|
315
|
+
if (typeof value !== "object" || ancestors.has(value)) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(value)) {
|
|
319
|
+
ancestors.add(value);
|
|
320
|
+
try {
|
|
321
|
+
return value.every((entry) => isJsonValue(entry, ancestors));
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
ancestors.delete(value);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const prototype = Object.getPrototypeOf(value);
|
|
328
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
ancestors.add(value);
|
|
332
|
+
try {
|
|
333
|
+
return Object.values(value).every((entry) => isJsonValue(entry, ancestors));
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
ancestors.delete(value);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Serialize one callback envelope exactly once in the frozen wire order.
|
|
341
|
+
*
|
|
342
|
+
* The returned bytes are both the bytes HMAC signs and the bytes `fetch`
|
|
343
|
+
* sends. `data` is checked as JSON before stringification so values such as
|
|
344
|
+
* `undefined`, `NaN`, `bigint`, a `Date`, or a cycle cannot be silently omitted
|
|
345
|
+
* or rewritten into a body different from the caller's input.
|
|
346
|
+
*/
|
|
347
|
+
export function serializeCallbackEnvelope(envelope) {
|
|
348
|
+
const checked = callbackEnvelopeSchema.parse(envelope);
|
|
349
|
+
if (!isJsonValue(checked.data, new Set())) {
|
|
350
|
+
throw new TypeError("callback envelope data must be a JSON value");
|
|
351
|
+
}
|
|
352
|
+
const head = {
|
|
353
|
+
version: checked.version,
|
|
354
|
+
type: checked.type,
|
|
355
|
+
scheduleId: checked.scheduleId,
|
|
356
|
+
occurrenceId: checked.occurrenceId,
|
|
357
|
+
plannedFor: checked.plannedFor,
|
|
358
|
+
attempt: checked.attempt,
|
|
359
|
+
};
|
|
360
|
+
if (!("trigger" in checked)) {
|
|
361
|
+
return textEncoder.encode(JSON.stringify({ ...head, data: checked.data }));
|
|
362
|
+
}
|
|
363
|
+
// Rebuilt member by member rather than spread, because these bytes are
|
|
364
|
+
// signed and a parsed object's key order is not a property anyone should
|
|
365
|
+
// have to reason about. `kind` first, `sourceAttemptId` second, and nothing
|
|
366
|
+
// else has a representation here.
|
|
367
|
+
const trigger = checked.trigger.kind === "redelivery"
|
|
368
|
+
? {
|
|
369
|
+
kind: checked.trigger.kind,
|
|
370
|
+
sourceAttemptId: checked.trigger.sourceAttemptId,
|
|
371
|
+
}
|
|
372
|
+
: { kind: checked.trigger.kind };
|
|
373
|
+
// Before `trigger` in both versions that carry it. The wire order is frozen
|
|
374
|
+
// because these bytes are signed, so a field added later still has one fixed
|
|
375
|
+
// position rather than wherever an object literal put it — and `deadline` is
|
|
376
|
+
// appended inside `completion` for the same reason, so a receiver that
|
|
377
|
+
// already reconstructs the `2026-08-08` object only has to add a key rather
|
|
378
|
+
// than to re-derive an order.
|
|
379
|
+
const completion = checked.version === CALLBACK_ENVELOPE_VERSION
|
|
380
|
+
? {
|
|
381
|
+
completion: {
|
|
382
|
+
token: checked.completion.token,
|
|
383
|
+
url: checked.completion.url,
|
|
384
|
+
deadline: checked.completion.deadline,
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
: checked.completion === undefined
|
|
388
|
+
? {}
|
|
389
|
+
: {
|
|
390
|
+
completion: {
|
|
391
|
+
token: checked.completion.token,
|
|
392
|
+
url: checked.completion.url,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
return textEncoder.encode(JSON.stringify({ ...head, ...completion, trigger, data: checked.data }));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The version is matched by *shape* rather than against the published list, and
|
|
399
|
+
* that is deliberate.
|
|
400
|
+
*
|
|
401
|
+
* This runs before the body is parsed, on bytes the MAC has already
|
|
402
|
+
* authenticated, and its one job is to find the two identifiers so the
|
|
403
|
+
* occurrence header can be compared without trusting attacker-controlled JSON.
|
|
404
|
+
* Whether the version is one we publish is a question the schema answers a
|
|
405
|
+
* moment later, and answering it *here* would collapse two different refusals
|
|
406
|
+
* into one: a receiver handed an envelope from a newer sender would report
|
|
407
|
+
* `invalid_headers`, sending an integrator to check their signing keys, when
|
|
408
|
+
* what actually happened is that the two ends disagree about the contract.
|
|
409
|
+
* Matching a dated literal keeps the prefix a fixed shape and lets
|
|
410
|
+
* `invalid_envelope` mean what it says.
|
|
411
|
+
*/
|
|
412
|
+
const CANONICAL_ENVELOPE_ID_PREFIX = /^\{"version":"\d{4}-\d{2}-\d{2}","type":"schedule\.occurrence","scheduleId":"(sch_[0-9abcdefghjkmnpqrstvwxyz]{26})","occurrenceId":"(occ_[0-9abcdefghjkmnpqrstvwxyz]{26})",/u;
|
|
413
|
+
/**
|
|
414
|
+
* Read the two leading envelope identifiers without parsing attacker-controlled
|
|
415
|
+
* JSON. This lets receiver middleware reject a mutated occurrence header after
|
|
416
|
+
* authenticating the exact bytes but before `JSON.parse` or an application
|
|
417
|
+
* handler sees them.
|
|
418
|
+
*/
|
|
419
|
+
export function extractCanonicalEnvelopeIds(rawBody) {
|
|
420
|
+
let decoded;
|
|
421
|
+
try {
|
|
422
|
+
decoded = new TextDecoder("utf-8", {
|
|
423
|
+
fatal: true,
|
|
424
|
+
ignoreBOM: false,
|
|
425
|
+
}).decode(rawBody);
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const match = CANONICAL_ENVELOPE_ID_PREFIX.exec(decoded);
|
|
431
|
+
if (match === null)
|
|
432
|
+
return undefined;
|
|
433
|
+
const scheduleId = scheduleIdSchema.safeParse(match[1]);
|
|
434
|
+
const occurrenceId = occurrenceIdSchema.safeParse(match[2]);
|
|
435
|
+
return scheduleId.success && occurrenceId.success
|
|
436
|
+
? { occurrenceId: occurrenceId.data, scheduleId: scheduleId.data }
|
|
437
|
+
: undefined;
|
|
438
|
+
}
|
|
439
|
+
export const callbackHeadersSchema = z.object({
|
|
440
|
+
[CALLBACK_HEADERS.deliveryId]: deliveryIdSchema,
|
|
441
|
+
[CALLBACK_HEADERS.keyId]: callbackSigningKeyIdSchema,
|
|
442
|
+
[CALLBACK_HEADERS.occurrenceId]: occurrenceIdSchema,
|
|
443
|
+
[CALLBACK_HEADERS.signature]: signatureHeaderSchema,
|
|
444
|
+
[CALLBACK_HEADERS.timestamp]: timestampSchema,
|
|
445
|
+
});
|
|
446
|
+
const VECTOR_PREFIX = "2026-07-28T15:00:00.000Z.dlv_0123456789abcdefghjkmnpqrs.";
|
|
447
|
+
const CALLBACK_DELIVERY_VECTOR_BODY = '{"version":"2026-08-23","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":1,"completion":{"token":"v2.example-claim-not-a-real-token.example-mac","url":"https://greatwait-scheduler.example/callbacks/completion","deadline":"2026-07-28T15:00:05.000Z"},"trigger":{"kind":"automatic"},"data":{}}';
|
|
448
|
+
const CALLBACK_DELIVERY_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_VECTOR_BODY}`;
|
|
449
|
+
const CALLBACK_DELIVERY_UNICODE_VECTOR_BODY = '{"version":"2026-08-23","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":1,"completion":{"token":"v2.example-claim-not-a-real-token.example-mac","url":"https://greatwait-scheduler.example/callbacks/completion","deadline":"2026-07-28T15:00:05.000Z"},"trigger":{"kind":"automatic"},"data":{"note":"café ☃"}}';
|
|
450
|
+
const CALLBACK_DELIVERY_UNICODE_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_UNICODE_VECTOR_BODY}`;
|
|
451
|
+
const CALLBACK_DELIVERY_REDELIVERY_VECTOR_BODY = '{"version":"2026-08-23","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":2,"completion":{"token":"v2.example-claim-not-a-real-token.example-mac","url":"https://greatwait-scheduler.example/callbacks/completion","deadline":"2026-07-28T15:00:05.000Z"},"trigger":{"kind":"redelivery","sourceAttemptId":"att_0123456789abcdefghjkmnpqrs"},"data":{}}';
|
|
452
|
+
const CALLBACK_DELIVERY_REDELIVERY_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_REDELIVERY_VECTOR_BODY}`;
|
|
453
|
+
/**
|
|
454
|
+
* The `2026-08-08` body with no `completion`, byte for byte as published.
|
|
455
|
+
*
|
|
456
|
+
* Under `2026-08-08` an absent `completion` was the ordinary case and this is
|
|
457
|
+
* its known answer. It is the vector `testVector` used to be, **moved rather
|
|
458
|
+
* than recomputed**: the bytes and the MAC are the ones published on
|
|
459
|
+
* 2026-08-08, so a change to `signingInput` still has to break them. Deleting
|
|
460
|
+
* it instead would have retired the only published answer for the shape a
|
|
461
|
+
* receiver mid-rollout is still being handed.
|
|
462
|
+
*/
|
|
463
|
+
const CALLBACK_DELIVERY_TRIGGER_VECTOR_BODY = '{"version":"2026-08-08","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":1,"trigger":{"kind":"automatic"},"data":{}}';
|
|
464
|
+
const CALLBACK_DELIVERY_TRIGGER_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_TRIGGER_VECTOR_BODY}`;
|
|
465
|
+
const CALLBACK_DELIVERY_COMPLETION_VECTOR_BODY = '{"version":"2026-08-08","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":1,"completion":{"token":"v1.example-claim-not-a-real-token.example-mac","url":"https://greatwait-scheduler.example/callbacks/completion"},"trigger":{"kind":"automatic"},"data":{}}';
|
|
466
|
+
const CALLBACK_DELIVERY_COMPLETION_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_COMPLETION_VECTOR_BODY}`;
|
|
467
|
+
/**
|
|
468
|
+
* The `2026-07-01` body, byte for byte as it was published, with the MAC it was
|
|
469
|
+
* published with.
|
|
470
|
+
*
|
|
471
|
+
* It is here rather than deleted because these vectors exist to make a change to
|
|
472
|
+
* `signingInput` fail loudly, and recomputing them is exactly the move that
|
|
473
|
+
* would hide one. Keeping the retired envelope's known answer *unchanged* is
|
|
474
|
+
* what separates "the bytes we send are new" from "the algorithm moved": if this
|
|
475
|
+
* MAC ever has to be recomputed, something signed differently and every
|
|
476
|
+
* published verifier is wrong.
|
|
477
|
+
*/
|
|
478
|
+
const CALLBACK_DELIVERY_LEGACY_VECTOR_BODY = '{"version":"2026-07-01","type":"schedule.occurrence","scheduleId":"sch_0123456789abcdefghjkmnpqrs","occurrenceId":"occ_0123456789abcdefghjkmnpqrs","plannedFor":"2026-07-28T15:00:00.000Z","attempt":1,"data":{}}';
|
|
479
|
+
const CALLBACK_DELIVERY_LEGACY_VECTOR_INPUT = `${VECTOR_PREFIX}${CALLBACK_DELIVERY_LEGACY_VECTOR_BODY}`;
|
|
480
|
+
/**
|
|
481
|
+
* Language-neutral known-answer contract for one complete delivery request.
|
|
482
|
+
* The secret is synthetic and deliberately not shaped like a credential.
|
|
483
|
+
*/
|
|
484
|
+
export const CALLBACK_DELIVERY_PROTOCOL = {
|
|
485
|
+
envelope: {
|
|
486
|
+
/**
|
|
487
|
+
* The frozen wire order, and `completion` sits inside it.
|
|
488
|
+
*
|
|
489
|
+
* **This list is what a receiver outside JavaScript implements against**,
|
|
490
|
+
* so a field that reached the wire without reaching this array would make
|
|
491
|
+
* every callback delivery fail signature verification for exactly the
|
|
492
|
+
* integrators who cannot read the serializer. From `2026-08-23` every
|
|
493
|
+
* delivery carries `completion`; under `2026-08-08` it was optional, and
|
|
494
|
+
* the order was fixed anyway, because a position decided at emit time is a
|
|
495
|
+
* position nobody else can reproduce.
|
|
496
|
+
*/
|
|
497
|
+
propertyOrder: [
|
|
498
|
+
"version",
|
|
499
|
+
"type",
|
|
500
|
+
"scheduleId",
|
|
501
|
+
"occurrenceId",
|
|
502
|
+
"plannedFor",
|
|
503
|
+
"attempt",
|
|
504
|
+
"completion",
|
|
505
|
+
"trigger",
|
|
506
|
+
"data",
|
|
507
|
+
],
|
|
508
|
+
/**
|
|
509
|
+
* The order **inside** `completion`, which is frozen for the same reason.
|
|
510
|
+
*
|
|
511
|
+
* `deadline` is appended rather than inserted, so a receiver that already
|
|
512
|
+
* reconstructs the `2026-08-08` object adds a key instead of re-deriving
|
|
513
|
+
* an order. Under `2026-08-08` the same object is the first two entries
|
|
514
|
+
* and nothing else.
|
|
515
|
+
*/
|
|
516
|
+
completionPropertyOrder: ["token", "url", "deadline"],
|
|
517
|
+
/**
|
|
518
|
+
* The order under `2026-08-08`, whose `completion` is optional and carries
|
|
519
|
+
* no `deadline`. The top-level list is the same; what differs is inside.
|
|
520
|
+
*/
|
|
521
|
+
propertyOrder_2026_08_08: [
|
|
522
|
+
"version",
|
|
523
|
+
"type",
|
|
524
|
+
"scheduleId",
|
|
525
|
+
"occurrenceId",
|
|
526
|
+
"plannedFor",
|
|
527
|
+
"attempt",
|
|
528
|
+
"completion",
|
|
529
|
+
"trigger",
|
|
530
|
+
"data",
|
|
531
|
+
],
|
|
532
|
+
/** The order under `2026-07-01`, which carries no trigger. */
|
|
533
|
+
propertyOrder_2026_07_01: [
|
|
534
|
+
"version",
|
|
535
|
+
"type",
|
|
536
|
+
"scheduleId",
|
|
537
|
+
"occurrenceId",
|
|
538
|
+
"plannedFor",
|
|
539
|
+
"attempt",
|
|
540
|
+
"data",
|
|
541
|
+
],
|
|
542
|
+
serialization: "compact UTF-8 JSON with the stated top-level property order",
|
|
543
|
+
trigger: {
|
|
544
|
+
automatic: "A first attempt or a retry the system chose. Nobody asked for it, it may repeat work already done, and a receiver must run the occurrence at most once.",
|
|
545
|
+
default: "An envelope with no trigger field is automatic. Only 2026-07-01 can omit it.",
|
|
546
|
+
propertyOrder: ["kind", "sourceAttemptId"],
|
|
547
|
+
ordinal: "attempt. It only ever increases within an occurrence and every copy of one redelivery carries the same value, so a receiver recognises a repeat by keeping the highest it has run to completion rather than by keeping them all.",
|
|
548
|
+
redelivery: "A person ordered this delivery under the occurrences:redeliver scope. sourceAttemptId names the retained attempt they chose to follow and is there to show them, not to decide whether the work runs — attempt is what decides that. It is a request for the work to happen again, and a receiver that suppresses it as a duplicate answers a deliberate instruction with silence.",
|
|
549
|
+
requirement: "run the handler again for a redelivery whose attempt is above the highest this receiver has run to completion for the occurrence, keep suppressing every automatic delivery of a completed occurrence, and suppress every redelivery at or below that high-water mark",
|
|
550
|
+
},
|
|
551
|
+
version: CALLBACK_ENVELOPE_VERSION,
|
|
552
|
+
/**
|
|
553
|
+
* Every version a receiver may be handed. A sender emits only the last;
|
|
554
|
+
* accepting the whole list is what lets a receiver deploy first.
|
|
555
|
+
*/
|
|
556
|
+
versions: CALLBACK_ENVELOPE_VERSIONS,
|
|
557
|
+
},
|
|
558
|
+
headers: {
|
|
559
|
+
contentType: CALLBACK_CONTENT_TYPE,
|
|
560
|
+
required: [
|
|
561
|
+
"Content-Type",
|
|
562
|
+
CALLBACK_HEADERS.deliveryId,
|
|
563
|
+
CALLBACK_HEADERS.occurrenceId,
|
|
564
|
+
CALLBACK_HEADERS.timestamp,
|
|
565
|
+
CALLBACK_HEADERS.keyId,
|
|
566
|
+
CALLBACK_HEADERS.signature,
|
|
567
|
+
],
|
|
568
|
+
},
|
|
569
|
+
replay: {
|
|
570
|
+
identity: CALLBACK_HEADERS.deliveryId,
|
|
571
|
+
requirement: "atomically reject a previously accepted delivery id before parsing",
|
|
572
|
+
},
|
|
573
|
+
signature: {
|
|
574
|
+
algorithm: "HMAC-SHA-256",
|
|
575
|
+
format: "v1=<64 lowercase hexadecimal characters>",
|
|
576
|
+
input: "ASCII <Callback-Timestamp>.<Callback-Delivery-Id>. followed by the exact raw request body bytes",
|
|
577
|
+
scheme: SIGNATURE_SCHEME,
|
|
578
|
+
timestampToleranceSeconds: SIGNATURE_TOLERANCE_SECONDS,
|
|
579
|
+
},
|
|
580
|
+
/**
|
|
581
|
+
* The retired `2026-07-01` envelope, kept as a known answer.
|
|
582
|
+
*
|
|
583
|
+
* A receiver is still held to verifying it, because a sender mid-rollout may
|
|
584
|
+
* have signed it before the version moved. Its MAC is the one published on
|
|
585
|
+
* 2026-07-28 and is deliberately not recomputed.
|
|
586
|
+
*/
|
|
587
|
+
legacyTestVector: {
|
|
588
|
+
envelope: {
|
|
589
|
+
attempt: 1,
|
|
590
|
+
data: {},
|
|
591
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
592
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
593
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
594
|
+
type: "schedule.occurrence",
|
|
595
|
+
version: CALLBACK_ENVELOPE_VERSION_2026_07_01,
|
|
596
|
+
},
|
|
597
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wNy0wMSIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoxLCJkYXRhIjp7fX0=",
|
|
598
|
+
exactBodyUtf8: CALLBACK_DELIVERY_LEGACY_VECTOR_BODY,
|
|
599
|
+
headers: {
|
|
600
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
601
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
602
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
603
|
+
"Callback-Signature": "v1=11135fdfc0bc5d49ebcc9d3a0d8df1116179097cefe961c6eca281339e5cfab3",
|
|
604
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
605
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
606
|
+
},
|
|
607
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
608
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA3LTAxIiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjEsImRhdGEiOnt9fQ==",
|
|
609
|
+
signingInputUtf8: CALLBACK_DELIVERY_LEGACY_VECTOR_INPUT,
|
|
610
|
+
},
|
|
611
|
+
/**
|
|
612
|
+
* The retired `2026-08-08` envelope **without** `completion`, kept as a known
|
|
613
|
+
* answer.
|
|
614
|
+
*
|
|
615
|
+
* It is the vector `testVector` was until 2026-08-23, moved here rather than
|
|
616
|
+
* recomputed. Under `2026-08-08` an absent `completion` was the ordinary
|
|
617
|
+
* case, so this is the shape most deliveries signed under that version had,
|
|
618
|
+
* and a receiver still verifying them needs its published answer to stay put.
|
|
619
|
+
*/
|
|
620
|
+
triggerTestVector: {
|
|
621
|
+
envelope: {
|
|
622
|
+
attempt: 1,
|
|
623
|
+
data: {},
|
|
624
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
625
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
626
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
627
|
+
trigger: { kind: "automatic" },
|
|
628
|
+
type: "schedule.occurrence",
|
|
629
|
+
version: CALLBACK_ENVELOPE_VERSION_2026_08_08,
|
|
630
|
+
},
|
|
631
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wOC0wOCIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoxLCJ0cmlnZ2VyIjp7ImtpbmQiOiJhdXRvbWF0aWMifSwiZGF0YSI6e319",
|
|
632
|
+
exactBodyUtf8: CALLBACK_DELIVERY_TRIGGER_VECTOR_BODY,
|
|
633
|
+
headers: {
|
|
634
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
635
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
636
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
637
|
+
"Callback-Signature": "v1=bd96c6d68a5c0521c18600d92913ce4dcf32036f2c930d0f9db9de55da9280e7",
|
|
638
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
639
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
640
|
+
},
|
|
641
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
642
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA4LTA4IiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjEsInRyaWdnZXIiOnsia2luZCI6ImF1dG9tYXRpYyJ9LCJkYXRhIjp7fX0=",
|
|
643
|
+
signingInputUtf8: CALLBACK_DELIVERY_TRIGGER_VECTOR_INPUT,
|
|
644
|
+
},
|
|
645
|
+
/**
|
|
646
|
+
* The retired `2026-08-08` envelope **with** `completion`, kept as a known
|
|
647
|
+
* answer.
|
|
648
|
+
*
|
|
649
|
+
* Under that version the field was optional and appeared only on a schedule
|
|
650
|
+
* configured for `callback` completion, and it carried no `deadline`. This is
|
|
651
|
+
* the published answer for that shape, byte for byte and MAC unchanged: a
|
|
652
|
+
* receiver mid-rollout is still handed it, and recomputing this vector is
|
|
653
|
+
* exactly the move that would hide a change to `signingInput`.
|
|
654
|
+
*
|
|
655
|
+
* The token is synthetic and shaped like ours without being one. A real token
|
|
656
|
+
* names an attempt at a dispatch epoch and expires; this is a vector.
|
|
657
|
+
*/
|
|
658
|
+
completionTestVector: {
|
|
659
|
+
envelope: {
|
|
660
|
+
attempt: 1,
|
|
661
|
+
completion: {
|
|
662
|
+
token: "v1.example-claim-not-a-real-token.example-mac",
|
|
663
|
+
url: "https://greatwait-scheduler.example/callbacks/completion",
|
|
664
|
+
},
|
|
665
|
+
data: {},
|
|
666
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
667
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
668
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
669
|
+
trigger: { kind: "automatic" },
|
|
670
|
+
type: "schedule.occurrence",
|
|
671
|
+
version: CALLBACK_ENVELOPE_VERSION_2026_08_08,
|
|
672
|
+
},
|
|
673
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wOC0wOCIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoxLCJjb21wbGV0aW9uIjp7InRva2VuIjoidjEuZXhhbXBsZS1jbGFpbS1ub3QtYS1yZWFsLXRva2VuLmV4YW1wbGUtbWFjIiwidXJsIjoiaHR0cHM6Ly9ncmVhdHdhaXQtc2NoZWR1bGVyLmV4YW1wbGUvY2FsbGJhY2tzL2NvbXBsZXRpb24ifSwidHJpZ2dlciI6eyJraW5kIjoiYXV0b21hdGljIn0sImRhdGEiOnt9fQ==",
|
|
674
|
+
exactBodyUtf8: CALLBACK_DELIVERY_COMPLETION_VECTOR_BODY,
|
|
675
|
+
headers: {
|
|
676
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
677
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
678
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
679
|
+
"Callback-Signature": "v1=38c6d854051087ed408a5f6252d90375859f305aeb0bca1c3f3a5bb63135e138",
|
|
680
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
681
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
682
|
+
},
|
|
683
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
684
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA4LTA4IiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjEsImNvbXBsZXRpb24iOnsidG9rZW4iOiJ2MS5leGFtcGxlLWNsYWltLW5vdC1hLXJlYWwtdG9rZW4uZXhhbXBsZS1tYWMiLCJ1cmwiOiJodHRwczovL2dyZWF0d2FpdC1zY2hlZHVsZXIuZXhhbXBsZS9jYWxsYmFja3MvY29tcGxldGlvbiJ9LCJ0cmlnZ2VyIjp7ImtpbmQiOiJhdXRvbWF0aWMifSwiZGF0YSI6e319",
|
|
685
|
+
signingInputUtf8: CALLBACK_DELIVERY_COMPLETION_VECTOR_INPUT,
|
|
686
|
+
},
|
|
687
|
+
/**
|
|
688
|
+
* A redelivery, which is the only vector that discriminates this change.
|
|
689
|
+
*
|
|
690
|
+
* The two automatic vectors would still pass an implementation that dropped
|
|
691
|
+
* `trigger` on the floor and hard-coded `{"kind":"automatic"}`; this one
|
|
692
|
+
* cannot, because both the kind and the source attempt are inside the signed
|
|
693
|
+
* bytes.
|
|
694
|
+
*/
|
|
695
|
+
redeliveryTestVector: {
|
|
696
|
+
envelope: {
|
|
697
|
+
attempt: 2,
|
|
698
|
+
completion: {
|
|
699
|
+
deadline: "2026-07-28T15:00:05.000Z",
|
|
700
|
+
token: "v2.example-claim-not-a-real-token.example-mac",
|
|
701
|
+
url: "https://greatwait-scheduler.example/callbacks/completion",
|
|
702
|
+
},
|
|
703
|
+
data: {},
|
|
704
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
705
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
706
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
707
|
+
trigger: {
|
|
708
|
+
kind: "redelivery",
|
|
709
|
+
sourceAttemptId: "att_0123456789abcdefghjkmnpqrs",
|
|
710
|
+
},
|
|
711
|
+
type: "schedule.occurrence",
|
|
712
|
+
version: CALLBACK_ENVELOPE_VERSION,
|
|
713
|
+
},
|
|
714
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wOC0yMyIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoyLCJjb21wbGV0aW9uIjp7InRva2VuIjoidjIuZXhhbXBsZS1jbGFpbS1ub3QtYS1yZWFsLXRva2VuLmV4YW1wbGUtbWFjIiwidXJsIjoiaHR0cHM6Ly9ncmVhdHdhaXQtc2NoZWR1bGVyLmV4YW1wbGUvY2FsbGJhY2tzL2NvbXBsZXRpb24iLCJkZWFkbGluZSI6IjIwMjYtMDctMjhUMTU6MDA6MDUuMDAwWiJ9LCJ0cmlnZ2VyIjp7ImtpbmQiOiJyZWRlbGl2ZXJ5Iiwic291cmNlQXR0ZW1wdElkIjoiYXR0XzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIn0sImRhdGEiOnt9fQ==",
|
|
715
|
+
exactBodyUtf8: CALLBACK_DELIVERY_REDELIVERY_VECTOR_BODY,
|
|
716
|
+
headers: {
|
|
717
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
718
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
719
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
720
|
+
"Callback-Signature": "v1=9d698aed6d05dcd964f00d6e6796aab238f4f50e6f5cfeb61751fd7850778a04",
|
|
721
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
722
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
723
|
+
},
|
|
724
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
725
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA4LTIzIiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjIsImNvbXBsZXRpb24iOnsidG9rZW4iOiJ2Mi5leGFtcGxlLWNsYWltLW5vdC1hLXJlYWwtdG9rZW4uZXhhbXBsZS1tYWMiLCJ1cmwiOiJodHRwczovL2dyZWF0d2FpdC1zY2hlZHVsZXIuZXhhbXBsZS9jYWxsYmFja3MvY29tcGxldGlvbiIsImRlYWRsaW5lIjoiMjAyNi0wNy0yOFQxNTowMDowNS4wMDBaIn0sInRyaWdnZXIiOnsia2luZCI6InJlZGVsaXZlcnkiLCJzb3VyY2VBdHRlbXB0SWQiOiJhdHRfMDEyMzQ1Njc4OWFiY2RlZmdoamttbnBxcnMifSwiZGF0YSI6e319",
|
|
726
|
+
signingInputUtf8: CALLBACK_DELIVERY_REDELIVERY_VECTOR_INPUT,
|
|
727
|
+
},
|
|
728
|
+
/**
|
|
729
|
+
* The ordinary current-version delivery: automatic, and carrying completion
|
|
730
|
+
* material because every `2026-08-23` delivery does.
|
|
731
|
+
*/
|
|
732
|
+
testVector: {
|
|
733
|
+
envelope: {
|
|
734
|
+
attempt: 1,
|
|
735
|
+
completion: {
|
|
736
|
+
deadline: "2026-07-28T15:00:05.000Z",
|
|
737
|
+
token: "v2.example-claim-not-a-real-token.example-mac",
|
|
738
|
+
url: "https://greatwait-scheduler.example/callbacks/completion",
|
|
739
|
+
},
|
|
740
|
+
data: {},
|
|
741
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
742
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
743
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
744
|
+
trigger: { kind: "automatic" },
|
|
745
|
+
type: "schedule.occurrence",
|
|
746
|
+
version: CALLBACK_ENVELOPE_VERSION,
|
|
747
|
+
},
|
|
748
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wOC0yMyIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoxLCJjb21wbGV0aW9uIjp7InRva2VuIjoidjIuZXhhbXBsZS1jbGFpbS1ub3QtYS1yZWFsLXRva2VuLmV4YW1wbGUtbWFjIiwidXJsIjoiaHR0cHM6Ly9ncmVhdHdhaXQtc2NoZWR1bGVyLmV4YW1wbGUvY2FsbGJhY2tzL2NvbXBsZXRpb24iLCJkZWFkbGluZSI6IjIwMjYtMDctMjhUMTU6MDA6MDUuMDAwWiJ9LCJ0cmlnZ2VyIjp7ImtpbmQiOiJhdXRvbWF0aWMifSwiZGF0YSI6e319",
|
|
749
|
+
exactBodyUtf8: CALLBACK_DELIVERY_VECTOR_BODY,
|
|
750
|
+
headers: {
|
|
751
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
752
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
753
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
754
|
+
"Callback-Signature": "v1=5bf68b786fbac1afa7e3474ae5039ad27900b0185bca43a3e45185d56315a312",
|
|
755
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
756
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
757
|
+
},
|
|
758
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
759
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA4LTIzIiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjEsImNvbXBsZXRpb24iOnsidG9rZW4iOiJ2Mi5leGFtcGxlLWNsYWltLW5vdC1hLXJlYWwtdG9rZW4uZXhhbXBsZS1tYWMiLCJ1cmwiOiJodHRwczovL2dyZWF0d2FpdC1zY2hlZHVsZXIuZXhhbXBsZS9jYWxsYmFja3MvY29tcGxldGlvbiIsImRlYWRsaW5lIjoiMjAyNi0wNy0yOFQxNTowMDowNS4wMDBaIn0sInRyaWdnZXIiOnsia2luZCI6ImF1dG9tYXRpYyJ9LCJkYXRhIjp7fX0=",
|
|
760
|
+
signingInputUtf8: CALLBACK_DELIVERY_VECTOR_INPUT,
|
|
761
|
+
},
|
|
762
|
+
unicodeTestVector: {
|
|
763
|
+
envelope: {
|
|
764
|
+
attempt: 1,
|
|
765
|
+
completion: {
|
|
766
|
+
deadline: "2026-07-28T15:00:05.000Z",
|
|
767
|
+
token: "v2.example-claim-not-a-real-token.example-mac",
|
|
768
|
+
url: "https://greatwait-scheduler.example/callbacks/completion",
|
|
769
|
+
},
|
|
770
|
+
data: { note: "café ☃" },
|
|
771
|
+
occurrenceId: "occ_0123456789abcdefghjkmnpqrs",
|
|
772
|
+
plannedFor: "2026-07-28T15:00:00.000Z",
|
|
773
|
+
scheduleId: "sch_0123456789abcdefghjkmnpqrs",
|
|
774
|
+
trigger: { kind: "automatic" },
|
|
775
|
+
type: "schedule.occurrence",
|
|
776
|
+
version: CALLBACK_ENVELOPE_VERSION,
|
|
777
|
+
},
|
|
778
|
+
exactBodyBase64: "eyJ2ZXJzaW9uIjoiMjAyNi0wOC0yMyIsInR5cGUiOiJzY2hlZHVsZS5vY2N1cnJlbmNlIiwic2NoZWR1bGVJZCI6InNjaF8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsIm9jY3VycmVuY2VJZCI6Im9jY18wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycyIsInBsYW5uZWRGb3IiOiIyMDI2LTA3LTI4VDE1OjAwOjAwLjAwMFoiLCJhdHRlbXB0IjoxLCJjb21wbGV0aW9uIjp7InRva2VuIjoidjIuZXhhbXBsZS1jbGFpbS1ub3QtYS1yZWFsLXRva2VuLmV4YW1wbGUtbWFjIiwidXJsIjoiaHR0cHM6Ly9ncmVhdHdhaXQtc2NoZWR1bGVyLmV4YW1wbGUvY2FsbGJhY2tzL2NvbXBsZXRpb24iLCJkZWFkbGluZSI6IjIwMjYtMDctMjhUMTU6MDA6MDUuMDAwWiJ9LCJ0cmlnZ2VyIjp7ImtpbmQiOiJhdXRvbWF0aWMifSwiZGF0YSI6eyJub3RlIjoiY2Fmw6kg4piDIn19",
|
|
779
|
+
exactBodyUtf8: CALLBACK_DELIVERY_UNICODE_VECTOR_BODY,
|
|
780
|
+
headers: {
|
|
781
|
+
"Callback-Delivery-Id": "dlv_0123456789abcdefghjkmnpqrs",
|
|
782
|
+
"Callback-Key-Id": "csk_0123456789abcdefghjkmnpqrs",
|
|
783
|
+
"Callback-Occurrence-Id": "occ_0123456789abcdefghjkmnpqrs",
|
|
784
|
+
"Callback-Signature": "v1=18a8d1c50ea54bd0a5ec27abe80d1c98e44a7f358ecf8f06615c174075100e72",
|
|
785
|
+
"Callback-Timestamp": "2026-07-28T15:00:00.000Z",
|
|
786
|
+
"Content-Type": CALLBACK_CONTENT_TYPE,
|
|
787
|
+
},
|
|
788
|
+
secret: "greatwait-signature-vector-key-not-a-credential",
|
|
789
|
+
signingInputBase64: "MjAyNi0wNy0yOFQxNTowMDowMC4wMDBaLmRsdl8wMTIzNDU2Nzg5YWJjZGVmZ2hqa21ucHFycy57InZlcnNpb24iOiIyMDI2LTA4LTIzIiwidHlwZSI6InNjaGVkdWxlLm9jY3VycmVuY2UiLCJzY2hlZHVsZUlkIjoic2NoXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwib2NjdXJyZW5jZUlkIjoib2NjXzAxMjM0NTY3ODlhYmNkZWZnaGprbW5wcXJzIiwicGxhbm5lZEZvciI6IjIwMjYtMDctMjhUMTU6MDA6MDAuMDAwWiIsImF0dGVtcHQiOjEsImNvbXBsZXRpb24iOnsidG9rZW4iOiJ2Mi5leGFtcGxlLWNsYWltLW5vdC1hLXJlYWwtdG9rZW4uZXhhbXBsZS1tYWMiLCJ1cmwiOiJodHRwczovL2dyZWF0d2FpdC1zY2hlZHVsZXIuZXhhbXBsZS9jYWxsYmFja3MvY29tcGxldGlvbiIsImRlYWRsaW5lIjoiMjAyNi0wNy0yOFQxNTowMDowNS4wMDBaIn0sInRyaWdnZXIiOnsia2luZCI6ImF1dG9tYXRpYyJ9LCJkYXRhIjp7Im5vdGUiOiJjYWbDqSDimIMifX0=",
|
|
790
|
+
signingInputUtf8: CALLBACK_DELIVERY_UNICODE_VECTOR_INPUT,
|
|
791
|
+
},
|
|
792
|
+
version: CALLBACK_DELIVERY_PROTOCOL_VERSION,
|
|
793
|
+
};
|
|
794
|
+
/**
|
|
795
|
+
* Every published vector, derived from the protocol rather than listed.
|
|
796
|
+
*
|
|
797
|
+
* **A hand-written list is the failure this exists to prevent.** The duplicated
|
|
798
|
+
* serializer in `@greatwait/delivery` is held byte-for-byte against these, and
|
|
799
|
+
* the one time it drifted, it drifted on the field whose vector nobody had
|
|
800
|
+
* added to the list yet. Reading the protocol object means a vector added later
|
|
801
|
+
* is driven by every suite that already iterates this, with no second edit.
|
|
802
|
+
*
|
|
803
|
+
* **Matched by shape rather than by name**, which is not fastidiousness: the
|
|
804
|
+
* first version of this matched keys ending in `TestVector` and silently
|
|
805
|
+
* dropped `testVector`, whose lower-case first letter makes that suffix test
|
|
806
|
+
* false. A filter that quietly returns one fewer than it should is the same
|
|
807
|
+
* failure as the hand-written list, arrived at differently.
|
|
808
|
+
*/
|
|
809
|
+
export function callbackDeliveryTestVectors() {
|
|
810
|
+
return Object.entries(CALLBACK_DELIVERY_PROTOCOL)
|
|
811
|
+
.filter(([, value]) => typeof value === "object" &&
|
|
812
|
+
value !== null &&
|
|
813
|
+
typeof value.exactBodyUtf8 === "string")
|
|
814
|
+
.map(([name, vector]) => ({
|
|
815
|
+
...vector,
|
|
816
|
+
name,
|
|
817
|
+
}));
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* What moving the envelope to `2026-08-08` is, in the vocabulary the
|
|
821
|
+
* compatibility module classifies.
|
|
822
|
+
*
|
|
823
|
+
* Recorded as data rather than asserted in prose because the verdict is an
|
|
824
|
+
* output: `assess(CALLBACK_ENVELOPE_VERSION_CHANGE)` returns
|
|
825
|
+
* `{ kind: "breaking", requiresNewContractVersion: true }`, and a fixture holds
|
|
826
|
+
* it there. "It only added a field, so it is additive" is the reading this
|
|
827
|
+
* exists to refuse — what a client parses is not the field, it is the version
|
|
828
|
+
* literal, and that literal *changed*.
|
|
829
|
+
*
|
|
830
|
+
* The contract version does not move with it. `CONTRACT_VERSION` versions the
|
|
831
|
+
* REST surface; the envelope carries its own dated version precisely so a break
|
|
832
|
+
* in the callback body does not force every API client to re-pin.
|
|
833
|
+
*/
|
|
834
|
+
export const CALLBACK_ENVELOPE_VERSION_CHANGE = {
|
|
835
|
+
operation: "changed",
|
|
836
|
+
subject: "response-field",
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* Classifies a status code alone. Use `classifyOutcome` where a request may not
|
|
840
|
+
* complete.
|
|
841
|
+
*
|
|
842
|
+
* **`isCallbackRefusalStatus` is consulted rather than duplicated here.** This
|
|
843
|
+
* function is the published statement of what a receiver's answer means and
|
|
844
|
+
* `performGreatwaitAttempt` is what acts on it; before FXO-1596 the two carried
|
|
845
|
+
* the same status boundary as two independent literals, kept equal by hand.
|
|
846
|
+
* They are now one list, so a status cannot be retried by the executor while
|
|
847
|
+
* this function tells the customer it was terminal.
|
|
848
|
+
*/
|
|
849
|
+
export function classifyStatus(status) {
|
|
850
|
+
if (status >= 200 && status < 300) {
|
|
851
|
+
return "success";
|
|
852
|
+
}
|
|
853
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
854
|
+
return "retryable";
|
|
855
|
+
}
|
|
856
|
+
// A refusal is retryable to a receiver author for the same reason it is to
|
|
857
|
+
// the executor: the cause is theirs to fix, and the delivery that follows the
|
|
858
|
+
// fix carries the identical bytes.
|
|
859
|
+
return isCallbackRefusalStatus(status) ? "retryable" : "terminal";
|
|
860
|
+
}
|
|
861
|
+
export function classifyOutcome(outcome) {
|
|
862
|
+
// A lost response is ambiguous, never terminal. Treating it as a customer
|
|
863
|
+
// refusal would drop the occurrence without ever retrying it.
|
|
864
|
+
return outcome.kind === "no-response"
|
|
865
|
+
? "ambiguous"
|
|
866
|
+
: classifyStatus(outcome.status);
|
|
867
|
+
}
|