@impetik/xeer-mcp 0.2.10 → 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 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 (34).**
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. |
@@ -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;
@@ -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.#status = 'ready';
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.10",
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.10"
50
+ "@impetik/xeer": "0.2.13"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^24.1.0"
@@ -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 ["dev [directory] [--host <host>] [--port <port>] [--json]"];
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"];
@@ -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>] [--json]'], helpOrder: 220,
341
- outputProtocol: 'xeer.dev.v0', effects: ['read-source', 'write-generated', 'run-local', 'write-state'],
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,
@@ -18,3 +18,5 @@ export * from './admin.js';
18
18
  export * from './admin-sql.js';
19
19
  export * from './network-policy.js';
20
20
  export * from './review.js';
21
+ export * from './template.js';
22
+ export * from './tunnel.js';
@@ -18,3 +18,5 @@ export * from './admin.js';
18
18
  export * from './admin-sql.js';
19
19
  export * from './network-policy.js';
20
20
  export * from './review.js';
21
+ export * from './template.js';
22
+ export * from './tunnel.js';
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ export declare const TEMPLATE_FORMAT: "xeer.template.v0";
3
+ export declare const TEMPLATE_MANIFEST_SCHEMA_URL: "https://docs.xeer.run/template-v0.schema.json";
4
+ export declare const templateManifestSchema: z.ZodObject<{
5
+ $schema: z.ZodOptional<z.ZodString>;
6
+ format: z.ZodLiteral<"xeer.template.v0">;
7
+ title: z.ZodString;
8
+ description: z.ZodString;
9
+ category: z.ZodString;
10
+ tags: z.ZodArray<z.ZodString>;
11
+ keyHighlights: z.ZodArray<z.ZodString>;
12
+ screenshots: z.ZodArray<z.ZodObject<{
13
+ path: z.ZodString;
14
+ alt: z.ZodString;
15
+ }, z.core.$strict>>;
16
+ author: z.ZodObject<{
17
+ name: z.ZodString;
18
+ url: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strict>;
20
+ capabilities: z.ZodArray<z.ZodEnum<{
21
+ database: "database";
22
+ storage: "storage";
23
+ }>>;
24
+ requiredSecrets: z.ZodArray<z.ZodObject<{
25
+ name: z.ZodString;
26
+ description: z.ZodString;
27
+ }, z.core.$strict>>;
28
+ previewAppId: z.ZodString;
29
+ }, z.core.$strict>;
30
+ export type TemplateManifestV0 = z.infer<typeof templateManifestSchema>;
31
+ export declare const templateManifestJsonSchema: Readonly<Record<string, unknown>>;
32
+ export declare function parseTemplateManifest(value: unknown): TemplateManifestV0;
@@ -0,0 +1,101 @@
1
+ import { z } from 'zod';
2
+ import { CAPABILITIES } from './types.js';
3
+ export const TEMPLATE_FORMAT = 'xeer.template.v0';
4
+ export const TEMPLATE_MANIFEST_SCHEMA_URL = 'https://docs.xeer.run/template-v0.schema.json';
5
+ const trimmed = (maximum) => z.string().min(1).max(maximum)
6
+ .refine((value) => value === value.trim(), 'must not have leading or trailing whitespace')
7
+ .refine((value) => !/[\u0000-\u001f\u007f]/u.test(value), 'must not contain control characters');
8
+ const slug = z.string().regex(/^[a-z][a-z0-9-]{1,62}$/u, 'must be a lowercase slug between 2 and 63 characters');
9
+ const environmentName = z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/u, 'must be an uppercase environment variable name');
10
+ const appId = z.string().regex(/^app_[A-Za-z0-9_-]{8,96}$/u, 'must be a valid Xeer appId');
11
+ const screenshotPath = z.string().regex(/^screenshots\/(?:[A-Za-z0-9][A-Za-z0-9._-]*\/)*[A-Za-z0-9][A-Za-z0-9._-]*\.(?:avif|jpe?g|png|webp)$/u, 'must be a repository-local AVIF, JPEG, PNG, or WebP path under screenshots/');
12
+ const authorSchema = z.strictObject({
13
+ name: trimmed(80),
14
+ url: z.string().url().optional(),
15
+ });
16
+ const requiredSecretSchema = z.strictObject({
17
+ name: environmentName,
18
+ description: trimmed(200),
19
+ });
20
+ const screenshotSchema = z.strictObject({
21
+ path: screenshotPath,
22
+ alt: trimmed(200),
23
+ });
24
+ export const templateManifestSchema = z.strictObject({
25
+ $schema: z.string().url().optional(),
26
+ format: z.literal(TEMPLATE_FORMAT),
27
+ title: trimmed(120),
28
+ description: trimmed(300),
29
+ category: slug,
30
+ tags: z.array(trimmed(40)).min(1).max(16),
31
+ keyHighlights: z.array(trimmed(120)).min(1).max(8),
32
+ screenshots: z.array(screenshotSchema).min(1).max(8),
33
+ author: authorSchema,
34
+ capabilities: z.array(z.enum(CAPABILITIES)).max(CAPABILITIES.length),
35
+ requiredSecrets: z.array(requiredSecretSchema).max(16),
36
+ previewAppId: appId,
37
+ }).superRefine((manifest, ctx) => {
38
+ const unique = (values, path, label) => {
39
+ const seen = new Set();
40
+ for (const [index, value] of values.entries()) {
41
+ const normalized = value.toLocaleLowerCase('en-US');
42
+ if (seen.has(normalized)) {
43
+ ctx.addIssue({ code: 'custom', path: [path, index], message: `${label} is declared twice` });
44
+ }
45
+ seen.add(normalized);
46
+ }
47
+ };
48
+ unique(manifest.tags, 'tags', 'tag');
49
+ unique(manifest.keyHighlights, 'keyHighlights', 'highlight');
50
+ unique(manifest.screenshots.map((screenshot) => screenshot.path), 'screenshots', 'screenshot');
51
+ unique(manifest.capabilities, 'capabilities', 'capability');
52
+ unique(manifest.requiredSecrets.map((secret) => secret.name), 'requiredSecrets', 'secret');
53
+ });
54
+ const generatedTemplateManifestJsonSchema = z.toJSONSchema(templateManifestSchema);
55
+ const generatedProperties = generatedTemplateManifestJsonSchema.properties;
56
+ export const templateManifestJsonSchema = Object.freeze({
57
+ ...generatedTemplateManifestJsonSchema,
58
+ $id: TEMPLATE_MANIFEST_SCHEMA_URL,
59
+ title: 'Xeer template manifest v0',
60
+ description: 'Authored metadata for one application in the Xeer template catalog.',
61
+ properties: {
62
+ ...generatedProperties,
63
+ $schema: {
64
+ ...generatedProperties.$schema,
65
+ description: 'Editor schema hint. Xeer does not fetch this URL during validation.',
66
+ },
67
+ format: {
68
+ ...generatedProperties.format,
69
+ description: `Template metadata protocol discriminator; exactly ${TEMPLATE_FORMAT}.`,
70
+ },
71
+ tags: {
72
+ ...generatedProperties.tags,
73
+ uniqueItems: true,
74
+ },
75
+ keyHighlights: {
76
+ ...generatedProperties.keyHighlights,
77
+ uniqueItems: true,
78
+ },
79
+ screenshots: {
80
+ ...generatedProperties.screenshots,
81
+ description: 'Repository-local raster images used by template galleries.',
82
+ uniqueItems: true,
83
+ },
84
+ capabilities: {
85
+ ...generatedProperties.capabilities,
86
+ description: 'Application capabilities, verified against xeer.app.json when the catalog is generated.',
87
+ uniqueItems: true,
88
+ },
89
+ previewAppId: {
90
+ ...generatedProperties.previewAppId,
91
+ description: 'Stable first-party application identity used only for the public live preview.',
92
+ },
93
+ requiredSecrets: {
94
+ ...generatedProperties.requiredSecrets,
95
+ description: 'Secrets a template copy needs before it is usable.',
96
+ },
97
+ },
98
+ });
99
+ export function parseTemplateManifest(value) {
100
+ return templateManifestSchema.parse(value);
101
+ }
@@ -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
+ }