@impetik/xeer-mcp 0.2.12 → 0.2.13
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 +3 -2
- package/dist/dev-session.d.ts +6 -0
- package/dist/dev-session.js +20 -2
- package/dist/server.js +7 -1
- package/package.json +2 -2
- package/vendor/spec/actions.d.ts +26 -2
- package/vendor/spec/actions.js +17 -2
- package/vendor/spec/index.d.ts +1 -0
- package/vendor/spec/index.js +1 -0
- package/vendor/spec/tunnel.d.ts +109 -0
- package/vendor/spec/tunnel.js +228 -0
package/README.md
CHANGED
|
@@ -71,18 +71,19 @@ The generated registry is the complete MCP tool and exclusion surface:
|
|
|
71
71
|
| `xeer_promote` | `promote.operator` | `operator` | Promote an exact review receipt in operator mode. | `directory?`: `string`<br>`receiptId`: `string`<br>`controlUrl?`: `string` | `network-read`<br>`network-write`<br>`production-change` | writes; non-idempotent; reversible; non-destructive | `project-or-url` | `xeer.command.v0` | A human must review the exact artifact and start xeer-mcp with XEER_MCP_PROFILE=operator. |
|
|
72
72
|
| `xeer_auth_status` | `auth.status` | `author` / `operator` | Report builder credential metadata. | `controlUrl?`: `string` | `network-read`<br>`secret-metadata` | read-only; idempotent; reversible; non-destructive | `none` | `xeer.command.v0` | none |
|
|
73
73
|
| `xeer_inspect` | `inspect` | `author` / `operator` | Read a running application manifest. | `previewUrl`: `string`<br>`view?`: `manifest` / `state` / `logs` / `export`; default `manifest`<br>`after?`: `string` | `network-read`<br>`read-state` | read-only; idempotent; reversible; non-destructive | `app-or-url` | `xeer.command.v0` | none |
|
|
74
|
-
| `xeer_dev_start` | `dev.start` | `author` / `operator` | Start a local development session. | `directory?`: `string`<br>`host?`: `string`<br>`port?`: `integer` [0..65535]<br>`timeoutMilliseconds?`: `integer` [1000..600000] | `read-source`<br>`write-generated`<br>`run-local`<br>`write-state` | writes; non-idempotent; reversible; non-destructive | `project-relative` | `xeer.dev.v0` | none |
|
|
74
|
+
| `xeer_dev_start` | `dev.start` | `author` / `operator` | Start a local development session. | `directory?`: `string`<br>`host?`: `string`<br>`port?`: `integer` [0..65535]<br>`share?`: `boolean`<br>`timeoutMilliseconds?`: `integer` [1000..600000] | `read-source`<br>`write-generated`<br>`run-local`<br>`write-state`<br>`network-read`<br>`network-write` | writes; non-idempotent; reversible; non-destructive | `project-relative` | `xeer.dev.v0` | none |
|
|
75
75
|
| `xeer_dev_status` | `dev.status` | `author` / `operator` | Read new events from a local development session. | `sessionId?`: `string`<br>`cursor?`: `integer` [0..9007199254740991]; default `0`<br>`waitMilliseconds?`: `integer` [0..600000]; default `0`<br>`limit?`: `integer` [1..1000]; default `200` | none | read-only; idempotent; reversible; non-destructive | `none` | `xeer.dev.v0` | none |
|
|
76
76
|
| `xeer_dev_stop` | `dev.stop` | `author` / `operator` | Stop a local development session and release its lease. | `sessionId?`: `string`<br>`cursor?`: `integer` [0..9007199254740991]; default `0` | `run-local` | writes; idempotent; reversible; non-destructive | `none` | `xeer.dev.v0` | none |
|
|
77
77
|
| `xeer_diagnostics` | `diagnostics` | `author` / `operator` | Explain one emitted diagnostic code. | `code`: `string` | none | read-only; idempotent; reversible; non-destructive | `none` | `xeer.command.v0` | none |
|
|
78
78
|
|
|
79
|
-
**CLI actions intentionally excluded from MCP (
|
|
79
|
+
**CLI actions intentionally excluded from MCP (35).**
|
|
80
80
|
|
|
81
81
|
| CLI action | Action | Summary | Effects | Safety | Path policy | Output | Why no MCP tool |
|
|
82
82
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
83
83
|
| `xeer agent setup` | `agent.setup` | Install or verify project-confined agent adapters. | `read-source`<br>`write-source` | writes; idempotent; reversible; non-destructive | `project-relative` | `xeer.command.v0` | Not exposed through MCP v0; use the CLI deliberately. |
|
|
84
84
|
| `xeer deploy` | `deploy` | Build and deploy an application artifact. | `read-source`<br>`write-generated`<br>`run-local`<br>`network-read`<br>`network-write`<br>`production-change` | writes; non-idempotent; reversible; non-destructive | `project-relative` | `xeer.command.v0` | MCP exposes a separate preview-only deploy action; direct production deploy stays CLI-only. |
|
|
85
85
|
| `xeer promote` | `promote` | Promote a preview artifact to production. | `network-read`<br>`network-write`<br>`production-change` | writes; non-idempotent; reversible; non-destructive | `project-or-url` | `xeer.command.v0` | MCP promotion is a separate action available only when the server starts in operator profile. |
|
|
86
|
+
| `xeer tunnel` | `tunnel.start` | Expose a loopback HTTP server through an authenticated tunnel. | `run-local`<br>`network-read`<br>`network-write` | writes; non-idempotent; reversible; non-destructive | `none` | `xeer.tunnel.cli.v0` | Not exposed through MCP v0; use the CLI deliberately. |
|
|
86
87
|
| `xeer link` | `link` | Link a checkout to an application the builder owns. | `read-source`<br>`write-source`<br>`network-read`<br>`network-write` | writes; non-idempotent; reversible; non-destructive | `project-relative` | `xeer.command.v0` | Human identity and project ownership decisions are not delegated through MCP. |
|
|
87
88
|
| `xeer deployments` | `deployments` | List deployment history for an application. | `network-read`<br>`read-state`<br>`secret-metadata` | read-only; idempotent; reversible; non-destructive | `project-or-url` | `xeer.command.v0` | Not exposed through MCP v0; use the CLI deliberately. |
|
|
88
89
|
| `xeer rollback` | `rollback` | Redeploy a previously deployed artifact. | `network-read`<br>`network-write`<br>`production-change` | writes; non-idempotent; reversible; non-destructive | `project-or-url` | `xeer.command.v0` | Not exposed through MCP v0; use the CLI deliberately. |
|
package/dist/dev-session.d.ts
CHANGED
|
@@ -34,6 +34,11 @@ export interface DevSessionSummary {
|
|
|
34
34
|
generation?: number;
|
|
35
35
|
artifactId?: string;
|
|
36
36
|
preview?: PreviewUrls;
|
|
37
|
+
tunnel?: {
|
|
38
|
+
url: string;
|
|
39
|
+
tunnelId: string;
|
|
40
|
+
leaseExpiresAt: string;
|
|
41
|
+
};
|
|
37
42
|
/** Diagnostics from the most recent compile attempt. Empty once a rebuild is accepted. */
|
|
38
43
|
openDiagnostics: Diagnostic[];
|
|
39
44
|
exit?: {
|
|
@@ -51,6 +56,7 @@ export interface DevSessionOptions {
|
|
|
51
56
|
directory: string;
|
|
52
57
|
host?: string;
|
|
53
58
|
port?: number;
|
|
59
|
+
share?: boolean;
|
|
54
60
|
}
|
|
55
61
|
export declare class DevSession {
|
|
56
62
|
#private;
|
package/dist/dev-session.js
CHANGED
|
@@ -21,6 +21,8 @@ export class DevSession {
|
|
|
21
21
|
#cursor = 0;
|
|
22
22
|
#status = 'starting';
|
|
23
23
|
#preview;
|
|
24
|
+
#tunnel;
|
|
25
|
+
#expectsTunnel;
|
|
24
26
|
#generation;
|
|
25
27
|
#artifactId;
|
|
26
28
|
#openDiagnostics = [];
|
|
@@ -35,10 +37,12 @@ export class DevSession {
|
|
|
35
37
|
constructor(sessionId, options) {
|
|
36
38
|
this.sessionId = sessionId;
|
|
37
39
|
this.directory = options.directory;
|
|
40
|
+
this.#expectsTunnel = options.share === true;
|
|
38
41
|
// Port 0 is the documented automation mode: the OS picks a free port and the
|
|
39
42
|
// preview.ready event reports it, so parallel sessions cannot collide.
|
|
40
43
|
const args = ['dev', options.directory, '--json',
|
|
41
|
-
'--host', options.host ?? '127.0.0.1', '--port', String(options.port ?? 0)
|
|
44
|
+
'--host', options.host ?? '127.0.0.1', '--port', String(options.port ?? 0),
|
|
45
|
+
...(options.share ? ['--share'] : [])];
|
|
42
46
|
this.#child = fork(resolveXeerCli(), args, {
|
|
43
47
|
cwd: options.directory,
|
|
44
48
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
@@ -140,9 +144,22 @@ export class DevSession {
|
|
|
140
144
|
break;
|
|
141
145
|
case 'preview.ready':
|
|
142
146
|
this.#preview = event.data;
|
|
143
|
-
this.#
|
|
147
|
+
if (!this.#expectsTunnel)
|
|
148
|
+
this.#status = 'ready';
|
|
144
149
|
this.#compiling = false;
|
|
145
150
|
break;
|
|
151
|
+
case 'tunnel.ready': {
|
|
152
|
+
const data = event.data;
|
|
153
|
+
if (typeof data.url === 'string' && typeof data.tunnelId === 'string'
|
|
154
|
+
&& typeof data.leaseExpiresAt === 'string') {
|
|
155
|
+
this.#tunnel = { url: data.url, tunnelId: data.tunnelId, leaseExpiresAt: data.leaseExpiresAt };
|
|
156
|
+
this.#status = 'ready';
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'tunnel.closed':
|
|
161
|
+
this.#tunnel = undefined;
|
|
162
|
+
break;
|
|
146
163
|
case 'process.exit': {
|
|
147
164
|
const data = event.data;
|
|
148
165
|
this.#exit = { ...(typeof data.code === 'number' ? { code: data.code } : {}), ...(data.reason ? { reason: data.reason } : {}) };
|
|
@@ -219,6 +236,7 @@ export class DevSession {
|
|
|
219
236
|
...(this.#generation === undefined ? {} : { generation: this.#generation }),
|
|
220
237
|
...(this.#artifactId === undefined ? {} : { artifactId: this.#artifactId }),
|
|
221
238
|
...(this.#preview ? { preview: this.#preview } : {}),
|
|
239
|
+
...(this.#tunnel ? { tunnel: this.#tunnel } : {}),
|
|
222
240
|
openDiagnostics: [...this.#openDiagnostics],
|
|
223
241
|
...(this.#exit ? { exit: this.#exit } : {}),
|
|
224
242
|
droppedEvents: this.#dropped,
|
package/dist/server.js
CHANGED
|
@@ -433,6 +433,11 @@ export function createXeerMcpServer(options = {}) {
|
|
|
433
433
|
debugUrl: z.string(),
|
|
434
434
|
logsUrl: z.string(),
|
|
435
435
|
}).loose().optional(),
|
|
436
|
+
tunnel: z.object({
|
|
437
|
+
url: z.string(),
|
|
438
|
+
tunnelId: z.string(),
|
|
439
|
+
leaseExpiresAt: z.string(),
|
|
440
|
+
}).loose().optional(),
|
|
436
441
|
openDiagnostics: z.array(diagnosticSchema),
|
|
437
442
|
exit: z.object({ code: z.number().optional(), reason: z.string().optional() }).loose().optional(),
|
|
438
443
|
droppedEvents: z.number(),
|
|
@@ -454,13 +459,14 @@ export function createXeerMcpServer(options = {}) {
|
|
|
454
459
|
server.registerTool('xeer_dev_start', {
|
|
455
460
|
...toolPolicy('dev.start'),
|
|
456
461
|
outputSchema: wrappedOutput(z.object(sessionOutput).loose()),
|
|
457
|
-
}, async ({ directory, host, port, timeoutMilliseconds }) => withDirectory(directory, 'dev.start', async (resolved) => {
|
|
462
|
+
}, async ({ directory, host, port, share, timeoutMilliseconds }) => withDirectory(directory, 'dev.start', async (resolved) => {
|
|
458
463
|
let session;
|
|
459
464
|
try {
|
|
460
465
|
session = devSessions.start({
|
|
461
466
|
directory: resolved,
|
|
462
467
|
...(host ? { host } : {}),
|
|
463
468
|
...(port === undefined ? {} : { port }),
|
|
469
|
+
...(share === undefined ? {} : { share }),
|
|
464
470
|
});
|
|
465
471
|
}
|
|
466
472
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@impetik/xeer-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Model Context Protocol server for Xeer: the scaffold, check, dev, build, and deploy loop as agent tools.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
49
49
|
"zod": "^4.0.10",
|
|
50
|
-
"@impetik/xeer": "0.2.
|
|
50
|
+
"@impetik/xeer": "0.2.13"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^24.1.0"
|
package/vendor/spec/actions.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface XeerActionDefinition {
|
|
|
49
49
|
readonly summary: string;
|
|
50
50
|
/** Help syntax without the `xeer ` prefix. May contain `{{templates}}`. */
|
|
51
51
|
readonly usage: readonly string[];
|
|
52
|
+
/** Optional abbreviated syntax for the global command list. Full usage remains authoritative. */
|
|
53
|
+
readonly globalUsage?: readonly string[];
|
|
52
54
|
readonly hiddenFromHelp?: boolean;
|
|
53
55
|
readonly helpOrder?: number;
|
|
54
56
|
readonly outputProtocol: string;
|
|
@@ -503,10 +505,10 @@ declare const actions: readonly [{
|
|
|
503
505
|
readonly id: "dev.start";
|
|
504
506
|
readonly command: readonly ["dev"];
|
|
505
507
|
readonly summary: "Start a local development session.";
|
|
506
|
-
readonly usage: readonly [
|
|
508
|
+
readonly usage: readonly [string];
|
|
507
509
|
readonly helpOrder: 220;
|
|
508
510
|
readonly outputProtocol: "xeer.dev.v0";
|
|
509
|
-
readonly effects: readonly ["read-source", "write-generated", "run-local", "write-state"];
|
|
511
|
+
readonly effects: readonly ["read-source", "write-generated", "run-local", "write-state", "network-read", "network-write"];
|
|
510
512
|
readonly idempotent: false;
|
|
511
513
|
readonly reversible: true;
|
|
512
514
|
readonly destructive: false;
|
|
@@ -530,6 +532,10 @@ declare const actions: readonly [{
|
|
|
530
532
|
readonly minimum: 0;
|
|
531
533
|
readonly maximum: 65535;
|
|
532
534
|
};
|
|
535
|
+
readonly share: {
|
|
536
|
+
readonly description: "Publish the session through an authenticated Xeer tunnel.";
|
|
537
|
+
readonly type: "boolean";
|
|
538
|
+
};
|
|
533
539
|
readonly timeoutMilliseconds: {
|
|
534
540
|
readonly type: "integer";
|
|
535
541
|
readonly minimum: 1000;
|
|
@@ -543,6 +549,24 @@ declare const actions: readonly [{
|
|
|
543
549
|
readonly mcpTool: "xeer_dev_start";
|
|
544
550
|
readonly mcpProfiles: readonly ["author", "operator"];
|
|
545
551
|
};
|
|
552
|
+
}, {
|
|
553
|
+
readonly id: "tunnel.start";
|
|
554
|
+
readonly command: readonly ["tunnel"];
|
|
555
|
+
readonly summary: "Expose a loopback HTTP server through an authenticated tunnel.";
|
|
556
|
+
readonly usage: readonly [string];
|
|
557
|
+
readonly globalUsage: readonly ["tunnel <port|loopback-url> [options]"];
|
|
558
|
+
readonly helpOrder: 225;
|
|
559
|
+
readonly outputProtocol: "xeer.tunnel.cli.v0";
|
|
560
|
+
readonly effects: readonly ["run-local", "network-read", "network-write"];
|
|
561
|
+
readonly idempotent: false;
|
|
562
|
+
readonly reversible: true;
|
|
563
|
+
readonly destructive: false;
|
|
564
|
+
readonly humanPrerequisites: readonly ["A human must sign in and deliberately keep the public tunnel process running."];
|
|
565
|
+
readonly pathPolicy: "none";
|
|
566
|
+
readonly surfaces: {
|
|
567
|
+
readonly cli: true;
|
|
568
|
+
readonly mcpExclusion: "Not exposed through MCP v0; use the CLI deliberately.";
|
|
569
|
+
};
|
|
546
570
|
}, {
|
|
547
571
|
readonly id: "dev.status";
|
|
548
572
|
readonly command: readonly ["dev"];
|
package/vendor/spec/actions.js
CHANGED
|
@@ -337,8 +337,9 @@ const actions = [
|
|
|
337
337
|
},
|
|
338
338
|
{
|
|
339
339
|
id: 'dev.start', command: ['dev'], summary: 'Start a local development session.',
|
|
340
|
-
usage: ['dev [directory] [--host <host>] [--port <port>] [--
|
|
341
|
-
|
|
340
|
+
usage: ['dev [directory] [--host <host>] [--port <port>] [--share] [--control-url <url>] '
|
|
341
|
+
+ '[--tunnel-bootstrap <file>] [--json]'], helpOrder: 220,
|
|
342
|
+
outputProtocol: 'xeer.dev.v0', effects: ['read-source', 'write-generated', 'run-local', 'write-state', 'network-read', 'network-write'],
|
|
342
343
|
idempotent: false, reversible: true, destructive: false, humanPrerequisites: [],
|
|
343
344
|
pathPolicy: 'project-relative', surfaces: {
|
|
344
345
|
cli: true, mcpTool: 'xeer_dev_start', mcpProfiles: XEER_MCP_PROFILES,
|
|
@@ -347,10 +348,24 @@ const actions = [
|
|
|
347
348
|
directory: PROJECT_DIRECTORY_INPUT,
|
|
348
349
|
host: { description: 'Defaults to 127.0.0.1.', type: 'string' },
|
|
349
350
|
port: { description: 'Defaults to 0 (OS-assigned).', type: 'integer', minimum: 0, maximum: 65_535 },
|
|
351
|
+
share: { description: 'Publish the session through an authenticated Xeer tunnel.', type: 'boolean' },
|
|
350
352
|
timeoutMilliseconds: { type: 'integer', minimum: 1_000, maximum: 600_000 },
|
|
351
353
|
} },
|
|
352
354
|
},
|
|
353
355
|
},
|
|
356
|
+
{
|
|
357
|
+
id: 'tunnel.start', command: ['tunnel'], summary: 'Expose a loopback HTTP server through an authenticated tunnel.',
|
|
358
|
+
usage: ['tunnel <port|loopback-url> [--label <name>] [--host-header <preserve|rewrite|host>] '
|
|
359
|
+
+ '[--request-header-add <name:value>] [--request-header-remove <name>] '
|
|
360
|
+
+ '[--response-header-add <name:value>] [--response-header-remove <name>] '
|
|
361
|
+
+ '[--upstream-tls-insecure] [--lifetime <seconds>] [--control-url <url>] [--json]'],
|
|
362
|
+
globalUsage: ['tunnel <port|loopback-url> [options]'],
|
|
363
|
+
helpOrder: 225, outputProtocol: 'xeer.tunnel.cli.v0',
|
|
364
|
+
effects: ['run-local', 'network-read', 'network-write'], idempotent: false,
|
|
365
|
+
reversible: true, destructive: false,
|
|
366
|
+
humanPrerequisites: ['A human must sign in and deliberately keep the public tunnel process running.'],
|
|
367
|
+
pathPolicy: 'none', surfaces: { cli: true, mcpExclusion: NOT_EXPOSED_V0 },
|
|
368
|
+
},
|
|
354
369
|
{
|
|
355
370
|
id: 'dev.status', command: ['dev'], summary: 'Read new events from a local development session.',
|
|
356
371
|
usage: [], outputProtocol: 'xeer.dev.v0', effects: [], idempotent: true, reversible: true,
|
package/vendor/spec/index.d.ts
CHANGED
package/vendor/spec/index.js
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export declare const TUNNEL_PROTOCOL: "xeer.tunnel.v0";
|
|
2
|
+
export declare const TUNNEL_GRANT_AUDIENCE: "xeer-tunnel";
|
|
3
|
+
export declare const TUNNEL_GRANT_TYP: "xeer-tunnel-grant+jws";
|
|
4
|
+
export declare const TUNNEL_FALLBACK_AUDIENCE: "xeer-tunnel-fallback";
|
|
5
|
+
export declare const TUNNEL_FALLBACK_TYP: "xeer-tunnel-fallback+jws";
|
|
6
|
+
export declare const TUNNEL_TICKET_HEADER: "x-xeer-tunnel-ticket";
|
|
7
|
+
export declare const TUNNEL_CONTROL_HEADER: "x-xeer-tunnel-control";
|
|
8
|
+
export declare const TUNNEL_SHARED_HEADER: "x-xeer-shared-dev";
|
|
9
|
+
export declare const TUNNEL_SHARED_HOST_HEADER: "x-xeer-shared-host";
|
|
10
|
+
export declare const TUNNEL_PROFILES: readonly ["shared-dev", "raw-http"];
|
|
11
|
+
export type TunnelProfile = (typeof TUNNEL_PROFILES)[number];
|
|
12
|
+
export declare const TUNNEL_BINARY_HEADER_BYTES = 12;
|
|
13
|
+
export declare const TUNNEL_MAX_FRAME_PAYLOAD = 65536;
|
|
14
|
+
export type TunnelBinaryOpcode = 0x01 | 0x02 | 0x03;
|
|
15
|
+
export interface TunnelBinaryFrame {
|
|
16
|
+
readonly opcode: TunnelBinaryOpcode;
|
|
17
|
+
readonly final: boolean;
|
|
18
|
+
readonly streamId: number;
|
|
19
|
+
/** Starts at zero and advances independently per sender and stream. */
|
|
20
|
+
readonly seq: number;
|
|
21
|
+
readonly payload: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
export interface TunnelLimits {
|
|
24
|
+
readonly maxBandwidthBytes: number;
|
|
25
|
+
readonly maxLifetimeSeconds: number;
|
|
26
|
+
readonly maxConcurrentStreams: number;
|
|
27
|
+
readonly connectionWindowBytes: number;
|
|
28
|
+
readonly streamWindowBytes: number;
|
|
29
|
+
readonly streamIdleSeconds: number;
|
|
30
|
+
}
|
|
31
|
+
export interface TunnelAgentBootstrap {
|
|
32
|
+
readonly protocol: typeof TUNNEL_PROTOCOL;
|
|
33
|
+
readonly profile: TunnelProfile;
|
|
34
|
+
readonly tunnelId: string;
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly resumeSecret: string;
|
|
37
|
+
readonly connect: {
|
|
38
|
+
readonly url: string;
|
|
39
|
+
readonly ticket: string;
|
|
40
|
+
readonly ticketExpiresAt: string;
|
|
41
|
+
};
|
|
42
|
+
readonly leaseExpiresAt: string;
|
|
43
|
+
readonly issuerEpoch: number;
|
|
44
|
+
readonly limits: TunnelLimits;
|
|
45
|
+
}
|
|
46
|
+
export interface TunnelAllocation extends TunnelAgentBootstrap {
|
|
47
|
+
readonly slug: string;
|
|
48
|
+
readonly controlSecret: string;
|
|
49
|
+
}
|
|
50
|
+
export type TunnelControlFrame = {
|
|
51
|
+
readonly t: 'hello';
|
|
52
|
+
readonly protocol: typeof TUNNEL_PROTOCOL;
|
|
53
|
+
readonly tunnelId: string;
|
|
54
|
+
readonly takeover: boolean;
|
|
55
|
+
} | {
|
|
56
|
+
readonly t: 'ready';
|
|
57
|
+
readonly tunnelId: string;
|
|
58
|
+
readonly leaseExpiresAt: string;
|
|
59
|
+
readonly limits: TunnelLimits;
|
|
60
|
+
readonly credit: {
|
|
61
|
+
readonly connectionBytes: number;
|
|
62
|
+
readonly streamBytes: number;
|
|
63
|
+
};
|
|
64
|
+
} | {
|
|
65
|
+
readonly t: 'open';
|
|
66
|
+
readonly streamId: number;
|
|
67
|
+
readonly method: string;
|
|
68
|
+
readonly path: string;
|
|
69
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
70
|
+
} | {
|
|
71
|
+
readonly t: 'upgrade';
|
|
72
|
+
readonly streamId: number;
|
|
73
|
+
readonly path: string;
|
|
74
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
75
|
+
readonly protocols: readonly string[];
|
|
76
|
+
} | {
|
|
77
|
+
readonly t: 'head';
|
|
78
|
+
readonly streamId: number;
|
|
79
|
+
readonly status: number;
|
|
80
|
+
readonly headers: readonly (readonly [string, string])[];
|
|
81
|
+
} | {
|
|
82
|
+
readonly t: 'upgrade.result';
|
|
83
|
+
readonly streamId: number;
|
|
84
|
+
readonly accepted: boolean;
|
|
85
|
+
readonly protocol?: string;
|
|
86
|
+
readonly headers?: readonly (readonly [string, string])[];
|
|
87
|
+
} | {
|
|
88
|
+
readonly t: 'end';
|
|
89
|
+
readonly streamId: number;
|
|
90
|
+
} | {
|
|
91
|
+
readonly t: 'ws.close';
|
|
92
|
+
readonly streamId: number;
|
|
93
|
+
readonly code: number;
|
|
94
|
+
readonly reason: string;
|
|
95
|
+
} | {
|
|
96
|
+
readonly t: 'window.update';
|
|
97
|
+
readonly streamId: number;
|
|
98
|
+
readonly bytes: number;
|
|
99
|
+
} | {
|
|
100
|
+
readonly t: 'reset';
|
|
101
|
+
readonly streamId: number;
|
|
102
|
+
readonly reason: TunnelResetReason;
|
|
103
|
+
};
|
|
104
|
+
export type TunnelResetReason = 'visitor_disconnect' | 'agent_error' | 'agent_refused' | 'revoked' | 'lease_expired' | 'slow_consumer' | 'flow_control_violation' | 'protocol_error' | 'replaced';
|
|
105
|
+
export declare function encodeTunnelBinaryFrame(frame: TunnelBinaryFrame): Uint8Array;
|
|
106
|
+
export declare function decodeTunnelBinaryFrame(input: ArrayBuffer | ArrayBufferView): TunnelBinaryFrame;
|
|
107
|
+
export declare function parseTunnelControlFrame(input: string): TunnelControlFrame;
|
|
108
|
+
export declare function sanitizeTunnelRequestHeaders(headers: Headers, profile?: TunnelProfile): Array<[string, string]>;
|
|
109
|
+
export declare function sanitizeTunnelResponseHeaders(headers: readonly (readonly [string, string])[], profile?: TunnelProfile): Headers;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
export const TUNNEL_PROTOCOL = 'xeer.tunnel.v0';
|
|
2
|
+
export const TUNNEL_GRANT_AUDIENCE = 'xeer-tunnel';
|
|
3
|
+
export const TUNNEL_GRANT_TYP = 'xeer-tunnel-grant+jws';
|
|
4
|
+
export const TUNNEL_FALLBACK_AUDIENCE = 'xeer-tunnel-fallback';
|
|
5
|
+
export const TUNNEL_FALLBACK_TYP = 'xeer-tunnel-fallback+jws';
|
|
6
|
+
export const TUNNEL_TICKET_HEADER = 'x-xeer-tunnel-ticket';
|
|
7
|
+
export const TUNNEL_CONTROL_HEADER = 'x-xeer-tunnel-control';
|
|
8
|
+
export const TUNNEL_SHARED_HEADER = 'x-xeer-shared-dev';
|
|
9
|
+
export const TUNNEL_SHARED_HOST_HEADER = 'x-xeer-shared-host';
|
|
10
|
+
export const TUNNEL_PROFILES = ['shared-dev', 'raw-http'];
|
|
11
|
+
export const TUNNEL_BINARY_HEADER_BYTES = 12;
|
|
12
|
+
export const TUNNEL_MAX_FRAME_PAYLOAD = 65_536;
|
|
13
|
+
function uint32(value, field) {
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
|
|
15
|
+
throw new RangeError(`${field} must be a uint32.`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function encodeTunnelBinaryFrame(frame) {
|
|
19
|
+
uint32(frame.streamId, 'streamId');
|
|
20
|
+
if (frame.streamId === 0)
|
|
21
|
+
throw new RangeError('streamId zero is reserved for connection control.');
|
|
22
|
+
uint32(frame.seq, 'seq');
|
|
23
|
+
if (![0x01, 0x02, 0x03].includes(frame.opcode))
|
|
24
|
+
throw new RangeError('Unknown tunnel binary opcode.');
|
|
25
|
+
if (frame.payload.byteLength > TUNNEL_MAX_FRAME_PAYLOAD) {
|
|
26
|
+
throw new RangeError(`Tunnel payload exceeds ${TUNNEL_MAX_FRAME_PAYLOAD} bytes.`);
|
|
27
|
+
}
|
|
28
|
+
const encoded = new Uint8Array(TUNNEL_BINARY_HEADER_BYTES + frame.payload.byteLength);
|
|
29
|
+
const view = new DataView(encoded.buffer);
|
|
30
|
+
view.setUint8(0, 0);
|
|
31
|
+
view.setUint8(1, frame.opcode);
|
|
32
|
+
view.setUint8(2, frame.final ? 1 : 0);
|
|
33
|
+
view.setUint8(3, 0);
|
|
34
|
+
view.setUint32(4, frame.streamId);
|
|
35
|
+
view.setUint32(8, frame.seq);
|
|
36
|
+
encoded.set(frame.payload, TUNNEL_BINARY_HEADER_BYTES);
|
|
37
|
+
return encoded;
|
|
38
|
+
}
|
|
39
|
+
export function decodeTunnelBinaryFrame(input) {
|
|
40
|
+
const bytes = input instanceof ArrayBuffer
|
|
41
|
+
? new Uint8Array(input)
|
|
42
|
+
: new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
43
|
+
if (bytes.byteLength < TUNNEL_BINARY_HEADER_BYTES)
|
|
44
|
+
throw new RangeError('Tunnel binary frame is truncated.');
|
|
45
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
46
|
+
if (view.getUint8(0) !== 0 || view.getUint8(3) !== 0 || (view.getUint8(2) & 0xfe) !== 0) {
|
|
47
|
+
throw new RangeError('Tunnel binary frame header is invalid.');
|
|
48
|
+
}
|
|
49
|
+
const opcode = view.getUint8(1);
|
|
50
|
+
if (opcode !== 0x01 && opcode !== 0x02 && opcode !== 0x03) {
|
|
51
|
+
throw new RangeError('Unknown tunnel binary opcode.');
|
|
52
|
+
}
|
|
53
|
+
const streamId = view.getUint32(4);
|
|
54
|
+
if (streamId === 0)
|
|
55
|
+
throw new RangeError('streamId zero is reserved for connection control.');
|
|
56
|
+
const payload = bytes.slice(TUNNEL_BINARY_HEADER_BYTES);
|
|
57
|
+
if (payload.byteLength > TUNNEL_MAX_FRAME_PAYLOAD)
|
|
58
|
+
throw new RangeError('Tunnel binary payload is too large.');
|
|
59
|
+
return { opcode, final: view.getUint8(2) === 1, streamId, seq: view.getUint32(8), payload };
|
|
60
|
+
}
|
|
61
|
+
export function parseTunnelControlFrame(input) {
|
|
62
|
+
let value;
|
|
63
|
+
try {
|
|
64
|
+
value = JSON.parse(input);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new TypeError('Tunnel control frame is not JSON.');
|
|
68
|
+
}
|
|
69
|
+
if (!value || typeof value !== 'object' || typeof value.t !== 'string') {
|
|
70
|
+
throw new TypeError('Tunnel control frame has no type.');
|
|
71
|
+
}
|
|
72
|
+
const frame = value;
|
|
73
|
+
const streamId = (allowZero = false) => {
|
|
74
|
+
if (!Number.isSafeInteger(frame.streamId) || frame.streamId < (allowZero ? 0 : 1)
|
|
75
|
+
|| frame.streamId > 0xffff_ffff)
|
|
76
|
+
invalidControlFrame();
|
|
77
|
+
};
|
|
78
|
+
const text = (field, maximum = 8_192, allowEmpty = false) => {
|
|
79
|
+
const candidate = frame[field];
|
|
80
|
+
if (typeof candidate !== 'string' || (!allowEmpty && candidate.length === 0)
|
|
81
|
+
|| candidate.length > maximum)
|
|
82
|
+
invalidControlFrame();
|
|
83
|
+
};
|
|
84
|
+
const headers = (field = 'headers', optional = false) => {
|
|
85
|
+
const candidate = frame[field];
|
|
86
|
+
if (optional && candidate === undefined)
|
|
87
|
+
return;
|
|
88
|
+
if (!Array.isArray(candidate) || candidate.some((header) => !Array.isArray(header)
|
|
89
|
+
|| header.length !== 2 || typeof header[0] !== 'string' || typeof header[1] !== 'string'
|
|
90
|
+
|| !HEADER_NAME.test(header[0]) || /[\0\r\n]/u.test(header[1]))) {
|
|
91
|
+
invalidControlFrame();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
switch (frame.t) {
|
|
95
|
+
case 'hello':
|
|
96
|
+
text('tunnelId', 256);
|
|
97
|
+
if (frame.protocol !== TUNNEL_PROTOCOL || typeof frame.takeover !== 'boolean')
|
|
98
|
+
invalidControlFrame();
|
|
99
|
+
break;
|
|
100
|
+
case 'ready': {
|
|
101
|
+
text('tunnelId', 256);
|
|
102
|
+
text('leaseExpiresAt', 64);
|
|
103
|
+
if (Number.isNaN(Date.parse(frame.leaseExpiresAt)) || !validLimits(frame.limits)
|
|
104
|
+
|| !frame.credit || typeof frame.credit !== 'object')
|
|
105
|
+
invalidControlFrame();
|
|
106
|
+
const credit = frame.credit;
|
|
107
|
+
if (!positiveInteger(credit.connectionBytes) || !positiveInteger(credit.streamBytes))
|
|
108
|
+
invalidControlFrame();
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case 'open':
|
|
112
|
+
streamId();
|
|
113
|
+
text('method', 32);
|
|
114
|
+
text('path');
|
|
115
|
+
headers();
|
|
116
|
+
if (!frame.path.startsWith('/'))
|
|
117
|
+
invalidControlFrame();
|
|
118
|
+
break;
|
|
119
|
+
case 'upgrade':
|
|
120
|
+
streamId();
|
|
121
|
+
text('path');
|
|
122
|
+
headers();
|
|
123
|
+
if (!frame.path.startsWith('/') || !Array.isArray(frame.protocols)
|
|
124
|
+
|| frame.protocols.some((protocol) => typeof protocol !== 'string' || protocol.length > 256)) {
|
|
125
|
+
invalidControlFrame();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
case 'head':
|
|
129
|
+
streamId();
|
|
130
|
+
headers();
|
|
131
|
+
if (!Number.isSafeInteger(frame.status) || frame.status < 100
|
|
132
|
+
|| frame.status > 599)
|
|
133
|
+
invalidControlFrame();
|
|
134
|
+
break;
|
|
135
|
+
case 'upgrade.result':
|
|
136
|
+
streamId();
|
|
137
|
+
headers('headers', true);
|
|
138
|
+
if (typeof frame.accepted !== 'boolean'
|
|
139
|
+
|| (frame.protocol !== undefined && (typeof frame.protocol !== 'string' || frame.protocol.length > 256))) {
|
|
140
|
+
invalidControlFrame();
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case 'end':
|
|
144
|
+
streamId();
|
|
145
|
+
break;
|
|
146
|
+
case 'ws.close':
|
|
147
|
+
streamId();
|
|
148
|
+
text('reason', 123, true);
|
|
149
|
+
if (!validWebSocketCloseCode(frame.code)
|
|
150
|
+
|| new TextEncoder().encode(frame.reason).byteLength > 123)
|
|
151
|
+
invalidControlFrame();
|
|
152
|
+
break;
|
|
153
|
+
case 'window.update':
|
|
154
|
+
streamId(true);
|
|
155
|
+
if (!positiveInteger(frame.bytes))
|
|
156
|
+
invalidControlFrame();
|
|
157
|
+
break;
|
|
158
|
+
case 'reset':
|
|
159
|
+
streamId();
|
|
160
|
+
if (!RESET_REASONS.has(frame.reason))
|
|
161
|
+
invalidControlFrame();
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
invalidControlFrame();
|
|
165
|
+
}
|
|
166
|
+
return frame;
|
|
167
|
+
}
|
|
168
|
+
const RESET_REASONS = new Set([
|
|
169
|
+
'visitor_disconnect', 'agent_error', 'agent_refused', 'revoked', 'lease_expired',
|
|
170
|
+
'slow_consumer', 'flow_control_violation', 'protocol_error', 'replaced',
|
|
171
|
+
]);
|
|
172
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
|
|
173
|
+
function invalidControlFrame() {
|
|
174
|
+
throw new TypeError('Tunnel control frame is invalid.');
|
|
175
|
+
}
|
|
176
|
+
function positiveInteger(value) {
|
|
177
|
+
return Number.isSafeInteger(value) && value > 0 && value <= 0xffff_ffff;
|
|
178
|
+
}
|
|
179
|
+
function positiveSafeInteger(value) {
|
|
180
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
181
|
+
}
|
|
182
|
+
function validLimits(value) {
|
|
183
|
+
if (!value || typeof value !== 'object')
|
|
184
|
+
return false;
|
|
185
|
+
const limits = value;
|
|
186
|
+
return ['maxBandwidthBytes', 'maxLifetimeSeconds', 'maxConcurrentStreams', 'connectionWindowBytes',
|
|
187
|
+
'streamWindowBytes', 'streamIdleSeconds'].every((field) => positiveSafeInteger(limits[field]));
|
|
188
|
+
}
|
|
189
|
+
function validWebSocketCloseCode(value) {
|
|
190
|
+
return Number.isSafeInteger(value)
|
|
191
|
+
&& ((value >= 1000 && value <= 1014) || value >= 3000)
|
|
192
|
+
&& value <= 4999
|
|
193
|
+
&& ![1004, 1005, 1006, 1015].includes(value);
|
|
194
|
+
}
|
|
195
|
+
const HOP_BY_HOP = new Set([
|
|
196
|
+
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
|
|
197
|
+
'transfer-encoding', 'upgrade',
|
|
198
|
+
]);
|
|
199
|
+
export function sanitizeTunnelRequestHeaders(headers, profile = 'shared-dev') {
|
|
200
|
+
const connectionNamed = new Set((headers.get('connection') ?? '').split(',').map((name) => name.trim().toLowerCase()).filter(Boolean));
|
|
201
|
+
const result = [];
|
|
202
|
+
for (const [rawName, value] of headers) {
|
|
203
|
+
const name = rawName.toLowerCase();
|
|
204
|
+
if ((profile === 'shared-dev' && (name === 'authorization' || name === 'cookie'))
|
|
205
|
+
|| name === 'forwarded'
|
|
206
|
+
|| name === 'x-real-ip' || name.startsWith('x-forwarded-')
|
|
207
|
+
|| name.startsWith('cf-')
|
|
208
|
+
|| name.startsWith('x-xeer-') || name.startsWith('xeer-')
|
|
209
|
+
|| name.startsWith('proxy-') || HOP_BY_HOP.has(name) || connectionNamed.has(name))
|
|
210
|
+
continue;
|
|
211
|
+
result.push([name, value]);
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
export function sanitizeTunnelResponseHeaders(headers, profile = 'shared-dev') {
|
|
216
|
+
const connectionNamed = new Set(headers.filter(([name]) => name.toLowerCase() === 'connection')
|
|
217
|
+
.flatMap(([, value]) => value.split(',')).map((name) => name.trim().toLowerCase()).filter(Boolean));
|
|
218
|
+
const result = new Headers();
|
|
219
|
+
for (const [rawName, value] of headers) {
|
|
220
|
+
const name = rawName.toLowerCase();
|
|
221
|
+
if ((profile === 'shared-dev' && name === 'set-cookie') || name.startsWith('proxy-')
|
|
222
|
+
|| HOP_BY_HOP.has(name) || connectionNamed.has(name))
|
|
223
|
+
continue;
|
|
224
|
+
result.append(name, value);
|
|
225
|
+
}
|
|
226
|
+
result.set('x-robots-tag', 'noindex');
|
|
227
|
+
return result;
|
|
228
|
+
}
|