import { whoAmI } from "@huggingface/hub"; import { config } from "$lib/server/config"; import type { HuggingFacePushTarget } from "./AgentDeliverables"; import type { Workspace } from "$lib/types/Workspace"; const NAMESPACE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const SERVICE_REPO_PATTERN = /^(space|model|dataset):([A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*)$/; type AgentPushConfigName = "AGENT_HF_TOKEN" | "HF_TOKEN" | "AGENT_SANDBOX_NAMESPACE" | "AGENT_SERVICE_PUSH_REPOS"; export class AgentPushAuthError extends Error { constructor( message: string, readonly status: number ) { super(message); this.name = "AgentPushAuthError"; } } export interface ResolveAgentPushCredentialOptions { userToken?: string; workspace: Pick; target: HuggingFacePushTarget; configValue?: (name: AgentPushConfigName) => string; resolveServiceOwner?: (accessToken: string) => Promise; } /** * A user's OAuth token retains normal Hub permissions. The service token is a * narrower fallback: it can create from a blank workspace, or update the exact * current HF source only when an operator explicitly allow-lists that repo. * Both paths remain confined to the configured (or token-owned) namespace. */ export async function resolveAgentPushCredential({ userToken, workspace, target, configValue = readConfigValue, resolveServiceOwner = resolveWhoAmIOwner, }: ResolveAgentPushCredentialOptions): Promise { if (userToken) return userToken; const serviceToken = configValue("AGENT_HF_TOKEN") || configValue("HF_TOKEN"); if (!serviceToken) { throw new AgentPushAuthError("Sign in with Hugging Face to push this workspace", 401); } const creatingFromBlank = workspace.repo.kind === "blank" && target.create === true; const serviceRepoKey = `${target.repoType}:${target.repoId}`; const updatingAllowlistedSource = workspace.repo.kind === "hf" && target.create === false && workspace.repo.ref === target.repoId && workspace.repo.repoType === target.repoType && parseServicePushRepos(configValue("AGENT_SERVICE_PUSH_REPOS")).has(serviceRepoKey); if (!creatingFromBlank && !updatingAllowlistedSource) { throw new AgentPushAuthError( "Service credentials may only create from a blank workspace or update an explicitly allowed source repository", 403 ); } const requestedNamespace = target.repoId.slice(0, target.repoId.indexOf("/")); let allowedNamespace = configValue("AGENT_SANDBOX_NAMESPACE").trim(); if (!allowedNamespace) { try { allowedNamespace = (await resolveServiceOwner(serviceToken)).trim(); } catch { throw new AgentPushAuthError("Unable to authorize the service repository namespace", 503); } } if ( !NAMESPACE_PATTERN.test(allowedNamespace) || requestedNamespace.toLowerCase() !== allowedNamespace.toLowerCase() ) { throw new AgentPushAuthError( "Anonymous pushes are limited to the configured service namespace", 403 ); } return serviceToken; } export function parseServicePushRepos(value: string): Set { const entries = value .split(",") .map((entry) => entry.trim()) .filter(Boolean); return new Set(entries.filter((entry) => SERVICE_REPO_PATTERN.test(entry))); } function readConfigValue(name: AgentPushConfigName): string { return config[name]; } async function resolveWhoAmIOwner(accessToken: string): Promise { const identity = await whoAmI({ accessToken }); return identity.name; } /** Route-facing service object keeps policy testable without exposing credentials. */ export const agentPushAuth = { resolveCredential: resolveAgentPushCredential };