File size: 3,628 Bytes
fae625a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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<Workspace, "repo">;
	target: HuggingFacePushTarget;
	configValue?: (name: AgentPushConfigName) => string;
	resolveServiceOwner?: (accessToken: string) => Promise<string>;
}

/**
 * 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<string> {
	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<string> {
	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<string> {
	const identity = await whoAmI({ accessToken });
	return identity.name;
}

/** Route-facing service object keeps policy testable without exposing credentials. */
export const agentPushAuth = { resolveCredential: resolveAgentPushCredential };