From 3ea2b079b41795a23bcf2bd1c561cbc72ee42a60 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:45:20 +0200 Subject: [PATCH 1/3] feat: add Hugging Face org pool access --- README.md | 11 ++- docs/implementation-plan.md | 23 +++-- explorer/src/components/AdminPanel.tsx | 64 ++++++++++++- explorer/src/lib/api.ts | 25 +++++ explorer/tests/App.test.tsx | 39 ++++++++ explorer/tests/api.test.ts | 5 + space/hf-space-README.md | 7 +- space/src/app.ts | 115 ++++++++++++++++------- space/src/hf-orgs.ts | 38 ++++++++ space/src/membership.ts | 121 +++++++++++++++++++++++++ space/src/oauth.ts | 73 +++++++++++++-- space/src/pool-token.ts | 75 +++++++++++++-- space/tests/app.test.ts | 115 ++++++++++++++++++++++- space/tests/hf-orgs.test.ts | 30 ++++++ space/tests/membership.test.ts | 53 +++++++++++ space/tests/oauth.test.ts | 32 ++++++- space/tests/pool-token.test.ts | 21 ++++- 17 files changed, 779 insertions(+), 68 deletions(-) create mode 100644 space/src/hf-orgs.ts create mode 100644 space/tests/hf-orgs.test.ts diff --git a/README.md b/README.md index fbe3945..61d8b87 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,13 @@ scripts/deploy-space.sh scripts/seed-dataset.sh /xtap-pool-data ~/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) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d96729f..30c71b1 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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 diff --git a/explorer/src/components/AdminPanel.tsx b/explorer/src/components/AdminPanel.tsx index 4930bf7..c364a16 100644 --- a/explorer/src/components/AdminPanel.tsx +++ b/explorer/src/components/AdminPanel.tsx @@ -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" } @@ -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({ status: "loading" }); const [memberInput, setMemberInput] = useState(""); const [adminInput, setAdminInput] = useState(""); + const [orgInput, setOrgInput] = useState(""); useEffect(() => { void fetchAdminPool().then( @@ -62,7 +70,7 @@ export function AdminPanel(): React.JSX.Element {

Pool Admin

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

@@ -138,6 +146,60 @@ export function AdminPanel(): React.JSX.Element { +
{ + event.preventDefault(); + const orgName = orgInput.trim(); + if (orgName === "") return; + setOrgInput(""); + void mutate(`member-org:${orgName}`, () => addPoolMemberOrg(orgName)); + }} + > + { + setOrgInput(event.target.value); + }} + /> + +
+ +
+

Member Organizations

+
    + {sortMemberOrgs(pool.member_orgs).map((org) => ( +
  • + + @{org.name} + {org.display_name === undefined ? null : ( + {org.display_name} + )} + + +
  • + ))} +
+
+
{ diff --git a/explorer/src/lib/api.ts b/explorer/src/lib/api.ts index 428fedb..57274a8 100644 --- a/explorer/src/lib/api.ts +++ b/explorer/src/lib/api.ts @@ -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; @@ -160,3 +167,21 @@ export async function removePoolAdmin(username: string): Promise { ) ).pool; } + +export async function addPoolMemberOrg(orgName: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/member-orgs/${encodeURIComponent(orgName)}`, + "PUT", + ) + ).pool; +} + +export async function removePoolMemberOrg(orgName: string): Promise { + return ( + await sendJson<{ pool: PoolSnapshot }>( + `/api/admin/member-orgs/${encodeURIComponent(orgName)}`, + "DELETE", + ) + ).pool; +} diff --git a/explorer/tests/App.test.tsx b/explorer/tests/App.test.tsx index 1812ac7..55d7945 100644 --- a/explorer/tests/App.test.tsx +++ b/explorer/tests/App.test.tsx @@ -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", @@ -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 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(); + 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"); + }); }); diff --git a/explorer/tests/api.test.ts b/explorer/tests/api.test.ts index 13202b1..5a1a322 100644 --- a/explorer/tests/api.test.ts +++ b/explorer/tests/api.test.ts @@ -10,6 +10,8 @@ import { fetchTweets, removePoolAdmin, removePoolMember, + addPoolMemberOrg, + removePoolMemberOrg, tweetsQueryString, } from "../src/lib/api.js"; @@ -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", @@ -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); }); }); diff --git a/space/hf-space-README.md b/space/hf-space-README.md index e19e3eb..86d421f 100644 --- a/space/hf-space-README.md +++ b/space/hf-space-README.md @@ -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. diff --git a/space/src/app.ts b/space/src/app.ts index 2c512f3..0dfd530 100644 --- a/space/src/app.ts +++ b/space/src/app.ts @@ -8,9 +8,11 @@ import { z } from "zod"; import { renderConnectPage } from "./connect-page.js"; import type { SpaceConfig } from "./config.js"; -import { authorizeUrl, exchangeCodeForUsername } from "./oauth.js"; +import { createHuggingFaceOrgResolver } from "./hf-orgs.js"; +import type { OrgResolver } from "./hf-orgs.js"; import type { IngestOutcome } from "./ingest.js"; -import type { PoolMembership } from "./membership.js"; +import type { PoolAccessGrant, PoolIdentity, PoolMembership } from "./membership.js"; +import { authorizeUrl, exchangeCodeForIdentity } from "./oauth.js"; import { mintPoolToken, verifyPoolToken } from "./pool-token.js"; import type { TweetStore, TweetQuery } from "./store.js"; @@ -18,6 +20,7 @@ const SESSION_COOKIE = "xtap_pool_session"; const OAUTH_STATE_COOKIE = "xtap_pool_oauth"; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; const POOL_TOKEN_TTL_MS = 180 * 24 * 60 * 60 * 1000; +const ORG_GRANT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; export type AppDeps = { config: SpaceConfig; @@ -26,6 +29,11 @@ export type AppDeps = { ingest: (username: string, payload: unknown) => Promise; now?: () => Date; oauthFetch?: typeof fetch; + resolveOrg?: OrgResolver; +}; + +type AuthorizedIdentity = PoolIdentity & { + grant: PoolAccessGrant; }; const tweetsQuerySchema = z.object({ @@ -67,26 +75,39 @@ function toTweetQuery(raw: z.infer): TweetQuery { export function createApp(deps: AppDeps): Hono { const { config, store, membership } = deps; const now = deps.now ?? ((): Date => new Date()); + const resolveOrg = + deps.resolveOrg ?? createHuggingFaceOrgResolver(config.openidProviderUrl, deps.oauthFetch); + + const authorizeIdentity = (identity: PoolIdentity): AuthorizedIdentity | undefined => { + const grant = membership.accessFor(identity); + if (grant === undefined) return undefined; + return { + username: identity.username, + grant, + ...(identity.orgs === undefined ? {} : { orgs: identity.orgs }), + }; + }; - const sessionUser = (c: Context): string | undefined => { + const sessionIdentity = (c: Context): AuthorizedIdentity | undefined => { const cookie = getCookie(c, SESSION_COOKIE); if (cookie === undefined) return undefined; const verified = verifyPoolToken(config.sessionSecret, cookie, now()); - return verified.ok && membership.isMember(verified.username) ? verified.username : undefined; + return verified.ok ? authorizeIdentity(verified) : undefined; }; - const bearerUser = (c: Context): string | undefined => { + const bearerIdentity = (c: Context): AuthorizedIdentity | 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 && membership.isMember(verified.username) ? verified.username : undefined; + return verified.ok ? authorizeIdentity(verified) : 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 identity = sessionIdentity(c); + if (identity === undefined) return c.json({ error: "unauthenticated" }, 401); + if (!membership.isAdmin(identity.username)) + return c.json({ error: "admin access required" }, 403); + return identity.username; }; const app = new Hono(); @@ -113,6 +134,7 @@ export function createApp(deps: AppDeps): Hono { redirectUri: `${config.publicUrl}/oauth/callback`, }, state, + { orgIds: membership.memberOrgIds() }, ), ); }); @@ -135,49 +157,52 @@ export function createApp(deps: AppDeps): Hono { redirectUri: `${config.publicUrl}/oauth/callback`, ...(deps.oauthFetch === undefined ? {} : { fetchFn: deps.oauthFetch }), }; - const username = await exchangeCodeForUsername(settings, code); - if (username === undefined) return c.text("oauth exchange failed", 401); - if (!membership.isMember(username)) { - return c.text(`@${username} is not on this pool's allowlist`, 403); + const identity = await exchangeCodeForIdentity(settings, code); + if (identity === undefined) return c.text("oauth exchange failed", 401); + const authorized = authorizeIdentity(identity); + if (authorized === undefined) { + return c.text(`@${identity.username} is not on this pool's allowlist`, 403); } + const sessionTtl = tokenTtlForGrant(authorized.grant, SESSION_TTL_MS); const session = mintPoolToken( config.sessionSecret, - username, - new Date(now().getTime() + SESSION_TTL_MS), + authorized, + new Date(now().getTime() + sessionTtl), ); setCookie(c, SESSION_COOKIE, session, { httpOnly: true, secure: true, sameSite: "Lax", path: "/", - maxAge: SESSION_TTL_MS / 1000, + maxAge: sessionTtl / 1000, }); return c.redirect(next); }); app.get("/connect", (c) => { - const username = sessionUser(c); - if (username === undefined) return c.redirect("/oauth/login?next=/connect"); + const identity = sessionIdentity(c); + if (identity === undefined) return c.redirect("/oauth/login?next=/connect"); + const poolTokenTtl = tokenTtlForGrant(identity.grant, POOL_TOKEN_TTL_MS); const token = mintPoolToken( config.poolSigningSecret, - username, - new Date(now().getTime() + POOL_TOKEN_TTL_MS), + identity, + new Date(now().getTime() + poolTokenTtl), ); - return c.html(renderConnectPage(username, token)); + return c.html(renderConnectPage(identity.username, token)); }); app.use("/api/*", cors({ origin: "*", allowHeaders: ["authorization", "content-type"] })); app.post("/api/ingest", async (c) => { - const username = bearerUser(c); - if (username === undefined) return c.json({ error: "invalid or missing pool token" }, 401); + const identity = bearerIdentity(c); + if (identity === undefined) return c.json({ error: "invalid or missing pool token" }, 401); let payload: unknown; try { payload = await c.req.json(); } catch { return c.json({ error: "body must be JSON" }, 400); } - const outcome = await deps.ingest(username, payload); + const outcome = await deps.ingest(identity.username, payload); if (!outcome.ok) return c.json({ error: outcome.error }, outcome.status); return c.json({ added: outcome.added, @@ -187,14 +212,14 @@ 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, isAdmin: membership.isAdmin(username) }); + const identity = bearerIdentity(c) ?? sessionIdentity(c); + if (identity === undefined) return c.json({ error: "unauthenticated" }, 401); + return c.json({ username: identity.username, isAdmin: membership.isAdmin(identity.username) }); }); app.get("/api/tweets", (c) => { - const username = sessionUser(c); - if (username === undefined) return c.json({ error: "unauthenticated" }, 401); + const identity = sessionIdentity(c); + if (identity === undefined) return c.json({ error: "unauthenticated" }, 401); const parsed = tweetsQuerySchema.safeParse(c.req.query()); if (!parsed.success) return c.json({ error: "invalid query parameters" }, 400); const page = store.query(toTweetQuery(parsed.data)); @@ -202,8 +227,8 @@ export function createApp(deps: AppDeps): Hono { }); app.get("/api/contributors", (c) => { - const username = sessionUser(c); - if (username === undefined) return c.json({ error: "unauthenticated" }, 401); + const identity = sessionIdentity(c); + if (identity === undefined) return c.json({ error: "unauthenticated" }, 401); return c.json({ contributors: store.contributors() }); }); @@ -253,9 +278,35 @@ export function createApp(deps: AppDeps): Hono { } }); + app.put("/api/admin/member-orgs/:org", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ + pool: await membership.addMemberOrg(actor, await resolveOrg(c.req.param("org"))), + }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + + app.delete("/api/admin/member-orgs/:org", async (c) => { + const actor = adminUser(c); + if (actor instanceof Response) return actor; + try { + return c.json({ pool: await membership.removeMemberOrg(actor, c.req.param("org")) }); + } catch (error) { + return c.json({ error: errorMessage(error) }, 400); + } + }); + return app; } +function tokenTtlForGrant(grant: PoolAccessGrant, fallback: number): number { + return grant.type === "member_org" ? ORG_GRANT_TOKEN_TTL_MS : fallback; +} + function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "request failed"; } diff --git a/space/src/hf-orgs.ts b/space/src/hf-orgs.ts new file mode 100644 index 0000000..0082b35 --- /dev/null +++ b/space/src/hf-orgs.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { normalizeOrgName } from "./membership.js"; +import type { MemberOrgGrant } from "./membership.js"; + +const orgOverviewSchema = z.looseObject({ + _id: z.string().min(1), + name: z.string().min(1), + fullname: z.string().min(1).optional(), +}); + +export type OrgResolver = (orgName: string) => Promise; + +export function createHuggingFaceOrgResolver( + providerUrl: string, + fetchFn: typeof fetch = fetch, +): OrgResolver { + return async (orgName) => { + const requestedName = normalizeOrgName(orgName); + const response = await fetchFn( + `${providerUrl}/api/organizations/${encodeURIComponent(requestedName)}/overview`, + { headers: { accept: "application/json" } }, + ); + if (!response.ok) { + throw new Error(`Hugging Face organization not found: ${requestedName}`); + } + const parsed = orgOverviewSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new Error(`Hugging Face organization response was invalid: ${requestedName}`); + } + const resolvedName = normalizeOrgName(parsed.data.name); + return { + name: resolvedName, + sub: parsed.data._id, + ...(parsed.data.fullname === undefined ? {} : { display_name: parsed.data.fullname }), + }; + }; +} diff --git a/space/src/membership.ts b/space/src/membership.ts index e90499e..bc50043 100644 --- a/space/src/membership.ts +++ b/space/src/membership.ts @@ -6,15 +6,36 @@ export const POOL_CONFIG_PATH = "config/pool.json"; const USERNAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const memberOrgSchema = z.object({ + name: z.string(), + sub: z.string().min(1), + display_name: z.string().min(1).optional(), +}); + const poolConfigSchema = z.object({ version: z.literal(1), admins: z.array(z.string()).default([]), members: z.array(z.string()).default([]), + member_orgs: z.array(memberOrgSchema).default([]), updated_at: z.string(), updated_by: z.string().optional(), }); export type PoolConfig = z.infer; +export type MemberOrgGrant = z.infer; + +export type PoolIdentityOrg = { + sub: string; + name?: string; +}; + +export type PoolIdentity = { + username: string; + orgs?: readonly PoolIdentityOrg[]; +}; + +export type PoolAccessGrant = + { type: "admin" } | { type: "member" } | { type: "member_org"; org: MemberOrgGrant }; export type PoolSnapshot = PoolConfig & { bootstrap_admins: readonly string[]; @@ -69,10 +90,24 @@ export class PoolMembership { return this.memberSet().has(normalizeUsername(username)); } + accessFor(identity: PoolIdentity): PoolAccessGrant | undefined { + const user = normalizeUsername(identity.username); + if (this.adminSet().has(user)) return { type: "admin" }; + if (normalizeUsers(this.config.members).includes(user)) return { type: "member" }; + + const matchingOrg = this.memberOrgFor(identity.orgs ?? []); + if (matchingOrg !== undefined) return { type: "member_org", org: matchingOrg }; + return undefined; + } + isAdmin(username: string): boolean { return this.adminSet().has(normalizeUsername(username)); } + memberOrgIds(): string[] { + return normalizeMemberOrgs(this.config.member_orgs).map((org) => org.sub); + } + snapshot(): PoolSnapshot { const admins = [...this.adminSet()].sort(); const members = [...new Set([...normalizeUsers(this.config.members), ...admins])].sort(); @@ -80,6 +115,7 @@ export class PoolMembership { version: 1, admins, members, + member_orgs: normalizeMemberOrgs(this.config.member_orgs), updated_at: this.config.updated_at, bootstrap_admins: this.bootstrapAdmins, source: this.source, @@ -152,6 +188,37 @@ export class PoolMembership { }); } + async addMemberOrg(actor: string, org: MemberOrgGrant): Promise { + return this.enqueueMutation(async () => { + const grant = normalizeMemberOrg(org); + if (this.memberOrgSet().has(grant.sub)) return this.snapshot(); + const nextConfig = { + ...this.config, + member_orgs: [...normalizeMemberOrgs(this.config.member_orgs), grant].sort((a, b) => + a.name.localeCompare(b.name), + ), + }; + await this.commit(nextConfig, actor, `config: add member org ${grant.name}`); + return this.snapshot(); + }); + } + + async removeMemberOrg(actor: string, orgName: string): Promise { + return this.enqueueMutation(async () => { + const name = normalizeOrgName(orgName); + const nextOrgs = normalizeMemberOrgs(this.config.member_orgs).filter( + (org) => org.name !== name, + ); + if (nextOrgs.length === this.config.member_orgs.length) return this.snapshot(); + await this.commit( + { ...this.config, member_orgs: nextOrgs }, + actor, + `config: remove member org ${name}`, + ); + return this.snapshot(); + }); + } + private adminSet(): Set { return new Set([...normalizeUsers(this.config.admins), ...this.bootstrapAdmins]); } @@ -160,6 +227,21 @@ export class PoolMembership { return new Set([...normalizeUsers(this.config.members), ...this.adminSet()]); } + private memberOrgSet(): Set { + return new Set(normalizeMemberOrgs(this.config.member_orgs).map((org) => org.sub)); + } + + private memberOrgFor(orgs: readonly PoolIdentityOrg[]): MemberOrgGrant | undefined { + const grantedBySub = new Map( + normalizeMemberOrgs(this.config.member_orgs).map((org) => [org.sub, org]), + ); + for (const org of normalizeIdentityOrgs(orgs)) { + const grant = grantedBySub.get(org.sub); + if (grant !== undefined) return grant; + } + return undefined; + } + private async enqueueMutation(operation: () => Promise): Promise { const result = this.mutationTail.then(operation); this.mutationTail = result.then( @@ -192,16 +274,54 @@ export function normalizeUsername(username: string): string { return normalized; } +export function normalizeOrgName(name: string): string { + const normalized = name.trim().toLowerCase(); + if (!USERNAME.test(normalized)) throw new Error(`invalid Hugging Face organization: ${name}`); + return normalized; +} + function normalizeUsers(users: readonly string[]): string[] { return [...new Set(users.map(normalizeUsername))].sort(); } +function normalizeMemberOrg(org: MemberOrgGrant): MemberOrgGrant { + const normalized: MemberOrgGrant = { + name: normalizeOrgName(org.name), + sub: org.sub.trim(), + }; + if (normalized.sub.length === 0) throw new Error("organization sub must not be empty"); + const displayName = org.display_name?.trim(); + if (displayName !== undefined && displayName.length > 0) normalized.display_name = displayName; + return normalized; +} + +function normalizeMemberOrgs(orgs: readonly MemberOrgGrant[]): MemberOrgGrant[] { + const bySub = new Map(); + for (const org of orgs.map(normalizeMemberOrg)) { + bySub.set(org.sub, org); + } + return [...bySub.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function normalizeIdentityOrgs(orgs: readonly PoolIdentityOrg[]): PoolIdentityOrg[] { + const bySub = new Map(); + for (const org of orgs) { + const sub = org.sub.trim(); + if (sub.length === 0) continue; + const normalized: PoolIdentityOrg = { sub }; + if (org.name !== undefined) normalized.name = normalizeOrgName(org.name); + bySub.set(sub, normalized); + } + return [...bySub.values()]; +} + function normalizeConfig(config: PoolConfig): PoolConfig { const admins = normalizeUsers(config.admins); return { version: 1, admins, members: [...new Set([...normalizeUsers(config.members), ...admins])].sort(), + member_orgs: normalizeMemberOrgs(config.member_orgs), updated_at: config.updated_at, ...(config.updated_by === undefined ? {} @@ -220,6 +340,7 @@ function bootstrapConfig(options: PoolMembershipOptions): PoolConfig { version: 1, admins, members: [...new Set([...members, ...admins])].sort(), + member_orgs: [], updated_at: options.now().toISOString(), }; } diff --git a/space/src/oauth.ts b/space/src/oauth.ts index 61a3c9c..52eedb2 100644 --- a/space/src/oauth.ts +++ b/space/src/oauth.ts @@ -8,28 +8,58 @@ export type OAuthSettings = { fetchFn?: typeof fetch; }; +export type OAuthOrg = { + sub: string; + name?: string; +}; + +export type OAuthIdentity = { + username: string; + orgs: readonly OAuthOrg[]; +}; + +export type AuthorizeOptions = { + orgIds?: readonly string[]; +}; + const tokenResponseSchema = z.looseObject({ access_token: z.string().min(1) }); -const userInfoSchema = z.looseObject({ preferred_username: z.string().min(1) }); +const orgInfoSchema = z.looseObject({ + sub: z.string().min(1), + name: z.string().optional(), + preferred_username: z.string().optional(), +}); +const userInfoSchema = z.looseObject({ + preferred_username: z.string().min(1), + orgs: z.array(orgInfoSchema).optional(), + organizations: z.array(orgInfoSchema).optional(), +}); /** Hugging Face OIDC authorize URL for the code flow. */ -export function authorizeUrl(settings: OAuthSettings, state: string): string { +export function authorizeUrl( + settings: OAuthSettings, + state: string, + options: AuthorizeOptions = {}, +): string { const url = new URL(`${settings.providerUrl}/oauth/authorize`); url.searchParams.set("client_id", settings.clientId); url.searchParams.set("redirect_uri", settings.redirectUri); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", "openid profile"); url.searchParams.set("state", state); + for (const orgId of [...new Set(options.orgIds ?? [])].sort()) { + url.searchParams.append("orgIds", orgId); + } return url.toString(); } /** - * Exchange an authorization code for the HF username it belongs to. + * Exchange an authorization code for the HF identity it belongs to. * Returns undefined when the provider rejects the exchange. */ -export async function exchangeCodeForUsername( +export async function exchangeCodeForIdentity( settings: OAuthSettings, code: string, -): Promise { +): Promise { const fetchFn = settings.fetchFn ?? fetch; // Hugging Face's token endpoint authenticates the client with HTTP Basic // (client_secret_basic), not a client_secret form field. @@ -57,5 +87,36 @@ export async function exchangeCodeForUsername( if (!userResponse.ok) return undefined; const userBody = userInfoSchema.safeParse(await userResponse.json()); if (!userBody.success) return undefined; - return userBody.data.preferred_username; + return { + username: userBody.data.preferred_username, + orgs: normalizeOAuthOrgs([ + ...(userBody.data.orgs ?? []), + ...(userBody.data.organizations ?? []), + ]), + }; +} + +/** + * Exchange an authorization code for the HF username it belongs to. + * Returns undefined when the provider rejects the exchange. + */ +export async function exchangeCodeForUsername( + settings: OAuthSettings, + code: string, +): Promise { + const identity = await exchangeCodeForIdentity(settings, code); + return identity?.username; +} + +function normalizeOAuthOrgs(orgs: readonly z.infer[]): OAuthOrg[] { + const bySub = new Map(); + for (const org of orgs) { + const normalized: OAuthOrg = { sub: org.sub }; + const name = org.preferred_username ?? org.name; + if (name !== undefined && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) { + normalized.name = name.toLowerCase(); + } + bySub.set(normalized.sub, normalized); + } + return [...bySub.values()]; } diff --git a/space/src/pool-token.ts b/space/src/pool-token.ts index a602fd3..b0bbad8 100644 --- a/space/src/pool-token.ts +++ b/space/src/pool-token.ts @@ -5,10 +5,17 @@ const PREFIX = "xp1"; export type TokenPayload = { username: string; expiresAt: number; + orgs?: readonly TokenOrg[]; +}; + +export type TokenOrg = { + sub: string; + name?: string; }; export type TokenVerification = - { ok: true; username: string } | { ok: false; reason: "malformed" | "bad-signature" | "expired" }; + | { ok: true; username: string; orgs: readonly TokenOrg[] } + | { ok: false; reason: "malformed" | "bad-signature" | "expired" }; function b64url(data: Buffer | string): string { return Buffer.from(data).toString("base64url"); @@ -20,14 +27,40 @@ function sign(secret: string, payload: string): string { /** * Mint a stateless pool token: `xp1..` where the payload - * is base64url JSON `{username, expiresAt}` and the signature is + * is base64url JSON `{username, expiresAt, orgs?}` and the signature is * HMAC-SHA256 over `xp1.` with the pool signing secret. */ -export function mintPoolToken(secret: string, username: string, expiresAt: Date): string { - const payload = b64url(JSON.stringify({ username, expiresAt: expiresAt.getTime() })); +export function mintPoolToken( + secret: string, + subject: string | { username: string; orgs?: readonly TokenOrg[] }, + expiresAt: Date, +): string { + const identity = typeof subject === "string" ? { username: subject } : subject; + const payload = b64url( + JSON.stringify({ + username: identity.username, + expiresAt: expiresAt.getTime(), + ...(identity.orgs === undefined || identity.orgs.length === 0 + ? {} + : { orgs: normalizeTokenOrgs(identity.orgs) }), + }), + ); return `${PREFIX}.${payload}.${sign(secret, `${PREFIX}.${payload}`)}`; } +function normalizeTokenOrgs(orgs: readonly TokenOrg[]): TokenOrg[] { + const bySub = new Map(); + for (const org of orgs) { + const sub = org.sub.trim(); + if (sub.length === 0) continue; + const normalized: TokenOrg = { sub }; + const name = org.name?.trim(); + if (name !== undefined && name.length > 0) normalized.name = name; + bySub.set(sub, normalized); + } + return [...bySub.values()]; +} + function decodePayload(payload: string): TokenPayload | undefined { let parsed: unknown; try { @@ -41,7 +74,37 @@ function decodePayload(payload: string): TokenPayload | undefined { return undefined; } if (typeof candidate["expiresAt"] !== "number") return undefined; - return { username: candidate["username"], expiresAt: candidate["expiresAt"] }; + const orgs = decodeOrgs(candidate["orgs"]); + if (orgs === undefined) return undefined; + return { username: candidate["username"], expiresAt: candidate["expiresAt"], orgs }; +} + +function decodeOrgs(raw: unknown): readonly TokenOrg[] | undefined { + if (raw === undefined) return []; + if (!Array.isArray(raw)) return undefined; + const orgs: TokenOrg[] = []; + for (const item of raw) { + const org = decodeOrg(item); + if (org === undefined) return undefined; + orgs.push(org); + } + return normalizeTokenOrgs(orgs); +} + +function decodeOrg(raw: unknown): TokenOrg | undefined { + if (typeof raw !== "object" || raw === null) return undefined; + const candidate = raw as Record; + if (typeof candidate["sub"] !== "string" || candidate["sub"].length === 0) return undefined; + const org: TokenOrg = { sub: candidate["sub"] }; + const name = decodeOptionalName(candidate["name"]); + if (name === undefined && candidate["name"] !== undefined) return undefined; + if (name !== undefined) org.name = name; + return org; +} + +function decodeOptionalName(raw: unknown): string | undefined { + if (raw === undefined) return undefined; + return typeof raw === "string" && raw.length > 0 ? raw : undefined; } function splitToken(token: string): [string, string, string] | undefined { @@ -65,5 +128,5 @@ export function verifyPoolToken(secret: string, token: string, now: Date): Token const decoded = decodePayload(payload); if (decoded === undefined) return { ok: false, reason: "malformed" }; if (decoded.expiresAt <= now.getTime()) return { ok: false, reason: "expired" }; - return { ok: true, username: decoded.username }; + return { ok: true, username: decoded.username, orgs: decoded.orgs ?? [] }; } diff --git a/space/tests/app.test.ts b/space/tests/app.test.ts index 273ff39..8ac7f7f 100644 --- a/space/tests/app.test.ts +++ b/space/tests/app.test.ts @@ -22,12 +22,19 @@ let store: TweetStore; let app: Hono; let membership: PoolMembership; -function sessionCookie(username: string): string { - return `xtap_pool_session=${mintPoolToken(testConfig.sessionSecret, username, FUTURE)}`; +function sessionCookie( + username: string, + orgs: readonly { sub: string; name?: string }[] = [], +): string { + return `xtap_pool_session=${mintPoolToken(testConfig.sessionSecret, { username, orgs }, FUTURE)}`; } -function bearer(username: string): string { - return `Bearer ${mintPoolToken(testConfig.poolSigningSecret, username, FUTURE)}`; +function bearer(username: string, orgs: readonly { sub: string; name?: string }[] = []): string { + return `Bearer ${mintPoolToken(testConfig.poolSigningSecret, { username, orgs }, FUTURE)}`; +} + +function sessionCookieFrom(setCookie: string | null): string { + return /xtap_pool_session=[^;,]+/.exec(setCookie ?? "")?.[0] ?? ""; } beforeEach(async () => { @@ -221,6 +228,61 @@ describe("oauth + connect flow", () => { expect(response.status).toBe(403); }); + it("callback accepts member organization users and mints usable pool tokens", async () => { + await membership.addMemberOrg("osolmaz", { + name: "huggingface", + sub: "org-hf", + display_name: "Hugging Face", + }); + const oauthFetch: typeof fetch = (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/oauth/token")) + return Promise.resolve(Response.json({ access_token: "t" })); + return Promise.resolve( + Response.json({ + preferred_username: "dana", + orgs: [{ sub: "org-hf", preferred_username: "huggingface" }], + }), + ); + }; + const oauthApp = createApp({ + config: testConfig, + store, + membership, + now: () => NOW, + ingest: (username, payload) => + new Mutex().run(() => + ingestBatch( + { store, mirror: new DatasetMirror(hub, dir), now: () => NOW }, + username, + payload, + ), + ), + oauthFetch, + }); + + const success = await oauthApp.request("/oauth/callback?code=c&state=s1", { + headers: { cookie: "xtap_pool_oauth=s1|/connect" }, + }); + expect(success.status).toBe(302); + const cookie = sessionCookieFrom(success.headers.get("set-cookie")); + expect(cookie).toContain("xtap_pool_session="); + const connect = await oauthApp.request("/connect", { headers: { cookie } }); + const html = await connect.text(); + const match = /data-token="([^"]+)"/.exec(html); + expect(match).not.toBeNull(); + + const ingestResponse = await oauthApp.request("/api/ingest", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${match?.[1] ?? ""}`, + }, + body: JSON.stringify({ tweets: [makeTweet()] }), + }); + expect(ingestResponse.status).toBe(200); + }); + it("renders the connect page with a working pool token for a session", async () => { const response = await app.request("/connect", { headers: { cookie: sessionCookie("osolmaz") }, @@ -307,4 +369,49 @@ describe("admin pool management", () => { }); expect(bootstrapDemote.status).toBe(400); }); + + it("adds and removes member organizations", async () => { + const orgApp = createApp({ + config: testConfig, + store, + membership, + now: () => NOW, + ingest: () => Promise.resolve({ ok: true, added: 0, duplicates: 0, rejected: [] }), + resolveOrg: (orgName) => + Promise.resolve({ + name: orgName.toLowerCase(), + sub: "org-hf", + display_name: "Hugging Face", + }), + }); + const adminHeaders = { cookie: sessionCookie("osolmaz") }; + const added = await orgApp.request("/api/admin/member-orgs/huggingface", { + method: "PUT", + headers: adminHeaders, + }); + expect(added.status).toBe(200); + await expect(added.json()).resolves.toMatchObject({ + pool: { member_orgs: [{ name: "huggingface", sub: "org-hf" }] }, + }); + expect( + ( + await orgApp.request("/api/me", { + headers: { authorization: bearer("dana", [{ sub: "org-hf", name: "huggingface" }]) }, + }) + ).status, + ).toBe(200); + + const removed = await orgApp.request("/api/admin/member-orgs/huggingface", { + method: "DELETE", + headers: adminHeaders, + }); + expect(removed.status).toBe(200); + expect( + ( + await orgApp.request("/api/me", { + headers: { authorization: bearer("dana", [{ sub: "org-hf", name: "huggingface" }]) }, + }) + ).status, + ).toBe(401); + }); }); diff --git a/space/tests/hf-orgs.test.ts b/space/tests/hf-orgs.test.ts new file mode 100644 index 0000000..f9c0100 --- /dev/null +++ b/space/tests/hf-orgs.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { createHuggingFaceOrgResolver } from "../src/hf-orgs.js"; + +describe("createHuggingFaceOrgResolver", () => { + it("resolves a Hugging Face org slug to a stable member org grant", async () => { + const fetchFn: typeof fetch = (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + expect(url).toBe("https://huggingface.co/api/organizations/huggingface/overview"); + return Promise.resolve( + Response.json({ _id: "org-hf", name: "HuggingFace", fullname: "Hugging Face" }), + ); + }; + + await expect( + createHuggingFaceOrgResolver("https://huggingface.co", fetchFn)("HuggingFace"), + ).resolves.toEqual({ + name: "huggingface", + sub: "org-hf", + display_name: "Hugging Face", + }); + }); + + it("rejects missing organizations", async () => { + const fetchFn: typeof fetch = () => Promise.resolve(new Response("no", { status: 404 })); + await expect( + createHuggingFaceOrgResolver("https://huggingface.co", fetchFn)("missing"), + ).rejects.toThrow("Hugging Face organization not found: missing"); + }); +}); diff --git a/space/tests/membership.test.ts b/space/tests/membership.test.ts index c99d18a..a59b129 100644 --- a/space/tests/membership.test.ts +++ b/space/tests/membership.test.ts @@ -66,6 +66,37 @@ describe("PoolMembership", () => { expect(membership.isMember("alice")).toBe(false); }); + it("authorizes identities through member organization grants", async () => { + hub.files.set( + "config/pool.json", + JSON.stringify({ + version: 1, + admins: ["carol"], + members: ["carol"], + member_orgs: [{ name: "huggingface", sub: "org-hf", display_name: "Hugging Face" }], + updated_at: NOW.toISOString(), + }), + ); + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + + expect( + membership.accessFor({ + username: "dana", + orgs: [{ sub: "org-hf", name: "huggingface" }], + }), + ).toMatchObject({ type: "member_org", org: { name: "huggingface" } }); + expect(membership.memberOrgIds()).toEqual(["org-hf"]); + expect(membership.isAdmin("dana")).toBe(false); + expect(membership.accessFor({ username: "erin", orgs: [{ sub: "org-other" }] })).toBe( + undefined, + ); + }); + it("commits member changes to config/pool.json", async () => { const membership = await PoolMembership.load({ mirror, @@ -83,6 +114,28 @@ describe("PoolMembership", () => { expect(hub.commits[0]?.title).toBe("config: add pool member alice"); }); + it("commits member organization changes to config/pool.json", async () => { + const membership = await PoolMembership.load({ + mirror, + bootstrapMembers: ["osolmaz"], + bootstrapAdmins: ["osolmaz"], + now: () => NOW, + }); + await membership.addMemberOrg("osolmaz", { + name: "HuggingFace", + sub: "org-hf", + display_name: "Hugging Face", + }); + expect(membership.snapshot().member_orgs).toEqual([ + { name: "huggingface", sub: "org-hf", display_name: "Hugging Face" }, + ]); + expect(hub.commits[0]?.title).toBe("config: add member org huggingface"); + + await membership.removeMemberOrg("osolmaz", "huggingface"); + expect(membership.snapshot().member_orgs).toEqual([]); + expect(hub.commits[1]?.title).toBe("config: remove member org huggingface"); + }); + it("leaves membership unchanged when a config commit fails", async () => { const membership = await PoolMembership.load({ mirror, diff --git a/space/tests/oauth.test.ts b/space/tests/oauth.test.ts index d182bfa..08453ea 100644 --- a/space/tests/oauth.test.ts +++ b/space/tests/oauth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { authorizeUrl, exchangeCodeForUsername } from "../src/oauth.js"; +import { authorizeUrl, exchangeCodeForIdentity, exchangeCodeForUsername } from "../src/oauth.js"; import type { OAuthSettings } from "../src/oauth.js"; const settings: OAuthSettings = { @@ -28,6 +28,14 @@ describe("authorizeUrl", () => { expect(url.searchParams.get("response_type")).toBe("code"); expect(url.searchParams.get("scope")).toBe("openid profile"); expect(url.searchParams.get("state")).toBe("state-123"); + expect(url.searchParams.getAll("orgIds")).toEqual([]); + }); + + it("adds configured organization ids to the authorize URL", () => { + const url = new URL( + authorizeUrl(settings, "state-123", { orgIds: ["org-b", "org-a", "org-b"] }), + ); + expect(url.searchParams.getAll("orgIds")).toEqual(["org-a", "org-b"]); }); }); @@ -42,6 +50,28 @@ describe("exchangeCodeForUsername", () => { ); }); + it("returns stable organization identities from userinfo", async () => { + const fetchFn = fakeFetch( + () => Response.json({ access_token: "at" }), + () => + Response.json({ + preferred_username: "osolmaz", + orgs: [{ sub: "org-hf", preferred_username: "HuggingFace" }], + organizations: [ + { sub: "org-hf", preferred_username: "huggingface" }, + { sub: "org-other", name: "OtherOrg" }, + ], + }), + ); + await expect(exchangeCodeForIdentity({ ...settings, fetchFn }, "code")).resolves.toEqual({ + username: "osolmaz", + orgs: [ + { sub: "org-hf", name: "huggingface" }, + { sub: "org-other", name: "otherorg" }, + ], + }); + }); + it("authenticates the client with HTTP Basic on the token exchange", async () => { let tokenInit: RequestInit | undefined; const fetchFn: typeof fetch = (input, init) => { diff --git a/space/tests/pool-token.test.ts b/space/tests/pool-token.test.ts index c9fe25a..3c55573 100644 --- a/space/tests/pool-token.test.ts +++ b/space/tests/pool-token.test.ts @@ -13,7 +13,20 @@ describe("pool tokens", () => { const token = mintPoolToken(SECRET, "osolmaz", FUTURE); expect(token.startsWith("xp1.")).toBe(true); const verified = verifyPoolToken(SECRET, token, NOW); - expect(verified).toEqual({ ok: true, username: "osolmaz" }); + expect(verified).toEqual({ ok: true, username: "osolmaz", orgs: [] }); + }); + + it("round-trips organization identities", () => { + const token = mintPoolToken( + SECRET, + { username: "dana", orgs: [{ sub: "org-hf", name: "huggingface" }] }, + FUTURE, + ); + expect(verifyPoolToken(SECRET, token, NOW)).toEqual({ + ok: true, + username: "dana", + orgs: [{ sub: "org-hf", name: "huggingface" }], + }); }); it("rejects an expired token", () => { @@ -59,6 +72,12 @@ describe("pool tokens", () => { JSON.stringify({ expiresAt: FUTURE.getTime() }), JSON.stringify({ username: "", expiresAt: FUTURE.getTime() }), JSON.stringify({ username: "osolmaz", expiresAt: "soon" }), + JSON.stringify({ username: "osolmaz", expiresAt: FUTURE.getTime(), orgs: "huggingface" }), + JSON.stringify({ + username: "osolmaz", + expiresAt: FUTURE.getTime(), + orgs: [{ name: "huggingface" }], + }), JSON.stringify("just-a-string"), "not json at all", ]; From 5726521b0356d190a4f1a040c9a6e2012798c069 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:49:37 +0200 Subject: [PATCH 2/3] fix: limit org claims to org-granted tokens --- space/src/app.ts | 4 +++- space/tests/app.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/space/src/app.ts b/space/src/app.ts index 0dfd530..9ad1d66 100644 --- a/space/src/app.ts +++ b/space/src/app.ts @@ -84,7 +84,9 @@ export function createApp(deps: AppDeps): Hono { return { username: identity.username, grant, - ...(identity.orgs === undefined ? {} : { orgs: identity.orgs }), + ...(grant.type === "member_org" && identity.orgs !== undefined + ? { orgs: identity.orgs } + : {}), }; }; diff --git a/space/tests/app.test.ts b/space/tests/app.test.ts index 8ac7f7f..bc88c5e 100644 --- a/space/tests/app.test.ts +++ b/space/tests/app.test.ts @@ -283,6 +283,53 @@ describe("oauth + connect flow", () => { expect(ingestResponse.status).toBe(200); }); + it("does not carry org claims into explicit member tokens", async () => { + await membership.addMemberOrg("osolmaz", { + name: "huggingface", + sub: "org-hf", + display_name: "Hugging Face", + }); + const oauthFetch: typeof fetch = (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith("/oauth/token")) + return Promise.resolve(Response.json({ access_token: "t" })); + return Promise.resolve( + Response.json({ + preferred_username: "alice", + orgs: [{ sub: "org-hf", preferred_username: "huggingface" }], + }), + ); + }; + const oauthApp = createApp({ + config: testConfig, + store, + membership, + now: () => NOW, + ingest: () => Promise.resolve({ ok: true, added: 0, duplicates: 0, rejected: [] }), + oauthFetch, + }); + + const success = await oauthApp.request("/oauth/callback?code=c&state=s1", { + headers: { cookie: "xtap_pool_oauth=s1|/connect" }, + }); + const cookie = sessionCookieFrom(success.headers.get("set-cookie")); + const connect = await oauthApp.request("/connect", { headers: { cookie } }); + const html = await connect.text(); + const match = /data-token="([^"]+)"/.exec(html); + expect(match).not.toBeNull(); + + await membership.removeMember("osolmaz", "alice"); + const ingestResponse = await oauthApp.request("/api/ingest", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${match?.[1] ?? ""}`, + }, + body: JSON.stringify({ tweets: [makeTweet()] }), + }); + expect(ingestResponse.status).toBe(401); + }); + it("renders the connect page with a working pool token for a session", async () => { const response = await app.request("/connect", { headers: { cookie: sessionCookie("osolmaz") }, From dce4a5ffe6a4bebaf3101d1f3cc4ad6aff8095ef Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:58:08 +0200 Subject: [PATCH 3/3] ci: skip mutation tests on pull requests --- .github/workflows/ci.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c23e7..c6cab57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -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