@layervai/qurl 0.3.1 → 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 (72) hide show
  1. package/README.md +187 -7
  2. package/dist/cjs/client.d.ts +17 -10
  3. package/dist/cjs/client.js +649 -96
  4. package/dist/cjs/errors.d.ts +11 -11
  5. package/dist/cjs/errors.js +25 -20
  6. package/dist/cjs/index.d.ts +1 -1
  7. package/dist/cjs/internal.d.ts +4 -0
  8. package/dist/cjs/internal.js +16 -0
  9. package/dist/cjs/node/deployment.d.ts +44 -0
  10. package/dist/cjs/node/deployment.js +167 -0
  11. package/dist/cjs/node/native-udp.d.ts +37 -0
  12. package/dist/cjs/node/native-udp.js +294 -0
  13. package/dist/cjs/node/nhp-wire.d.ts +35 -0
  14. package/dist/cjs/node/nhp-wire.js +261 -0
  15. package/dist/cjs/node/portal-opener.d.ts +83 -0
  16. package/dist/cjs/node/portal-opener.js +767 -0
  17. package/dist/cjs/node/qv2.d.ts +38 -0
  18. package/dist/cjs/node/qv2.js +292 -0
  19. package/dist/cjs/node/strict-json.d.ts +12 -0
  20. package/dist/cjs/node/strict-json.js +174 -0
  21. package/dist/cjs/node.d.ts +3 -0
  22. package/dist/cjs/node.js +11 -0
  23. package/dist/cjs/types.d.ts +7 -18
  24. package/dist/cjs/version.d.ts +1 -1
  25. package/dist/cjs/version.js +1 -1
  26. package/dist/esm/client.d.ts +17 -10
  27. package/dist/esm/client.d.ts.map +1 -1
  28. package/dist/esm/client.js +647 -94
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/errors.d.ts +11 -11
  31. package/dist/esm/errors.d.ts.map +1 -1
  32. package/dist/esm/errors.js +25 -20
  33. package/dist/esm/errors.js.map +1 -1
  34. package/dist/esm/index.d.ts +1 -1
  35. package/dist/esm/index.d.ts.map +1 -1
  36. package/dist/esm/internal.d.ts +5 -0
  37. package/dist/esm/internal.d.ts.map +1 -0
  38. package/dist/esm/internal.js +14 -0
  39. package/dist/esm/internal.js.map +1 -0
  40. package/dist/esm/node/deployment.d.ts +45 -0
  41. package/dist/esm/node/deployment.d.ts.map +1 -0
  42. package/dist/esm/node/deployment.js +163 -0
  43. package/dist/esm/node/deployment.js.map +1 -0
  44. package/dist/esm/node/native-udp.d.ts +38 -0
  45. package/dist/esm/node/native-udp.d.ts.map +1 -0
  46. package/dist/esm/node/native-udp.js +291 -0
  47. package/dist/esm/node/native-udp.js.map +1 -0
  48. package/dist/esm/node/nhp-wire.d.ts +36 -0
  49. package/dist/esm/node/nhp-wire.d.ts.map +1 -0
  50. package/dist/esm/node/nhp-wire.js +257 -0
  51. package/dist/esm/node/nhp-wire.js.map +1 -0
  52. package/dist/esm/node/portal-opener.d.ts +84 -0
  53. package/dist/esm/node/portal-opener.d.ts.map +1 -0
  54. package/dist/esm/node/portal-opener.js +757 -0
  55. package/dist/esm/node/portal-opener.js.map +1 -0
  56. package/dist/esm/node/qv2.d.ts +39 -0
  57. package/dist/esm/node/qv2.d.ts.map +1 -0
  58. package/dist/esm/node/qv2.js +286 -0
  59. package/dist/esm/node/qv2.js.map +1 -0
  60. package/dist/esm/node/strict-json.d.ts +13 -0
  61. package/dist/esm/node/strict-json.d.ts.map +1 -0
  62. package/dist/esm/node/strict-json.js +171 -0
  63. package/dist/esm/node/strict-json.js.map +1 -0
  64. package/dist/esm/node.d.ts +4 -0
  65. package/dist/esm/node.d.ts.map +1 -0
  66. package/dist/esm/node.js +2 -0
  67. package/dist/esm/node.js.map +1 -0
  68. package/dist/esm/types.d.ts +7 -18
  69. package/dist/esm/types.d.ts.map +1 -1
  70. package/dist/esm/version.d.ts +1 -1
  71. package/dist/esm/version.js +1 -1
  72. package/package.json +25 -6
@@ -23,14 +23,12 @@ export declare const ERROR_CODE_UNKNOWN = "unknown";
23
23
  /**
24
24
  * Base error thrown by the qURL API client. Catch this to handle all SDK errors.
25
25
  *
26
- * **`status: 0` convention:** Client-detected failures — validation errors
27
- * (`code: "client_validation"`), unexpected response shapes
28
- * (`code: "unexpected_response"`), runtime capability errors
29
- * (`code: "runtime_error"`), network errors (`code: "network_error"`), and
30
- * timeouts (`code: "timeout"`) — all use `status: 0` because no real HTTP
31
- * status code applies. To distinguish between these cases, branch on `.code`
32
- * rather than `.status`. Non-zero `.status` always reflects a real HTTP
33
- * status from the API (e.g. 400, 401, 429, 500).
26
+ * **`status: 0` convention:** Client-only validation, runtime, network, and
27
+ * timeout failures use `status: 0` because no HTTP status applies. Logical
28
+ * response-shape guards can also use zero. An `unexpected_response` tied to a
29
+ * concrete HTTP contract violation (redirect, oversized body, wrong
30
+ * no-content status/body) instead preserves the observed status. Branch on
31
+ * `.code` first and then `.status`; see {@link ValidationError}.
34
32
  *
35
33
  * **`.code === "unknown"`** is a possible value when the server returns a
36
34
  * non-RFC-7807 response (e.g. a Cloudflare HTML error page, a gateway
@@ -88,9 +86,11 @@ export declare class NotFoundError extends QURLError {
88
86
  * counts/length mismatch, per-entry contract violation): `.status`
89
87
  * is `0`. The HTTP status that produced the bad body is appended
90
88
  * to `.detail` as `(HTTP 400)` / `(HTTP 207)` etc. for diagnostics.
91
- * - Non-JSON body on a 2xx or passthrough status (e.g. proxy HTML
92
- * error page, plaintext gateway error, truncated body): `.status`
93
- * is the actual HTTP status (e.g. `400`, `200`).
89
+ * - Non-JSON, oversized, redirect, or exact-status/body-contract failure on a
90
+ * 2xx, passthrough, or other observed HTTP status: `.status` is the actual
91
+ * HTTP status (e.g. `200`, `302`, `400`, `503`). Filtered opaque redirects
92
+ * in browsers and Node native fetch use `.status === 0` because Fetch does
93
+ * not expose their 3xx status.
94
94
  *
95
95
  * Consumers branching purely on `.status` should branch on `.code`
96
96
  * first, then `.detail` for shape-guard cases. See #59 for tracking
@@ -26,14 +26,12 @@ exports.ERROR_CODE_UNKNOWN = "unknown";
26
26
  /**
27
27
  * Base error thrown by the qURL API client. Catch this to handle all SDK errors.
28
28
  *
29
- * **`status: 0` convention:** Client-detected failures — validation errors
30
- * (`code: "client_validation"`), unexpected response shapes
31
- * (`code: "unexpected_response"`), runtime capability errors
32
- * (`code: "runtime_error"`), network errors (`code: "network_error"`), and
33
- * timeouts (`code: "timeout"`) — all use `status: 0` because no real HTTP
34
- * status code applies. To distinguish between these cases, branch on `.code`
35
- * rather than `.status`. Non-zero `.status` always reflects a real HTTP
36
- * status from the API (e.g. 400, 401, 429, 500).
29
+ * **`status: 0` convention:** Client-only validation, runtime, network, and
30
+ * timeout failures use `status: 0` because no HTTP status applies. Logical
31
+ * response-shape guards can also use zero. An `unexpected_response` tied to a
32
+ * concrete HTTP contract violation (redirect, oversized body, wrong
33
+ * no-content status/body) instead preserves the observed status. Branch on
34
+ * `.code` first and then `.status`; see {@link ValidationError}.
37
35
  *
38
36
  * **`.code === "unknown"`** is a possible value when the server returns a
39
37
  * non-RFC-7807 response (e.g. a Cloudflare HTML error page, a gateway
@@ -119,9 +117,11 @@ exports.NotFoundError = NotFoundError;
119
117
  * counts/length mismatch, per-entry contract violation): `.status`
120
118
  * is `0`. The HTTP status that produced the bad body is appended
121
119
  * to `.detail` as `(HTTP 400)` / `(HTTP 207)` etc. for diagnostics.
122
- * - Non-JSON body on a 2xx or passthrough status (e.g. proxy HTML
123
- * error page, plaintext gateway error, truncated body): `.status`
124
- * is the actual HTTP status (e.g. `400`, `200`).
120
+ * - Non-JSON, oversized, redirect, or exact-status/body-contract failure on a
121
+ * 2xx, passthrough, or other observed HTTP status: `.status` is the actual
122
+ * HTTP status (e.g. `200`, `302`, `400`, `503`). Filtered opaque redirects
123
+ * in browsers and Node native fetch use `.status === 0` because Fetch does
124
+ * not expose their 3xx status.
125
125
  *
126
126
  * Consumers branching purely on `.status` should branch on `.code`
127
127
  * first, then `.detail` for shape-guard cases. See #59 for tracking
@@ -150,14 +150,23 @@ class ServerError extends QURLError {
150
150
  }
151
151
  }
152
152
  exports.ServerError = ServerError;
153
+ function attachErrorCause(error, options) {
154
+ if (!options || !("cause" in options))
155
+ return;
156
+ // Match the native Error `cause` descriptor. In particular, keep transport
157
+ // objects out of Object.keys(), object spread, and JSON logging.
158
+ Object.defineProperty(error, "cause", {
159
+ value: options.cause,
160
+ writable: true,
161
+ configurable: true,
162
+ });
163
+ }
153
164
  /** Transport-level error — DNS failure, connection refused, etc. */
154
165
  class NetworkError extends QURLError {
155
166
  constructor(message, options) {
156
167
  super({ status: 0, code: exports.ERROR_CODE_NETWORK, title: "Network Error", detail: message });
157
168
  this.name = "NetworkError";
158
- if (options?.cause) {
159
- this.cause = options.cause;
160
- }
169
+ attachErrorCause(this, options);
161
170
  }
162
171
  }
163
172
  exports.NetworkError = NetworkError;
@@ -166,9 +175,7 @@ class TimeoutError extends QURLError {
166
175
  constructor(message = "Request timed out", options) {
167
176
  super({ status: 0, code: exports.ERROR_CODE_TIMEOUT, title: "Timeout", detail: message });
168
177
  this.name = "TimeoutError";
169
- if (options?.cause) {
170
- this.cause = options.cause;
171
- }
178
+ attachErrorCause(this, options);
172
179
  }
173
180
  }
174
181
  exports.TimeoutError = TimeoutError;
@@ -177,9 +184,7 @@ class RuntimeError extends QURLError {
177
184
  constructor(message, options) {
178
185
  super({ status: 0, code: exports.ERROR_CODE_RUNTIME, title: "Runtime Error", detail: message });
179
186
  this.name = "RuntimeError";
180
- if (options && "cause" in options) {
181
- this.cause = options.cause;
182
- }
187
+ attachErrorCause(this, options);
183
188
  }
184
189
  }
185
190
  exports.RuntimeError = RuntimeError;
@@ -1,4 +1,4 @@
1
1
  export { ProtectedResource, QURLClient } from "./client.js";
2
2
  export { AuthenticationError, AuthorizationError, createError, ERROR_CODE_AMBIGUOUS_RESOURCE, ERROR_CODE_CLIENT_VALIDATION, ERROR_CODE_NETWORK, ERROR_CODE_RESOURCE_NOT_FOUND, ERROR_CODE_RUNTIME, ERROR_CODE_TIMEOUT, ERROR_CODE_UNEXPECTED_RESPONSE, ERROR_CODE_UNKNOWN, NetworkError, NotFoundError, QURLError, RateLimitError, RuntimeError, ServerError, TimeoutError, ValidationError, } from "./errors.js";
3
3
  export { VERSION } from "./version.js";
4
- export type { AccessCode, AccessCodeListOutput, AccessGrant, AccessPolicy, AccessToken, AgentBootstrapInput, AgentBootstrapOutput, AIAgentCategory, AIAgentPolicy, ApiKey, ApiKeyListOutput, ApiKeyScope, BatchCreateInput, BatchCreateOutput, BatchItemFailure, BatchItemResult, BatchItemSuccess, BillingInvoiceListOutput, ClientOptions, ConnectorInstallation, ConnectorInstallationCapabilities, ConnectorInstallationListOutput, ConnectorInstallationStats, CreateAccessCodeInput, CreateAccessCodeOutput, CreateApiKeyInput, CreateApiKeyOutput, CreateBillingCheckoutInput, CreateInput, CreateOutput, CreatePortalOptions, CreateQurlForResourceInput, CreateResourceInput, CreateWebhookInput, Customer, CheckoutSession, DnsRecord, Domain, DomainCheckDetail, DomainListOutput, DomainVerifyResult, ExtendInput, Invoice, ListApiKeysInput, ListBillingInvoicesInput, ListConnectorInstallationsInput, ListDomainsInput, ListInput, ListOutput, ListWebhooksInput, ListWebhookDeliveriesInput, Meta, MintInput, MintOutput, NHPServerPeerInfo, PaginatedOutput, Portal, PortalSession, ProtectUrlOptions, QURL, QURLErrorData, QurlSummary, Quota, RedeemAccessCodeInput, RedeemAccessCodeOutput, RegisterDomainInput, Resource, ResourceDetail, ResourceHandle, ResourceListInput, ResourceListOutput, ResourceType, RequestOptions, ResolveInput, ResolveOutput, Session, SessionListOutput, SessionTerminateOutput, TombstoneInfo, UnpaginatedOutput, UpdateApiKeyInput, UpdateCustomerInput, UpdateInput, UpdateResourceInput, UpdateResourceQurlInput, UpdateWebhookInput, UsageCostEstimate, UsageCurrentPeriod, UsageDaily, UsageDailyEntry, Webhook, WebhookDelivery, WebhookDeliveryListOutput, WebhookEventType, WebhookEventTypeInfo, WebhookListOutput, WebhookPayload, WebhookWithSecret, } from "./types.js";
4
+ export type { AccessCode, AccessCodeListOutput, AccessGrant, AccessPolicy, AccessToken, AIAgentCategory, AIAgentPolicy, ApiKey, ApiKeyListOutput, ApiKeyScope, BatchCreateInput, BatchCreateOutput, BatchItemFailure, BatchItemResult, BatchItemSuccess, BillingInvoiceListOutput, ClientOptions, ConnectorInstallation, ConnectorInstallationCapabilities, ConnectorInstallationListOutput, ConnectorInstallationStats, CreateAccessCodeInput, CreateAccessCodeOutput, CreateApiKeyInput, CreateApiKeyOutput, CreateBillingCheckoutInput, CreateInput, CreateOutput, CreatePortalOptions, CreateQurlForResourceInput, CreateResourceInput, CreateWebhookInput, Customer, CheckoutSession, DnsRecord, Domain, DomainCheckDetail, DomainListOutput, DomainVerifyResult, ExtendInput, Invoice, ListApiKeysInput, ListBillingInvoicesInput, ListConnectorInstallationsInput, ListDomainsInput, ListInput, ListOutput, ListWebhooksInput, ListWebhookDeliveriesInput, Meta, MintInput, MintOutput, PaginatedOutput, Portal, PortalSession, ProtectUrlOptions, QURL, QURLErrorData, QurlSummary, Quota, RedeemAccessCodeInput, RedeemAccessCodeOutput, RegisterDomainInput, Resource, ResourceDetail, ResourceHandle, ResourceListInput, ResourceListOutput, ResourceType, RequestOptions, ResolveInput, ResolveOutput, Session, SessionListOutput, SessionTerminateOutput, TombstoneInfo, UnpaginatedOutput, UpdateApiKeyInput, UpdateCustomerInput, UpdateInput, UpdateResourceInput, UpdateResourceQurlInput, UpdateWebhookInput, UsageCostEstimate, UsageCurrentPeriod, UsageDaily, UsageDailyEntry, Webhook, WebhookDelivery, WebhookDeliveryListOutput, WebhookEventType, WebhookEventTypeInfo, WebhookListOutput, WebhookPayload, WebhookWithSecret, } from "./types.js";
@@ -0,0 +1,4 @@
1
+ /** Strips trailing "/" characters. A manual scan rather than `replace(/\/+$/, "")`
2
+ * so it is linear on any input (the regex form is polynomial-ReDoS on a long run
3
+ * of slashes over caller-supplied input, e.g. a `baseUrl` option). */
4
+ export declare function stripTrailingSlashes(url: string): string;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ // Small internal string helpers shared across the SDK. Not exported from the
3
+ // package barrel (`index.ts`) and unreachable via the `exports` map, so this is
4
+ // private surface.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.stripTrailingSlashes = stripTrailingSlashes;
7
+ /** Strips trailing "/" characters. A manual scan rather than `replace(/\/+$/, "")`
8
+ * so it is linear on any input (the regex form is polynomial-ReDoS on a long run
9
+ * of slashes over caller-supplied input, e.g. a `baseUrl` option). */
10
+ function stripTrailingSlashes(url) {
11
+ let end = url.length;
12
+ while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
13
+ end--;
14
+ }
15
+ return end === url.length ? url : url.slice(0, end);
16
+ }
@@ -0,0 +1,44 @@
1
+ import type { KeyObject } from "node:crypto";
2
+ import { type StrictJsonValue } from "./strict-json.js";
3
+ export interface PortalDeployment {
4
+ readonly issuers: readonly PortalDeploymentIssuer[];
5
+ readonly cells: readonly PortalDeploymentCell[];
6
+ }
7
+ export interface PortalDeploymentIssuer {
8
+ readonly kid: string;
9
+ readonly spki_der_b64: string;
10
+ }
11
+ export interface PortalDeploymentCell {
12
+ readonly cell_id?: string;
13
+ readonly host: string;
14
+ readonly port: number;
15
+ readonly server_public_key_b64: string;
16
+ }
17
+ export interface ValidatedDeployment {
18
+ readonly issuers: ReadonlyMap<string, KeyObject>;
19
+ readonly cells: ReadonlyMap<string, ValidatedCell>;
20
+ }
21
+ export interface ValidatedCell {
22
+ readonly host: string;
23
+ readonly port: 443;
24
+ readonly serverPublicKey: Uint8Array;
25
+ }
26
+ interface DeploymentFileRuntime {
27
+ readonly open: (path: string, flags: number) => number;
28
+ readonly stat: (descriptor: number) => {
29
+ readonly size: number;
30
+ isFile(): boolean;
31
+ };
32
+ readonly read: (descriptor: number, buffer: Buffer, offset: number, length: number, position: number) => number;
33
+ readonly close: (descriptor: number) => void;
34
+ }
35
+ /** Load and validate deployment trust once, before an opener can do network I/O. */
36
+ export declare function loadPortalDeployment(explicit?: PortalDeployment): ValidatedDeployment;
37
+ declare function readBoundedDeploymentFile(path: string, runtime?: DeploymentFileRuntime): Buffer;
38
+ export declare function fingerprintKey(key: Uint8Array): string;
39
+ declare function decodeFlexibleBase64(value: StrictJsonValue | undefined, name: string): Uint8Array;
40
+ export declare const deploymentTesting: {
41
+ readBoundedDeploymentFile: typeof readBoundedDeploymentFile;
42
+ decodeFlexibleBase64: typeof decodeFlexibleBase64;
43
+ };
44
+ export {};
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deploymentTesting = void 0;
4
+ exports.loadPortalDeployment = loadPortalDeployment;
5
+ exports.fingerprintKey = fingerprintKey;
6
+ const node_fs_1 = require("node:fs");
7
+ const node_crypto_1 = require("node:crypto");
8
+ const strict_json_js_1 = require("./strict-json.js");
9
+ const qv2_js_1 = require("./qv2.js");
10
+ const DEPLOYMENT_KEYS = new Set(["issuers", "cells", "relay_allowlist", "hub"]);
11
+ const ISSUER_KEYS = new Set(["kid", "spki_der_b64"]);
12
+ const CELL_KEYS = new Set(["cell_id", "host", "port", "server_public_key_b64"]);
13
+ const MAX_DEPLOYMENT_BYTES = 1_048_576;
14
+ const defaultDeploymentFileRuntime = {
15
+ open: node_fs_1.openSync,
16
+ stat: node_fs_1.fstatSync,
17
+ read: node_fs_1.readSync,
18
+ close: node_fs_1.closeSync,
19
+ };
20
+ /** Load and validate deployment trust once, before an opener can do network I/O. */
21
+ function loadPortalDeployment(explicit) {
22
+ if (explicit !== undefined)
23
+ return validateDeployment(explicit);
24
+ const configured = process.env.QURL_DEPLOYMENT;
25
+ if (!configured || configured.trim() === "") {
26
+ throw new Error("native qURL opening requires deployment trust in QURL_DEPLOYMENT");
27
+ }
28
+ const trimmed = configured.trim();
29
+ const raw = trimmed.startsWith("{")
30
+ ? Buffer.from(trimmed, "utf8")
31
+ : readBoundedDeploymentFile(trimmed);
32
+ return validateDeploymentValue((0, strict_json_js_1.parseStrictJson)(raw, MAX_DEPLOYMENT_BYTES));
33
+ }
34
+ function readBoundedDeploymentFile(path, runtime = defaultDeploymentFileRuntime) {
35
+ // O_NONBLOCK prevents a FIFO or device path from hanging construction before
36
+ // fstat can enforce the regular-file contract. It has no effect on files.
37
+ const descriptor = runtime.open(path, node_fs_1.constants.O_RDONLY | node_fs_1.constants.O_NONBLOCK);
38
+ try {
39
+ const metadata = runtime.stat(descriptor);
40
+ if (!metadata.isFile())
41
+ throw new Error("native qURL deployment path must be a regular file");
42
+ if (!Number.isSafeInteger(metadata.size) || metadata.size > MAX_DEPLOYMENT_BYTES) {
43
+ throw new Error("native qURL deployment file exceeds its 1 MiB limit");
44
+ }
45
+ const bytes = Buffer.alloc(metadata.size);
46
+ let offset = 0;
47
+ while (offset < bytes.byteLength) {
48
+ const count = runtime.read(descriptor, bytes, offset, bytes.byteLength - offset, offset);
49
+ if (count === 0) {
50
+ throw new Error("native qURL deployment file changed while it was read");
51
+ }
52
+ offset += count;
53
+ }
54
+ const extra = Buffer.alloc(1);
55
+ if (runtime.read(descriptor, extra, 0, 1, offset) !== 0) {
56
+ throw new Error("native qURL deployment file changed while it was read");
57
+ }
58
+ return bytes.subarray(0, offset);
59
+ }
60
+ finally {
61
+ runtime.close(descriptor);
62
+ }
63
+ }
64
+ function validateDeployment(explicit) {
65
+ // Convert through JSON only to give the programmatic form the same strict,
66
+ // immutable validation path as the environment/file form. Deployment data is
67
+ // public trust material, not a secret.
68
+ let encoded;
69
+ try {
70
+ encoded = JSON.stringify(explicit);
71
+ }
72
+ catch {
73
+ throw new Error("native qURL deployment is not JSON serializable");
74
+ }
75
+ if (encoded === undefined)
76
+ throw new Error("native qURL deployment is missing");
77
+ return validateDeploymentValue((0, strict_json_js_1.parseStrictJson)(Buffer.from(encoded, "utf8"), MAX_DEPLOYMENT_BYTES));
78
+ }
79
+ function validateDeploymentValue(value) {
80
+ const root = requireObject(value, "deployment");
81
+ rejectUnknown(root, DEPLOYMENT_KEYS, "deployment");
82
+ const issuerRows = requireArray(root.issuers, "deployment issuers");
83
+ const cellRows = requireArray(root.cells, "deployment cells");
84
+ if (issuerRows.length === 0)
85
+ throw new Error("deployment must contain at least one issuer");
86
+ if (cellRows.length === 0)
87
+ throw new Error("native-only deployment must contain at least one cell");
88
+ const issuers = new Map();
89
+ for (const row of issuerRows) {
90
+ const item = requireObject(row, "deployment issuer");
91
+ rejectUnknown(item, ISSUER_KEYS, "deployment issuer");
92
+ const kid = requireTrimmed(item.kid, "deployment issuer kid");
93
+ if (issuers.has(kid))
94
+ throw new Error("deployment contains a duplicate issuer kid");
95
+ issuers.set(kid, (0, qv2_js_1.issuerKeyFromSpki)(decodeFlexibleBase64(item.spki_der_b64, "issuer key")));
96
+ }
97
+ const cells = new Map();
98
+ for (const row of cellRows) {
99
+ const item = requireObject(row, "deployment cell");
100
+ rejectUnknown(item, CELL_KEYS, "deployment cell");
101
+ const host = requireTrimmed(item.host, "deployment cell host");
102
+ if (item.port !== 443n)
103
+ throw new Error("deployment cell must use NHP UDP port 443");
104
+ const serverPublicKey = decodeFlexibleBase64(item.server_public_key_b64, "cell public key");
105
+ if (serverPublicKey.byteLength !== 32)
106
+ throw new Error("cell public key must be 32 bytes");
107
+ const fingerprint = fingerprintKey(serverPublicKey);
108
+ if (cells.has(fingerprint))
109
+ throw new Error("deployment contains a duplicate cell public key");
110
+ // Validate cell_id for schema parity, but do not use it as identity. The
111
+ // full X25519 public key is the cryptographic cell binding.
112
+ if (item.cell_id !== undefined)
113
+ requireString(item.cell_id, "cell id");
114
+ cells.set(fingerprint, { host, port: 443, serverPublicKey });
115
+ }
116
+ return { issuers, cells };
117
+ }
118
+ function fingerprintKey(key) {
119
+ return (0, node_crypto_1.createHash)("sha256").update(key).digest().subarray(0, 8).toString("base64url");
120
+ }
121
+ function decodeFlexibleBase64(value, name) {
122
+ const encoded = requireTrimmed(value, name);
123
+ const hasStandard = /[+/]/.test(encoded);
124
+ const hasUrl = /[-_]/.test(encoded);
125
+ if (hasStandard && hasUrl)
126
+ throw new Error(`${name} mixes base64 alphabets`);
127
+ if (!/^(?:[A-Za-z0-9+/_-]{4})*(?:[A-Za-z0-9+/_-]{2}==|[A-Za-z0-9+/_-]{3}=)?$/.test(encoded)) {
128
+ // Also permit the unpadded two/three-character tail used by Raw encodings.
129
+ if (!/^[A-Za-z0-9+/_-]+$/.test(encoded) || encoded.length % 4 === 1) {
130
+ throw new Error(`${name} is not valid base64`);
131
+ }
132
+ }
133
+ const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
134
+ const decoded = Buffer.from(normalized, "base64");
135
+ const canonical = decoded.toString("base64").replace(/=+$/, "");
136
+ if (canonical !== normalized)
137
+ throw new Error(`${name} is not canonical base64`);
138
+ return new Uint8Array(decoded);
139
+ }
140
+ function requireObject(value, name) {
141
+ if (!(0, strict_json_js_1.isStrictJsonObject)(value))
142
+ throw new Error(`${name} must be one JSON object`);
143
+ return value;
144
+ }
145
+ function requireArray(value, name) {
146
+ if (!Array.isArray(value))
147
+ throw new Error(`${name} must be an array`);
148
+ return value;
149
+ }
150
+ function requireString(value, name) {
151
+ if (typeof value !== "string")
152
+ throw new Error(`${name} must be a string`);
153
+ return value;
154
+ }
155
+ function requireTrimmed(value, name) {
156
+ const string = requireString(value, name);
157
+ if (string === "" || string.trim() !== string)
158
+ throw new Error(`${name} must be non-empty and trimmed`);
159
+ return string;
160
+ }
161
+ function rejectUnknown(value, allowed, name) {
162
+ for (const key of Object.keys(value)) {
163
+ if (!allowed.has(key))
164
+ throw new Error(`${name} contains an unknown field`);
165
+ }
166
+ }
167
+ exports.deploymentTesting = { readBoundedDeploymentFile, decodeFlexibleBase64 };
@@ -0,0 +1,37 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { type Socket } from "node:dgram";
3
+ import { buildNHPMessage, decryptNHPReply, type NHPMessage } from "./nhp-wire.js";
4
+ import type { ValidatedCell } from "./deployment.js";
5
+ export declare const NATIVE_DEFAULT_TIMEOUT_MS = 3000;
6
+ export declare const NATIVE_DEFAULT_MAX_ADDRESSES = 3;
7
+ export interface NativeExchangeOptions {
8
+ readonly signal?: AbortSignal;
9
+ /** Per-address timeout; serial fallback can use maxAddresses times this value. */
10
+ readonly timeoutMs?: number;
11
+ /** Maximum number of resolved addresses tried serially. */
12
+ readonly maxAddresses?: number;
13
+ }
14
+ interface NativeKnockRuntime {
15
+ readonly buildMessage: typeof buildNHPMessage;
16
+ readonly resolveAddresses: typeof resolvePublicAddresses;
17
+ readonly exchange: typeof exchangeDatagram;
18
+ readonly decryptReply: typeof decryptNHPReply;
19
+ }
20
+ export declare function nativeKnock(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options?: NativeExchangeOptions): Promise<NHPMessage>;
21
+ declare function nativeKnockWithRuntime(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options: NativeExchangeOptions, runtime: NativeKnockRuntime): Promise<NHPMessage>;
22
+ declare function resolvePublicAddresses(host: string, maximum: number, signal?: AbortSignal, lookupAddresses?: typeof lookup): Promise<Array<{
23
+ address: string;
24
+ family: 4 | 6;
25
+ }>>;
26
+ declare function isPublicAddress(address: string, family: number): boolean;
27
+ declare function exchangeDatagram(address: string, family: 4 | 6, port: number, packet: Uint8Array, timeoutMs: number, signal?: AbortSignal): Promise<Uint8Array>;
28
+ declare function sendOwnedDatagram(socket: Socket, packet: Uint8Array, finish: (error: unknown) => void): void;
29
+ export declare const nativeUdpTesting: {
30
+ isPublicAddress: typeof isPublicAddress;
31
+ resolvePublicAddresses: typeof resolvePublicAddresses;
32
+ nativeKnockWithRuntime: typeof nativeKnockWithRuntime;
33
+ exchangeDatagram: typeof exchangeDatagram;
34
+ sendOwnedDatagram: typeof sendOwnedDatagram;
35
+ socketExchangeError: (cause: unknown) => Error;
36
+ };
37
+ export {};