@openparachute/hub 0.7.5-rc.3 → 0.7.5-rc.5
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/package.json +2 -1
- package/scripts/git-credential-parachute +50 -0
- package/src/__tests__/admin-agent-grants.test.ts +310 -0
- package/src/__tests__/grants-store.test.ts +13 -0
- package/src/__tests__/surface-command.test.ts +492 -0
- package/src/__tests__/surface-token.test.ts +276 -0
- package/src/admin-agent-grants.ts +156 -6
- package/src/cli.ts +6 -0
- package/src/commands/auth.ts +1 -25
- package/src/commands/surface.ts +493 -0
- package/src/git-registry.ts +13 -0
- package/src/grants-store.ts +25 -4
- package/src/help.ts +64 -0
- package/src/hub-issuer.ts +30 -0
- package/src/jwt-sign.ts +9 -1
- package/src/surface-token.ts +244 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { surface } from "../commands/surface.ts";
|
|
7
|
+
import { ensureSurfaceRepo, isSurfaceRegistered, registerSurface } from "../git-registry.ts";
|
|
8
|
+
import { handleGitTransport } from "../git-transport.ts";
|
|
9
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
10
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
11
|
+
import { issueOperatorToken } from "../operator-token.ts";
|
|
12
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
13
|
+
import { createUser } from "../users.ts";
|
|
14
|
+
|
|
15
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
16
|
+
const HELPER = join(import.meta.dir, "..", "..", "scripts", "git-credential-parachute");
|
|
17
|
+
|
|
18
|
+
interface Tmp {
|
|
19
|
+
dir: string;
|
|
20
|
+
dbPath: string;
|
|
21
|
+
userId: string;
|
|
22
|
+
cleanup: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Seed a hub with a signing key, an owner user, and an admin operator token. */
|
|
26
|
+
async function makeTmp(): Promise<Tmp> {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-surfcmd-"));
|
|
28
|
+
const dbPath = hubDbPath(dir);
|
|
29
|
+
const db = openHubDb(dbPath);
|
|
30
|
+
let userId: string;
|
|
31
|
+
try {
|
|
32
|
+
rotateSigningKey(db);
|
|
33
|
+
const u = await createUser(db, "owner", "pw");
|
|
34
|
+
userId = u.id;
|
|
35
|
+
await issueOperatorToken(db, userId, { dir, issuer: ISSUER });
|
|
36
|
+
} finally {
|
|
37
|
+
db.close();
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
dir,
|
|
41
|
+
dbPath,
|
|
42
|
+
userId,
|
|
43
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function capture(fn: () => Promise<number> | number): Promise<{
|
|
48
|
+
code: number;
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
}> {
|
|
52
|
+
const origLog = console.log;
|
|
53
|
+
const origErr = console.error;
|
|
54
|
+
let stdout = "";
|
|
55
|
+
let stderr = "";
|
|
56
|
+
console.log = (...a: unknown[]) => {
|
|
57
|
+
stdout += `${a.map(String).join(" ")}\n`;
|
|
58
|
+
};
|
|
59
|
+
console.error = (...a: unknown[]) => {
|
|
60
|
+
stderr += `${a.map(String).join(" ")}\n`;
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
const code = await fn();
|
|
64
|
+
return { code, stdout, stderr };
|
|
65
|
+
} finally {
|
|
66
|
+
console.log = origLog;
|
|
67
|
+
console.error = origErr;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Synchronous git — for setup ops that never touch the in-process server. */
|
|
72
|
+
function git(
|
|
73
|
+
args: string[],
|
|
74
|
+
cwd: string,
|
|
75
|
+
env?: Record<string, string>,
|
|
76
|
+
): { code: number; err: string } {
|
|
77
|
+
const r = spawnSync("git", args, {
|
|
78
|
+
cwd,
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
|
81
|
+
});
|
|
82
|
+
return { code: r.status ?? -1, err: r.stderr ?? "" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Async git — for ops that talk to the in-process Bun.serve (avoids event-loop deadlock). */
|
|
86
|
+
async function gitAsync(
|
|
87
|
+
args: string[],
|
|
88
|
+
cwd: string,
|
|
89
|
+
env?: Record<string, string>,
|
|
90
|
+
): Promise<{ code: number; err: string }> {
|
|
91
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
92
|
+
cwd,
|
|
93
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env },
|
|
94
|
+
stdout: "pipe",
|
|
95
|
+
stderr: "pipe",
|
|
96
|
+
});
|
|
97
|
+
const [code, err] = await Promise.all([
|
|
98
|
+
proc.exited,
|
|
99
|
+
new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
|
|
100
|
+
]);
|
|
101
|
+
return { code, err };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// help / dispatch
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe("parachute surface dispatch", () => {
|
|
109
|
+
test("--help exits 0", async () => {
|
|
110
|
+
const { code, stdout } = await capture(() => surface(["--help"]));
|
|
111
|
+
expect(code).toBe(0);
|
|
112
|
+
expect(stdout).toContain("Deploy tokens");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("no args prints help + exits 1", async () => {
|
|
116
|
+
const { code } = await capture(() => surface([]));
|
|
117
|
+
expect(code).toBe(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("unknown subcommand exits 1", async () => {
|
|
121
|
+
const { code, stderr } = await capture(() => surface(["frobnicate"]));
|
|
122
|
+
expect(code).toBe(1);
|
|
123
|
+
expect(stderr).toContain("unknown subcommand");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("token with no action prints help + exits 1", async () => {
|
|
127
|
+
const { code } = await capture(() => surface(["token"]));
|
|
128
|
+
expect(code).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// mint
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe("parachute surface token mint", () => {
|
|
137
|
+
test("no operator.token is an actionable error", async () => {
|
|
138
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-surfcmd-"));
|
|
139
|
+
try {
|
|
140
|
+
const { code, stderr } = await capture(() =>
|
|
141
|
+
surface(["token", "mint", "foo"], { dbPath: hubDbPath(dir), configDir: dir }),
|
|
142
|
+
);
|
|
143
|
+
expect(code).toBe(1);
|
|
144
|
+
expect(stderr).toContain("operator.token");
|
|
145
|
+
} finally {
|
|
146
|
+
rmSync(dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("missing name is a usage error", async () => {
|
|
151
|
+
const t = await makeTmp();
|
|
152
|
+
try {
|
|
153
|
+
const { code, stderr } = await capture(() =>
|
|
154
|
+
surface(["token", "mint"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
155
|
+
);
|
|
156
|
+
expect(code).toBe(1);
|
|
157
|
+
expect(stderr).toContain("missing surface name");
|
|
158
|
+
} finally {
|
|
159
|
+
t.cleanup();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("--read and --write are mutually exclusive", async () => {
|
|
164
|
+
const t = await makeTmp();
|
|
165
|
+
try {
|
|
166
|
+
const { code, stderr } = await capture(() =>
|
|
167
|
+
surface(["token", "mint", "foo", "--read", "--write"], {
|
|
168
|
+
dbPath: t.dbPath,
|
|
169
|
+
configDir: t.dir,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
expect(code).toBe(1);
|
|
173
|
+
expect(stderr).toContain("mutually exclusive");
|
|
174
|
+
} finally {
|
|
175
|
+
t.cleanup();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("mints a write token: stdout is JUST the token, guidance on stderr", async () => {
|
|
180
|
+
const t = await makeTmp();
|
|
181
|
+
try {
|
|
182
|
+
const { code, stdout, stderr } = await capture(() =>
|
|
183
|
+
surface(["token", "mint", "gitcoin-brain", "--write"], {
|
|
184
|
+
dbPath: t.dbPath,
|
|
185
|
+
configDir: t.dir,
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
expect(code).toBe(0);
|
|
189
|
+
const token = stdout.trim();
|
|
190
|
+
expect(stdout).toBe(`${token}\n`); // pipe purity: token + newline only
|
|
191
|
+
expect(token.split(".").length).toBe(3);
|
|
192
|
+
// Guidance names the surface, the remote, and how to revoke.
|
|
193
|
+
expect(stderr).toContain("surface:gitcoin-brain:write");
|
|
194
|
+
expect(stderr).toContain("/git/gitcoin-brain");
|
|
195
|
+
expect(stderr).toContain("PARACHUTE_SURFACE_TOKEN");
|
|
196
|
+
expect(stderr).toContain("parachute surface token revoke");
|
|
197
|
+
|
|
198
|
+
const db = openHubDb(t.dbPath);
|
|
199
|
+
try {
|
|
200
|
+
const validated = await validateAccessToken(db, token, [ISSUER]);
|
|
201
|
+
expect(validated.payload.scope).toBe("surface:gitcoin-brain:write");
|
|
202
|
+
} finally {
|
|
203
|
+
db.close();
|
|
204
|
+
}
|
|
205
|
+
} finally {
|
|
206
|
+
t.cleanup();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("--json emits token + jti + scope + remoteUrl + helper", async () => {
|
|
211
|
+
const t = await makeTmp();
|
|
212
|
+
try {
|
|
213
|
+
const { code, stdout } = await capture(() =>
|
|
214
|
+
surface(["token", "mint", "docs", "--read", "--json"], {
|
|
215
|
+
dbPath: t.dbPath,
|
|
216
|
+
configDir: t.dir,
|
|
217
|
+
}),
|
|
218
|
+
);
|
|
219
|
+
expect(code).toBe(0);
|
|
220
|
+
const blob = JSON.parse(stdout) as Record<string, unknown>;
|
|
221
|
+
expect(blob.scope).toBe("surface:docs:read");
|
|
222
|
+
expect(blob.access).toBe("read");
|
|
223
|
+
expect(blob.surface).toBe("docs");
|
|
224
|
+
expect(String(blob.remoteUrl)).toContain("/git/docs");
|
|
225
|
+
expect(typeof blob.jti).toBe("string");
|
|
226
|
+
expect(String(blob.credentialHelper)).toContain("x-access-token");
|
|
227
|
+
} finally {
|
|
228
|
+
t.cleanup();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("--ttl parses a duration; a bad one errors", async () => {
|
|
233
|
+
const t = await makeTmp();
|
|
234
|
+
try {
|
|
235
|
+
const ok = await capture(() =>
|
|
236
|
+
surface(["token", "mint", "foo", "--ttl", "30d"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
237
|
+
);
|
|
238
|
+
expect(ok.code).toBe(0);
|
|
239
|
+
const bad = await capture(() =>
|
|
240
|
+
surface(["token", "mint", "foo", "--ttl", "banana"], {
|
|
241
|
+
dbPath: t.dbPath,
|
|
242
|
+
configDir: t.dir,
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
expect(bad.code).toBe(1);
|
|
246
|
+
expect(bad.stderr).toContain("invalid --ttl");
|
|
247
|
+
} finally {
|
|
248
|
+
t.cleanup();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("rejects a TTL over the 365d cap (--ttl and --expires-in)", async () => {
|
|
253
|
+
const t = await makeTmp();
|
|
254
|
+
try {
|
|
255
|
+
const overTtl = await capture(() =>
|
|
256
|
+
surface(["token", "mint", "foo", "--ttl", "400d"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
257
|
+
);
|
|
258
|
+
expect(overTtl.code).toBe(1);
|
|
259
|
+
expect(overTtl.stderr).toContain("365d cap");
|
|
260
|
+
|
|
261
|
+
const overSecs = await capture(() =>
|
|
262
|
+
surface(["token", "mint", "foo", "--expires-in", String(366 * 86400)], {
|
|
263
|
+
dbPath: t.dbPath,
|
|
264
|
+
configDir: t.dir,
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
expect(overSecs.code).toBe(1);
|
|
268
|
+
expect(overSecs.stderr).toContain("365d cap");
|
|
269
|
+
} finally {
|
|
270
|
+
t.cleanup();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("rejects an invalid surface name", async () => {
|
|
275
|
+
const t = await makeTmp();
|
|
276
|
+
try {
|
|
277
|
+
const { code, stderr } = await capture(() =>
|
|
278
|
+
surface(["token", "mint", "../evil"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
279
|
+
);
|
|
280
|
+
expect(code).toBe(1);
|
|
281
|
+
expect(stderr).toContain("invalid surface name");
|
|
282
|
+
} finally {
|
|
283
|
+
t.cleanup();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// list
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
describe("parachute surface token list", () => {
|
|
293
|
+
test("lists minted tokens; --json is structured; narrows by name", async () => {
|
|
294
|
+
const t = await makeTmp();
|
|
295
|
+
try {
|
|
296
|
+
const deps = { dbPath: t.dbPath, configDir: t.dir };
|
|
297
|
+
await capture(() => surface(["token", "mint", "alpha", "--write"], deps));
|
|
298
|
+
await capture(() => surface(["token", "mint", "beta", "--read"], deps));
|
|
299
|
+
|
|
300
|
+
const human = await capture(() => surface(["token", "list"], deps));
|
|
301
|
+
expect(human.code).toBe(0);
|
|
302
|
+
expect(human.stdout).toContain("alpha");
|
|
303
|
+
expect(human.stdout).toContain("beta");
|
|
304
|
+
expect(human.stdout).toContain("active");
|
|
305
|
+
|
|
306
|
+
const json = await capture(() => surface(["token", "list", "alpha", "--json"], deps));
|
|
307
|
+
const rows = JSON.parse(json.stdout) as Array<Record<string, unknown>>;
|
|
308
|
+
expect(rows.length).toBe(1);
|
|
309
|
+
expect(rows[0]?.name).toBe("alpha");
|
|
310
|
+
expect(rows[0]?.status).toBe("active");
|
|
311
|
+
// never leaks the token bytes
|
|
312
|
+
expect(json.stdout).not.toContain("eyJ");
|
|
313
|
+
} finally {
|
|
314
|
+
t.cleanup();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("empty list is a friendly message", async () => {
|
|
319
|
+
const t = await makeTmp();
|
|
320
|
+
try {
|
|
321
|
+
const { code, stdout } = await capture(() =>
|
|
322
|
+
surface(["token", "list"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
323
|
+
);
|
|
324
|
+
expect(code).toBe(0);
|
|
325
|
+
expect(stdout).toContain("No surface deploy tokens");
|
|
326
|
+
} finally {
|
|
327
|
+
t.cleanup();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// revoke
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
describe("parachute surface token revoke", () => {
|
|
337
|
+
test("revokes a minted token; re-revoke is idempotent; unknown jti errors", async () => {
|
|
338
|
+
const t = await makeTmp();
|
|
339
|
+
try {
|
|
340
|
+
const deps = { dbPath: t.dbPath, configDir: t.dir };
|
|
341
|
+
const mint = await capture(() =>
|
|
342
|
+
surface(["token", "mint", "foo", "--write", "--json"], deps),
|
|
343
|
+
);
|
|
344
|
+
const jti = String((JSON.parse(mint.stdout) as Record<string, unknown>).jti);
|
|
345
|
+
|
|
346
|
+
const r1 = await capture(() => surface(["token", "revoke", jti], deps));
|
|
347
|
+
expect(r1.code).toBe(0);
|
|
348
|
+
expect(r1.stdout).toContain("Revoked");
|
|
349
|
+
|
|
350
|
+
const r2 = await capture(() => surface(["token", "revoke", jti], deps));
|
|
351
|
+
expect(r2.code).toBe(0);
|
|
352
|
+
expect(r2.stdout).toContain("already revoked");
|
|
353
|
+
|
|
354
|
+
const r3 = await capture(() => surface(["token", "revoke", "nope"], deps));
|
|
355
|
+
expect(r3.code).toBe(1);
|
|
356
|
+
expect(r3.stderr).toContain("no surface deploy token");
|
|
357
|
+
} finally {
|
|
358
|
+
t.cleanup();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("missing jti is a usage error", async () => {
|
|
363
|
+
const t = await makeTmp();
|
|
364
|
+
try {
|
|
365
|
+
const { code, stderr } = await capture(() =>
|
|
366
|
+
surface(["token", "revoke"], { dbPath: t.dbPath, configDir: t.dir }),
|
|
367
|
+
);
|
|
368
|
+
expect(code).toBe(1);
|
|
369
|
+
expect(stderr).toContain("missing jti");
|
|
370
|
+
} finally {
|
|
371
|
+
t.cleanup();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// scope enforcement (handler-level, deterministic)
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe("deploy-token scope enforcement", () => {
|
|
381
|
+
test("a --read token cannot push (git-receive-pack → 403)", async () => {
|
|
382
|
+
const t = await makeTmp();
|
|
383
|
+
try {
|
|
384
|
+
const mint = await capture(() =>
|
|
385
|
+
surface(["token", "mint", "foo", "--read", "--json"], {
|
|
386
|
+
dbPath: t.dbPath,
|
|
387
|
+
configDir: t.dir,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
const token = String((JSON.parse(mint.stdout) as Record<string, unknown>).token);
|
|
391
|
+
const gitRoot = join(t.dir, "git");
|
|
392
|
+
const db = openHubDb(t.dbPath);
|
|
393
|
+
try {
|
|
394
|
+
const res = await handleGitTransport(
|
|
395
|
+
new Request("http://127.0.0.1/git/foo/info/refs?service=git-receive-pack", {
|
|
396
|
+
headers: { authorization: `Bearer ${token}` },
|
|
397
|
+
}),
|
|
398
|
+
{
|
|
399
|
+
db,
|
|
400
|
+
gitRoot,
|
|
401
|
+
knownIssuers: () => [ISSUER],
|
|
402
|
+
isDeclared: () => true,
|
|
403
|
+
ensureRepo: (name) => ensureSurfaceRepo(gitRoot, name),
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
expect(res.status).toBe(403);
|
|
407
|
+
} finally {
|
|
408
|
+
db.close();
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
t.cleanup();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
// real git push round-trip via the shipped credential helper
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("deploy token → real git push via git-credential-parachute", () => {
|
|
421
|
+
test("a command-minted write token pushes; revoke then blocks the next push", async () => {
|
|
422
|
+
const t = await makeTmp();
|
|
423
|
+
const gitRoot = join(t.dir, "git");
|
|
424
|
+
const work = mkdtempSync(join(tmpdir(), "phub-surfcmd-work-"));
|
|
425
|
+
// Mint via the COMMAND (own handle, closes before the server opens).
|
|
426
|
+
const mint = await capture(() =>
|
|
427
|
+
surface(["token", "mint", "foo", "--write", "--json"], {
|
|
428
|
+
dbPath: t.dbPath,
|
|
429
|
+
configDir: t.dir,
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
const parsed = JSON.parse(mint.stdout) as { token: string; jti: string };
|
|
433
|
+
|
|
434
|
+
// Declare the surface → provisions the bare repo (Phase-1 lifecycle).
|
|
435
|
+
await registerSurface(gitRoot, "foo");
|
|
436
|
+
|
|
437
|
+
const db = openHubDb(t.dbPath);
|
|
438
|
+
const server = Bun.serve({
|
|
439
|
+
port: 0,
|
|
440
|
+
hostname: "127.0.0.1",
|
|
441
|
+
fetch: (req) =>
|
|
442
|
+
handleGitTransport(req, {
|
|
443
|
+
db,
|
|
444
|
+
gitRoot,
|
|
445
|
+
knownIssuers: () => [ISSUER],
|
|
446
|
+
isDeclared: (name) => isSurfaceRegistered(gitRoot, name),
|
|
447
|
+
ensureRepo: (name) => ensureSurfaceRepo(gitRoot, name),
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
const base = `http://127.0.0.1:${server.port}`;
|
|
451
|
+
// The static-token mechanism: the SHIPPED helper reads $PARACHUTE_SURFACE_TOKEN.
|
|
452
|
+
const helperEnv = { PARACHUTE_SURFACE_TOKEN: parsed.token };
|
|
453
|
+
const helperCfg = `credential.helper=${HELPER}`;
|
|
454
|
+
try {
|
|
455
|
+
// Author a commit.
|
|
456
|
+
expect(git(["init", "-q", "-b", "main", work], tmpdir()).code).toBe(0);
|
|
457
|
+
git(["config", "user.email", "t@parachute.computer"], work);
|
|
458
|
+
git(["config", "user.name", "T"], work);
|
|
459
|
+
await Bun.write(join(work, "index.html"), "<h1>surface</h1>\n");
|
|
460
|
+
expect(git(["add", "-A"], work).code).toBe(0);
|
|
461
|
+
expect(git(["commit", "-q", "-m", "first"], work).code).toBe(0);
|
|
462
|
+
|
|
463
|
+
// Push using ONLY the static deploy token, supplied by the helper script.
|
|
464
|
+
const push = await gitAsync(
|
|
465
|
+
["-c", helperCfg, "push", `${base}/git/foo`, "main"],
|
|
466
|
+
work,
|
|
467
|
+
helperEnv,
|
|
468
|
+
);
|
|
469
|
+
expect(push.code).toBe(0);
|
|
470
|
+
expect(existsSync(join(gitRoot, "foo.git", "post-receive.log"))).toBe(true);
|
|
471
|
+
|
|
472
|
+
// Revoke the token (through the same DB handle the server validates on).
|
|
473
|
+
const { revokeSurfaceToken } = await import("../surface-token.ts");
|
|
474
|
+
expect(revokeSurfaceToken(db, parsed.jti, new Date()).status).toBe("revoked");
|
|
475
|
+
|
|
476
|
+
// A follow-up push with the now-revoked token is rejected (git exits non-zero).
|
|
477
|
+
await Bun.write(join(work, "index.html"), "<h1>v2</h1>\n");
|
|
478
|
+
git(["commit", "-qam", "second"], work);
|
|
479
|
+
const push2 = await gitAsync(
|
|
480
|
+
["-c", helperCfg, "push", `${base}/git/foo`, "main"],
|
|
481
|
+
work,
|
|
482
|
+
helperEnv,
|
|
483
|
+
);
|
|
484
|
+
expect(push2.code).not.toBe(0);
|
|
485
|
+
} finally {
|
|
486
|
+
server.stop(true);
|
|
487
|
+
db.close();
|
|
488
|
+
rmSync(work, { recursive: true, force: true });
|
|
489
|
+
t.cleanup();
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|