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
25 changes: 21 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
- run: npm run coverage
- run: npm run dry
- run: npm run mutate
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- name: Check shared
run: |
npm run format --workspace shared
Expand All @@ -34,7 +35,11 @@ jobs:
npm run test --workspace shared
npm run coverage --workspace shared
npm run dry --workspace shared
npm run mutate --workspace shared
if [ "$RUN_MUTATION_TESTS" = "true" ]; then
npm run mutate --workspace shared
fi
env:
RUN_MUTATION_TESTS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- name: Check space
run: |
npm run format --workspace space
Expand All @@ -43,7 +48,11 @@ jobs:
npm run test --workspace space
npm run coverage --workspace space
npm run dry --workspace space
npm run mutate --workspace space
if [ "$RUN_MUTATION_TESTS" = "true" ]; then
npm run mutate --workspace space
fi
env:
RUN_MUTATION_TESTS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- name: Check explorer
run: |
npm run format --workspace explorer
Expand All @@ -52,7 +61,11 @@ jobs:
npm run test --workspace explorer
npm run coverage --workspace explorer
npm run dry --workspace explorer
npm run mutate --workspace explorer
if [ "$RUN_MUTATION_TESTS" = "true" ]; then
npm run mutate --workspace explorer
fi
env:
RUN_MUTATION_TESTS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- name: Check setup
run: |
npm run format --workspace setup
Expand All @@ -61,7 +74,11 @@ jobs:
npm run test --workspace setup
npm run coverage --workspace setup
npm run dry --workspace setup
npm run mutate --workspace setup
if [ "$RUN_MUTATION_TESTS" = "true" ]; then
npm run mutate --workspace setup
fi
env:
RUN_MUTATION_TESTS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- run: npm run build --workspace explorer && npm run build --workspace space && npm run build --workspace setup
- name: Slophammer
uses: dutifuldev/slophammer@main
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,13 @@ scripts/deploy-space.sh <namespace>
scripts/seed-dataset.sh <namespace>/xtap-pool-data <hf-username> ~/Downloads/xtap
```

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
After setup, admins manage pool users and allowed Hugging Face organizations
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, or a Space restart. Individual users and
members of allowed organizations can connect through HF sign-in; org-based pool
tokens are shorter-lived so removed org members eventually lose access without
manual cleanup. `ALLOWED_USERS` and `POOL_ADMINS` remain bootstrap/recovery
variables for first setup and break-glass access.

## Join a pool (each friend)
Expand Down
23 changes: 13 additions & 10 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,24 +112,27 @@ 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 pool membership config,
mints a **pool token**, and renders it in the page DOM.
4. `/connect` verifies the HF identity against the pool membership config
(individual users plus allowed HF organizations), 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
"Connected as @user". **No copy-paste, no manual HF token creation.**

Pool token design: stateless signed token (HMAC-SHA256 with a Space secret;
payload = username + issued-at + expiry ~180 days). The Space verifies
signatures without any user database. Revocation = rotate the signing secret
(re-connect is one click) — acceptable at friend scale. A "paste token
payload = username + expiry + optional OAuth-proven organization IDs). The
Space verifies signatures against current pool membership. Explicit user tokens
last ~180 days; organization-derived tokens are shorter-lived so org removals
take effect without keeping HF access tokens. Revocation = remove the user/org
grant or rotate the signing secret (re-connect is one click). A "paste token
manually" field in extension options is the fallback for browsers where the
content-script handoff fails.

Explorer access: same private-Space HF login; session cookie (signed,
httpOnly) issued after OAuth. Friends need **zero** repo permissions —
the allowlist variable is the entire membership system. Optionally friends
can also be given read access on the dataset repo for `load_dataset`/DuckDB
power use; not required for any flow.
Explorer access: same HF login; session cookie (signed, httpOnly) issued after
OAuth. Friends need **zero** repo permissions — the app-level pool membership
config is the entire access system. Optionally friends can also be given read
access on the dataset repo for `load_dataset`/DuckDB power use; not required for
any flow.

## Components

Expand Down
64 changes: 63 additions & 1 deletion explorer/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import type { PoolSnapshot } from "../lib/api.js";
import {
addPoolAdmin,
addPoolMember,
addPoolMemberOrg,
fetchAdminPool,
removePoolAdmin,
removePoolMember,
removePoolMemberOrg,
} from "../lib/api.js";
import type { MemberOrgGrant } from "../lib/api.js";

type AdminState =
| { status: "loading" }
Expand All @@ -18,10 +21,15 @@ function sortUsers(users: readonly string[]): string[] {
return [...users].sort((a, b) => a.localeCompare(b));
}

function sortMemberOrgs(orgs: readonly MemberOrgGrant[]): MemberOrgGrant[] {
return [...orgs].sort((a, b) => a.name.localeCompare(b.name));
}

export function AdminPanel(): React.JSX.Element {
const [state, setState] = useState<AdminState>({ status: "loading" });
const [memberInput, setMemberInput] = useState("");
const [adminInput, setAdminInput] = useState("");
const [orgInput, setOrgInput] = useState("");

useEffect(() => {
void fetchAdminPool().then(
Expand Down Expand Up @@ -62,7 +70,7 @@ export function AdminPanel(): React.JSX.Element {
<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
admins · {pool.member_orgs.length.toLocaleString()} orgs
</p>
</header>

Expand Down Expand Up @@ -138,6 +146,60 @@ export function AdminPanel(): React.JSX.Element {
</ul>
</section>

<form
className="flex flex-wrap gap-2"
onSubmit={(event) => {
event.preventDefault();
const orgName = orgInput.trim();
if (orgName === "") return;
setOrgInput("");
void mutate(`member-org:${orgName}`, () => addPoolMemberOrg(orgName));
}}
>
<input
aria-label="Member organization"
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 organization"
value={orgInput}
onChange={(event) => {
setOrgInput(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 org
</button>
</form>

<section>
<h3 className="mb-2 font-bold">Member Organizations</h3>
<ul className="divide-y divide-(--x-border) border-y border-(--x-border)">
{sortMemberOrgs(pool.member_orgs).map((org) => (
<li key={org.sub} className="flex items-center justify-between gap-3 py-2">
<span>
@{org.name}
{org.display_name === undefined ? null : (
<span className="ml-2 text-sm text-(--x-muted)">{org.display_name}</span>
)}
</span>
<button
type="button"
className="rounded-md border border-(--x-border) px-2 py-1 text-sm"
disabled={busy !== undefined}
onClick={() => {
void mutate(`member-org:${org.name}`, () => removePoolMemberOrg(org.name));
}}
>
Remove
</button>
</li>
))}
</ul>
</section>

<form
className="flex flex-wrap gap-2"
onSubmit={(event) => {
Expand Down
25 changes: 25 additions & 0 deletions explorer/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ export type Me = {
isAdmin: boolean;
};

export type MemberOrgGrant = {
name: string;
sub: string;
display_name?: string;
};

export type PoolSnapshot = {
version: 1;
admins: readonly string[];
members: readonly string[];
member_orgs: readonly MemberOrgGrant[];
bootstrap_admins: readonly string[];
updated_at: string;
updated_by?: string;
Expand Down Expand Up @@ -160,3 +167,21 @@ export async function removePoolAdmin(username: string): Promise<PoolSnapshot> {
)
).pool;
}

export async function addPoolMemberOrg(orgName: string): Promise<PoolSnapshot> {
return (
await sendJson<{ pool: PoolSnapshot }>(
`/api/admin/member-orgs/${encodeURIComponent(orgName)}`,
"PUT",
)
).pool;
}

export async function removePoolMemberOrg(orgName: string): Promise<PoolSnapshot> {
return (
await sendJson<{ pool: PoolSnapshot }>(
`/api/admin/member-orgs/${encodeURIComponent(orgName)}`,
"DELETE",
)
).pool;
}
39 changes: 39 additions & 0 deletions explorer/tests/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function poolResponse(members: readonly string[]): Response {
version: 1,
admins: ["osolmaz"],
members,
member_orgs: [],
bootstrap_admins: ["osolmaz"],
updated_at: "2026-07-06T00:00:00.000Z",
source: "dataset",
Expand Down Expand Up @@ -142,4 +143,42 @@ describe("App", () => {
fireEvent.click(screen.getByText("Add member"));
await screen.findByText("@alice");
});

it("lets admins add member organizations", async () => {
const routes: Record<string, (init?: RequestInit) => 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/member-orgs/huggingface": (init) =>
init?.method === "PUT"
? Response.json({
pool: {
version: 1,
admins: ["osolmaz"],
members: ["osolmaz"],
member_orgs: [{ name: "huggingface", sub: "org-hf", display_name: "Hugging Face" }],
bootstrap_admins: ["osolmaz"],
updated_at: "2026-07-06T00:00:00.000Z",
source: "dataset",
},
viewer: { username: "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(<App />);
fireEvent.click(await screen.findByText("Admin"));
fireEvent.change(await screen.findByLabelText("Member organization"), {
target: { value: "huggingface" },
});
fireEvent.click(screen.getByText("Add org"));
await screen.findByText("@huggingface");
});
});
5 changes: 5 additions & 0 deletions explorer/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
fetchTweets,
removePoolAdmin,
removePoolMember,
addPoolMemberOrg,
removePoolMemberOrg,
tweetsQueryString,
} from "../src/lib/api.js";

Expand Down Expand Up @@ -87,6 +89,7 @@ describe("api client", () => {
version: 1,
admins: ["osolmaz"],
members: ["osolmaz"],
member_orgs: [{ name: "huggingface", sub: "org-hf", display_name: "Hugging Face" }],
bootstrap_admins: ["osolmaz"],
updated_at: "2026-07-06T00:00:00.000Z",
source: "dataset",
Expand All @@ -107,5 +110,7 @@ describe("api client", () => {
await expect(removePoolMember("alice")).resolves.toEqual(pool);
await expect(addPoolAdmin("alice")).resolves.toEqual(pool);
await expect(removePoolAdmin("alice")).resolves.toEqual(pool);
await expect(addPoolMemberOrg("huggingface")).resolves.toEqual(pool);
await expect(removePoolMemberOrg("huggingface")).resolves.toEqual(pool);
});
});
7 changes: 4 additions & 3 deletions space/hf-space-README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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.
After setup, admins manage individual members and allowed member organizations
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.
Loading
Loading