@lovrozagar/oat 0.12.19 → 0.12.22
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/dist/runtime/disposable.d.ts +10 -1
- package/dist/runtime/disposable.d.ts.map +1 -1
- package/dist/runtime/disposable.js +1298 -35
- package/dist/runtime/disposable.js.map +1 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +6 -0
- package/dist/runtime/run.js.map +1 -1
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +7 -1
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/tag-schema.d.ts.map +1 -1
- package/dist/spec/tag-schema.js +1 -1
- package/dist/spec/tag-schema.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
* that read, the disposable credential's next read is 401.
|
|
5
5
|
*/
|
|
6
6
|
import { ASSERTED } from "./finding.js";
|
|
7
|
+
import { deriveCollectionShape, effective, successSchema } from "../spec/collection.js";
|
|
7
8
|
import { buildCohort, isOverflowError } from "./fixture.js";
|
|
8
9
|
import { applyResolveInput } from "./input.js";
|
|
9
|
-
import { requestSchemaOf } from "./world.js";
|
|
10
|
+
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";
|
|
10
15
|
export const DISPOSABLE_CHECK_ID = "auth.destructive-spares-the-caller";
|
|
11
16
|
const REGISTER_LIKE = /register|sign[-_]?up/i;
|
|
12
17
|
export function destructiveOps(model) {
|
|
@@ -37,6 +42,12 @@ function callerRead(model) {
|
|
|
37
42
|
return (reads.find((op) => op.operationId === "auth.getProfile" || op.path === "/v1/auth/me" || op.path === "/v1/me") ??
|
|
38
43
|
reads[0]);
|
|
39
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
|
+
}
|
|
40
51
|
function requiredSchema(schema) {
|
|
41
52
|
const required = Array.isArray(schema.required)
|
|
42
53
|
? schema.required.filter((name) => typeof name === "string")
|
|
@@ -72,51 +83,97 @@ export async function runDisposableOps(input) {
|
|
|
72
83
|
input.grades.skipped(ids, reason);
|
|
73
84
|
return;
|
|
74
85
|
}
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
|
78
95
|
try {
|
|
79
|
-
|
|
80
|
-
body = member?.body ?? {};
|
|
96
|
+
session = await sessionFromFlow(flow, input);
|
|
81
97
|
}
|
|
82
98
|
catch (error) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
|
86
105
|
return;
|
|
87
106
|
}
|
|
88
|
-
if (
|
|
89
|
-
|
|
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;
|
|
90
114
|
}
|
|
115
|
+
headers = session.headers;
|
|
116
|
+
issued = session.known;
|
|
117
|
+
adopt = session.adopt;
|
|
91
118
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
: {};
|
|
101
|
-
if (signed.status < 200 || signed.status >= 300 || token === null) {
|
|
102
|
-
input.findings
|
|
103
|
-
.attributed(ids)
|
|
104
|
-
.ownedBy(DISPOSABLE_CHECK_ID)
|
|
105
|
-
.backend(DISPOSABLE_CHECK_ID, read.entity ?? "caller", "the disposable signup failed", `${register.operationId} returned ${signed.status}.`, [signed]);
|
|
106
|
-
input.grades.graded(ids, DISPOSABLE_CHECK_ID);
|
|
107
|
-
return;
|
|
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;
|
|
108
127
|
}
|
|
109
|
-
const
|
|
110
|
-
|
|
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) {
|
|
111
145
|
const blame = input.findings.attributed([op.operationId]).ownedBy(DISPOSABLE_CHECK_ID);
|
|
112
|
-
|
|
113
|
-
const
|
|
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, {
|
|
114
160
|
...(Object.keys(sent).length === 0 ? {} : { body: sent }),
|
|
115
|
-
headers
|
|
161
|
+
headers,
|
|
116
162
|
operationId: op.operationId,
|
|
117
163
|
});
|
|
164
|
+
let sameUser = true;
|
|
118
165
|
if (called.status < 200 || called.status >= 300) {
|
|
119
|
-
|
|
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]);
|
|
120
177
|
input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
|
|
121
178
|
continue;
|
|
122
179
|
}
|
|
@@ -129,10 +186,10 @@ export async function runDisposableOps(input) {
|
|
|
129
186
|
input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
|
|
130
187
|
continue;
|
|
131
188
|
}
|
|
132
|
-
const endsSession = op.
|
|
189
|
+
const endsSession = /logout|deleteaccount|account\/delete/i.test(`${op.operationId} ${op.path}`);
|
|
133
190
|
if (endsSession) {
|
|
134
191
|
const gone = await input.client.request(read.method, read.path, {
|
|
135
|
-
headers
|
|
192
|
+
headers,
|
|
136
193
|
operationId: read.operationId,
|
|
137
194
|
});
|
|
138
195
|
const refreshToken = typeof sent.refresh_token === "string" ? sent.refresh_token : undefined;
|
|
@@ -145,7 +202,10 @@ export async function runDisposableOps(input) {
|
|
|
145
202
|
operationId: renew.operationId,
|
|
146
203
|
});
|
|
147
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. */
|
|
148
207
|
const sessionGone = gone.status === 401 ||
|
|
208
|
+
gone.status === 404 ||
|
|
149
209
|
(renewExchange !== undefined && renewExchange.status >= 400 && renewExchange.status < 500);
|
|
150
210
|
if (!sessionGone) {
|
|
151
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]);
|
|
@@ -154,6 +214,1209 @@ export async function runDisposableOps(input) {
|
|
|
154
214
|
input.grades.graded([op.operationId], DISPOSABLE_CHECK_ID);
|
|
155
215
|
}
|
|
156
216
|
}
|
|
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;
|
|
254
|
+
}
|
|
255
|
+
function authFlowOf(auth) {
|
|
256
|
+
if (auth === undefined || isHookAuth(auth) || !("steps" in auth))
|
|
257
|
+
return null;
|
|
258
|
+
return auth;
|
|
259
|
+
}
|
|
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
|
+
function signupEmail(spec) {
|
|
274
|
+
for (const step of spec.steps) {
|
|
275
|
+
if ("bind" in step && typeof step.bind?.address === "string")
|
|
276
|
+
return step.bind.address;
|
|
277
|
+
if ("body" in step && step.body !== null && typeof step.body === "object" && "email" in step.body) {
|
|
278
|
+
const email = step.body.email;
|
|
279
|
+
if (typeof email === "string")
|
|
280
|
+
return email;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
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
|
+
function rewriteEmail(value, from, to) {
|
|
296
|
+
if (typeof value === "string")
|
|
297
|
+
return value.split(from).join(to);
|
|
298
|
+
if (Array.isArray(value))
|
|
299
|
+
return value.map((item) => rewriteEmail(item, from, to));
|
|
300
|
+
if (value !== null && typeof value === "object") {
|
|
301
|
+
const out = {};
|
|
302
|
+
for (const [key, item] of Object.entries(value))
|
|
303
|
+
out[key] = rewriteEmail(item, from, to);
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
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
|
+
};
|
|
346
|
+
}
|
|
347
|
+
async function sessionFromRegister(register, input) {
|
|
348
|
+
const ids = destructiveOps(input.model).map((op) => op.operationId);
|
|
349
|
+
const read = callerRead(input.model);
|
|
350
|
+
const schema = requestSchemaOf(register, input.model);
|
|
351
|
+
let body = {};
|
|
352
|
+
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
|
+
}
|
|
367
|
+
if (input.resolveInput !== undefined) {
|
|
368
|
+
body = await applyResolveInput(body, register.operationId, schema, input.resolveInput);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const exchange = await input.client.request(register.method, register.path, {
|
|
372
|
+
body,
|
|
373
|
+
headers: {},
|
|
374
|
+
operationId: register.operationId,
|
|
375
|
+
});
|
|
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);
|
|
386
|
+
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
|
+
};
|
|
412
|
+
}
|
|
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) {
|
|
452
|
+
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");
|
|
622
|
+
}
|
|
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;
|
|
636
|
+
}
|
|
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;
|
|
650
|
+
}
|
|
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;
|
|
679
|
+
}
|
|
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;
|
|
687
|
+
}
|
|
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")
|
|
693
|
+
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)
|
|
699
|
+
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
|
+
}
|
|
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;
|
|
738
|
+
try {
|
|
739
|
+
path = fillPath(list.path, {});
|
|
740
|
+
}
|
|
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")
|
|
899
|
+
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
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
return false;
|
|
950
|
+
}
|
|
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
|
+
});
|
|
1213
|
+
}
|
|
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))
|
|
1262
|
+
continue;
|
|
1263
|
+
const kinds = values.filter((item) => typeof item === "string");
|
|
1264
|
+
if (kinds.length > 0)
|
|
1265
|
+
return kinds;
|
|
1266
|
+
}
|
|
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));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
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)
|
|
1346
|
+
return;
|
|
1347
|
+
if (Array.isArray(node.required)) {
|
|
1348
|
+
for (const name of node.required)
|
|
1349
|
+
if (typeof name === "string")
|
|
1350
|
+
names.add(name);
|
|
1351
|
+
}
|
|
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
|
+
}
|
|
1360
|
+
}
|
|
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 }),
|
|
1405
|
+
});
|
|
1406
|
+
body = member?.body ?? {};
|
|
1407
|
+
}
|
|
1408
|
+
catch {
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
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
|
+
}
|
|
157
1420
|
function renewOperation(model, exceptId) {
|
|
158
1421
|
return model.operations.find((op) => {
|
|
159
1422
|
if (op.operationId === exceptId)
|