@substrat-run/control-plane-api 0.134.0 → 0.136.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 (88) hide show
  1. package/dist/aggregate-reads.d.ts.map +1 -1
  2. package/dist/aggregate-reads.js +4 -1
  3. package/dist/aggregate-reads.js.map +1 -1
  4. package/dist/aggregate-source.d.ts +17 -1
  5. package/dist/aggregate-source.d.ts.map +1 -1
  6. package/dist/aggregate-source.js +16 -10
  7. package/dist/aggregate-source.js.map +1 -1
  8. package/dist/api.d.ts.map +1 -1
  9. package/dist/api.js +1037 -66
  10. package/dist/api.js.map +1 -1
  11. package/dist/auth.d.ts +2 -13
  12. package/dist/auth.d.ts.map +1 -1
  13. package/dist/auth.js +4 -13
  14. package/dist/auth.js.map +1 -1
  15. package/dist/browser.d.ts +4 -4
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +6 -3
  18. package/dist/browser.js.map +1 -1
  19. package/dist/cf-observability.d.ts +6 -0
  20. package/dist/cf-observability.d.ts.map +1 -1
  21. package/dist/cf-observability.js +126 -34
  22. package/dist/cf-observability.js.map +1 -1
  23. package/dist/connect-url.d.ts +2 -2
  24. package/dist/connect-url.d.ts.map +1 -1
  25. package/dist/connect-url.js +7 -1
  26. package/dist/connect-url.js.map +1 -1
  27. package/dist/connection-relay.d.ts +4 -2
  28. package/dist/connection-relay.d.ts.map +1 -1
  29. package/dist/connection-relay.js +10 -1
  30. package/dist/connection-relay.js.map +1 -1
  31. package/dist/deploy.d.ts +3 -0
  32. package/dist/deploy.d.ts.map +1 -1
  33. package/dist/deploy.js.map +1 -1
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js +6 -4
  36. package/dist/errors.js.map +1 -1
  37. package/dist/failure-attribution.js +2 -2
  38. package/dist/failure-attribution.js.map +1 -1
  39. package/dist/index.d.ts +6 -9
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +5 -5
  42. package/dist/index.js.map +1 -1
  43. package/dist/observability.d.ts +20 -1
  44. package/dist/observability.d.ts.map +1 -1
  45. package/dist/observability.js +11 -1
  46. package/dist/observability.js.map +1 -1
  47. package/dist/platform-drain.d.ts +24 -2
  48. package/dist/platform-drain.d.ts.map +1 -1
  49. package/dist/platform-drain.js +37 -4
  50. package/dist/platform-drain.js.map +1 -1
  51. package/dist/platform-entry.d.ts +62 -6
  52. package/dist/platform-entry.d.ts.map +1 -1
  53. package/dist/platform-entry.generated.d.ts +8 -1
  54. package/dist/platform-entry.generated.d.ts.map +1 -1
  55. package/dist/platform-entry.generated.js +10 -3
  56. package/dist/platform-entry.generated.js.map +1 -1
  57. package/dist/platform-entry.js +151 -9
  58. package/dist/platform-entry.js.map +1 -1
  59. package/dist/preview-auth.js +1 -1
  60. package/dist/preview-auth.js.map +1 -1
  61. package/dist/provider-error.d.ts +9 -0
  62. package/dist/provider-error.d.ts.map +1 -0
  63. package/dist/provider-error.js +9 -0
  64. package/dist/provider-error.js.map +1 -0
  65. package/dist/staff-client.d.ts +4 -3
  66. package/dist/staff-client.d.ts.map +1 -1
  67. package/dist/staff-client.js +5 -2
  68. package/dist/staff-client.js.map +1 -1
  69. package/dist/tenant-request-query.d.ts.map +1 -1
  70. package/dist/tenant-request-query.js +3 -1
  71. package/dist/tenant-request-query.js.map +1 -1
  72. package/dist/vertical-client.d.ts +179 -40
  73. package/dist/vertical-client.d.ts.map +1 -1
  74. package/dist/vertical-client.js +357 -172
  75. package/dist/vertical-client.js.map +1 -1
  76. package/package.json +6 -5
  77. package/dist/client.d.ts +0 -111
  78. package/dist/client.d.ts.map +0 -1
  79. package/dist/client.js +0 -122
  80. package/dist/client.js.map +0 -1
  81. package/dist/identity-tenants.d.ts +0 -30
  82. package/dist/identity-tenants.d.ts.map +0 -1
  83. package/dist/identity-tenants.js +0 -23
  84. package/dist/identity-tenants.js.map +0 -1
  85. package/dist/transport.d.ts +0 -56
  86. package/dist/transport.d.ts.map +0 -1
  87. package/dist/transport.js +0 -88
  88. package/dist/transport.js.map +0 -1
@@ -1,6 +1,23 @@
1
- import { PREVIEW_CLIENT_PATH, attachmentRecord, denialFilterParams, mintedPreviewClient, ownerSeat, ownerClaimLink, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, } from '@substrat-run/contracts';
2
- import { CONNECTOR_ATTACHMENT_RECORD_HEADER, PLATFORM_SECRET_HEADER, undrainedEventsOf } from '@substrat-run/kernel';
3
- import { ControlPlaneError } from './client.js';
1
+ import { PREVIEW_CLIENT_PATH, attachmentRecord, denialFilterParams, capabilityFilterParams, capabilityPage, mintedPreviewClient, ownerSeat, ownerClaimLink, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, } from '@substrat-run/contracts';
2
+ import { undrainedEventsOf } from '@substrat-run/kernel';
3
+ import { ControlPlaneError } from '@substrat-run/control-plane-client';
4
+ /** `routeAnswer`'s word for a deployment that predates the route it was asked (#1722, #2010). */
5
+ const PREDATES = Symbol('predates');
6
+ /** #1722's kept-copy and fenced-wipe verbs: a host without the method answers a 501. */
7
+ const FENCED = { legacy501: true, lost: 'it may or may not have acted' };
8
+ /**
9
+ * What a 200 whose body is not JSON says, when it is an HTML page (#2010, Codex #2014 r1). An old
10
+ * deployment's SPA fallback answers its own app's `index.html` for a path it does not route, and a
11
+ * proxy or error page in between answers HTML too. Nothing the platform controls marks the first
12
+ * (the page is the vertical's, served by the asset layer before any platform code runs, and the
13
+ * deployments in question predate any marker that could be added now), so the two cannot be told
14
+ * apart, and an HTML page is never read as "predates the route".
15
+ */
16
+ function htmlNote(res) {
17
+ return (res.headers.get('content-type') ?? '').toLowerCase().includes('text/html')
18
+ ? " (an HTML page: an old deployment's app shell cannot be told from an error page in between)"
19
+ : '';
20
+ }
4
21
  /**
5
22
  * The query string for both internal denial reads. The filter's own fields come from
6
23
  * the ONE encoder in contracts (#971) — this branch answers the same two routes as
@@ -176,6 +193,12 @@ export class VerticalClient {
176
193
  async summarizeDenials(scopeId, filter) {
177
194
  return this.getInternal(`/internal/denials/summary?${denialParams(scopeId, filter)}`);
178
195
  }
196
+ /** The scope's capability directory (#1686), pulled from the vertical that holds it and parsed on arrival, so a hash-bearing answer cannot pass through. */
197
+ async listCapabilities(scopeId, filter) {
198
+ const q = capabilityFilterParams(filter);
199
+ q.set('scopeId', scopeId);
200
+ return capabilityPage.parse(await this.getInternal(`/internal/capabilities?${q}`));
201
+ }
179
202
  /**
180
203
  * The scope's OWNER SEAT (#925) — claimed, unclaimed (with whether a plain first sign-in
181
204
  * still claims it), or unknown. Pulled for the same reason the denials are: the seat lives
@@ -313,6 +336,30 @@ export class VerticalClient {
313
336
  async connectorGrant(input) {
314
337
  await this.postInternal('/internal/connector-grant', input, 'connector-grant');
315
338
  }
339
+ /**
340
+ * Deliver one scope's lifecycle to the deployment serving it (#1713). Throws on anything but
341
+ * a parsed answer, a deployment built before the route (404/501) included, so the caller
342
+ * records no receipt and the heal sweep asks again.
343
+ */
344
+ async setLifecycle(input) {
345
+ const verb = 'lifecycle';
346
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
347
+ const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/lifecycle`, {
348
+ method: 'POST',
349
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
350
+ body: JSON.stringify(input),
351
+ }));
352
+ if (res.status === 404) {
353
+ throw new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the lifecycle delivery (#1713) — redeploy the vertical`);
354
+ }
355
+ if (!res.ok)
356
+ throw await this.refusal(verb, res);
357
+ const parsed = lifecycleDelivery.safeParse(await res.json().catch(() => null));
358
+ if (!parsed.success) {
359
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${input.scopeId} with an unexpected shape`);
360
+ }
361
+ return parsed.data;
362
+ }
316
363
  /**
317
364
  * Move one module's schedule kill switch in the deployment serving the scope (#1666) —
318
365
  * the far end of `revokeFromSystem` / `restoreToSystem` for a hosted scope, whose
@@ -321,114 +368,83 @@ export class VerticalClient {
321
368
  * ONE shape is normalized to "redeploy the vertical", and only because it is the
322
369
  * deployment's own proof that it cannot have acted: a script built before the route
323
370
  * answers a **404** (the path does not exist — the route itself answers a module it holds
324
- * nothing for with a 200 `held: false`, never a 404) or its **SPA shell** (a 200 that is
325
- * not JSON). "Nothing was switched" is true of exactly those.
371
+ * nothing for with a 200 `held: false`, never a 404). "Nothing was switched" is true of exactly
372
+ * that. An HTML 200 is NOT that proof (`routeAnswer`): an old deployment's app shell cannot be
373
+ * told from an error page after a switch that moved.
326
374
  *
327
375
  * Everything else surfaces as the failure it is, and never claims that: a transport
328
- * failure (`reach` → 502 "unreachable"), a genuine 5xx from the far end, and a 200 JSON of
329
- * the wrong shape. The request may have landed and the switch may have moved before the
330
- * answer was lost, so the only honest instruction is to confirm the position first.
376
+ * failure (`reach` → 502 "unreachable"), a genuine 5xx from the far end, a 200 whose body
377
+ * is truncated or fails to read (#2010), and a 200 JSON of the wrong shape. The request may
378
+ * have landed and the switch may have moved before the answer was lost, so the only honest
379
+ * instruction is to confirm the position first.
331
380
  */
332
381
  async systemSwitch(input) {
333
382
  const verb = 'system-switch';
334
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the schedule switch (#1666) — ` +
335
- `redeploy the vertical, then retry. Nothing was switched.`);
383
+ const lost = `the switch on scope ${input.scopeId} may or may not have moved. ` +
384
+ `Confirm its position (read the scope's schedule status) before retrying.`;
336
385
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
337
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/system-switch`, {
386
+ const rule = {
387
+ legacy501: false,
388
+ lost,
389
+ predates: `the deployment serving scope ${input.scopeId} predates the schedule switch (#1666) — ` +
390
+ `redeploy the vertical, then retry. Nothing was switched.`,
391
+ shape: `vertical answered ${verb} with an unexpected shape — ${lost}`,
392
+ };
393
+ return this.parsedAnswer(verb, rule, systemSwitchOutcome, () => this.options.fetch(`${base}/internal/system-switch`, {
338
394
  method: 'POST',
339
395
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
340
396
  body: JSON.stringify(input),
341
397
  }));
342
- if (res.status === 404)
343
- throw predates();
344
- if (!res.ok)
345
- throw await this.refusal(verb, res);
346
- const text = await res.text();
347
- let raw;
348
- try {
349
- raw = JSON.parse(text);
350
- }
351
- catch {
352
- throw predates();
353
- }
354
- const parsed = systemSwitchOutcome.safeParse(raw);
355
- if (!parsed.success) {
356
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the switch on scope ${input.scopeId} may ` +
357
- `or may not have moved. Confirm its position (the scope's SQL console) before retrying.`);
358
- }
359
- return parsed.data;
360
398
  }
361
399
  /**
362
400
  * The read half of the schedule kill switch's status (#1674): every module the
363
401
  * deployment serving this scope holds or has held system authority for, and where each
364
- * stands. Mirrors `systemSwitch`'s seam and its skew rule exactly (#1666's "only the
365
- * explicit legacy signal reads as redeploy"): a 404 (the route does not exist) or an SPA
366
- * shell (a 200 that is not JSON) are the deployment's own proof it predates this read,
367
- * and become a 501 that says so. A 200 of the wrong-shaped JSON is NOT that proof — it
368
- * is a failure this client cannot explain, so it surfaces as the 502 it is rather than a
369
- * guess at "redeploy". The far end's own 501 (the route exists, the method doesn't)
370
- * passes through `refusal` verbatim, unchanged either way.
402
+ * stands. Mirrors `systemSwitch`'s seam and its skew rule exactly (`routeAnswer`): a 404
403
+ * is the deployment's own proof it predates this read, and becomes a 501 that says so. A
404
+ * truncated, unreadable or HTML 200 (#2010) and a 200 of the wrong-shaped JSON are NOT that
405
+ * proof — they are a failed read, a 502, rather than a guess at
406
+ * "redeploy". The far end's own 501 (the route exists, the method doesn't) passes through
407
+ * `refusal` verbatim, unchanged either way.
371
408
  */
372
409
  async systemGrantsStatus(input) {
373
410
  const verb = 'system-grants';
374
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the schedule switch's status read (#1674) — ` +
375
- `redeploy the vertical, then retry.`);
411
+ const lost = `the status of scope ${input.scopeId} could not be read. Nothing was changed; retry the read.`;
376
412
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
377
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/system-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
413
+ const rule = {
414
+ legacy501: false,
415
+ lost,
416
+ predates: `the deployment serving scope ${input.scopeId} predates the schedule switch's status read (#1674) — ` +
417
+ `redeploy the vertical, then retry.`,
418
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`,
419
+ };
420
+ return this.parsedAnswer(verb, rule, systemScheduleEntry.array(), () => this.options.fetch(`${base}/internal/system-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
378
421
  method: 'GET',
379
422
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
380
423
  }));
381
- if (res.status === 404)
382
- throw predates();
383
- if (!res.ok)
384
- throw await this.refusal(verb, res);
385
- const text = await res.text();
386
- let raw;
387
- try {
388
- raw = JSON.parse(text);
389
- }
390
- catch {
391
- throw predates();
392
- }
393
- const parsed = systemScheduleEntry.array().safeParse(raw);
394
- if (!parsed.success) {
395
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`);
396
- }
397
- return parsed.data;
398
424
  }
399
425
  /**
400
426
  * The read half of the peer kill switch's status (#1706): every peer the deployment
401
427
  * serving this scope holds or has held grants for, and where each stands. Mirrors
402
- * `peerSwitch`'s seam and `systemGrantsStatus`'s skew rule exactly — a 404 or an SPA
403
- * shell are the deployment's own proof it predates this read and become a 501 that says
404
- * to redeploy; a wrong-shaped 200 is not that proof and surfaces as the 502 it is.
428
+ * `peerSwitch`'s seam and `systemGrantsStatus`'s skew rule exactly — a 404 is the
429
+ * deployment's own proof it predates this read and becomes a 501 that says to redeploy; a
430
+ * truncated, unreadable, HTML or wrong-shaped 200 is not that proof and surfaces as the 502
431
+ * it is.
405
432
  */
406
433
  async peerGrantsStatus(input) {
407
434
  const verb = 'peer-grants';
408
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the peer switch's status read (#1706) — ` +
409
- `redeploy the vertical, then retry.`);
435
+ const lost = `the peer status of scope ${input.scopeId} could not be read. Nothing was changed; retry the read.`;
410
436
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
411
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/peer-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
437
+ const rule = {
438
+ legacy501: false,
439
+ lost,
440
+ predates: `the deployment serving scope ${input.scopeId} predates the peer switch's status read (#1706) — ` +
441
+ `redeploy the vertical, then retry.`,
442
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`,
443
+ };
444
+ return this.parsedAnswer(verb, rule, peerGrantsEntry.array(), () => this.options.fetch(`${base}/internal/peer-grants?scopeId=${encodeURIComponent(input.scopeId)}`, {
412
445
  method: 'GET',
413
446
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
414
447
  }));
415
- if (res.status === 404)
416
- throw predates();
417
- if (!res.ok)
418
- throw await this.refusal(verb, res);
419
- const text = await res.text();
420
- let raw;
421
- try {
422
- raw = JSON.parse(text);
423
- }
424
- catch {
425
- throw predates();
426
- }
427
- const parsed = peerGrantsEntry.array().safeParse(raw);
428
- if (!parsed.success) {
429
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`);
430
- }
431
- return parsed.data;
432
448
  }
433
449
  /**
434
450
  * Move one PEER's kill switch in the deployment serving the scope (#1706) — the far end
@@ -436,40 +452,30 @@ export class VerticalClient {
436
452
  * live there and nowhere the shared control plane can reach.
437
453
  *
438
454
  * The seam, the skew rule and the honesty rule are `systemSwitch`'s, for the same reasons:
439
- * a **404** (no such route) or an **SPA shell** (a 200 that is not JSON) are the
440
- * deployment's own proof that it predates this route and therefore cannot have switched
441
- * anything — the route itself answers a peer the scope holds nothing for with a 200
442
- * `held: false`, never a 404. Everything else surfaces as the failure it is, because the
443
- * request may have landed and the switch may have moved before the answer was lost.
455
+ * a **404** (no such route) is the deployment's own proof that it
456
+ * predates this route and therefore cannot have switched anything — the route itself
457
+ * answers a peer the scope holds nothing for with a 200 `held: false`, never a 404.
458
+ * Everything else, a truncated or unreadable 200 included (#2010), surfaces as the failure
459
+ * it is, because the request may have landed and the switch may have moved before the
460
+ * answer was lost.
444
461
  */
445
462
  async peerSwitch(input) {
446
463
  const verb = 'peer-switch';
447
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${input.scopeId} predates the peer kill switch (#1706) — ` +
448
- `redeploy the vertical, then retry. Nothing was switched.`);
464
+ const lost = `the switch for peer '${input.vertical}' on scope ${input.scopeId} may or may not have moved. ` +
465
+ `Confirm its position (read the scope's peer status) before retrying.`;
449
466
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
450
- const res = await this.reach(verb, () => this.options.fetch(`${base}/internal/peer-switch`, {
467
+ const rule = {
468
+ legacy501: false,
469
+ lost,
470
+ predates: `the deployment serving scope ${input.scopeId} predates the peer kill switch (#1706) — ` +
471
+ `redeploy the vertical, then retry. Nothing was switched.`,
472
+ shape: `vertical answered ${verb} with an unexpected shape — ${lost}`,
473
+ };
474
+ return this.parsedAnswer(verb, rule, peerSwitchOutcome, () => this.options.fetch(`${base}/internal/peer-switch`, {
451
475
  method: 'POST',
452
476
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
453
477
  body: JSON.stringify(input),
454
478
  }));
455
- if (res.status === 404)
456
- throw predates();
457
- if (!res.ok)
458
- throw await this.refusal(verb, res);
459
- const text = await res.text();
460
- let raw;
461
- try {
462
- raw = JSON.parse(text);
463
- }
464
- catch {
465
- throw predates();
466
- }
467
- const parsed = peerSwitchOutcome.safeParse(raw);
468
- if (!parsed.success) {
469
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the switch for peer '${input.vertical}' on ` +
470
- `scope ${input.scopeId} may or may not have moved. Confirm its position before retrying.`);
471
- }
472
- return parsed.data;
473
479
  }
474
480
  /**
475
481
  * A preview's own client at a team auth-server (#1704, `@substrat-run/contracts`'
@@ -477,13 +483,14 @@ export class VerticalClient {
477
483
  * here, delete the preview's clients. Addressed to the ISSUER's deployment (this client),
478
484
  * never the preview's.
479
485
  *
480
- * The skew rule is `systemSwitch`'s, widened by one status: a deployment built before these
486
+ * The skew rule is `routeAnswer`'s with `legacy501`: a deployment built before these
481
487
  * routes answers its `/internal/*` fallback — a JSON **501** on the auth-server — or a
482
- * **404**, or an SPA shell (a 200 that is not JSON). All three are the deployment's own
483
- * proof it cannot have acted, and become a 501 that says to redeploy the auth server. The
484
- * caller must never read that as "the parent does not sign in here". A 200 of the wrong
485
- * shape is not that proof and surfaces as a 502; a refusal (403 another tenant's issuer,
486
- * 409 a parent it does not claim) passes through verbatim.
488
+ * **404**. Both are the deployment's own proof it cannot have acted,
489
+ * and become a 501 that says to redeploy the auth server. The caller must never read that
490
+ * as "the parent does not sign in here". A truncated or unreadable 200 (#2010) and a 200 of
491
+ * the wrong shape are not that proof and surface as a 502: a mint or a retire may have run.
492
+ * A refusal (403 another tenant's issuer, 409 a parent it does not claim) passes through
493
+ * verbatim.
487
494
  *
488
495
  * The mint's answer carries a client SECRET. It is returned to the caller and nothing here
489
496
  * keeps, logs or quotes it: no error message on this path includes a response body that
@@ -499,31 +506,19 @@ export class VerticalClient {
499
506
  return this.previewClientCall('DELETE', PREVIEW_CLIENT_PATH, input, retiredPreviewClients, 'preview-client retire', input.scopeId);
500
507
  }
501
508
  async previewClientCall(method, path, body, schema, verb, issuerScopeId) {
502
- const predates = () => new ControlPlaneError(501, `the auth server serving scope ${issuerScopeId} predates preview clients (#1704) — redeploy it, ` +
503
- `then re-run. Nothing was minted or deleted there.`);
504
509
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
505
- const res = await this.reach(verb, () => this.options.fetch(`${base}${path}`, {
510
+ const rule = {
511
+ legacy501: true,
512
+ lost: `the auth server serving scope ${issuerScopeId} may or may not have acted on it.`,
513
+ predates: `the auth server serving scope ${issuerScopeId} predates preview clients (#1704) — redeploy it, ` +
514
+ `then re-run. Nothing was minted or deleted there.`,
515
+ shape: `auth server answered ${verb} with an unexpected shape for scope ${issuerScopeId}.`,
516
+ };
517
+ return this.parsedAnswer(verb, rule, schema, () => this.options.fetch(`${base}${path}`, {
506
518
  method,
507
519
  headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
508
520
  body: JSON.stringify(body),
509
521
  }));
510
- if (res.status === 404 || res.status === 501)
511
- throw predates();
512
- if (!res.ok)
513
- throw await this.refusal(verb, res);
514
- const text = await res.text();
515
- let raw;
516
- try {
517
- raw = JSON.parse(text);
518
- }
519
- catch {
520
- throw predates();
521
- }
522
- const parsed = schema.safeParse(raw);
523
- if (!parsed.success) {
524
- throw new ControlPlaneError(502, `auth server answered ${verb} with an unexpected shape for scope ${issuerScopeId}.`);
525
- }
526
- return parsed.data;
527
522
  }
528
523
  /** Journal a platform-request outcome back in the vertical after the platform ran it. */
529
524
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
@@ -556,6 +551,19 @@ export class VerticalClient {
556
551
  async exportScope(scopeId) {
557
552
  return this.getInternal(`/internal/export?scopeId=${encodeURIComponent(scopeId)}`);
558
553
  }
554
+ /**
555
+ * `exportScope` with the store's load stamp (#1722), read by the vertical in the same call as
556
+ * the dump and handed over in a header: what a carry's fenced wipe of the copy it leaves
557
+ * expects. Null from a deployment that predates the stamp, which cannot fence a wipe either.
558
+ * A store nothing has stamped is stamped by this read.
559
+ */
560
+ async exportScopeStamped(scopeId) {
561
+ const path = `/internal/export?scopeId=${encodeURIComponent(scopeId)}&stamp=1`;
562
+ const res = await this.getInternalResponse(path);
563
+ const loadStamp = res.headers.get(LOAD_STAMP_HEADER) || null;
564
+ const revision = res.headers.get(WRITE_REVISION_HEADER) || null;
565
+ return { tables: await this.parseInternal('introspection', path, res), loadStamp, revision };
566
+ }
559
567
  /**
560
568
  * The write half of `exportScope` — load a dump into one existing scope in this
561
569
  * deployment (drop-then-replay), for the governed restore/backout. The control-plane
@@ -570,7 +578,12 @@ export class VerticalClient {
570
578
  /** #1742: the recorded-off modules, switched off on THIS scope in the restore's own event.
571
579
  * #1869: `sourceScopeId`, the scope the tables were captured from, so the vertical re-points
572
580
  * exactly that scope's grants, and `exact` when the platform exported them itself. A
573
- * vertical that predates the fields ignores them. */
581
+ * vertical that predates the fields ignores them. #1722: `loadStamp`, the stamp a carry
582
+ * leaves on the copy it lands, which a later fenced wipe of that copy expects, and `expect`,
583
+ * the marker the carry read here (`loadMarker`): the load is refused if the store moved since.
584
+ * #2005: `markCopy` — the directory's classification of this scope, sent when it is not
585
+ * primary, so the vertical marks it a copy in its own storage (and refuses a primary one).
586
+ * A vertical that predates the field ignores it. */
574
587
  opts) {
575
588
  // `exact` vouches for a named source; the vertical refuses it without one, so say so here.
576
589
  if (opts?.exact && !opts.sourceScopeId) {
@@ -583,9 +596,191 @@ export class VerticalClient {
583
596
  ...(opts?.switchedOff ? { switchedOff: opts.switchedOff } : {}),
584
597
  ...(opts?.sourceScopeId ? { sourceScopeId: opts.sourceScopeId } : {}),
585
598
  ...(opts?.exact ? { exact: true } : {}),
599
+ ...(opts?.loadStamp ? { loadStamp: opts.loadStamp } : {}),
600
+ ...(opts?.expect ? { expect: opts.expect } : {}),
601
+ ...(opts?.markCopy ? { markCopy: opts.markCopy } : {}),
586
602
  }, 'restore');
587
603
  return { tables: count, ...switchedOffFrom(switchedOff) };
588
604
  }
605
+ /**
606
+ * Wipe the copy a carry left in this deployment (#1722), only if nothing was loaded into the
607
+ * scope since `expectLoadStamp` (null: a store no load has stamped). `wiped: false` is the
608
+ * vertical's refusal: something was restored there since, and it stays.
609
+ *
610
+ * `'unfenced'` is a deployment that cannot compare the stamp, and the only answer that lets
611
+ * the caller fall back to an unconditional wipe: a 404 (built before the route), a 501 (a host
612
+ * without the method) are the deployment's own proof that it wiped nothing (`routeAnswer`).
613
+ * Everything else, a truncated or malformed JSON answer and any HTML page included, surfaces as
614
+ * the failure it is: the wipe may or may not have run.
615
+ */
616
+ async wipeCarriedCopy(input) {
617
+ const verb = 'wipe-carried';
618
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
619
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/wipe-carried`, {
620
+ method: 'POST',
621
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
622
+ body: JSON.stringify(input),
623
+ }));
624
+ if (answer === PREDATES)
625
+ return 'unfenced';
626
+ const wiped = answer?.wiped;
627
+ if (typeof wiped !== 'boolean') {
628
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the copy of scope ${input.scopeId} may or may not be wiped`);
629
+ }
630
+ return { wiped };
631
+ }
632
+ /**
633
+ * What a carry's restore into this scope expects to find unchanged (#1722): the load stamp and
634
+ * the store's write revision. `'unfenced'` on `wipeCarriedCopy`'s terms: only the
635
+ * deployment's own answer that it predates the route; everything else is a failure.
636
+ */
637
+ async loadMarker(scopeId) {
638
+ const verb = 'load-marker';
639
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
640
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/load-marker?scopeId=${encodeURIComponent(scopeId)}`, {
641
+ headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
642
+ }));
643
+ if (answer === PREDATES)
644
+ return 'unfenced';
645
+ const { loadStamp, revision } = (answer ?? {});
646
+ const field = (v) => v === null || (typeof v === 'string' && v.length > 0);
647
+ if (!field(loadStamp) || !field(revision)) {
648
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${scopeId} with an unexpected shape`);
649
+ }
650
+ return { loadStamp: loadStamp, revision: revision };
651
+ }
652
+ /**
653
+ * The kept copy of a scope in this deployment (#1722), or null. A deployment built before the
654
+ * fenced wipe cannot have made one, so its "predates" answer reads as null too.
655
+ */
656
+ async keptCopy(scopeId) {
657
+ const verb = 'kept-copy';
658
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
659
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy?scopeId=${encodeURIComponent(scopeId)}`, {
660
+ headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
661
+ }));
662
+ if (answer === PREDATES)
663
+ return null;
664
+ const kept = answer?.kept;
665
+ if (kept === null)
666
+ return null;
667
+ const k = kept;
668
+ if (!k || typeof k.carriedTo !== 'string' || typeof k.keptAt !== 'string' || !(k.revision === null || typeof k.revision === 'string')) {
669
+ throw new ControlPlaneError(502, `vertical answered ${verb} for scope ${scopeId} with an unexpected shape`);
670
+ }
671
+ return {
672
+ carriedTo: k.carriedTo,
673
+ keptAt: k.keptAt,
674
+ revision: k.revision,
675
+ ...(k.leftAgain && typeof k.leftAgain.to === 'string' && typeof k.leftAgain.at === 'string'
676
+ ? { leftAgain: { to: k.leftAgain.to, at: k.leftAgain.at } }
677
+ : {}),
678
+ ...(k.clearedOnly && (k.clearedOnly.revision === null || typeof k.clearedOnly.revision === 'string')
679
+ ? { clearedOnly: { revision: k.clearedOnly.revision } }
680
+ : {}),
681
+ };
682
+ }
683
+ /** Release the marker of a kept copy that is the live store after all (#1722), at the revision read. */
684
+ async releaseKeptCopy(input) {
685
+ const verb = 'kept-copy-release';
686
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
687
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy/release`, {
688
+ method: 'POST',
689
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
690
+ body: JSON.stringify(input),
691
+ }));
692
+ if (answer === PREDATES) {
693
+ throw new ControlPlaneError(501, `the deployment holding scope ${input.scopeId} keeps no copies (#1722)`);
694
+ }
695
+ const a = answer;
696
+ if (a?.released === true)
697
+ return { released: true };
698
+ if (a?.refused === 'changed' || a?.refused === 'not-kept')
699
+ return { refused: a.refused };
700
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the marker may or may not be cleared`);
701
+ }
702
+ /** Discard the kept copy of a scope in this deployment (#1722), at the revision the operator read. */
703
+ async discardKeptCopy(input) {
704
+ const verb = 'kept-copy-discard';
705
+ const base = this.options.baseUrl ?? 'https://vertical.invalid';
706
+ const answer = await this.routeAnswer(verb, FENCED, () => this.options.fetch(`${base}/internal/kept-copy/discard`, {
707
+ method: 'POST',
708
+ headers: { 'content-type': 'application/json', [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
709
+ body: JSON.stringify(input),
710
+ }));
711
+ if (answer === PREDATES) {
712
+ throw new ControlPlaneError(501, `the deployment holding scope ${input.scopeId} keeps no copies (#1722)`);
713
+ }
714
+ const a = answer;
715
+ if (a?.discarded === true)
716
+ return { discarded: true };
717
+ if (a?.refused === 'changed' || a?.refused === 'not-kept')
718
+ return { refused: a.refused };
719
+ throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape — the copy may or may not be discarded`);
720
+ }
721
+ /**
722
+ * The ONE skew rule for a route a deployment may predate (#1722, #2010): `PREDATES` only for a
723
+ * status that says the route is not there — a 404, and a 501 where `rule.legacy501` says the far
724
+ * end's own fallback answers one. No body is that proof. Everything else is a failure, never
725
+ * `PREDATES`, because the request may have landed: a transport failure, a refusal, a body that
726
+ * fails to read, and a body that is not valid JSON — a truncated `{"held":` from a deployment
727
+ * that DID act, or an HTML page (`htmlNote`), which an old deployment's app shell and an error
728
+ * page after a switch that moved both are. Those last ones are a 502 that ends with
729
+ * `rule.lost`, the verb's own sentence for what a lost answer leaves unknown.
730
+ */
731
+ async routeAnswer(verb, rule, request) {
732
+ const res = await this.reach(verb, request);
733
+ if (res.status === 404 || (rule.legacy501 && res.status === 501))
734
+ return PREDATES;
735
+ if (!res.ok)
736
+ throw await this.refusal(verb, res);
737
+ return this.readAnswer(verb, res, rule.lost);
738
+ }
739
+ /**
740
+ * `routeAnswer` for a verb whose "predates" is always a 501 and whose answer has a schema:
741
+ * the parsed answer, or the 501 `rule.predates`, or the 502 `rule.shape` for a wrong shape.
742
+ */
743
+ async parsedAnswer(verb, rule, schema, request) {
744
+ const answer = await this.routeAnswer(verb, rule, request);
745
+ if (answer === PREDATES)
746
+ throw new ControlPlaneError(501, rule.predates);
747
+ const parsed = schema.safeParse(answer);
748
+ if (!parsed.success)
749
+ throw new ControlPlaneError(502, rule.shape);
750
+ return parsed.data;
751
+ }
752
+ /**
753
+ * A 200's body, read under #2010's rule: the JSON it carries. A body that fails to read, or is
754
+ * not JSON (an HTML page included, `htmlNote`), is a 502 that names `subject` and ends with
755
+ * `lost`, because the deployment that answered may have acted.
756
+ */
757
+ async readAnswer(subject, res, lost) {
758
+ let text;
759
+ try {
760
+ text = await res.text();
761
+ }
762
+ catch (e) {
763
+ throw new ControlPlaneError(502, `reading the vertical's answer to ${subject} failed (${e instanceof Error ? e.message : String(e)}) — ${lost}`);
764
+ }
765
+ try {
766
+ return JSON.parse(text);
767
+ }
768
+ catch {
769
+ throw new ControlPlaneError(502, `vertical answered ${subject} with a body that is not JSON${htmlNote(res)} — ${lost}`);
770
+ }
771
+ }
772
+ /** Mark one scope a copy in the vertical's own storage (#2005), given the directory's
773
+ * classification of it; whether this call stamped it. */
774
+ async markCopy(scopeId, lineage) {
775
+ return this.postInternal('/internal/mark-copy', { scopeId, lineage }, 'mark-copy');
776
+ }
777
+ /** Remove a mistaken copy marker (#2005), given the directory's classification; whether it did. */
778
+ async clearCopyMark(scopeId, lineage,
779
+ /** #1722 (Codex #2008 r12–r13): clear only the store with this load stamp and revision; 412
780
+ * otherwise. */
781
+ opts = {}) {
782
+ return this.postInternal('/internal/clear-copy-mark', { scopeId, lineage, ...(opts.expect ? { expect: opts.expect } : {}) }, 'clear-copy-mark');
783
+ }
589
784
  /** Facets over one scope's outbox (#1239) — through the vertical that holds the data. */
590
785
  async facetEvents(scopeId, input) {
591
786
  const q = new URLSearchParams({ scopeId });
@@ -781,21 +976,16 @@ export class VerticalClient {
781
976
  : `vertical refused ${verb}: ${res.status} ${res.statusText}`);
782
977
  }
783
978
  /**
784
- * A 200 whose body is not JSON is a script that does not SERVE this route: an old
785
- * worker build falls through to its SPA fallback and answers the app shell (200,
786
- * `<!doctype …`) for any `/internal/*` path it predates. Surface that as the
787
- * diagnosis instead of an unhandled SyntaxError → 500 (#389: a pre-#236 script has
788
- * no `/internal/export`, so a carried rebind cannot dump it).
979
+ * A 200 that is not JSON is a 502 that names the verb, instead of an unhandled SyntaxError →
980
+ * 500 (#389: a pre-#236 script has no `/internal/export`, and its SPA fallback answered the app
981
+ * shell). That shell cannot be told from an error page in between (`htmlNote`), and a truncated
982
+ * answer from a deployment that has the route may follow a write that ran (#2010), so the
983
+ * message says the call may or may not have acted, and names redeploying only as the remedy
984
+ * for the case it cannot prove.
789
985
  */
790
986
  async parseInternal(verb, path, res) {
791
- const text = await res.text();
792
- try {
793
- return JSON.parse(text);
794
- }
795
- catch {
796
- throw new ControlPlaneError(502, `vertical answered ${verb} (${path}) with non-JSON — its deployed script predates this ` +
797
- `surface. Redeploy the vertical (or, for a rebind, use abandonData).`);
798
- }
987
+ return (await this.readAnswer(`${verb} (${path})`, res, 'it may or may not have acted. If this deployment answers its app for /internal/*, it predates ' +
988
+ 'this surface: redeploy the vertical (or, for a rebind, use abandonData).'));
799
989
  }
800
990
  /** A platform-authenticated POST to the vertical's `/internal/*` surface. */
801
991
  /**
@@ -821,10 +1011,11 @@ export class VerticalClient {
821
1011
  *
822
1012
  * The skew rule is `systemSwitch`'s, and here it is the whole point: an EMPTY batch is a
823
1013
  * real answer (nothing new), so a deployment that cannot answer must never produce one. A
824
- * 404 (the route does not exist) or a 200 that is not JSON (the SPA shell of a script that
825
- * predates the route) is that deployment's own proof, and becomes a 501 saying to redeploy.
1014
+ * 404 (the route does not exist) is that deployment's own proof (`routeAnswer`), and becomes a
1015
+ * 501 saying to redeploy.
826
1016
  * The far end's own 501 (the route exists, the host method does not) passes through
827
- * verbatim. A JSON 200 of the wrong shape is a 502, never a guess.
1017
+ * verbatim. A truncated or unreadable 200 (#2010) and a JSON 200 of the wrong shape are a
1018
+ * 502, never a guess.
828
1019
  */
829
1020
  async exportedEvents(input) {
830
1021
  return this.crossVerticalCall('exported-events', '/internal/exported-events', input.scopeId, exportedBatch, input);
@@ -850,8 +1041,8 @@ export class VerticalClient {
850
1041
  /**
851
1042
  * The replay lever's far end (#1705 PR 3): move the watermark of the consumer scope this
852
1043
  * deployment serves. The same skew rule, plus the write's one difference: a JSON 200 of the
853
- * wrong shape may follow a move that happened, so the 502 says to read the edge before
854
- * pulling the lever again. A 404 or an SPA shell proves the route is absent, so nothing moved.
1044
+ * wrong shape, or one truncated or unreadable (#2010), may follow a move that happened, so the
1045
+ * 502 says to read the edge before pulling the lever again. A 404 proves the route is absent, so nothing moved.
855
1046
  */
856
1047
  async importCursorMove(input) {
857
1048
  return this.crossVerticalCall('import-cursor', '/internal/import-cursor', input.scopeId, importCursorMoved, input, {
@@ -863,31 +1054,21 @@ export class VerticalClient {
863
1054
  * of `body` when there is one, a GET otherwise.
864
1055
  */
865
1056
  async crossVerticalCall(verb, path, scopeId, schema, body, opts = {}) {
866
- const predates = () => new ControlPlaneError(501, `the deployment serving scope ${scopeId} predates cross-vertical events (#1705) — redeploy the vertical. ` +
867
- `Nothing was read or delivered; the edge's watermark holds.`);
1057
+ const lost = opts.write
1058
+ ? 'the write may have taken effect: read the edge before retrying.'
1059
+ : 'it may or may not have acted; repeating it is safe.';
868
1060
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
869
1061
  const secret = { [PLATFORM_SECRET_HEADER]: this.options.platformSecret };
870
- const res = await this.reach(verb, () => this.options.fetch(`${base}${path}`, body === undefined
1062
+ const rule = {
1063
+ legacy501: false,
1064
+ lost,
1065
+ predates: `the deployment serving scope ${scopeId} predates cross-vertical events (#1705) — redeploy the vertical. ` +
1066
+ `Nothing was read or delivered; the edge's watermark holds.`,
1067
+ shape: `vertical answered ${verb} with an unexpected shape for scope ${scopeId}` + (opts.write ? ` — ${lost}` : '.'),
1068
+ };
1069
+ return this.parsedAnswer(verb, rule, schema, () => this.options.fetch(`${base}${path}`, body === undefined
871
1070
  ? { method: 'GET', headers: secret }
872
1071
  : { method: 'POST', headers: { ...secret, 'content-type': 'application/json' }, body: JSON.stringify(body) }));
873
- if (res.status === 404)
874
- throw predates();
875
- if (!res.ok)
876
- throw await this.refusal(verb, res);
877
- const text = await res.text();
878
- let raw;
879
- try {
880
- raw = JSON.parse(text);
881
- }
882
- catch {
883
- throw predates();
884
- }
885
- const parsed = schema.safeParse(raw);
886
- if (!parsed.success) {
887
- throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${scopeId}.` +
888
- (opts.write ? ' The write may have taken effect: read the edge before retrying.' : ''));
889
- }
890
- return parsed.data;
891
1072
  }
892
1073
  async postInternal(path, body, verb) {
893
1074
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
@@ -905,13 +1086,17 @@ export class VerticalClient {
905
1086
  }
906
1087
  /** A platform-authenticated GET to the vertical's `/internal/*` surface. */
907
1088
  async getInternal(path) {
1089
+ return this.parseInternal('introspection', path, await this.getInternalResponse(path));
1090
+ }
1091
+ /** `getInternal`'s request, answering the response itself, for a caller that reads a header too. */
1092
+ async getInternalResponse(path) {
908
1093
  const base = this.options.baseUrl ?? 'https://vertical.invalid';
909
1094
  const res = await this.reach('introspection', () => this.options.fetch(`${base}${path}`, {
910
1095
  headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
911
1096
  }));
912
1097
  if (!res.ok)
913
1098
  throw await this.refusal('introspection', res);
914
- return this.parseInternal('introspection', path, res);
1099
+ return res;
915
1100
  }
916
1101
  }
917
1102
  //# sourceMappingURL=vertical-client.js.map