@openparachute/vault 0.6.0 → 0.6.1
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 +25 -0
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-routes.test.ts +778 -19
- package/src/mirror-routes.ts +313 -26
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* - pollForToken: granted / pending / slow_down / expired / denied
|
|
8
8
|
* - fetchUser happy path
|
|
9
9
|
* - listRepos: single-page, paginated, truncated
|
|
10
|
-
* -
|
|
10
|
+
* - listInstallations: happy (user + org), empty, bad shape, API error
|
|
11
|
+
* - listInstallationRepos: happy, paginated/truncated, bad shape
|
|
12
|
+
* - createRepo happy path + GitHubApiError status (403 = no Administration)
|
|
13
|
+
* - app slug helpers + install URL
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { describe, expect, test } from "bun:test";
|
|
@@ -15,9 +18,15 @@ import { describe, expect, test } from "bun:test";
|
|
|
15
18
|
import {
|
|
16
19
|
createRepo,
|
|
17
20
|
fetchUser,
|
|
18
|
-
|
|
21
|
+
GITHUB_APP_SLUG_DEFAULT,
|
|
22
|
+
GITHUB_CLIENT_ID_DEFAULT,
|
|
23
|
+
getGithubAppSlug,
|
|
19
24
|
getGithubClientId,
|
|
25
|
+
GitHubApiError,
|
|
26
|
+
installUrlForSlug,
|
|
20
27
|
isPlaceholderClientId,
|
|
28
|
+
listInstallationRepos,
|
|
29
|
+
listInstallations,
|
|
21
30
|
listRepos,
|
|
22
31
|
pollForToken,
|
|
23
32
|
requestDeviceCode,
|
|
@@ -70,23 +79,68 @@ describe("client id helpers", () => {
|
|
|
70
79
|
}
|
|
71
80
|
});
|
|
72
81
|
|
|
73
|
-
test("getGithubClientId falls back to
|
|
82
|
+
test("getGithubClientId falls back to the shared-app default when env unset", () => {
|
|
74
83
|
const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
75
84
|
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
76
85
|
try {
|
|
77
|
-
expect(getGithubClientId()).toBe(
|
|
86
|
+
expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_DEFAULT);
|
|
78
87
|
} finally {
|
|
79
88
|
if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
|
|
80
89
|
}
|
|
81
90
|
});
|
|
82
91
|
|
|
83
|
-
test("
|
|
84
|
-
|
|
92
|
+
test("the shipped default is the registered Parachute GitHub App id", () => {
|
|
93
|
+
// Pin the exact value — it's public by design, and a typo'd constant
|
|
94
|
+
// would otherwise still pass a shape check.
|
|
95
|
+
expect(GITHUB_CLIENT_ID_DEFAULT).toBe("Iv23livaRF4VcvPhu3uB");
|
|
96
|
+
expect(isPlaceholderClientId(GITHUB_CLIENT_ID_DEFAULT)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("isPlaceholderClientId catches placeholder-shaped env overrides", () => {
|
|
100
|
+
expect(isPlaceholderClientId("Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE")).toBe(true);
|
|
85
101
|
expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
|
|
86
102
|
expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
|
|
87
103
|
});
|
|
88
104
|
});
|
|
89
105
|
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// App slug helpers
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("app slug helpers", () => {
|
|
111
|
+
test("getGithubAppSlug reads from env when set (BYO-app pairing)", () => {
|
|
112
|
+
const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
113
|
+
try {
|
|
114
|
+
process.env.PARACHUTE_GITHUB_APP_SLUG = "my-own-app";
|
|
115
|
+
expect(getGithubAppSlug()).toBe("my-own-app");
|
|
116
|
+
} finally {
|
|
117
|
+
if (prev === undefined) delete process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
118
|
+
else process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("getGithubAppSlug falls back to the shared-app default when env unset", () => {
|
|
123
|
+
const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
124
|
+
delete process.env.PARACHUTE_GITHUB_APP_SLUG;
|
|
125
|
+
try {
|
|
126
|
+
expect(getGithubAppSlug()).toBe(GITHUB_APP_SLUG_DEFAULT);
|
|
127
|
+
} finally {
|
|
128
|
+
if (prev !== undefined) process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("the shipped default slug is the registered Parachute GitHub App's", () => {
|
|
133
|
+
// Pin the exact value — a typo'd slug would build a 404 install link.
|
|
134
|
+
expect(GITHUB_APP_SLUG_DEFAULT).toBe("parachute-computer");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("installUrlForSlug builds the installations/new URL", () => {
|
|
138
|
+
expect(installUrlForSlug("parachute-computer")).toBe(
|
|
139
|
+
"https://github.com/apps/parachute-computer/installations/new",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
90
144
|
// ---------------------------------------------------------------------------
|
|
91
145
|
// requestDeviceCode
|
|
92
146
|
// ---------------------------------------------------------------------------
|
|
@@ -351,6 +405,187 @@ describe("listRepos", () => {
|
|
|
351
405
|
});
|
|
352
406
|
});
|
|
353
407
|
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// listInstallations
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
describe("listInstallations", () => {
|
|
413
|
+
test("returns user + org installations with typed fields", async () => {
|
|
414
|
+
const fetcher = mockFetch([
|
|
415
|
+
{
|
|
416
|
+
match: (u) => u.includes("/user/installations"),
|
|
417
|
+
response: {
|
|
418
|
+
ok: true,
|
|
419
|
+
status: 200,
|
|
420
|
+
body: {
|
|
421
|
+
total_count: 2,
|
|
422
|
+
installations: [
|
|
423
|
+
{
|
|
424
|
+
id: 101,
|
|
425
|
+
app_slug: "parachute-computer",
|
|
426
|
+
account: { login: "aaron", type: "User" },
|
|
427
|
+
repository_selection: "selected",
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: 202,
|
|
431
|
+
app_slug: "parachute-computer",
|
|
432
|
+
account: { login: "unforced-org", type: "Organization" },
|
|
433
|
+
repository_selection: "all",
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
]);
|
|
440
|
+
const installations = await listInstallations("ghu_test", fetcher);
|
|
441
|
+
expect(installations).toHaveLength(2);
|
|
442
|
+
expect(installations[0]!.id).toBe(101);
|
|
443
|
+
expect(installations[0]!.account.login).toBe("aaron");
|
|
444
|
+
expect(installations[0]!.account.type).toBe("User");
|
|
445
|
+
expect(installations[0]!.repository_selection).toBe("selected");
|
|
446
|
+
expect(installations[1]!.account.type).toBe("Organization");
|
|
447
|
+
expect(installations[1]!.app_slug).toBe("parachute-computer");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("empty installations array = authorized but not installed", async () => {
|
|
451
|
+
const fetcher = mockFetch([
|
|
452
|
+
{
|
|
453
|
+
match: () => true,
|
|
454
|
+
response: { ok: true, status: 200, body: { total_count: 0, installations: [] } },
|
|
455
|
+
},
|
|
456
|
+
]);
|
|
457
|
+
const installations = await listInstallations("ghu_test", fetcher);
|
|
458
|
+
expect(installations).toEqual([]);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("throws on a response missing the installations array", async () => {
|
|
462
|
+
const fetcher = mockFetch([
|
|
463
|
+
{
|
|
464
|
+
match: () => true,
|
|
465
|
+
response: { ok: true, status: 200, body: { total_count: 0 } },
|
|
466
|
+
},
|
|
467
|
+
]);
|
|
468
|
+
await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
|
|
469
|
+
/missing installations array/,
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("throws on an item missing required fields", async () => {
|
|
474
|
+
const fetcher = mockFetch([
|
|
475
|
+
{
|
|
476
|
+
match: () => true,
|
|
477
|
+
response: {
|
|
478
|
+
ok: true,
|
|
479
|
+
status: 200,
|
|
480
|
+
body: { total_count: 1, installations: [{ id: "not-a-number", account: null }] },
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
|
|
485
|
+
/missing required fields/,
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("throws GitHubApiError carrying the status on non-2xx", async () => {
|
|
490
|
+
const fetcher = mockFetch([
|
|
491
|
+
{
|
|
492
|
+
match: () => true,
|
|
493
|
+
response: { ok: false, status: 401, body: { message: "Bad credentials" } },
|
|
494
|
+
},
|
|
495
|
+
]);
|
|
496
|
+
try {
|
|
497
|
+
await listInstallations("ghu_revoked", fetcher);
|
|
498
|
+
throw new Error("expected listInstallations to throw");
|
|
499
|
+
} catch (err) {
|
|
500
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
501
|
+
expect((err as GitHubApiError).status).toBe(401);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// listInstallationRepos
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
function installationRepoItem(i: number, owner = "aaron"): Record<string, unknown> {
|
|
511
|
+
return {
|
|
512
|
+
name: `repo${i}`,
|
|
513
|
+
full_name: `${owner}/repo${i}`,
|
|
514
|
+
private: true,
|
|
515
|
+
html_url: `https://github.com/${owner}/repo${i}`,
|
|
516
|
+
description: null,
|
|
517
|
+
updated_at: "2026-06-10T00:00:00Z",
|
|
518
|
+
clone_url: `https://github.com/${owner}/repo${i}.git`,
|
|
519
|
+
owner: { login: owner },
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
describe("listInstallationRepos", () => {
|
|
524
|
+
test("single page returns GitHubRepoInfo shapes from the installation", async () => {
|
|
525
|
+
const fetcher = mockFetch([
|
|
526
|
+
{
|
|
527
|
+
match: (u) => u.includes("/user/installations/101/repositories") && u.includes("page=1"),
|
|
528
|
+
response: {
|
|
529
|
+
ok: true,
|
|
530
|
+
status: 200,
|
|
531
|
+
body: { total_count: 1, repositories: [installationRepoItem(0)] },
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
]);
|
|
535
|
+
const result = await listInstallationRepos("ghu_test", 101, { perPage: 100, maxPages: 3 }, fetcher);
|
|
536
|
+
expect(result.repos).toHaveLength(1);
|
|
537
|
+
expect(result.truncated).toBe(false);
|
|
538
|
+
expect(result.repos[0]!.full_name).toBe("aaron/repo0");
|
|
539
|
+
expect(result.repos[0]!.owner).toBe("aaron");
|
|
540
|
+
expect(result.repos[0]!.private).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("paginates until a short page; truncates at the maxPages cap", async () => {
|
|
544
|
+
const fullPage = { total_count: 4, repositories: [installationRepoItem(0), installationRepoItem(1)] };
|
|
545
|
+
const fetcher = mockFetch([
|
|
546
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
547
|
+
{ match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
|
|
548
|
+
]);
|
|
549
|
+
const result = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 2 }, fetcher);
|
|
550
|
+
expect(result.repos).toHaveLength(4);
|
|
551
|
+
expect(result.truncated).toBe(true);
|
|
552
|
+
|
|
553
|
+
// Short-page end: page 2 has fewer than perPage → untruncated.
|
|
554
|
+
const fetcher2 = mockFetch([
|
|
555
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
556
|
+
{
|
|
557
|
+
match: (u) => u.includes("page=2"),
|
|
558
|
+
response: { ok: true, status: 200, body: { total_count: 3, repositories: [installationRepoItem(2)] } },
|
|
559
|
+
},
|
|
560
|
+
]);
|
|
561
|
+
const result2 = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 3 }, fetcher2);
|
|
562
|
+
expect(result2.repos).toHaveLength(3);
|
|
563
|
+
expect(result2.truncated).toBe(false);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("throws on a response missing the repositories array", async () => {
|
|
567
|
+
const fetcher = mockFetch([
|
|
568
|
+
{ match: () => true, response: { ok: true, status: 200, body: { total_count: 0 } } },
|
|
569
|
+
]);
|
|
570
|
+
await expect(
|
|
571
|
+
listInstallationRepos("ghu_test", 101, {}, fetcher),
|
|
572
|
+
).rejects.toThrow(/missing repositories array/);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("throws GitHubApiError carrying the status on non-2xx", async () => {
|
|
576
|
+
const fetcher = mockFetch([
|
|
577
|
+
{ match: () => true, response: { ok: false, status: 404, body: { message: "Not Found" } } },
|
|
578
|
+
]);
|
|
579
|
+
try {
|
|
580
|
+
await listInstallationRepos("ghu_test", 999, {}, fetcher);
|
|
581
|
+
throw new Error("expected listInstallationRepos to throw");
|
|
582
|
+
} catch (err) {
|
|
583
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
584
|
+
expect((err as GitHubApiError).status).toBe(404);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
354
589
|
// ---------------------------------------------------------------------------
|
|
355
590
|
// createRepo
|
|
356
591
|
// ---------------------------------------------------------------------------
|
|
@@ -401,4 +636,28 @@ describe("createRepo", () => {
|
|
|
401
636
|
createRepo("gho_test", { name: "exists" }, fetcher),
|
|
402
637
|
).rejects.toThrow(/already exists/);
|
|
403
638
|
});
|
|
639
|
+
|
|
640
|
+
test("403 (shared Contents-only app) throws GitHubApiError with status 403", async () => {
|
|
641
|
+
// POST /user/repos needs Administration:write — the shared app's
|
|
642
|
+
// expected failure. The route maps this status to the guided-manual
|
|
643
|
+
// error, so the status must survive the throw.
|
|
644
|
+
const fetcher = mockFetch([
|
|
645
|
+
{
|
|
646
|
+
match: () => true,
|
|
647
|
+
response: {
|
|
648
|
+
ok: false,
|
|
649
|
+
status: 403,
|
|
650
|
+
body: { message: "Resource not accessible by integration" },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
]);
|
|
654
|
+
try {
|
|
655
|
+
await createRepo("ghu_test", { name: "my-vault" }, fetcher);
|
|
656
|
+
throw new Error("expected createRepo to throw");
|
|
657
|
+
} catch (err) {
|
|
658
|
+
expect(err).toBeInstanceOf(GitHubApiError);
|
|
659
|
+
expect((err as GitHubApiError).status).toBe(403);
|
|
660
|
+
expect((err as Error).message).toContain("Resource not accessible");
|
|
661
|
+
}
|
|
662
|
+
});
|
|
404
663
|
});
|