Skip to content
This repository was archived by the owner on May 23, 2026. It is now read-only.

Commit a3cfa2c

Browse files
Verify extension CDN packages
1 parent 3a191cc commit a3cfa2c

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ on:
1414
- "registry.json"
1515
- "index.json"
1616
- "manifests.json"
17+
- "scripts/deploy-extensions-cdn.ts"
18+
- "scripts/verify-installable-packages.ts"
19+
- ".github/workflows/deploy.yml"
1720
workflow_dispatch:
1821

1922
jobs:
@@ -77,3 +80,6 @@ jobs:
7780
remote_user: ${{ secrets.VPS_USER }}
7881
remote_key: ${{ secrets.VPS_SSH_KEY }}
7982
remote_port: ${{ secrets.VPS_PORT || 22 }}
83+
84+
- name: Verify installable packages on CDN
85+
run: bun scripts/verify-installable-packages.ts

scripts/deploy-extensions-cdn.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
#!/usr/bin/env bun
22

33
import { $ } from "bun";
4+
import { createHash } from "node:crypto";
5+
import { readFile, stat } from "node:fs/promises";
46
import { join, resolve } from "node:path";
57

68
const root = resolve(import.meta.dirname, "..");
79
const extensionsDir = join(root, "extensions");
810
const packagesDir = join(root, "packages");
11+
const databaseDir = join(root, "database");
912
const targetDir = process.env.EXTENSIONS_CDN_ROOT;
13+
const cdnBaseUrl = process.env.EXTENSIONS_CDN_BASE_URL || "https://athas.dev/extensions";
1014

1115
if (!targetDir) {
1216
console.error("Missing EXTENSIONS_CDN_ROOT environment variable.");
@@ -25,9 +29,98 @@ await $`rsync -az --include='*/' --include='*.json' --include='*.scm' --include=
2529
// Sync packaged installable extensions
2630
await $`rsync -az ${packagesDir}/ ${targetDir}/packages/`;
2731

32+
// Sync packaged database sidecars
33+
await $`test ! -d ${databaseDir} || rsync -az ${databaseDir}/ ${targetDir}/database/`;
34+
2835
// Sync root-level registry files
2936
for (const file of ["registry.json", "index.json", "manifests.json"]) {
3037
await $`cp ${join(root, file)} ${targetDir}/${file}`;
3138
}
3239

40+
type InstallablePackage = {
41+
url: string;
42+
size: number;
43+
checksum: string;
44+
};
45+
46+
function collectInstallablePackages(value: unknown, packages: InstallablePackage[] = []) {
47+
if (Array.isArray(value)) {
48+
for (const item of value) {
49+
collectInstallablePackages(item, packages);
50+
}
51+
return packages;
52+
}
53+
54+
if (!value || typeof value !== "object") {
55+
return packages;
56+
}
57+
58+
const entry = value as Record<string, unknown>;
59+
if (
60+
typeof entry.downloadUrl === "string" &&
61+
typeof entry.size === "number" &&
62+
entry.size > 0 &&
63+
typeof entry.checksum === "string" &&
64+
entry.checksum.length > 0
65+
) {
66+
packages.push({
67+
url: entry.downloadUrl,
68+
size: entry.size,
69+
checksum: entry.checksum,
70+
});
71+
}
72+
73+
for (const item of Object.values(entry)) {
74+
collectInstallablePackages(item, packages);
75+
}
76+
77+
return packages;
78+
}
79+
80+
async function sha256(path: string) {
81+
const bytes = await readFile(path);
82+
return createHash("sha256").update(bytes).digest("hex");
83+
}
84+
85+
async function verifyInstallablePackages() {
86+
const manifests = JSON.parse(await readFile(join(root, "manifests.json"), "utf8")) as unknown;
87+
const cdnPrefix = `${cdnBaseUrl.replace(/\/$/, "")}/`;
88+
const failures: string[] = [];
89+
const installablePackages = new Map(
90+
collectInstallablePackages(manifests).map((installablePackage) => [
91+
installablePackage.url,
92+
installablePackage,
93+
]),
94+
);
95+
96+
for (const installablePackage of installablePackages.values()) {
97+
if (!installablePackage.url.startsWith(cdnPrefix)) {
98+
continue;
99+
}
100+
101+
const relativePath = installablePackage.url.slice(cdnPrefix.length);
102+
const deployedPath = join(targetDir!, relativePath);
103+
104+
try {
105+
const fileStats = await stat(deployedPath);
106+
const checksum = await sha256(deployedPath);
107+
if (fileStats.size !== installablePackage.size || checksum !== installablePackage.checksum) {
108+
failures.push(
109+
`${relativePath}: expected ${installablePackage.size}/${installablePackage.checksum}, got ${fileStats.size}/${checksum}`,
110+
);
111+
}
112+
} catch (error) {
113+
failures.push(
114+
`${relativePath}: ${error instanceof Error ? error.message : String(error)}`,
115+
);
116+
}
117+
}
118+
119+
if (failures.length > 0) {
120+
throw new Error(`Extension CDN verification failed:\n${failures.join("\n")}`);
121+
}
122+
}
123+
124+
await verifyInstallablePackages();
125+
33126
console.log("Extensions CDN sync complete.");
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env bun
2+
3+
import { createHash } from "node:crypto";
4+
import { readFile } from "node:fs/promises";
5+
import { join, resolve } from "node:path";
6+
7+
type InstallablePackage = {
8+
url: string;
9+
size: number;
10+
checksum: string;
11+
};
12+
13+
const root = resolve(import.meta.dirname, "..");
14+
const cdnBaseUrl = process.env.EXTENSIONS_CDN_BASE_URL || "https://athas.dev/extensions";
15+
16+
function collectInstallablePackages(value: unknown, packages: InstallablePackage[] = []) {
17+
if (Array.isArray(value)) {
18+
for (const item of value) {
19+
collectInstallablePackages(item, packages);
20+
}
21+
return packages;
22+
}
23+
24+
if (!value || typeof value !== "object") {
25+
return packages;
26+
}
27+
28+
const entry = value as Record<string, unknown>;
29+
if (
30+
typeof entry.downloadUrl === "string" &&
31+
typeof entry.size === "number" &&
32+
entry.size > 0 &&
33+
typeof entry.checksum === "string" &&
34+
entry.checksum.length > 0
35+
) {
36+
packages.push({
37+
url: entry.downloadUrl,
38+
size: entry.size,
39+
checksum: entry.checksum,
40+
});
41+
}
42+
43+
for (const item of Object.values(entry)) {
44+
collectInstallablePackages(item, packages);
45+
}
46+
47+
return packages;
48+
}
49+
50+
function sha256(bytes: Uint8Array) {
51+
return createHash("sha256").update(bytes).digest("hex");
52+
}
53+
54+
async function verifyRemotePackage(installablePackage: InstallablePackage) {
55+
const url = new URL(installablePackage.url);
56+
url.searchParams.set("verify", String(Date.now()));
57+
58+
const response = await fetch(url, { cache: "no-store" });
59+
if (!response.ok) {
60+
return `HTTP ${response.status} for ${installablePackage.url}`;
61+
}
62+
63+
const bytes = new Uint8Array(await response.arrayBuffer());
64+
const checksum = sha256(bytes);
65+
66+
if (bytes.byteLength !== installablePackage.size || checksum !== installablePackage.checksum) {
67+
return `${installablePackage.url}: expected ${installablePackage.size}/${installablePackage.checksum}, got ${bytes.byteLength}/${checksum}`;
68+
}
69+
70+
return null;
71+
}
72+
73+
const manifests = JSON.parse(await readFile(join(root, "manifests.json"), "utf8")) as unknown;
74+
const cdnPrefix = `${cdnBaseUrl.replace(/\/$/, "")}/`;
75+
const installablePackages = new Map(
76+
collectInstallablePackages(manifests)
77+
.filter((installablePackage) => installablePackage.url.startsWith(cdnPrefix))
78+
.map((installablePackage) => [installablePackage.url, installablePackage]),
79+
);
80+
81+
const failures: string[] = [];
82+
83+
for (const installablePackage of installablePackages.values()) {
84+
const failure = await verifyRemotePackage(installablePackage);
85+
if (failure) {
86+
failures.push(failure);
87+
}
88+
}
89+
90+
if (failures.length > 0) {
91+
console.error(`Extension package verification failed:\n${failures.join("\n")}`);
92+
process.exit(1);
93+
}
94+
95+
console.log(`Verified ${installablePackages.size} installable extension package(s).`);

0 commit comments

Comments
 (0)