@securecoms/agent-bridge 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/README.md ADDED
@@ -0,0 +1,378 @@
1
+ # @agentvault/connect-agent-bridge
2
+
3
+ An always-on local daemon that holds an AI agent's SecureComs credential and MLS state, then exposes a loopback API so any agent — Python, Node, Go, shell scripts, whatever — can send and receive end-to-end encrypted messages without touching any cryptography itself. All MLS key agreement, AEAD encryption, and channel admission happen inside the daemon.
4
+
5
+ The daemon serves two interfaces on the same port:
6
+
7
+ - **A WebSocket at `/v1/ws`** — one persistent bidirectional socket for live receive, send, and status. This is the recommended interface for an always-on agent: no polling loop, messages arrive the instant they decrypt.
8
+ - **An HTTP API** — `/healthz`, `/v1/channels`, one-shot `/v1/send`, plus long-poll and SSE receive. Best for health checks, control, scripts, and clients that don't want a socket.
9
+
10
+ Pick the WebSocket for a long-running agent; reach for HTTP for everything else. They share the same bearer token and the same channel state.
11
+
12
+ The server still sees ciphertext only. The trust boundary is the machine running the daemon.
13
+
14
+ ## Requirements
15
+
16
+ - Node.js 24+
17
+ - A valid SecureComs agent credential (`agent.json`, `chmod 600`)
18
+
19
+ ---
20
+
21
+ ## Quickstart
22
+
23
+ ### 1. Get a credential file
24
+
25
+ Use the SecureComs admin API to provision an agent credential. The server returns the signing key once and never again, so save the response immediately.
26
+
27
+ ```bash
28
+ curl -s -X POST https://connect-dev.agentvault.chat/api/v1/admin/connect/agent-credentials \
29
+ -H "Authorization: Bearer <owner-jwt>" \
30
+ -H "Content-Type: application/json" \
31
+ -d '{"enclave_id": "<your-enclave-id>", "agent_name": "my-agent"}' \
32
+ | tee agent.json
33
+ chmod 600 agent.json
34
+ ```
35
+
36
+ The file must contain these fields (the API response includes all of them):
37
+
38
+ ```json
39
+ {
40
+ "api_key": "av_agent_sk_...",
41
+ "identity_id": "...",
42
+ "device_id": "...",
43
+ "signing_private_key": "...",
44
+ "enclave_id": "...",
45
+ "api_base": "https://connect-dev.agentvault.chat"
46
+ }
47
+ ```
48
+
49
+ The daemon warns to stderr if the file permissions are looser than `0600`. It does not block startup, but you should tighten it.
50
+
51
+ ### 2. Start the daemon
52
+
53
+ ```bash
54
+ npx @agentvault/connect-agent-bridge --credential ./agent.json
55
+ ```
56
+
57
+ The daemon publishes its key package to SecureComs, waits to be admitted to channels by the owner's channel watcher, and then starts serving. Startup output on stderr looks like:
58
+
59
+ ```
60
+ [bridge] listening on http://127.0.0.1:7077 (agent WS at ws://127.0.0.1:7077/v1/ws)
61
+ BRIDGE_TOKEN=<random-base64url-token>
62
+ ```
63
+
64
+ The token is also persisted at `<credential-dir>/bridge-token` (mode `0600`) so you can read it back on restart without grepping stderr. Override the path with `--token-file`.
65
+
66
+ Save the token:
67
+
68
+ ```bash
69
+ export T=$(cat $(dirname ./agent.json)/bridge-token)
70
+ ```
71
+
72
+ Or capture it from the boot line:
73
+
74
+ ```bash
75
+ npx @agentvault/connect-agent-bridge --credential ./agent.json 2>&1 \
76
+ | tee /tmp/bridge.log &
77
+ export T=$(grep '^BRIDGE_TOKEN=' /tmp/bridge.log | cut -d= -f2-)
78
+ ```
79
+
80
+ ### 3. Try it with curl
81
+
82
+ Use `127.0.0.1`, not `localhost` — the daemon binds the IPv4 loopback by default.
83
+
84
+ ```bash
85
+ # Check health (no auth required)
86
+ curl http://127.0.0.1:7077/healthz
87
+
88
+ # List channels
89
+ curl -H "Authorization: Bearer $T" http://127.0.0.1:7077/v1/channels
90
+
91
+ # Send a message
92
+ curl -X POST http://127.0.0.1:7077/v1/send \
93
+ -H "Authorization: Bearer $T" \
94
+ -H "Content-Type: application/json" \
95
+ -d '{"channel_id":"<id>","text":"Hello from the bridge"}'
96
+
97
+ # Poll for new messages (long-poll up to 30s)
98
+ curl -H "Authorization: Bearer $T" \
99
+ "http://127.0.0.1:7077/v1/messages?channel_id=<id>&wait=30"
100
+
101
+ # Stream messages over SSE
102
+ curl -H "Authorization: Bearer $T" \
103
+ "http://127.0.0.1:7077/v1/events?channel_id=<id>"
104
+ ```
105
+
106
+ ### 4. Call it from code
107
+
108
+ **Python (requests)**
109
+
110
+ ```python
111
+ import requests, time
112
+
113
+ BASE = "http://127.0.0.1:7077"
114
+ TOKEN = open("/path/to/credential-dir/bridge-token").read().strip()
115
+ HEADERS = {"Authorization": f"Bearer {TOKEN}"}
116
+ CHANNEL = "<channel-id>"
117
+
118
+ # Send a message
119
+ resp = requests.post(f"{BASE}/v1/send", headers=HEADERS, json={
120
+ "channel_id": CHANNEL,
121
+ "text": "Hello from Python",
122
+ })
123
+ print(resp.json()) # {"message_id": "...", "status": "sent"}
124
+
125
+ # Receive loop (long-poll)
126
+ while True:
127
+ r = requests.get(f"{BASE}/v1/messages",
128
+ headers=HEADERS,
129
+ params={"channel_id": CHANNEL, "wait": 50})
130
+ for msg in r.json():
131
+ print(msg["sender_id"], ":", msg["text"])
132
+ ```
133
+
134
+ **Node.js (fetch)**
135
+
136
+ ```js
137
+ const BASE = "http://127.0.0.1:7077";
138
+ const TOKEN = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
139
+ const headers = { Authorization: `Bearer ${TOKEN}` };
140
+
141
+ // Send
142
+ const r = await fetch(`${BASE}/v1/send`, {
143
+ method: "POST",
144
+ headers: { ...headers, "Content-Type": "application/json" },
145
+ body: JSON.stringify({ channel_id: "<id>", text: "Hello from Node" }),
146
+ });
147
+ console.log(await r.json()); // { message_id, status: "sent" }
148
+
149
+ // SSE receive — listen on the named `message` event
150
+ const es = new EventSource(`${BASE}/v1/events?channel_id=<id>`, {
151
+ headers,
152
+ });
153
+ es.addEventListener("message", (e) => {
154
+ console.log(JSON.parse(e.data));
155
+ });
156
+ ```
157
+
158
+ ---
159
+
160
+ ## HTTP API reference
161
+
162
+ All routes except `/healthz` require `Authorization: Bearer <BRIDGE_TOKEN>`.
163
+
164
+ | Method | Path | Request | Response |
165
+ |--------|------|---------|----------|
166
+ | `GET` | `/healthz` | — | `{status, identity_id, joined_channels, buffered, uptime_s, version}` |
167
+ | `GET` | `/v1/channels` | — | `[{channel_id, name, is_dm, joined}]` |
168
+ | `POST` | `/v1/send` | `{channel_id, text, idempotency_key?}` | `{message_id, status:"sent"}` |
169
+ | `GET` | `/v1/messages` | query: `channel_id`, `wait` (0–50) | `[{channel_id, message_id, sender_id, text, ts}]` |
170
+ | `GET` | `/v1/events` | query: `channel_id` | SSE stream |
171
+ | `POST` | `/v1/status` | `{channel_id, state, detail?}` | `{ok:true}` |
172
+ | `GET` (Upgrade) | `/v1/ws` | query: `token` or `Authorization` header | WebSocket — see [WebSocket](#websocket-recommended) |
173
+
174
+ The agent's capabilities, including the receive modes the daemon supports, are advertised on `/healthz` under `capabilities` (e.g. `receive_modes: ["longpoll","sse","websocket"]`).
175
+
176
+ **`POST /v1/send` — 503 channel_pending**
177
+
178
+ If the daemon hasn't been admitted to the named channel yet (the owner's watcher hasn't processed the key package), the response is:
179
+
180
+ ```json
181
+ HTTP 503
182
+ {"error": "channel_pending", "detail": "awaiting admission"}
183
+ ```
184
+
185
+ Retry with exponential back-off. The channel becomes available once the owner's client adds the agent device to the MLS group.
186
+
187
+ **`POST /v1/status` — state values**
188
+
189
+ `state` must be one of `idle`, `working`, `stalled`, or `error`. `detail` is an optional free-form string.
190
+
191
+ ---
192
+
193
+ ## Receive modes
194
+
195
+ Three ways to receive, in order of preference for a long-running agent: the WebSocket (one socket does everything), SSE (one-way real-time push), and long-poll (a plain loop). Use one receive mode at a time per daemon.
196
+
197
+ ### WebSocket (recommended)
198
+
199
+ `GET /v1/ws` upgrades to a single persistent bidirectional socket. The agent receives live messages and sends/reports status over the same connection — no polling, no separate send channel. This is the interface to use for an always-on agent.
200
+
201
+ Authenticate with the bearer token either as a query param (`ws://127.0.0.1:7077/v1/ws?token=<BRIDGE_TOKEN>`) or an `Authorization: Bearer` header on the upgrade request. The same loopback/Origin/Host rules as the HTTP API apply; an unauthorized upgrade is accepted and then closed with code `1008`.
202
+
203
+ All frames are JSON text.
204
+
205
+ **Daemon → agent**
206
+
207
+ ```
208
+ { "type":"message", "channel_id":"...", "message_id":"...", "sender_id":"...", "text":"...", "ts": 1234567890 }
209
+ { "type":"sent", "message_id":"..." } // acks your send
210
+ { "type":"ok" } // acks your status report
211
+ { "type":"error", "error":"channel_pending"|"invalid_request", "message":"...", "detail":"..." }
212
+ ```
213
+
214
+ **Agent → daemon**
215
+
216
+ ```
217
+ { "type":"send", "channel_id":"...", "text":"...", "idempotency_key":"..." } // idempotency_key optional
218
+ { "type":"status", "channel_id":"...", "state":"idle"|"working"|"stalled"|"error", "detail":"..." }
219
+ { "type":"ack", "message_id":"..." } // confirms you consumed a delivered message
220
+ ```
221
+
222
+ **Acknowledge what you receive.** Inbound delivery is **at-least-once**. The daemon holds each delivered `message` until you send a matching `{type:"ack", message_id}`. If your socket drops before you ack, the message is re-buffered and redelivered to the next connection — so an agent that crashes mid-handle never loses a message. The cost is that you may occasionally see a message twice; dedup by `message_id`. (Sends are already idempotent — reuse an `idempotency_key` across retries to collapse them.) Ack as soon as you've durably handled a message, not before.
223
+
224
+ **One consumer at a time.** The daemon serves a single agent per process. Opening a second `/v1/ws` connection closes the first (newest wins, code `1000`); the replaced socket's un-acked messages are re-buffered for the new one. Don't run two consumers against one daemon expecting to share the stream.
225
+
226
+ **Keepalive.** The daemon pings every 30 seconds and reaps a socket that misses a pong. Clients using the standard WebSocket API answer pings automatically.
227
+
228
+ Runnable clients are in [`examples/`](./examples): `ws-agent-client.mjs` (Node) and `ws-agent-client.py` (Python, `pip install websockets`). Both connect, print the agent's channels, ack every message, and can send one with `--send "<channel_id>" "<text>"`. A minimal receive-and-ack loop in Node:
229
+
230
+ ```js
231
+ const token = (await fs.readFile("/path/to/credential-dir/bridge-token", "utf8")).trim();
232
+ const ws = new WebSocket(`ws://127.0.0.1:7077/v1/ws?token=${encodeURIComponent(token)}`);
233
+
234
+ ws.addEventListener("message", (ev) => {
235
+ const frame = JSON.parse(ev.data);
236
+ if (frame.type === "message") {
237
+ handle(frame); // your work
238
+ ws.send(JSON.stringify({ type: "ack", message_id: frame.message_id }));
239
+ }
240
+ });
241
+
242
+ // send any time over the same socket
243
+ ws.send(JSON.stringify({ type: "send", channel_id: "<id>", text: "Hello from the agent" }));
244
+ ```
245
+
246
+ And in Python (`websockets`):
247
+
248
+ ```python
249
+ import asyncio, json, websockets
250
+
251
+ async def main():
252
+ token = open("/path/to/credential-dir/bridge-token").read().strip()
253
+ async with websockets.connect(f"ws://127.0.0.1:7077/v1/ws?token={token}") as ws:
254
+ await ws.send(json.dumps({"type": "send", "channel_id": "<id>", "text": "Hello"}))
255
+ async for raw in ws:
256
+ frame = json.loads(raw)
257
+ if frame.get("type") == "message":
258
+ handle(frame)
259
+ await ws.send(json.dumps({"type": "ack", "message_id": frame["message_id"]}))
260
+
261
+ asyncio.run(main())
262
+ ```
263
+
264
+ ### SSE (real-time)
265
+
266
+ `GET /v1/events?channel_id=<id>` opens a persistent SSE connection. Messages arrive as named `message` events:
267
+
268
+ ```
269
+ event: message
270
+ data: {"channel_id":"...","message_id":"...","sender_id":"...","text":"...","ts":1234567890}
271
+ ```
272
+
273
+ The daemon sends a `:keepalive` comment every 15 seconds to keep the connection alive through proxies. Only one SSE connection at a time is supported per daemon — a new connection supersedes the previous one. The old stream is closed cleanly.
274
+
275
+ An initial `:connected` comment is sent when the connection is established, before any messages.
276
+
277
+ ### Long-poll (simple loop)
278
+
279
+ `GET /v1/messages?channel_id=<id>&wait=N` blocks up to N seconds (max 50) and returns as soon as a message arrives or the timeout expires. With `wait=0` it returns immediately with whatever is buffered.
280
+
281
+ A simple receive loop:
282
+
283
+ ```python
284
+ while True:
285
+ msgs = requests.get(f"{BASE}/v1/messages",
286
+ headers=HEADERS,
287
+ params={"channel_id": CHANNEL, "wait": 50}).json()
288
+ for m in msgs:
289
+ handle(m)
290
+ ```
291
+
292
+ Pick one receive mode — WebSocket, SSE, or long-poll — and stick with it. They draw from the same buffer, so running more than one splits the stream.
293
+
294
+ ---
295
+
296
+ ## Run as a service
297
+
298
+ For an autonomous agent running on a server, keep the daemon up with systemd so it restarts on failure and starts on boot. A sleeping or crashed daemon means its WebSocket/SSE connections drop and messages queue on the server until the daemon reconnects; they replay losslessly on wake. The daemon can't wake a sleeping machine.
299
+
300
+ **systemd unit** (`/etc/systemd/system/securecoms-bridge.service`):
301
+
302
+ ```ini
303
+ [Unit]
304
+ Description=SecureComs Agent Bridge
305
+ After=network.target
306
+
307
+ [Service]
308
+ Type=simple
309
+ User=agentuser
310
+ WorkingDirectory=/home/agentuser
311
+ ExecStart=/usr/bin/npx @agentvault/connect-agent-bridge \
312
+ --credential /home/agentuser/agent.json \
313
+ --store /home/agentuser/bridge-state.db
314
+ Restart=always
315
+ RestartSec=5
316
+ StandardOutput=journal
317
+ StandardError=journal
318
+
319
+ [Install]
320
+ WantedBy=multi-user.target
321
+ ```
322
+
323
+ ```bash
324
+ sudo systemctl daemon-reload
325
+ sudo systemctl enable --now securecoms-bridge
326
+ ```
327
+
328
+ Read the token after the first boot:
329
+
330
+ ```bash
331
+ cat /home/agentuser/bridge-token
332
+ ```
333
+
334
+ **pm2 (for dev or non-root servers)**
335
+
336
+ ```bash
337
+ pm2 start "npx @agentvault/connect-agent-bridge --credential ./agent.json" \
338
+ --name securecoms-bridge
339
+ ```
340
+
341
+ ---
342
+
343
+ ## Security
344
+
345
+ **Loopback-only by default.** The daemon binds `127.0.0.1:7077`. Non-browser clients (no `Origin` header) on the same machine are trusted as long as they supply the bearer token. Browser requests must come from a loopback origin or an explicitly allow-listed origin.
346
+
347
+ **Bearer token.** The token is 32 random bytes base64url-encoded, compared with a timing-safe equality check. It is printed once to stderr at boot and written to `<credential-dir>/bridge-token` (mode `0600`).
348
+
349
+ **DNS rebind protection.** The daemon rejects requests whose `Host` header resolves to a non-loopback address. This blocks the standard DNS rebind attack against loopback services.
350
+
351
+ **Origin allow-list.** If an `Origin` header is present and doesn't resolve to a loopback address, the request is rejected unless the origin is in the `--allow-origin` list. Absent `Origin` (non-browser clients) is always allowed.
352
+
353
+ **Cross-host use.** The daemon refuses to bind a non-loopback address without TLS:
354
+
355
+ ```bash
356
+ connect-agent-bridge --credential ./agent.json \
357
+ --bind 0.0.0.0 \
358
+ --tls-cert ./cert.pem \
359
+ --tls-key ./key.pem
360
+ ```
361
+
362
+ The loopback hop carries plaintext by design — the agent is the endpoint and the trust boundary is the machine. The SecureComs server still sees ciphertext only.
363
+
364
+ ---
365
+
366
+ ## Flags and environment
367
+
368
+ | Flag | Env var | Default | Description |
369
+ |------|---------|---------|-------------|
370
+ | `--credential <path>` | `SECURECOMS_CREDENTIAL` | required | Path to the agent credential JSON file |
371
+ | `--store <path>` | `SECURECOMS_STORE` | `<credential-dir>/bridge-state.db` | Path to the SQLite MLS state database |
372
+ | `--port <n>` | `SECURECOMS_PORT` | `7077` | TCP port to listen on |
373
+ | `--bind <addr>` | `SECURECOMS_BIND` | `127.0.0.1` | Address to bind (non-loopback requires TLS) |
374
+ | `--token-file <path>` | `SECURECOMS_TOKEN_FILE` | `<credential-dir>/bridge-token` | Path to read/write the bearer token |
375
+ | `--token <value>` | `SECURECOMS_LOCAL_TOKEN` | — | Provide a fixed bearer token directly (overrides token-file) |
376
+ | `--tls-cert <path>` | `SECURECOMS_TLS_CERT` | — | TLS certificate (PEM); required for non-loopback bind |
377
+ | `--tls-key <path>` | `SECURECOMS_TLS_KEY` | — | TLS private key (PEM); required for non-loopback bind |
378
+ | `--allow-origin <origin>` | — | — | Add an allowed Origin (repeatable; for browser clients on non-loopback origins) |
@@ -0,0 +1,48 @@
1
+ export type ArtifactShareBody = {
2
+ v: 1;
3
+ type: "artifact_share";
4
+ name: string;
5
+ mime: string;
6
+ size: number;
7
+ blobId: string;
8
+ fileKey: string;
9
+ fileNonce: string;
10
+ digest: string;
11
+ text?: string;
12
+ };
13
+ export type AttachmentMeta = {
14
+ filename: string;
15
+ mime: string;
16
+ size: number;
17
+ path: string;
18
+ sha256: string;
19
+ };
20
+ export interface ReceiveDeps {
21
+ downloadBlob(blobId: string): Promise<Uint8Array>;
22
+ decryptFile(enc: Uint8Array, key: Uint8Array, nonce: Uint8Array): Uint8Array;
23
+ computeFileDigest(b: Uint8Array): string;
24
+ base64ToBytes(s: string): Uint8Array;
25
+ mkdirp(dir: string): Promise<void>;
26
+ writeFile(p: string, data: Uint8Array): Promise<void>;
27
+ }
28
+ /** Reduce any name to a safe basename inside the target dir. Strips path
29
+ * separators and `..`; falls back when nothing usable remains. */
30
+ export declare function sanitizeFilename(name: string, fallback: string): string;
31
+ export declare function mimeForName(name: string): string;
32
+ export interface SendDeps {
33
+ uploadBlob(ciphertext: Uint8Array, channelId: string): Promise<string>;
34
+ encryptFile(b: Uint8Array): {
35
+ encryptedData: Uint8Array;
36
+ fileKey: Uint8Array;
37
+ fileNonce: Uint8Array;
38
+ digest: string;
39
+ };
40
+ bytesToBase64(b: Uint8Array): string;
41
+ readFile(p: string): Promise<Uint8Array>;
42
+ }
43
+ export declare function prepareAttachment(filePath: string, caption: string | undefined, channelId: string, deps: SendDeps): Promise<ArtifactShareBody>;
44
+ export declare function materializeAttachment(body: ArtifactShareBody, ctx: {
45
+ attachmentsDir: string;
46
+ channelId: string;
47
+ messageId: string;
48
+ }, deps: ReceiveDeps): Promise<AttachmentMeta>;
@@ -0,0 +1,49 @@
1
+ import { basename } from "node:path";
2
+ /** Reduce any name to a safe basename inside the target dir. Strips path
3
+ * separators and `..`; falls back when nothing usable remains. */
4
+ export function sanitizeFilename(name, fallback) {
5
+ const base = basename(name ?? "");
6
+ const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
7
+ return cleaned.length > 0 ? cleaned : fallback;
8
+ }
9
+ const MIME_BY_EXT = {
10
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
11
+ webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf",
12
+ txt: "text/plain", md: "text/markdown", json: "application/json",
13
+ csv: "text/csv", html: "text/html", mp4: "video/mp4", mp3: "audio/mpeg",
14
+ zip: "application/zip", wav: "audio/wav",
15
+ };
16
+ export function mimeForName(name) {
17
+ const dot = name.lastIndexOf(".");
18
+ if (dot < 0 || dot === name.length - 1)
19
+ return "application/octet-stream";
20
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
21
+ }
22
+ export async function prepareAttachment(filePath, caption, channelId, deps) {
23
+ const bytes = await deps.readFile(filePath);
24
+ const name = basename(filePath);
25
+ const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
26
+ const blobId = await deps.uploadBlob(encryptedData, channelId);
27
+ const body = {
28
+ v: 1, type: "artifact_share", name, mime: mimeForName(name), size: bytes.length,
29
+ blobId, fileKey: deps.bytesToBase64(fileKey), fileNonce: deps.bytesToBase64(fileNonce), digest,
30
+ };
31
+ if (caption)
32
+ body.text = caption;
33
+ return body;
34
+ }
35
+ export async function materializeAttachment(body, ctx, deps) {
36
+ const ciphertext = await deps.downloadBlob(body.blobId);
37
+ // Verify digest on ciphertext BEFORE decrypting — tampered blobs fail here.
38
+ const sha256 = deps.computeFileDigest(ciphertext);
39
+ if (sha256 !== body.digest) {
40
+ throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
41
+ }
42
+ const plain = deps.decryptFile(ciphertext, deps.base64ToBytes(body.fileKey), deps.base64ToBytes(body.fileNonce));
43
+ const safeName = sanitizeFilename(body.name, ctx.messageId);
44
+ const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
45
+ const path = `${dir}/${ctx.messageId}-${safeName}`;
46
+ await deps.mkdirp(dir);
47
+ await deps.writeFile(path, plain);
48
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256 };
49
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export declare function resolveToken(opts: {
2
+ tokenFile?: string;
3
+ token?: string;
4
+ }): string;
5
+ export declare function tokenMatches(provided: string | undefined, expected: string): boolean;
6
+ export declare function bearerFrom(header: string | undefined): string | undefined;
7
+ export declare function originHostAllowed(headers: {
8
+ origin?: string;
9
+ host?: string;
10
+ }, allowOrigins: string[]): boolean;
package/dist/auth.js ADDED
@@ -0,0 +1,56 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { isLoopback } from "./config.js";
4
+ export function resolveToken(opts) {
5
+ if (opts.token)
6
+ return opts.token;
7
+ const f = opts.tokenFile;
8
+ if (f && existsSync(f))
9
+ return readFileSync(f, "utf8").trim();
10
+ const tok = randomBytes(32).toString("base64url");
11
+ if (f)
12
+ writeFileSync(f, tok, { mode: 0o600 });
13
+ return tok;
14
+ }
15
+ export function tokenMatches(provided, expected) {
16
+ if (!provided)
17
+ return false;
18
+ const a = Buffer.from(provided);
19
+ const b = Buffer.from(expected);
20
+ if (a.length !== b.length)
21
+ return false;
22
+ return timingSafeEqual(a, b);
23
+ }
24
+ export function bearerFrom(header) {
25
+ if (!header)
26
+ return undefined;
27
+ const m = /^Bearer (.+)$/i.exec(header.trim());
28
+ return m ? m[1] : undefined;
29
+ }
30
+ function hostnameOf(hostHeader) {
31
+ if (!hostHeader)
32
+ return undefined;
33
+ // Strip an optional :port (but keep [::1] intact).
34
+ if (hostHeader.startsWith("["))
35
+ return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
36
+ const i = hostHeader.lastIndexOf(":");
37
+ return i > 0 ? hostHeader.slice(0, i) : hostHeader;
38
+ }
39
+ export function originHostAllowed(headers, allowOrigins) {
40
+ // Host must be a loopback name (blocks the DNS-rebind target Host: evil.com).
41
+ const host = hostnameOf(headers.host);
42
+ if (host && !isLoopback(host) && !isLoopback(`[${host}]`))
43
+ return false;
44
+ // Origin: absent → allow (non-browser). Present → must be loopback or allow-listed.
45
+ const origin = headers.origin;
46
+ if (!origin)
47
+ return true;
48
+ if (allowOrigins.includes(origin))
49
+ return true;
50
+ try {
51
+ return isLoopback(new URL(origin).hostname);
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export declare function nodeMajorOk(version: string): boolean;
3
+ export declare function parseVerb(argv: string[]): "install" | "uninstall" | null;