@gafj/gafj 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/bin/cli.js +628 -0
- package/bridge/byo_key.js +50 -0
- package/bridge/credits.js +72 -0
- package/bridge/paste.js +44 -0
- package/bridge/providers/anthropic.js +16 -0
- package/bridge/providers/gemini.js +15 -0
- package/bridge/providers/index.js +39 -0
- package/bridge/providers/openai_compatible.js +17 -0
- package/core/extract.js +55 -0
- package/core/generators/excerpt.js +64 -0
- package/core/generators/packet.js +197 -0
- package/core/generators/serialize.js +56 -0
- package/core/generators/slice.js +61 -0
- package/core/house_rules.js +49 -0
- package/core/kb_import.js +257 -0
- package/core/migrations/002-profile.sql +3 -0
- package/core/migrations/003-discover.sql +49 -0
- package/core/parsers.js +79 -0
- package/core/practice.js +75 -0
- package/core/provenance.js +90 -0
- package/core/qr.js +185 -0
- package/core/rules/categories.md +5 -0
- package/core/rules/deep_answers.md +7 -0
- package/core/rules/discover.md +30 -0
- package/core/rules/extract.md +31 -0
- package/core/rules/followup.md +9 -0
- package/core/rules/index.js +32 -0
- package/core/rules/onepager.md +15 -0
- package/core/rules/practice.md +10 -0
- package/core/rules/prep.md +47 -0
- package/core/rules/resume.md +49 -0
- package/core/rules/rubric.md +120 -0
- package/core/schema.sql +342 -0
- package/core/schemas/categories.js +16 -0
- package/core/schemas/cover.js +10 -0
- package/core/schemas/deep_answers.js +16 -0
- package/core/schemas/discover.js +19 -0
- package/core/schemas/extract.js +32 -0
- package/core/schemas/followup.js +10 -0
- package/core/schemas/index.js +32 -0
- package/core/schemas/lang.js +111 -0
- package/core/schemas/onepager.js +26 -0
- package/core/schemas/practice.js +18 -0
- package/core/schemas/prep.js +15 -0
- package/core/schemas/resume.js +25 -0
- package/core/scoring.js +586 -0
- package/core/text.js +98 -0
- package/core/tracker_map.js +94 -0
- package/core/ulid.js +38 -0
- package/core/validators/highlight_body_match.js +49 -0
- package/core/validators/index.js +43 -0
- package/core/validators/metric_pairing.js +52 -0
- package/core/validators/no_confabulated_history.js +63 -0
- package/core/validators/no_dashes.js +31 -0
- package/core/validators/no_derived_tenure.js +60 -0
- package/core/validators/style_config.js +18 -0
- package/core/validators/verb_ladder.js +51 -0
- package/core/verbs.js +43 -0
- package/http/api.js +260 -0
- package/http/guard.js +106 -0
- package/http/headers.js +17 -0
- package/http/hosted.js +273 -0
- package/http/identity.js +70 -0
- package/http/server.js +136 -0
- package/http/sse.js +41 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +237 -0
- package/package.json +59 -0
- package/store/backup.js +63 -0
- package/store/backup_dir.js +60 -0
- package/store/config.js +32 -0
- package/store/context.js +74 -0
- package/store/db.js +37 -0
- package/store/discover.js +120 -0
- package/store/events.js +28 -0
- package/store/export_xlsx.js +62 -0
- package/store/home.js +36 -0
- package/store/import_kb.js +88 -0
- package/store/import_tracker.js +195 -0
- package/store/ingest.js +140 -0
- package/store/kb.js +202 -0
- package/store/log.js +88 -0
- package/store/migrate.js +55 -0
- package/store/onboarding.js +267 -0
- package/store/packets.js +110 -0
- package/store/practice.js +52 -0
- package/store/proposals.js +119 -0
- package/store/reads.js +110 -0
- package/store/render.js +111 -0
- package/store/reply.js +19 -0
- package/store/review.js +64 -0
- package/store/scoring.js +134 -0
- package/store/settings.js +142 -0
- package/store/snapshot.js +88 -0
- package/store/transitions.js +391 -0
- package/store/tx.js +71 -0
- package/store/tz.js +24 -0
- package/ui/app.css +137 -0
- package/ui/app.js +75 -0
- package/ui/fonts/plexmono-400.woff2 +0 -0
- package/ui/fonts/plexmono-500.woff2 +0 -0
- package/ui/fonts/plexmono-600.woff2 +0 -0
- package/ui/fonts/spacegrotesk-var.woff2 +0 -0
- package/ui/hosted/signin.css +8 -0
- package/ui/hosted/signin.html +30 -0
- package/ui/hosted/signin.js +28 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/index.html +20 -0
- package/ui/lib.js +92 -0
- package/ui/manifest.webmanifest +18 -0
- package/ui/screens/application.js +73 -0
- package/ui/screens/dashboard.js +37 -0
- package/ui/screens/document.js +64 -0
- package/ui/screens/interview.js +85 -0
- package/ui/screens/kb.js +142 -0
- package/ui/screens/packet.js +122 -0
- package/ui/screens/postings.js +92 -0
- package/ui/screens/settings.js +115 -0
- package/ui/vendor/preact.mjs +1 -0
package/http/hosted.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The hosted server (plan section W): the same routes, screens, doors, and
|
|
5
|
+
* gate as the local server, behind a sign-in instead of a launch cookie,
|
|
6
|
+
* with one store per user. Tenant isolation is a property of the request:
|
|
7
|
+
* the session names a uid, the uid names a home folder, and every handler
|
|
8
|
+
* runs against that folder's database and config only. There is no query
|
|
9
|
+
* that spans tenants. A user can export their store (a restorable backup
|
|
10
|
+
* file) and delete their account (the folder is gone, sessions with it).
|
|
11
|
+
*
|
|
12
|
+
* Provider keys are never stored for hosted users; the model is reached
|
|
13
|
+
* through the relay with a service key and the uid, never a user token.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const http = require("http");
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const os = require("os");
|
|
20
|
+
const crypto = require("crypto");
|
|
21
|
+
const { readJson, parseCookies } = require("./guard");
|
|
22
|
+
const { securityHeaders, CSP } = require("./headers");
|
|
23
|
+
const { serveEvents } = require("./sse");
|
|
24
|
+
const api = require("./api");
|
|
25
|
+
const { open } = require("../store/db");
|
|
26
|
+
const { migrate } = require("../store/migrate");
|
|
27
|
+
const { readConfig, writeConfig } = require("../store/config");
|
|
28
|
+
const { DEFAULT_THRESHOLDS } = require("../store/snapshot");
|
|
29
|
+
const { ulid } = require("../core/ulid");
|
|
30
|
+
const { backup, backupName } = require("../store/backup");
|
|
31
|
+
const { silent } = require("../store/log");
|
|
32
|
+
const { UI_DIR } = require("./server");
|
|
33
|
+
|
|
34
|
+
const COOKIE = "gafj_hs";
|
|
35
|
+
const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
36
|
+
const IDLE_CLOSE_MS = 10 * 60 * 1000;
|
|
37
|
+
const SIGNIN_CSP = "default-src 'self'; script-src 'self' https://www.gstatic.com https://apis.google.com; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://identitytoolkit.googleapis.com https://securetoken.googleapis.com https://www.googleapis.com https://apis.google.com; frame-src https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
|
|
38
|
+
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json", ".ico": "image/x-icon", ".woff2": "font/woff2" };
|
|
39
|
+
const BLOCKED_IN_HOSTED = [/^\/api\/settings\/providers/, /^\/api\/mcp-config$/, /^\/api\/restore-test$/];
|
|
40
|
+
|
|
41
|
+
function json(res, status, body) {
|
|
42
|
+
const text = JSON.stringify(body);
|
|
43
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(text) });
|
|
44
|
+
res.end(text);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const safeUid = (uid) => (/^[A-Za-z0-9_-]{1,128}$/.test(uid) ? uid : crypto.createHash("sha256").update(String(uid)).digest("hex").slice(0, 40));
|
|
48
|
+
|
|
49
|
+
/** Server-side sessions: random id to uid, persisted so a restart signs nobody out. */
|
|
50
|
+
class Sessions {
|
|
51
|
+
constructor(file, now) { this.file = file; this.now = now; this.map = new Map(); this.load(); }
|
|
52
|
+
load() {
|
|
53
|
+
try { for (const [k, v] of Object.entries(JSON.parse(fs.readFileSync(this.file, "utf8")))) this.map.set(k, v); } catch (_) { /* first start */ }
|
|
54
|
+
}
|
|
55
|
+
save() {
|
|
56
|
+
try { fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.map)) + "\n", { mode: 0o600 }); } catch (_) { /* read-only disk: sessions live in memory */ }
|
|
57
|
+
}
|
|
58
|
+
create(who) {
|
|
59
|
+
for (const [k, v] of this.map) if (v.expires < this.now()) this.map.delete(k);
|
|
60
|
+
const id = crypto.randomBytes(32).toString("base64url");
|
|
61
|
+
this.map.set(id, { uid: who.uid, email: who.email || null, created: this.now(), expires: this.now() + SESSION_TTL_MS });
|
|
62
|
+
this.save();
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
get(id) {
|
|
66
|
+
if (!id) return null;
|
|
67
|
+
const s = this.map.get(id);
|
|
68
|
+
if (!s) return null;
|
|
69
|
+
if (s.expires < this.now()) { this.map.delete(id); this.save(); return null; }
|
|
70
|
+
return s;
|
|
71
|
+
}
|
|
72
|
+
drop(id) { if (this.map.delete(id)) this.save(); }
|
|
73
|
+
dropUid(uid) { let n = 0; for (const [k, v] of this.map) if (v.uid === uid) { this.map.delete(k); n++; } if (n) this.save(); return n; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One home folder per uid; open databases cached and closed when idle. */
|
|
77
|
+
class Tenants {
|
|
78
|
+
constructor(root, { now, relay }) { this.root = root; this.now = now; this.relay = relay; this.openDbs = new Map(); }
|
|
79
|
+
homeFor(uid) { return path.join(this.root, "tenants", safeUid(uid)); }
|
|
80
|
+
exists(uid) { return fs.existsSync(path.join(this.homeFor(uid), "gafj.db")); }
|
|
81
|
+
/** First sign-in creates the store, the candidate, and a config with the credits route and nothing else. */
|
|
82
|
+
ensure(who, nowIso) {
|
|
83
|
+
const home = this.homeFor(who.uid);
|
|
84
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
85
|
+
const t = this.open(who.uid);
|
|
86
|
+
let cfg = readConfig(home);
|
|
87
|
+
if (!cfg.candidate_id) {
|
|
88
|
+
const id = ulid(Date.parse(nowIso));
|
|
89
|
+
const name = (who.name || (who.email ? who.email.split("@")[0] : "") || "You").slice(0, 80);
|
|
90
|
+
t.db.prepare("INSERT INTO candidate (id, name, created_at) VALUES (?, ?, ?)").run(id, name, nowIso);
|
|
91
|
+
cfg = { candidate_id: id, route: "credits", providers: [], thresholds: { ...DEFAULT_THRESHOLDS }, hosted: true, email: who.email || null };
|
|
92
|
+
writeConfig(home, cfg);
|
|
93
|
+
require("../store/onboarding").openBatch(t.db, { candidate_id: id, now: nowIso, actor: { type: "ui", ref: "signup" } });
|
|
94
|
+
}
|
|
95
|
+
return t;
|
|
96
|
+
}
|
|
97
|
+
open(uid) {
|
|
98
|
+
const hit = this.openDbs.get(uid);
|
|
99
|
+
if (hit) { hit.last = this.now(); return hit; }
|
|
100
|
+
const home = this.homeFor(uid);
|
|
101
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
102
|
+
const db = open(path.join(home, "gafj.db"));
|
|
103
|
+
migrate(db);
|
|
104
|
+
const t = { uid, home, db, last: this.now() };
|
|
105
|
+
this.openDbs.set(uid, t);
|
|
106
|
+
return t;
|
|
107
|
+
}
|
|
108
|
+
close(uid) { const t = this.openDbs.get(uid); if (t) { try { t.db.close(); } catch (_) { /* closed */ } this.openDbs.delete(uid); } }
|
|
109
|
+
closeIdle() { for (const [uid, t] of this.openDbs) if (this.now() - t.last > IDLE_CLOSE_MS) this.close(uid); }
|
|
110
|
+
closeAll() { for (const uid of [...this.openDbs.keys()]) this.close(uid); }
|
|
111
|
+
/** The account is gone: database, exports, config, everything under the folder. */
|
|
112
|
+
destroy(uid) { this.close(uid); fs.rmSync(this.homeFor(uid), { recursive: true, force: true }); }
|
|
113
|
+
/** The request context the shared handlers run against: this tenant's store, config, and folders only. */
|
|
114
|
+
ctx(t, { version, fetchImpl, now }) {
|
|
115
|
+
const cfg = readConfig(t.home);
|
|
116
|
+
const ctx = { db: t.db, candidate_id: cfg.candidate_id, thresholds: cfg.thresholds || {}, route: cfg.route || "credits", exportDir: path.join(t.home, "exports"), home: t.home, version, now, fetch: fetchImpl, hosted: true, uid: t.uid };
|
|
117
|
+
ctx.reload = () => {};
|
|
118
|
+
if (this.relay && this.relay.url) ctx.relayAuth = { relay_url: this.relay.url, headers: { "x-gafj-service": this.relay.service_key, "x-gafj-uid": t.uid } };
|
|
119
|
+
return ctx;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {object} o { root, verifyIdToken, host?, port?, insecure?, firebase?: {apiKey, authDomain, projectId}, relay?: {url, service_key}, version, now?, log?, fetch? }
|
|
125
|
+
*/
|
|
126
|
+
function createHostedServer(o) {
|
|
127
|
+
const nowIso = o.now || (() => new Date().toISOString());
|
|
128
|
+
const nowMs = () => Date.parse(nowIso());
|
|
129
|
+
const log = o.log || silent();
|
|
130
|
+
const secure = !o.insecure;
|
|
131
|
+
const host = o.host ? String(o.host).toLowerCase() : null;
|
|
132
|
+
const originOf = (req) => `${secure ? "https" : "http"}://${String(req.headers.host || "").toLowerCase()}`;
|
|
133
|
+
fs.mkdirSync(o.root, { recursive: true, mode: 0o700 });
|
|
134
|
+
const sessions = new Sessions(path.join(o.root, "sessions.json"), nowMs);
|
|
135
|
+
const tenants = new Tenants(o.root, { now: nowMs, relay: o.relay });
|
|
136
|
+
const sse = new Set();
|
|
137
|
+
const cookie = (id) => `${COOKIE}=${id}; HttpOnly; SameSite=Lax; Path=/${secure ? "; Secure" : ""}`;
|
|
138
|
+
const clearCookie = () => `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secure ? "; Secure" : ""}`;
|
|
139
|
+
const idle = setInterval(() => tenants.closeIdle(), 60 * 1000);
|
|
140
|
+
idle.unref();
|
|
141
|
+
|
|
142
|
+
function mutationCheck(req) {
|
|
143
|
+
if ((req.headers.origin || "") !== originOf(req)) return { status: 403, error: "foreign origin" };
|
|
144
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return { status: 415, error: "application/json only" };
|
|
145
|
+
if (req.headers["sec-fetch-site"] && !["same-origin", "none"].includes(req.headers["sec-fetch-site"])) return { status: 403, error: "cross-site fetch" };
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const sessionOf = (req) => { const id = parseCookies(req.headers.cookie)[COOKIE]; const s = sessions.get(id); return s ? { id, ...s } : null; };
|
|
149
|
+
|
|
150
|
+
const server = http.createServer(async (req, res) => {
|
|
151
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
152
|
+
const p = url.pathname;
|
|
153
|
+
const isApi = p.startsWith("/api/");
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
let who = null;
|
|
156
|
+
res.on("finish", () => log.write(res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", "request",
|
|
157
|
+
{ entity: "http", entity_id: `${req.method} ${p}`, actor: who ? "user" : "anon", ms: Date.now() - started, status: res.statusCode }));
|
|
158
|
+
try {
|
|
159
|
+
if (host && String(req.headers.host || "").toLowerCase() !== host) return json(res, 421, { error: "wrong host" });
|
|
160
|
+
const mutation = req.method !== "GET" && req.method !== "HEAD";
|
|
161
|
+
securityHeaders(res, { noStore: isApi || p.startsWith("/auth/") || p.startsWith("/account/") });
|
|
162
|
+
|
|
163
|
+
if (p === "/signin" || p === "/signin/") {
|
|
164
|
+
res.setHeader("Content-Security-Policy", SIGNIN_CSP);
|
|
165
|
+
res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
|
|
166
|
+
return fs.createReadStream(path.join(UI_DIR, "hosted", "signin.html")).pipe(res);
|
|
167
|
+
}
|
|
168
|
+
if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase || null, signed_in: !!sessionOf(req) });
|
|
169
|
+
if (p === "/auth/session" && req.method === "POST") {
|
|
170
|
+
const g = mutationCheck(req);
|
|
171
|
+
if (g) return json(res, g.status, { error: g.error });
|
|
172
|
+
const body = await readJson(req);
|
|
173
|
+
let id;
|
|
174
|
+
try { id = await o.verifyIdToken(String(body.id_token || "")); } catch (e) { return json(res, 401, { error: e.message || "token rejected" }); }
|
|
175
|
+
if (!id || !id.uid) return json(res, 401, { error: "token rejected" });
|
|
176
|
+
if (id.email_verified === false) return json(res, 403, { error: "verify your Google email first" });
|
|
177
|
+
who = id;
|
|
178
|
+
tenants.ensure(id, nowIso());
|
|
179
|
+
const sid = sessions.create(id);
|
|
180
|
+
log.info("signin", { entity: "account", entity_id: safeUid(id.uid), actor: "user" });
|
|
181
|
+
res.setHeader("Set-Cookie", cookie(sid));
|
|
182
|
+
return json(res, 200, { ok: true, uid: id.uid, email: id.email || null });
|
|
183
|
+
}
|
|
184
|
+
if (p === "/auth/signout" && req.method === "POST") {
|
|
185
|
+
const s = sessionOf(req);
|
|
186
|
+
if (s) sessions.drop(s.id);
|
|
187
|
+
res.setHeader("Set-Cookie", clearCookie());
|
|
188
|
+
return json(res, 200, { ok: true });
|
|
189
|
+
}
|
|
190
|
+
if (p === "/auth/me") {
|
|
191
|
+
const s = sessionOf(req);
|
|
192
|
+
return json(res, s ? 200 : 401, s ? { uid: s.uid, email: s.email, hosted: true } : { error: "not signed in", hosted: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const s = sessionOf(req);
|
|
196
|
+
if (p === "/account/export" && req.method === "GET") {
|
|
197
|
+
if (!s) return json(res, 401, { error: "not signed in", hosted: true });
|
|
198
|
+
who = s;
|
|
199
|
+
const t = tenants.open(s.uid);
|
|
200
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gafj-export-"));
|
|
201
|
+
const file = path.join(tmp, backupName(nowIso()));
|
|
202
|
+
await backup(t.db, file);
|
|
203
|
+
res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Disposition": `attachment; filename="gafj-export-${backupName(nowIso()).slice(5, 18)}.db"`, "Content-Length": fs.statSync(file).size });
|
|
204
|
+
fs.createReadStream(file).on("close", () => fs.rmSync(tmp, { recursive: true, force: true })).pipe(res);
|
|
205
|
+
log.info("export", { entity: "account", entity_id: safeUid(s.uid), actor: "user" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (p === "/account/delete" && req.method === "POST") {
|
|
209
|
+
if (!s) return json(res, 401, { error: "not signed in", hosted: true });
|
|
210
|
+
const g = mutationCheck(req);
|
|
211
|
+
if (g) return json(res, g.status, { error: g.error });
|
|
212
|
+
const body = await readJson(req);
|
|
213
|
+
if (body.confirm !== "delete") return json(res, 400, { error: 'send {"confirm":"delete"} to delete the account and everything in it' });
|
|
214
|
+
who = s;
|
|
215
|
+
for (const stop of sse) if (stop.uid === s.uid) stop();
|
|
216
|
+
tenants.destroy(s.uid);
|
|
217
|
+
sessions.dropUid(s.uid);
|
|
218
|
+
res.setHeader("Set-Cookie", clearCookie());
|
|
219
|
+
log.info("account_deleted", { entity: "account", entity_id: safeUid(s.uid), actor: "user" });
|
|
220
|
+
return json(res, 200, { deleted: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isApi) {
|
|
224
|
+
if (!s) return json(res, 401, { error: "not signed in", hosted: true });
|
|
225
|
+
who = s;
|
|
226
|
+
if (mutation) { const g = mutationCheck(req); if (g) return json(res, g.status, { error: g.error }); }
|
|
227
|
+
if (BLOCKED_IN_HOSTED.some((re) => re.test(p))) return json(res, 404, { error: "no such route" });
|
|
228
|
+
const t = tenants.open(s.uid);
|
|
229
|
+
const ctx = tenants.ctx(t, { version: o.version, fetchImpl: o.fetch, now: nowIso });
|
|
230
|
+
if (!ctx.candidate_id) return json(res, 409, { error: "account not initialized; sign in again" });
|
|
231
|
+
if (p === "/api/events" && req.method === "GET") {
|
|
232
|
+
const stop = serveEvents(t.db, req, res, { candidate_id: ctx.candidate_id, cursor: req.headers["last-event-id"] || url.searchParams.get("since") });
|
|
233
|
+
stop.uid = s.uid;
|
|
234
|
+
sse.add(stop);
|
|
235
|
+
req.on("close", () => sse.delete(stop));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const m = api.match(req.method, p);
|
|
239
|
+
if (!m) return json(res, 404, { error: "no such route" });
|
|
240
|
+
const body = mutation ? await readJson(req, p === "/api/sources" ? 16 * 1024 * 1024 : undefined) : {};
|
|
241
|
+
if (p === "/api/settings" && req.method === "PUT" && body.route && body.route !== "credits" && body.route !== "paste") return json(res, 400, { error: "hosted accounts use credits or paste; provider keys are never stored here" });
|
|
242
|
+
let result = await m.handler(ctx, m.params, body, url.searchParams);
|
|
243
|
+
if (result === undefined) result = { ok: true };
|
|
244
|
+
if (p === "/api/me" || p === "/api/settings") result = { ...result, hosted: true, email: s.email || null };
|
|
245
|
+
return json(res, 200, result);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (mutation) { res.writeHead(405); return res.end(); }
|
|
249
|
+
if ((p === "/" || p === "/index.html") && !s) { res.writeHead(302, { Location: "/signin" }); return res.end(); }
|
|
250
|
+
const rel = p === "/" || !path.extname(p) ? "index.html" : p.replace(/^\/+/, "");
|
|
251
|
+
const file = path.normalize(path.join(UI_DIR, rel));
|
|
252
|
+
if (!file.startsWith(UI_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end("not found"); }
|
|
253
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-cache" });
|
|
254
|
+
return fs.createReadStream(file).pipe(res);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
const status = api.statusOf(e);
|
|
257
|
+
if (status >= 500) process.stderr.write(`gafj serve-hosted: ${e.stack || e.message}\n`);
|
|
258
|
+
return json(res, status, { error: e.message, allowed: e.allowed, detail: e.detail });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
server.keepAliveTimeout = 5000;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
server, sessions, tenants,
|
|
265
|
+
listen: (bind = "127.0.0.1") => new Promise((resolve, reject) => {
|
|
266
|
+
server.once("error", reject);
|
|
267
|
+
server.listen(o.port || 0, bind, () => resolve({ host: bind, port: server.address().port, origin: `http://${bind}:${server.address().port}` }));
|
|
268
|
+
}),
|
|
269
|
+
close: () => new Promise((resolve) => { clearInterval(idle); for (const stop of sse) stop(); server.closeAllConnections?.(); server.close(() => { tenants.closeAll(); resolve(); }); }),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { createHostedServer, Sessions, Tenants, COOKIE, SIGNIN_CSP, CSP, safeUid };
|
package/http/identity.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who is signing in (plan section W, gate 1). A Firebase ID token is an
|
|
5
|
+
* RS256 JWT signed by Google's securetoken keys; verifying one needs the
|
|
6
|
+
* public JWK set, the project id, and a clock. No SDK: node:crypto does
|
|
7
|
+
* the signature, this file does the claims. The JWK set is cached for the
|
|
8
|
+
* time Google's Cache-Control allows. A test signs with its own key pair
|
|
9
|
+
* and serves its own JWK set through fetchImpl.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
|
|
14
|
+
const JWK_URL = "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com";
|
|
15
|
+
const SKEW_S = 60;
|
|
16
|
+
|
|
17
|
+
class IdentityError extends Error {
|
|
18
|
+
constructor(message) { super(message); this.name = "IdentityError"; this.status = 401; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const b64 = (s) => Buffer.from(String(s), "base64url");
|
|
22
|
+
const parseJson = (buf) => { try { return JSON.parse(buf.toString("utf8")); } catch (_) { throw new IdentityError("token is not a JWT"); } };
|
|
23
|
+
|
|
24
|
+
function firebaseVerifier({ projectId, fetchImpl, now = () => Date.now(), jwkUrl = JWK_URL }) {
|
|
25
|
+
if (!projectId) throw new Error("firebaseVerifier needs a projectId");
|
|
26
|
+
const f = fetchImpl || fetch;
|
|
27
|
+
let cache = { keys: null, until: 0 };
|
|
28
|
+
|
|
29
|
+
async function keys(force) {
|
|
30
|
+
if (!force && cache.keys && now() < cache.until) return cache.keys;
|
|
31
|
+
const res = await f(jwkUrl, { redirect: "error" });
|
|
32
|
+
if (!res.ok) throw new IdentityError("could not fetch Google's signing keys");
|
|
33
|
+
const body = await res.json();
|
|
34
|
+
const m = /max-age=(\d+)/.exec(String(res.headers && typeof res.headers.get === "function" ? res.headers.get("cache-control") || "" : ""));
|
|
35
|
+
cache = { keys: new Map((body.keys || []).filter((k) => k.kid && k.kty === "RSA").map((k) => [k.kid, k])), until: now() + (m ? Number(m[1]) : 3600) * 1000 };
|
|
36
|
+
return cache.keys;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return async function verifyIdToken(token) {
|
|
40
|
+
const parts = String(token || "").split(".");
|
|
41
|
+
if (parts.length !== 3) throw new IdentityError("token is not a JWT");
|
|
42
|
+
const header = parseJson(b64(parts[0]));
|
|
43
|
+
const claims = parseJson(b64(parts[1]));
|
|
44
|
+
if (header.alg !== "RS256" || !header.kid) throw new IdentityError("unexpected token algorithm");
|
|
45
|
+
let jwk = (await keys(false)).get(header.kid);
|
|
46
|
+
if (!jwk) jwk = (await keys(true)).get(header.kid);
|
|
47
|
+
if (!jwk) throw new IdentityError("unknown signing key");
|
|
48
|
+
const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
49
|
+
const ok = crypto.verify("RSA-SHA256", Buffer.from(parts[0] + "." + parts[1]), key, b64(parts[2]));
|
|
50
|
+
if (!ok) throw new IdentityError("bad signature");
|
|
51
|
+
const t = Math.floor(now() / 1000);
|
|
52
|
+
if (typeof claims.exp !== "number" || claims.exp < t - SKEW_S) throw new IdentityError("token expired");
|
|
53
|
+
if (typeof claims.iat !== "number" || claims.iat > t + SKEW_S) throw new IdentityError("token issued in the future");
|
|
54
|
+
if (claims.aud !== projectId) throw new IdentityError("token is for another project");
|
|
55
|
+
if (claims.iss !== `https://securetoken.google.com/${projectId}`) throw new IdentityError("token issuer mismatch");
|
|
56
|
+
if (!claims.sub || typeof claims.sub !== "string" || claims.sub.length > 128) throw new IdentityError("token has no subject");
|
|
57
|
+
return { uid: claims.sub, email: claims.email || null, email_verified: claims.email_verified !== false, name: claims.name || null };
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Tests and dry runs: a fixed map of token to identity. */
|
|
62
|
+
function staticVerifier(map) {
|
|
63
|
+
return async (token) => {
|
|
64
|
+
const who = map[token];
|
|
65
|
+
if (!who) throw new IdentityError("token rejected");
|
|
66
|
+
return { email_verified: true, ...who };
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { firebaseVerifier, staticVerifier, IdentityError, JWK_URL };
|
package/http/server.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The local server (plan section O). 127.0.0.1 only. Static UI from ui/,
|
|
5
|
+
* JSON API behind the guard, SSE change feed, launch URL with a single-use
|
|
6
|
+
* nonce. The CLI mints a launch URL by presenting the session token as a
|
|
7
|
+
* header (same OS user, same config file). serve.lock holds pid and port.
|
|
8
|
+
* Every request writes one line to the operational log: method, path
|
|
9
|
+
* shape, status, and milliseconds, never a body. Launch nonces and query
|
|
10
|
+
* strings are not in it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const http = require("http");
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
const crypto = require("crypto");
|
|
17
|
+
const { Guard, readJson } = require("./guard");
|
|
18
|
+
const { securityHeaders } = require("./headers");
|
|
19
|
+
const { serveEvents } = require("./sse");
|
|
20
|
+
const api = require("./api");
|
|
21
|
+
const { silent } = require("../store/log");
|
|
22
|
+
|
|
23
|
+
/** The path as logged: launch nonces collapsed, nothing after the path. */
|
|
24
|
+
const logPath = (p) => (p.startsWith("/launch/") ? "/launch/*" : p);
|
|
25
|
+
|
|
26
|
+
const UI_DIR = path.join(__dirname, "..", "ui");
|
|
27
|
+
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".webmanifest": "application/manifest+json", ".ico": "image/x-icon", ".woff2": "font/woff2" };
|
|
28
|
+
|
|
29
|
+
function json(res, status, body) {
|
|
30
|
+
const text = JSON.stringify(body);
|
|
31
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(text) });
|
|
32
|
+
res.end(text);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serveStatic(res, urlPath) {
|
|
36
|
+
const rel = urlPath === "/" || !path.extname(urlPath) ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
37
|
+
const file = path.normalize(path.join(UI_DIR, rel));
|
|
38
|
+
if (!file.startsWith(UI_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); res.end("not found"); return; }
|
|
39
|
+
res.writeHead(200, { "Content-Type": MIME[path.extname(file)] || "application/octet-stream", "Cache-Control": "no-cache" });
|
|
40
|
+
fs.createReadStream(file).pipe(res);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} o { db, cfg, home, exportDir, host?, port?, now?, version, log? }
|
|
45
|
+
*/
|
|
46
|
+
function createServer(o) {
|
|
47
|
+
const host = o.host || "127.0.0.1";
|
|
48
|
+
const log = o.log || silent();
|
|
49
|
+
const guard = new Guard({ host, port: o.port || 0, sessionToken: o.cfg.session_token, allowedHosts: o.cfg.allowed_hosts || [] });
|
|
50
|
+
const ctx = { db: o.db, candidate_id: o.cfg.candidate_id, thresholds: o.cfg.thresholds || {}, route: o.cfg.route || "paste", exportDir: o.exportDir, home: o.home, version: o.version, now: o.now || (() => new Date().toISOString()), fetch: o.fetch };
|
|
51
|
+
ctx.reload = () => {
|
|
52
|
+
try {
|
|
53
|
+
const { readConfig } = require("../store/config");
|
|
54
|
+
const fresh = readConfig(o.home);
|
|
55
|
+
if (fresh.candidate_id) { ctx.thresholds = fresh.thresholds || {}; ctx.route = fresh.route || "paste"; ctx.exportDir = fresh.export_dir || o.exportDir; }
|
|
56
|
+
} catch (_) { /* keep the running values */ }
|
|
57
|
+
};
|
|
58
|
+
const sse = new Set();
|
|
59
|
+
|
|
60
|
+
const server = http.createServer(async (req, res) => {
|
|
61
|
+
const url = new URL(req.url, `http://${host}`);
|
|
62
|
+
const p = url.pathname;
|
|
63
|
+
const isApi = p.startsWith("/api/");
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
res.on("finish", () => log.write(res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", "request",
|
|
66
|
+
{ entity: "http", entity_id: `${req.method} ${logPath(p)}`, actor: isApi ? "ui" : "browser", ms: Date.now() - started, status: res.statusCode }));
|
|
67
|
+
securityHeaders(res, { noStore: isApi || p.startsWith("/launch") });
|
|
68
|
+
try {
|
|
69
|
+
if (p === "/launch/new") {
|
|
70
|
+
if (guard.check(req)) return json(res, 421, { error: "wrong host" });
|
|
71
|
+
if (!guard.checkCliToken(req)) return json(res, 401, { error: "no token" });
|
|
72
|
+
const want = String(url.searchParams.get("host") || "").toLowerCase();
|
|
73
|
+
if (want && !guard.allowedHosts.includes(want)) return json(res, 400, { error: `${want} is not in allowed_hosts` });
|
|
74
|
+
return json(res, 200, { url: `${want ? "https://" + want : guard.origin}/launch/${guard.mintNonce()}` });
|
|
75
|
+
}
|
|
76
|
+
if (p.startsWith("/launch/")) {
|
|
77
|
+
const g = guard.check(req);
|
|
78
|
+
if (g) return json(res, g.status, { error: g.error });
|
|
79
|
+
if (!guard.burnNonce(p.slice(8))) { res.writeHead(403, { "Content-Type": "text/plain" }); return res.end("launch link used or expired; run gafj url"); }
|
|
80
|
+
res.writeHead(302, { "Set-Cookie": guard.cookieHeader(guard.viaProxy(req)), Location: "/" });
|
|
81
|
+
return res.end();
|
|
82
|
+
}
|
|
83
|
+
if (isApi) {
|
|
84
|
+
const mutation = req.method !== "GET" && req.method !== "HEAD";
|
|
85
|
+
const g = guard.check(req, { api: true, mutation });
|
|
86
|
+
if (g) return json(res, g.status, { error: g.error });
|
|
87
|
+
if (p === "/api/events" && req.method === "GET") {
|
|
88
|
+
const stop = serveEvents(o.db, req, res, { candidate_id: ctx.candidate_id, cursor: req.headers["last-event-id"] || url.searchParams.get("since") });
|
|
89
|
+
sse.add(stop);
|
|
90
|
+
req.on("close", () => sse.delete(stop));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const m = api.match(req.method, p);
|
|
94
|
+
if (!m) return json(res, 404, { error: "no such route" });
|
|
95
|
+
const body = mutation ? await readJson(req, p === "/api/sources" ? 16 * 1024 * 1024 : undefined) : {};
|
|
96
|
+
const result = await m.handler(ctx, m.params, body, url.searchParams);
|
|
97
|
+
return json(res, 200, result === undefined ? { ok: true } : result);
|
|
98
|
+
}
|
|
99
|
+
if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405); return res.end(); }
|
|
100
|
+
if (guard.check(req)) return json(res, 421, { error: "wrong host" });
|
|
101
|
+
return serveStatic(res, p);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const status = api.statusOf(e);
|
|
104
|
+
if (status >= 500) process.stderr.write(`gafj serve: ${e.stack || e.message}\n`);
|
|
105
|
+
return json(res, status, { error: e.message, allowed: e.allowed, detail: e.detail });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
server.keepAliveTimeout = 5000;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
server, guard,
|
|
112
|
+
listen: () => new Promise((resolve, reject) => {
|
|
113
|
+
server.once("error", reject);
|
|
114
|
+
server.listen(o.port || 0, host, () => { guard.port = server.address().port; resolve({ host, port: guard.port, origin: guard.origin }); });
|
|
115
|
+
}),
|
|
116
|
+
launchUrl: () => `${guard.origin}/launch/${guard.mintNonce()}`,
|
|
117
|
+
close: () => new Promise((resolve) => { for (const stop of sse) stop(); server.closeAllConnections?.(); server.close(() => resolve()); }),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureSessionToken(cfg) {
|
|
122
|
+
if (!cfg.session_token || String(cfg.session_token).length < 32) cfg.session_token = crypto.randomBytes(32).toString("hex");
|
|
123
|
+
return cfg;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const LOCK = "serve.lock";
|
|
127
|
+
function readLock(homeDir) {
|
|
128
|
+
try { return JSON.parse(fs.readFileSync(path.join(homeDir, LOCK), "utf8")); } catch (_) { return null; }
|
|
129
|
+
}
|
|
130
|
+
function pidAlive(pid) {
|
|
131
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
132
|
+
}
|
|
133
|
+
function writeLock(homeDir, data) { fs.writeFileSync(path.join(homeDir, LOCK), JSON.stringify(data) + "\n"); }
|
|
134
|
+
function removeLock(homeDir) { try { fs.unlinkSync(path.join(homeDir, LOCK)); } catch (_) { /* gone */ } }
|
|
135
|
+
|
|
136
|
+
module.exports = { createServer, ensureSessionToken, readLock, writeLock, removeLock, pidAlive, UI_DIR };
|
package/http/sse.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The change feed: /api/events as Server-Sent Events. The event log is the
|
|
5
|
+
* source; each connection polls it from its cursor (Last-Event-ID) because
|
|
6
|
+
* the MCP process writes to the same file from outside this process. The
|
|
7
|
+
* client refetches on any event; the payload is a hint, not the data.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { eventsSince } = require("../store/events");
|
|
11
|
+
|
|
12
|
+
const POLL_MS = 1000;
|
|
13
|
+
const HEARTBEAT_MS = 15000;
|
|
14
|
+
|
|
15
|
+
function serveEvents(db, req, res, { candidate_id, cursor }) {
|
|
16
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
|
|
17
|
+
res.write(": connected\n\n");
|
|
18
|
+
let last = Number(cursor || 0);
|
|
19
|
+
if (!cursor) {
|
|
20
|
+
const max = db.prepare("SELECT max(id) AS m FROM event_log").get().m;
|
|
21
|
+
last = Number(max || 0);
|
|
22
|
+
res.write(`id: ${last}\nevent: hello\ndata: {"cursor":${last}}\n\n`);
|
|
23
|
+
}
|
|
24
|
+
const tick = () => {
|
|
25
|
+
try {
|
|
26
|
+
for (const e of eventsSince(db, last, 200)) {
|
|
27
|
+
if (e.candidate_id !== candidate_id) { last = e.id; continue; }
|
|
28
|
+
last = e.id;
|
|
29
|
+
res.write(`id: ${e.id}\nevent: change\ndata: ${JSON.stringify({ entity: e.entity, entity_id: e.entity_id, event: e.event, actor: e.actor_type, at: e.at })}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
} catch (err) { res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`); }
|
|
32
|
+
};
|
|
33
|
+
const poll = setInterval(tick, POLL_MS);
|
|
34
|
+
const beat = setInterval(() => res.write(": heartbeat\n\n"), HEARTBEAT_MS);
|
|
35
|
+
const stop = () => { clearInterval(poll); clearInterval(beat); };
|
|
36
|
+
req.on("close", stop);
|
|
37
|
+
res.on("close", stop);
|
|
38
|
+
return stop;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { serveEvents, POLL_MS };
|
package/mcp/server.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The stdio MCP server, phase 3. One process, one store, the tiered tools.
|
|
5
|
+
* stdout is the wire: nothing else may write to it. Errors and warnings go
|
|
6
|
+
* to stderr. The store stays open for the life of the process; WAL and
|
|
7
|
+
* busy_timeout let the CLI and a UI server share it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { McpServer } = require("@modelcontextprotocol/server");
|
|
11
|
+
const { registerTools } = require("./tools");
|
|
12
|
+
const pkg = require("../package.json");
|
|
13
|
+
|
|
14
|
+
function createServer(deps) {
|
|
15
|
+
const server = new McpServer({ name: "gafj", version: pkg.version }, { capabilities: { tools: {} } });
|
|
16
|
+
registerTools(server, deps);
|
|
17
|
+
return server;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function serve(deps) {
|
|
21
|
+
const { serveStdio } = require("@modelcontextprotocol/server/stdio");
|
|
22
|
+
return serveStdio(() => createServer(deps), { onerror: (e) => process.stderr.write(`gafj mcp: ${e.message}\n`) });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { createServer, serve };
|