@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,755 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CALLBACK_DEFERRAL_STATUS, CALLBACK_PENDING_DISCRIMINATOR, } from "./delivery.js";
|
|
3
|
+
import { callbackSigningKeyIdSchema, deliveryIdSchema, endpointIdSchema, occurrenceIdSchema, projectIdSchema, scheduleIdSchema, testSinkDeliveryIdSchema, testSinkIdSchema, } from "./identifiers.js";
|
|
4
|
+
import { pageSchema } from "./protocol.js";
|
|
5
|
+
import { CALLBACK_HEADERS, signatureHeaderSchema } from "./signatures.js";
|
|
6
|
+
import { timestampSchema } from "./time.js";
|
|
7
|
+
/**
|
|
8
|
+
* The deterministic test sink and the receiver contract it and the shipped
|
|
9
|
+
* middleware both implement.
|
|
10
|
+
*
|
|
11
|
+
* An integrator cannot rehearse a delivery failure against their own receiver
|
|
12
|
+
* without first building one that fails on demand, and the failures that matter
|
|
13
|
+
* — a dropped connection, a receiver that answers three minutes late, a 429
|
|
14
|
+
* carrying a delay hint — are exactly the ones hardest to produce by hand. The
|
|
15
|
+
* sink produces them on request, in test environments only.
|
|
16
|
+
*
|
|
17
|
+
* Two properties are load-bearing and are the reason this vocabulary lives in
|
|
18
|
+
* the contract package rather than inside the API Worker.
|
|
19
|
+
*
|
|
20
|
+
* **The sink and the SDK middleware must agree.** The sink is a reference
|
|
21
|
+
* receiver built on the middleware this story ships, so an integrator who
|
|
22
|
+
* compares the two is comparing one implementation against itself. That is only
|
|
23
|
+
* worth anything if the status a receiver returns for each outcome is published
|
|
24
|
+
* rather than chosen twice — hence `CALLBACK_RECEIVER_STATUS` below.
|
|
25
|
+
*
|
|
26
|
+
* **A sink is a receiver, and the only thing it ever sends is a report about a
|
|
27
|
+
* delivery it has already received.** That sentence used to read "never a
|
|
28
|
+
* sender", and the deferring behaviors added on 2026-08-23 are why it does not:
|
|
29
|
+
* a receiver that defers is one that answers `202` now and reports later, and a
|
|
30
|
+
* sink that could not report later could rehearse the first half of that
|
|
31
|
+
* exchange and none of the half where it goes wrong.
|
|
32
|
+
*
|
|
33
|
+
* What has not changed is that **there is deliberately no field anywhere in
|
|
34
|
+
* this file that names a place to send to.** A completion report goes to the
|
|
35
|
+
* credential inside the signed envelope of the delivery in hand, sent to the
|
|
36
|
+
* one address the stage is configured with, and
|
|
37
|
+
* nowhere else: no program, no control operation and no request header names a
|
|
38
|
+
* destination, so a sink still cannot be asked to forward, redirect or fetch,
|
|
39
|
+
* and still cannot be configured into an egress proxy. A delivery whose
|
|
40
|
+
* envelope carries no completion material cannot defer at all.
|
|
41
|
+
*/
|
|
42
|
+
/* -------------------------------------------------------------------------- */
|
|
43
|
+
/* Failure simulation */
|
|
44
|
+
/* -------------------------------------------------------------------------- */
|
|
45
|
+
/**
|
|
46
|
+
* What a sink does with a delivery. Exhaustive, and each entry corresponds to a
|
|
47
|
+
* distinct sender-visible outcome class rather than to a distinct status code.
|
|
48
|
+
*
|
|
49
|
+
* `timeout` and `disconnect` are both ambiguous to the sender, and that is the
|
|
50
|
+
* point rather than a defect in the simulation: a sender genuinely cannot tell
|
|
51
|
+
* them apart, which is why the architecture records ambiguity honestly instead
|
|
52
|
+
* of guessing. They differ in what the *receiver* did — see
|
|
53
|
+
* `TEST_SINK_BEHAVIOR_FACTS`.
|
|
54
|
+
*
|
|
55
|
+
* **The three `defer_*` behaviors are three entries rather than one entry with
|
|
56
|
+
* an option**, for exactly the reason stated above: they produce three
|
|
57
|
+
* different sender-visible outcomes. All three answer the same `202` and the
|
|
58
|
+
* same pending marker, so a list that collapsed them would be a list keyed on
|
|
59
|
+
* the status a receiver returns — which is the thing this list is deliberately
|
|
60
|
+
* not keyed on. What separates them happens *after* the acknowledgement, and it
|
|
61
|
+
* is the whole of what an integrator rehearsing deferral needs to see.
|
|
62
|
+
*/
|
|
63
|
+
export const TEST_SINK_BEHAVIORS = [
|
|
64
|
+
"client_error",
|
|
65
|
+
"defer_abandon",
|
|
66
|
+
"defer_fail",
|
|
67
|
+
"defer_succeed",
|
|
68
|
+
"delay",
|
|
69
|
+
"disconnect",
|
|
70
|
+
"duplicate",
|
|
71
|
+
"invalid_signature",
|
|
72
|
+
"rate_limited",
|
|
73
|
+
"server_error",
|
|
74
|
+
"success",
|
|
75
|
+
"timeout",
|
|
76
|
+
];
|
|
77
|
+
export const testSinkBehaviorSchema = z.enum(TEST_SINK_BEHAVIORS);
|
|
78
|
+
/**
|
|
79
|
+
* Bounds on what a sink may be asked to do.
|
|
80
|
+
*
|
|
81
|
+
* Every one of these exists because the sink runs inside the API Worker's
|
|
82
|
+
* request budget on the product's own account. A test facility that can be
|
|
83
|
+
* asked to hold a request open indefinitely, or to accumulate history without
|
|
84
|
+
* limit, is a denial-of-service surface wearing a test badge.
|
|
85
|
+
*/
|
|
86
|
+
export const TEST_SINK_BOUNDS = {
|
|
87
|
+
/**
|
|
88
|
+
* Longest a sink will wait between acknowledging a deferral and reporting
|
|
89
|
+
* completion.
|
|
90
|
+
*
|
|
91
|
+
* Bounded for the same reason `maxDelayMs` is, and the reason survives the
|
|
92
|
+
* response having already been sent: the report is performed on the same
|
|
93
|
+
* invocation, so a sink asked to report an hour later would be asking the API
|
|
94
|
+
* Worker to stay alive for an hour on the product's own account. Smaller than
|
|
95
|
+
* `maxDelayMs` because nothing is waiting on this wait — a rehearsal of
|
|
96
|
+
* deferral is a rehearsal of the *shape* of the exchange, and any integrator
|
|
97
|
+
* who needs to watch a real deadline expire has `defer_abandon`, which costs
|
|
98
|
+
* nothing at all.
|
|
99
|
+
*/
|
|
100
|
+
maxCompletionDelayMs: 5_000,
|
|
101
|
+
/** Longest `delay` a sink will honour before answering. */
|
|
102
|
+
maxDelayMs: 10_000,
|
|
103
|
+
/** Longest a `timeout` or `disconnect` holds a request open. */
|
|
104
|
+
maxHoldMs: 45_000,
|
|
105
|
+
/** Retained delivery receipts per sink; the oldest are evicted. */
|
|
106
|
+
maxRetainedDeliveries: 50,
|
|
107
|
+
/** Sinks per project/environment. */
|
|
108
|
+
maxSinksPerEnvironment: 10,
|
|
109
|
+
/** Largest request body a sink will read. Larger is refused unread. */
|
|
110
|
+
maxRequestBodyBytes: 65_536,
|
|
111
|
+
/** Captured body bytes retained on a receipt; beyond this it is truncated. */
|
|
112
|
+
maxRetainedBodyBytes: 4_096,
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Selected 4xx a sink may answer with.
|
|
116
|
+
*
|
|
117
|
+
* **They are no longer all terminal to the sender, and which are is not this
|
|
118
|
+
* file's decision.** FXO-1596 moved the refusals — a receiver declining *this
|
|
119
|
+
* delivery now* rather than objecting to the message — onto the receiver's
|
|
120
|
+
* ordinary five-failure budget. `TEST_SINK_REFUSAL_STATUSES` below is the
|
|
121
|
+
* subset of this list that the sender will retry, and `test-sink.test.ts`
|
|
122
|
+
* holds it equal to what `classifyStatus` actually says, so a status moved in
|
|
123
|
+
* `CALLBACK_REFUSAL_STATUSES` and not here fails a test rather than teaching an
|
|
124
|
+
* integrator the wrong lesson.
|
|
125
|
+
*
|
|
126
|
+
* Nothing is removed from what a caller may request: every status here was
|
|
127
|
+
* accepted before and still is.
|
|
128
|
+
*/
|
|
129
|
+
export const TEST_SINK_CLIENT_ERROR_STATUSES = [
|
|
130
|
+
400, 401, 403, 404, 409, 410, 422,
|
|
131
|
+
];
|
|
132
|
+
/**
|
|
133
|
+
* The statuses above that the sender retries rather than settling on.
|
|
134
|
+
*
|
|
135
|
+
* Exists so an integrator simulating a rotated credential (401) or a WAF rule
|
|
136
|
+
* (403) can see five attempts arrive, and one simulating a malformed body (400)
|
|
137
|
+
* or a duplicate (409) can see exactly one.
|
|
138
|
+
*/
|
|
139
|
+
export const TEST_SINK_REFUSAL_STATUSES = [401, 403, 404];
|
|
140
|
+
/** Selected 5xx a sink may answer with. Each is retryable to the sender. */
|
|
141
|
+
export const TEST_SINK_SERVER_ERROR_STATUSES = [500, 502, 503, 504];
|
|
142
|
+
/**
|
|
143
|
+
* A deterministic behavior program.
|
|
144
|
+
*
|
|
145
|
+
* `behavior` applies to the first `forDeliveries` deliveries and `then` to
|
|
146
|
+
* every one after that, so "fail twice, then succeed" is one declaration rather
|
|
147
|
+
* than a sequence of control calls racing the schedule that is firing. Leaving
|
|
148
|
+
* `forDeliveries` out makes `behavior` permanent, which is what a single
|
|
149
|
+
* failure mode under test wants.
|
|
150
|
+
*
|
|
151
|
+
* The count is over deliveries this sink has *received*, not over a particular
|
|
152
|
+
* occurrence's attempts: a sink is a destination and does not get to know which
|
|
153
|
+
* occurrence the sender considers current.
|
|
154
|
+
*/
|
|
155
|
+
export const testSinkProgramSchema = z
|
|
156
|
+
.object({
|
|
157
|
+
behavior: testSinkBehaviorSchema,
|
|
158
|
+
/**
|
|
159
|
+
* Milliseconds between acknowledging a deferral and reporting completion.
|
|
160
|
+
* Only meaningful for a behavior that reports one.
|
|
161
|
+
*/
|
|
162
|
+
completionDelayMs: z
|
|
163
|
+
.number()
|
|
164
|
+
.int()
|
|
165
|
+
.positive()
|
|
166
|
+
.max(TEST_SINK_BOUNDS.maxCompletionDelayMs)
|
|
167
|
+
.optional(),
|
|
168
|
+
/** Milliseconds to wait before answering. Only meaningful for `delay`. */
|
|
169
|
+
delayMs: z
|
|
170
|
+
.number()
|
|
171
|
+
.int()
|
|
172
|
+
.positive()
|
|
173
|
+
.max(TEST_SINK_BOUNDS.maxDelayMs)
|
|
174
|
+
.optional(),
|
|
175
|
+
/**
|
|
176
|
+
* How many deliveries `behavior` applies to before `then` takes over.
|
|
177
|
+
* Absent means "every delivery".
|
|
178
|
+
*/
|
|
179
|
+
forDeliveries: z.number().int().positive().max(100).optional(),
|
|
180
|
+
/** `Retry-After` seconds sent with `rate_limited`. */
|
|
181
|
+
retryAfterSeconds: z.number().int().positive().max(300).optional(),
|
|
182
|
+
/** Which 4xx or 5xx to answer with. Defaults per behavior. */
|
|
183
|
+
status: z.number().int().min(400).max(599).optional(),
|
|
184
|
+
/** Behavior after `forDeliveries` is exhausted. Defaults to `success`. */
|
|
185
|
+
then: testSinkBehaviorSchema.optional(),
|
|
186
|
+
})
|
|
187
|
+
.superRefine((program, context) => {
|
|
188
|
+
if (program.status !== undefined) {
|
|
189
|
+
const allowed = program.behavior === "client_error"
|
|
190
|
+
? TEST_SINK_CLIENT_ERROR_STATUSES
|
|
191
|
+
: program.behavior === "server_error"
|
|
192
|
+
? TEST_SINK_SERVER_ERROR_STATUSES
|
|
193
|
+
: [];
|
|
194
|
+
if (!allowed.includes(program.status)) {
|
|
195
|
+
context.addIssue({
|
|
196
|
+
code: "custom",
|
|
197
|
+
message: "status is only accepted with client_error or server_error, and only from the published list",
|
|
198
|
+
path: ["status"],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (program.delayMs !== undefined && program.behavior !== "delay") {
|
|
203
|
+
context.addIssue({
|
|
204
|
+
code: "custom",
|
|
205
|
+
message: "delayMs is only accepted with the delay behavior",
|
|
206
|
+
path: ["delayMs"],
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// The gap before a *report*, so it is only meaningful for a behavior that
|
|
210
|
+
// sends one. `defer_abandon` deliberately sends none, and accepting a delay
|
|
211
|
+
// there would read as configured while nothing would ever honour it —
|
|
212
|
+
// the same reason `then` without `forDeliveries` is refused below.
|
|
213
|
+
//
|
|
214
|
+
// Read off the facts rather than off a list of behavior names: the facts
|
|
215
|
+
// are what the runtime resolves against, so a behavior that stopped
|
|
216
|
+
// reporting would stop accepting the modifier in the same edit.
|
|
217
|
+
if (program.completionDelayMs !== undefined &&
|
|
218
|
+
!reportsCompletion(program.behavior)) {
|
|
219
|
+
context.addIssue({
|
|
220
|
+
code: "custom",
|
|
221
|
+
message: "completionDelayMs is only accepted with a behavior that reports completion",
|
|
222
|
+
path: ["completionDelayMs"],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (program.retryAfterSeconds !== undefined &&
|
|
226
|
+
program.behavior !== "rate_limited") {
|
|
227
|
+
context.addIssue({
|
|
228
|
+
code: "custom",
|
|
229
|
+
message: "retryAfterSeconds is only accepted with the rate_limited behavior",
|
|
230
|
+
path: ["retryAfterSeconds"],
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// `then` exists to end a bounded run of failures. Without `forDeliveries`
|
|
234
|
+
// it is unreachable, and a program with an unreachable half reads as
|
|
235
|
+
// configured when it is not.
|
|
236
|
+
if (program.then !== undefined && program.forDeliveries === undefined) {
|
|
237
|
+
context.addIssue({
|
|
238
|
+
code: "custom",
|
|
239
|
+
message: "then requires forDeliveries",
|
|
240
|
+
path: ["then"],
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
/**
|
|
245
|
+
* What each behavior does, as data rather than prose, so the published artifact
|
|
246
|
+
* and the implementation cannot drift and a receiver author can read the whole
|
|
247
|
+
* simulation without running it.
|
|
248
|
+
*
|
|
249
|
+
* `senderOutcome` is stated in the vocabulary of `classifyOutcome`, which is
|
|
250
|
+
* what the delivery path actually applies. `receiverActed` is the honest answer
|
|
251
|
+
* to "may the receiver have processed this?" — the question a sender cannot
|
|
252
|
+
* answer for itself and must therefore record as ambiguity.
|
|
253
|
+
*
|
|
254
|
+
* `deferredReport` is present on the behaviors that acknowledge now and report
|
|
255
|
+
* later, and it is the reason `senderOutcome` cannot be derived from
|
|
256
|
+
* `defaultStatus` for those three: all three answer the same `202`, which
|
|
257
|
+
* `classifyStatus` reads as an ordinary success, and what the sender finally
|
|
258
|
+
* records comes from the report — or, for `never`, from the deadline passing
|
|
259
|
+
* with no report at all.
|
|
260
|
+
*/
|
|
261
|
+
export const TEST_SINK_BEHAVIOR_FACTS = {
|
|
262
|
+
client_error: {
|
|
263
|
+
defaultStatus: 422,
|
|
264
|
+
deferredReport: undefined,
|
|
265
|
+
receiverActed: false,
|
|
266
|
+
senderOutcome: "terminal",
|
|
267
|
+
summary: "Answers a selected 4xx. The sender records a customer refusal, and retries it only when the status is one the receiver may recover from — 401, 403 and 404, listed as `clientErrorStatusesRetried`. The default, 422, is not one of those, so it settles on the first attempt.",
|
|
268
|
+
},
|
|
269
|
+
defer_abandon: {
|
|
270
|
+
defaultStatus: CALLBACK_DEFERRAL_STATUS,
|
|
271
|
+
deferredReport: "never",
|
|
272
|
+
// It answered 202, which is the receiver taking the work. That is what
|
|
273
|
+
// makes this the ambiguity the deadline exists for rather than a failure:
|
|
274
|
+
// the work may well have run, and nothing anyone can observe says whether
|
|
275
|
+
// it did.
|
|
276
|
+
receiverActed: true,
|
|
277
|
+
senderOutcome: "ambiguous",
|
|
278
|
+
summary: "Acknowledges the delivery and then never reports at all. The sender waits until the deadline in the envelope and records ambiguity, because a receiver that took the work and went quiet may well have done it. This is the case the completion deadline exists to bound.",
|
|
279
|
+
},
|
|
280
|
+
defer_fail: {
|
|
281
|
+
defaultStatus: CALLBACK_DEFERRAL_STATUS,
|
|
282
|
+
deferredReport: "failed",
|
|
283
|
+
receiverActed: true,
|
|
284
|
+
senderOutcome: "retryable",
|
|
285
|
+
summary: "Acknowledges the delivery, then reports `failed` with the credential in its signed envelope. The occurrence goes back on the ordinary retry ladder — the same place a 500 puts it — which is how a receiver says its job died rather than that it vanished.",
|
|
286
|
+
},
|
|
287
|
+
defer_succeed: {
|
|
288
|
+
defaultStatus: CALLBACK_DEFERRAL_STATUS,
|
|
289
|
+
deferredReport: "succeeded",
|
|
290
|
+
receiverActed: true,
|
|
291
|
+
senderOutcome: "success",
|
|
292
|
+
summary: "Acknowledges the delivery with 202 and the pending marker, keeps the work, then reports `succeeded` with the credential in its signed envelope. The whole deferred exchange, without writing a receiver.",
|
|
293
|
+
},
|
|
294
|
+
delay: {
|
|
295
|
+
defaultStatus: 200,
|
|
296
|
+
deferredReport: undefined,
|
|
297
|
+
receiverActed: true,
|
|
298
|
+
senderOutcome: "success",
|
|
299
|
+
summary: "Waits a bounded interval, then accepts. Exercises a slow receiver that still succeeds.",
|
|
300
|
+
},
|
|
301
|
+
disconnect: {
|
|
302
|
+
defaultStatus: undefined,
|
|
303
|
+
deferredReport: undefined,
|
|
304
|
+
receiverActed: false,
|
|
305
|
+
senderOutcome: "ambiguous",
|
|
306
|
+
summary: "Authenticates the delivery and then drops the connection without processing it, holding until the sender gives up. Nothing was claimed and nothing ran, but the sender cannot know that.",
|
|
307
|
+
},
|
|
308
|
+
duplicate: {
|
|
309
|
+
defaultStatus: 200,
|
|
310
|
+
deferredReport: undefined,
|
|
311
|
+
receiverActed: false,
|
|
312
|
+
senderOutcome: "success",
|
|
313
|
+
summary: "Accepts and reports the occurrence as already handled, without processing it again. What occurrence idempotency looks like from the sender's side.",
|
|
314
|
+
},
|
|
315
|
+
invalid_signature: {
|
|
316
|
+
defaultStatus: 401,
|
|
317
|
+
deferredReport: undefined,
|
|
318
|
+
receiverActed: false,
|
|
319
|
+
// Retryable since FXO-1596, and the reversal is deliberate. The reasoning
|
|
320
|
+
// it replaces was that a key mismatch cannot self-heal the way a stale
|
|
321
|
+
// timestamp does — true of a mismatch caused by our own signing, and wrong
|
|
322
|
+
// about the population that actually occurs, which is a receiver holding a
|
|
323
|
+
// credential it has rotated or not yet deployed. That fixes itself on their
|
|
324
|
+
// next release, and the delivery that follows carries a fresh signature.
|
|
325
|
+
senderOutcome: "retryable",
|
|
326
|
+
summary: "Rejects the delivery as unauthenticated regardless of whether the signature verified. Exercises the key-mismatch path without forging a signature. The sender retries on the receiver's ordinary budget, because the usual cause is a credential the receiver is about to redeploy.",
|
|
327
|
+
},
|
|
328
|
+
rate_limited: {
|
|
329
|
+
defaultStatus: 429,
|
|
330
|
+
deferredReport: undefined,
|
|
331
|
+
receiverActed: false,
|
|
332
|
+
senderOutcome: "retryable",
|
|
333
|
+
summary: "Answers 429 with a bounded Retry-After. The sender honours the hint and does not spend its give-up budget.",
|
|
334
|
+
},
|
|
335
|
+
server_error: {
|
|
336
|
+
defaultStatus: 503,
|
|
337
|
+
deferredReport: undefined,
|
|
338
|
+
receiverActed: false,
|
|
339
|
+
senderOutcome: "retryable",
|
|
340
|
+
summary: "Answers a selected 5xx. The sender retries with backoff.",
|
|
341
|
+
},
|
|
342
|
+
success: {
|
|
343
|
+
defaultStatus: 200,
|
|
344
|
+
deferredReport: undefined,
|
|
345
|
+
receiverActed: true,
|
|
346
|
+
senderOutcome: "success",
|
|
347
|
+
summary: "Verifies, records, and accepts.",
|
|
348
|
+
},
|
|
349
|
+
timeout: {
|
|
350
|
+
defaultStatus: undefined,
|
|
351
|
+
deferredReport: undefined,
|
|
352
|
+
receiverActed: true,
|
|
353
|
+
senderOutcome: "ambiguous",
|
|
354
|
+
summary: "Verifies and records the delivery, then never answers. The receiver acted and the sender cannot know it.",
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* Whether this behavior acknowledges now and reports later.
|
|
359
|
+
*
|
|
360
|
+
* `defer_abandon` defers and does *not* report, so the two questions are
|
|
361
|
+
* separate ones and a caller asking "does it defer" must not be answered with
|
|
362
|
+
* "does it report".
|
|
363
|
+
*/
|
|
364
|
+
function defersCompletion(behavior) {
|
|
365
|
+
return TEST_SINK_BEHAVIOR_FACTS[behavior].deferredReport !== undefined;
|
|
366
|
+
}
|
|
367
|
+
/** The outcome this behavior reports, or `undefined` if it reports nothing. */
|
|
368
|
+
function completionReportFor(behavior) {
|
|
369
|
+
const report = TEST_SINK_BEHAVIOR_FACTS[behavior].deferredReport;
|
|
370
|
+
return report === undefined || report === "never" ? undefined : report;
|
|
371
|
+
}
|
|
372
|
+
/** Whether this behavior sends a completion report at all. */
|
|
373
|
+
function reportsCompletion(behavior) {
|
|
374
|
+
return completionReportFor(behavior) !== undefined;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* The exact body a deferring sink answers with.
|
|
378
|
+
*
|
|
379
|
+
* Built from the published discriminator rather than written out, so a sink
|
|
380
|
+
* cannot emit a marker the sender does not recognise. Deliberately without
|
|
381
|
+
* `completeBy`: the sink's report follows within `completionDelayMs`, and an
|
|
382
|
+
* acknowledgement that named an instant that close would be asking the sender
|
|
383
|
+
* to stop waiting at roughly the moment the report arrives — a race the
|
|
384
|
+
* rehearsal would lose intermittently and blame on the protocol. A receiver
|
|
385
|
+
* that wants to bring its deadline forward is exercising
|
|
386
|
+
* `effectiveCompletionDeadlineMs`, which is the delivery path's own test rather
|
|
387
|
+
* than this simulation's.
|
|
388
|
+
*/
|
|
389
|
+
export const TEST_SINK_PENDING_ACKNOWLEDGEMENT = JSON.stringify({
|
|
390
|
+
[CALLBACK_PENDING_DISCRIMINATOR.key]: {
|
|
391
|
+
status: CALLBACK_PENDING_DISCRIMINATOR.status,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
/**
|
|
395
|
+
* Resolve a program against how many deliveries this sink has already taken.
|
|
396
|
+
*
|
|
397
|
+
* Pure and total: the same `(program, deliveryIndex)` always produces the same
|
|
398
|
+
* action, which is what "deterministic" means here and what lets the same
|
|
399
|
+
* engine drive both the hosted sink and an offline conformance run.
|
|
400
|
+
*
|
|
401
|
+
* `deliveryIndex` is zero-based and counts every delivery the sink accepted for
|
|
402
|
+
* processing, including refused ones — a receiver that answers 401 has still
|
|
403
|
+
* received a request, and a program that said "fail the first two" must not be
|
|
404
|
+
* satisfiable by failing the same delivery twice.
|
|
405
|
+
*/
|
|
406
|
+
export function resolveTestSinkAction(program, deliveryIndex) {
|
|
407
|
+
const inFirstPhase = program.forDeliveries === undefined ||
|
|
408
|
+
deliveryIndex < program.forDeliveries;
|
|
409
|
+
const behavior = inFirstPhase
|
|
410
|
+
? program.behavior
|
|
411
|
+
: (program.then ?? "success");
|
|
412
|
+
const facts = TEST_SINK_BEHAVIOR_FACTS[behavior];
|
|
413
|
+
const status = inFirstPhase
|
|
414
|
+
? (program.status ?? facts.defaultStatus)
|
|
415
|
+
: facts.defaultStatus;
|
|
416
|
+
const retryAfterSeconds = behavior === "rate_limited"
|
|
417
|
+
? ((inFirstPhase ? program.retryAfterSeconds : undefined) ?? 5)
|
|
418
|
+
: undefined;
|
|
419
|
+
const defers = defersCompletion(behavior);
|
|
420
|
+
const completionOutcome = completionReportFor(behavior);
|
|
421
|
+
return {
|
|
422
|
+
...(defers ? { acknowledgement: TEST_SINK_PENDING_ACKNOWLEDGEMENT } : {}),
|
|
423
|
+
behavior,
|
|
424
|
+
claims: behavior !== "disconnect",
|
|
425
|
+
// Defaulted the way `delayMs` is, and for the same reason: the wait is the
|
|
426
|
+
// only thing separating a report that arrives after the response from one
|
|
427
|
+
// that races it, so a default of zero would make the rehearsal prove less
|
|
428
|
+
// than it appears to.
|
|
429
|
+
completionDelayMs: completionOutcome === undefined
|
|
430
|
+
? 0
|
|
431
|
+
: ((inFirstPhase ? program.completionDelayMs : undefined) ?? 250),
|
|
432
|
+
...(completionOutcome === undefined ? {} : { completionOutcome }),
|
|
433
|
+
defers,
|
|
434
|
+
delayMs: behavior === "delay"
|
|
435
|
+
? ((inFirstPhase ? program.delayMs : undefined) ?? 250)
|
|
436
|
+
: 0,
|
|
437
|
+
hold: behavior === "timeout" || behavior === "disconnect",
|
|
438
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
439
|
+
...(status === undefined ? {} : { status }),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/* -------------------------------------------------------------------------- */
|
|
443
|
+
/* The receiver contract */
|
|
444
|
+
/* -------------------------------------------------------------------------- */
|
|
445
|
+
/**
|
|
446
|
+
* Every way a receiver can dispose of one delivery.
|
|
447
|
+
*
|
|
448
|
+
* `duplicate` and `replay` are both "already handled" and both succeed, and
|
|
449
|
+
* separating them is what makes an integrator's logs legible: `replay` is the
|
|
450
|
+
* same delivery identity arriving twice — a duplicated request — while
|
|
451
|
+
* `duplicate` is a *different* delivery for an occurrence already processed,
|
|
452
|
+
* which is what a retry after an ambiguous attempt, and what a customer-ordered
|
|
453
|
+
* redelivery, both look like on the wire.
|
|
454
|
+
*
|
|
455
|
+
* `in_flight` is the concurrent case: another worker holds the occurrence and
|
|
456
|
+
* has not finished. Answering success there would claim an outcome nobody has
|
|
457
|
+
* yet produced.
|
|
458
|
+
*/
|
|
459
|
+
export const CALLBACK_RECEIVER_DISPOSITIONS = [
|
|
460
|
+
"body_too_large",
|
|
461
|
+
"duplicate",
|
|
462
|
+
"handler_failed",
|
|
463
|
+
"in_flight",
|
|
464
|
+
"invalid_envelope",
|
|
465
|
+
"invalid_headers",
|
|
466
|
+
"invalid_signature",
|
|
467
|
+
"processed",
|
|
468
|
+
"replay",
|
|
469
|
+
"stale_timestamp",
|
|
470
|
+
];
|
|
471
|
+
export const callbackReceiverDispositionSchema = z.enum(CALLBACK_RECEIVER_DISPOSITIONS);
|
|
472
|
+
/**
|
|
473
|
+
* What a sink recorded, which is one value wider than what a receiver can
|
|
474
|
+
* answer with.
|
|
475
|
+
*
|
|
476
|
+
* `connection_dropped` has no status because the point of it is that there was
|
|
477
|
+
* none: the sink modelled a connection that died before the receiver read
|
|
478
|
+
* anything, so nothing was authenticated and nothing ran. Widening
|
|
479
|
+
* `CALLBACK_RECEIVER_DISPOSITIONS` to hold it would break the property that
|
|
480
|
+
* makes that list useful — every member maps to exactly one status.
|
|
481
|
+
*/
|
|
482
|
+
export const TEST_SINK_DISPOSITIONS = [
|
|
483
|
+
...CALLBACK_RECEIVER_DISPOSITIONS,
|
|
484
|
+
"connection_dropped",
|
|
485
|
+
];
|
|
486
|
+
export const testSinkDispositionSchema = z.enum(TEST_SINK_DISPOSITIONS);
|
|
487
|
+
/**
|
|
488
|
+
* The HTTP status a conforming receiver returns for each disposition.
|
|
489
|
+
*
|
|
490
|
+
* Published, and published *here*, because the sender classifies on the status
|
|
491
|
+
* and the classification decides whether an occurrence retries, settles, or is
|
|
492
|
+
* abandoned. A receiver that picks its own numbers can be perfectly correct
|
|
493
|
+
* about what it did and still tell the sender the opposite.
|
|
494
|
+
*
|
|
495
|
+
* Each choice is answerable against `classifyStatus`:
|
|
496
|
+
*
|
|
497
|
+
* - `processed`, `duplicate`, `replay` — 200. All three mean the occurrence is
|
|
498
|
+
* handled. A replayed or duplicated delivery answering 4xx would make the
|
|
499
|
+
* sender record a terminal failure for work that succeeded, which is the
|
|
500
|
+
* single most damaging thing a receiver can do.
|
|
501
|
+
* - `stale_timestamp` — 408, which is retryable. A clock skew wide enough to
|
|
502
|
+
* fail tolerance is an operator problem no retry fixes, but a request held on
|
|
503
|
+
* the network for six minutes is not, and every attempt re-signs with a fresh
|
|
504
|
+
* timestamp, so the transient case self-heals and the permanent case exhausts
|
|
505
|
+
* the attempt bound honestly.
|
|
506
|
+
* - `invalid_signature`, `invalid_headers` — 401, **retryable since
|
|
507
|
+
* FXO-1596**, and this entry used to say the opposite. The reasoning it
|
|
508
|
+
* replaces was that retrying with the same key cannot succeed and a retryable
|
|
509
|
+
* answer would spend the give-up budget hiding a misconfiguration. That is
|
|
510
|
+
* true of a mismatch in *our* signing and false of the population that
|
|
511
|
+
* actually reaches a 401: a receiver holding a credential it has rotated,
|
|
512
|
+
* expired, or not yet deployed. Those recover on the receiver's next release,
|
|
513
|
+
* and the delivery after it carries a fresh signature. The misconfiguration
|
|
514
|
+
* is not hidden either — five identical 401s settle the occurrence 82 seconds
|
|
515
|
+
* later with the same `inspect_receiver` advice, five rows instead of one.
|
|
516
|
+
* - `invalid_envelope` — 400, terminal. The bytes authenticated and did not
|
|
517
|
+
* parse, so the sender and receiver disagree about the contract.
|
|
518
|
+
* - `body_too_large` — 413, terminal, and matched to the `payload_too_large`
|
|
519
|
+
* problem code the API surface already publishes.
|
|
520
|
+
* - `in_flight` — 503, retryable. Honest: someone else holds the occurrence.
|
|
521
|
+
* - `handler_failed` — 500, retryable. The receiver's own fault and worth
|
|
522
|
+
* another attempt.
|
|
523
|
+
*/
|
|
524
|
+
export const CALLBACK_RECEIVER_STATUS = {
|
|
525
|
+
body_too_large: 413,
|
|
526
|
+
duplicate: 200,
|
|
527
|
+
handler_failed: 500,
|
|
528
|
+
in_flight: 503,
|
|
529
|
+
invalid_envelope: 400,
|
|
530
|
+
invalid_headers: 401,
|
|
531
|
+
invalid_signature: 401,
|
|
532
|
+
processed: 200,
|
|
533
|
+
replay: 200,
|
|
534
|
+
stale_timestamp: 408,
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* The response body a conforming receiver returns.
|
|
538
|
+
*
|
|
539
|
+
* Deliberately tiny and deliberately free of prose: a receiver's response is
|
|
540
|
+
* read by a machine, and a body that carries an exception message is how a
|
|
541
|
+
* receiver leaks its internals to whoever can provoke an error.
|
|
542
|
+
*/
|
|
543
|
+
export const callbackReceiverAcknowledgementSchema = z.object({
|
|
544
|
+
disposition: callbackReceiverDispositionSchema,
|
|
545
|
+
/** Present only when the delivery authenticated. */
|
|
546
|
+
occurrenceId: occurrenceIdSchema.optional(),
|
|
547
|
+
});
|
|
548
|
+
/* -------------------------------------------------------------------------- */
|
|
549
|
+
/* The sink resource */
|
|
550
|
+
/* -------------------------------------------------------------------------- */
|
|
551
|
+
export const testSinkSchema = z.object({
|
|
552
|
+
createdAt: timestampSchema,
|
|
553
|
+
/** Deliveries this sink has received, across its whole retained lifetime. */
|
|
554
|
+
deliveryCount: z.number().int().nonnegative(),
|
|
555
|
+
/** The callback endpoint whose signing keys this sink verifies against. */
|
|
556
|
+
endpoint: endpointIdSchema,
|
|
557
|
+
/** Always `test`. Published so a reader never has to infer it. */
|
|
558
|
+
environmentKind: z.literal("test"),
|
|
559
|
+
id: testSinkIdSchema,
|
|
560
|
+
program: testSinkProgramSchema,
|
|
561
|
+
project: projectIdSchema,
|
|
562
|
+
updatedAt: timestampSchema,
|
|
563
|
+
/**
|
|
564
|
+
* Where to point the endpoint. Absolute and server-assigned.
|
|
565
|
+
*
|
|
566
|
+
* The scheme is the stage's own public origin rather than a fixed `https://`,
|
|
567
|
+
* which is not a weakening: every hosted stage is HTTPS, and a callback
|
|
568
|
+
* endpoint's URL policy refuses anything else outright, so a sink served over
|
|
569
|
+
* plain HTTP is one no endpoint can be pointed at. Asserting `https` here
|
|
570
|
+
* would only make the local development origin — `http://127.0.0.1` — fail
|
|
571
|
+
* schema validation instead of failing endpoint validation, which moves the
|
|
572
|
+
* refusal somewhere less informative.
|
|
573
|
+
*/
|
|
574
|
+
url: z.url(),
|
|
575
|
+
});
|
|
576
|
+
export const testSinkPageSchema = pageSchema(testSinkSchema);
|
|
577
|
+
export const testSinkCreateRequestSchema = z.object({
|
|
578
|
+
endpoint: endpointIdSchema,
|
|
579
|
+
/** Initial behavior. Defaults to plain acceptance. */
|
|
580
|
+
program: testSinkProgramSchema.optional(),
|
|
581
|
+
});
|
|
582
|
+
/* -------------------------------------------------------------------------- */
|
|
583
|
+
/* Delivery receipts */
|
|
584
|
+
/* -------------------------------------------------------------------------- */
|
|
585
|
+
/**
|
|
586
|
+
* One received delivery, as the sink recorded it.
|
|
587
|
+
*
|
|
588
|
+
* The identifiers, the key that signed it, and the disposition are the whole of
|
|
589
|
+
* what a *list* returns. The exact body is available only from the single-record
|
|
590
|
+
* read, capped, and only in a test environment — see `testSinkDeliveryBodySchema`.
|
|
591
|
+
*/
|
|
592
|
+
export const testSinkDeliverySchema = z.object({
|
|
593
|
+
/** Which of the sink's behaviors produced the response. */
|
|
594
|
+
behavior: testSinkBehaviorSchema,
|
|
595
|
+
disposition: testSinkDispositionSchema,
|
|
596
|
+
/** The sender's delivery identity, from the signed header. */
|
|
597
|
+
delivery: deliveryIdSchema,
|
|
598
|
+
id: testSinkDeliveryIdSchema,
|
|
599
|
+
/** Absent when the delivery failed authentication. */
|
|
600
|
+
keyId: callbackSigningKeyIdSchema.optional(),
|
|
601
|
+
occurrenceId: occurrenceIdSchema.optional(),
|
|
602
|
+
receivedAt: timestampSchema,
|
|
603
|
+
/** Absent when the sink held the connection instead of answering. */
|
|
604
|
+
respondedWith: z.number().int().min(100).max(599).optional(),
|
|
605
|
+
scheduleId: scheduleIdSchema.optional(),
|
|
606
|
+
sink: testSinkIdSchema,
|
|
607
|
+
});
|
|
608
|
+
export const testSinkDeliveryPageSchema = pageSchema(testSinkDeliverySchema);
|
|
609
|
+
/**
|
|
610
|
+
* One received delivery including the bytes that arrived.
|
|
611
|
+
*
|
|
612
|
+
* **The one deliberate exception to "callback bodies never leave the delivery
|
|
613
|
+
* path".** The rule exists because a payload is customer content and a log,
|
|
614
|
+
* metric, or support index is the wrong place for it. A test sink is none of
|
|
615
|
+
* those: it is a destination the customer created, in their own test
|
|
616
|
+
* environment, to look at what their receiver would have been sent, and
|
|
617
|
+
* withholding it would leave the sink unable to answer the first question
|
|
618
|
+
* anybody asks it.
|
|
619
|
+
*
|
|
620
|
+
* The exception is kept narrow rather than assumed safe. The body is available
|
|
621
|
+
* only from the single-delivery read and never from a listing, only under the
|
|
622
|
+
* creating authority, only in test, capped at
|
|
623
|
+
* `TEST_SINK_BOUNDS.maxRetainedBodyBytes` with the truncation stated, and never
|
|
624
|
+
* written to a log. `signature` is included because a MAC is not secret and
|
|
625
|
+
* comparing it against a locally computed one is how a receiver author finds a
|
|
626
|
+
* body-mutation bug; no signing key has a field anywhere in this file.
|
|
627
|
+
*/
|
|
628
|
+
export const testSinkDeliveryBodySchema = testSinkDeliverySchema.extend({
|
|
629
|
+
/** The exact bytes as received, UTF-8 decoded, possibly truncated. */
|
|
630
|
+
body: z.string(),
|
|
631
|
+
/** True when the body exceeded the retained cap and was cut. */
|
|
632
|
+
bodyTruncated: z.boolean(),
|
|
633
|
+
/** Exactly the bytes that arrived, before truncation. */
|
|
634
|
+
receivedBytes: z.number().int().nonnegative(),
|
|
635
|
+
/** The offered MAC. Public by construction; never a key. */
|
|
636
|
+
signature: signatureHeaderSchema.optional(),
|
|
637
|
+
});
|
|
638
|
+
/* -------------------------------------------------------------------------- */
|
|
639
|
+
/* The published artifact */
|
|
640
|
+
/* -------------------------------------------------------------------------- */
|
|
641
|
+
/**
|
|
642
|
+
* Dated, and moved on 2026-08-23 because the sink gained the three deferring
|
|
643
|
+
* behaviors and the bound on how long one may wait before reporting. A reader
|
|
644
|
+
* holding the older artifact has a complete and correct description of a sink
|
|
645
|
+
* that could not defer.
|
|
646
|
+
*/
|
|
647
|
+
export const TEST_SINK_PROTOCOL_VERSION = "2026-08-23";
|
|
648
|
+
/**
|
|
649
|
+
* The language-neutral receiver contract.
|
|
650
|
+
*
|
|
651
|
+
* A receiver written in any stack can be held to this: verify these bytes,
|
|
652
|
+
* answer these statuses, dedupe on this identity. It is generated beside
|
|
653
|
+
* OpenAPI so an integrator does not have to read TypeScript to write a Go
|
|
654
|
+
* receiver.
|
|
655
|
+
*/
|
|
656
|
+
export const CALLBACK_RECEIVER_PROTOCOL = {
|
|
657
|
+
idempotency: {
|
|
658
|
+
/**
|
|
659
|
+
* Stated plainly because it is the property most often got wrong, and the
|
|
660
|
+
* one the contract vocabulary already implies without saying so anywhere a
|
|
661
|
+
* receiver author would look.
|
|
662
|
+
*/
|
|
663
|
+
occurrence: {
|
|
664
|
+
identity: CALLBACK_HEADERS.occurrenceId,
|
|
665
|
+
note: "Every automatic delivery of one occurrence — the first attempt and every retry the system chose — is at most one execution. A receiver claims the occurrence atomically and answers 200 for every later automatic delivery of it. This is what makes at-least-once delivery safe; a receiver that needs a second execution asks for a second occurrence, or a person orders a redelivery.",
|
|
666
|
+
requirement: "claim the occurrence atomically before running the handler; release the claim if the handler fails so a later attempt can retry it",
|
|
667
|
+
},
|
|
668
|
+
/**
|
|
669
|
+
* The one case where an occurrence runs twice, and the reason it is safe.
|
|
670
|
+
*
|
|
671
|
+
* Until the envelope carried `trigger` this could not be expressed: a retry
|
|
672
|
+
* nobody asked for and a redelivery a person ordered arrived identically, so
|
|
673
|
+
* the rule safe against the first was applied to both and pressing
|
|
674
|
+
* "redeliver" changed nothing. The unit of idempotency is therefore not the
|
|
675
|
+
* occurrence alone but the occurrence *and what asked for it*.
|
|
676
|
+
*/
|
|
677
|
+
redelivery: {
|
|
678
|
+
identity: `${CALLBACK_HEADERS.occurrenceId} together with the signed attempt number, which is the redelivery's ordinal`,
|
|
679
|
+
note: "A redelivery is a deliberate, authorized instruction to do the work again. Suppressing it as a duplicate answers a person with silence, which is what a customer pressing redeliver after fixing their endpoint experiences as the attempt succeeding while nothing happens.",
|
|
680
|
+
optOut: "A receiver that genuinely requires strict once-only per occurrence chooses it explicitly and keeps answering duplicate. It is an opt-out, not the default, because a default of suppression makes the product lie to the person who pressed the button.",
|
|
681
|
+
ordering: "attempt only ever increases within an occurrence and every copy of one redelivery carries the same value, so a receiver keeps the highest redelivery it has run to completion and refuses anything at or below it. That is what recognises a copy delayed in a proxy, a queue or an abandoned connection, however late it arrives and however many redeliveries have completed since. Remembering only the most recent instruction cannot do this and will run a customer's work twice; remembering every instruction can, at the cost of a store that grows without bound. sourceAttemptId names the attempt the person chose to follow and is there to show them, not to decide whether the work runs.",
|
|
682
|
+
requirement: "run the handler again for a redelivery whose attempt is above the highest this receiver has run to completion for the occurrence, and suppress every redelivery at or below that high-water mark",
|
|
683
|
+
strictness: "A receiver that chose strict once-only suppresses every delivery for an occurrence once anything has finished, whatever caused it. Reading strictness as a rule about redeliveries alone leaves a hole: a strict receiver that processed a redelivery first would then admit a delayed automatic delivery and run a payment twice.",
|
|
684
|
+
warranty: "Each accepted redelivery is one logical run. Repeats are suppressed once the receiver has durably completed that run — not from the moment it starts one. A handler that performs its external effect and then dies before its claim is settled will be run again when the lease expires, which is the honest limit of what any at-least-once receiver can promise. A customer whose effect must happen exactly once commits the occurrence and ordinal alongside their own state, or passes them downstream as an idempotency key.",
|
|
685
|
+
},
|
|
686
|
+
replay: {
|
|
687
|
+
identity: CALLBACK_HEADERS.deliveryId,
|
|
688
|
+
requirement: "reject a previously accepted delivery identity before parsing, and answer 200 — a duplicated request is not a failure",
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
/**
|
|
692
|
+
* How a receiver's occurrence store answers one claim, in order.
|
|
693
|
+
*
|
|
694
|
+
* Published as data because a receiver author outside JavaScript had nowhere
|
|
695
|
+
* to read it: `order` covers authenticating a request, and the prose guide is
|
|
696
|
+
* TypeScript-shaped and not machine-readable. It drifted from the code three
|
|
697
|
+
* times during W3-08 — and the third time it published a rule that would have
|
|
698
|
+
* had a customer take a payment twice — so the ordered list now lives beside
|
|
699
|
+
* the rules it describes and the guide is checked against it.
|
|
700
|
+
*
|
|
701
|
+
* Rule 2 is deliberately about what has *finished* rather than what *arrived*.
|
|
702
|
+
* Splitting it — suppress an automatic delivery when the automatic run
|
|
703
|
+
* finished, apply strictness only to redeliveries — is the hole: a strict
|
|
704
|
+
* receiver that legitimately saw an ordered redelivery first would then admit
|
|
705
|
+
* a delayed automatic copy and run the work again.
|
|
706
|
+
*/
|
|
707
|
+
claimRules: [
|
|
708
|
+
"another delivery holds an unexpired lease, whatever the run, so an ordered redelivery never runs alongside the attempt it follows -> in_flight",
|
|
709
|
+
"the delivery may not retake a settled occurrence and anything at all has finished for it -> settled. This covers every automatic delivery and every delivery at a receiver that chose strict once-only",
|
|
710
|
+
"the delivery is a redelivery whose ordinal is at or below the highest this receiver has run to completion -> settled",
|
|
711
|
+
"otherwise take it, recording the holder and the run -> claimed",
|
|
712
|
+
],
|
|
713
|
+
order: [
|
|
714
|
+
"read the exact request bytes before any body parser sees them",
|
|
715
|
+
"reject a body over the receiver's byte bound without buffering it",
|
|
716
|
+
"validate the five Callback-* headers and the content type",
|
|
717
|
+
"reject a timestamp outside tolerance",
|
|
718
|
+
"select the signing key by Callback-Key-Id within the receiver's own endpoint, project, and environment",
|
|
719
|
+
"compare the HMAC in constant time over <timestamp>.<delivery-id>. plus the exact bytes",
|
|
720
|
+
"compare the canonical envelope identifiers with the occurrence header",
|
|
721
|
+
"claim the delivery identity atomically",
|
|
722
|
+
// Parsing moved ahead of the occurrence claim on 2026-08-08, and it is a
|
|
723
|
+
// correction as much as a change: the shipped verifier already parsed here,
|
|
724
|
+
// because nothing downstream can use an envelope it has not decoded. What
|
|
725
|
+
// is new is that the step now *matters* — the trigger deciding which run is
|
|
726
|
+
// being claimed lives in the body, so it must come from bytes the MAC has
|
|
727
|
+
// already authenticated rather than from a header anyone could set.
|
|
728
|
+
"only now parse the body, and read the trigger that says why this delivery exists",
|
|
729
|
+
"claim the occurrence identity atomically, for the run the signed body names — the occurrence for an automatic delivery, and the occurrence with the redelivery's attempt ordinal for one a person ordered",
|
|
730
|
+
"run the application handler",
|
|
731
|
+
],
|
|
732
|
+
responses: CALLBACK_RECEIVER_STATUS,
|
|
733
|
+
sink: {
|
|
734
|
+
behaviors: TEST_SINK_BEHAVIOR_FACTS,
|
|
735
|
+
bounds: TEST_SINK_BOUNDS,
|
|
736
|
+
clientErrorStatuses: TEST_SINK_CLIENT_ERROR_STATUSES,
|
|
737
|
+
// Published as data rather than described in a summary, because a receiver
|
|
738
|
+
// author reading this artifact writes their integration test against it and
|
|
739
|
+
// has no way to read a TypeScript constant. Which of the 4xx above are
|
|
740
|
+
// retried is the one fact about the sink that changed at FXO-1596.
|
|
741
|
+
clientErrorStatusesRetried: TEST_SINK_REFUSAL_STATUSES,
|
|
742
|
+
// The `defer_*` behaviors made "never sends" false, and stating the
|
|
743
|
+
// narrower property is worth more than restating the old one: what keeps a
|
|
744
|
+
// sink from being an egress surface was never that it made no request, but
|
|
745
|
+
// that nothing anyone can set names a place for it to send to.
|
|
746
|
+
deferral: {
|
|
747
|
+
acknowledgement: TEST_SINK_PENDING_ACKNOWLEDGEMENT,
|
|
748
|
+
note: "A deferring behavior answers this exact body with this status, then reports with the bearer credential from the delivery's own signed envelope — to the one completion address the stage is configured with, which the envelope's own URL must match. A delivery whose envelope carries no completion material, or names a different address, cannot defer, and the sink answers it as an ordinary success instead.",
|
|
749
|
+
status: CALLBACK_DEFERRAL_STATUS,
|
|
750
|
+
},
|
|
751
|
+
egress: "A test sink has no destination field: no program, no control operation and no request header names anywhere for it to send. The one request it performs is a deferred-completion report for a delivery it has already received and authenticated, to the URL and credential carried inside that delivery's signed envelope. It cannot forward or redirect a delivery.",
|
|
752
|
+
visibility: "Test environments only. Organization, project, environment, and credential kind come from the verified principal; no request may widen them.",
|
|
753
|
+
},
|
|
754
|
+
version: TEST_SINK_PROTOCOL_VERSION,
|
|
755
|
+
};
|