@layervai/qurl 0.7.0 → 2.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.
Files changed (121) hide show
  1. package/README.md +212 -1
  2. package/dist/cjs/client.d.ts +19 -1
  3. package/dist/cjs/client.js +520 -9
  4. package/dist/cjs/crid.d.ts +2 -1
  5. package/dist/cjs/crid.js +11 -2
  6. package/dist/cjs/errors.d.ts +20 -3
  7. package/dist/cjs/errors.js +31 -4
  8. package/dist/cjs/index.d.ts +2 -2
  9. package/dist/cjs/index.js +3 -1
  10. package/dist/cjs/node/agent-runtime.d.ts +108 -0
  11. package/dist/cjs/node/agent-runtime.js +952 -0
  12. package/dist/cjs/node/agent-state.d.ts +100 -0
  13. package/dist/cjs/node/agent-state.js +311 -0
  14. package/dist/cjs/node/agent-transport.d.ts +17 -0
  15. package/dist/cjs/node/agent-transport.js +118 -0
  16. package/dist/cjs/node/deployment.d.ts +6 -0
  17. package/dist/cjs/node/deployment.js +24 -3
  18. package/dist/cjs/node/file-agent-state.d.ts +15 -0
  19. package/dist/cjs/node/file-agent-state.js +137 -0
  20. package/dist/cjs/node/native-loader.cjs +7 -0
  21. package/dist/cjs/node/native-loader.d.cts +11 -0
  22. package/dist/cjs/node/native-udp.d.ts +8 -3
  23. package/dist/cjs/node/native-udp.js +8 -3
  24. package/dist/cjs/node/nhp-wire.d.ts +9 -0
  25. package/dist/cjs/node/nhp-wire.js +24 -7
  26. package/dist/cjs/node/portal-opener.d.ts +7 -0
  27. package/dist/cjs/node/portal-opener.js +28 -6
  28. package/dist/cjs/node/provider.d.ts +19 -0
  29. package/dist/cjs/node/provider.js +153 -0
  30. package/dist/cjs/node/qv2.d.ts +3 -1
  31. package/dist/cjs/node/qv2.js +16 -2
  32. package/dist/cjs/node/relay.d.ts +12 -0
  33. package/dist/cjs/node/relay.js +111 -0
  34. package/dist/cjs/node/sealed-agent-state.d.ts +19 -0
  35. package/dist/cjs/node/sealed-agent-state.js +193 -0
  36. package/dist/cjs/node/strict-json.d.ts +1 -1
  37. package/dist/cjs/node/strict-json.js +8 -3
  38. package/dist/cjs/node.d.ts +14 -0
  39. package/dist/cjs/node.js +24 -1
  40. package/dist/cjs/types.d.ts +97 -0
  41. package/dist/cjs/version.d.ts +1 -1
  42. package/dist/cjs/version.js +1 -1
  43. package/dist/esm/client.d.ts +19 -1
  44. package/dist/esm/client.d.ts.map +1 -1
  45. package/dist/esm/client.js +521 -10
  46. package/dist/esm/client.js.map +1 -1
  47. package/dist/esm/crid.d.ts +2 -1
  48. package/dist/esm/crid.d.ts.map +1 -1
  49. package/dist/esm/crid.js +10 -2
  50. package/dist/esm/crid.js.map +1 -1
  51. package/dist/esm/errors.d.ts +20 -3
  52. package/dist/esm/errors.d.ts.map +1 -1
  53. package/dist/esm/errors.js +29 -3
  54. package/dist/esm/errors.js.map +1 -1
  55. package/dist/esm/index.d.ts +2 -2
  56. package/dist/esm/index.d.ts.map +1 -1
  57. package/dist/esm/index.js +1 -1
  58. package/dist/esm/index.js.map +1 -1
  59. package/dist/esm/node/agent-runtime.d.ts +109 -0
  60. package/dist/esm/node/agent-runtime.d.ts.map +1 -0
  61. package/dist/esm/node/agent-runtime.js +946 -0
  62. package/dist/esm/node/agent-runtime.js.map +1 -0
  63. package/dist/esm/node/agent-state.d.ts +101 -0
  64. package/dist/esm/node/agent-state.d.ts.map +1 -0
  65. package/dist/esm/node/agent-state.js +298 -0
  66. package/dist/esm/node/agent-state.js.map +1 -0
  67. package/dist/esm/node/agent-transport.d.ts +18 -0
  68. package/dist/esm/node/agent-transport.d.ts.map +1 -0
  69. package/dist/esm/node/agent-transport.js +114 -0
  70. package/dist/esm/node/agent-transport.js.map +1 -0
  71. package/dist/esm/node/deployment.d.ts +6 -0
  72. package/dist/esm/node/deployment.d.ts.map +1 -1
  73. package/dist/esm/node/deployment.js +23 -3
  74. package/dist/esm/node/deployment.js.map +1 -1
  75. package/dist/esm/node/file-agent-state.d.ts +16 -0
  76. package/dist/esm/node/file-agent-state.d.ts.map +1 -0
  77. package/dist/esm/node/file-agent-state.js +134 -0
  78. package/dist/esm/node/file-agent-state.js.map +1 -0
  79. package/dist/esm/node/native-loader.cjs +7 -0
  80. package/dist/esm/node/native-loader.d.cts +11 -0
  81. package/dist/esm/node/native-udp.d.ts +8 -3
  82. package/dist/esm/node/native-udp.d.ts.map +1 -1
  83. package/dist/esm/node/native-udp.js +7 -5
  84. package/dist/esm/node/native-udp.js.map +1 -1
  85. package/dist/esm/node/nhp-wire.d.ts +9 -0
  86. package/dist/esm/node/nhp-wire.d.ts.map +1 -1
  87. package/dist/esm/node/nhp-wire.js +23 -6
  88. package/dist/esm/node/nhp-wire.js.map +1 -1
  89. package/dist/esm/node/portal-opener.d.ts +7 -0
  90. package/dist/esm/node/portal-opener.d.ts.map +1 -1
  91. package/dist/esm/node/portal-opener.js +28 -6
  92. package/dist/esm/node/portal-opener.js.map +1 -1
  93. package/dist/esm/node/provider.d.ts +20 -0
  94. package/dist/esm/node/provider.d.ts.map +1 -0
  95. package/dist/esm/node/provider.js +149 -0
  96. package/dist/esm/node/provider.js.map +1 -0
  97. package/dist/esm/node/qv2.d.ts +3 -1
  98. package/dist/esm/node/qv2.d.ts.map +1 -1
  99. package/dist/esm/node/qv2.js +17 -3
  100. package/dist/esm/node/qv2.js.map +1 -1
  101. package/dist/esm/node/relay.d.ts +13 -0
  102. package/dist/esm/node/relay.d.ts.map +1 -0
  103. package/dist/esm/node/relay.js +105 -0
  104. package/dist/esm/node/relay.js.map +1 -0
  105. package/dist/esm/node/sealed-agent-state.d.ts +20 -0
  106. package/dist/esm/node/sealed-agent-state.d.ts.map +1 -0
  107. package/dist/esm/node/sealed-agent-state.js +190 -0
  108. package/dist/esm/node/sealed-agent-state.js.map +1 -0
  109. package/dist/esm/node/strict-json.d.ts +1 -1
  110. package/dist/esm/node/strict-json.d.ts.map +1 -1
  111. package/dist/esm/node/strict-json.js +8 -3
  112. package/dist/esm/node/strict-json.js.map +1 -1
  113. package/dist/esm/node.d.ts +14 -0
  114. package/dist/esm/node.d.ts.map +1 -1
  115. package/dist/esm/node.js +7 -0
  116. package/dist/esm/node.js.map +1 -1
  117. package/dist/esm/types.d.ts +97 -0
  118. package/dist/esm/types.d.ts.map +1 -1
  119. package/dist/esm/version.d.ts +1 -1
  120. package/dist/esm/version.js +1 -1
  121. package/package.json +16 -7
@@ -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",
@@ -767,6 +790,14 @@ const MAX_TARGET_PATH = 2048;
767
790
  const MAX_TAGS = 10;
768
791
  const MAX_TAG_LENGTH = 50;
769
792
  const MAX_AUTO_PAGINATION_PAGES = 10_000;
793
+ const MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY = 32;
794
+ const MAX_DELEGATED_MINT_CAPABILITY = 8192;
795
+ const MAX_DELEGATED_ETAG = 96;
796
+ const MAX_DELEGATED_BATCH_ITEMS = 100;
797
+ const DELEGATED_BATCH_ID_PATTERN = /^dqb_[A-Za-z0-9_-]{22}$/;
798
+ const DELEGATED_QURL_ID_PATTERN = /^q_[0-9a-f]{11}$/;
799
+ const STRONG_ETAG_PATTERN = /^"[\x21\x23-\x7e]+"$/;
800
+ const UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
770
801
  // Keep these identity contracts aligned with qurl-go and the public API.
771
802
  const CONNECTOR_SLUG_PATTERN = /^[a-z][a-z0-9-]{1,62}[a-z0-9]$/;
772
803
  // Current service schemas intentionally share this grammar, but keep the
@@ -1252,6 +1283,289 @@ function requireValidAccessPolicy(policy) {
1252
1283
  }
1253
1284
  }
1254
1285
  }
1286
+ function validateCreateDelegatedQurlBatchInput(input) {
1287
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
1288
+ throw clientValidationError(`createDelegatedQurlBatch: input must be an object (got ${describeShape(input)})`);
1289
+ }
1290
+ const typed = input;
1291
+ requireNoUnknownFields(typed, DELEGATED_BATCH_INPUT_KEYS, "createDelegatedQurlBatch");
1292
+ if (typeof typed.mint_capability !== "string" ||
1293
+ typed.mint_capability.length < 1 ||
1294
+ typed.mint_capability.length > MAX_DELEGATED_MINT_CAPABILITY) {
1295
+ throw clientValidationError(`createDelegatedQurlBatch: mint_capability must be a string of 1-${MAX_DELEGATED_MINT_CAPABILITY} characters`);
1296
+ }
1297
+ if (!Array.isArray(typed.grants) ||
1298
+ typed.grants.length < 1 ||
1299
+ typed.grants.length > MAX_DELEGATED_BATCH_ITEMS) {
1300
+ throw clientValidationError(`createDelegatedQurlBatch: grants must contain 1-${MAX_DELEGATED_BATCH_ITEMS} items`);
1301
+ }
1302
+ for (const [index, grant] of typed.grants.entries()) {
1303
+ const field = `createDelegatedQurlBatch: grants[${index}]`;
1304
+ if (typeof grant !== "object" || grant === null || Array.isArray(grant)) {
1305
+ throw clientValidationError(`${field}: must be an object (got ${describeShape(grant)})`);
1306
+ }
1307
+ requireNoUnknownFields(grant, DELEGATED_BATCH_GRANT_KEYS, field);
1308
+ requireMaxUtf8Bytes(grant.label, `${field}.label`, MAX_LABEL);
1309
+ requireBooleanIfPresent(grant.one_time_use, `${field}.one_time_use`);
1310
+ requireMaxSessionsInRange(grant.max_sessions, `${field}.max_sessions`);
1311
+ for (const durationField of ["expires_in", "session_duration"]) {
1312
+ const value = grant[durationField];
1313
+ if (value !== undefined && (typeof value !== "string" || value.length === 0)) {
1314
+ throw clientValidationError(`${field}.${durationField}: must be a non-empty string`);
1315
+ }
1316
+ }
1317
+ if (grant.access_policy !== undefined) {
1318
+ requireValidAccessPolicy(grant.access_policy);
1319
+ }
1320
+ }
1321
+ }
1322
+ function validateDelegatedBatchCreateOptions(options) {
1323
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
1324
+ throw clientValidationError("createDelegatedQurlBatch: options are required");
1325
+ }
1326
+ requireNoUnknownFields(options, DELEGATED_BATCH_CREATE_OPTION_KEYS, "createDelegatedQurlBatch options");
1327
+ const key = options.idempotencyKey;
1328
+ if (typeof key !== "string" ||
1329
+ key.length < MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY ||
1330
+ key.length > MAX_IDEMPOTENCY_KEY ||
1331
+ !/^[!-~]+$/.test(key)) {
1332
+ throw clientValidationError(`createDelegatedQurlBatch: idempotencyKey must contain ${MIN_DELEGATED_BATCH_IDEMPOTENCY_KEY}-${MAX_IDEMPOTENCY_KEY} visible ASCII characters`);
1333
+ }
1334
+ }
1335
+ function validateGetDelegatedQurlBatchOptions(options) {
1336
+ if (options === undefined)
1337
+ return;
1338
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
1339
+ throw clientValidationError(`getDelegatedQurlBatch: options must be an object (got ${describeShape(options)})`);
1340
+ }
1341
+ requireNoUnknownFields(options, DELEGATED_BATCH_GET_OPTION_KEYS, "getDelegatedQurlBatch options");
1342
+ const etag = options.etag;
1343
+ if (etag !== undefined &&
1344
+ (typeof etag !== "string" ||
1345
+ etag.length > MAX_DELEGATED_ETAG ||
1346
+ !STRONG_ETAG_PATTERN.test(etag))) {
1347
+ throw clientValidationError("getDelegatedQurlBatch: etag must be a valid strong ETag");
1348
+ }
1349
+ }
1350
+ function delegatedResponseError(status, detail, requestId) {
1351
+ return unexpectedResponseError(`Delegated qURL batch ${detail}`, { status, requestId });
1352
+ }
1353
+ function delegatedResponseObject(value, status, label, requestId) {
1354
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1355
+ throw delegatedResponseError(status, `${label} is not an object`, requestId);
1356
+ }
1357
+ return value;
1358
+ }
1359
+ function delegatedEnvelope(envelope, status) {
1360
+ if (typeof envelope.meta !== "object" || envelope.meta === null || Array.isArray(envelope.meta)) {
1361
+ throw delegatedResponseError(status, "response meta is not an object");
1362
+ }
1363
+ const meta = envelope.meta;
1364
+ if (typeof meta.request_id !== "string") {
1365
+ throw delegatedResponseError(status, "response meta has invalid request_id");
1366
+ }
1367
+ const data = delegatedResponseObject(envelope.data, status, "response data", meta.request_id);
1368
+ return { data, requestId: meta.request_id };
1369
+ }
1370
+ function requiredDelegatedHeader(envelope, name, status, requestId) {
1371
+ const headers = envelope.__http_headers;
1372
+ if (!headers || typeof headers.get !== "function") {
1373
+ throw delegatedResponseError(status, "response headers are unavailable", requestId);
1374
+ }
1375
+ const value = headers.get(name);
1376
+ if (value === null) {
1377
+ throw delegatedResponseError(status, `response is missing ${name}`, requestId);
1378
+ }
1379
+ return value;
1380
+ }
1381
+ function hasDelegatedNoStore(value) {
1382
+ if (value === null)
1383
+ return false;
1384
+ const directives = new Set(value.split(",").map((directive) => directive.trim().split("=", 1)[0].toLowerCase()));
1385
+ return directives.has("private") && directives.has("no-store");
1386
+ }
1387
+ function delegatedResponseHeaders(envelope, status, requestId) {
1388
+ const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
1389
+ if (cacheControl !== null) {
1390
+ if (!hasDelegatedNoStore(cacheControl)) {
1391
+ throw delegatedResponseError(status, "response has invalid Cache-Control", requestId);
1392
+ }
1393
+ }
1394
+ else if (status !== 304) {
1395
+ requiredDelegatedHeader(envelope, "Cache-Control", status, requestId);
1396
+ }
1397
+ const etag = envelope.__http_headers?.get("ETag") ?? undefined;
1398
+ if (etag !== undefined && (etag.length > MAX_DELEGATED_ETAG || !STRONG_ETAG_PATTERN.test(etag))) {
1399
+ throw delegatedResponseError(status, "response has invalid strong ETag", requestId);
1400
+ }
1401
+ const rawRetryAfter = envelope.__http_headers?.get("Retry-After") ?? "";
1402
+ const trimmed = rawRetryAfter.trim();
1403
+ const seconds = /^\d+$/.test(trimmed)
1404
+ ? Number(trimmed)
1405
+ : /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)
1406
+ ? Math.ceil((Date.parse(trimmed) - Date.now()) / 1000)
1407
+ : NaN;
1408
+ const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
1409
+ ? seconds
1410
+ : undefined;
1411
+ return { etag, retryAfter };
1412
+ }
1413
+ function isUtcTimestamp(value) {
1414
+ return (typeof value === "string" &&
1415
+ UTC_TIMESTAMP_PATTERN.test(value) &&
1416
+ Number.isFinite(Date.parse(value)));
1417
+ }
1418
+ function delegatedBatchBase(data, status, requestId, expectedBatchId) {
1419
+ if (typeof data.batch_id !== "string" ||
1420
+ !DELEGATED_BATCH_ID_PATTERN.test(data.batch_id) ||
1421
+ (expectedBatchId !== undefined && data.batch_id !== expectedBatchId)) {
1422
+ throw delegatedResponseError(status, "response has invalid batch_id", requestId);
1423
+ }
1424
+ if (!Number.isInteger(data.item_count) ||
1425
+ Number(data.item_count) < 1 ||
1426
+ Number(data.item_count) > MAX_DELEGATED_BATCH_ITEMS) {
1427
+ throw delegatedResponseError(status, "response has invalid item_count", requestId);
1428
+ }
1429
+ if (!isUtcTimestamp(data.submitted_at)) {
1430
+ throw delegatedResponseError(status, "response has invalid submitted_at", requestId);
1431
+ }
1432
+ return {
1433
+ batchId: data.batch_id,
1434
+ itemCount: Number(data.item_count),
1435
+ submittedAt: data.submitted_at,
1436
+ };
1437
+ }
1438
+ function validateDelegatedLocation(value, baseUrl, batchId, status, requestId) {
1439
+ let location;
1440
+ try {
1441
+ const base = new URL(`${baseUrl}/`);
1442
+ const relative = value.startsWith("/v1/delegated-qurl-batches/")
1443
+ ? `${base.pathname.replace(/\/$/, "")}${value}`
1444
+ : value;
1445
+ location = new URL(relative, base);
1446
+ }
1447
+ catch {
1448
+ throw delegatedResponseError(status, "response has invalid Location", requestId);
1449
+ }
1450
+ const expected = new URL(`${baseUrl}/v1/delegated-qurl-batches/${batchId}`);
1451
+ if (location.username !== "" ||
1452
+ location.password !== "" ||
1453
+ location.search !== "" ||
1454
+ location.hash !== "" ||
1455
+ location.href !== expected.href) {
1456
+ throw delegatedResponseError(status, "response has invalid Location", requestId);
1457
+ }
1458
+ return location.href;
1459
+ }
1460
+ function validateDelegatedQurlLink(value) {
1461
+ if (typeof value !== "string")
1462
+ return false;
1463
+ try {
1464
+ const link = new URL(value);
1465
+ return (link.protocol === "https:" &&
1466
+ link.username === "" &&
1467
+ link.password === "" &&
1468
+ link.hash.length > 1);
1469
+ }
1470
+ catch {
1471
+ return false;
1472
+ }
1473
+ }
1474
+ function validateDelegatedQurl(value, expectedQurlId, status, requestId) {
1475
+ const data = delegatedResponseObject(value, status, "response data", requestId);
1476
+ if (data.qurl_id !== expectedQurlId) {
1477
+ throw delegatedResponseError(status, "response has invalid qurl_id", requestId);
1478
+ }
1479
+ if (data.status !== "active" &&
1480
+ data.status !== "consumed" &&
1481
+ data.status !== "expired" &&
1482
+ data.status !== "revoked") {
1483
+ throw delegatedResponseError(status, "response has invalid status", requestId);
1484
+ }
1485
+ if (!isUtcTimestamp(data.expires_at) ||
1486
+ typeof data.one_time_use !== "boolean" ||
1487
+ !Number.isInteger(data.max_sessions) ||
1488
+ Number(data.max_sessions) < 0 ||
1489
+ !Number.isInteger(data.session_duration) ||
1490
+ Number(data.session_duration) <= 0 ||
1491
+ (data.label !== undefined && typeof data.label !== "string") ||
1492
+ (data.access_policy !== undefined &&
1493
+ (typeof data.access_policy !== "object" ||
1494
+ data.access_policy === null ||
1495
+ Array.isArray(data.access_policy)))) {
1496
+ throw delegatedResponseError(status, "response has invalid qURL metadata", requestId);
1497
+ }
1498
+ return { ...data, request_id: requestId };
1499
+ }
1500
+ function validateDelegatedBatchResults(value, itemCount, batchStatus, status, requestId) {
1501
+ if (!Array.isArray(value) || value.length !== itemCount) {
1502
+ throw delegatedResponseError(status, "terminal response has invalid results", requestId);
1503
+ }
1504
+ let succeeded = 0;
1505
+ const qurlIds = new Set();
1506
+ const links = new Set();
1507
+ const results = value.map((raw, index) => {
1508
+ const item = delegatedResponseObject(raw, status, `result ${index}`, requestId);
1509
+ if (item.index !== index) {
1510
+ throw delegatedResponseError(status, `result ${index} has invalid index`, requestId);
1511
+ }
1512
+ if (item.status === "succeeded") {
1513
+ if (item.error !== undefined && item.error !== null) {
1514
+ throw delegatedResponseError(status, `result ${index} has conflicting fields`, requestId);
1515
+ }
1516
+ const qurl = delegatedResponseObject(item.qurl, status, `result ${index} qurl`, requestId);
1517
+ if (typeof qurl.qurl_id !== "string" ||
1518
+ !DELEGATED_QURL_ID_PATTERN.test(qurl.qurl_id) ||
1519
+ !validateDelegatedQurlLink(qurl.qurl_link) ||
1520
+ !isUtcTimestamp(qurl.expires_at) ||
1521
+ qurlIds.has(qurl.qurl_id) ||
1522
+ links.has(qurl.qurl_link)) {
1523
+ throw delegatedResponseError(status, `result ${index} has invalid qurl`, requestId);
1524
+ }
1525
+ qurlIds.add(qurl.qurl_id);
1526
+ links.add(qurl.qurl_link);
1527
+ succeeded++;
1528
+ return {
1529
+ index,
1530
+ status: "succeeded",
1531
+ qurl: {
1532
+ qurl_id: qurl.qurl_id,
1533
+ qurl_link: qurl.qurl_link,
1534
+ expires_at: qurl.expires_at,
1535
+ },
1536
+ };
1537
+ }
1538
+ if (item.status !== "failed" || (item.qurl !== undefined && item.qurl !== null)) {
1539
+ throw delegatedResponseError(status, `result ${index} has invalid status`, requestId);
1540
+ }
1541
+ const error = delegatedResponseObject(item.error, status, `result ${index} error`, requestId);
1542
+ if (typeof error.code !== "string" ||
1543
+ error.code.length === 0 ||
1544
+ typeof error.message !== "string") {
1545
+ throw delegatedResponseError(status, `result ${index} has invalid error`, requestId);
1546
+ }
1547
+ return {
1548
+ index,
1549
+ status: "failed",
1550
+ error: { code: error.code, message: error.message },
1551
+ };
1552
+ });
1553
+ if ((batchStatus === "succeeded" && succeeded !== itemCount) ||
1554
+ (batchStatus === "failed" && succeeded !== 0) ||
1555
+ (batchStatus === "partially_failed" && (succeeded === 0 || succeeded === itemCount))) {
1556
+ throw delegatedResponseError(status, "terminal status does not match results", requestId);
1557
+ }
1558
+ return results;
1559
+ }
1560
+ function classifyDelegatedBatchCreateFailure(error, batchId) {
1561
+ if (!(error instanceof errors_js_1.QURLError)) {
1562
+ throw new errors_js_1.DelegatedBatchOutcomeUnknownError(new errors_js_1.RuntimeError("Unexpected failure after delegated batch dispatch", { cause: error }), batchId);
1563
+ }
1564
+ if ((error.status >= 400 && error.status < 500 && error.status !== 408) || error.status === 501) {
1565
+ throw error;
1566
+ }
1567
+ throw new errors_js_1.DelegatedBatchOutcomeUnknownError(error, batchId);
1568
+ }
1255
1569
  // `format: uri` in the OpenAPI spec allows schemes the SDK doesn't
1256
1570
  // usefully support (`ftp://`, `file://`, `javascript:`, …). This is a
1257
1571
  // cheap client-side sanity check — the server is still the
@@ -2506,6 +2820,195 @@ class QURLClient {
2506
2820
  }
2507
2821
  return requestId !== undefined ? { ...result, request_id: requestId } : result;
2508
2822
  }
2823
+ /**
2824
+ * Start one durable delegated qURL batch. The SDK sends this mutation once.
2825
+ * If the result may have committed, retry explicitly with the same key and
2826
+ * semantic body.
2827
+ */
2828
+ async createDelegatedQurlBatch(input, options) {
2829
+ validateCreateDelegatedQurlBatchInput(input);
2830
+ validateDelegatedBatchCreateOptions(options);
2831
+ let dispatched = false;
2832
+ let acceptedBatchId;
2833
+ try {
2834
+ const envelope = await this.rawRequest("POST", "/v1/delegated-qurl-batches", input, {
2835
+ requestOptions: options,
2836
+ retry: false,
2837
+ generateIdempotencyKey: false,
2838
+ captureResponseHeaders: true,
2839
+ onDispatch: () => {
2840
+ dispatched = true;
2841
+ },
2842
+ });
2843
+ const status = envelope.__http_status ?? 0;
2844
+ if (status !== 202) {
2845
+ throw delegatedResponseError(status, "create returned an unexpected success status");
2846
+ }
2847
+ const rawData = envelope.data;
2848
+ if (typeof rawData === "object" && rawData !== null && !Array.isArray(rawData)) {
2849
+ const candidate = rawData.batch_id;
2850
+ if (typeof candidate === "string" && DELEGATED_BATCH_ID_PATTERN.test(candidate)) {
2851
+ acceptedBatchId = candidate;
2852
+ }
2853
+ }
2854
+ const { data, requestId } = delegatedEnvelope(envelope, status);
2855
+ const base = delegatedBatchBase(data, status, requestId);
2856
+ if (data.status !== "queued" || base.itemCount !== input.grants.length) {
2857
+ throw delegatedResponseError(status, "acceptance data is inconsistent", requestId);
2858
+ }
2859
+ const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
2860
+ const location = validateDelegatedLocation(requiredDelegatedHeader(envelope, "Location", status, requestId), this.baseUrl, base.batchId, status, requestId);
2861
+ return {
2862
+ http_status: 202,
2863
+ batch_id: base.batchId,
2864
+ status: "queued",
2865
+ item_count: base.itemCount,
2866
+ submitted_at: base.submittedAt,
2867
+ etag: responseHeaders.etag,
2868
+ retry_after: responseHeaders.retryAfter,
2869
+ location,
2870
+ request_id: requestId,
2871
+ };
2872
+ }
2873
+ catch (error) {
2874
+ if (!dispatched)
2875
+ throw error;
2876
+ classifyDelegatedBatchCreateFailure(error, acceptedBatchId);
2877
+ }
2878
+ }
2879
+ /**
2880
+ * Read one delegated qURL batch state. One call performs one logical read;
2881
+ * callers retain control of the poll count and total deadline.
2882
+ */
2883
+ async getDelegatedQurlBatch(batchId, options) {
2884
+ if (typeof batchId !== "string" || !DELEGATED_BATCH_ID_PATTERN.test(batchId)) {
2885
+ throw clientValidationError("getDelegatedQurlBatch: batchId must match dqb_ plus 22 URL-safe characters");
2886
+ }
2887
+ validateGetDelegatedQurlBatchOptions(options);
2888
+ const envelope = await this.rawRequest("GET", `/v1/delegated-qurl-batches/${encodeURIComponent(batchId)}`, undefined, {
2889
+ passthroughStatuses: DELEGATED_BATCH_PASSTHROUGH_STATUSES,
2890
+ allowEmptyPassthroughBody: true,
2891
+ ifNoneMatch: options?.etag,
2892
+ retry: false,
2893
+ captureResponseHeaders: true,
2894
+ });
2895
+ const status = envelope.__http_status ?? 0;
2896
+ if (status === 304) {
2897
+ if (envelope.__http_body_empty !== true || options?.etag === undefined) {
2898
+ throw delegatedResponseError(status, "304 response is inconsistent");
2899
+ }
2900
+ const responseHeaders = delegatedResponseHeaders(envelope, status);
2901
+ if (responseHeaders.etag !== undefined && responseHeaders.etag !== options.etag) {
2902
+ throw delegatedResponseError(status, "304 response changed the ETag");
2903
+ }
2904
+ return {
2905
+ http_status: 304,
2906
+ etag: responseHeaders.etag ?? options.etag,
2907
+ ...(responseHeaders.retryAfter === undefined
2908
+ ? {}
2909
+ : { retry_after: responseHeaders.retryAfter }),
2910
+ };
2911
+ }
2912
+ if (status !== 200 && status !== 202) {
2913
+ throw delegatedResponseError(status, "read returned an unexpected success status");
2914
+ }
2915
+ const { data, requestId } = delegatedEnvelope(envelope, status);
2916
+ const base = delegatedBatchBase(data, status, requestId, batchId);
2917
+ if (status === 202) {
2918
+ if (data.status !== "queued" && data.status !== "running") {
2919
+ throw delegatedResponseError(status, "pending data has a terminal status", requestId);
2920
+ }
2921
+ const responseHeaders = delegatedResponseHeaders(envelope, status, requestId);
2922
+ return {
2923
+ http_status: 202,
2924
+ batch_id: base.batchId,
2925
+ status: data.status,
2926
+ item_count: base.itemCount,
2927
+ submitted_at: base.submittedAt,
2928
+ etag: responseHeaders.etag,
2929
+ retry_after: responseHeaders.retryAfter,
2930
+ request_id: requestId,
2931
+ };
2932
+ }
2933
+ if (data.status !== "succeeded" &&
2934
+ data.status !== "partially_failed" &&
2935
+ data.status !== "failed") {
2936
+ throw delegatedResponseError(status, "terminal data has a non-terminal status", requestId);
2937
+ }
2938
+ let results;
2939
+ try {
2940
+ if (!isUtcTimestamp(data.completed_at)) {
2941
+ throw delegatedResponseError(status, "terminal data has invalid completed_at", requestId);
2942
+ }
2943
+ results = validateDelegatedBatchResults(data.results, base.itemCount, data.status, status, requestId);
2944
+ }
2945
+ catch (error) {
2946
+ const partialQurlIds = Array.isArray(data.results)
2947
+ ? [
2948
+ ...new Set(data.results.flatMap((item) => item &&
2949
+ typeof item === "object" &&
2950
+ item.status === "succeeded" &&
2951
+ typeof item.qurl?.qurl_id === "string" &&
2952
+ DELEGATED_QURL_ID_PATTERN.test(item.qurl.qurl_id)
2953
+ ? [item.qurl.qurl_id]
2954
+ : [])),
2955
+ ]
2956
+ : [];
2957
+ if (error instanceof errors_js_1.QURLError)
2958
+ Object.defineProperty(error, "partialQurlIds", {
2959
+ value: Object.freeze(partialQurlIds),
2960
+ enumerable: true,
2961
+ });
2962
+ throw error;
2963
+ }
2964
+ // This is the caller's only copy of the bearer links. Do not discard a
2965
+ // valid terminal body because an intermediary removed a cache validator;
2966
+ // qurl-service owns the required no-store response policy.
2967
+ const cacheControl = envelope.__http_headers?.get("Cache-Control") ?? null;
2968
+ if (!hasDelegatedNoStore(cacheControl)) {
2969
+ this.log("delegated batch terminal response is missing private, no-store", {
2970
+ batch_id: base.batchId,
2971
+ });
2972
+ }
2973
+ const etag = envelope.__http_headers?.get("ETag") ?? undefined;
2974
+ const terminalEtag = etag !== undefined && etag.length <= MAX_DELEGATED_ETAG && STRONG_ETAG_PATTERN.test(etag)
2975
+ ? etag
2976
+ : undefined;
2977
+ return {
2978
+ http_status: 200,
2979
+ batch_id: base.batchId,
2980
+ status: data.status,
2981
+ item_count: base.itemCount,
2982
+ submitted_at: base.submittedAt,
2983
+ completed_at: data.completed_at,
2984
+ results,
2985
+ ...(terminalEtag === undefined ? {} : { etag: terminalEtag }),
2986
+ request_id: requestId,
2987
+ };
2988
+ }
2989
+ /** Read owner-visible metadata for one delegated qURL. */
2990
+ async getDelegatedQurl(qurlId) {
2991
+ if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
2992
+ throw clientValidationError("getDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
2993
+ }
2994
+ const envelope = await this.rawRequest("GET", `/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
2995
+ const status = envelope.__http_status ?? 0;
2996
+ if (status !== 200) {
2997
+ throw delegatedResponseError(status, "read returned an unexpected success status");
2998
+ }
2999
+ const { data, requestId } = delegatedEnvelope(envelope, status);
3000
+ return validateDelegatedQurl(data, qurlId, status, requestId);
3001
+ }
3002
+ /**
3003
+ * Revoke one delegated qURL. DELETE is never retried automatically. A 503
3004
+ * `mutation_outcome_unknown` is safe to retry with the same qURL ID.
3005
+ */
3006
+ async deleteDelegatedQurl(qurlId) {
3007
+ if (typeof qurlId !== "string" || !DELEGATED_QURL_ID_PATTERN.test(qurlId)) {
3008
+ throw clientValidationError("deleteDelegatedQurl: qurlId must match q_ plus 11 lowercase hexadecimal characters");
3009
+ }
3010
+ await this.requestNoContent(`/v1/delegated-qurls/${encodeURIComponent(qurlId)}`);
3011
+ }
2509
3012
  /**
2510
3013
  * Get a qURL resource and its access tokens.
2511
3014
  *
@@ -3258,7 +3761,7 @@ class QURLClient {
3258
3761
  */
3259
3762
  async rawRequest(method, path, body, rawOptions = {}) {
3260
3763
  const url = `${this.baseUrl}${path}`;
3261
- const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, retry = true, generateIdempotencyKey = true, } = rawOptions;
3764
+ const { passthroughStatuses = NO_PASSTHROUGH_STATUSES, requestOptions, allowEmptySuccessBody = false, allowEmptyPassthroughBody = false, retry = true, generateIdempotencyKey = true, ifNoneMatch, captureResponseHeaders = false, onDispatch, } = rawOptions;
3262
3765
  const idempotencyKey = idempotencyKeyForRequest(method, requestOptions, generateIdempotencyKey);
3263
3766
  const headers = {
3264
3767
  Authorization: `Bearer ${this.apiKey}`,
@@ -3271,6 +3774,9 @@ class QURLClient {
3271
3774
  if (idempotencyKey !== undefined) {
3272
3775
  headers["Idempotency-Key"] = idempotencyKey;
3273
3776
  }
3777
+ if (ifNoneMatch !== undefined) {
3778
+ headers["If-None-Match"] = ifNoneMatch;
3779
+ }
3274
3780
  // Reads may retry transient statuses and transport failures. POST/PATCH
3275
3781
  // keep status-code retries limited to rate limits and reuse the same
3276
3782
  // Idempotency-Key for fetch-level failures. DELETE is deliberately never
@@ -3307,6 +3813,7 @@ class QURLClient {
3307
3813
  // attempt shouldn't poison the next try's deadline. Total
3308
3814
  // request bound: roughly `timeout * (maxRetries + 1) +
3309
3815
  // sum(retryDelay)`.
3816
+ onDispatch?.();
3310
3817
  response = await this.fetchFn(url, {
3311
3818
  method,
3312
3819
  headers,
@@ -3482,16 +3989,20 @@ class QURLClient {
3482
3989
  data: undefined,
3483
3990
  __http_status: response.status,
3484
3991
  __http_body_empty: responseBody.length === 0,
3992
+ __http_headers: captureResponseHeaders ? response.headers : undefined,
3485
3993
  };
3486
3994
  }
3487
3995
  // Exact no-content helpers must inspect both axes of the success
3488
3996
  // contract. Let only those callers observe an empty non-204 response;
3489
3997
  // body-returning methods still reject it as non-JSON below.
3490
- if (response.ok && allowEmptySuccessBody && responseBody.length === 0) {
3998
+ if (((response.ok && allowEmptySuccessBody) ||
3999
+ (isPassthrough && allowEmptyPassthroughBody)) &&
4000
+ responseBody.length === 0) {
3491
4001
  return {
3492
4002
  data: undefined,
3493
4003
  __http_status: response.status,
3494
4004
  __http_body_empty: true,
4005
+ __http_headers: captureResponseHeaders ? response.headers : undefined,
3495
4006
  };
3496
4007
  }
3497
4008
  try {
@@ -3501,6 +4012,7 @@ class QURLClient {
3501
4012
  __http_status: response.status,
3502
4013
  // JSON.parse("") throws, so parsed JSON is necessarily non-empty.
3503
4014
  __http_body_empty: false,
4015
+ __http_headers: captureResponseHeaders ? response.headers : undefined,
3504
4016
  };
3505
4017
  }
3506
4018
  catch {
@@ -3611,14 +4123,13 @@ class QURLClient {
3611
4123
  const header = response.headers.get("Retry-After");
3612
4124
  if (!header)
3613
4125
  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
4126
  const trimmed = header.trim();
4127
+ if (/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(trimmed)) {
4128
+ const seconds = Math.ceil((Date.parse(trimmed) - Date.now()) / 1000);
4129
+ return Number.isSafeInteger(seconds) && seconds > 0 && seconds <= RETRY_AFTER_PARSE_LIMIT_S
4130
+ ? seconds
4131
+ : undefined;
4132
+ }
3622
4133
  if (!/^\d+$/.test(trimmed)) {
3623
4134
  this.log("Retry-After header was non-numeric, using exponential backoff", {
3624
4135
  header_value: header.slice(0, 100),
@@ -1,2 +1,3 @@
1
- export declare function parseCrid(value: unknown): Uint8Array | undefined;
1
+ export declare const CRID_DOMAIN = "NHP-QURL-CRID-V1\0";
2
+ export declare function parseCrid(value: unknown, knownVersionOnly?: boolean): Uint8Array | undefined;
2
3
  export declare function cridKeyMatches(value: unknown, der: Uint8Array): Promise<boolean>;
package/dist/cjs/crid.js CHANGED
@@ -1,13 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CRID_DOMAIN = void 0;
3
4
  exports.parseCrid = parseCrid;
4
5
  exports.cridKeyMatches = cridKeyMatches;
5
6
  // CRID v1 local gate and delivered-key binding, matching qurl-go/crid.
6
7
  // The byte selects a version/environment/digest width, not a new hash scheme.
7
8
  // All key matches use the v1 domain separator, as in qurl-go/crid.KeyMatches.
8
9
  // Unknown nonzero versions remain forwardable; the service owns activation.
10
+ // Access binding requires a locally known version and its registered width.
9
11
  const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
10
- function parseCrid(value) {
12
+ exports.CRID_DOMAIN = "NHP-QURL-CRID-V1\0";
13
+ function parseCrid(value, knownVersionOnly = false) {
11
14
  if (typeof value !== "string" || ![47, 60].includes(value.length))
12
15
  return undefined;
13
16
  const bytes = new Uint8Array(Math.floor((value.length * 5) / 8));
@@ -27,6 +30,12 @@ function parseCrid(value) {
27
30
  if (buffer !== 0 || bytes[0] === 0)
28
31
  return undefined;
29
32
  const payload = bytes.subarray(0, -4);
33
+ if (knownVersionOnly) {
34
+ const version = bytes[0];
35
+ const width = version === 1 || version === 0x81 ? 32 : version === 2 || version === 0x82 ? 24 : 0;
36
+ if (payload.length !== width + 1 || width === 0)
37
+ return undefined;
38
+ }
30
39
  let crc = 0xffffffff;
31
40
  for (const byte of payload) {
32
41
  crc ^= byte;
@@ -41,7 +50,7 @@ async function cridKeyMatches(value, der) {
41
50
  const expected = parseCrid(value);
42
51
  if (!expected)
43
52
  return false;
44
- const prefix = new TextEncoder().encode("NHP-QURL-CRID-V1\0");
53
+ const prefix = new TextEncoder().encode(exports.CRID_DOMAIN);
45
54
  const message = new Uint8Array(prefix.length + der.length);
46
55
  message.set(prefix);
47
56
  message.set(der, prefix.length);