@openparachute/hub 0.5.2 → 0.5.7

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/src/oauth-ui.ts CHANGED
@@ -97,6 +97,33 @@ export interface ErrorViewProps {
97
97
  status: number;
98
98
  }
99
99
 
100
+ /**
101
+ * Props for the "App not yet approved" view rendered when an unapproved
102
+ * client lands on `/oauth/authorize`. When `session` is true the operator is
103
+ * authenticated to this hub from the browser making the request, so we render
104
+ * an inline approve form (closes #208). When false we fall back to the
105
+ * pre-#208 CLI-only message.
106
+ */
107
+ export interface ApprovePendingViewProps {
108
+ /** Display name to show — falls back to client_id when no name was supplied at DCR. */
109
+ clientName: string;
110
+ clientId: string;
111
+ redirectUris: string[];
112
+ /** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
113
+ requestedScopes: string[];
114
+ /**
115
+ * When set, render the inline approve form. The form posts to
116
+ * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
117
+ * server will redirect to after the approve commits — the original
118
+ * `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
119
+ * approved client and lands on the consent screen.
120
+ */
121
+ approveForm?: {
122
+ csrfToken: string;
123
+ returnTo: string;
124
+ };
125
+ }
126
+
100
127
  export function renderLogin(props: LoginViewProps): string {
101
128
  const { params, errorMessage, csrfToken } = props;
102
129
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
@@ -204,6 +231,79 @@ function renderVaultPicker(picker: VaultPicker): string {
204
231
  </section>`;
205
232
  }
206
233
 
234
+ /**
235
+ * "App not yet approved" page (#74). When the request carries a valid
236
+ * operator session (#208), render the inline approve form so one click lands
237
+ * the client as `approved` and re-enters the OAuth flow at consent. Without
238
+ * a session, fall back to the original CLI-only message — anyone hitting
239
+ * /oauth/authorize unauthenticated to the hub itself can't be trusted to
240
+ * approve a DCR client from the browser, so they need to drop to a terminal
241
+ * and run `parachute auth approve-client <id>`.
242
+ *
243
+ * The CLI fallback hint is shown in BOTH branches: a button-equipped operator
244
+ * may still want the CLI invocation handy (different machine, scriptable
245
+ * context). The button is the easy path; the CLI is always-available.
246
+ */
247
+ export function renderApprovePending(props: ApprovePendingViewProps): string {
248
+ const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
249
+ const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
250
+ const scopeRows =
251
+ requestedScopes.length === 0
252
+ ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
253
+ : requestedScopes.map(renderScopeRow).join("\n");
254
+ const formSection = approveForm
255
+ ? `
256
+ <form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
257
+ ${renderCsrfHiddenInput(approveForm.csrfToken)}
258
+ <input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
259
+ <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
260
+ <button type="submit" class="btn btn-primary">Approve and continue</button>
261
+ </form>
262
+ <p class="approve-cli-hint">
263
+ Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
264
+ </p>`
265
+ : `
266
+ <p class="approve-cli-hint">
267
+ Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
268
+ from a terminal, then try again.
269
+ </p>`;
270
+ const body = `
271
+ <div class="card">
272
+ <div class="card-header">
273
+ <div class="brand">
274
+ <span class="brand-mark">⌬</span>
275
+ <span class="brand-name">Parachute</span>
276
+ </div>
277
+ <h1>App not yet approved</h1>
278
+ <p class="subtitle">
279
+ ${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
280
+ Review the details below before approving.
281
+ </p>
282
+ </div>
283
+ <section class="approve-meta">
284
+ <h2 class="scopes-title">Application</h2>
285
+ <p class="approve-meta-row">
286
+ <span class="approve-meta-label">name</span>
287
+ <code class="approve-meta-value">${escapeHtml(clientName)}</code>
288
+ </p>
289
+ <p class="approve-meta-row">
290
+ <span class="approve-meta-label">client_id</span>
291
+ <code class="approve-meta-value">${escapeHtml(clientId)}</code>
292
+ </p>
293
+ <div class="approve-meta-row approve-meta-row-block">
294
+ <span class="approve-meta-label">redirect_uris</span>
295
+ <ul class="approve-redirect-list">${redirectList}</ul>
296
+ </div>
297
+ </section>
298
+ <section class="scopes">
299
+ <h2 class="scopes-title">Permissions requested</h2>
300
+ <ul class="scope-list">${scopeRows}</ul>
301
+ </section>
302
+ ${formSection}
303
+ </div>`;
304
+ return baseDocument("App not yet approved", body);
305
+ }
306
+
207
307
  export function renderError(props: ErrorViewProps): string {
208
308
  const body = `
209
309
  <div class="card">
@@ -542,6 +642,73 @@ const STYLES = `
542
642
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
543
643
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
544
644
 
645
+ .approve-meta {
646
+ margin: 0 0 1.25rem;
647
+ padding: 0.75rem 0.85rem;
648
+ border: 1px solid ${PALETTE.borderLight};
649
+ border-radius: 6px;
650
+ background: ${PALETTE.bgSoft};
651
+ }
652
+ .approve-meta .scopes-title { margin-bottom: 0.5rem; }
653
+ .approve-meta-row {
654
+ margin: 0 0 0.4rem;
655
+ display: flex;
656
+ gap: 0.5rem;
657
+ align-items: baseline;
658
+ flex-wrap: wrap;
659
+ }
660
+ .approve-meta-row:last-child { margin-bottom: 0; }
661
+ .approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
662
+ .approve-meta-label {
663
+ text-transform: uppercase;
664
+ letter-spacing: 0.05em;
665
+ font-size: 0.7rem;
666
+ color: ${PALETTE.fgDim};
667
+ }
668
+ .approve-meta-value {
669
+ font-family: ${FONT_MONO};
670
+ font-size: 0.82rem;
671
+ background: ${PALETTE.cardBg};
672
+ padding: 0.1rem 0.4rem;
673
+ border-radius: 4px;
674
+ color: ${PALETTE.fg};
675
+ word-break: break-all;
676
+ }
677
+ .approve-redirect-list {
678
+ list-style: none;
679
+ margin: 0;
680
+ padding: 0;
681
+ display: flex;
682
+ flex-direction: column;
683
+ gap: 0.25rem;
684
+ }
685
+ .approve-redirect-list li code {
686
+ font-family: ${FONT_MONO};
687
+ font-size: 0.82rem;
688
+ background: ${PALETTE.cardBg};
689
+ padding: 0.1rem 0.4rem;
690
+ border-radius: 4px;
691
+ color: ${PALETTE.fg};
692
+ word-break: break-all;
693
+ }
694
+ .approve-form { gap: 0; }
695
+ .approve-cli-hint {
696
+ margin-top: 1rem;
697
+ padding-top: 0.85rem;
698
+ border-top: 1px solid ${PALETTE.borderLight};
699
+ color: ${PALETTE.fgMuted};
700
+ font-size: 0.85rem;
701
+ }
702
+ .approve-cli-hint code {
703
+ font-family: ${FONT_MONO};
704
+ font-size: 0.8rem;
705
+ background: ${PALETTE.bgSoft};
706
+ padding: 0.1rem 0.4rem;
707
+ border-radius: 4px;
708
+ color: ${PALETTE.fg};
709
+ word-break: break-all;
710
+ }
711
+
545
712
  .badge {
546
713
  display: inline-block;
547
714
  font-size: 0.7rem;
@@ -1,23 +1,32 @@
1
- import { parseEnvFile, upsertEnvLine, writeEnvFile } from "./env-file.ts";
2
1
  import { CANONICAL_PORT_MAX, CANONICAL_PORT_MIN, PORT_RESERVATIONS } from "./service-spec.ts";
3
2
 
4
3
  /**
5
- * The CLI is the port authority for Parachute services. At install time it
6
- * picks a port for each service, writes `PORT=<port>` into the service's
7
- * `~/.parachute/<svc>/.env`, and reflects the chosen port in services.json.
8
- * Services keep a compiled-in fallback (e.g. vault → 1940) so a stand-alone
9
- * `bun run` still works, but the CLI's PORT env var wins on installs it
10
- * manages.
4
+ * The hub is the port authority for Parachute services. At install time it
5
+ * picks a port for each service and reflects the chosen port in
6
+ * `services.json`. That manifest is the single source of truth at boot
7
+ * (parachute-scribe#41 / parachute-agent#146 / parachute-agent#148): each
8
+ * service reads `services.json` first and only falls through to lower-tier
9
+ * sources (its own config, the bare `PORT` env, the compiled-in canonical
10
+ * default) when the manifest doesn't pin a port. So writing PORT into the
11
+ * service's `.env` is no longer load-bearing — services.json wins.
12
+ *
13
+ * Pre-hub#206, install also wrote `PORT=<port>` into `~/.parachute/<svc>/.env`
14
+ * and preserved any pre-existing value across re-installs ("operator-edited
15
+ * port survives upgrade"). Post-#206 (option A from the design discussion):
16
+ * we stop writing PORT to `.env` entirely. The duplicate state was at best
17
+ * dead weight and at worst a source of drift — operators editing
18
+ * `services.json` would get re-stamped by a stale `.env` PORT on the next
19
+ * `parachute install`. Existing `.env` PORT lines stay where they are
20
+ * (harmless — service-side resolvePort reads services.json first; the bare
21
+ * PORT env tier is the lowest priority).
11
22
  *
12
23
  * Why up-front assignment instead of detect-on-collision-at-boot:
13
24
  * - Two services racing to bind the same port produces an opaque "address in
14
- * use" deep inside one of them. Assigning at install lets the CLI keep
25
+ * use" deep inside one of them. Assigning at install lets the hub keep
15
26
  * a single coherent picture of who owns what.
16
27
  * - The hub's reverse-proxy targets are computed from services.json. If a
17
28
  * service silently falls back to a different port at runtime, the hub
18
29
  * proxies to a dead port and the user sees a 502 with no explanation.
19
- * - Re-installs stay idempotent: the existing `PORT=` in .env wins, so a
20
- * user who edited their port keeps it across upgrades.
21
30
  */
22
31
 
23
32
  export type AssignmentSource = "canonical" | "fallback-in-range" | "fallback-out-of-range";
@@ -72,8 +81,6 @@ export function assignPort(
72
81
  }
73
82
 
74
83
  export interface AssignServicePortOpts {
75
- /** Path to the service's `.env` file. */
76
- readonly envPath: string;
77
84
  /** Canonical default for this service, or undefined for third-party. */
78
85
  readonly canonical?: number;
79
86
  /** Ports we already know to be taken. */
@@ -82,41 +89,27 @@ export interface AssignServicePortOpts {
82
89
 
83
90
  export interface AssignServicePortResult {
84
91
  readonly port: number;
85
- /** "preserved" when an existing PORT in .env was kept; otherwise the
86
- * source from `assignPort`. */
87
- readonly source: "preserved" | AssignmentSource;
88
- /** True when we wrote PORT into .env on this call. */
89
- readonly written: boolean;
92
+ readonly source: AssignmentSource;
90
93
  /** Warning to surface to the user, if any. */
91
94
  readonly warning?: string;
92
95
  }
93
96
 
94
97
  /**
95
- * Reconcile a service's PORT with its `.env`. Idempotent:
96
- * - If PORT is already set in .env, preserve it (`source: "preserved"`).
97
- * Re-installs and user-edited ports survive across upgrades.
98
- * - Otherwise call `assignPort` and write `PORT=<port>` into .env.
98
+ * Assign a port for a service at install time.
99
99
  *
100
- * Reads only the value of PORT from .env; everything else is round-tripped
101
- * untouched via `parseEnvFile` / `upsertEnvLine` / `writeEnvFile`.
100
+ * As of hub#206 this is a thin wrapper over `assignPort`: services.json is
101
+ * the source of truth for service ports (parachute-scribe#41 /
102
+ * parachute-agent#146 / parachute-agent#148 / parachute-patterns#45), so the
103
+ * install path no longer touches the service's `.env`. The wrapper still
104
+ * exists to give the install path a stable seam — `collectOccupiedPorts`
105
+ * feeds into the same shape regardless of how the underlying picker
106
+ * evolves — and to keep the warning return path centralized.
102
107
  */
103
108
  export function assignServicePort(opts: AssignServicePortOpts): AssignServicePortResult {
104
- const env = parseEnvFile(opts.envPath);
105
- const existing = env.values.PORT;
106
- if (existing !== undefined && /^[1-9]\d{0,4}$/.test(existing)) {
107
- const port = Number(existing);
108
- if (port > 0 && port < 65536) {
109
- return { port, source: "preserved", written: false };
110
- }
111
- }
112
-
113
109
  const assignment = assignPort(opts.canonical, opts.occupied);
114
- const nextLines = upsertEnvLine(env.lines, "PORT", String(assignment.port));
115
- writeEnvFile(opts.envPath, nextLines);
116
110
  const result: AssignServicePortResult = {
117
111
  port: assignment.port,
118
112
  source: assignment.source,
119
- written: true,
120
113
  };
121
114
  if (assignment.warning) {
122
115
  return { ...result, warning: assignment.warning };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Per-IP rate-limit on `POST /admin/login`. Lands as a floor under brute-force
3
+ * after hub#187 collapsed the public-reach matrix: with a cloudflare tunnel
4
+ * up, `/admin/login` is now reachable from the open internet, and 2FA (#186)
5
+ * is the next PR rather than this one. A 5-attempts-per-15-minute bucket per
6
+ * IP is the standard login-form floor; it's not the primary defense, just the
7
+ * one that turns "infinite credential grinding" into "rotate IPs".
8
+ *
9
+ * Shape: sliding window. Each key keeps the last N attempt timestamps; on a
10
+ * new attempt we prune anything older than the window, count what remains,
11
+ * decide allow / deny, and (on allow) append the current timestamp. Sliding
12
+ * gives an exact `Retry-After` (seconds until the *oldest* in-window
13
+ * timestamp falls off) rather than the rough next-refill of a token bucket.
14
+ *
15
+ * Storage: process-local `Map`. Persistence isn't worth a SQLite write per
16
+ * attempt — process restart is itself a defense (the attacker loses all
17
+ * progress against any one bucket). Memory is bounded by an opportunistic
18
+ * sweep of empty buckets every time we touch the map, so an attacker
19
+ * cycling through IPs can't grow the map without also leaving timestamps in
20
+ * each.
21
+ *
22
+ * Auth-stage independence: callers MUST gate via `checkAndRecord` *before*
23
+ * the credential check. A 2FA (or password) failure should count toward the
24
+ * same bucket as a wrong password — an attacker who knows the password
25
+ * shouldn't get unlimited grinding against backup codes.
26
+ *
27
+ * Layer-independent: the limiter applies on every layer (loopback included).
28
+ * A buggy script hammering loopback gets 429'd just like a public attacker.
29
+ * The one wrinkle is `tailscale serve` proxying from `127.0.0.1`, so all
30
+ * tailnet logins share the loopback bucket — acceptable because tailnet is
31
+ * authed at the network layer and brute-force isn't the threat model there.
32
+ *
33
+ * Testable: inject the clock via `now` so the tests can advance time
34
+ * deterministically without `setTimeout`. Module-level state is exported via
35
+ * `__resetForTests` so the test file can reset between cases without
36
+ * recreating the module.
37
+ */
38
+
39
+ /** Window length: 15 minutes. */
40
+ export const WINDOW_MS = 15 * 60 * 1000;
41
+ /** Attempts allowed per window. 6th attempt within the window is denied. */
42
+ export const MAX_ATTEMPTS = 5;
43
+ /** Sentinel for the IP-extraction priority chain when nothing parsed. */
44
+ export const UNKNOWN_IP_SENTINEL = "unknown";
45
+
46
+ export interface RateLimitResult {
47
+ /** True if the attempt is admitted; caller proceeds to credential check. */
48
+ allowed: boolean;
49
+ /**
50
+ * Seconds until the bucket reset (oldest in-window timestamp falls off).
51
+ * Only set when `allowed` is false. Always >= 1: the deny branch only
52
+ * fires when the oldest in-window timestamp is strictly inside the
53
+ * window, so `Math.ceil(positiveMs / 1000) >= 1` naturally. The
54
+ * `Math.max(1, ...)` clamp inside `checkAndRecord` is a defense-in-depth
55
+ * floor in case the filter logic is ever loosened.
56
+ */
57
+ retryAfterSeconds?: number;
58
+ }
59
+
60
+ /**
61
+ * Module-level state. `Map<key, attemptsTimestampsMs[]>`. Each array holds
62
+ * raw `Date.now()`-style millisecond timestamps for in-window attempts.
63
+ */
64
+ const buckets: Map<string, number[]> = new Map();
65
+
66
+ /**
67
+ * Record an attempt and return whether it's admitted. `key` is typically a
68
+ * client IP from `clientIpFromRequest`. `now` is injected for testability;
69
+ * production callers pass `new Date()`.
70
+ *
71
+ * Behavior:
72
+ * - Prune timestamps older than `now - WINDOW_MS`.
73
+ * - If remaining count >= MAX_ATTEMPTS, deny with `retryAfterSeconds`
74
+ * pointing at the oldest in-window timestamp's age-out moment. The
75
+ * denied attempt is NOT recorded — we don't want a flood of denials
76
+ * pushing the reset further into the future. The window stays anchored
77
+ * to the actual 5 admitted attempts.
78
+ * - Otherwise admit, append the current timestamp, return allowed.
79
+ */
80
+ export function checkAndRecord(key: string, now: Date): RateLimitResult {
81
+ const cutoff = now.getTime() - WINDOW_MS;
82
+ const existing = buckets.get(key) ?? [];
83
+ // Drop anything that fell out of the window. Mutating a copy keeps the
84
+ // semantics clear: `pruned` is always the in-window slice.
85
+ const pruned = existing.filter((t) => t > cutoff);
86
+
87
+ if (pruned.length >= MAX_ATTEMPTS) {
88
+ // Reset moment = oldest in-window attempt + WINDOW_MS. `pruned[0]` is
89
+ // the oldest because timestamps are appended in order. Subtract `now`
90
+ // for seconds-until-reset. The unclamped value is provably >= 1 in this
91
+ // branch (see below), but `Math.max(1, ...)` stays as a defense-in-depth
92
+ // floor so Retry-After never reads 0 if the filter logic is ever
93
+ // loosened. Reasoning: the deny branch requires `pruned.length >=
94
+ // MAX_ATTEMPTS`, which implies every entry survived the `t > cutoff`
95
+ // filter, i.e. `pruned[0] > now - WINDOW_MS` strictly, i.e. `resetAtMs -
96
+ // now > 0` strictly, i.e. `Math.ceil(positive / 1000) >= 1`.
97
+ const resetAtMs = (pruned[0] ?? now.getTime()) + WINDOW_MS;
98
+ const retryAfterSeconds = Math.max(1, Math.ceil((resetAtMs - now.getTime()) / 1000));
99
+ // Denied attempt: the bucket is still full (>= MAX_ATTEMPTS in-window
100
+ // entries), so unconditionally re-store the pruned slice. Persisting the
101
+ // prune keeps stale entries from leaking forward past their window. We
102
+ // do NOT delete here — that branch is structurally unreachable in deny
103
+ // because deny requires a non-empty `pruned`.
104
+ buckets.set(key, pruned);
105
+ return { allowed: false, retryAfterSeconds };
106
+ }
107
+
108
+ pruned.push(now.getTime());
109
+ buckets.set(key, pruned);
110
+ return { allowed: true };
111
+ }
112
+
113
+ /**
114
+ * Test-only escape hatch. Production code never calls this; the test file
115
+ * uses it between cases so module-level state doesn't leak across tests.
116
+ */
117
+ export function __resetForTests(): void {
118
+ buckets.clear();
119
+ }
120
+
121
+ /**
122
+ * Extract the client IP from request headers, in priority order:
123
+ * 1. `CF-Connecting-IP` — cloudflared sets this on every forwarded request,
124
+ * and it's the actual client IP (not the cloudflare edge). This is the
125
+ * authoritative source on cloudflare-fronted hubs.
126
+ * 2. `X-Forwarded-For` first hop — defensive fallback for any non-cloudflare
127
+ * proxy fronting hub. The first comma-separated value is the original
128
+ * client; later values are intermediate proxies.
129
+ * 3. `Forwarded` (RFC 7239) — not parsed; covered by the comment in the
130
+ * issue's IP-priority list as deferred. `X-Forwarded-For` covers the
131
+ * operator-deploy reality (every common reverse proxy sets it).
132
+ * 4. Fall through to `UNKNOWN_IP_SENTINEL`. Hub binds `127.0.0.1`, so the
133
+ * "request remote addr" case the spec mentions doesn't materialize at
134
+ * this layer (Bun's `requestIP` is on `Server`, not `Request`, and
135
+ * everything reaching here is either loopback or proxy-injected). The
136
+ * sentinel ensures the limiter always has a key — all sentinel
137
+ * requests share one bucket, which is the intended bound for
138
+ * direct-loopback callers (curl from the same host).
139
+ *
140
+ * Returns a trimmed string. Empty / whitespace-only header values are
141
+ * treated as absent.
142
+ */
143
+ export function clientIpFromRequest(req: Request): string {
144
+ const cfConnectingIp = trimOrNull(req.headers.get("cf-connecting-ip"));
145
+ if (cfConnectingIp) return cfConnectingIp;
146
+
147
+ const xff = req.headers.get("x-forwarded-for");
148
+ if (xff) {
149
+ // First hop only. RFC 7239 / X-Forwarded-For convention is
150
+ // `client, proxy1, proxy2`; the leftmost entry is the original client.
151
+ const first = xff.split(",")[0];
152
+ const trimmed = trimOrNull(first ?? "");
153
+ if (trimmed) return trimmed;
154
+ }
155
+
156
+ return UNKNOWN_IP_SENTINEL;
157
+ }
158
+
159
+ function trimOrNull(s: string | null | undefined): string | null {
160
+ if (s == null) return null;
161
+ const t = s.trim();
162
+ return t.length > 0 ? t : null;
163
+ }
@@ -19,20 +19,29 @@ import type { ServiceEntry } from "./services-manifest.ts";
19
19
  * (see hub-control.ts) — if something else is on 1939 we fail loudly rather
20
20
  * than walking up into a service's slot.
21
21
  *
22
- * **CLI is the port authority.** `parachute install <svc>` picks the port at
23
- * install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`.
24
- * lifecycle.start merges that .env into the spawn env, so the next daemon
25
- * boot binds the port the CLI assigned. Algorithm (see port-assign.ts):
22
+ * **Hub is the port authority.** `parachute install <svc>` picks the port
23
+ * at install time and reflects it in `services.json`. Algorithm (see
24
+ * port-assign.ts):
26
25
  *
27
26
  * 1. Prefer the canonical slot (`spec.seedEntry().port`).
28
27
  * 2. On collision, walk the unassigned range (1944–1949 today).
29
28
  * 3. Range exhausted: assign past 1949 with a warning.
30
29
  *
31
- * Idempotent: an existing `PORT=` in .env wins, so re-installs and
32
- * operator-edited ports survive across upgrades. Services keep their
33
- * compiled-in fallbacks (vault1940 etc.) so a stand-alone `bun run`
34
- * still works without a CLI-managed .env, but the CLI's PORT wins on any
35
- * install it manages.
30
+ * `services.json` is the single source of truth at boot: each service
31
+ * follows a 4-tier resolvePort ladder (services.json service config →
32
+ * bare PORT envcompiled-in canonical default), per parachute-scribe#41,
33
+ * parachute-agent#146, parachute-agent#148, and parachute-patterns#45.
34
+ * Pre-hub#206 the install path also wrote `PORT=<port>` into the service's
35
+ * `~/.parachute/<svc>/.env`; post-#206 it doesn't — services.json wins,
36
+ * the duplicate `.env` PORT was at best dead weight and at worst a source
37
+ * of drift on re-install (a stale `.env` PORT would re-stamp services.json
38
+ * even after an operator had fixed it).
39
+ *
40
+ * Operator override is now "edit services.json" (or `parachute config`
41
+ * once that lands), not "edit `.env`". Pre-#206 stale `.env` PORT lines on
42
+ * existing operator machines stay where they are — harmless, since the
43
+ * boot-time ladder reads services.json before falling through to the bare
44
+ * PORT env tier — and future installs no longer touch them.
36
45
  *
37
46
  * **No speculative reservations.** Future first-party modules claim a slot
38
47
  * the moment they ship, not before — pre-reservation for unbuilt things has
@@ -384,10 +393,20 @@ export const FIRST_PARTY_FALLBACKS: Record<string, FirstPartyFallback> = {
384
393
  /**
385
394
  * Effective publicExposure for a service, given what's on its services.json
386
395
  * entry. Explicit wins. If absent, derive from the spec: known api/tool
387
- * services without declared auth fall back to "auth-required" (treated as
388
- * loopback at launch); everything else defaults to "allowed" — so vault,
389
- * notes, channel and unknown third-party services continue to be exposed
390
- * without needing to opt in.
396
+ * services without declared auth fall back to "auth-required"; everything
397
+ * else defaults to "allowed" — so vault, notes, channel and unknown
398
+ * third-party services continue to be exposed without needing to opt in.
399
+ *
400
+ * Layer behavior (post-#187 layer-aware proxy):
401
+ * "allowed" — reaches all layers (loopback / tailnet / public);
402
+ * service self-gates if it has any auth.
403
+ * "loopback" — hub layer-gates; tailnet/public requests 404 at
404
+ * proxyToService / proxyToVault before reaching the
405
+ * service.
406
+ * "auth-required" — reaches all layers; service self-gates. Same gate
407
+ * behavior as "allowed" today; the field documents
408
+ * operator/UI intent ("requires auth before exposing")
409
+ * separately from the loopback hard-block.
391
410
  */
392
411
  export function effectivePublicExposure(
393
412
  entry: ServiceEntry,
@@ -409,6 +428,32 @@ export function knownServices(): string[] {
409
428
  return Object.keys(FIRST_PARTY_FALLBACKS);
410
429
  }
411
430
 
431
+ /**
432
+ * Canonical port assignment for a known short name, or `undefined` for
433
+ * third-party services we don't have a fallback for. Drives the
434
+ * canonical-port drift warning in `parachute status` (hub#195) — when an
435
+ * entry's actual port doesn't match the canonical, we surface it without
436
+ * blocking. Operators may have intentionally moved a service off canonical
437
+ * (e.g. to dodge a third-party clash), so the drift is a warning, not an
438
+ * error.
439
+ *
440
+ * Known gap (intentional, tracked separately): multi-vault instance rows
441
+ * (`parachute-vault-default`, `parachute-vault-techne`, etc.) don't match
442
+ * any `manifestName` in `FIRST_PARTY_FALLBACKS` — only the canonical
443
+ * `parachute-vault` does — so `shortNameForManifest` returns undefined and
444
+ * drift warnings never fire for them. That's tolerable: multi-vault is the
445
+ * deliberate exception in the duplicate-port gate (one process, N mounts,
446
+ * one port), and no operator-actionable drift signal is well-defined when
447
+ * N rows share a port. Documented here so the gap doesn't read as an
448
+ * oversight; revisit if a clean drift shape for multi-vault emerges.
449
+ */
450
+ export function canonicalPortForManifest(manifestName: string): number | undefined {
451
+ const short = shortNameForManifest(manifestName);
452
+ if (short === undefined) return undefined;
453
+ const fb = FIRST_PARTY_FALLBACKS[short];
454
+ return fb?.manifest.port;
455
+ }
456
+
412
457
  /**
413
458
  * Resolve the runtime spec for a known short name. Returns undefined for
414
459
  * unknown names; third-party modules installed via `module.json` resolve
@@ -134,6 +134,57 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
134
134
  return entry;
135
135
  }
136
136
 
137
+ /**
138
+ * Vault is a multi-instance service: one parachute-vault process serves
139
+ * every vault on a single port at distinct mount paths (`/vault/default`,
140
+ * `/vault/techne`, …). Every multi-vault row carries a `parachute-vault*`
141
+ * name. Sharing a port between vault rows is intentional and not a
142
+ * collision; sharing a port between two non-vault services (or between a
143
+ * vault and a non-vault) is.
144
+ *
145
+ * Inlined rather than imported from `well-known.ts` to keep the parser
146
+ * self-contained — well-known.ts already imports from this file.
147
+ */
148
+ function isVaultName(name: string): boolean {
149
+ return name === "parachute-vault" || name.startsWith("parachute-vault-");
150
+ }
151
+
152
+ /**
153
+ * Reject manifests where two distinct services share a port. Without this
154
+ * gate, both services land in services.json, the OS lets only one bind,
155
+ * and the hub reverse-proxy quietly routes everyone to whichever service
156
+ * won the race. That's exactly how parachute-hub#195 (scribe + agent both
157
+ * at 1944) produced a silent /agent → scribe miswire. The underlying
158
+ * overwrite bugs are fixed in parachute-scribe#41 + parachute-agent#146;
159
+ * this is the hub-side gate so the same class can't recur silently.
160
+ *
161
+ * Multi-vault is the deliberate exception: one parachute-vault process
162
+ * serves N vault instances on a single port at distinct mount paths, so
163
+ * multiple `parachute-vault*` rows sharing a port is intentional, not a
164
+ * collision. The check fires only when the conflicting names aren't
165
+ * both vault rows.
166
+ *
167
+ * Pulled out of `validateManifest` so the write side (`upsertService`) can
168
+ * apply the same gate after merging without re-validating every entry's
169
+ * shape — the merged manifest's entries are already typed `ServiceEntry`,
170
+ * but a duplicate-port collision is a property of the merged set, not of
171
+ * any individual entry. Read-side path runs this after `validateEntry`
172
+ * across the array; write-side path runs this on the post-merge entries.
173
+ * Both surface the same `ServicesManifestError` shape.
174
+ */
175
+ function assertNoDuplicatePorts(entries: ServiceEntry[], where: string): void {
176
+ const portsSeen = new Map<number, string>();
177
+ for (const entry of entries) {
178
+ const prev = portsSeen.get(entry.port);
179
+ if (prev !== undefined && !(isVaultName(prev) && isVaultName(entry.name))) {
180
+ throw new ServicesManifestError(
181
+ `${where}: duplicate port ${entry.port} — claimed by both "${prev}" and "${entry.name}". Edit services.json to give each service a unique port.`,
182
+ );
183
+ }
184
+ if (prev === undefined) portsSeen.set(entry.port, entry.name);
185
+ }
186
+ }
187
+
137
188
  function validateManifest(raw: unknown, where: string): ServicesManifest {
138
189
  if (!raw || typeof raw !== "object") {
139
190
  throw new ServicesManifestError(`${where}: root must be an object`);
@@ -142,9 +193,9 @@ function validateManifest(raw: unknown, where: string): ServicesManifest {
142
193
  if (!Array.isArray(services)) {
143
194
  throw new ServicesManifestError(`${where}: "services" must be an array`);
144
195
  }
145
- return {
146
- services: services.map((s, i) => validateEntry(s, `${where} services[${i}]`)),
147
- };
196
+ const entries = services.map((s, i) => validateEntry(s, `${where} services[${i}]`));
197
+ assertNoDuplicatePorts(entries, where);
198
+ return { services: entries };
148
199
  }
149
200
 
150
201
  export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
@@ -222,6 +273,14 @@ export function upsertService(
222
273
  } else {
223
274
  current.services.push(entry);
224
275
  }
276
+ // Symmetric port-collision gate (closes hub#205). Read-time validation
277
+ // (`validateManifest` → `assertNoDuplicatePorts`) catches duplicates the
278
+ // next time `services.json` is read, but without this write-side check the
279
+ // bad state lives on disk for that window. A buggy service boot calling
280
+ // `upsertService({ name: "agent", port: 1944 })` while scribe is already
281
+ // at 1944 would otherwise succeed and corrupt the manifest. Same
282
+ // multi-vault carve-out as the read path.
283
+ assertNoDuplicatePorts(current.services, path);
225
284
  writeManifest(current, path);
226
285
  return current;
227
286
  }
package/src/sessions.ts CHANGED
@@ -113,3 +113,22 @@ export function parseSessionCookie(cookieHeader: string | null): string | null {
113
113
  }
114
114
  return null;
115
115
  }
116
+
117
+ /**
118
+ * Returns the active (un-expired) session for this request, or null. The
119
+ * canonical "is the operator logged in to this hub?" check — combines
120
+ * `parseSessionCookie` + `findSession` (which already enforces expiry) so
121
+ * callers don't repeat the parse+find+null-check dance.
122
+ *
123
+ * Caller decides what to do on null — admin pages redirect to
124
+ * `/admin/login?next=<path>`, OAuth's DCR endpoint falls through to
125
+ * status=`pending` (closes #199).
126
+ */
127
+ export function findActiveSession(
128
+ db: Database,
129
+ req: Request,
130
+ now: () => Date = () => new Date(),
131
+ ): Session | null {
132
+ const sid = parseSessionCookie(req.headers.get("cookie"));
133
+ return sid ? findSession(db, sid, now) : null;
134
+ }