diff --git a/docs/releases/2026-11-02i.md b/docs/releases/2026-11-02i.md new file mode 100644 index 00000000..b35e386c --- /dev/null +++ b/docs/releases/2026-11-02i.md @@ -0,0 +1,21 @@ +# Release 2026-11-02i +Build: 5d7f33ed87f3 Worker: b9d46e1f (staging 0d08b847; rollback f85b54d3) Tag: v2026.11.02i + +## What changed (user-visible) +- Admin panel lists Firebase Auth accounts that have no Firestore profile (email sign-ups that never verified), tagged "no profile yet" / "email not verified" / "disabled". +- Email/password sign-ups write their profile before the verification gate. + +## Security / rules / data changes +- New Worker route GET /api/admin/users, role admin (custom claim), reads Identity Toolkit accounts:batchGet with FIREBASE_SA; returns uid, email, name, emailVerified, disabled, providers, createdAt, lastLoginAt only. No rules changes. + +## Verified (how) +- npm test 4999/0 (4 new route tests); release:check READY. +- Staging + prod: route 401 without / with a bad token; health ok; FIREBASE_SA present in both envs. +- Live build.json and CSP checked. + +## Skipped or known issues +- The owner-token path (batchGet with the service account) is untested until the owner opens the admin panel. +- EmailJS is off (EMAILJS_CSP_ALLOWED=false): sign-ups only get Firebase's verification link. + +## Owner decisions still open +- Server plan gating for monitors/sync/keys; ban list readable by signed-in users; confirm security@darknode.ai forward. diff --git a/public/js/admin.js b/public/js/admin.js index cc33be85..98c11a07 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -310,6 +310,18 @@ export async function renderAdmin(main, user) { try { const snap = await getDocs(collection(db, "users")); users = snap.docs.map((d) => ({ uid: d.id, ...d.data() })); + // Firebase Auth accounts with no users/{uid} doc (an email sign-up that never verified + // never writes one). Listed by the Worker with its service account; owner only. + try { + const { api } = await import("/js/core/api.js"); + const r = await api("/api/admin/users"); + const have = new Map(users.map((u) => [u.uid, u])); + for (const a of (r && r.users) || []) { + const u = have.get(a.uid); + if (u) { u.emailVerified = a.emailVerified; u.disabled = a.disabled; continue; } + users.push({ uid: a.uid, email: a.email, name: a.name, provider: a.providers.join(", ") || "email", createdAt: a.createdAt, lastLoginAt: a.lastLoginAt, lastSeen: a.lastLoginAt, emailVerified: a.emailVerified, disabled: a.disabled, authOnly: true }); + } + } catch (_) { /* Worker unreachable: the Firestore list still shows */ } $("#stUsers").textContent = users.length; const toMs = (ts) => ts && ts.toMillis ? ts.toMillis() : (ts && ts.toDate ? ts.toDate().getTime() : (typeof ts === "number" ? (ts < 1e12 ? ts * 1000 : ts) : 0)); const now = Date.now(), day = 864e5, midnight = new Date(); midnight.setHours(0, 0, 0, 0); @@ -347,7 +359,7 @@ export async function renderAdmin(main, user) { return `
${avatar}
-
${esc(u.name || "(no name)")} ${provBadge}${isOwner ? 'OWNER' : ""}${banned ? 'banned' : ""}
+
${esc(u.name || "(no name)")} ${provBadge}${isOwner ? 'OWNER' : ""}${banned ? 'banned' : ""}${u.emailVerified === false ? 'email not verified' : ""}${u.authOnly ? 'no profile yet' : ""}${u.disabled ? 'disabled' : ""}
${esc(u.email || u.uid)}
last seen ${esc(fmtDate(u.lastSeen))}${u.createdAt ? " · joined " + esc(fmtDate(u.createdAt)) : ""}
diff --git a/public/js/auth.js b/public/js/auth.js index 3d73fe03..881fd71a 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -2948,6 +2948,9 @@ onAuthStateChanged(auth, async (user) => { // Email/password users must verify — a 6-digit code when EmailJS is configured, else the Firebase link. const providerEmailPw = user.providerData.some((p) => p.providerId === "password"); if (providerEmailPw) { + // Write the profile before the verification gate, so a sign-up that never verifies + // still shows in the admin user list. + ensureUserDoc(user); if (emailConfigured()) { const udoc = await getDoc(doc(db, "users", user.uid)).catch(() => null); if (!(udoc && udoc.exists() && udoc.data().codeVerified)) { renderCodeVerify(user); return; } diff --git a/test/worker/admin-users.test.mjs b/test/worker/admin-users.test.mjs new file mode 100644 index 00000000..ddc9594b --- /dev/null +++ b/test/worker/admin-users.test.mjs @@ -0,0 +1,34 @@ +// Unit tests for worker/admin-users.js — the owner-only Firebase Auth account listing. +import { test, group, assert } from "../harness.mjs"; +import { handleAdminUsers, authUserRow } from "../../worker/admin-users.js"; +import { matchRoute, authorize } from "../../worker/auth.js"; + +const json = (body, status = 200) => ({ status, body }); + +group("admin-users", () => { + test("the route is owner only", () => { + const { route } = matchRoute("/api/admin/users", "GET"); + assert.equal(route.id, "admin-users"); + assert.equal(authorize(route, { kind: "user", claims: {} }).status, 403); + assert.equal(authorize(route, { kind: "anonymous" }).status, 401); + assert.equal(authorize(route, { kind: "user", claims: { admin: true } }), null); + }); + test("an unverified email sign-up is listed with only the panel's fields", () => { + const row = authUserRow({ localId: "u1", email: "test@darknode.ai", emailVerified: false, passwordHash: "x", providerUserInfo: [{ providerId: "password" }], createdAt: "1793404800000", lastLoginAt: "1793404800000" }); + assert.deepEqual(row, { uid: "u1", email: "test@darknode.ai", name: "", emailVerified: false, disabled: false, providers: ["password"], createdAt: 1793404800000, lastLoginAt: 1793404800000 }); + }); + test("every page is read", async () => { + const pages = [{ users: [{ localId: "a" }], nextPageToken: "p2" }, { users: [{ localId: "b", providerUserInfo: [{ providerId: "google.com" }] }] }]; + const urls = []; + const fetch = async (u) => { urls.push(u); return { ok: true, json: async () => pages[urls.length - 1] }; }; + const r = await handleAdminUsers({ FIREBASE_PROJECT_ID: "p" }, json, { fetch, token: async () => "t" }); + assert.deepEqual(r.body.users.map((u) => u.uid), ["a", "b"]); + assert.deepEqual(r.body.users[1].providers, ["google"]); + assert.ok(urls[1].includes("nextPageToken=p2")); + assert.equal(r.body.truncated, false); + }); + test("an upstream failure is an error, not an empty list", async () => { + const r = await handleAdminUsers({ FIREBASE_PROJECT_ID: "p" }, json, { fetch: async () => ({ ok: false, status: 403 }), token: async () => "t" }); + assert.equal(r.status, 502); + }); +}); diff --git a/worker/admin-users.js b/worker/admin-users.js new file mode 100644 index 00000000..8424ae29 --- /dev/null +++ b/worker/admin-users.js @@ -0,0 +1,38 @@ +// GET /api/admin/users (owner only, role "admin" in the route table): every Firebase Auth +// account, read with the Worker's service account. The admin panel lists Firestore users/{uid} +// docs, and an email sign-up that never verifies never writes one, so those accounts were +// invisible there. Only the fields the panel shows are returned. +import { googleToken } from "./digest.js"; + +export function authUserRow(u) { + return { + uid: u.localId, + email: u.email || "", + name: u.displayName || "", + emailVerified: !!u.emailVerified, + disabled: !!u.disabled, + providers: (u.providerUserInfo || []).map((p) => String(p.providerId || "").replace(/\.com$/, "")).filter(Boolean), + createdAt: Number(u.createdAt) || null, + lastLoginAt: Number(u.lastLoginAt) || null, + }; +} + +export async function handleAdminUsers(env, json, opts = {}) { + if (!env.FIREBASE_PROJECT_ID) return json({ error: "FIREBASE_PROJECT_ID is not set" }, 503); + const doFetch = opts.fetch || fetch; + let token; + try { token = await (opts.token || googleToken)(env); } + catch (e) { return json({ error: "Server credentials unavailable: " + e.message }, 503); } + const users = []; + let next = ""; + for (let page = 0; page < 20; page++) { // 20 x 1000 accounts + const url = "https://identitytoolkit.googleapis.com/v1/projects/" + env.FIREBASE_PROJECT_ID + "/accounts:batchGet?maxResults=1000" + (next ? "&nextPageToken=" + encodeURIComponent(next) : ""); + const r = await doFetch(url, { headers: { Authorization: "Bearer " + token } }); + if (!r.ok) return json({ error: "Firebase Auth listing failed (HTTP " + r.status + ")." }, 502); + const d = await r.json().catch(() => ({})); + for (const u of d.users || []) users.push(authUserRow(u)); + next = d.nextPageToken || ""; + if (!next) break; + } + return json({ users, truncated: !!next }); +} diff --git a/worker/auth.js b/worker/auth.js index 6f9944af..b13315fa 100644 --- a/worker/auth.js +++ b/worker/auth.js @@ -27,6 +27,7 @@ export const ROUTES = [ { id: "keys", prefix: "/api/keys/", auth: "user" }, { id: "digest-unsub", path: "/api/digest/unsubscribe", methods: ["GET", "POST"], auth: "none" }, { id: "digest", prefix: "/api/digest/", auth: "user" }, + { id: "admin-users", path: "/api/admin/users", methods: ["GET"], auth: "user", role: "admin" }, { id: "ingest", prefix: "/api/v1/ingest/", auth: "key", scope: "ingest:write" }, { id: "findings", prefix: "/api/v1/findings/", auth: "key", scope: "findings:read" }, ]; diff --git a/worker/digest.js b/worker/digest.js index 31c2e61e..8d7dcf71 100644 --- a/worker/digest.js +++ b/worker/digest.js @@ -167,7 +167,7 @@ async function unsubUrl(env, uid) { } let _tok = null; // { token, exp } -async function googleToken(env) { +export async function googleToken(env) { if (_tok && _tok.exp > Date.now() + 60000) return _tok.token; const sa = readSA(env); if (!sa) throw new Error("FIREBASE_SA is not set"); diff --git a/worker/worker.js b/worker/worker.js index ed2e1111..f48021f5 100644 --- a/worker/worker.js +++ b/worker/worker.js @@ -28,6 +28,7 @@ import { matchRoute, authenticate, authorize, attachPrincipal, rateCheck, ipCeil import { GEMINI_MODELS, CLAUDE_PLAN, userTier, planDenied } from "./plan.js"; import { chargeQuota, refundQuota, quotaMessage } from "./quota.js"; import { handleDigest, handleUnsubscribe, runDigest } from "./digest.js"; +import { handleAdminUsers } from "./admin-users.js"; export { VaultDO } from "./vault.js"; export { GraphDO } from "./graph.js"; export { MonitorDO } from "./monitor.js"; @@ -814,6 +815,7 @@ async function route(request, env, rid) { case "keys": return handleKeys(request, env, rest, json); case "digest-unsub": return handleUnsubscribe(request, env); case "digest": return handleDigest(request, env, rest, json, principal); + case "admin-users": return handleAdminUsers(env, json); case "ingest": return handleIngest(request, env, rest, json, "ingest"); case "findings": return handleIngest(request, env, rest, json, "findings"); }