@layervai/qurl 0.3.0 → 0.4.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 (75) hide show
  1. package/README.md +340 -23
  2. package/dist/cjs/client.d.ts +180 -13
  3. package/dist/cjs/client.js +1126 -96
  4. package/dist/cjs/errors.d.ts +15 -11
  5. package/dist/cjs/errors.js +30 -21
  6. package/dist/cjs/index.d.ts +3 -3
  7. package/dist/cjs/index.js +4 -1
  8. package/dist/cjs/internal.d.ts +4 -0
  9. package/dist/cjs/internal.js +16 -0
  10. package/dist/cjs/node/deployment.d.ts +44 -0
  11. package/dist/cjs/node/deployment.js +167 -0
  12. package/dist/cjs/node/native-udp.d.ts +37 -0
  13. package/dist/cjs/node/native-udp.js +294 -0
  14. package/dist/cjs/node/nhp-wire.d.ts +35 -0
  15. package/dist/cjs/node/nhp-wire.js +261 -0
  16. package/dist/cjs/node/portal-opener.d.ts +83 -0
  17. package/dist/cjs/node/portal-opener.js +767 -0
  18. package/dist/cjs/node/qv2.d.ts +38 -0
  19. package/dist/cjs/node/qv2.js +292 -0
  20. package/dist/cjs/node/strict-json.d.ts +12 -0
  21. package/dist/cjs/node/strict-json.js +174 -0
  22. package/dist/cjs/node.d.ts +3 -0
  23. package/dist/cjs/node.js +11 -0
  24. package/dist/cjs/types.d.ts +90 -18
  25. package/dist/cjs/version.d.ts +1 -1
  26. package/dist/cjs/version.js +1 -1
  27. package/dist/esm/client.d.ts +180 -13
  28. package/dist/esm/client.d.ts.map +1 -1
  29. package/dist/esm/client.js +1125 -96
  30. package/dist/esm/client.js.map +1 -1
  31. package/dist/esm/errors.d.ts +15 -11
  32. package/dist/esm/errors.d.ts.map +1 -1
  33. package/dist/esm/errors.js +29 -20
  34. package/dist/esm/errors.js.map +1 -1
  35. package/dist/esm/index.d.ts +3 -3
  36. package/dist/esm/index.d.ts.map +1 -1
  37. package/dist/esm/index.js +2 -2
  38. package/dist/esm/index.js.map +1 -1
  39. package/dist/esm/internal.d.ts +5 -0
  40. package/dist/esm/internal.d.ts.map +1 -0
  41. package/dist/esm/internal.js +14 -0
  42. package/dist/esm/internal.js.map +1 -0
  43. package/dist/esm/node/deployment.d.ts +45 -0
  44. package/dist/esm/node/deployment.d.ts.map +1 -0
  45. package/dist/esm/node/deployment.js +163 -0
  46. package/dist/esm/node/deployment.js.map +1 -0
  47. package/dist/esm/node/native-udp.d.ts +38 -0
  48. package/dist/esm/node/native-udp.d.ts.map +1 -0
  49. package/dist/esm/node/native-udp.js +291 -0
  50. package/dist/esm/node/native-udp.js.map +1 -0
  51. package/dist/esm/node/nhp-wire.d.ts +36 -0
  52. package/dist/esm/node/nhp-wire.d.ts.map +1 -0
  53. package/dist/esm/node/nhp-wire.js +257 -0
  54. package/dist/esm/node/nhp-wire.js.map +1 -0
  55. package/dist/esm/node/portal-opener.d.ts +84 -0
  56. package/dist/esm/node/portal-opener.d.ts.map +1 -0
  57. package/dist/esm/node/portal-opener.js +757 -0
  58. package/dist/esm/node/portal-opener.js.map +1 -0
  59. package/dist/esm/node/qv2.d.ts +39 -0
  60. package/dist/esm/node/qv2.d.ts.map +1 -0
  61. package/dist/esm/node/qv2.js +286 -0
  62. package/dist/esm/node/qv2.js.map +1 -0
  63. package/dist/esm/node/strict-json.d.ts +13 -0
  64. package/dist/esm/node/strict-json.d.ts.map +1 -0
  65. package/dist/esm/node/strict-json.js +171 -0
  66. package/dist/esm/node/strict-json.js.map +1 -0
  67. package/dist/esm/node.d.ts +4 -0
  68. package/dist/esm/node.d.ts.map +1 -0
  69. package/dist/esm/node.js +2 -0
  70. package/dist/esm/node.js.map +1 -0
  71. package/dist/esm/types.d.ts +90 -18
  72. package/dist/esm/types.d.ts.map +1 -1
  73. package/dist/esm/version.d.ts +1 -1
  74. package/dist/esm/version.js +1 -1
  75. package/package.json +25 -6
@@ -0,0 +1,38 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ export interface VerifiedQv2Link {
3
+ readonly claimsB64: string;
4
+ readonly signatureB64: string;
5
+ readonly claims: {
6
+ readonly kid: string;
7
+ readonly cellPublicKey: Uint8Array;
8
+ readonly resourcePublicKeyB64: string;
9
+ };
10
+ readonly devicePrivateKey: Uint8Array;
11
+ }
12
+ export declare function verifyQv2Link(qurl: string, issuers: ReadonlyMap<string, KeyObject>): VerifiedQv2Link;
13
+ export declare function issuerKeyFromSpki(spki: Uint8Array): KeyObject;
14
+ export declare function x25519PublicFromPrivate(raw: Uint8Array): Uint8Array;
15
+ export declare function strictBase64Url(value: string): Uint8Array;
16
+ declare function decodeTransport(value: string): string;
17
+ type ParsedClaims = {
18
+ kid: string;
19
+ cellPublicKeyB64: string;
20
+ resourcePublicKeyB64: string;
21
+ qurlUserPublicKeyB64: string;
22
+ };
23
+ declare function parseClaims(raw: Uint8Array): ParsedClaims;
24
+ declare function verifyIssuerClaims(claimsB64: string, signatureB64: string, issuers: ReadonlyMap<string, KeyObject>, parsedClaims?: ParsedClaims): ParsedClaims;
25
+ declare function parseSecret(raw: Uint8Array): {
26
+ qurlUserPrivateKeyB64: string;
27
+ };
28
+ declare function parseAndWipeSecret(raw: Uint8Array): {
29
+ qurlUserPrivateKeyB64: string;
30
+ };
31
+ export declare const qv2Testing: {
32
+ decodeTransport: typeof decodeTransport;
33
+ parseClaims: typeof parseClaims;
34
+ parseSecret: typeof parseSecret;
35
+ parseAndWipeSecret: typeof parseAndWipeSecret;
36
+ verifyIssuerClaims: typeof verifyIssuerClaims;
37
+ };
38
+ export {};
@@ -0,0 +1,292 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.qv2Testing = void 0;
4
+ exports.verifyQv2Link = verifyQv2Link;
5
+ exports.issuerKeyFromSpki = issuerKeyFromSpki;
6
+ exports.x25519PublicFromPrivate = x25519PublicFromPrivate;
7
+ exports.strictBase64Url = strictBase64Url;
8
+ const node_crypto_1 = require("node:crypto");
9
+ const strict_json_js_1 = require("./strict-json.js");
10
+ const TRANSPORT_PREFIX = "qv2t1";
11
+ const FRAGMENT_PREFIX = "qv2";
12
+ const TRANSPORT_MAX_LENGTH = 6_826;
13
+ const TRANSPORT_COMPONENT_MAX = 240;
14
+ const SIGNING_DOMAIN = Buffer.from("NHP-QURL-V2-ISSUER\0", "utf8");
15
+ const P256_ORDER = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551");
16
+ const P256_HALF_ORDER = P256_ORDER >> 1n;
17
+ const X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
18
+ const CLAIM_KEYS = new Set([
19
+ "v",
20
+ "iss",
21
+ "kid",
22
+ "iat",
23
+ "nbf",
24
+ "exp",
25
+ "jti",
26
+ "cell_public_key_b64",
27
+ "cell_id",
28
+ "relay_url",
29
+ "resource_public_key_b64",
30
+ "qurl_user_public_key_b64",
31
+ ]);
32
+ const REQUIRED_CLAIM_KEYS = [...CLAIM_KEYS].filter((key) => key !== "cell_id");
33
+ function verifyQv2Link(qurl, issuers) {
34
+ const hash = qurl.indexOf("#");
35
+ if (hash < 0)
36
+ throw new Error("qURL link has no credential fragment");
37
+ const canonical = decodeTransport(qurl.slice(hash + 1));
38
+ const parts = canonical.split(".");
39
+ if (parts.length !== 4 || parts[0] !== FRAGMENT_PREFIX || parts.slice(1).some((p) => p === "")) {
40
+ throw new Error("invalid qURL credential fragment");
41
+ }
42
+ const [, claimsB64, secretB64, signatureB64] = parts;
43
+ // Match Go ParseFragment -> Verify ordering: strict claims and secret parsing
44
+ // precede signature/trust errors for an otherwise valid outer transport.
45
+ const claims = parseClaims(decodeCanonicalBase64Url(claimsB64));
46
+ const secret = parseAndWipeSecret(decodeCanonicalBase64Url(secretB64));
47
+ verifyIssuerClaims(claimsB64, signatureB64, issuers, claims);
48
+ const privateKey = decodeCanonicalBase64Url(secret.qurlUserPrivateKeyB64);
49
+ let retainPrivateKey = false;
50
+ try {
51
+ if (privateKey.byteLength !== 32)
52
+ throw new Error("qURL private key has an invalid length");
53
+ const derivedPublicKey = x25519PublicFromPrivate(privateKey);
54
+ const signedPublicKey = decodeX25519(claims.qurlUserPublicKeyB64, "qURL public key");
55
+ if (!(0, node_crypto_1.timingSafeEqual)(derivedPublicKey, signedPublicKey)) {
56
+ throw new Error("qURL private key does not match its signed public key");
57
+ }
58
+ retainPrivateKey = true;
59
+ return {
60
+ claimsB64,
61
+ signatureB64,
62
+ claims: {
63
+ kid: claims.kid,
64
+ cellPublicKey: decodeX25519(claims.cellPublicKeyB64, "cell public key"),
65
+ resourcePublicKeyB64: claims.resourcePublicKeyB64,
66
+ },
67
+ devicePrivateKey: privateKey,
68
+ };
69
+ }
70
+ finally {
71
+ if (!retainPrivateKey)
72
+ privateKey.fill(0);
73
+ }
74
+ }
75
+ function issuerKeyFromSpki(spki) {
76
+ const key = (0, node_crypto_1.createPublicKey)({ key: spki, format: "der", type: "spki" });
77
+ const canonical = key.export({ format: "der", type: "spki" });
78
+ const supplied = Buffer.from(spki.buffer, spki.byteOffset, spki.byteLength);
79
+ // Node accepts a valid SPKI followed by unrelated DER bytes. Go's
80
+ // x509.ParsePKIXPublicKey requires the complete input to contain one key, so
81
+ // require the same byte-exact consumption before this key becomes trust.
82
+ if (canonical.byteLength !== supplied.byteLength || !canonical.equals(supplied)) {
83
+ throw new Error("issuer key must contain exactly one canonical SPKI public key");
84
+ }
85
+ if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
86
+ throw new Error("issuer key must be a P-256 SPKI public key");
87
+ }
88
+ return key;
89
+ }
90
+ function x25519PublicFromPrivate(raw) {
91
+ if (raw.byteLength !== 32)
92
+ throw new Error("X25519 private key must be 32 bytes");
93
+ const der = Buffer.alloc(X25519_PKCS8_PREFIX.byteLength + raw.byteLength);
94
+ X25519_PKCS8_PREFIX.copy(der);
95
+ der.set(raw, X25519_PKCS8_PREFIX.byteLength);
96
+ let privateKey;
97
+ try {
98
+ privateKey = (0, node_crypto_1.createPrivateKey)({ key: der, format: "der", type: "pkcs8" });
99
+ }
100
+ finally {
101
+ der.fill(0);
102
+ }
103
+ const spki = (0, node_crypto_1.createPublicKey)(privateKey).export({ format: "der", type: "spki" });
104
+ return new Uint8Array(spki.subarray(spki.byteLength - 32));
105
+ }
106
+ function strictBase64Url(value) {
107
+ return decodeCanonicalBase64Url(value);
108
+ }
109
+ function decodeTransport(value) {
110
+ if (value.length > TRANSPORT_MAX_LENGTH)
111
+ throw new Error("qURL transport exceeds its size limit");
112
+ const parts = value.split(".");
113
+ if (parts.length < 4 || parts[0] !== TRANSPORT_PREFIX) {
114
+ throw new Error("qURL does not use the qv2t1 credential transport");
115
+ }
116
+ const counts = [parseCount(parts[1], 26), parseCount(parts[2], 3), parseCount(parts[3], 1)];
117
+ if (parts.length !== 4 + counts[0] + counts[1] + counts[2]) {
118
+ throw new Error("qURL transport part count does not match its declarations");
119
+ }
120
+ let offset = 4;
121
+ const limits = [6_144, 512, 128];
122
+ const fields = counts.map((count, index) => {
123
+ const chunks = parts.slice(offset, offset + count);
124
+ offset += count;
125
+ let total = 0;
126
+ for (let i = 0; i < chunks.length; i++) {
127
+ const chunk = chunks[i];
128
+ if (chunk.length === 0 ||
129
+ chunk.length > TRANSPORT_COMPONENT_MAX ||
130
+ (i < chunks.length - 1 && chunk.length !== TRANSPORT_COMPONENT_MAX) ||
131
+ !/^[A-Za-z0-9_-]+$/.test(chunk)) {
132
+ throw new Error("qURL transport has an invalid chunk");
133
+ }
134
+ total += chunk.length;
135
+ if (total > limits[index])
136
+ throw new Error("qURL transport field exceeds its size limit");
137
+ }
138
+ return chunks.join("");
139
+ });
140
+ return `${FRAGMENT_PREFIX}.${fields[0]}.${fields[1]}.${fields[2]}`;
141
+ }
142
+ function parseCount(value, maximum) {
143
+ if (!/^[1-9][0-9]*$/.test(value))
144
+ throw new Error("qURL transport count is not canonical");
145
+ const count = Number(value);
146
+ if (!Number.isSafeInteger(count) || count > maximum) {
147
+ throw new Error("qURL transport count exceeds its limit");
148
+ }
149
+ return count;
150
+ }
151
+ function decodeCanonicalBase64Url(value) {
152
+ if (value === "" || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
153
+ throw new Error("value is not canonical unpadded base64url");
154
+ }
155
+ const decoded = Buffer.from(value, "base64url");
156
+ if (decoded.toString("base64url") !== value) {
157
+ decoded.fill(0);
158
+ throw new Error("value is not canonical unpadded base64url");
159
+ }
160
+ return decoded;
161
+ }
162
+ function decodeX25519(value, name) {
163
+ const decoded = decodeCanonicalBase64Url(value);
164
+ if (decoded.byteLength !== 32)
165
+ throw new Error(`${name} has an invalid length`);
166
+ return decoded;
167
+ }
168
+ function parseClaims(raw) {
169
+ const value = (0, strict_json_js_1.parseStrictJson)(raw, 4_608);
170
+ if (!(0, strict_json_js_1.isStrictJsonObject)(value))
171
+ throw new Error("qURL claims must be one JSON object");
172
+ for (const key of Object.keys(value)) {
173
+ if (!CLAIM_KEYS.has(key))
174
+ throw new Error("qURL claims contain an unknown field");
175
+ if (value[key] === null)
176
+ throw new Error("qURL claims contain a null field");
177
+ }
178
+ for (const key of REQUIRED_CLAIM_KEYS) {
179
+ if (!(key in value))
180
+ throw new Error("qURL claims are missing a required field");
181
+ }
182
+ if (value.v !== 2n || value.iss !== "qurl-service")
183
+ throw new Error("qURL claims version or issuer is invalid");
184
+ const times = {
185
+ iat: 0n,
186
+ nbf: 0n,
187
+ exp: 0n,
188
+ };
189
+ for (const key of ["iat", "nbf", "exp"]) {
190
+ const item = value[key];
191
+ if (typeof item !== "bigint" || item <= 0n || item > 9999999999n) {
192
+ throw new Error("qURL claims contain an invalid time");
193
+ }
194
+ times[key] = item;
195
+ }
196
+ if (times.iat > times.exp || times.nbf > times.exp) {
197
+ throw new Error("qURL claims contain an invalid time window");
198
+ }
199
+ const kid = requireNonEmptyString(value.kid, "issuer key id");
200
+ requireNonEmptyString(value.jti, "qURL id");
201
+ const cellPublicKeyB64 = requireNonEmptyString(value.cell_public_key_b64, "cell public key");
202
+ decodeX25519(cellPublicKeyB64, "cell public key");
203
+ const qurlPublic = requireNonEmptyString(value.qurl_user_public_key_b64, "qURL public key");
204
+ decodeX25519(qurlPublic, "qURL public key");
205
+ const resourcePublicKeyB64 = requireNonEmptyString(value.resource_public_key_b64, "resource public key");
206
+ const resourcePublic = decodeCanonicalBase64Url(resourcePublicKeyB64);
207
+ if (resourcePublic.byteLength < 80 || resourcePublic.byteLength > 160) {
208
+ throw new Error("resource public key has an invalid length");
209
+ }
210
+ requireNonEmptyString(value.relay_url, "relay URL");
211
+ // cell_id is descriptive deployment metadata, not a security identity. The
212
+ // signed 32-byte cell public key selects and then fully binds the UDP peer.
213
+ if ("cell_id" in value && typeof value.cell_id !== "string") {
214
+ throw new Error("qURL cell id must be a string");
215
+ }
216
+ return {
217
+ kid,
218
+ cellPublicKeyB64,
219
+ resourcePublicKeyB64,
220
+ qurlUserPublicKeyB64: qurlPublic,
221
+ };
222
+ }
223
+ function verifyIssuerClaims(claimsB64, signatureB64, issuers, parsedClaims) {
224
+ // Full-link verification supplies the claims it parsed before the secret so
225
+ // this shared verification path preserves Go's ParseFragment error order.
226
+ const claims = parsedClaims ?? parseClaims(decodeCanonicalBase64Url(claimsB64));
227
+ verifyParsedIssuerClaims(claims, claimsB64, signatureB64, issuers);
228
+ return claims;
229
+ }
230
+ function verifyParsedIssuerClaims(claims, claimsB64, signatureB64, issuers) {
231
+ const signature = decodeCanonicalBase64Url(signatureB64);
232
+ const issuer = issuers.get(claims.kid);
233
+ if (!issuer)
234
+ throw new Error("qURL uses an unknown issuer key id");
235
+ // Go resolves trust after base64 decoding but before raw signature shape
236
+ // validation. Preserve that error precedence for full-link verification.
237
+ validateRawP256Signature(signature);
238
+ const signingInput = Buffer.concat([SIGNING_DOMAIN, Buffer.from(claimsB64, "ascii")]);
239
+ if (!(0, node_crypto_1.verify)("sha256", signingInput, { key: issuer, dsaEncoding: "ieee-p1363" }, signature)) {
240
+ throw new Error("qURL issuer signature verification failed");
241
+ }
242
+ }
243
+ function parseSecret(raw) {
244
+ const value = (0, strict_json_js_1.parseStrictJson)(raw, 1_024);
245
+ if (!(0, strict_json_js_1.isStrictJsonObject)(value) || Object.keys(value).length !== 1) {
246
+ throw new Error("qURL secret has an invalid object shape");
247
+ }
248
+ const qurlUserPrivateKeyB64 = requireNonEmptyString(value.qurl_user_private_key_b64, "qURL private key");
249
+ const privateKey = decodeCanonicalBase64Url(qurlUserPrivateKeyB64);
250
+ try {
251
+ if (privateKey.byteLength !== 32)
252
+ throw new Error("qURL private key has an invalid length");
253
+ }
254
+ finally {
255
+ privateKey.fill(0);
256
+ }
257
+ return { qurlUserPrivateKeyB64 };
258
+ }
259
+ function parseAndWipeSecret(raw) {
260
+ try {
261
+ return parseSecret(raw);
262
+ }
263
+ finally {
264
+ raw.fill(0);
265
+ }
266
+ }
267
+ function requireNonEmptyString(value, name) {
268
+ if (typeof value !== "string" || value === "")
269
+ throw new Error(`${name} must be a non-empty string`);
270
+ return value;
271
+ }
272
+ function validateRawP256Signature(signature) {
273
+ if (signature.byteLength !== 64)
274
+ throw new Error("qURL signature must be 64 bytes");
275
+ const r = bytesToBigInt(signature.subarray(0, 32));
276
+ const s = bytesToBigInt(signature.subarray(32));
277
+ if (r <= 0n || r >= P256_ORDER || s <= 0n || s >= P256_ORDER) {
278
+ throw new Error("qURL signature scalar is outside the P-256 range");
279
+ }
280
+ if (s > P256_HALF_ORDER)
281
+ throw new Error("qURL signature is not low-S normalized");
282
+ }
283
+ function bytesToBigInt(value) {
284
+ return BigInt(`0x${Buffer.from(value).toString("hex")}`);
285
+ }
286
+ exports.qv2Testing = {
287
+ decodeTransport,
288
+ parseClaims,
289
+ parseSecret,
290
+ parseAndWipeSecret,
291
+ verifyIssuerClaims,
292
+ };
@@ -0,0 +1,12 @@
1
+ export type StrictJsonValue = null | boolean | number | bigint | string | StrictJsonValue[] | {
2
+ [key: string]: StrictJsonValue;
3
+ };
4
+ /**
5
+ * Parse a bounded JSON value without JavaScript's duplicate-key or unsafe-
6
+ * integer ambiguity. NHP uses uint64 session identifiers, so integral values
7
+ * outside Number's safe range remain bigint instead of losing wire bytes.
8
+ */
9
+ export declare function parseStrictJson(input: Uint8Array, maxBytes: number): StrictJsonValue;
10
+ export declare function isStrictJsonObject(value: StrictJsonValue | undefined): value is {
11
+ [key: string]: StrictJsonValue;
12
+ };
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStrictJson = parseStrictJson;
4
+ exports.isStrictJsonObject = isStrictJsonObject;
5
+ const MAX_JSON_DEPTH = 32;
6
+ const JSON_NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
7
+ /**
8
+ * Parse a bounded JSON value without JavaScript's duplicate-key or unsafe-
9
+ * integer ambiguity. NHP uses uint64 session identifiers, so integral values
10
+ * outside Number's safe range remain bigint instead of losing wire bytes.
11
+ */
12
+ function parseStrictJson(input, maxBytes) {
13
+ if (input.byteLength > maxBytes)
14
+ throw new Error(`JSON exceeds ${maxBytes}-byte limit`);
15
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(input);
16
+ const parser = new StrictJsonParser(text);
17
+ const value = parser.parseValue(0);
18
+ parser.skipWhitespace();
19
+ if (!parser.done)
20
+ throw new Error("trailing data after JSON value");
21
+ return value;
22
+ }
23
+ class StrictJsonParser {
24
+ input;
25
+ #offset = 0;
26
+ constructor(input) {
27
+ this.input = input;
28
+ }
29
+ get done() {
30
+ return this.#offset === this.input.length;
31
+ }
32
+ skipWhitespace() {
33
+ while (this.#offset < this.input.length) {
34
+ const code = this.input.charCodeAt(this.#offset);
35
+ if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
36
+ break;
37
+ this.#offset++;
38
+ }
39
+ }
40
+ parseValue(depth) {
41
+ if (depth > MAX_JSON_DEPTH)
42
+ throw new Error("JSON nesting is too deep");
43
+ this.skipWhitespace();
44
+ const token = this.input[this.#offset];
45
+ if (token === '"')
46
+ return this.parseString();
47
+ if (token === "{")
48
+ return this.parseObject(depth + 1);
49
+ if (token === "[")
50
+ return this.parseArray(depth + 1);
51
+ if (token === "t")
52
+ return this.parseLiteral("true", true);
53
+ if (token === "f")
54
+ return this.parseLiteral("false", false);
55
+ if (token === "n")
56
+ return this.parseLiteral("null", null);
57
+ if (token === "-" || (token !== undefined && token >= "0" && token <= "9")) {
58
+ return this.parseNumber();
59
+ }
60
+ throw new Error("invalid JSON value");
61
+ }
62
+ parseObject(depth) {
63
+ this.#offset++;
64
+ const value = Object.create(null);
65
+ const keys = new Set();
66
+ this.skipWhitespace();
67
+ if (this.consume("}"))
68
+ return value;
69
+ while (true) {
70
+ this.skipWhitespace();
71
+ if (this.input[this.#offset] !== '"')
72
+ throw new Error("JSON object key must be a string");
73
+ const key = this.parseString();
74
+ if (keys.has(key))
75
+ throw new Error("duplicate JSON object key");
76
+ keys.add(key);
77
+ this.skipWhitespace();
78
+ if (!this.consume(":"))
79
+ throw new Error("JSON object key has no value");
80
+ value[key] = this.parseValue(depth);
81
+ this.skipWhitespace();
82
+ if (this.consume("}"))
83
+ return value;
84
+ if (!this.consume(","))
85
+ throw new Error("invalid JSON object separator");
86
+ }
87
+ }
88
+ parseArray(depth) {
89
+ this.#offset++;
90
+ const value = [];
91
+ this.skipWhitespace();
92
+ if (this.consume("]"))
93
+ return value;
94
+ while (true) {
95
+ value.push(this.parseValue(depth));
96
+ this.skipWhitespace();
97
+ if (this.consume("]"))
98
+ return value;
99
+ if (!this.consume(","))
100
+ throw new Error("invalid JSON array separator");
101
+ }
102
+ }
103
+ parseString() {
104
+ const start = this.#offset++;
105
+ while (this.#offset < this.input.length) {
106
+ const code = this.input.charCodeAt(this.#offset++);
107
+ if (code === 0x22) {
108
+ const encoded = this.input.slice(start, this.#offset);
109
+ const value = JSON.parse(encoded);
110
+ if (typeof value !== "string")
111
+ throw new Error("invalid JSON string");
112
+ return value;
113
+ }
114
+ if (code < 0x20)
115
+ throw new Error("unescaped control in JSON string");
116
+ if (code === 0x5c) {
117
+ if (this.#offset >= this.input.length)
118
+ throw new Error("unterminated JSON escape");
119
+ if (this.input[this.#offset] === "u") {
120
+ const hex = this.input.slice(this.#offset + 1, this.#offset + 5);
121
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
122
+ throw new Error("invalid JSON unicode escape");
123
+ this.#offset += 5;
124
+ }
125
+ else {
126
+ if (!'"\\/bfnrt'.includes(this.input[this.#offset])) {
127
+ throw new Error("invalid JSON escape");
128
+ }
129
+ this.#offset++;
130
+ }
131
+ }
132
+ }
133
+ throw new Error("unterminated JSON string");
134
+ }
135
+ parseNumber() {
136
+ // A sticky expression scans at the current offset without copying the
137
+ // remaining document for every number in a large deployment file.
138
+ JSON_NUMBER.lastIndex = this.#offset;
139
+ const match = JSON_NUMBER.exec(this.input);
140
+ if (!match)
141
+ throw new Error("invalid JSON number");
142
+ const encoded = match[0];
143
+ this.#offset = JSON_NUMBER.lastIndex;
144
+ // Keep every syntactic JSON integer distinct from fractional or exponent
145
+ // notation. NHP integer fields reject 1.0 and 1e0 even when Number would
146
+ // reduce them to the same mathematical value as 1.
147
+ if (!encoded.includes(".") && !/[eE]/.test(encoded)) {
148
+ // BigInt("-0") becomes 0n and loses the wire sign. Keep negative zero as
149
+ // a number so unsigned integer fields reject this noncanonical syntax.
150
+ if (encoded === "-0")
151
+ return -0;
152
+ return BigInt(encoded);
153
+ }
154
+ const value = Number(encoded);
155
+ if (!Number.isFinite(value))
156
+ throw new Error("JSON number is outside the finite range");
157
+ return value;
158
+ }
159
+ parseLiteral(literal, value) {
160
+ if (!this.input.startsWith(literal, this.#offset))
161
+ throw new Error("invalid JSON literal");
162
+ this.#offset += literal.length;
163
+ return value;
164
+ }
165
+ consume(value) {
166
+ if (this.input[this.#offset] !== value)
167
+ return false;
168
+ this.#offset++;
169
+ return true;
170
+ }
171
+ }
172
+ function isStrictJsonObject(value) {
173
+ return typeof value === "object" && value !== null && !Array.isArray(value);
174
+ }
@@ -0,0 +1,3 @@
1
+ export { createPortalOpener, PortalBusyError, PortalConfigurationError, PortalDenyError, PortalInvalidReplyError, PortalStateError, PortalVerificationError, } from "./node/portal-opener.js";
2
+ export type { CreatePortalOpenerOptions, PortalFetchOptions, PortalOpener, PortalRenewalFailure, PortalRequestBuilder, PortalSessionHealth, PortalStartOptions, } from "./node/portal-opener.js";
3
+ export type { PortalDeployment, PortalDeploymentCell, PortalDeploymentIssuer, } from "./node/deployment.js";
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PortalVerificationError = exports.PortalStateError = exports.PortalInvalidReplyError = exports.PortalDenyError = exports.PortalConfigurationError = exports.PortalBusyError = exports.createPortalOpener = void 0;
4
+ var portal_opener_js_1 = require("./node/portal-opener.js");
5
+ Object.defineProperty(exports, "createPortalOpener", { enumerable: true, get: function () { return portal_opener_js_1.createPortalOpener; } });
6
+ Object.defineProperty(exports, "PortalBusyError", { enumerable: true, get: function () { return portal_opener_js_1.PortalBusyError; } });
7
+ Object.defineProperty(exports, "PortalConfigurationError", { enumerable: true, get: function () { return portal_opener_js_1.PortalConfigurationError; } });
8
+ Object.defineProperty(exports, "PortalDenyError", { enumerable: true, get: function () { return portal_opener_js_1.PortalDenyError; } });
9
+ Object.defineProperty(exports, "PortalInvalidReplyError", { enumerable: true, get: function () { return portal_opener_js_1.PortalInvalidReplyError; } });
10
+ Object.defineProperty(exports, "PortalStateError", { enumerable: true, get: function () { return portal_opener_js_1.PortalStateError; } });
11
+ Object.defineProperty(exports, "PortalVerificationError", { enumerable: true, get: function () { return portal_opener_js_1.PortalVerificationError; } });
@@ -229,6 +229,89 @@ export interface MintOutput {
229
229
  expires_at?: string;
230
230
  type?: ResourceType;
231
231
  }
232
+ /** Options for `QURLClient.protectUrl`. */
233
+ export interface ProtectUrlOptions {
234
+ /** Optional resource-level metadata. Max 500 chars. */
235
+ description?: string;
236
+ /** Optional resource-level tags. Max 10 items. */
237
+ tags?: string[];
238
+ /** Custom domain already verified in LayerV. */
239
+ customDomain?: string;
240
+ /** Owner-scoped handle for the resource. */
241
+ alias?: string;
242
+ }
243
+ /**
244
+ * Options for minting a portal (mirrors qurl-go's `PortalOption` set).
245
+ *
246
+ * Duration options accept either a duration string (`"5m"`, `"24h"`) that is
247
+ * passed through for the server to validate, or a number of milliseconds that
248
+ * gets qurl-go's client-side guardrails: whole seconds only, at least one
249
+ * minute for `validFor` and at least one second for `sessionDuration`.
250
+ * Millisecond values are serialized to the API's h/m/s duration grammar with
251
+ * hours as the largest unit.
252
+ */
253
+ export interface CreatePortalOptions {
254
+ /**
255
+ * How long the qURL link stays valid. Omit to use the API default
256
+ * lifetime; prefer short lifetimes such as `"5m"`. The LayerV API remains
257
+ * the source of truth for account limits — there is intentionally no
258
+ * SDK-side maximum.
259
+ */
260
+ validFor?: string | number;
261
+ /** Human-readable label for the link. Max 500 chars, must not be blank. */
262
+ label?: string;
263
+ /** If `true`, the link expires after its first successful use. */
264
+ oneTimeUse?: boolean;
265
+ /**
266
+ * Concurrent-session limit. `0` is sent explicitly and means unlimited;
267
+ * omit to keep the server default.
268
+ */
269
+ maxSessions?: number;
270
+ /**
271
+ * How long access lasts after someone opens the link. Omit to use the
272
+ * server default; zero is rejected rather than treated as default.
273
+ */
274
+ sessionDuration?: string | number;
275
+ }
276
+ /**
277
+ * A qURL link minted for a protected resource.
278
+ *
279
+ * A portal is cryptographic, just-in-time permission for one actor to reach
280
+ * one private resource. `link` is the shareable qURL link; recipients open it
281
+ * directly and need no LayerV credentials.
282
+ *
283
+ * Mirrors `qurl.Portal` in qurl-go.
284
+ */
285
+ export interface Portal {
286
+ /** Identifies the protected resource this link opens. */
287
+ resourceId: string;
288
+ /** The shareable qURL link. */
289
+ link: string;
290
+ /** The qURL-hosted site for this resource, when returned by the API. */
291
+ site?: string;
292
+ /** Link expiration time, when returned by the API. */
293
+ expiresAt?: Date;
294
+ /** Identifies the specific access token, when returned by the API. */
295
+ qurlId?: string;
296
+ /** Token label, when returned by the API. */
297
+ label?: string;
298
+ }
299
+ /**
300
+ * Result of a successful `enterPortal`: the reachable resource.
301
+ *
302
+ * Carries the reachable resource URL and the access lifetime reported by
303
+ * qURL (`openSeconds` is `0` when the API does not report one). `resourceId`
304
+ * is populated by this SDK's API-backed opener; the field does not exist on
305
+ * qurl-go's offline `ResourceHandle`.
306
+ */
307
+ export interface ResourceHandle {
308
+ /** The reachable resource location. */
309
+ resourceUrl: string;
310
+ /** How long access stays open in seconds, as reported by qURL. */
311
+ openSeconds: number;
312
+ /** The LayerV resource id behind the portal, when reported. */
313
+ resourceId?: string;
314
+ }
232
315
  /** Input for headless qURL resolution. */
233
316
  export interface ResolveInput {
234
317
  access_token: string;
@@ -704,23 +787,6 @@ export interface AccessCodeListOutput extends UnpaginatedOutput {
704
787
  */
705
788
  access_codes: AccessCode[];
706
789
  }
707
- export interface AgentBootstrapInput {
708
- public_key: string;
709
- agent_id?: string;
710
- hostname?: string;
711
- version?: string;
712
- }
713
- export interface NHPServerPeerInfo {
714
- public_key_b64: string;
715
- host: string;
716
- port: number;
717
- expire_time: number;
718
- }
719
- export interface AgentBootstrapOutput {
720
- agent_id: string;
721
- registered_at: string;
722
- nhp_server_peer: NHPServerPeerInfo;
723
- }
724
790
  /** API error from the qURL service (RFC 7807). */
725
791
  export interface QURLErrorData {
726
792
  status: number;
@@ -755,7 +821,13 @@ export interface ClientOptions {
755
821
  apiKey: string;
756
822
  /** Base URL. Defaults to https://api.layerv.ai */
757
823
  baseUrl?: string;
758
- /** Custom fetch implementation. */
824
+ /**
825
+ * Custom fetch implementation. Redirect-safety requires it to honor
826
+ * `redirect: "manual"`, accurately expose `Response.redirected`, and leave
827
+ * `Response.url` empty or set it to the normalized request URL when no
828
+ * redirect was followed. Every Response-like result must also provide a
829
+ * `headers` object with a WHATWG-compatible `headers.get(name)` method.
830
+ */
759
831
  fetch?: typeof globalThis.fetch;
760
832
  /** Maximum retry attempts for transient errors (429, 5xx). Default: 3. */
761
833
  maxRetries?: number;
@@ -1 +1 @@
1
- export declare const VERSION = "0.3.0";
1
+ export declare const VERSION = "0.4.0";
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = "0.3.0"; // x-release-please-version
4
+ exports.VERSION = "0.4.0"; // x-release-please-version