@layervai/qurl 0.7.0 → 3.0.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/README.md +242 -2
- package/dist/cjs/client.d.ts +41 -9
- package/dist/cjs/client.js +734 -41
- package/dist/cjs/crid.d.ts +2 -1
- package/dist/cjs/crid.js +11 -2
- package/dist/cjs/errors.d.ts +20 -3
- package/dist/cjs/errors.js +31 -4
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/node/agent-runtime.d.ts +108 -0
- package/dist/cjs/node/agent-runtime.js +952 -0
- package/dist/cjs/node/agent-state.d.ts +100 -0
- package/dist/cjs/node/agent-state.js +311 -0
- package/dist/cjs/node/agent-transport.d.ts +17 -0
- package/dist/cjs/node/agent-transport.js +118 -0
- package/dist/cjs/node/deployment.d.ts +6 -0
- package/dist/cjs/node/deployment.js +24 -3
- package/dist/cjs/node/file-agent-state.d.ts +15 -0
- package/dist/cjs/node/file-agent-state.js +137 -0
- package/dist/cjs/node/native-loader.cjs +7 -0
- package/dist/cjs/node/native-loader.d.cts +11 -0
- package/dist/cjs/node/native-udp.d.ts +8 -3
- package/dist/cjs/node/native-udp.js +8 -3
- package/dist/cjs/node/nhp-wire.d.ts +9 -0
- package/dist/cjs/node/nhp-wire.js +24 -7
- package/dist/cjs/node/portal-opener.d.ts +7 -0
- package/dist/cjs/node/portal-opener.js +28 -6
- package/dist/cjs/node/provider.d.ts +19 -0
- package/dist/cjs/node/provider.js +153 -0
- package/dist/cjs/node/qv2.d.ts +3 -1
- package/dist/cjs/node/qv2.js +16 -2
- package/dist/cjs/node/relay.d.ts +12 -0
- package/dist/cjs/node/relay.js +111 -0
- package/dist/cjs/node/sealed-agent-state.d.ts +19 -0
- package/dist/cjs/node/sealed-agent-state.js +193 -0
- package/dist/cjs/node/strict-json.d.ts +1 -1
- package/dist/cjs/node/strict-json.js +8 -3
- package/dist/cjs/node.d.ts +14 -0
- package/dist/cjs/node.js +24 -1
- package/dist/cjs/types.d.ts +161 -8
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +41 -9
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +735 -42
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/crid.d.ts +2 -1
- package/dist/esm/crid.d.ts.map +1 -1
- package/dist/esm/crid.js +10 -2
- package/dist/esm/crid.js.map +1 -1
- package/dist/esm/errors.d.ts +20 -3
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +29 -3
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/node/agent-runtime.d.ts +109 -0
- package/dist/esm/node/agent-runtime.d.ts.map +1 -0
- package/dist/esm/node/agent-runtime.js +946 -0
- package/dist/esm/node/agent-runtime.js.map +1 -0
- package/dist/esm/node/agent-state.d.ts +101 -0
- package/dist/esm/node/agent-state.d.ts.map +1 -0
- package/dist/esm/node/agent-state.js +298 -0
- package/dist/esm/node/agent-state.js.map +1 -0
- package/dist/esm/node/agent-transport.d.ts +18 -0
- package/dist/esm/node/agent-transport.d.ts.map +1 -0
- package/dist/esm/node/agent-transport.js +114 -0
- package/dist/esm/node/agent-transport.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +6 -0
- package/dist/esm/node/deployment.d.ts.map +1 -1
- package/dist/esm/node/deployment.js +23 -3
- package/dist/esm/node/deployment.js.map +1 -1
- package/dist/esm/node/file-agent-state.d.ts +16 -0
- package/dist/esm/node/file-agent-state.d.ts.map +1 -0
- package/dist/esm/node/file-agent-state.js +134 -0
- package/dist/esm/node/file-agent-state.js.map +1 -0
- package/dist/esm/node/native-loader.cjs +7 -0
- package/dist/esm/node/native-loader.d.cts +11 -0
- package/dist/esm/node/native-udp.d.ts +8 -3
- package/dist/esm/node/native-udp.d.ts.map +1 -1
- package/dist/esm/node/native-udp.js +7 -5
- package/dist/esm/node/native-udp.js.map +1 -1
- package/dist/esm/node/nhp-wire.d.ts +9 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -1
- package/dist/esm/node/nhp-wire.js +23 -6
- package/dist/esm/node/nhp-wire.js.map +1 -1
- package/dist/esm/node/portal-opener.d.ts +7 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -1
- package/dist/esm/node/portal-opener.js +28 -6
- package/dist/esm/node/portal-opener.js.map +1 -1
- package/dist/esm/node/provider.d.ts +20 -0
- package/dist/esm/node/provider.d.ts.map +1 -0
- package/dist/esm/node/provider.js +149 -0
- package/dist/esm/node/provider.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +3 -1
- package/dist/esm/node/qv2.d.ts.map +1 -1
- package/dist/esm/node/qv2.js +17 -3
- package/dist/esm/node/qv2.js.map +1 -1
- package/dist/esm/node/relay.d.ts +13 -0
- package/dist/esm/node/relay.d.ts.map +1 -0
- package/dist/esm/node/relay.js +105 -0
- package/dist/esm/node/relay.js.map +1 -0
- package/dist/esm/node/sealed-agent-state.d.ts +20 -0
- package/dist/esm/node/sealed-agent-state.d.ts.map +1 -0
- package/dist/esm/node/sealed-agent-state.js +190 -0
- package/dist/esm/node/sealed-agent-state.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +1 -1
- package/dist/esm/node/strict-json.d.ts.map +1 -1
- package/dist/esm/node/strict-json.js +8 -3
- package/dist/esm/node/strict-json.js.map +1 -1
- package/dist/esm/node.d.ts +14 -0
- package/dist/esm/node.d.ts.map +1 -1
- package/dist/esm/node.js +7 -0
- package/dist/esm/node.js.map +1 -1
- package/dist/esm/types.d.ts +161 -8
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +16 -6
package/dist/cjs/client.js
CHANGED
|
@@ -77,6 +77,7 @@ class ResponseBodyMaterializationError extends Error {
|
|
|
77
77
|
}
|
|
78
78
|
const NO_PASSTHROUGH_STATUSES = [];
|
|
79
79
|
const BATCH_PASSTHROUGH_STATUSES = [400];
|
|
80
|
+
const DELEGATED_BATCH_PASSTHROUGH_STATUSES = [304];
|
|
80
81
|
/** Allowlist of known query-param keys for the `list()` endpoint. */
|
|
81
82
|
const LIST_PARAM_KEYS = [
|
|
82
83
|
"limit",
|
|
@@ -90,6 +91,24 @@ const LIST_PARAM_KEYS = [
|
|
|
90
91
|
"expires_after",
|
|
91
92
|
];
|
|
92
93
|
const REQUEST_OPTION_KEYS = ["idempotencyKey"];
|
|
94
|
+
const DELEGATED_BATCH_INPUT_KEYS = [
|
|
95
|
+
"mint_capability",
|
|
96
|
+
"grants",
|
|
97
|
+
];
|
|
98
|
+
const DELEGATED_BATCH_GRANT_KEYS = [
|
|
99
|
+
"expires_in",
|
|
100
|
+
"label",
|
|
101
|
+
"one_time_use",
|
|
102
|
+
"max_sessions",
|
|
103
|
+
"session_duration",
|
|
104
|
+
"access_policy",
|
|
105
|
+
];
|
|
106
|
+
const DELEGATED_BATCH_CREATE_OPTION_KEYS = [
|
|
107
|
+
"idempotencyKey",
|
|
108
|
+
];
|
|
109
|
+
const DELEGATED_BATCH_GET_OPTION_KEYS = [
|
|
110
|
+
"etag",
|
|
111
|
+
];
|
|
93
112
|
// Compile-time witness: `Exclude<keyof X, (typeof KEYS)[number]>` is
|
|
94
113
|
// `never` iff KEYS lists every key of X. Paired with the
|
|
95
114
|
// `satisfies readonly (keyof X)[]` clause on the array (which catches
|
|
@@ -100,6 +119,10 @@ function assertExhaustive(_) {
|
|
|
100
119
|
}
|
|
101
120
|
assertExhaustive(true);
|
|
102
121
|
assertExhaustive(true);
|
|
122
|
+
assertExhaustive(true);
|
|
123
|
+
assertExhaustive(true);
|
|
124
|
+
assertExhaustive(true);
|
|
125
|
+
assertExhaustive(true);
|
|
103
126
|
const CREATE_FIELD_KEYS = [
|
|
104
127
|
"type",
|
|
105
128
|
"target_url",
|
|
@@ -152,6 +175,8 @@ const RESOURCE_LIST_PARAM_KEYS = [
|
|
|
152
175
|
];
|
|
153
176
|
assertExhaustive(true);
|
|
154
177
|
const CREATE_RESOURCE_FIELD_KEYS = [
|
|
178
|
+
"private",
|
|
179
|
+
"consumer_access",
|
|
155
180
|
"type",
|
|
156
181
|
"target_url",
|
|
157
182
|
"description",
|
|
@@ -163,6 +188,8 @@ const CREATE_RESOURCE_FIELD_KEYS = [
|
|
|
163
188
|
];
|
|
164
189
|
assertExhaustive(true);
|
|
165
190
|
const UPDATE_RESOURCE_FIELD_KEYS = [
|
|
191
|
+
"consumer_access",
|
|
192
|
+
"expected_consumer_access_version",
|
|
166
193
|
"description",
|
|
167
194
|
"tags",
|
|
168
195
|
"custom_domain",
|
|
@@ -191,6 +218,8 @@ assertExhaustive(true);
|
|
|
191
218
|
* fields the REST-shaped methods send.
|
|
192
219
|
*/
|
|
193
220
|
const PROTECT_URL_OPTION_KEYS = [
|
|
221
|
+
"private",
|
|
222
|
+
"consumerAccess",
|
|
194
223
|
"description",
|
|
195
224
|
"tags",
|
|
196
225
|
"customDomain",
|
|
@@ -767,6 +796,14 @@ const MAX_TARGET_PATH = 2048;
|
|
|
767
796
|
const MAX_TAGS = 10;
|
|
768
797
|
const MAX_TAG_LENGTH = 50;
|
|
769
798
|
const MAX_AUTO_PAGINATION_PAGES = 10_000;
|
|
799
|
+
const MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY = 32;
|
|
800
|
+
const MAX_DELEGATED_MINT_CAPABILITY = 8192;
|
|
801
|
+
const MAX_DELEGATED_ETAG = 96;
|
|
802
|
+
const MAX_DELEGATED_BATCH_ITEMS = 100;
|
|
803
|
+
const DELEGATED_BATCH_ID_PATTERN = /^dqb_[A-Za-z0-9_-]{22}$/;
|
|
804
|
+
const DELEGATED_QURL_ID_PATTERN = /^q_[0-9a-f]{11}$/;
|
|
805
|
+
const STRONG_ETAG_PATTERN = /^"[\x21\x23-\x7e]+"$/;
|
|
806
|
+
const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
770
807
|
// Keep these identity contracts aligned with qurl-go and the public API.
|
|
771
808
|
const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
|
|
772
809
|
// Current service schemas intentionally share this grammar, but keep the
|
|
@@ -1252,6 +1289,289 @@ function requireValidAccessPolicy(policy) {
|
|
|
1252
1289
|
}
|
|
1253
1290
|
}
|
|
1254
1291
|
}
|
|
1292
|
+
function validateCreateDelegatedQurlBatchInput(input) {
|
|
1293
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) {
|
|
1294
|
+
throw clientValidationError(`createDelegatedQurlBatch: input must be an object (got ${describeShape(input)})`);
|
|
1295
|
+
}
|
|
1296
|
+
const typed = input;
|
|
1297
|
+
requireNoUnknownFields(typed, DELEGATED_BATCH_INPUT_KEYS, "createDelegatedQurlBatch");
|
|
1298
|
+
if (typeof typed.mint_capability !== "string" ||
|
|
1299
|
+
typed.mint_capability.length < 1 ||
|
|
1300
|
+
typed.mint_capability.length > MAX_DELEGATED_MINT_CAPABILITY) {
|
|
1301
|
+
throw clientValidationError(`createDelegatedQurlBatch: mint_capability must be a string of 1-${MAX_DELEGATED_MINT_CAPABILITY} characters`);
|
|
1302
|
+
}
|
|
1303
|
+
if (!Array.isArray(typed.grants) ||
|
|
1304
|
+
typed.grants.length < 1 ||
|
|
1305
|
+
typed.grants.length > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1306
|
+
throw clientValidationError(`createDelegatedQurlBatch: grants must contain 1-${MAX_DELEGATED_BATCH_ITEMS} items`);
|
|
1307
|
+
}
|
|
1308
|
+
for (const [index, grant] of typed.grants.entries()) {
|
|
1309
|
+
const field = `createDelegatedQurlBatch: grants[${index}]`;
|
|
1310
|
+
if (typeof grant !== "object" || grant === null || Array.isArray(grant)) {
|
|
1311
|
+
throw clientValidationError(`${field}: must be an object (got ${describeShape(grant)})`);
|
|
1312
|
+
}
|
|
1313
|
+
requireNoUnknownFields(grant, DELEGATED_BATCH_GRANT_KEYS, field);
|
|
1314
|
+
requireMaxUtf8Bytes(grant.label, `${field}.label`, MAX_LABEL);
|
|
1315
|
+
requireBooleanIfPresent(grant.one_time_use, `${field}.one_time_use`);
|
|
1316
|
+
requireMaxSessionsInRange(grant.max_sessions, `${field}.max_sessions`);
|
|
1317
|
+
for (const durationField of ["expires_in", "session_duration"]) {
|
|
1318
|
+
const value = grant[durationField];
|
|
1319
|
+
if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
|
|
1320
|
+
throw clientValidationError(`${field}.${durationField}: must be a non-empty string`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (grant.access_policy !== undefined) {
|
|
1324
|
+
requireValidAccessPolicy(grant.access_policy);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
function validateDelegatedBatchCreateOptions(options) {
|
|
1329
|
+
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1330
|
+
throw clientValidationError("createDelegatedQurlBatch: options are required");
|
|
1331
|
+
}
|
|
1332
|
+
requireNoUnknownFields(options, DELEGATED_BATCH_CREATE_OPTION_KEYS, "createDelegatedQurlBatch options");
|
|
1333
|
+
const key = options.idempotencyKey;
|
|
1334
|
+
if (typeof key !== "string" ||
|
|
1335
|
+
key.length < MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY ||
|
|
1336
|
+
key.length > MAX_IDEMPOTENCY_KEY ||
|
|
1337
|
+
!/^[!-~]+$/.test(key)) {
|
|
1338
|
+
throw clientValidationError(`createDelegatedQurlBatch: idempotencyKey must contain ${MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY}-${MAX_IDEMPOTENCY_KEY} visible ASCII characters`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
function validateGetDelegatedQurlBatchOptions(options) {
|
|
1342
|
+
if (options === undefined)
|
|
1343
|
+
return;
|
|
1344
|
+
if (typeof options !== "object" || options === null || Array.isArray(options)) {
|
|
1345
|
+
throw clientValidationError(`getDelegatedQurlBatch: options must be an object (got ${describeShape(options)})`);
|
|
1346
|
+
}
|
|
1347
|
+
requireNoUnknownFields(options, DELEGATED_BATCH_GET_OPTION_KEYS, "getDelegatedQurlBatch options");
|
|
1348
|
+
const etag = options.etag;
|
|
1349
|
+
if (etag !== undefined &&
|
|
1350
|
+
(typeof etag !== "string" ||
|
|
1351
|
+
etag.length > MAX_DELEGATED_ETAG ||
|
|
1352
|
+
!STRONG_ETAG_PATTERN.test(etag))) {
|
|
1353
|
+
throw clientValidationError("getDelegatedQurlBatch: etag must be a valid strong ETag");
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function delegatedResponseError(status, detail, requestId) {
|
|
1357
|
+
return unexpectedResponseError(`Delegated qURL batch ${detail}`, { status, requestId });
|
|
1358
|
+
}
|
|
1359
|
+
function delegatedResponseObject(value, status, label, requestId) {
|
|
1360
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1361
|
+
throw delegatedResponseError(status, `${label} is not an object`, requestId);
|
|
1362
|
+
}
|
|
1363
|
+
return value;
|
|
1364
|
+
}
|
|
1365
|
+
function delegatedEnvelope(envelope, status) {
|
|
1366
|
+
if (typeof envelope.meta !== "object" || envelope.meta === null || Array.isArray(envelope.meta)) {
|
|
1367
|
+
throw delegatedResponseError(status, "response meta is not an object");
|
|
1368
|
+
}
|
|
1369
|
+
const meta = envelope.meta;
|
|
1370
|
+
if (typeof meta.request_id !== "string") {
|
|
1371
|
+
throw delegatedResponseError(status, "response meta has invalid request_id");
|
|
1372
|
+
}
|
|
1373
|
+
const data = delegatedResponseObject(envelope.data, status, "response data", meta.request_id);
|
|
1374
|
+
return { data, requestId: meta.request_id };
|
|
1375
|
+
}
|
|
1376
|
+
function requiredDelegatedHeader(envelope, name, status, requestId) {
|
|
1377
|
+
const headers = envelope.__http_headers;
|
|
1378
|
+
if (!headers || typeof headers.get !== "function") {
|
|
1379
|
+
throw delegatedResponseError(status, "response headers are unavailable", requestId);
|
|
1380
|
+
}
|
|
1381
|
+
const value = headers.get(name);
|
|
1382
|
+
if (value === null) {
|
|
1383
|
+
throw delegatedResponseError(status, `response is missing ${name}`, requestId);
|
|
1384
|
+
}
|
|
1385
|
+
return value;
|
|
1386
|
+
}
|
|
1387
|
+
function hasDelegatedNoStore(value) {
|
|
1388
|
+
if (value === null)
|
|
1389
|
+
return false;
|
|
1390
|
+
const directives = new Set(value.split(",").map((directive) => directive.trim().split("=", 1)[0].toLowerCase()));
|
|
1391
|
+
return directives.has("private") && directives.has("no-store");
|
|
1392
|
+
}
|
|
1393
|
+
function delegatedResponseHeaders(envelope, status, requestId) {
|
|
1394
|
+
const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
|
|
1395
|
+
if (cacheControl !== null) {
|
|
1396
|
+
if (!hasDelegatedNoStore(cacheControl)) {
|
|
1397
|
+
throw delegatedResponseError(status, "response has invalid Cache-Control", requestId);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
else if (status !== 304) {
|
|
1401
|
+
requiredDelegatedHeader(envelope, "Cache-Control", status, requestId);
|
|
1402
|
+
}
|
|
1403
|
+
const etag = envelope.__http_headers?.get("ETag") ?? undefined;
|
|
1404
|
+
if (etag !== undefined && (etag.length > MAX_DELEGATED_ETAG || !STRONG_ETAG_PATTERN.test(etag))) {
|
|
1405
|
+
throw delegatedResponseError(status, "response has invalid strong ETag", requestId);
|
|
1406
|
+
}
|
|
1407
|
+
const rawRetryAfter = envelope.__http_headers?.get("Retry-After") ?? "";
|
|
1408
|
+
const trimmed = rawRetryAfter.trim();
|
|
1409
|
+
const seconds = /^\d+$/.test(trimmed)
|
|
1410
|
+
? Number(trimmed)
|
|
1411
|
+
: /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)
|
|
1412
|
+
? Math.ceil((Date.parse(trimmed) - Date.now()) / 1000)
|
|
1413
|
+
: NaN;
|
|
1414
|
+
const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
|
|
1415
|
+
? seconds
|
|
1416
|
+
: undefined;
|
|
1417
|
+
return { etag, retryAfter };
|
|
1418
|
+
}
|
|
1419
|
+
function isUtcTimestamp(value) {
|
|
1420
|
+
return (typeof value === "string" &&
|
|
1421
|
+
UTC_TIMESTAMP_PATTERN.test(value) &&
|
|
1422
|
+
Number.isFinite(Date.parse(value)));
|
|
1423
|
+
}
|
|
1424
|
+
function delegatedBatchBase(data, status, requestId, expectedBatchId) {
|
|
1425
|
+
if (typeof data.batch_id !== "string" ||
|
|
1426
|
+
!DELEGATED_BATCH_ID_PATTERN.test(data.batch_id) ||
|
|
1427
|
+
(expectedBatchId !== undefined && data.batch_id !== expectedBatchId)) {
|
|
1428
|
+
throw delegatedResponseError(status, "response has invalid batch_id", requestId);
|
|
1429
|
+
}
|
|
1430
|
+
if (!Number.isInteger(data.item_count) ||
|
|
1431
|
+
Number(data.item_count) < 1 ||
|
|
1432
|
+
Number(data.item_count) > MAX_DELEGATED_BATCH_ITEMS) {
|
|
1433
|
+
throw delegatedResponseError(status, "response has invalid item_count", requestId);
|
|
1434
|
+
}
|
|
1435
|
+
if (!isUtcTimestamp(data.submitted_at)) {
|
|
1436
|
+
throw delegatedResponseError(status, "response has invalid submitted_at", requestId);
|
|
1437
|
+
}
|
|
1438
|
+
return {
|
|
1439
|
+
batchId: data.batch_id,
|
|
1440
|
+
itemCount: Number(data.item_count),
|
|
1441
|
+
submittedAt: data.submitted_at,
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
function validateDelegatedLocation(value, baseUrl, batchId, status, requestId) {
|
|
1445
|
+
let location;
|
|
1446
|
+
try {
|
|
1447
|
+
const base = new URL(`${baseUrl}/`);
|
|
1448
|
+
const relative = value.startsWith("/v1/delegated-qurl-batches/")
|
|
1449
|
+
? `${base.pathname.replace(/\/$/, "")}${value}`
|
|
1450
|
+
: value;
|
|
1451
|
+
location = new URL(relative, base);
|
|
1452
|
+
}
|
|
1453
|
+
catch {
|
|
1454
|
+
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1455
|
+
}
|
|
1456
|
+
const expected = new URL(`${baseUrl}/v1/delegated-qurl-batches/${batchId}`);
|
|
1457
|
+
if (location.username !== "" ||
|
|
1458
|
+
location.password !== "" ||
|
|
1459
|
+
location.search !== "" ||
|
|
1460
|
+
location.hash !== "" ||
|
|
1461
|
+
location.href !== expected.href) {
|
|
1462
|
+
throw delegatedResponseError(status, "response has invalid Location", requestId);
|
|
1463
|
+
}
|
|
1464
|
+
return location.href;
|
|
1465
|
+
}
|
|
1466
|
+
function validateDelegatedQurlLink(value) {
|
|
1467
|
+
if (typeof value !== "string")
|
|
1468
|
+
return false;
|
|
1469
|
+
try {
|
|
1470
|
+
const link = new URL(value);
|
|
1471
|
+
return (link.protocol === "https:" &&
|
|
1472
|
+
link.username === "" &&
|
|
1473
|
+
link.password === "" &&
|
|
1474
|
+
link.hash.length > 1);
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
return false;
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function validateDelegatedQurl(value, expectedQurlId, status, requestId) {
|
|
1481
|
+
const data = delegatedResponseObject(value, status, "response data", requestId);
|
|
1482
|
+
if (data.qurl_id !== expectedQurlId) {
|
|
1483
|
+
throw delegatedResponseError(status, "response has invalid qurl_id", requestId);
|
|
1484
|
+
}
|
|
1485
|
+
if (data.status !== "active" &&
|
|
1486
|
+
data.status !== "consumed" &&
|
|
1487
|
+
data.status !== "expired" &&
|
|
1488
|
+
data.status !== "revoked") {
|
|
1489
|
+
throw delegatedResponseError(status, "response has invalid status", requestId);
|
|
1490
|
+
}
|
|
1491
|
+
if (!isUtcTimestamp(data.expires_at) ||
|
|
1492
|
+
typeof data.one_time_use !== "boolean" ||
|
|
1493
|
+
!Number.isInteger(data.max_sessions) ||
|
|
1494
|
+
Number(data.max_sessions) < 0 ||
|
|
1495
|
+
!Number.isInteger(data.session_duration) ||
|
|
1496
|
+
Number(data.session_duration) <= 0 ||
|
|
1497
|
+
(data.label !== undefined && typeof data.label !== "string") ||
|
|
1498
|
+
(data.access_policy !== undefined &&
|
|
1499
|
+
(typeof data.access_policy !== "object" ||
|
|
1500
|
+
data.access_policy === null ||
|
|
1501
|
+
Array.isArray(data.access_policy)))) {
|
|
1502
|
+
throw delegatedResponseError(status, "response has invalid qURL metadata", requestId);
|
|
1503
|
+
}
|
|
1504
|
+
return { ...data, request_id: requestId };
|
|
1505
|
+
}
|
|
1506
|
+
function validateDelegatedBatchResults(value, itemCount, batchStatus, status, requestId) {
|
|
1507
|
+
if (!Array.isArray(value) || value.length !== itemCount) {
|
|
1508
|
+
throw delegatedResponseError(status, "terminal response has invalid results", requestId);
|
|
1509
|
+
}
|
|
1510
|
+
let succeeded = 0;
|
|
1511
|
+
const qurlIds = new Set();
|
|
1512
|
+
const links = new Set();
|
|
1513
|
+
const results = value.map((raw, index) => {
|
|
1514
|
+
const item = delegatedResponseObject(raw, status, `result ${index}`, requestId);
|
|
1515
|
+
if (item.index !== index) {
|
|
1516
|
+
throw delegatedResponseError(status, `result ${index} has invalid index`, requestId);
|
|
1517
|
+
}
|
|
1518
|
+
if (item.status === "succeeded") {
|
|
1519
|
+
if (item.error !== undefined && item.error !== null) {
|
|
1520
|
+
throw delegatedResponseError(status, `result ${index} has conflicting fields`, requestId);
|
|
1521
|
+
}
|
|
1522
|
+
const qurl = delegatedResponseObject(item.qurl, status, `result ${index} qurl`, requestId);
|
|
1523
|
+
if (typeof qurl.qurl_id !== "string" ||
|
|
1524
|
+
!DELEGATED_QURL_ID_PATTERN.test(qurl.qurl_id) ||
|
|
1525
|
+
!validateDelegatedQurlLink(qurl.qurl_link) ||
|
|
1526
|
+
!isUtcTimestamp(qurl.expires_at) ||
|
|
1527
|
+
qurlIds.has(qurl.qurl_id) ||
|
|
1528
|
+
links.has(qurl.qurl_link)) {
|
|
1529
|
+
throw delegatedResponseError(status, `result ${index} has invalid qurl`, requestId);
|
|
1530
|
+
}
|
|
1531
|
+
qurlIds.add(qurl.qurl_id);
|
|
1532
|
+
links.add(qurl.qurl_link);
|
|
1533
|
+
succeeded++;
|
|
1534
|
+
return {
|
|
1535
|
+
index,
|
|
1536
|
+
status: "succeeded",
|
|
1537
|
+
qurl: {
|
|
1538
|
+
qurl_id: qurl.qurl_id,
|
|
1539
|
+
qurl_link: qurl.qurl_link,
|
|
1540
|
+
expires_at: qurl.expires_at,
|
|
1541
|
+
},
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
if (item.status !== "failed" || (item.qurl !== undefined && item.qurl !== null)) {
|
|
1545
|
+
throw delegatedResponseError(status, `result ${index} has invalid status`, requestId);
|
|
1546
|
+
}
|
|
1547
|
+
const error = delegatedResponseObject(item.error, status, `result ${index} error`, requestId);
|
|
1548
|
+
if (typeof error.code !== "string" ||
|
|
1549
|
+
error.code.length === 0 ||
|
|
1550
|
+
typeof error.message !== "string") {
|
|
1551
|
+
throw delegatedResponseError(status, `result ${index} has invalid error`, requestId);
|
|
1552
|
+
}
|
|
1553
|
+
return {
|
|
1554
|
+
index,
|
|
1555
|
+
status: "failed",
|
|
1556
|
+
error: { code: error.code, message: error.message },
|
|
1557
|
+
};
|
|
1558
|
+
});
|
|
1559
|
+
if ((batchStatus === "succeeded" && succeeded !== itemCount) ||
|
|
1560
|
+
(batchStatus === "failed" && succeeded !== 0) ||
|
|
1561
|
+
(batchStatus === "partially_failed" && (succeeded === 0 || succeeded === itemCount))) {
|
|
1562
|
+
throw delegatedResponseError(status, "terminal status does not match results", requestId);
|
|
1563
|
+
}
|
|
1564
|
+
return results;
|
|
1565
|
+
}
|
|
1566
|
+
function classifyDelegatedBatchCreateFailure(error, batchId) {
|
|
1567
|
+
if (!(error instanceof errors_js_1.QURLError)) {
|
|
1568
|
+
throw new errors_js_1.DelegatedBatchOutcomeUnknownError(new errors_js_1.RuntimeError("Unexpected failure after delegated batch dispatch", { cause: error }), batchId);
|
|
1569
|
+
}
|
|
1570
|
+
if ((error.status >= 400 && error.status < 500 && error.status !== 408) || error.status === 501) {
|
|
1571
|
+
throw error;
|
|
1572
|
+
}
|
|
1573
|
+
throw new errors_js_1.DelegatedBatchOutcomeUnknownError(error, batchId);
|
|
1574
|
+
}
|
|
1255
1575
|
// `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
|
|
1256
1576
|
// usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
|
|
1257
1577
|
// cheap client-side sanity check — the server is still the
|
|
@@ -1426,6 +1746,13 @@ function normalizePatchFields(input, fields, options = {}) {
|
|
|
1426
1746
|
}
|
|
1427
1747
|
return normalized;
|
|
1428
1748
|
}
|
|
1749
|
+
function requireConsumerDecision(id, version, allowNew = false) {
|
|
1750
|
+
if (typeof id !== "string" ||
|
|
1751
|
+
(!/^[a-f0-9]{64}$/.test(id) && !(allowNew && id === "new")) ||
|
|
1752
|
+
!Number.isSafeInteger(version) ||
|
|
1753
|
+
version < (allowNew ? 0 : 1))
|
|
1754
|
+
throw clientValidationError("consumer decision: invalid id or expected version");
|
|
1755
|
+
}
|
|
1429
1756
|
function validateResourceWriteFields(input, options = {}) {
|
|
1430
1757
|
const errors = [];
|
|
1431
1758
|
const collect = (fn) => {
|
|
@@ -1440,6 +1767,50 @@ function validateResourceWriteFields(input, options = {}) {
|
|
|
1440
1767
|
throw err;
|
|
1441
1768
|
}
|
|
1442
1769
|
};
|
|
1770
|
+
collect(() => {
|
|
1771
|
+
if (input.private !== undefined && typeof input.private !== "boolean") {
|
|
1772
|
+
throw clientValidationError("private: must be a boolean");
|
|
1773
|
+
}
|
|
1774
|
+
if (input.consumer_access !== undefined) {
|
|
1775
|
+
const policy = input.consumer_access;
|
|
1776
|
+
requireObjectInput(policy, "consumer_access");
|
|
1777
|
+
const p = policy;
|
|
1778
|
+
requireNoUnknownFields(p, ["mode", "version", "contract_id"], "consumer_access");
|
|
1779
|
+
if (!["keys", "invite_only", "email_capture", "review", "contract"].includes(p.mode) ||
|
|
1780
|
+
!Number.isSafeInteger(p.version) ||
|
|
1781
|
+
p.version < 1) {
|
|
1782
|
+
throw clientValidationError("consumer_access: invalid mode or version");
|
|
1783
|
+
}
|
|
1784
|
+
if (p.mode === "contract") {
|
|
1785
|
+
if (typeof p.contract_id !== "string" ||
|
|
1786
|
+
!p.contract_id.trim() ||
|
|
1787
|
+
new TextEncoder().encode(p.contract_id).length > 128) {
|
|
1788
|
+
throw clientValidationError("consumer_access: contract mode requires a bounded contract_id");
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
else if (p.contract_id !== undefined) {
|
|
1792
|
+
throw clientValidationError("consumer_access: contract_id requires contract mode");
|
|
1793
|
+
}
|
|
1794
|
+
if (input.private === false)
|
|
1795
|
+
throw clientValidationError("consumer_access: requires a private resource");
|
|
1796
|
+
if (options.requireUrlTarget) {
|
|
1797
|
+
if (p.version !== 1 || input.private !== true)
|
|
1798
|
+
throw clientValidationError("consumer_access: creation requires private true and version one");
|
|
1799
|
+
}
|
|
1800
|
+
else if (options.allowAliasClear) {
|
|
1801
|
+
const expected = input.expected_consumer_access_version;
|
|
1802
|
+
if (!Number.isSafeInteger(expected) ||
|
|
1803
|
+
expected < 0 ||
|
|
1804
|
+
p.version !== expected + 1 ||
|
|
1805
|
+
Object.keys(input).some((key) => key !== "consumer_access" && key !== "expected_consumer_access_version")) {
|
|
1806
|
+
throw clientValidationError("consumer_access: update requires its expected version and no metadata changes");
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
else if (input.expected_consumer_access_version !== undefined) {
|
|
1811
|
+
throw clientValidationError("expected_consumer_access_version: requires consumer_access");
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1443
1814
|
const resourceType = input.type;
|
|
1444
1815
|
if (resourceType !== undefined && typeof resourceType !== "string") {
|
|
1445
1816
|
errors.push(`type: must be a string (got ${resourceType === null ? "null" : typeof resourceType})`);
|
|
@@ -2112,6 +2483,12 @@ class QURLClient {
|
|
|
2112
2483
|
// SDK-wide normalizePatchFields convention.
|
|
2113
2484
|
const normalized = normalizePatchFields(opts, PROTECT_URL_OPTION_KEYS);
|
|
2114
2485
|
const body = { target_url: targetUrl };
|
|
2486
|
+
if (normalized.private !== undefined)
|
|
2487
|
+
body.private = normalized.private;
|
|
2488
|
+
if (normalized.consumerAccess !== undefined) {
|
|
2489
|
+
body.private = normalized.private ?? true;
|
|
2490
|
+
body.consumer_access = normalized.consumerAccess;
|
|
2491
|
+
}
|
|
2115
2492
|
if (normalized.description !== undefined)
|
|
2116
2493
|
body.description = normalized.description;
|
|
2117
2494
|
if (normalized.tags !== undefined)
|
|
@@ -2120,9 +2497,19 @@ class QURLClient {
|
|
|
2120
2497
|
body.custom_domain = normalized.customDomain;
|
|
2121
2498
|
if (normalized.alias !== undefined)
|
|
2122
2499
|
body.alias = normalized.alias;
|
|
2123
|
-
validateResourceWriteFields(body);
|
|
2124
|
-
const
|
|
2500
|
+
validateResourceWriteFields(body, { requireUrlTarget: true });
|
|
2501
|
+
const response = await this.rawRequest("POST", "/v1/resources", body, {
|
|
2502
|
+
requestOptions,
|
|
2503
|
+
});
|
|
2504
|
+
if (response.__http_status === 204)
|
|
2505
|
+
throw unexpectedResponseError("protectUrl: expected a resource response body");
|
|
2506
|
+
const resource = response.data;
|
|
2125
2507
|
await verifyResourceIdentity(resource, "protectUrl");
|
|
2508
|
+
if (normalized.consumerAccess &&
|
|
2509
|
+
(resource.private !== true ||
|
|
2510
|
+
!sameConsumerPolicy(resource.consumer_access, normalized.consumerAccess, response.meta?.found_existing === true))) {
|
|
2511
|
+
throw unexpectedResponseError("protectUrl: private policy was not confirmed");
|
|
2512
|
+
}
|
|
2126
2513
|
return new ProtectedResource(this, resource.crid,
|
|
2127
2514
|
// Some resource types redact target_url in responses; fall back to
|
|
2128
2515
|
// the caller-supplied URL so the handle stays useful.
|
|
@@ -2148,8 +2535,13 @@ class QURLClient {
|
|
|
2148
2535
|
return new ProtectedResource(this, id);
|
|
2149
2536
|
}
|
|
2150
2537
|
/** Find or create the active qURL Connector resource for an immutable slug. */
|
|
2151
|
-
async ensureConnectorResource(slug, requestOptions) {
|
|
2538
|
+
async ensureConnectorResource(slug, requestOptions, consumerAccess) {
|
|
2152
2539
|
requireConnectorSlug(slug, "ensureConnectorResource");
|
|
2540
|
+
if (consumerAccess !== undefined) {
|
|
2541
|
+
validateResourceWriteFields({ consumer_access: consumerAccess, private: true });
|
|
2542
|
+
if (consumerAccess.version !== 1)
|
|
2543
|
+
throw clientValidationError("consumer_access: creation requires version one");
|
|
2544
|
+
}
|
|
2153
2545
|
// Response validation imports the producer's P-256 resource key. Preflight
|
|
2154
2546
|
// before POST so a missing runtime capability cannot orphan a committed row.
|
|
2155
2547
|
requireConnectorSubtleCrypto("ensureConnectorResource");
|
|
@@ -2164,12 +2556,18 @@ class QURLClient {
|
|
|
2164
2556
|
type: "tunnel",
|
|
2165
2557
|
slug,
|
|
2166
2558
|
find_or_create: true,
|
|
2559
|
+
...(consumerAccess ? { private: true, consumer_access: consumerAccess } : {}),
|
|
2167
2560
|
}, { requestOptions, retry: false, generateIdempotencyKey: false });
|
|
2168
2561
|
// The find-or-create endpoint returns 201 for both newly-created
|
|
2169
2562
|
// and found-existing rows; meta.found_existing distinguishes the arms.
|
|
2170
2563
|
if (response.__http_status !== 201) {
|
|
2171
2564
|
throw unexpectedResponseError(`ensureConnectorResource: expected HTTP 201, got ${response.__http_status ?? "unknown"}`);
|
|
2172
2565
|
}
|
|
2566
|
+
if (consumerAccess &&
|
|
2567
|
+
(response.data.private !== true ||
|
|
2568
|
+
!sameConsumerPolicy(response.data.consumer_access, consumerAccess, response.meta?.found_existing === true))) {
|
|
2569
|
+
throw unexpectedResponseError("ensureConnectorResource: private policy was not confirmed");
|
|
2570
|
+
}
|
|
2173
2571
|
resource = await parseConnectorResource(this, response.data, "ensureConnectorResource", {
|
|
2174
2572
|
slug,
|
|
2175
2573
|
});
|
|
@@ -2506,6 +2904,195 @@ class QURLClient {
|
|
|
2506
2904
|
}
|
|
2507
2905
|
return requestId !== undefined ? { ...result, request_id: requestId } : result;
|
|
2508
2906
|
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Start one durable delegated qURL batch. The SDK sends this mutation once.
|
|
2909
|
+
* If the result may have committed, retry explicitly with the same key and
|
|
2910
|
+
* semantic body.
|
|
2911
|
+
*/
|
|
2912
|
+
async createDelegatedQurlBatch(input, options) {
|
|
2913
|
+
validateCreateDelegatedQurlBatchInput(input);
|
|
2914
|
+
validateDelegatedBatchCreateOptions(options);
|
|
2915
|
+
let dispatched = false;
|
|
2916
|
+
let acceptedBatchId;
|
|
2917
|
+
try {
|
|
2918
|
+
const envelope = await this.rawRequest("POST", "/v1/delegated-qurl-batches", input, {
|
|
2919
|
+
requestOptions: options,
|
|
2920
|
+
retry: false,
|
|
2921
|
+
generateIdempotencyKey: false,
|
|
2922
|
+
captureResponseHeaders: true,
|
|
2923
|
+
onDispatch: () => {
|
|
2924
|
+
dispatched = true;
|
|
2925
|
+
},
|
|
2926
|
+
});
|
|
2927
|
+
const status = envelope.__http_status ?? 0;
|
|
2928
|
+
if (status !== 202) {
|
|
2929
|
+
throw delegatedResponseError(status, "create returned an unexpected success status");
|
|
2930
|
+
}
|
|
2931
|
+
const rawData = envelope.data;
|
|
2932
|
+
if (typeof rawData === "object" && rawData !== null && !Array.isArray(rawData)) {
|
|
2933
|
+
const candidate = rawData.batch_id;
|
|
2934
|
+
if (typeof candidate === "string" && DELEGATED_BATCH_ID_PATTERN.test(candidate)) {
|
|
2935
|
+
acceptedBatchId = candidate;
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
2939
|
+
const base = delegatedBatchBase(data, status, requestId);
|
|
2940
|
+
if (data.status !== "queued" || base.itemCount !== input.grants.length) {
|
|
2941
|
+
throw delegatedResponseError(status, "acceptance data is inconsistent", requestId);
|
|
2942
|
+
}
|
|
2943
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
|
|
2944
|
+
const location = validateDelegatedLocation(requiredDelegatedHeader(envelope, "Location", status, requestId), this.baseUrl, base.batchId, status, requestId);
|
|
2945
|
+
return {
|
|
2946
|
+
http_status: 202,
|
|
2947
|
+
batch_id: base.batchId,
|
|
2948
|
+
status: "queued",
|
|
2949
|
+
item_count: base.itemCount,
|
|
2950
|
+
submitted_at: base.submittedAt,
|
|
2951
|
+
etag: responseHeaders.etag,
|
|
2952
|
+
retry_after: responseHeaders.retryAfter,
|
|
2953
|
+
location,
|
|
2954
|
+
request_id: requestId,
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
catch (error) {
|
|
2958
|
+
if (!dispatched)
|
|
2959
|
+
throw error;
|
|
2960
|
+
classifyDelegatedBatchCreateFailure(error, acceptedBatchId);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Read one delegated qURL batch state. One call performs one logical read;
|
|
2965
|
+
* callers retain control of the poll count and total deadline.
|
|
2966
|
+
*/
|
|
2967
|
+
async getDelegatedQurlBatch(batchId, options) {
|
|
2968
|
+
if (typeof batchId !== "string" || !DELEGATED_BATCH_ID_PATTERN.test(batchId)) {
|
|
2969
|
+
throw clientValidationError("getDelegatedQurlBatch: batchId must match dqb_ plus 22 URL-safe characters");
|
|
2970
|
+
}
|
|
2971
|
+
validateGetDelegatedQurlBatchOptions(options);
|
|
2972
|
+
const envelope = await this.rawRequest("GET", `/v1/delegated-qurl-batches/${encodeURIComponent(batchId)}`, undefined, {
|
|
2973
|
+
passthroughStatuses: DELEGATED_BATCH_PASSTHROUGH_STATUSES,
|
|
2974
|
+
allowEmptyPassthroughBody: true,
|
|
2975
|
+
ifNoneMatch: options?.etag,
|
|
2976
|
+
retry: false,
|
|
2977
|
+
captureResponseHeaders: true,
|
|
2978
|
+
});
|
|
2979
|
+
const status = envelope.__http_status ?? 0;
|
|
2980
|
+
if (status === 304) {
|
|
2981
|
+
if (envelope.__http_body_empty !== true || options?.etag === undefined) {
|
|
2982
|
+
throw delegatedResponseError(status, "304 response is inconsistent");
|
|
2983
|
+
}
|
|
2984
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status);
|
|
2985
|
+
if (responseHeaders.etag !== undefined && responseHeaders.etag !== options.etag) {
|
|
2986
|
+
throw delegatedResponseError(status, "304 response changed the ETag");
|
|
2987
|
+
}
|
|
2988
|
+
return {
|
|
2989
|
+
http_status: 304,
|
|
2990
|
+
etag: responseHeaders.etag ?? options.etag,
|
|
2991
|
+
...(responseHeaders.retryAfter === undefined
|
|
2992
|
+
? {}
|
|
2993
|
+
: { retry_after: responseHeaders.retryAfter }),
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
if (status !== 200 && status !== 202) {
|
|
2997
|
+
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
2998
|
+
}
|
|
2999
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
3000
|
+
const base = delegatedBatchBase(data, status, requestId, batchId);
|
|
3001
|
+
if (status === 202) {
|
|
3002
|
+
if (data.status !== "queued" && data.status !== "running") {
|
|
3003
|
+
throw delegatedResponseError(status, "pending data has a terminal status", requestId);
|
|
3004
|
+
}
|
|
3005
|
+
const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
|
|
3006
|
+
return {
|
|
3007
|
+
http_status: 202,
|
|
3008
|
+
batch_id: base.batchId,
|
|
3009
|
+
status: data.status,
|
|
3010
|
+
item_count: base.itemCount,
|
|
3011
|
+
submitted_at: base.submittedAt,
|
|
3012
|
+
etag: responseHeaders.etag,
|
|
3013
|
+
retry_after: responseHeaders.retryAfter,
|
|
3014
|
+
request_id: requestId,
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
if (data.status !== "succeeded" &&
|
|
3018
|
+
data.status !== "partially_failed" &&
|
|
3019
|
+
data.status !== "failed") {
|
|
3020
|
+
throw delegatedResponseError(status, "terminal data has a non-terminal status", requestId);
|
|
3021
|
+
}
|
|
3022
|
+
let results;
|
|
3023
|
+
try {
|
|
3024
|
+
if (!isUtcTimestamp(data.completed_at)) {
|
|
3025
|
+
throw delegatedResponseError(status, "terminal data has invalid completed_at", requestId);
|
|
3026
|
+
}
|
|
3027
|
+
results = validateDelegatedBatchResults(data.results, base.itemCount, data.status, status, requestId);
|
|
3028
|
+
}
|
|
3029
|
+
catch (error) {
|
|
3030
|
+
const partialQurlIds = Array.isArray(data.results)
|
|
3031
|
+
? [
|
|
3032
|
+
...new Set(data.results.flatMap((item) => item &&
|
|
3033
|
+
typeof item === "object" &&
|
|
3034
|
+
item.status === "succeeded" &&
|
|
3035
|
+
typeof item.qurl?.qurl_id === "string" &&
|
|
3036
|
+
DELEGATED_QURL_ID_PATTERN.test(item.qurl.qurl_id)
|
|
3037
|
+
? [item.qurl.qurl_id]
|
|
3038
|
+
: [])),
|
|
3039
|
+
]
|
|
3040
|
+
: [];
|
|
3041
|
+
if (error instanceof errors_js_1.QURLError)
|
|
3042
|
+
Object.defineProperty(error, "partialQurlIds", {
|
|
3043
|
+
value: Object.freeze(partialQurlIds),
|
|
3044
|
+
enumerable: true,
|
|
3045
|
+
});
|
|
3046
|
+
throw error;
|
|
3047
|
+
}
|
|
3048
|
+
// This is the caller's only copy of the bearer links. Do not discard a
|
|
3049
|
+
// valid terminal body because an intermediary removed a cache validator;
|
|
3050
|
+
// qurl-service owns the required no-store response policy.
|
|
3051
|
+
const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
|
|
3052
|
+
if (!hasDelegatedNoStore(cacheControl)) {
|
|
3053
|
+
this.log("delegated batch terminal response is missing private, no-store", {
|
|
3054
|
+
batch_id: base.batchId,
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
const etag = envelope.__http_headers?.get("ETag") ?? undefined;
|
|
3058
|
+
const terminalEtag = etag !== undefined && etag.length <= MAX_DELEGATED_ETAG && STRONG_ETAG_PATTERN.test(etag)
|
|
3059
|
+
? etag
|
|
3060
|
+
: undefined;
|
|
3061
|
+
return {
|
|
3062
|
+
http_status: 200,
|
|
3063
|
+
batch_id: base.batchId,
|
|
3064
|
+
status: data.status,
|
|
3065
|
+
item_count: base.itemCount,
|
|
3066
|
+
submitted_at: base.submittedAt,
|
|
3067
|
+
completed_at: data.completed_at,
|
|
3068
|
+
results,
|
|
3069
|
+
...(terminalEtag === undefined ? {} : { etag: terminalEtag }),
|
|
3070
|
+
request_id: requestId,
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
/** Read owner-visible metadata for one delegated qURL. */
|
|
3074
|
+
async getDelegatedQurl(qurlId) {
|
|
3075
|
+
if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
|
|
3076
|
+
throw clientValidationError("getDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
|
|
3077
|
+
}
|
|
3078
|
+
const envelope = await this.rawRequest("GET", `/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
|
|
3079
|
+
const status = envelope.__http_status ?? 0;
|
|
3080
|
+
if (status !== 200) {
|
|
3081
|
+
throw delegatedResponseError(status, "read returned an unexpected success status");
|
|
3082
|
+
}
|
|
3083
|
+
const { data, requestId } = delegatedEnvelope(envelope, status);
|
|
3084
|
+
return validateDelegatedQurl(data, qurlId, status, requestId);
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* Revoke one delegated qURL. DELETE is never retried automatically. A 503
|
|
3088
|
+
* `mutation_outcome_unknown` is safe to retry with the same qURL ID.
|
|
3089
|
+
*/
|
|
3090
|
+
async deleteDelegatedQurl(qurlId) {
|
|
3091
|
+
if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
|
|
3092
|
+
throw clientValidationError("deleteDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
|
|
3093
|
+
}
|
|
3094
|
+
await this.requestNoContent(`/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
|
|
3095
|
+
}
|
|
2509
3096
|
/**
|
|
2510
3097
|
* Get a qURL resource and its access tokens.
|
|
2511
3098
|
*
|
|
@@ -2767,6 +3354,76 @@ class QURLClient {
|
|
|
2767
3354
|
});
|
|
2768
3355
|
return this.request("PATCH", `/v1/resources/${encodeURIComponent(id)}`, validationInput, options);
|
|
2769
3356
|
}
|
|
3357
|
+
/** Send a publisher-only invitation. Mail delivery does not prove enrollment. */
|
|
3358
|
+
async inviteConsumer(id, email) {
|
|
3359
|
+
requireResourceCrid(id, "inviteConsumer");
|
|
3360
|
+
requireNonEmptyIfPresent(email, "email");
|
|
3361
|
+
if (typeof email !== "string" || email.length > 320)
|
|
3362
|
+
throw clientValidationError("email: must be a bounded string");
|
|
3363
|
+
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/invitations`, { email });
|
|
3364
|
+
}
|
|
3365
|
+
async listConsumerInvitations(id, input = {}) {
|
|
3366
|
+
requireResourceCrid(id, "listConsumerInvitations");
|
|
3367
|
+
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/invitations`, input, ["cursor", "limit"], "listConsumerInvitations"));
|
|
3368
|
+
return pageFromMeta({ invitations: data ?? [] }, meta);
|
|
3369
|
+
}
|
|
3370
|
+
async listConsumerGrants(id, input = {}) {
|
|
3371
|
+
requireResourceCrid(id, "listConsumerGrants");
|
|
3372
|
+
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/consumer-grants`, input, ["cursor", "limit"], "listConsumerGrants"));
|
|
3373
|
+
return pageFromMeta({ grants: data ?? [] }, meta);
|
|
3374
|
+
}
|
|
3375
|
+
async resendConsumerInvitation(id, invitationId, expectedVersion) {
|
|
3376
|
+
requireResourceCrid(id, "resendConsumerInvitation");
|
|
3377
|
+
requireConsumerDecision(invitationId, expectedVersion);
|
|
3378
|
+
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/invitations/${encodeURIComponent(invitationId)}/resend`, { expected_version: expectedVersion });
|
|
3379
|
+
}
|
|
3380
|
+
/** Remove the invitation and its grant. Inspect session_cleanup before reporting stream removal. */
|
|
3381
|
+
async revokeConsumerInvitation(id, invitationId, expectedVersion) {
|
|
3382
|
+
requireResourceCrid(id, "revokeConsumerInvitation");
|
|
3383
|
+
requireConsumerDecision(invitationId, expectedVersion);
|
|
3384
|
+
return this.request("DELETE", `/v1/resources/${encodeURIComponent(id)}/invitations/${encodeURIComponent(invitationId)}?expected_version=${expectedVersion}`);
|
|
3385
|
+
}
|
|
3386
|
+
/** Review approval, key opt-in with grantId new, or trusted publisher entitlement. */
|
|
3387
|
+
async approveConsumerGrant(id, grantId, input) {
|
|
3388
|
+
requireResourceCrid(id, "approveConsumerGrant");
|
|
3389
|
+
requireObjectInput(input, "approveConsumerGrant");
|
|
3390
|
+
requireNoUnknownFields(input, [
|
|
3391
|
+
"expected_version",
|
|
3392
|
+
"credential_kind",
|
|
3393
|
+
"credential_id",
|
|
3394
|
+
"credential_public_key_b64",
|
|
3395
|
+
"entitlement_id",
|
|
3396
|
+
"expires_at",
|
|
3397
|
+
], "approveConsumerGrant");
|
|
3398
|
+
requireConsumerDecision(grantId, input.expected_version, grantId === "new");
|
|
3399
|
+
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/consumer-grants/${encodeURIComponent(grantId)}/approve`, input);
|
|
3400
|
+
}
|
|
3401
|
+
async denyConsumerGrant(id, grantId, expectedVersion) {
|
|
3402
|
+
requireResourceCrid(id, "denyConsumerGrant");
|
|
3403
|
+
requireConsumerDecision(grantId, expectedVersion);
|
|
3404
|
+
return this.request("POST", `/v1/resources/${encodeURIComponent(id)}/consumer-grants/${encodeURIComponent(grantId)}/deny`, { expected_version: expectedVersion });
|
|
3405
|
+
}
|
|
3406
|
+
async revokeConsumerGrant(id, grantId, expectedVersion) {
|
|
3407
|
+
requireResourceCrid(id, "revokeConsumerGrant");
|
|
3408
|
+
requireConsumerDecision(grantId, expectedVersion);
|
|
3409
|
+
return this.request("DELETE", `/v1/resources/${encodeURIComponent(id)}/consumer-grants/${encodeURIComponent(grantId)}?expected_version=${expectedVersion}`);
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Revoke an API resource identifier retained by a client migrating to CRIDs.
|
|
3413
|
+
* New callers should use deleteResource with the resource CRID.
|
|
3414
|
+
*/
|
|
3415
|
+
async deleteResourceByIdentifier(id) {
|
|
3416
|
+
if (typeof id !== "string")
|
|
3417
|
+
throw clientValidationError("id: invalid resource identifier");
|
|
3418
|
+
if (/^r_[a-z0-9_-]{11}$/.test(id)) {
|
|
3419
|
+
await this.requestNoContent(`/v1/qurls/${encodeURIComponent(id)}`);
|
|
3420
|
+
return;
|
|
3421
|
+
}
|
|
3422
|
+
const publicKey = /^[A-Za-z0-9_-]{107,214}$/.test(id) && id.length % 4 !== 1;
|
|
3423
|
+
if (!publicKey)
|
|
3424
|
+
requireResourceCrid(id, "deleteResourceByIdentifier");
|
|
3425
|
+
await this.requestNoContent(`/v1/resources/${encodeURIComponent(id)}`);
|
|
3426
|
+
}
|
|
2770
3427
|
/** Revoke a resource and all of its qURLs. */
|
|
2771
3428
|
async deleteResource(id) {
|
|
2772
3429
|
requireResourceCrid(id, "deleteResource");
|
|
@@ -2873,43 +3530,63 @@ class QURLClient {
|
|
|
2873
3530
|
validateResourceQurlUpdateInput(normalized);
|
|
2874
3531
|
return this.request("PATCH", `/v1/resources/${encodeURIComponent(id)}/qurls/${encodeURIComponent(qurlId)}`, normalized, options);
|
|
2875
3532
|
}
|
|
2876
|
-
/**
|
|
2877
|
-
* List active access sessions for a resource.
|
|
2878
|
-
*
|
|
2879
|
-
* The current API contract has no cursor query params for this endpoint.
|
|
2880
|
-
* If the service later advertises pagination metadata, the SDK returns
|
|
2881
|
-
* this page and emits a debug log rather than pretending it fetched all pages.
|
|
2882
|
-
*/
|
|
3533
|
+
/** List every active session, following bounded service pages. */
|
|
2883
3534
|
async listResourceSessions(id) {
|
|
2884
3535
|
requireResourceCrid(id, "listResourceSessions");
|
|
2885
|
-
const
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
3536
|
+
const sessions = [];
|
|
3537
|
+
let request_id;
|
|
3538
|
+
let page_size;
|
|
3539
|
+
for await (const session of this.paginateAll("listResourceSessions", {}, async ({ cursor }) => {
|
|
3540
|
+
const { data, meta } = await this.rawRequest("GET", appendQuery(`/v1/resources/${encodeURIComponent(id)}/sessions`, { paged: "true", cursor }, ["paged", "cursor"], "listResourceSessions"));
|
|
3541
|
+
if (meta?.has_more && !meta.next_cursor) {
|
|
3542
|
+
throw unexpectedResponseError("Session page has no continuation cursor", {
|
|
3543
|
+
requestId: meta.request_id,
|
|
3544
|
+
});
|
|
3545
|
+
}
|
|
3546
|
+
request_id = meta?.request_id;
|
|
3547
|
+
page_size = meta?.page_size;
|
|
3548
|
+
return { ...meta, sessions: data ?? [] };
|
|
3549
|
+
}, (page) => page.sessions)) {
|
|
3550
|
+
sessions.push(session);
|
|
2891
3551
|
}
|
|
2892
|
-
return {
|
|
2893
|
-
sessions: data ?? [],
|
|
2894
|
-
request_id: meta?.request_id,
|
|
2895
|
-
has_more: false,
|
|
2896
|
-
page_size: meta?.page_size,
|
|
2897
|
-
};
|
|
3552
|
+
return { sessions, request_id, has_more: false, page_size };
|
|
2898
3553
|
}
|
|
2899
|
-
/**
|
|
3554
|
+
/**
|
|
3555
|
+
* End every session page. This is not atomic: a failed page throws after earlier
|
|
3556
|
+
* pages may have closed sessions. List sessions to reconcile, then retry the call.
|
|
3557
|
+
* A retry can close newly admitted sessions and counts only its own transitions.
|
|
3558
|
+
*/
|
|
2900
3559
|
async terminateAllResourceSessions(id) {
|
|
2901
3560
|
requireResourceCrid(id, "terminateAllResourceSessions");
|
|
2902
3561
|
const path = `/v1/resources/${encodeURIComponent(id)}/sessions`;
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
3562
|
+
let terminated = 0;
|
|
3563
|
+
let request_id;
|
|
3564
|
+
for await (const count of this.paginateAll("terminateAllResourceSessions", {}, async ({ cursor }) => {
|
|
3565
|
+
const { data, meta, __http_status } = await this.rawRequest("DELETE", appendQuery(path, { paged: "true", cursor }, ["paged", "cursor"], "terminateAllResourceSessions"));
|
|
3566
|
+
if (__http_status === 204) {
|
|
3567
|
+
throw unexpectedResponseError(`Unexpected 204 No Content from DELETE ${path}; expected response body`, { status: 204, requestId: meta?.request_id });
|
|
3568
|
+
}
|
|
3569
|
+
if (meta?.has_more && !meta.next_cursor) {
|
|
3570
|
+
throw unexpectedResponseError("Session page has no continuation cursor", {
|
|
3571
|
+
requestId: meta.request_id,
|
|
3572
|
+
});
|
|
3573
|
+
}
|
|
3574
|
+
if (data?.terminated === undefined) {
|
|
3575
|
+
this.log("terminateAllResourceSessions: missing terminated count; defaulting to zero", {
|
|
3576
|
+
request_id: meta?.request_id,
|
|
3577
|
+
});
|
|
3578
|
+
}
|
|
3579
|
+
else if (!Number.isSafeInteger(data.terminated) || data.terminated < 0) {
|
|
3580
|
+
throw unexpectedResponseError("terminateAllResourceSessions: invalid terminated count", {
|
|
3581
|
+
requestId: meta?.request_id,
|
|
3582
|
+
});
|
|
3583
|
+
}
|
|
3584
|
+
request_id = meta?.request_id;
|
|
3585
|
+
return { ...meta, counts: [data?.terminated ?? 0] };
|
|
3586
|
+
}, (page) => page.counts)) {
|
|
3587
|
+
terminated += count;
|
|
2911
3588
|
}
|
|
2912
|
-
return { terminated
|
|
3589
|
+
return { terminated, request_id };
|
|
2913
3590
|
}
|
|
2914
3591
|
/** Terminate a specific resource session. */
|
|
2915
3592
|
async terminateResourceSession(id, sessionId) {
|
|
@@ -3258,7 +3935,7 @@ class QURLClient {
|
|
|
3258
3935
|
*/
|
|
3259
3936
|
async rawRequest(method, path, body, rawOptions = {}) {
|
|
3260
3937
|
const url = `${this.baseUrl}${path}`;
|
|
3261
|
-
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
|
|
3938
|
+
const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, allowEmptyPassthroughBody = false, retry = true, generateIdempotencyKey = true, ifNoneMatch, captureResponseHeaders = false, onDispatch, } = rawOptions;
|
|
3262
3939
|
const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
|
|
3263
3940
|
const headers = {
|
|
3264
3941
|
Authorization: `Bearer ${this.apiKey}`,
|
|
@@ -3271,6 +3948,9 @@ class QURLClient {
|
|
|
3271
3948
|
if (idempotencyKey !== undefined) {
|
|
3272
3949
|
headers["Idempotency-Key"] = idempotencyKey;
|
|
3273
3950
|
}
|
|
3951
|
+
if (ifNoneMatch !== undefined) {
|
|
3952
|
+
headers["If-None-Match"] = ifNoneMatch;
|
|
3953
|
+
}
|
|
3274
3954
|
// Reads may retry transient statuses and transport failures. POST/PATCH
|
|
3275
3955
|
// keep status-code retries limited to rate limits and reuse the same
|
|
3276
3956
|
// Idempotency-Key for fetch-level failures. DELETE is deliberately never
|
|
@@ -3307,6 +3987,7 @@ class QURLClient {
|
|
|
3307
3987
|
// attempt shouldn't poison the next try's deadline. Total
|
|
3308
3988
|
// request bound: roughly `timeout * (maxRetries + 1) +
|
|
3309
3989
|
// sum(retryDelay)`.
|
|
3990
|
+
onDispatch?.();
|
|
3310
3991
|
response = await this.fetchFn(url, {
|
|
3311
3992
|
method,
|
|
3312
3993
|
headers,
|
|
@@ -3482,16 +4163,20 @@ class QURLClient {
|
|
|
3482
4163
|
data: undefined,
|
|
3483
4164
|
__http_status: response.status,
|
|
3484
4165
|
__http_body_empty: responseBody.length === 0,
|
|
4166
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3485
4167
|
};
|
|
3486
4168
|
}
|
|
3487
4169
|
// Exact no-content helpers must inspect both axes of the success
|
|
3488
4170
|
// contract. Let only those callers observe an empty non-204 response;
|
|
3489
4171
|
// body-returning methods still reject it as non-JSON below.
|
|
3490
|
-
if (response.ok && allowEmptySuccessBody
|
|
4172
|
+
if (((response.ok && allowEmptySuccessBody) ||
|
|
4173
|
+
(isPassthrough && allowEmptyPassthroughBody)) &&
|
|
4174
|
+
responseBody.length === 0) {
|
|
3491
4175
|
return {
|
|
3492
4176
|
data: undefined,
|
|
3493
4177
|
__http_status: response.status,
|
|
3494
4178
|
__http_body_empty: true,
|
|
4179
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3495
4180
|
};
|
|
3496
4181
|
}
|
|
3497
4182
|
try {
|
|
@@ -3501,6 +4186,7 @@ class QURLClient {
|
|
|
3501
4186
|
__http_status: response.status,
|
|
3502
4187
|
// JSON.parse("") throws, so parsed JSON is necessarily non-empty.
|
|
3503
4188
|
__http_body_empty: false,
|
|
4189
|
+
__http_headers: captureResponseHeaders ? response.headers : undefined,
|
|
3504
4190
|
};
|
|
3505
4191
|
}
|
|
3506
4192
|
catch {
|
|
@@ -3611,14 +4297,13 @@ class QURLClient {
|
|
|
3611
4297
|
const header = response.headers.get("Retry-After");
|
|
3612
4298
|
if (!header)
|
|
3613
4299
|
return undefined;
|
|
3614
|
-
// TODO: parse HTTP-date format per RFC 7231 §7.1.3 — currently only
|
|
3615
|
-
// handles delta-seconds; HTTP-date headers fall through to the
|
|
3616
|
-
// exponential-backoff branch in `retryDelay`. Tracked in #61.
|
|
3617
|
-
//
|
|
3618
|
-
// Digit-only pre-check rather than `parseInt` alone: `parseInt("60abc")`
|
|
3619
|
-
// returns `60` and would silently honor a malformed header. The
|
|
3620
|
-
// strict check makes any deviation observable (debug log) instead.
|
|
3621
4300
|
const trimmed = header.trim();
|
|
4301
|
+
if (/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)) {
|
|
4302
|
+
const seconds = Math.ceil((Date.parse(trimmed) - Date.now()) / 1000);
|
|
4303
|
+
return Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
|
|
4304
|
+
? seconds
|
|
4305
|
+
: undefined;
|
|
4306
|
+
}
|
|
3622
4307
|
if (!/^\d+$/.test(trimmed)) {
|
|
3623
4308
|
this.log("Retry-After header was non-numeric, using exponential backoff", {
|
|
3624
4309
|
header_value: header.slice(0, 100),
|
|
@@ -3783,3 +4468,11 @@ class ConnectorResource {
|
|
|
3783
4468
|
}
|
|
3784
4469
|
}
|
|
3785
4470
|
exports.ConnectorResource = ConnectorResource;
|
|
4471
|
+
function sameConsumerPolicy(actual, expected, foundExisting) {
|
|
4472
|
+
return (actual?.mode === expected.mode &&
|
|
4473
|
+
(actual?.version === expected.version ||
|
|
4474
|
+
(foundExisting &&
|
|
4475
|
+
Number.isSafeInteger(actual?.version) &&
|
|
4476
|
+
Number(actual?.version) > expected.version)) &&
|
|
4477
|
+
actual?.contract_id === expected.contract_id);
|
|
4478
|
+
}
|