diff --git a/README.md b/README.md index 440b720..b46c10a 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,11 @@ scripts/deploy-space.sh scripts/seed-dataset.sh /xtap-pool-data ~/Downloads/xtap ``` -Add friends by putting their HF usernames in the Space's `ALLOWED_USERS` -variable — no repo permissions, no org membership needed. +After setup, admins manage pool users from the Space's **Admin** tab. The Space +stores membership in `config/pool.json` inside the private dataset repo, so +adding friends does not require CLI access, repo permissions, org membership, or +a Space restart. `ALLOWED_USERS` and `POOL_ADMINS` remain bootstrap/recovery +variables for first setup and break-glass access. ## Join a pool (each friend) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 2537a0d..d96729f 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -112,7 +112,7 @@ Target friend onboarding: **install extension, one OAuth authorize, done.** **"Sign in with Hugging Face"** (one click if already logged into HF). Everything beyond the sign-in page is enforced in-app by the allowlist; the dataset repo stays private. -4. `/connect` verifies the username against the `ALLOWED_USERS` allowlist, +4. `/connect` verifies the username against the pool membership config, mints a **pool token**, and renders it in the page DOM. 5. A content script (matching the Space origin only) picks the token up automatically and stores it in `chrome.storage.local`. Popup flips to @@ -163,7 +163,7 @@ Endpoints: | Route | Auth | Purpose | | -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- | | `GET /` + static | session | serves built explorer | -| `GET /oauth/login` → `/oauth/callback` | — | HF OIDC code flow, sets session cookie, enforces `ALLOWED_USERS` | +| `GET /oauth/login` → `/oauth/callback` | — | HF OIDC code flow, sets session cookie, enforces pool membership | | `GET /connect` | session | mints + renders pool token for extension pickup | | `POST /api/ingest` | pool token | validate (zod), stamp, dedup, persist | | `GET /api/tweets` | session | filters: `contributors`, `author`, `q` (FTS), `since`/`until`, `has_media`, `is_article`, `dedup=true`, cursor pagination | @@ -179,8 +179,11 @@ buffering; ephemeral disk must never hold unpersisted data). Commits use through a single writer queue. Config via Space secrets/variables: `HF_TOKEN` (fine-grained, read/write on -the one dataset repo), `POOL_SIGNING_SECRET`, `SESSION_SECRET`, `ALLOWED_USERS` -(comma-separated HF usernames), `DATASET_REPO`. +the one dataset repo), `POOL_SIGNING_SECRET`, `SESSION_SECRET`, +`ALLOWED_USERS` (initial comma-separated HF usernames), `POOL_ADMINS` +(bootstrap admins), `DATASET_REPO`. After bootstrap, durable pool membership +lives in `config/pool.json` in the private dataset repo and is managed from the +Space Admin tab. README metadata: `sdk: docker`, `hf_oauth: true`, scopes `openid profile`. diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index 86cf28c..59e2492 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -1,12 +1,17 @@ import { useEffect, useState } from "react"; +import { AdminPanel } from "./components/AdminPanel.js"; import { FiltersPanel } from "./components/Filters.js"; import { Feed } from "./components/Feed.js"; import type { ContributorStats, Filters } from "./lib/api.js"; import { defaultFilters, fetchContributors, fetchMe } from "./lib/api.js"; type AuthState = - { status: "checking" } | { status: "signed-out" } | { status: "signed-in"; username: string }; + | { status: "checking" } + | { status: "signed-out" } + | { status: "signed-in"; username: string; isAdmin: boolean }; + +type View = "feed" | "admin"; function SignIn(): React.JSX.Element { return ( @@ -28,9 +33,17 @@ function SignIn(): React.JSX.Element { /** Root explorer app: auth gate, filter rail and tweet feed. */ export function App(): React.JSX.Element { const [auth, setAuth] = useState({ status: "checking" }); + const [view, setView] = useState("feed"); const [filters, setFilters] = useState(defaultFilters); const [contributors, setContributors] = useState([]); const [now] = useState(() => new Date()); + const tabClass = (active: boolean): string => + [ + "rounded-md border border-(--x-border) px-3 py-1.5 text-sm font-semibold", + active ? "bg-(--x-soft-active)" : "", + ] + .filter(Boolean) + .join(" "); useEffect(() => { void (async (): Promise => { @@ -39,7 +52,7 @@ export function App(): React.JSX.Element { setAuth( me === undefined ? { status: "signed-out" } - : { status: "signed-in", username: me.username }, + : { status: "signed-in", username: me.username, isAdmin: me.isAdmin }, ); } catch { setAuth({ status: "signed-out" }); @@ -66,10 +79,36 @@ export function App(): React.JSX.Element {

xtap-pool

signed in as @{auth.username}

- + + {view === "feed" ? ( + + ) : null}
- + {view === "admin" && auth.isAdmin ? : }
); diff --git a/explorer/src/components/AdminPanel.tsx b/explorer/src/components/AdminPanel.tsx new file mode 100644 index 0000000..4930bf7 --- /dev/null +++ b/explorer/src/components/AdminPanel.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; + +import type { PoolSnapshot } from "../lib/api.js"; +import { + addPoolAdmin, + addPoolMember, + fetchAdminPool, + removePoolAdmin, + removePoolMember, +} from "../lib/api.js"; + +type AdminState = + | { status: "loading" } + | { status: "ready"; pool: PoolSnapshot; busy?: string; error?: string } + | { status: "error"; error: string }; + +function sortUsers(users: readonly string[]): string[] { + return [...users].sort((a, b) => a.localeCompare(b)); +} + +export function AdminPanel(): React.JSX.Element { + const [state, setState] = useState({ status: "loading" }); + const [memberInput, setMemberInput] = useState(""); + const [adminInput, setAdminInput] = useState(""); + + useEffect(() => { + void fetchAdminPool().then( + ({ pool }) => { + setState({ status: "ready", pool }); + }, + (error: unknown) => { + setState({ status: "error", error: message(error) }); + }, + ); + }, []); + + async function mutate(label: string, action: () => Promise): Promise { + if (state.status !== "ready") return; + setState({ status: "ready", pool: state.pool, busy: label }); + try { + const pool = await action(); + setState({ status: "ready", pool }); + } catch (error) { + setState({ status: "ready", pool: state.pool, error: message(error) }); + } + } + + if (state.status === "loading") { + return

Loading…

; + } + if (state.status === "error") { + return

{state.error}

; + } + + const { pool, busy, error } = state; + const admins = new Set(pool.admins); + const bootstrapAdmins = new Set(pool.bootstrap_admins); + + return ( +
+
+

Pool Admin

+

+ {pool.members.length.toLocaleString()} members · {pool.admins.length.toLocaleString()}{" "} + admins +

+
+ + {pool.config_error === undefined ? null : ( +

+ {pool.config_error} +

+ )} + {error === undefined ? null :

{error}

} + +
{ + event.preventDefault(); + const username = memberInput.trim(); + if (username === "") return; + setMemberInput(""); + void mutate(`member:${username}`, () => addPoolMember(username)); + }} + > + { + setMemberInput(event.target.value); + }} + /> + +
+ +
+

Members

+
    + {sortUsers(pool.members).map((member) => ( +
  • + @{member} +
    + {!admins.has(member) ? ( + + ) : null} + {!admins.has(member) ? ( + + ) : null} +
    +
  • + ))} +
+
+ +
{ + event.preventDefault(); + const username = adminInput.trim(); + if (username === "") return; + setAdminInput(""); + void mutate(`admin:${username}`, () => addPoolAdmin(username)); + }} + > + { + setAdminInput(event.target.value); + }} + /> + +
+ +
+

Admins

+
    + {sortUsers(pool.admins).map((admin) => ( +
  • + @{admin} + {bootstrapAdmins.has(admin) ? ( + Bootstrap + ) : ( + + )} +
  • + ))} +
+
+
+ ); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : "request failed"; +} diff --git a/explorer/src/lib/api.ts b/explorer/src/lib/api.ts index 3a68c40..428fedb 100644 --- a/explorer/src/lib/api.ts +++ b/explorer/src/lib/api.ts @@ -16,6 +16,27 @@ export type ContributorStats = { lastPooledAt: string; }; +export type Me = { + username: string; + isAdmin: boolean; +}; + +export type PoolSnapshot = { + version: 1; + admins: readonly string[]; + members: readonly string[]; + bootstrap_admins: readonly string[]; + updated_at: string; + updated_by?: string; + source: "dataset" | "bootstrap"; + config_error?: string; +}; + +export type AdminPoolResponse = { + pool: PoolSnapshot; + viewer: { username: string }; +}; + export type Filters = { contributors: readonly string[]; q: string; @@ -71,9 +92,19 @@ async function getJson(path: string): Promise { return (await response.json()) as T; } +async function sendJson(path: string, method: "PUT" | "DELETE"): Promise { + const response = await fetch(path, { + method, + headers: { accept: "application/json" }, + }); + if (!response.ok) throw new Error(`request failed: ${String(response.status)} ${path}`); + return (await response.json()) as T; +} + /** Current viewer, or undefined when not signed in. */ -export async function fetchMe(): Promise<{ username: string } | undefined> { - return getJson<{ username: string }>("/api/me"); +export async function fetchMe(): Promise { + const me = await getJson & { username: string }>("/api/me"); + return me === undefined ? undefined : { username: me.username, isAdmin: me.isAdmin === true }; } export async function fetchTweets(filters: Filters, cursor?: string): Promise { @@ -87,3 +118,45 @@ export async function fetchContributors(): Promise { if (body === undefined) throw new Error("session expired"); return body.contributors; } + +export async function fetchAdminPool(): Promise { + const body = await getJson("/api/admin/pool"); + if (body === undefined) throw new Error("session expired"); + return body; +} + +export async function addPoolMember(username: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/members/${encodeURIComponent(username)}`, + "PUT", + ) + ).pool; +} + +export async function removePoolMember(username: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/members/${encodeURIComponent(username)}`, + "DELETE", + ) + ).pool; +} + +export async function addPoolAdmin(username: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/admins/${encodeURIComponent(username)}`, + "PUT", + ) + ).pool; +} + +export async function removePoolAdmin(username: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/admins/${encodeURIComponent(username)}`, + "DELETE", + ) + ).pool; +} diff --git a/explorer/tests/App.test.tsx b/explorer/tests/App.test.tsx index 9aee5a0..1812ac7 100644 --- a/explorer/tests/App.test.tsx +++ b/explorer/tests/App.test.tsx @@ -37,6 +37,20 @@ function stubApi(responses: Record Response>): ReturnType { it("shows the sign-in screen when unauthenticated", async () => { stubApi({ "/api/me": () => new Response("no", { status: 401 }) }); @@ -47,7 +61,7 @@ describe("App", () => { it("renders the feed and filters when signed in", async () => { stubApi({ - "/api/me": () => Response.json({ username: "osolmaz" }), + "/api/me": () => Response.json({ username: "osolmaz", isAdmin: false }), "/api/contributors": () => Response.json({ contributors: [ @@ -68,7 +82,7 @@ describe("App", () => { it("loads the next page via the load-more button", async () => { let call = 0; stubApi({ - "/api/me": () => Response.json({ username: "osolmaz" }), + "/api/me": () => Response.json({ username: "osolmaz", isAdmin: false }), "/api/contributors": () => Response.json({ contributors: [] }), "/api/tweets": () => { call += 1; @@ -94,11 +108,38 @@ describe("App", () => { it("surfaces feed errors", async () => { stubApi({ - "/api/me": () => Response.json({ username: "osolmaz" }), + "/api/me": () => Response.json({ username: "osolmaz", isAdmin: false }), "/api/contributors": () => Response.json({ contributors: [] }), "/api/tweets": () => new Response("boom", { status: 500 }), }); render(); await screen.findByText(/request failed: 500/); }); + + it("lets admins add pool members", async () => { + const routes: Record Response> = { + "/api/me": () => Response.json({ username: "osolmaz", isAdmin: true }), + "/api/contributors": () => Response.json({ contributors: [] }), + "/api/tweets": () => Response.json({ records: [] }), + "/api/admin/pool": () => poolResponse(["osolmaz"]), + "/api/admin/members/alice": (init) => + init?.method === "PUT" + ? poolResponse(["alice", "osolmaz"]) + : new Response("missing", { status: 404 }), + }; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const path = url.split("?")[0] ?? url; + return Promise.resolve(routes[path]?.(init) ?? new Response("missing", { status: 404 })); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + fireEvent.click(await screen.findByText("Admin")); + fireEvent.change(await screen.findByLabelText("Member username"), { + target: { value: "alice" }, + }); + fireEvent.click(screen.getByText("Add member")); + await screen.findByText("@alice"); + }); }); diff --git a/explorer/tests/api.test.ts b/explorer/tests/api.test.ts index 18d8913..13202b1 100644 --- a/explorer/tests/api.test.ts +++ b/explorer/tests/api.test.ts @@ -2,9 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultFilters, + addPoolAdmin, + addPoolMember, fetchContributors, + fetchAdminPool, fetchMe, fetchTweets, + removePoolAdmin, + removePoolMember, tweetsQueryString, } from "../src/lib/api.js"; @@ -44,8 +49,11 @@ describe("tweetsQueryString", () => { describe("api client", () => { it("fetchMe returns the user or undefined on 401", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ username: "osolmaz" }))); - await expect(fetchMe()).resolves.toEqual({ username: "osolmaz" }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ username: "osolmaz", isAdmin: true })), + ); + await expect(fetchMe()).resolves.toEqual({ username: "osolmaz", isAdmin: true }); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("no", { status: 401 }))); await expect(fetchMe()).resolves.toBeUndefined(); @@ -73,4 +81,31 @@ describe("api client", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("no", { status: 401 }))); await expect(fetchContributors()).rejects.toThrow("session expired"); }); + + it("manages pool membership through admin endpoints", async () => { + const pool = { + version: 1, + admins: ["osolmaz"], + members: ["osolmaz"], + bootstrap_admins: ["osolmaz"], + updated_at: "2026-07-06T00:00:00.000Z", + source: "dataset", + }; + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ pool, viewer: { username: "osolmaz" } })), + ); + vi.stubGlobal("fetch", fetchMock); + await expect(fetchAdminPool()).resolves.toEqual({ pool, viewer: { username: "osolmaz" } }); + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => Promise.resolve(Response.json({ pool }))), + ); + await expect(addPoolMember("alice")).resolves.toEqual(pool); + await expect(removePoolMember("alice")).resolves.toEqual(pool); + await expect(addPoolAdmin("alice")).resolves.toEqual(pool); + await expect(removePoolAdmin("alice")).resolves.toEqual(pool); + }); }); diff --git a/setup/src/config.ts b/setup/src/config.ts index 296a584..19dde8b 100644 --- a/setup/src/config.ts +++ b/setup/src/config.ts @@ -3,6 +3,7 @@ export type SetupConfig = { spaceRepo: string; datasetRepo: string; allowedUsers: readonly string[]; + poolAdmins: readonly string[]; }; const REPO_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/; @@ -14,6 +15,7 @@ export function defaultSetupConfig(username: string): SetupConfig { spaceRepo: `${username}/xtap-pool`, datasetRepo: `${username}/xtap-pool-data`, allowedUsers: [username], + poolAdmins: [username], }; } diff --git a/setup/src/deploy.ts b/setup/src/deploy.ts index f7880d1..9a89082 100644 --- a/setup/src/deploy.ts +++ b/setup/src/deploy.ts @@ -39,6 +39,7 @@ export async function configureSpace(client: HubClient, config: SetupConfig): Pr "ALLOWED_USERS", usersValue(config.allowedUsers), ); + await setSpaceVariable(client, config.spaceRepo, "POOL_ADMINS", usersValue(config.poolAdmins)); if (!variables.has("SECRETS_INITIALIZED")) { await setSpaceSecret(client, config.spaceRepo, "POOL_SIGNING_SECRET", randomSecret()); await setSpaceSecret(client, config.spaceRepo, "SESSION_SECRET", randomSecret()); diff --git a/setup/src/wizard.ts b/setup/src/wizard.ts index 7514ca5..108f040 100644 --- a/setup/src/wizard.ts +++ b/setup/src/wizard.ts @@ -74,11 +74,13 @@ async function promptConfig(username: string): Promise { usersValue(defaults.allowedUsers), validateUserList, ); + const admins = await promptText("Pool admins", usersValue(defaults.poolAdmins), validateUserList); return { namespace, spaceRepo, datasetRepo, allowedUsers: normalizeUsers(allowed), + poolAdmins: normalizeUsers(admins), }; } @@ -88,6 +90,7 @@ async function confirmPlan(config: SetupConfig): Promise { `Space: ${config.spaceRepo}`, `Dataset: ${config.datasetRepo}`, `Allowed users: ${usersValue(config.allowedUsers)}`, + `Pool admins: ${usersValue(config.poolAdmins)}`, ].join("\n"), "Plan", ); diff --git a/setup/tests/config.test.ts b/setup/tests/config.test.ts index b8ef559..b7d6916 100644 --- a/setup/tests/config.test.ts +++ b/setup/tests/config.test.ts @@ -19,6 +19,7 @@ describe("setup config helpers", () => { spaceRepo: "alice/xtap-pool", datasetRepo: "alice/xtap-pool-data", allowedUsers: ["alice"], + poolAdmins: ["alice"], }); }); diff --git a/space/hf-space-README.md b/space/hf-space-README.md index 64c9a1d..e19e3eb 100644 --- a/space/hf-space-README.md +++ b/space/hf-space-README.md @@ -21,5 +21,10 @@ Private tweet pool for a group of friends running the Required Space secrets: `HF_TOKEN` (fine-grained, read/write access to the dataset repo only), `POOL_SIGNING_SECRET`, `SESSION_SECRET`. -Required Space variables: `DATASET_REPO`, `ALLOWED_USERS` (comma-separated HF -usernames), `SPACE_HOST` (auto-injected by HF). +Required Space variables: `DATASET_REPO`, `ALLOWED_USERS` (initial +comma-separated HF usernames), `POOL_ADMINS` (bootstrap admins), `SPACE_HOST` +(auto-injected by HF). + +After setup, admins manage members in the Space Admin tab. Durable membership is +stored in the private dataset repo at `config/pool.json`; the Space variables +are kept as bootstrap and recovery inputs. diff --git a/space/src/app.ts b/space/src/app.ts index 68bc6a7..2c512f3 100644 --- a/space/src/app.ts +++ b/space/src/app.ts @@ -10,6 +10,7 @@ import { renderConnectPage } from "./connect-page.js"; import type { SpaceConfig } from "./config.js"; import { authorizeUrl, exchangeCodeForUsername } from "./oauth.js"; import type { IngestOutcome } from "./ingest.js"; +import type { PoolMembership } from "./membership.js"; import { mintPoolToken, verifyPoolToken } from "./pool-token.js"; import type { TweetStore, TweetQuery } from "./store.js"; @@ -21,6 +22,7 @@ const POOL_TOKEN_TTL_MS = 180 * 24 * 60 * 60 * 1000; export type AppDeps = { config: SpaceConfig; store: TweetStore; + membership: PoolMembership; ingest: (username: string, payload: unknown) => Promise; now?: () => Date; oauthFetch?: typeof fetch; @@ -63,23 +65,28 @@ function toTweetQuery(raw: z.infer): TweetQuery { } export function createApp(deps: AppDeps): Hono { - const { config, store } = deps; + const { config, store, membership } = deps; const now = deps.now ?? ((): Date => new Date()); - const isAllowed = (username: string): boolean => - config.allowedUsers.some((user) => user.toLowerCase() === username.toLowerCase()); const sessionUser = (c: Context): string | undefined => { const cookie = getCookie(c, SESSION_COOKIE); if (cookie === undefined) return undefined; const verified = verifyPoolToken(config.sessionSecret, cookie, now()); - return verified.ok && isAllowed(verified.username) ? verified.username : undefined; + return verified.ok && membership.isMember(verified.username) ? verified.username : undefined; }; const bearerUser = (c: Context): string | undefined => { const header = c.req.header("authorization"); if (header?.toLowerCase().startsWith("bearer ") !== true) return undefined; const verified = verifyPoolToken(config.poolSigningSecret, header.slice(7).trim(), now()); - return verified.ok && isAllowed(verified.username) ? verified.username : undefined; + return verified.ok && membership.isMember(verified.username) ? verified.username : undefined; + }; + + const adminUser = (c: Context): string | Response => { + const username = sessionUser(c); + if (username === undefined) return c.json({ error: "unauthenticated" }, 401); + if (!membership.isAdmin(username)) return c.json({ error: "admin access required" }, 403); + return username; }; const app = new Hono(); @@ -130,7 +137,7 @@ export function createApp(deps: AppDeps): Hono { }; const username = await exchangeCodeForUsername(settings, code); if (username === undefined) return c.text("oauth exchange failed", 401); - if (!isAllowed(username)) { + if (!membership.isMember(username)) { return c.text(`@${username} is not on this pool's allowlist`, 403); } const session = mintPoolToken( @@ -182,7 +189,7 @@ export function createApp(deps: AppDeps): Hono { app.get("/api/me", (c) => { const username = bearerUser(c) ?? sessionUser(c); if (username === undefined) return c.json({ error: "unauthenticated" }, 401); - return c.json({ username }); + return c.json({ username, isAdmin: membership.isAdmin(username) }); }); app.get("/api/tweets", (c) => { @@ -200,9 +207,59 @@ export function createApp(deps: AppDeps): Hono { return c.json({ contributors: store.contributors() }); }); + app.get("/api/admin/pool", (c) => { + const username = adminUser(c); + if (username instanceof Response) return username; + return c.json({ pool: membership.snapshot(), viewer: { username } }); + }); + + app.put("/api/admin/members/:username", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ pool: await membership.addMember(actor, c.req.param("username")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.delete("/api/admin/members/:username", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ pool: await membership.removeMember(actor, c.req.param("username")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.put("/api/admin/admins/:username", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ pool: await membership.addAdmin(actor, c.req.param("username")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.delete("/api/admin/admins/:username", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ pool: await membership.removeAdmin(actor, c.req.param("username")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + return app; } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "request failed"; +} + function splitStateCookie(cookie: string): [string, string] { const separator = cookie.indexOf("|"); if (separator === -1) return [cookie, "/"]; diff --git a/space/src/config.ts b/space/src/config.ts index e342e46..e41837e 100644 --- a/space/src/config.ts +++ b/space/src/config.ts @@ -8,6 +8,7 @@ const configSchema = z.object({ POOL_SIGNING_SECRET: z.string().min(32), SESSION_SECRET: z.string().min(32), ALLOWED_USERS: z.string().min(1), + POOL_ADMINS: z.string().default(""), OAUTH_CLIENT_ID: z.string().min(1), OAUTH_CLIENT_SECRET: z.string().min(1), OPENID_PROVIDER_URL: z.string().default("https://huggingface.co"), @@ -23,6 +24,7 @@ export type SpaceConfig = { poolSigningSecret: string; sessionSecret: string; allowedUsers: readonly string[]; + poolAdmins: readonly string[]; oauthClientId: string; oauthClientSecret: string; openidProviderUrl: string; @@ -35,6 +37,8 @@ export type SpaceConfig = { export function loadConfig(env: Record): SpaceConfig { const parsed = configSchema.parse(env); const host = parsed.SPACE_HOST.replace(/\/+$/, ""); + const allowedUsers = users(parsed.ALLOWED_USERS); + const poolAdmins = users(parsed.POOL_ADMINS); return { port: parsed.PORT, dataDir: parsed.DATA_DIR, @@ -42,9 +46,8 @@ export function loadConfig(env: Record): SpaceConfig hfToken: parsed.HF_TOKEN, poolSigningSecret: parsed.POOL_SIGNING_SECRET, sessionSecret: parsed.SESSION_SECRET, - allowedUsers: parsed.ALLOWED_USERS.split(",") - .map((user) => user.trim()) - .filter((user) => user.length > 0), + allowedUsers, + poolAdmins: poolAdmins.length > 0 ? poolAdmins : allowedUsers.slice(0, 1), oauthClientId: parsed.OAUTH_CLIENT_ID, oauthClientSecret: parsed.OAUTH_CLIENT_SECRET, openidProviderUrl: parsed.OPENID_PROVIDER_URL.replace(/\/+$/, ""), @@ -52,3 +55,10 @@ export function loadConfig(env: Record): SpaceConfig staticRoot: parsed.STATIC_ROOT, }; } + +function users(value: string): string[] { + return value + .split(",") + .map((user) => user.trim()) + .filter((user) => user.length > 0); +} diff --git a/space/src/dataset.ts b/space/src/dataset.ts index e8f13b7..286d27b 100644 --- a/space/src/dataset.ts +++ b/space/src/dataset.ts @@ -24,6 +24,15 @@ function isNotFound(error: unknown): boolean { ); } +function isMissingDatasetFile(error: unknown, path: string): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + isNotFound(error) || + message.includes(`missing: ${path}`) || + message.includes(`dataset file not found: ${path}`) + ); +} + export function createHubClient(datasetRepo: string, accessToken: string): HubClient { const repo = { type: "dataset", name: datasetRepo } as const; return { @@ -101,6 +110,24 @@ export class DatasetMirror { return { files: paths.length, tweets }; } + /** Read a dataset file through the Hub, returning undefined when it is absent. */ + async readText(path: string): Promise { + try { + return await this.hub.downloadFile(path); + } catch (error) { + if (isMissingDatasetFile(error, path)) return undefined; + throw error; + } + } + + /** Commit one metadata file and update the local mirror after the commit succeeds. */ + async writeTextAndCommit(path: string, content: string, title: string): Promise { + await this.hub.commitFiles([{ path, content }], title); + const local = this.localPath(path); + mkdirSync(dirname(local), { recursive: true }); + writeFileSync(local, content); + } + /** * Append accepted tweets to their contributors' daily files and commit the * result to the Hub. The mirror is only updated after the commit succeeds. diff --git a/space/src/membership.ts b/space/src/membership.ts new file mode 100644 index 0000000..e90499e --- /dev/null +++ b/space/src/membership.ts @@ -0,0 +1,225 @@ +import { z } from "zod"; + +import type { DatasetMirror } from "./dataset.js"; + +export const POOL_CONFIG_PATH = "config/pool.json"; + +const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +const poolConfigSchema = z.object({ + version: z.literal(1), + admins: z.array(z.string()).default([]), + members: z.array(z.string()).default([]), + updated_at: z.string(), + updated_by: z.string().optional(), +}); + +export type PoolConfig = z.infer; + +export type PoolSnapshot = PoolConfig & { + bootstrap_admins: readonly string[]; + source: "dataset" | "bootstrap"; + config_error?: string; +}; + +type PoolMembershipOptions = { + mirror: DatasetMirror; + bootstrapMembers: readonly string[]; + bootstrapAdmins: readonly string[]; + now: () => Date; +}; + +export class PoolMembership { + private config: PoolConfig; + private readonly bootstrapAdmins: readonly string[]; + private source: PoolSnapshot["source"]; + private configError: string | undefined; + private mutationTail: Promise = Promise.resolve(); + + private constructor( + private readonly options: PoolMembershipOptions, + config: PoolConfig, + source: PoolSnapshot["source"], + configError?: string, + ) { + this.bootstrapAdmins = normalizeUsers( + options.bootstrapAdmins.length > 0 + ? options.bootstrapAdmins + : options.bootstrapMembers.slice(0, 1), + ); + this.config = normalizeConfig(config); + this.source = source; + this.configError = configError; + } + + static async load(options: PoolMembershipOptions): Promise { + const fallback = bootstrapConfig(options); + const raw = await options.mirror.readText(POOL_CONFIG_PATH); + if (raw === undefined) return new PoolMembership(options, fallback, "bootstrap"); + try { + const parsed = poolConfigSchema.parse(JSON.parse(raw)); + return new PoolMembership(options, parsed, "dataset"); + } catch (error) { + const message = error instanceof Error ? error.message : "invalid pool config"; + return new PoolMembership(options, fallback, "bootstrap", message); + } + } + + isMember(username: string): boolean { + return this.memberSet().has(normalizeUsername(username)); + } + + isAdmin(username: string): boolean { + return this.adminSet().has(normalizeUsername(username)); + } + + snapshot(): PoolSnapshot { + const admins = [...this.adminSet()].sort(); + const members = [...new Set([...normalizeUsers(this.config.members), ...admins])].sort(); + const snapshot: PoolSnapshot = { + version: 1, + admins, + members, + updated_at: this.config.updated_at, + bootstrap_admins: this.bootstrapAdmins, + source: this.source, + }; + if (this.config.updated_by !== undefined) snapshot.updated_by = this.config.updated_by; + if (this.configError !== undefined) snapshot.config_error = this.configError; + return snapshot; + } + + async addMember(actor: string, username: string): Promise { + return this.enqueueMutation(async () => { + const user = normalizeUsername(username); + if (this.memberSet().has(user)) return this.snapshot(); + const nextConfig = { + ...this.config, + members: [...normalizeUsers(this.config.members), user].sort(), + }; + await this.commit(nextConfig, actor, `config: add pool member ${user}`); + return this.snapshot(); + }); + } + + async removeMember(actor: string, username: string): Promise { + return this.enqueueMutation(async () => { + const user = normalizeUsername(username); + if (this.adminSet().has(user)) + throw new Error(`@${user} is an admin; demote before removing`); + const nextMembers = normalizeUsers(this.config.members).filter((member) => member !== user); + if (nextMembers.length === this.config.members.length) return this.snapshot(); + await this.commit( + { ...this.config, members: nextMembers }, + actor, + `config: remove pool member ${user}`, + ); + return this.snapshot(); + }); + } + + async addAdmin(actor: string, username: string): Promise { + return this.enqueueMutation(async () => { + const user = normalizeUsername(username); + if (this.adminSet().has(user)) return this.snapshot(); + const nextConfig = { + ...this.config, + admins: [...normalizeUsers(this.config.admins), user].sort(), + members: [...new Set([...normalizeUsers(this.config.members), user])].sort(), + }; + await this.commit(nextConfig, actor, `config: add pool admin ${user}`); + return this.snapshot(); + }); + } + + async removeAdmin(actor: string, username: string): Promise { + return this.enqueueMutation(async () => { + const user = normalizeUsername(username); + if (this.bootstrapAdmins.includes(user)) { + throw new Error(`@${user} is a bootstrap admin; change POOL_ADMINS to demote`); + } + const nextAdmins = normalizeUsers(this.config.admins).filter((admin) => admin !== user); + if (nextAdmins.length === this.config.admins.length) return this.snapshot(); + if (new Set([...nextAdmins, ...this.bootstrapAdmins]).size === 0) { + throw new Error("pool must keep at least one admin"); + } + await this.commit( + { ...this.config, admins: nextAdmins }, + actor, + `config: remove pool admin ${user}`, + ); + return this.snapshot(); + }); + } + + private adminSet(): Set { + return new Set([...normalizeUsers(this.config.admins), ...this.bootstrapAdmins]); + } + + private memberSet(): Set { + return new Set([...normalizeUsers(this.config.members), ...this.adminSet()]); + } + + private async enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async commit(nextConfig: PoolConfig, actor: string, title: string): Promise { + const committedConfig = normalizeConfig({ + ...nextConfig, + updated_at: this.options.now().toISOString(), + updated_by: normalizeUsername(actor), + }); + await this.options.mirror.writeTextAndCommit( + POOL_CONFIG_PATH, + `${JSON.stringify(committedConfig, null, 2)}\n`, + title, + ); + this.config = committedConfig; + this.source = "dataset"; + this.configError = undefined; + } +} + +export function normalizeUsername(username: string): string { + const normalized = username.trim().toLowerCase(); + if (!USERNAME.test(normalized)) throw new Error(`invalid Hugging Face username: ${username}`); + return normalized; +} + +function normalizeUsers(users: readonly string[]): string[] { + return [...new Set(users.map(normalizeUsername))].sort(); +} + +function normalizeConfig(config: PoolConfig): PoolConfig { + const admins = normalizeUsers(config.admins); + return { + version: 1, + admins, + members: [...new Set([...normalizeUsers(config.members), ...admins])].sort(), + updated_at: config.updated_at, + ...(config.updated_by === undefined + ? {} + : { updated_by: normalizeUsername(config.updated_by) }), + }; +} + +function bootstrapConfig(options: PoolMembershipOptions): PoolConfig { + const members = normalizeUsers(options.bootstrapMembers); + const admins = normalizeUsers( + options.bootstrapAdmins.length > 0 + ? options.bootstrapAdmins + : options.bootstrapMembers.slice(0, 1), + ); + return { + version: 1, + admins, + members: [...new Set([...members, ...admins])].sort(), + updated_at: options.now().toISOString(), + }; +} diff --git a/space/src/server.ts b/space/src/server.ts index 421a935..1d1335e 100644 --- a/space/src/server.ts +++ b/space/src/server.ts @@ -7,6 +7,7 @@ import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { createHubClient, DatasetMirror } from "./dataset.js"; import { ingestBatch, Mutex } from "./ingest.js"; +import { PoolMembership } from "./membership.js"; import { TweetStore } from "./store.js"; const config = loadConfig(process.env); @@ -14,10 +15,17 @@ const store = new TweetStore(); const hub = createHubClient(config.datasetRepo, config.hfToken); const mirror = new DatasetMirror(hub, join(config.dataDir, "mirror")); const mutex = new Mutex(); +const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: config.allowedUsers, + bootstrapAdmins: config.poolAdmins, + now: () => new Date(), +}); const app = createApp({ config, store, + membership, ingest: (username, payload) => mutex.run(() => ingestBatch({ store, mirror, now: () => new Date() }, username, payload)), }); @@ -28,8 +36,10 @@ app.use("*", serveStatic({ root: config.staticRoot, path: "index.html" })); console.log(`[xtap-pool] rebuilding index from ${config.datasetRepo} ...`); const rebuilt = await mirror.rebuild(store); +const pool = membership.snapshot(); console.log( - `[xtap-pool] indexed ${String(rebuilt.tweets)} tweets from ${String(rebuilt.files)} files`, + `[xtap-pool] indexed ${String(rebuilt.tweets)} tweets from ${String(rebuilt.files)} files; ` + + `${String(pool.members.length)} pool members, ${String(pool.admins.length)} admins`, ); serve({ fetch: app.fetch, port: config.port }, (info) => { diff --git a/space/tests/app.test.ts b/space/tests/app.test.ts index 167109f..273ff39 100644 --- a/space/tests/app.test.ts +++ b/space/tests/app.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createApp } from "../src/app.js"; import { DatasetMirror } from "../src/dataset.js"; import { Mutex, ingestBatch } from "../src/ingest.js"; +import { PoolMembership } from "../src/membership.js"; import { mintPoolToken } from "../src/pool-token.js"; import { TweetStore } from "../src/store.js"; import { FakeHub, makeTweet, testConfig } from "./helpers.js"; @@ -19,6 +20,7 @@ let dir: string; let hub: FakeHub; let store: TweetStore; let app: Hono; +let membership: PoolMembership; function sessionCookie(username: string): string { return `xtap_pool_session=${mintPoolToken(testConfig.sessionSecret, username, FUTURE)}`; @@ -28,15 +30,22 @@ function bearer(username: string): string { return `Bearer ${mintPoolToken(testConfig.poolSigningSecret, username, FUTURE)}`; } -beforeEach(() => { +beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), "xtap-pool-app-")); hub = new FakeHub(); store = new TweetStore(); const mirror = new DatasetMirror(hub, dir); + membership = await PoolMembership.load({ + mirror, + bootstrapMembers: testConfig.allowedUsers, + bootstrapAdmins: testConfig.poolAdmins, + now: () => NOW, + }); const mutex = new Mutex(); app = createApp({ config: testConfig, store, + membership, now: () => NOW, ingest: (username, payload) => mutex.run(() => ingestBatch({ store, mirror, now: () => NOW }, username, payload)), @@ -127,11 +136,12 @@ describe("session-guarded reads", () => { await expect((await app.request("/api/me", { headers })).json()).resolves.toEqual({ username: "osolmaz", + isAdmin: true, }); const viaBearer = await app.request("/api/me", { headers: { authorization: bearer("alice") }, }); - await expect(viaBearer.json()).resolves.toEqual({ username: "alice" }); + await expect(viaBearer.json()).resolves.toEqual({ username: "alice", isAdmin: false }); }); it("rejects invalid query parameters", async () => { @@ -168,6 +178,7 @@ describe("oauth + connect flow", () => { const oauthApp = createApp({ config: testConfig, store, + membership, now: () => NOW, ingest: () => Promise.resolve({ ok: true, added: 0, duplicates: 0, rejected: [] }), oauthFetch, @@ -199,6 +210,7 @@ describe("oauth + connect flow", () => { const oauthApp = createApp({ config: testConfig, store, + membership, now: () => NOW, ingest: () => Promise.resolve({ ok: true, added: 0, duplicates: 0, rejected: [] }), oauthFetch, @@ -228,3 +240,71 @@ describe("oauth + connect flow", () => { expect(ingestResponse.status).toBe(200); }); }); + +describe("admin pool management", () => { + it("requires a signed-in admin", async () => { + expect((await app.request("/api/admin/pool")).status).toBe(401); + expect( + (await app.request("/api/admin/pool", { headers: { cookie: sessionCookie("alice") } })) + .status, + ).toBe(403); + }); + + it("adds and removes members without a Space restart", async () => { + const adminHeaders = { cookie: sessionCookie("osolmaz") }; + const added = await app.request("/api/admin/members/mallory", { + method: "PUT", + headers: adminHeaders, + }); + expect(added.status).toBe(200); + expect(hub.files.get("config/pool.json")).toContain("mallory"); + expect( + ( + await app.request("/api/ingest", { + method: "POST", + headers: { "content-type": "application/json", authorization: bearer("mallory") }, + body: JSON.stringify({ tweets: [makeTweet()] }), + }) + ).status, + ).toBe(200); + + const removed = await app.request("/api/admin/members/mallory", { + method: "DELETE", + headers: adminHeaders, + }); + expect(removed.status).toBe(200); + expect( + ( + await app.request("/api/ingest", { + method: "POST", + headers: { "content-type": "application/json", authorization: bearer("mallory") }, + body: JSON.stringify({ tweets: [makeTweet({ id: "2" })] }), + }) + ).status, + ).toBe(401); + }); + + it("promotes and demotes admins with lockout protection", async () => { + const adminHeaders = { cookie: sessionCookie("osolmaz") }; + const promoted = await app.request("/api/admin/admins/alice", { + method: "PUT", + headers: adminHeaders, + }); + expect(promoted.status).toBe(200); + await expect(promoted.json()).resolves.toMatchObject({ + pool: { admins: ["alice", "osolmaz"] }, + }); + + const demoted = await app.request("/api/admin/admins/alice", { + method: "DELETE", + headers: adminHeaders, + }); + expect(demoted.status).toBe(200); + + const bootstrapDemote = await app.request("/api/admin/admins/osolmaz", { + method: "DELETE", + headers: adminHeaders, + }); + expect(bootstrapDemote.status).toBe(400); + }); +}); diff --git a/space/tests/config.test.ts b/space/tests/config.test.ts index eb20084..1e157cd 100644 --- a/space/tests/config.test.ts +++ b/space/tests/config.test.ts @@ -8,6 +8,7 @@ const baseEnv = { POOL_SIGNING_SECRET: "pool-secret-0123456789abcdef0123456789abcdef", SESSION_SECRET: "session-secret-0123456789abcdef0123456789ab", ALLOWED_USERS: "osolmaz, alice ,bob,", + POOL_ADMINS: "osolmaz", OAUTH_CLIENT_ID: "cid", OAUTH_CLIENT_SECRET: "csecret", SPACE_HOST: "dutifuldev-xtap-pool.hf.space", @@ -18,10 +19,16 @@ describe("loadConfig", () => { const config = loadConfig(baseEnv); expect(config.port).toBe(7860); expect(config.allowedUsers).toEqual(["osolmaz", "alice", "bob"]); + expect(config.poolAdmins).toEqual(["osolmaz"]); expect(config.publicUrl).toBe("https://dutifuldev-xtap-pool.hf.space"); expect(config.openidProviderUrl).toBe("https://huggingface.co"); }); + it("defaults pool admins to the first allowed user", () => { + const config = loadConfig({ ...baseEnv, POOL_ADMINS: "" }); + expect(config.poolAdmins).toEqual(["osolmaz"]); + }); + it("keeps explicit scheme and strips trailing slashes", () => { const config = loadConfig({ ...baseEnv, diff --git a/space/tests/dataset.test.ts b/space/tests/dataset.test.ts index b2b6757..72d7ded 100644 --- a/space/tests/dataset.test.ts +++ b/space/tests/dataset.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { DatasetMirror, parseJsonlTweets } from "../src/dataset.js"; +import type { HubClient } from "../src/dataset.js"; import { TweetStore } from "../src/store.js"; import { FakeHub, makePooled, makeTweet } from "./helpers.js"; @@ -69,6 +70,19 @@ describe("DatasetMirror.rebuild", () => { }); }); +describe("DatasetMirror.readText", () => { + it("treats the Hub client's missing-file error as an absent metadata file", async () => { + const missingHub: HubClient = { + listDataFiles: () => Promise.resolve([]), + downloadFile: (path) => Promise.reject(new Error(`dataset file not found: ${path}`)), + commitFiles: () => Promise.resolve(), + }; + const freshMirror = new DatasetMirror(missingHub, dir); + + await expect(freshMirror.readText("config/pool.json")).resolves.toBeUndefined(); + }); +}); + describe("DatasetMirror.appendAndCommit", () => { it("appends to per-day files and commits before touching the mirror", async () => { const tweetA = makePooled({ id: "1", captured_at: "2026-05-21T10:00:00.000Z" }); diff --git a/space/tests/helpers.ts b/space/tests/helpers.ts index 6320941..efaa161 100644 --- a/space/tests/helpers.ts +++ b/space/tests/helpers.ts @@ -11,6 +11,7 @@ export const testConfig: SpaceConfig = { poolSigningSecret: "pool-secret-0123456789abcdef0123456789abcdef", sessionSecret: "session-secret-0123456789abcdef0123456789ab", allowedUsers: ["osolmaz", "alice"], + poolAdmins: ["osolmaz"], oauthClientId: "client-id", oauthClientSecret: "client-secret", openidProviderUrl: "https://huggingface.co", @@ -47,7 +48,9 @@ export class FakeHub implements HubClient { failNextCommit = false; listDataFiles(): Promise { - return Promise.resolve([...this.files.keys()]); + return Promise.resolve( + [...this.files.keys()].filter((path) => path.startsWith("data/") && path.endsWith(".jsonl")), + ); } downloadFile(path: string): Promise { diff --git a/space/tests/membership.test.ts b/space/tests/membership.test.ts new file mode 100644 index 0000000..c99d18a --- /dev/null +++ b/space/tests/membership.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DatasetMirror } from "../src/dataset.js"; +import { PoolMembership } from "../src/membership.js"; +import { TweetStore } from "../src/store.js"; +import { FakeHub } from "./helpers.js"; + +const NOW = new Date("2026-07-06T12:00:00.000Z"); + +let dir: string; +let hub: FakeHub; +let mirror: DatasetMirror; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "xtap-pool-membership-")); + hub = new FakeHub(); + mirror = new DatasetMirror(hub, dir); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("PoolMembership", () => { + it("bootstraps members and first-user admin when no config exists", async () => { + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz", "alice"], + bootstrapAdmins: [], + now: () => NOW, + }); + expect(membership.snapshot()).toMatchObject({ + members: ["alice", "osolmaz"], + admins: ["osolmaz"], + source: "bootstrap", + }); + expect(membership.isMember("alice")).toBe(true); + expect(membership.isAdmin("alice")).toBe(false); + }); + + it("loads dataset config without treating bootstrap members as permanent members", async () => { + hub.files.set( + "config/pool.json", + JSON.stringify({ + version: 1, + admins: ["carol"], + members: ["carol"], + updated_at: NOW.toISOString(), + }), + ); + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz", "alice"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + expect(membership.snapshot()).toMatchObject({ + members: ["carol", "osolmaz"], + admins: ["carol", "osolmaz"], + source: "dataset", + }); + expect(membership.isMember("alice")).toBe(false); + }); + + it("commits member changes to config/pool.json", async () => { + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + await membership.addMember("osolmaz", "Alice"); + const raw = hub.files.get("config/pool.json"); + expect(raw).toBeDefined(); + expect(JSON.parse(raw ?? "{}")).toMatchObject({ + members: ["alice", "osolmaz"], + updated_by: "osolmaz", + }); + expect(hub.commits[0]?.title).toBe("config: add pool member alice"); + }); + + it("leaves membership unchanged when a config commit fails", async () => { + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + await membership.addMember("osolmaz", "alice"); + + hub.failNextCommit = true; + await expect(membership.addMember("osolmaz", "bob")).rejects.toThrow("hub unavailable"); + expect(membership.isMember("bob")).toBe(false); + expect(membership.snapshot().members).toEqual(["alice", "osolmaz"]); + + hub.failNextCommit = true; + await expect(membership.removeMember("osolmaz", "alice")).rejects.toThrow("hub unavailable"); + expect(membership.isMember("alice")).toBe(true); + expect(membership.snapshot().members).toEqual(["alice", "osolmaz"]); + }); + + it("falls back to bootstrap admins when config is invalid", async () => { + hub.files.set("config/pool.json", "not json"); + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + expect(membership.snapshot().source).toBe("bootstrap"); + expect(membership.snapshot().config_error).toBeDefined(); + expect(membership.isAdmin("osolmaz")).toBe(true); + }); +}); + +describe("DatasetMirror metadata files", () => { + it("does not include config files in data rebuilds", async () => { + hub.files.set("config/pool.json", "{}"); + const store = new TweetStore(); + try { + await expect(mirror.rebuild(store)).resolves.toEqual({ files: 0, tweets: 0 }); + } finally { + store.close(); + } + }); +});