@layervai/qurl 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +261 -11
- package/dist/cjs/client.d.ts +22 -12
- package/dist/cjs/client.js +675 -100
- package/dist/cjs/errors.d.ts +11 -11
- package/dist/cjs/errors.js +25 -20
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/internal.d.ts +4 -0
- package/dist/cjs/internal.js +16 -0
- package/dist/cjs/node/deployment.d.ts +44 -0
- package/dist/cjs/node/deployment.js +167 -0
- package/dist/cjs/node/native-udp.d.ts +35 -0
- package/dist/cjs/node/native-udp.js +294 -0
- package/dist/cjs/node/nhp-wire.d.ts +35 -0
- package/dist/cjs/node/nhp-wire.js +261 -0
- package/dist/cjs/node/portal-opener.d.ts +114 -0
- package/dist/cjs/node/portal-opener.js +1070 -0
- package/dist/cjs/node/qv2.d.ts +38 -0
- package/dist/cjs/node/qv2.js +292 -0
- package/dist/cjs/node/strict-json.d.ts +12 -0
- package/dist/cjs/node/strict-json.js +174 -0
- package/dist/cjs/node.d.ts +3 -0
- package/dist/cjs/node.js +18 -0
- package/dist/cjs/types.d.ts +17 -20
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +22 -12
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +673 -98
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/errors.d.ts +11 -11
- package/dist/esm/errors.d.ts.map +1 -1
- package/dist/esm/errors.js +25 -20
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/internal.d.ts +5 -0
- package/dist/esm/internal.d.ts.map +1 -0
- package/dist/esm/internal.js +14 -0
- package/dist/esm/internal.js.map +1 -0
- package/dist/esm/node/deployment.d.ts +45 -0
- package/dist/esm/node/deployment.d.ts.map +1 -0
- package/dist/esm/node/deployment.js +163 -0
- package/dist/esm/node/deployment.js.map +1 -0
- package/dist/esm/node/native-udp.d.ts +36 -0
- package/dist/esm/node/native-udp.d.ts.map +1 -0
- package/dist/esm/node/native-udp.js +291 -0
- package/dist/esm/node/native-udp.js.map +1 -0
- package/dist/esm/node/nhp-wire.d.ts +36 -0
- package/dist/esm/node/nhp-wire.d.ts.map +1 -0
- package/dist/esm/node/nhp-wire.js +257 -0
- package/dist/esm/node/nhp-wire.js.map +1 -0
- package/dist/esm/node/portal-opener.d.ts +115 -0
- package/dist/esm/node/portal-opener.d.ts.map +1 -0
- package/dist/esm/node/portal-opener.js +1053 -0
- package/dist/esm/node/portal-opener.js.map +1 -0
- package/dist/esm/node/qv2.d.ts +39 -0
- package/dist/esm/node/qv2.d.ts.map +1 -0
- package/dist/esm/node/qv2.js +286 -0
- package/dist/esm/node/qv2.js.map +1 -0
- package/dist/esm/node/strict-json.d.ts +13 -0
- package/dist/esm/node/strict-json.d.ts.map +1 -0
- package/dist/esm/node/strict-json.js +171 -0
- package/dist/esm/node/strict-json.js.map +1 -0
- package/dist/esm/node.d.ts +4 -0
- package/dist/esm/node.d.ts.map +1 -0
- package/dist/esm/node.js +2 -0
- package/dist/esm/node.js.map +1 -0
- package/dist/esm/types.d.ts +17 -20
- package/dist/esm/types.d.ts.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +26 -7
package/dist/cjs/errors.d.ts
CHANGED
|
@@ -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-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
package/dist/cjs/errors.js
CHANGED
|
@@ -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-
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
this.cause = options.cause;
|
|
182
|
-
}
|
|
187
|
+
attachErrorCause(this, options);
|
|
183
188
|
}
|
|
184
189
|
}
|
|
185
190
|
exports.RuntimeError = RuntimeError;
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -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,
|
|
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 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 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 startup 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,35 @@
|
|
|
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 interface NativeExchangeOptions {
|
|
6
|
+
readonly signal?: AbortSignal;
|
|
7
|
+
/** Internal per-address timeout. PortalOpener does not expose this setting. */
|
|
8
|
+
readonly timeoutMs?: number;
|
|
9
|
+
/** Internal serial-address cap. PortalOpener does not expose this setting. */
|
|
10
|
+
readonly maxAddresses?: number;
|
|
11
|
+
}
|
|
12
|
+
interface NativeKnockRuntime {
|
|
13
|
+
readonly buildMessage: typeof buildNHPMessage;
|
|
14
|
+
readonly resolveAddresses: typeof resolvePublicAddresses;
|
|
15
|
+
readonly exchange: typeof exchangeDatagram;
|
|
16
|
+
readonly decryptReply: typeof decryptNHPReply;
|
|
17
|
+
}
|
|
18
|
+
export declare function nativeKnock(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options?: NativeExchangeOptions): Promise<NHPMessage>;
|
|
19
|
+
declare function nativeKnockWithRuntime(cell: ValidatedCell, devicePrivateKey: Uint8Array, body: Uint8Array, options: NativeExchangeOptions, runtime: NativeKnockRuntime): Promise<NHPMessage>;
|
|
20
|
+
declare function resolvePublicAddresses(host: string, maximum: number, signal?: AbortSignal, lookupAddresses?: typeof lookup): Promise<Array<{
|
|
21
|
+
address: string;
|
|
22
|
+
family: 4 | 6;
|
|
23
|
+
}>>;
|
|
24
|
+
declare function isPublicAddress(address: string, family: number): boolean;
|
|
25
|
+
declare function exchangeDatagram(address: string, family: 4 | 6, port: number, packet: Uint8Array, timeoutMs: number, signal?: AbortSignal): Promise<Uint8Array>;
|
|
26
|
+
declare function sendOwnedDatagram(socket: Socket, packet: Uint8Array, finish: (error: unknown) => void): void;
|
|
27
|
+
export declare const nativeUdpTesting: {
|
|
28
|
+
isPublicAddress: typeof isPublicAddress;
|
|
29
|
+
resolvePublicAddresses: typeof resolvePublicAddresses;
|
|
30
|
+
nativeKnockWithRuntime: typeof nativeKnockWithRuntime;
|
|
31
|
+
exchangeDatagram: typeof exchangeDatagram;
|
|
32
|
+
sendOwnedDatagram: typeof sendOwnedDatagram;
|
|
33
|
+
socketExchangeError: (cause: unknown) => Error;
|
|
34
|
+
};
|
|
35
|
+
export {};
|