Skip to content

Commit f97cc1d

Browse files
authored
Merge pull request #4 from tokenhost/feat/build-artifacts-signing
SPEC 11: th build packages artifacts + UI bundle and signs manifest
2 parents 32c8c69 + b855004 commit f97cc1d

3 files changed

Lines changed: 286 additions & 21 deletions

File tree

.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,12 @@ TH_PRIVATE_KEY=0xreplace_me
2121
# Explorer verification (future: `th verify`)
2222
ETHERSCAN_API_KEY=replace_me
2323

24+
# --- Build/publish metadata (optional) ---
25+
# If set, th build/deploy will sign manifest.json (ed25519).
26+
# Provide either:
27+
# - TH_MANIFEST_SIGNING_KEY_PATH=/absolute/path/to/ed25519-private-key.pem
28+
# - TH_MANIFEST_SIGNING_KEY=base64:<pkcs8-der-base64>
29+
TH_MANIFEST_SIGNING_KEY_PATH=/path/to/ed25519-private-key.pem
30+
31+
# Optional: UI base URL recorded in manifest.json (defaults to a local file:// URL for ui-bundle/).
32+
TH_UI_BASE_URL=https://your-app.apps.tokenhost.com/

packages/cli/src/index.ts

Lines changed: 202 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import fs from 'fs';
2+
import os from 'os';
23
import path from 'path';
34
import crypto from 'crypto';
45
import { spawnSync } from 'child_process';
56
import { createRequire } from 'module';
6-
import { fileURLToPath } from 'url';
7+
import { fileURLToPath, pathToFileURL } from 'url';
78
import { Command } from 'commander';
89

910
import { generateAppSolidity } from '@tokenhost/generator';
@@ -112,6 +113,106 @@ function resolveNextExportUiTemplateDir(): string {
112113
);
113114
}
114115

116+
function toFileUrl(p: string): string {
117+
return pathToFileURL(path.resolve(p)).toString();
118+
}
119+
120+
function ensureTrailingSlash(url: string): string {
121+
return url.endsWith('/') ? url : `${url}/`;
122+
}
123+
124+
function runCommand(cmd: string, args: string[], opts?: { cwd?: string }) {
125+
const res = spawnSync(cmd, args, {
126+
cwd: opts?.cwd,
127+
stdio: 'inherit'
128+
});
129+
if (res.error && (res.error as any).code === 'ENOENT') {
130+
throw new Error(`${cmd} not found on PATH. Install it and retry.`);
131+
}
132+
if (res.status !== 0) {
133+
throw new Error(`${cmd} ${args.join(' ')} failed with exit code ${res.status ?? 'unknown'}`);
134+
}
135+
}
136+
137+
function runPnpmCommand(args: string[], opts?: { cwd?: string }) {
138+
// Prefer local/global pnpm; fall back to corepack if pnpm isn't installed.
139+
const res = spawnSync('pnpm', args, { cwd: opts?.cwd, stdio: 'inherit' });
140+
if (res.error && (res.error as any).code === 'ENOENT') {
141+
const res2 = spawnSync('corepack', ['pnpm', ...args], { cwd: opts?.cwd, stdio: 'inherit' });
142+
if (res2.error && (res2.error as any).code === 'ENOENT') {
143+
throw new Error(`pnpm not found. Install pnpm or enable corepack, then retry.`);
144+
}
145+
if (res2.status !== 0) {
146+
throw new Error(`corepack pnpm ${args.join(' ')} failed with exit code ${res2.status ?? 'unknown'}`);
147+
}
148+
return;
149+
}
150+
if (res.status !== 0) {
151+
throw new Error(`pnpm ${args.join(' ')} failed with exit code ${res.status ?? 'unknown'}`);
152+
}
153+
}
154+
155+
function renderThsTs(schema: ThsSchema): string {
156+
// Embed the full THS schema in the UI so it can render forms + routes without server-side code.
157+
return (
158+
`/*\n` +
159+
` * GENERATED FILE\n` +
160+
` *\n` +
161+
` * This file is generated by \`th generate\` from the THS schema.\n` +
162+
` */\n\n` +
163+
`export const ths = ${JSON.stringify(schema, null, 2)} as const;\n\n` +
164+
`export type Ths = typeof ths;\n`
165+
);
166+
}
167+
168+
function ensureEd25519PrivateKey(key: crypto.KeyObject): crypto.KeyObject {
169+
const type = (key as any).asymmetricKeyType as string | undefined;
170+
if (type && type !== 'ed25519') {
171+
throw new Error(`Manifest signing key must be Ed25519 (got ${type}).`);
172+
}
173+
return key;
174+
}
175+
176+
function loadManifestSigningKey(): crypto.KeyObject | null {
177+
const keyPath = process.env.TH_MANIFEST_SIGNING_KEY_PATH;
178+
if (keyPath) {
179+
const pem = fs.readFileSync(keyPath, 'utf-8');
180+
return ensureEd25519PrivateKey(crypto.createPrivateKey(pem));
181+
}
182+
183+
const env = process.env.TH_MANIFEST_SIGNING_KEY || process.env.TH_MANIFEST_SIGNING_PRIVATE_KEY;
184+
if (!env) return null;
185+
186+
const raw = env.trim();
187+
if (raw.startsWith('-----BEGIN')) {
188+
return ensureEd25519PrivateKey(crypto.createPrivateKey(raw));
189+
}
190+
191+
// Assume base64-encoded PKCS#8 DER.
192+
const b64 = raw.startsWith('base64:') ? raw.slice('base64:'.length) : raw;
193+
const der = Buffer.from(b64, 'base64');
194+
return ensureEd25519PrivateKey(crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' }));
195+
}
196+
197+
function computeKeyIdEd25519(privateKey: crypto.KeyObject): string {
198+
const pub = crypto.createPublicKey(privateKey);
199+
const spki = pub.export({ format: 'der', type: 'spki' }) as Buffer;
200+
return sha256Digest(spki);
201+
}
202+
203+
function signManifest(manifest: any, privateKey: crypto.KeyObject): { alg: string; keyId: string; sig: string } {
204+
// Sign the canonical manifest digest of the manifest with signatures removed.
205+
// Verifiers should recompute this same digest before verifying.
206+
const unsigned = { ...manifest, signatures: [] };
207+
const digest = computeSchemaHash(unsigned);
208+
const signature = crypto.sign(null, Buffer.from(digest, 'utf-8'), privateKey);
209+
return {
210+
alg: 'ed25519',
211+
keyId: computeKeyIdEd25519(privateKey),
212+
sig: signature.toString('base64')
213+
};
214+
}
215+
115216
function findUp(filename: string, startDir: string): string | null {
116217
let dir = path.resolve(startDir);
117218
while (true) {
@@ -450,17 +551,7 @@ program
450551

451552
const thsTsPath = path.join(uiDir, 'src', 'generated', 'ths.ts');
452553
ensureDir(path.dirname(thsTsPath));
453-
454-
// Embed the full THS schema in the UI so it can render forms + routes without server-side code.
455-
const thsTs =
456-
`/*\n` +
457-
` * GENERATED FILE\n` +
458-
` *\n` +
459-
` * This file is generated by \`th generate\` from the THS schema.\n` +
460-
` */\n\n` +
461-
`export const ths = ${JSON.stringify(schema, null, 2)} as const;\n\n` +
462-
`export type Ths = typeof ths;\n`;
463-
fs.writeFileSync(thsTsPath, thsTs);
554+
fs.writeFileSync(thsTsPath, renderThsTs(schema));
464555

465556
console.log(`Wrote ui/ (Next.js static export template)`);
466557
}
@@ -472,7 +563,8 @@ program
472563
.command('build')
473564
.argument('<schema>', 'Path to THS schema JSON file')
474565
.option('--out <dir>', 'Output directory', 'artifacts')
475-
.action((schemaPath: string, opts: { out: string }) => {
566+
.option('--no-ui', 'Do not generate/build UI bundle')
567+
.action((schemaPath: string, opts: { out: string; ui: boolean }) => {
476568
const input = readJsonFile(schemaPath);
477569
const structural = validateThsStructural(input);
478570
if (!structural.ok) {
@@ -515,14 +607,69 @@ program
515607
// 3) Write schema copy
516608
fs.writeFileSync(path.join(outDir, 'schema.json'), JSON.stringify(schema, null, 2));
517609

518-
// 4) Build a local (unsigned) manifest. This is spec-shaped but uses placeholders
519-
// for deployments/UI until `th deploy`/`th publish` are implemented.
610+
// 4) Package build artifacts (SPEC 11)
611+
const sourcesTgzPath = path.join(outDir, 'sources.tgz');
612+
const compiledTgzPath = path.join(outDir, 'compiled.tgz');
613+
runCommand('tar', ['-czf', sourcesTgzPath, '-C', outDir, path.dirname(appSol.path)]);
614+
runCommand('tar', ['-czf', compiledTgzPath, '-C', outDir, 'compiled']);
615+
616+
// 5) Build UI bundle (Next.js static export) (SPEC 8 / 11)
617+
const emptyUiBundleDigest = computeSchemaHash({ version: 1, files: [] });
618+
let uiBundleDigest = emptyUiBundleDigest;
619+
let uiBaseUrl = ensureTrailingSlash(process.env.TH_UI_BASE_URL ?? 'http://localhost/');
620+
let uiBundleDir: string | null = null;
621+
let uiSiteDir: string | null = null;
622+
623+
if (opts.ui) {
624+
uiBundleDir = path.join(outDir, 'ui-bundle');
625+
uiSiteDir = path.join(outDir, 'ui-site');
626+
fs.rmSync(uiBundleDir, { recursive: true, force: true });
627+
ensureDir(uiBundleDir);
628+
629+
const uiWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenhost-ui-build-'));
630+
try {
631+
const templateDir = resolveNextExportUiTemplateDir();
632+
copyDir(templateDir, uiWorkDir);
633+
634+
// Inject schema for client-side routing/forms.
635+
const thsTsPath = path.join(uiWorkDir, 'src', 'generated', 'ths.ts');
636+
ensureDir(path.dirname(thsTsPath));
637+
fs.writeFileSync(thsTsPath, renderThsTs(schema));
638+
639+
// Ship ABI alongside the UI so it can operate without additional servers.
640+
const compiledPublicPath = path.join(uiWorkDir, 'public', 'compiled', 'App.json');
641+
ensureDir(path.dirname(compiledPublicPath));
642+
fs.writeFileSync(compiledPublicPath, compiledJson);
643+
644+
// Do not bake a manifest into the UI bundle; it is published separately and signed.
645+
const bakedManifestPath = path.join(uiWorkDir, 'public', '.well-known', 'tokenhost', 'manifest.json');
646+
if (fs.existsSync(bakedManifestPath)) fs.rmSync(bakedManifestPath, { force: true });
647+
648+
runPnpmCommand(['install'], { cwd: uiWorkDir });
649+
runPnpmCommand(['build'], { cwd: uiWorkDir });
650+
651+
const exportedDir = path.join(uiWorkDir, 'out');
652+
if (!fs.existsSync(exportedDir)) {
653+
throw new Error(`UI build did not produce an export directory at ${exportedDir}.`);
654+
}
655+
656+
// Copy the static export output into the build output directory.
657+
copyDir(exportedDir, uiBundleDir);
658+
} finally {
659+
fs.rmSync(uiWorkDir, { recursive: true, force: true });
660+
}
661+
662+
uiBundleDigest = computeDirectoryDigest(uiBundleDir);
663+
uiBaseUrl = ensureTrailingSlash(process.env.TH_UI_BASE_URL ?? toFileUrl(uiSiteDir));
664+
}
665+
666+
// 6) Build a local manifest. This is spec-shaped but uses placeholder deployments
667+
// until `th deploy` updates it.
520668
const schemaHash = computeSchemaHash(schema);
521669
const sourcesDigest = computeDirectoryDigest(path.join(outDir, path.dirname(appSol.path)));
522670
const compiledDigest = computeDirectoryDigest(path.join(outDir, 'compiled'));
523671
const abiDigest = sha256Digest(JSON.stringify(compiled.abi));
524672
const bytecodeDigest = sha256Digest(compiled.bytecode);
525-
const emptyUiBundleDigest = computeSchemaHash({ version: 1, files: [] });
526673

527674
const features = {
528675
indexer: schema.app.features?.indexer ?? false,
@@ -559,8 +706,8 @@ program
559706
},
560707
collections,
561708
artifacts: {
562-
soliditySources: { digest: sourcesDigest },
563-
compiledContracts: { digest: compiledDigest }
709+
soliditySources: { digest: sourcesDigest, url: toFileUrl(sourcesTgzPath) },
710+
compiledContracts: { digest: compiledDigest, url: toFileUrl(compiledTgzPath) }
564711
},
565712
deployments: [
566713
{
@@ -585,14 +732,19 @@ program
585732
}
586733
],
587734
ui: {
588-
bundleHash: emptyUiBundleDigest,
589-
baseUrl: 'http://localhost/',
735+
bundleHash: uiBundleDigest,
736+
baseUrl: uiBaseUrl,
590737
wellKnown: '/.well-known/tokenhost/manifest.json'
591738
},
592739
features,
593740
signatures: [{ alg: 'none', sig: 'UNSIGNED' }]
594741
};
595742

743+
const signingKey = loadManifestSigningKey();
744+
if (signingKey) {
745+
manifest.signatures = [signManifest(manifest, signingKey)];
746+
}
747+
596748
// Validate manifest shape against the local JSON schema.
597749
const { ok, errors: manifestErrors } = validateManifest(manifest);
598750
if (!ok) {
@@ -603,9 +755,26 @@ program
603755
}
604756

605757
const manifestPath = path.join(outDir, 'manifest.json');
606-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
758+
const manifestJsonOut = JSON.stringify(manifest, null, 2);
759+
fs.writeFileSync(manifestPath, manifestJsonOut);
760+
761+
// Convenience: create a self-hostable static site root that includes the UI bundle + manifest.
762+
// Note: ui.bundleHash is computed over ui-bundle/ (UI code only), not this directory.
763+
if (uiBundleDir && uiSiteDir) {
764+
fs.rmSync(uiSiteDir, { recursive: true, force: true });
765+
copyDir(uiBundleDir, uiSiteDir);
766+
ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost'));
767+
fs.writeFileSync(path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'), manifestJsonOut);
768+
fs.writeFileSync(path.join(uiSiteDir, 'manifest.json'), manifestJsonOut);
769+
}
607770
console.log(`Wrote ${appSol.path}`);
608771
console.log(`Wrote compiled/App.json`);
772+
console.log(`Wrote sources.tgz`);
773+
console.log(`Wrote compiled.tgz`);
774+
if (uiBundleDir) {
775+
console.log(`Wrote ui-bundle/ (digest: ${uiBundleDigest})`);
776+
console.log(`Wrote ui-site/ (self-hostable static root)`);
777+
}
609778
console.log(`Wrote manifest.json`);
610779
});
611780

@@ -730,6 +899,18 @@ program
730899
throw new Error('Schema includes paid creates, but deployed contract has no treasuryAddress constructor.');
731900
}
732901

902+
// Re-sign manifest after mutating deployments.
903+
const signingKey = loadManifestSigningKey();
904+
if (signingKey) {
905+
manifest.signatures = [signManifest(manifest, signingKey)];
906+
} else {
907+
const hadRealSig = Array.isArray(manifest.signatures) && manifest.signatures.some((s: any) => s && s.alg && s.alg !== 'none');
908+
if (hadRealSig) {
909+
console.warn('WARN manifest: signing key not provided; clearing signatures and marking UNSIGNED');
910+
}
911+
manifest.signatures = [{ alg: 'none', sig: 'UNSIGNED' }];
912+
}
913+
733914
const validation = validateManifest(manifest);
734915
if (!validation.ok) {
735916
throw new Error(`Updated manifest failed validation:\n${JSON.stringify(validation.errors, null, 2)}`);

test/testCliBuildArtifacts.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect } from 'chai';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { spawnSync } from 'child_process';
6+
7+
function writeJson(filePath, value) {
8+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
9+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
10+
}
11+
12+
function runTh(args, cwd) {
13+
return spawnSync('node', [path.resolve('packages/cli/dist/index.js'), ...args], {
14+
cwd,
15+
encoding: 'utf-8'
16+
});
17+
}
18+
19+
function minimalSchema(overrides = {}) {
20+
return {
21+
thsVersion: '2025-12',
22+
schemaVersion: '0.0.1',
23+
app: {
24+
name: 'Build Artifacts Test',
25+
slug: 'build-artifacts-test',
26+
features: { uploads: false, onChainIndexing: true }
27+
},
28+
collections: [
29+
{
30+
name: 'Item',
31+
fields: [{ name: 'title', type: 'string', required: true }],
32+
createRules: { required: ['title'], access: 'public' },
33+
visibilityRules: { gets: ['title'], access: 'public' },
34+
updateRules: { mutable: ['title'], access: 'owner' },
35+
deleteRules: { softDelete: true, access: 'owner' },
36+
indexes: { unique: [], index: [] }
37+
}
38+
],
39+
...overrides
40+
};
41+
}
42+
43+
describe('th build (artifacts)', function () {
44+
it('emits sources.tgz + compiled.tgz + manifest (no UI)', function () {
45+
this.timeout(60000);
46+
47+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-build-artifacts-'));
48+
const schemaPath = path.join(dir, 'schema.json');
49+
const outDir = path.join(dir, 'out');
50+
writeJson(schemaPath, minimalSchema());
51+
52+
const res = runTh(['build', schemaPath, '--out', outDir, '--no-ui'], process.cwd());
53+
expect(res.status, res.stderr || res.stdout).to.equal(0);
54+
55+
for (const p of [
56+
'schema.json',
57+
'contracts/App.sol',
58+
'compiled/App.json',
59+
'manifest.json',
60+
'sources.tgz',
61+
'compiled.tgz'
62+
]) {
63+
expect(fs.existsSync(path.join(outDir, p)), `missing ${p}`).to.equal(true);
64+
}
65+
66+
expect(fs.existsSync(path.join(outDir, 'ui-bundle'))).to.equal(false);
67+
expect(fs.existsSync(path.join(outDir, 'ui-site'))).to.equal(false);
68+
69+
const manifest = JSON.parse(fs.readFileSync(path.join(outDir, 'manifest.json'), 'utf-8'));
70+
expect(manifest?.artifacts?.soliditySources?.url).to.match(/^file:\/\//);
71+
expect(manifest?.artifacts?.compiledContracts?.url).to.match(/^file:\/\//);
72+
expect(manifest?.signatures?.[0]?.sig).to.be.a('string');
73+
});
74+
});
75+

0 commit comments

Comments
 (0)