@lovrozagar/oat 0.12.22 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +1 -0
  3. package/dist/config/define-config.d.ts +82 -0
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/load.d.ts.map +1 -1
  7. package/dist/config/load.js +6 -1
  8. package/dist/config/load.js.map +1 -1
  9. package/dist/config/validate.d.ts.map +1 -1
  10. package/dist/config/validate.js +1 -0
  11. package/dist/config/validate.js.map +1 -1
  12. package/dist/conformance/suite.d.ts.map +1 -1
  13. package/dist/conformance/suite.js +3 -0
  14. package/dist/conformance/suite.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/reference/defects.d.ts +7 -0
  17. package/dist/reference/defects.d.ts.map +1 -1
  18. package/dist/reference/defects.js +7 -0
  19. package/dist/reference/defects.js.map +1 -1
  20. package/dist/reference/http.js +1 -1
  21. package/dist/reference/http.js.map +1 -1
  22. package/dist/report/render.js +3 -3
  23. package/dist/report/render.js.map +1 -1
  24. package/dist/runtime/auth.d.ts +14 -0
  25. package/dist/runtime/auth.d.ts.map +1 -1
  26. package/dist/runtime/auth.js +16 -5
  27. package/dist/runtime/auth.js.map +1 -1
  28. package/dist/runtime/check-costs.d.ts.map +1 -1
  29. package/dist/runtime/check-costs.js +1 -0
  30. package/dist/runtime/check-costs.js.map +1 -1
  31. package/dist/runtime/check-ids.d.ts +1 -1
  32. package/dist/runtime/check-ids.d.ts.map +1 -1
  33. package/dist/runtime/check-ids.js +1 -0
  34. package/dist/runtime/check-ids.js.map +1 -1
  35. package/dist/runtime/checks.d.ts.map +1 -1
  36. package/dist/runtime/checks.js +214 -48
  37. package/dist/runtime/checks.js.map +1 -1
  38. package/dist/runtime/client.d.ts +2 -0
  39. package/dist/runtime/client.d.ts.map +1 -1
  40. package/dist/runtime/client.js +6 -1
  41. package/dist/runtime/client.js.map +1 -1
  42. package/dist/runtime/contract.d.ts +52 -0
  43. package/dist/runtime/contract.d.ts.map +1 -0
  44. package/dist/runtime/contract.js +261 -0
  45. package/dist/runtime/contract.js.map +1 -0
  46. package/dist/runtime/disposable.d.ts +22 -15
  47. package/dist/runtime/disposable.d.ts.map +1 -1
  48. package/dist/runtime/disposable.js +250 -1360
  49. package/dist/runtime/disposable.js.map +1 -1
  50. package/dist/runtime/effects.d.ts +1 -1
  51. package/dist/runtime/effects.d.ts.map +1 -1
  52. package/dist/runtime/evidence.d.ts +21 -0
  53. package/dist/runtime/evidence.d.ts.map +1 -0
  54. package/dist/runtime/evidence.js +168 -0
  55. package/dist/runtime/evidence.js.map +1 -0
  56. package/dist/runtime/path.d.ts +6 -0
  57. package/dist/runtime/path.d.ts.map +1 -1
  58. package/dist/runtime/path.js +46 -0
  59. package/dist/runtime/path.js.map +1 -1
  60. package/dist/runtime/run.d.ts +3 -1
  61. package/dist/runtime/run.d.ts.map +1 -1
  62. package/dist/runtime/run.js +103 -14
  63. package/dist/runtime/run.js.map +1 -1
  64. package/dist/runtime/scenario.d.ts +39 -0
  65. package/dist/runtime/scenario.d.ts.map +1 -0
  66. package/dist/runtime/scenario.js +337 -0
  67. package/dist/runtime/scenario.js.map +1 -0
  68. package/dist/runtime/scope.d.ts +28 -0
  69. package/dist/runtime/scope.d.ts.map +1 -1
  70. package/dist/runtime/scope.js +31 -4
  71. package/dist/runtime/scope.js.map +1 -1
  72. package/dist/runtime/self.d.ts.map +1 -1
  73. package/dist/runtime/self.js +12 -0
  74. package/dist/runtime/self.js.map +1 -1
  75. package/dist/runtime/teardown.d.ts +3 -0
  76. package/dist/runtime/teardown.d.ts.map +1 -1
  77. package/dist/runtime/teardown.js +7 -1
  78. package/dist/runtime/teardown.js.map +1 -1
  79. package/dist/runtime/transcript.d.ts.map +1 -1
  80. package/dist/runtime/transcript.js +9 -5
  81. package/dist/runtime/transcript.js.map +1 -1
  82. package/dist/spec/collection.d.ts +2 -1
  83. package/dist/spec/collection.d.ts.map +1 -1
  84. package/dist/spec/collection.js +13 -2
  85. package/dist/spec/collection.js.map +1 -1
  86. package/dist/spec/extensions.d.ts +6 -0
  87. package/dist/spec/extensions.d.ts.map +1 -1
  88. package/dist/spec/extensions.js +2 -0
  89. package/dist/spec/extensions.js.map +1 -1
  90. package/dist/spec/graph.d.ts +8 -0
  91. package/dist/spec/graph.d.ts.map +1 -1
  92. package/dist/spec/graph.js +44 -2
  93. package/dist/spec/graph.js.map +1 -1
  94. package/dist/spec/tag-schema.d.ts.map +1 -1
  95. package/dist/spec/tag-schema.js +1 -0
  96. package/dist/spec/tag-schema.js.map +1 -1
  97. package/docs/configuration.md +43 -1
  98. package/docs/reports.md +19 -1
  99. package/docs/tags.md +7 -2
  100. package/package.json +14 -14
@@ -1,19 +1,31 @@
1
1
  /**
2
- * `x-destructive` runs as a second user registered through the spec, never the suite caller.
3
- * Afterwards the suite caller's own read still returns 200. When the operation invalidates
4
- * that read, the disposable credential's next read is 401.
2
+ * `x-destructive` runs as a second user, never the suite caller. That user signs up through the
3
+ * suite principal's own auth flow with a fresh address, or through the document's
4
+ * `x-fresh-principal` operation. Afterwards the suite caller's own read still answers 200. An
5
+ * operation that invalidates the caller read must leave that second user's credential refused,
6
+ * and a declared refresh must no longer renew it.
7
+ *
8
+ * What a destructive call needs beforehand — a second factor enabled, a credential registered,
9
+ * ownership handed off — is product knowledge. When the call is refused for want of it, the
10
+ * operation stays untested and a scenario sets the scene.
5
11
  */
12
+ import { isHookAuth } from "../config/define-config.js";
13
+ import { deriveCollectionShape, deriveIdentity, effective, successSchema } from "../spec/collection.js";
14
+ import { createPrincipal, readPath, runAuthSteps } from "./auth.js";
6
15
  import { ASSERTED } from "./finding.js";
7
- import { deriveCollectionShape, effective, successSchema } from "../spec/collection.js";
8
16
  import { buildCohort, isOverflowError } from "./fixture.js";
9
17
  import { applyResolveInput } from "./input.js";
10
18
  import { fillPath, requestSchemaOf } from "./world.js";
11
- import { isHookAuth } from "../config/define-config.js";
12
- import { createHmac } from "node:crypto";
13
- import { createPrincipal, readPath, resolveOutOfBandValue } from "./auth.js";
14
- import { sleep } from "./poll.js";
15
19
  export const DISPOSABLE_CHECK_ID = "auth.destructive-spares-the-caller";
16
- const REGISTER_LIKE = /register|sign[-_]?up/i;
20
+ export const disposableCheck = {
21
+ id: DISPOSABLE_CHECK_ID,
22
+ mutates: true,
23
+ needs: "x-destructive, a way to sign up a second user, and a caller read",
24
+ plan: () => ({ ok: false }),
25
+ run: () => Promise.resolve(ASSERTED),
26
+ /* Destructive routes have no entity. `runDisposableOps` grades them after the entity loop. */
27
+ subjects: () => [],
28
+ };
17
29
  export function destructiveOps(model) {
18
30
  return model.operations.filter((op) => op.destructive);
19
31
  }
@@ -26,250 +38,18 @@ export function signupOperationId(auth) {
26
38
  }
27
39
  return undefined;
28
40
  }
29
- function looksLikeSignup(op) {
30
- if (/passkey/i.test(op.operationId) || /passkey/i.test(op.path))
31
- return false;
32
- return REGISTER_LIKE.test(op.operationId) || REGISTER_LIKE.test(op.path);
33
- }
34
- function registerOperation(model, preferredId) {
35
- const preferred = preferredId === undefined ? undefined : model.byOperationId.get(preferredId);
36
- if (preferred !== undefined && (preferred.freshPrincipal || looksLikeSignup(preferred)))
37
- return preferred;
38
- return model.operations.find((op) => op.freshPrincipal || looksLikeSignup(op));
39
- }
41
+ /** The caller reading itself: an `identity: self` read with nothing to fill in. */
40
42
  function callerRead(model) {
41
- const reads = model.operations.filter((op) => op.identity === "self" && op.action === "read" && op.pathParams.length === 0);
42
- return (reads.find((op) => op.operationId === "auth.getProfile" || op.path === "/v1/auth/me" || op.path === "/v1/me") ??
43
- reads[0]);
44
- }
45
- function propertyNames(schema) {
46
- const properties = schema.properties;
47
- if (properties === null || typeof properties !== "object" || Array.isArray(properties))
48
- return new Set();
49
- return new Set(Object.keys(properties));
50
- }
51
- function requiredSchema(schema) {
52
- const required = Array.isArray(schema.required)
53
- ? schema.required.filter((name) => typeof name === "string")
54
- : [];
55
- if (required.length === 0)
56
- return schema;
57
- const properties = schema.properties;
58
- if (properties === null || typeof properties !== "object" || Array.isArray(properties))
59
- return schema;
60
- const source = properties;
61
- const picked = {};
62
- for (const name of required)
63
- if (name in source)
64
- picked[name] = source[name];
65
- return { ...schema, properties: picked, required };
66
- }
67
- function tokenOf(body) {
68
- if (body === null || typeof body !== "object")
69
- return null;
70
- const token = body.access_token;
71
- return typeof token === "string" && token !== "" ? token : null;
72
- }
73
- export async function runDisposableOps(input) {
74
- const ops = destructiveOps(input.model).filter((op) => input.mode === "full" || input.inScope(op.operationId));
75
- if (ops.length === 0)
76
- return;
77
- input.checksRun?.add(DISPOSABLE_CHECK_ID);
78
- const ids = ops.map((op) => op.operationId);
79
- const register = registerOperation(input.model, input.registerOperationId);
80
- const read = callerRead(input.model);
81
- if (register === undefined || read === undefined) {
82
- const reason = register === undefined ? "no register operation" : "no caller read";
83
- input.grades.skipped(ids, reason);
84
- return;
85
- }
86
- const flow = authFlowOf(input.authFlow);
87
- const flowSignsUp = flow !== null &&
88
- flow.steps.some((step) => "operationId" in step && /register|sign[-_]?up/i.test(step.operationId) && !/passkey/i.test(step.operationId));
89
- let headers;
90
- let issued;
91
- let signupExchange;
92
- let adopt = (_body) => undefined;
93
- if (flowSignsUp && flow !== null && input.hooks !== undefined) {
94
- let session;
95
- try {
96
- session = await sessionFromFlow(flow, input);
97
- }
98
- catch (error) {
99
- session = null;
100
- input.findings
101
- .attributed(ids)
102
- .ownedBy(DISPOSABLE_CHECK_ID)
103
- .backend(DISPOSABLE_CHECK_ID, read.entity ?? "caller", "the disposable signup failed", error instanceof Error ? error.message : "the auth flow did not yield a credential.", []);
104
- input.grades.graded(ids, DISPOSABLE_CHECK_ID);
105
- return;
106
- }
107
- if (session === null) {
108
- input.findings
109
- .attributed(ids)
110
- .ownedBy(DISPOSABLE_CHECK_ID)
111
- .backend(DISPOSABLE_CHECK_ID, read.entity ?? "caller", "the disposable signup failed", "the auth flow did not yield a credential.", []);
112
- input.grades.graded(ids, DISPOSABLE_CHECK_ID);
113
- return;
114
- }
115
- headers = session.headers;
116
- issued = session.known;
117
- adopt = session.adopt;
118
- }
119
- else {
120
- const acquired = await sessionFromRegister(register, input);
121
- if (acquired === null)
122
- return;
123
- headers = acquired.headers;
124
- issued = acquired.issued;
125
- signupExchange = acquired.exchange;
126
- adopt = acquired.adopt;
127
- }
128
- const suiteEmail = flow === null ? undefined : signupEmail(flow);
129
- const scene = createScene({
130
- adopt,
131
- client: input.client,
132
- headers,
133
- known: issued,
134
- model: input.model,
135
- nonce: input.nonce ?? "user",
136
- seed: input.seed ?? 1,
137
- suiteAuth: input.auth,
138
- ...(input.hooks === undefined ? {} : { hooks: input.hooks }),
139
- ...(input.outOfBand === undefined ? {} : { outOfBand: input.outOfBand }),
140
- ...(input.resolveInput === undefined ? {} : { resolveInput: input.resolveInput }),
141
- ...(suiteEmail === undefined ? {} : { suiteEmail }),
142
- });
143
- const ordered = [...ops].sort((a, b) => destructiveRank(a, input.model) - destructiveRank(b, input.model));
144
- for (const op of ordered) {
145
- const blame = input.findings.attributed([op.operationId]).ownedBy(DISPOSABLE_CHECK_ID);
146
- await createOnSamePath(input, op, headers);
147
- const values = await scene.pathValues(op, rootsFor(input.rootsFromFlow, issued));
148
- let path = op.path;
149
- try {
150
- path = fillPath(op.path, values);
151
- }
152
- catch {
153
- blame.unresolved(DISPOSABLE_CHECK_ID, op.entity ?? read.entity ?? "caller", `${op.operationId} needs a path id`);
154
- input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
155
- continue;
156
- }
157
- await scene.reauthenticate();
158
- const sent = await scene.completeBody(op, bodyFrom(requestSchemaOf(op, input.model), issued));
159
- let called = await input.client.request(op.method, path, {
160
- ...(Object.keys(sent).length === 0 ? {} : { body: sent }),
161
- headers,
162
- operationId: op.operationId,
163
- });
164
- let sameUser = true;
165
- if (called.status < 200 || called.status >= 300) {
166
- const recovered = await scene.recover(op, called, values, sent);
167
- if (recovered !== null) {
168
- called = recovered.exchange;
169
- path = recovered.path;
170
- sameUser = recovered.sameUser;
171
- }
172
- }
173
- if (sameUser)
174
- scene.note(called.responseBody, sent, called.status);
175
- if (called.status < 200 || called.status >= 300) {
176
- blame.backend(DISPOSABLE_CHECK_ID, op.entity ?? read.entity ?? "caller", "the disposable call failed", `${op.operationId} returned ${called.status} for the second user.`, signupExchange === undefined ? [called] : [signupExchange, called]);
177
- input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
178
- continue;
179
- }
180
- const caller = await input.client.request(read.method, read.path, {
181
- headers: input.auth,
182
- operationId: read.operationId,
183
- });
184
- if (caller.status !== 200) {
185
- blame.backend(DISPOSABLE_CHECK_ID, read.entity ?? "caller", "the suite caller was logged out", `${op.operationId} ran as the second user, and the suite ${read.operationId} then returned ${caller.status}.`, [called, caller]);
186
- input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
187
- continue;
188
- }
189
- const endsSession = /logout|deleteaccount|account\/delete/i.test(`${op.operationId} ${op.path}`);
190
- if (endsSession) {
191
- const gone = await input.client.request(read.method, read.path, {
192
- headers,
193
- operationId: read.operationId,
194
- });
195
- const refreshToken = typeof sent.refresh_token === "string" ? sent.refresh_token : undefined;
196
- const renew = refreshToken === undefined ? undefined : renewOperation(input.model, op.operationId);
197
- let renewExchange;
198
- if (gone.status !== 401 && renew !== undefined && refreshToken !== undefined) {
199
- renewExchange = await input.client.request(renew.method, renew.path, {
200
- body: { refresh_token: refreshToken },
201
- headers: {},
202
- operationId: renew.operationId,
203
- });
204
- }
205
- /* Logout leaves the account and refuses the session (401). Delete removes the account,
206
- * so the same read is 404. Either way the disposable credential can no longer read it. */
207
- const sessionGone = gone.status === 401 ||
208
- gone.status === 404 ||
209
- (renewExchange !== undefined && renewExchange.status >= 400 && renewExchange.status < 500);
210
- if (!sessionGone) {
211
- blame.backend(DISPOSABLE_CHECK_ID, op.entity ?? read.entity ?? "caller", "the disposable session was still accepted", `${op.operationId} invalidates ${read.operationId}, and that read then returned ${gone.status} with the second user's credential.`, renewExchange === undefined ? [called, gone] : [called, gone, renewExchange]);
212
- }
213
- }
214
- input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
215
- }
43
+ return model.operations.find((op) => op.identity === "self" && op.action === "read" && op.pathParams.length === 0);
216
44
  }
217
- function destructiveRank(op, model) {
218
- const label = `${op.operationId} ${op.path}`;
219
- /* Deleting the account has to see a live reauth window, so it runs after the other calls
220
- * and before logout, which revokes that session. */
221
- if (/deleteaccount|account\/delete/i.test(label))
222
- return 80;
223
- if (/logout/i.test(label))
224
- return 90;
225
- const request = propertyBag(requestSchemaOf(op, model));
226
- const required = requiredNames(requestSchemaOf(op, model));
227
- /* A password rotation revokes every session and wipes trusted devices. */
228
- if ("new_password" in request)
229
- return 70;
230
- if (responseHas(op, model, "revoked_count"))
231
- return 70;
232
- if (op.method === "DELETE" && op.pathParams.length === 0 && deviceCollection(op, model))
233
- return 70;
234
- /* Setting a password is for an account that does not have one yet, and it has to see the
235
- * credential a rotation just issued when both calls are in the same run. */
236
- if (required.includes("password") && !("new_password" in request) && !("current_password" in request))
237
- return 75;
238
- if (op.method === "DELETE" && op.pathParams.length > 0 && deviceItem(op, model))
239
- return 40;
240
- if (responseHas(op, model, "secret", "otpauth_uri"))
241
- return 10;
242
- if (required.includes("code") && responseHas(op, model, "enabled"))
243
- return 20;
244
- if (required.includes("code"))
245
- return 30;
246
- if (responseHas(op, model, "disabled"))
247
- return 50;
248
- /* Leave a nested resource before the organization that contains it. Creating the membership
249
- * needs the caller to still belong to that organization. */
250
- if (op.method === "POST" && op.path.endsWith("/leave")) {
251
- return op.pathParams.includes("organization_id") ? 65 : 55;
252
- }
253
- return 60;
45
+ function invalidatesCaller(op, read) {
46
+ return op.invalidates.some((route) => route === `${read.method.toUpperCase()} ${read.path}` || route === read.route);
254
47
  }
255
48
  function authFlowOf(auth) {
256
49
  if (auth === undefined || isHookAuth(auth) || !("steps" in auth))
257
50
  return null;
258
51
  return auth;
259
52
  }
260
- export function freshSignupFlow(spec, email) {
261
- const from = signupEmail(spec);
262
- const copy = structuredClone(spec);
263
- if (from === undefined || from === email)
264
- return copy;
265
- return rewriteEmail(copy, from, email);
266
- }
267
- export function disposableAddress(spec, nonce) {
268
- const from = signupEmail(spec);
269
- const at = from?.lastIndexOf("@") ?? -1;
270
- const domain = at > 0 && from !== undefined ? from.slice(at + 1) : "example.test";
271
- return `oat-disp-${nonce}@${domain}`;
272
- }
273
53
  function signupEmail(spec) {
274
54
  for (const step of spec.steps) {
275
55
  if ("bind" in step && typeof step.bind?.address === "string")
@@ -282,16 +62,6 @@ function signupEmail(spec) {
282
62
  }
283
63
  return undefined;
284
64
  }
285
- function signupPassword(spec) {
286
- for (const step of spec.steps) {
287
- if ("body" in step && step.body !== null && typeof step.body === "object" && "password" in step.body) {
288
- const password = step.body.password;
289
- if (typeof password === "string")
290
- return password;
291
- }
292
- }
293
- return undefined;
294
- }
295
65
  function rewriteEmail(value, from, to) {
296
66
  if (typeof value === "string")
297
67
  return value.split(from).join(to);
@@ -305,1150 +75,270 @@ function rewriteEmail(value, from, to) {
305
75
  }
306
76
  return value;
307
77
  }
308
- async function sessionFromFlow(flow, input) {
309
- if (input.hooks === undefined)
310
- return null;
311
- const email = disposableAddress(flow, input.nonce ?? "user");
312
- const password = signupPassword(flow);
313
- const principal = await createPrincipal("disposable", freshSignupFlow(flow, email), {
314
- client: input.client,
315
- hooks: input.hooks,
316
- model: input.model,
317
- principalId: "disposable",
318
- ...(input.outOfBand === undefined ? {} : { outOfBand: input.outOfBand }),
319
- });
320
- if (principal.credential() === "")
321
- return null;
322
- const known = { email };
323
- if (password !== undefined) {
324
- known.password = password;
325
- known.current_password = password;
326
- known.new_password = `${password}2`;
327
- }
328
- const refresh = principal.scope.refreshToken;
329
- if (refresh !== undefined)
330
- known.refresh_token = refresh;
331
- for (const [key, value] of Object.entries(principal.scope))
332
- known[key] = value;
333
- return {
334
- adopt(body) {
335
- if (body === null || typeof body !== "object")
336
- return;
337
- const record = body;
338
- if (typeof record.access_token === "string")
339
- principal.adoptCredential(record.access_token);
340
- if (typeof record.refresh_token === "string")
341
- known.refresh_token = record.refresh_token;
342
- },
343
- headers: principal.headers,
344
- known,
345
- };
78
+ /** The suite principal's signup flow, sent for a different address. Nothing else changes. */
79
+ export function freshSignupFlow(spec, email) {
80
+ const from = signupEmail(spec);
81
+ const copy = structuredClone(spec);
82
+ if (from === undefined || from === email)
83
+ return copy;
84
+ return rewriteEmail(copy, from, email);
346
85
  }
347
- async function sessionFromRegister(register, input) {
348
- const ids = destructiveOps(input.model).map((op) => op.operationId);
349
- const read = callerRead(input.model);
86
+ export function disposableAddress(spec, nonce) {
87
+ const from = signupEmail(spec);
88
+ const at = from?.lastIndexOf("@") ?? -1;
89
+ const domain = at > 0 && from !== undefined ? from.slice(at + 1) : "example.test";
90
+ return `oat-disp-${nonce}@${domain}`;
91
+ }
92
+ /** Top-level strings a signup answered with — tokens a later body may need by the same name. */
93
+ function answered(exchange) {
94
+ const body = exchange?.responseBody;
95
+ if (body === null || typeof body !== "object" || Array.isArray(body))
96
+ return {};
97
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => typeof value === "string"));
98
+ }
99
+ async function signUp(input, label) {
100
+ const flow = authFlowOf(input.authFlow);
101
+ /* Only a flow that signs up an address can sign up a different one; a fixed login would just
102
+ * hand back the suite user. */
103
+ if (flow !== null && input.hooks !== undefined && signupEmail(flow) !== undefined) {
104
+ const email = disposableAddress(flow, `${input.nonce ?? "user"}-${label}`);
105
+ const fresh = freshSignupFlow(flow, email);
106
+ const context = {
107
+ client: input.client,
108
+ hooks: input.hooks,
109
+ model: input.model,
110
+ principalId: `disposable-${label}`,
111
+ ...(input.outOfBand === undefined ? {} : { outOfBand: input.outOfBand }),
112
+ };
113
+ const principal = await createPrincipal(`disposable-${label}`, fresh, context);
114
+ if (principal.credential() === "")
115
+ return null;
116
+ const signupId = signupOperationId(fresh);
117
+ const signup = input.client.transcript.findLast((exchange) => exchange.operationId === signupId && exchange.status >= 200 && exchange.status < 300);
118
+ const known = { ...answered(signup), ...principal.scope, email };
119
+ for (const step of fresh.steps) {
120
+ if ("body" in step && step.body !== null && typeof step.body === "object") {
121
+ for (const [key, value] of Object.entries(step.body))
122
+ known[key] ??= value;
123
+ }
124
+ }
125
+ const roots = {};
126
+ for (const [param, key] of Object.entries(input.rootsFromFlow ?? {})) {
127
+ const value = principal.scope[key];
128
+ if (value !== undefined)
129
+ roots[param] = value;
130
+ }
131
+ const refresh = fresh.refresh;
132
+ return {
133
+ headers: principal.headers,
134
+ known,
135
+ roots,
136
+ ...(refresh === undefined
137
+ ? {}
138
+ : {
139
+ renew: async () => {
140
+ const before = input.client.transcript.length;
141
+ try {
142
+ await runAuthSteps(refresh.steps, fresh, context, { ...principal.scope });
143
+ }
144
+ catch {
145
+ /* A refused refresh is what a revoked session should answer. */
146
+ }
147
+ return input.client.transcript.slice(before).at(-1);
148
+ },
149
+ }),
150
+ };
151
+ }
152
+ const register = input.model.operations.find((op) => op.freshPrincipal);
153
+ if (register === undefined)
154
+ return null;
350
155
  const schema = requestSchemaOf(register, input.model);
351
156
  let body = {};
352
157
  if (schema !== null) {
353
- try {
354
- const required = requiredSchema(schema);
355
- const [member] = buildCohort(required, input.seed ?? 1, ["baseline"], register.operationId, {
356
- distinct: propertyNames(required),
357
- ...(input.nonce === undefined ? {} : { nonce: input.nonce }),
358
- });
359
- body = member?.body ?? {};
360
- }
361
- catch (error) {
362
- if (!isOverflowError(error))
363
- throw error;
364
- input.grades.skipped(ids, error instanceof Error ? error.message : "register body could not be generated");
365
- return null;
366
- }
158
+ const [member] = buildCohort(schema, input.seed ?? 1, ["baseline"], register.operationId, {
159
+ nonce: `${input.nonce ?? "user"}${label}`,
160
+ });
161
+ body = member?.body ?? {};
367
162
  if (input.resolveInput !== undefined) {
368
163
  body = await applyResolveInput(body, register.operationId, schema, input.resolveInput);
369
164
  }
370
165
  }
371
- const exchange = await input.client.request(register.method, register.path, {
166
+ const signup = await input.client.request(register.method, register.path, {
372
167
  body,
373
168
  headers: {},
374
169
  operationId: register.operationId,
375
170
  });
376
- const token = tokenOf(exchange.responseBody);
377
- const issued = exchange.responseBody !== null && typeof exchange.responseBody === "object"
378
- ? exchange.responseBody
379
- : {};
380
- if (exchange.status < 200 || exchange.status >= 300 || token === null) {
381
- input.findings
382
- .attributed(ids)
383
- .ownedBy(DISPOSABLE_CHECK_ID)
384
- .backend(DISPOSABLE_CHECK_ID, read?.entity ?? "caller", "the disposable signup failed", `${register.operationId} returned ${exchange.status}.`, [exchange]);
385
- input.grades.graded(ids, DISPOSABLE_CHECK_ID);
171
+ const token = readPath(signup.responseBody, "$.access_token");
172
+ if (signup.status < 200 || signup.status >= 300 || typeof token !== "string" || token === "")
386
173
  return null;
387
- }
388
- if (typeof body.email === "string" && typeof issued.email !== "string")
389
- issued.email = body.email;
390
- if (typeof body.password === "string") {
391
- issued.password = body.password;
392
- issued.current_password = body.password;
393
- issued.new_password = `${body.password}2`;
394
- }
395
- const cred = { token };
396
- return {
397
- adopt(next) {
398
- if (next === null || typeof next !== "object")
399
- return;
400
- const record = next;
401
- if (typeof record.access_token === "string") {
402
- cred.token = record.access_token;
403
- issued.access_token = record.access_token;
404
- }
405
- if (typeof record.refresh_token === "string")
406
- issued.refresh_token = record.refresh_token;
407
- },
408
- exchange,
409
- headers: () => ({ authorization: `Bearer ${cred.token}` }),
410
- issued,
411
- };
174
+ return { headers: () => ({ authorization: `Bearer ${token}` }), known: { ...answered(signup), ...body }, roots: {} };
412
175
  }
413
- function rootsFor(map, known) {
414
- const roots = {};
415
- for (const [param, key] of Object.entries(map ?? {})) {
416
- const value = known[key];
417
- if (typeof value === "string")
418
- roots[param] = value;
419
- }
420
- return roots;
421
- }
422
- function createScene(scene) {
423
- const backups = [];
424
- const issuedCode = { last: "" };
425
- let stampedBearer = null;
426
- const bearer = () => scene.headers().authorization ?? "";
427
- const nextCode = () => harvestCode(scene, issuedCode);
428
- const note = (body, sent, status) => {
429
- const before = bearer();
430
- scene.adopt(body);
431
- if (bearer() !== before)
432
- stampedBearer = null;
433
- if (status < 200 || status >= 300)
434
- return;
435
- if (typeof sent.new_password === "string") {
436
- scene.known.password = sent.new_password;
437
- scene.known.current_password = sent.new_password;
438
- }
439
- remember(body, scene.known, backups);
440
- };
441
- return {
442
- completeBody: (op, body) => completeBody(scene, op, body, nextCode),
443
- note,
444
- pathValues: (op, roots) => pathValues(scene, op, roots, backups, nextCode),
445
- reauthenticate: () => reauthenticate(scene, () => stampedBearer, (value) => {
446
- stampedBearer = value;
447
- }, nextCode),
448
- recover: (op, failed, values, sent) => recover(scene, op, failed, values, sent),
449
- };
450
- }
451
- function remember(body, known, backups) {
176
+ function recordsOf(body, op, model) {
177
+ const raw = model.rawOperations.get(op.operationId);
178
+ const shape = raw === undefined ? null : deriveCollectionShape(successSchema(raw));
452
179
  if (body === null || typeof body !== "object")
453
- return;
454
- const record = body;
455
- if (typeof record.secret === "string" && typeof record.otpauth_uri === "string") {
456
- known.totp_secret = record.secret;
457
- known.totp_uri = record.otpauth_uri;
458
- }
459
- if (record.enabled === true)
460
- known.totp_on = true;
461
- if (record.disabled === true)
462
- known.totp_on = false;
463
- if (Array.isArray(record.backup_codes)) {
464
- const fresh = record.backup_codes.filter((code) => typeof code === "string" && code !== "");
465
- if (fresh.length > 0) {
466
- backups.length = 0;
467
- backups.push(...fresh);
468
- }
469
- }
470
- }
471
- async function reauthenticate(scene, stamped, stamp, nextCode) {
472
- const password = scene.known.password;
473
- if (typeof password !== "string")
474
- return;
475
- const op = reauthOperation(scene.model);
476
- if (op === undefined)
477
- return;
478
- const current = scene.headers().authorization ?? "";
479
- if (current !== "" && current === stamped())
480
- return;
481
- const request = propertyBag(requestSchemaOf(op, scene.model));
482
- const body = {};
483
- if ("password" in request)
484
- body.password = password;
485
- if (scene.known.totp_on === true && "two_factor_code" in request) {
486
- const code = await nextCode();
487
- if (code !== null)
488
- body.two_factor_code = code;
489
- }
490
- if (Object.keys(body).length === 0)
491
- return;
492
- let called = await scene.client.request(op.method, op.path, {
493
- body,
494
- headers: scene.headers,
495
- operationId: op.operationId,
496
- });
497
- if (called.status >= 400 &&
498
- called.status < 500 &&
499
- errorKey(called.responseBody).includes("two_factor") &&
500
- !("two_factor_code" in body)) {
501
- const code = await nextCode();
502
- if (code !== null) {
503
- body.two_factor_code = code;
504
- called = await scene.client.request(op.method, op.path, {
505
- body,
506
- headers: scene.headers,
507
- operationId: op.operationId,
508
- });
509
- scene.known.totp_on = true;
510
- }
511
- }
512
- if (called.status >= 200 && called.status < 300)
513
- stamp(scene.headers().authorization ?? current);
514
- }
515
- function reauthOperation(model) {
516
- return model.operations.find((item) => {
517
- if (!/\/reauth/.test(item.path) || /\/code/.test(item.path))
518
- return false;
519
- return "password" in propertyBag(requestSchemaOf(item, model));
520
- });
521
- }
522
- async function completeBody(scene, op, body, nextCode) {
523
- const schema = requestSchemaOf(op, scene.model);
524
- const required = requiredNames(schema);
525
- if (required.includes("code") && body.code === undefined && typeof scene.known.totp_secret === "string") {
526
- const code = await nextCode();
527
- if (code !== null)
528
- body.code = code;
529
- }
530
- if (required.includes("family_id") && body.family_id === undefined) {
531
- const family = await otherFamilyId(scene, nextCode);
532
- if (family !== null)
533
- body.family_id = family;
534
- }
535
- return body;
536
- }
537
- async function harvestFromSpec(scene, issuedCode) {
538
- const source = scene.model.operations.find((item) => {
539
- const props = propertyBag(requestSchemaOf(item, scene.model));
540
- return "secret" in props && "run_id" in props && responseHas(item, scene.model, "code");
541
- });
542
- const secret = scene.known.totp_secret;
543
- if (source === undefined || typeof secret !== "string")
544
- return null;
545
- for (let attempt = 0; attempt < 3; attempt++) {
546
- const runId = `oat-${scene.nonce}-${attempt}-${Date.now().toString(36)}`.slice(0, 64);
547
- const called = await scene.client.request(source.method, source.path, {
548
- body: { run_id: runId, secret },
549
- headers: scene.headers,
550
- operationId: source.operationId,
551
- });
552
- const code = stringField(called.responseBody, "code");
553
- if (code === null)
554
- return null;
555
- if (code !== issuedCode.last) {
556
- issuedCode.last = code;
557
- return code;
558
- }
559
- await sleep(totpPeriodMs(scene.known));
560
- }
561
- return null;
562
- }
563
- async function harvestCode(scene, issuedCode) {
564
- const fromSpec = await harvestFromSpec(scene, issuedCode);
565
- if (fromSpec !== null)
566
- return fromSpec;
567
- const secret = scene.known.totp_secret;
568
- if (typeof secret !== "string")
569
- return null;
570
- const period = totpPeriodMs(scene.known);
571
- const digits = totpDigits(scene.known);
572
- for (let attempt = 0; attempt < 3; attempt++) {
573
- const code = totpCode(secret, Date.now(), period / 1000, digits);
574
- if (code !== "" && code !== issuedCode.last) {
575
- issuedCode.last = code;
576
- return code;
577
- }
578
- await sleep(period);
579
- }
580
- return null;
581
- }
582
- const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
583
- function base32Decode(input) {
584
- const clean = input
585
- .replace(/=+$/g, "")
586
- .toUpperCase()
587
- .replace(/[^A-Z2-7]/g, "");
588
- let bits = 0;
589
- let value = 0;
590
- const out = [];
591
- for (const char of clean) {
592
- const index = BASE32.indexOf(char);
593
- if (index < 0)
594
- continue;
595
- value = (value << 5) | index;
596
- bits += 5;
597
- if (bits >= 8) {
598
- bits -= 8;
599
- out.push((value >>> bits) & 0xff);
600
- }
601
- }
602
- return Buffer.from(out);
603
- }
604
- /** RFC 6238 TOTP from a base32 secret. `atMs` 0 and the SHA-1 test secret yields `755224`. */
605
- export function totpCode(secret, atMs, periodSeconds = 30, digits = 6) {
606
- if (periodSeconds <= 0 || digits <= 0)
607
- return "";
608
- const counter = Math.floor(atMs / 1000 / periodSeconds);
609
- const key = base32Decode(secret);
610
- if (key.length === 0)
611
- return "";
612
- const message = Buffer.alloc(8);
613
- message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
614
- message.writeUInt32BE(counter >>> 0, 4);
615
- const hmac = createHmac("sha1", key).update(message).digest();
616
- const offset = (hmac.at(-1) ?? 0) & 0x0f;
617
- const bin = ((hmac[offset] ?? 0) & 0x7f) * 2 ** 24 +
618
- (hmac[offset + 1] ?? 0) * 2 ** 16 +
619
- (hmac[offset + 2] ?? 0) * 2 ** 8 +
620
- (hmac[offset + 3] ?? 0);
621
- return String(bin % 10 ** digits).padStart(digits, "0");
180
+ return [];
181
+ const value = shape?.key === null || shape?.key === undefined ? body : body[shape.key];
182
+ if (!Array.isArray(value))
183
+ return [];
184
+ return value.filter((item) => item !== null && typeof item === "object" && !Array.isArray(item));
622
185
  }
623
- function totpDigits(known) {
624
- const uri = known.totp_uri;
625
- if (typeof uri !== "string")
626
- return 6;
627
- try {
628
- const digits = Number(new URL(uri).searchParams.get("digits"));
629
- if (Number.isInteger(digits) && digits > 0)
630
- return digits;
631
- }
632
- catch {
633
- /* The secret's own length is the fallback. */
634
- }
635
- return 6;
186
+ function identityOf(list, model, param) {
187
+ const raw = model.rawOperations.get(list.operationId);
188
+ const shape = raw === undefined ? null : deriveCollectionShape(successSchema(raw));
189
+ const props = effective(shape?.itemSchema ?? null)?.properties;
190
+ if (props !== null && props !== undefined && typeof props === "object" && param in props)
191
+ return param;
192
+ return deriveIdentity(shape?.itemSchema ?? null, param) ?? "id";
636
193
  }
637
- function totpPeriodMs(known) {
638
- const uri = known.totp_uri;
639
- if (typeof uri !== "string")
640
- return 30_000;
641
- try {
642
- const period = Number(new URL(uri).searchParams.get("period"));
643
- if (Number.isFinite(period) && period > 0)
644
- return period * 1000;
645
- }
646
- catch {
647
- /* A secret without a parseable otpauth URI still steps on the common 30s window. */
648
- }
649
- return 30_000;
194
+ /** The collection read one level up — `GET /a/{id}` lists for `DELETE /a/{id}/b/{item}`. */
195
+ function parentList(model, op) {
196
+ const parent = op.path.replace(/\/\{[^}]+\}$/, "");
197
+ return model.operations.find((item) => item.method.toUpperCase() === "GET" && item.path === parent);
650
198
  }
651
- async function otherFamilyId(scene, nextCode) {
652
- const email = scene.known.email;
653
- const password = scene.known.password;
654
- if (typeof email !== "string" || typeof password !== "string")
655
- return null;
656
- const login = scene.model.operations.find((item) => {
657
- if (looksLikeSignup(item))
658
- return false;
659
- const props = propertyBag(requestSchemaOf(item, scene.model));
660
- return "email" in props && "password" in props;
661
- });
662
- if (login === undefined)
663
- return null;
664
- const signed = await scene.client.request(login.method, login.path, {
665
- body: { email, password },
666
- headers: {},
667
- operationId: login.operationId,
668
- });
669
- await finishSecondFactor(scene, signed.responseBody, nextCode, false, []);
670
- const list = scene.model.operations.find((item) => item.method === "GET" && itemHas(item, scene.model, ["id", "current"]));
671
- if (list === undefined)
672
- return null;
673
- let path = list.path;
674
- try {
675
- path = fillPath(list.path, {});
676
- }
677
- catch {
678
- return null;
199
+ function bodyFor(schema, known, generated) {
200
+ if (schema === null)
201
+ return undefined;
202
+ const out = generated !== null && typeof generated === "object" ? { ...generated } : {};
203
+ const props = schema.properties;
204
+ if (props !== null && props !== undefined && typeof props === "object") {
205
+ for (const name of Object.keys(props))
206
+ if (name in known)
207
+ out[name] = known[name];
679
208
  }
680
- const listed = await scene.client.request(list.method, path, {
681
- headers: scene.headers,
682
- operationId: list.operationId,
683
- });
684
- const other = recordsOf(listed.responseBody, list, scene.model).find((row) => row.current === false);
685
- const id = other?.id;
686
- return typeof id === "string" && id !== "" ? id : null;
209
+ return out;
687
210
  }
688
- async function finishSecondFactor(scene, body, nextCode, trust, backups) {
689
- if (body === null || typeof body !== "object")
690
- return;
691
- const record = body;
692
- if (record.requires_2fa !== true || typeof record.temp_token !== "string")
211
+ export async function runDisposableOps(input) {
212
+ const ops = destructiveOps(input.model).filter((op) => input.mode === "full" || input.inScope(op.operationId));
213
+ if (ops.length === 0)
693
214
  return;
694
- const verify = scene.model.operations.find((item) => {
695
- const props = propertyBag(requestSchemaOf(item, scene.model));
696
- return "temp_token" in props && "code" in props;
697
- });
698
- if (verify === undefined)
215
+ input.checksRun?.add(DISPOSABLE_CHECK_ID);
216
+ const selfRead = callerRead(input.model);
217
+ if (selfRead === undefined) {
218
+ input.grades.skipped(ops.map((op) => op.operationId), "no caller read (an identity: self read)");
699
219
  return;
700
- const props = propertyBag(requestSchemaOf(verify, scene.model));
701
- const sent = { temp_token: record.temp_token };
702
- if (trust && "trust_device" in props)
703
- sent.trust_device = true;
704
- const backup = trust && "type" in props ? backups.shift() : undefined;
705
- if (backup !== undefined) {
706
- sent.code = backup;
707
- sent.type = enumValue(props.type, "backup") ?? "backup";
708
- }
709
- else {
710
- const code = await nextCode();
711
- if (code === null)
712
- return;
713
- sent.code = code;
714
- if ("type" in props)
715
- sent.type = enumValue(props.type, "totp") ?? "totp";
716
220
  }
717
- /* The second session must not replace the disposable credential. */
718
- await scene.client.request(verify.method, verify.path, { body: sent, headers: {}, operationId: verify.operationId });
719
- }
720
- async function pathValues(scene, op, roots, backups, nextCode) {
721
- const values = { ...roots };
722
- for (const param of op.pathParams) {
723
- if (values[param] !== undefined)
724
- continue;
725
- const id = await discoverPathId(scene, op, backups, nextCode);
726
- if (id !== null)
727
- values[param] = id;
728
- }
729
- return values;
730
- }
731
- async function discoverPathId(scene, op, backups, nextCode) {
732
- const parent = op.path.replace(/\/\{[^}]+\}$/, "");
733
- const list = scene.model.operations.find((item) => item.method === "GET" && item.path === parent);
734
- if (list === undefined || !itemHas(list, scene.model, ["id"]) || itemHas(list, scene.model, ["current"]))
735
- return null;
736
- const read = async () => {
737
- let path = list.path;
221
+ /* An operation that ends the session gets a user of its own, so no order between them matters. */
222
+ let shared;
223
+ for (const [index, op] of ops.entries()) {
224
+ const ends = invalidatesCaller(op, selfRead);
225
+ let session;
738
226
  try {
739
- path = fillPath(list.path, {});
227
+ session = ends ? await signUp(input, `${index}`) : (shared ??= await signUp(input, "shared"));
740
228
  }
741
- catch {
742
- return null;
743
- }
744
- const listed = await scene.client.request(list.method, path, {
745
- headers: scene.headers,
746
- operationId: list.operationId,
747
- });
748
- const id = recordsOf(listed.responseBody, list, scene.model).find((row) => typeof row.id === "string")?.id;
749
- return typeof id === "string" ? id : null;
750
- };
751
- const existing = await read();
752
- if (existing !== null)
753
- return existing;
754
- if (!itemHas(list, scene.model, ["expires_at"]) || scene.known.totp_on !== true)
755
- return null;
756
- const email = scene.known.email;
757
- const password = scene.known.password;
758
- const login = scene.model.operations.find((item) => {
759
- if (looksLikeSignup(item))
760
- return false;
761
- const props = propertyBag(requestSchemaOf(item, scene.model));
762
- return "email" in props && "password" in props;
763
- });
764
- if (login === undefined || typeof email !== "string" || typeof password !== "string")
765
- return null;
766
- const signed = await scene.client.request(login.method, login.path, {
767
- body: { email, password },
768
- headers: {},
769
- operationId: login.operationId,
770
- });
771
- await finishSecondFactor(scene, signed.responseBody, nextCode, true, backups);
772
- return read();
773
- }
774
- async function recover(scene, op, failed, values, sent) {
775
- const key = errorKey(failed.responseBody);
776
- if (key === "password_already_exists") {
777
- const exchange = await callAsPasswordless(scene, op, sent);
778
- return exchange === null ? null : { exchange, path: failed.url, sameUser: false };
779
- }
780
- if (key.endsWith("last_owner")) {
781
- const handed = op.pathParams.length === 0 ? await releaseOwnedOrgs(scene) : await handOff(scene, op, values);
782
- if (!handed)
783
- return null;
784
- const path = fillPath(op.path, values);
785
- const exchange = await scene.client.request(op.method, path, {
786
- ...(Object.keys(sent).length === 0 ? {} : { body: sent }),
787
- headers: scene.headers,
788
- operationId: op.operationId,
789
- });
790
- return { exchange, path, sameUser: true };
791
- }
792
- if (key.endsWith("not_found") && op.pathParams.length > 0) {
793
- const created = await createOwnedId(scene, op, values);
794
- if (created === null)
795
- return null;
796
- const next = { ...values, [created.param]: created.id };
797
- let path = fillPath(op.path, next);
798
- let exchange = await scene.client.request(op.method, path, {
799
- ...(Object.keys(sent).length === 0 ? {} : { body: sent }),
800
- headers: scene.headers,
801
- operationId: op.operationId,
802
- });
803
- if (errorKey(exchange.responseBody).endsWith("last_owner")) {
804
- const handed = await handOff(scene, op, next);
805
- if (!handed)
806
- return { exchange, path, sameUser: true };
807
- path = fillPath(op.path, next);
808
- exchange = await scene.client.request(op.method, path, {
809
- ...(Object.keys(sent).length === 0 ? {} : { body: sent }),
810
- headers: scene.headers,
811
- operationId: op.operationId,
812
- });
813
- }
814
- return { exchange, path, sameUser: true };
815
- }
816
- return null;
817
- }
818
- async function callAsPasswordless(scene, op, sent) {
819
- if (scene.hooks?.resolveOutOfBand === undefined)
820
- return null;
821
- const start = scene.model.operations.find((item) => {
822
- const props = propertyBag(requestSchemaOf(item, scene.model));
823
- if (!("email" in props) || "password" in props)
824
- return false;
825
- return tokenVerify(scene.model, item) !== undefined;
826
- });
827
- const verify = start === undefined ? undefined : tokenVerify(scene.model, start);
828
- if (start === undefined || verify === undefined)
829
- return null;
830
- const email = `oat-set-${scene.nonce}@${domainOf(scene.known.email)}`;
831
- const opened = await scene.client.request(start.method, start.path, {
832
- body: { email },
833
- headers: {},
834
- operationId: start.operationId,
835
- });
836
- if (opened.status < 200 || opened.status >= 300)
837
- return null;
838
- const token = await mailed(scene, email, mailKind(scene.model, start));
839
- if (token === null)
840
- return null;
841
- const verified = await scene.client.request(verify.method, verify.path, {
842
- body: { token },
843
- headers: {},
844
- operationId: verify.operationId,
845
- });
846
- const access = stringField(verified.responseBody, "access_token");
847
- if (access === null)
848
- return null;
849
- const headers = () => ({ authorization: `Bearer ${access}` });
850
- await reauthByMail(scene, headers, email);
851
- return scene.client.request(op.method, op.path, {
852
- ...(Object.keys(sent).length === 0 ? {} : { body: sent }),
853
- headers,
854
- operationId: op.operationId,
855
- });
856
- }
857
- async function reauthByMail(scene, headers, email) {
858
- const requestCode = scene.model.operations.find((item) => item.path.endsWith("/reauth/code"));
859
- const reauth = reauthOperation(scene.model);
860
- if (requestCode === undefined || reauth === undefined || scene.hooks?.resolveOutOfBand === undefined)
861
- return;
862
- const asked = await scene.client.request(requestCode.method, requestCode.path, {
863
- headers,
864
- operationId: requestCode.operationId,
865
- });
866
- if (asked.status < 200 || asked.status >= 300)
867
- return;
868
- const code = await mailed(scene, email, mailKind(scene.model, requestCode));
869
- if (code === null)
870
- return;
871
- const props = propertyBag(requestSchemaOf(reauth, scene.model));
872
- if (!("email_code" in props))
873
- return;
874
- await scene.client.request(reauth.method, reauth.path, {
875
- body: { email_code: code },
876
- headers,
877
- operationId: reauth.operationId,
878
- });
879
- }
880
- async function releaseOwnedOrgs(scene) {
881
- const leave = scene.model.operations.find((item) => item.method === "POST" && item.path.endsWith("/leave") && item.pathParams.includes("organization_id"));
882
- const list = collectionByParam(scene.model, "organization_id");
883
- if (leave === undefined || list === undefined)
884
- return false;
885
- let path = list.path;
886
- try {
887
- path = fillPath(list.path, {});
888
- }
889
- catch {
890
- return false;
891
- }
892
- const listed = await scene.client.request(list.method, path, {
893
- headers: scene.headers,
894
- operationId: list.operationId,
895
- });
896
- let handed = false;
897
- for (const row of recordsOf(listed.responseBody, list, scene.model)) {
898
- if (typeof row.id !== "string")
229
+ catch (error) {
230
+ input.grades.skipped([op.operationId], `the second user could not sign up: ${error instanceof Error ? error.message : String(error)}`);
899
231
  continue;
900
- handed = (await handOff(scene, leave, { organization_id: row.id })) || handed;
901
- }
902
- return handed;
903
- }
904
- async function handOff(scene, op, values) {
905
- const email = scene.suiteEmail;
906
- if (email === undefined || scene.hooks?.resolveOutOfBand === undefined)
907
- return false;
908
- /* A project owner can hand the project to an organization member, not to a collaborator. */
909
- if (!op.pathParams.includes("organization_id") && values.organization_id !== undefined) {
910
- return handOffThroughOrg(scene, op, values);
911
- }
912
- const invite = scene.model.operations.find((item) => item.invite !== null && item.pathParams.some((param) => op.pathParams.includes(param)));
913
- if (invite?.invite === null || invite?.invite === undefined)
914
- return false;
915
- let invitePath;
916
- try {
917
- invitePath = fillPath(invite.path, values);
918
- }
919
- catch {
920
- return false;
921
- }
922
- const inviteFields = propertyBag(requestSchemaOf(invite, scene.model));
923
- const grantee = invite.invite.granteeField;
924
- const granteeField = grantee in inviteFields ? grantee : "email" in inviteFields ? "email" : grantee;
925
- const invited = await scene.client.request(invite.method, invitePath, {
926
- body: { [granteeField]: email },
927
- headers: scene.headers,
928
- operationId: invite.operationId,
929
- });
930
- const duplicate = errorKey(invited.responseBody).includes("already");
931
- if ((invited.status < 200 || invited.status >= 300) && !duplicate)
932
- return false;
933
- if (!duplicate) {
934
- const mailedToken = invite.invite.tokenFrom === "outOfBand"
935
- ? await mailed(scene, email, invite.invite.tokenKind ?? mailKind(scene.model, invite))
936
- : null;
937
- const pointed = readPath(invited.responseBody, invite.invite.tokenPointer);
938
- const token = mailedToken ?? (typeof pointed === "string" && pointed !== "" ? pointed : null);
939
- if (token === null)
940
- return false;
941
- const accept = scene.model.byOperationId.get(invite.invite.accept);
942
- if (accept === undefined)
943
- return false;
944
- let acceptPath;
945
- try {
946
- acceptPath = fillPath(accept.path, values);
947
232
  }
948
- catch {
949
- return false;
233
+ if (session === null) {
234
+ input.grades.skipped([op.operationId], "no way to sign up a second user (an auth flow or x-fresh-principal)");
235
+ continue;
950
236
  }
951
- const accepted = await scene.client.request(accept.method, acceptPath, {
952
- body: { token },
953
- headers: scene.suiteAuth,
954
- operationId: accept.operationId,
955
- });
956
- if (accepted.status < 200 || accepted.status >= 300)
957
- return false;
958
- }
959
- const members = scene.model.operations.find((item) => item.method === "GET" &&
960
- item.pathParams.some((param) => op.pathParams.includes(param)) &&
961
- itemHas(item, scene.model, ["id", "invite_email"]));
962
- if (members === undefined)
963
- return false;
964
- let memberPath;
965
- try {
966
- memberPath = fillPath(members.path, values);
967
- }
968
- catch {
969
- return false;
970
- }
971
- const listed = await scene.client.request(members.method, memberPath, {
972
- headers: scene.headers,
973
- operationId: members.operationId,
974
- });
975
- const memberId = memberIdFor(recordsOf(listed.responseBody, members, scene.model), email);
976
- if (typeof memberId !== "string")
977
- return false;
978
- const transfer = scene.model.operations.find((item) => {
979
- if (!requiredNames(requestSchemaOf(item, scene.model)).includes("member_id"))
980
- return false;
981
- return item.pathParams.some((param) => op.pathParams.includes(param));
982
- });
983
- if (transfer === undefined)
984
- return false;
985
- let transferPath;
986
- try {
987
- transferPath = fillPath(transfer.path, values);
988
- }
989
- catch {
990
- return false;
991
- }
992
- const moved = await scene.client.request(transfer.method, transferPath, {
993
- body: { member_id: memberId },
994
- headers: scene.headers,
995
- operationId: transfer.operationId,
996
- });
997
- return moved.status >= 200 && moved.status < 300;
998
- }
999
- async function handOffThroughOrg(scene, op, values) {
1000
- const email = scene.suiteEmail;
1001
- const organizationId = values.organization_id;
1002
- if (email === undefined || organizationId === undefined)
1003
- return false;
1004
- const invite = scene.model.operations.find((item) => item.invite !== null && item.pathParams.includes("organization_id"));
1005
- if (invite?.invite === null || invite?.invite === undefined)
1006
- return false;
1007
- const orgValues = { organization_id: organizationId };
1008
- let invitePath;
1009
- try {
1010
- invitePath = fillPath(invite.path, orgValues);
1011
- }
1012
- catch {
1013
- return false;
1014
- }
1015
- const fields = propertyBag(requestSchemaOf(invite, scene.model));
1016
- const grantee = invite.invite.granteeField;
1017
- const granteeField = grantee in fields ? grantee : "email" in fields ? "email" : grantee;
1018
- const invited = await scene.client.request(invite.method, invitePath, {
1019
- body: { [granteeField]: email },
1020
- headers: scene.headers,
1021
- operationId: invite.operationId,
1022
- });
1023
- const duplicate = errorKey(invited.responseBody).includes("already");
1024
- if ((invited.status < 200 || invited.status >= 300) && !duplicate)
1025
- return false;
1026
- if (!duplicate) {
1027
- const token = await mailed(scene, email, invite.invite.tokenKind ?? mailKind(scene.model, invite));
1028
- if (token === null)
1029
- return false;
1030
- const accept = scene.model.byOperationId.get(invite.invite.accept);
1031
- if (accept === undefined)
1032
- return false;
1033
- const accepted = await scene.client.request(accept.method, accept.path, {
1034
- body: { token },
1035
- headers: scene.suiteAuth,
1036
- operationId: accept.operationId,
1037
- });
1038
- if (accepted.status < 200 || accepted.status >= 300)
1039
- return false;
1040
- }
1041
- const members = scene.model.operations.find((item) => item.method === "GET" &&
1042
- item.pathParams.includes("organization_id") &&
1043
- itemHas(item, scene.model, ["id", "user_id", "invite_email"]));
1044
- if (members === undefined)
1045
- return false;
1046
- let memberPath;
1047
- try {
1048
- memberPath = fillPath(members.path, orgValues);
1049
- }
1050
- catch {
1051
- return false;
1052
- }
1053
- const listed = await scene.client.request(members.method, memberPath, {
1054
- headers: scene.headers,
1055
- operationId: members.operationId,
1056
- });
1057
- const people = recordsOf(listed.responseBody, members, scene.model);
1058
- const person = people.find((row) => row.id === memberIdFor(people, email));
1059
- const userId = person?.user_id;
1060
- if (typeof userId !== "string")
1061
- return false;
1062
- const add = scene.model.operations.find((item) => {
1063
- if (item.method !== "POST")
1064
- return false;
1065
- if (!item.pathParams.some((param) => op.pathParams.includes(param)))
1066
- return false;
1067
- return requiredNames(requestSchemaOf(item, scene.model)).includes("user_id");
1068
- });
1069
- if (add === undefined)
1070
- return false;
1071
- let addPath;
1072
- try {
1073
- addPath = fillPath(add.path, values);
1074
- }
1075
- catch {
1076
- return false;
1077
- }
1078
- const schema = requestSchemaOf(add, scene.model);
1079
- const permissions = permissionList(schema);
1080
- const added = await scene.client.request(add.method, addPath, {
1081
- body: permissions === undefined ? { user_id: userId } : { permissions, user_id: userId },
1082
- headers: scene.headers,
1083
- operationId: add.operationId,
1084
- });
1085
- const createdId = stringField(added.responseBody, "id");
1086
- const already = errorKey(added.responseBody).includes("already");
1087
- if ((added.status < 200 || added.status >= 300) && !already)
1088
- return false;
1089
- const memberId = createdId ?? (await projectMemberId(scene, op, values, userId));
1090
- if (memberId === null)
1091
- return false;
1092
- const transfer = scene.model.operations.find((item) => {
1093
- if (!requiredNames(requestSchemaOf(item, scene.model)).includes("member_id"))
1094
- return false;
1095
- return item.pathParams.some((param) => op.pathParams.includes(param));
1096
- });
1097
- if (transfer === undefined)
1098
- return false;
1099
- let transferPath;
1100
- try {
1101
- transferPath = fillPath(transfer.path, values);
1102
- }
1103
- catch {
1104
- return false;
1105
- }
1106
- const moved = await scene.client.request(transfer.method, transferPath, {
1107
- body: { member_id: memberId },
1108
- headers: scene.headers,
1109
- operationId: transfer.operationId,
1110
- });
1111
- return moved.status >= 200 && moved.status < 300;
1112
- }
1113
- async function projectMemberId(scene, op, values, userId) {
1114
- const list = scene.model.operations.find((item) => item.method === "GET" &&
1115
- item.pathParams.some((param) => op.pathParams.includes(param)) &&
1116
- itemHas(item, scene.model, ["id", "user_id"]));
1117
- if (list === undefined)
1118
- return null;
1119
- let path;
1120
- try {
1121
- path = fillPath(list.path, values);
1122
- }
1123
- catch {
1124
- return null;
1125
- }
1126
- const listed = await scene.client.request(list.method, path, {
1127
- headers: scene.headers,
1128
- operationId: list.operationId,
1129
- });
1130
- const rows = recordsOf(listed.responseBody, list, scene.model).filter((row) => row.user_id === userId);
1131
- const direct = rows.find((row) => row.collaborator !== true) ?? rows[0];
1132
- return typeof direct?.id === "string" ? direct.id : null;
1133
- }
1134
- function memberIdFor(records, email) {
1135
- const matches = records.filter((row) => row.invite_email === email || row.email === email);
1136
- /* A second invite can leave a pending row beside the accepted member. Ownership moves to the
1137
- * accepted one; a pending row is refused. */
1138
- const accepted = matches.find((row) => typeof row.accepted_at === "number");
1139
- const chosen = accepted ?? matches[0];
1140
- return typeof chosen?.id === "string" ? chosen.id : null;
1141
- }
1142
- function permissionList(schema) {
1143
- const prop = propertyBag(schema).permissions;
1144
- if (prop === null || typeof prop !== "object")
1145
- return undefined;
1146
- const items = prop.items;
1147
- if (items === null || typeof items !== "object")
1148
- return undefined;
1149
- const values = items.enum;
1150
- if (!Array.isArray(values))
1151
- return undefined;
1152
- const first = values.find((item) => typeof item === "string");
1153
- return first === undefined ? undefined : [first];
1154
- }
1155
- async function createOwnedId(scene, op, values) {
1156
- const param = op.pathParams.find((name) => name.endsWith("_id"));
1157
- if (param === undefined)
1158
- return null;
1159
- const resource = param.slice(0, -"_id".length);
1160
- const plural = resource.endsWith("s") ? resource : `${resource}s`;
1161
- const create = scene.model.operations.find((item) => {
1162
- if (item.method !== "POST" || item.operationId === op.operationId)
1163
- return false;
1164
- if (item.path.split("/").filter(Boolean).at(-1) !== plural)
1165
- return false;
1166
- return responseHas(item, scene.model, "id");
1167
- });
1168
- if (create === undefined)
1169
- return null;
1170
- let path;
1171
- try {
1172
- path = fillPath(create.path, values);
1173
- }
1174
- catch {
1175
- return null;
1176
- }
1177
- const body = await generatedBody(scene, create);
1178
- const created = await scene.client.request(create.method, path, {
1179
- ...(Object.keys(body).length === 0 ? {} : { body }),
1180
- headers: scene.headers,
1181
- operationId: create.operationId,
1182
- });
1183
- const id = stringField(created.responseBody, "id");
1184
- return id === null ? null : { id, param };
1185
- }
1186
- async function generatedBody(scene, op) {
1187
- const schema = requestSchemaOf(op, scene.model);
1188
- if (schema === null)
1189
- return {};
1190
- try {
1191
- const required = requiredSchema(schema);
1192
- const [member] = buildCohort(required, scene.seed, ["baseline"], op.operationId, {
1193
- distinct: propertyNames(required),
1194
- nonce: scene.nonce,
1195
- });
1196
- let body = member?.body ?? {};
1197
- if (scene.resolveInput !== undefined)
1198
- body = await applyResolveInput(body, op.operationId, schema, scene.resolveInput);
1199
- return body;
1200
- }
1201
- catch {
1202
- return {};
1203
- }
1204
- }
1205
- async function mailed(scene, address, kind) {
1206
- if (scene.hooks?.resolveOutOfBand === undefined)
1207
- return null;
1208
- try {
1209
- return await resolveOutOfBandValue(scene.hooks.resolveOutOfBand, address, kind, {
1210
- label: kind,
1211
- ...(scene.outOfBand === undefined ? {} : { outOfBand: scene.outOfBand }),
1212
- });
237
+ await runOne(op, session, selfRead, ends);
1213
238
  }
1214
- catch {
1215
- return null;
1216
- }
1217
- }
1218
- function tokenVerify(model, start) {
1219
- return model.operations.find((item) => {
1220
- if (item.operationId === start.operationId || !item.path.startsWith(start.path))
1221
- return false;
1222
- if (!("token" in propertyBag(requestSchemaOf(item, model))))
1223
- return false;
1224
- return responseHas(item, model, "access_token");
1225
- });
1226
- }
1227
- function mailKind(model, op) {
1228
- const kinds = mailboxKinds(model);
1229
- const segments = op.path.split("/").filter(Boolean);
1230
- const exact = kinds.find((kind) => segments.includes(kind));
1231
- if (exact !== undefined)
1232
- return exact;
1233
- const fuzzy = kinds.find((kind) => segments.some((segment) => segment.length >= 4 && kind.includes(segment)));
1234
- if (fuzzy !== undefined)
1235
- return fuzzy;
1236
- const summary = model.rawOperations.get(op.operationId)?.summary;
1237
- if (typeof summary === "string") {
1238
- const named = summary.toLowerCase().match(/[a-z]+ code/);
1239
- if (named !== null)
1240
- return named[0].replace(" ", "-");
1241
- }
1242
- return segments.at(-1) ?? "mail";
1243
- }
1244
- function mailboxKinds(model) {
1245
- for (const op of model.operations) {
1246
- if (op.method !== "GET")
1247
- continue;
1248
- const params = model.rawOperations.get(op.operationId)?.parameters;
1249
- if (!Array.isArray(params))
1250
- continue;
1251
- for (const param of params) {
1252
- if (param === null || typeof param !== "object")
1253
- continue;
1254
- const record = param;
1255
- if (record.in !== "query" || record.name !== "type")
1256
- continue;
1257
- const schema = record.schema;
1258
- if (schema === null || typeof schema !== "object")
1259
- continue;
1260
- const values = schema.enum;
1261
- if (!Array.isArray(values))
239
+ async function runOne(op, session, read, ends) {
240
+ const blame = input.findings.attributed([op.operationId]).ownedBy(DISPOSABLE_CHECK_ID);
241
+ const entity = op.entity ?? read.entity ?? "caller";
242
+ const values = { ...session.roots };
243
+ for (const param of op.pathParams) {
244
+ if (values[param] !== undefined)
1262
245
  continue;
1263
- const kinds = values.filter((item) => typeof item === "string");
1264
- if (kinds.length > 0)
1265
- return kinds;
246
+ const list = parentList(input.model, op);
247
+ if (list === undefined) {
248
+ input.grades.skipped([op.operationId], `unresolved param ${param}`);
249
+ return;
250
+ }
251
+ const listed = await input.client.request(list.method, fillPath(list.path, values), {
252
+ headers: session.headers,
253
+ operationId: list.operationId,
254
+ });
255
+ const field = identityOf(list, input.model, param);
256
+ const row = recordsOf(listed.responseBody, list, input.model).find((item) => typeof item[field] === "string");
257
+ if (row === undefined) {
258
+ input.grades.skipped([op.operationId], "the list returned no id");
259
+ return;
260
+ }
261
+ values[param] = String(row[field]);
1266
262
  }
1267
- }
1268
- return [];
1269
- }
1270
- function collectionByParam(model, param) {
1271
- const resource = param.endsWith("_id") ? param.slice(0, -"_id".length) : param;
1272
- const plural = resource.endsWith("s") ? resource : `${resource}s`;
1273
- return model.operations.find((item) => {
1274
- if (item.method !== "GET")
1275
- return false;
1276
- if (item.path.split("/").filter(Boolean).at(-1) !== plural)
1277
- return false;
1278
- return itemHas(item, model, ["id"]);
1279
- });
1280
- }
1281
- function deviceCollection(op, model) {
1282
- const list = model.operations.find((item) => item.method === "GET" && item.path === op.path);
1283
- return list !== undefined && itemHas(list, model, ["id", "expires_at"]) && !itemHas(list, model, ["current"]);
1284
- }
1285
- function deviceItem(op, model) {
1286
- const parent = op.path.replace(/\/\{[^}]+\}$/, "");
1287
- const list = model.operations.find((item) => item.method === "GET" && item.path === parent);
1288
- return list !== undefined && itemHas(list, model, ["id", "expires_at"]) && !itemHas(list, model, ["current"]);
1289
- }
1290
- function itemHas(op, model, names) {
1291
- const props = itemProperties(op, model);
1292
- return names.every((name) => name in props);
1293
- }
1294
- function itemProperties(op, model) {
1295
- const raw = model.rawOperations.get(op.operationId);
1296
- const shape = raw === undefined ? null : deriveCollectionShape(successSchema(raw));
1297
- const props = shape?.itemSchema?.properties;
1298
- if (props === null || typeof props !== "object" || Array.isArray(props))
1299
- return {};
1300
- return props;
1301
- }
1302
- function recordsOf(body, op, model) {
1303
- const raw = model.rawOperations.get(op.operationId);
1304
- const shape = raw === undefined ? null : deriveCollectionShape(successSchema(raw));
1305
- if (body === null || typeof body !== "object")
1306
- return [];
1307
- const record = body;
1308
- const value = shape?.key === null || shape?.key === undefined ? body : record[shape.key];
1309
- if (!Array.isArray(value))
1310
- return [];
1311
- return value.filter((item) => item !== null && typeof item === "object" && !Array.isArray(item));
1312
- }
1313
- function responseHas(op, model, ...names) {
1314
- const raw = model.rawOperations.get(op.operationId);
1315
- const schema = raw === undefined ? null : effective(successSchema(raw));
1316
- const props = propertyBag(schema);
1317
- return names.every((name) => name in props);
1318
- }
1319
- function propertyBag(schema, depth = 0) {
1320
- if (schema === null || depth > 6)
1321
- return {};
1322
- const out = {};
1323
- const flat = effective(schema);
1324
- const properties = flat?.properties;
1325
- if (properties !== null && typeof properties === "object" && !Array.isArray(properties)) {
1326
- Object.assign(out, properties);
1327
- }
1328
- for (const key of ["oneOf", "anyOf", "allOf"]) {
1329
- const list = schema[key];
1330
- if (!Array.isArray(list))
1331
- continue;
1332
- for (const item of list) {
1333
- if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1334
- Object.assign(out, propertyBag(item, depth + 1));
263
+ const schema = requestSchemaOf(op, input.model);
264
+ let generated = {};
265
+ if (schema !== null) {
266
+ try {
267
+ generated = buildCohort(schema, input.seed ?? 1, ["baseline"], op.operationId, {
268
+ nonce: `${input.nonce ?? "user"}d`,
269
+ })[0]?.body;
270
+ }
271
+ catch (error) {
272
+ if (!isOverflowError(error))
273
+ throw error;
274
+ }
275
+ if (input.resolveInput !== undefined && generated !== null && typeof generated === "object") {
276
+ generated = await applyResolveInput(generated, op.operationId, schema, input.resolveInput);
1335
277
  }
1336
278
  }
1337
- }
1338
- return out;
1339
- }
1340
- function requiredNames(schema) {
1341
- if (schema === null)
1342
- return [];
1343
- const names = new Set();
1344
- const collect = (node, depth) => {
1345
- if (depth > 6)
279
+ const body = bodyFor(schema, session.known, generated);
280
+ const called = await input.client.request(op.method, fillPath(op.path, values), {
281
+ ...(body === undefined ? {} : { body }),
282
+ headers: session.headers,
283
+ operationId: op.operationId,
284
+ });
285
+ if (called.status >= 500) {
286
+ input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
287
+ blame.backend(DISPOSABLE_CHECK_ID, entity, "the disposable call failed", `${op.operationId} returned ${called.status}.`, [called]);
1346
288
  return;
1347
- if (Array.isArray(node.required)) {
1348
- for (const name of node.required)
1349
- if (typeof name === "string")
1350
- names.add(name);
1351
289
  }
1352
- for (const key of ["oneOf", "anyOf", "allOf"]) {
1353
- const list = node[key];
1354
- if (!Array.isArray(list))
1355
- continue;
1356
- for (const item of list) {
1357
- if (item !== null && typeof item === "object" && !Array.isArray(item))
1358
- collect(item, depth + 1);
1359
- }
290
+ if (called.status < 200 || called.status >= 300) {
291
+ input.grades.skipped([op.operationId], `destructive call returned ${called.status}; a scenario must set the scene`);
292
+ return;
1360
293
  }
1361
- };
1362
- collect(schema, 0);
1363
- return [...names];
1364
- }
1365
- function enumValue(schema, prefer) {
1366
- if (schema === null || typeof schema !== "object")
1367
- return undefined;
1368
- const values = schema.enum;
1369
- if (!Array.isArray(values))
1370
- return prefer;
1371
- const strings = values.filter((item) => typeof item === "string");
1372
- if (strings.includes(prefer))
1373
- return prefer;
1374
- return strings[0];
1375
- }
1376
- function errorKey(body) {
1377
- return stringField(body, "error_key") ?? "";
1378
- }
1379
- function stringField(body, key) {
1380
- if (body === null || typeof body !== "object")
1381
- return null;
1382
- const value = body[key];
1383
- return typeof value === "string" && value !== "" ? value : null;
1384
- }
1385
- function domainOf(email) {
1386
- if (typeof email !== "string")
1387
- return "example.test";
1388
- const at = email.lastIndexOf("@");
1389
- return at > 0 ? email.slice(at + 1) : "example.test";
1390
- }
1391
- async function createOnSamePath(input, op, headers) {
1392
- if (op.method !== "DELETE")
1393
- return;
1394
- const create = input.model.operations.find((item) => item.method === "POST" && item.path === op.path && item.operationId !== op.operationId);
1395
- if (create === undefined)
1396
- return;
1397
- const schema = requestSchemaOf(create, input.model);
1398
- let body = {};
1399
- if (schema !== null) {
1400
- try {
1401
- const required = requiredSchema(schema);
1402
- const [member] = buildCohort(required, input.seed ?? 1, ["baseline"], create.operationId, {
1403
- distinct: propertyNames(required),
1404
- ...(input.nonce === undefined ? {} : { nonce: input.nonce }),
294
+ input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
295
+ const caller = await input.client.request(read.method, read.path, {
296
+ headers: input.auth,
297
+ operationId: read.operationId,
298
+ });
299
+ if (caller.status !== 200) {
300
+ blame.backend(DISPOSABLE_CHECK_ID, read.entity ?? "caller", "the suite caller was logged out", `${op.operationId} ran as the second user, and the suite ${read.operationId} then returned ${caller.status}.`, [called, caller]);
301
+ return;
302
+ }
303
+ let observation;
304
+ const param = op.pathParams.at(-1);
305
+ const list = parentList(input.model, op);
306
+ if (op.method.toUpperCase() === "DELETE" && param !== undefined && list !== undefined) {
307
+ const listed = await input.client.request(list.method, fillPath(list.path, values), {
308
+ headers: session.headers,
309
+ operationId: list.operationId,
1405
310
  });
1406
- body = member?.body ?? {};
311
+ const field = identityOf(list, input.model, param);
312
+ const value = values[param] ?? "";
313
+ if (listed.status >= 200 && listed.status < 300) {
314
+ if (recordsOf(listed.responseBody, list, input.model).some((row) => row[field] === value)) {
315
+ blame.backend(DISPOSABLE_CHECK_ID, entity, "the credential was still listed", `${op.operationId} returned ${called.status}, and the follow-up list still contained ${value}.`, [called, listed]);
316
+ return;
317
+ }
318
+ observation = { absent: true, path: "$", seq: listed.seq, value };
319
+ }
1407
320
  }
1408
- catch {
1409
- return;
321
+ if (ends) {
322
+ const gone = await input.client.request(read.method, read.path, {
323
+ headers: session.headers,
324
+ operationId: read.operationId,
325
+ });
326
+ const renewed = session.renew === undefined ? undefined : await session.renew();
327
+ const refused = gone.status === 401 || gone.status === 403 || gone.status === 404;
328
+ const renewRefused = renewed === undefined || (renewed.status >= 400 && renewed.status < 500);
329
+ if (!refused && !renewRefused) {
330
+ blame.backend(DISPOSABLE_CHECK_ID, entity, "the disposable session was still accepted", `${op.operationId} invalidates ${read.operationId}, and that read then returned ${gone.status} with the second user's credential.`, renewed === undefined ? [called, gone] : [called, gone, renewed]);
331
+ return;
332
+ }
333
+ if (refused && renewed !== undefined && !renewRefused) {
334
+ blame.backend(DISPOSABLE_CHECK_ID, entity, "the refresh token still renews after the session ended", `${op.operationId} ended the session, and the declared refresh then returned ${renewed.status}.`, [called, gone, renewed]);
335
+ return;
336
+ }
337
+ observation = refused
338
+ ? { seq: gone.seq, status: gone.status }
339
+ : { seq: renewed.seq, status: renewed.status };
1410
340
  }
1411
- if (input.resolveInput !== undefined)
1412
- body = await applyResolveInput(body, create.operationId, schema, input.resolveInput);
1413
- }
1414
- await input.client.request(create.method, create.path, {
1415
- ...(Object.keys(body).length === 0 ? {} : { body }),
1416
- headers,
1417
- operationId: create.operationId,
1418
- });
1419
- }
1420
- function renewOperation(model, exceptId) {
1421
- return model.operations.find((op) => {
1422
- if (op.operationId === exceptId)
1423
- return false;
1424
- const schema = requestSchemaOf(op, model);
1425
- const properties = schema?.properties;
1426
- if (properties === null || typeof properties !== "object" || Array.isArray(properties))
1427
- return false;
1428
- return "refresh_token" in properties;
1429
- });
1430
- }
1431
- function bodyFrom(schema, issued) {
1432
- if (schema === null)
1433
- return {};
1434
- const properties = schema.properties;
1435
- if (properties === null || typeof properties !== "object" || Array.isArray(properties))
1436
- return {};
1437
- const body = {};
1438
- for (const name of Object.keys(properties)) {
1439
- const value = issued[name];
1440
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
1441
- body[name] = value;
341
+ input.grades.prove(op.operationId, observation === undefined ? { call: called.seq } : { call: called.seq, observation });
1442
342
  }
1443
- return body;
1444
343
  }
1445
- export const disposableCheck = {
1446
- id: DISPOSABLE_CHECK_ID,
1447
- mutates: true,
1448
- needs: "x-destructive, a register operation, and a caller read",
1449
- plan: () => ({ ok: false }),
1450
- run: () => Promise.resolve(ASSERTED),
1451
- /* Destructive routes have no entity. `runDisposableOps` grades them after the entity loop. */
1452
- subjects: () => [],
1453
- };
1454
344
  //# sourceMappingURL=disposable.js.map