@whooshbang/contract-mock 1.0.0-rc.16
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/CHANGELOG.md +141 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/app.d.ts +8 -0
- package/dist/app.js +16 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +26 -0
- package/dist/cli.js +249 -0
- package/dist/cli.js.map +1 -0
- package/dist/control-surface.d.ts +13 -0
- package/dist/control-surface.js +158 -0
- package/dist/control-surface.js.map +1 -0
- package/dist/core.d.ts +33 -0
- package/dist/core.js +3459 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction-control.d.ts +6 -0
- package/dist/interaction-control.js +76 -0
- package/dist/interaction-control.js.map +1 -0
- package/dist/scenarios.d.ts +14 -0
- package/dist/scenarios.js +73 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +9 -0
- package/dist/worker.js +10 -0
- package/dist/worker.js.map +1 -0
- package/package.json +67 -0
- package/scenarios/v1/manifest.json +31 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,3459 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { classifyWebhookDeliveryOutcome, contractValidationErrors, MAX_WEBHOOK_DELIVERY_ATTEMPTS, validateAccountProjection, validateApiCredential, validateApiCredentialCollection, validateApiCredentialRotation, validateApiCredentialSecretReveal, validateCreateApiCredentialRequest, validateCreateCustomerEndpointRequest, validateCreateMachineClientRequest, validateCreateMessageRequest, validateCreateProjectRequest, validateCreateSubscriptionLinkRequest, validateCustomerEndpoint, validateCustomerEndpointAttemptCollection, validateCustomerEndpointCollection, validateCustomerEndpointMutationResult, validateInteractionEvent, validateInteractionSummary, validateMachineClient, validateMachineCredential, validateMachineEventAckRequest, validateMachineEventAckResponse, validateMachineEventPollResponse, validateMessage, validateWhooshBangEvent, validateProblem, validateProject, validateProjectCollection, validateProviderCapabilitiesCollection, validateRegisterMachineCredentialRequest, validateRotateApiCredentialRequest, validateRotateCustomerEndpointRequest, validateSubscriptionLink, verifyMachineCredentialToken, } from "@whooshbang/contracts";
|
|
3
|
+
import { getScenarioDefinition } from "./scenarios.js";
|
|
4
|
+
import { parseInteractionControlInput } from "./interaction-control.js";
|
|
5
|
+
const CONTRACT_VERSION = "1.0.0-rc.13";
|
|
6
|
+
const SCENARIO_VERSION = "contract-mock-scenarios.v1";
|
|
7
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
8
|
+
const MAX_PROVIDER_ATTEMPTS = 8;
|
|
9
|
+
const INTERACTION_POLL_DEFAULT_WAIT_SECONDS = 30;
|
|
10
|
+
const INTERACTION_POLL_MAX_WAIT_SECONDS = 30;
|
|
11
|
+
const INTERACTION_POLL_MAX_CONCURRENCY = 5;
|
|
12
|
+
const INTERACTION_POLL_RECHECK_MILLISECONDS = 500;
|
|
13
|
+
const MACHINE_SCOPE_SUMMARY = [
|
|
14
|
+
"machine-messages:write",
|
|
15
|
+
"machine-messages:read",
|
|
16
|
+
"machine-events:read",
|
|
17
|
+
"machine-events:ack",
|
|
18
|
+
];
|
|
19
|
+
const CURRENT_SIGNING_SECRET = "whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
|
|
20
|
+
const PREVIOUS_SIGNING_SECRET = "whsec_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=";
|
|
21
|
+
function containsAsciiControl(value) {
|
|
22
|
+
for (const character of value) {
|
|
23
|
+
const codePoint = character.codePointAt(0);
|
|
24
|
+
if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function codePointLength(value) {
|
|
31
|
+
return Array.from(value).length;
|
|
32
|
+
}
|
|
33
|
+
const projectScopes = [
|
|
34
|
+
"subscriptions:write",
|
|
35
|
+
"subscriptions:read",
|
|
36
|
+
"messages:write",
|
|
37
|
+
"messages:read",
|
|
38
|
+
"machine-clients:write",
|
|
39
|
+
"machine-clients:read",
|
|
40
|
+
"capabilities:read",
|
|
41
|
+
];
|
|
42
|
+
const accountScopes = ["projects:write", "projects:read"];
|
|
43
|
+
/** Every scope an OAuth grant may carry: account-level, then project-level. */
|
|
44
|
+
const oauthScopes = [...accountScopes, ...projectScopes];
|
|
45
|
+
/**
|
|
46
|
+
* The synthetic project and environment identifiers the fixture project
|
|
47
|
+
* credentials authenticate against.
|
|
48
|
+
*
|
|
49
|
+
* Environment-scoped paths need an environment identifier that the bearer
|
|
50
|
+
* token alone does not carry, so consumers building customer-endpoint URLs
|
|
51
|
+
* read it from here rather than guessing.
|
|
52
|
+
*/
|
|
53
|
+
export const CONTRACT_MOCK_FIXTURE_ENVIRONMENTS = Object.freeze({
|
|
54
|
+
liveEnvironmentId: "environment_project_synthetic_live",
|
|
55
|
+
projectId: "project_synthetic",
|
|
56
|
+
testEnvironmentId: "environment_project_synthetic_test",
|
|
57
|
+
});
|
|
58
|
+
export const CONTRACT_MOCK_FIXTURE_CREDENTIALS = Object.freeze({
|
|
59
|
+
grant: "oauth-grant-token",
|
|
60
|
+
humanSession: "human-session-token",
|
|
61
|
+
otherGrant: "oauth-grant-other-token",
|
|
62
|
+
liveProject: "project-live-token",
|
|
63
|
+
machineA: "machine-a-token",
|
|
64
|
+
machineB: "machine-b-token",
|
|
65
|
+
otherHumanSession: "human-other-session-token",
|
|
66
|
+
projectA: "project-a-token",
|
|
67
|
+
projectB: "project-b-token",
|
|
68
|
+
projectTest: "project-test-token",
|
|
69
|
+
readOnly: "project-read-only-token",
|
|
70
|
+
});
|
|
71
|
+
function defaultCredentials() {
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
accountId: "account_synthetic",
|
|
75
|
+
kind: "human",
|
|
76
|
+
role: "admin",
|
|
77
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.humanSession,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
accountId: "account_other",
|
|
81
|
+
kind: "human",
|
|
82
|
+
role: "admin",
|
|
83
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.otherHumanSession,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
accountId: "account_synthetic",
|
|
87
|
+
kind: "grant",
|
|
88
|
+
scopes: oauthScopes,
|
|
89
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.grant,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
accountId: "account_other",
|
|
93
|
+
kind: "grant",
|
|
94
|
+
scopes: oauthScopes,
|
|
95
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.otherGrant,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
accountId: "account_synthetic",
|
|
99
|
+
environment: "test",
|
|
100
|
+
kind: "project",
|
|
101
|
+
projectId: "project_synthetic",
|
|
102
|
+
scopes: projectScopes,
|
|
103
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.projectTest,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
accountId: "account_synthetic",
|
|
107
|
+
environment: "live",
|
|
108
|
+
kind: "project",
|
|
109
|
+
projectId: "project_synthetic",
|
|
110
|
+
scopes: projectScopes,
|
|
111
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.liveProject,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
accountId: "account_a",
|
|
115
|
+
environment: "test",
|
|
116
|
+
kind: "project",
|
|
117
|
+
projectId: "project_a",
|
|
118
|
+
scopes: projectScopes,
|
|
119
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.projectA,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
accountId: "account_b",
|
|
123
|
+
environment: "test",
|
|
124
|
+
kind: "project",
|
|
125
|
+
projectId: "project_b",
|
|
126
|
+
scopes: projectScopes,
|
|
127
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.projectB,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
accountId: "account_synthetic",
|
|
131
|
+
environment: "test",
|
|
132
|
+
kind: "project",
|
|
133
|
+
projectId: "project_synthetic",
|
|
134
|
+
scopes: ["subscriptions:read", "messages:read"],
|
|
135
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.readOnly,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
accountId: "account_synthetic",
|
|
139
|
+
bindingId: "binding_synthetic_relay",
|
|
140
|
+
credentialId: "mcred_AAECAwQFBgcICQoLDA0ODw",
|
|
141
|
+
environment: "test",
|
|
142
|
+
kind: "machine",
|
|
143
|
+
machineClientId: "machine_client_synthetic_001",
|
|
144
|
+
machineId: "machine_synthetic_a",
|
|
145
|
+
notifierId: "default",
|
|
146
|
+
projectId: "project_synthetic",
|
|
147
|
+
scopes: MACHINE_SCOPE_SUMMARY,
|
|
148
|
+
status: "active",
|
|
149
|
+
subscriberId: "agent_relay_operator",
|
|
150
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.machineA,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
accountId: "account_synthetic",
|
|
154
|
+
bindingId: "binding_synthetic_relay_b",
|
|
155
|
+
credentialId: "mcred_AQEBAQEBAQEBAQEBAQEBAQ",
|
|
156
|
+
environment: "test",
|
|
157
|
+
kind: "machine",
|
|
158
|
+
machineClientId: "machine_client_synthetic_002",
|
|
159
|
+
machineId: "machine_synthetic_b",
|
|
160
|
+
notifierId: "default",
|
|
161
|
+
projectId: "project_synthetic",
|
|
162
|
+
scopes: MACHINE_SCOPE_SUMMARY,
|
|
163
|
+
status: "active",
|
|
164
|
+
subscriberId: "agent_relay_operator_b",
|
|
165
|
+
token: CONTRACT_MOCK_FIXTURE_CREDENTIALS.machineB,
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
function requireFixtureIdentifier(value, field) {
|
|
170
|
+
if (typeof value !== "string" ||
|
|
171
|
+
codePointLength(value) === 0 ||
|
|
172
|
+
codePointLength(value) > 128 ||
|
|
173
|
+
containsAsciiControl(value)) {
|
|
174
|
+
throw new TypeError(`Fixture credential ${field} is invalid.`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function snapshotFixtureScopes(value, allowed) {
|
|
179
|
+
if (!Array.isArray(value)) {
|
|
180
|
+
throw new TypeError("Fixture credential scopes are invalid.");
|
|
181
|
+
}
|
|
182
|
+
const scopes = [];
|
|
183
|
+
for (const candidate of value) {
|
|
184
|
+
const scope = allowed.find((allowedScope) => allowedScope === candidate);
|
|
185
|
+
if (scope === undefined) {
|
|
186
|
+
throw new TypeError("Fixture credential scopes are invalid.");
|
|
187
|
+
}
|
|
188
|
+
scopes.push(scope);
|
|
189
|
+
}
|
|
190
|
+
return Object.freeze(scopes);
|
|
191
|
+
}
|
|
192
|
+
function snapshotFixtureCredentials(value) {
|
|
193
|
+
if (!Array.isArray(value)) {
|
|
194
|
+
throw new TypeError("Fixture credentials must be an array.");
|
|
195
|
+
}
|
|
196
|
+
const tokens = new Set();
|
|
197
|
+
const snapshot = [];
|
|
198
|
+
for (const candidate of value) {
|
|
199
|
+
if (typeof candidate !== "object" ||
|
|
200
|
+
candidate === null ||
|
|
201
|
+
Array.isArray(candidate)) {
|
|
202
|
+
throw new TypeError("A fixture credential is invalid.");
|
|
203
|
+
}
|
|
204
|
+
const record = candidate;
|
|
205
|
+
const token = record.token;
|
|
206
|
+
if (typeof token !== "string" ||
|
|
207
|
+
token.length === 0 ||
|
|
208
|
+
token.length > 4096 ||
|
|
209
|
+
!/^[\u0021-\u007e]+$/u.test(token)) {
|
|
210
|
+
throw new TypeError("A fixture credential token is invalid.");
|
|
211
|
+
}
|
|
212
|
+
if (tokens.has(token)) {
|
|
213
|
+
throw new TypeError("Fixture credential tokens must be unique.");
|
|
214
|
+
}
|
|
215
|
+
tokens.add(token);
|
|
216
|
+
const accountId = requireFixtureIdentifier(record.accountId, "accountId");
|
|
217
|
+
if (record.kind === "human") {
|
|
218
|
+
if (record.role !== "admin" && record.role !== "member") {
|
|
219
|
+
throw new TypeError("Fixture human credential role is invalid.");
|
|
220
|
+
}
|
|
221
|
+
snapshot.push(Object.freeze({
|
|
222
|
+
accountId,
|
|
223
|
+
kind: "human",
|
|
224
|
+
role: record.role,
|
|
225
|
+
token,
|
|
226
|
+
}));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (record.kind === "grant") {
|
|
230
|
+
snapshot.push(Object.freeze({
|
|
231
|
+
accountId,
|
|
232
|
+
kind: "grant",
|
|
233
|
+
scopes: snapshotFixtureScopes(record.scopes, oauthScopes),
|
|
234
|
+
token,
|
|
235
|
+
}));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const projectId = requireFixtureIdentifier(record.projectId, "projectId");
|
|
239
|
+
if (record.environment !== "test" && record.environment !== "live") {
|
|
240
|
+
throw new TypeError("Fixture credential environment is invalid.");
|
|
241
|
+
}
|
|
242
|
+
const environment = record.environment;
|
|
243
|
+
if (record.kind === "project") {
|
|
244
|
+
snapshot.push(Object.freeze({
|
|
245
|
+
accountId,
|
|
246
|
+
environment,
|
|
247
|
+
kind: "project",
|
|
248
|
+
projectId,
|
|
249
|
+
scopes: snapshotFixtureScopes(record.scopes, projectScopes),
|
|
250
|
+
token,
|
|
251
|
+
}));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (record.kind !== "machine") {
|
|
255
|
+
throw new TypeError("Fixture credential kind is invalid.");
|
|
256
|
+
}
|
|
257
|
+
if (record.status !== "active" && record.status !== "revoked") {
|
|
258
|
+
throw new TypeError("Fixture machine credential status is invalid.");
|
|
259
|
+
}
|
|
260
|
+
const credentialId = record.credentialId === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: requireFixtureIdentifier(record.credentialId, "credentialId");
|
|
263
|
+
snapshot.push(Object.freeze({
|
|
264
|
+
accountId,
|
|
265
|
+
bindingId: requireFixtureIdentifier(record.bindingId, "bindingId"),
|
|
266
|
+
...(credentialId === undefined ? {} : { credentialId }),
|
|
267
|
+
environment,
|
|
268
|
+
kind: "machine",
|
|
269
|
+
machineClientId: requireFixtureIdentifier(record.machineClientId, "machineClientId"),
|
|
270
|
+
machineId: requireFixtureIdentifier(record.machineId, "machineId"),
|
|
271
|
+
notifierId: requireFixtureIdentifier(record.notifierId, "notifierId"),
|
|
272
|
+
projectId,
|
|
273
|
+
scopes: snapshotFixtureScopes(record.scopes, MACHINE_SCOPE_SUMMARY),
|
|
274
|
+
status: record.status,
|
|
275
|
+
subscriberId: requireFixtureIdentifier(record.subscriberId, "subscriberId"),
|
|
276
|
+
token,
|
|
277
|
+
}));
|
|
278
|
+
}
|
|
279
|
+
return Object.freeze(snapshot);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Resolves the environment identifier a project credential authenticates for.
|
|
283
|
+
*
|
|
284
|
+
* A fixture credential may declare one explicitly; otherwise it is derived
|
|
285
|
+
* from the project and environment kind so every credential has a stable,
|
|
286
|
+
* inspectable environment path segment.
|
|
287
|
+
*/
|
|
288
|
+
function credentialEnvironmentId(credential) {
|
|
289
|
+
return (credential.environmentId ??
|
|
290
|
+
`environment_${credential.projectId}_${credential.environment}`);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Mints a deterministic Standard Webhooks secret.
|
|
294
|
+
*
|
|
295
|
+
* The mock is reproducible by design, so the bytes come from the endpoint
|
|
296
|
+
* sequence and secret version rather than a random source. These are local
|
|
297
|
+
* fixtures and must never be treated as production signing material.
|
|
298
|
+
*/
|
|
299
|
+
function syntheticEndpointSecret(sequence, version) {
|
|
300
|
+
const bytes = new Uint8Array(32);
|
|
301
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
302
|
+
bytes[index] = (index * 7 + sequence * 31 + version * 131) % 256;
|
|
303
|
+
}
|
|
304
|
+
let binary = "";
|
|
305
|
+
for (const byte of bytes) {
|
|
306
|
+
binary += String.fromCharCode(byte);
|
|
307
|
+
}
|
|
308
|
+
return `whsec_${btoa(binary)}`;
|
|
309
|
+
}
|
|
310
|
+
function createDefaultIdGenerator() {
|
|
311
|
+
const counters = new Map();
|
|
312
|
+
return (kind) => {
|
|
313
|
+
const next = (counters.get(kind) ?? 0) + 1;
|
|
314
|
+
counters.set(kind, next);
|
|
315
|
+
return `${kind}_mock_${String(next).padStart(3, "0")}`;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function clone(value) {
|
|
319
|
+
return structuredClone(value);
|
|
320
|
+
}
|
|
321
|
+
function timestamp(options) {
|
|
322
|
+
const date = options.clock?.now() ?? new Date("2026-07-25T17:45:00.000Z");
|
|
323
|
+
if (Number.isNaN(date.getTime())) {
|
|
324
|
+
throw new RangeError("Contract mock clock returned an invalid Date.");
|
|
325
|
+
}
|
|
326
|
+
return date.toISOString();
|
|
327
|
+
}
|
|
328
|
+
function timestampAfter(options, seconds) {
|
|
329
|
+
const now = Date.parse(timestamp(options));
|
|
330
|
+
return new Date(now + seconds * 1000).toISOString();
|
|
331
|
+
}
|
|
332
|
+
function timestampAfterValue(value, seconds) {
|
|
333
|
+
return new Date(Date.parse(value) + seconds * 1000).toISOString();
|
|
334
|
+
}
|
|
335
|
+
function base64Url(bytes) {
|
|
336
|
+
let binary = "";
|
|
337
|
+
for (const byte of bytes) {
|
|
338
|
+
binary += String.fromCharCode(byte);
|
|
339
|
+
}
|
|
340
|
+
return btoa(binary)
|
|
341
|
+
.replaceAll("+", "-")
|
|
342
|
+
.replaceAll("/", "_")
|
|
343
|
+
.replace(/=+$/u, "");
|
|
344
|
+
}
|
|
345
|
+
function deterministicApiCredentialMaterial(state) {
|
|
346
|
+
state.apiCredentialSequence += 1;
|
|
347
|
+
const identity = new Uint8Array(16);
|
|
348
|
+
new DataView(identity.buffer).setUint32(identity.byteLength - 4, state.apiCredentialSequence);
|
|
349
|
+
const secret = new Uint8Array(32);
|
|
350
|
+
for (let index = 0; index < secret.byteLength; index += 1) {
|
|
351
|
+
secret[index] = (state.apiCredentialSequence * 31 + index) % 256;
|
|
352
|
+
}
|
|
353
|
+
const id = `pcred_${base64Url(identity)}`;
|
|
354
|
+
return {
|
|
355
|
+
id,
|
|
356
|
+
prefix: `wb_pc1.${id.slice(0, 14)}`,
|
|
357
|
+
token: `wb_pc1.${id}.${base64Url(secret)}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function refreshApiCredentialStatus(record, state) {
|
|
361
|
+
const scheduled = record.credential.scheduled_revocation_at;
|
|
362
|
+
if (record.credential.status !== "active" ||
|
|
363
|
+
scheduled === undefined ||
|
|
364
|
+
Date.parse(scheduled) > Date.parse(state.now())) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const metadata = { ...record.credential };
|
|
368
|
+
delete metadata.scheduled_revocation_at;
|
|
369
|
+
record.credential = {
|
|
370
|
+
...metadata,
|
|
371
|
+
revoked_at: scheduled,
|
|
372
|
+
status: "revoked",
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function credentialPageOffset(cursor) {
|
|
376
|
+
if (cursor === null) {
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
const match = /^api_credential_cursor_([1-9]\d*)$/u.exec(cursor);
|
|
380
|
+
if (match?.[1] === undefined) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
const offset = Number(match[1]);
|
|
384
|
+
return Number.isSafeInteger(offset) ? offset : undefined;
|
|
385
|
+
}
|
|
386
|
+
function projectEnvironmentForHuman(state, identity, projectId, environmentId) {
|
|
387
|
+
const project = state.projects.get(projectId);
|
|
388
|
+
if (project === undefined ||
|
|
389
|
+
project.account_id !== identity.credential.accountId) {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
return project.environments.find((environment) => environment.id === environmentId);
|
|
393
|
+
}
|
|
394
|
+
function createApiCredentialMaterial(request, projectId, environment, identity, state) {
|
|
395
|
+
const material = deterministicApiCredentialMaterial(state);
|
|
396
|
+
const credential = {
|
|
397
|
+
created_at: state.now(),
|
|
398
|
+
created_by: `member_${identity.credential.accountId}`,
|
|
399
|
+
environment: environment.kind,
|
|
400
|
+
environment_id: environment.id,
|
|
401
|
+
id: material.id,
|
|
402
|
+
label: request.label,
|
|
403
|
+
prefix: material.prefix,
|
|
404
|
+
project_id: projectId,
|
|
405
|
+
scopes: [...request.scopes],
|
|
406
|
+
status: "active",
|
|
407
|
+
};
|
|
408
|
+
const reveal = {
|
|
409
|
+
credential,
|
|
410
|
+
secret: material.token,
|
|
411
|
+
};
|
|
412
|
+
if (!validateApiCredentialSecretReveal(reveal)) {
|
|
413
|
+
throw new Error("Internal contract-mock credential response violation.");
|
|
414
|
+
}
|
|
415
|
+
state.apiCredentials.set(credential.id, {
|
|
416
|
+
credential,
|
|
417
|
+
});
|
|
418
|
+
state.credentialsByToken.set(material.token, {
|
|
419
|
+
accountId: identity.credential.accountId,
|
|
420
|
+
credentialId: credential.id,
|
|
421
|
+
environment: credential.environment,
|
|
422
|
+
kind: "project",
|
|
423
|
+
projectId,
|
|
424
|
+
scopes: credential.scopes,
|
|
425
|
+
token: material.token,
|
|
426
|
+
});
|
|
427
|
+
return reveal;
|
|
428
|
+
}
|
|
429
|
+
function bindingKey(projectId, environment, subscriberId, notifierId) {
|
|
430
|
+
return `${projectId}\u0000${environment}\u0000${subscriberId}\u0000${notifierId}`;
|
|
431
|
+
}
|
|
432
|
+
function idempotencyKey(identity, operation, key) {
|
|
433
|
+
const credential = identity.credential;
|
|
434
|
+
if (credential.kind === "human" || credential.kind === "grant") {
|
|
435
|
+
return `${credential.accountId}\u0000${operation}\u0000${key}`;
|
|
436
|
+
}
|
|
437
|
+
return `${credential.projectId}\u0000${credential.environment}\u0000${operation}\u0000${key}`;
|
|
438
|
+
}
|
|
439
|
+
function acknowledgementKey(machineClientId, eventId) {
|
|
440
|
+
return `${machineClientId}\u0000${eventId}`;
|
|
441
|
+
}
|
|
442
|
+
function stableValue(value) {
|
|
443
|
+
if (Array.isArray(value)) {
|
|
444
|
+
return value.map(stableValue);
|
|
445
|
+
}
|
|
446
|
+
if (typeof value === "object" && value !== null) {
|
|
447
|
+
return Object.fromEntries(Object.entries(value)
|
|
448
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
449
|
+
.map(([key, child]) => [key, stableValue(child)]));
|
|
450
|
+
}
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
function fingerprint(value) {
|
|
454
|
+
return JSON.stringify(stableValue(value));
|
|
455
|
+
}
|
|
456
|
+
function problem(code, status, diagnosticId, overrides = {}) {
|
|
457
|
+
const defaults = {
|
|
458
|
+
authentication_required: {
|
|
459
|
+
detail: "Provide a bearer credential.",
|
|
460
|
+
retryable: false,
|
|
461
|
+
title: "Authentication required",
|
|
462
|
+
},
|
|
463
|
+
cancellation_too_late: {
|
|
464
|
+
detail: "The operation can no longer be cancelled.",
|
|
465
|
+
retryable: false,
|
|
466
|
+
title: "Cancellation too late",
|
|
467
|
+
},
|
|
468
|
+
credential_invalid: {
|
|
469
|
+
detail: "The bearer credential is malformed, invalid, or revoked.",
|
|
470
|
+
retryable: false,
|
|
471
|
+
title: "Credential invalid",
|
|
472
|
+
},
|
|
473
|
+
environment_mismatch: {
|
|
474
|
+
detail: "The credential cannot address a binding in another environment.",
|
|
475
|
+
retryable: false,
|
|
476
|
+
title: "Environment mismatch",
|
|
477
|
+
},
|
|
478
|
+
endpoint_rotation_conflict: {
|
|
479
|
+
detail: "A prior signing-secret overlap is still active. Retry after that overlap expires.",
|
|
480
|
+
retryable: true,
|
|
481
|
+
title: "Endpoint rotation conflict",
|
|
482
|
+
},
|
|
483
|
+
idempotency_conflict: {
|
|
484
|
+
detail: "The key was already accepted with materially different validated input.",
|
|
485
|
+
retryable: false,
|
|
486
|
+
title: "Idempotency conflict",
|
|
487
|
+
},
|
|
488
|
+
idempotency_key_required: {
|
|
489
|
+
detail: "Provide an Idempotency-Key containing 8 to 255 visible ASCII characters.",
|
|
490
|
+
retryable: false,
|
|
491
|
+
title: "Idempotency key required",
|
|
492
|
+
},
|
|
493
|
+
provider_outcome_unknown: {
|
|
494
|
+
detail: "The provider may have accepted the operation; no automatic retry is scheduled.",
|
|
495
|
+
retryable: false,
|
|
496
|
+
title: "Provider outcome unknown",
|
|
497
|
+
},
|
|
498
|
+
provider_retryable: {
|
|
499
|
+
detail: "The provider call failed before acceptance and may be retried by the service.",
|
|
500
|
+
retryable: true,
|
|
501
|
+
title: "Provider retryable failure",
|
|
502
|
+
},
|
|
503
|
+
provider_terminal: {
|
|
504
|
+
detail: "The provider rejected the operation and no automatic retry is scheduled.",
|
|
505
|
+
retryable: false,
|
|
506
|
+
title: "Provider terminal failure",
|
|
507
|
+
},
|
|
508
|
+
project_slug_conflict: {
|
|
509
|
+
detail: "A project with this slug already exists in the account.",
|
|
510
|
+
retryable: false,
|
|
511
|
+
title: "Project slug conflict",
|
|
512
|
+
},
|
|
513
|
+
rate_limited: {
|
|
514
|
+
detail: "The project or operation rate limit was exceeded.",
|
|
515
|
+
retryable: true,
|
|
516
|
+
title: "Rate limited",
|
|
517
|
+
},
|
|
518
|
+
request_invalid: {
|
|
519
|
+
detail: "One or more request fields are invalid.",
|
|
520
|
+
retryable: false,
|
|
521
|
+
title: "Request invalid",
|
|
522
|
+
},
|
|
523
|
+
replay_unavailable: {
|
|
524
|
+
detail: "The customer event cannot be replayed in its current state.",
|
|
525
|
+
retryable: false,
|
|
526
|
+
title: "Replay unavailable",
|
|
527
|
+
},
|
|
528
|
+
resource_expired: {
|
|
529
|
+
detail: "The resource is no longer available.",
|
|
530
|
+
retryable: false,
|
|
531
|
+
title: "Resource expired",
|
|
532
|
+
},
|
|
533
|
+
resource_not_found: {
|
|
534
|
+
detail: "The resource was not found.",
|
|
535
|
+
retryable: false,
|
|
536
|
+
title: "Resource not found",
|
|
537
|
+
},
|
|
538
|
+
scope_forbidden: {
|
|
539
|
+
detail: "The credential does not grant the required operation scope.",
|
|
540
|
+
retryable: false,
|
|
541
|
+
title: "Scope forbidden",
|
|
542
|
+
},
|
|
543
|
+
subscriber_unbound: {
|
|
544
|
+
detail: "No authorized channel binding is available for this subscriber and notifier.",
|
|
545
|
+
retryable: false,
|
|
546
|
+
title: "Subscriber unbound",
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
const base = defaults[code];
|
|
550
|
+
const value = {
|
|
551
|
+
code,
|
|
552
|
+
detail: overrides.detail ?? base.detail,
|
|
553
|
+
diagnostic_id: diagnosticId,
|
|
554
|
+
retryable: overrides.retryable ?? base.retryable,
|
|
555
|
+
status,
|
|
556
|
+
title: overrides.title ?? base.title,
|
|
557
|
+
type: `https://whooshbang.flowxo.com/problems/${code}`,
|
|
558
|
+
...(overrides.retry_at === undefined
|
|
559
|
+
? {}
|
|
560
|
+
: { retry_at: overrides.retry_at }),
|
|
561
|
+
...(overrides.field_errors === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { field_errors: overrides.field_errors }),
|
|
564
|
+
};
|
|
565
|
+
if (!validateProblem(value)) {
|
|
566
|
+
throw new Error("Internal contract-mock Problem Details violation.");
|
|
567
|
+
}
|
|
568
|
+
return value;
|
|
569
|
+
}
|
|
570
|
+
function problemResponse(value, additionalHeaders = {}) {
|
|
571
|
+
return new Response(JSON.stringify(value), {
|
|
572
|
+
headers: {
|
|
573
|
+
"content-type": "application/problem+json",
|
|
574
|
+
"whooshbang-diagnostic-id": value.diagnostic_id,
|
|
575
|
+
...additionalHeaders,
|
|
576
|
+
},
|
|
577
|
+
status: value.status,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function bodyDiagnosticId(value) {
|
|
581
|
+
return typeof value === "object" &&
|
|
582
|
+
value !== null &&
|
|
583
|
+
"diagnostic_id" in value &&
|
|
584
|
+
typeof value.diagnostic_id === "string"
|
|
585
|
+
? value.diagnostic_id
|
|
586
|
+
: undefined;
|
|
587
|
+
}
|
|
588
|
+
function assertDiagnosticId(value) {
|
|
589
|
+
if (value.length < 8 ||
|
|
590
|
+
value.length > 128 ||
|
|
591
|
+
!/^[A-Za-z0-9_-]+$/u.test(value)) {
|
|
592
|
+
throw new Error("Internal contract-mock diagnostic ID violation.");
|
|
593
|
+
}
|
|
594
|
+
return value;
|
|
595
|
+
}
|
|
596
|
+
function validatedResponse(value, validator, status, state, diagnosticId) {
|
|
597
|
+
if (!validator(value)) {
|
|
598
|
+
const errors = contractValidationErrors(validator);
|
|
599
|
+
throw new Error(`Internal contract-mock response violation: ${JSON.stringify(errors)}`);
|
|
600
|
+
}
|
|
601
|
+
const responseDiagnosticId = assertDiagnosticId(diagnosticId ?? bodyDiagnosticId(value) ?? state.idGenerator("diagnostic"));
|
|
602
|
+
return new Response(JSON.stringify(value), {
|
|
603
|
+
headers: {
|
|
604
|
+
"content-type": "application/json",
|
|
605
|
+
"whooshbang-diagnostic-id": responseDiagnosticId,
|
|
606
|
+
},
|
|
607
|
+
status,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
async function readJson(request, validator, diagnosticId) {
|
|
611
|
+
const mediaType = request.headers
|
|
612
|
+
.get("content-type")
|
|
613
|
+
?.split(";", 1)[0]
|
|
614
|
+
?.trim()
|
|
615
|
+
.toLowerCase() ?? "";
|
|
616
|
+
if (mediaType !== "application/json") {
|
|
617
|
+
return {
|
|
618
|
+
response: problemResponse(problem("request_invalid", 400, diagnosticId)),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
const declaredLength = request.headers.get("content-length");
|
|
622
|
+
if (declaredLength !== null &&
|
|
623
|
+
(!/^(?:0|[1-9]\d*)$/u.test(declaredLength) ||
|
|
624
|
+
!Number.isSafeInteger(Number(declaredLength)) ||
|
|
625
|
+
Number(declaredLength) > MAX_REQUEST_BYTES)) {
|
|
626
|
+
return {
|
|
627
|
+
response: problemResponse(problem("request_invalid", 400, diagnosticId)),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const reader = request.body?.getReader();
|
|
631
|
+
const chunks = [];
|
|
632
|
+
let length = 0;
|
|
633
|
+
if (reader !== undefined) {
|
|
634
|
+
try {
|
|
635
|
+
for (;;) {
|
|
636
|
+
const result = await reader.read();
|
|
637
|
+
if (result.done) {
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
length += result.value.byteLength;
|
|
641
|
+
if (length > MAX_REQUEST_BYTES) {
|
|
642
|
+
await reader.cancel();
|
|
643
|
+
return {
|
|
644
|
+
response: problemResponse(problem("request_invalid", 400, diagnosticId)),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
chunks.push(result.value);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
reader.releaseLock();
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const bytes = new Uint8Array(length);
|
|
655
|
+
let offset = 0;
|
|
656
|
+
for (const chunk of chunks) {
|
|
657
|
+
bytes.set(chunk, offset);
|
|
658
|
+
offset += chunk.byteLength;
|
|
659
|
+
}
|
|
660
|
+
let value;
|
|
661
|
+
try {
|
|
662
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return {
|
|
666
|
+
response: problemResponse(problem("request_invalid", 400, diagnosticId)),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (!validator(value)) {
|
|
670
|
+
const errors = contractValidationErrors(validator);
|
|
671
|
+
const fieldErrors = errors.slice(0, 20).map((error) => ({
|
|
672
|
+
code: error.keyword,
|
|
673
|
+
detail: error.message ?? "The field is invalid.",
|
|
674
|
+
field: error.instancePath === "" ? "/" : error.instancePath,
|
|
675
|
+
}));
|
|
676
|
+
return {
|
|
677
|
+
response: problemResponse(problem("request_invalid", 400, diagnosticId, {
|
|
678
|
+
field_errors: fieldErrors,
|
|
679
|
+
})),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return { ok: true, value };
|
|
683
|
+
}
|
|
684
|
+
function isAuthFailure(value) {
|
|
685
|
+
return "response" in value;
|
|
686
|
+
}
|
|
687
|
+
function authenticate(request, state, acceptedKind) {
|
|
688
|
+
const requestId = state.idGenerator("request");
|
|
689
|
+
const authorization = request.headers.get("authorization");
|
|
690
|
+
if (authorization === null) {
|
|
691
|
+
return {
|
|
692
|
+
response: problemResponse(problem("authentication_required", 401, state.idGenerator("diagnostic"))),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
const match = /^Bearer ([\u0021-\u007e]{1,4096})$/u.exec(authorization);
|
|
696
|
+
if (match?.[1] === undefined) {
|
|
697
|
+
return {
|
|
698
|
+
response: problemResponse(problem("credential_invalid", 401, state.idGenerator("diagnostic"))),
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
const credential = state.credentialsByToken.get(match[1]);
|
|
702
|
+
const linkedMachineClient = credential?.kind === "machine"
|
|
703
|
+
? state.machineClients.get(credential.machineClientId)?.client
|
|
704
|
+
: undefined;
|
|
705
|
+
const linkedMachineCredential = credential?.kind === "machine" && credential.credentialId !== undefined
|
|
706
|
+
? state.machineCredentials.get(credential.credentialId)?.credential
|
|
707
|
+
: undefined;
|
|
708
|
+
const linkedApiCredential = credential?.kind === "project" && credential.credentialId !== undefined
|
|
709
|
+
? state.apiCredentials.get(credential.credentialId)
|
|
710
|
+
: undefined;
|
|
711
|
+
if (linkedApiCredential !== undefined) {
|
|
712
|
+
refreshApiCredentialStatus(linkedApiCredential, state);
|
|
713
|
+
}
|
|
714
|
+
if (credential === undefined ||
|
|
715
|
+
(credential.kind === "project" &&
|
|
716
|
+
credential.credentialId !== undefined &&
|
|
717
|
+
(linkedApiCredential === undefined ||
|
|
718
|
+
linkedApiCredential.credential.status === "revoked")) ||
|
|
719
|
+
(credential.kind === "machine" &&
|
|
720
|
+
(credential.status === "revoked" ||
|
|
721
|
+
linkedMachineClient?.status === "revoked" ||
|
|
722
|
+
linkedMachineCredential?.status === "revoked")) ||
|
|
723
|
+
(acceptedKind === "either" && credential.kind === "grant") ||
|
|
724
|
+
(acceptedKind === "account" &&
|
|
725
|
+
credential.kind !== "human" &&
|
|
726
|
+
credential.kind !== "grant") ||
|
|
727
|
+
(acceptedKind !== "either" &&
|
|
728
|
+
acceptedKind !== "account" &&
|
|
729
|
+
credential.kind !== acceptedKind)) {
|
|
730
|
+
return {
|
|
731
|
+
response: problemResponse(problem("credential_invalid", 401, state.idGenerator("diagnostic"))),
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
if (linkedApiCredential !== undefined) {
|
|
735
|
+
linkedApiCredential.credential = {
|
|
736
|
+
...linkedApiCredential.credential,
|
|
737
|
+
last_used_at: state.now(),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
return { credential, requestId };
|
|
741
|
+
}
|
|
742
|
+
function requireHumanSession(request, state) {
|
|
743
|
+
const result = authenticate(request, state, "human");
|
|
744
|
+
if (isAuthFailure(result)) {
|
|
745
|
+
return result;
|
|
746
|
+
}
|
|
747
|
+
if (result.credential.kind !== "human") {
|
|
748
|
+
return {
|
|
749
|
+
response: problemResponse(problem("credential_invalid", 401, state.idGenerator("diagnostic"))),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
credential: result.credential,
|
|
754
|
+
requestId: result.requestId,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Authenticates an account-scoped request under either authority that can
|
|
759
|
+
* carry one: a human session, or an OAuth grant holding the account-level
|
|
760
|
+
* scope the operation needs.
|
|
761
|
+
*
|
|
762
|
+
* A project credential is refused structurally rather than by a scope check —
|
|
763
|
+
* it has no account-level scope to hold, because it lives inside one project.
|
|
764
|
+
*/
|
|
765
|
+
function requireAccountAuthority(request, state, scope) {
|
|
766
|
+
const result = authenticate(request, state, "account");
|
|
767
|
+
if (isAuthFailure(result)) {
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
if (result.credential.kind === "grant") {
|
|
771
|
+
if (scope !== undefined && !result.credential.scopes.includes(scope)) {
|
|
772
|
+
return {
|
|
773
|
+
response: problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic"))),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return { credential: result.credential, requestId: result.requestId };
|
|
777
|
+
}
|
|
778
|
+
if (result.credential.kind !== "human") {
|
|
779
|
+
return {
|
|
780
|
+
response: problemResponse(problem("credential_invalid", 401, state.idGenerator("diagnostic"))),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
return { credential: result.credential, requestId: result.requestId };
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* The environment-scoped resources the published contract lets a grant reach.
|
|
787
|
+
*
|
|
788
|
+
* `customer-endpoints` already carries project and environment in its own
|
|
789
|
+
* path, so it is served where it is; the rest are the environment-scoped forms
|
|
790
|
+
* of operations a credential reaches at the flat path.
|
|
791
|
+
*/
|
|
792
|
+
const GRANT_REACHABLE_RESOURCES = new Set([
|
|
793
|
+
"capabilities",
|
|
794
|
+
"customer-endpoints",
|
|
795
|
+
"interactions",
|
|
796
|
+
"machine-clients",
|
|
797
|
+
"messages",
|
|
798
|
+
"subscription-links",
|
|
799
|
+
]);
|
|
800
|
+
function presentedGrant(request, state) {
|
|
801
|
+
const token = /^Bearer ([\u0021-\u007e]{1,4096})$/u.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
802
|
+
const credential = token === undefined ? undefined : state.credentialsByToken.get(token);
|
|
803
|
+
return credential?.kind === "grant" ? credential : undefined;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Derives the project authority one grant has inside one named environment.
|
|
807
|
+
*
|
|
808
|
+
* The derived token is deterministic and cached, so repeating a call reuses
|
|
809
|
+
* the same authority rather than growing the credential table per request. It
|
|
810
|
+
* carries only the project-level half of the grant's scopes: an account scope
|
|
811
|
+
* cannot be exercised through a project-scoped path.
|
|
812
|
+
*
|
|
813
|
+
* It is derived from the grant's own token, so anyone who could construct it
|
|
814
|
+
* already holds that grant and already reaches this project environment; it
|
|
815
|
+
* grants nothing the caller did not have. A deployment would derive the same
|
|
816
|
+
* authority from a verified token instead of a fixture one.
|
|
817
|
+
*/
|
|
818
|
+
function admitGrantProjectAuthority(state, grant, projectId, environment) {
|
|
819
|
+
const token = `grant.${grant.token}.${projectId}.${environment.id}`;
|
|
820
|
+
if (!state.credentialsByToken.has(token)) {
|
|
821
|
+
state.credentialsByToken.set(token, Object.freeze({
|
|
822
|
+
accountId: grant.accountId,
|
|
823
|
+
environment: environment.kind,
|
|
824
|
+
environmentId: environment.id,
|
|
825
|
+
kind: "project",
|
|
826
|
+
projectId,
|
|
827
|
+
scopes: Object.freeze(projectScopes.filter((scope) => grant.scopes.includes(scope))),
|
|
828
|
+
token,
|
|
829
|
+
}));
|
|
830
|
+
}
|
|
831
|
+
return token;
|
|
832
|
+
}
|
|
833
|
+
function requireProjectScope(request, state, scope) {
|
|
834
|
+
const result = authenticate(request, state, "either");
|
|
835
|
+
if (isAuthFailure(result)) {
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
if (result.credential.kind !== "project" ||
|
|
839
|
+
!result.credential.scopes.includes(scope)) {
|
|
840
|
+
return {
|
|
841
|
+
response: problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic"))),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
credential: result.credential,
|
|
846
|
+
requestId: result.requestId,
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function requireProductCredential(request, state) {
|
|
850
|
+
const result = authenticate(request, state, "either");
|
|
851
|
+
if (isAuthFailure(result)) {
|
|
852
|
+
return result;
|
|
853
|
+
}
|
|
854
|
+
if (result.credential.kind === "human" ||
|
|
855
|
+
result.credential.kind === "grant") {
|
|
856
|
+
return {
|
|
857
|
+
response: problemResponse(problem("credential_invalid", 401, state.idGenerator("diagnostic"))),
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
credential: result.credential,
|
|
862
|
+
requestId: result.requestId,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function requireMachineScope(request, state, scope) {
|
|
866
|
+
const result = authenticate(request, state, "machine");
|
|
867
|
+
if (isAuthFailure(result)) {
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
if (result.credential.kind !== "machine" ||
|
|
871
|
+
!result.credential.scopes.includes(scope)) {
|
|
872
|
+
return {
|
|
873
|
+
response: problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic"))),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
credential: result.credential,
|
|
878
|
+
requestId: result.requestId,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function recordDiagnostic(state, identity, operation, details = {}) {
|
|
882
|
+
const scope = identity.credential.kind === "human" || identity.credential.kind === "grant"
|
|
883
|
+
? {}
|
|
884
|
+
: {
|
|
885
|
+
environment: identity.credential.environment,
|
|
886
|
+
projectId: identity.credential.projectId,
|
|
887
|
+
};
|
|
888
|
+
state.diagnostics.push({
|
|
889
|
+
operation,
|
|
890
|
+
requestId: identity.requestId,
|
|
891
|
+
scenario: state.scenario,
|
|
892
|
+
...scope,
|
|
893
|
+
...details,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
function normalizeLinkRequest(request) {
|
|
897
|
+
return {
|
|
898
|
+
...request,
|
|
899
|
+
expires_in_seconds: request.expires_in_seconds ?? 900,
|
|
900
|
+
notifier_id: request.notifier_id ?? "default",
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function normalizeMessageRequest(request) {
|
|
904
|
+
return {
|
|
905
|
+
...request,
|
|
906
|
+
notifier_id: request.notifier_id ?? "default",
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
function messageTemporalInputIsValid(request, acceptedAt) {
|
|
910
|
+
const acceptedAtMilliseconds = Date.parse(acceptedAt);
|
|
911
|
+
const latestMilliseconds = acceptedAtMilliseconds + 24 * 60 * 60 * 1000;
|
|
912
|
+
if (request.expires_at !== undefined) {
|
|
913
|
+
const expiresAtMilliseconds = Date.parse(request.expires_at);
|
|
914
|
+
if (expiresAtMilliseconds <= acceptedAtMilliseconds ||
|
|
915
|
+
expiresAtMilliseconds > latestMilliseconds) {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (request.interaction !== undefined) {
|
|
920
|
+
const interactionExpiresAtMilliseconds = Date.parse(request.interaction.expires_at);
|
|
921
|
+
if (interactionExpiresAtMilliseconds < acceptedAtMilliseconds + 30 * 1000 ||
|
|
922
|
+
interactionExpiresAtMilliseconds > latestMilliseconds) {
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
function interactionAnswer(interaction) {
|
|
929
|
+
if (interaction.type === "confirm") {
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
932
|
+
if (interaction.type === "select") {
|
|
933
|
+
return interaction.options[0].value;
|
|
934
|
+
}
|
|
935
|
+
return "synthetic pre-existing answer";
|
|
936
|
+
}
|
|
937
|
+
function createInteractionSummary(interaction, id, scenario, now) {
|
|
938
|
+
const base = {
|
|
939
|
+
...(interaction.correlation_id === undefined
|
|
940
|
+
? {}
|
|
941
|
+
: { correlation_id: interaction.correlation_id }),
|
|
942
|
+
expires_at: interaction.expires_at,
|
|
943
|
+
id,
|
|
944
|
+
type: interaction.type,
|
|
945
|
+
};
|
|
946
|
+
if (scenario === "interaction-duplicate") {
|
|
947
|
+
return {
|
|
948
|
+
...base,
|
|
949
|
+
answer: interactionAnswer(interaction),
|
|
950
|
+
answer_retained: true,
|
|
951
|
+
answered_at: now,
|
|
952
|
+
state: "answered",
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
if (scenario === "interaction-stale") {
|
|
956
|
+
return { ...base, state: "cancelled" };
|
|
957
|
+
}
|
|
958
|
+
return { ...base, state: "open" };
|
|
959
|
+
}
|
|
960
|
+
function hasOtherEnvironmentBinding(state, credential, subscriberId, notifierId) {
|
|
961
|
+
return [...state.bindings.values()].some(({ binding }) => binding.project_id === credential.projectId &&
|
|
962
|
+
binding.environment !== credential.environment &&
|
|
963
|
+
binding.subscriber_id === subscriberId &&
|
|
964
|
+
binding.notifier_id === notifierId);
|
|
965
|
+
}
|
|
966
|
+
function transitionMessage(record, state, options, nextState) {
|
|
967
|
+
const previousState = record.message.state;
|
|
968
|
+
if (previousState === nextState) {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
record.message = {
|
|
972
|
+
...record.message,
|
|
973
|
+
state: nextState,
|
|
974
|
+
updated_at: timestamp(options),
|
|
975
|
+
};
|
|
976
|
+
state.diagnostics.push({
|
|
977
|
+
environment: record.message.environment,
|
|
978
|
+
operation: "processMessage",
|
|
979
|
+
projectId: record.message.project_id,
|
|
980
|
+
requestId: record.requestId,
|
|
981
|
+
resourceId: record.message.id,
|
|
982
|
+
scenario: state.scenario,
|
|
983
|
+
stateTransition: `${previousState}->${nextState}`,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
function requireIdempotency(request, state) {
|
|
987
|
+
const key = request.headers.get("idempotency-key");
|
|
988
|
+
if (key === null ||
|
|
989
|
+
key.length < 8 ||
|
|
990
|
+
key.length > 255 ||
|
|
991
|
+
!/^[\u0021-\u007e]+$/u.test(key)) {
|
|
992
|
+
return {
|
|
993
|
+
response: problemResponse(problem("idempotency_key_required", 400, state.idGenerator("diagnostic"))),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return { key };
|
|
997
|
+
}
|
|
998
|
+
function idempotentReplay(state, identity, operation, key, requestFingerprint) {
|
|
999
|
+
const mapKey = idempotencyKey(identity, operation, key);
|
|
1000
|
+
const stored = state.idempotency.get(mapKey);
|
|
1001
|
+
if (stored === undefined) {
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
if (stored.fingerprint !== requestFingerprint) {
|
|
1005
|
+
state.logicalOperations.push({
|
|
1006
|
+
idempotencyResult: "conflict",
|
|
1007
|
+
operation,
|
|
1008
|
+
});
|
|
1009
|
+
recordDiagnostic(state, identity, operation, {
|
|
1010
|
+
idempotencyResult: "conflict",
|
|
1011
|
+
});
|
|
1012
|
+
return problemResponse(problem("idempotency_conflict", 409, state.idGenerator("diagnostic")));
|
|
1013
|
+
}
|
|
1014
|
+
state.logicalOperations.push({
|
|
1015
|
+
idempotencyResult: "replayed",
|
|
1016
|
+
operation,
|
|
1017
|
+
...(typeof stored.body === "object" &&
|
|
1018
|
+
stored.body !== null &&
|
|
1019
|
+
"id" in stored.body &&
|
|
1020
|
+
typeof stored.body.id === "string"
|
|
1021
|
+
? { resourceId: stored.body.id }
|
|
1022
|
+
: {}),
|
|
1023
|
+
});
|
|
1024
|
+
recordDiagnostic(state, identity, operation, {
|
|
1025
|
+
idempotencyResult: "replayed",
|
|
1026
|
+
});
|
|
1027
|
+
return new Response(JSON.stringify(stored.body), {
|
|
1028
|
+
headers: {
|
|
1029
|
+
"content-type": "application/json",
|
|
1030
|
+
"whooshbang-diagnostic-id": stored.diagnosticId,
|
|
1031
|
+
},
|
|
1032
|
+
status: stored.status,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
function storeIdempotentResponse(state, identity, operation, key, requestFingerprint, status, body, resourceId, stateTransition) {
|
|
1036
|
+
const diagnosticId = assertDiagnosticId(bodyDiagnosticId(body) ?? state.idGenerator("diagnostic"));
|
|
1037
|
+
state.idempotency.set(idempotencyKey(identity, operation, key), {
|
|
1038
|
+
body: clone(body),
|
|
1039
|
+
diagnosticId,
|
|
1040
|
+
fingerprint: requestFingerprint,
|
|
1041
|
+
status,
|
|
1042
|
+
});
|
|
1043
|
+
state.logicalOperations.push({
|
|
1044
|
+
idempotencyResult: "new",
|
|
1045
|
+
operation,
|
|
1046
|
+
...(resourceId === undefined ? {} : { resourceId }),
|
|
1047
|
+
});
|
|
1048
|
+
recordDiagnostic(state, identity, operation, {
|
|
1049
|
+
idempotencyResult: "new",
|
|
1050
|
+
...(resourceId === undefined ? {} : { resourceId }),
|
|
1051
|
+
...(stateTransition === undefined ? {} : { stateTransition }),
|
|
1052
|
+
});
|
|
1053
|
+
return diagnosticId;
|
|
1054
|
+
}
|
|
1055
|
+
function ownerMatches(credential, resource) {
|
|
1056
|
+
return (credential.projectId === resource.project_id &&
|
|
1057
|
+
credential.environment === resource.environment);
|
|
1058
|
+
}
|
|
1059
|
+
function originatingMachineClientId(state, record) {
|
|
1060
|
+
if (record.machineClientId === undefined) {
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
const client = state.machineClients.get(record.machineClientId)?.client;
|
|
1064
|
+
if (client === undefined ||
|
|
1065
|
+
client.status === "revoked" ||
|
|
1066
|
+
client.project_id !== record.message.project_id ||
|
|
1067
|
+
client.environment !== record.message.environment ||
|
|
1068
|
+
client.subscriber_id !== record.message.subscriber_id ||
|
|
1069
|
+
client.notifier_id !== record.message.notifier_id ||
|
|
1070
|
+
client.binding_id !== record.message.binding.id) {
|
|
1071
|
+
return undefined;
|
|
1072
|
+
}
|
|
1073
|
+
return record.machineClientId;
|
|
1074
|
+
}
|
|
1075
|
+
function createBinding(projectId, environment, subscriberId, notifierId, id) {
|
|
1076
|
+
return {
|
|
1077
|
+
channel: "telegram",
|
|
1078
|
+
environment,
|
|
1079
|
+
id,
|
|
1080
|
+
notifier_id: notifierId,
|
|
1081
|
+
project_id: projectId,
|
|
1082
|
+
status: "active",
|
|
1083
|
+
subscriber_id: subscriberId,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function confirmEvent(cursor, suffix) {
|
|
1087
|
+
return {
|
|
1088
|
+
answer_retained: true,
|
|
1089
|
+
channel_context: {
|
|
1090
|
+
binding_id: "binding_synthetic_relay",
|
|
1091
|
+
channel: "telegram",
|
|
1092
|
+
conversation_kind: "private_chat",
|
|
1093
|
+
display_name: "Synthetic operator",
|
|
1094
|
+
},
|
|
1095
|
+
correlation_id: `request_confirm_${suffix}`,
|
|
1096
|
+
cursor,
|
|
1097
|
+
expires_at: "2026-07-25T18:00:00Z",
|
|
1098
|
+
id: `event_interaction_confirm_${suffix}`,
|
|
1099
|
+
interaction_id: `interaction_synthetic_confirm_${suffix}`,
|
|
1100
|
+
message_id: `msg_interactive_confirm_${suffix}`,
|
|
1101
|
+
occurred_at: "2026-07-25T17:47:00Z",
|
|
1102
|
+
response: { type: "confirm", value: true },
|
|
1103
|
+
schema: "whooshbang.interaction-event.v1",
|
|
1104
|
+
type: "interaction.received",
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function selectEvent(cursor, suffix) {
|
|
1108
|
+
return {
|
|
1109
|
+
answer_retained: true,
|
|
1110
|
+
channel_context: {
|
|
1111
|
+
binding_id: "binding_synthetic_relay",
|
|
1112
|
+
channel: "telegram",
|
|
1113
|
+
conversation_kind: "private_chat",
|
|
1114
|
+
display_name: "Synthetic operator",
|
|
1115
|
+
},
|
|
1116
|
+
correlation_id: `request_select_${suffix}`,
|
|
1117
|
+
cursor,
|
|
1118
|
+
expires_at: "2026-07-25T18:00:00Z",
|
|
1119
|
+
id: `event_interaction_select_${suffix}`,
|
|
1120
|
+
interaction_id: `interaction_synthetic_select_${suffix}`,
|
|
1121
|
+
message_id: `msg_interactive_select_${suffix}`,
|
|
1122
|
+
occurred_at: "2026-07-25T17:48:00Z",
|
|
1123
|
+
response: { type: "select", value: "staging" },
|
|
1124
|
+
schema: "whooshbang.interaction-event.v1",
|
|
1125
|
+
type: "interaction.received",
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function seedEvents(kind) {
|
|
1129
|
+
if (kind === "none") {
|
|
1130
|
+
return [];
|
|
1131
|
+
}
|
|
1132
|
+
const first = {
|
|
1133
|
+
event: confirmEvent("mcur_synthetic_001", "001"),
|
|
1134
|
+
machineClientId: "machine_client_synthetic_001",
|
|
1135
|
+
};
|
|
1136
|
+
if (kind === "confirm") {
|
|
1137
|
+
return [first];
|
|
1138
|
+
}
|
|
1139
|
+
const second = {
|
|
1140
|
+
event: selectEvent("mcur_synthetic_002", "001"),
|
|
1141
|
+
machineClientId: "machine_client_synthetic_001",
|
|
1142
|
+
...(kind === "poison" ? { quarantinable: true } : {}),
|
|
1143
|
+
};
|
|
1144
|
+
return kind === "poison" ? [second] : [first, second];
|
|
1145
|
+
}
|
|
1146
|
+
function injectedMachineEvents(options) {
|
|
1147
|
+
if (options.machineEventDeliveryScript === undefined) {
|
|
1148
|
+
return [];
|
|
1149
|
+
}
|
|
1150
|
+
const runtimeSeeds = options.machineEventDeliveryScript();
|
|
1151
|
+
if (!Array.isArray(runtimeSeeds)) {
|
|
1152
|
+
throw new TypeError("machineEventDeliveryScript must synchronously return a fixture array.");
|
|
1153
|
+
}
|
|
1154
|
+
return runtimeSeeds.map((runtimeSeed) => {
|
|
1155
|
+
if (typeof runtimeSeed !== "object" ||
|
|
1156
|
+
runtimeSeed === null ||
|
|
1157
|
+
Array.isArray(runtimeSeed)) {
|
|
1158
|
+
throw new TypeError("A machine-event seed is invalid.");
|
|
1159
|
+
}
|
|
1160
|
+
const seed = runtimeSeed;
|
|
1161
|
+
if (Object.keys(seed).some((key) => ![
|
|
1162
|
+
"event",
|
|
1163
|
+
"machineClientId",
|
|
1164
|
+
"quarantinable",
|
|
1165
|
+
"signatureValid",
|
|
1166
|
+
"streamIdentityValid",
|
|
1167
|
+
].includes(key)) ||
|
|
1168
|
+
!validateInteractionEvent(seed.event) ||
|
|
1169
|
+
typeof seed.machineClientId !== "string" ||
|
|
1170
|
+
codePointLength(seed.machineClientId) === 0 ||
|
|
1171
|
+
codePointLength(seed.machineClientId) > 128 ||
|
|
1172
|
+
containsAsciiControl(seed.machineClientId) ||
|
|
1173
|
+
(seed.quarantinable !== undefined &&
|
|
1174
|
+
typeof seed.quarantinable !== "boolean") ||
|
|
1175
|
+
(seed.signatureValid !== undefined &&
|
|
1176
|
+
typeof seed.signatureValid !== "boolean") ||
|
|
1177
|
+
(seed.streamIdentityValid !== undefined &&
|
|
1178
|
+
typeof seed.streamIdentityValid !== "boolean")) {
|
|
1179
|
+
throw new TypeError("A machine-event seed is invalid.");
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
event: clone(seed.event),
|
|
1183
|
+
machineClientId: seed.machineClientId,
|
|
1184
|
+
...(seed.quarantinable === undefined
|
|
1185
|
+
? {}
|
|
1186
|
+
: { quarantinable: seed.quarantinable }),
|
|
1187
|
+
...(seed.signatureValid === undefined
|
|
1188
|
+
? {}
|
|
1189
|
+
: { signatureValid: seed.signatureValid }),
|
|
1190
|
+
...(seed.streamIdentityValid === undefined
|
|
1191
|
+
? {}
|
|
1192
|
+
: { streamIdentityValid: seed.streamIdentityValid }),
|
|
1193
|
+
};
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
function initialState(options, scenarioName) {
|
|
1197
|
+
const scenario = getScenarioDefinition(scenarioName);
|
|
1198
|
+
const idGenerator = options.idGenerator ?? createDefaultIdGenerator();
|
|
1199
|
+
const credentials = options.credentials ?? defaultCredentials();
|
|
1200
|
+
const state = {
|
|
1201
|
+
accounts: new Map(),
|
|
1202
|
+
apiCredentials: new Map(),
|
|
1203
|
+
apiCredentialSequence: 0,
|
|
1204
|
+
acknowledgements: new Map(),
|
|
1205
|
+
bindings: new Map(),
|
|
1206
|
+
credentialsByToken: new Map(credentials.map((credential) => [credential.token, credential])),
|
|
1207
|
+
cursorCommits: [],
|
|
1208
|
+
customerEndpoints: new Map(),
|
|
1209
|
+
customerEndpointSequence: 0,
|
|
1210
|
+
diagnostics: [],
|
|
1211
|
+
idempotency: new Map(),
|
|
1212
|
+
idGenerator,
|
|
1213
|
+
links: new Map(),
|
|
1214
|
+
logicalEvents: [],
|
|
1215
|
+
logicalOperations: [],
|
|
1216
|
+
machineClients: new Map(),
|
|
1217
|
+
machineCredentials: new Map(),
|
|
1218
|
+
machineEvents: seedEvents(scenario.seedMachineEvents),
|
|
1219
|
+
messages: new Map(),
|
|
1220
|
+
now: () => timestamp(options),
|
|
1221
|
+
providerAttempts: [],
|
|
1222
|
+
providerOutcomes: [...scenario.providerOutcomes],
|
|
1223
|
+
projects: new Map(),
|
|
1224
|
+
scenario: scenarioName,
|
|
1225
|
+
webhookAttempts: [],
|
|
1226
|
+
webhookResponses: [...scenario.webhookResponses],
|
|
1227
|
+
};
|
|
1228
|
+
let accountFixtureIndex = 0;
|
|
1229
|
+
for (const credential of credentials) {
|
|
1230
|
+
if (credential.kind !== "human" ||
|
|
1231
|
+
state.accounts.has(credential.accountId)) {
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
accountFixtureIndex += 1;
|
|
1235
|
+
const account = {
|
|
1236
|
+
created_at: timestamp(options),
|
|
1237
|
+
diagnostic_id: `diagnostic_mock_account_${String(accountFixtureIndex).padStart(3, "0")}`,
|
|
1238
|
+
id: credential.accountId,
|
|
1239
|
+
membership: { role: credential.role },
|
|
1240
|
+
name: `Synthetic account ${String(accountFixtureIndex)}`,
|
|
1241
|
+
status: "active",
|
|
1242
|
+
updated_at: timestamp(options),
|
|
1243
|
+
};
|
|
1244
|
+
if (!validateAccountProjection(account)) {
|
|
1245
|
+
throw new TypeError("A human fixture credential has an invalid account.");
|
|
1246
|
+
}
|
|
1247
|
+
state.accounts.set(account.id, { account });
|
|
1248
|
+
}
|
|
1249
|
+
// Every fixture project credential names a project that has to exist as a
|
|
1250
|
+
// record too, because an account-scoped caller reaches a project through
|
|
1251
|
+
// `GET /v1/projects` and its environments rather than through a credential.
|
|
1252
|
+
for (const credential of credentials) {
|
|
1253
|
+
if (credential.kind !== "project" ||
|
|
1254
|
+
state.projects.has(credential.projectId)) {
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const project = fixtureProject(credential.projectId, credential.accountId, options);
|
|
1258
|
+
state.projects.set(project.id, project);
|
|
1259
|
+
}
|
|
1260
|
+
for (const subscriberId of scenario.boundSubscribers) {
|
|
1261
|
+
const binding = createBinding("project_synthetic", "test", subscriberId, "default", subscriberId === "agent_relay_operator"
|
|
1262
|
+
? "binding_synthetic_relay"
|
|
1263
|
+
: `binding_${subscriberId}`);
|
|
1264
|
+
state.bindings.set(bindingKey(binding.project_id, binding.environment, binding.subscriber_id, binding.notifier_id), { binding });
|
|
1265
|
+
}
|
|
1266
|
+
const machineClient = {
|
|
1267
|
+
binding_id: "binding_synthetic_relay",
|
|
1268
|
+
created_at: timestamp(options),
|
|
1269
|
+
environment: "test",
|
|
1270
|
+
id: "machine_client_synthetic_001",
|
|
1271
|
+
machine_id: "machine_synthetic_a",
|
|
1272
|
+
notifier_id: "default",
|
|
1273
|
+
project_id: "project_synthetic",
|
|
1274
|
+
scope_summary: [...MACHINE_SCOPE_SUMMARY],
|
|
1275
|
+
status: "active",
|
|
1276
|
+
subscriber_id: "agent_relay_operator",
|
|
1277
|
+
updated_at: timestamp(options),
|
|
1278
|
+
};
|
|
1279
|
+
state.machineClients.set(machineClient.id, { client: machineClient });
|
|
1280
|
+
for (const credential of credentials) {
|
|
1281
|
+
if (credential.kind === "machine" &&
|
|
1282
|
+
credential.credentialId !== undefined) {
|
|
1283
|
+
state.machineCredentials.set(credential.credentialId, {
|
|
1284
|
+
credential: {
|
|
1285
|
+
created_at: timestamp(options),
|
|
1286
|
+
credential_id: credential.credentialId,
|
|
1287
|
+
machine_client_id: credential.machineClientId,
|
|
1288
|
+
scope_summary: [...MACHINE_SCOPE_SUMMARY],
|
|
1289
|
+
status: credential.status,
|
|
1290
|
+
...(credential.status === "revoked"
|
|
1291
|
+
? { revoked_at: timestamp(options) }
|
|
1292
|
+
: {}),
|
|
1293
|
+
},
|
|
1294
|
+
secretDigest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (scenario.preload !== "none") {
|
|
1299
|
+
const binding = state.bindings.get(bindingKey("project_synthetic", "test", "customer_123", "default"))?.binding ??
|
|
1300
|
+
createBinding("project_synthetic", "test", "customer_123", "default", "binding_customer_123");
|
|
1301
|
+
const cancellationTooLate = scenario.preload === "cancellation-too-late";
|
|
1302
|
+
const message = {
|
|
1303
|
+
accepted_at: timestamp(options),
|
|
1304
|
+
binding,
|
|
1305
|
+
delivery: {
|
|
1306
|
+
attempt_count: cancellationTooLate ? 1 : 0,
|
|
1307
|
+
highest_proven_provider_state: "none",
|
|
1308
|
+
retry_scheduled: false,
|
|
1309
|
+
},
|
|
1310
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1311
|
+
environment: "test",
|
|
1312
|
+
expires_at: timestampAfter(options, 24 * 60 * 60),
|
|
1313
|
+
id: cancellationTooLate ? "msg_cancel_too_late" : "msg_cancel_pending",
|
|
1314
|
+
notifier_id: "default",
|
|
1315
|
+
project_id: "project_synthetic",
|
|
1316
|
+
state: cancellationTooLate ? "sending" : "accepted",
|
|
1317
|
+
subscriber_id: "customer_123",
|
|
1318
|
+
updated_at: timestamp(options),
|
|
1319
|
+
};
|
|
1320
|
+
state.messages.set(message.id, {
|
|
1321
|
+
message,
|
|
1322
|
+
ownerAccountId: "account_synthetic",
|
|
1323
|
+
providerInFlight: false,
|
|
1324
|
+
providerProcessed: cancellationTooLate,
|
|
1325
|
+
requestId: state.idGenerator("request"),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
state.machineEvents.push(...injectedMachineEvents(options));
|
|
1329
|
+
return state;
|
|
1330
|
+
}
|
|
1331
|
+
function scenarioRateLimit(state, options, operation) {
|
|
1332
|
+
const scenario = getScenarioDefinition(state.scenario);
|
|
1333
|
+
if (scenario.rateLimitedOperation !== operation) {
|
|
1334
|
+
return undefined;
|
|
1335
|
+
}
|
|
1336
|
+
return rateLimitedResponse(state, options);
|
|
1337
|
+
}
|
|
1338
|
+
function rateLimitedResponse(state, options) {
|
|
1339
|
+
return problemResponse(problem("rate_limited", 429, state.idGenerator("diagnostic"), {
|
|
1340
|
+
retry_at: timestampAfter(options, 60),
|
|
1341
|
+
}), { "retry-after": "60" });
|
|
1342
|
+
}
|
|
1343
|
+
function decodeSigningSecret(value) {
|
|
1344
|
+
if (!value.startsWith("whsec_")) {
|
|
1345
|
+
throw new RangeError("Contract mock signing secret is malformed.");
|
|
1346
|
+
}
|
|
1347
|
+
const decoded = atob(value.slice("whsec_".length));
|
|
1348
|
+
const bytes = Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
1349
|
+
if (bytes.byteLength < 24 || bytes.byteLength > 64) {
|
|
1350
|
+
throw new RangeError("Contract mock signing secret is malformed.");
|
|
1351
|
+
}
|
|
1352
|
+
return bytes;
|
|
1353
|
+
}
|
|
1354
|
+
async function signWebhook(secret, eventId, timestampSeconds, rawBody) {
|
|
1355
|
+
const prefix = new TextEncoder().encode(`${eventId}.${timestampSeconds}.`);
|
|
1356
|
+
const input = new Uint8Array(prefix.byteLength + rawBody.byteLength);
|
|
1357
|
+
input.set(prefix);
|
|
1358
|
+
input.set(rawBody, prefix.byteLength);
|
|
1359
|
+
const key = await globalThis.crypto.subtle.importKey("raw", new Uint8Array([...decodeSigningSecret(secret)]), { hash: "SHA-256", name: "HMAC" }, false, ["sign"]);
|
|
1360
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, new Uint8Array([...input])));
|
|
1361
|
+
let binary = "";
|
|
1362
|
+
for (const byte of digest) {
|
|
1363
|
+
binary += String.fromCharCode(byte);
|
|
1364
|
+
}
|
|
1365
|
+
return `v1,${btoa(binary)}`;
|
|
1366
|
+
}
|
|
1367
|
+
async function deliverWebhook(event, state, options) {
|
|
1368
|
+
if (!validateWhooshBangEvent(event)) {
|
|
1369
|
+
throw new Error("Internal contract-mock event violation.");
|
|
1370
|
+
}
|
|
1371
|
+
const body = JSON.stringify(event);
|
|
1372
|
+
const rawBody = new TextEncoder().encode(body);
|
|
1373
|
+
const firstAttemptAtSeconds = Math.floor(Date.parse(timestamp(options)) / 1000);
|
|
1374
|
+
const secrets = [
|
|
1375
|
+
options.currentSigningSecret ?? CURRENT_SIGNING_SECRET,
|
|
1376
|
+
options.previousSigningSecret ?? PREVIOUS_SIGNING_SECRET,
|
|
1377
|
+
];
|
|
1378
|
+
for (let attempt = 1; attempt <= MAX_WEBHOOK_DELIVERY_ATTEMPTS; attempt += 1) {
|
|
1379
|
+
const attemptTimestampSeconds = Math.max(firstAttemptAtSeconds + attempt - 1, Math.floor(Date.parse(timestamp(options)) / 1000));
|
|
1380
|
+
const signatures = await Promise.all(secrets.map((secret) => signWebhook(secret, event.id, attemptTimestampSeconds, rawBody)));
|
|
1381
|
+
let status;
|
|
1382
|
+
let outcome;
|
|
1383
|
+
if (options.webhookReceiver !== undefined) {
|
|
1384
|
+
try {
|
|
1385
|
+
const response = await options.webhookReceiver(new Request("https://receiver.example.test/webhooks/notifications", {
|
|
1386
|
+
body,
|
|
1387
|
+
headers: {
|
|
1388
|
+
"content-type": "application/json",
|
|
1389
|
+
"webhook-attempt": String(attempt),
|
|
1390
|
+
"webhook-id": event.id,
|
|
1391
|
+
"webhook-signature": signatures.join(" "),
|
|
1392
|
+
"webhook-timestamp": String(attemptTimestampSeconds),
|
|
1393
|
+
},
|
|
1394
|
+
method: "POST",
|
|
1395
|
+
}));
|
|
1396
|
+
status = response.status;
|
|
1397
|
+
const retryAfter = response.headers.get("retry-after");
|
|
1398
|
+
outcome = {
|
|
1399
|
+
kind: "http",
|
|
1400
|
+
status,
|
|
1401
|
+
...(retryAfter === null ? {} : { retryAfter }),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
catch {
|
|
1405
|
+
outcome = { kind: "network_failure" };
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
const scripted = state.webhookResponses.shift() ?? 204;
|
|
1410
|
+
if (scripted === "network_failure") {
|
|
1411
|
+
outcome = { kind: "network_failure" };
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
status = scripted;
|
|
1415
|
+
outcome = { kind: "http", status };
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
const classification = classifyWebhookDeliveryOutcome({
|
|
1419
|
+
attempt,
|
|
1420
|
+
firstAttemptAtEpochSeconds: firstAttemptAtSeconds,
|
|
1421
|
+
nowEpochSeconds: attemptTimestampSeconds,
|
|
1422
|
+
outcome,
|
|
1423
|
+
});
|
|
1424
|
+
state.webhookAttempts.push({
|
|
1425
|
+
action: classification.action,
|
|
1426
|
+
attempt,
|
|
1427
|
+
eventId: event.id,
|
|
1428
|
+
reason: classification.reason,
|
|
1429
|
+
...("retryAfterSeconds" in classification
|
|
1430
|
+
? { retryAfterSeconds: classification.retryAfterSeconds }
|
|
1431
|
+
: {}),
|
|
1432
|
+
signatureCount: signatures.length,
|
|
1433
|
+
...(status === undefined ? {} : { status }),
|
|
1434
|
+
timestamp: attemptTimestampSeconds,
|
|
1435
|
+
});
|
|
1436
|
+
if (classification.action !== "retry") {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
function addLogicalEvent(state, event, resourceId) {
|
|
1442
|
+
state.logicalEvents.push({
|
|
1443
|
+
eventId: event.id,
|
|
1444
|
+
...(resourceId === undefined ? {} : { resourceId }),
|
|
1445
|
+
type: event.type,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
async function activateLink(record, state, options, identity) {
|
|
1449
|
+
if (record.link.status !== "pending") {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const binding = createBinding(record.link.project_id, record.link.environment, record.link.subscriber_id, record.link.notifier_id, state.idGenerator("binding"));
|
|
1453
|
+
state.bindings.set(bindingKey(binding.project_id, binding.environment, binding.subscriber_id, binding.notifier_id), { binding });
|
|
1454
|
+
record.link = {
|
|
1455
|
+
...record.link,
|
|
1456
|
+
binding,
|
|
1457
|
+
status: "activated",
|
|
1458
|
+
updated_at: timestamp(options),
|
|
1459
|
+
};
|
|
1460
|
+
delete record.link.authorization_url;
|
|
1461
|
+
const event = {
|
|
1462
|
+
account_id: record.ownerAccountId,
|
|
1463
|
+
created_at: timestamp(options),
|
|
1464
|
+
data: {
|
|
1465
|
+
activated_at: timestamp(options),
|
|
1466
|
+
binding_id: binding.id,
|
|
1467
|
+
channel: "telegram",
|
|
1468
|
+
notifier_id: binding.notifier_id,
|
|
1469
|
+
state: "activated",
|
|
1470
|
+
subscriber_id: binding.subscriber_id,
|
|
1471
|
+
subscription_link_id: record.link.id,
|
|
1472
|
+
},
|
|
1473
|
+
environment: record.link.environment,
|
|
1474
|
+
id: `evt_${state.idGenerator("event")}`,
|
|
1475
|
+
project_id: record.link.project_id,
|
|
1476
|
+
schema: "whooshbang.event.v1",
|
|
1477
|
+
type: "subscription.activated",
|
|
1478
|
+
};
|
|
1479
|
+
addLogicalEvent(state, event, record.link.id);
|
|
1480
|
+
recordDiagnostic(state, identity, "activateSubscriptionLink", {
|
|
1481
|
+
resourceId: record.link.id,
|
|
1482
|
+
stateTransition: "pending->activated",
|
|
1483
|
+
});
|
|
1484
|
+
await deliverWebhook(event, state, options);
|
|
1485
|
+
}
|
|
1486
|
+
async function nextProviderOutcome(state, options, messageId, attempt) {
|
|
1487
|
+
if (options.providerOutcomeScript !== undefined) {
|
|
1488
|
+
return options.providerOutcomeScript({
|
|
1489
|
+
attempt,
|
|
1490
|
+
messageId,
|
|
1491
|
+
scenario: state.scenario,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
return state.providerOutcomes.shift() ?? { kind: "accepted" };
|
|
1495
|
+
}
|
|
1496
|
+
async function processMessage(record, state, options) {
|
|
1497
|
+
if (record.providerInFlight ||
|
|
1498
|
+
record.providerProcessed ||
|
|
1499
|
+
record.message.state === "cancelled") {
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
record.providerInFlight = true;
|
|
1503
|
+
try {
|
|
1504
|
+
await processMessageWithProviderOwnership(record, state, options);
|
|
1505
|
+
}
|
|
1506
|
+
finally {
|
|
1507
|
+
record.providerInFlight = false;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
async function processMessageWithProviderOwnership(record, state, options) {
|
|
1511
|
+
if (record.providerProcessed || record.message.state === "cancelled") {
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
let attempt = record.message.delivery.attempt_count;
|
|
1515
|
+
while (attempt < MAX_PROVIDER_ATTEMPTS) {
|
|
1516
|
+
if (record.message.state === "accepted") {
|
|
1517
|
+
transitionMessage(record, state, options, "dispatch_pending");
|
|
1518
|
+
}
|
|
1519
|
+
if (record.message.state === "dispatch_pending" ||
|
|
1520
|
+
record.message.state === "retry_wait") {
|
|
1521
|
+
transitionMessage(record, state, options, "queued");
|
|
1522
|
+
}
|
|
1523
|
+
if (record.message.state === "queued") {
|
|
1524
|
+
transitionMessage(record, state, options, "sending");
|
|
1525
|
+
}
|
|
1526
|
+
if (record.message.state !== "sending") {
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
attempt += 1;
|
|
1530
|
+
const priorDelivery = record.message.delivery;
|
|
1531
|
+
record.message = {
|
|
1532
|
+
...record.message,
|
|
1533
|
+
delivery: {
|
|
1534
|
+
attempt_count: attempt,
|
|
1535
|
+
...(priorDelivery.classified_error === undefined
|
|
1536
|
+
? {}
|
|
1537
|
+
: { classified_error: priorDelivery.classified_error }),
|
|
1538
|
+
highest_proven_provider_state: priorDelivery.highest_proven_provider_state,
|
|
1539
|
+
retry_scheduled: false,
|
|
1540
|
+
},
|
|
1541
|
+
};
|
|
1542
|
+
const outcome = await nextProviderOutcome(state, options, record.message.id, attempt);
|
|
1543
|
+
state.providerAttempts.push({
|
|
1544
|
+
at: timestamp(options),
|
|
1545
|
+
attempt,
|
|
1546
|
+
messageId: record.message.id,
|
|
1547
|
+
outcome: outcome.kind,
|
|
1548
|
+
});
|
|
1549
|
+
if (outcome.kind === "accepted") {
|
|
1550
|
+
record.message = {
|
|
1551
|
+
...record.message,
|
|
1552
|
+
delivery: {
|
|
1553
|
+
attempt_count: attempt,
|
|
1554
|
+
highest_proven_provider_state: "provider_accepted",
|
|
1555
|
+
retry_scheduled: false,
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
transitionMessage(record, state, options, "provider_accepted");
|
|
1559
|
+
record.providerProcessed = true;
|
|
1560
|
+
const event = {
|
|
1561
|
+
account_id: record.ownerAccountId,
|
|
1562
|
+
created_at: timestamp(options),
|
|
1563
|
+
data: {
|
|
1564
|
+
accepted_at: timestamp(options),
|
|
1565
|
+
...(record.message.correlation_id === undefined
|
|
1566
|
+
? {}
|
|
1567
|
+
: { correlation_id: record.message.correlation_id }),
|
|
1568
|
+
message_id: record.message.id,
|
|
1569
|
+
state: "provider_accepted",
|
|
1570
|
+
},
|
|
1571
|
+
environment: record.message.environment,
|
|
1572
|
+
id: `evt_${state.idGenerator("event")}`,
|
|
1573
|
+
project_id: record.message.project_id,
|
|
1574
|
+
schema: "whooshbang.event.v1",
|
|
1575
|
+
type: "message.provider_accepted",
|
|
1576
|
+
};
|
|
1577
|
+
addLogicalEvent(state, event, record.message.id);
|
|
1578
|
+
await deliverWebhook(event, state, options);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
if (outcome.kind === "ambiguous") {
|
|
1582
|
+
record.message = {
|
|
1583
|
+
...record.message,
|
|
1584
|
+
delivery: {
|
|
1585
|
+
attempt_count: attempt,
|
|
1586
|
+
classified_error: {
|
|
1587
|
+
code: "provider_outcome_unknown",
|
|
1588
|
+
detail: "The provider may have accepted the request, but no trustworthy result is available.",
|
|
1589
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1590
|
+
retryable: false,
|
|
1591
|
+
},
|
|
1592
|
+
highest_proven_provider_state: "none",
|
|
1593
|
+
retry_scheduled: false,
|
|
1594
|
+
},
|
|
1595
|
+
outcome_guidance: "Delivery may have been accepted by the provider. No automatic retry is scheduled; create a new message explicitly if another attempt is appropriate.",
|
|
1596
|
+
};
|
|
1597
|
+
transitionMessage(record, state, options, "outcome_unknown");
|
|
1598
|
+
record.providerProcessed = true;
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (outcome.kind === "terminal") {
|
|
1602
|
+
record.message = {
|
|
1603
|
+
...record.message,
|
|
1604
|
+
delivery: {
|
|
1605
|
+
attempt_count: attempt,
|
|
1606
|
+
classified_error: {
|
|
1607
|
+
code: "provider_terminal",
|
|
1608
|
+
detail: "The provider rejected the operation.",
|
|
1609
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1610
|
+
retryable: false,
|
|
1611
|
+
},
|
|
1612
|
+
highest_proven_provider_state: "none",
|
|
1613
|
+
retry_scheduled: false,
|
|
1614
|
+
},
|
|
1615
|
+
};
|
|
1616
|
+
transitionMessage(record, state, options, "terminal_failed");
|
|
1617
|
+
record.providerProcessed = true;
|
|
1618
|
+
const event = {
|
|
1619
|
+
account_id: record.ownerAccountId,
|
|
1620
|
+
created_at: timestamp(options),
|
|
1621
|
+
data: {
|
|
1622
|
+
failed_at: timestamp(options),
|
|
1623
|
+
failure_code: "provider_terminal",
|
|
1624
|
+
message_id: record.message.id,
|
|
1625
|
+
retryable: false,
|
|
1626
|
+
state: "terminal_failed",
|
|
1627
|
+
},
|
|
1628
|
+
environment: record.message.environment,
|
|
1629
|
+
id: `evt_${state.idGenerator("event")}`,
|
|
1630
|
+
project_id: record.message.project_id,
|
|
1631
|
+
schema: "whooshbang.event.v1",
|
|
1632
|
+
type: "message.failed",
|
|
1633
|
+
};
|
|
1634
|
+
addLogicalEvent(state, event, record.message.id);
|
|
1635
|
+
await deliverWebhook(event, state, options);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const retryAt = timestampAfter(options, outcome.retryAfterSeconds ?? 60);
|
|
1639
|
+
record.message = {
|
|
1640
|
+
...record.message,
|
|
1641
|
+
delivery: {
|
|
1642
|
+
attempt_count: attempt,
|
|
1643
|
+
classified_error: {
|
|
1644
|
+
code: "provider_retryable",
|
|
1645
|
+
detail: "The provider failed before acceptance.",
|
|
1646
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1647
|
+
retryable: true,
|
|
1648
|
+
},
|
|
1649
|
+
highest_proven_provider_state: "none",
|
|
1650
|
+
retry_at: retryAt,
|
|
1651
|
+
retry_scheduled: true,
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
transitionMessage(record, state, options, "retry_wait");
|
|
1655
|
+
if (attempt >= MAX_PROVIDER_ATTEMPTS ||
|
|
1656
|
+
(options.providerOutcomeScript === undefined &&
|
|
1657
|
+
state.providerOutcomes.length === 0)) {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
function inspectState(state) {
|
|
1663
|
+
return clone({
|
|
1664
|
+
accounts: [...state.accounts.values()].map((record) => record.account),
|
|
1665
|
+
apiCredentials: [...state.apiCredentials.values()].map((record) => record.credential),
|
|
1666
|
+
contractVersion: CONTRACT_VERSION,
|
|
1667
|
+
cursorCommits: state.cursorCommits,
|
|
1668
|
+
customerEndpoints: [...state.customerEndpoints.values()].map((record) => record.endpoint),
|
|
1669
|
+
diagnostics: state.diagnostics,
|
|
1670
|
+
links: [...state.links.values()].map((record) => record.link),
|
|
1671
|
+
logicalEvents: state.logicalEvents,
|
|
1672
|
+
logicalOperations: state.logicalOperations,
|
|
1673
|
+
machineClients: [...state.machineClients.values()].map((record) => record.client),
|
|
1674
|
+
machineCredentials: [...state.machineCredentials.values()].map((record) => record.credential),
|
|
1675
|
+
messages: [...state.messages.values()].map((record) => record.message),
|
|
1676
|
+
providerAttempts: state.providerAttempts,
|
|
1677
|
+
projects: [...state.projects.values()],
|
|
1678
|
+
scenario: state.scenario,
|
|
1679
|
+
scenarioVersion: SCENARIO_VERSION,
|
|
1680
|
+
webhookAttempts: state.webhookAttempts,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
function notFound(state) {
|
|
1684
|
+
return problemResponse(problem("resource_not_found", 404, state.idGenerator("diagnostic")));
|
|
1685
|
+
}
|
|
1686
|
+
function requestInvalid(state) {
|
|
1687
|
+
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic")));
|
|
1688
|
+
}
|
|
1689
|
+
function projectPageOffset(cursor) {
|
|
1690
|
+
if (cursor === null) {
|
|
1691
|
+
return 0;
|
|
1692
|
+
}
|
|
1693
|
+
const match = /^project_cursor_([1-9]\d*)$/u.exec(cursor);
|
|
1694
|
+
if (match?.[1] === undefined) {
|
|
1695
|
+
return undefined;
|
|
1696
|
+
}
|
|
1697
|
+
const offset = Number(match[1]);
|
|
1698
|
+
return Number.isSafeInteger(offset) ? offset : undefined;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* The project record behind a fixture project credential.
|
|
1702
|
+
*
|
|
1703
|
+
* Its environment identifiers are the ones `credentialEnvironmentId` derives,
|
|
1704
|
+
* so the record and the credential agree about which environment a path
|
|
1705
|
+
* segment names.
|
|
1706
|
+
*/
|
|
1707
|
+
function fixtureProject(projectId, accountId, options) {
|
|
1708
|
+
const now = timestamp(options);
|
|
1709
|
+
const environment = (kind) => ({
|
|
1710
|
+
default_notifier: {
|
|
1711
|
+
display_name: "Default",
|
|
1712
|
+
environment_id: `environment_${projectId}_${kind}`,
|
|
1713
|
+
id: "default",
|
|
1714
|
+
is_default: true,
|
|
1715
|
+
status: "active",
|
|
1716
|
+
},
|
|
1717
|
+
id: `environment_${projectId}_${kind}`,
|
|
1718
|
+
kind,
|
|
1719
|
+
project_id: projectId,
|
|
1720
|
+
status: "active",
|
|
1721
|
+
});
|
|
1722
|
+
const project = {
|
|
1723
|
+
account_id: accountId,
|
|
1724
|
+
created_at: now,
|
|
1725
|
+
diagnostic_id: `diagnostic_mock_${projectId}`,
|
|
1726
|
+
environments: [environment("test"), environment("live")],
|
|
1727
|
+
id: projectId,
|
|
1728
|
+
name: `Synthetic ${projectId}`,
|
|
1729
|
+
slug: projectId.replaceAll("_", "-"),
|
|
1730
|
+
status: "active",
|
|
1731
|
+
updated_at: now,
|
|
1732
|
+
};
|
|
1733
|
+
if (!validateProject(project)) {
|
|
1734
|
+
throw new TypeError("A fixture project credential names an invalid project.");
|
|
1735
|
+
}
|
|
1736
|
+
return project;
|
|
1737
|
+
}
|
|
1738
|
+
function createProjectResource(request, accountId, state, options) {
|
|
1739
|
+
const projectId = state.idGenerator("project");
|
|
1740
|
+
const testEnvironmentId = state.idGenerator("environment");
|
|
1741
|
+
const liveEnvironmentId = state.idGenerator("environment");
|
|
1742
|
+
const now = timestamp(options);
|
|
1743
|
+
const project = {
|
|
1744
|
+
account_id: accountId,
|
|
1745
|
+
created_at: now,
|
|
1746
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1747
|
+
environments: [
|
|
1748
|
+
{
|
|
1749
|
+
default_notifier: {
|
|
1750
|
+
display_name: "Default",
|
|
1751
|
+
environment_id: testEnvironmentId,
|
|
1752
|
+
id: state.idGenerator("notifier"),
|
|
1753
|
+
is_default: true,
|
|
1754
|
+
status: "active",
|
|
1755
|
+
},
|
|
1756
|
+
id: testEnvironmentId,
|
|
1757
|
+
kind: "test",
|
|
1758
|
+
project_id: projectId,
|
|
1759
|
+
status: "active",
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
default_notifier: {
|
|
1763
|
+
display_name: "Default",
|
|
1764
|
+
environment_id: liveEnvironmentId,
|
|
1765
|
+
id: state.idGenerator("notifier"),
|
|
1766
|
+
is_default: true,
|
|
1767
|
+
status: "active",
|
|
1768
|
+
},
|
|
1769
|
+
id: liveEnvironmentId,
|
|
1770
|
+
kind: "live",
|
|
1771
|
+
project_id: projectId,
|
|
1772
|
+
status: "active",
|
|
1773
|
+
},
|
|
1774
|
+
],
|
|
1775
|
+
id: projectId,
|
|
1776
|
+
name: request.name,
|
|
1777
|
+
slug: request.slug,
|
|
1778
|
+
status: "active",
|
|
1779
|
+
updated_at: now,
|
|
1780
|
+
};
|
|
1781
|
+
if (!validateProject(project)) {
|
|
1782
|
+
throw new Error("Internal contract-mock project response violation.");
|
|
1783
|
+
}
|
|
1784
|
+
return project;
|
|
1785
|
+
}
|
|
1786
|
+
const machineBearerPattern = /^Bearer (wb_mc1\.(mcred_[A-Za-z0-9_-]{21}[AQgw])\.[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048])$/u;
|
|
1787
|
+
/**
|
|
1788
|
+
* Admits a machine bearer the caller generated locally.
|
|
1789
|
+
*
|
|
1790
|
+
* The service only ever received the secret's digest, so proving the bearer
|
|
1791
|
+
* means hashing it here and comparing. Verified tokens join the in-memory
|
|
1792
|
+
* credential table; revocation still runs through the registered credential
|
|
1793
|
+
* and its machine client, so a revoked credential stops authenticating.
|
|
1794
|
+
*/
|
|
1795
|
+
async function admitRegisteredMachineBearer(request, state) {
|
|
1796
|
+
const match = machineBearerPattern.exec(request.headers.get("authorization") ?? "");
|
|
1797
|
+
const token = match?.[1];
|
|
1798
|
+
const credentialId = match?.[2];
|
|
1799
|
+
if (token === undefined ||
|
|
1800
|
+
credentialId === undefined ||
|
|
1801
|
+
state.credentialsByToken.has(token)) {
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const registered = state.machineCredentials.get(credentialId);
|
|
1805
|
+
const client = registered === undefined
|
|
1806
|
+
? undefined
|
|
1807
|
+
: state.machineClients.get(registered.credential.machine_client_id)
|
|
1808
|
+
?.client;
|
|
1809
|
+
if (registered === undefined || client === undefined) {
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const owner = [...state.credentialsByToken.values()].find((candidate) => (candidate.kind === "project" || candidate.kind === "machine") &&
|
|
1813
|
+
candidate.projectId === client.project_id &&
|
|
1814
|
+
candidate.environment === client.environment);
|
|
1815
|
+
if (owner === undefined) {
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
const digest = /^sha256:[a-f0-9]{64}$/u.exec(registered.secretDigest)?.[0];
|
|
1819
|
+
if (digest === undefined) {
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const verified = await verifyMachineCredentialToken(token, {
|
|
1823
|
+
credentialId,
|
|
1824
|
+
secretSha256: digest,
|
|
1825
|
+
});
|
|
1826
|
+
if (!verified) {
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
state.credentialsByToken.set(token, {
|
|
1830
|
+
accountId: owner.accountId,
|
|
1831
|
+
bindingId: client.binding_id,
|
|
1832
|
+
credentialId,
|
|
1833
|
+
environment: client.environment,
|
|
1834
|
+
kind: "machine",
|
|
1835
|
+
machineClientId: client.id,
|
|
1836
|
+
machineId: client.machine_id,
|
|
1837
|
+
notifierId: client.notifier_id,
|
|
1838
|
+
projectId: client.project_id,
|
|
1839
|
+
scopes: [...MACHINE_SCOPE_SUMMARY],
|
|
1840
|
+
status: "active",
|
|
1841
|
+
subscriberId: client.subscriber_id,
|
|
1842
|
+
token,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
function addPublicRoutes(app, getState, options) {
|
|
1846
|
+
app.use("/v1/*", async (context, next) => {
|
|
1847
|
+
await admitRegisteredMachineBearer(context.req.raw, getState());
|
|
1848
|
+
await next();
|
|
1849
|
+
});
|
|
1850
|
+
/**
|
|
1851
|
+
* Serves the environment-scoped operations to an account-scoped grant.
|
|
1852
|
+
*
|
|
1853
|
+
* The path names the project and environment that a credential would carry
|
|
1854
|
+
* in its own record, so this resolves that pair against the grant's account,
|
|
1855
|
+
* derives the same authority a credential would have produced, and lets the
|
|
1856
|
+
* ordinary handler run. A project outside the account is refused as absent,
|
|
1857
|
+
* exactly as one that does not exist, so a caller learns nothing about who
|
|
1858
|
+
* else has projects.
|
|
1859
|
+
*
|
|
1860
|
+
* Test versus live is read off the environment record and never off the
|
|
1861
|
+
* request: a path may name an environment, but only the project decides
|
|
1862
|
+
* which kind it is.
|
|
1863
|
+
*/
|
|
1864
|
+
app.use("/v1/projects/:projectId/environments/:environmentId/*", async (context, next) => {
|
|
1865
|
+
const state = getState();
|
|
1866
|
+
const presented = presentedGrant(context.req.raw, state);
|
|
1867
|
+
if (presented === undefined) {
|
|
1868
|
+
await next();
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
const url = new URL(context.req.raw.url);
|
|
1872
|
+
const segments = url.pathname.split("/").slice(1);
|
|
1873
|
+
const resource = segments[5];
|
|
1874
|
+
if (resource === undefined || !GRANT_REACHABLE_RESOURCES.has(resource)) {
|
|
1875
|
+
// Credential administration is a human's job; a grant is refused there
|
|
1876
|
+
// by the route's own authority check rather than admitted here.
|
|
1877
|
+
await next();
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const projectId = context.req.param("projectId");
|
|
1881
|
+
const environmentId = context.req.param("environmentId");
|
|
1882
|
+
const project = state.projects.get(projectId);
|
|
1883
|
+
const environment = project?.environments.find((candidate) => candidate.id === environmentId);
|
|
1884
|
+
if (project === undefined ||
|
|
1885
|
+
project.account_id !== presented.accountId ||
|
|
1886
|
+
environment === undefined ||
|
|
1887
|
+
environment.status !== "active") {
|
|
1888
|
+
return notFound(state);
|
|
1889
|
+
}
|
|
1890
|
+
const token = admitGrantProjectAuthority(state, presented, project.id, environment);
|
|
1891
|
+
url.pathname =
|
|
1892
|
+
resource === "customer-endpoints"
|
|
1893
|
+
? url.pathname
|
|
1894
|
+
: `/v1/${segments.slice(5).join("/")}`;
|
|
1895
|
+
const headers = new Headers(context.req.raw.headers);
|
|
1896
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
1897
|
+
return app.fetch(new Request(url, {
|
|
1898
|
+
...(context.req.raw.method === "GET" ||
|
|
1899
|
+
context.req.raw.method === "HEAD"
|
|
1900
|
+
? {}
|
|
1901
|
+
: { body: await context.req.raw.arrayBuffer() }),
|
|
1902
|
+
headers,
|
|
1903
|
+
method: context.req.raw.method,
|
|
1904
|
+
}));
|
|
1905
|
+
});
|
|
1906
|
+
let heldInteractionPolls = 0;
|
|
1907
|
+
const parsePollWait = (request) => {
|
|
1908
|
+
const values = new URL(request.url).searchParams.getAll("wait");
|
|
1909
|
+
if (values.length === 0)
|
|
1910
|
+
return INTERACTION_POLL_DEFAULT_WAIT_SECONDS;
|
|
1911
|
+
if (values.length !== 1 || !/^(?:0|[1-9]\d*)$/u.test(values[0] ?? "")) {
|
|
1912
|
+
return null;
|
|
1913
|
+
}
|
|
1914
|
+
const wait = Number(values[0]);
|
|
1915
|
+
return Number.isSafeInteger(wait) &&
|
|
1916
|
+
wait >= 0 &&
|
|
1917
|
+
wait <= INTERACTION_POLL_MAX_WAIT_SECONDS
|
|
1918
|
+
? wait
|
|
1919
|
+
: null;
|
|
1920
|
+
};
|
|
1921
|
+
const defaultPollWait = (delayMilliseconds, signal) => new Promise((resolve, reject) => {
|
|
1922
|
+
if (signal.aborted) {
|
|
1923
|
+
reject(new DOMException("The request was aborted.", "AbortError"));
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const onAbort = () => {
|
|
1927
|
+
clearTimeout(timeout);
|
|
1928
|
+
reject(new DOMException("The request was aborted.", "AbortError"));
|
|
1929
|
+
};
|
|
1930
|
+
const timeout = setTimeout(() => {
|
|
1931
|
+
signal.removeEventListener("abort", onAbort);
|
|
1932
|
+
resolve();
|
|
1933
|
+
}, delayMilliseconds);
|
|
1934
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1935
|
+
});
|
|
1936
|
+
const waitForInteraction = async (read, waitSeconds, signal) => {
|
|
1937
|
+
let record = read();
|
|
1938
|
+
if (record === undefined ||
|
|
1939
|
+
record.message.interaction === undefined ||
|
|
1940
|
+
record.message.interaction.state !== "open" ||
|
|
1941
|
+
waitSeconds === 0) {
|
|
1942
|
+
return record;
|
|
1943
|
+
}
|
|
1944
|
+
if (heldInteractionPolls >= INTERACTION_POLL_MAX_CONCURRENCY) {
|
|
1945
|
+
return "capacity";
|
|
1946
|
+
}
|
|
1947
|
+
heldInteractionPolls += 1;
|
|
1948
|
+
let remainingMilliseconds = waitSeconds * 1000;
|
|
1949
|
+
try {
|
|
1950
|
+
while (remainingMilliseconds > 0) {
|
|
1951
|
+
const delay = Math.min(INTERACTION_POLL_RECHECK_MILLISECONDS, remainingMilliseconds);
|
|
1952
|
+
await (options.interactionPollWait ?? defaultPollWait)(delay, signal);
|
|
1953
|
+
remainingMilliseconds -= delay;
|
|
1954
|
+
record = read();
|
|
1955
|
+
if (record === undefined ||
|
|
1956
|
+
record.message.interaction === undefined ||
|
|
1957
|
+
record.message.interaction.state !== "open") {
|
|
1958
|
+
return record;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
return read();
|
|
1962
|
+
}
|
|
1963
|
+
finally {
|
|
1964
|
+
heldInteractionPolls -= 1;
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
app.get("/v1/account", (context) => {
|
|
1968
|
+
const state = getState();
|
|
1969
|
+
// The contract requires no scope here: the account projection is who the
|
|
1970
|
+
// authorizing person already is.
|
|
1971
|
+
const identity = requireAccountAuthority(context.req.raw, state);
|
|
1972
|
+
if (isAuthFailure(identity)) {
|
|
1973
|
+
return identity.response;
|
|
1974
|
+
}
|
|
1975
|
+
const record = state.accounts.get(identity.credential.accountId);
|
|
1976
|
+
if (record === undefined) {
|
|
1977
|
+
return problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic")));
|
|
1978
|
+
}
|
|
1979
|
+
const account = {
|
|
1980
|
+
...record.account,
|
|
1981
|
+
// A grant carries no role of its own: it reports the membership the
|
|
1982
|
+
// account already records for the person who authorized it.
|
|
1983
|
+
membership: identity.credential.kind === "human"
|
|
1984
|
+
? { role: identity.credential.role }
|
|
1985
|
+
: record.account.membership,
|
|
1986
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
1987
|
+
};
|
|
1988
|
+
recordDiagnostic(state, identity, "getAccount");
|
|
1989
|
+
return validatedResponse(account, validateAccountProjection, 200, state, account.diagnostic_id);
|
|
1990
|
+
});
|
|
1991
|
+
app.get("/v1/projects", (context) => {
|
|
1992
|
+
const state = getState();
|
|
1993
|
+
const identity = requireAccountAuthority(context.req.raw, state, "projects:read");
|
|
1994
|
+
if (isAuthFailure(identity)) {
|
|
1995
|
+
return identity.response;
|
|
1996
|
+
}
|
|
1997
|
+
const url = new URL(context.req.url);
|
|
1998
|
+
if (url.searchParams.getAll("after").length > 1 ||
|
|
1999
|
+
url.searchParams.getAll("limit").length > 1) {
|
|
2000
|
+
return requestInvalid(state);
|
|
2001
|
+
}
|
|
2002
|
+
const offset = projectPageOffset(url.searchParams.get("after"));
|
|
2003
|
+
const rawLimit = url.searchParams.get("limit");
|
|
2004
|
+
if (offset === undefined ||
|
|
2005
|
+
(rawLimit !== null &&
|
|
2006
|
+
(!/^(?:[1-9]|[1-9]\d|100)$/u.test(rawLimit) ||
|
|
2007
|
+
!Number.isSafeInteger(Number(rawLimit))))) {
|
|
2008
|
+
return requestInvalid(state);
|
|
2009
|
+
}
|
|
2010
|
+
const limit = rawLimit === null ? 50 : Number(rawLimit);
|
|
2011
|
+
const projects = [...state.projects.values()]
|
|
2012
|
+
.filter((project) => project.account_id === identity.credential.accountId)
|
|
2013
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) ||
|
|
2014
|
+
left.id.localeCompare(right.id));
|
|
2015
|
+
if (offset > projects.length) {
|
|
2016
|
+
return requestInvalid(state);
|
|
2017
|
+
}
|
|
2018
|
+
const items = projects.slice(offset, offset + limit);
|
|
2019
|
+
const nextOffset = offset + items.length;
|
|
2020
|
+
const collection = {
|
|
2021
|
+
items,
|
|
2022
|
+
...(nextOffset < projects.length
|
|
2023
|
+
? { next_cursor: `project_cursor_${String(nextOffset)}` }
|
|
2024
|
+
: {}),
|
|
2025
|
+
};
|
|
2026
|
+
recordDiagnostic(state, identity, "listProjects");
|
|
2027
|
+
return validatedResponse(collection, validateProjectCollection, 200, state);
|
|
2028
|
+
});
|
|
2029
|
+
app.post("/v1/projects", async (context) => {
|
|
2030
|
+
const state = getState();
|
|
2031
|
+
const identity = requireAccountAuthority(context.req.raw, state, "projects:write");
|
|
2032
|
+
if (isAuthFailure(identity)) {
|
|
2033
|
+
return identity.response;
|
|
2034
|
+
}
|
|
2035
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2036
|
+
if ("response" in idempotency) {
|
|
2037
|
+
return idempotency.response;
|
|
2038
|
+
}
|
|
2039
|
+
const parsed = await readJson(context.req.raw, validateCreateProjectRequest, state.idGenerator("diagnostic"));
|
|
2040
|
+
if ("response" in parsed) {
|
|
2041
|
+
return parsed.response;
|
|
2042
|
+
}
|
|
2043
|
+
const requestFingerprint = fingerprint(parsed.value);
|
|
2044
|
+
const replay = idempotentReplay(state, identity, "createProject", idempotency.key, requestFingerprint);
|
|
2045
|
+
if (replay !== undefined) {
|
|
2046
|
+
return replay;
|
|
2047
|
+
}
|
|
2048
|
+
const slugExists = [...state.projects.values()].some((project) => project.account_id === identity.credential.accountId &&
|
|
2049
|
+
project.slug === parsed.value.slug);
|
|
2050
|
+
if (slugExists) {
|
|
2051
|
+
return problemResponse(problem("project_slug_conflict", 409, state.idGenerator("diagnostic")));
|
|
2052
|
+
}
|
|
2053
|
+
const project = createProjectResource(parsed.value, identity.credential.accountId, state, options);
|
|
2054
|
+
state.projects.set(project.id, project);
|
|
2055
|
+
const diagnosticId = storeIdempotentResponse(state, identity, "createProject", idempotency.key, requestFingerprint, 201, project, project.id, "absent->active");
|
|
2056
|
+
return validatedResponse(project, validateProject, 201, state, diagnosticId);
|
|
2057
|
+
});
|
|
2058
|
+
app.get("/v1/projects/:projectId", (context) => {
|
|
2059
|
+
const state = getState();
|
|
2060
|
+
const identity = requireAccountAuthority(context.req.raw, state, "projects:read");
|
|
2061
|
+
if (isAuthFailure(identity)) {
|
|
2062
|
+
return identity.response;
|
|
2063
|
+
}
|
|
2064
|
+
const projectId = context.req.param("projectId");
|
|
2065
|
+
if (projectId.length === 0 ||
|
|
2066
|
+
projectId.length > 128 ||
|
|
2067
|
+
containsAsciiControl(projectId)) {
|
|
2068
|
+
return requestInvalid(state);
|
|
2069
|
+
}
|
|
2070
|
+
const project = state.projects.get(projectId);
|
|
2071
|
+
if (project === undefined ||
|
|
2072
|
+
project.account_id !== identity.credential.accountId) {
|
|
2073
|
+
return notFound(state);
|
|
2074
|
+
}
|
|
2075
|
+
recordDiagnostic(state, identity, "getProject", {
|
|
2076
|
+
resourceId: project.id,
|
|
2077
|
+
});
|
|
2078
|
+
return validatedResponse(project, validateProject, 200, state);
|
|
2079
|
+
});
|
|
2080
|
+
app.get("/v1/projects/:projectId/environments/:environmentId/credentials", (context) => {
|
|
2081
|
+
const state = getState();
|
|
2082
|
+
const identity = requireHumanSession(context.req.raw, state);
|
|
2083
|
+
if (isAuthFailure(identity)) {
|
|
2084
|
+
return identity.response;
|
|
2085
|
+
}
|
|
2086
|
+
const projectId = context.req.param("projectId");
|
|
2087
|
+
const environmentId = context.req.param("environmentId");
|
|
2088
|
+
const environment = projectEnvironmentForHuman(state, identity, projectId, environmentId);
|
|
2089
|
+
if (environment === undefined) {
|
|
2090
|
+
return notFound(state);
|
|
2091
|
+
}
|
|
2092
|
+
const url = new URL(context.req.url);
|
|
2093
|
+
if (url.searchParams.getAll("after").length > 1 ||
|
|
2094
|
+
url.searchParams.getAll("limit").length > 1) {
|
|
2095
|
+
return requestInvalid(state);
|
|
2096
|
+
}
|
|
2097
|
+
const offset = credentialPageOffset(url.searchParams.get("after"));
|
|
2098
|
+
const rawLimit = url.searchParams.get("limit");
|
|
2099
|
+
if (offset === undefined ||
|
|
2100
|
+
(rawLimit !== null &&
|
|
2101
|
+
(!/^(?:[1-9]|[1-9]\d|100)$/u.test(rawLimit) ||
|
|
2102
|
+
!Number.isSafeInteger(Number(rawLimit))))) {
|
|
2103
|
+
return requestInvalid(state);
|
|
2104
|
+
}
|
|
2105
|
+
const limit = rawLimit === null ? 50 : Number(rawLimit);
|
|
2106
|
+
const credentials = [...state.apiCredentials.values()]
|
|
2107
|
+
.filter((record) => {
|
|
2108
|
+
refreshApiCredentialStatus(record, state);
|
|
2109
|
+
return (record.credential.project_id === projectId &&
|
|
2110
|
+
record.credential.environment_id === environmentId);
|
|
2111
|
+
})
|
|
2112
|
+
.map(({ credential }) => credential)
|
|
2113
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) ||
|
|
2114
|
+
left.id.localeCompare(right.id));
|
|
2115
|
+
if (offset > credentials.length) {
|
|
2116
|
+
return requestInvalid(state);
|
|
2117
|
+
}
|
|
2118
|
+
const items = credentials.slice(offset, offset + limit);
|
|
2119
|
+
const nextOffset = offset + items.length;
|
|
2120
|
+
const collection = {
|
|
2121
|
+
items,
|
|
2122
|
+
...(nextOffset < credentials.length
|
|
2123
|
+
? { next_cursor: `api_credential_cursor_${String(nextOffset)}` }
|
|
2124
|
+
: {}),
|
|
2125
|
+
};
|
|
2126
|
+
recordDiagnostic(state, identity, "listApiCredentials");
|
|
2127
|
+
return validatedResponse(collection, validateApiCredentialCollection, 200, state);
|
|
2128
|
+
});
|
|
2129
|
+
app.post("/v1/projects/:projectId/environments/:environmentId/credentials", async (context) => {
|
|
2130
|
+
const state = getState();
|
|
2131
|
+
const identity = requireHumanSession(context.req.raw, state);
|
|
2132
|
+
if (isAuthFailure(identity)) {
|
|
2133
|
+
return identity.response;
|
|
2134
|
+
}
|
|
2135
|
+
const projectId = context.req.param("projectId");
|
|
2136
|
+
const environmentId = context.req.param("environmentId");
|
|
2137
|
+
const environment = projectEnvironmentForHuman(state, identity, projectId, environmentId);
|
|
2138
|
+
if (environment === undefined) {
|
|
2139
|
+
return notFound(state);
|
|
2140
|
+
}
|
|
2141
|
+
const parsed = await readJson(context.req.raw, validateCreateApiCredentialRequest, state.idGenerator("diagnostic"));
|
|
2142
|
+
if ("response" in parsed) {
|
|
2143
|
+
return parsed.response;
|
|
2144
|
+
}
|
|
2145
|
+
const reveal = createApiCredentialMaterial(parsed.value, projectId, environment, identity, state);
|
|
2146
|
+
recordDiagnostic(state, identity, "createApiCredential", {
|
|
2147
|
+
resourceId: reveal.credential.id,
|
|
2148
|
+
stateTransition: "absent->active",
|
|
2149
|
+
});
|
|
2150
|
+
return validatedResponse(reveal, validateApiCredentialSecretReveal, 201, state);
|
|
2151
|
+
});
|
|
2152
|
+
app.post("/v1/projects/:projectId/environments/:environmentId/credentials/:credentialId/rotate", async (context) => {
|
|
2153
|
+
const state = getState();
|
|
2154
|
+
const identity = requireHumanSession(context.req.raw, state);
|
|
2155
|
+
if (isAuthFailure(identity)) {
|
|
2156
|
+
return identity.response;
|
|
2157
|
+
}
|
|
2158
|
+
const projectId = context.req.param("projectId");
|
|
2159
|
+
const environmentId = context.req.param("environmentId");
|
|
2160
|
+
const environment = projectEnvironmentForHuman(state, identity, projectId, environmentId);
|
|
2161
|
+
if (environment === undefined) {
|
|
2162
|
+
return notFound(state);
|
|
2163
|
+
}
|
|
2164
|
+
const parsed = await readJson(context.req.raw, validateRotateApiCredentialRequest, state.idGenerator("diagnostic"));
|
|
2165
|
+
if ("response" in parsed) {
|
|
2166
|
+
return parsed.response;
|
|
2167
|
+
}
|
|
2168
|
+
const credentialId = context.req.param("credentialId");
|
|
2169
|
+
const record = state.apiCredentials.get(credentialId);
|
|
2170
|
+
if (record !== undefined) {
|
|
2171
|
+
refreshApiCredentialStatus(record, state);
|
|
2172
|
+
}
|
|
2173
|
+
if (record === undefined ||
|
|
2174
|
+
record.credential.project_id !== projectId ||
|
|
2175
|
+
record.credential.environment_id !== environmentId ||
|
|
2176
|
+
record.credential.status !== "active") {
|
|
2177
|
+
return notFound(state);
|
|
2178
|
+
}
|
|
2179
|
+
const reveal = createApiCredentialMaterial({
|
|
2180
|
+
label: record.credential.label,
|
|
2181
|
+
scopes: record.credential.scopes,
|
|
2182
|
+
}, projectId, environment, identity, state);
|
|
2183
|
+
const now = state.now();
|
|
2184
|
+
const activeMetadata = { ...record.credential };
|
|
2185
|
+
delete activeMetadata.revoked_at;
|
|
2186
|
+
delete activeMetadata.scheduled_revocation_at;
|
|
2187
|
+
record.credential =
|
|
2188
|
+
parsed.value.overlap_seconds === 0
|
|
2189
|
+
? {
|
|
2190
|
+
...activeMetadata,
|
|
2191
|
+
revoked_at: now,
|
|
2192
|
+
status: "revoked",
|
|
2193
|
+
}
|
|
2194
|
+
: {
|
|
2195
|
+
...activeMetadata,
|
|
2196
|
+
scheduled_revocation_at: timestampAfterValue(now, parsed.value.overlap_seconds),
|
|
2197
|
+
status: "active",
|
|
2198
|
+
};
|
|
2199
|
+
const rotation = {
|
|
2200
|
+
credential: reveal,
|
|
2201
|
+
replaced_credential: record.credential,
|
|
2202
|
+
};
|
|
2203
|
+
recordDiagnostic(state, identity, "rotateApiCredential", {
|
|
2204
|
+
resourceId: reveal.credential.id,
|
|
2205
|
+
stateTransition: parsed.value.overlap_seconds === 0
|
|
2206
|
+
? "active->revoked"
|
|
2207
|
+
: "active->revocation_scheduled",
|
|
2208
|
+
});
|
|
2209
|
+
return validatedResponse(rotation, validateApiCredentialRotation, 201, state);
|
|
2210
|
+
});
|
|
2211
|
+
app.post("/v1/projects/:projectId/environments/:environmentId/credentials/:credentialId/revoke", (context) => {
|
|
2212
|
+
const state = getState();
|
|
2213
|
+
const identity = requireHumanSession(context.req.raw, state);
|
|
2214
|
+
if (isAuthFailure(identity)) {
|
|
2215
|
+
return identity.response;
|
|
2216
|
+
}
|
|
2217
|
+
const projectId = context.req.param("projectId");
|
|
2218
|
+
const environmentId = context.req.param("environmentId");
|
|
2219
|
+
if (projectEnvironmentForHuman(state, identity, projectId, environmentId) === undefined) {
|
|
2220
|
+
return notFound(state);
|
|
2221
|
+
}
|
|
2222
|
+
const credentialId = context.req.param("credentialId");
|
|
2223
|
+
const record = state.apiCredentials.get(credentialId);
|
|
2224
|
+
if (record !== undefined) {
|
|
2225
|
+
refreshApiCredentialStatus(record, state);
|
|
2226
|
+
}
|
|
2227
|
+
if (record === undefined ||
|
|
2228
|
+
record.credential.project_id !== projectId ||
|
|
2229
|
+
record.credential.environment_id !== environmentId) {
|
|
2230
|
+
return notFound(state);
|
|
2231
|
+
}
|
|
2232
|
+
const previousStatus = record.credential.status;
|
|
2233
|
+
if (previousStatus !== "revoked") {
|
|
2234
|
+
const metadata = { ...record.credential };
|
|
2235
|
+
delete metadata.scheduled_revocation_at;
|
|
2236
|
+
record.credential = {
|
|
2237
|
+
...metadata,
|
|
2238
|
+
revoked_at: state.now(),
|
|
2239
|
+
status: "revoked",
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
recordDiagnostic(state, identity, "revokeApiCredential", {
|
|
2243
|
+
resourceId: record.credential.id,
|
|
2244
|
+
stateTransition: `${previousStatus}->revoked`,
|
|
2245
|
+
});
|
|
2246
|
+
return validatedResponse(record.credential, validateApiCredential, 200, state);
|
|
2247
|
+
});
|
|
2248
|
+
app.post("/v1/subscription-links", async (context) => {
|
|
2249
|
+
const state = getState();
|
|
2250
|
+
const identity = requireProjectScope(context.req.raw, state, "subscriptions:write");
|
|
2251
|
+
if (isAuthFailure(identity)) {
|
|
2252
|
+
return identity.response;
|
|
2253
|
+
}
|
|
2254
|
+
const rateLimit = scenarioRateLimit(state, options, "createSubscriptionLink");
|
|
2255
|
+
if (rateLimit !== undefined) {
|
|
2256
|
+
return rateLimit;
|
|
2257
|
+
}
|
|
2258
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2259
|
+
if ("response" in idempotency) {
|
|
2260
|
+
return idempotency.response;
|
|
2261
|
+
}
|
|
2262
|
+
const parsed = await readJson(context.req.raw, validateCreateSubscriptionLinkRequest, state.idGenerator("diagnostic"));
|
|
2263
|
+
if ("response" in parsed) {
|
|
2264
|
+
return parsed.response;
|
|
2265
|
+
}
|
|
2266
|
+
const normalized = normalizeLinkRequest(parsed.value);
|
|
2267
|
+
const requestFingerprint = fingerprint(normalized);
|
|
2268
|
+
const replay = idempotentReplay(state, identity, "createSubscriptionLink", idempotency.key, requestFingerprint);
|
|
2269
|
+
if (replay !== undefined) {
|
|
2270
|
+
return replay;
|
|
2271
|
+
}
|
|
2272
|
+
const now = timestamp(options);
|
|
2273
|
+
const link = {
|
|
2274
|
+
authorization_url: `https://subscribe.whooshbang.test/s/${state.idGenerator("subscription_link")}`,
|
|
2275
|
+
channels: normalized.channels,
|
|
2276
|
+
created_at: now,
|
|
2277
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
2278
|
+
environment: identity.credential.environment,
|
|
2279
|
+
expires_at: timestampAfterValue(now, normalized.expires_in_seconds ?? 900),
|
|
2280
|
+
id: state.idGenerator("subscription_link"),
|
|
2281
|
+
notifier_id: normalized.notifier_id ?? "default",
|
|
2282
|
+
project_id: identity.credential.projectId,
|
|
2283
|
+
status: "pending",
|
|
2284
|
+
subscriber_id: normalized.subscriber_id,
|
|
2285
|
+
updated_at: now,
|
|
2286
|
+
};
|
|
2287
|
+
const record = {
|
|
2288
|
+
link,
|
|
2289
|
+
ownerAccountId: identity.credential.accountId,
|
|
2290
|
+
};
|
|
2291
|
+
state.links.set(link.id, record);
|
|
2292
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "createSubscriptionLink", idempotency.key, requestFingerprint, 201, link, link.id, "none->pending");
|
|
2293
|
+
const response = validatedResponse(link, validateSubscriptionLink, 201, state, responseDiagnosticId);
|
|
2294
|
+
if (getScenarioDefinition(state.scenario).activateLinks) {
|
|
2295
|
+
await activateLink(record, state, options, identity);
|
|
2296
|
+
}
|
|
2297
|
+
return response;
|
|
2298
|
+
});
|
|
2299
|
+
app.get("/v1/subscription-links/:subscriptionLinkId", (context) => {
|
|
2300
|
+
const state = getState();
|
|
2301
|
+
const identity = requireProjectScope(context.req.raw, state, "subscriptions:read");
|
|
2302
|
+
if (isAuthFailure(identity)) {
|
|
2303
|
+
return identity.response;
|
|
2304
|
+
}
|
|
2305
|
+
const record = state.links.get(context.req.param("subscriptionLinkId"));
|
|
2306
|
+
if (record === undefined ||
|
|
2307
|
+
!ownerMatches(identity.credential, record.link)) {
|
|
2308
|
+
return notFound(state);
|
|
2309
|
+
}
|
|
2310
|
+
recordDiagnostic(state, identity, "getSubscriptionLink", {
|
|
2311
|
+
resourceId: record.link.id,
|
|
2312
|
+
});
|
|
2313
|
+
return validatedResponse(record.link, validateSubscriptionLink, 200, state);
|
|
2314
|
+
});
|
|
2315
|
+
app.post("/v1/subscription-links/:subscriptionLinkId/cancel", (context) => {
|
|
2316
|
+
const state = getState();
|
|
2317
|
+
const identity = requireProjectScope(context.req.raw, state, "subscriptions:write");
|
|
2318
|
+
if (isAuthFailure(identity)) {
|
|
2319
|
+
return identity.response;
|
|
2320
|
+
}
|
|
2321
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2322
|
+
if ("response" in idempotency) {
|
|
2323
|
+
return idempotency.response;
|
|
2324
|
+
}
|
|
2325
|
+
const linkId = context.req.param("subscriptionLinkId");
|
|
2326
|
+
const requestFingerprint = fingerprint({ linkId });
|
|
2327
|
+
const replay = idempotentReplay(state, identity, "cancelSubscriptionLink", idempotency.key, requestFingerprint);
|
|
2328
|
+
if (replay !== undefined) {
|
|
2329
|
+
return replay;
|
|
2330
|
+
}
|
|
2331
|
+
const record = state.links.get(linkId);
|
|
2332
|
+
if (record === undefined ||
|
|
2333
|
+
!ownerMatches(identity.credential, record.link)) {
|
|
2334
|
+
return notFound(state);
|
|
2335
|
+
}
|
|
2336
|
+
if (record.link.status !== "pending") {
|
|
2337
|
+
return problemResponse(problem("cancellation_too_late", 409, state.idGenerator("diagnostic")));
|
|
2338
|
+
}
|
|
2339
|
+
record.link = {
|
|
2340
|
+
...record.link,
|
|
2341
|
+
cancellation_effective: true,
|
|
2342
|
+
status: "cancelled",
|
|
2343
|
+
updated_at: timestamp(options),
|
|
2344
|
+
};
|
|
2345
|
+
delete record.link.authorization_url;
|
|
2346
|
+
storeIdempotentResponse(state, identity, "cancelSubscriptionLink", idempotency.key, requestFingerprint, 200, record.link, record.link.id, "pending->cancelled");
|
|
2347
|
+
return validatedResponse(record.link, validateSubscriptionLink, 200, state);
|
|
2348
|
+
});
|
|
2349
|
+
app.post("/v1/messages", async (context) => {
|
|
2350
|
+
const state = getState();
|
|
2351
|
+
const identity = requireProductCredential(context.req.raw, state);
|
|
2352
|
+
if (isAuthFailure(identity)) {
|
|
2353
|
+
return identity.response;
|
|
2354
|
+
}
|
|
2355
|
+
const credential = identity.credential;
|
|
2356
|
+
const correctScope = credential.kind === "project"
|
|
2357
|
+
? credential.scopes.includes("messages:write")
|
|
2358
|
+
: credential.scopes.includes("machine-messages:write");
|
|
2359
|
+
if (!correctScope) {
|
|
2360
|
+
return problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic")));
|
|
2361
|
+
}
|
|
2362
|
+
const rateLimit = scenarioRateLimit(state, options, "createMessage");
|
|
2363
|
+
if (rateLimit !== undefined) {
|
|
2364
|
+
return rateLimit;
|
|
2365
|
+
}
|
|
2366
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2367
|
+
if ("response" in idempotency) {
|
|
2368
|
+
return idempotency.response;
|
|
2369
|
+
}
|
|
2370
|
+
const parsed = await readJson(context.req.raw, validateCreateMessageRequest, state.idGenerator("diagnostic"));
|
|
2371
|
+
if ("response" in parsed) {
|
|
2372
|
+
return parsed.response;
|
|
2373
|
+
}
|
|
2374
|
+
const normalized = normalizeMessageRequest(parsed.value);
|
|
2375
|
+
if (credential.kind === "machine" &&
|
|
2376
|
+
(normalized.to.subscriber_id !== credential.subscriberId ||
|
|
2377
|
+
normalized.notifier_id !== credential.notifierId)) {
|
|
2378
|
+
return notFound(state);
|
|
2379
|
+
}
|
|
2380
|
+
const requestFingerprint = fingerprint(normalized);
|
|
2381
|
+
const replay = idempotentReplay(state, identity, "createMessage", idempotency.key, requestFingerprint);
|
|
2382
|
+
if (replay !== undefined) {
|
|
2383
|
+
return replay;
|
|
2384
|
+
}
|
|
2385
|
+
const now = timestamp(options);
|
|
2386
|
+
if (!messageTemporalInputIsValid(normalized, now)) {
|
|
2387
|
+
return requestInvalid(state);
|
|
2388
|
+
}
|
|
2389
|
+
const matchingBinding = state.bindings.get(bindingKey(credential.projectId, credential.environment, normalized.to.subscriber_id, normalized.notifier_id ?? "default"))?.binding;
|
|
2390
|
+
if (matchingBinding === undefined) {
|
|
2391
|
+
if (hasOtherEnvironmentBinding(state, credential, normalized.to.subscriber_id, normalized.notifier_id ?? "default")) {
|
|
2392
|
+
return problemResponse(problem("environment_mismatch", 403, state.idGenerator("diagnostic")));
|
|
2393
|
+
}
|
|
2394
|
+
return problemResponse(problem("subscriber_unbound", 422, state.idGenerator("diagnostic")));
|
|
2395
|
+
}
|
|
2396
|
+
const interaction = normalized.interaction === undefined
|
|
2397
|
+
? undefined
|
|
2398
|
+
: createInteractionSummary(normalized.interaction, state.idGenerator("interaction"), state.scenario, now);
|
|
2399
|
+
const message = {
|
|
2400
|
+
accepted_at: now,
|
|
2401
|
+
binding: matchingBinding,
|
|
2402
|
+
...(normalized.correlation_id === undefined
|
|
2403
|
+
? {}
|
|
2404
|
+
: { correlation_id: normalized.correlation_id }),
|
|
2405
|
+
delivery: {
|
|
2406
|
+
attempt_count: 0,
|
|
2407
|
+
highest_proven_provider_state: "none",
|
|
2408
|
+
retry_scheduled: false,
|
|
2409
|
+
},
|
|
2410
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
2411
|
+
environment: credential.environment,
|
|
2412
|
+
expires_at: normalized.expires_at ?? timestampAfterValue(now, 86_400),
|
|
2413
|
+
id: state.idGenerator("message"),
|
|
2414
|
+
...(interaction === undefined ? {} : { interaction }),
|
|
2415
|
+
...(normalized.metadata === undefined
|
|
2416
|
+
? {}
|
|
2417
|
+
: { metadata: normalized.metadata }),
|
|
2418
|
+
notifier_id: normalized.notifier_id ?? "default",
|
|
2419
|
+
project_id: credential.projectId,
|
|
2420
|
+
state: "accepted",
|
|
2421
|
+
subscriber_id: normalized.to.subscriber_id,
|
|
2422
|
+
updated_at: now,
|
|
2423
|
+
};
|
|
2424
|
+
if (!validateMessage(message)) {
|
|
2425
|
+
throw new Error("Internal contract-mock message violation.");
|
|
2426
|
+
}
|
|
2427
|
+
const record = {
|
|
2428
|
+
...(normalized.interaction === undefined
|
|
2429
|
+
? {}
|
|
2430
|
+
: { interactionRequest: normalized.interaction }),
|
|
2431
|
+
...(credential.kind === "machine"
|
|
2432
|
+
? { machineClientId: credential.machineClientId }
|
|
2433
|
+
: {}),
|
|
2434
|
+
message,
|
|
2435
|
+
ownerAccountId: credential.accountId,
|
|
2436
|
+
providerInFlight: false,
|
|
2437
|
+
providerProcessed: false,
|
|
2438
|
+
requestId: identity.requestId,
|
|
2439
|
+
};
|
|
2440
|
+
state.messages.set(message.id, record);
|
|
2441
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "createMessage", idempotency.key, requestFingerprint, 202, message, message.id, interaction === undefined || interaction.state === "open"
|
|
2442
|
+
? "none->accepted"
|
|
2443
|
+
: `none->accepted;interaction:none->${interaction.state}`);
|
|
2444
|
+
const response = validatedResponse(message, validateMessage, 202, state, responseDiagnosticId);
|
|
2445
|
+
await processMessage(record, state, options);
|
|
2446
|
+
return response;
|
|
2447
|
+
});
|
|
2448
|
+
app.get("/v1/messages/:messageId", async (context) => {
|
|
2449
|
+
const state = getState();
|
|
2450
|
+
const identity = requireProductCredential(context.req.raw, state);
|
|
2451
|
+
if (isAuthFailure(identity)) {
|
|
2452
|
+
return identity.response;
|
|
2453
|
+
}
|
|
2454
|
+
const credential = identity.credential;
|
|
2455
|
+
const correctScope = credential.kind === "project"
|
|
2456
|
+
? credential.scopes.includes("messages:read")
|
|
2457
|
+
: credential.scopes.includes("machine-messages:read");
|
|
2458
|
+
if (!correctScope) {
|
|
2459
|
+
return problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic")));
|
|
2460
|
+
}
|
|
2461
|
+
const wait = parsePollWait(context.req.raw);
|
|
2462
|
+
if (wait === null) {
|
|
2463
|
+
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic")));
|
|
2464
|
+
}
|
|
2465
|
+
const read = () => {
|
|
2466
|
+
const current = getState().messages.get(context.req.param("messageId"));
|
|
2467
|
+
return current !== undefined &&
|
|
2468
|
+
ownerMatches(credential, current.message) &&
|
|
2469
|
+
(credential.kind !== "machine" ||
|
|
2470
|
+
current.machineClientId === credential.machineClientId)
|
|
2471
|
+
? current
|
|
2472
|
+
: undefined;
|
|
2473
|
+
};
|
|
2474
|
+
const initial = read();
|
|
2475
|
+
if (initial === undefined) {
|
|
2476
|
+
return notFound(state);
|
|
2477
|
+
}
|
|
2478
|
+
const record = await waitForInteraction(read, wait, context.req.raw.signal);
|
|
2479
|
+
if (record === "capacity")
|
|
2480
|
+
return rateLimitedResponse(state, options);
|
|
2481
|
+
if (record === undefined)
|
|
2482
|
+
return notFound(getState());
|
|
2483
|
+
recordDiagnostic(state, identity, "getMessage", {
|
|
2484
|
+
resourceId: record.message.id,
|
|
2485
|
+
});
|
|
2486
|
+
return validatedResponse(record.message, validateMessage, 200, state);
|
|
2487
|
+
});
|
|
2488
|
+
app.get("/v1/interactions/:interactionId", async (context) => {
|
|
2489
|
+
const state = getState();
|
|
2490
|
+
const identity = requireProductCredential(context.req.raw, state);
|
|
2491
|
+
if (isAuthFailure(identity))
|
|
2492
|
+
return identity.response;
|
|
2493
|
+
const credential = identity.credential;
|
|
2494
|
+
const correctScope = credential.kind === "project"
|
|
2495
|
+
? credential.scopes.includes("messages:read")
|
|
2496
|
+
: credential.scopes.includes("machine-messages:read");
|
|
2497
|
+
if (!correctScope) {
|
|
2498
|
+
return problemResponse(problem("scope_forbidden", 403, state.idGenerator("diagnostic")));
|
|
2499
|
+
}
|
|
2500
|
+
const wait = parsePollWait(context.req.raw);
|
|
2501
|
+
if (wait === null) {
|
|
2502
|
+
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic")));
|
|
2503
|
+
}
|
|
2504
|
+
const read = () => {
|
|
2505
|
+
for (const candidate of getState().messages.values()) {
|
|
2506
|
+
if (candidate.message.interaction?.id ===
|
|
2507
|
+
context.req.param("interactionId") &&
|
|
2508
|
+
ownerMatches(credential, candidate.message) &&
|
|
2509
|
+
(credential.kind !== "machine" ||
|
|
2510
|
+
candidate.machineClientId === credential.machineClientId)) {
|
|
2511
|
+
return candidate;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
return undefined;
|
|
2515
|
+
};
|
|
2516
|
+
if (read() === undefined)
|
|
2517
|
+
return notFound(state);
|
|
2518
|
+
const record = await waitForInteraction(read, wait, context.req.raw.signal);
|
|
2519
|
+
if (record === "capacity")
|
|
2520
|
+
return rateLimitedResponse(state, options);
|
|
2521
|
+
const interaction = record?.message.interaction;
|
|
2522
|
+
if (interaction === undefined)
|
|
2523
|
+
return notFound(getState());
|
|
2524
|
+
recordDiagnostic(getState(), identity, "getInteraction", {
|
|
2525
|
+
resourceId: interaction.id,
|
|
2526
|
+
});
|
|
2527
|
+
return validatedResponse(interaction, validateInteractionSummary, 200, getState());
|
|
2528
|
+
});
|
|
2529
|
+
app.post("/v1/messages/:messageId/cancel", (context) => {
|
|
2530
|
+
const state = getState();
|
|
2531
|
+
const identity = requireProjectScope(context.req.raw, state, "messages:write");
|
|
2532
|
+
if (isAuthFailure(identity)) {
|
|
2533
|
+
return identity.response;
|
|
2534
|
+
}
|
|
2535
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2536
|
+
if ("response" in idempotency) {
|
|
2537
|
+
return idempotency.response;
|
|
2538
|
+
}
|
|
2539
|
+
const messageId = context.req.param("messageId");
|
|
2540
|
+
const requestFingerprint = fingerprint({ messageId });
|
|
2541
|
+
const replay = idempotentReplay(state, identity, "cancelMessage", idempotency.key, requestFingerprint);
|
|
2542
|
+
if (replay !== undefined) {
|
|
2543
|
+
return replay;
|
|
2544
|
+
}
|
|
2545
|
+
const record = state.messages.get(messageId);
|
|
2546
|
+
if (record === undefined ||
|
|
2547
|
+
!ownerMatches(identity.credential, record.message)) {
|
|
2548
|
+
return notFound(state);
|
|
2549
|
+
}
|
|
2550
|
+
const previousState = record.message.state;
|
|
2551
|
+
const effective = [
|
|
2552
|
+
"accepted",
|
|
2553
|
+
"dispatch_pending",
|
|
2554
|
+
"queued",
|
|
2555
|
+
"retry_wait",
|
|
2556
|
+
].includes(record.message.state);
|
|
2557
|
+
record.message = {
|
|
2558
|
+
...record.message,
|
|
2559
|
+
cancellation_effective: effective,
|
|
2560
|
+
...(effective
|
|
2561
|
+
? { state: "cancelled", updated_at: timestamp(options) }
|
|
2562
|
+
: {}),
|
|
2563
|
+
};
|
|
2564
|
+
if (effective && record.message.interaction?.state === "open") {
|
|
2565
|
+
record.message.interaction = {
|
|
2566
|
+
...record.message.interaction,
|
|
2567
|
+
state: "cancelled",
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
storeIdempotentResponse(state, identity, "cancelMessage", idempotency.key, requestFingerprint, 200, record.message, record.message.id, `${previousState}->${record.message.state}`);
|
|
2571
|
+
return validatedResponse(record.message, validateMessage, 200, state);
|
|
2572
|
+
});
|
|
2573
|
+
app.post("/v1/machine-clients", async (context) => {
|
|
2574
|
+
const state = getState();
|
|
2575
|
+
const identity = requireProjectScope(context.req.raw, state, "machine-clients:write");
|
|
2576
|
+
if (isAuthFailure(identity)) {
|
|
2577
|
+
return identity.response;
|
|
2578
|
+
}
|
|
2579
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2580
|
+
if ("response" in idempotency) {
|
|
2581
|
+
return idempotency.response;
|
|
2582
|
+
}
|
|
2583
|
+
const parsed = await readJson(context.req.raw, validateCreateMachineClientRequest, state.idGenerator("diagnostic"));
|
|
2584
|
+
if ("response" in parsed) {
|
|
2585
|
+
return parsed.response;
|
|
2586
|
+
}
|
|
2587
|
+
const request = parsed.value;
|
|
2588
|
+
const requestFingerprint = fingerprint(request);
|
|
2589
|
+
const replay = idempotentReplay(state, identity, "createMachineClient", idempotency.key, requestFingerprint);
|
|
2590
|
+
if (replay !== undefined) {
|
|
2591
|
+
return replay;
|
|
2592
|
+
}
|
|
2593
|
+
const binding = state.bindings.get(bindingKey(identity.credential.projectId, identity.credential.environment, request.subscriber_id, request.notifier_id))?.binding;
|
|
2594
|
+
if (binding === undefined) {
|
|
2595
|
+
if (hasOtherEnvironmentBinding(state, identity.credential, request.subscriber_id, request.notifier_id)) {
|
|
2596
|
+
return problemResponse(problem("environment_mismatch", 403, state.idGenerator("diagnostic")));
|
|
2597
|
+
}
|
|
2598
|
+
return problemResponse(problem("subscriber_unbound", 422, state.idGenerator("diagnostic")));
|
|
2599
|
+
}
|
|
2600
|
+
const now = timestamp(options);
|
|
2601
|
+
const client = {
|
|
2602
|
+
binding_id: binding.id,
|
|
2603
|
+
created_at: now,
|
|
2604
|
+
...(request.display_name === undefined
|
|
2605
|
+
? {}
|
|
2606
|
+
: { display_name: request.display_name }),
|
|
2607
|
+
environment: identity.credential.environment,
|
|
2608
|
+
id: state.idGenerator("machine_client"),
|
|
2609
|
+
machine_id: request.machine_id,
|
|
2610
|
+
notifier_id: request.notifier_id,
|
|
2611
|
+
project_id: identity.credential.projectId,
|
|
2612
|
+
scope_summary: [...MACHINE_SCOPE_SUMMARY],
|
|
2613
|
+
status: "provisioning",
|
|
2614
|
+
subscriber_id: request.subscriber_id,
|
|
2615
|
+
updated_at: now,
|
|
2616
|
+
};
|
|
2617
|
+
state.machineClients.set(client.id, { client });
|
|
2618
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "createMachineClient", idempotency.key, requestFingerprint, 201, client, client.id, "none->provisioning");
|
|
2619
|
+
return validatedResponse(client, validateMachineClient, 201, state, responseDiagnosticId);
|
|
2620
|
+
});
|
|
2621
|
+
app.get("/v1/machine-clients/:machineClientId", (context) => {
|
|
2622
|
+
const state = getState();
|
|
2623
|
+
const identity = requireProjectScope(context.req.raw, state, "machine-clients:read");
|
|
2624
|
+
if (isAuthFailure(identity)) {
|
|
2625
|
+
return identity.response;
|
|
2626
|
+
}
|
|
2627
|
+
const record = state.machineClients.get(context.req.param("machineClientId"));
|
|
2628
|
+
if (record === undefined ||
|
|
2629
|
+
!ownerMatches(identity.credential, record.client)) {
|
|
2630
|
+
return notFound(state);
|
|
2631
|
+
}
|
|
2632
|
+
recordDiagnostic(state, identity, "getMachineClient", {
|
|
2633
|
+
resourceId: record.client.id,
|
|
2634
|
+
});
|
|
2635
|
+
return validatedResponse(record.client, validateMachineClient, 200, state);
|
|
2636
|
+
});
|
|
2637
|
+
app.post("/v1/machine-clients/:machineClientId/credentials", async (context) => {
|
|
2638
|
+
const state = getState();
|
|
2639
|
+
const identity = requireProjectScope(context.req.raw, state, "machine-clients:write");
|
|
2640
|
+
if (isAuthFailure(identity)) {
|
|
2641
|
+
return identity.response;
|
|
2642
|
+
}
|
|
2643
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2644
|
+
if ("response" in idempotency) {
|
|
2645
|
+
return idempotency.response;
|
|
2646
|
+
}
|
|
2647
|
+
const parsed = await readJson(context.req.raw, validateRegisterMachineCredentialRequest, state.idGenerator("diagnostic"));
|
|
2648
|
+
if ("response" in parsed) {
|
|
2649
|
+
return parsed.response;
|
|
2650
|
+
}
|
|
2651
|
+
const request = parsed.value;
|
|
2652
|
+
const machineClientId = context.req.param("machineClientId");
|
|
2653
|
+
const requestFingerprint = fingerprint({ machineClientId, request });
|
|
2654
|
+
const replay = idempotentReplay(state, identity, "registerMachineCredential", idempotency.key, requestFingerprint);
|
|
2655
|
+
if (replay !== undefined) {
|
|
2656
|
+
return replay;
|
|
2657
|
+
}
|
|
2658
|
+
const clientRecord = state.machineClients.get(machineClientId);
|
|
2659
|
+
if (clientRecord === undefined ||
|
|
2660
|
+
!ownerMatches(identity.credential, clientRecord.client)) {
|
|
2661
|
+
return notFound(state);
|
|
2662
|
+
}
|
|
2663
|
+
const existing = state.machineCredentials.get(request.credential_id);
|
|
2664
|
+
if (existing !== undefined) {
|
|
2665
|
+
if (existing.secretDigest !== request.secret_sha256 ||
|
|
2666
|
+
existing.credential.machine_client_id !== machineClientId) {
|
|
2667
|
+
return problemResponse(problem("idempotency_conflict", 409, state.idGenerator("diagnostic")));
|
|
2668
|
+
}
|
|
2669
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "registerMachineCredential", idempotency.key, requestFingerprint, 200, existing.credential, request.credential_id, "credential:active->active");
|
|
2670
|
+
return validatedResponse(existing.credential, validateMachineCredential, 200, state, responseDiagnosticId);
|
|
2671
|
+
}
|
|
2672
|
+
const now = timestamp(options);
|
|
2673
|
+
const credential = {
|
|
2674
|
+
created_at: now,
|
|
2675
|
+
credential_id: request.credential_id,
|
|
2676
|
+
machine_client_id: machineClientId,
|
|
2677
|
+
scope_summary: [...MACHINE_SCOPE_SUMMARY],
|
|
2678
|
+
status: "active",
|
|
2679
|
+
};
|
|
2680
|
+
state.machineCredentials.set(request.credential_id, {
|
|
2681
|
+
credential,
|
|
2682
|
+
secretDigest: request.secret_sha256,
|
|
2683
|
+
});
|
|
2684
|
+
if (clientRecord.client.status === "provisioning") {
|
|
2685
|
+
clientRecord.client = {
|
|
2686
|
+
...clientRecord.client,
|
|
2687
|
+
status: "active",
|
|
2688
|
+
updated_at: now,
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "registerMachineCredential", idempotency.key, requestFingerprint, 201, credential, request.credential_id, "credential:none->active");
|
|
2692
|
+
return validatedResponse(credential, validateMachineCredential, 201, state, responseDiagnosticId);
|
|
2693
|
+
});
|
|
2694
|
+
app.post("/v1/machine-clients/:machineClientId/credentials/:credentialId/revoke", (context) => {
|
|
2695
|
+
const state = getState();
|
|
2696
|
+
const identity = requireProjectScope(context.req.raw, state, "machine-clients:write");
|
|
2697
|
+
if (isAuthFailure(identity)) {
|
|
2698
|
+
return identity.response;
|
|
2699
|
+
}
|
|
2700
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2701
|
+
if ("response" in idempotency) {
|
|
2702
|
+
return idempotency.response;
|
|
2703
|
+
}
|
|
2704
|
+
const machineClientId = context.req.param("machineClientId");
|
|
2705
|
+
const credentialId = context.req.param("credentialId");
|
|
2706
|
+
const requestFingerprint = fingerprint({ credentialId, machineClientId });
|
|
2707
|
+
const replay = idempotentReplay(state, identity, "revokeMachineCredential", idempotency.key, requestFingerprint);
|
|
2708
|
+
if (replay !== undefined) {
|
|
2709
|
+
return replay;
|
|
2710
|
+
}
|
|
2711
|
+
const clientRecord = state.machineClients.get(machineClientId);
|
|
2712
|
+
const credentialRecord = state.machineCredentials.get(credentialId);
|
|
2713
|
+
if (clientRecord === undefined ||
|
|
2714
|
+
credentialRecord === undefined ||
|
|
2715
|
+
credentialRecord.credential.machine_client_id !== machineClientId ||
|
|
2716
|
+
!ownerMatches(identity.credential, clientRecord.client)) {
|
|
2717
|
+
return notFound(state);
|
|
2718
|
+
}
|
|
2719
|
+
const previousStatus = credentialRecord.credential.status;
|
|
2720
|
+
if (credentialRecord.credential.status !== "revoked") {
|
|
2721
|
+
credentialRecord.credential = {
|
|
2722
|
+
...credentialRecord.credential,
|
|
2723
|
+
revoked_at: timestamp(options),
|
|
2724
|
+
status: "revoked",
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "revokeMachineCredential", idempotency.key, requestFingerprint, 200, credentialRecord.credential, credentialId, `credential:${previousStatus}->${credentialRecord.credential.status}`);
|
|
2728
|
+
return validatedResponse(credentialRecord.credential, validateMachineCredential, 200, state, responseDiagnosticId);
|
|
2729
|
+
});
|
|
2730
|
+
app.post("/v1/machine-clients/:machineClientId/revoke", (context) => {
|
|
2731
|
+
const state = getState();
|
|
2732
|
+
const identity = requireProjectScope(context.req.raw, state, "machine-clients:write");
|
|
2733
|
+
if (isAuthFailure(identity)) {
|
|
2734
|
+
return identity.response;
|
|
2735
|
+
}
|
|
2736
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2737
|
+
if ("response" in idempotency) {
|
|
2738
|
+
return idempotency.response;
|
|
2739
|
+
}
|
|
2740
|
+
const machineClientId = context.req.param("machineClientId");
|
|
2741
|
+
const requestFingerprint = fingerprint({ machineClientId });
|
|
2742
|
+
const replay = idempotentReplay(state, identity, "revokeMachineClient", idempotency.key, requestFingerprint);
|
|
2743
|
+
if (replay !== undefined) {
|
|
2744
|
+
return replay;
|
|
2745
|
+
}
|
|
2746
|
+
const record = state.machineClients.get(machineClientId);
|
|
2747
|
+
if (record === undefined ||
|
|
2748
|
+
!ownerMatches(identity.credential, record.client)) {
|
|
2749
|
+
return notFound(state);
|
|
2750
|
+
}
|
|
2751
|
+
const previousStatus = record.client.status;
|
|
2752
|
+
if (record.client.status !== "revoked") {
|
|
2753
|
+
const now = timestamp(options);
|
|
2754
|
+
record.client = {
|
|
2755
|
+
...record.client,
|
|
2756
|
+
revoked_at: now,
|
|
2757
|
+
status: "revoked",
|
|
2758
|
+
updated_at: now,
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "revokeMachineClient", idempotency.key, requestFingerprint, 200, record.client, machineClientId, `${previousStatus}->${record.client.status}`);
|
|
2762
|
+
return validatedResponse(record.client, validateMachineClient, 200, state, responseDiagnosticId);
|
|
2763
|
+
});
|
|
2764
|
+
app.get("/v1/machine-events", (context) => {
|
|
2765
|
+
const state = getState();
|
|
2766
|
+
const identity = requireMachineScope(context.req.raw, state, "machine-events:read");
|
|
2767
|
+
if (isAuthFailure(identity)) {
|
|
2768
|
+
return identity.response;
|
|
2769
|
+
}
|
|
2770
|
+
const wait = context.req.query("wait");
|
|
2771
|
+
const limit = context.req.query("limit");
|
|
2772
|
+
if ((wait !== undefined &&
|
|
2773
|
+
(!/^\d+$/u.test(wait) || Number(wait) < 0 || Number(wait) > 30)) ||
|
|
2774
|
+
(limit !== undefined &&
|
|
2775
|
+
(!/^\d+$/u.test(limit) || Number(limit) < 1 || Number(limit) > 50))) {
|
|
2776
|
+
return requestInvalid(state);
|
|
2777
|
+
}
|
|
2778
|
+
const streamEvents = state.machineEvents.filter((record) => record.machineClientId === identity.credential.machineClientId);
|
|
2779
|
+
let committedCursor = null;
|
|
2780
|
+
for (const record of streamEvents) {
|
|
2781
|
+
if (state.acknowledgements.has(acknowledgementKey(identity.credential.machineClientId, record.event.id))) {
|
|
2782
|
+
committedCursor = record.event.cursor;
|
|
2783
|
+
}
|
|
2784
|
+
else {
|
|
2785
|
+
break;
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
const after = context.req.query("after");
|
|
2789
|
+
if (after !== undefined &&
|
|
2790
|
+
(after.length < 8 ||
|
|
2791
|
+
after.length > 128 ||
|
|
2792
|
+
!/^mcur_[A-Za-z0-9_-]+$/u.test(after))) {
|
|
2793
|
+
return requestInvalid(state);
|
|
2794
|
+
}
|
|
2795
|
+
if ((committedCursor === null && after !== undefined) ||
|
|
2796
|
+
(committedCursor !== null && after !== committedCursor)) {
|
|
2797
|
+
return requestInvalid(state);
|
|
2798
|
+
}
|
|
2799
|
+
const start = committedCursor === null
|
|
2800
|
+
? 0
|
|
2801
|
+
: streamEvents.findIndex((record) => record.event.cursor === committedCursor) + 1;
|
|
2802
|
+
const take = limit === undefined ? 25 : Number(limit);
|
|
2803
|
+
const response = {
|
|
2804
|
+
committed_cursor: committedCursor,
|
|
2805
|
+
events: streamEvents
|
|
2806
|
+
.slice(start)
|
|
2807
|
+
.filter((record) => !state.acknowledgements.has(acknowledgementKey(identity.credential.machineClientId, record.event.id)))
|
|
2808
|
+
.slice(0, take)
|
|
2809
|
+
.map((record) => record.event),
|
|
2810
|
+
schema: "whooshbang.machine-events.v1",
|
|
2811
|
+
server_time: timestamp(options),
|
|
2812
|
+
};
|
|
2813
|
+
recordDiagnostic(state, identity, "pollMachineEvents");
|
|
2814
|
+
return validatedResponse(response, validateMachineEventPollResponse, 200, state);
|
|
2815
|
+
});
|
|
2816
|
+
app.post("/v1/machine-events/:eventId/ack", async (context) => {
|
|
2817
|
+
const state = getState();
|
|
2818
|
+
const identity = requireMachineScope(context.req.raw, state, "machine-events:ack");
|
|
2819
|
+
if (isAuthFailure(identity)) {
|
|
2820
|
+
return identity.response;
|
|
2821
|
+
}
|
|
2822
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
2823
|
+
if ("response" in idempotency) {
|
|
2824
|
+
return idempotency.response;
|
|
2825
|
+
}
|
|
2826
|
+
const parsed = await readJson(context.req.raw, validateMachineEventAckRequest, state.idGenerator("diagnostic"));
|
|
2827
|
+
if ("response" in parsed) {
|
|
2828
|
+
return parsed.response;
|
|
2829
|
+
}
|
|
2830
|
+
const request = parsed.value;
|
|
2831
|
+
const normalizedReasonCode = request.disposition === "processed"
|
|
2832
|
+
? undefined
|
|
2833
|
+
: (request.reason_code ?? undefined);
|
|
2834
|
+
const normalizedRequest = request.disposition === "processed"
|
|
2835
|
+
? {
|
|
2836
|
+
cursor: request.cursor,
|
|
2837
|
+
disposition: request.disposition,
|
|
2838
|
+
reason_code: null,
|
|
2839
|
+
}
|
|
2840
|
+
: request;
|
|
2841
|
+
const eventId = context.req.param("eventId");
|
|
2842
|
+
const requestFingerprint = fingerprint({
|
|
2843
|
+
eventId,
|
|
2844
|
+
request: normalizedRequest,
|
|
2845
|
+
});
|
|
2846
|
+
const storedIdempotency = state.idempotency.get(idempotencyKey(identity, "ackMachineEvent", idempotency.key));
|
|
2847
|
+
if (storedIdempotency !== undefined &&
|
|
2848
|
+
storedIdempotency.fingerprint !== requestFingerprint) {
|
|
2849
|
+
return idempotentReplay(state, identity, "ackMachineEvent", idempotency.key, requestFingerprint);
|
|
2850
|
+
}
|
|
2851
|
+
const streamEvents = state.machineEvents.filter((record) => record.machineClientId === identity.credential.machineClientId);
|
|
2852
|
+
const eventRecord = streamEvents.find((record) => record.event.id === eventId);
|
|
2853
|
+
if (eventRecord === undefined) {
|
|
2854
|
+
return notFound(state);
|
|
2855
|
+
}
|
|
2856
|
+
if (request.cursor !== eventRecord.event.cursor) {
|
|
2857
|
+
return problemResponse(problem("idempotency_conflict", 409, state.idGenerator("diagnostic")));
|
|
2858
|
+
}
|
|
2859
|
+
if (request.disposition === "quarantined" &&
|
|
2860
|
+
(eventRecord.streamIdentityValid === false ||
|
|
2861
|
+
eventRecord.signatureValid === false ||
|
|
2862
|
+
eventRecord.quarantinable !== true)) {
|
|
2863
|
+
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic")));
|
|
2864
|
+
}
|
|
2865
|
+
const ackKey = acknowledgementKey(identity.credential.machineClientId, eventId);
|
|
2866
|
+
const existing = state.acknowledgements.get(ackKey);
|
|
2867
|
+
if (existing !== undefined &&
|
|
2868
|
+
(existing.disposition !== request.disposition ||
|
|
2869
|
+
existing.reasonCode !== normalizedReasonCode)) {
|
|
2870
|
+
return problemResponse(problem("idempotency_conflict", 409, state.idGenerator("diagnostic")));
|
|
2871
|
+
}
|
|
2872
|
+
if (storedIdempotency !== undefined) {
|
|
2873
|
+
void idempotentReplay(state, identity, "ackMachineEvent", idempotency.key, requestFingerprint);
|
|
2874
|
+
}
|
|
2875
|
+
let previousCommitted = null;
|
|
2876
|
+
for (const record of streamEvents) {
|
|
2877
|
+
if (state.acknowledgements.has(acknowledgementKey(identity.credential.machineClientId, record.event.id))) {
|
|
2878
|
+
previousCommitted = record.event.cursor;
|
|
2879
|
+
}
|
|
2880
|
+
else {
|
|
2881
|
+
break;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
if (existing === undefined) {
|
|
2885
|
+
state.acknowledgements.set(ackKey, {
|
|
2886
|
+
disposition: request.disposition,
|
|
2887
|
+
...(normalizedReasonCode === undefined
|
|
2888
|
+
? {}
|
|
2889
|
+
: { reasonCode: normalizedReasonCode }),
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
let committedCursor = null;
|
|
2893
|
+
for (const record of streamEvents) {
|
|
2894
|
+
if (state.acknowledgements.has(acknowledgementKey(identity.credential.machineClientId, record.event.id))) {
|
|
2895
|
+
committedCursor = record.event.cursor;
|
|
2896
|
+
}
|
|
2897
|
+
else {
|
|
2898
|
+
break;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
const response = {
|
|
2902
|
+
acknowledgement_status: existing === undefined ? "recorded" : "existing",
|
|
2903
|
+
advanced: committedCursor !== previousCommitted,
|
|
2904
|
+
committed_cursor: committedCursor,
|
|
2905
|
+
disposition: request.disposition,
|
|
2906
|
+
event_id: eventId,
|
|
2907
|
+
...(request.disposition === "quarantined" &&
|
|
2908
|
+
request.reason_code !== undefined &&
|
|
2909
|
+
request.reason_code !== null
|
|
2910
|
+
? { reason_code: request.reason_code }
|
|
2911
|
+
: {}),
|
|
2912
|
+
cursor: request.cursor,
|
|
2913
|
+
};
|
|
2914
|
+
state.cursorCommits.push({
|
|
2915
|
+
committedCursor,
|
|
2916
|
+
disposition: request.disposition,
|
|
2917
|
+
eventId,
|
|
2918
|
+
machineClientId: identity.credential.machineClientId,
|
|
2919
|
+
});
|
|
2920
|
+
const responseDiagnosticId = storedIdempotency?.diagnosticId ??
|
|
2921
|
+
storeIdempotentResponse(state, identity, "ackMachineEvent", idempotency.key, requestFingerprint, 200, response, eventId, `cursor:${previousCommitted ?? "none"}->${committedCursor ?? "none"}`);
|
|
2922
|
+
return validatedResponse(response, validateMachineEventAckResponse, 200, state, responseDiagnosticId);
|
|
2923
|
+
});
|
|
2924
|
+
const customerEndpointRoot = "/v1/projects/:projectId/environments/:environmentId/customer-endpoints";
|
|
2925
|
+
/**
|
|
2926
|
+
* Authenticates an environment-scoped customer-endpoint request.
|
|
2927
|
+
*
|
|
2928
|
+
* A credential for another project or environment is refused as
|
|
2929
|
+
* `resource_not_found`, matching the service: a caller must not learn that
|
|
2930
|
+
* someone else's endpoint exists.
|
|
2931
|
+
*/
|
|
2932
|
+
const authorizeCustomerEndpoints = (context, state, scope) => {
|
|
2933
|
+
const identity = requireProjectScope(context.req.raw, state, scope);
|
|
2934
|
+
if (isAuthFailure(identity)) {
|
|
2935
|
+
return identity;
|
|
2936
|
+
}
|
|
2937
|
+
if (context.req.param("projectId") !== identity.credential.projectId ||
|
|
2938
|
+
context.req.param("environmentId") !==
|
|
2939
|
+
credentialEnvironmentId(identity.credential)) {
|
|
2940
|
+
return { response: notFound(state) };
|
|
2941
|
+
}
|
|
2942
|
+
return identity;
|
|
2943
|
+
};
|
|
2944
|
+
/**
|
|
2945
|
+
* Answers an endpoint replay with the endpoint's *current* state.
|
|
2946
|
+
*
|
|
2947
|
+
* The service re-reads the row on the replay path, so a create replayed
|
|
2948
|
+
* after verification reports `active`, not the `unverified` snapshot taken
|
|
2949
|
+
* when the key was first used. Returning the stored snapshot here would
|
|
2950
|
+
* teach consumers a state machine the product does not have.
|
|
2951
|
+
*/
|
|
2952
|
+
const liveEndpointReplay = async (replay, state) => {
|
|
2953
|
+
if (replay.status !== 200) {
|
|
2954
|
+
return replay;
|
|
2955
|
+
}
|
|
2956
|
+
const stored = await replay.clone().json();
|
|
2957
|
+
const identity = typeof stored === "object" && stored !== null && "endpoint" in stored
|
|
2958
|
+
? stored.endpoint.id
|
|
2959
|
+
: undefined;
|
|
2960
|
+
const record = typeof identity === "string"
|
|
2961
|
+
? state.customerEndpoints.get(identity)
|
|
2962
|
+
: undefined;
|
|
2963
|
+
if (record === undefined) {
|
|
2964
|
+
return replay;
|
|
2965
|
+
}
|
|
2966
|
+
return validatedResponse({ endpoint: record.endpoint }, validateCustomerEndpointMutationResult, 200, state, replay.headers.get("whooshbang-diagnostic-id") ?? undefined);
|
|
2967
|
+
};
|
|
2968
|
+
const ownedEndpoint = (state, identity, endpointId) => {
|
|
2969
|
+
const record = state.customerEndpoints.get(endpointId);
|
|
2970
|
+
return record !== undefined &&
|
|
2971
|
+
record.ownerAccountId === identity.credential.accountId &&
|
|
2972
|
+
record.endpoint.project_id === identity.credential.projectId &&
|
|
2973
|
+
record.endpoint.environment === identity.credential.environment
|
|
2974
|
+
? record
|
|
2975
|
+
: undefined;
|
|
2976
|
+
};
|
|
2977
|
+
app.get(customerEndpointRoot, (context) => {
|
|
2978
|
+
const state = getState();
|
|
2979
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:read");
|
|
2980
|
+
if (isAuthFailure(identity)) {
|
|
2981
|
+
return identity.response;
|
|
2982
|
+
}
|
|
2983
|
+
const collection = {
|
|
2984
|
+
items: [...state.customerEndpoints.values()]
|
|
2985
|
+
.filter((record) => ownedEndpoint(state, identity, record.endpoint.id))
|
|
2986
|
+
.map((record) => record.endpoint)
|
|
2987
|
+
.sort((left, right) => left.created_at.localeCompare(right.created_at) ||
|
|
2988
|
+
left.id.localeCompare(right.id))
|
|
2989
|
+
// The contract caps the page at 100. Truncating keeps an unusual
|
|
2990
|
+
// fixture from turning into an internal validation failure.
|
|
2991
|
+
.slice(0, 100),
|
|
2992
|
+
};
|
|
2993
|
+
recordDiagnostic(state, identity, "listCustomerEndpoints");
|
|
2994
|
+
return validatedResponse(collection, validateCustomerEndpointCollection, 200, state);
|
|
2995
|
+
});
|
|
2996
|
+
app.post(customerEndpointRoot, async (context) => {
|
|
2997
|
+
const state = getState();
|
|
2998
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:write");
|
|
2999
|
+
if (isAuthFailure(identity)) {
|
|
3000
|
+
return identity.response;
|
|
3001
|
+
}
|
|
3002
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
3003
|
+
if ("response" in idempotency) {
|
|
3004
|
+
return idempotency.response;
|
|
3005
|
+
}
|
|
3006
|
+
const parsed = await readJson(context.req.raw, validateCreateCustomerEndpointRequest, state.idGenerator("diagnostic"));
|
|
3007
|
+
if ("response" in parsed) {
|
|
3008
|
+
return parsed.response;
|
|
3009
|
+
}
|
|
3010
|
+
const request = parsed.value;
|
|
3011
|
+
// Sorting matches the service's documented normalization, so the same
|
|
3012
|
+
// event-type set in a different order replays instead of conflicting.
|
|
3013
|
+
const eventTypes = [
|
|
3014
|
+
...request.event_types,
|
|
3015
|
+
].sort();
|
|
3016
|
+
const normalized = {
|
|
3017
|
+
event_types: eventTypes,
|
|
3018
|
+
local_test: request.local_test === true,
|
|
3019
|
+
url: request.url,
|
|
3020
|
+
};
|
|
3021
|
+
const requestFingerprint = fingerprint(normalized);
|
|
3022
|
+
const replay = idempotentReplay(state, identity, "createCustomerEndpoint", idempotency.key, requestFingerprint);
|
|
3023
|
+
if (replay !== undefined) {
|
|
3024
|
+
return liveEndpointReplay(replay, state);
|
|
3025
|
+
}
|
|
3026
|
+
state.customerEndpointSequence += 1;
|
|
3027
|
+
const now = timestamp(options);
|
|
3028
|
+
const endpoint = {
|
|
3029
|
+
created_at: now,
|
|
3030
|
+
diagnostic_id: state.idGenerator("diagnostic"),
|
|
3031
|
+
environment: identity.credential.environment,
|
|
3032
|
+
environment_id: credentialEnvironmentId(identity.credential),
|
|
3033
|
+
event_types: eventTypes,
|
|
3034
|
+
health: "never_delivered",
|
|
3035
|
+
id: state.idGenerator("customer_endpoint"),
|
|
3036
|
+
project_id: identity.credential.projectId,
|
|
3037
|
+
secret_version: 1,
|
|
3038
|
+
status: "unverified",
|
|
3039
|
+
updated_at: now,
|
|
3040
|
+
verification_status: "pending",
|
|
3041
|
+
};
|
|
3042
|
+
const result = {
|
|
3043
|
+
endpoint,
|
|
3044
|
+
secret: syntheticEndpointSecret(state.customerEndpointSequence, 1),
|
|
3045
|
+
};
|
|
3046
|
+
state.customerEndpoints.set(endpoint.id, {
|
|
3047
|
+
attempts: [],
|
|
3048
|
+
endpoint,
|
|
3049
|
+
ownerAccountId: identity.credential.accountId,
|
|
3050
|
+
secret: result.secret ?? "",
|
|
3051
|
+
sequence: state.customerEndpointSequence,
|
|
3052
|
+
});
|
|
3053
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "createCustomerEndpoint", idempotency.key, requestFingerprint, 200, { endpoint }, endpoint.id, "none->unverified");
|
|
3054
|
+
return validatedResponse(result, validateCustomerEndpointMutationResult, 201, state, responseDiagnosticId);
|
|
3055
|
+
});
|
|
3056
|
+
app.get(`${customerEndpointRoot}/:endpointId`, (context) => {
|
|
3057
|
+
const state = getState();
|
|
3058
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:read");
|
|
3059
|
+
if (isAuthFailure(identity)) {
|
|
3060
|
+
return identity.response;
|
|
3061
|
+
}
|
|
3062
|
+
const record = ownedEndpoint(state, identity, context.req.param("endpointId"));
|
|
3063
|
+
if (record === undefined) {
|
|
3064
|
+
return notFound(state);
|
|
3065
|
+
}
|
|
3066
|
+
recordDiagnostic(state, identity, "getCustomerEndpoint", {
|
|
3067
|
+
resourceId: record.endpoint.id,
|
|
3068
|
+
});
|
|
3069
|
+
return validatedResponse(record.endpoint, validateCustomerEndpoint, 200, state);
|
|
3070
|
+
});
|
|
3071
|
+
app.post(`${customerEndpointRoot}/:endpointId/verify`, (context) => {
|
|
3072
|
+
const state = getState();
|
|
3073
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:write");
|
|
3074
|
+
if (isAuthFailure(identity)) {
|
|
3075
|
+
return identity.response;
|
|
3076
|
+
}
|
|
3077
|
+
const record = ownedEndpoint(state, identity, context.req.param("endpointId"));
|
|
3078
|
+
if (record === undefined) {
|
|
3079
|
+
return notFound(state);
|
|
3080
|
+
}
|
|
3081
|
+
if (record.endpoint.status !== "unverified") {
|
|
3082
|
+
recordDiagnostic(state, identity, "verifyCustomerEndpoint", {
|
|
3083
|
+
resourceId: record.endpoint.id,
|
|
3084
|
+
});
|
|
3085
|
+
return validatedResponse(record.endpoint, validateCustomerEndpoint, 200, state);
|
|
3086
|
+
}
|
|
3087
|
+
const now = timestamp(options);
|
|
3088
|
+
record.endpoint = {
|
|
3089
|
+
...record.endpoint,
|
|
3090
|
+
status: "active",
|
|
3091
|
+
updated_at: now,
|
|
3092
|
+
verification_attempted_at: now,
|
|
3093
|
+
verification_status: "verified",
|
|
3094
|
+
verified_at: now,
|
|
3095
|
+
};
|
|
3096
|
+
recordDiagnostic(state, identity, "verifyCustomerEndpoint", {
|
|
3097
|
+
resourceId: record.endpoint.id,
|
|
3098
|
+
stateTransition: "unverified->active",
|
|
3099
|
+
});
|
|
3100
|
+
return validatedResponse(record.endpoint, validateCustomerEndpoint, 200, state);
|
|
3101
|
+
});
|
|
3102
|
+
app.post(`${customerEndpointRoot}/:endpointId/rotate`, async (context) => {
|
|
3103
|
+
const state = getState();
|
|
3104
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:write");
|
|
3105
|
+
if (isAuthFailure(identity)) {
|
|
3106
|
+
return identity.response;
|
|
3107
|
+
}
|
|
3108
|
+
const idempotency = requireIdempotency(context.req.raw, state);
|
|
3109
|
+
if ("response" in idempotency) {
|
|
3110
|
+
return idempotency.response;
|
|
3111
|
+
}
|
|
3112
|
+
const parsed = await readJson(context.req.raw, validateRotateCustomerEndpointRequest, state.idGenerator("diagnostic"));
|
|
3113
|
+
if ("response" in parsed) {
|
|
3114
|
+
return parsed.response;
|
|
3115
|
+
}
|
|
3116
|
+
const request = parsed.value;
|
|
3117
|
+
const endpointId = context.req.param("endpointId");
|
|
3118
|
+
const requestFingerprint = fingerprint({ endpointId, request });
|
|
3119
|
+
const replay = idempotentReplay(state, identity, "rotateCustomerEndpoint", idempotency.key, requestFingerprint);
|
|
3120
|
+
if (replay !== undefined) {
|
|
3121
|
+
return liveEndpointReplay(replay, state);
|
|
3122
|
+
}
|
|
3123
|
+
const record = ownedEndpoint(state, identity, endpointId);
|
|
3124
|
+
if (record === undefined) {
|
|
3125
|
+
return notFound(state);
|
|
3126
|
+
}
|
|
3127
|
+
// A rotation already inside its overlap window must not start another:
|
|
3128
|
+
// a third live secret would break the two-secret verification contract.
|
|
3129
|
+
// Once the window closes the previous secret retires and rotating again
|
|
3130
|
+
// is ordinary, which is why the window is checked rather than the flag.
|
|
3131
|
+
const now = timestamp(options);
|
|
3132
|
+
const nowMilliseconds = Date.parse(now);
|
|
3133
|
+
if (record.previousSecret !== undefined &&
|
|
3134
|
+
record.rotationOverlapEndsAt !== undefined &&
|
|
3135
|
+
nowMilliseconds < record.rotationOverlapEndsAt) {
|
|
3136
|
+
return problemResponse(problem("endpoint_rotation_conflict", 409, state.idGenerator("diagnostic")));
|
|
3137
|
+
}
|
|
3138
|
+
const version = record.endpoint.secret_version + 1;
|
|
3139
|
+
record.previousSecret = record.secret;
|
|
3140
|
+
record.rotationOverlapEndsAt =
|
|
3141
|
+
nowMilliseconds + request.overlap_seconds * 1_000;
|
|
3142
|
+
record.secret = syntheticEndpointSecret(record.sequence, version);
|
|
3143
|
+
record.endpoint = {
|
|
3144
|
+
...record.endpoint,
|
|
3145
|
+
rotated_at: now,
|
|
3146
|
+
secret_version: version,
|
|
3147
|
+
updated_at: now,
|
|
3148
|
+
};
|
|
3149
|
+
const result = {
|
|
3150
|
+
endpoint: record.endpoint,
|
|
3151
|
+
secret: record.secret,
|
|
3152
|
+
};
|
|
3153
|
+
const responseDiagnosticId = storeIdempotentResponse(state, identity, "rotateCustomerEndpoint", idempotency.key, requestFingerprint, 200, { endpoint: record.endpoint }, record.endpoint.id, `secret_version_${String(version)}`);
|
|
3154
|
+
return validatedResponse(result, validateCustomerEndpointMutationResult, 200, state, responseDiagnosticId);
|
|
3155
|
+
});
|
|
3156
|
+
for (const action of ["pause", "resume", "disable"]) {
|
|
3157
|
+
app.post(`${customerEndpointRoot}/:endpointId/${action}`, (context) => {
|
|
3158
|
+
const state = getState();
|
|
3159
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:write");
|
|
3160
|
+
if (isAuthFailure(identity)) {
|
|
3161
|
+
return identity.response;
|
|
3162
|
+
}
|
|
3163
|
+
const record = ownedEndpoint(state, identity, context.req.param("endpointId"));
|
|
3164
|
+
if (record === undefined) {
|
|
3165
|
+
return notFound(state);
|
|
3166
|
+
}
|
|
3167
|
+
const previous = record.endpoint.status;
|
|
3168
|
+
const next = customerEndpointTransition(previous, action);
|
|
3169
|
+
if (next === undefined) {
|
|
3170
|
+
return requestInvalid(state);
|
|
3171
|
+
}
|
|
3172
|
+
const now = timestamp(options);
|
|
3173
|
+
record.endpoint = {
|
|
3174
|
+
...record.endpoint,
|
|
3175
|
+
...(action === "pause" ? { paused_at: now } : {}),
|
|
3176
|
+
...(action === "disable" ? { disabled_at: now } : {}),
|
|
3177
|
+
status: next,
|
|
3178
|
+
updated_at: now,
|
|
3179
|
+
};
|
|
3180
|
+
recordDiagnostic(state, identity, `${action}CustomerEndpoint`, {
|
|
3181
|
+
resourceId: record.endpoint.id,
|
|
3182
|
+
stateTransition: `${previous}->${next}`,
|
|
3183
|
+
});
|
|
3184
|
+
return validatedResponse(record.endpoint, validateCustomerEndpoint, 200, state);
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
app.get(`${customerEndpointRoot}/:endpointId/attempts`, (context) => {
|
|
3188
|
+
const state = getState();
|
|
3189
|
+
const identity = authorizeCustomerEndpoints(context, state, "messages:read");
|
|
3190
|
+
if (isAuthFailure(identity)) {
|
|
3191
|
+
return identity.response;
|
|
3192
|
+
}
|
|
3193
|
+
const record = ownedEndpoint(state, identity, context.req.param("endpointId"));
|
|
3194
|
+
if (record === undefined) {
|
|
3195
|
+
return notFound(state);
|
|
3196
|
+
}
|
|
3197
|
+
const collection = {
|
|
3198
|
+
attempts: [...record.attempts],
|
|
3199
|
+
endpoint_id: record.endpoint.id,
|
|
3200
|
+
};
|
|
3201
|
+
recordDiagnostic(state, identity, "listCustomerEndpointAttempts", {
|
|
3202
|
+
resourceId: record.endpoint.id,
|
|
3203
|
+
});
|
|
3204
|
+
return validatedResponse(collection, validateCustomerEndpointAttemptCollection, 200, state);
|
|
3205
|
+
});
|
|
3206
|
+
app.get("/v1/capabilities", (context) => {
|
|
3207
|
+
const state = getState();
|
|
3208
|
+
const identity = requireProjectScope(context.req.raw, state, "capabilities:read");
|
|
3209
|
+
if (isAuthFailure(identity)) {
|
|
3210
|
+
return identity.response;
|
|
3211
|
+
}
|
|
3212
|
+
const response = {
|
|
3213
|
+
providers: [
|
|
3214
|
+
{
|
|
3215
|
+
contexts: {
|
|
3216
|
+
group: {
|
|
3217
|
+
explanation: "Group use requires an authenticated subscriber binding and exact reply context.",
|
|
3218
|
+
mode: "conditional",
|
|
3219
|
+
},
|
|
3220
|
+
private_chat: { mode: "supported" },
|
|
3221
|
+
thread: {
|
|
3222
|
+
explanation: "Thread support depends on the bound Telegram chat context.",
|
|
3223
|
+
mode: "conditional",
|
|
3224
|
+
},
|
|
3225
|
+
},
|
|
3226
|
+
degradation: { active: false },
|
|
3227
|
+
edit: { supported: true },
|
|
3228
|
+
interaction: {
|
|
3229
|
+
confirm: { mode: "native" },
|
|
3230
|
+
input: {
|
|
3231
|
+
explanation: "Input requires an exact provider reply context.",
|
|
3232
|
+
mode: "conditional",
|
|
3233
|
+
},
|
|
3234
|
+
select: { mode: "native" },
|
|
3235
|
+
},
|
|
3236
|
+
provider: "telegram",
|
|
3237
|
+
rate_limit: {
|
|
3238
|
+
explanation: "Safe provider retry timing is honored when available.",
|
|
3239
|
+
strategy: "provider_retry_after",
|
|
3240
|
+
},
|
|
3241
|
+
receipts: {
|
|
3242
|
+
delivered: false,
|
|
3243
|
+
provider_accepted: true,
|
|
3244
|
+
read: false,
|
|
3245
|
+
},
|
|
3246
|
+
schema: "whooshbang.provider-capabilities.v1",
|
|
3247
|
+
streaming: {
|
|
3248
|
+
explanation: "Progressive content uses bounded message edits.",
|
|
3249
|
+
mode: "edit",
|
|
3250
|
+
},
|
|
3251
|
+
text: { maximum_bytes: 8192, supported: true },
|
|
3252
|
+
},
|
|
3253
|
+
],
|
|
3254
|
+
schema: "whooshbang.provider-capabilities-collection.v1",
|
|
3255
|
+
};
|
|
3256
|
+
recordDiagnostic(state, identity, "getCapabilities");
|
|
3257
|
+
return validatedResponse(response, validateProviderCapabilitiesCollection, 200, state);
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* The documented endpoint lifecycle transitions.
|
|
3262
|
+
*
|
|
3263
|
+
* `disabled` is terminal and an unverified endpoint cannot be paused or
|
|
3264
|
+
* resumed, so every other request is a client error rather than a silent
|
|
3265
|
+
* no-op.
|
|
3266
|
+
*/
|
|
3267
|
+
function customerEndpointTransition(current, action) {
|
|
3268
|
+
if (current === "disabled") {
|
|
3269
|
+
return undefined;
|
|
3270
|
+
}
|
|
3271
|
+
if (action === "disable") {
|
|
3272
|
+
return "disabled";
|
|
3273
|
+
}
|
|
3274
|
+
if (action === "pause") {
|
|
3275
|
+
return current === "active" ? "paused" : undefined;
|
|
3276
|
+
}
|
|
3277
|
+
return current === "paused" ? "active" : undefined;
|
|
3278
|
+
}
|
|
3279
|
+
function makeInteractionEvent(message, input, state, options) {
|
|
3280
|
+
const interaction = message.interaction;
|
|
3281
|
+
if (interaction === undefined) {
|
|
3282
|
+
throw new RangeError("The selected message has no interaction.");
|
|
3283
|
+
}
|
|
3284
|
+
const trimmedResponse = input.response.type === "input"
|
|
3285
|
+
? { type: "input", value: input.response.value.trim() }
|
|
3286
|
+
: input.response;
|
|
3287
|
+
const event = {
|
|
3288
|
+
answer_retained: true,
|
|
3289
|
+
channel_context: {
|
|
3290
|
+
binding_id: message.binding.id,
|
|
3291
|
+
channel: "telegram",
|
|
3292
|
+
conversation_kind: "private_chat",
|
|
3293
|
+
display_name: "Synthetic operator",
|
|
3294
|
+
},
|
|
3295
|
+
...(interaction.correlation_id === undefined
|
|
3296
|
+
? {}
|
|
3297
|
+
: { correlation_id: interaction.correlation_id }),
|
|
3298
|
+
cursor: `mcur_${state.idGenerator("event")}`,
|
|
3299
|
+
expires_at: interaction.expires_at,
|
|
3300
|
+
id: `event_${state.idGenerator("event")}`,
|
|
3301
|
+
interaction_id: interaction.id,
|
|
3302
|
+
message_id: message.id,
|
|
3303
|
+
occurred_at: timestamp(options),
|
|
3304
|
+
response: trimmedResponse,
|
|
3305
|
+
schema: "whooshbang.interaction-event.v1",
|
|
3306
|
+
type: "interaction.received",
|
|
3307
|
+
};
|
|
3308
|
+
if (!validateInteractionEvent(event)) {
|
|
3309
|
+
throw new Error("Internal contract-mock interaction event violation.");
|
|
3310
|
+
}
|
|
3311
|
+
return event;
|
|
3312
|
+
}
|
|
3313
|
+
async function submitInteraction(state, options, input) {
|
|
3314
|
+
const record = state.messages.get(input.messageId);
|
|
3315
|
+
if (record?.message.interaction === undefined) {
|
|
3316
|
+
throw new RangeError("Unknown interactive message.");
|
|
3317
|
+
}
|
|
3318
|
+
const requestedOutcome = input.outcome ?? getScenarioDefinition(state.scenario).interactionOutcome;
|
|
3319
|
+
const interaction = record.message.interaction;
|
|
3320
|
+
const typeMatches = input.response.type === interaction.type;
|
|
3321
|
+
const selectedOptionValid = input.response.type !== "select" ||
|
|
3322
|
+
record.interactionRequest?.type !== "select" ||
|
|
3323
|
+
record.interactionRequest.options.some((option) => option.value === input.response.value);
|
|
3324
|
+
const expiredByClock = Date.parse(timestamp(options)) >= Date.parse(interaction.expires_at);
|
|
3325
|
+
const resolvedOutcome = interaction.state === "answered"
|
|
3326
|
+
? "duplicate"
|
|
3327
|
+
: interaction.state === "cancelled" || interaction.state === "retired"
|
|
3328
|
+
? "stale"
|
|
3329
|
+
: interaction.state === "expired"
|
|
3330
|
+
? "expired"
|
|
3331
|
+
: requestedOutcome !== "authorized"
|
|
3332
|
+
? requestedOutcome
|
|
3333
|
+
: !typeMatches || !selectedOptionValid
|
|
3334
|
+
? "unauthorized"
|
|
3335
|
+
: expiredByClock
|
|
3336
|
+
? "expired"
|
|
3337
|
+
: "authorized";
|
|
3338
|
+
if (resolvedOutcome !== "authorized") {
|
|
3339
|
+
if (resolvedOutcome === "expired" && interaction.state === "open") {
|
|
3340
|
+
record.message.interaction = { ...interaction, state: "expired" };
|
|
3341
|
+
}
|
|
3342
|
+
state.diagnostics.push({
|
|
3343
|
+
environment: record.message.environment,
|
|
3344
|
+
operation: "submitInteraction",
|
|
3345
|
+
projectId: record.message.project_id,
|
|
3346
|
+
requestId: state.idGenerator("request"),
|
|
3347
|
+
resourceId: record.message.id,
|
|
3348
|
+
scenario: state.scenario,
|
|
3349
|
+
stateTransition: `interaction:${interaction.state}->${record.message.interaction.state}:${resolvedOutcome}`,
|
|
3350
|
+
});
|
|
3351
|
+
return { eventCreated: false, outcome: resolvedOutcome };
|
|
3352
|
+
}
|
|
3353
|
+
const event = makeInteractionEvent(record.message, input, state, options);
|
|
3354
|
+
if (event.response === undefined) {
|
|
3355
|
+
throw new Error("Internal retained event response is missing.");
|
|
3356
|
+
}
|
|
3357
|
+
const answer = event.response.value;
|
|
3358
|
+
const nextMessage = {
|
|
3359
|
+
...record.message,
|
|
3360
|
+
interaction: {
|
|
3361
|
+
...interaction,
|
|
3362
|
+
answer,
|
|
3363
|
+
answer_retained: true,
|
|
3364
|
+
answered_at: event.occurred_at,
|
|
3365
|
+
state: "answered",
|
|
3366
|
+
},
|
|
3367
|
+
};
|
|
3368
|
+
if (!validateMessage(nextMessage)) {
|
|
3369
|
+
throw new Error("Internal contract-mock interaction state violation.");
|
|
3370
|
+
}
|
|
3371
|
+
const customerEvent = {
|
|
3372
|
+
account_id: record.ownerAccountId,
|
|
3373
|
+
created_at: timestamp(options),
|
|
3374
|
+
data: {
|
|
3375
|
+
binding_id: record.message.binding.id,
|
|
3376
|
+
channel: "telegram",
|
|
3377
|
+
...(interaction.correlation_id === undefined
|
|
3378
|
+
? {}
|
|
3379
|
+
: { correlation_id: interaction.correlation_id }),
|
|
3380
|
+
expires_at: interaction.expires_at,
|
|
3381
|
+
interaction_id: interaction.id,
|
|
3382
|
+
interaction_type: interaction.type,
|
|
3383
|
+
message_id: record.message.id,
|
|
3384
|
+
occurred_at: event.occurred_at,
|
|
3385
|
+
response: event.response,
|
|
3386
|
+
},
|
|
3387
|
+
environment: record.message.environment,
|
|
3388
|
+
id: `evt_${state.idGenerator("event")}`,
|
|
3389
|
+
project_id: record.message.project_id,
|
|
3390
|
+
schema: "whooshbang.event.v1",
|
|
3391
|
+
type: "interaction.received",
|
|
3392
|
+
};
|
|
3393
|
+
if (!validateWhooshBangEvent(customerEvent)) {
|
|
3394
|
+
throw new Error("Internal contract-mock customer event violation.");
|
|
3395
|
+
}
|
|
3396
|
+
const machineClientId = originatingMachineClientId(state, record);
|
|
3397
|
+
record.message = nextMessage;
|
|
3398
|
+
state.diagnostics.push({
|
|
3399
|
+
environment: record.message.environment,
|
|
3400
|
+
operation: "submitInteraction",
|
|
3401
|
+
projectId: record.message.project_id,
|
|
3402
|
+
requestId: state.idGenerator("request"),
|
|
3403
|
+
resourceId: record.message.id,
|
|
3404
|
+
scenario: state.scenario,
|
|
3405
|
+
stateTransition: "interaction:open->answered",
|
|
3406
|
+
});
|
|
3407
|
+
if (machineClientId !== undefined) {
|
|
3408
|
+
state.machineEvents.push({
|
|
3409
|
+
event,
|
|
3410
|
+
machineClientId,
|
|
3411
|
+
});
|
|
3412
|
+
}
|
|
3413
|
+
addLogicalEvent(state, customerEvent, record.message.id);
|
|
3414
|
+
await deliverWebhook(customerEvent, state, options);
|
|
3415
|
+
return { eventCreated: true, outcome: "authorized" };
|
|
3416
|
+
}
|
|
3417
|
+
export function createWhooshBangContractMockCore(options = {}) {
|
|
3418
|
+
const runtimeOptions = Object.freeze({
|
|
3419
|
+
...options,
|
|
3420
|
+
credentials: snapshotFixtureCredentials(options.credentials === undefined
|
|
3421
|
+
? defaultCredentials()
|
|
3422
|
+
: options.credentials),
|
|
3423
|
+
});
|
|
3424
|
+
let state = initialState(runtimeOptions, runtimeOptions.scenario ?? "nominal");
|
|
3425
|
+
const app = new Hono();
|
|
3426
|
+
addPublicRoutes(app, () => state, runtimeOptions);
|
|
3427
|
+
app.notFound(() => notFound(state));
|
|
3428
|
+
app.onError(() => problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic"))));
|
|
3429
|
+
const control = {
|
|
3430
|
+
enqueueMachineEvent(seed) {
|
|
3431
|
+
state.machineEvents.push({ ...clone(seed) });
|
|
3432
|
+
},
|
|
3433
|
+
reset(scenario = state.scenario) {
|
|
3434
|
+
state = initialState(runtimeOptions, scenario);
|
|
3435
|
+
return Promise.resolve();
|
|
3436
|
+
},
|
|
3437
|
+
async runPending() {
|
|
3438
|
+
for (const record of state.messages.values()) {
|
|
3439
|
+
await processMessage(record, state, runtimeOptions);
|
|
3440
|
+
}
|
|
3441
|
+
},
|
|
3442
|
+
setProviderOutcomes(outcomes) {
|
|
3443
|
+
state.providerOutcomes = clone([...outcomes]);
|
|
3444
|
+
},
|
|
3445
|
+
setWebhookResponses(responses) {
|
|
3446
|
+
state.webhookResponses = [...responses];
|
|
3447
|
+
},
|
|
3448
|
+
async submitInteraction(input) {
|
|
3449
|
+
return submitInteraction(state, runtimeOptions, parseInteractionControlInput(clone(input)));
|
|
3450
|
+
},
|
|
3451
|
+
};
|
|
3452
|
+
return {
|
|
3453
|
+
app,
|
|
3454
|
+
control,
|
|
3455
|
+
fetch: async (request) => app.fetch(request),
|
|
3456
|
+
inspect: () => inspectState(state),
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
//# sourceMappingURL=core.js.map
|