diff --git a/src/config.ts b/src/config.ts index 63a93b6..10f63f7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -305,8 +305,6 @@ const AuthSchemaFields = z const OAuthSchemaFields = z .object({ - hmacSecret: z.string().optional(), - issuer: z.string().optional(), clientId: z.string().optional(), }) .default({}); @@ -434,7 +432,6 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [ { env: "ZEROID_URL", path: "zeroidUrl", kind: "string" }, { env: "ZEROID_ISSUER", path: "auth.issuer", kind: "string" }, { env: "ZEROID_AUDIENCE", path: "auth.audience", kind: "string" }, - { env: "CODEOID_HMAC_SECRET", path: "oauth.hmacSecret", kind: "string" }, { env: "CODEOID_OAUTH_CLIENT_ID", path: "oauth.clientId", kind: "string" }, { env: "ZEROID_ACCOUNT_ID", path: "agentIdentity.accountId", kind: "string" }, { env: "ZEROID_PROJECT_ID", path: "agentIdentity.projectId", kind: "string" }, @@ -559,13 +556,17 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { const memoryDbPath = configRelResolve(parsed.memory.dbPath); const memoryCacheDir = configRelResolve(parsed.memory.modelCacheDir); - // 5. Assemble OAuth only when hmacSecret is present — keeps optionality. - const oauth: OAuthConfig | undefined = parsed.oauth.hmacSecret + // 5. Assemble OAuth when Google credentials are present in env. + const googleClientId = env.GOOGLE_CLIENT_ID; + const googleClientSecret = env.GOOGLE_CLIENT_SECRET; + + const oauth: OAuthConfig | undefined = + googleClientId && googleClientSecret ? { - hmacSecret: parsed.oauth.hmacSecret, - issuer: parsed.oauth.issuer ?? resolvedZeroidUrl, - tokenEndpoint: `${resolvedZeroidUrl}/oauth2/token`, + zeroidTokenEndpoint: `${resolvedZeroidUrl}/oauth2/token`, clientId: parsed.oauth.clientId ?? "codeoid", + googleClientId, + googleClientSecret, accountId: parsed.agentIdentity.accountId, projectId: parsed.agentIdentity.projectId, allowedRedirectUris: [ diff --git a/src/daemon/identity-provider.ts b/src/daemon/identity-provider.ts index b1634cb..e3302cc 100644 --- a/src/daemon/identity-provider.ts +++ b/src/daemon/identity-provider.ts @@ -28,6 +28,12 @@ export interface VerifiedUser { avatarUrl?: string; /** Which provider verified this user */ provider: string; + /** + * Raw OIDC ID token from the upstream IdP — preserved so the daemon can + * forward it to ZeroID's token-exchange endpoint for final token issuance. + * Only set for external IdPs (Google etc.), not for LocalProvider. + */ + rawIdToken?: string; } export interface IdentityProvider { @@ -133,6 +139,7 @@ export class GoogleOAuthProvider implements IdentityProvider { name: payload.name, avatarUrl: payload.picture, provider: "google", + rawIdToken: tokens.id_token, }; } } diff --git a/src/daemon/memory/store.ts b/src/daemon/memory/store.ts index f08df45..7e426bf 100644 --- a/src/daemon/memory/store.ts +++ b/src/daemon/memory/store.ts @@ -19,6 +19,23 @@ import { resolve, isAbsolute } from "node:path"; import type { Episode, FileReadRecord, RecallQuery } from "./types.js"; import type { TurnUsage } from "../../protocol/types.js"; +export interface DailyUsageBucket { + day: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + numSessions: number; +} + +export interface LifetimeUsageTotals { + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + numSessions: number; +} + /** Row shape as stored in SQLite. */ interface EpisodeRow { id: string; @@ -393,6 +410,77 @@ export class SqliteEpisodeStore { return (row?.last_turn ?? 0) + 1; } + dailyUsage(days = 30, sessionIds?: string[]): DailyUsageBucket[] { + const sessionFilter = + sessionIds && sessionIds.length > 0 + ? `AND session_id IN (${sessionIds.map(() => "?").join(",")})` + : ""; + const rows = this.#db + .prepare( + `SELECT + date(created_at / 1000, 'unixepoch') AS day, + COALESCE(SUM(total_cost_usd), 0) AS cost_usd, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COUNT(*) AS num_turns, + COUNT(DISTINCT session_id) AS num_sessions + FROM turn_usage + WHERE date(created_at / 1000, 'unixepoch') >= date('now', printf('-%d days', ? - 1)) + ${sessionFilter} + GROUP BY day + ORDER BY day ASC`, + ) + .all(days, ...(sessionIds ?? [])) as Array<{ + day: string; + cost_usd: number; + input_tokens: number; + output_tokens: number; + num_turns: number; + num_sessions: number; + }>; + + return rows.map((r) => ({ + day: r.day, + costUsd: r.cost_usd, + inputTokens: r.input_tokens, + outputTokens: r.output_tokens, + numTurns: r.num_turns, + numSessions: r.num_sessions, + })); + } + + lifetimeTotals(sessionIds?: string[]): LifetimeUsageTotals { + const sessionFilter = + sessionIds && sessionIds.length > 0 + ? `WHERE session_id IN (${sessionIds.map(() => "?").join(",")})` + : ""; + const row = this.#db + .prepare( + `SELECT + COALESCE(SUM(total_cost_usd), 0) AS cost_usd, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COUNT(*) AS num_turns, + COUNT(DISTINCT session_id) AS num_sessions + FROM turn_usage ${sessionFilter}`, + ) + .get(...(sessionIds ?? [])) as { + cost_usd: number; + input_tokens: number; + output_tokens: number; + num_turns: number; + num_sessions: number; + }; + + return { + costUsd: row?.cost_usd ?? 0, + inputTokens: row?.input_tokens ?? 0, + outputTokens: row?.output_tokens ?? 0, + numTurns: row?.num_turns ?? 0, + numSessions: row?.num_sessions ?? 0, + }; + } + #rowToTurnUsage(row: { turn_number: number; created_at: number; diff --git a/src/daemon/oauth.ts b/src/daemon/oauth.ts index 9662760..5253c6a 100644 --- a/src/daemon/oauth.ts +++ b/src/daemon/oauth.ts @@ -1,33 +1,33 @@ /** - * OAuth authorization server — Codeoid mints HS256 auth code JWTs - * that ZeroID validates and exchanges for access tokens. + * OAuth authorization server — handles the browser-facing login flow and + * exchanges verified identity for a ZeroID RS256 access token. * * Flow: - * 1. Frontend redirects to GET /auth/authorize?client_id=codeoid&code_challenge=... + * 1. Frontend redirects to GET /auth/authorize * 2. Daemon delegates to the configured IdentityProvider (Google, local, etc.) * 3. IdP verifies the user, redirects back to /auth/idp-callback - * 4. Daemon mints HS256 auth code JWT signed with shared hmac_secret - * 5. Redirects to the original redirect_uri with ?code=&state= - * 6. Frontend exchanges code at ZeroID /oauth2/token (authorization_code + PKCE) - * 7. ZeroID returns RS256 access token with user identity + * 4. For external IdPs: daemon forwards the raw OIDC id_token to ZeroID's + * token-exchange endpoint (RFC 8693) — ZeroID is the authority that issues + * the final RS256 access token. Google configured as ZeroID external issuer. + * 5. Daemon redirects to /auth/callback#token= (fragment, never sent to server) + * 7. Frontend stores token, connects to daemon WebSocket. */ -import { createHmac, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import type { IdentityProvider, VerifiedUser } from "./identity-provider.js"; -import { LocalProvider } from "./identity-provider.js"; export interface OAuthConfig { - /** Shared HMAC secret with ZeroID (base64url encoded) */ - hmacSecret: string; - /** Issuer claim in auth code JWTs — must match ZeroID's auth_code_issuer */ - issuer: string; - /** ZeroID token endpoint */ - tokenEndpoint: string; - /** Registered OAuth client_id */ + /** ZeroID token endpoint for the final token exchange */ + zeroidTokenEndpoint: string; + /** Registered OAuth client_id (used to identify this codeoid instance) */ clientId: string; - /** Account ID for tenant scoping */ + /** Google OAuth client ID — passed to GoogleOAuthProvider */ + googleClientId: string; + /** Google OAuth client secret — passed to GoogleOAuthProvider */ + googleClientSecret: string; + /** Account ID for ZeroID tenant scoping */ accountId: string; - /** Project ID */ + /** Project ID for ZeroID tenant scoping */ projectId: string; /** Allowed redirect URIs */ allowedRedirectUris: string[]; @@ -38,7 +38,6 @@ export interface OAuthConfig { /** Pending authorization — stored between /auth/authorize and IdP callback */ interface PendingAuth { redirectUri: string; - codeChallenge: string; scope: string; state: string; createdAt: number; @@ -61,9 +60,9 @@ export class OAuthHandler { #pending = new Map(); #sweepTimer: ReturnType | null = null; - constructor(config: OAuthConfig, idp?: IdentityProvider) { + constructor(config: OAuthConfig, idp: IdentityProvider) { this.#config = config; - this.#idp = idp ?? new LocalProvider(); + this.#idp = idp; // Clean up expired pending auths every 60s. `unref()` so the // timer doesn't hold the Bun event loop open after `stop()`. @@ -130,6 +129,10 @@ export class OAuthHandler { return this.#handleFinalCallback(url); } + if (url.pathname === "/auth/provider" && req.method === "GET") { + return Response.json({ provider: this.#idp.name }); + } + return null; } @@ -138,13 +141,11 @@ export class OAuthHandler { #handleAuthorize(url: URL): Response { const clientId = url.searchParams.get("client_id"); const redirectUri = url.searchParams.get("redirect_uri"); - const codeChallenge = url.searchParams.get("code_challenge"); - const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256"; const scope = url.searchParams.get("scope") ?? this.#config.defaultScopes.join(" "); const state = url.searchParams.get("state") ?? ""; - // Validate - if (!clientId || clientId !== this.#config.clientId) { + // Validate client_id if provided (optional for single-client deployments) + if (clientId && clientId !== this.#config.clientId) { return new Response("Invalid client_id", { status: 400 }); } if (!redirectUri || !this.#config.allowedRedirectUris.some( @@ -152,15 +153,11 @@ export class OAuthHandler { )) { return new Response("Invalid redirect_uri", { status: 400 }); } - if (!codeChallenge || codeChallengeMethod !== "S256") { - return new Response("PKCE code_challenge (S256) required", { status: 400 }); - } // Store pending auth — LRU-bounded via #setPending. const internalState = randomBytes(16).toString("hex"); this.#setPending(internalState, { redirectUri, - codeChallenge, scope, state, createdAt: Date.now(), @@ -198,7 +195,7 @@ export class OAuthHandler { provider: "local", }; - return this.#completeAuth(internalState, user); + return await this.#completeAuth(internalState, user); } // ── GET /auth/idp-callback — external IdP redirects back here ───── @@ -213,7 +210,7 @@ export class OAuthHandler { try { const user = await this.#idp.handleCallback(callbackUri, url.searchParams); - return this.#completeAuth(internalState, user); + return await this.#completeAuth(internalState, user); } catch (err) { const pending = this.#pending.get(internalState); this.#pending.delete(internalState); @@ -229,96 +226,96 @@ export class OAuthHandler { } } - // ── Complete auth — mint code, redirect to original redirect_uri ── + // ── Complete auth — exchange identity for ZeroID token, redirect ── - #completeAuth(internalState: string, user: VerifiedUser): Response { + async #completeAuth(internalState: string, user: VerifiedUser): Promise { const pending = this.#pending.get(internalState); if (!pending) { return new Response("Invalid or expired authorization request", { status: 400 }); } this.#pending.delete(internalState); - const scopes = pending.scope.split(" ").filter(Boolean); - const code = mintAuthCode( - this.#config, - user.id, - pending.codeChallenge, - pending.redirectUri, - scopes.length > 0 ? scopes : this.#config.defaultScopes, - ); + let accessToken: string; + try { + accessToken = await this.#exchangeForZeroIDToken(user, pending.scope); + } catch (err) { + const callbackUrl = new URL(pending.redirectUri); + callbackUrl.searchParams.set( + "error", + err instanceof Error ? err.message : "Token exchange failed", + ); + if (pending.state) callbackUrl.searchParams.set("state", pending.state); + return Response.redirect(callbackUrl.toString(), 302); + } const callbackUrl = new URL(pending.redirectUri); - callbackUrl.searchParams.set("code", code); + // Use a URL fragment so the token is never sent to the server (not in logs, history, or Referer). + callbackUrl.hash = `token=${encodeURIComponent(accessToken)}`; if (pending.state) callbackUrl.searchParams.set("state", pending.state); - return Response.redirect(callbackUrl.toString(), 302); } - // ── GET /auth/callback — landing page for client-side token exchange + async #exchangeForZeroIDToken(user: VerifiedUser, scope: string): Promise { + if (!user.rawIdToken) { + throw new Error( + `Provider "${user.provider}" did not return an OIDC id_token — cannot exchange with ZeroID. Configure Google OAuth or another OIDC provider.`, + ); + } + + const body = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type: "urn:ietf:params:oauth:token-type:id_token", + subject_token: user.rawIdToken, + client_id: this.#config.clientId, + account_id: this.#config.accountId, + project_id: this.#config.projectId, + scope, + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + let resp: Response; + try { + resp = await fetch(this.#config.zeroidTokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`ZeroID token exchange failed (${resp.status}): ${text.slice(0, 200)}`); + } + + const data = (await resp.json()) as { access_token?: string }; + if (!data.access_token) { + throw new Error("ZeroID response missing access_token"); + } + return data.access_token; + } + + // ── GET /auth/callback — landing page, stores the ZeroID token ──── #handleFinalCallback(url: URL): Response { - const code = url.searchParams.get("code"); + // Error case: query param (server-side readable, no token involved) const error = url.searchParams.get("error"); - if (error) { return new Response(errorPage(error), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } - if (!code) { - return new Response("Missing authorization code", { status: 400 }); - } - - return new Response(callbackPage(code, this.#config.clientId), { + // Token case: fragment — the token is never sent to the server. + // The callbackPage JS reads window.location.hash to extract it. + return new Response(callbackPage(), { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } -// ============================================================================= -// Auth code minting -// ============================================================================= - -function mintAuthCode( - config: OAuthConfig, - userId: string, - codeChallenge: string, - redirectUri: string, - scopes: string[], -): string { - const now = Math.floor(Date.now() / 1000); - - const header = { alg: "HS256", typ: "JWT" }; - const payload = { - iss: config.issuer, - sub: "auth-code", - iat: now, - exp: now + 300, - cid: config.clientId, - uid: userId, - aid: config.accountId, - pid: config.projectId, - cc: codeChallenge, - ruri: redirectUri, - scp: scopes, - }; - - const headerB64 = b64url(JSON.stringify(header)); - const payloadB64 = b64url(JSON.stringify(payload)); - const signature = b64url( - createHmac("sha256", config.hmacSecret) - .update(`${headerB64}.${payloadB64}`) - .digest(), - ); - - return `${headerB64}.${payloadB64}.${signature}`; -} - -function b64url(input: string | Buffer): string { - const buf = typeof input === "string" ? Buffer.from(input) : input; - return buf.toString("base64url"); -} - function normalizeLoopback(uri: string): string { return uri.replace("://localhost", "://127.0.0.1"); } @@ -390,13 +387,16 @@ input:focus { border-color: #6366f1; } `; } -function callbackPage(code: string, clientId: string): string { +function callbackPage(): string { + // The token exchange has already happened server-side (daemon ↔ ZeroID). + // The access token is in the URL fragment (#token=...) — never sent to server. + // This page's JS reads window.location.hash, stores the token, and redirects. return ` -Codeoid — Authenticating... +Codeoid — Signing in...
-
Exchanging token...
-
Please wait
+
Authenticated!
+
Redirecting to Codeoid...
diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 7e37625..96da4ce 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -17,7 +17,7 @@ import { RateLimiter } from "./rate-limit.js"; import { ShutdownManager } from "./shutdown.js"; import { AgentIdentityManager } from "./agent-identity.js"; import { OAuthHandler, type OAuthConfig } from "./oauth.js"; -import { GoogleOAuthProvider, LocalProvider } from "./identity-provider.js"; +import { GoogleOAuthProvider } from "./identity-provider.js"; import { createMemory, type MemoryEngine } from "./memory/index.js"; import { type CompressionRegistry, @@ -103,14 +103,10 @@ export class DaemonServer { this.#shutdown = new ShutdownManager(); if (config.oauth) { - // Choose IdP based on env config - const googleClientId = process.env.GOOGLE_CLIENT_ID; - const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; - - const idp = (googleClientId && googleClientSecret) - ? new GoogleOAuthProvider({ clientId: googleClientId, clientSecret: googleClientSecret }) - : new LocalProvider(); - + const idp = new GoogleOAuthProvider({ + clientId: config.oauth.googleClientId, + clientSecret: config.oauth.googleClientSecret, + }); this.#oauthHandler = new OAuthHandler(config.oauth, idp); console.log(`[codeoid] auth provider: ${idp.name}`); } @@ -320,10 +316,15 @@ export class DaemonServer { } } - // OAuth authorization routes (/auth/authorize, /auth/callback) - if (self.#oauthHandler && url.pathname.startsWith("/auth/")) { - const oauthResp = await self.#oauthHandler.handleFetch(req); - if (oauthResp) return oauthResp; + // OAuth authorization routes (/auth/authorize, /auth/callback, /auth/provider) + if (url.pathname.startsWith("/auth/")) { + if (url.pathname === "/auth/provider" && req.method === "GET" && !self.#oauthHandler) { + return Response.json({ provider: null }); + } + if (self.#oauthHandler) { + const oauthResp = await self.#oauthHandler.handleFetch(req); + if (oauthResp) return oauthResp; + } } // Frontend routes (Web UI etc.) diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 5dc4ab1..711762c 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -42,6 +42,7 @@ import type { ModelInfo, SessionInfo, } from "../protocol/types.js"; +import type { DailyUsageBucket, LifetimeUsageTotals } from "./memory/store.js"; /** * Resolve a user-supplied workdir to an absolute, existing directory. @@ -270,6 +271,8 @@ export class SessionManager { return this.#sessionExport(msg, auth); case "session.import": return this.#sessionImport(msg, auth); + case "usage.daily": + return this.#usageDaily(msg, auth); default: { // Inbound messages are cast from raw JSON at the transport, so an // unknown/malformed `type` reaches here. Without this the function @@ -1299,6 +1302,39 @@ export class SessionManager { this.#rateLimiter.recordDestruction(auth.sub); return { type: "response.ok", requestId: msg.id }; } + + #usageDaily( + msg: Extract, + auth: AuthContext, + ): DaemonMessage { + if (!this.#memory) { + return { + type: "response.ok", + requestId: msg.id, + data: { + daily: [] as DailyUsageBucket[], + lifetime: { + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + numTurns: 0, + numSessions: 0, + } as LifetimeUsageTotals, + }, + }; + } + const days = typeof msg.days === "number" && msg.days > 0 ? Math.min(msg.days, 365) : 30; + const ownedSessionIds = this.#store + .listSessions(auth.accountId, auth.projectId) + .map((s) => s.id); + const daily = this.#memory.store.dailyUsage(days, ownedSessionIds); + const lifetime = this.#memory.store.lifetimeTotals(ownedSessionIds); + return { + type: "response.ok", + requestId: msg.id, + data: { daily, lifetime }, + }; + } } /** diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 77e8912..38291c7 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2138,13 +2138,20 @@ export class Session { } if (this.#activeAssistantMsg !== msg) return; // interrupted on last frame - msg.content = content; // exact match regardless of ceiling-division rounding - msg.parts = [{ kind: "text", text: content, markdown: true }]; + const finalParts: ContentPart[] = [{ kind: "text", text: content, markdown: true }]; + // Reset to the placeholder size so updateMessage measures the correct + // before/after byte delta — the buffer holds msg by reference, so + // mutations here are visible to the accounting logic inside updateMessage. + msg.content = ""; + msg.parts = []; // Do NOT call #persistAndBuffer again — it would push a second scrollback entry // for the same messageId, causing duplicate messages on scrollback.replay. - // The scrollback buffer holds msg by reference so content is already up-to-date; - // we only need to recount bytes and persist the final state to transcript. - this.#scrollback.updateMessage(msg.messageId, () => {}); + // The updater sets final content/parts inside the buffer's size-accounting pass. + this.#scrollback.updateMessage(msg.messageId, (entry) => { + const sm = entry as SessionMessage; + sm.content = content; + sm.parts = finalParts; + }); this.#transcriptStore.append(this.id, msg, this.#seq++).catch((e) => { console.error(`[codeoid/session ${this.id}] transcript append failed: ${e instanceof Error ? e.message : String(e)}`); }); diff --git a/src/protocol/types.ts b/src/protocol/types.ts index a17e0cd..43ec2ad 100644 --- a/src/protocol/types.ts +++ b/src/protocol/types.ts @@ -219,6 +219,25 @@ export interface TurnUsage { primaryMaxCallInputTokens?: number; } +// ── Usage analytics ─────────────────────────────────────────────────────────── + +export interface DailyUsageBucket { + day: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + numSessions: number; +} + +export interface LifetimeUsageTotals { + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + numSessions: number; +} + export interface Subagent { /** SDK-side agent id (opaque handle). */ agentId: string; @@ -541,6 +560,11 @@ export interface PingMsg extends BaseClientMsg { type: "ping"; } +export interface UsageDailyMsg extends BaseClientMsg { + type: "usage.daily"; + days?: number; +} + export type ClientMessage = | PingMsg | SessionCreateMsg @@ -564,7 +588,8 @@ export type ClientMessage = | ClaudeConfigMsg | ModelsListMsg | SessionExportMsg - | SessionImportMsg; + | SessionImportMsg + | UsageDailyMsg; interface BaseClientMsg { /** Request ID for correlating responses */ diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts index 3fb0173..e6aa394 100644 --- a/src/tests/config.test.ts +++ b/src/tests/config.test.ts @@ -289,16 +289,19 @@ describe("loadConfig — issuer presets + iss pinning", () => { }); describe("loadConfig — oauth conditional", () => { - it("populates oauth only when hmacSecret is set", () => { + it("populates oauth only when Google credentials are set", () => { const none = loadConfig({ configPath, env: {} }); expect(none.oauth).toBeUndefined(); const viaEnv = loadConfig({ configPath, - env: { CODEOID_HMAC_SECRET: "deadbeef" }, + env: { + GOOGLE_CLIENT_ID: "client-id.apps.googleusercontent.com", + GOOGLE_CLIENT_SECRET: "secret", + }, }); expect(viaEnv.oauth).toBeDefined(); - expect(viaEnv.oauth?.hmacSecret).toBe("deadbeef"); + expect(viaEnv.oauth?.zeroidTokenEndpoint).toContain("/oauth2/token"); expect(viaEnv.oauth?.clientId).toBe("codeoid"); }); }); diff --git a/src/tests/memory.test.ts b/src/tests/memory.test.ts index cb73514..7424054 100644 --- a/src/tests/memory.test.ts +++ b/src/tests/memory.test.ts @@ -20,7 +20,7 @@ import { workspaceIdFromPath, } from "../daemon/memory/index.js"; import type { Embedder } from "../daemon/memory/embedder.js"; -import type { SessionMessage } from "../protocol/types.js"; +import type { SessionMessage, TurnUsage } from "../protocol/types.js"; class StubEmbedder implements Embedder { readonly modelName = "stub-embed"; @@ -302,6 +302,177 @@ describe("EpisodeChunker", () => { }); }); +// ── Usage analytics (dailyUsage / lifetimeTotals) ─────────────────────── + +function makeTurnInput( + sessionId: string, + turnNumber: number, + overrides: Partial = {}, +): Parameters[0] { + const base: TurnUsage = { + turnNumber, + createdAt: Date.now(), + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalCostUsd: 0.001, + durationMs: 500, + totalInputTokens: 100, + billableInputTokens: 100, + cacheHitRate: 0, + ...overrides, + }; + return { workspaceId: "ws_analytics", sessionId, turn: base }; +} + +describe("SqliteEpisodeStore — dailyUsage", () => { + it("returns empty array when no turns exist", () => { + const store = new SqliteEpisodeStore(dbPath); + expect(store.dailyUsage()).toEqual([]); + store.close(); + }); + + it("aggregates turns from the same day into one bucket", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01, inputTokens: 200, outputTokens: 100 })); + store.recordTurnUsage(makeTurnInput("s-a", 2, { totalCostUsd: 0.02, inputTokens: 300, outputTokens: 150 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.005, inputTokens: 50, outputTokens: 25 })); + + const buckets = store.dailyUsage(); + expect(buckets.length).toBe(1); + const b = buckets[0]!; + expect(b.numTurns).toBe(3); + expect(b.numSessions).toBe(2); + expect(b.costUsd).toBeCloseTo(0.035, 6); + expect(b.inputTokens).toBe(550); + expect(b.outputTokens).toBe(275); + expect(b.day).toMatch(/^\d{4}-\d{2}-\d{2}$/); + store.close(); + }); + + it("filters to only the specified sessionIds", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01, inputTokens: 100, outputTokens: 50 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.02, inputTokens: 200, outputTokens: 100 })); + + const buckets = store.dailyUsage(30, ["s-a"]); + expect(buckets.length).toBe(1); + expect(buckets[0]!.numTurns).toBe(1); + expect(buckets[0]!.numSessions).toBe(1); + expect(buckets[0]!.costUsd).toBeCloseTo(0.01, 6); + store.close(); + }); + + it("filters across multiple sessionIds", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.02 })); + store.recordTurnUsage(makeTurnInput("s-c", 1, { totalCostUsd: 0.04 })); + + const buckets = store.dailyUsage(30, ["s-a", "s-b"]); + expect(buckets[0]!.numTurns).toBe(2); + expect(buckets[0]!.numSessions).toBe(2); + expect(buckets[0]!.costUsd).toBeCloseTo(0.03, 6); + store.close(); + }); + + it("empty sessionIds array behaves identically to undefined (no filter)", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1)); + store.recordTurnUsage(makeTurnInput("s-b", 1)); + + const withEmpty = store.dailyUsage(30, []); + const withUndefined = store.dailyUsage(30, undefined); + expect(withEmpty[0]?.numTurns).toBe(withUndefined[0]?.numTurns); + expect(withEmpty[0]?.numSessions).toBe(2); + store.close(); + }); + + it("returns empty when sessionIds filter matches nothing", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1)); + const buckets = store.dailyUsage(30, ["nonexistent"]); + expect(buckets).toEqual([]); + store.close(); + }); +}); + +describe("SqliteEpisodeStore — lifetimeTotals", () => { + it("returns zeros when no turns exist", () => { + const store = new SqliteEpisodeStore(dbPath); + const totals = store.lifetimeTotals(); + expect(totals.costUsd).toBe(0); + expect(totals.inputTokens).toBe(0); + expect(totals.outputTokens).toBe(0); + expect(totals.numTurns).toBe(0); + expect(totals.numSessions).toBe(0); + store.close(); + }); + + it("sums all turns across all sessions", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01, inputTokens: 100, outputTokens: 50 })); + store.recordTurnUsage(makeTurnInput("s-a", 2, { totalCostUsd: 0.02, inputTokens: 200, outputTokens: 100 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.005, inputTokens: 50, outputTokens: 25 })); + + const totals = store.lifetimeTotals(); + expect(totals.numTurns).toBe(3); + expect(totals.numSessions).toBe(2); + expect(totals.costUsd).toBeCloseTo(0.035, 6); + expect(totals.inputTokens).toBe(350); + expect(totals.outputTokens).toBe(175); + store.close(); + }); + + it("filters to the specified sessionIds", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01, inputTokens: 100, outputTokens: 50 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.02, inputTokens: 200, outputTokens: 100 })); + + const totals = store.lifetimeTotals(["s-a"]); + expect(totals.numTurns).toBe(1); + expect(totals.numSessions).toBe(1); + expect(totals.costUsd).toBeCloseTo(0.01, 6); + expect(totals.inputTokens).toBe(100); + store.close(); + }); + + it("filters across multiple sessionIds", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1, { totalCostUsd: 0.01 })); + store.recordTurnUsage(makeTurnInput("s-b", 1, { totalCostUsd: 0.02 })); + store.recordTurnUsage(makeTurnInput("s-c", 1, { totalCostUsd: 0.04 })); + + const totals = store.lifetimeTotals(["s-a", "s-b"]); + expect(totals.numTurns).toBe(2); + expect(totals.numSessions).toBe(2); + expect(totals.costUsd).toBeCloseTo(0.03, 6); + store.close(); + }); + + it("empty sessionIds array behaves identically to undefined (no filter)", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1)); + store.recordTurnUsage(makeTurnInput("s-b", 1)); + + const withEmpty = store.lifetimeTotals([]); + const withUndefined = store.lifetimeTotals(undefined); + expect(withEmpty.numTurns).toBe(withUndefined.numTurns); + expect(withEmpty.numSessions).toBe(2); + store.close(); + }); + + it("returns zeros when sessionIds filter matches nothing", () => { + const store = new SqliteEpisodeStore(dbPath); + store.recordTurnUsage(makeTurnInput("s-a", 1)); + const totals = store.lifetimeTotals(["nonexistent"]); + expect(totals.numTurns).toBe(0); + expect(totals.numSessions).toBe(0); + store.close(); + }); +}); + // ── test helpers ──────────────────────────────────────────────────────── function msg( @@ -324,9 +495,7 @@ function msg( function msgTool( messageId: string, name: string, - // biome-ignore lint/suspicious/noExplicitAny: test helper state: any, - // biome-ignore lint/suspicious/noExplicitAny: test helper input: any, timestamp: string, ): SessionMessage { diff --git a/src/tests/oauth-handler.test.ts b/src/tests/oauth-handler.test.ts new file mode 100644 index 0000000..57d6630 --- /dev/null +++ b/src/tests/oauth-handler.test.ts @@ -0,0 +1,330 @@ +/** + * OAuthHandler tests — exercise all HTTP routes and the ZeroID token-exchange + * path without spawning a real daemon or hitting any network. + * + * Auth paths covered: + * A. Google OAuth (external IdP): + * GET /auth/authorize → 302 to Google + * GET /auth/idp-callback → ZeroID token exchange → 302 to /auth/callback#token=… + * B. API-key path is not handled here — that's ZeroID's /oauth2/token proxy, + * tested in tui-ws.test.ts. + * + * Routes also covered: + * GET /auth/provider — discovery endpoint + * GET /auth/callback — static landing page (success + error variants) + * Input validation (bad client_id, bad redirect_uri, missing state) + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { OAuthHandler, type OAuthConfig } from "../daemon/oauth.js"; +import type { IdentityProvider, VerifiedUser } from "../daemon/identity-provider.js"; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const BASE_CONFIG: OAuthConfig = { + zeroidTokenEndpoint: "http://zeroid.test/oauth2/token", + clientId: "codeoid", + googleClientId: "gid_xxx", + googleClientSecret: "gsecret_xxx", + accountId: "acct_test", + projectId: "proj_test", + allowedRedirectUris: ["http://localhost:7400/auth/callback"], + defaultScopes: ["session:list", "session:create"], +}; + +const CALLBACK_URI = "http://localhost:7400/auth/callback"; + +// Minimal IdentityProvider stub — simulates Google OIDC. +// handleCallback reads `id_token` from params so tests can inject specific tokens. +class StubIdP implements IdentityProvider { + readonly name = "google"; + + getAuthorizationUrl(callbackUri: string, state: string): string { + return `https://accounts.google.com/o/oauth2/auth?redirect_uri=${encodeURIComponent(callbackUri)}&state=${state}`; + } + + async handleCallback(_callbackUri: string, params: URLSearchParams): Promise { + const idToken = params.get("id_token") ?? "stub-id-token"; + return { + id: "google-sub-123", + email: "user@example.com", + name: "Test User", + provider: "google", + rawIdToken: idToken, + }; + } +} + +// Variant that always throws from handleCallback (simulates IdP auth failure). +class FailingIdP extends StubIdP { + override async handleCallback(): Promise { + throw new Error("IdP rejected the code"); + } +} + +function makeHandler(idp: IdentityProvider = new StubIdP()): OAuthHandler { + return new OAuthHandler(BASE_CONFIG, idp); +} + +function get(handler: OAuthHandler, path: string): Promise { + return handler.handleFetch(new Request(`http://localhost:7400${path}`)); +} + +// Run a GET /auth/authorize to obtain the internal state value that the handler +// embedded in the Google redirect URL, then return it so tests can chain to +// /auth/idp-callback with the correct state. +async function authorize(handler: OAuthHandler): Promise { + const params = new URLSearchParams({ + client_id: "codeoid", + redirect_uri: CALLBACK_URI, + scope: "session:list", + }); + const resp = await get(handler, `/auth/authorize?${params}`); + expect(resp?.status).toBe(302); + const location = resp!.headers.get("Location")!; + const stateMatch = location.match(/state=([^&]+)/); + expect(stateMatch).not.toBeNull(); + return stateMatch![1]; +} + +// ── ZeroID fetch mock ───────────────────────────────────────────────────────── + +let savedFetch: typeof globalThis.fetch; + +function mockZeroIDOk(token = "zeroid-rs256-token"): void { + (globalThis as { fetch: unknown }).fetch = async () => ({ + ok: true, + json: async () => ({ access_token: token }), + text: async () => "", + }); +} + +function mockZeroIDFail(status = 500, body = "internal error"): void { + (globalThis as { fetch: unknown }).fetch = async () => ({ + ok: false, + status, + text: async () => body, + }); +} + +beforeEach(() => { + savedFetch = globalThis.fetch; +}); +afterEach(() => { + (globalThis as { fetch: unknown }).fetch = savedFetch; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("GET /auth/provider", () => { + it("returns the IdP name when OAuth is configured", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/auth/provider"); + expect(resp?.status).toBe(200); + const body = await resp!.json() as { provider: string }; + expect(body.provider).toBe("google"); + handler.stop(); + }); +}); + +describe("GET /auth/authorize", () => { + it("redirects to Google with a state param embedded", async () => { + const handler = makeHandler(); + const params = new URLSearchParams({ + client_id: "codeoid", + redirect_uri: CALLBACK_URI, + scope: "session:list", + }); + const resp = await get(handler, `/auth/authorize?${params}`); + expect(resp?.status).toBe(302); + const location = resp!.headers.get("Location")!; + expect(location).toContain("accounts.google.com"); + expect(location).toContain("state="); + handler.stop(); + }); + + it("accepts localhost and 127.0.0.1 redirect URIs interchangeably", async () => { + const handler = makeHandler(); + const params = new URLSearchParams({ + redirect_uri: "http://127.0.0.1:7400/auth/callback", + scope: "session:list", + }); + const resp = await get(handler, `/auth/authorize?${params}`); + expect(resp?.status).toBe(302); + handler.stop(); + }); + + it("returns 400 on wrong client_id", async () => { + const handler = makeHandler(); + const params = new URLSearchParams({ + client_id: "evil-client", + redirect_uri: CALLBACK_URI, + }); + const resp = await get(handler, `/auth/authorize?${params}`); + expect(resp?.status).toBe(400); + handler.stop(); + }); + + it("returns 400 on unregistered redirect_uri", async () => { + const handler = makeHandler(); + const params = new URLSearchParams({ + redirect_uri: "https://evil.example.com/callback", + }); + const resp = await get(handler, `/auth/authorize?${params}`); + expect(resp?.status).toBe(400); + handler.stop(); + }); + + it("returns 400 when redirect_uri is missing", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/auth/authorize?client_id=codeoid"); + expect(resp?.status).toBe(400); + handler.stop(); + }); +}); + +describe("GET /auth/idp-callback — Google OAuth (ZeroID token exchange)", () => { + it("exchanges id_token with ZeroID and redirects to /auth/callback#token=…", async () => { + const handler = makeHandler(); + mockZeroIDOk("final-rs256-token"); + + const internalState = await authorize(handler); + const params = new URLSearchParams({ + state: internalState, + code: "google-auth-code", + id_token: "google-id-token-abc", + }); + const resp = await get(handler, `/auth/idp-callback?${params}`); + expect(resp?.status).toBe(302); + const location = resp!.headers.get("Location")!; + // Token must be in the fragment — never in the query string (not server-logged) + expect(location).toContain("#token="); + expect(location).toContain("final-rs256-token"); + expect(location).not.toContain("?token="); + handler.stop(); + }); + + it("forwards scope and client_id to ZeroID", async () => { + const handler = makeHandler(); + const captured: { body?: string } = {}; + (globalThis as { fetch: unknown }).fetch = async (_url: string, opts: RequestInit) => { + captured.body = opts.body as string; + return { ok: true, json: async () => ({ access_token: "tok" }), text: async () => "" }; + }; + + const internalState = await authorize(handler); + await get(handler, `/auth/idp-callback?state=${internalState}&id_token=gid-tok`); + + const body = new URLSearchParams(captured.body); + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange"); + expect(body.get("subject_token_type")).toBe("urn:ietf:params:oauth:token-type:id_token"); + expect(body.get("client_id")).toBe("codeoid"); + expect(body.get("account_id")).toBe("acct_test"); + expect(body.get("project_id")).toBe("proj_test"); + expect(body.get("scope")).toBe("session:list"); // scope from /auth/authorize + handler.stop(); + }); + + it("redirects to callback with ?error= when ZeroID rejects", async () => { + const handler = makeHandler(); + mockZeroIDFail(401, "invalid id_token"); + + const internalState = await authorize(handler); + const resp = await get(handler, `/auth/idp-callback?state=${internalState}&id_token=bad`); + expect(resp?.status).toBe(302); + const location = resp!.headers.get("Location")!; + expect(location).toContain("error="); + expect(location).not.toContain("#token="); + handler.stop(); + }); + + it("redirects with ?error= when the IdP itself fails", async () => { + const handler = makeHandler(new FailingIdP()); + + const internalState = await authorize(handler); + const resp = await get(handler, `/auth/idp-callback?state=${internalState}`); + expect(resp?.status).toBe(302); + const location = resp!.headers.get("Location")!; + expect(location).toContain("error="); + handler.stop(); + }); + + it("returns 400 when state param is absent", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/auth/idp-callback"); + expect(resp?.status).toBe(400); + handler.stop(); + }); + + it("returns 400 on an unknown / expired state", async () => { + const handler = makeHandler(); + mockZeroIDOk(); + const resp = await get(handler, "/auth/idp-callback?state=nonexistent"); + // IdP callback is called; StubIdP succeeds; but #completeAuth finds no pending → 400 + expect(resp?.status).toBe(400); + handler.stop(); + }); + + it("consumes pending state so replay is rejected", async () => { + const handler = makeHandler(); + mockZeroIDOk(); + const internalState = await authorize(handler); + // First callback: succeeds + const first = await get(handler, `/auth/idp-callback?state=${internalState}&id_token=tok`); + expect(first?.status).toBe(302); + // Replay with the same state: pending is gone + const replay = await get(handler, `/auth/idp-callback?state=${internalState}&id_token=tok`); + expect(replay?.status).toBe(400); + handler.stop(); + }); +}); + +describe("GET /auth/callback", () => { + it("serves the landing page (reads token from fragment client-side)", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/auth/callback"); + expect(resp?.status).toBe(200); + const html = await resp!.text(); + expect(html).toContain("window.location.hash"); + expect(html).toContain("localStorage.setItem"); + expect(html).toContain("codeoid.token"); + handler.stop(); + }); + + it("serves an error page when ?error= is present", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/auth/callback?error=access_denied"); + expect(resp?.status).toBe(200); + const html = await resp!.text(); + expect(html).toContain("access_denied"); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + handler.stop(); + }); +}); + +describe("unhandled routes", () => { + it("returns null for unknown paths", async () => { + const handler = makeHandler(); + const resp = await get(handler, "/some/other/path"); + expect(resp).toBeNull(); + handler.stop(); + }); + + it("returns null for POST to /auth/provider", async () => { + const handler = makeHandler(); + const resp = await handler.handleFetch( + new Request("http://localhost:7400/auth/provider", { method: "POST" }), + ); + expect(resp).toBeNull(); + handler.stop(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8879aa3..d620aba 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,7 @@ import { Component, Show, createEffect, createSignal, on, onCleanup, onMount } from "solid-js"; -import { rememberedApiKey } from "./lib/auth"; +import { rememberedApiKey, rememberedOAuthToken } from "./lib/auth"; import SignIn from "./components/SignIn"; import Shell from "./components/Shell"; import { @@ -36,10 +36,13 @@ const App: Component = () => { installApprovalNotifications(); onMount(async () => { - const saved = rememberedApiKey(); - if (saved) { + const savedKey = rememberedApiKey(); + const savedToken = rememberedOAuthToken(); + if (savedKey || savedToken) { try { - await bootstrap({ apiKey: saved }); + // Prefer explicit API key exchange (yields a fresh JWT). + // With no apiKey, resolveToken falls back to the stored OAuth token. + await bootstrap(savedKey ? { apiKey: savedKey } : {}); } catch { // bootstrap surfaces the reason via bootstrapError; SignIn renders it. } diff --git a/web/src/components/AnalyticsPanel.tsx b/web/src/components/AnalyticsPanel.tsx new file mode 100644 index 0000000..be9ad18 --- /dev/null +++ b/web/src/components/AnalyticsPanel.tsx @@ -0,0 +1,111 @@ +import { For, Show, onMount } from "solid-js"; +import { analyticsLoading, dailyUsage, fetchAnalytics, lifetimeTotals } from "../state/analytics"; +import { formatCostUsd, formatTokens } from "../lib/format"; +import type { DailyUsageBucket } from "../protocol/types"; + +function padDays(data: DailyUsageBucket[], days: number): Array<{ day: string; costUsd: number }> { + const map = new Map(data.map((d) => [d.day, d.costUsd])); + const result = []; + const now = new Date(); + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const key = d.toISOString().slice(0, 10); + result.push({ day: key, costUsd: map.get(key) ?? 0 }); + } + return result; +} + +const DAYS = 14; +const BAR_W = 14; +const BAR_GAP = 6; +const CHART_H = 48; +const LABEL_H = 14; +const CHART_W = DAYS * (BAR_W + BAR_GAP) - BAR_GAP; + +const AnalyticsPanel = () => { + onMount(() => { void fetchAnalytics(DAYS); }); + + const padded = () => padDays(dailyUsage(), DAYS); + const maxCost = () => Math.max(...padded().map((d) => d.costUsd), 0.001); + const today = new Date().toISOString().slice(0, 10); + + return ( +
+ + {(lt) => ( +
+
+
{formatCostUsd(lt().costUsd)}
+
all time
+
+
+
{lt().numTurns.toLocaleString()}
+
turns
+
+
+
{formatTokens(lt().inputTokens + lt().outputTokens)}
+
tokens
+
+
+ )} +
+ +
last 14 days
+ + +
loading…
+
+ 0}> + + + {(bucket, i) => { + const barH = () => + bucket.costUsd > 0 + ? Math.max(2, (bucket.costUsd / maxCost()) * CHART_H) + : 0; + const x = () => i() * (BAR_W + BAR_GAP); + const isToday = () => bucket.day === today; + const dayLabel = () => { + const d = new Date(bucket.day + "T00:00:00"); + return d.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 1); + }; + return ( + <> + {`${bucket.day}: ${formatCostUsd(bucket.costUsd)}`} + + + {dayLabel()} + + + ); + }} + + + +
+ ); +}; + +export default AnalyticsPanel; diff --git a/web/src/components/SessionListPane.tsx b/web/src/components/SessionListPane.tsx index 0066598..1d6138e 100644 --- a/web/src/components/SessionListPane.tsx +++ b/web/src/components/SessionListPane.tsx @@ -5,7 +5,7 @@ * chat area dominates the viewport. */ -import { Component, For, Show } from "solid-js"; +import { Component, createSignal, For, Show } from "solid-js"; import { formatCostUsd, formatTokens, relativeTime } from "../lib/format"; import { sessionAgentLabel, shortSub } from "../lib/identity"; @@ -24,6 +24,7 @@ import type { SessionInfo, SessionStatus } from "../protocol/types"; import FileTree from "./files/FileTree"; import { openNewSessionModal } from "./NewSessionModal"; +import AnalyticsPanel from "./AnalyticsPanel"; /** Focus a session and, on mobile, close the off-canvas drawer. */ function pickSession(id: string): void { @@ -42,13 +43,23 @@ function newSession(): void { } const SessionListPane: Component = () => { + const [showAnalytics, setShowAnalytics] = createSignal(false); + return ( } > ); -const SectionHeader: Component<{ title: string; count: number }> = (props) => ( +const SectionHeader: Component<{ + title: string; + count: number; + showAnalytics: boolean; + onToggleAnalytics: () => void; +}> = (props) => (
{props.title} 0}> @@ -127,10 +143,18 @@ const SectionHeader: Component<{ title: string; count: number }> = (props) => ( {props.count} + +
+ + or use API key + +
+ +