@openpalm/lib 0.11.0-rc.2 → 0.11.0-rc.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-rc.2",
3
+ "version": "0.11.0-rc.3",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
@@ -264,35 +264,135 @@ export function resolveUiBuildDir(): string {
264
264
  }
265
265
 
266
266
  /**
267
- * Install the UI build to OP_HOME/data/ui/.
268
- *
269
- * Copies from local packages/ui/build/ when running from source,
270
- * otherwise downloads ui-build.tar.gz from the GitHub release.
271
- * Called during install and update; always replaces existing content.
272
- *
273
- * data/ui/ is automatically included in backups because
274
- * backupOpenPalmHome() copies all of OP_HOME/data/.
267
+ * The UI ships as `@openpalm/ui` on npm — a self-contained `adapter-node`
268
+ * bundle (only `build/` is published; no `node_modules` is needed at runtime,
269
+ * because the build bundles every dependency). The desktop and host updaters
270
+ * fetch the registry TARBALL over plain HTTPS and verify its integrity hash;
271
+ * they never invoke a package manager (the Electron runtime has none). npm gives
272
+ * us, for free, the four things the GitHub-release path forced us to hand-roll:
273
+ * an independent version line, `latest`/`next` dist-tag channels (prerelease-
274
+ * aware unlike `releases/latest`, which silently excludes prereleases),
275
+ * immutable versions, and a sha512 integrity we verify fail-closed.
276
+ */
277
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
278
+ const UI_PACKAGE = '@openpalm/ui';
279
+
280
+ interface NpmUiManifest {
281
+ version: string;
282
+ tarball: string;
283
+ /** Subresource-integrity string ("sha512-<base64>"); null if the registry omitted it. */
284
+ integrity: string | null;
285
+ }
286
+
287
+ /** Strip a single leading 'v' so a release ref (v1.2.3) becomes an npm version (1.2.3). */
288
+ function toNpmVersion(repoRef: string): string {
289
+ return repoRef.replace(/^v/, '');
290
+ }
291
+
292
+ /**
293
+ * The npm dist-tag channel a release stream tracks: prereleases ride `next`,
294
+ * stable rides `latest`. `@openpalm/ui` is independently versioned (it publishes
295
+ * on its own `publish-ui.yml` workflow, like the channel adapters), so the
296
+ * desktop/host updaters can't compare a UI version against the app version —
297
+ * they pick the CHANNEL from the app's release stream and then resolve the
298
+ * newest UI on that channel.
275
299
  */
276
- /** SHA-256 hex digest of arbitrary bytes. */
277
- function sha256Hex(data: Uint8Array): string {
278
- return createHash('sha256').update(data).digest('hex');
300
+ export function uiUpdateChannel(appVersion: string): 'latest' | 'next' {
301
+ return appVersion.includes('-') ? 'next' : 'latest';
279
302
  }
280
303
 
281
304
  /**
282
- * Parse a `sha256sum`-format checksums file into a filename→hash map.
283
- * Each line is: `<hash> <filename>` (one or two spaces).
305
+ * Resolve the npm manifest for `@openpalm/ui` by exact version OR dist-tag.
306
+ * `GET <registry>/@openpalm/ui/<version-or-tag>` returns the abbreviated
307
+ * manifest (version + dist.tarball + dist.integrity). Throws on non-OK.
284
308
  */
285
- function parseChecksumsFile(content: string): Map<string, string> {
286
- const map = new Map<string, string>();
287
- for (const line of content.trim().split('\n')) {
288
- const parts = line.trim().split(/\s+/);
289
- if (parts.length >= 2) {
290
- map.set(parts[parts.length - 1], parts[0]);
309
+ async function fetchNpmUiManifest(versionOrTag: string): Promise<NpmUiManifest> {
310
+ const url = `${NPM_REGISTRY}/${UI_PACKAGE}/${versionOrTag}`;
311
+ const res = await fetchWithRetry(url);
312
+ if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status} for ${UI_PACKAGE}@${versionOrTag}`);
313
+ const m = await res.json() as { version?: string; dist?: { tarball?: string; integrity?: string } };
314
+ if (!m.version || !m.dist?.tarball) {
315
+ throw new Error(`npm manifest for ${UI_PACKAGE}@${versionOrTag} is missing version/dist.tarball`);
316
+ }
317
+ return { version: m.version, tarball: m.dist.tarball, integrity: m.dist.integrity ?? null };
318
+ }
319
+
320
+ /**
321
+ * Verify a Subresource-Integrity string against the bytes. FAIL-CLOSED: a
322
+ * present-but-wrong hash throws (the corruption / tamper case). A registry that
323
+ * omits the hash entirely (legacy metadata) is logged and allowed — modern npm
324
+ * always provides one, so this only affects pathological registry responses.
325
+ */
326
+ function verifyNpmIntegrity(data: Uint8Array, integrity: string): void {
327
+ const entries = integrity.trim().split(/\s+/);
328
+ const entry = entries.find(e => e.startsWith('sha512-')) ?? entries.find(e => e.startsWith('sha256-'));
329
+ if (!entry) throw new Error(`unrecognized integrity format: ${integrity}`);
330
+ const dash = entry.indexOf('-');
331
+ const algo = entry.slice(0, dash);
332
+ const expected = entry.slice(dash + 1);
333
+ const actual = createHash(algo).update(data).digest('base64');
334
+ if (actual !== expected) throw new Error(`UI bundle integrity mismatch (${algo})`);
335
+ }
336
+
337
+ /**
338
+ * Download `@openpalm/ui`'s npm tarball, verify integrity, and install its
339
+ * `build/` contents into `uiDir`. npm tarballs nest everything under `package/`
340
+ * and we publish `files: ["build"]`, so the bundle lives at `package/build/**` —
341
+ * strip 2 path components and filter to that subtree.
342
+ *
343
+ * FAIL-CLOSED + non-destructive: we throw if integrity is missing or mismatched
344
+ * (the contract is that npm always provides a sha512), and we extract into a
345
+ * STAGING dir and validate it has a runnable `index.js` before swapping it over
346
+ * `uiDir` — so a truncated download or bad tarball never leaves `uiDir` empty.
347
+ */
348
+ async function downloadNpmUiBundle(manifest: NpmUiManifest, uiDir: string, dataDir: string): Promise<void> {
349
+ const res = await fetchWithRetry(manifest.tarball);
350
+ if (!res.ok) throw new Error(`Failed to download UI bundle (HTTP ${res.status})`);
351
+ const data = new Uint8Array(await res.arrayBuffer());
352
+
353
+ // Verify BEFORE touching anything. Fail closed: a missing hash is treated as a
354
+ // verification failure, not a warning — modern npm always supplies dist.integrity,
355
+ // so its absence means a non-canonical/altered registry response.
356
+ if (!manifest.integrity) {
357
+ throw new Error(`npm manifest for ${UI_PACKAGE}@${manifest.version} has no integrity hash — refusing to install unverified`);
358
+ }
359
+ verifyNpmIntegrity(data, manifest.integrity);
360
+ logger.debug('UI bundle integrity verified', { version: manifest.version });
361
+
362
+ const tmpTar = join(dataDir, '.ui-build.tgz.tmp');
363
+ const staging = join(dataDir, '.ui-build.staging');
364
+ try {
365
+ rmSync(staging, { recursive: true, force: true });
366
+ mkdirSync(staging, { recursive: true });
367
+ writeFileSync(tmpTar, data);
368
+ await tarExtract({
369
+ file: tmpTar,
370
+ cwd: staging,
371
+ strip: 2,
372
+ filter: (p) => p.startsWith('package/build/'),
373
+ });
374
+ // Validate the staged build is runnable before destroying the live one.
375
+ if (!existsSync(join(staging, 'index.js'))) {
376
+ throw new Error('downloaded UI bundle is missing build/index.js');
291
377
  }
378
+ // Swap: only now do we remove the existing build and move staging into place.
379
+ rmSync(uiDir, { recursive: true, force: true });
380
+ renameSync(staging, uiDir);
381
+ } finally {
382
+ rmSync(tmpTar, { force: true });
383
+ rmSync(staging, { recursive: true, force: true });
292
384
  }
293
- return map;
294
385
  }
295
386
 
387
+ /**
388
+ * Install the UI build to OP_HOME/data/ui/.
389
+ *
390
+ * Copies from the local/bundled `packages/ui/build/` when available, otherwise
391
+ * downloads the `@openpalm/ui` bundle from npm (by exact version; `repoRef` may
392
+ * be a tag like `latest`/`next` via the admin route). Always replaces existing
393
+ * content. data/ui/ is included in backups because backupOpenPalmHome() copies
394
+ * all of OP_HOME/data/.
395
+ */
296
396
  export async function seedUiBuild(repoRef: string, dataDir: string, options?: { forceRemote?: boolean }): Promise<void> {
297
397
  const uiDir = join(dataDir, 'ui');
298
398
  mkdirSync(uiDir, { recursive: true });
@@ -301,54 +401,23 @@ export async function seedUiBuild(repoRef: string, dataDir: string, options?: {
301
401
  if (local) {
302
402
  logger.debug('seeding UI build from local source', { src: local });
303
403
  copyTree(local, uiDir);
404
+ // The build script (stamp-version.mjs) writes .openpalm-ui-version into build/.
405
+ // A local build missing it would seed an UNSTAMPED data/ui, which makes the
406
+ // update check unable to read the running UI version. Surface it loudly rather
407
+ // than silently degrade update behavior.
408
+ if (!readUiBuildVersion(uiDir)) {
409
+ logger.warn('seeded UI build has no version stamp — auto-update comparison will be unreliable', { src: local });
410
+ }
304
411
  return;
305
412
  }
306
413
 
307
- const base = `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${repoRef}`;
308
- const tarballUrl = `${base}/ui-build.tar.gz`;
309
- const checksumUrl = `${base}/checksums-sha256.txt`;
310
- logger.debug('downloading UI build', { url: tarballUrl });
311
-
312
- const tmpTar = join(dataDir, '.ui-build.tar.gz.tmp');
313
- try {
314
- // Download tarball and checksums file in parallel (checksums best-effort)
315
- const [tarRes, csRes] = await Promise.all([
316
- fetchWithRetry(tarballUrl),
317
- fetchWithRetry(checksumUrl).catch(() => null),
318
- ]);
319
- if (!tarRes.ok) throw new Error(`Failed to download UI build (HTTP ${tarRes.status})`);
320
-
321
- const tarData = new Uint8Array(await tarRes.arrayBuffer());
322
-
323
- // Verify SHA-256 if the checksums file was available
324
- if (csRes?.ok) {
325
- const checksums = parseChecksumsFile(await csRes.text());
326
- const expected = checksums.get('ui-build.tar.gz');
327
- if (expected) {
328
- const actual = sha256Hex(tarData);
329
- if (actual !== expected) {
330
- throw new Error(`UI build checksum mismatch (expected ${expected}, got ${actual})`);
331
- }
332
- logger.debug('UI build checksum verified', { sha256: actual });
333
- }
334
- }
335
-
336
- writeFileSync(tmpTar, tarData);
337
-
338
- // Clear stale files before extracting so old build files don't persist
339
- rmSync(uiDir, { recursive: true, force: true });
340
- mkdirSync(uiDir, { recursive: true });
341
- // Cross-platform extraction via the `tar` npm package — no shell dependency
342
- await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
343
- } finally {
344
- rmSync(tmpTar, { force: true });
345
- }
414
+ const manifest = await fetchNpmUiManifest(toNpmVersion(repoRef));
415
+ logger.debug('downloading UI build from npm', { version: manifest.version });
416
+ await downloadNpmUiBundle(manifest, uiDir, dataDir);
346
417
  }
347
418
 
348
419
  // ── UI update check ──────────────────────────────────────────────────────────
349
420
 
350
- const GITHUB_API = 'https://api.github.com';
351
-
352
421
  /** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. Handles pre-release tags. */
353
422
  function compareVersionTags(a: string, b: string): number {
354
423
  const parse = (v: string): [number, number, number, string | null] => {
@@ -398,48 +467,50 @@ export interface UiBuildUpdateResult {
398
467
  }
399
468
 
400
469
  /**
401
- * Check GitHub for a newer UI build and apply it if one exists.
470
+ * Check npm for a newer `@openpalm/ui` build and apply it if one exists.
471
+ *
472
+ * `@openpalm/ui` is INDEPENDENTLY versioned, so we do NOT compare against the
473
+ * app/platform version. We pick the dist-tag CHANNEL from the app's release
474
+ * stream (`appVersion`: prerelease → `next`, stable → `latest`) and compare the
475
+ * newest UI on that channel against the version actually on disk (the stamp in
476
+ * the resolved build). This tracks prerelease UIs for prerelease apps and fixes
477
+ * the `releases/latest`-excludes-prereleases blind spot.
402
478
  *
403
479
  * When an update is available:
404
480
  * 1. Move data/ui/ → data/backups/ui-{timestamp}/ (preserves the old build)
405
- * 2. Download ui-build.tar.gz from the latest release and extract to data/ui/
481
+ * 2. Download the npm bundle (integrity-verified) and extract to data/ui/
406
482
  *
407
483
  * Non-fatal: any network or extraction error returns { updated: false, error }.
408
484
  * The caller should proceed with the existing build on failure.
409
485
  */
410
486
  export async function checkAndUpdateUiBuild(
411
- currentVersion: string,
487
+ appVersion: string,
412
488
  dataDir: string,
413
489
  ): Promise<UiBuildUpdateResult> {
414
490
  try {
415
- const res = await fetch(
416
- `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
417
- {
418
- headers: { 'User-Agent': `OpenPalm/${currentVersion}` },
419
- signal: AbortSignal.timeout(10_000),
420
- },
421
- );
422
- if (!res.ok) {
423
- return { updated: false, latestVersion: null, error: `GitHub API returned ${res.status}` };
424
- }
425
-
426
- const release = await res.json() as {
427
- tag_name: string;
428
- assets: Array<{ name: string }>;
429
- };
430
- const latestTag = release.tag_name; // e.g. "v0.11.0"
431
- const latestVersion = latestTag.replace(/^v/, '');
432
-
433
- if (compareVersionTags(latestTag, currentVersion) <= 0) {
434
- logger.debug('UI build is up to date', { current: currentVersion, latest: latestVersion });
491
+ const channel = uiUpdateChannel(appVersion);
492
+ const manifest = await fetchNpmUiManifest(channel);
493
+ const latestVersion = manifest.version;
494
+
495
+ // Compare against the UI build currently on disk, NOT the app version — the
496
+ // UI floats on its own version line, so the platform/app version is not
497
+ // comparable. If the build is unstamped (e.g. a legacy data/ui seeded by the
498
+ // old GitHub-asset path before npm distribution), we cannot compare, so we
499
+ // refresh once from npm — the npm bundle is stamped, so it self-heals to the
500
+ // normal compare path on the next launch. Do NOT fall back to appVersion:
501
+ // comparing two independent version lines silently suppresses real updates.
502
+ const currentUiVersion = readUiBuildVersion(resolveUiBuildDir());
503
+
504
+ if (currentUiVersion && compareVersionTags(latestVersion, currentUiVersion) <= 0) {
505
+ logger.debug('UI build is up to date', { currentUi: currentUiVersion, latest: latestVersion, channel });
435
506
  return { updated: false, latestVersion };
436
507
  }
437
-
438
- if (!release.assets.some(a => a.name === 'ui-build.tar.gz')) {
439
- return { updated: false, latestVersion, error: 'Latest release has no ui-build.tar.gz' };
508
+ if (!currentUiVersion) {
509
+ logger.debug('UI build is unstamped — refreshing from npm to re-establish a known version', { latest: latestVersion, channel });
440
510
  }
441
511
 
442
- // Back up the existing UI build before replacing it
512
+ // Back up the existing UI build before replacing it. (Automatic rollback on
513
+ // a failed start is deferred — see ui-distribution-gap-analysis.md G1.)
443
514
  const uiDir = join(dataDir, 'ui');
444
515
  if (existsSync(join(uiDir, 'index.js'))) {
445
516
  const backupDir = join(resolveBackupsDir(), `ui-${Date.now()}`);
@@ -448,8 +519,8 @@ export async function checkAndUpdateUiBuild(
448
519
  logger.debug('backed up UI build before update', { backup: backupDir });
449
520
  }
450
521
 
451
- await seedUiBuild(latestTag, dataDir);
452
- logger.debug('UI build updated', { from: currentVersion, to: latestVersion });
522
+ await downloadNpmUiBundle(manifest, uiDir, dataDir);
523
+ logger.debug('UI build updated', { from: currentUiVersion ?? '(unstamped)', to: latestVersion });
453
524
 
454
525
  return { updated: true, latestVersion };
455
526
  } catch (err) {
package/src/index.ts CHANGED
@@ -380,4 +380,5 @@ export {
380
380
  resolveUiBuildDir,
381
381
  seedUiBuild,
382
382
  checkAndUpdateUiBuild,
383
+ uiUpdateChannel,
383
384
  } from "./control-plane/ui-assets.js";