Public: anyone can read this; the crew can change it.
The owner reads what outside agents tried: a compact, redacted request log, landed from pull #1383 on today's main #1761
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1458-the-owner-reads-what-outside-agents-trie → main 4 commits fork 1 thread · 1 unresolved
11 files
changed +847
−15
docs/APPS.md +13
| @@ -1147,6 +1147,19 @@ sync cadence in seconds, 10–86400). `src/apps/service.ts` validates it; the | ||
| 1147 | 1147 | manifest carries it as `service`, `null` for a web app. Running it, exposing |
| 1148 | 1148 | the ports and syncing volumes are not built yet (issue #391). |
| 1149 | 1149 | |
| 1150 | ## The access log | |
| 1151 | ||
| 1152 | The forge keeps one compact JSON line per request that is not a static asset | |
| 1153 | (or `/healthz`) under `<data>/access/access-YYYY-MM-DD.log`: time, host, | |
| 1154 | method, path without its query string (secret-looking segments blanked), | |
| 1155 | status, duration, user agent (200 characters), whether a credential came with | |
| 1156 | it, and a hash of the client address keyed with the data directory's secret | |
| 1157 | and the day. Lines are queued in memory and appended in one write a second (or per 200 lines), so a request pays for no disk write. No token, cookie or query string is ever written. Files go after | |
| 1158 | 14 days, a day's file stops at 4 MB and the directory at 32 MB, oldest first. | |
| 1159 | Nothing serves the files. A forge admin reads the last day's non-browser | |
| 1160 | requests to public paths at Settings → Access, or with the `access` MCP tool | |
| 1161 | (`since`, `ua`, `path`; write-scope token). | |
| 1162 | ||
| 1150 | 1163 | ## The forge as an OpenID Connect provider |
| 1151 | 1164 | |
| 1152 | 1165 | Discovery is at `/.well-known/openid-configuration`. Register a client at |
scripts/gate.ts +1
| @@ -110,6 +110,7 @@ export const GATE: readonly string[] = [ | ||
| 110 | 110 | "test/decide.test.ts", |
| 111 | 111 | "test/ledger.test.ts", |
| 112 | 112 | "test/capacity.test.ts", |
| 113 | "test/access-log.test.ts", | |
| 113 | 114 | "test/flow-pipeline.test.ts", |
| 114 | 115 | "test/limits.test.ts", |
| 115 | 116 | "test/guard-new-accounts.test.ts", |
src/actions/access_log.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** | |
| 2 | * The access log, read by the forge's owner (`http/access_log.ts`): a forge | |
| 3 | * admin only, never an agent, and everyone else is told `not_found`. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { App } from "../app.ts"; | |
| 7 | import { | |
| 8 | readAccess, | |
| 9 | type AccessLine, | |
| 10 | type AccessQuery, | |
| 11 | } from "../http/access_log.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | ||
| 15 | export function listAccess( | |
| 16 | app: App, | |
| 17 | actor: User | undefined, | |
| 18 | query: AccessQuery, | |
| 19 | ): Result<AccessLine[], "not_found"> { | |
| 20 | if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found"); | |
| 21 | return ok(readAccess(app.config.dataDir, query)); | |
| 22 | } |
src/http/access_log.ts added +325
| @@ -0,0 +1,325 @@ | ||
| 1 | /** | |
| 2 | * What outside callers tried: one compact line per request that is not a | |
| 3 | * static asset, kept under `<data>/access/` as one file a day. | |
| 4 | * | |
| 5 | * A line is JSON, built only from parts that are safe to keep: the time, the | |
| 6 | * host, the method, the path without its query string (and with any | |
| 7 | * secret-looking segment blanked), the status, the time to headers (a streamed body is not counted), the first 200 | |
| 8 | * characters of the user agent, whether the caller presented a credential | |
| 9 | * (never which), and a hash of the client address keyed with the data | |
| 10 | * directory's secret and the day — so one caller is one hash for a day and | |
| 11 | * no hash can be walked back to an address or followed across days. Headers | |
| 12 | * other than the user agent, cookies, bodies and queries are never read. | |
| 13 | * | |
| 14 | * Files older than 14 days go, and the directory is capped in total, oldest | |
| 15 | * first; one day's file stops growing at its own cap. Nothing here is served | |
| 16 | * by any route; the owner reads it through `readAccess` (a settings page and | |
| 17 | * the `access` MCP tool). | |
| 18 | */ | |
| 19 | ||
| 20 | import { | |
| 21 | appendFileSync, | |
| 22 | mkdirSync, | |
| 23 | readdirSync, | |
| 24 | closeSync, | |
| 25 | openSync, | |
| 26 | readSync, | |
| 27 | statSync, | |
| 28 | unlinkSync, | |
| 29 | } from "node:fs"; | |
| 30 | import { createHmac } from "node:crypto"; | |
| 31 | import { join } from "node:path"; | |
| 32 | import { loadKey } from "../kernel/secret.ts"; | |
| 33 | ||
| 34 | export const KEEP_DAYS = 14; | |
| 35 | export const DAY_BYTES = 4 * 1024 * 1024; | |
| 36 | export const TOTAL_BYTES = 32 * 1024 * 1024; | |
| 37 | const UA_MAX = 200; | |
| 38 | const PATH_MAX = 200; | |
| 39 | const HOST_MAX = 100; | |
| 40 | const READ_MAX = 500; | |
| 41 | /** The most one read looks at, newest first: it never stalls the loop for long. */ | |
| 42 | const READ_BYTES = 4 * 1024 * 1024; | |
| 43 | const DAY_MS = 86_400_000; | |
| 44 | ||
| 45 | /** Paths a caller can reach without signing in, for the owner's view. */ | |
| 46 | export const PUBLIC_PREFIXES = [ | |
| 47 | "/issues", | |
| 48 | "/api/", | |
| 49 | "/mcp", | |
| 50 | "/.well-known/", | |
| 51 | "/llms.txt", | |
| 52 | "/signin", | |
| 53 | "/signup", | |
| 54 | ] as const; | |
| 55 | ||
| 56 | export interface AccessLine { | |
| 57 | /** ISO time the request finished. */ | |
| 58 | readonly t: string; | |
| 59 | readonly host: string; | |
| 60 | readonly method: string; | |
| 61 | readonly path: string; | |
| 62 | readonly status: number; | |
| 63 | readonly ms: number; | |
| 64 | readonly ua: string; | |
| 65 | /** Whether a session cookie or bearer token came with it. */ | |
| 66 | readonly auth: boolean; | |
| 67 | /** Keyed daily hash of the client address. */ | |
| 68 | readonly who: string; | |
| 69 | } | |
| 70 | ||
| 71 | const STATIC = /\.(?:css|js|map|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf)$/i; | |
| 72 | ||
| 73 | /** A request the log leaves out: assets, and the platform's own health probe. */ | |
| 74 | export function isQuiet(path: string): boolean { | |
| 75 | return ( | |
| 76 | path.startsWith("/assets/") || path === "/healthz" || STATIC.test(path) | |
| 77 | ); | |
| 78 | } | |
| 79 | ||
| 80 | /** Control characters and DEL removed, then cut: nothing here breaks a line. */ | |
| 81 | const clean = (text: string, max: number): string => | |
| 82 | // eslint-disable-next-line no-control-regex | |
| 83 | text.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max); | |
| 84 | ||
| 85 | /** | |
| 86 | * A path as it may be kept: no query, and a segment that could be a secret | |
| 87 | * (a long run of token characters, or a forge token's own prefix) blanked. | |
| 88 | */ | |
| 89 | export function keptPath(pathname: string): string { | |
| 90 | const segments = pathname.split("/").map((s) => { | |
| 91 | let seg = s; | |
| 92 | try { | |
| 93 | seg = decodeURIComponent(s); | |
| 94 | } catch { | |
| 95 | // keep the raw text; it is judged the same way | |
| 96 | } | |
| 97 | return /^[A-Za-z0-9_~.+=-]{24,}$/.test(seg) || /^(?:frg|forge|fgt)_/i.test(seg) | |
| 98 | ? ":redacted" | |
| 99 | : s; | |
| 100 | }); | |
| 101 | return clean(segments.join("/"), PATH_MAX); | |
| 102 | } | |
| 103 | ||
| 104 | const dayOf = (at: Date): string => at.toISOString().slice(0, 10); | |
| 105 | const fileOf = (dir: string, day: string): string => | |
| 106 | join(dir, `access-${day}.log`); | |
| 107 | export const accessDir = (dataDir: string): string => join(dataDir, "access"); | |
| 108 | ||
| 109 | /** The client, as one hash for one day. */ | |
| 110 | export function clientHash(dataDir: string, address: string, at: Date): string { | |
| 111 | return createHmac("sha256", loadKey(dataDir)) | |
| 112 | .update(`access:${dayOf(at)}:${address}`) | |
| 113 | .digest("hex") | |
| 114 | .slice(0, 16); | |
| 115 | } | |
| 116 | ||
| 117 | export interface Seen { | |
| 118 | readonly request: Request; | |
| 119 | readonly status: number; | |
| 120 | readonly ms: number; | |
| 121 | readonly client: string; | |
| 122 | } | |
| 123 | ||
| 124 | /** The line for a request, or undefined when it is not logged. */ | |
| 125 | export function lineFor( | |
| 126 | dataDir: string, | |
| 127 | seen: Seen, | |
| 128 | at: Date, | |
| 129 | ): AccessLine | undefined { | |
| 130 | let url: URL; | |
| 131 | try { | |
| 132 | url = new URL(seen.request.url); | |
| 133 | } catch { | |
| 134 | return undefined; | |
| 135 | } | |
| 136 | if (isQuiet(url.pathname)) return undefined; | |
| 137 | const h = seen.request.headers; | |
| 138 | return { | |
| 139 | t: at.toISOString(), | |
| 140 | host: clean((h.get("host") ?? url.host).toLowerCase(), HOST_MAX), | |
| 141 | method: clean(seen.request.method, 16), | |
| 142 | path: keptPath(url.pathname), | |
| 143 | status: seen.status, | |
| 144 | ms: Math.round(seen.ms), | |
| 145 | ua: clean(h.get("user-agent") ?? "", UA_MAX), | |
| 146 | auth: /(?:^|;\s*)forge_session=/.test(h.get("cookie") ?? "") || | |
| 147 | (h.get("authorization") ?? "") !== "", | |
| 148 | who: clientHash(dataDir, seen.client, at), | |
| 149 | }; | |
| 150 | } | |
| 151 | ||
| 152 | let pruned = ""; | |
| 153 | ||
| 154 | /** Drop files past their days, then the oldest until the directory fits. */ | |
| 155 | export function prune(dataDir: string, at: Date): void { | |
| 156 | const dir = accessDir(dataDir); | |
| 157 | const oldest = dayOf(new Date(at.getTime() - (KEEP_DAYS - 1) * DAY_MS)); | |
| 158 | const files = readdirSync(dir) | |
| 159 | .filter((f) => /^access-\d{4}-\d{2}-\d{2}\.log$/.test(f)) | |
| 160 | .sort(); | |
| 161 | const kept: string[] = []; | |
| 162 | for (const f of files) { | |
| 163 | if (f.slice(7, 17) < oldest) unlinkSync(join(dir, f)); | |
| 164 | else kept.push(f); | |
| 165 | } | |
| 166 | let total = kept.reduce((n, f) => n + statSync(join(dir, f)).size, 0); | |
| 167 | for (const f of kept) { | |
| 168 | if (total <= TOTAL_BYTES || f === kept[kept.length - 1]) break; | |
| 169 | total -= statSync(join(dir, f)).size; | |
| 170 | unlinkSync(join(dir, f)); | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * Lines waiting for the next flush, by the day's file they belong to, with | |
| 176 | * the data directory that owns the file: a hosted child's lines wait beside | |
| 177 | * the parent's and never go into its directory. | |
| 178 | */ | |
| 179 | const pending = new Map<string, { dataDir: string; lines: string[] }>(); | |
| 180 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 181 | const FLUSH_MS = 1000; | |
| 182 | const FLUSH_LINES = 200; | |
| 183 | ||
| 184 | /** Write everything that waits, one append per file. Never throws. */ | |
| 185 | export function flushAccess(at: Date = new Date()): void { | |
| 186 | clearTimeout(timer); | |
| 187 | timer = undefined; | |
| 188 | const batches = [...pending]; | |
| 189 | pending.clear(); | |
| 190 | for (const [file, { dataDir, lines }] of batches) { | |
| 191 | try { | |
| 192 | const dir = accessDir(dataDir); | |
| 193 | mkdirSync(dir, { recursive: true, mode: 0o700 }); | |
| 194 | const day = dayOf(at); | |
| 195 | if (pruned !== `${dir}:${day}`) { | |
| 196 | pruned = `${dir}:${day}`; | |
| 197 | prune(dataDir, at); | |
| 198 | } | |
| 199 | let size = 0; | |
| 200 | try { | |
| 201 | size = statSync(file).size; | |
| 202 | } catch { | |
| 203 | // no file yet | |
| 204 | } | |
| 205 | if (size >= DAY_BYTES) continue; | |
| 206 | appendFileSync(file, lines.join(""), { mode: 0o600 }); | |
| 207 | } catch { | |
| 208 | // the log is a convenience; the request is the work | |
| 209 | } | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * Keep a request: a line in memory now, on disk within a second or 200 lines, | |
| 215 | * so a request pays for a string and not for a write. Never throws. | |
| 216 | */ | |
| 217 | export function recordAccess( | |
| 218 | dataDir: string, | |
| 219 | seen: Seen, | |
| 220 | at: Date = new Date(), | |
| 221 | ): void { | |
| 222 | try { | |
| 223 | const line = lineFor(dataDir, seen, at); | |
| 224 | if (line === undefined) return; | |
| 225 | const file = fileOf(accessDir(dataDir), dayOf(at)); | |
| 226 | const queued = pending.get(file) ?? { dataDir, lines: [] }; | |
| 227 | queued.lines.push(`${JSON.stringify(line)}\n`); | |
| 228 | pending.set(file, queued); | |
| 229 | let count = 0; | |
| 230 | for (const q of pending.values()) count += q.lines.length; | |
| 231 | if (count >= FLUSH_LINES) flushAccess(at); | |
| 232 | else if (timer === undefined) { | |
| 233 | timer = setTimeout(() => flushAccess(), FLUSH_MS); | |
| 234 | timer.unref(); | |
| 235 | } | |
| 236 | } catch { | |
| 237 | // the log is a convenience; the request is the work | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /** The last `max` bytes of a file as whole lines. */ | |
| 242 | function tailOf(file: string, max: number): string[] { | |
| 243 | const fd = openSync(file, "r"); | |
| 244 | try { | |
| 245 | const size = statSync(file).size; | |
| 246 | const length = Math.min(size, max); | |
| 247 | const buffer = Buffer.alloc(length); | |
| 248 | readSync(fd, buffer, 0, length, size - length); | |
| 249 | const lines = buffer.toString("utf8").split("\n"); | |
| 250 | // A cut file begins part-way through a line: drop that line. | |
| 251 | if (length < size) lines.shift(); | |
| 252 | return lines; | |
| 253 | } finally { | |
| 254 | closeSync(fd); | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | export interface AccessQuery { | |
| 259 | /** ISO time to read from; the last day when absent or unreadable. */ | |
| 260 | readonly since?: string; | |
| 261 | /** Case-insensitive part of the user agent. */ | |
| 262 | readonly ua?: string; | |
| 263 | /** Start of the path. */ | |
| 264 | readonly path?: string; | |
| 265 | } | |
| 266 | ||
| 267 | /** A browser says so in its user agent; an agent, a script or a probe does not. */ | |
| 268 | export const isBrowser = (ua: string): boolean => ua.includes("Mozilla/"); | |
| 269 | ||
| 270 | const isPublic = (path: string): boolean => | |
| 271 | PUBLIC_PREFIXES.some((p) => path.startsWith(p)); | |
| 272 | ||
| 273 | /** | |
| 274 | * The last day's (or `since`'s) non-browser requests to public paths, newest | |
| 275 | * first, at most 500. | |
| 276 | */ | |
| 277 | export function readAccess( | |
| 278 | dataDir: string, | |
| 279 | query: AccessQuery, | |
| 280 | now: Date = new Date(), | |
| 281 | ): AccessLine[] { | |
| 282 | flushAccess(now); | |
| 283 | const floor = now.getTime() - KEEP_DAYS * DAY_MS; | |
| 284 | const parsed = query.since === undefined ? NaN : Date.parse(query.since); | |
| 285 | const from = Math.max( | |
| 286 | Number.isNaN(parsed) ? now.getTime() - DAY_MS : parsed, | |
| 287 | floor, | |
| 288 | ); | |
| 289 | const ua = query.ua?.toLowerCase(); | |
| 290 | const dir = accessDir(dataDir); | |
| 291 | let files: string[]; | |
| 292 | try { | |
| 293 | files = readdirSync(dir) | |
| 294 | .filter((f) => /^access-\d{4}-\d{2}-\d{2}\.log$/.test(f)) | |
| 295 | .filter((f) => f.slice(7, 17) >= dayOf(new Date(from))) | |
| 296 | .sort() | |
| 297 | .reverse(); | |
| 298 | } catch { | |
| 299 | return []; | |
| 300 | } | |
| 301 | let budget = READ_BYTES; | |
| 302 | const out: AccessLine[] = []; | |
| 303 | for (const f of files) { | |
| 304 | if (budget <= 0) break; | |
| 305 | const lines = tailOf(join(dir, f), budget).reverse(); | |
| 306 | budget -= lines.reduce((n, l) => n + l.length + 1, 0); | |
| 307 | for (const raw of lines) { | |
| 308 | if (raw === "") continue; | |
| 309 | let line: AccessLine; | |
| 310 | try { | |
| 311 | line = JSON.parse(raw) as AccessLine; | |
| 312 | } catch { | |
| 313 | continue; | |
| 314 | } | |
| 315 | if (Date.parse(line.t) < from) continue; | |
| 316 | if (isBrowser(line.ua) || !isPublic(line.path)) continue; | |
| 317 | if (ua !== undefined && !line.ua.toLowerCase().includes(ua)) continue; | |
| 318 | if (query.path !== undefined && !line.path.startsWith(query.path)) | |
| 319 | continue; | |
| 320 | out.push(line); | |
| 321 | if (out.length >= READ_MAX) return out; | |
| 322 | } | |
| 323 | } | |
| 324 | return out; | |
| 325 | } |
src/http/mcp.ts +29
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | |
| 20 | 20 | import { voteVariant } from "../actions/variants.ts"; |
| 21 | 21 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 22 | import { listAccess } from "../actions/access_log.ts"; | |
| 22 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 23 | 24 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 24 | 25 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| @@ -1880,6 +1881,34 @@ const TOOLS: readonly Tool[] = [ | ||
| 1880 | 1881 | }); |
| 1881 | 1882 | }, |
| 1882 | 1883 | }, |
| 1884 | { | |
| 1885 | name: "access", | |
| 1886 | description: | |
| 1887 | "What outside agents tried: the forge's own request log, redacted, for a forge admin. Non-browser requests to public paths (/issues, /api/, /mcp, /.well-known/, /llms.txt, sign-in, sign-up), newest first, at most 500: time, host, method, path without query, status, duration in ms, user agent, whether a credential came with it, and a daily hash of the caller (never an address). `since` is an ISO time (the last day when absent; at most 14 days back), `ua` a part of the user agent, `path` the start of the path. Needs a write-scope token. The same as Settings → Access.", | |
| 1888 | inputSchema: { | |
| 1889 | type: "object", | |
| 1890 | properties: { | |
| 1891 | since: { type: "string", maxLength: 40 }, | |
| 1892 | ua: { type: "string", maxLength: 100 }, | |
| 1893 | path: { type: "string", maxLength: 200 }, | |
| 1894 | }, | |
| 1895 | }, | |
| 1896 | writes: true, | |
| 1897 | run(ctx, user, args) { | |
| 1898 | const text = (k: string): string | undefined => | |
| 1899 | typeof args[k] === "string" && args[k] !== "" ? (args[k] as string) : undefined; | |
| 1900 | const since = text("since"); | |
| 1901 | const ua = text("ua"); | |
| 1902 | const path = text("path"); | |
| 1903 | const listed = listAccess(ctx.app, user, { | |
| 1904 | ...(since === undefined ? {} : { since: since.slice(0, 40) }), | |
| 1905 | ...(ua === undefined ? {} : { ua: ua.slice(0, 100) }), | |
| 1906 | ...(path === undefined ? {} : { path: path.slice(0, 200) }), | |
| 1907 | }); | |
| 1908 | if (!listed.ok) return bad("not_found", "no such tool here"); | |
| 1909 | return good({ requests: listed.value }); | |
| 1910 | }, | |
| 1911 | }, | |
| 1883 | 1912 | { |
| 1884 | 1913 | name: "capacity", |
| 1885 | 1914 | description: |
src/http/server.ts +44 −14
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { TOOLBAR_NAME, toolbarResponse } from "../apps/toolbar.ts"; |
| 9 | 9 | import { unsealReleased } from "../actions/sealed.ts"; |
| 10 | import { accessPage } from "../web/pages/access.ts"; | |
| 10 | 11 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 11 | 12 | import { flowPage } from "../web/pages/flow.ts"; |
| 12 | 13 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; |
| @@ -390,6 +391,7 @@ import { apiRoutes } from "./api.ts"; | ||
| 390 | 391 | import { feedRoutes } from "./feed.ts"; |
| 391 | 392 | import { mcpRoutes } from "./mcp.ts"; |
| 392 | 393 | import { oauthRoutes } from "./oauth.ts"; |
| 394 | import { flushAccess, recordAccess } from "./access_log.ts"; | |
| 393 | 395 | import { carryPeer, clientAddress, notePeer, type ProxyTrust } from "./peer.ts"; |
| 394 | 396 | import { parseCidrs } from "../kernel/cidr.ts"; |
| 395 | 397 | import { |
| @@ -430,6 +432,7 @@ import { getUserById } from "../core/users.ts"; | ||
| 430 | 432 | import { appsRuntime } from "../apps/runtime.ts"; |
| 431 | 433 | import { |
| 432 | 434 | appWebSocket, |
| 435 | type AppSocket, | |
| 433 | 436 | proxyApp, |
| 434 | 437 | resolveAppHost, |
| 435 | 438 | } from "../apps/proxy.ts"; |
| @@ -696,6 +699,7 @@ export function buildRouter(): Router<Handler> { | ||
| 696 | 699 | router.add("POST", "/settings/cloud/write", cloudWrite); |
| 697 | 700 | router.add("POST", "/settings/cloud/audit", cloudAudit); |
| 698 | 701 | router.add("POST", "/settings/cloud/remove", cloudRemove); |
| 702 | router.add("GET", "/settings/access", accessPage); | |
| 699 | 703 | router.add("GET", "/settings/capacity", capacityPage); |
| 700 | 704 | router.add("POST", "/settings/capacity", capacitySet); |
| 701 | 705 | router.add("GET", "/boards/data", boardsPage); |
| @@ -1732,19 +1736,28 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1732 | 1736 | : { header: app.config.clientIpHeader }), |
| 1733 | 1737 | }; |
| 1734 | 1738 | |
| 1735 | const web = Bun.serve({ | |
| 1736 | port: app.config.port ?? 3000, | |
| 1737 | // A release swaps in beside this process on the same port (#1663). | |
| 1738 | ...(app.config.selfSwap === true ? { reusePort: true } : {}), | |
| 1739 | idleTimeout: 120, | |
| 1740 | // The largest thing anyone may send — a git push. Every reader below it | |
| 1741 | // keeps its own, smaller cap (\`http/limits.ts\`); the runtime enforces | |
| 1742 | // this one only on a declared length. | |
| 1743 | maxRequestBodySize: REQUEST_MAX, | |
| 1744 | // The only sockets the forge holds are an app's, carried through the | |
| 1745 | // proxy; its own live surfaces are SSE (`sse.ts`) and stay so. | |
| 1746 | websocket: appWebSocket, | |
| 1747 | async fetch(request, server) { | |
| 1739 | // What the forge itself answered, for the owner's access log. Called only | |
| 1740 | // after `notePeer`, so the caller is known; an app's own traffic (the | |
| 1741 | // `proxyApp` branch) is the app's and never comes through here. `ms` is the | |
| 1742 | // time to headers: a streamed body is still being sent. | |
| 1743 | const logged = ( | |
| 1744 | dataDir: string, | |
| 1745 | request: Request, | |
| 1746 | response: Response, | |
| 1747 | started: number, | |
| 1748 | ) => | |
| 1749 | recordAccess(dataDir, { | |
| 1750 | request, | |
| 1751 | status: response.status, | |
| 1752 | ms: performance.now() - started, | |
| 1753 | client: clientAddress(request), | |
| 1754 | }); | |
| 1755 | ||
| 1756 | const handle = async ( | |
| 1757 | request: Request, | |
| 1758 | server: Bun.Server<AppSocket>, | |
| 1759 | ): Promise<Response | undefined> => { | |
| 1760 | const started = performance.now(); | |
| 1748 | 1761 | // An app's hostname is answered before any route: `hello.<domain>` is |
| 1749 | 1762 | // the app, whatever its path, and the forge's own routes never apply. |
| 1750 | 1763 | const runtime = appsRuntime(app); |
| @@ -1775,6 +1788,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1775 | 1788 | notePeer(request, server.requestIP(request)?.address, trust); |
| 1776 | 1789 | armClose(request, () => server.timeout(request, 1)); |
| 1777 | 1790 | const response = securityFloor(await http.handle(request)); |
| 1791 | logged(key, request, response, started); | |
| 1778 | 1792 | await settleBody(request); |
| 1779 | 1793 | if (!mustClose(request)) return response; |
| 1780 | 1794 | server.timeout(request, 1); |
| @@ -1790,12 +1804,27 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1790 | 1804 | const response = securityFloor( |
| 1791 | 1805 | await answer(app, router, apiRouter, limiter, request), |
| 1792 | 1806 | ); |
| 1807 | logged(app.config.dataDir, request, response, started); | |
| 1793 | 1808 | await settleBody(request); |
| 1794 | 1809 | if (!mustClose(request)) return response; |
| 1795 | 1810 | // Said, so no client or proxy reuses it; then done, so none can. |
| 1796 | 1811 | server.timeout(request, 1); |
| 1797 | 1812 | return withHeader(response, "connection", "close"); |
| 1798 | }, | |
| 1813 | }; | |
| 1814 | ||
| 1815 | const web = Bun.serve({ | |
| 1816 | port: app.config.port ?? 3000, | |
| 1817 | // A release swaps in beside this process on the same port (#1663). | |
| 1818 | ...(app.config.selfSwap === true ? { reusePort: true } : {}), | |
| 1819 | idleTimeout: 120, | |
| 1820 | // The largest thing anyone may send — a git push. Every reader below it | |
| 1821 | // keeps its own, smaller cap (\`http/limits.ts\`); the runtime enforces | |
| 1822 | // this one only on a declared length. | |
| 1823 | maxRequestBodySize: REQUEST_MAX, | |
| 1824 | // The only sockets the forge holds are an app's, carried through the | |
| 1825 | // proxy; its own live surfaces are SSE (`sse.ts`) and stay so. | |
| 1826 | websocket: appWebSocket, | |
| 1827 | fetch: handle, | |
| 1799 | 1828 | error(error) { |
| 1800 | 1829 | const refused = refusedByDatabase(error); |
| 1801 | 1830 | if (refused !== undefined) { |
| @@ -1841,6 +1870,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1841 | 1870 | for (const http of childHttps.values()) http.close(); |
| 1842 | 1871 | childHttps.clear(); |
| 1843 | 1872 | closeHostedChildren(app); |
| 1873 | flushAccess(); | |
| 1844 | 1874 | web.stop(true); |
| 1845 | 1875 | internal.stop(true); |
| 1846 | 1876 | // Our socket goes with us; a corpse is only ever an unclean exit's. |
src/web/pages/access.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * What outside agents tried, for a forge admin: the last day's non-browser | |
| 3 | * requests to public paths (`http/access_log.ts`), filterable by user agent | |
| 4 | * and path. The log holds no token, cookie or query string, and a hash — | |
| 5 | * never an address — stands for the caller. | |
| 6 | */ | |
| 7 | ||
| 8 | import { listAccess } from "../../actions/access_log.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 11 | import { html } from "../html.ts"; | |
| 12 | import { page } from "../layout.ts"; | |
| 13 | import { btn, card, icon, row } from "../ui.ts"; | |
| 14 | import { accountSections } from "./origins.ts"; | |
| 15 | ||
| 16 | // GET /settings/access | |
| 17 | export function accessPage(ctx: Ctx): Response { | |
| 18 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 19 | const ua = ctx.url.searchParams.get("ua")?.slice(0, 100) ?? ""; | |
| 20 | const path = ctx.url.searchParams.get("path")?.slice(0, 200) ?? ""; | |
| 21 | const listed = listAccess(ctx.app, ctx.auth.user, { | |
| 22 | ...(ua === "" ? {} : { ua }), | |
| 23 | ...(path === "" ? {} : { path }), | |
| 24 | }); | |
| 25 | if (!listed.ok) return notFoundPage(ctx); | |
| 26 | const rows = listed.value.map((l) => | |
| 27 | row({ | |
| 28 | lead: icon("activity"), | |
| 29 | title: html`<span class="mono">${l.method} ${l.path}</span> | |
| 30 | <span class="mono">${String(l.status)}</span>`, | |
| 31 | meta: html`${l.t} · ${l.host} · ${String(l.ms)} ms · | |
| 32 | ${l.auth ? "signed in" : "anonymous"} · | |
| 33 | <span class="mono">${l.who}</span> · | |
| 34 | ${l.ua === "" ? "no user agent" : l.ua}`, | |
| 35 | }), | |
| 36 | ); | |
| 37 | const body = html`${card( | |
| 38 | html`<div class="card-body"> | |
| 39 | <p class="muted small"> | |
| 40 | The last day's requests from anything that is not a browser, to | |
| 41 | public paths (issues, the API, MCP, discovery files, sign-in and | |
| 42 | sign-up). Tokens, cookies and query strings are never kept; the | |
| 43 | hash stands for one caller for one day. | |
| 44 | </p> | |
| 45 | <form class="row-actions" method="get" action="/settings/access"> | |
| 46 | <input | |
| 47 | class="input" | |
| 48 | name="ua" | |
| 49 | value="${ua}" | |
| 50 | placeholder="User agent contains" | |
| 51 | aria-label="User agent contains" | |
| 52 | /> | |
| 53 | <input | |
| 54 | class="input mono" | |
| 55 | name="path" | |
| 56 | value="${path}" | |
| 57 | placeholder="/api/" | |
| 58 | aria-label="Path starts with" | |
| 59 | /> | |
| 60 | ${btn("Filter", { size: "sm", variant: "primary" })} | |
| 61 | </form> | |
| 62 | </div> | |
| 63 | ${rows.length === 0 | |
| 64 | ? html`<div class="card-body"> | |
| 65 | <p class="muted small" id="access-none">Nothing in the last day.</p> | |
| 66 | </div>` | |
| 67 | : html`<ul class="list" id="access-list"> | |
| 68 | ${rows} | |
| 69 | </ul>`}`, | |
| 70 | { | |
| 71 | head: html`${icon("activity")} | |
| 72 | <h2>Outside requests</h2>`, | |
| 73 | pad: false, | |
| 74 | }, | |
| 75 | )}`; | |
| 76 | return page({ | |
| 77 | ctx, | |
| 78 | title: "Access", | |
| 79 | body: html`<div class="page-head"><h1>Access</h1></div> | |
| 80 | ${settingsLayout( | |
| 81 | accountSections("access", { admin: true }), | |
| 82 | body, | |
| 83 | "Account settings", | |
| 84 | )}`, | |
| 85 | }); | |
| 86 | } |
src/web/pages/origins.ts +7
| @@ -81,6 +81,7 @@ export type AccountSection = | ||
| 81 | 81 | | "backups" |
| 82 | 82 | | "secrets" |
| 83 | 83 | | "capacity" |
| 84 | | "access" | |
| 84 | 85 | | "limits" |
| 85 | 86 | | "cloud"; |
| 86 | 87 | |
| @@ -133,6 +134,12 @@ export function accountSections( | ||
| 133 | 134 | icon: "activity" as const, |
| 134 | 135 | current: current === "capacity", |
| 135 | 136 | }, |
| 137 | { | |
| 138 | label: "Access", | |
| 139 | href: "/settings/access", | |
| 140 | icon: "activity" as const, | |
| 141 | current: current === "access", | |
| 142 | }, | |
| 136 | 143 | { |
| 137 | 144 | label: "Limits", |
| 138 | 145 | href: "/admin/limits", |
test/access-log.test.ts added +294
| @@ -0,0 +1,294 @@ | ||
| 1 | /** | |
| 2 | * What outside agents tried (#1061): one compact line per request that is | |
| 3 | * not a static asset, with no token, cookie, query string or address in it, | |
| 4 | * kept 14 days and size-capped, and readable only by a forge admin through | |
| 5 | * Settings → Access and the `access` MCP tool (write scope). | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { | |
| 17 | accessDir, | |
| 18 | clientHash, | |
| 19 | flushAccess, | |
| 20 | keptPath, | |
| 21 | prune, | |
| 22 | readAccess, | |
| 23 | recordAccess, | |
| 24 | TOTAL_BYTES, | |
| 25 | } from "../src/http/access_log.ts"; | |
| 26 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 27 | import { newApp } from "../src/actions/apps.ts"; | |
| 28 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 29 | ||
| 30 | const logText = (dir: string): string => { | |
| 31 | flushAccess(); | |
| 32 | return readdirSync(accessDir(dir)) | |
| 33 | .map((f) => readFileSync(join(accessDir(dir), f), "utf8")) | |
| 34 | .join(""); | |
| 35 | }; | |
| 36 | ||
| 37 | describe("the access log", () => { | |
| 38 | let dataDir: string; | |
| 39 | let app: App; | |
| 40 | let servers: Servers; | |
| 41 | let adminRw: string; | |
| 42 | let adminRo: string; | |
| 43 | let bobRw: string; | |
| 44 | ||
| 45 | const mcp = async (token: string, args: Record<string, unknown>) => { | |
| 46 | const res = await fetch(`${servers.url}/mcp`, { | |
| 47 | method: "POST", | |
| 48 | headers: { | |
| 49 | authorization: `Bearer ${token}`, | |
| 50 | "content-type": "application/json", | |
| 51 | accept: "application/json, text/event-stream", | |
| 52 | }, | |
| 53 | body: JSON.stringify({ | |
| 54 | jsonrpc: "2.0", | |
| 55 | id: 1, | |
| 56 | method: "tools/call", | |
| 57 | params: { name: "access", arguments: args }, | |
| 58 | }), | |
| 59 | }); | |
| 60 | const body = (await res.json()) as { | |
| 61 | result: { isError?: boolean; content: { text: string }[] }; | |
| 62 | }; | |
| 63 | return { | |
| 64 | error: body.result.isError === true, | |
| 65 | text: body.result.content[0]!.text, | |
| 66 | }; | |
| 67 | }; | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-access-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | servers = await serve(app); | |
| 73 | const a = await createUser(app.db, { | |
| 74 | trusted: true, | |
| 75 | username: "alice", | |
| 76 | email: "[email protected]", | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst: true, | |
| 79 | }); | |
| 80 | const b = await createUser(app.db, { | |
| 81 | trusted: true, | |
| 82 | username: "bob", | |
| 83 | email: "[email protected]", | |
| 84 | password: "a-good-passphrase", | |
| 85 | }); | |
| 86 | if (!a.ok || !b.ok) throw new Error("users"); | |
| 87 | adminRw = createToken(app.db, a.value.id, "t", "rw").token; | |
| 88 | adminRo = createToken(app.db, a.value.id, "t", "ro").token; | |
| 89 | bobRw = createToken(app.db, b.value.id, "t", "rw").token; | |
| 90 | }); | |
| 91 | ||
| 92 | afterAll(async () => { | |
| 93 | servers.stop(); | |
| 94 | app.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a replayed agent request is kept, redacted, and read back", async () => { | |
| 99 | const secret = "forge_SECRETTOKENVALUE0123456789abcdef"; | |
| 100 | await fetch(`${servers.url}/api/v1/nothing-here?token=${secret}&q=hunter2`, { | |
| 101 | headers: { | |
| 102 | "user-agent": "ExampleAgent/1.0 (+https://example.test)", | |
| 103 | authorization: `Bearer ${secret}`, | |
| 104 | cookie: "forge_session=COOKIEVALUE123; other=zzz", | |
| 105 | }, | |
| 106 | }); | |
| 107 | await fetch(`${servers.url}/mcp`, { | |
| 108 | method: "POST", | |
| 109 | headers: { "user-agent": "ExampleAgent/1.0" }, | |
| 110 | body: "{}", | |
| 111 | }); | |
| 112 | // A browser and a static asset are not what this reads. | |
| 113 | await fetch(`${servers.url}/issues`, { | |
| 114 | headers: { "user-agent": "Mozilla/5.0 (X11) Firefox/130" }, | |
| 115 | }); | |
| 116 | await fetch(`${servers.url}/assets/missing.css`); | |
| 117 | ||
| 118 | const raw = logText(dataDir); | |
| 119 | for (const leak of [secret, "SECRETTOKENVALUE", "hunter2", "COOKIEVALUE", "zzz", "q=", "127.0.0.1"]) | |
| 120 | expect(raw).not.toContain(leak); | |
| 121 | expect(raw).not.toContain("assets"); | |
| 122 | const lines = raw.trim().split("\n").map((l) => JSON.parse(l)); | |
| 123 | const hit = lines.find((l) => l.path === "/api/v1/nothing-here"); | |
| 124 | expect(hit).toMatchObject({ | |
| 125 | method: "GET", | |
| 126 | status: 404, | |
| 127 | auth: true, | |
| 128 | ua: "ExampleAgent/1.0 (+https://example.test)", | |
| 129 | }); | |
| 130 | expect(hit.who).toMatch(/^[0-9a-f]{16}$/); | |
| 131 | expect(typeof hit.ms).toBe("number"); | |
| 132 | ||
| 133 | const read = await mcp(adminRw, { ua: "exampleagent" }); | |
| 134 | expect(read.error).toBe(false); | |
| 135 | const rows = JSON.parse(read.text).requests as { path: string; ua: string }[]; | |
| 136 | expect(rows.map((r) => r.path)).toContain("/api/v1/nothing-here"); | |
| 137 | expect(rows.every((r) => !r.ua.includes("Mozilla"))).toBe(true); | |
| 138 | const narrow = JSON.parse((await mcp(adminRw, { path: "/mcp" })).text).requests; | |
| 139 | expect(narrow.every((r: { path: string }) => r.path.startsWith("/mcp"))).toBe(true); | |
| 140 | expect(narrow.length).toBeGreaterThan(0); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a path never carries a secret-looking segment or a long line", () => { | |
| 144 | expect(keptPath("/claim/abcdefghijklmnopqrstuvwxyz0123")).toBe("/claim/:redacted"); | |
| 145 | expect(keptPath("/x/forge_abc")).toBe("/x/:redacted"); | |
| 146 | expect(keptPath("/issues/12")).toBe("/issues/12"); | |
| 147 | expect(keptPath("/a\n/b" + "c".repeat(500)).length).toBeLessThanOrEqual(200); | |
| 148 | expect(keptPath("/a\nb")).not.toContain("\n"); | |
| 149 | }); | |
| 150 | ||
| 151 | test("only an admin with write scope reads it, on the page and by MCP", async () => { | |
| 152 | expect((await mcp(adminRo, {})).error).toBe(true); | |
| 153 | expect((await mcp(adminRo, {})).text).toContain("forbidden"); | |
| 154 | const bob = await mcp(bobRw, {}); | |
| 155 | expect(bob.error).toBe(true); | |
| 156 | expect(bob.text).toContain("not_found"); | |
| 157 | const anon = await fetch(`${servers.url}/settings/access`, { redirect: "manual" }); | |
| 158 | expect(anon.status).not.toBe(200); | |
| 159 | }); | |
| 160 | ||
| 161 | test("the settings page lists what was tried, escaped", async () => { | |
| 162 | const res = await fetch(`${servers.url}/signin`, { | |
| 163 | method: "POST", | |
| 164 | redirect: "manual", | |
| 165 | headers: { | |
| 166 | "content-type": "application/x-www-form-urlencoded", | |
| 167 | origin: servers.url, | |
| 168 | }, | |
| 169 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 170 | }); | |
| 171 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 172 | await fetch(`${servers.url}/api/v1/x`, { | |
| 173 | headers: { "user-agent": "<script>alert(1)</script>-bot" }, | |
| 174 | }); | |
| 175 | const page = await fetch(`${servers.url}/settings/access`, { headers: { cookie } }); | |
| 176 | expect(page.status).toBe(200); | |
| 177 | const html = await page.text(); | |
| 178 | expect(html).toContain("Outside requests"); | |
| 179 | expect(html).toContain("/api/v1/nothing-here"); | |
| 180 | expect(html).not.toContain("<script>alert(1)</script>-bot"); | |
| 181 | expect(html).toContain("<script>"); | |
| 182 | }); | |
| 183 | }); | |
| 184 | ||
| 185 | describe("the access log's keeping", () => { | |
| 186 | test("files older than 14 days go, and the directory stays under its cap", async () => { | |
| 187 | const dir = await mkdtemp(join(tmpdir(), "forge-access-rot-")); | |
| 188 | try { | |
| 189 | const keep = accessDir(dir); | |
| 190 | mkdirSync(keep, { recursive: true }); | |
| 191 | writeFileSync(join(keep, "access-2026-09-01.log"), "old\n"); | |
| 192 | writeFileSync(join(keep, "access-2026-09-30.log"), "recent\n"); | |
| 193 | const now = new Date("2026-10-13T12:00:00Z"); | |
| 194 | prune(dir, now); | |
| 195 | expect(readdirSync(keep)).toEqual(["access-2026-09-30.log"]); | |
| 196 | // Over the cap: the oldest goes first, today's file always stays. | |
| 197 | const big = "x".repeat(TOTAL_BYTES / 2 + 1); | |
| 198 | writeFileSync(join(keep, "access-2026-10-10.log"), big); | |
| 199 | writeFileSync(join(keep, "access-2026-10-11.log"), big); | |
| 200 | writeFileSync(join(keep, "access-2026-10-13.log"), "today\n"); | |
| 201 | prune(dir, now); | |
| 202 | expect(readdirSync(keep).sort()).toEqual([ | |
| 203 | "access-2026-10-11.log", | |
| 204 | "access-2026-10-13.log", | |
| 205 | ]); | |
| 206 | } finally { | |
| 207 | await rm(dir, { recursive: true, force: true }); | |
| 208 | } | |
| 209 | }); | |
| 210 | ||
| 211 | test("recording prunes on a new day, and the hash changes with the day", async () => { | |
| 212 | const dir = await mkdtemp(join(tmpdir(), "forge-access-day-")); | |
| 213 | try { | |
| 214 | const seen = (path: string) => ({ | |
| 215 | request: new Request(`http://forge.test${path}?k=v`, { | |
| 216 | headers: { "user-agent": "bot" }, | |
| 217 | }), | |
| 218 | status: 200, | |
| 219 | ms: 3.4, | |
| 220 | client: "203.0.113.9", | |
| 221 | }); | |
| 222 | mkdirSync(accessDir(dir), { recursive: true }); | |
| 223 | writeFileSync(join(accessDir(dir), "access-2026-09-01.log"), "old\n"); | |
| 224 | recordAccess(dir, seen("/llms.txt"), new Date("2026-10-13T01:00:00Z")); | |
| 225 | recordAccess(dir, seen("/llms.txt"), new Date("2026-10-14T01:00:00Z")); | |
| 226 | // Batched: a request has only queued its line. | |
| 227 | expect(readdirSync(accessDir(dir))).toEqual(["access-2026-09-01.log"]); | |
| 228 | flushAccess(new Date("2026-10-14T01:00:00Z")); | |
| 229 | expect(readdirSync(accessDir(dir)).sort()).toEqual([ | |
| 230 | "access-2026-10-13.log", | |
| 231 | "access-2026-10-14.log", | |
| 232 | ]); | |
| 233 | const [a, b] = readdirSync(accessDir(dir)) | |
| 234 | .sort() | |
| 235 | .map((f) => JSON.parse(readFileSync(join(accessDir(dir), f), "utf8"))); | |
| 236 | expect(a.who).not.toBe(b.who); | |
| 237 | expect(logText(dir)).not.toContain("203.0.113.9"); | |
| 238 | const rows = readAccess(dir, { since: "2026-10-13T00:00:00Z" }, new Date("2026-10-14T02:00:00Z")); | |
| 239 | expect(rows.map((r) => r.path)).toEqual(["/llms.txt", "/llms.txt"]); | |
| 240 | } finally { | |
| 241 | await rm(dir, { recursive: true, force: true }); | |
| 242 | } | |
| 243 | }); | |
| 244 | }); | |
| 245 | ||
| 246 | describe("the access log and app hostnames", () => { | |
| 247 | test("an app's own traffic, and a name nobody holds, are not the forge's access", async () => { | |
| 248 | const dataDir = await mkdtemp(join(tmpdir(), "forge-access-apps-")); | |
| 249 | const app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 250 | const servers = await serve(app); | |
| 251 | const runtime = new AppsRuntime(app, { | |
| 252 | domain: "apps.test", | |
| 253 | scheme: "http", | |
| 254 | publicPort: servers.port, | |
| 255 | forgeUrl: servers.url, | |
| 256 | concurrency: 1, | |
| 257 | }); | |
| 258 | try { | |
| 259 | await runtime.start(); | |
| 260 | const u = await createUser(app.db, { | |
| 261 | trusted: true, | |
| 262 | username: "alice", | |
| 263 | email: "[email protected]", | |
| 264 | password: "a-good-passphrase", | |
| 265 | adminIfFirst: true, | |
| 266 | }); | |
| 267 | if (!u.ok) throw new Error(u.error); | |
| 268 | const made = await newApp(app, u.value, { | |
| 269 | name: "notes", | |
| 270 | description: "", | |
| 271 | visibility: "public", | |
| 272 | }); | |
| 273 | if (!made.ok) throw new Error(String(made.error)); | |
| 274 | const ask = (host: string, path: string) => | |
| 275 | fetch(`${servers.url}${path}`, { | |
| 276 | headers: { host, "user-agent": "VisitorBot/1.0" }, | |
| 277 | }); | |
| 278 | await ask("notes.apps.test", "/api/visitor-of-the-app"); | |
| 279 | await ask("nosuch.apps.test", "/api/nobody-home"); | |
| 280 | await ask("forge.test", "/api/the-forge-itself"); | |
| 281 | const lines = logText(dataDir) | |
| 282 | .trim() | |
| 283 | .split("\n") | |
| 284 | .map((l) => JSON.parse(l)); | |
| 285 | expect(lines.map((l) => l.path)).toEqual(["/api/the-forge-itself"]); | |
| 286 | expect(lines[0].who).not.toBe(clientHash(dataDir, "unknown", new Date())); | |
| 287 | } finally { | |
| 288 | await runtime.stop(); | |
| 289 | servers.stop(); | |
| 290 | app.close(); | |
| 291 | await rm(dataDir, { recursive: true, force: true }); | |
| 292 | } | |
| 293 | }); | |
| 294 | }); |
test/mcp.test.ts +1
| @@ -202,6 +202,7 @@ describe("the forge over MCP", () => { | ||
| 202 | 202 | "attach_look", |
| 203 | 203 | "hold", |
| 204 | 204 | "plan", |
| 205 | "access", | |
| 205 | 206 | "capacity", |
| 206 | 207 | "flow", |
| 207 | 208 | "limits", |
test/spawn-host.test.ts +25 −1
| @@ -6,9 +6,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { spawnForge } from "../src/actions/spawn.ts"; | |
| 9 | import { readdirSync, readFileSync } from "node:fs"; | |
| 10 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 10 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 12 | import { createUser } from "../src/core/users.ts"; |
| 13 | import { flushAccess } from "../src/http/access_log.ts"; | |
| 12 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 15 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| 14 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -72,4 +74,26 @@ describe("hosted child Host dispatch", () => { | ||
| 72 | 74 | }); |
| 73 | 75 | expect(unknown.status).toBe(404); |
| 74 | 76 | }); |
| 77 | ||
| 78 | test("a child's requests are logged in the child's data, never the parent's", async () => { | |
| 79 | const ask = (host: string, path: string) => | |
| 80 | fetch(servers.url + path, { | |
| 81 | headers: { host, "user-agent": "VisitorBot/1.0" }, | |
| 82 | }); | |
| 83 | await ask("homie--forge.test", "/api/in-the-childs-house"); | |
| 84 | await ask("forge.test", "/api/in-the-parents-house"); | |
| 85 | flushAccess(); | |
| 86 | const { bodyId } = JSON.parse( | |
| 87 | readFileSync(join(dir, "spawn", "homie.json"), "utf8"), | |
| 88 | ) as { bodyId: string }; | |
| 89 | const paths = (dataDir: string): string[] => | |
| 90 | readdirSync(join(dataDir, "access")).flatMap((f) => | |
| 91 | readFileSync(join(dataDir, "access", f), "utf8") | |
| 92 | .split("\n") | |
| 93 | .filter((l) => l !== "") | |
| 94 | .map((l) => (JSON.parse(l) as { path: string }).path), | |
| 95 | ); | |
| 96 | expect(paths(childDirOf(parent, bodyId))).toEqual(["/api/in-the-childs-house"]); | |
| 97 | expect(paths(dir)).toEqual(["/api/in-the-parents-house"]); | |
| 98 | }); | |
| 75 | 99 | }); |
new above
offline