@gafj/gafj 0.1.20 → 0.1.21

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/bin/cli.js CHANGED
@@ -321,6 +321,7 @@ async function main(argv) {
321
321
  const { createHostedServer } = require("../http/hosted");
322
322
  const { firebaseVerifier } = require("../http/identity");
323
323
  const { Log } = require("../store/log");
324
+ const pkg = require("../package.json");
324
325
  const env = process.env;
325
326
  const root = env.GAFJ_HOSTED_ROOT || "/data";
326
327
  if (!env.GAFJ_FIREBASE_PROJECT_ID) { process.stderr.write("serve-hosted needs GAFJ_FIREBASE_PROJECT_ID (and GAFJ_FIREBASE_API_KEY, GAFJ_FIREBASE_AUTH_DOMAIN for the sign-in page)\n"); process.exit(2); }
package/core/scoring.js CHANGED
@@ -128,8 +128,10 @@ function conceptTags(requirementText) {
128
128
  return hit;
129
129
  }
130
130
 
131
+ /** Lines a job board adds around a posting; never a requirement of the job. */
132
+ const BOARD_NOISE = /\b(your profile and resume match|see how you compare|applicants?\b.*\b(ago|clicked)|easy apply|promoted by|reposted|set alert for similar|show more|show less|tailor my resume|help me stand out|create cover letter|people you can reach out to|meet the hiring team|skills you have|sign in to|referrals increase)\b/i;
131
133
  function lines(text) {
132
- return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter(Boolean);
134
+ return String(text).split(/\r?\n|(?<=[.;])\s{2,}/).map((l) => l.trim()).filter((l) => l && !BOARD_NOISE.test(l));
133
135
  }
134
136
 
135
137
  /** Every figure in a document: money, percents, counts, durations. */
package/http/hosted.js CHANGED
@@ -34,8 +34,34 @@ const { UI_DIR } = require("./server");
34
34
  const COOKIE = "gafj_hs";
35
35
  const SESSION_TTL_MS = 30 * 24 * 3600 * 1000;
36
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'";
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 'self' https://*.firebaseapp.com https://accounts.google.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'";
38
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
+ /**
40
+ * Google sign-in's handler pages live on <project>.firebaseapp.com. Served from another site, the
41
+ * popup and redirect flows depend on third-party storage that browsers now block, so the app
42
+ * serves them itself: everything under /__/ is fetched from Firebase and passed through, and the
43
+ * sign-in page uses this host as its auth domain. Firebase's own headers come back with the page;
44
+ * only hop-by-hop and cookie headers are dropped.
45
+ */
46
+ const PASS_REQ = new Set(["accept", "accept-language", "content-type", "content-length", "referer", "user-agent", "x-requested-with", "origin"]);
47
+ const DROP_RES = new Set(["set-cookie", "transfer-encoding", "connection", "keep-alive", "content-encoding", "content-length", "strict-transport-security", "alt-svc"]);
48
+ async function proxyAuth(req, res, { projectId, fetchImpl, originOf }) {
49
+ const upstream = `https://${projectId}.firebaseapp.com${req.url}`;
50
+ const headers = {};
51
+ for (const [k, v] of Object.entries(req.headers)) if (PASS_REQ.has(k)) headers[k] = v;
52
+ if (headers.origin) headers.origin = `https://${projectId}.firebaseapp.com`;
53
+ if (headers.referer) headers.referer = headers.referer.replace(originOf, `https://${projectId}.firebaseapp.com`);
54
+ headers["accept-encoding"] = "identity";
55
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await new Promise((resolve, reject) => { const c = []; req.on("data", (d) => c.push(d)); req.on("end", () => resolve(Buffer.concat(c))); req.on("error", reject); });
56
+ const r = await fetchImpl(upstream, { method: req.method, headers, body, redirect: "manual" });
57
+ const out = {};
58
+ for (const [k, v] of r.headers.entries()) if (!DROP_RES.has(k.toLowerCase())) out[k] = v;
59
+ const buf = Buffer.from(await r.arrayBuffer());
60
+ out["content-length"] = String(buf.length);
61
+ res.writeHead(r.status, out);
62
+ return res.end(buf);
63
+ }
64
+
39
65
  const BLOCKED_IN_HOSTED = [/^\/api\/settings\/providers/, /^\/api\/mcp-config$/, /^\/api\/restore-test$/];
40
66
 
41
67
  function json(res, status, body) {
@@ -157,7 +183,9 @@ function createHostedServer(o) {
157
183
  res.on("finish", () => log.write(res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info", "request",
158
184
  { entity: "http", entity_id: `${req.method} ${p}`, actor: who ? "user" : "anon", ms: Date.now() - started, status: res.statusCode }));
159
185
  try {
186
+ if (p === "/healthz" && req.method === "GET") return json(res, 200, { ok: true, version: o.version }); // the platform's check arrives under its own host name
160
187
  if (host && String(req.headers.host || "").toLowerCase() !== host) return json(res, 421, { error: "wrong host" });
188
+ if (p.startsWith("/__/") && o.firebase && o.firebase.projectId) return proxyAuth(req, res, { projectId: o.firebase.projectId, fetchImpl: o.fetch || fetch, originOf: originOf(req) });
161
189
  const mutation = req.method !== "GET" && req.method !== "HEAD";
162
190
  securityHeaders(res, { noStore: isApi || p.startsWith("/auth/") || p.startsWith("/account/") });
163
191
 
@@ -166,7 +194,8 @@ function createHostedServer(o) {
166
194
  res.writeHead(200, { "Content-Type": MIME[".html"], "Cache-Control": "no-cache" });
167
195
  return fs.createReadStream(path.join(UI_DIR, "hosted", "signin.html")).pipe(res);
168
196
  }
169
- if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase || null, signed_in: !!sessionOf(req) });
197
+ // the auth domain is this host, so Google's handler pages come through the proxy above and stay same-site
198
+ if (p === "/signin/config.json") return json(res, 200, { firebase: o.firebase ? { ...o.firebase, authDomain: String(req.headers.host || o.firebase.authDomain || "").toLowerCase() } : null, signed_in: !!sessionOf(req) });
170
199
  if (p === "/auth/session" && req.method === "POST") {
171
200
  const g = mutationCheck(req);
172
201
  if (g) return json(res, g.status, { error: g.error });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
package/store/setup.js CHANGED
@@ -36,8 +36,10 @@ function setupView(db, homeDir, candidate_id) {
36
36
  { key: "discover", title: "Answer what's missing", done: questions > 0, detail: questions ? `${questions} question${questions === 1 ? "" : "s"} asked` : null },
37
37
  { key: "posting", title: "Paste your first job posting", done: postings > 0, detail: postings ? `${postings} posting${postings === 1 ? "" : "s"}` : null },
38
38
  ];
39
- const next = steps.find((s) => !s.done);
40
- return { steps, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
39
+ // a hosted account signed in to use the included AI; which AI is not a question there (Paste stays a Settings option)
40
+ const shown = cfg.hosted ? steps.filter((s) => s.key !== "ai") : steps;
41
+ const next = shown.find((s) => !s.done);
42
+ return { steps: shown, next: next ? next.key : null, complete: !next, dismissed: !!(cfg.setup && cfg.setup.dismissed), route: cfg.route || "paste" };
41
43
  }
42
44
 
43
45
  /** The user picked how they will use AI; the route is set and the step is done. */
package/ui/app.css CHANGED
@@ -130,7 +130,10 @@ form.inline input, form.inline select { min-width: 120px; }
130
130
  table.cats { border-collapse: collapse; width: 100%; font-size: 13px; }
131
131
  table.cats th, table.cats td { border-bottom: 1px solid var(--line); padding: 6px; text-align: left; vertical-align: top; }
132
132
  table.cats th { font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 500; }
133
- table.cats input:not([type=checkbox]) { width: 100%; }
133
+ table.cats input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
134
+ table.cats td:first-child { min-width: 150px; }
135
+ table.cats td:nth-child(2), table.cats td:nth-child(3) { width: 58px; }
136
+ table.cats td:nth-child(5) { font-family: var(--sans); font-size: 12.5px; color: var(--muted); }
134
137
  input.narrow { width: 76px; }
135
138
  pre.wrap { white-space: pre-wrap; }
136
139
  /* KB "receipt" moment: the confirmed span highlighted inline against its muted source sentence. */
@@ -1,7 +1,6 @@
1
1
  .signin { max-width: 420px; margin: 10vh auto; padding: 32px 28px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-align: center; }
2
- .signin .mark { width: 64px; height: 64px; border-radius: 14px; margin-bottom: 12px; }
3
- .signin h1 { font-family: var(--mono); font-size: 20px; margin: 0 0 8px; }
4
- .signin .sub { color: var(--muted); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
2
+ .signin .mark { width: 80px; height: 80px; border-radius: 18px; margin-bottom: 18px; }
3
+ .signin .sub { color: var(--muted); font-family: var(--sans); font-size: 14px; line-height: 1.6; margin: 0 0 22px; text-align: left; }
5
4
  .signin button.primary { width: 100%; padding: 11px; font-size: 15px; }
6
5
  .signin details { text-align: left; margin-top: 22px; border-top: 1px solid var(--line); padding-top: 12px; }
7
6
  .signin textarea { min-height: 70px; font-size: 11px; }
@@ -10,8 +10,7 @@
10
10
  </head>
11
11
  <body>
12
12
  <main class="signin">
13
- <img class="mark" src="/icon-192.png" alt="" width="64" height="64">
14
- <h1>GAF-J</h1>
13
+ <img class="mark" src="/icon-192.png" alt="GAF-J" width="80" height="80">
15
14
  <p class="sub">Sign in with Google. Your record, documents, and campaign live in your account here, encrypted, yours to export or delete in one click.</p>
16
15
  <button id="google" class="primary" type="button">Continue with Google</button>
17
16
  <p id="status" class="small muted" hidden></p>
@@ -1,5 +1,6 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
2
  import { cleanPosting, guessPosting, api } from "../lib.js";
3
+ import { PacketModal } from "./packet.js";
3
4
 
4
5
  /** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
5
6
 
@@ -50,6 +51,18 @@ export function Postings({ live, id }) {
50
51
  };
51
52
  const [err, setErr] = useState("");
52
53
  const [msg, setMsg] = useState("");
54
+ const [me, setMe] = useState(null);
55
+ const [modal, setModal] = useState(null);
56
+ const [asking, setAsking] = useState(false);
57
+ useEffect(() => { api.get("/api/me").then(setMe).catch(() => {}); }, []);
58
+ const direct = me && (me.route === "byo_key" || me.route === "credits");
59
+ // the AI reads the posting and proposes the five to eight things the employer is really asking for, each with its verbatim line
60
+ const askAi = async (posting_id) => {
61
+ if (!direct) { setModal({ op: "categories", posting_id }); return; }
62
+ setAsking(true); setErr(""); setMsg("");
63
+ try { const r = await api.post(`/api/run/categories?posting_id=${encodeURIComponent(posting_id)}`, {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); else setMsg("your AI proposed the categories; edit and freeze below"); await load(); } catch (e) { setErr(e.message); }
64
+ setAsking(false);
65
+ };
53
66
  const load = async () => { setRows(await api.get("/api/postings")); if (id) setSel(await api.get(`/api/postings/${id}`)); };
54
67
  useEffect(() => { load().catch((e) => setErr(e.message)); }, [live, id]);
55
68
  const store = async (e) => {
@@ -87,7 +100,9 @@ export function Postings({ live, id }) {
87
100
  <div class="row"><span class="tag">${sel.status}</span><span class="muted small">${[sel.location, sel.comp_text].filter(Boolean).join(" · ")}</span>
88
101
  ${sel.status === "proposed" ? html`<button class="primary" onClick=${() => act(`/api/postings/${sel.posting_id}/accept`)}>Accept</button>` : ""}
89
102
  ${sel.status === "proposed" ? html`<button onClick=${() => act(`/api/postings/${sel.posting_id}/archive`)}>Archive</button>` : ""}
103
+ <button class="primary" disabled=${asking} onClick=${() => askAi(sel.posting_id)}>${asking ? "asking" : "Ask your AI for categories"}</button>
90
104
  <button onClick=${() => act(`/api/postings/${sel.posting_id}/scan`)}>Scan again</button></div>
105
+ ${modal ? html`<${PacketModal} op="categories" label="Categories" target=${{ posting_id: modal.posting_id }} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
91
106
  <details><summary>raw text (${sel.raw_text.length} chars)</summary><pre class="mono wrap">${sel.raw_text}</pre></details>
92
107
  </div>
93
108
  ${frozen ? html`<div class="card"><b>fit ${frozen.fit_score} · ${frozen.verdict.toUpperCase()}</b> <span class="muted small">${frozen.verdict_reason}</span>
@@ -95,7 +110,7 @@ export function Postings({ live, id }) {
95
110
  ${!rows.find((r) => r.posting_id === sel.posting_id && r.application_id) && sel.status === "accepted" ? html`<div class="row"><button class="primary" onClick=${async () => { const r = await act("/api/applications", { posting_id: sel.posting_id }); if (r) location.hash = "#/applications/" + r.application_id; }}>Log application</button></div>` : ""}
96
111
  </div>` : ""}
97
112
  <h3>${frozen ? "Re-score" : "Proposed categories"} ${proposed ? html`<span class="tag">draft from ${proposed.scored_at.slice(0, 10)}</span>` : ""}</h3>
98
- ${proposed || frozen ? html`<div class="card"><${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; scan the posting or let a model propose them through MCP</p>`}
113
+ ${proposed || frozen ? html`<div class="card">${proposed && !frozen ? html`<p class="small muted">${direct ? "A quick local scan made this draft. \"Ask your AI for categories\" above replaces it with a read of the whole posting." : "A quick local scan made this draft; your AI can do better through the button above."}</p>` : ""}<${CategoryEditor} key=${(proposed || frozen).scoring_id} posting=${sel} scoring=${proposed || frozen} onDone=${load} /></div>` : html`<p class="muted">no categories yet; ask your AI above, or scan the posting</p>`}
99
114
  ${sel.gaps.length ? html`<h3>KB gaps (${sel.gaps.length})</h3><ul class="list">${sel.gaps.map((g) => html`<li key=${g.gap_id}>${g.question} <span class="small muted">"${g.requirement_text}"</span> ${g.resolved_at ? html`<span class="tag passed">answered</span>` : html`<a class="small" href="#/kb">answer</a>`}</li>`)}</ul>` : ""}
100
115
  ` : html`<p class="muted">pick a posting</p>`}
101
116
  </div>
@@ -58,12 +58,13 @@ function StepAi({ s, reload, hosted }) {
58
58
  </div>`;
59
59
  }
60
60
 
61
- function StepPosting({ reload }) {
61
+ function StepPosting({ reload, direct }) {
62
62
  const [text, setText] = useState("");
63
63
  const [company, setCompany] = useState("");
64
64
  const [title, setTitle] = useState("");
65
65
  const [g, setG] = useState({ company: true, title: true });
66
66
  const [err, setErr] = useState("");
67
+ const [busy, setBusy] = useState(false);
67
68
  const onPaste = (raw) => {
68
69
  const t = cleanPosting(raw);
69
70
  setText(t);
@@ -73,13 +74,20 @@ function StepPosting({ reload }) {
73
74
  };
74
75
  const store = async (e) => {
75
76
  e.preventDefault(); setErr("");
76
- try { const r = await api.post("/api/postings", { company, title, raw_text: text }); await api.post(`/api/postings/${r.posting_id}/scan`, {}); await reload(); } catch (e2) { setErr(e2.message); }
77
+ setBusy(true);
78
+ try {
79
+ const r = await api.post("/api/postings", { company, title, raw_text: text });
80
+ await api.post(`/api/postings/${r.posting_id}/scan`, {});
81
+ if (direct) { try { await api.post(`/api/run/categories?posting_id=${encodeURIComponent(r.posting_id)}`, {}); } catch (e3) { /* the local scan's draft stands; the posting page can ask again */ } }
82
+ await reload();
83
+ } catch (e2) { setErr(e2.message); }
84
+ setBusy(false);
77
85
  };
78
86
  return html`<form onSubmit=${store}>
79
87
  <p>Paste the text of a job you want, exactly as the site shows it. GAF-J scores it against your record and shows what the posting asks for beside what you have. Everything else (the resume, the prep) starts from there.</p>
80
88
  <textarea required placeholder="the whole posting" value=${text} onInput=${(e) => onPaste(e.target.value)}></textarea>
81
89
  <div class="row"><input required placeholder="company (guessed; fix if wrong)" value=${company} onInput=${(e) => { setG({ ...g, company: false }); setCompany(e.target.value); }} /><input required placeholder="title (guessed; fix if wrong)" value=${title} onInput=${(e) => { setG({ ...g, title: false }); setTitle(e.target.value); }} /></div>
82
- <div class="row"><button class="primary">Store and score</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
90
+ <div class="row"><button class="primary" disabled=${busy}>${busy ? (direct ? "storing, then asking your AI what it wants" : "storing") : "Store and score"}</button>${err ? html`<span class="error small">${err}</span>` : ""}</div>
83
91
  </form>`;
84
92
  }
85
93
 
@@ -123,10 +131,10 @@ export function Welcome({ live, onDone }) {
123
131
  ${kb.questions.length ? html`<ul class="list">${kb.questions.map((q) => html`<li key=${q.question_id}><div><span class="tag">${q.kind}</span> <b>${q.title}</b> <span class="muted">${q.company}</span><br />${q.question}
124
132
  ${answering === q.question_id ? html`<${DraftForm} label="Add answer" initial=${fromDraft(q.draft)} onSubmit=${(draft) => { post(`/api/kb/questions/${q.question_id}/answer`, { draft }); setAnswering(null); }} />` : html`<div class="row"><button class="small" onClick=${() => setAnswering(q.question_id)}>Answer</button><button class="small danger" onClick=${() => post(`/api/kb/questions/${q.question_id}/dismiss`, {})}>Skip</button></div>`}</div></li>`)}</ul>`
125
133
  : html`<div class="row"><button class="primary" disabled=${!!busy} onClick=${discover}>${busy ? "asking" : "Ask what's missing"}</button><button onClick=${() => post("/api/setup/dismiss", { dismissed: true })}>Later</button></div>`}</div>`,
126
- posting: html`<${StepPosting} reload=${load} />`,
134
+ posting: html`<${StepPosting} reload=${load} direct=${direct} />`,
127
135
  };
128
136
  return html`<div class="welcome">
129
- <div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">Seven short steps, then the app is yours.</div></div>
137
+ <div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">${["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"][setup.steps.length] || setup.steps.length} short steps, then the app is yours.</div></div>
130
138
  <span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
131
139
  <ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
132
140
  ${err ? html`<p class="error">${err}</p>` : ""}