@substrat-run/control-plane-api 0.159.0 → 0.161.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +33 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +674 -63
- package/dist/api.js.map +1 -1
- package/dist/browser.d.ts +2 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +2 -1
- package/dist/browser.js.map +1 -1
- package/dist/cf-observability.d.ts +30 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +304 -99
- package/dist/cf-observability.js.map +1 -1
- package/dist/custom-hostnames.d.ts +30 -0
- package/dist/custom-hostnames.d.ts.map +1 -1
- package/dist/custom-hostnames.js +25 -2
- package/dist/custom-hostnames.js.map +1 -1
- package/dist/deploy.d.ts +3 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +29 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/observability.js +8 -0
- package/dist/observability.js.map +1 -1
- package/dist/platform-drain.d.ts +26 -2
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +297 -119
- package/dist/platform-drain.js.map +1 -1
- package/dist/platform-entry.generated.d.ts +2 -2
- package/dist/platform-entry.generated.d.ts.map +1 -1
- package/dist/platform-entry.generated.js +5 -5
- package/dist/platform-entry.generated.js.map +1 -1
- package/dist/platform-wire.d.ts +51 -0
- package/dist/platform-wire.d.ts.map +1 -0
- package/dist/platform-wire.js +163 -0
- package/dist/platform-wire.js.map +1 -0
- package/dist/scope-copy-backfill.d.ts.map +1 -1
- package/dist/scope-copy-backfill.js +22 -9
- package/dist/scope-copy-backfill.js.map +1 -1
- package/dist/scope-copy-cleanup.js +1 -1
- package/dist/scope-copy-cleanup.js.map +1 -1
- package/dist/staff-client.d.ts +38 -2
- package/dist/staff-client.d.ts.map +1 -1
- package/dist/staff-client.js +24 -1
- package/dist/staff-client.js.map +1 -1
- package/dist/vertical-client.d.ts +116 -4
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +366 -25
- package/dist/vertical-client.js.map +1 -1
- package/dist/wire-settings.d.ts +77 -0
- package/dist/wire-settings.d.ts.map +1 -0
- package/dist/wire-settings.js +155 -0
- package/dist/wire-settings.js.map +1 -0
- package/dist/wire-upload.d.ts +100 -0
- package/dist/wire-upload.d.ts.map +1 -0
- package/dist/wire-upload.js +141 -0
- package/dist/wire-upload.js.map +1 -0
- package/dist/wire-walk.d.ts +140 -0
- package/dist/wire-walk.d.ts.map +1 -0
- package/dist/wire-walk.js +339 -0
- package/dist/wire-walk.js.map +1 -0
- package/package.json +10 -10
package/dist/vertical-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from '@substrat-run/contracts';
|
|
2
|
-
import { PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, 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, mergeDumpPages, scopeDumpPage, } from '@substrat-run/contracts';
|
|
3
|
-
import { ulid, undrainedEventsOf } from '@substrat-run/kernel';
|
|
2
|
+
import { PREVIEW_CLIENT_PATH, attachmentRecord, projectedConnectionGrant, denialFilterParams, capabilityFilterParams, capabilityPage, capabilityRevokeAnswer, capabilityRevokeRequest, mintedPreviewClient, ownerSeat, ownerClaimLink, memberInviteLink, memberRemoval, scopeMembers, ownerTransferResult, previewClientClaim, retiredPreviewClients, peerGrantsEntry, peerSwitchOutcome, systemSwitchOutcome, lifecycleDelivery, systemScheduleEntry, switchedOffInUnit, exportedBatch, importResult, importCursorMoved, importState, substratError, CONNECTOR_ATTACHMENT_META_HEADER, CONNECTOR_ATTACHMENT_META_MAX_BYTES, CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES, CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES, connectorAttachmentMetaBytes, connectorAttachmentMetaRefusal, connectorAttachmentBufferedRefusal, connectorAttachmentSizeRefusal, CONNECTOR_ATTACHMENT_RECORD_HEADER, LOAD_STAMP_HEADER, PLATFORM_SECRET_HEADER, WRITE_REVISION_HEADER, mergeDumpPages, scopeDumpPage, } from '@substrat-run/contracts';
|
|
3
|
+
import { STREAM_IDLE_TIMEOUT_MS, STREAM_MAX_DURATION_MS, attachmentSha256, deadlineFetch, isSizedByteStream, knownLengthInit, runtimeSha256, scopeNotHeldRefusal, ulid, undrainedEventsOf, verifiedStream } from '@substrat-run/kernel';
|
|
4
4
|
import { ControlPlaneError } from '@substrat-run/control-plane-client';
|
|
5
5
|
/**
|
|
6
6
|
* A 404 the vertical's router answered because it has no such route: a non-empty body that is
|
|
@@ -19,6 +19,116 @@ export function isRouteMiss(err) {
|
|
|
19
19
|
return true;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* The 409 a deployment answers a size read for a scope it does not hold (#2541): never provisioned
|
|
24
|
+
* there, reaped, or carried away. Told by its sentence (`scopeNotHeldRefusal`) as well as its status,
|
|
25
|
+
* since a 409 on the same route could be any other conflict. Whether that is benign is
|
|
26
|
+
* `notHeldIsBenign`'s question, asked of the directory.
|
|
27
|
+
*/
|
|
28
|
+
export function isScopeNotHeld(err, scopeId) {
|
|
29
|
+
return err instanceof ControlPlaneError && err.status === 409 && err.message.includes(scopeNotHeldRefusal(scopeId));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether a deployment's "not held" answer is expected, judged against the directory's record read
|
|
33
|
+
* NOW (#2541): the scope was reaped (or archived) since it was listed, or the directory has no such
|
|
34
|
+
* scope. Every other status (`provisioning`, `active`, `suspended`, `archiving`) holds a store, so
|
|
35
|
+
* "not held" there is a real fault and the caller keeps it an error. ONE rule for the storage
|
|
36
|
+
* gauge (skip) and the on-demand console page (read 0).
|
|
37
|
+
*
|
|
38
|
+
* `record` is not caught: a directory read that FAILS rejects here, so the failure is reported
|
|
39
|
+
* instead of being taken for a gone scope.
|
|
40
|
+
*/
|
|
41
|
+
export async function notHeldIsBenign(record) {
|
|
42
|
+
const found = await record();
|
|
43
|
+
return found === undefined || found.status === 'reaped' || found.status === 'archived';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The record an attachment read's response carries in its header (#711), parsed, not cast: it holds
|
|
47
|
+
* the sha256 the bytes are checked against, and a shape this side invented would witness nothing.
|
|
48
|
+
*/
|
|
49
|
+
function attachmentRecordOf(res) {
|
|
50
|
+
const raw = res.headers.get(CONNECTOR_ATTACHMENT_RECORD_HEADER);
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new Error(`connector-attachment-open: the vertical answered ${res.status} with no ` +
|
|
53
|
+
`${CONNECTOR_ATTACHMENT_RECORD_HEADER} header — the bytes cannot be trusted without ` +
|
|
54
|
+
`the record that witnesses them (#711)`);
|
|
55
|
+
}
|
|
56
|
+
return attachmentRecord.parse(JSON.parse(raw));
|
|
57
|
+
}
|
|
58
|
+
/** How a refusal of an attachment read ends: what the caller holds of the file when it is thrown (#2514). */
|
|
59
|
+
const NOTHING_READ = 'Nothing was read.';
|
|
60
|
+
/**
|
|
61
|
+
* A streamed read's refusal can land after most of the file has been handed on: every chunk but the
|
|
62
|
+
* held-back last one. So it says what those bytes are, and what to do with them, never "nothing".
|
|
63
|
+
*/
|
|
64
|
+
export const STREAMED_ATTACHMENT_UNVERIFIED = 'The bytes delivered before this error are a prefix of an unverified file: discard them, and undo anything written from them.';
|
|
65
|
+
/** An attachment read whose bytes are not the ones its record names (#2514): the deployment's fault. */
|
|
66
|
+
function attachmentIntegrity(record, why, cause, tail = NOTHING_READ) {
|
|
67
|
+
return new ControlPlaneError(502, `connector-attachment-open: attachment ${record.id} failed its integrity check: ${why} (#2514). ${tail}`, undefined, { code: 'internal', ...(cause !== undefined ? { cause } : {}) });
|
|
68
|
+
}
|
|
69
|
+
/** A `deadlineFetch` bound ran out (#2514): the deployment was slow, which is retryable, never an integrity fault. */
|
|
70
|
+
const isTimeout = (err) => err?.name === 'TimeoutError';
|
|
71
|
+
/** The deployment too slow to answer an attachment read (#2514): a 504, `unavailable`, retryable. */
|
|
72
|
+
function attachmentTimeout(what, err, tail) {
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return new ControlPlaneError(504, `connector-attachment-open: ${what} timed out: ${message} (#2514). ${tail}`, undefined, { code: 'unavailable', cause: err });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What a streamed attachment read's error becomes (#2514). A bound running out is a 504; anything
|
|
78
|
+
* else, a body that broke off or bytes that did not match, is the integrity refusal the buffered read
|
|
79
|
+
* gives. Both end in {@link STREAMED_ATTACHMENT_UNVERIFIED}: by the time they land, the caller may hold
|
|
80
|
+
* all but the last chunk.
|
|
81
|
+
*/
|
|
82
|
+
function streamRefusal(record, err) {
|
|
83
|
+
if (err instanceof ControlPlaneError)
|
|
84
|
+
return err;
|
|
85
|
+
if (isTimeout(err))
|
|
86
|
+
return attachmentTimeout(`reading attachment ${record.id}`, err, STREAMED_ATTACHMENT_UNVERIFIED);
|
|
87
|
+
return attachmentIntegrity(record, err instanceof Error ? err.message : String(err), err, STREAMED_ATTACHMENT_UNVERIFIED);
|
|
88
|
+
}
|
|
89
|
+
/** The longest any one bound of a streamed attachment read may be set to (#2514): an hour. */
|
|
90
|
+
const ATTACHMENT_STREAM_BOUND_MAX_MS = 60 * 60_000;
|
|
91
|
+
/**
|
|
92
|
+
* `VerticalClientOptions.attachmentStream`'s bounds, with the defaults filled in and each held to a
|
|
93
|
+
* whole number of milliseconds in (0, an hour] (#2514). Zero, a negative, NaN or Infinity would end
|
|
94
|
+
* every read at once or never, so it is refused in words instead.
|
|
95
|
+
*/
|
|
96
|
+
function attachmentStreamBounds(given) {
|
|
97
|
+
const bounds = {
|
|
98
|
+
timeoutMs: given?.timeoutMs ?? STREAM_IDLE_TIMEOUT_MS,
|
|
99
|
+
idleMs: given?.idleMs ?? STREAM_IDLE_TIMEOUT_MS,
|
|
100
|
+
maxStreamMs: given?.maxStreamMs ?? STREAM_MAX_DURATION_MS,
|
|
101
|
+
};
|
|
102
|
+
for (const [name, ms] of Object.entries(bounds)) {
|
|
103
|
+
if (!Number.isSafeInteger(ms) || ms <= 0 || ms > ATTACHMENT_STREAM_BOUND_MAX_MS) {
|
|
104
|
+
throw new ControlPlaneError(500, `VerticalClientOptions.attachmentStream.${name} must be a whole number of milliseconds from 1 to ` +
|
|
105
|
+
`${ATTACHMENT_STREAM_BOUND_MAX_MS}, not ${ms}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { ...bounds, ...(given?.timers ? { timers: given.timers } : {}) };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `source`, passed through chunk by chunk with its errors made `classify(err)`. Pull-driven with no
|
|
112
|
+
* queue of its own, as every relay on this path is, so it holds at most the chunk in flight.
|
|
113
|
+
*/
|
|
114
|
+
function classifiedStream(source, classify) {
|
|
115
|
+
const reader = source.getReader();
|
|
116
|
+
return new ReadableStream({
|
|
117
|
+
async pull(controller) {
|
|
118
|
+
try {
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done)
|
|
121
|
+
controller.close();
|
|
122
|
+
else
|
|
123
|
+
controller.enqueue(value);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
controller.error(classify(err));
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
cancel: (reason) => reader.cancel(reason),
|
|
130
|
+
}, { highWaterMark: 0 });
|
|
131
|
+
}
|
|
22
132
|
/** The orphan-attachment report as it crosses the wire (#2314): `OrphanSweepReport`, checked. */
|
|
23
133
|
const orphanSweepReportWire = z.object({
|
|
24
134
|
listed: z.number().int().nonnegative(),
|
|
@@ -31,6 +141,14 @@ const orphanSweepReportWire = z.object({
|
|
|
31
141
|
bucket: z.literal('none').optional(),
|
|
32
142
|
consumeFailed: z.literal(true).optional(),
|
|
33
143
|
});
|
|
144
|
+
/** #2446: a scope's role state as it crosses the wire (`RoleState`), checked. */
|
|
145
|
+
const roleShapeWire = z.object({ key: z.string().min(1), permissions: z.array(z.string()), holder: z.enum(['person', 'system']).optional() });
|
|
146
|
+
const roleStateWire = z.object({
|
|
147
|
+
roles: z.array(roleShapeWire),
|
|
148
|
+
seated: z.array(z.string()),
|
|
149
|
+
/** #2531: the tombstones of the roles a projection dropped. Absent from a deployment that predates them. */
|
|
150
|
+
dropped: z.array(roleShapeWire).optional(),
|
|
151
|
+
});
|
|
34
152
|
/** `routeAnswer`'s word for a deployment that predates the route it was asked (#1722, #2010). */
|
|
35
153
|
const PREDATES = Symbol('predates');
|
|
36
154
|
/** #1722's kept-copy and fenced-wipe verbs: a host without the method answers a 501. */
|
|
@@ -265,12 +383,29 @@ export class VerticalClient {
|
|
|
265
383
|
async listScopeTables(scopeId) {
|
|
266
384
|
return this.getInternal(`/internal/tables?scopeId=${encodeURIComponent(scopeId)}`);
|
|
267
385
|
}
|
|
268
|
-
/**
|
|
386
|
+
/**
|
|
387
|
+
* A bounded page of one of the scope's tables, for the Data view (#2563). Sends `held=1`: a
|
|
388
|
+
* deployment that does not hold the scope refuses it (409, `scopeNotHeldRefusal`) instead of
|
|
389
|
+
* answering an empty store. A vertical deployed before the flag ignores it and answers as it
|
|
390
|
+
* always did.
|
|
391
|
+
*/
|
|
269
392
|
async readScopeTable(scopeId, input) {
|
|
393
|
+
return this.tablePage(scopeId, input, true);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* A table of a store the deployment may no longer hold, for the carry and its cleanup, which ask
|
|
397
|
+
* a wiped copy whether it carries the `carried_away` tombstone (#2563). Sends no `held` flag, so
|
|
398
|
+
* a wiped copy answers. Nothing that shows a person data calls this: `readScopeTable` does.
|
|
399
|
+
*/
|
|
400
|
+
async readTombstoneTable(scopeId, input) {
|
|
401
|
+
return this.tablePage(scopeId, input, false);
|
|
402
|
+
}
|
|
403
|
+
async tablePage(scopeId, input, held) {
|
|
270
404
|
const q = new URLSearchParams({
|
|
271
405
|
scopeId,
|
|
272
406
|
limit: String(input.limit),
|
|
273
407
|
offset: String(input.offset),
|
|
408
|
+
...(held ? { held: '1' } : {}),
|
|
274
409
|
});
|
|
275
410
|
return this.getInternal(`/internal/tables/${encodeURIComponent(input.table)}?${q}`);
|
|
276
411
|
}
|
|
@@ -430,11 +565,24 @@ export class VerticalClient {
|
|
|
430
565
|
* Land provider bytes in the vertical's deployment as a CONNECTION (#574) — the bytes
|
|
431
566
|
* leg (a sealed signed PDF cannot ride the JSON invoke). Multipart: `meta` carries the
|
|
432
567
|
* JSON envelope, `body` the blob; fetch stamps the boundary content-type itself.
|
|
568
|
+
*
|
|
569
|
+
* A streamed body (#2498) goes to `/internal/connector-attachment-stream` instead, so it is
|
|
570
|
+
* never held here. A buffer keeps the multipart route, which every deployment has.
|
|
433
571
|
*/
|
|
434
572
|
async connectorUploadAttachment(input) {
|
|
435
573
|
const { body, ...meta } = input;
|
|
574
|
+
if (isSizedByteStream(body))
|
|
575
|
+
return this.connectorStreamAttachment(meta, body);
|
|
576
|
+
const metaJson = JSON.stringify(meta);
|
|
577
|
+
// The buffer is already held, so its length is known and the route's buffered bound is judged
|
|
578
|
+
// here, before it is copied into a form (#2538). This covers the typed inputs only: the route
|
|
579
|
+
// judges the bytes that arrive, so a caller that does not go through this client meets it there.
|
|
580
|
+
this.assertWithinAttachmentBounds('connector-attachment', connectorAttachmentMetaBytes(metaJson), {
|
|
581
|
+
length: body.byteLength,
|
|
582
|
+
held: 'buffered',
|
|
583
|
+
});
|
|
436
584
|
const form = new FormData();
|
|
437
|
-
form.append('meta',
|
|
585
|
+
form.append('meta', metaJson);
|
|
438
586
|
form.append('body', new Blob([body], { type: input.contentType }), input.filename);
|
|
439
587
|
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
440
588
|
const res = await this.reach('connector-attachment', () => this.options.fetch(`${base}/internal/connector-attachment`, {
|
|
@@ -447,6 +595,66 @@ export class VerticalClient {
|
|
|
447
595
|
throw await this.refusal('connector-attachment', res);
|
|
448
596
|
return this.parseInternal('connector-attachment', '/internal/connector-attachment', res);
|
|
449
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* Hold an upload to the bounds the far end holds it to (#2516), so it is refused here, with
|
|
600
|
+
* nothing sent, in the words the route would use. `body` is its length and how it is held: a
|
|
601
|
+
* buffered one is judged against the buffered bound, a streamed one against the stream bound.
|
|
602
|
+
*/
|
|
603
|
+
assertWithinAttachmentBounds(verb, metaBytes, body) {
|
|
604
|
+
if (metaBytes > CONNECTOR_ATTACHMENT_META_MAX_BYTES) {
|
|
605
|
+
throw new ControlPlaneError(400, `${verb}: ${connectorAttachmentMetaRefusal(metaBytes)}. Nothing was sent.`);
|
|
606
|
+
}
|
|
607
|
+
if (body?.held === 'buffered' && body.length > CONNECTOR_ATTACHMENT_BUFFERED_MAX_BYTES) {
|
|
608
|
+
throw new ControlPlaneError(413, `${verb}: ${connectorAttachmentBufferedRefusal(body.length)}. Nothing was sent.`);
|
|
609
|
+
}
|
|
610
|
+
if (body?.held === 'streamed' && body.length > CONNECTOR_ATTACHMENT_STREAM_MAX_BYTES) {
|
|
611
|
+
throw new ControlPlaneError(413, `${verb}: ${connectorAttachmentSizeRefusal(body.length)}. Nothing was sent.`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The bytes leg for a streamed body (#2498): the request body IS the file, sent at its
|
|
616
|
+
* declared length as it is read, and the metadata rides `CONNECTOR_ATTACHMENT_META_HEADER`.
|
|
617
|
+
* Its own route rather than a second shape on the multipart one, so a deployment that
|
|
618
|
+
* predates it answers a route miss, and that becomes a 501 saying to re-push it, rather than
|
|
619
|
+
* an error from a parser that was handed a file.
|
|
620
|
+
*/
|
|
621
|
+
async connectorStreamAttachment(meta, body) {
|
|
622
|
+
const verb = 'connector-attachment-stream';
|
|
623
|
+
const path = '/internal/connector-attachment-stream';
|
|
624
|
+
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
625
|
+
// The metadata is the only header a caller's strings reach. Its characters are always safe:
|
|
626
|
+
// JSON escapes a control character or a lone surrogate, and URI encoding everything else.
|
|
627
|
+
// Its SIZE is not, since it grows with the filename, so it is held to a named bound here and
|
|
628
|
+
// refused before a byte is sent rather than answered with a server's bare 431. (The multipart
|
|
629
|
+
// route is held to the same bound, #2516.) The file's own type rides inside it (the route
|
|
630
|
+
// reads `contentType` from there), so the request's own
|
|
631
|
+
// `content-type` is the plain octet-stream it is.
|
|
632
|
+
// Counted without `size`, as the multipart route's `meta` is, so one name passes or fails alike.
|
|
633
|
+
this.assertWithinAttachmentBounds(verb, connectorAttachmentMetaBytes(JSON.stringify(meta)), {
|
|
634
|
+
length: body.length,
|
|
635
|
+
held: 'streamed',
|
|
636
|
+
});
|
|
637
|
+
const encodedMeta = encodeURIComponent(JSON.stringify({ ...meta, size: body.length }));
|
|
638
|
+
const init = knownLengthInit({
|
|
639
|
+
method: 'POST',
|
|
640
|
+
headers: {
|
|
641
|
+
'content-type': 'application/octet-stream',
|
|
642
|
+
[PLATFORM_SECRET_HEADER]: this.options.platformSecret,
|
|
643
|
+
[CONNECTOR_ATTACHMENT_META_HEADER]: encodedMeta,
|
|
644
|
+
},
|
|
645
|
+
body,
|
|
646
|
+
});
|
|
647
|
+
const res = await this.reach(verb, () => this.options.fetch(`${base}${path}`, init));
|
|
648
|
+
if (!res.ok) {
|
|
649
|
+
const refused = await this.refusal(verb, res);
|
|
650
|
+
if (isRouteMiss(refused)) {
|
|
651
|
+
throw new ControlPlaneError(501, `the deployment serving scope ${meta.scopeId} predates streamed connector uploads (#2498) — ` +
|
|
652
|
+
`re-push the deployment, then retry. Nothing was stored.`);
|
|
653
|
+
}
|
|
654
|
+
throw refused;
|
|
655
|
+
}
|
|
656
|
+
return this.parseInternal(verb, path, res);
|
|
657
|
+
}
|
|
450
658
|
/**
|
|
451
659
|
* Fetch ONE attachment's bytes back OUT of the vertical's deployment as a CONNECTION
|
|
452
660
|
* (#711) — the outbound leg. The platform runs the vertical's signing connector and
|
|
@@ -459,37 +667,132 @@ export class VerticalClient {
|
|
|
459
667
|
* own document rather than failing a dispatch; a refusal is still a throw.
|
|
460
668
|
*/
|
|
461
669
|
async connectorOpenAttachment(input) {
|
|
462
|
-
const
|
|
463
|
-
connectionId: input.connectionId,
|
|
464
|
-
tenantId: input.tenantId,
|
|
465
|
-
scopeId: input.scopeId,
|
|
466
|
-
...(input.eventId ? { eventId: input.eventId } : {}),
|
|
467
|
-
});
|
|
468
|
-
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
469
|
-
const path = `/internal/connector-attachment/${encodeURIComponent(input.attachmentId)}`;
|
|
470
|
-
const res = await this.reach('connector-attachment-open', () => this.options.fetch(`${base}${path}?${q}`, {
|
|
670
|
+
const res = await this.reach('connector-attachment-open', () => this.options.fetch(this.attachmentReadUrl(input), {
|
|
471
671
|
headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
|
|
472
672
|
}));
|
|
473
673
|
if (res.status === 404)
|
|
474
674
|
return null;
|
|
475
675
|
if (!res.ok)
|
|
476
676
|
throw await this.refusal('connector-attachment-open', res);
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
677
|
+
const record = attachmentRecordOf(res);
|
|
678
|
+
// Held to that record here too (#2514). A deployment that streams the read sends its 200 before
|
|
679
|
+
// the digest is known and cuts the body when the bytes do not match, so a broken body is the
|
|
680
|
+
// integrity refusal, not a network fault, and is classified as one. The length and the digest are
|
|
681
|
+
// checked again on the whole body, so no transport's handling of a short body is the only guard.
|
|
682
|
+
let body;
|
|
683
|
+
try {
|
|
684
|
+
body = new Uint8Array(await res.arrayBuffer());
|
|
685
|
+
}
|
|
686
|
+
catch (e) {
|
|
687
|
+
throw attachmentIntegrity(record, `its body broke off before the ${record.size} bytes its record names arrived`, e);
|
|
688
|
+
}
|
|
689
|
+
if (body.byteLength !== record.size) {
|
|
690
|
+
throw attachmentIntegrity(record, `the deployment sent ${body.byteLength} bytes; its record names ${record.size}`);
|
|
691
|
+
}
|
|
692
|
+
if ((await attachmentSha256(body)) !== record.sha256) {
|
|
693
|
+
throw attachmentIntegrity(record, 'the bytes do not match the recorded sha256');
|
|
482
694
|
}
|
|
483
|
-
// Parsed, not cast: the record carries the sha256 the bytes are checked against at
|
|
484
|
-
// the far end, and a shape this side invented would witness nothing.
|
|
485
|
-
const record = attachmentRecord.parse(JSON.parse(raw));
|
|
486
|
-
const body = new Uint8Array(await res.arrayBuffer());
|
|
487
695
|
return {
|
|
488
696
|
record,
|
|
489
697
|
body,
|
|
490
|
-
|
|
698
|
+
// The record's, not the response's: the response header carries a type only when the
|
|
699
|
+
// stored one is header-safe (#2519), and the record is the exact stored value.
|
|
700
|
+
contentType: record.contentType,
|
|
491
701
|
};
|
|
492
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* `connectorOpenAttachment`, with the bytes as a stream read off the response as the caller reads
|
|
705
|
+
* it, never held here (#2514). The same route, the same `null` for an unknown id, the same refusals
|
|
706
|
+
* before the first byte. A deployment that predates streamed reads answers the same route in full;
|
|
707
|
+
* the bytes are still handed on as they arrive.
|
|
708
|
+
*
|
|
709
|
+
* - **Integrity.** The stream is held to the record in the response header: its `size`, and its
|
|
710
|
+
* sha256, checked as the bytes pass, with the chunk that completes the file held back until the
|
|
711
|
+
* digest matches. A body that breaks off (how a streaming deployment refuses bytes that do not
|
|
712
|
+
* match), runs short or long, or does not match, errors the stream with a `ControlPlaneError` 502,
|
|
713
|
+
* code `internal`, as the buffered read refuses it. A consumer that has received all `size` bytes
|
|
714
|
+
* has received the bytes the record names.
|
|
715
|
+
* - **Time.** `deadlineFetch`'s bounds, as on a connection's own `fetch`. The call's `timeoutMs`
|
|
716
|
+
* runs from the request until the caller's FIRST read of the stream, so read promptly once it is
|
|
717
|
+
* open: work awaited in between spends it. From that read, the body errors when the deployment
|
|
718
|
+
* sends no byte for `STREAM_IDLE_TIMEOUT_MS`, or after `STREAM_MAX_DURATION_MS` in all. Each of
|
|
719
|
+
* these, a deployment too slow to answer at all included, is a `ControlPlaneError` 504, code
|
|
720
|
+
* `unavailable`, and the call is released. The bounds are `VerticalClientOptions.attachmentStream`,
|
|
721
|
+
* each a whole number of milliseconds up to an hour.
|
|
722
|
+
* - **What an error means.** One thrown before the stream is handed back means nothing was read.
|
|
723
|
+
* One the stream errors with ends in {@link STREAMED_ATTACHMENT_UNVERIFIED}: the caller may hold
|
|
724
|
+
* all but the last chunk by then, and must discard it.
|
|
725
|
+
* - **Cancel.** Cancelling the stream cancels the response body, which the deployment reads as its
|
|
726
|
+
* reader going away.
|
|
727
|
+
*/
|
|
728
|
+
async connectorOpenAttachmentStream(input) {
|
|
729
|
+
const verb = 'connector-attachment-open';
|
|
730
|
+
const bounds = attachmentStreamBounds(this.options.attachmentStream);
|
|
731
|
+
// Asked for here, so a runtime that cannot check the bytes sends nothing; made only once there is a
|
|
732
|
+
// body to check, so a refusal or an unknown id leaves no digest open.
|
|
733
|
+
const makeDigest = this.options.attachmentStream?.sha256 ?? runtimeSha256();
|
|
734
|
+
if (!makeDigest) {
|
|
735
|
+
throw new ControlPlaneError(500, `${verb}: this runtime has no running sha256 to check a streamed attachment with; ` +
|
|
736
|
+
`pass VerticalClientOptions.attachmentStream.sha256`);
|
|
737
|
+
}
|
|
738
|
+
const timed = deadlineFetch(this.options.fetch, bounds.timeoutMs, bounds);
|
|
739
|
+
const res = (await this.reach(verb, async () => {
|
|
740
|
+
try {
|
|
741
|
+
return (await timed(this.attachmentReadUrl(input), {
|
|
742
|
+
headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
// The same slowness as a stall in the body, so the same answer, not `reach`'s "unreachable".
|
|
747
|
+
if (isTimeout(err)) {
|
|
748
|
+
throw attachmentTimeout(`attachment ${input.attachmentId}: the deployment's answer`, err, NOTHING_READ);
|
|
749
|
+
}
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
}));
|
|
753
|
+
if (res.status === 404) {
|
|
754
|
+
await res.text().catch(() => { });
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
if (!res.ok)
|
|
758
|
+
throw await this.refusal(verb, res);
|
|
759
|
+
let record;
|
|
760
|
+
try {
|
|
761
|
+
record = attachmentRecordOf(res);
|
|
762
|
+
}
|
|
763
|
+
catch (err) {
|
|
764
|
+
await res.text().catch(() => { });
|
|
765
|
+
throw err;
|
|
766
|
+
}
|
|
767
|
+
const source = res.body ?? new Response(new Uint8Array(0)).body;
|
|
768
|
+
let verified;
|
|
769
|
+
try {
|
|
770
|
+
verified = verifiedStream({ stream: source, length: record.size }, record, makeDigest());
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
await source.cancel().catch(() => { });
|
|
774
|
+
throw attachmentIntegrity(record, err.message, err);
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
record,
|
|
778
|
+
body: {
|
|
779
|
+
stream: classifiedStream(verified.stream, (err) => streamRefusal(record, err)),
|
|
780
|
+
length: record.size,
|
|
781
|
+
},
|
|
782
|
+
contentType: record.contentType,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/** Where one attachment is read from (#711): the id in the path, the rest in the query. */
|
|
786
|
+
attachmentReadUrl(input) {
|
|
787
|
+
const q = new URLSearchParams({
|
|
788
|
+
connectionId: input.connectionId,
|
|
789
|
+
tenantId: input.tenantId,
|
|
790
|
+
scopeId: input.scopeId,
|
|
791
|
+
...(input.eventId ? { eventId: input.eventId } : {}),
|
|
792
|
+
});
|
|
793
|
+
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
794
|
+
return `${base}/internal/connector-attachment/${encodeURIComponent(input.attachmentId)}?${q}`;
|
|
795
|
+
}
|
|
493
796
|
/**
|
|
494
797
|
* Deliver a connection's scope-level grant tuple into the vertical's deployment
|
|
495
798
|
* (#574) — the delivery half of `grantToConnection` for a scope served there.
|
|
@@ -622,6 +925,29 @@ export class VerticalClient {
|
|
|
622
925
|
headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
|
|
623
926
|
}));
|
|
624
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* #2446: the roles the deployment serving `scopeId` stores for `tenantId` there, and the role keys
|
|
930
|
+
* seated there (`/internal/role-state`): what its reconcile judges a role set against. `'predates'`
|
|
931
|
+
* on a 404, the deployment's own proof it has no such route, and on its 501, which a vertical-host
|
|
932
|
+
* carrying the route answers when its scope host predates `roleStateLocal`. Either is a deployment
|
|
933
|
+
* built before the read, which a promote counts rather than refuses on, since the promote that
|
|
934
|
+
* upgrades it is judged by it. Every other failure throws, as does a wrong-shaped answer.
|
|
935
|
+
*/
|
|
936
|
+
async roleState(input) {
|
|
937
|
+
const verb = 'role-state';
|
|
938
|
+
const base = this.options.baseUrl ?? 'https://vertical.invalid';
|
|
939
|
+
const q = new URLSearchParams({ tenantId: input.tenantId, scopeId: input.scopeId });
|
|
940
|
+
const answer = await this.routeAnswer(verb, { legacy501: true, lost: 'nothing was changed; the read is safe to repeat.' }, () => this.options.fetch(`${base}/internal/role-state?${q}`, {
|
|
941
|
+
method: 'GET',
|
|
942
|
+
headers: { [PLATFORM_SECRET_HEADER]: this.options.platformSecret },
|
|
943
|
+
}));
|
|
944
|
+
if (answer === PREDATES)
|
|
945
|
+
return 'predates';
|
|
946
|
+
const parsed = roleStateWire.safeParse(answer);
|
|
947
|
+
if (!parsed.success)
|
|
948
|
+
throw new ControlPlaneError(502, `vertical answered ${verb} with an unexpected shape for scope ${input.scopeId}.`);
|
|
949
|
+
return parsed.data;
|
|
950
|
+
}
|
|
625
951
|
/**
|
|
626
952
|
* The read half of the peer kill switch's status (#1706): every peer the deployment
|
|
627
953
|
* serving this scope holds or has held grants for, and where each stands. Mirrors
|
|
@@ -1177,9 +1503,21 @@ export class VerticalClient {
|
|
|
1177
1503
|
* missing size read as 0 would sit inside a sum and look like a real, small scope.
|
|
1178
1504
|
*/
|
|
1179
1505
|
async databaseSize(scopeId) {
|
|
1180
|
-
|
|
1506
|
+
return this.internalSize('database-size', scopeId);
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* What one scope's attachment rows sum to, in bytes (#2501), for the storage gauge. A deployment
|
|
1510
|
+
* built before the route answers 501, or 404 with no route at all; both throw here, and the
|
|
1511
|
+
* caller decides that is a deployment to redeploy, never a size of 0.
|
|
1512
|
+
*/
|
|
1513
|
+
async attachmentSize(scopeId) {
|
|
1514
|
+
return this.internalSize('attachment-size', scopeId);
|
|
1515
|
+
}
|
|
1516
|
+
/** One `/internal/<route>?scopeId=` size read; a 200 without a non-negative integer throws. */
|
|
1517
|
+
async internalSize(route, scopeId) {
|
|
1518
|
+
const answer = await this.getInternal(`/internal/${route}?scopeId=${encodeURIComponent(scopeId)}`);
|
|
1181
1519
|
if (typeof answer.bytes !== 'number' || !Number.isInteger(answer.bytes) || answer.bytes < 0) {
|
|
1182
|
-
throw new ControlPlaneError(502, `vertical answered
|
|
1520
|
+
throw new ControlPlaneError(502, `vertical answered ${route} without a size for scope ${scopeId}`);
|
|
1183
1521
|
}
|
|
1184
1522
|
return answer.bytes;
|
|
1185
1523
|
}
|
|
@@ -1236,6 +1574,9 @@ export class VerticalClient {
|
|
|
1236
1574
|
return await request();
|
|
1237
1575
|
}
|
|
1238
1576
|
catch (e) {
|
|
1577
|
+
// Already classified by the call itself (a streamed read's timeout, #2514): kept as it is.
|
|
1578
|
+
if (e instanceof ControlPlaneError)
|
|
1579
|
+
throw e;
|
|
1239
1580
|
const detail = e instanceof Error ? e.message : String(e);
|
|
1240
1581
|
throw new ControlPlaneError(502, `vertical unreachable during ${verb}: ${detail}`);
|
|
1241
1582
|
}
|