@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.
- package/README.md +6 -1
- package/dist/cli.js +1 -0
- package/dist/config/define-config.d.ts +82 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +6 -1
- package/dist/config/load.js.map +1 -1
- package/dist/config/validate.d.ts.map +1 -1
- package/dist/config/validate.js +1 -0
- package/dist/config/validate.js.map +1 -1
- package/dist/conformance/suite.d.ts.map +1 -1
- package/dist/conformance/suite.js +3 -0
- package/dist/conformance/suite.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/reference/defects.d.ts +7 -0
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +7 -0
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/http.js +1 -1
- package/dist/reference/http.js.map +1 -1
- package/dist/report/render.js +3 -3
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/auth.d.ts +14 -0
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +16 -5
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/check-costs.d.ts.map +1 -1
- package/dist/runtime/check-costs.js +1 -0
- package/dist/runtime/check-costs.js.map +1 -1
- package/dist/runtime/check-ids.d.ts +1 -1
- package/dist/runtime/check-ids.d.ts.map +1 -1
- package/dist/runtime/check-ids.js +1 -0
- package/dist/runtime/check-ids.js.map +1 -1
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +214 -48
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +2 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +6 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/contract.d.ts +52 -0
- package/dist/runtime/contract.d.ts.map +1 -0
- package/dist/runtime/contract.js +261 -0
- package/dist/runtime/contract.js.map +1 -0
- package/dist/runtime/disposable.d.ts +22 -15
- package/dist/runtime/disposable.d.ts.map +1 -1
- package/dist/runtime/disposable.js +250 -1360
- package/dist/runtime/disposable.js.map +1 -1
- package/dist/runtime/effects.d.ts +1 -1
- package/dist/runtime/effects.d.ts.map +1 -1
- package/dist/runtime/evidence.d.ts +21 -0
- package/dist/runtime/evidence.d.ts.map +1 -0
- package/dist/runtime/evidence.js +168 -0
- package/dist/runtime/evidence.js.map +1 -0
- package/dist/runtime/path.d.ts +6 -0
- package/dist/runtime/path.d.ts.map +1 -1
- package/dist/runtime/path.js +46 -0
- package/dist/runtime/path.js.map +1 -1
- package/dist/runtime/run.d.ts +3 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +103 -14
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/scenario.d.ts +39 -0
- package/dist/runtime/scenario.d.ts.map +1 -0
- package/dist/runtime/scenario.js +337 -0
- package/dist/runtime/scenario.js.map +1 -0
- package/dist/runtime/scope.d.ts +28 -0
- package/dist/runtime/scope.d.ts.map +1 -1
- package/dist/runtime/scope.js +31 -4
- package/dist/runtime/scope.js.map +1 -1
- package/dist/runtime/self.d.ts.map +1 -1
- package/dist/runtime/self.js +12 -0
- package/dist/runtime/self.js.map +1 -1
- package/dist/runtime/teardown.d.ts +3 -0
- package/dist/runtime/teardown.d.ts.map +1 -1
- package/dist/runtime/teardown.js +7 -1
- package/dist/runtime/teardown.js.map +1 -1
- package/dist/runtime/transcript.d.ts.map +1 -1
- package/dist/runtime/transcript.js +9 -5
- package/dist/runtime/transcript.js.map +1 -1
- package/dist/spec/collection.d.ts +2 -1
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +13 -2
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/extensions.d.ts +6 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +2 -0
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +8 -0
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +44 -2
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/tag-schema.d.ts.map +1 -1
- package/dist/spec/tag-schema.js +1 -0
- package/dist/spec/tag-schema.js.map +1 -1
- package/docs/configuration.md +43 -1
- package/docs/reports.md +19 -1
- package/docs/tags.md +7 -2
- package/package.json +14 -14
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `x-destructive` runs as a second user
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
348
|
-
const
|
|
349
|
-
const
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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 =
|
|
377
|
-
|
|
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
|
|
414
|
-
const
|
|
415
|
-
|
|
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
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
/*
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
227
|
+
session = ends ? await signUp(input, `${index}`) : (shared ??= await signUp(input, "shared"));
|
|
740
228
|
}
|
|
741
|
-
catch {
|
|
742
|
-
|
|
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
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
|
1264
|
-
if (
|
|
1265
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
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
|