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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ scripts/deploy-space.sh <namespace>
scripts/seed-dataset.sh <namespace>/xtap-pool-data <hf-username> ~/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)

Expand Down
11 changes: 7 additions & 4 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand All @@ -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`.

Expand Down
47 changes: 43 additions & 4 deletions explorer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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<AuthState>({ status: "checking" });
const [view, setView] = useState<View>("feed");
const [filters, setFilters] = useState<Filters>(defaultFilters);
const [contributors, setContributors] = useState<readonly ContributorStats[]>([]);
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<void> => {
Expand All @@ -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" });
Expand All @@ -66,10 +79,36 @@ export function App(): React.JSX.Element {
<h1 className="text-xl font-bold">xtap-pool</h1>
<p className="text-sm text-(--x-muted)">signed in as @{auth.username}</p>
</header>
<FiltersPanel filters={filters} contributors={contributors} onChange={setFilters} />
<nav className="mb-4 flex gap-2">
<button
type="button"
aria-pressed={view === "feed"}
className={tabClass(view === "feed")}
onClick={() => {
setView("feed");
}}
>
Feed
</button>
{auth.isAdmin ? (
<button
type="button"
aria-pressed={view === "admin"}
className={tabClass(view === "admin")}
onClick={() => {
setView("admin");
}}
>
Admin
</button>
) : null}
</nav>
{view === "feed" ? (
<FiltersPanel filters={filters} contributors={contributors} onChange={setFilters} />
) : null}
</aside>
<main className="border-x border-(--x-border)">
<Feed filters={filters} now={now} />
{view === "admin" && auth.isAdmin ? <AdminPanel /> : <Feed filters={filters} now={now} />}
</main>
</div>
);
Expand Down
199 changes: 199 additions & 0 deletions explorer/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<AdminState>({ 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<PoolSnapshot>): Promise<void> {
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 <p className="p-4 text-sm text-(--x-muted)">Loading…</p>;
}
if (state.status === "error") {
return <p className="p-4 text-sm text-red-500">{state.error}</p>;
}

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

return (
<div className="flex flex-col gap-6 p-4">
<header className="border-b border-(--x-border) pb-4">
<h2 className="text-lg font-bold">Pool Admin</h2>
<p className="text-sm text-(--x-muted)">
{pool.members.length.toLocaleString()} members · {pool.admins.length.toLocaleString()}{" "}
admins
</p>
</header>

{pool.config_error === undefined ? null : (
<p className="rounded-md border border-red-400 px-3 py-2 text-sm text-red-500">
{pool.config_error}
</p>
)}
{error === undefined ? null : <p className="text-sm text-red-500">{error}</p>}

<form
className="flex flex-wrap gap-2"
onSubmit={(event) => {
event.preventDefault();
const username = memberInput.trim();
if (username === "") return;
setMemberInput("");
void mutate(`member:${username}`, () => addPoolMember(username));
}}
>
<input
aria-label="Member username"
className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm outline-none focus:border-(--x-accent)"
placeholder="HF username"
value={memberInput}
onChange={(event) => {
setMemberInput(event.target.value);
}}
/>
<button
type="submit"
className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white"
disabled={busy !== undefined}
>
Add member
</button>
</form>

<section>
<h3 className="mb-2 font-bold">Members</h3>
<ul className="divide-y divide-(--x-border) border-y border-(--x-border)">
{sortUsers(pool.members).map((member) => (
<li key={member} className="flex items-center justify-between gap-3 py-2">
<span>@{member}</span>
<div className="flex gap-2">
{!admins.has(member) ? (
<button
type="button"
className="rounded-md border border-(--x-border) px-2 py-1 text-sm"
disabled={busy !== undefined}
onClick={() => {
void mutate(`admin:${member}`, () => addPoolAdmin(member));
}}
>
Promote
</button>
) : null}
{!admins.has(member) ? (
<button
type="button"
className="rounded-md border border-(--x-border) px-2 py-1 text-sm"
disabled={busy !== undefined}
onClick={() => {
void mutate(`member:${member}`, () => removePoolMember(member));
}}
>
Remove
</button>
) : null}
</div>
</li>
))}
</ul>
</section>

<form
className="flex flex-wrap gap-2"
onSubmit={(event) => {
event.preventDefault();
const username = adminInput.trim();
if (username === "") return;
setAdminInput("");
void mutate(`admin:${username}`, () => addPoolAdmin(username));
}}
>
<input
aria-label="Admin username"
className="min-w-0 flex-1 rounded-md border border-(--x-border) bg-(--x-soft) px-3 py-2 text-sm outline-none focus:border-(--x-accent)"
placeholder="HF username"
value={adminInput}
onChange={(event) => {
setAdminInput(event.target.value);
}}
/>
<button
type="submit"
className="rounded-md bg-(--x-accent) px-3 py-2 text-sm font-semibold text-white"
disabled={busy !== undefined}
>
Add admin
</button>
</form>

<section>
<h3 className="mb-2 font-bold">Admins</h3>
<ul className="divide-y divide-(--x-border) border-y border-(--x-border)">
{sortUsers(pool.admins).map((admin) => (
<li key={admin} className="flex items-center justify-between gap-3 py-2">
<span>@{admin}</span>
{bootstrapAdmins.has(admin) ? (
<span className="text-sm text-(--x-muted)">Bootstrap</span>
) : (
<button
type="button"
className="rounded-md border border-(--x-border) px-2 py-1 text-sm"
disabled={busy !== undefined}
onClick={() => {
void mutate(`admin:${admin}`, () => removePoolAdmin(admin));
}}
>
Demote
</button>
)}
</li>
))}
</ul>
</section>
</div>
);
}

function message(error: unknown): string {
return error instanceof Error ? error.message : "request failed";
}
Loading
Loading