@lovrozagar/oat 0.6.1 → 0.7.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 (72) hide show
  1. package/README.md +148 -21
  2. package/dist/cli.js +7 -0
  3. package/dist/config/define-config.d.ts +95 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js +6 -0
  6. package/dist/config/define-config.js.map +1 -1
  7. package/dist/index.d.ts +5 -2
  8. package/dist/index.js +3 -1
  9. package/dist/report/console.d.ts.map +1 -1
  10. package/dist/report/console.js +2 -0
  11. package/dist/report/console.js.map +1 -1
  12. package/dist/report/render.d.ts.map +1 -1
  13. package/dist/report/render.js +4 -2
  14. package/dist/report/render.js.map +1 -1
  15. package/dist/runtime/auth.d.ts +22 -2
  16. package/dist/runtime/auth.d.ts.map +1 -1
  17. package/dist/runtime/auth.js +129 -16
  18. package/dist/runtime/auth.js.map +1 -1
  19. package/dist/runtime/body.d.ts.map +1 -1
  20. package/dist/runtime/body.js +6 -3
  21. package/dist/runtime/body.js.map +1 -1
  22. package/dist/runtime/checks.d.ts +6 -0
  23. package/dist/runtime/checks.d.ts.map +1 -1
  24. package/dist/runtime/checks.js +103 -4
  25. package/dist/runtime/checks.js.map +1 -1
  26. package/dist/runtime/client.d.ts +8 -2
  27. package/dist/runtime/client.d.ts.map +1 -1
  28. package/dist/runtime/client.js +42 -10
  29. package/dist/runtime/client.js.map +1 -1
  30. package/dist/runtime/finding.d.ts +2 -0
  31. package/dist/runtime/finding.d.ts.map +1 -1
  32. package/dist/runtime/finding.js.map +1 -1
  33. package/dist/runtime/input.d.ts +7 -0
  34. package/dist/runtime/input.d.ts.map +1 -0
  35. package/dist/runtime/input.js +46 -0
  36. package/dist/runtime/input.js.map +1 -0
  37. package/dist/runtime/poll.d.ts +29 -0
  38. package/dist/runtime/poll.d.ts.map +1 -0
  39. package/dist/runtime/poll.js +59 -0
  40. package/dist/runtime/poll.js.map +1 -0
  41. package/dist/runtime/principals.d.ts +29 -0
  42. package/dist/runtime/principals.d.ts.map +1 -0
  43. package/dist/runtime/principals.js +97 -0
  44. package/dist/runtime/principals.js.map +1 -0
  45. package/dist/runtime/rate-limit.d.ts +40 -2
  46. package/dist/runtime/rate-limit.d.ts.map +1 -1
  47. package/dist/runtime/rate-limit.js +115 -4
  48. package/dist/runtime/rate-limit.js.map +1 -1
  49. package/dist/runtime/run.d.ts +10 -1
  50. package/dist/runtime/run.d.ts.map +1 -1
  51. package/dist/runtime/run.js +119 -23
  52. package/dist/runtime/run.js.map +1 -1
  53. package/dist/runtime/upload.d.ts +1 -0
  54. package/dist/runtime/upload.d.ts.map +1 -1
  55. package/dist/runtime/upload.js.map +1 -1
  56. package/dist/runtime/wait.d.ts +32 -0
  57. package/dist/runtime/wait.d.ts.map +1 -0
  58. package/dist/runtime/wait.js +125 -0
  59. package/dist/runtime/wait.js.map +1 -0
  60. package/dist/runtime/world.d.ts +16 -0
  61. package/dist/runtime/world.d.ts.map +1 -1
  62. package/dist/runtime/world.js +91 -0
  63. package/dist/runtime/world.js.map +1 -1
  64. package/dist/spec/extensions.d.ts +16 -0
  65. package/dist/spec/extensions.d.ts.map +1 -1
  66. package/dist/spec/extensions.js +24 -1
  67. package/dist/spec/extensions.js.map +1 -1
  68. package/dist/spec/graph.d.ts +2 -1
  69. package/dist/spec/graph.d.ts.map +1 -1
  70. package/dist/spec/graph.js +2 -1
  71. package/dist/spec/graph.js.map +1 -1
  72. package/package.json +4 -2
@@ -12,7 +12,9 @@ import { encodeForOperation } from "./body.js";
12
12
  import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
13
13
  import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
14
14
  import { driveAsync, inspectStreamAsync, matchesPredicate, resolveAsyncId } from "./async.js";
15
+ import { resolveOutOfBandValue } from "./auth.js";
15
16
  import { buildCohort } from "./fixture.js";
17
+ import { driveWait } from "./wait.js";
16
18
  import { fillPath } from "./world.js";
17
19
  /* ------------------------------------------------------------------- helpers */
18
20
  /**
@@ -33,6 +35,16 @@ function standDownForFeatureGate(ctx, op, exchange, check) {
33
35
  "down rather than treat the documented 403 as a defect.");
34
36
  return true;
35
37
  }
38
+ /**
39
+ * A 429 that survived Client retries is the bucket, not the backend. The check that needed a
40
+ * 2xx stands down; it is not a defect and it is not a concurrency conclusion.
41
+ */
42
+ function standDownForRateLimit(ctx, exchange, check) {
43
+ if (exchange.status !== 429)
44
+ return false;
45
+ ctx.findings.unresolved(check, ctx.entityName, "the request was rate-limited (429) after retries, so this check could not complete");
46
+ return true;
47
+ }
36
48
  /** The list endpoint's derived conventions — parameter roles and envelope spellings. */
37
49
  function conv(ctx) {
38
50
  return ctx.listOp.conventions;
@@ -151,6 +163,7 @@ function linkRelations(exchange) {
151
163
  async function list(ctx, query = {}, auth = ctx.auth, scope = ctx.scope) {
152
164
  const exchange = await ctx.client.get(fillPath(ctx.listOp.path, scope), {
153
165
  headers: auth(),
166
+ operationId: ctx.listOp.operationId,
154
167
  query,
155
168
  });
156
169
  const body = exchange.responseBody;
@@ -2591,6 +2604,7 @@ const inviteGrantsThenRevokes = {
2591
2604
  const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
2592
2605
  body: { [spec.granteeField]: delegate.inviteAs },
2593
2606
  headers: { ...owner.headers(), "content-type": "application/json" },
2607
+ operationId: inviteOp.operationId,
2594
2608
  });
2595
2609
  if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
2596
2610
  return;
@@ -2602,10 +2616,25 @@ const inviteGrantsThenRevokes = {
2602
2616
  "step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
2603
2617
  return;
2604
2618
  }
2605
- const token = pointerValue(invited.responseBody, spec.tokenPointer);
2606
2619
  const grantId = pointerValue(invited.responseBody, spec.grantPointer);
2607
- if (token === undefined) {
2608
- return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
2620
+ let token;
2621
+ if (spec.tokenFrom === "outOfBand") {
2622
+ const kind = spec.tokenKind ?? `${ctx.entityName}-invite`;
2623
+ try {
2624
+ token = await resolveOutOfBandValue(ctx.hooks.resolveOutOfBand, delegate.inviteAs, kind, {
2625
+ label: `invite to ${ctx.entityName}`,
2626
+ outOfBand: ctx.outOfBand,
2627
+ });
2628
+ }
2629
+ catch (error) {
2630
+ return ctx.findings.unresolved(this.id, ctx.entityName, error instanceof Error ? error.message : String(error));
2631
+ }
2632
+ }
2633
+ else {
2634
+ token = pointerValue(invited.responseBody, spec.tokenPointer);
2635
+ if (token === undefined) {
2636
+ return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
2637
+ }
2609
2638
  }
2610
2639
  const acceptScope = { ...resource, token };
2611
2640
  const acceptBody = documentedJsonBody(ctx, acceptOp, acceptScope);
@@ -2615,6 +2644,7 @@ const inviteGrantsThenRevokes = {
2615
2644
  ...delegate.headers(),
2616
2645
  ...(acceptBody === undefined ? {} : { "content-type": "application/json" }),
2617
2646
  },
2647
+ operationId: acceptOp.operationId,
2618
2648
  });
2619
2649
  if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
2620
2650
  return;
@@ -2637,6 +2667,7 @@ const inviteGrantsThenRevokes = {
2637
2667
  ...owner.headers(),
2638
2668
  ...(revokeBody === undefined ? {} : { "content-type": "application/json" }),
2639
2669
  },
2670
+ operationId: revokeOp.operationId,
2640
2671
  });
2641
2672
  if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
2642
2673
  return;
@@ -3246,6 +3277,10 @@ const noLostUpdate = {
3246
3277
  return;
3247
3278
  if (standDownForFeatureGate(ctx, ctx.updateOp, secondWrite, this.id))
3248
3279
  return;
3280
+ if (standDownForRateLimit(ctx, firstWrite, this.id))
3281
+ return;
3282
+ if (standDownForRateLimit(ctx, secondWrite, this.id))
3283
+ return;
3249
3284
  if (firstWrite.status >= 300 || secondWrite.status >= 300) {
3250
3285
  return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no ` +
3251
3286
  "race actually took place");
@@ -3343,6 +3378,8 @@ const declaredEffectsOccur = {
3343
3378
  });
3344
3379
  if (standDownForFeatureGate(ctx, op, invoked, this.id))
3345
3380
  continue;
3381
+ if (standDownForRateLimit(ctx, invoked, this.id))
3382
+ continue;
3346
3383
  if (invoked.status >= 400) {
3347
3384
  ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`);
3348
3385
  continue;
@@ -3380,6 +3417,61 @@ const declaredEffectsOccur = {
3380
3417
  }
3381
3418
  },
3382
3419
  };
3420
+ /**
3421
+ * After a write that declares `x-wait`, poll the named GET until a JSON path is occupied
3422
+ * (or `awaitSideEffect` returns true). Timeout is a backend finding, not a coverage gap.
3423
+ */
3424
+ const sideEffectArrives = {
3425
+ applicable: (ctx) => ctx.waitOps.length > 0,
3426
+ dependsOn: ["list.read-after-write"],
3427
+ mutates: true,
3428
+ id: "effects.side-effect-arrives",
3429
+ needs: "an operation declaring x-wait",
3430
+ async run(ctx) {
3431
+ for (const op of ctx.waitOps) {
3432
+ const spec = op.wait;
3433
+ if (spec === null)
3434
+ continue;
3435
+ const pollOp = ctx.model.byOperationId.get(spec.operationId);
3436
+ if (pollOp === undefined) {
3437
+ ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} x-wait names "${spec.operationId}", which is not in the document`, "the side effect cannot be observed, so it is not verified");
3438
+ continue;
3439
+ }
3440
+ const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3441
+ const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3442
+ headers: ctx.auth(),
3443
+ operationId: op.operationId,
3444
+ ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3445
+ });
3446
+ if (standDownForFeatureGate(ctx, op, invoked, this.id))
3447
+ continue;
3448
+ if (standDownForRateLimit(ctx, invoked, this.id))
3449
+ continue;
3450
+ if (invoked.status >= 400) {
3451
+ ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`);
3452
+ continue;
3453
+ }
3454
+ const outcome = await driveWait({
3455
+ awaitSideEffect: ctx.hooks.awaitSideEffect,
3456
+ client: ctx.client,
3457
+ headers: ctx.auth,
3458
+ pollOp,
3459
+ record: invoked.responseBody,
3460
+ scope: ctx.scope,
3461
+ spec,
3462
+ writeOpId: op.operationId,
3463
+ ...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
3464
+ });
3465
+ if (!outcome.timedOut)
3466
+ continue;
3467
+ ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
3468
+ (spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
3469
+ ` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
3470
+ "Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
3471
+ "missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)]);
3472
+ }
3473
+ },
3474
+ };
3383
3475
  async function observe(ctx, listOp) {
3384
3476
  let path;
3385
3477
  try {
@@ -3590,6 +3682,8 @@ const stringPayloadSurvives = {
3590
3682
  const control = await writeField(ASCII_PAYLOAD_PROBE, id);
3591
3683
  if (standDownForFeatureGate(ctx, writeOp, control, this.id))
3592
3684
  return;
3685
+ if (standDownForRateLimit(ctx, control, this.id))
3686
+ return;
3593
3687
  if (control.status === 401 || control.status === 403) {
3594
3688
  return ctx.findings.unresolved(this.id, ctx.entityName, `the ASCII control write returned ${control.status}, so later refusals cannot be attributed to the payload`);
3595
3689
  }
@@ -3632,7 +3726,9 @@ const stringPayloadSurvives = {
3632
3726
  evidence.push(written);
3633
3727
  continue;
3634
3728
  }
3635
- if (written.status === 404 || written.status === 409 || written.status === 415 || written.status === 429) {
3729
+ if (standDownForRateLimit(ctx, written, this.id))
3730
+ return;
3731
+ if (written.status === 404 || written.status === 409 || written.status === 415) {
3636
3732
  continue;
3637
3733
  }
3638
3734
  if (written.status >= 400 && written.status < 500) {
@@ -3731,6 +3827,8 @@ const documentedStatusHonoured = {
3731
3827
  continue;
3732
3828
  if (isDocumentedFeatureGateDenial(op, exchange.status, exchange.responseBody))
3733
3829
  continue;
3830
+ if (exchange.status === 429)
3831
+ continue;
3734
3832
  const raw = ctx.model.rawOperations.get(op.operationId);
3735
3833
  if (!declaresConcreteStatuses(raw))
3736
3834
  continue;
@@ -3827,6 +3925,7 @@ export const CHECKS = [
3827
3925
  /* declared side effects and async lifecycles, last: both invoke operations that change the
3828
3926
  * world, and both are meaningless if the read surface above is already known broken */
3829
3927
  declaredEffectsOccur,
3928
+ sideEffectArrives,
3830
3929
  asyncReachesTerminalState,
3831
3930
  asyncReceiptIsResolvable,
3832
3931
  /* After every other check has written to the transcript — create is owned by