trantor 0.18.113 → 0.18.115

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.113",
3
+ "version": "0.18.115",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
@@ -4,6 +4,14 @@ import { loadOrCreate } from "../../lib/identity.mjs";
4
4
  import { ensureEnrolled } from "../../lib/enroll.mjs";
5
5
  import { sfetchJson } from "../../lib/signed-fetch.mjs";
6
6
 
7
+ export async function unregisterSwapStage(hub, project, session) {
8
+ const identity = loadOrCreate(session, "agent");
9
+ const response = await sfetchJson(`${hub}/unregister`, {
10
+ identity, payload: { session, project }, signal: AbortSignal.timeout(8000),
11
+ });
12
+ if (!response.ok) throw new Error(`replacement deregistration failed: ${response.status}`);
13
+ }
14
+
7
15
  // A replacement registers before it touches the old runner's worktree or queue.
8
16
  export async function awaitSwapRelease(env = process.env) {
9
17
  if (!env.CREW_SWAP_STAGE) return;
@@ -25,4 +33,5 @@ export async function awaitSwapRelease(env = process.env) {
25
33
  }
26
34
  const handoff = JSON.parse(readFileSync(`${stage}.release`, "utf8"));
27
35
  if (handoff.agent !== env.CREW_SWAP_AGENT) throw new Error("swap release names a different seat");
36
+ await unregisterSwapStage(env.RELAY_URL, env.RELAY_PROJECT, session);
28
37
  }
package/bin/crew/swap.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { unregisterSwapStage } from "./stage.mjs";
1
2
  import { updatePersona } from "../../lib/persona.mjs";
2
3
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -85,7 +86,7 @@ async function hubRequest(ctx, path, body) {
85
86
  return response.json;
86
87
  }
87
88
 
88
- export function swapRuntime(ctx, adapters, spawn) {
89
+ export function swapRuntime(ctx, adapters, spawn, { now = Date.now, pause = setTimeout, requestTimeoutMs = 8000 } = {}) {
89
90
  let pids = [];
90
91
  let replacementPid;
91
92
  return {
@@ -97,14 +98,23 @@ export function swapRuntime(ctx, adapters, spawn) {
97
98
  spawn(staged, { ...seat, persona: stage.label });
98
99
  },
99
100
  async ready(stage) {
100
- const deadline = Date.now() + 30000;
101
+ const deadline = now() + 30000;
101
102
  while (!existsSync(`${stage.path}.ready`)) {
102
- if (Date.now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
103
- await setTimeout(100);
103
+ if (now() > deadline) throw new Error("replacement did not join the bus within 30s; old seat unchanged");
104
+ await pause(100);
104
105
  }
105
106
  const ready = JSON.parse(readFileSync(`${stage.path}.ready`, "utf8"));
106
- const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, { project: ctx.project });
107
- if (!response.ok || !response.json?.peers?.some(p => p.session === ready.session)) throw new Error("replacement registration is not visible on the bus");
107
+ let failure = "status=0 reason=readiness deadline elapsed timedOut=false";
108
+ let visible = false;
109
+ while (now() < deadline) {
110
+ const response = await signedGet(`${ctx.hub}/peers?project=${encodeURIComponent(ctx.project)}`, {
111
+ project: ctx.project, timeoutMs: Math.min(requestTimeoutMs, deadline - now()),
112
+ });
113
+ if (response.ok && response.json?.peers?.some(p => p.session === ready.session)) { visible = true; break; }
114
+ failure = `status=${response.status} reason=${response.reason || response.json?.error || "replacement absent from roster"} timedOut=${response.timedOut === true}`;
115
+ await pause(Math.min(100, Math.max(0, deadline - now())));
116
+ }
117
+ if (!visible) throw new Error(`replacement registration is not visible on the bus: ${failure}`);
108
118
  process.kill(ready.pid, 0);
109
119
  replacementPid = ready.pid;
110
120
  return ready.session;
@@ -117,7 +127,10 @@ export function swapRuntime(ctx, adapters, spawn) {
117
127
  await down(ctx, [agent], adapters);
118
128
  signalProcesses(pids, "SIGKILL");
119
129
  },
120
- async cancel(stage) { await down(ctx, [stage.label], adapters); },
130
+ async cancel(stage) {
131
+ await down(ctx, [stage.label], adapters);
132
+ await unregisterSwapStage(ctx.hub, ctx.project, `${stage.label}:${ctx.project}`);
133
+ },
121
134
  release(stage, persona, cursor) {
122
135
  writeRows(ctx, readRows(ctx).map(row => row.project === ctx.project && row.persona === stage.label ? { ...row, persona } : row));
123
136
  writeFileSync(runnerRecord(ctx, ctx.project, persona), JSON.stringify({ label: stage.label, dir: ctx.dir }));
@@ -154,7 +167,10 @@ export async function performSwap(ctx, options, runtime) {
154
167
  } catch (error) {
155
168
  if (!transferred) {
156
169
  if (frozen) await runtime.resume();
157
- if (stage) await runtime.cancel(stage);
170
+ if (stage) {
171
+ try { await runtime.cancel(stage); }
172
+ catch (cleanupError) { error.message += `; replacement cleanup failed: ${cleanupError.message}`; }
173
+ }
158
174
  }
159
175
  result.reason = error.message;
160
176
  if (transferred) result.reason += "; handoff recorded on hub; old seat held for recovery";
package/bin/pair.mjs CHANGED
@@ -9,10 +9,11 @@ import { spawnSync } from "node:child_process";
9
9
 
10
10
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
11
11
  const args = process.argv.slice(2);
12
- const USAGE = "usage: trantor pair [--role owner] [--sim] [--json]";
12
+ const USAGE = "usage: trantor pair [--role owner] [--sim] [--tailnet] [--json]";
13
13
  if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
14
14
  const asJson = args.includes("--json");
15
15
  const openSim = args.includes("--sim");
16
+ const forceTailnet = args.includes("--tailnet");
16
17
  const roleIdx = args.indexOf("--role");
17
18
  const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
18
19
  if (!role || role.startsWith("--")) {
@@ -63,14 +64,30 @@ if (!r.ok) {
63
64
  process.exit(1);
64
65
  }
65
66
 
66
- const link = `trantor://enroll?hub=${encodeURIComponent(hub)}&token=${j.token}`;
67
+ // v1.4: the link carries the hub's public address (Tailscale Funnel) so the phone pairs without
68
+ // Tailscale. /invite itself always goes to the tailnet hub — owner routes never answer in public.
69
+ // /health reports publicUrl; --tailnet forces the tailnet address. No publicUrl (or /health
70
+ // unreachable — the invite already minted) falls back to the tailnet hub.
71
+ const linkHub = (async () => {
72
+ if (forceTailnet) return { hub, public: false };
73
+ try {
74
+ const h = await fetch(`${hub}/health`).then((r) => r.json()).catch(() => ({}));
75
+ const pub = String(h.publicUrl || "").trim().replace(/\/+$/, "");
76
+ if (pub) return { hub: pub, public: true };
77
+ } catch {}
78
+ return { hub, public: false };
79
+ })();
80
+
81
+ const used = await linkHub;
82
+ const link = `trantor://enroll?hub=${encodeURIComponent(used.hub)}&token=${j.token}`;
67
83
  const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
68
84
  const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
69
85
 
70
86
  if (asJson) {
71
- console.log(JSON.stringify({ link, token: j.token, hub, expiresAt }));
87
+ console.log(JSON.stringify({ link, token: j.token, hub: used.hub, hubPublic: used.public, expiresAt }));
72
88
  } else {
73
- console.log(`Pair link (single use):\n${link}\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
89
+ const where = used.public ? "public — the phone needs no Tailscale" : "tailnet — the phone needs Tailscale";
90
+ console.log(`Pair link (single use):\n${link}\nHub in the link: ${used.hub} (${where}).\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
74
91
  }
75
92
 
76
93
  if (openSim) {
package/hub/auth.mjs CHANGED
@@ -5,7 +5,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
5
5
  import { publicView } from "../lib/identity.mjs";
6
6
  import { IDENTITY_KINDS } from "../lib/store-contract.mjs";
7
7
 
8
- export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE }) {
8
+ export function createAuthRuntime({ state, markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS, LOOPBACK_BIND, ENROLL_MODE, seenNonces = new Map() }) {
9
9
  const now = () => Date.now();
10
10
  function rawBody(req) {
11
11
  if (req._rawBody !== undefined) return Promise.resolve(req._rawBody);
@@ -287,13 +287,13 @@ function inboxReadable(auth, msg, session) {
287
287
  function canUseInboxSession(auth, session) {
288
288
  return !auth?.identity || String(auth.identity.name || "") === String(session || "");
289
289
  }
290
- const seenNonces = new Map();
291
290
 
292
- function handleEnrollment({ req, res, P, u, b0 }) {
291
+ function handleEnrollment({ req, res, P, u, b0, inviteOnly = false }) {
293
292
  if (req.method !== "POST" || P !== "/enroll") return false;
294
293
  const raw = req._rawBody || "";
295
294
  const verified = verifyRequest({ headers: req.headers, method: req.method, path: authPath(u), body: raw });
296
295
  if (!verified.ok) return json(res, 401, { error: verified.reason || "bad signature" });
296
+ if (inviteOnly && !b0?.token) return json(res, 403, { error: "invite token required" });
297
297
  const requestedKind = String(b0.kind || "agent").slice(0, 40);
298
298
  if (!IDENTITY_KINDS.includes(requestedKind)) {
299
299
  return json(res, 400, { error: `kind must be one of: ${IDENTITY_KINDS.join(", ")}`, allowedKinds: IDENTITY_KINDS });
@@ -305,9 +305,9 @@ const seenNonces = new Map();
305
305
  const token = String(b0.token || "");
306
306
  const bootstrap = String(process.env.RELAY_BOOTSTRAP_TOKEN || "");
307
307
  const noIdentitiesYet = Object.keys(state.identities || {}).length === 0;
308
- const tokenMatchesBootstrap = !!bootstrap && !!token && token.length === bootstrap.length &&
308
+ const tokenMatchesBootstrap = !inviteOnly && !!bootstrap && !!token && token.length === bootstrap.length &&
309
309
  timingSafeEqual(Buffer.from(token), Buffer.from(bootstrap));
310
- if (bootstrap && noIdentitiesYet && tokenMatchesBootstrap) {
310
+ if (!inviteOnly && bootstrap && noIdentitiesYet && tokenMatchesBootstrap) {
311
311
  scopes = [cleanScope({ project: "*", role: "owner" })].filter(Boolean);
312
312
  enrolledBy = "bootstrap";
313
313
  } else if (token) {
package/hub/public.mjs ADDED
@@ -0,0 +1,108 @@
1
+ import { isIP } from "node:net";
2
+
3
+ const ALLOWED = new Set([
4
+ ...["/health", "/projects", "/catchup", "/tasks", "/card", "/events", "/stream",
5
+ "/inbox", "/poll", "/recent", "/peers", "/holds", "/verify-gates", "/proposals",
6
+ "/balances", "/economics", "/session/chat", "/session/prompt", "/crew/roster", "/_op"]
7
+ .map(path => `GET ${path}`),
8
+ ...["/enroll", "/task/update", "/task/checklist-toggle", "/send", "/delivered",
9
+ "/verify-gate", "/hold/decide", "/proposal/decide", "/session/prompt", "/session/answer",
10
+ "/crew/command", "/device/register", "/device/unregister"].map(path => `POST ${path}`),
11
+ ]);
12
+ const WINDOW_MS = 60000;
13
+ const MAX_CLIENTS = 10000;
14
+
15
+ function normalizedIP(address) {
16
+ if (!isIP(address) || address.includes("%")) return "";
17
+ if (isIP(address) === 4) return address;
18
+ const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1);
19
+ if (!canonical.startsWith("::ffff:")) return canonical;
20
+ const words = canonical.slice(7).split(":").map(word => parseInt(word, 16));
21
+ return [words[0] >> 8, words[0] & 255, words[1] >> 8, words[1] & 255].join(".");
22
+ }
23
+
24
+ export function publicClientIP(req) {
25
+ const socket = normalizedIP(req.socket.remoteAddress || "") || "unknown";
26
+ const loopback = socket === "::1" || socket.startsWith("127.");
27
+ if (!loopback) return socket;
28
+ // The proxy (tailscale serve/funnel) APPENDS the address it saw, so only the last entry is
29
+ // trustworthy; the first is whatever the caller sent and would let anyone dodge the limits.
30
+ const forwarded = String(req.headers["x-forwarded-for"] || "").split(",").pop().trim();
31
+ return normalizedIP(forwarded) || socket;
32
+ }
33
+
34
+ export function createPublicGate({ now = Date.now, log = line => process.stderr.write(`${line}\n`) } = {}) {
35
+ const clients = new Map();
36
+ const admissions = new WeakMap();
37
+ const refusals = new Map();
38
+ function count(kind) { refusals.set(kind, (refusals.get(kind) || 0) + 1); }
39
+ function prune() {
40
+ const cutoff = now() - WINDOW_MS;
41
+ for (const [ip, bucket] of clients) {
42
+ if (!bucket.streams && bucket.lastSeen <= cutoff) clients.delete(ip);
43
+ }
44
+ }
45
+ function report() {
46
+ prune();
47
+ for (const [kind, total] of refusals) log(`[trantor] public refusal ${kind}: count=${total}`);
48
+ refusals.clear();
49
+ }
50
+ const timer = setInterval(report, WINDOW_MS);
51
+ timer.unref();
52
+
53
+ function refuse(res, code, kind, retry = 0) {
54
+ count(kind);
55
+ const headers = { "content-type": "application/json" };
56
+ if (retry) headers["retry-after"] = String(retry);
57
+ res.writeHead(code, headers);
58
+ res.end(JSON.stringify({ error: code === 404 ? "not found" : "rate limit exceeded" }));
59
+ return false;
60
+ }
61
+
62
+ function admit(req, res, path) {
63
+ const ts = now();
64
+ const ip = publicClientIP(req);
65
+ let bucket = clients.get(ip);
66
+ if (!bucket) {
67
+ if (clients.size >= MAX_CLIENTS) prune();
68
+ // Fail closed at capacity; evicting a live bucket would reset its limit.
69
+ if (clients.size >= MAX_CLIENTS) return refuse(res, 429, "capacity", 60);
70
+ bucket = { start: ts, lastSeen: ts, requests: 0, enrolls: 0, streams: 0 };
71
+ clients.set(ip, bucket);
72
+ }
73
+ bucket.lastSeen = ts;
74
+ if (ts - bucket.start >= WINDOW_MS) {
75
+ bucket.start = ts;
76
+ bucket.requests = 0;
77
+ bucket.enrolls = 0;
78
+ }
79
+ const retry = Math.max(1, Math.ceil((bucket.start + WINDOW_MS - ts) / 1000));
80
+ if (bucket.requests >= 120) return refuse(res, 429, "general-limit", retry);
81
+ bucket.requests++;
82
+ if (!ALLOWED.has(`${req.method} ${path}`)) return refuse(res, 404, "route");
83
+ if (path === "/enroll") {
84
+ if (bucket.enrolls >= 10) return refuse(res, 429, "enroll-limit", retry);
85
+ bucket.enrolls++;
86
+ }
87
+ admissions.set(req, bucket);
88
+ res.once("finish", () => {
89
+ if (res.statusCode === 401) {
90
+ // The first unit was charged at admission; a failed signature costs one more.
91
+ if (bucket.start + WINDOW_MS > now()) bucket.requests++;
92
+ count("auth");
93
+ } else if (res.statusCode === 403) count(path === "/enroll" ? "enrollment" : "authorization");
94
+ });
95
+ return true;
96
+ }
97
+
98
+ function openStream(req, res) {
99
+ if (res.destroyed) return false;
100
+ const bucket = admissions.get(req);
101
+ if (bucket.streams >= 5) return refuse(res, 429, "stream-limit", 60);
102
+ bucket.streams++;
103
+ res.once("close", () => { bucket.streams--; bucket.lastSeen = now(); });
104
+ return true;
105
+ }
106
+
107
+ return { admit, openStream, close: () => clearInterval(timer) };
108
+ }
@@ -36,6 +36,18 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
36
36
  }
37
37
  return json(res, 200, keyUsageSnapshot(state, now(), ONLINE_MS));
38
38
  }
39
+ if (req.method === "POST" && P === "/unregister") {
40
+ const b = await body(req);
41
+ if (!b.session || !b.project) return json(res, 400, { error: "session and project required" });
42
+ if (AUTH_MODE !== "off" && (auth?.identity?.name !== b.session || !scopeAllows(auth.identity, b.project, "write"))) {
43
+ return json(res, 403, { error: "only the session owner can unregister its peer" });
44
+ }
45
+ const peer = state.peers[b.session];
46
+ if (peer && peer.project !== canon(b.project)) return json(res, 403, { error: "peer belongs to a different project" });
47
+ delete state.peers[b.session];
48
+ markDirty();
49
+ return json(res, 200, { ok: true });
50
+ }
39
51
  if (req.method === "POST" && P === "/register") {
40
52
  const b = await body(req);
41
53
  const cpg = crossProjectGuard(auth, P, b);
@@ -9,7 +9,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
9
9
  appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
10
10
  deliverable, inboxReadable, inboxResponse, filterReadable, canRead, streams, UI,
11
11
  AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
12
- CONTRACT_WINDOW_MS,
12
+ CONTRACT_WINDOW_MS, PUBLIC_URL,
13
13
  } = ctx;
14
14
  if (req.method === "POST" && P === "/send") {
15
15
  const b = await body(req);
@@ -168,6 +168,8 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
168
168
  // #8723: the count is a listing in aggregate — memory rows (past PEER_TTL_MS) never count.
169
169
  if (P === "/health") return json(res, 200, { ok: true, authMode: AUTH_MODE, peers: Object.values(state.peers).filter(v => (v.lastSeen || 0) >= now() - PEER_TTL_MS).length, messages: state.messages.length, streams: streams.length,
170
170
  persist: persistHealth.view(),
171
+ // v1.4: the public front door, empty when RELAY_PUBLIC_URL is unset — pairing hands it out.
172
+ publicUrl: PUBLIC_URL,
171
173
  // #5686: duty liveness rides /health so the app's Home strip and doctor read one truth.
172
174
  duty: { ...duty.dutyLiveness(), darkSinceMs: duty.darkSince ? now() - duty.darkSince : 0, queuedEscalations: duty.dutyQueuedEscalations() } });
173
175
  return false;
package/hub.mjs CHANGED
@@ -7,6 +7,7 @@ import { assertNoSecrets } from "./lib/scrub.mjs";
7
7
  import { DEFAULT_ORG } from "./lib/store-contract.mjs";
8
8
  import { createStoreRuntime } from "./hub/store.mjs";
9
9
  import { createAuthRuntime } from "./hub/auth.mjs";
10
+ import { createPublicGate } from "./hub/public.mjs";
10
11
  import { createEventRuntime } from "./hub/events.mjs";
11
12
  import { createReaper } from "./hub/reaper.mjs";
12
13
  import { createDuty } from "./hub/duty.mjs";
@@ -31,6 +32,9 @@ const PG_URL = process.env.RELAY_DATABASE_URL || process.env.POSTGRES_URL ||
31
32
  const STORE_KIND = RAW_STORE_KIND || (PG_URL ? "pg" : "json");
32
33
  const ORG_ID = process.env.RELAY_ORG_ID || DEFAULT_ORG;
33
34
  const AUTH_MODE = ["off", "warn", "enforce"].includes(process.env.RELAY_AUTH) ? process.env.RELAY_AUTH : "enforce";
35
+ // The public front door (Tailscale Funnel, v1.4) — /health reports it so clients learn the
36
+ // address the phone can reach without Tailscale. Empty when the hub has none.
37
+ const PUBLIC_URL = String(process.env.RELAY_PUBLIC_URL || "").trim().replace(/\/+$/, "");
34
38
  const ENROLL_MODE = process.env.RELAY_ENROLL || "tofu";
35
39
  const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
36
40
  const PEER_TTL_DEFAULT_MS = 21600000;
@@ -62,9 +66,10 @@ if (!LOOPBACK_BIND && AUTH_MODE !== "enforce") {
62
66
  }
63
67
 
64
68
  const store = await createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA });
69
+ const seenNonces = new Map();
65
70
  const authRuntime = createAuthRuntime({
66
71
  state: store.state, markDirty: store.markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS,
67
- LOOPBACK_BIND, ENROLL_MODE,
72
+ LOOPBACK_BIND, ENROLL_MODE, seenNonces,
68
73
  });
69
74
  const notifier = createNotifier({ state: store.state, markDirty: store.markDirty, canRead: authRuntime.canRead });
70
75
  const events = createEventRuntime({ state: store.state, markDirty: store.markDirty, AUTH_MODE, ONLINE_MS, canon: authRuntime.canon, notifier });
@@ -124,7 +129,7 @@ try { UI = readFileSync(new URL("./ui.html", import.meta.url), "utf8"); } catch
124
129
  const context = {
125
130
  ...store, ...authRuntime, ...events, ...reaper, duty, overseer, derivePhases,
126
131
  hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
127
- REAP_GRACE_MS, CONTRACT_WINDOW_MS,
132
+ REAP_GRACE_MS, CONTRACT_WINDOW_MS, PUBLIC_URL,
128
133
  };
129
134
  const sessionChat = createSessionRuntime({
130
135
  state: store.state, markDirty: store.markDirty,
@@ -132,33 +137,50 @@ const sessionChat = createSessionRuntime({
132
137
  });
133
138
  const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
134
139
 
135
- const server = http.createServer(async (req, res) => {
136
- const u = new URL(req.url, "http://x");
137
- const q = Object.fromEntries(u.searchParams);
138
- const P = u.pathname;
139
- try {
140
- const b0 = req.method === "POST" ? await authRuntime.body(req) : {};
141
- if (authRuntime.handleEnrollment({ req, res, P, u, b0 })) return;
142
- const auth = authRuntime.PUBLIC_ENDPOINTS.has(P)
143
- ? { ok: true, mode: AUTH_MODE, trusted: false }
144
- : await authRuntime.authenticate(req, authRuntime.authPath(u));
145
- if (authRuntime.handleInvite({ req, res, P, auth, b0 })) return;
146
- if (!auth.ok) return authRuntime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
147
- const authorization = authRuntime.authorize(auth, req.method, P, authRuntime.projectFromRequest(P, q, b0));
148
- if (!authorization.ok) return authRuntime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
149
- if (req.method === "GET" && P === "/_op") {
150
- const o = opOutcomes.get(String(q.id || "").slice(0, 80));
151
- return authRuntime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
152
- }
153
- const op = req.method === "POST" ? opIdOf(b0) : null;
154
- const seen = op ? opOutcomes.get(op) : null;
155
- if (seen) return authRuntime.json(res, seen.code, seen.body);
156
- if (op) recordOpOutcome(res, op);
157
- for (const route of routes) if (await route({ req, res, q, P, auth, ctx: context })) return;
158
- authRuntime.json(res, 404, { error: "not found" });
159
- } catch (error) {
160
- authRuntime.json(res, 500, { error: String(error?.message || error) });
161
- }
140
+ const publicAuth = createAuthRuntime({
141
+ state: store.state, markDirty: store.markDirty, AUTH_MODE: "enforce", SUPERSEDE_GRACE_MS,
142
+ LOOPBACK_BIND: true, ENROLL_MODE: "invite", seenNonces,
162
143
  });
144
+ const publicContext = { ...context, ...publicAuth, AUTH_MODE: "enforce" };
163
145
 
146
+ function requestHandler(runtime, ctx, gate = null) {
147
+ return async (req, res) => {
148
+ try {
149
+ const u = new URL(req.url, "http://x");
150
+ const q = Object.fromEntries(u.searchParams);
151
+ const P = u.pathname;
152
+ if (gate && !gate.admit(req, res, P)) return;
153
+ const b0 = req.method === "POST" ? await runtime.body(req) : {};
154
+ if (runtime.handleEnrollment({ req, res, P, u, b0, inviteOnly: !!gate })) return;
155
+ const auth = runtime.PUBLIC_ENDPOINTS.has(P)
156
+ ? { ok: true, mode: ctx.AUTH_MODE, trusted: false }
157
+ : await runtime.authenticate(req, runtime.authPath(u));
158
+ if (runtime.handleInvite({ req, res, P, auth, b0 })) return;
159
+ if (!auth.ok) return runtime.json(res, auth.code || 401, { error: auth.error || "unauthorized" });
160
+ const authorization = runtime.authorize(auth, req.method, P, runtime.projectFromRequest(P, q, b0));
161
+ if (!authorization.ok) return runtime.json(res, authorization.code || 403, { error: authorization.error || "forbidden" });
162
+ if (gate && P === "/stream" && !gate.openStream(req, res)) return;
163
+ if (req.method === "GET" && P === "/_op") {
164
+ const o = opOutcomes.get(String(q.id || "").slice(0, 80));
165
+ return runtime.json(res, 200, o ? { status: o.status, code: o.code, body: o.body } : { status: "unknown" });
166
+ }
167
+ const op = req.method === "POST" ? opIdOf(b0) : null;
168
+ const seen = op ? opOutcomes.get(op) : null;
169
+ if (seen) return runtime.json(res, seen.code, seen.body);
170
+ if (op) recordOpOutcome(res, op);
171
+ for (const route of routes) if (await route({ req, res, q, P, auth, ctx })) return;
172
+ runtime.json(res, 404, { error: "not found" });
173
+ } catch (error) {
174
+ runtime.json(res, 500, { error: String(error?.message || error) });
175
+ }
176
+ };
177
+ }
178
+
179
+ const server = http.createServer(requestHandler(authRuntime, context));
164
180
  server.listen(PORT, HOST, () => console.error(`[trantor] hub on http://${HOST}:${PORT} (auth: ${AUTH_MODE}; data: ${DATA})`));
181
+
182
+ if (process.env.RELAY_PUBLIC_PORT) {
183
+ const publicPort = Number(process.env.RELAY_PUBLIC_PORT);
184
+ const publicServer = http.createServer(requestHandler(publicAuth, publicContext, createPublicGate()));
185
+ publicServer.listen(publicPort, "127.0.0.1", () => console.error(`[trantor] public hub on http://127.0.0.1:${publicServer.address().port} (auth: enforce)`));
186
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.113",
3
+ "version": "0.18.115",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"