Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/releases/2026-11-02i.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion public/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -347,7 +359,7 @@ export async function renderAdmin(main, user) {
return `<div class="user-row" data-uid="${esc(u.uid)}">
${avatar}
<div class="ur-main">
<div class="ur-name">${esc(u.name || "(no name)")} ${provBadge}${isOwner ? '<span class="owner-badge">OWNER</span>' : ""}${banned ? '<span class="chip chip-danger">banned</span>' : ""}</div>
<div class="ur-name">${esc(u.name || "(no name)")} ${provBadge}${isOwner ? '<span class="owner-badge">OWNER</span>' : ""}${banned ? '<span class="chip chip-danger">banned</span>' : ""}${u.emailVerified === false ? '<span class="chip">email not verified</span>' : ""}${u.authOnly ? '<span class="chip">no profile yet</span>' : ""}${u.disabled ? '<span class="chip chip-danger">disabled</span>' : ""}</div>
<div class="ur-mail muted">${esc(u.email || u.uid)}</div>
<div class="ur-meta muted">last seen ${esc(fmtDate(u.lastSeen))}${u.createdAt ? " · joined " + esc(fmtDate(u.createdAt)) : ""}</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions public/js/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
34 changes: 34 additions & 0 deletions test/worker/admin-users.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions worker/admin-users.js
Original file line number Diff line number Diff line change
@@ -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 });
}
1 change: 1 addition & 0 deletions worker/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand Down
2 changes: 1 addition & 1 deletion worker/digest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions worker/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
}
Expand Down
Loading