@vercel/board 0.0.0-stage → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -2
- package/dist/a2-5EG8FkZ8.d.mts +3359 -0
- package/dist/a2-DYGYankn.mjs +3045 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +29 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-rdN4dDTU.mjs +223 -0
- package/dist/attempts-BQUp9MKO.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-wHFcAte3.d.mts +203 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-8ex5g7Wv.d.mts +630 -0
- package/dist/backend-http-BsDSgmeo.mjs +613 -0
- package/dist/backend-http-Cv6xbEy9.mjs +469 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-3EvQ8idk.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-BpgyEdh6.d.mts +4 -0
- package/dist/exports-CyY3_FjZ.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-BZ3lItjH.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-Ccbb2LSb.mjs +170 -0
- package/dist/server-Cxey38B-.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-ojFEXEyN.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-D9hbHLKB.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +262 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +499 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +165 -3
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { t as BoardError } from "./client-DGDPaN7U.mjs";
|
|
2
|
+
import { t as createHttp } from "./backend-http-Cv6xbEy9.mjs";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { decodeJwt } from "jose";
|
|
11
|
+
import { exchangeVercelOidcToken, getVercelOidcToken, getVercelToken } from "@vercel/oidc";
|
|
12
|
+
//#region src/client/node.ts
|
|
13
|
+
const configDir = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "board");
|
|
14
|
+
/** The board saved by `board use <url>`. */
|
|
15
|
+
function savedUrl() {
|
|
16
|
+
try {
|
|
17
|
+
const { url } = JSON.parse(readFileSync(join(configDir(), "config.json"), "utf8"));
|
|
18
|
+
return url;
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A Git checkout's root: `.git` is a directory with a `HEAD`, or a worktree's
|
|
25
|
+
* `gitdir:` file. A stray empty `.git` (say, in `~` or `/tmp`) isn't one.
|
|
26
|
+
*/
|
|
27
|
+
function isCheckout(directory) {
|
|
28
|
+
const git = join(directory, ".git");
|
|
29
|
+
try {
|
|
30
|
+
if (statSync(git).isDirectory()) return existsSync(join(git, "HEAD"));
|
|
31
|
+
return readFileSync(git, "utf8").startsWith("gitdir:");
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function projectDir(cwd = process.cwd()) {
|
|
37
|
+
let directory = resolve(cwd);
|
|
38
|
+
while (true) {
|
|
39
|
+
if (existsSync(join(directory, ".board", "config.json")) || isCheckout(directory)) return directory;
|
|
40
|
+
const parent = dirname(directory);
|
|
41
|
+
if (parent === directory) return resolve(cwd);
|
|
42
|
+
directory = parent;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function projectUrl() {
|
|
46
|
+
const path = join(projectDir(), ".board", "config.json");
|
|
47
|
+
let contents;
|
|
48
|
+
try {
|
|
49
|
+
contents = readFileSync(path, "utf8");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error.code === "ENOENT") return void 0;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const { url } = JSON.parse(contents);
|
|
56
|
+
if (typeof url === "string" && ["http:", "https:"].includes(new URL(url).protocol)) return url;
|
|
57
|
+
} catch {}
|
|
58
|
+
throw new BoardError("invalid_config", `invalid board link at ${path}: expected an http(s) url`, 0);
|
|
59
|
+
}
|
|
60
|
+
const LOOPBACK = /* @__PURE__ */ new Set([
|
|
61
|
+
"127.0.0.1",
|
|
62
|
+
"localhost",
|
|
63
|
+
"[::1]"
|
|
64
|
+
]);
|
|
65
|
+
/** Where `vb serve` leaves the token for the board it runs at `url`: only this user can read it. */
|
|
66
|
+
function localTokenFile(url) {
|
|
67
|
+
const normalized = url.replace(/\/+$/, "");
|
|
68
|
+
return join(configDir(), "local", `${createHash("sha256").update(normalized).digest("hex").slice(0, 32)}.json`);
|
|
69
|
+
}
|
|
70
|
+
/** The token a local board on this machine left for you, if `url` is one. */
|
|
71
|
+
function localToken(url) {
|
|
72
|
+
try {
|
|
73
|
+
if (!LOOPBACK.has(new URL(url).hostname)) return null;
|
|
74
|
+
const saved = JSON.parse(readFileSync(localTokenFile(url), "utf8"));
|
|
75
|
+
if (saved.url !== url.replace(/\/+$/, "") || typeof saved.token !== "string") return null;
|
|
76
|
+
return {
|
|
77
|
+
token: saved.token,
|
|
78
|
+
handle: saved.handle ?? null
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Claude Code, Codex, and Grok sessions select their own profile, like Just Chat's CLI. */
|
|
85
|
+
function detectAgent(env = process.env) {
|
|
86
|
+
const found = [];
|
|
87
|
+
if (env.CLAUDECODE === "1" || env.CLAUDE_CODE_SESSION_ID) found.push({
|
|
88
|
+
profile: "claude",
|
|
89
|
+
session: env.CLAUDE_CODE_SESSION_ID || null
|
|
90
|
+
});
|
|
91
|
+
if (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID) found.push({
|
|
92
|
+
profile: "codex",
|
|
93
|
+
session: env.CODEX_THREAD_ID || env.CODEX_SESSION_ID || null
|
|
94
|
+
});
|
|
95
|
+
if (env.GROK_SESSION_ID) found.push({
|
|
96
|
+
profile: "grok",
|
|
97
|
+
session: env.GROK_SESSION_ID
|
|
98
|
+
});
|
|
99
|
+
if (env.CURSOR_AGENT === "1") found.push({
|
|
100
|
+
profile: "cursor",
|
|
101
|
+
session: null
|
|
102
|
+
});
|
|
103
|
+
if (env.PI_CODING_AGENT === "true" || env.AI_AGENT === "pi") found.push({
|
|
104
|
+
profile: "pi",
|
|
105
|
+
session: env.PI_SESSION_ID || null
|
|
106
|
+
});
|
|
107
|
+
const named = env.AI_AGENT?.trim().toLowerCase();
|
|
108
|
+
if (named && /^[a-z][a-z0-9-]{0,31}$/.test(named) && !found.some((agent) => agent.profile === named)) found.push({
|
|
109
|
+
profile: named,
|
|
110
|
+
session: null
|
|
111
|
+
});
|
|
112
|
+
if (found.length > 1) throw new BoardError("ambiguous_agent", `several agent environments detected (${found.map((a) => a.profile).join(", ")}): choose one with a profile, or none`, 0);
|
|
113
|
+
return found[0] ?? null;
|
|
114
|
+
}
|
|
115
|
+
const expiry = (token) => {
|
|
116
|
+
try {
|
|
117
|
+
const { exp } = decodeJwt(token);
|
|
118
|
+
return exp ? exp * 1e3 : Date.now() + 3e5;
|
|
119
|
+
} catch {
|
|
120
|
+
return Date.now() + 3e5;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const cacheFile = (audience) => join(configDir(), "tokens", `${createHash("sha256").update(audience).digest("hex").slice(0, 32)}.json`);
|
|
124
|
+
async function readCache(audience) {
|
|
125
|
+
try {
|
|
126
|
+
const cached = JSON.parse(await readFile(cacheFile(audience), "utf8"));
|
|
127
|
+
return cached.expiresAt - 3e5 > Date.now() ? cached : null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function writeCache(audience, cached) {
|
|
133
|
+
await mkdir(join(configDir(), "tokens"), {
|
|
134
|
+
recursive: true,
|
|
135
|
+
mode: 448
|
|
136
|
+
});
|
|
137
|
+
await writeFile(cacheFile(audience), JSON.stringify(cached), { mode: 384 });
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* A person's token: their Vercel CLI login mints a development token for the
|
|
141
|
+
* project the board's host is a deployment of, exchanged so its audience is the
|
|
142
|
+
* board's URL. Cached on disk for its 12-hour life.
|
|
143
|
+
*/
|
|
144
|
+
async function personToken(audience, discovery) {
|
|
145
|
+
const cached = await readCache(audience);
|
|
146
|
+
if (cached && cached.name === void 0) {
|
|
147
|
+
const user = await getVercelToken().then(vercelUser).catch(() => null);
|
|
148
|
+
const filled = {
|
|
149
|
+
...cached,
|
|
150
|
+
name: user?.name ?? null,
|
|
151
|
+
avatar: user?.avatar ?? null
|
|
152
|
+
};
|
|
153
|
+
await writeCache(audience, filled).catch(() => {});
|
|
154
|
+
return filled;
|
|
155
|
+
}
|
|
156
|
+
if (cached) return cached;
|
|
157
|
+
if (!discovery.project_id) throw new BoardError("no_project", `the board at ${discovery.url} doesn't know its Vercel project`, 0);
|
|
158
|
+
const authToken = await getVercelToken().catch(() => null);
|
|
159
|
+
const project = await boardProject(audience, discovery, authToken);
|
|
160
|
+
let development;
|
|
161
|
+
try {
|
|
162
|
+
if (!authToken) throw new Error("no Vercel CLI credentials file");
|
|
163
|
+
const response = await fetch(`https://api.vercel.com/v1/projects/${project.id}/token?source=board${project.team ? `&teamId=${project.team}` : ""}`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { authorization: `Bearer ${authToken}` }
|
|
166
|
+
});
|
|
167
|
+
if (response.status === 403 || response.status === 404) throw new BoardError("no_access", "your Vercel account can't access this board's project: ask for access to it", response.status);
|
|
168
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
169
|
+
development = (await response.json()).token;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof BoardError) throw error;
|
|
172
|
+
development = await tokenFromVercelCli(project);
|
|
173
|
+
}
|
|
174
|
+
const token = await exchangeVercelOidcToken({
|
|
175
|
+
token: development,
|
|
176
|
+
audience
|
|
177
|
+
});
|
|
178
|
+
const user = authToken ? await vercelUser(authToken) : null;
|
|
179
|
+
const fresh = {
|
|
180
|
+
token,
|
|
181
|
+
expiresAt: expiry(token),
|
|
182
|
+
handle: user?.handle ?? null,
|
|
183
|
+
name: user?.name ?? null,
|
|
184
|
+
avatar: user?.avatar ?? null
|
|
185
|
+
};
|
|
186
|
+
await writeCache(audience, fresh).catch(() => {});
|
|
187
|
+
return fresh;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The project to mint a person's token for: the one Vercel, asked with your own
|
|
191
|
+
* login, says the board's host is a deployment of (its deployment URL, a branch
|
|
192
|
+
* alias, or a domain), found as `vercel curl` finds it. Discovery names a project
|
|
193
|
+
* too, but any board can name any project you can access, and a token for one it
|
|
194
|
+
* isn't could be replayed where that project is trusted, so the two must agree.
|
|
195
|
+
* Vercel knows no loopback host: a board there runs on your machine (`next dev`).
|
|
196
|
+
*/
|
|
197
|
+
async function boardProject(url, discovery, authToken) {
|
|
198
|
+
const { host, hostname } = new URL(url);
|
|
199
|
+
if (LOOPBACK.has(hostname)) return {
|
|
200
|
+
id: discovery.project_id,
|
|
201
|
+
team: discovery.team_id
|
|
202
|
+
};
|
|
203
|
+
const found = await findProject(authToken ? async (method, path) => {
|
|
204
|
+
const response = await fetch(`https://api.vercel.com${path}`, {
|
|
205
|
+
method,
|
|
206
|
+
headers: { authorization: `Bearer ${authToken}` }
|
|
207
|
+
});
|
|
208
|
+
return response.ok ? await response.json() : null;
|
|
209
|
+
} : vercelCliApi, host);
|
|
210
|
+
if (!found) throw new BoardError("no_access", `your Vercel login can't see the project ${host} belongs to: run \`vercel login\` or ask for access to it`, 0);
|
|
211
|
+
if (found.projectId !== discovery.project_id) throw new BoardError("wrong_project", `the board at ${url} says its project is ${discovery.project_id}, but it's a deployment of ${found.projectId}, so vb won't sign in to it`, 0);
|
|
212
|
+
return {
|
|
213
|
+
id: found.projectId,
|
|
214
|
+
team: found.ownerId.startsWith("team_") ? found.ownerId : null
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** When credentials live in the keychain, the Vercel CLI mints the token itself. */
|
|
218
|
+
async function tokenFromVercelCli(project) {
|
|
219
|
+
const args = [
|
|
220
|
+
"project",
|
|
221
|
+
"token",
|
|
222
|
+
project.id,
|
|
223
|
+
"--format=json"
|
|
224
|
+
];
|
|
225
|
+
if (project.team) args.push("--scope", project.team);
|
|
226
|
+
try {
|
|
227
|
+
const { stdout } = await promisify(execFile)("vercel", args, { timeout: 3e4 });
|
|
228
|
+
return JSON.parse(stdout).token;
|
|
229
|
+
} catch {
|
|
230
|
+
throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Your Vercel username, display name, and avatar (Vercel serves avatars publicly). */
|
|
234
|
+
async function vercelUser(authToken) {
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetch("https://api.vercel.com/v2/user", { headers: { authorization: `Bearer ${authToken}` } });
|
|
237
|
+
if (!response.ok) return null;
|
|
238
|
+
const { user } = await response.json();
|
|
239
|
+
return {
|
|
240
|
+
handle: user?.username?.toLowerCase() ?? null,
|
|
241
|
+
name: user?.name?.trim() || null,
|
|
242
|
+
avatar: user?.avatar && /^[0-9a-f]+$/i.test(user.avatar) ? `https://vercel.com/api/www/avatar/${user.avatar}?s=128` : null
|
|
243
|
+
};
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function githubActionsToken(audience) {
|
|
249
|
+
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL);
|
|
250
|
+
url.searchParams.set("audience", audience);
|
|
251
|
+
const response = await fetch(url, { headers: { authorization: `bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` } });
|
|
252
|
+
if (!response.ok) throw new BoardError("no_token", `GitHub Actions refused an OIDC token (${response.status}): does the job have \`permissions: id-token: write\`?`, 0);
|
|
253
|
+
return (await response.json()).value;
|
|
254
|
+
}
|
|
255
|
+
/** The board the environment points at: `BOARD_URL`, the project's link (`vb link`, `vb serve`), or `vb use`'s. */
|
|
256
|
+
function boardUrl() {
|
|
257
|
+
const url = (process.env.BOARD_URL ?? projectUrl() ?? savedUrl())?.replace(/\/+$/, "");
|
|
258
|
+
if (!url) throw new BoardError("no_board", "no board URL: run `board link <url>`, set BOARD_URL, run `board use <url>`, or `board serve` for a local one", 0);
|
|
259
|
+
return url;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* How a client reaches the board at `url` from Node: through Deployment Protection, with a token
|
|
263
|
+
* from `token`, `BOARD_TOKEN`, a local board's token file (`vb serve`), GitHub Actions, the Vercel
|
|
264
|
+
* runtime, or your Vercel CLI login, in that order. `handle` is yours, in place of `BOARD_HANDLE`
|
|
265
|
+
* or your Vercel username.
|
|
266
|
+
*/
|
|
267
|
+
function connect(url, options = {}) {
|
|
268
|
+
url = url.replace(/\/+$/, "");
|
|
269
|
+
const doFetch = guarded(url, options.fetch ?? ((input, init) => globalThis.fetch(input, init)), options.token);
|
|
270
|
+
let discovery;
|
|
271
|
+
const discover = () => discovery ??= doFetch(url).then(async (response) => {
|
|
272
|
+
if (!response.ok) throw new BoardError("no_board", `no board at ${url} (${response.status})`, response.status);
|
|
273
|
+
const found = await response.json();
|
|
274
|
+
if (found.audience !== url) throw new BoardError("wrong_audience", `the board at ${url} says its URL is ${found.audience}: use that URL`, 0);
|
|
275
|
+
return found;
|
|
276
|
+
});
|
|
277
|
+
let cached = null;
|
|
278
|
+
let source = null;
|
|
279
|
+
let person = null;
|
|
280
|
+
const mint = async () => {
|
|
281
|
+
if (options.token !== void 0) {
|
|
282
|
+
const token = typeof options.token === "function" ? await options.token() : options.token;
|
|
283
|
+
return {
|
|
284
|
+
token,
|
|
285
|
+
expiresAt: expiry(token)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (process.env.BOARD_TOKEN) {
|
|
289
|
+
const token = process.env.BOARD_TOKEN;
|
|
290
|
+
source = "BOARD_TOKEN";
|
|
291
|
+
return {
|
|
292
|
+
token,
|
|
293
|
+
expiresAt: expiry(token)
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const local = localToken(url);
|
|
297
|
+
if (local) {
|
|
298
|
+
source = "vb serve";
|
|
299
|
+
return {
|
|
300
|
+
token: local.token,
|
|
301
|
+
expiresAt: expiry(local.token),
|
|
302
|
+
handle: local.handle,
|
|
303
|
+
name: null,
|
|
304
|
+
avatar: null
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
const discovered = await discover();
|
|
308
|
+
if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) {
|
|
309
|
+
const token = await githubActionsToken(url);
|
|
310
|
+
source = "GitHub Actions OIDC";
|
|
311
|
+
return {
|
|
312
|
+
token,
|
|
313
|
+
expiresAt: expiry(token)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (process.env.VERCEL === "1") {
|
|
317
|
+
const token = await hostedToken(url);
|
|
318
|
+
source = "Vercel OIDC";
|
|
319
|
+
return {
|
|
320
|
+
token,
|
|
321
|
+
expiresAt: expiry(token)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
person ??= personToken(url, discovered).finally(() => {
|
|
325
|
+
person = null;
|
|
326
|
+
});
|
|
327
|
+
const minted = await person;
|
|
328
|
+
source = "vercel login";
|
|
329
|
+
return minted;
|
|
330
|
+
};
|
|
331
|
+
const token = async () => {
|
|
332
|
+
if (options.token !== void 0 || !cached || cached.expiresAt - 6e4 < Date.now()) cached = await mint();
|
|
333
|
+
return cached.token;
|
|
334
|
+
};
|
|
335
|
+
const send = (async (input, init = {}) => {
|
|
336
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
337
|
+
const method = (init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
338
|
+
const path = (href.startsWith(url) ? href.slice(url.length) : new URL(href).pathname).split("?")[0] || "/";
|
|
339
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
340
|
+
if (!(method === "GET" && path === "/")) {
|
|
341
|
+
if (!headers.has("authorization")) headers.set("authorization", `Bearer ${await token()}`);
|
|
342
|
+
const handle = options.handle ?? (process.env.BOARD_HANDLE || (options.token === void 0 ? cached?.handle : null));
|
|
343
|
+
if (handle && !headers.has("x-board-handle")) headers.set("x-board-handle", handle);
|
|
344
|
+
if (!headers.has("x-board-profile") && options.token === void 0) {
|
|
345
|
+
if (cached?.name && !headers.has("x-board-name")) headers.set("x-board-name", encodeURIComponent(cached.name));
|
|
346
|
+
if (cached?.avatar && !headers.has("x-board-avatar")) headers.set("x-board-avatar", cached.avatar);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return doFetch(input, {
|
|
350
|
+
...init,
|
|
351
|
+
headers
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
fetch: send,
|
|
356
|
+
source: () => source
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** The profile the environment picks: `BOARD_AS`, or the coding agent it runs in, with its session. */
|
|
360
|
+
function environmentProfile() {
|
|
361
|
+
const detected = detectAgent();
|
|
362
|
+
const name = process.env.BOARD_AS || detected?.profile;
|
|
363
|
+
const avatar = process.env.BOARD_AVATAR;
|
|
364
|
+
return {
|
|
365
|
+
profile: name ? avatar ? {
|
|
366
|
+
name,
|
|
367
|
+
avatar
|
|
368
|
+
} : name : null,
|
|
369
|
+
session: detected?.session ?? null
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const WHERE = "Project → Settings → Deployment Protection → Protection Bypass for Automation";
|
|
373
|
+
var Wall = class extends Error {
|
|
374
|
+
status;
|
|
375
|
+
constructor(status) {
|
|
376
|
+
super("protected");
|
|
377
|
+
this.status = status;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* This deployment's Vercel OIDC token, with `url` as its audience: what a hosted client sends a
|
|
382
|
+
* board, for Deployment Protection's Trusted Sources. Its `cause` says why it couldn't.
|
|
383
|
+
*/
|
|
384
|
+
async function hostedToken(url) {
|
|
385
|
+
try {
|
|
386
|
+
return await getVercelOidcToken({ audience: url });
|
|
387
|
+
} catch (cause) {
|
|
388
|
+
throw Object.assign(new BoardError("no_token", `cannot obtain Vercel OIDC credentials for ${url}: enable OIDC on the calling project or supply token / BOARD_TOKEN; configure Deployment Protection Trusted Sources for its project and environment`, 0), { cause });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** A host this deployment answers on, as Vercel's system variables name them (bare hosts). */
|
|
392
|
+
const ownHost = (host) => [
|
|
393
|
+
process.env.VERCEL_URL,
|
|
394
|
+
process.env.VERCEL_BRANCH_URL,
|
|
395
|
+
process.env.VERCEL_PROJECT_PRODUCTION_URL
|
|
396
|
+
].some((own) => !!own && own.toLowerCase() === host.toLowerCase());
|
|
397
|
+
/** Explicit bypass wins; otherwise hosted calls use OIDC and local people use their saved bypass. */
|
|
398
|
+
function guarded(url, doFetch, suppliedToken) {
|
|
399
|
+
const host = new URL(url).host;
|
|
400
|
+
let secret;
|
|
401
|
+
let looked;
|
|
402
|
+
let envRejected = false;
|
|
403
|
+
return async (input, init = {}) => {
|
|
404
|
+
const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
405
|
+
init = {
|
|
406
|
+
...init,
|
|
407
|
+
headers
|
|
408
|
+
};
|
|
409
|
+
const fromEnv = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
410
|
+
if (fromEnv && !envRejected && (process.env.VERCEL !== "1" || ownHost(host))) try {
|
|
411
|
+
return await through(doFetch, input, init, fromEnv);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (!(error instanceof Wall)) throw error;
|
|
414
|
+
if (process.env.VERCEL !== "1") throw new BoardError("protected", `${url} is behind Vercel Deployment Protection and rejected VERCEL_AUTOMATION_BYPASS_SECRET; check it against ${WHERE}`, error.status);
|
|
415
|
+
envRejected = true;
|
|
416
|
+
}
|
|
417
|
+
if (process.env.VERCEL === "1") {
|
|
418
|
+
if (!headers.has("x-vercel-trusted-oidc-idp-token")) {
|
|
419
|
+
const token = headers.get("authorization")?.match(/^Bearer (.+)$/i)?.[1] ?? (typeof suppliedToken === "function" ? await suppliedToken() : suppliedToken) ?? (process.env.BOARD_TOKEN || await hostedToken(url));
|
|
420
|
+
headers.set("x-vercel-trusted-oidc-idp-token", token);
|
|
421
|
+
}
|
|
422
|
+
return through(doFetch, input, init, null).catch((error) => {
|
|
423
|
+
throw error instanceof Wall ? new BoardError("protected", `${url} rejected Vercel OIDC access: configure Deployment Protection Trusted Sources for the calling project and environment`, error.status) : error;
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const used = await (secret ??= readBypass(host));
|
|
427
|
+
try {
|
|
428
|
+
return await through(doFetch, input, init, used);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!(error instanceof Wall)) throw error;
|
|
431
|
+
}
|
|
432
|
+
looked ??= projectBypass(url, host, doFetch).then(async (found) => {
|
|
433
|
+
await writeBypass(host, found.secret).catch(() => {});
|
|
434
|
+
return found;
|
|
435
|
+
});
|
|
436
|
+
const { secret: found, created } = await looked;
|
|
437
|
+
secret = Promise.resolve(found);
|
|
438
|
+
try {
|
|
439
|
+
return await through(doFetch, input, init, found).catch(async (error) => {
|
|
440
|
+
if (!(error instanceof Wall) || !created) throw error;
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
442
|
+
return through(doFetch, input, init, found);
|
|
443
|
+
});
|
|
444
|
+
} catch (error) {
|
|
445
|
+
throw error instanceof Wall ? new BoardError("protected", `${url} is behind Vercel Deployment Protection and its project's automation bypass didn't get through; set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE})`, error.status) : error;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* One request that recognizes the wall: a redirect to `/sso-api`, or an HTML
|
|
451
|
+
* page where the board answers JSON (on SAML teams, the redirects end at the
|
|
452
|
+
* IdP's login page with a 200). Only a 2xx, 401, or 403 HTML page counts, so a
|
|
453
|
+
* 404 page keeps its own error; attachments, which may be HTML, come with a
|
|
454
|
+
* `content-disposition`. Other redirects of a GET or HEAD are followed as fetch
|
|
455
|
+
* would, dropping the token and bypass secret on the way to another origin; a
|
|
456
|
+
* redirected POST comes back as its 3xx.
|
|
457
|
+
*/
|
|
458
|
+
async function through(doFetch, input, init, bypass) {
|
|
459
|
+
let target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
460
|
+
const method = (init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
|
|
461
|
+
let headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
462
|
+
if (bypass) headers.set("x-vercel-protection-bypass", bypass);
|
|
463
|
+
for (let hops = 0;; hops++) {
|
|
464
|
+
const response = await doFetch(hops === 0 ? input : target, {
|
|
465
|
+
...init,
|
|
466
|
+
headers,
|
|
467
|
+
redirect: "manual"
|
|
468
|
+
});
|
|
469
|
+
const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
470
|
+
if (location) {
|
|
471
|
+
const next = new URL(location, target);
|
|
472
|
+
if (next.pathname.startsWith("/sso-api")) throw new Wall(response.status);
|
|
473
|
+
if (method === "GET" || method === "HEAD") {
|
|
474
|
+
if (hops >= 20) throw new BoardError("too_many_redirects", `too many redirects from ${target}`, response.status);
|
|
475
|
+
if (next.origin !== new URL(target).origin) {
|
|
476
|
+
headers = new Headers(headers);
|
|
477
|
+
headers.delete("authorization");
|
|
478
|
+
headers.delete("x-vercel-trusted-oidc-idp-token");
|
|
479
|
+
headers.delete("x-vercel-protection-bypass");
|
|
480
|
+
}
|
|
481
|
+
target = next.href;
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const { status } = response;
|
|
486
|
+
if (/^text\/html\b/i.test(response.headers.get("content-type") ?? "") && !response.headers.has("content-disposition") && (status >= 200 && status < 300 || status === 401 || status === 403)) throw new Wall(status);
|
|
487
|
+
return response;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const bypassFile = (host) => join(configDir(), "bypass", `${createHash("sha256").update(host).digest("hex").slice(0, 32)}.json`);
|
|
491
|
+
async function readBypass(host) {
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(await readFile(bypassFile(host), "utf8")).secret || null;
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function writeBypass(host, secret) {
|
|
499
|
+
await mkdir(join(configDir(), "bypass"), {
|
|
500
|
+
recursive: true,
|
|
501
|
+
mode: 448
|
|
502
|
+
});
|
|
503
|
+
await writeFile(bypassFile(host), JSON.stringify({
|
|
504
|
+
host,
|
|
505
|
+
secret
|
|
506
|
+
}), { mode: 384 });
|
|
507
|
+
}
|
|
508
|
+
const automationBypass = (bypasses) => Object.entries(bypasses ?? {}).find(([, value]) => value.scope === "automation-bypass")?.[0] ?? null;
|
|
509
|
+
/**
|
|
510
|
+
* The board's project's automation bypass secret, as `vercel curl` finds it:
|
|
511
|
+
* the deployment (or alias) behind the host gives the project, whose
|
|
512
|
+
* `protectionBypass` has it. When there's none, it's created.
|
|
513
|
+
*/
|
|
514
|
+
async function projectBypass(url, host, doFetch) {
|
|
515
|
+
const blocked = (why) => new BoardError("protected", `${url} is behind Vercel Deployment Protection, and your Vercel login ${why}, or set VERCEL_AUTOMATION_BYPASS_SECRET (${WHERE})`, 0);
|
|
516
|
+
let api;
|
|
517
|
+
try {
|
|
518
|
+
const token = await getVercelToken();
|
|
519
|
+
api = async (method, path) => {
|
|
520
|
+
const response = await doFetch(`https://api.vercel.com${path}`, {
|
|
521
|
+
method,
|
|
522
|
+
headers: {
|
|
523
|
+
authorization: `Bearer ${token}`,
|
|
524
|
+
...method === "PATCH" ? { "content-type": "application/json" } : {}
|
|
525
|
+
},
|
|
526
|
+
...method === "PATCH" ? { body: "{}" } : {}
|
|
527
|
+
});
|
|
528
|
+
return response.ok ? await response.json() : null;
|
|
529
|
+
};
|
|
530
|
+
} catch {
|
|
531
|
+
api = vercelCliApi;
|
|
532
|
+
}
|
|
533
|
+
const project = await findProject(api, host);
|
|
534
|
+
if (!project) throw blocked("can't see its project: run `vercel login` or ask for access");
|
|
535
|
+
const scope = project.ownerId.startsWith("team_") ? `?teamId=${project.ownerId}` : "";
|
|
536
|
+
const details = await api("GET", `/v9/projects/${project.projectId}${scope}`);
|
|
537
|
+
if (!details) throw blocked("can't read its project's settings: ask for access");
|
|
538
|
+
const existing = automationBypass(details.protectionBypass);
|
|
539
|
+
if (existing) return {
|
|
540
|
+
secret: existing,
|
|
541
|
+
created: false
|
|
542
|
+
};
|
|
543
|
+
if (!details.ssoProtection && !details.passwordProtection) throw new BoardError("no_board", `no board at ${url}: it answered with a web page, and its Vercel project isn't protected`, 0);
|
|
544
|
+
const created = await api("PATCH", `/v1/projects/${project.projectId}/protection-bypass${scope}`);
|
|
545
|
+
const secret = automationBypass(created?.protectionBypass);
|
|
546
|
+
if (!secret) throw blocked("can't read or create its project's automation bypass: ask a project admin");
|
|
547
|
+
process.stderr.write(`created a Protection Bypass for Automation on the Vercel project ${details?.name ?? project.projectId}\n`);
|
|
548
|
+
return {
|
|
549
|
+
secret,
|
|
550
|
+
created: true
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** The project behind a host, as `vercel curl` finds it: the deployment, then the alias, in your own scope and then each of your teams. */
|
|
554
|
+
async function findProject(api, host) {
|
|
555
|
+
const lookup = async (kind, team) => {
|
|
556
|
+
const found = await api("GET", `${kind === "deployment" ? `/v13/deployments/${encodeURIComponent(host)}` : `/now/aliases/${encodeURIComponent(host)}`}${team ? `?teamId=${team}` : ""}`).catch(() => null);
|
|
557
|
+
const owner = found?.ownerId ?? found?.team?.id ?? team;
|
|
558
|
+
return found?.projectId && owner ? {
|
|
559
|
+
projectId: found.projectId,
|
|
560
|
+
ownerId: owner
|
|
561
|
+
} : null;
|
|
562
|
+
};
|
|
563
|
+
const own = await lookup("deployment", null) ?? await lookup("alias", null);
|
|
564
|
+
if (own) return own;
|
|
565
|
+
const { teams } = await api("GET", "/v2/teams?limit=100").catch(() => null) ?? {};
|
|
566
|
+
const ids = (teams ?? []).filter((team) => !team.limited).map((team) => team.id);
|
|
567
|
+
for (const kind of ["deployment", "alias"]) for (let i = 0; i < ids.length; i += 8) {
|
|
568
|
+
const found = (await Promise.all(ids.slice(i, i + 8).map((id) => lookup(kind, id)))).find(Boolean);
|
|
569
|
+
if (found) return found;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
/** When credentials live in the keychain, the Vercel CLI makes the API call itself. */
|
|
574
|
+
let vercelCli;
|
|
575
|
+
const vercelCliApi = async (method, path) => {
|
|
576
|
+
vercelCli ??= promisify(execFile)("vercel", ["whoami", "--non-interactive"], { timeout: 1e4 }).then(() => true, () => false);
|
|
577
|
+
if (!await vercelCli) throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
|
|
578
|
+
const args = [
|
|
579
|
+
"api",
|
|
580
|
+
path,
|
|
581
|
+
"-X",
|
|
582
|
+
method,
|
|
583
|
+
"--raw",
|
|
584
|
+
"--non-interactive"
|
|
585
|
+
];
|
|
586
|
+
if (method === "PATCH") args.push("--input", "-");
|
|
587
|
+
try {
|
|
588
|
+
const run = promisify(execFile)("vercel", args, { timeout: 3e4 });
|
|
589
|
+
run.child.stdin?.on("error", () => {});
|
|
590
|
+
run.child.stdin?.end(method === "PATCH" ? "{}" : "");
|
|
591
|
+
return JSON.parse((await run).stdout);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error.code === "ENOENT") throw new BoardError("not_logged_in", "not signed in to Vercel: run `vercel login`", 0);
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/backend-http/index.ts
|
|
599
|
+
const environment = {
|
|
600
|
+
url: boardUrl,
|
|
601
|
+
fetch: (url) => connect(url).fetch,
|
|
602
|
+
profile: environmentProfile
|
|
603
|
+
};
|
|
604
|
+
/**
|
|
605
|
+
* The board at `url`, which keeps the log: this board uses it as this deployment. Without a
|
|
606
|
+
* `url`, the one the environment points at: `BOARD_URL`, the repo's link, or `vb use`'s.
|
|
607
|
+
* Without a `fetch`, it sends the credentials `vb` would.
|
|
608
|
+
*/
|
|
609
|
+
function http(url, options = {}) {
|
|
610
|
+
return createHttp(url, options, environment);
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
export { detectAgent as a, projectUrl as c, connect as i, boardUrl as n, localTokenFile as o, configDir as r, projectDir as s, http as t };
|