@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/api.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The JSON API (plan section L), UI only, behind the guard. Every handler
|
|
5
|
+
* is (ctx, params, body, query) and returns a value or throws an error with
|
|
6
|
+
* a status. Transition errors are 409 with the allowed set. The run id of
|
|
7
|
+
* an ingest comes from the path; generated JSON never names it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { snapshot } = require("../store/snapshot");
|
|
12
|
+
const reads = require("../store/reads");
|
|
13
|
+
const T = require("../store/transitions");
|
|
14
|
+
const { openRun } = require("../store/packets");
|
|
15
|
+
const { ingestDocument } = require("../store/ingest");
|
|
16
|
+
const { saveReply } = require("../store/reply");
|
|
17
|
+
const { renderDocument } = require("../store/render");
|
|
18
|
+
const { renderPasteText } = require("../bridge/paste");
|
|
19
|
+
const { storePosting } = require("../store/proposals");
|
|
20
|
+
const { proposeFromScan, freezeScoring } = require("../store/scoring");
|
|
21
|
+
const { PacketError } = require("../core/generators/packet");
|
|
22
|
+
const { SliceError } = require("../core/generators/slice");
|
|
23
|
+
const { listReview, mutate } = require("../store/review");
|
|
24
|
+
|
|
25
|
+
const ACTOR = (ref) => ({ type: "ui", ref });
|
|
26
|
+
const bad = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
|
27
|
+
|
|
28
|
+
function need(body, key) {
|
|
29
|
+
if (body[key] === undefined || body[key] === null || body[key] === "") throw bad(`${key} is required`);
|
|
30
|
+
return body[key];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const routes = [];
|
|
34
|
+
const route = (method, pattern, handler) => routes.push({ method, re: new RegExp("^" + pattern.replace(/:(\w+)/g, "(?<$1>[^/]+)") + "$"), handler });
|
|
35
|
+
|
|
36
|
+
route("GET", "/api/snapshot", (c) => snapshot(c.db, { candidate_id: c.candidate_id, now: c.now(), thresholds: c.thresholds }));
|
|
37
|
+
route("GET", "/api/me", (c) => ({ candidate_id: c.candidate_id, name: c.db.prepare("SELECT name FROM candidate WHERE id = ?").get(c.candidate_id).name, route: c.route, version: c.version, home: c.home }));
|
|
38
|
+
|
|
39
|
+
route("GET", "/api/applications", (c) => reads.listApplications(c.db, c.candidate_id));
|
|
40
|
+
route("GET", "/api/applications/:id", (c, p) => reads.applicationDetail(c.db, p.id) || (() => { throw bad("unknown application", 404); })());
|
|
41
|
+
route("POST", "/api/applications", (c, p, b) => T.logApplication(c.db, { posting_id: need(b, "posting_id"), status: b.status, now: c.now(), actor: ACTOR("log_application") }));
|
|
42
|
+
route("POST", "/api/applications/:id/status", (c, p, b) => T.updateStatus(c.db, { application_id: p.id, status: need(b, "status"), resume_doc_id: b.resume_doc_id, cover_doc_id: b.cover_doc_id, now: c.now(), actor: ACTOR("update_status") }));
|
|
43
|
+
route("POST", "/api/applications/:id/correct", (c, p, b) => T.correctStatus(c.db, { application_id: p.id, status: need(b, "status"), reason: need(b, "reason"), now: c.now(), actor: ACTOR("correct_status") }));
|
|
44
|
+
route("POST", "/api/applications/:id/decision-expected", (c, p, b) => T.setDecisionExpected(c.db, { application_id: p.id, at: b.at, now: c.now(), actor: ACTOR("decision_expected") }));
|
|
45
|
+
route("POST", "/api/applications/:id/notes", (c, p, b) => T.setNotes(c.db, { application_id: p.id, notes: b.notes, now: c.now(), actor: ACTOR("notes") }));
|
|
46
|
+
route("POST", "/api/applications/:id/people", (c, p, b) => T.attachPersonToApplication(c.db, { application_id: p.id, person: b.person || b, role: b.role, now: c.now(), actor: ACTOR("person_attach") }));
|
|
47
|
+
|
|
48
|
+
route("GET", "/api/interviews/:id", (c, p) => reads.interviewDetail(c.db, p.id) || (() => { throw bad("unknown interview", 404); })());
|
|
49
|
+
route("POST", "/api/interviews", (c, p, b) => T.scheduleInterview(c.db, { application_id: need(b, "application_id"), round_label: need(b, "round_label"), scheduled_at: need(b, "scheduled_at"), tz: need(b, "tz"), format: need(b, "format"), invite_received_at: b.invite_received_at, people: b.people || [], now: c.now(), actor: ACTOR("schedule_interview") }));
|
|
50
|
+
route("POST", "/api/interviews/:id/reschedule", (c, p, b) => T.rescheduleInterview(c.db, { interview_id: p.id, scheduled_at: need(b, "scheduled_at"), tz: b.tz, format: b.format, now: c.now(), actor: ACTOR("reschedule") }));
|
|
51
|
+
route("POST", "/api/interviews/:id/complete", (c, p, b) => T.completeInterview(c.db, { interview_id: p.id, completed_at: b.completed_at, outcome: b.outcome, now: c.now(), actor: ACTOR("mark_completed") }));
|
|
52
|
+
route("POST", "/api/interviews/:id/outcome", (c, p, b) => T.setOutcome(c.db, { interview_id: p.id, outcome: need(b, "outcome"), now: c.now(), actor: ACTOR("outcome") }));
|
|
53
|
+
route("POST", "/api/interviews/:id/cancel", (c, p) => T.cancelInterview(c.db, { interview_id: p.id, now: c.now(), actor: ACTOR("cancel") }));
|
|
54
|
+
route("POST", "/api/interviews/:id/invite-received", (c, p, b) => T.inviteReceived(c.db, { interview_id: p.id, at: b.at, now: c.now(), actor: ACTOR("invite_received") }));
|
|
55
|
+
route("POST", "/api/interviews/:id/debrief", (c, p, b) => T.captureDebrief(c.db, { interview_id: p.id, text: need(b, "text"), now: c.now(), actor: ACTOR("capture_debrief") }));
|
|
56
|
+
route("POST", "/api/interviews/:id/pins", (c, p, b) => T.pinStories(c.db, { interview_id: p.id, ids: need(b, "ids"), now: c.now(), actor: ACTOR("pin_stories") }));
|
|
57
|
+
route("POST", "/api/interviews/:id/people", (c, p, b) => T.attachPersonToInterview(c.db, { interview_id: p.id, person: b.person || b, now: c.now(), actor: ACTOR("person_attach") }));
|
|
58
|
+
|
|
59
|
+
route("POST", "/api/people", (c, p, b) => T.addPerson(c.db, { candidate_id: c.candidate_id, ...b, now: c.now(), actor: ACTOR("person_add") }));
|
|
60
|
+
route("GET", "/api/people/:id", (c, p) => reads.person(c.db, p.id) || (() => { throw bad("unknown person", 404); })());
|
|
61
|
+
route("POST", "/api/people/:id", (c, p, b) => T.updatePerson(c.db, { person_id: p.id, ...b, now: c.now(), actor: ACTOR("person_update") }));
|
|
62
|
+
route("POST", "/api/people/:id/merge", (c, p, b) => T.mergePerson(c.db, { person_id: p.id, into_id: need(b, "into_id"), now: c.now(), actor: ACTOR("person_merge") }));
|
|
63
|
+
|
|
64
|
+
route("GET", "/api/packets/:op", (c, p, b, q) => {
|
|
65
|
+
if (p.op === "discover") require("../store/discover").assertCanOpen(c.db, c.candidate_id);
|
|
66
|
+
const r = openRun(c.db, { op: p.op, candidate_id: c.candidate_id, source_document_id: q.get("source_document_id") || undefined, interview_id: q.get("interview_id") || undefined, posting_id: q.get("posting_id") || undefined, route: "paste", actor: ACTOR("packet"), now: c.now() });
|
|
67
|
+
return { run_id: r.run_id, packet: r.packet, paste_text: renderPasteText(r.packet, { run_id: r.run_id }) };
|
|
68
|
+
});
|
|
69
|
+
route("POST", "/api/runs/:run_id/ingest", (c, p, b) => {
|
|
70
|
+
const routeName = b.route === "manual" ? "manual" : "paste";
|
|
71
|
+
return saveReply(c.db, { run_id: p.run_id, content: need(b, "content"), route: routeName, actor: ACTOR("ingest"), now: c.now() });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// onboarding (phase 5b)
|
|
75
|
+
const onboarding = require("../store/onboarding");
|
|
76
|
+
route("GET", "/api/onboarding", (c) => ({ batch: onboarding.batchView(c.db, c.candidate_id), style: onboarding.proposeStyle(c.db, c.candidate_id) }));
|
|
77
|
+
route("POST", "/api/onboarding/batches", (c) => onboarding.openBatch(c.db, { candidate_id: c.candidate_id, now: c.now(), actor: ACTOR("onboarding") }));
|
|
78
|
+
route("POST", "/api/onboarding/batches/:id/status", (c, p, b) => onboarding.setBatchStatus(c.db, { candidate_id: c.candidate_id, batch_id: p.id, status: need(b, "status"), now: c.now(), actor: ACTOR("onboarding") }));
|
|
79
|
+
route("POST", "/api/sources", async (c, p, b) => {
|
|
80
|
+
const batch = b.batch_id || onboarding.openBatch(c.db, { candidate_id: c.candidate_id, now: c.now(), actor: ACTOR("onboarding") }).batch_id;
|
|
81
|
+
const buffer = Buffer.from(need(b, "data_base64"), "base64");
|
|
82
|
+
return onboarding.addSource(c.db, { candidate_id: c.candidate_id, batch_id: batch, filename: need(b, "filename"), buffer, now: c.now(), actor: ACTOR("upload") });
|
|
83
|
+
});
|
|
84
|
+
route("GET", "/api/sources/:id", (c, p) => { const s = onboarding.sourceText(c.db, p.id); if (s.candidate_id !== c.candidate_id) throw bad("unknown source document", 404); return { source_document_id: s.id, filename: s.filename, kind: s.kind, extractor: s.extractor, extractor_version: s.extractor_version, text: s.text }; });
|
|
85
|
+
route("POST", "/api/onboarding/batches/:id/dedup", (c, p) => onboarding.suggestDedup(c.db, { candidate_id: c.candidate_id, batch_id: p.id, now: c.now(), actor: ACTOR("dedup") }));
|
|
86
|
+
route("POST", "/api/kb/groups/:id/merge", (c, p, b) => onboarding.mergeGroup(c.db, { candidate_id: c.candidate_id, dedup_group_id: p.id, keep_id: b.keep_id, now: c.now(), actor: ACTOR("merge") }));
|
|
87
|
+
route("POST", "/api/kb/groups/:id/keep", (c, p) => onboarding.keepSeparate(c.db, { candidate_id: c.candidate_id, dedup_group_id: p.id, now: c.now(), actor: ACTOR("keep_separate") }));
|
|
88
|
+
route("GET", "/api/runs/:run_id", (c, p) => {
|
|
89
|
+
const r = c.db.prepare("SELECT id AS run_id, operation, route, interview_id, posting_id, final_result, started_at, finished_at FROM ai_run WHERE id = ? AND candidate_id = ?").get(p.run_id, c.candidate_id);
|
|
90
|
+
if (!r) throw bad("unknown run", 404);
|
|
91
|
+
r.attempts = c.db.prepare("SELECT attempt_no, result, error_code, started_at FROM ai_attempt WHERE ai_run_id = ? ORDER BY attempt_no").all(r.run_id);
|
|
92
|
+
return r;
|
|
93
|
+
});
|
|
94
|
+
route("GET", "/api/documents/:id", (c, p) => {
|
|
95
|
+
const d = reads.documentDetail(c.db, p.id);
|
|
96
|
+
if (!d || d.candidate_id !== c.candidate_id) throw bad("unknown document", 404);
|
|
97
|
+
return d;
|
|
98
|
+
});
|
|
99
|
+
/** Repair: a new run for the same op and target, then ingest as manual. */
|
|
100
|
+
route("POST", "/api/documents/:id/repair", (c, p, b) => {
|
|
101
|
+
const d = reads.documentDetail(c.db, p.id);
|
|
102
|
+
if (!d || d.candidate_id !== c.candidate_id) throw bad("unknown document", 404);
|
|
103
|
+
const op = d.run ? d.run.operation : d.kind === "email" ? "followup" : d.kind;
|
|
104
|
+
const r = openRun(c.db, { op, interview_id: d.interview_id || (d.kind === "email" && d.run ? d.run.interview_id : undefined) || undefined, posting_id: d.posting_id || undefined, route: "manual", actor: ACTOR("repair"), now: c.now() });
|
|
105
|
+
return ingestDocument(c.db, { run_id: r.run_id, content: need(b, "content"), route: "manual", actor: ACTOR("repair"), now: c.now() });
|
|
106
|
+
});
|
|
107
|
+
route("POST", "/api/documents/:id/export", (c, p, b) => {
|
|
108
|
+
const r = renderDocument(c.db, { document_id: p.id, outDir: path.join(c.exportDir, "documents"), homeDir: c.home, now: c.now(), actor: ACTOR("export") });
|
|
109
|
+
return { rel_path: r.rel, file: r.file };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
route("POST", "/api/communications", (c, p, b) => T.markSent(c.db, { application_id: need(b, "application_id"), interview_id: b.interview_id, kind: need(b, "kind"), document_id: b.document_id, to_addr: b.to_addr, sent_body_text: b.sent_body_text, sent_at: b.sent_at, now: c.now(), actor: ACTOR("mark_sent") }));
|
|
113
|
+
|
|
114
|
+
route("GET", "/api/accomplishments", (c, p, b, q) => reads.searchAccomplishments(c.db, c.candidate_id, q.get("q") || "", Number(q.get("limit") || 20)));
|
|
115
|
+
route("GET", "/api/kb/review", (c) => listReview(c.db, c.candidate_id, {}));
|
|
116
|
+
route("POST", "/api/kb/:id/review", (c, p, b) => mutate(c.db, { candidate_id: c.candidate_id, record: p.id, dimension: need(b, "dimension"), value: need(b, "value"), action: b.action || "confirm", edits: b.edits || {}, now: c.now(), actor: ACTOR("review") }));
|
|
117
|
+
|
|
118
|
+
// postings: UI paste inserts accepted; accept promotes a proposed row
|
|
119
|
+
route("GET", "/api/postings", (c) => c.db.prepare(`SELECT p.id AS posting_id, p.status, p.company, p.title, p.location, p.comp_text, p.captured_at, p.taken_down_at,
|
|
120
|
+
(SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen, a.id AS application_id FROM posting p LEFT JOIN application a ON a.posting_id = p.id
|
|
121
|
+
WHERE p.candidate_id = ? ORDER BY p.captured_at DESC`).all(c.candidate_id).map((r) => ({ ...r, scoring_frozen: !!r.scoring_frozen })));
|
|
122
|
+
route("GET", "/api/postings/:id", (c, p) => {
|
|
123
|
+
const r = c.db.prepare("SELECT id AS posting_id, status, company, title, location, comp_text, source_url, captured_at, taken_down_at, raw_text FROM posting WHERE id = ? AND candidate_id = ?").get(p.id, c.candidate_id);
|
|
124
|
+
if (!r) throw bad("unknown posting", 404);
|
|
125
|
+
r.scoring = c.db.prepare("SELECT id AS scoring_id, status, fit_score, verdict, verdict_reason, categories_json, scored_at FROM scoring WHERE posting_id = ? ORDER BY scored_at DESC, rowid DESC").all(r.posting_id)
|
|
126
|
+
.map((s) => ({ ...s, categories: JSON.parse(s.categories_json), categories_json: undefined }));
|
|
127
|
+
r.gaps = c.db.prepare("SELECT id AS gap_id, requirement_text, question, created_at, resolved_at FROM kb_gap WHERE posting_id = ?").all(r.posting_id);
|
|
128
|
+
return r;
|
|
129
|
+
});
|
|
130
|
+
route("POST", "/api/postings", (c, p, b) => {
|
|
131
|
+
const r = storePosting(c.db, { candidate_id: c.candidate_id, company: need(b, "company"), title: need(b, "title"), raw_text: need(b, "raw_text"), source_url: b.source_url, comp_text: b.comp_text, location: b.location, now: c.now(), actor: ACTOR("paste_posting") });
|
|
132
|
+
if (r.created || r.status === "proposed") T.acceptPosting(c.db, { posting_id: r.posting_id, now: c.now(), actor: ACTOR("paste_posting") });
|
|
133
|
+
return { posting_id: r.posting_id, status: "accepted", created: r.created };
|
|
134
|
+
});
|
|
135
|
+
route("POST", "/api/postings/:id/accept", (c, p) => T.acceptPosting(c.db, { posting_id: p.id, now: c.now(), actor: ACTOR("accept_posting") }));
|
|
136
|
+
route("POST", "/api/postings/:id/archive", (c, p) => T.archivePosting(c.db, { posting_id: p.id, now: c.now(), actor: ACTOR("archive_posting") }));
|
|
137
|
+
route("POST", "/api/postings/:id/taken-down", (c, p) => T.postingTakenDown(c.db, { posting_id: p.id, now: c.now(), actor: ACTOR("taken_down") }));
|
|
138
|
+
route("POST", "/api/postings/:id/scan", (c, p) => proposeFromScan(c.db, { posting_id: p.id, now: c.now(), actor: ACTOR("scan") }));
|
|
139
|
+
route("POST", "/api/postings/:id/freeze", (c, p, b) => freezeScoring(c.db, { posting_id: p.id, categories: b.categories, scores: b.scores, now: c.now(), actor: ACTOR("freeze_categories"), thresholds: c.thresholds }));
|
|
140
|
+
|
|
141
|
+
// knowledge base (screen 5)
|
|
142
|
+
const kb = require("../store/kb");
|
|
143
|
+
route("GET", "/api/kb", (c) => ({ ...kb.summary(c.db, c.candidate_id), gaps: kb.listGaps(c.db, c.candidate_id), holes: kb.listHoles(c.db, c.candidate_id), questions: require("../store/discover").listQuestions(c.db, c.candidate_id), pending: kb.listPending(c.db, c.candidate_id), review: listReview(c.db, c.candidate_id, {}) }));
|
|
144
|
+
route("POST", "/api/kb/gaps/:id/answer", (c, p, b) => kb.answerGap(c.db, { candidate_id: c.candidate_id, gap_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_gap") }));
|
|
145
|
+
route("POST", "/api/kb/pending", (c, p, b) => kb.proposeManual(c.db, { candidate_id: c.candidate_id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("propose_manual") }));
|
|
146
|
+
route("POST", "/api/kb/pending/:id/confirm", (c, p, b) => kb.confirmPending(c.db, { candidate_id: c.candidate_id, pending_id: p.id, edits: b.edits || {}, now: c.now(), actor: ACTOR("confirm_accomplishment") }));
|
|
147
|
+
route("POST", "/api/kb/pending/:id/dismiss", (c, p) => kb.dismissPending(c.db, { candidate_id: c.candidate_id, pending_id: p.id, now: c.now(), actor: ACTOR("dismiss_pending") }));
|
|
148
|
+
// discovery (the shakedown): the AI's questions, answered here in the candidate's words
|
|
149
|
+
const discover = require("../store/discover");
|
|
150
|
+
route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c.candidate_id, { all: q.get("all") === "1" }));
|
|
151
|
+
route("POST", "/api/kb/questions/:id/answer", (c, p, b) => discover.answerQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_question") }));
|
|
152
|
+
route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, now: c.now(), actor: ACTOR("dismiss_question") }));
|
|
153
|
+
|
|
154
|
+
// settings (screen 6)
|
|
155
|
+
const settings = require("../store/settings");
|
|
156
|
+
route("GET", "/api/settings", (c) => settings.getSettings(c.db, c.home, c.candidate_id));
|
|
157
|
+
route("PUT", "/api/settings", (c, p, b) => { const r = settings.putSettings(c.db, c.home, c.candidate_id, b, { now: c.now(), actor: ACTOR("settings") }); if (c.reload) c.reload(); return r; });
|
|
158
|
+
route("PUT", "/api/settings/providers/:id", (c, p, b) => settings.putProvider(c.home, p.id, b));
|
|
159
|
+
route("POST", "/api/settings/providers", (c, p, b) => settings.putProvider(c.home, null, b));
|
|
160
|
+
route("DELETE", "/api/settings/providers/:id", (c, p) => settings.deleteProvider(c.home, p.id));
|
|
161
|
+
route("POST", "/api/settings/providers/:id/test", async (c, p) => {
|
|
162
|
+
const { testProvider } = require("../bridge/byo_key");
|
|
163
|
+
const r = await testProvider(c.home, p.id, c.fetch);
|
|
164
|
+
settings.recordTest(c.home, p.id, { ok: r.ok, at: c.now(), message: r.message });
|
|
165
|
+
return r;
|
|
166
|
+
});
|
|
167
|
+
route("GET", "/api/mcp-config", (c) => {
|
|
168
|
+
const cli = path.resolve(__dirname, "..", "bin", "cli.js");
|
|
169
|
+
return { desktop: { mcpServers: { gafj: { command: process.execPath, args: [cli, "mcp"], env: { GAFJ_HOME: c.home } } } }, code: `claude mcp add gafj -e GAFJ_HOME=${JSON.stringify(c.home)} -- ${JSON.stringify(process.execPath)} ${JSON.stringify(cli)} mcp` };
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
route("GET", "/api/credits/balance", async (c) => {
|
|
173
|
+
const { balance } = require("../bridge/credits");
|
|
174
|
+
const r = await balance(c.home, c.fetch, c.relayAuth);
|
|
175
|
+
settings.recordBalance(c.home, { ...r, at: c.now() });
|
|
176
|
+
return r;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
route("POST", "/api/credits/checkout", async (c, p, b) => {
|
|
180
|
+
const { checkout } = require("../bridge/credits");
|
|
181
|
+
return checkout(c.home, need(b, "pack"), c.fetch, c.relayAuth);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// byo_key or credits: run a packet through the active provider or the relay, then through the door (phases 6 and 7)
|
|
185
|
+
route("POST", "/api/run/:op", async (c, p, b, q) => {
|
|
186
|
+
const { readConfig } = require("../store/config");
|
|
187
|
+
const cfg = readConfig(c.home);
|
|
188
|
+
if (cfg.route !== "byo_key" && cfg.route !== "credits") throw bad("the active route is neither byo_key nor credits; set it in settings", 409);
|
|
189
|
+
const routeName = cfg.route;
|
|
190
|
+
let reply;
|
|
191
|
+
if (p.op === "discover") require("../store/discover").assertCanOpen(c.db, c.candidate_id);
|
|
192
|
+
const opened = openRun(c.db, { op: p.op, candidate_id: c.candidate_id, interview_id: q.get("interview_id") || undefined, posting_id: q.get("posting_id") || undefined, source_document_id: q.get("source_document_id") || undefined, route: routeName, actor: ACTOR("run"), now: c.now() });
|
|
193
|
+
if (routeName === "credits") {
|
|
194
|
+
const { runPacket } = require("../bridge/credits");
|
|
195
|
+
reply = await runPacket(c.home, { packet: opened.packet, fetchImpl: c.fetch, auth: c.relayAuth });
|
|
196
|
+
} else {
|
|
197
|
+
const providerId = b.provider_id || cfg.active_provider_id;
|
|
198
|
+
if (!providerId) throw bad("no active provider", 409);
|
|
199
|
+
const { runPacket } = require("../bridge/byo_key");
|
|
200
|
+
reply = await runPacket(c.home, providerId, { packet: opened.packet, fetchImpl: c.fetch });
|
|
201
|
+
}
|
|
202
|
+
const r = saveReply(c.db, { run_id: opened.run_id, content: reply.content, route: routeName, actor: ACTOR("run"), now: c.now(), provider: reply.provider, model: reply.model, tokens_in: reply.tokens_in, tokens_out: reply.tokens_out, latency_ms: reply.latency_ms });
|
|
203
|
+
return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
route("POST", "/api/documents/:id/workbook", async (c, p) => {
|
|
207
|
+
const { writeWorkbook } = require("../store/practice");
|
|
208
|
+
const r = await writeWorkbook(c.db, { document_id: p.id, outDir: path.join(c.exportDir, "documents"), homeDir: c.home, now: c.now(), actor: ACTOR("workbook") });
|
|
209
|
+
return { rel_path: r.rel, file: r.file, planted: r.planted };
|
|
210
|
+
});
|
|
211
|
+
route("POST", "/api/restore-test", async (c) => {
|
|
212
|
+
const os = require("os");
|
|
213
|
+
const fs = require("fs");
|
|
214
|
+
const { backup, restore, backupName } = require("../store/backup");
|
|
215
|
+
const { snapshot: snap } = require("../store/snapshot");
|
|
216
|
+
const { open } = require("../store/db");
|
|
217
|
+
const file = path.join(c.exportDir, "backups", backupName(c.now()));
|
|
218
|
+
await backup(c.db, file);
|
|
219
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gafj-restore-"));
|
|
220
|
+
const r = await restore(file, tmp, { now: c.now() });
|
|
221
|
+
const db2 = open(r.file);
|
|
222
|
+
try {
|
|
223
|
+
const now = c.now();
|
|
224
|
+
const a = snap(c.db, { candidate_id: c.candidate_id, now, thresholds: c.thresholds });
|
|
225
|
+
const b2 = snap(db2, { candidate_id: c.candidate_id, now, thresholds: c.thresholds });
|
|
226
|
+
return { ok: true, backup: file, restored_to: tmp, snapshot_matches: JSON.stringify(a) === JSON.stringify(b2) };
|
|
227
|
+
} finally { db2.close(); }
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
route("POST", "/api/backup", async (c) => {
|
|
231
|
+
const { readConfig } = require("../store/config");
|
|
232
|
+
const { backupDir, backupTo } = require("../store/backup_dir");
|
|
233
|
+
const r = await backupTo(c.db, backupDir(readConfig(c.home), c.exportDir), c.now());
|
|
234
|
+
settings.recordBackup(c.home, { file: r.file, at: c.now(), method: r.method, by: "click" });
|
|
235
|
+
return { file: r.file, method: r.method, pruned: r.pruned };
|
|
236
|
+
});
|
|
237
|
+
route("POST", "/api/export-tracker", async (c) => {
|
|
238
|
+
const { exportTracker } = require("../store/export_xlsx");
|
|
239
|
+
const r = await exportTracker(c.db, { candidate_id: c.candidate_id, outDir: c.exportDir, now: c.now() });
|
|
240
|
+
return { file: r.file, rows: r.rows };
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
function match(method, pathname) {
|
|
244
|
+
for (const r of routes) {
|
|
245
|
+
if (r.method !== method) continue;
|
|
246
|
+
const m = r.re.exec(pathname);
|
|
247
|
+
if (m) return { handler: r.handler, params: m.groups || {} };
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function statusOf(e) {
|
|
253
|
+
if (e.status) return e.status;
|
|
254
|
+
if (e instanceof PacketError || e instanceof SliceError) return 409;
|
|
255
|
+
if (/^unknown /.test(e.message)) return 404;
|
|
256
|
+
if (/needs a |is required|must be|not accepted|expected|is not/.test(e.message)) return 400;
|
|
257
|
+
return 500;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { match, routes, statusOf };
|
package/http/guard.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guard (plan section H): bind 127.0.0.1; reject any Host that is not
|
|
5
|
+
* the bound host and port; every /api request carries the session cookie,
|
|
6
|
+
* reads included; mutations also carry a matching Origin and a JSON body;
|
|
7
|
+
* bodies are capped at 1 MB. Launch nonces are single use and expire.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
|
|
12
|
+
const COOKIE = "gafj_session";
|
|
13
|
+
const BODY_CAP = 1024 * 1024;
|
|
14
|
+
const NONCE_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
function parseCookies(header) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const part of String(header || "").split(";")) {
|
|
19
|
+
const i = part.indexOf("=");
|
|
20
|
+
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function timingEqual(a, b) {
|
|
26
|
+
const x = Buffer.from(String(a || "")), y = Buffer.from(String(b || ""));
|
|
27
|
+
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class Guard {
|
|
31
|
+
constructor({ host, port, sessionToken, allowedHosts = [] }) {
|
|
32
|
+
this.host = host;
|
|
33
|
+
this.port = port;
|
|
34
|
+
this.sessionToken = sessionToken;
|
|
35
|
+
this.nonces = new Map();
|
|
36
|
+
// Extra hosts a local TLS proxy (Tailscale Serve, for the phone) presents; each is reached over https.
|
|
37
|
+
this.allowedHosts = allowedHosts.map((h) => String(h).toLowerCase().trim()).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
get origin() { return `http://${this.host}:${this.port}`; }
|
|
40
|
+
get hostHeader() { return `${this.host}:${this.port}`; }
|
|
41
|
+
/** True when the request arrived under one of the allowed proxy hosts (https on the outside). */
|
|
42
|
+
viaProxy(req) {
|
|
43
|
+
const h = String(req.headers.host || "").toLowerCase();
|
|
44
|
+
return this.allowedHosts.includes(h) || this.allowedHosts.includes(h.replace(/:443$/, ""));
|
|
45
|
+
}
|
|
46
|
+
originFor(req) {
|
|
47
|
+
return this.viaProxy(req) ? `https://${String(req.headers.host).toLowerCase().replace(/:443$/, "")}` : this.origin;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
mintNonce(now = Date.now()) {
|
|
51
|
+
for (const [k, t] of this.nonces) if (now - t > NONCE_TTL_MS) this.nonces.delete(k);
|
|
52
|
+
const n = crypto.randomBytes(24).toString("base64url");
|
|
53
|
+
this.nonces.set(n, now);
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
burnNonce(n, now = Date.now()) {
|
|
57
|
+
const t = this.nonces.get(n);
|
|
58
|
+
if (t === undefined) return false;
|
|
59
|
+
this.nonces.delete(n);
|
|
60
|
+
return now - t <= NONCE_TTL_MS;
|
|
61
|
+
}
|
|
62
|
+
cookieHeader(secure = false) {
|
|
63
|
+
return `${COOKIE}=${this.sessionToken}; HttpOnly; SameSite=Strict; Path=/${secure ? "; Secure" : ""}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Returns null when the request may proceed, else { status, error }. */
|
|
67
|
+
check(req, { api = false, mutation = false } = {}) {
|
|
68
|
+
if ((req.headers.host || "") !== this.hostHeader && !this.viaProxy(req)) return { status: 421, error: "wrong host" };
|
|
69
|
+
if (api) {
|
|
70
|
+
const c = parseCookies(req.headers.cookie);
|
|
71
|
+
if (!timingEqual(c[COOKIE], this.sessionToken)) return { status: 401, error: "no session; open the launch URL from gafj url" };
|
|
72
|
+
}
|
|
73
|
+
if (mutation) {
|
|
74
|
+
if ((req.headers.origin || "") !== this.originFor(req)) return { status: 403, error: "foreign origin" };
|
|
75
|
+
const ct = String(req.headers["content-type"] || "");
|
|
76
|
+
if (!/^application\/json\b/i.test(ct)) return { status: 415, error: "application/json only" };
|
|
77
|
+
if (req.headers["sec-fetch-site"] && !["same-origin", "none"].includes(req.headers["sec-fetch-site"])) return { status: 403, error: "cross-site fetch" };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The CLI's way in: the session token itself, as a header, from the same OS user. */
|
|
83
|
+
checkCliToken(req) {
|
|
84
|
+
return timingEqual(req.headers["x-gafj-token"], this.sessionToken);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson(req, cap = BODY_CAP) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let size = 0;
|
|
91
|
+
const chunks = [];
|
|
92
|
+
req.on("data", (c) => {
|
|
93
|
+
size += c.length;
|
|
94
|
+
if (size > cap) { reject(Object.assign(new Error("body larger than 1 MB"), { status: 413 })); req.destroy(); return; }
|
|
95
|
+
chunks.push(c);
|
|
96
|
+
});
|
|
97
|
+
req.on("end", () => {
|
|
98
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
99
|
+
if (!text.trim()) return resolve({});
|
|
100
|
+
try { resolve(JSON.parse(text)); } catch (e) { reject(Object.assign(new Error("invalid JSON body"), { status: 400 })); }
|
|
101
|
+
});
|
|
102
|
+
req.on("error", reject);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { Guard, readJson, parseCookies, COOKIE, BODY_CAP };
|
package/http/headers.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/** Security headers on every response (plan section H). No remote scripts, no framing, no caching of API or launch. */
|
|
4
|
+
const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
|
|
5
|
+
|
|
6
|
+
function securityHeaders(res, { noStore = false } = {}) {
|
|
7
|
+
res.setHeader("Content-Security-Policy", CSP);
|
|
8
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
9
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
10
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
11
|
+
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
12
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
13
|
+
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
|
|
14
|
+
if (noStore) res.setHeader("Cache-Control", "no-store");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { securityHeaders, CSP };
|