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");
}