@plingjs/surface-gateway 0.1.0-alpha.5 → 0.1.0-alpha.6

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.
File without changes
package/dist/README.md ADDED
@@ -0,0 +1,178 @@
1
+ # @plingjs/surface-gateway
2
+
3
+ Reference Node.js transport and control plane for a Pling Agent Surface. It
4
+ connects an open business page to a user-owned Agent runtime without moving page
5
+ execution or business permissions into the gateway.
6
+
7
+ ```text
8
+ Agent runtime (Codex / Claude / Hermes / WorkBuddy)
9
+ │ MCP over HTTPS (Bearer Agent session token)
10
+ ▼
11
+ surface.example.com
12
+ │ opaque describe / invoke envelopes over WebSocket
13
+ ▼
14
+ business page (the page the user already has open)
15
+ │
16
+ ▼
17
+ application backend + existing authorization
18
+ ```
19
+
20
+ The gateway is deliberately narrow:
21
+
22
+ - it authenticates a page session created by the application backend;
23
+ - it binds a one-time pairing code to one Agent session;
24
+ - it forwards `describe`, `invoke`, approval-pending, and final responses;
25
+ - it enforces session TTLs, Agent scope, revocation, expiry, and audit callbacks;
26
+ - it never exposes DOM, `click(selector)`, script evaluation, cookies, or provider keys.
27
+
28
+ The page's Surface session remains the capability and approval authority. The
29
+ application backend remains the business authorization authority.
30
+
31
+ ## Install
32
+
33
+ Node.js 20.12 or newer is required.
34
+
35
+ ```bash
36
+ npm install @plingjs/surface-gateway
37
+ ```
38
+
39
+ Run the bundled reference server:
40
+
41
+ ```bash
42
+ SURFACE_ADMIN_TOKEN="$(openssl rand -hex 32)" npx pling-surface-gateway
43
+ ```
44
+
45
+ It listens on `http://127.0.0.1:5219` and uses the bundled
46
+ `config.example.json`. For a real deployment, create your own JSON config and set:
47
+
48
+ ```bash
49
+ SURFACE_CONFIG=/etc/pling/surface.json
50
+ SURFACE_ADMIN_TOKEN=<server-side secret>
51
+ SURFACE_PUBLIC_BASE_URL=https://surface.example.com
52
+ SURFACE_HOST=127.0.0.1
53
+ SURFACE_PORT=5219
54
+ ```
55
+
56
+ Set `SURFACE_TRUST_PROXY=1` only when a trusted reverse proxy overwrites
57
+ `X-Forwarded-For`. If the gateway is directly reachable, leave it disabled or a
58
+ client can forge its rate-limit identity.
59
+
60
+ `SURFACE_ADMIN_TOKEN` is server-to-server only. Never put it in browser code,
61
+ an MCP server configuration, logs, or a public repository.
62
+
63
+ ## Library API
64
+
65
+ ```js
66
+ import {
67
+ createSurfaceGateway,
68
+ createSurfaceServer,
69
+ loadConfig,
70
+ } from "@plingjs/surface-gateway";
71
+
72
+ const config = await loadConfig("./surface.json");
73
+ const server = createSurfaceServer({
74
+ config,
75
+ adminToken: process.env.SURFACE_ADMIN_TOKEN,
76
+ publicBaseUrl: "https://surface.example.com",
77
+ host: "127.0.0.1",
78
+ port: 5219,
79
+ });
80
+
81
+ await server.start();
82
+ // server.gateway exposes the control-plane API for tests or custom transports.
83
+ ```
84
+
85
+ The package also exports `createSurfaceGateway`, `createMcpHandler`,
86
+ `normalizeConfig`, `GatewayError`, and `ConfigError`. TypeScript declarations are
87
+ included.
88
+
89
+ ## Application flow
90
+
91
+ 1. The business backend verifies the signed-in user and calls
92
+ `POST /v1/page-sessions` with the narrow set of capability ids that page may
93
+ expose. It returns a short-lived page token to that page only.
94
+ 2. The page opens `WS /surface?token=...`, sends a `pling.bridge/0.1` hello, and
95
+ keeps its existing Pling Surface session active.
96
+ 3. The page calls `POST /v1/pairings` to display a one-time code. Pairing is
97
+ refused unless the page WebSocket is connected and the `Origin` is allowed for
98
+ its tenant.
99
+ 4. The user gives the code to their Agent runtime. The runtime claims it at
100
+ `POST /v1/agent-sessions` and receives an Agent token and MCP URL.
101
+ 5. The Agent configures `https://surface.example.com/mcp` with
102
+ `Authorization: Bearer <agent token>`. It first calls
103
+ `pling_surface_describe`, then `pling_surface_invoke`.
104
+ 6. If the page requires human confirmation, the gateway returns
105
+ `pending_approval`. The user confirms in the business page. The Agent polls
106
+ `pling_surface_approval_status`; there is no approval tool and no way for the
107
+ Agent to approve its own request.
108
+
109
+ Pairing codes are single-use, expire after two minutes by default, and contain
110
+ 48 random bits. Agent `invoke` calls are checked against both the page session
111
+ scope and the narrowed Agent scope.
112
+
113
+ ## HTTP routes
114
+
115
+ | Method | Route | Authentication | Purpose |
116
+ | --- | --- | --- | --- |
117
+ | `GET` | `/health` | public | Liveness only |
118
+ | `GET` | `/v1/discovery/:tenantId` | public | Draft discovery metadata |
119
+ | `POST` | `/v1/page-sessions` | admin Bearer | Create a page session after the application has authenticated the user |
120
+ | `POST` | `/v1/demo/page-sessions` | configured Origin | Create a rate-limited public-demo page session |
121
+ | `POST` | `/v1/pairings` | page token + Origin | Create a one-time pairing code |
122
+ | `POST` | `/v1/agent-sessions` | pairing code | Claim a pairing and receive an Agent token |
123
+ | `POST` | `/v1/agent-sessions/revoke` | page token + Origin | Let the signed-in page revoke its Agent |
124
+ | `GET` | `/admin/sessions` | admin Bearer | Inspect live sessions |
125
+ | `DELETE` | `/admin/page-sessions/:id` | admin Bearer | Revoke a page and all of its Agents |
126
+ | `DELETE` | `/admin/agent-sessions/:id` | admin Bearer | Revoke one Agent session |
127
+ | `POST` | `/mcp` | Agent Bearer | JSON-RPC MCP endpoint (JSON response mode) |
128
+ | `WS` | `/surface?token=...` | page token + Origin | Page transport for Surface envelopes |
129
+
130
+ ## MCP tools
131
+
132
+ - `pling_surface_describe` reads the paired page's current capabilities,
133
+ records, regions, state, and feedback.
134
+ - `pling_surface_invoke` invokes one capability returned by `describe`.
135
+ - `pling_surface_approval_status` reads the final state of an earlier invocation.
136
+
137
+ The Agent token is bound to the active HTTP request. It is not a model-visible
138
+ tool argument, and tool arguments cannot override it.
139
+
140
+ ## Scope matching
141
+
142
+ Scopes follow `@plingjs/surface` semantics:
143
+
144
+ - `add_task` also covers `add_task:row` and `add_task@row` families;
145
+ - `delete@` covers concrete ids such as `delete@task`;
146
+ - `delete@task` does not cover the broader `delete@` family;
147
+ - `*` is accepted only when the page session itself allows `*`.
148
+
149
+ ## Production boundary
150
+
151
+ This package is a reference implementation, not an enterprise control plane.
152
+ Its default stores are in memory. Before exposing it to untrusted traffic:
153
+
154
+ - terminate TLS at a reverse proxy and bind Node to loopback;
155
+ - use scoped, per-tenant admin credentials instead of one global control token;
156
+ - put shared rate limiting and abuse detection in front of pairing and MCP;
157
+ - persist session metadata and audit events in the application's system of record;
158
+ - connect approval receipts to the application's approval backend;
159
+ - configure one pairing per user/page, short Agent TTLs, and explicit scopes;
160
+ - monitor failed pairing, repeated auth failures, revocations, and approval latency.
161
+
162
+ The gateway cannot stop an Agent that already owns the browser through CDP from
163
+ clicking the page directly. Its security value is that declared operations have
164
+ identity, scope, confirmation, and audit evidence, while business authorization
165
+ stays in the backend.
166
+
167
+ ## Test
168
+
169
+ ```bash
170
+ pnpm --filter @plingjs/surface-gateway test
171
+ pnpm --filter @plingjs/surface-gateway check
172
+ ```
173
+
174
+ Source and issues: [github.com/plingjs/pling](https://github.com/plingjs/pling).
175
+
176
+ ## License
177
+
178
+ Apache-2.0.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { cliHelp, PACKAGE_VERSION, runSurfaceGatewayCli } from "../index.mjs";
3
+
4
+ const args = new Set(process.argv.slice(2));
5
+ if (args.has("--help") || args.has("-h")) {
6
+ process.stdout.write(cliHelp());
7
+ process.exit(0);
8
+ }
9
+ if (args.has("--version") || args.has("-v")) {
10
+ process.stdout.write(`${PACKAGE_VERSION}\n`);
11
+ process.exit(0);
12
+ }
13
+
14
+ try {
15
+ await runSurfaceGatewayCli();
16
+ } catch (error) {
17
+ process.stderr.write(`[surface-gateway] ${error instanceof Error ? error.message : String(error)}\n`);
18
+ process.exitCode = 1;
19
+ }
@@ -0,0 +1,33 @@
1
+ {
2
+ "tenants": [
3
+ {
4
+ "id": "demo",
5
+ "displayName": "Pling Todo Demo",
6
+ "origins": [
7
+ "http://127.0.0.1:5220",
8
+ "http://localhost:5220",
9
+ "http://127.0.0.1:5191",
10
+ "http://localhost:5191",
11
+ "https://todo.plingjs.dev",
12
+ "https://plingjs.dev"
13
+ ],
14
+ "maxPageSessionTtlMs": 86400000,
15
+ "maxAgentSessionTtlMs": 86400000,
16
+ "publicDemo": {
17
+ "origins": ["http://127.0.0.1:5191", "http://localhost:5191", "https://plingjs.dev"],
18
+ "scope": [
19
+ "todo.overview",
20
+ "todo.add",
21
+ "todo.update",
22
+ "todo.toggle",
23
+ "todo.delete",
24
+ "todo.clear_completed"
25
+ ],
26
+ "pageSessionTtlMs": 86400000,
27
+ "maxSessionsPerWindow": 30,
28
+ "windowMs": 600000,
29
+ "maxActiveSessions": 40
30
+ }
31
+ }
32
+ ]
33
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@plingjs/surface-gateway",
3
+ "version": "0.1.0-alpha.6",
4
+ "description": "Reference Node gateway for Pling Agent Surface: page sessions, pairing, MCP, scopes, revocation, and audit.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/plingjs/pling.git",
9
+ "directory": "packages/surface-gateway"
10
+ },
11
+ "homepage": "https://plingjs.dev",
12
+ "bugs": "https://github.com/plingjs/pling/issues",
13
+ "type": "module",
14
+ "main": "./index.mjs",
15
+ "module": "./index.mjs",
16
+ "types": "./index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./index.d.ts",
20
+ "import": "./index.mjs",
21
+ "default": "./index.mjs"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "bin": {
26
+ "pling-surface-gateway": "./bin/surface-gateway.mjs"
27
+ },
28
+ "dependencies": {
29
+ "ws": "^8.18.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=20.12"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }
@@ -0,0 +1 @@
1
+ export const PACKAGE_VERSION = "0.1.0-alpha.6";
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "@plingjs/surface-gateway",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.6",
4
+ "private": false,
5
+ "type": "module",
4
6
  "description": "Reference Node gateway for Pling Agent Surface: page sessions, pairing, MCP, scopes, revocation, and audit.",
5
7
  "license": "Apache-2.0",
6
8
  "repository": {
@@ -10,28 +12,31 @@
10
12
  },
11
13
  "homepage": "https://plingjs.dev",
12
14
  "bugs": "https://github.com/plingjs/pling/issues",
13
- "type": "module",
14
- "main": "./index.mjs",
15
- "module": "./index.mjs",
16
- "types": "./index.d.ts",
15
+ "publishConfig": {
16
+ "access": "public",
17
+ "directory": "dist"
18
+ },
17
19
  "exports": {
18
20
  ".": {
19
- "types": "./index.d.ts",
20
- "import": "./index.mjs",
21
- "default": "./index.mjs"
21
+ "types": "./src/index.d.ts",
22
+ "default": "./src/index.mjs"
22
23
  },
23
24
  "./package.json": "./package.json"
24
25
  },
25
26
  "bin": {
26
27
  "pling-surface-gateway": "./bin/surface-gateway.mjs"
27
28
  },
29
+ "scripts": {
30
+ "build": "node ../../scripts/build-surface-gateway.mjs",
31
+ "prepack": "pnpm build",
32
+ "start": "node bin/surface-gateway.mjs",
33
+ "test": "node --test test/*.test.mjs",
34
+ "check": "node --check src/index.mjs && node --check src/gateway.mjs && node --check src/server.mjs && node --check src/mcp.mjs && node --check src/config.mjs && node --check src/errors.mjs && node --check src/cli.mjs && node --check src/version.mjs && node --check bin/surface-gateway.mjs"
35
+ },
28
36
  "dependencies": {
29
37
  "ws": "^8.18.0"
30
38
  },
31
39
  "engines": {
32
40
  "node": ">=20.12"
33
- },
34
- "publishConfig": {
35
- "access": "public"
36
41
  }
37
- }
42
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,132 @@
1
+ import { existsSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { ConfigError, loadConfig } from "./config.mjs";
4
+ import { createSurfaceServer } from "./server.mjs";
5
+
6
+ /**
7
+ * The same module runs from `src/` during workspace development and from the
8
+ * package root after publishing. Resolve both layouts instead of assuming one.
9
+ */
10
+ export function resolveDefaultConfig(moduleUrl) {
11
+ const candidates = [
12
+ new URL("../config.example.json", moduleUrl),
13
+ new URL("./config.example.json", moduleUrl),
14
+ ];
15
+ return fileURLToPath(candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]);
16
+ }
17
+
18
+ const DEFAULT_CONFIG = resolveDefaultConfig(import.meta.url);
19
+
20
+ /**
21
+ * Start the gateway from environment variables.
22
+ *
23
+ * This function is also used by the thin deployment wrapper in services/. It
24
+ * installs SIGINT/SIGTERM handlers by default so a container can stop cleanly.
25
+ */
26
+ export async function runSurfaceGatewayCli(options = {}) {
27
+ const env = options.env ?? process.env;
28
+ const stderr = options.stderr ?? process.stderr;
29
+ const configPath = env.SURFACE_CONFIG?.trim() || DEFAULT_CONFIG;
30
+ const adminToken = env.SURFACE_ADMIN_TOKEN?.trim();
31
+ const publicBaseUrl = env.SURFACE_PUBLIC_BASE_URL?.trim() || undefined;
32
+ const host = env.SURFACE_HOST?.trim() || "127.0.0.1";
33
+ const port = numberFromEnv(env, "SURFACE_PORT", 5219);
34
+ const demoTenantId = env.SURFACE_DEMO_TENANT_ID?.trim() || "demo";
35
+ const trustProxy = booleanFromEnv(env, "SURFACE_TRUST_PROXY", false);
36
+
37
+ if (!adminToken) {
38
+ throw new ConfigError(
39
+ "SURFACE_ADMIN_TOKEN_REQUIRED",
40
+ "SURFACE_ADMIN_TOKEN is required. It creates page sessions and reads admin state; never expose it to browsers.",
41
+ );
42
+ }
43
+
44
+ const config = await loadConfig(configPath);
45
+ const server = createSurfaceServer({
46
+ config,
47
+ adminToken,
48
+ publicBaseUrl,
49
+ host,
50
+ port,
51
+ demoTenantId,
52
+ trustProxy,
53
+ });
54
+
55
+ let address;
56
+ try {
57
+ address = await server.start();
58
+ } catch (error) {
59
+ throw new ConfigError(
60
+ "SURFACE_START_FAILED",
61
+ `Failed to start Surface Gateway: ${error instanceof Error ? error.message : String(error)}`,
62
+ );
63
+ }
64
+
65
+ stderr.write(`[surface-gateway] config ${configPath}\n`);
66
+ stderr.write(`[surface-gateway] page WebSocket ${address.baseUrl.replace(/^http/, "ws")}/surface\n`);
67
+ stderr.write(`[surface-gateway] remote MCP ${address.baseUrl}/mcp\n`);
68
+
69
+ let stopping = false;
70
+ async function stop(signal = "shutdown") {
71
+ if (stopping) return;
72
+ stopping = true;
73
+ stderr.write(`[surface-gateway] ${signal}: stopping\n`);
74
+ removeSignalHandlers();
75
+ await server.stop();
76
+ }
77
+
78
+ const onSigint = () => void stop("SIGINT").catch((error) => handleStopFailure(stderr, error));
79
+ const onSigterm = () => void stop("SIGTERM").catch((error) => handleStopFailure(stderr, error));
80
+ process.once("SIGINT", onSigint);
81
+ process.once("SIGTERM", onSigterm);
82
+
83
+ function removeSignalHandlers() {
84
+ process.off("SIGINT", onSigint);
85
+ process.off("SIGTERM", onSigterm);
86
+ }
87
+
88
+ return { server, config, configPath, ...address, stop };
89
+ }
90
+
91
+ export function cliHelp() {
92
+ return [
93
+ "Pling Surface Gateway",
94
+ "",
95
+ "Usage: pling-surface-gateway",
96
+ "",
97
+ "Required environment:",
98
+ " SURFACE_ADMIN_TOKEN Server-to-server admin token",
99
+ "",
100
+ "Optional environment:",
101
+ " SURFACE_CONFIG JSON config path (default: bundled config.example.json)",
102
+ " SURFACE_PUBLIC_BASE_URL Public origin used in generated URLs",
103
+ " SURFACE_HOST Listen host (default: 127.0.0.1)",
104
+ " SURFACE_PORT Listen port (default: 5219)",
105
+ " SURFACE_DEMO_TENANT_ID Tenant used by the public demo route (default: demo)",
106
+ " SURFACE_TRUST_PROXY Set to 1 only behind a trusted proxy that overwrites X-Forwarded-For",
107
+ "",
108
+ ].join("\n");
109
+ }
110
+
111
+ function numberFromEnv(env, name, fallback) {
112
+ const value = env[name];
113
+ if (!value) return fallback;
114
+ const number = Number(value);
115
+ if (!Number.isInteger(number) || number < 1 || number > 65535) {
116
+ throw new ConfigError("CONFIG_INVALID", `${name} must be a TCP port between 1 and 65535.`);
117
+ }
118
+ return number;
119
+ }
120
+
121
+ function booleanFromEnv(env, name, fallback) {
122
+ const value = env[name]?.trim().toLowerCase();
123
+ if (!value) return fallback;
124
+ if (["1", "true", "yes", "on"].includes(value)) return true;
125
+ if (["0", "false", "no", "off"].includes(value)) return false;
126
+ throw new ConfigError("CONFIG_INVALID", `${name} must be 1/0, true/false, yes/no, or on/off.`);
127
+ }
128
+
129
+ function handleStopFailure(stderr, error) {
130
+ stderr.write(`[surface-gateway] shutdown failed: ${error instanceof Error ? error.message : String(error)}\n`);
131
+ process.exitCode = 1;
132
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,214 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const MAX_SCOPE_ENTRIES = 256;
4
+
5
+ export class ConfigError extends Error {
6
+ constructor(code, message) {
7
+ super(message);
8
+ this.name = "ConfigError";
9
+ this.code = code;
10
+ }
11
+ }
12
+
13
+ export async function loadConfig(file) {
14
+ let raw;
15
+ try {
16
+ raw = JSON.parse(await readFile(file, "utf8"));
17
+ } catch (error) {
18
+ throw new ConfigError(
19
+ "CONFIG_READ_FAILED",
20
+ `Cannot read Surface Gateway config ${file}: ${error instanceof Error ? error.message : String(error)}`,
21
+ );
22
+ }
23
+ return normalizeConfig(raw);
24
+ }
25
+
26
+ export function normalizeConfig(raw) {
27
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray(raw.tenants)) {
28
+ throw new ConfigError("CONFIG_INVALID", "Config must be an object with a tenants array.");
29
+ }
30
+
31
+ const seen = new Set();
32
+ const tenants = raw.tenants.map((tenant, index) => {
33
+ if (!tenant || typeof tenant !== "object" || Array.isArray(tenant)) {
34
+ throw new ConfigError("CONFIG_INVALID", `tenants[${index}] must be an object.`);
35
+ }
36
+
37
+ const id = stringValue(tenant.id, `tenants[${index}].id`);
38
+ if (seen.has(id)) throw new ConfigError("CONFIG_INVALID", `Duplicate tenant id: ${id}`);
39
+ seen.add(id);
40
+
41
+ const rawOrigins = Array.isArray(tenant.origins)
42
+ ? tenant.origins
43
+ : typeof tenant.origin === "string"
44
+ ? [tenant.origin]
45
+ : [];
46
+ if (rawOrigins.length === 0) {
47
+ throw new ConfigError("CONFIG_INVALID", `tenant ${id} must configure at least one origin.`);
48
+ }
49
+ const origins = [...new Set(rawOrigins.map((origin, originIndex) => {
50
+ if (typeof origin !== "string" || !origin.trim()) {
51
+ throw new ConfigError("CONFIG_INVALID", `tenants[${index}].origins[${originIndex}] must be a non-empty string.`);
52
+ }
53
+ return normalizeOrigin(origin);
54
+ }))];
55
+
56
+ const maxPageSessionTtlMs = positiveInteger(
57
+ tenant.maxPageSessionTtlMs,
58
+ 24 * 60 * 60_000,
59
+ `tenant ${id}.maxPageSessionTtlMs`,
60
+ );
61
+ const maxAgentSessionTtlMs = positiveInteger(
62
+ tenant.maxAgentSessionTtlMs,
63
+ 24 * 60 * 60_000,
64
+ `tenant ${id}.maxAgentSessionTtlMs`,
65
+ );
66
+
67
+ return {
68
+ id,
69
+ displayName:
70
+ typeof tenant.displayName === "string" && tenant.displayName.trim()
71
+ ? tenant.displayName.trim()
72
+ : id,
73
+ origins,
74
+ maxPageSessionTtlMs,
75
+ maxAgentSessionTtlMs,
76
+ publicDemo: normalizePublicDemo(
77
+ tenant.publicDemo,
78
+ id,
79
+ origins,
80
+ maxPageSessionTtlMs,
81
+ ),
82
+ };
83
+ });
84
+
85
+ if (tenants.length === 0) {
86
+ throw new ConfigError("CONFIG_INVALID", "At least one tenant is required.");
87
+ }
88
+ return { tenants };
89
+ }
90
+
91
+ function normalizePublicDemo(value, tenantId, tenantOrigins, maxPageSessionTtlMs) {
92
+ if (value === undefined || value === null) return null;
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
94
+ throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo must be an object.`);
95
+ }
96
+
97
+ if (!Array.isArray(value.origins) || value.origins.length === 0) {
98
+ throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo must configure at least one origin.`);
99
+ }
100
+ const tenantOriginSet = new Set(tenantOrigins);
101
+ const origins = [...new Set(value.origins.map((origin, index) => {
102
+ if (typeof origin !== "string" || !origin.trim()) {
103
+ throw new ConfigError(
104
+ "CONFIG_INVALID",
105
+ `tenant ${tenantId}.publicDemo.origins[${index}] must be a non-empty string.`,
106
+ );
107
+ }
108
+ return normalizeOrigin(origin);
109
+ }))];
110
+ const unknownOrigin = origins.find((origin) => !tenantOriginSet.has(origin));
111
+ if (unknownOrigin) {
112
+ throw new ConfigError(
113
+ "CONFIG_INVALID",
114
+ `tenant ${tenantId}.publicDemo origins must also appear in tenant.origins: ${unknownOrigin}`,
115
+ );
116
+ }
117
+
118
+ if (!Array.isArray(value.scope)) {
119
+ throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo.scope must be an array.`);
120
+ }
121
+ const scope = [...new Set(value.scope.map((item, index) => {
122
+ if (typeof item !== "string" || !item.trim()) {
123
+ throw new ConfigError(
124
+ "CONFIG_INVALID",
125
+ `tenant ${tenantId}.publicDemo.scope[${index}] must be a non-empty string.`,
126
+ );
127
+ }
128
+ return item.trim();
129
+ }))];
130
+ if (scope.length === 0) {
131
+ throw new ConfigError("CONFIG_INVALID", `tenant ${tenantId}.publicDemo.scope must not be empty.`);
132
+ }
133
+ if (scope.length > MAX_SCOPE_ENTRIES) {
134
+ throw new ConfigError(
135
+ "CONFIG_INVALID",
136
+ `tenant ${tenantId}.publicDemo.scope must contain at most ${MAX_SCOPE_ENTRIES} entries.`,
137
+ );
138
+ }
139
+
140
+ const pageSessionTtlMs = positiveInteger(
141
+ value.pageSessionTtlMs,
142
+ 24 * 60 * 60_000,
143
+ `tenant ${tenantId}.publicDemo.pageSessionTtlMs`,
144
+ );
145
+ if (pageSessionTtlMs > maxPageSessionTtlMs) {
146
+ throw new ConfigError(
147
+ "CONFIG_INVALID",
148
+ `tenant ${tenantId}.publicDemo.pageSessionTtlMs cannot exceed tenant.maxPageSessionTtlMs.`,
149
+ );
150
+ }
151
+
152
+ return {
153
+ origins,
154
+ scope,
155
+ pageSessionTtlMs,
156
+ maxSessionsPerWindow: positiveInteger(
157
+ value.maxSessionsPerWindow,
158
+ 30,
159
+ `tenant ${tenantId}.publicDemo.maxSessionsPerWindow`,
160
+ ),
161
+ windowMs: positiveInteger(
162
+ value.windowMs,
163
+ 10 * 60_000,
164
+ `tenant ${tenantId}.publicDemo.windowMs`,
165
+ ),
166
+ maxActiveSessions: positiveInteger(
167
+ value.maxActiveSessions,
168
+ 40,
169
+ `tenant ${tenantId}.publicDemo.maxActiveSessions`,
170
+ ),
171
+ userIdPrefix:
172
+ typeof value.userIdPrefix === "string" && value.userIdPrefix.trim()
173
+ ? value.userIdPrefix.trim()
174
+ : "public-demo",
175
+ };
176
+ }
177
+
178
+ export function normalizeOrigin(value) {
179
+ let url;
180
+ try {
181
+ url = new URL(value);
182
+ } catch {
183
+ throw new ConfigError("CONFIG_INVALID", `Invalid origin: ${value}`);
184
+ }
185
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
186
+ throw new ConfigError("CONFIG_INVALID", `Origin must use http or https: ${value}`);
187
+ }
188
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
189
+ throw new ConfigError(
190
+ "CONFIG_INVALID",
191
+ `Origin must not contain a path, query, fragment, or credentials: ${value}`,
192
+ );
193
+ }
194
+ return url.origin;
195
+ }
196
+
197
+ function stringValue(value, path) {
198
+ if (typeof value !== "string" || !value.trim()) {
199
+ throw new ConfigError("CONFIG_INVALID", `${path} must be a non-empty string.`);
200
+ }
201
+ const text = value.trim();
202
+ if (text.length > 128) {
203
+ throw new ConfigError("CONFIG_INVALID", `${path} must be at most 128 characters.`);
204
+ }
205
+ return text;
206
+ }
207
+
208
+ function positiveInteger(value, fallback, path) {
209
+ if (value === undefined || value === null) return fallback;
210
+ if (!Number.isInteger(value) || value <= 0) {
211
+ throw new ConfigError("CONFIG_INVALID", `${path} must be a positive integer.`);
212
+ }
213
+ return value;
214
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,15 @@
1
+ export class GatewayError extends Error {
2
+ constructor(code, message, status = 400, options) {
3
+ super(message, options);
4
+ this.name = "GatewayError";
5
+ this.code = code;
6
+ this.status = status;
7
+ }
8
+ }
9
+
10
+ export function asGatewayError(error) {
11
+ if (error instanceof GatewayError) return error;
12
+ return new GatewayError("INTERNAL_ERROR", "Internal gateway error.", 500, {
13
+ cause: error,
14
+ });
15
+ }