@lovrozagar/oat 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +60 -1
  2. package/dist/cli.js +17 -1
  3. package/dist/reference/http.d.ts.map +1 -1
  4. package/dist/reference/http.js +2 -2
  5. package/dist/reference/http.js.map +1 -1
  6. package/dist/report/console.d.ts.map +1 -1
  7. package/dist/report/console.js +18 -0
  8. package/dist/report/console.js.map +1 -1
  9. package/dist/runtime/checks.d.ts.map +1 -1
  10. package/dist/runtime/checks.js +122 -31
  11. package/dist/runtime/checks.js.map +1 -1
  12. package/dist/runtime/feature-gate.d.ts +23 -0
  13. package/dist/runtime/feature-gate.d.ts.map +1 -0
  14. package/dist/runtime/feature-gate.js +68 -0
  15. package/dist/runtime/feature-gate.js.map +1 -0
  16. package/dist/runtime/fixture.d.ts +28 -1
  17. package/dist/runtime/fixture.d.ts.map +1 -1
  18. package/dist/runtime/fixture.js +551 -90
  19. package/dist/runtime/fixture.js.map +1 -1
  20. package/dist/runtime/run.d.ts.map +1 -1
  21. package/dist/runtime/run.js +135 -41
  22. package/dist/runtime/run.js.map +1 -1
  23. package/dist/runtime/world.d.ts +17 -1
  24. package/dist/runtime/world.d.ts.map +1 -1
  25. package/dist/runtime/world.js +56 -4
  26. package/dist/runtime/world.js.map +1 -1
  27. package/dist/spec/extensions.d.ts +7 -0
  28. package/dist/spec/extensions.d.ts.map +1 -1
  29. package/dist/spec/extensions.js +10 -0
  30. package/dist/spec/extensions.js.map +1 -1
  31. package/dist/spec/graph.d.ts +2 -0
  32. package/dist/spec/graph.d.ts.map +1 -1
  33. package/dist/spec/graph.js +7 -2
  34. package/dist/spec/graph.js.map +1 -1
  35. package/dist/spec/load.d.ts.map +1 -1
  36. package/dist/spec/load.js +19 -4
  37. package/dist/spec/load.js.map +1 -1
  38. package/labs/annotated-openapi.yaml +6 -0
  39. package/package.json +3 -2
@@ -6,10 +6,29 @@
6
6
  * the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
7
7
  */
8
8
  import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
9
+ import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
9
10
  import { driveAsync } from "./async.js";
10
11
  import { buildCohort } from "./fixture.js";
11
12
  import { fillPath } from "./world.js";
12
13
  /* ------------------------------------------------------------------- helpers */
14
+ /**
15
+ * A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
16
+ *
17
+ * The check that needed a success stands down with a named gap. The 403 body is still
18
+ * validated against the documented error schema — coverage is not a free pass for drift.
19
+ */
20
+ function standDownForFeatureGate(ctx, op, exchange, check) {
21
+ if (op === undefined)
22
+ return false;
23
+ if (!isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
24
+ return false;
25
+ if (ctx.validator !== undefined) {
26
+ reportFeatureGateSchemaDrift(ctx.findings, ctx.validator, op, ctx.model.rawOperations.get(op.operationId), exchange, ctx.entityName);
27
+ }
28
+ ctx.findings.gap(check, ctx.entityName, `${op.operationId} did not apply`, `${describeFeatureGate(op, exchange.responseBody)}. The check that needed a 2xx stands ` +
29
+ "down rather than treat the documented 403 as a defect.");
30
+ return true;
31
+ }
13
32
  /** The list endpoint's derived conventions — parameter roles and envelope spellings. */
14
33
  function conv(ctx) {
15
34
  return ctx.listOp.conventions;
@@ -977,6 +996,8 @@ const patchMinimality = {
977
996
  const drifting = new Set([
978
997
  "updated_at",
979
998
  "modified_at",
999
+ /* A job's progress counter advances on GET without this PATCH having written it. */
1000
+ "progress",
980
1001
  /*
981
1002
  * From both operations, not just the one being probed. A document commonly declares
982
1003
  * its server-owned fields on `create` — that is where they are conspicuous, being the
@@ -1001,6 +1022,8 @@ const patchMinimality = {
1001
1022
  body: { [field]: "oat patched value" },
1002
1023
  headers: ctx.auth(),
1003
1024
  });
1025
+ if (standDownForFeatureGate(ctx, ctx.updateOp, patched, this.id))
1026
+ return;
1004
1027
  if (patched.status >= 300)
1005
1028
  return;
1006
1029
  const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
@@ -1077,6 +1100,8 @@ const immutableRejected = {
1077
1100
  body: { [field]: probe },
1078
1101
  headers: ctx.auth(),
1079
1102
  });
1103
+ if (standDownForFeatureGate(ctx, ctx.updateOp, patched, this.id))
1104
+ return;
1080
1105
  if (patched.status >= 400)
1081
1106
  return;
1082
1107
  /* Read the echoed record first. Re-reading can itself fail once the write lands — writing
@@ -1177,6 +1202,8 @@ const deleteMissingIs404 = {
1177
1202
  const exchange = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
1178
1203
  headers: ctx.auth(),
1179
1204
  });
1205
+ if (standDownForFeatureGate(ctx, ctx.deleteOp, exchange, this.id))
1206
+ return;
1180
1207
  if (exchange.status === 404 || exchange.status === 410 || exchange.status === 400)
1181
1208
  return;
1182
1209
  if (exchange.status >= 300)
@@ -1202,6 +1229,8 @@ const softDeleteHidden = {
1202
1229
  const deleted = await ctx.client.request("DELETE", fillPath(ctx.deleteOp.path, params), {
1203
1230
  headers: ctx.auth(),
1204
1231
  });
1232
+ if (standDownForFeatureGate(ctx, ctx.deleteOp, deleted, this.id))
1233
+ return;
1205
1234
  if (deleted.status >= 300) {
1206
1235
  return ctx.findings.unresolved(this.id, ctx.entityName, `DELETE returned ${deleted.status}, so no tombstone was created to look for`);
1207
1236
  }
@@ -1290,10 +1319,14 @@ const idempotentReplay = {
1290
1319
  const path = fillPath(createOp.path, ctx.scope);
1291
1320
  const headers = { ...ctx.auth(), [header]: key };
1292
1321
  const first = await ctx.client.request("POST", path, { body, headers });
1322
+ if (standDownForFeatureGate(ctx, createOp, first, this.id))
1323
+ return;
1293
1324
  if (first.status >= 300) {
1294
1325
  return ctx.findings.unresolved(this.id, ctx.entityName, `the first create returned ${first.status}, so there was nothing to replay`);
1295
1326
  }
1296
1327
  const second = await ctx.client.request("POST", path, { body, headers });
1328
+ if (standDownForFeatureGate(ctx, createOp, second, this.id))
1329
+ return;
1297
1330
  if (second.status >= 300) {
1298
1331
  return ctx.findings.unresolved(this.id, ctx.entityName, `the replay returned ${second.status}; a repeated key must be answered, not refused`);
1299
1332
  }
@@ -1365,6 +1398,8 @@ const declaredInvalidationHappens = {
1365
1398
  body: validBody(ctx, schema),
1366
1399
  headers: ctx.auth(),
1367
1400
  });
1401
+ if (standDownForFeatureGate(ctx, createOp, created, this.id))
1402
+ return;
1368
1403
  if (created.status >= 300) {
1369
1404
  return ctx.findings.unresolved(this.id, ctx.entityName, `the create returned ${created.status}, so nothing was invalidated`);
1370
1405
  }
@@ -2508,25 +2543,20 @@ function pointerValue(body, pointer) {
2508
2543
  * at the same check — the timeline is the property.
2509
2544
  */
2510
2545
  const inviteGrantsThenRevokes = {
2511
- applicable: (ctx) => ctx.invite !== null &&
2512
- ctx.readOp !== undefined &&
2513
- ctx.records.length > 0 &&
2514
- ctx.actors.some((actor) => actor.inviteAs !== undefined &&
2515
- ctx.actors[0] !== undefined &&
2516
- !sameTenantScope(actor.roots, ctx.actors[0].roots)),
2546
+ applicable: (ctx) => ctx.invite !== null && (ctx.readOp !== undefined || ctx.listOp !== undefined),
2517
2547
  dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
2518
2548
  id: "auth.invite-grants-then-revokes",
2519
- needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item route",
2549
+ needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item or list route",
2520
2550
  async run(ctx) {
2521
2551
  const spec = ctx.invite;
2522
2552
  const owner = ctx.actors[0];
2523
- const target = ctx.records[0];
2524
- if (spec === null || owner === undefined || target === undefined || ctx.readOp === undefined) {
2553
+ if (spec === null || owner === undefined) {
2525
2554
  return;
2526
2555
  }
2527
- const delegate = ctx.actors.find((actor) => actor.inviteAs !== undefined && !sameTenantScope(actor.roots, owner.roots));
2556
+ const delegate = ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined && !sameTenantScope(actor.roots, owner.roots)) ?? ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined);
2528
2557
  if (delegate === undefined || delegate.inviteAs === undefined) {
2529
- return ctx.findings.unresolved(this.id, ctx.entityName, "no peer principal declares inviteAs, so there is no one to invite");
2558
+ return ctx.findings.gap(this.id, ctx.entityName, "x-invite requires a peer principal with inviteAs", "x-invite names the grant flow, but no peer principal declares inviteAs, so oat " +
2559
+ "cannot fill the grantee field without inventing an email");
2530
2560
  }
2531
2561
  const inviteOp = ctx.model.byOperationId.get(spec.invite);
2532
2562
  const acceptOp = ctx.model.byOperationId.get(spec.accept);
@@ -2534,26 +2564,35 @@ const inviteGrantsThenRevokes = {
2534
2564
  if (inviteOp === undefined || acceptOp === undefined || revokeOp === undefined) {
2535
2565
  return ctx.findings.unresolved(this.id, ctx.entityName, "x-invite names an operation that is not in the document");
2536
2566
  }
2537
- const id = String(target[ctx.identity]);
2538
- const resource = { ...ctx.scope, ...itemParamFor(ctx, id) };
2539
- const itemPath = ctx.readOp.path;
2567
+ const target = ctx.records[0];
2568
+ const id = target === undefined ? undefined : String(target[ctx.identity]);
2569
+ const resource = {
2570
+ ...ctx.scope,
2571
+ ...(id === undefined ? {} : itemParamFor(ctx, id)),
2572
+ };
2573
+ const itemPath = ctx.readOp?.path;
2540
2574
  const canRead = async () => {
2575
+ if (itemPath === undefined || id === undefined)
2576
+ return null;
2541
2577
  const exchange = await ctx.client.get(fillPath(itemPath, { ...delegate.roots, ...resource }), {
2542
2578
  headers: delegate.headers(),
2543
2579
  });
2544
2580
  return exchange.status < 300;
2545
2581
  };
2546
- if (await canRead()) {
2582
+ const alreadyReadable = await canRead();
2583
+ if (alreadyReadable === true) {
2547
2584
  return ctx.findings.unresolved(this.id, ctx.entityName, "the delegate could already read the record, so invite cannot be distinguished from a leak");
2548
2585
  }
2549
2586
  const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
2550
2587
  body: { [spec.granteeField]: delegate.inviteAs },
2551
2588
  headers: { ...owner.headers(), "content-type": "application/json" },
2552
2589
  });
2590
+ if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
2591
+ return;
2553
2592
  if (invited.status >= 300) {
2554
2593
  return ctx.findings.unresolved(this.id, ctx.entityName, `invite returned ${invited.status}, so accept/revoke were never exercised`);
2555
2594
  }
2556
- if (await canRead()) {
2595
+ if ((await canRead()) === true) {
2557
2596
  ctx.findings.backend(this.id, ctx.entityName, "an invite grants access before it is accepted", `inviting ${delegate.id} made ${ctx.entityName} ${id} readable immediately. The accept ` +
2558
2597
  "step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
2559
2598
  return;
@@ -2566,10 +2605,12 @@ const inviteGrantsThenRevokes = {
2566
2605
  const accepted = await ctx.client.request(acceptOp.method, fillPath(acceptOp.path, { token }), {
2567
2606
  headers: delegate.headers(),
2568
2607
  });
2608
+ if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
2609
+ return;
2569
2610
  if (accepted.status >= 300) {
2570
2611
  return ctx.findings.unresolved(this.id, ctx.entityName, `accept returned ${accepted.status}`);
2571
2612
  }
2572
- if (!(await canRead())) {
2613
+ if ((await canRead()) === false) {
2573
2614
  ctx.findings.backend(this.id, ctx.entityName, "accepting an invite does not grant access", `${delegate.id} accepted an invite to ${ctx.entityName} ${id} and still cannot read it. ` +
2574
2615
  "The invite flow completed; the grant did not.", [invited, accepted]);
2575
2616
  return;
@@ -2578,10 +2619,12 @@ const inviteGrantsThenRevokes = {
2578
2619
  return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no grant id at ${spec.grantPointer}, so revoke cannot be expressed`);
2579
2620
  }
2580
2621
  const revoked = await ctx.client.request(revokeOp.method, fillPath(revokeOp.path, { ...resource, grant_id: grantId }), { headers: owner.headers() });
2622
+ if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
2623
+ return;
2581
2624
  if (revoked.status >= 300) {
2582
2625
  return ctx.findings.unresolved(this.id, ctx.entityName, `revoke returned ${revoked.status}`);
2583
2626
  }
2584
- if (await canRead()) {
2627
+ if ((await canRead()) === true) {
2585
2628
  ctx.findings.backend(this.id, ctx.entityName, "revoking an invite does not remove access", `${delegate.id} can still read ${ctx.entityName} ${id} after the grant was revoked. ` +
2586
2629
  "A share that cannot be taken back is a standing leak.", [invited, accepted, revoked]);
2587
2630
  }
@@ -2771,6 +2814,8 @@ const enumValidated = {
2771
2814
  body,
2772
2815
  headers: ctx.auth(),
2773
2816
  });
2817
+ if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
2818
+ return;
2774
2819
  if (exchange.status >= 400)
2775
2820
  return;
2776
2821
  const declared = target.schema.enum.map((v) => JSON.stringify(v)).join(", ");
@@ -2800,6 +2845,8 @@ const maxLengthValidated = {
2800
2845
  body,
2801
2846
  headers: ctx.auth(),
2802
2847
  });
2848
+ if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
2849
+ return;
2803
2850
  if (exchange.status >= 400)
2804
2851
  return;
2805
2852
  ctx.findings.backend(this.id, ctx.entityName, "a string longer than the declared maxLength was accepted", `"${target.name}" declares maxLength ${max} but a ${max + 25}-character value was stored ` +
@@ -2827,6 +2874,8 @@ const requiredValidated = {
2827
2874
  body,
2828
2875
  headers: ctx.auth(),
2829
2876
  });
2877
+ if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
2878
+ return;
2830
2879
  if (exchange.status >= 400)
2831
2880
  return;
2832
2881
  ctx.findings.backend(this.id, ctx.entityName, "create succeeded without a required field", `"${field}" is listed in the schema's required array, but omitting it returned ` +
@@ -2851,6 +2900,8 @@ const contentTypeEnforced = {
2851
2900
  contentType: "text/plain",
2852
2901
  headers: ctx.auth(),
2853
2902
  });
2903
+ if (standDownForFeatureGate(ctx, createOp, exchange, this.id))
2904
+ return;
2854
2905
  if (exchange.status === 415 || exchange.status >= 400)
2855
2906
  return;
2856
2907
  ctx.findings.backend(this.id, ctx.entityName, "a non-JSON content type was accepted on a JSON endpoint", `the operation documents 415 and declares only a JSON request body, but a request sent ` +
@@ -2872,16 +2923,33 @@ const errorSchemaHonoured = {
2872
2923
  return;
2873
2924
  const params = { ...ctx.scope, ...itemParamFor(ctx, "oat-definitely-missing-id") };
2874
2925
  const exchange = await ctx.client.get(fillPath(readOp.path, params), { headers: ctx.auth() });
2875
- if (exchange.status < 400)
2876
- return;
2877
- if (!validator.documents(raw, exchange.status))
2878
- return;
2879
- const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
2880
- if (result.ok)
2881
- return;
2882
- ctx.findings.spec(this.id, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
2883
- `document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
2884
- "from the spec will not understand this response.", [exchange]);
2926
+ if (exchange.status >= 400 && validator.documents(raw, exchange.status)) {
2927
+ const result = validator.validate(readOp.operationId, raw, exchange.status, exchange.responseBody);
2928
+ if (!result.ok) {
2929
+ ctx.findings.spec(this.id, ctx.entityName, `${exchange.status} error body does not match its documented schema`, `${readOp.operationId} returned ${exchange.status} with a body that fails the schema the ` +
2930
+ `document declares for it: ${result.errors.join("; ")}. Clients that parse errors ` +
2931
+ "from the spec will not understand this response.", [exchange]);
2932
+ }
2933
+ }
2934
+ /* A documented feature-gate 403 is coverage for the 2xx check, not for the error schema.
2935
+ * Walk this entity's already-observed gate denials so an undeclared body is still drift. */
2936
+ const seen = new Set();
2937
+ for (const op of ctx.model.operations) {
2938
+ if (op.entity !== ctx.entityName || op.featureGate === null)
2939
+ continue;
2940
+ const opRaw = ctx.model.rawOperations.get(op.operationId);
2941
+ if (opRaw === undefined)
2942
+ continue;
2943
+ for (const prior of ctx.client.transcript) {
2944
+ if (!isDocumentedFeatureGateDenial(op, prior.status, prior.responseBody))
2945
+ continue;
2946
+ const key = `${op.operationId}:${prior.seq}`;
2947
+ if (seen.has(key))
2948
+ continue;
2949
+ seen.add(key);
2950
+ reportFeatureGateSchemaDrift(ctx.findings, validator, op, opRaw, prior, ctx.entityName);
2951
+ }
2952
+ }
2885
2953
  },
2886
2954
  };
2887
2955
  const successSchemaHonoured = {
@@ -2917,9 +2985,24 @@ const successSchemaHonoured = {
2917
2985
  };
2918
2986
  /** Builds a body that should be accepted, for use as the base of a negative probe. */
2919
2987
  function validBody(ctx, schema) {
2920
- const [member] = buildCohort(schema, ctx.seed, ["baseline"]);
2988
+ const [member] = buildCohort(schema, ctx.seed, ["baseline"], ctx.createOp?.operationId ?? ctx.entityName);
2921
2989
  return member?.body ?? {};
2922
2990
  }
2991
+ /** Invite bodies must carry the peer's `inviteAs`, never a generated email. */
2992
+ function bodyForOp(ctx, op) {
2993
+ const body = validBody(ctx, requestSchemaOf(ctx, op) ?? {});
2994
+ const spec = op.invite ?? ctx.invite;
2995
+ if (spec === null || spec === undefined)
2996
+ return body;
2997
+ const owner = ctx.actors[0];
2998
+ const delegate = ctx.actors.find((actor) => actor !== owner &&
2999
+ actor.inviteAs !== undefined &&
3000
+ owner !== undefined &&
3001
+ !sameTenantScope(actor.roots, owner.roots)) ?? ctx.actors.find((actor) => actor !== owner && actor.inviteAs !== undefined);
3002
+ if (delegate?.inviteAs !== undefined)
3003
+ body[spec.granteeField] = delegate.inviteAs;
3004
+ return body;
3005
+ }
2923
3006
  function requestSchemaOf(ctx, op) {
2924
3007
  const raw = ctx.model.rawOperations.get(op.operationId);
2925
3008
  const content = raw?.requestBody?.content;
@@ -3085,6 +3168,10 @@ const noLostUpdate = {
3085
3168
  headers: ctx.auth(),
3086
3169
  }),
3087
3170
  ]);
3171
+ if (standDownForFeatureGate(ctx, ctx.updateOp, firstWrite, this.id))
3172
+ return;
3173
+ if (standDownForFeatureGate(ctx, ctx.updateOp, secondWrite, this.id))
3174
+ return;
3088
3175
  if (firstWrite.status >= 300 || secondWrite.status >= 300) {
3089
3176
  return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no ` +
3090
3177
  "race actually took place");
@@ -3175,11 +3262,13 @@ const declaredEffectsOccur = {
3175
3262
  const before = await observe(ctx, listOp);
3176
3263
  if (before === null)
3177
3264
  continue;
3178
- const body = op.hasRequestBody ? validBody(ctx, requestSchemaOf(ctx, op) ?? {}) : undefined;
3265
+ const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3179
3266
  const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3180
3267
  headers: ctx.auth(),
3181
3268
  ...(body === undefined ? {} : { body }),
3182
3269
  });
3270
+ if (standDownForFeatureGate(ctx, op, invoked, this.id))
3271
+ continue;
3183
3272
  if (invoked.status >= 400) {
3184
3273
  ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`);
3185
3274
  continue;
@@ -3245,11 +3334,13 @@ const asyncReachesTerminalState = {
3245
3334
  const spec = op.async;
3246
3335
  if (spec === null)
3247
3336
  continue;
3248
- const body = op.hasRequestBody ? validBody(ctx, requestSchemaOf(ctx, op) ?? {}) : undefined;
3337
+ const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3249
3338
  const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3250
3339
  headers: ctx.auth(),
3251
3340
  ...(body === undefined ? {} : { body }),
3252
3341
  });
3342
+ if (standDownForFeatureGate(ctx, op, start, this.id))
3343
+ continue;
3253
3344
  if (start.status >= 400) {
3254
3345
  ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be started`, `returned ${start.status}; the async lifecycle after it is untested`);
3255
3346
  continue;