@procedures/peer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +725 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/connection/index.d.ts +45 -0
- package/dist/connection/index.d.ts.map +1 -0
- package/dist/connection/index.js +148 -0
- package/dist/connection/index.js.map +1 -0
- package/dist/discovery/index.d.ts +30 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +101 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/expose.d.ts +14 -0
- package/dist/expose.d.ts.map +1 -0
- package/dist/expose.js +16 -0
- package/dist/expose.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/input-schemas.d.ts +21 -0
- package/dist/input-schemas.d.ts.map +1 -0
- package/dist/input-schemas.js +80 -0
- package/dist/input-schemas.js.map +1 -0
- package/dist/lockfile.d.ts +48 -0
- package/dist/lockfile.d.ts.map +1 -0
- package/dist/lockfile.js +115 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/manifest/index.d.ts +8 -0
- package/dist/manifest/index.d.ts.map +1 -0
- package/dist/manifest/index.js +8 -0
- package/dist/manifest/index.js.map +1 -0
- package/dist/manifest/json.d.ts +27 -0
- package/dist/manifest/json.d.ts.map +1 -0
- package/dist/manifest/json.js +24 -0
- package/dist/manifest/json.js.map +1 -0
- package/dist/manifest/typescript.d.ts +17 -0
- package/dist/manifest/typescript.d.ts.map +1 -0
- package/dist/manifest/typescript.js +123 -0
- package/dist/manifest/typescript.js.map +1 -0
- package/dist/module.d.ts +20 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +42 -0
- package/dist/module.js.map +1 -0
- package/dist/output-schemas.d.ts +21 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +85 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/peer/index.d.ts +47 -0
- package/dist/peer/index.d.ts.map +1 -0
- package/dist/peer/index.js +234 -0
- package/dist/peer/index.js.map +1 -0
- package/dist/peer/security.d.ts +25 -0
- package/dist/peer/security.d.ts.map +1 -0
- package/dist/peer/security.js +58 -0
- package/dist/peer/security.js.map +1 -0
- package/dist/procedures/index.d.ts +24 -0
- package/dist/procedures/index.d.ts.map +1 -0
- package/dist/procedures/index.js +15 -0
- package/dist/procedures/index.js.map +1 -0
- package/dist/procedures/manifest.generate.d.ts +11 -0
- package/dist/procedures/manifest.generate.d.ts.map +1 -0
- package/dist/procedures/manifest.generate.js +97 -0
- package/dist/procedures/manifest.generate.js.map +1 -0
- package/dist/procedures/server.call.d.ts +30 -0
- package/dist/procedures/server.call.d.ts.map +1 -0
- package/dist/procedures/server.call.js +60 -0
- package/dist/procedures/server.call.js.map +1 -0
- package/dist/procedures/server.connect.d.ts +11 -0
- package/dist/procedures/server.connect.d.ts.map +1 -0
- package/dist/procedures/server.connect.js +35 -0
- package/dist/procedures/server.connect.js.map +1 -0
- package/dist/procedures/server.connections.d.ts +22 -0
- package/dist/procedures/server.connections.d.ts.map +1 -0
- package/dist/procedures/server.connections.js +38 -0
- package/dist/procedures/server.connections.js.map +1 -0
- package/dist/procedures/server.create.d.ts +20 -0
- package/dist/procedures/server.create.d.ts.map +1 -0
- package/dist/procedures/server.create.js +64 -0
- package/dist/procedures/server.create.js.map +1 -0
- package/dist/procedures/server.disconnect.d.ts +11 -0
- package/dist/procedures/server.disconnect.d.ts.map +1 -0
- package/dist/procedures/server.disconnect.js +22 -0
- package/dist/procedures/server.disconnect.js.map +1 -0
- package/dist/procedures/server.start.d.ts +13 -0
- package/dist/procedures/server.start.d.ts.map +1 -0
- package/dist/procedures/server.start.js +179 -0
- package/dist/procedures/server.start.js.map +1 -0
- package/dist/procedures/server.status.d.ts +12 -0
- package/dist/procedures/server.status.d.ts.map +1 -0
- package/dist/procedures/server.status.js +116 -0
- package/dist/procedures/server.status.js.map +1 -0
- package/dist/procedures/server.stop.d.ts +11 -0
- package/dist/procedures/server.stop.d.ts.map +1 -0
- package/dist/procedures/server.stop.js +96 -0
- package/dist/procedures/server.stop.js.map +1 -0
- package/dist/register.d.ts +8 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +14 -0
- package/dist/register.js.map +1 -0
- package/dist/schema.d.ts +14 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +14 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +192 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +87 -3
- package/src/connection/index.ts +219 -0
- package/src/discovery/index.ts +137 -0
- package/src/expose.ts +17 -0
- package/src/index.ts +69 -0
- package/src/input-schemas.ts +121 -0
- package/src/lockfile.ts +129 -0
- package/src/manifest/index.ts +8 -0
- package/src/manifest/json.ts +39 -0
- package/src/manifest/typescript.ts +148 -0
- package/src/module.ts +46 -0
- package/src/output-schemas.ts +110 -0
- package/src/peer/index.ts +325 -0
- package/src/peer/security.ts +58 -0
- package/src/procedures/index.ts +32 -0
- package/src/procedures/manifest.generate.ts +113 -0
- package/src/procedures/server.call.ts +96 -0
- package/src/procedures/server.connect.ts +45 -0
- package/src/procedures/server.connections.ts +64 -0
- package/src/procedures/server.create.ts +84 -0
- package/src/procedures/server.disconnect.ts +31 -0
- package/src/procedures/server.start.ts +209 -0
- package/src/procedures/server.status.ts +139 -0
- package/src/procedures/server.stop.ts +121 -0
- package/src/register.ts +17 -0
- package/src/schema.ts +15 -0
- package/src/types.ts +257 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer Module
|
|
3
|
+
*
|
|
4
|
+
* Transport-agnostic peer implementation for bidirectional RPC.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ProcedureServer,
|
|
9
|
+
HttpServerTransport,
|
|
10
|
+
WebSocketServerTransport,
|
|
11
|
+
type ProcedureRegistry,
|
|
12
|
+
PROCEDURE_REGISTRY,
|
|
13
|
+
rpcServerUrlStrategy,
|
|
14
|
+
defaultServerUrlStrategy,
|
|
15
|
+
defaultUrlPattern,
|
|
16
|
+
procedureServerOptions,
|
|
17
|
+
type BrowserGuardOptions,
|
|
18
|
+
type HostPolicy,
|
|
19
|
+
type Method,
|
|
20
|
+
} from "@procedures/client";
|
|
21
|
+
import type { Request } from "express";
|
|
22
|
+
import type { ProcedurePath } from "@procedures/client";
|
|
23
|
+
import type { HttpTransportConfig, TransportConfig, TransportType, WebSocketTransportConfig } from "../types.js";
|
|
24
|
+
import { bearerToken, hostAllowed, originAllowed, tokenMatches } from "./security.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* URL strategy that matches the client's defaultUrlPattern
|
|
28
|
+
* Maps: POST /api/server/status → { service: "server", operation: "status" }
|
|
29
|
+
*/
|
|
30
|
+
function patternUrlStrategy(req: Request): Method | null {
|
|
31
|
+
const basePath = "/api";
|
|
32
|
+
let path = req.path;
|
|
33
|
+
|
|
34
|
+
// Remove base path prefix if present
|
|
35
|
+
if (path.startsWith(basePath)) {
|
|
36
|
+
path = path.slice(basePath.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Use the client's defaultUrlPattern parser
|
|
40
|
+
// Cast method to expected type (parser doesn't actually use it for this pattern)
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
return defaultUrlPattern.parse(path, req.method.toUpperCase() as any);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Types
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
export interface PeerOptions {
|
|
50
|
+
/** Unique peer ID */
|
|
51
|
+
id: string;
|
|
52
|
+
/** Transports to enable */
|
|
53
|
+
transports: TransportConfig[];
|
|
54
|
+
/** Auto-register procedures from registry */
|
|
55
|
+
autoRegister?: boolean;
|
|
56
|
+
/** Procedure registry to use */
|
|
57
|
+
registry?: ProcedureRegistry;
|
|
58
|
+
/** The procedures that the peer serves (see `exposeRule`). Without it, the peer serves every procedure. */
|
|
59
|
+
expose?: ((path: ProcedurePath) => boolean) | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* The policy of the host (`hostPolicy()` of the core, roadmap 2.3): the expose rule, the call
|
|
62
|
+
* rule, the interceptors (a time limit), the token of each network transport that has none of
|
|
63
|
+
* its own, and the browser checks. `expose` above, when given, wins over the policy's.
|
|
64
|
+
*/
|
|
65
|
+
policy?: HostPolicy | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PeerEndpoint {
|
|
69
|
+
type: TransportType;
|
|
70
|
+
address: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Peer {
|
|
74
|
+
/** Peer ID */
|
|
75
|
+
id: string;
|
|
76
|
+
/** Start all transports */
|
|
77
|
+
start(): Promise<void>;
|
|
78
|
+
/** Stop all transports */
|
|
79
|
+
stop(): Promise<void>;
|
|
80
|
+
/** Get active endpoints */
|
|
81
|
+
getEndpoints(): PeerEndpoint[];
|
|
82
|
+
/** Get underlying server */
|
|
83
|
+
getServer(): ProcedureServer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// Implementation
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
class PeerImpl implements Peer {
|
|
91
|
+
readonly id: string;
|
|
92
|
+
private server: ProcedureServer;
|
|
93
|
+
private transports: TransportConfig[];
|
|
94
|
+
private endpoints: PeerEndpoint[] = [];
|
|
95
|
+
private started = false;
|
|
96
|
+
private registry: ProcedureRegistry;
|
|
97
|
+
private startTime: number = Date.now();
|
|
98
|
+
|
|
99
|
+
/** The browser checks of the policy (an origin rule here replaces the peer's own rule). */
|
|
100
|
+
private browser: BrowserGuardOptions;
|
|
101
|
+
|
|
102
|
+
constructor(options: PeerOptions) {
|
|
103
|
+
this.id = options.id;
|
|
104
|
+
const policy = options.policy;
|
|
105
|
+
// The policy's token goes to each network transport that has none of its own
|
|
106
|
+
this.transports = options.transports.map((transport) =>
|
|
107
|
+
transport.type === "local" || policy?.token === undefined || transport.token !== undefined
|
|
108
|
+
? transport
|
|
109
|
+
: { ...transport, token: policy.token }
|
|
110
|
+
);
|
|
111
|
+
this.registry = options.registry ?? PROCEDURE_REGISTRY;
|
|
112
|
+
this.browser = policy?.browser ?? {};
|
|
113
|
+
|
|
114
|
+
// Create procedure server
|
|
115
|
+
this.server = new ProcedureServer({
|
|
116
|
+
...(policy ? procedureServerOptions(policy, this.registry) : {}),
|
|
117
|
+
autoRegister: options.autoRegister ?? true,
|
|
118
|
+
registry: this.registry,
|
|
119
|
+
...(options.expose ? { expose: options.expose } : {}),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async start(): Promise<void> {
|
|
124
|
+
if (this.started) return;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
for (const transport of this.transports) {
|
|
128
|
+
await this.startTransport(transport);
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Stop the transports that started, so a failed start leaves no port open
|
|
132
|
+
await this.server.stop().catch(() => {});
|
|
133
|
+
this.endpoints = [];
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.started = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async startTransport(config: TransportConfig): Promise<void> {
|
|
141
|
+
switch (config.type) {
|
|
142
|
+
case "http":
|
|
143
|
+
await this.startHttpTransport(config);
|
|
144
|
+
break;
|
|
145
|
+
case "websocket":
|
|
146
|
+
await this.startWebSocketTransport(config);
|
|
147
|
+
break;
|
|
148
|
+
case "local":
|
|
149
|
+
// Local transport doesn't need network setup
|
|
150
|
+
this.endpoints.push({
|
|
151
|
+
type: "local",
|
|
152
|
+
address: "local://in-process",
|
|
153
|
+
});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async startHttpTransport(config: HttpTransportConfig): Promise<void> {
|
|
159
|
+
// Dynamic import to avoid bundling express if not used
|
|
160
|
+
const express = (await import("express")).default;
|
|
161
|
+
|
|
162
|
+
const app = express();
|
|
163
|
+
app.use(express.json());
|
|
164
|
+
|
|
165
|
+
const port = config.port ?? 3000;
|
|
166
|
+
// Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
|
|
167
|
+
// served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
|
|
168
|
+
// deliberately. See documentation/BUGS-2026-07.md (H19).
|
|
169
|
+
const host = config.host ?? "127.0.0.1";
|
|
170
|
+
const basePath = config.basePath ?? "/api";
|
|
171
|
+
|
|
172
|
+
// Host, origin and token checks (deep dive TRN-1, CLI-1, CLI-2). The health endpoints need no
|
|
173
|
+
// token: a client uses them to check that a lockfile's server is alive.
|
|
174
|
+
app.use((req, res, next) => {
|
|
175
|
+
if (!hostAllowed(host, req.headers.host) || !originAllowed(req.headers.origin, config.corsOrigins)) {
|
|
176
|
+
res.status(403).json({ error: "Forbidden: the host or the origin is not allowed", code: "FORBIDDEN" });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const isHealth = req.path === "/health" || req.path === `${basePath}/health`;
|
|
180
|
+
if (!isHealth && req.method !== "OPTIONS" && !tokenMatches(config.token, bearerToken(req.headers.authorization))) {
|
|
181
|
+
res.status(401).json({ error: "Unauthorized: this server requires its token", code: "UNAUTHORIZED" });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
next();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Health endpoint for container orchestration
|
|
188
|
+
app.get(`${basePath}/health`, (_req, res) => {
|
|
189
|
+
const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
|
|
190
|
+
res.json({
|
|
191
|
+
status: "ok",
|
|
192
|
+
uptime: Date.now() - this.startTime,
|
|
193
|
+
procedures: procedureCount,
|
|
194
|
+
peerId: this.id,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Also add at root /health for convenience
|
|
199
|
+
app.get("/health", (_req, res) => {
|
|
200
|
+
const procedureCount = this.registry.getAll().filter((p) => p.handler).length;
|
|
201
|
+
res.json({
|
|
202
|
+
status: "ok",
|
|
203
|
+
uptime: Date.now() - this.startTime,
|
|
204
|
+
procedures: procedureCount,
|
|
205
|
+
peerId: this.id,
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Select URL strategy based on config
|
|
210
|
+
// Default to patternUrlStrategy which matches the client's defaultUrlPattern
|
|
211
|
+
const urlStrategy = config.urlStrategy === "rpc"
|
|
212
|
+
? rpcServerUrlStrategy
|
|
213
|
+
: config.urlStrategy === "rest"
|
|
214
|
+
? defaultServerUrlStrategy
|
|
215
|
+
: patternUrlStrategy;
|
|
216
|
+
|
|
217
|
+
const httpTransport = new HttpServerTransport(this.server, {
|
|
218
|
+
app,
|
|
219
|
+
port,
|
|
220
|
+
host,
|
|
221
|
+
basePath,
|
|
222
|
+
// Off by default: before, every peer sent Access-Control-Allow-Origin: * (deep dive CLI-2)
|
|
223
|
+
cors: config.cors ?? false,
|
|
224
|
+
urlStrategy,
|
|
225
|
+
// The transport's browser checks follow the peer's rule: loopback origins and the listed
|
|
226
|
+
// ones (the token is required in any case). The RESTful strategy calls by GET.
|
|
227
|
+
allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.corsOrigins)),
|
|
228
|
+
...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
|
|
229
|
+
allowGet: this.browser.allowGet ?? config.urlStrategy === "rest",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
this.server.addTransport(httpTransport);
|
|
233
|
+
await httpTransport.start();
|
|
234
|
+
|
|
235
|
+
this.endpoints.push({
|
|
236
|
+
type: "http",
|
|
237
|
+
address: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${basePath}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async startWebSocketTransport(config: WebSocketTransportConfig): Promise<void> {
|
|
242
|
+
// WebSocket transport requires an HTTP server to attach to
|
|
243
|
+
// For standalone WebSocket, we create an HTTP server first
|
|
244
|
+
const { createServer } = await import("http");
|
|
245
|
+
|
|
246
|
+
const port = config.port ?? 3001;
|
|
247
|
+
// Loopback by default so the full procedure registry (incl. shell.run / fs.write) is not
|
|
248
|
+
// served unauthenticated on all interfaces. Pass host: "0.0.0.0" to expose on the LAN
|
|
249
|
+
// deliberately. See documentation/BUGS-2026-07.md (H19).
|
|
250
|
+
const host = config.host ?? "127.0.0.1";
|
|
251
|
+
const path = config.path ?? "/ws";
|
|
252
|
+
|
|
253
|
+
const httpServer = createServer();
|
|
254
|
+
|
|
255
|
+
// Listen first, then attach the WebSocket server. A listen error (EADDRINUSE, EACCES) rejects:
|
|
256
|
+
// before, the error had no listener, so the process ended with an unhandled 'error' event
|
|
257
|
+
// (deep dive CLI-16).
|
|
258
|
+
await new Promise<void>((resolve, reject) => {
|
|
259
|
+
const onError = (error: Error): void => {
|
|
260
|
+
reject(new Error(`The WebSocket server cannot listen on ${host}:${port}: ${error.message}`));
|
|
261
|
+
};
|
|
262
|
+
httpServer.once("error", onError);
|
|
263
|
+
httpServer.listen(port, host, () => {
|
|
264
|
+
httpServer.off("error", onError);
|
|
265
|
+
resolve();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const wsTransport = new WebSocketServerTransport(this.server, {
|
|
270
|
+
server: httpServer,
|
|
271
|
+
path,
|
|
272
|
+
allowedOrigins: this.browser.allowedOrigins ?? ((origin) => originAllowed(origin, config.origins)),
|
|
273
|
+
...(this.browser.allowedHosts ? { allowedHosts: this.browser.allowedHosts } : {}),
|
|
274
|
+
// Host, origin and token checks: before, any web page could open a connection and call
|
|
275
|
+
// procedures (cross-site WebSocket hijacking, deep dive TRN-1)
|
|
276
|
+
authenticate: (req) => {
|
|
277
|
+
const token = new URL(req.url ?? "/", "http://localhost").searchParams.get("token") ?? bearerToken(req.headers.authorization);
|
|
278
|
+
return (
|
|
279
|
+
hostAllowed(host, req.headers.host) &&
|
|
280
|
+
originAllowed(req.headers.origin, config.origins) &&
|
|
281
|
+
tokenMatches(config.token, token)
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this.server.addTransport(wsTransport);
|
|
287
|
+
try {
|
|
288
|
+
await wsTransport.start();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
httpServer.close();
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.endpoints.push({
|
|
295
|
+
type: "websocket",
|
|
296
|
+
address: `ws://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async stop(): Promise<void> {
|
|
301
|
+
if (!this.started) return;
|
|
302
|
+
await this.server.stop();
|
|
303
|
+
this.started = false;
|
|
304
|
+
this.endpoints = [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getEndpoints(): PeerEndpoint[] {
|
|
308
|
+
return [...this.endpoints];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
getServer(): ProcedureServer {
|
|
312
|
+
return this.server;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// =============================================================================
|
|
317
|
+
// Factory
|
|
318
|
+
// =============================================================================
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create a new peer instance
|
|
322
|
+
*/
|
|
323
|
+
export async function createPeer(options: PeerOptions): Promise<Peer> {
|
|
324
|
+
return new PeerImpl(options);
|
|
325
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request checks of a peer (deep dive TRN-1, CLI-1, CLI-2).
|
|
3
|
+
*
|
|
4
|
+
* A peer serves the whole registry, `shell.*` included. So:
|
|
5
|
+
* - a peer bound to loopback answers only requests addressed to a loopback name (a DNS
|
|
6
|
+
* rebinding attack sends another name);
|
|
7
|
+
* - a browser request is accepted only from a loopback origin or an origin in the allowlist
|
|
8
|
+
* (before, CORS was on by default with `Access-Control-Allow-Origin: *`, so any web page could
|
|
9
|
+
* call the server);
|
|
10
|
+
* - when the peer has a token, every request must carry it: `Authorization: Bearer <token>` over
|
|
11
|
+
* HTTP, `?token=<token>` for a WebSocket connection (a browser WebSocket cannot set headers).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { timingSafeEqual } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
const LOOPBACK_NAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
17
|
+
|
|
18
|
+
/** True for a bind address that only this computer can reach. */
|
|
19
|
+
export function isLoopback(host: string): boolean {
|
|
20
|
+
return LOOPBACK_NAMES.has(host);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The name of a Host header without its port: "localhost:3000" gives "localhost", "[::1]:80" gives "[::1]". */
|
|
24
|
+
export function hostName(header: string | undefined): string {
|
|
25
|
+
if (!header) return "";
|
|
26
|
+
if (header.startsWith("[")) return header.slice(0, header.indexOf("]") + 1).toLowerCase();
|
|
27
|
+
return (header.split(":")[0] ?? "").toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A peer bound to loopback accepts only a loopback Host header. */
|
|
31
|
+
export function hostAllowed(bindHost: string, header: string | undefined): boolean {
|
|
32
|
+
return !isLoopback(bindHost) || LOOPBACK_NAMES.has(hostName(header));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A request without Origin (not a browser) passes; a browser origin must be loopback or in the allowlist. */
|
|
36
|
+
export function originAllowed(origin: string | undefined, allowlist: readonly string[] = []): boolean {
|
|
37
|
+
if (origin === undefined) return true;
|
|
38
|
+
if (allowlist.includes(origin)) return true;
|
|
39
|
+
try {
|
|
40
|
+
return LOOPBACK_NAMES.has(new URL(origin).hostname);
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when no token is required, or when the presented token is the expected one (a constant-time comparison). */
|
|
47
|
+
export function tokenMatches(expected: string | undefined, presented: string | undefined | null): boolean {
|
|
48
|
+
if (expected === undefined) return true;
|
|
49
|
+
if (!presented) return false;
|
|
50
|
+
const a = Buffer.from(expected);
|
|
51
|
+
const b = Buffer.from(presented);
|
|
52
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The token of an `Authorization: Bearer <token>` header. */
|
|
56
|
+
export function bearerToken(header: string | undefined): string | undefined {
|
|
57
|
+
return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Procedures
|
|
3
|
+
*
|
|
4
|
+
* All procedures exported from this module.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { serverCreateProcedure, getPeer, getActivePeerIds } from "./server.create.js";
|
|
8
|
+
export type { ServerCreateInput, ServerCreateOutput } from "./server.create.js";
|
|
9
|
+
|
|
10
|
+
export { serverConnectProcedure } from "./server.connect.js";
|
|
11
|
+
export type { ServerConnectInput, ServerConnectOutput } from "./server.connect.js";
|
|
12
|
+
|
|
13
|
+
export { serverDisconnectProcedure } from "./server.disconnect.js";
|
|
14
|
+
export type { ServerDisconnectInput, ServerDisconnectOutput } from "./server.disconnect.js";
|
|
15
|
+
|
|
16
|
+
export { manifestGenerateProcedure } from "./manifest.generate.js";
|
|
17
|
+
export type { ManifestGenerateInput, ManifestGenerateOutput } from "./manifest.generate.js";
|
|
18
|
+
|
|
19
|
+
export { serverStartProcedure } from "./server.start.js";
|
|
20
|
+
export type { ServerStartInput, ServerStartOutput } from "./server.start.js";
|
|
21
|
+
|
|
22
|
+
export { serverStopProcedure } from "./server.stop.js";
|
|
23
|
+
export type { ServerStopInput, ServerStopOutput } from "./server.stop.js";
|
|
24
|
+
|
|
25
|
+
export { serverStatusProcedure } from "./server.status.js";
|
|
26
|
+
export type { ServerStatusInput, ServerStatusOutput } from "./server.status.js";
|
|
27
|
+
|
|
28
|
+
export { serverCallProcedure } from "./server.call.js";
|
|
29
|
+
export type { ServerCallInput, ServerCallOutput } from "./server.call.js";
|
|
30
|
+
|
|
31
|
+
export { serverConnectionsProcedure } from "./server.connections.js";
|
|
32
|
+
export type { ServerConnectionsInput, ServerConnectionsOutput } from "./server.connections.js";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedure: manifest.generate
|
|
3
|
+
* Generates procedure manifests in various formats
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createProcedure, jsonSchemaOf, type Procedure, PROCEDURE_REGISTRY } from "@procedures/client";
|
|
7
|
+
import { wrapSchema } from "../schema.js";
|
|
8
|
+
import { ManifestGenerateOutputSchema } from "../output-schemas.js";
|
|
9
|
+
import { manifestGenerateInputSchema } from "../input-schemas.js";
|
|
10
|
+
import type { ManifestGenerateInput, ManifestGenerateOutput, ProcedureInfo } from "../types.js";
|
|
11
|
+
import { generateTypeScript } from "../manifest/typescript.js";
|
|
12
|
+
import { generateJson } from "../manifest/json.js";
|
|
13
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
|
|
16
|
+
// Use pass-through schemas
|
|
17
|
+
const manifestGenerateOutputSchema = wrapSchema<ManifestGenerateOutput>(ManifestGenerateOutputSchema);
|
|
18
|
+
|
|
19
|
+
export const manifestGenerateProcedure: Procedure<
|
|
20
|
+
ManifestGenerateInput,
|
|
21
|
+
ManifestGenerateOutput,
|
|
22
|
+
{ description: string }
|
|
23
|
+
> = createProcedure()
|
|
24
|
+
.path(["manifest", "generate"])
|
|
25
|
+
.input(manifestGenerateInputSchema)
|
|
26
|
+
.output(manifestGenerateOutputSchema)
|
|
27
|
+
.meta({ description: "Generate procedure manifests in various formats" })
|
|
28
|
+
.handler(async (input: ManifestGenerateInput) => {
|
|
29
|
+
// Get all registered procedures
|
|
30
|
+
const allProcedures = PROCEDURE_REGISTRY.getAll();
|
|
31
|
+
|
|
32
|
+
// Filter by namespace if specified
|
|
33
|
+
let procedures = allProcedures;
|
|
34
|
+
if (input.namespace && input.namespace.length > 0) {
|
|
35
|
+
procedures = allProcedures.filter((proc) => {
|
|
36
|
+
// Check if procedure path starts with namespace
|
|
37
|
+
for (let i = 0; i < input.namespace!.length; i++) {
|
|
38
|
+
if (proc.path[i] !== input.namespace![i]) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Convert to ProcedureInfo format
|
|
47
|
+
const procedureInfos: ProcedureInfo[] = procedures.map((proc) => {
|
|
48
|
+
const meta = proc.metadata as { description?: string; internal?: boolean } | undefined;
|
|
49
|
+
const info: ProcedureInfo = {
|
|
50
|
+
path: proc.path,
|
|
51
|
+
};
|
|
52
|
+
if (meta?.description !== undefined) {
|
|
53
|
+
info.description = meta.description;
|
|
54
|
+
}
|
|
55
|
+
// The JSON Schemas of the input and the output, when the schemas can describe themselves
|
|
56
|
+
const inputSchema = jsonSchemaOf(proc.input, { io: "input" });
|
|
57
|
+
const outputSchema = jsonSchemaOf(proc.output, { io: "output" });
|
|
58
|
+
if (inputSchema) info.inputSchema = inputSchema;
|
|
59
|
+
if (outputSchema) info.outputSchema = outputSchema;
|
|
60
|
+
return info;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Determine formats to generate
|
|
64
|
+
const formats = input.formats ?? ["json"];
|
|
65
|
+
const result: ManifestGenerateOutput = {};
|
|
66
|
+
const filesWritten: string[] = [];
|
|
67
|
+
|
|
68
|
+
// Generate JSON manifest
|
|
69
|
+
if (formats.includes("json")) {
|
|
70
|
+
const jsonManifest = generateJson(procedureInfos);
|
|
71
|
+
result.json = jsonManifest;
|
|
72
|
+
|
|
73
|
+
if (input.outputDir) {
|
|
74
|
+
const filePath = join(input.outputDir, "procedures.json");
|
|
75
|
+
await ensureDir(dirname(filePath));
|
|
76
|
+
await writeFile(filePath, JSON.stringify(jsonManifest, null, 2));
|
|
77
|
+
filesWritten.push(filePath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate TypeScript declarations
|
|
82
|
+
if (formats.includes("typescript")) {
|
|
83
|
+
const typescript = generateTypeScript(procedureInfos);
|
|
84
|
+
result.typescript = typescript;
|
|
85
|
+
|
|
86
|
+
if (input.outputDir) {
|
|
87
|
+
const filePath = join(input.outputDir, "procedures.d.ts");
|
|
88
|
+
await ensureDir(dirname(filePath));
|
|
89
|
+
await writeFile(filePath, typescript);
|
|
90
|
+
filesWritten.push(filePath);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (filesWritten.length > 0) {
|
|
95
|
+
result.filesWritten = filesWritten;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return result;
|
|
99
|
+
})
|
|
100
|
+
.build();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ensure directory exists
|
|
104
|
+
*/
|
|
105
|
+
async function ensureDir(dir: string): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(dir, { recursive: true });
|
|
108
|
+
} catch {
|
|
109
|
+
// Directory may already exist
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type { ManifestGenerateInput, ManifestGenerateOutput };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedure: server.call
|
|
3
|
+
* Call a procedure on a remote peer through an outbound connection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createProcedure, pathToMethod, type Procedure } from "@procedures/client";
|
|
7
|
+
import { wrapSchema } from "../schema.js";
|
|
8
|
+
import { ServerCallOutputSchema } from "../output-schemas.js";
|
|
9
|
+
import { serverCallInputSchema } from "../input-schemas.js";
|
|
10
|
+
import { getConnection, connect } from "../connection/index.js";
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
export interface ServerCallInput {
|
|
17
|
+
/** Connection ID (from server.connect) */
|
|
18
|
+
connectionId?: string | undefined;
|
|
19
|
+
/** Remote address (alternative to connectionId - creates one-shot connection) */
|
|
20
|
+
address?: string | undefined;
|
|
21
|
+
/** Procedure path on the remote peer - dot-separated string or array */
|
|
22
|
+
path: string | string[];
|
|
23
|
+
/** Input payload for the remote procedure */
|
|
24
|
+
input?: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ServerCallOutput {
|
|
28
|
+
/** Result from the remote procedure */
|
|
29
|
+
result: unknown;
|
|
30
|
+
/** Connection ID used */
|
|
31
|
+
connectionId: string;
|
|
32
|
+
/** Procedure path called */
|
|
33
|
+
path: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Procedure
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
const serverCallOutputSchema = wrapSchema<ServerCallOutput>(ServerCallOutputSchema);
|
|
41
|
+
|
|
42
|
+
export const serverCallProcedure: Procedure<
|
|
43
|
+
ServerCallInput,
|
|
44
|
+
ServerCallOutput,
|
|
45
|
+
{ description: string; args: string[]; shorts: Record<string, string>; output: string }
|
|
46
|
+
> = createProcedure()
|
|
47
|
+
.path(["server", "call"])
|
|
48
|
+
.input(serverCallInputSchema)
|
|
49
|
+
.output(serverCallOutputSchema)
|
|
50
|
+
.meta({
|
|
51
|
+
description: "Call a procedure on a remote peer through an outbound connection",
|
|
52
|
+
args: ["path"],
|
|
53
|
+
shorts: { connectionId: "c", address: "a" },
|
|
54
|
+
output: "json",
|
|
55
|
+
})
|
|
56
|
+
.handler(async (input: ServerCallInput): Promise<ServerCallOutput> => {
|
|
57
|
+
// Normalize path: accept dot-separated string or array
|
|
58
|
+
const pathArray = typeof input.path === "string"
|
|
59
|
+
? input.path.split(".")
|
|
60
|
+
: input.path;
|
|
61
|
+
|
|
62
|
+
// Resolve connection: by ID, or create one-shot from address
|
|
63
|
+
let connectionId: string;
|
|
64
|
+
let client: import("@procedures/client").Client;
|
|
65
|
+
|
|
66
|
+
if (input.connectionId) {
|
|
67
|
+
const connection = getConnection(input.connectionId);
|
|
68
|
+
if (!connection) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Connection not found: ${input.connectionId}. Use 'server connect' first, or pass --address.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
connectionId = connection.id;
|
|
74
|
+
client = connection.client;
|
|
75
|
+
} else if (input.address) {
|
|
76
|
+
// Create a connection on-the-fly
|
|
77
|
+
const connection = await connect({ address: input.address });
|
|
78
|
+
connectionId = connection.id;
|
|
79
|
+
client = connection.client;
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error("Either connectionId or address is required");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Convert procedure path to Method format for client.call: the core mapping of every host
|
|
85
|
+
// (deep dive architecture review 3.3)
|
|
86
|
+
const method = pathToMethod(pathArray);
|
|
87
|
+
|
|
88
|
+
const result = await client.call(method, input.input ?? {});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
result,
|
|
92
|
+
connectionId,
|
|
93
|
+
path: pathArray,
|
|
94
|
+
};
|
|
95
|
+
})
|
|
96
|
+
.build();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedure: server.connect
|
|
3
|
+
* Connects to a remote peer for bidirectional RPC
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createProcedure, type Procedure } from "@procedures/client";
|
|
7
|
+
import { wrapSchema } from "../schema.js";
|
|
8
|
+
import { ServerConnectOutputSchema } from "../output-schemas.js";
|
|
9
|
+
import { serverConnectInputSchema } from "../input-schemas.js";
|
|
10
|
+
import type { ServerConnectInput, ServerConnectOutput } from "../types.js";
|
|
11
|
+
import { connect } from "../connection/index.js";
|
|
12
|
+
|
|
13
|
+
// Use pass-through schemas
|
|
14
|
+
const serverConnectOutputSchema = wrapSchema<ServerConnectOutput>(ServerConnectOutputSchema);
|
|
15
|
+
|
|
16
|
+
export const serverConnectProcedure: Procedure<
|
|
17
|
+
ServerConnectInput,
|
|
18
|
+
ServerConnectOutput,
|
|
19
|
+
{ description: string }
|
|
20
|
+
> = createProcedure()
|
|
21
|
+
.path(["server", "connect"])
|
|
22
|
+
.input(serverConnectInputSchema)
|
|
23
|
+
.output(serverConnectOutputSchema)
|
|
24
|
+
.meta({ description: "Connect to a remote peer for bidirectional RPC", args: ["address"] })
|
|
25
|
+
.handler(async (input: ServerConnectInput) => {
|
|
26
|
+
const connectOptions: Parameters<typeof connect>[0] = {
|
|
27
|
+
address: input.address,
|
|
28
|
+
};
|
|
29
|
+
if (input.transport !== undefined) {
|
|
30
|
+
connectOptions.transport = input.transport;
|
|
31
|
+
}
|
|
32
|
+
if (input.timeout !== undefined) {
|
|
33
|
+
connectOptions.timeout = input.timeout;
|
|
34
|
+
}
|
|
35
|
+
const connection = await connect(connectOptions);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
connectionId: connection.id,
|
|
39
|
+
remotePeerId: connection.remote.remotePeerId,
|
|
40
|
+
remoteProcedures: connection.remote.remoteProcedures,
|
|
41
|
+
};
|
|
42
|
+
})
|
|
43
|
+
.build();
|
|
44
|
+
|
|
45
|
+
export type { ServerConnectInput, ServerConnectOutput };
|