app/worker/src/accountsDb.ts
zzmq.dev@gmail.com/zz / app / worker / src / accountsDb.ts
@ 807046bf
Download
Ladder Summary
- file This module defines the TypeScript interfaces for the account‑related D1 tables (agents, identities, tokens, sessions, repos and CLI login sessions) and implements a set of helper functions that query and mutate those tables – e.g. looking up an agent or identity, creating a new agent with a guaranteed‑unique username, and generating short prefixed IDs. The exported symbols are the utility `shortId` function and the database‑access functions `findAgentIdentity`, `getAgent`, `getAgentByEmail`, and `createAgent`. No classes are exported.
// D1 query helpers for the unified agents/tokens/sessions/repos model
// -- see migrations/0002_unify_agents.sql for the schema and why
// there's one identity table, not an accounts-owns-agents hierarchy.
// repoMeta.ts's header covers why `repos` is an index/cache, not the
// source of truth (agents/tokens/sessions have no git home, so D1
// *is* authoritative for those).
import { randomBytes } from "node:crypto";
export interface Agent {
id: string;
display_name: string;
email: string | null;
+ username: string; // the public URL segment (/<username>) -- frozen at creation, never recomputed from display_name (see createAgent)
created_at: string;
created_by: string | null; // provenance only, never an authority relationship
}
export interface AgentIdentity {
id: string;
agent_id: string;
provider: "cloudflare" | "google";
provider_subject: string;
access_token_enc: string | null;
refresh_token_enc: string | null;
token_expires_at: string | null;
created_at: string;
}
export interface AgentToken {
id: string;
agent_id: string;
token_hash: string;
created_at: string;
revoked_at: string | null;
last_used_at: string | null;
}
export interface Session {
id: string;
agent_id: string;
created_at: string;
expires_at: string;
}
export interface RepoRow {
id: string;
slug: string;
artifacts_name: string;
description: string | null;
owner_id: string;
created_at: string;
updated_at: string;
}
export interface CliLoginSession {
nonce: string;
agent_id: string | null;
token: string | null;
created_at: string;
consumed: number;
}
export function shortId(prefix: string): string {
return `${prefix}-${randomBytes(4).toString("hex")}`;
}
+function usernameSlug(name: string): string {
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
+}
+
+/** `base`, or `base-2`/`base-3`/... if already taken -- usernames are frozen at creation (createAgent), so collisions are only ever checked once, here. */
+async function uniqueUsername(db: D1Database, base: string): Promise<string> {
+ let candidate = base;
+ let n = 2;
+ while (await db.prepare("SELECT 1 FROM agents WHERE username = ?").bind(candidate).first()) {
+ candidate = `${base}-${n}`;
+ n++;
+ }
+ return candidate;
+}
+
export async function findAgentIdentity(db: D1Database, provider: string, providerSubject: string): Promise<AgentIdentity | null> {
return db.prepare("SELECT * FROM agent_identities WHERE provider = ? AND provider_subject = ?").bind(provider, providerSubject).first<AgentIdentity>();
}
export async function getAgent(db: D1Database, id: string): Promise<Agent | null> {
return db.prepare("SELECT * FROM agents WHERE id = ?").bind(id).first<Agent>();
}
+/** The agent a verified email already belongs to, if any -- used to link a second OAuth provider to an EXISTING agent instead of minting a disconnected duplicate (see handleAuthCallback's header comment for the real bug this closes). */
+export async function getAgentByEmail(db: D1Database, email: string): Promise<Agent | null> {
+ return db.prepare("SELECT * FROM agents WHERE email = ? LIMIT 1").bind(email).first<Agent>();
+}
+
+export async function getAgentByUsername(db: D1Database, username: string): Promise<Agent | null> {
+ return db.prepare("SELECT * FROM agents WHERE username = ?").bind(username).first<Agent>();
+}
+
export async function createAgent(db: D1Database, displayName: string, email: string | null, createdBy: string | null): Promise<Agent> {
- const agent: Agent = { id: shortId("agent"), display_name: displayName, email, created_at: new Date().toISOString(), created_by: createdBy };
- await db.prepare("INSERT INTO agents (id, display_name, email, created_at, created_by) VALUES (?, ?, ?, ?, ?)").bind(agent.id, agent.display_name, agent.email, agent.created_at, agent.created_by).run();
+ const username = await uniqueUsername(db, email ?? usernameSlug(displayName));
+ const agent: Agent = { id: shortId("agent"), display_name: displayName, email, username, created_at: new Date().toISOString(), created_by: createdBy };
+ await db
+ .prepare("INSERT INTO agents (id, display_name, email, username, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")
+ .bind(agent.id, agent.display_name, agent.email, agent.username, agent.created_at, agent.created_by)
+ .run();
return agent;
}
/** Refreshes display_name/email on every login, not just signup -- a provider's profile data (especially Cloudflare's, fetched via a second REST call) can be absent or wrong at signup time and correct itself later. Never clears a field the provider stops returning: only overwrites with a real, non-null value. */
export async function refreshAgentProfile(db: D1Database, agentId: string, displayName: string, email: string | null): Promise<void> {
if (email) await db.prepare("UPDATE agents SET display_name = ?, email = ? WHERE id = ?").bind(displayName, email, agentId).run();
else await db.prepare("UPDATE agents SET display_name = ? WHERE id = ?").bind(displayName, agentId).run();
}
export async function createAgentIdentity(db: D1Database, agentId: string, provider: string, providerSubject: string, tokens: { accessTokenEnc: string | null; refreshTokenEnc: string | null; expiresAt: string | null }): Promise<AgentIdentity> {
const identity: AgentIdentity = {
id: shortId("ident"),
agent_id: agentId,
provider: provider as "cloudflare" | "google",
provider_subject: providerSubject,
access_token_enc: tokens.accessTokenEnc,
refresh_token_enc: tokens.refreshTokenEnc,
token_expires_at: tokens.expiresAt,
created_at: new Date().toISOString(),
};
await db
.prepare("INSERT INTO agent_identities (id, agent_id, provider, provider_subject, access_token_enc, refresh_token_enc, token_expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.bind(identity.id, identity.agent_id, identity.provider, identity.provider_subject, identity.access_token_enc, identity.refresh_token_enc, identity.token_expires_at, identity.created_at)
.run();
return identity;
}
export async function updateIdentityTokens(db: D1Database, identityId: string, tokens: { accessTokenEnc: string | null; refreshTokenEnc: string | null; expiresAt: string | null }): Promise<void> {
await db
.prepare("UPDATE agent_identities SET access_token_enc = ?, refresh_token_enc = ?, token_expires_at = ? WHERE id = ?")
.bind(tokens.accessTokenEnc, tokens.refreshTokenEnc, tokens.expiresAt, identityId)
.run();
}
export async function createSession(db: D1Database, agentId: string, ttlSeconds: number): Promise<Session> {
const now = Date.now();
const session: Session = { id: shortId("sess"), agent_id: agentId, created_at: new Date(now).toISOString(), expires_at: new Date(now + ttlSeconds * 1000).toISOString() };
await db.prepare("INSERT INTO sessions (id, agent_id, created_at, expires_at) VALUES (?, ?, ?, ?)").bind(session.id, session.agent_id, session.created_at, session.expires_at).run();
return session;
}
export async function getValidSession(db: D1Database, sessionId: string): Promise<Session | null> {
const session = await db.prepare("SELECT * FROM sessions WHERE id = ?").bind(sessionId).first<Session>();
if (!session) return null;
if (new Date(session.expires_at).getTime() < Date.now()) return null;
return session;
}
export async function deleteSession(db: D1Database, sessionId: string): Promise<void> {
await db.prepare("DELETE FROM sessions WHERE id = ?").bind(sessionId).run();
}
/** Agents this agent created -- provenance/display only (see Agent.created_by), never an access-control boundary. */
export async function listAgentsCreatedBy(db: D1Database, creatorId: string): Promise<Agent[]> {
const result = await db.prepare("SELECT * FROM agents WHERE created_by = ? ORDER BY created_at DESC").bind(creatorId).all<Agent>();
return result.results;
}
export async function createAgentToken(db: D1Database, agentId: string, tokenHash: string): Promise<AgentToken> {
const token: AgentToken = { id: shortId("tok"), agent_id: agentId, token_hash: tokenHash, created_at: new Date().toISOString(), revoked_at: null, last_used_at: null };
await db.prepare("INSERT INTO agent_tokens (id, agent_id, token_hash, created_at) VALUES (?, ?, ?, ?)").bind(token.id, token.agent_id, token.token_hash, token.created_at).run();
return token;
}
export async function findActiveAgentToken(db: D1Database, tokenHash: string): Promise<AgentToken | null> {
const token = await db.prepare("SELECT * FROM agent_tokens WHERE token_hash = ?").bind(tokenHash).first<AgentToken>();
if (!token || token.revoked_at) return null;
return token;
}
export async function touchAgentToken(db: D1Database, tokenId: string): Promise<void> {
await db.prepare("UPDATE agent_tokens SET last_used_at = ? WHERE id = ?").bind(new Date().toISOString(), tokenId).run();
}
export async function revokeAgentToken(db: D1Database, tokenId: string): Promise<void> {
await db.prepare("UPDATE agent_tokens SET revoked_at = ? WHERE id = ?").bind(new Date().toISOString(), tokenId).run();
}
export async function listTokensForAgent(db: D1Database, agentId: string): Promise<AgentToken[]> {
const result = await db.prepare("SELECT * FROM agent_tokens WHERE agent_id = ? ORDER BY created_at DESC").bind(agentId).all<AgentToken>();
return result.results;
}
export async function getAgentToken(db: D1Database, tokenId: string): Promise<AgentToken | null> {
return db.prepare("SELECT * FROM agent_tokens WHERE id = ?").bind(tokenId).first<AgentToken>();
}
export async function upsertRepo(db: D1Database, row: { slug: string; artifactsName: string; description: string | null; ownerId: string }): Promise<RepoRow> {
const existing = await db.prepare("SELECT * FROM repos WHERE slug = ?").bind(row.slug).first<RepoRow>();
const now = new Date().toISOString();
if (existing) {
await db.prepare("UPDATE repos SET description = ?, updated_at = ? WHERE id = ?").bind(row.description, now, existing.id).run();
return { ...existing, description: row.description, updated_at: now };
}
const repo: RepoRow = { id: shortId("repo"), slug: row.slug, artifacts_name: row.artifactsName, description: row.description, owner_id: row.ownerId, created_at: now, updated_at: now };
await db
.prepare("INSERT INTO repos (id, slug, artifacts_name, description, owner_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(repo.id, repo.slug, repo.artifacts_name, repo.description, repo.owner_id, repo.created_at, repo.updated_at)
.run();
return repo;
}
export async function getRepoBySlug(db: D1Database, slug: string): Promise<RepoRow | null> {
return db.prepare("SELECT * FROM repos WHERE slug = ?").bind(slug).first<RepoRow>();
}
export async function touchRepoUpdatedAt(db: D1Database, repoId: string): Promise<void> {
await db.prepare("UPDATE repos SET updated_at = ? WHERE id = ?").bind(new Date().toISOString(), repoId).run();
}
export async function listRecentRepos(db: D1Database, limit: number): Promise<RepoRow[]> {
const result = await db.prepare("SELECT * FROM repos ORDER BY updated_at DESC LIMIT ?").bind(limit).all<RepoRow>();
return result.results;
}
export async function searchRepos(db: D1Database, query: string, limit: number): Promise<RepoRow[]> {
const like = `%${query}%`;
const result = await db
.prepare(
`SELECT DISTINCT r.* FROM repos r
LEFT JOIN agents a ON r.owner_id = a.id
WHERE r.slug LIKE ? OR r.description LIKE ? OR a.display_name LIKE ?
ORDER BY r.updated_at DESC LIMIT ?`,
)
.bind(like, like, like, limit)
.all<RepoRow>();
return result.results;
}
export async function listReposOwnedBy(db: D1Database, ownerId: string): Promise<RepoRow[]> {
const result = await db.prepare("SELECT * FROM repos WHERE owner_id = ? ORDER BY updated_at DESC").bind(ownerId).all<RepoRow>();
return result.results;
}
+export async function deleteRepoRow(db: D1Database, repoId: string): Promise<void> {
+ await db.prepare("DELETE FROM repo_roles WHERE repo_id = ?").bind(repoId).run();
+ await db.prepare("DELETE FROM repos WHERE id = ?").bind(repoId).run();
+}
+
+export async function updateRepoOwner(db: D1Database, repoId: string, ownerId: string): Promise<void> {
+ await db.prepare("UPDATE repos SET owner_id = ?, updated_at = ? WHERE id = ?").bind(ownerId, new Date().toISOString(), repoId).run();
+}
+
+/** Agents matching a display name/email/id prefix -- autocomplete for "who should this role/token belong to", searched across every Agent known to zz, not just ones you created. */
+export async function searchAgents(db: D1Database, query: string, limit: number): Promise<Agent[]> {
+ const like = `%${query}%`;
+ const result = await db.prepare("SELECT * FROM agents WHERE display_name LIKE ? OR email LIKE ? OR username LIKE ? OR id LIKE ? ORDER BY display_name LIMIT ?").bind(like, like, like, like, limit).all<Agent>();
+ return result.results;
+}
+
+export interface RepoRoleRow {
+ repo_id: string;
+ principal: string;
+ role: string;
+ granted_by: string;
+ at: string;
+}
+
+/** Mirrors a repo's CURRENT full grant list (roles.json, the source of truth) into the D1 cache -- replace-all for that repo_id, so the cache can never drift into showing a grant that was since revoked. */
+export async function syncRepoRoleCache(db: D1Database, repoId: string, grants: { principal: string; role: string; granted_by: string; at: string }[]): Promise<void> {
+ const writes: D1PreparedStatement[] = [db.prepare("DELETE FROM repo_roles WHERE repo_id = ?").bind(repoId)];
+ for (const g of grants) {
+ writes.push(db.prepare("INSERT INTO repo_roles (repo_id, principal, role, granted_by, at) VALUES (?, ?, ?, ?, ?)").bind(repoId, g.principal, g.role, g.granted_by, g.at));
+ }
+ await db.batch(writes);
+}
+
+/** Repos (beyond ones owned outright) a principal has any repo-level Role grant on -- the profile page's "shared with you" list. Reads the D1 cache, not roles.json directly (see migration 0004's header for why a live cross-repo scan isn't affordable here). */
+export async function listRepoRolesForAgent(db: D1Database, principal: string): Promise<(RepoRow & { role: string })[]> {
+ const result = await db
+ .prepare("SELECT r.*, rr.role as role FROM repo_roles rr JOIN repos r ON r.id = rr.repo_id WHERE rr.principal = ? AND r.owner_id != ? ORDER BY r.updated_at DESC")
+ .bind(principal, principal)
+ .all<RepoRow & { role: string }>();
+ return result.results;
+}
+
// --- CLI login bridge: `zz login` opens a browser against the same
// OAuth flow the web app uses, then polls here for the token the
// callback mints once the human finishes logging in. Single-use,
// short-lived (the row is deleted once polled successfully) -- the
// nonce itself is the only secret standing between "pending" and
// "here's your token", so it has to be unguessable (crypto-random)
// and short-lived, same security shape as any OAuth authorization
// code.
export async function createCliLoginSession(db: D1Database, nonce: string): Promise<void> {
await db.prepare("INSERT INTO cli_login_sessions (nonce, agent_id, token, created_at, consumed) VALUES (?, NULL, NULL, ?, 0)").bind(nonce, new Date().toISOString()).run();
}
export async function fulfillCliLoginSession(db: D1Database, nonce: string, agentId: string, token: string): Promise<boolean> {
const result = await db.prepare("UPDATE cli_login_sessions SET agent_id = ?, token = ? WHERE nonce = ? AND consumed = 0").bind(agentId, token, nonce).run();
return (result.meta.changes ?? 0) > 0;
}
/** Returns the token once, then marks the session consumed so it can never be read again -- the whole point of the bridge is a one-time handoff. */
export async function pollAndConsumeCliLoginSession(db: D1Database, nonce: string): Promise<{ agentId: string; token: string } | null> {
const row = await db.prepare("SELECT agent_id, token FROM cli_login_sessions WHERE nonce = ? AND consumed = 0").bind(nonce).first<{ agent_id: string | null; token: string | null }>();
if (!row?.agent_id || !row.token) return null;
await db.prepare("UPDATE cli_login_sessions SET consumed = 1 WHERE nonce = ?").bind(nonce).run();
return { agentId: row.agent_id, token: row.token };
}