@plingjs/surface-gateway 0.1.0-alpha.4 → 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.
- package/bin/surface-gateway.mjs +0 -0
- package/dist/README.md +178 -0
- package/dist/bin/surface-gateway.mjs +19 -0
- package/dist/config.example.json +33 -0
- package/dist/package.json +37 -0
- package/dist/version.mjs +1 -0
- package/package.json +16 -11
- package/src/cli.mjs +132 -0
- package/src/config.mjs +214 -0
- package/src/errors.mjs +15 -0
- package/src/gateway.mjs +935 -0
- package/src/index.d.ts +273 -0
- package/src/index.mjs +7 -0
- package/src/mcp.mjs +145 -0
- package/src/server.mjs +462 -0
- package/src/version.mjs +1 -0
- package/test/cli.test.mjs +29 -0
- package/test/config.test.mjs +56 -0
- package/test/gateway.test.mjs +372 -0
- package/test/mcp.test.mjs +88 -0
- package/test/transport.test.mjs +363 -0
- package/version.mjs +0 -1
- /package/{LICENSE → dist/LICENSE} +0 -0
- /package/{NOTICE → dist/NOTICE} +0 -0
- /package/{cli.mjs → dist/cli.mjs} +0 -0
- /package/{config.mjs → dist/config.mjs} +0 -0
- /package/{errors.mjs → dist/errors.mjs} +0 -0
- /package/{gateway.mjs → dist/gateway.mjs} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{index.mjs → dist/index.mjs} +0 -0
- /package/{mcp.mjs → dist/mcp.mjs} +0 -0
- /package/{server.mjs → dist/server.mjs} +0 -0
package/bin/surface-gateway.mjs
CHANGED
|
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
|
+
}
|
package/dist/version.mjs
ADDED
|
@@ -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.
|
|
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
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"directory": "dist"
|
|
18
|
+
},
|
|
17
19
|
"exports": {
|
|
18
20
|
".": {
|
|
19
|
-
"types": "./index.d.ts",
|
|
20
|
-
"
|
|
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
|
+
}
|