@mindees/updates 0.1.0

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.
@@ -0,0 +1,83 @@
1
+ import { SignedManifest } from "./signing.js";
2
+
3
+ //#region src/server.d.ts
4
+ /** A published release the server can offer. */
5
+ interface PublishedRelease {
6
+ /** The pre-signed manifest (the server never signs — signing is offline). */
7
+ readonly signed: SignedManifest;
8
+ /** Release channel; defaults to `"stable"`. */
9
+ readonly channel?: string;
10
+ /** Staged-rollout percentage in `[0, 100]`. Default 100 (everyone). */
11
+ readonly rollout?: number;
12
+ }
13
+ /** An operator directive to roll a channel's clients back to the embedded build. */
14
+ interface RollbackDirective {
15
+ /** Channel the directive applies to; defaults to `"stable"`. */
16
+ readonly channel?: string;
17
+ /** Applies to clients whose current version is ≥ this. Default 0 (all). */
18
+ readonly sinceVersion?: number;
19
+ }
20
+ /** Injected server I/O: the release catalog + asset bytes (+ optional rollbacks). */
21
+ interface UpdateServerStore {
22
+ /** Every published release the server may select among. */
23
+ listReleases(): Promise<readonly PublishedRelease[]>;
24
+ /** Asset bytes by lowercase-hex SHA-256, or `null` if absent. */
25
+ getAsset(sha256: string): Promise<Uint8Array | null>;
26
+ /** Active rollback directives. Optional; default none. */
27
+ listRollbacks?(): Promise<readonly RollbackDirective[]>;
28
+ }
29
+ /** A client's update query. */
30
+ interface ResolveUpdateQuery {
31
+ /** The client's native runtime version (must match a release exactly). */
32
+ readonly runtimeVersion: string;
33
+ /** Channel to resolve against; defaults to `"stable"`. */
34
+ readonly channel?: string;
35
+ /**
36
+ * The client's anti-downgrade floor — its **high-water mark** (`state().highestVersion`),
37
+ * not necessarily the version currently running (which can be lower after a rollback).
38
+ * The server never offers a version `≤` this. Default 0. A non-integer/negative value
39
+ * is treated as 0 (fail closed).
40
+ */
41
+ readonly currentVersion?: number;
42
+ /** A stable per-device id for deterministic staged-rollout bucketing. */
43
+ readonly rolloutKey?: string;
44
+ }
45
+ /** The outcome of {@link UpdateServer.resolveUpdate}. */
46
+ type UpdateResolution = {
47
+ readonly type: 'update';
48
+ readonly signed: SignedManifest;
49
+ } | {
50
+ readonly type: 'no-update';
51
+ } | {
52
+ readonly type: 'roll-back-to-embedded';
53
+ };
54
+ /** Options for {@link createUpdateServer}. */
55
+ interface UpdateServerOptions {
56
+ /** Injected release/asset/rollback I/O. */
57
+ readonly store: UpdateServerStore;
58
+ /** Clock, for expiry checks + tests. Default `() => Date.now()`. */
59
+ readonly now?: () => number;
60
+ }
61
+ /** The reference update server. */
62
+ interface UpdateServer {
63
+ /** Resolve the best update for a client query (or no-update / roll-back). */
64
+ resolveUpdate(query: ResolveUpdateQuery): Promise<UpdateResolution>;
65
+ /** Serve an asset's bytes by SHA-256 (or `null` if absent / malformed address). */
66
+ getAsset(sha256: string): Promise<Uint8Array | null>;
67
+ }
68
+ /** Create a {@link UpdateServer}. */
69
+ declare function createUpdateServer(options: UpdateServerOptions): UpdateServer;
70
+ /** A mutable in-memory {@link UpdateServerStore} for tests, examples, and reference. */
71
+ interface MemoryUpdateServerStore extends UpdateServerStore {
72
+ /** Publish a release. */
73
+ publish(release: PublishedRelease): void;
74
+ /** Store an asset's bytes under its SHA-256. */
75
+ putAsset(sha256: string, bytes: Uint8Array): void;
76
+ /** Post a rollback directive. */
77
+ rollback(directive: RollbackDirective): void;
78
+ }
79
+ /** Create an in-memory {@link UpdateServerStore}. */
80
+ declare function createMemoryUpdateServerStore(): MemoryUpdateServerStore;
81
+ //#endregion
82
+ export { MemoryUpdateServerStore, PublishedRelease, ResolveUpdateQuery, RollbackDirective, UpdateResolution, UpdateServer, UpdateServerOptions, UpdateServerStore, createMemoryUpdateServerStore, createUpdateServer };
83
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","names":[],"sources":["../src/server.ts"],"mappings":";;;AAyCA;AAAA,UAlBiB,gBAAA;;WAEN,MAAA,EAAQ,cAAc;EAkBf;EAAA,SAhBP,OAAA;EAkBiB;EAAA,SAhBjB,OAAA;AAAA;;UAIM,iBAAA;EAUf;EAAA,SARS,OAAA;EAQwB;EAAA,SANxB,YAAY;AAAA;;UAIN,iBAAA;EAMf;EAJA,YAAA,IAAgB,OAAA,UAAiB,gBAAA;EAIE;EAFnC,QAAA,CAAS,MAAA,WAAiB,OAAA,CAAQ,UAAA;EAEkB;EAApD,aAAA,KAAkB,OAAA,UAAiB,iBAAA;AAAA;;UAIpB,kBAAA;EAEN;EAAA,SAAA,cAAA;EASA;EAAA,SAPA,OAAA;EASU;AAAA;AAIrB;;;;EAJqB,SAFV,cAAA;EAO6B;EAAA,SAL7B,UAAA;AAAA;;KAIC,gBAAA;EAAA,SACG,IAAA;EAAA,SAAyB,MAAA,EAAQ,cAAc;AAAA;EAAA,SAC/C,IAAA;AAAA;EAAA,SACA,IAAA;AAAA;;UAGE,mBAAA;EAIH;EAAA,SAFH,KAAA,EAAO,iBAAiB;EAMN;EAAA,SAJlB,GAAA;AAAA;;UAIM,YAAA;EAImB;EAFlC,aAAA,CAAc,KAAA,EAAO,kBAAA,GAAqB,OAAA,CAAQ,gBAAA;EAEjB;EAAjC,QAAA,CAAS,MAAA,WAAiB,OAAA,CAAQ,UAAA;AAAA;;iBAqBpB,kBAAA,CAAmB,OAAA,EAAS,mBAAA,GAAsB,YAAY;;UA6D7D,uBAAA,SAAgC,iBAAA;EAlF/C;EAoFA,OAAA,CAAQ,OAAA,EAAS,gBAAA;EApFS;EAsF1B,QAAA,CAAS,MAAA,UAAgB,KAAA,EAAO,UAAA;EAtFY;EAwF5C,QAAA,CAAS,SAAA,EAAW,iBAAA;AAAA;;iBAIN,6BAAA,IAAiC,uBAAuB"}
package/dist/server.js ADDED
@@ -0,0 +1,109 @@
1
+ import { sha256Hex, utf8 } from "./crypto.js";
2
+ import { parseManifest } from "./manifest.js";
3
+ //#region src/server.ts
4
+ /**
5
+ * The Pulse **reference update server** core — the other side of the wire from the
6
+ * {@link "./client".UpdateClient}. It is a pure, capability-injected function of an
7
+ * {@link UpdateServerStore} (the release list + asset bytes + optional rollback
8
+ * directives) and a clock, so selection / staged-rollout / anti-downgrade / freeze
9
+ * logic is deterministic and unit-testable with no network and no native I/O.
10
+ *
11
+ * It is exported from the **`@mindees/updates/server`** subpath, never the device
12
+ * entry, so a client bundle never pulls server code. A thin `node:http` adapter lives
13
+ * in `examples/pulse-server/`.
14
+ *
15
+ * **The server never signs.** Signing is an offline build step (see `signing.ts`); the
16
+ * store holds only pre-signed {@link SignedManifest} objects, and the server returns
17
+ * them verbatim — it holds no private key. See `docs/adr/0010-pulse-reference-server.md`.
18
+ *
19
+ * @module
20
+ */
21
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
22
+ const UINT32 = 4294967296;
23
+ /** Deterministic `[0, 100)` cohort bucket for a (release, device) pair. */
24
+ function rolloutBucket(manifestId, rolloutKey) {
25
+ const hex = sha256Hex(utf8(`${manifestId}:${rolloutKey}`)).slice(0, 8);
26
+ return Number.parseInt(hex, 16) / UINT32 * 100;
27
+ }
28
+ /** Whether a device is eligible for a release at the given rollout percentage. */
29
+ function isEligible(manifestId, rollout, rolloutKey) {
30
+ if (rollout >= 100) return true;
31
+ if (rollout <= 0) return false;
32
+ if (rolloutKey === void 0) return false;
33
+ return rolloutBucket(manifestId, rolloutKey) < rollout;
34
+ }
35
+ /** Create a {@link UpdateServer}. */
36
+ function createUpdateServer(options) {
37
+ const { store, now = () => Date.now() } = options;
38
+ return {
39
+ async resolveUpdate(query) {
40
+ const channel = query.channel ?? "stable";
41
+ const cv = query.currentVersion;
42
+ const currentVersion = typeof cv === "number" && Number.isInteger(cv) && cv >= 0 ? cv : 0;
43
+ const rollbacks = store.listRollbacks ? await store.listRollbacks() : [];
44
+ for (const rb of rollbacks) if ((rb.channel ?? "stable") === channel && currentVersion >= (rb.sinceVersion ?? 0)) return { type: "roll-back-to-embedded" };
45
+ const releases = await store.listReleases();
46
+ let best = null;
47
+ for (const rel of releases) {
48
+ if ((rel.channel ?? "stable") !== channel) continue;
49
+ let id;
50
+ let version;
51
+ let runtimeVersion;
52
+ let expires;
53
+ try {
54
+ const m = parseManifest(rel.signed.manifest);
55
+ id = m.id;
56
+ version = m.version;
57
+ runtimeVersion = m.runtimeVersion;
58
+ expires = m.expires;
59
+ } catch {
60
+ continue;
61
+ }
62
+ if (runtimeVersion !== query.runtimeVersion) continue;
63
+ if (version <= currentVersion) continue;
64
+ if (expires !== void 0 && Date.parse(expires) <= now()) continue;
65
+ if (!isEligible(id, rel.rollout ?? 100, query.rolloutKey)) continue;
66
+ if (!best || version > best.version || version === best.version && id < best.id) best = {
67
+ version,
68
+ id,
69
+ signed: rel.signed
70
+ };
71
+ }
72
+ return best ? {
73
+ type: "update",
74
+ signed: best.signed
75
+ } : { type: "no-update" };
76
+ },
77
+ getAsset(sha256) {
78
+ if (!SHA256_HEX.test(sha256)) return Promise.resolve(null);
79
+ return store.getAsset(sha256);
80
+ }
81
+ };
82
+ }
83
+ /** Create an in-memory {@link UpdateServerStore}. */
84
+ function createMemoryUpdateServerStore() {
85
+ const releases = [];
86
+ const rollbacks = [];
87
+ const assets = /* @__PURE__ */ new Map();
88
+ return {
89
+ listReleases: () => Promise.resolve([...releases]),
90
+ listRollbacks: () => Promise.resolve([...rollbacks]),
91
+ getAsset: (sha256) => {
92
+ const bytes = assets.get(sha256);
93
+ return Promise.resolve(bytes ? new Uint8Array(bytes) : null);
94
+ },
95
+ publish: (release) => {
96
+ releases.push(release);
97
+ },
98
+ putAsset: (sha256, bytes) => {
99
+ assets.set(sha256, new Uint8Array(bytes));
100
+ },
101
+ rollback: (directive) => {
102
+ rollbacks.push(directive);
103
+ }
104
+ };
105
+ }
106
+ //#endregion
107
+ export { createMemoryUpdateServerStore, createUpdateServer };
108
+
109
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":[],"sources":["../src/server.ts"],"sourcesContent":["/**\n * The Pulse **reference update server** core — the other side of the wire from the\n * {@link \"./client\".UpdateClient}. It is a pure, capability-injected function of an\n * {@link UpdateServerStore} (the release list + asset bytes + optional rollback\n * directives) and a clock, so selection / staged-rollout / anti-downgrade / freeze\n * logic is deterministic and unit-testable with no network and no native I/O.\n *\n * It is exported from the **`@mindees/updates/server`** subpath, never the device\n * entry, so a client bundle never pulls server code. A thin `node:http` adapter lives\n * in `examples/pulse-server/`.\n *\n * **The server never signs.** Signing is an offline build step (see `signing.ts`); the\n * store holds only pre-signed {@link SignedManifest} objects, and the server returns\n * them verbatim — it holds no private key. See `docs/adr/0010-pulse-reference-server.md`.\n *\n * @module\n */\n\nimport { sha256Hex, utf8 } from './crypto'\nimport { parseManifest } from './manifest'\nimport type { SignedManifest } from './signing'\n\n/** A published release the server can offer. */\nexport interface PublishedRelease {\n /** The pre-signed manifest (the server never signs — signing is offline). */\n readonly signed: SignedManifest\n /** Release channel; defaults to `\"stable\"`. */\n readonly channel?: string\n /** Staged-rollout percentage in `[0, 100]`. Default 100 (everyone). */\n readonly rollout?: number\n}\n\n/** An operator directive to roll a channel's clients back to the embedded build. */\nexport interface RollbackDirective {\n /** Channel the directive applies to; defaults to `\"stable\"`. */\n readonly channel?: string\n /** Applies to clients whose current version is ≥ this. Default 0 (all). */\n readonly sinceVersion?: number\n}\n\n/** Injected server I/O: the release catalog + asset bytes (+ optional rollbacks). */\nexport interface UpdateServerStore {\n /** Every published release the server may select among. */\n listReleases(): Promise<readonly PublishedRelease[]>\n /** Asset bytes by lowercase-hex SHA-256, or `null` if absent. */\n getAsset(sha256: string): Promise<Uint8Array | null>\n /** Active rollback directives. Optional; default none. */\n listRollbacks?(): Promise<readonly RollbackDirective[]>\n}\n\n/** A client's update query. */\nexport interface ResolveUpdateQuery {\n /** The client's native runtime version (must match a release exactly). */\n readonly runtimeVersion: string\n /** Channel to resolve against; defaults to `\"stable\"`. */\n readonly channel?: string\n /**\n * The client's anti-downgrade floor — its **high-water mark** (`state().highestVersion`),\n * not necessarily the version currently running (which can be lower after a rollback).\n * The server never offers a version `≤` this. Default 0. A non-integer/negative value\n * is treated as 0 (fail closed).\n */\n readonly currentVersion?: number\n /** A stable per-device id for deterministic staged-rollout bucketing. */\n readonly rolloutKey?: string\n}\n\n/** The outcome of {@link UpdateServer.resolveUpdate}. */\nexport type UpdateResolution =\n | { readonly type: 'update'; readonly signed: SignedManifest }\n | { readonly type: 'no-update' }\n | { readonly type: 'roll-back-to-embedded' }\n\n/** Options for {@link createUpdateServer}. */\nexport interface UpdateServerOptions {\n /** Injected release/asset/rollback I/O. */\n readonly store: UpdateServerStore\n /** Clock, for expiry checks + tests. Default `() => Date.now()`. */\n readonly now?: () => number\n}\n\n/** The reference update server. */\nexport interface UpdateServer {\n /** Resolve the best update for a client query (or no-update / roll-back). */\n resolveUpdate(query: ResolveUpdateQuery): Promise<UpdateResolution>\n /** Serve an asset's bytes by SHA-256 (or `null` if absent / malformed address). */\n getAsset(sha256: string): Promise<Uint8Array | null>\n}\n\nconst SHA256_HEX = /^[0-9a-f]{64}$/\nconst UINT32 = 0x1_0000_0000\n\n/** Deterministic `[0, 100)` cohort bucket for a (release, device) pair. */\nfunction rolloutBucket(manifestId: string, rolloutKey: string): number {\n const hex = sha256Hex(utf8(`${manifestId}:${rolloutKey}`)).slice(0, 8)\n return (Number.parseInt(hex, 16) / UINT32) * 100\n}\n\n/** Whether a device is eligible for a release at the given rollout percentage. */\nfunction isEligible(manifestId: string, rollout: number, rolloutKey: string | undefined): boolean {\n if (rollout >= 100) return true\n if (rollout <= 0) return false\n if (rolloutKey === undefined) return false // a partial rollout needs a stable key\n return rolloutBucket(manifestId, rolloutKey) < rollout\n}\n\n/** Create a {@link UpdateServer}. */\nexport function createUpdateServer(options: UpdateServerOptions): UpdateServer {\n const { store, now = () => Date.now() } = options\n\n return {\n async resolveUpdate(query): Promise<UpdateResolution> {\n const channel = query.channel ?? 'stable'\n // Fail closed on a degenerate floor: a NaN/negative/non-integer currentVersion\n // would otherwise make `version <= currentVersion` and `>= sinceVersion` silently\n // no-op (NaN comparisons are always false). Treat anything invalid as 0.\n const cv = query.currentVersion\n const currentVersion = typeof cv === 'number' && Number.isInteger(cv) && cv >= 0 ? cv : 0\n\n // 1. Emergency rollback directive for this channel takes precedence (the\n // \"stop everything\" signal). To ship a forward fix instead, clear the\n // directive — see ADR-0010 (a version-bounded directive is a future extension).\n const rollbacks = store.listRollbacks ? await store.listRollbacks() : []\n for (const rb of rollbacks) {\n if ((rb.channel ?? 'stable') === channel && currentVersion >= (rb.sinceVersion ?? 0)) {\n return { type: 'roll-back-to-embedded' }\n }\n }\n\n // 2. Best eligible release: channel + runtime match, strictly newer than the\n // client's floor, not expired, rollout-eligible; highest version wins, ties\n // broken by id so selection never depends on store iteration order.\n const releases = await store.listReleases()\n let best: { version: number; id: string; signed: SignedManifest } | null = null\n for (const rel of releases) {\n if ((rel.channel ?? 'stable') !== channel) continue\n let id: string\n let version: number\n let runtimeVersion: string\n let expires: string | undefined\n try {\n const m = parseManifest(rel.signed.manifest)\n id = m.id\n version = m.version\n runtimeVersion = m.runtimeVersion\n expires = m.expires\n } catch {\n continue // skip a malformed release rather than failing the whole query\n }\n if (runtimeVersion !== query.runtimeVersion) continue\n if (version <= currentVersion) continue // anti-downgrade: never offer an older build\n if (expires !== undefined && Date.parse(expires) <= now()) continue // freeze\n if (!isEligible(id, rel.rollout ?? 100, query.rolloutKey)) continue\n if (!best || version > best.version || (version === best.version && id < best.id)) {\n best = { version, id, signed: rel.signed }\n }\n }\n return best ? { type: 'update', signed: best.signed } : { type: 'no-update' }\n },\n\n getAsset(sha256): Promise<Uint8Array | null> {\n if (!SHA256_HEX.test(sha256)) return Promise.resolve(null)\n return store.getAsset(sha256)\n },\n }\n}\n\n/** A mutable in-memory {@link UpdateServerStore} for tests, examples, and reference. */\nexport interface MemoryUpdateServerStore extends UpdateServerStore {\n /** Publish a release. */\n publish(release: PublishedRelease): void\n /** Store an asset's bytes under its SHA-256. */\n putAsset(sha256: string, bytes: Uint8Array): void\n /** Post a rollback directive. */\n rollback(directive: RollbackDirective): void\n}\n\n/** Create an in-memory {@link UpdateServerStore}. */\nexport function createMemoryUpdateServerStore(): MemoryUpdateServerStore {\n const releases: PublishedRelease[] = []\n const rollbacks: RollbackDirective[] = []\n const assets = new Map<string, Uint8Array>()\n return {\n listReleases: () => Promise.resolve([...releases]),\n listRollbacks: () => Promise.resolve([...rollbacks]),\n getAsset: (sha256) => {\n const bytes = assets.get(sha256)\n return Promise.resolve(bytes ? new Uint8Array(bytes) : null)\n },\n publish: (release) => {\n releases.push(release)\n },\n putAsset: (sha256, bytes) => {\n assets.set(sha256, new Uint8Array(bytes))\n },\n rollback: (directive) => {\n rollbacks.push(directive)\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAyFA,MAAM,aAAa;AACnB,MAAM,SAAS;;AAGf,SAAS,cAAc,YAAoB,YAA4B;CACrE,MAAM,MAAM,UAAU,KAAK,GAAG,WAAW,GAAG,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC;CACrE,OAAQ,OAAO,SAAS,KAAK,EAAE,IAAI,SAAU;AAC/C;;AAGA,SAAS,WAAW,YAAoB,SAAiB,YAAyC;CAChG,IAAI,WAAW,KAAK,OAAO;CAC3B,IAAI,WAAW,GAAG,OAAO;CACzB,IAAI,eAAe,KAAA,GAAW,OAAO;CACrC,OAAO,cAAc,YAAY,UAAU,IAAI;AACjD;;AAGA,SAAgB,mBAAmB,SAA4C;CAC7E,MAAM,EAAE,OAAO,YAAY,KAAK,IAAI,MAAM;CAE1C,OAAO;EACL,MAAM,cAAc,OAAkC;GACpD,MAAM,UAAU,MAAM,WAAW;GAIjC,MAAM,KAAK,MAAM;GACjB,MAAM,iBAAiB,OAAO,OAAO,YAAY,OAAO,UAAU,EAAE,KAAK,MAAM,IAAI,KAAK;GAKxF,MAAM,YAAY,MAAM,gBAAgB,MAAM,MAAM,cAAc,IAAI,CAAC;GACvE,KAAK,MAAM,MAAM,WACf,KAAK,GAAG,WAAW,cAAc,WAAW,mBAAmB,GAAG,gBAAgB,IAChF,OAAO,EAAE,MAAM,wBAAwB;GAO3C,MAAM,WAAW,MAAM,MAAM,aAAa;GAC1C,IAAI,OAAuE;GAC3E,KAAK,MAAM,OAAO,UAAU;IAC1B,KAAK,IAAI,WAAW,cAAc,SAAS;IAC3C,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;IACJ,IAAI;KACF,MAAM,IAAI,cAAc,IAAI,OAAO,QAAQ;KAC3C,KAAK,EAAE;KACP,UAAU,EAAE;KACZ,iBAAiB,EAAE;KACnB,UAAU,EAAE;IACd,QAAQ;KACN;IACF;IACA,IAAI,mBAAmB,MAAM,gBAAgB;IAC7C,IAAI,WAAW,gBAAgB;IAC/B,IAAI,YAAY,KAAA,KAAa,KAAK,MAAM,OAAO,KAAK,IAAI,GAAG;IAC3D,IAAI,CAAC,WAAW,IAAI,IAAI,WAAW,KAAK,MAAM,UAAU,GAAG;IAC3D,IAAI,CAAC,QAAQ,UAAU,KAAK,WAAY,YAAY,KAAK,WAAW,KAAK,KAAK,IAC5E,OAAO;KAAE;KAAS;KAAI,QAAQ,IAAI;IAAO;GAE7C;GACA,OAAO,OAAO;IAAE,MAAM;IAAU,QAAQ,KAAK;GAAO,IAAI,EAAE,MAAM,YAAY;EAC9E;EAEA,SAAS,QAAoC;GAC3C,IAAI,CAAC,WAAW,KAAK,MAAM,GAAG,OAAO,QAAQ,QAAQ,IAAI;GACzD,OAAO,MAAM,SAAS,MAAM;EAC9B;CACF;AACF;;AAaA,SAAgB,gCAAyD;CACvE,MAAM,WAA+B,CAAC;CACtC,MAAM,YAAiC,CAAC;CACxC,MAAM,yBAAS,IAAI,IAAwB;CAC3C,OAAO;EACL,oBAAoB,QAAQ,QAAQ,CAAC,GAAG,QAAQ,CAAC;EACjD,qBAAqB,QAAQ,QAAQ,CAAC,GAAG,SAAS,CAAC;EACnD,WAAW,WAAW;GACpB,MAAM,QAAQ,OAAO,IAAI,MAAM;GAC/B,OAAO,QAAQ,QAAQ,QAAQ,IAAI,WAAW,KAAK,IAAI,IAAI;EAC7D;EACA,UAAU,YAAY;GACpB,SAAS,KAAK,OAAO;EACvB;EACA,WAAW,QAAQ,UAAU;GAC3B,OAAO,IAAI,QAAQ,IAAI,WAAW,KAAK,CAAC;EAC1C;EACA,WAAW,cAAc;GACvB,UAAU,KAAK,SAAS;EAC1B;CACF;AACF"}
@@ -0,0 +1,54 @@
1
+ import { UpdateManifest } from "./manifest.js";
2
+
3
+ //#region src/signing.d.ts
4
+ /** One signature over the canonical manifest bytes, tagged with its key id. */
5
+ interface SignatureEntry {
6
+ /** Which trusted key produced this signature. */
7
+ readonly keyId: string;
8
+ /** Lowercase hex Ed25519 signature over `utf8(manifest)`. */
9
+ readonly signature: string;
10
+ }
11
+ /** A manifest plus the canonical bytes that were signed and the signatures. */
12
+ interface SignedManifest {
13
+ /** The exact canonical JSON string that was signed (verify over these bytes). */
14
+ readonly manifest: string;
15
+ /** One or more signatures over `manifest`. */
16
+ readonly signatures: readonly SignatureEntry[];
17
+ }
18
+ /** A signing key (build/server side — keep the secret key offline). */
19
+ interface Signer {
20
+ readonly keyId: string;
21
+ readonly secretKey: Uint8Array;
22
+ }
23
+ /** A trusted verification key (embedded in the app). */
24
+ interface TrustedKey {
25
+ readonly keyId: string;
26
+ /** Lowercase hex Ed25519 public key. */
27
+ readonly publicKey: string;
28
+ }
29
+ declare const verifiedBrand: unique symbol;
30
+ /**
31
+ * An {@link UpdateManifest} whose signature has been verified against trusted keys.
32
+ * The brand is unforgeable in TypeScript: only {@link verifySignedManifest} (and
33
+ * therefore {@link "./client".UpdateClient.check}) can produce one. APIs that
34
+ * activate code — `download()` / `apply()` — accept only a `VerifiedManifest`, so a
35
+ * caller cannot smuggle an unsigned, locally-constructed manifest past the trust gate.
36
+ */
37
+ type VerifiedManifest = UpdateManifest & {
38
+ readonly [verifiedBrand]: true;
39
+ };
40
+ /**
41
+ * Sign a manifest with one or more {@link Signer}s. Produces the canonical bytes
42
+ * and a signature per signer.
43
+ */
44
+ declare function signManifest(manifest: UpdateManifest, signers: readonly Signer[]): SignedManifest;
45
+ /**
46
+ * Verify a {@link SignedManifest}: require `≥ threshold` valid signatures from
47
+ * **distinct trusted public keys** (verified over the exact shipped bytes), then
48
+ * parse and return the manifest. `threshold` must be a positive integer. Throws
49
+ * {@link UpdateError} (`SIGNATURE_INVALID`) otherwise.
50
+ */
51
+ declare function verifySignedManifest(signed: SignedManifest, trustedKeys: readonly TrustedKey[], threshold?: number): VerifiedManifest;
52
+ //#endregion
53
+ export { SignatureEntry, SignedManifest, Signer, TrustedKey, VerifiedManifest, signManifest, verifySignedManifest };
54
+ //# sourceMappingURL=signing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing.d.ts","names":[],"sources":["../src/signing.ts"],"mappings":";;;;UAkBiB,cAAA;EAY6B;EAAA,SAVnC,KAAA;EAcM;EAAA,SAZN,SAAS;AAAA;;UAIH,cAAA;EAUN;EAAA,SARA,QAAA;EAQqB;EAAA,SANrB,UAAA,WAAqB,cAAc;AAAA;;UAI7B,MAAA;EAAA,SACN,KAAA;EAAA,SACA,SAAA,EAAW,UAAU;AAAA;;UAIf,UAAA;EAAA,SACN,KAAA;EAK+B;EAAA,SAH/B,SAAS;AAAA;AAAA,cAGN,aAAA;;AAS2D;AAMzE;;;;;KANY,gBAAA,GAAmB,cAAA;EAAA,UAA6B,aAAa;AAAA;;;;;iBAMzD,YAAA,CAAa,QAAA,EAAU,cAAA,EAAgB,OAAA,WAAkB,MAAA,KAAW,cAAA;;AAAc;AAiBlG;;;;iBAAgB,oBAAA,CACd,MAAA,EAAQ,cAAA,EACR,WAAA,WAAsB,UAAA,IACtB,SAAA,YACC,gBAAA"}
@@ -0,0 +1,64 @@
1
+ import { fromHex, sign, toHex, utf8, verify } from "./crypto.js";
2
+ import { UpdateError } from "./errors.js";
3
+ import { canonicalManifestJson, parseManifest } from "./manifest.js";
4
+ //#region src/signing.ts
5
+ /**
6
+ * Signing + verification of update manifests (the trust layer over {@link crypto}).
7
+ *
8
+ * A {@link SignedManifest} ships the **exact canonical JSON bytes that were signed**
9
+ * plus the signatures, so the verifier checks the signature over the *received*
10
+ * bytes and never re-serializes — sidestepping JSON-canonicalization edge cases.
11
+ * Verification requires `≥ threshold` valid signatures from **distinct trusted
12
+ * public keys** (default 1), which supports key rotation (trust old + new) and
13
+ * multi-party signing. A signature whose `keyId` is not trusted is ignored.
14
+ *
15
+ * @module
16
+ */
17
+ /**
18
+ * Sign a manifest with one or more {@link Signer}s. Produces the canonical bytes
19
+ * and a signature per signer.
20
+ */
21
+ function signManifest(manifest, signers) {
22
+ if (signers.length === 0) throw new UpdateError("SIGNATURE_INVALID", "no signers provided");
23
+ const canonical = canonicalManifestJson(manifest);
24
+ const bytes = utf8(canonical);
25
+ return {
26
+ manifest: canonical,
27
+ signatures: signers.map((s) => ({
28
+ keyId: s.keyId,
29
+ signature: toHex(sign(bytes, s.secretKey))
30
+ }))
31
+ };
32
+ }
33
+ /**
34
+ * Verify a {@link SignedManifest}: require `≥ threshold` valid signatures from
35
+ * **distinct trusted public keys** (verified over the exact shipped bytes), then
36
+ * parse and return the manifest. `threshold` must be a positive integer. Throws
37
+ * {@link UpdateError} (`SIGNATURE_INVALID`) otherwise.
38
+ */
39
+ function verifySignedManifest(signed, trustedKeys, threshold = 1) {
40
+ if (!Number.isInteger(threshold) || threshold < 1) throw new UpdateError("SIGNATURE_INVALID", `threshold must be a positive integer, got ${threshold}`);
41
+ const bytes = utf8(signed.manifest);
42
+ const trusted = new Map(trustedKeys.map((k) => [k.keyId, k.publicKey]));
43
+ const validPublicKeys = /* @__PURE__ */ new Set();
44
+ for (const sig of signed.signatures) {
45
+ const publicKeyHex = trusted.get(sig.keyId);
46
+ if (publicKeyHex === void 0) continue;
47
+ if (validPublicKeys.has(publicKeyHex)) continue;
48
+ let sigBytes;
49
+ let pubBytes;
50
+ try {
51
+ sigBytes = fromHex(sig.signature);
52
+ pubBytes = fromHex(publicKeyHex);
53
+ } catch {
54
+ continue;
55
+ }
56
+ if (verify(sigBytes, bytes, pubBytes)) validPublicKeys.add(publicKeyHex);
57
+ }
58
+ if (validPublicKeys.size < threshold) throw new UpdateError("SIGNATURE_INVALID", `manifest has ${validPublicKeys.size} valid trusted signature(s), need ${threshold}`);
59
+ return parseManifest(signed.manifest);
60
+ }
61
+ //#endregion
62
+ export { signManifest, verifySignedManifest };
63
+
64
+ //# sourceMappingURL=signing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signing.js","names":[],"sources":["../src/signing.ts"],"sourcesContent":["/**\n * Signing + verification of update manifests (the trust layer over {@link crypto}).\n *\n * A {@link SignedManifest} ships the **exact canonical JSON bytes that were signed**\n * plus the signatures, so the verifier checks the signature over the *received*\n * bytes and never re-serializes — sidestepping JSON-canonicalization edge cases.\n * Verification requires `≥ threshold` valid signatures from **distinct trusted\n * public keys** (default 1), which supports key rotation (trust old + new) and\n * multi-party signing. A signature whose `keyId` is not trusted is ignored.\n *\n * @module\n */\n\nimport { fromHex, sign, toHex, utf8, verify } from './crypto'\nimport { UpdateError } from './errors'\nimport { canonicalManifestJson, parseManifest, type UpdateManifest } from './manifest'\n\n/** One signature over the canonical manifest bytes, tagged with its key id. */\nexport interface SignatureEntry {\n /** Which trusted key produced this signature. */\n readonly keyId: string\n /** Lowercase hex Ed25519 signature over `utf8(manifest)`. */\n readonly signature: string\n}\n\n/** A manifest plus the canonical bytes that were signed and the signatures. */\nexport interface SignedManifest {\n /** The exact canonical JSON string that was signed (verify over these bytes). */\n readonly manifest: string\n /** One or more signatures over `manifest`. */\n readonly signatures: readonly SignatureEntry[]\n}\n\n/** A signing key (build/server side — keep the secret key offline). */\nexport interface Signer {\n readonly keyId: string\n readonly secretKey: Uint8Array\n}\n\n/** A trusted verification key (embedded in the app). */\nexport interface TrustedKey {\n readonly keyId: string\n /** Lowercase hex Ed25519 public key. */\n readonly publicKey: string\n}\n\ndeclare const verifiedBrand: unique symbol\n\n/**\n * An {@link UpdateManifest} whose signature has been verified against trusted keys.\n * The brand is unforgeable in TypeScript: only {@link verifySignedManifest} (and\n * therefore {@link \"./client\".UpdateClient.check}) can produce one. APIs that\n * activate code — `download()` / `apply()` — accept only a `VerifiedManifest`, so a\n * caller cannot smuggle an unsigned, locally-constructed manifest past the trust gate.\n */\nexport type VerifiedManifest = UpdateManifest & { readonly [verifiedBrand]: true }\n\n/**\n * Sign a manifest with one or more {@link Signer}s. Produces the canonical bytes\n * and a signature per signer.\n */\nexport function signManifest(manifest: UpdateManifest, signers: readonly Signer[]): SignedManifest {\n if (signers.length === 0) throw new UpdateError('SIGNATURE_INVALID', 'no signers provided')\n const canonical = canonicalManifestJson(manifest)\n const bytes = utf8(canonical)\n const signatures = signers.map((s) => ({\n keyId: s.keyId,\n signature: toHex(sign(bytes, s.secretKey)),\n }))\n return { manifest: canonical, signatures }\n}\n\n/**\n * Verify a {@link SignedManifest}: require `≥ threshold` valid signatures from\n * **distinct trusted public keys** (verified over the exact shipped bytes), then\n * parse and return the manifest. `threshold` must be a positive integer. Throws\n * {@link UpdateError} (`SIGNATURE_INVALID`) otherwise.\n */\nexport function verifySignedManifest(\n signed: SignedManifest,\n trustedKeys: readonly TrustedKey[],\n threshold = 1,\n): VerifiedManifest {\n // A non-positive (or non-integer) threshold would otherwise accept a manifest\n // with *zero* valid signatures — a signature-check bypass. Fail closed.\n if (!Number.isInteger(threshold) || threshold < 1) {\n throw new UpdateError(\n 'SIGNATURE_INVALID',\n `threshold must be a positive integer, got ${threshold}`,\n )\n }\n const bytes = utf8(signed.manifest)\n const trusted = new Map(trustedKeys.map((k) => [k.keyId, k.publicKey]))\n // Count distinct *public keys* (not key ids): two trusted ids mapped to the same\n // public key are one signer and must not jointly satisfy a multi-key threshold.\n const validPublicKeys = new Set<string>()\n\n for (const sig of signed.signatures) {\n const publicKeyHex = trusted.get(sig.keyId)\n if (publicKeyHex === undefined) continue // untrusted key id\n if (validPublicKeys.has(publicKeyHex)) continue // this key already counted\n let sigBytes: Uint8Array\n let pubBytes: Uint8Array\n try {\n sigBytes = fromHex(sig.signature)\n pubBytes = fromHex(publicKeyHex)\n } catch {\n continue // malformed hex → not a valid signature\n }\n if (verify(sigBytes, bytes, pubBytes)) validPublicKeys.add(publicKeyHex)\n }\n\n if (validPublicKeys.size < threshold) {\n throw new UpdateError(\n 'SIGNATURE_INVALID',\n `manifest has ${validPublicKeys.size} valid trusted signature(s), need ${threshold}`,\n )\n }\n // Signature verified ⇒ mint the brand. parseManifest still strictly validates shape.\n return parseManifest(signed.manifest) as VerifiedManifest\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA6DA,SAAgB,aAAa,UAA0B,SAA4C;CACjG,IAAI,QAAQ,WAAW,GAAG,MAAM,IAAI,YAAY,qBAAqB,qBAAqB;CAC1F,MAAM,YAAY,sBAAsB,QAAQ;CAChD,MAAM,QAAQ,KAAK,SAAS;CAK5B,OAAO;EAAE,UAAU;EAAW,YAJX,QAAQ,KAAK,OAAO;GACrC,OAAO,EAAE;GACT,WAAW,MAAM,KAAK,OAAO,EAAE,SAAS,CAAC;EAC3C,EACuC;CAAE;AAC3C;;;;;;;AAQA,SAAgB,qBACd,QACA,aACA,YAAY,GACM;CAGlB,IAAI,CAAC,OAAO,UAAU,SAAS,KAAK,YAAY,GAC9C,MAAM,IAAI,YACR,qBACA,6CAA6C,WAC/C;CAEF,MAAM,QAAQ,KAAK,OAAO,QAAQ;CAClC,MAAM,UAAU,IAAI,IAAI,YAAY,KAAK,MAAM,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;CAGtE,MAAM,kCAAkB,IAAI,IAAY;CAExC,KAAK,MAAM,OAAO,OAAO,YAAY;EACnC,MAAM,eAAe,QAAQ,IAAI,IAAI,KAAK;EAC1C,IAAI,iBAAiB,KAAA,GAAW;EAChC,IAAI,gBAAgB,IAAI,YAAY,GAAG;EACvC,IAAI;EACJ,IAAI;EACJ,IAAI;GACF,WAAW,QAAQ,IAAI,SAAS;GAChC,WAAW,QAAQ,YAAY;EACjC,QAAQ;GACN;EACF;EACA,IAAI,OAAO,UAAU,OAAO,QAAQ,GAAG,gBAAgB,IAAI,YAAY;CACzE;CAEA,IAAI,gBAAgB,OAAO,WACzB,MAAM,IAAI,YACR,qBACA,gBAAgB,gBAAgB,KAAK,oCAAoC,WAC3E;CAGF,OAAO,cAAc,OAAO,QAAQ;AACtC"}
@@ -0,0 +1,61 @@
1
+ //#region src/store.d.ts
2
+ /**
3
+ * Content-addressed update storage + the persisted generation state.
4
+ *
5
+ * Storage is an **injected capability** (like the CLI's `FileSystem`), so the
6
+ * client is deterministic in tests and backend-agnostic (filesystem, S3/R2, RN
7
+ * storage). Blobs are keyed by SHA-256, so identical files across updates are
8
+ * stored once and only changed hashes are ever downloaded — differential download
9
+ * falls out of content-addressing for free. {@link createMemoryStorage} is the
10
+ * in-memory reference implementation.
11
+ *
12
+ * @module
13
+ */
14
+ /** Lifecycle status of a stored generation. */
15
+ type GenerationStatus = 'pending' | 'current' | 'previous' | 'failed';
16
+ /** Metadata for one downloaded update generation. */
17
+ interface GenerationMeta {
18
+ /** The manifest id (also the generation id). */
19
+ readonly id: string;
20
+ /** The manifest's monotonic version. */
21
+ readonly version: number;
22
+ /** The verified canonical manifest JSON. */
23
+ readonly manifest: string;
24
+ /** Where this generation sits in the lifecycle. */
25
+ readonly status: GenerationStatus;
26
+ }
27
+ /** Persisted client state: the generation pointers + rollback bookkeeping. */
28
+ interface UpdateState {
29
+ /** Active generation id, or `null` to run the embedded build. */
30
+ readonly current: string | null;
31
+ /** Last-known-good fallback generation id (or `null` = embedded). */
32
+ readonly previous: string | null;
33
+ /** Highest version ever applied — rejects downgrades (rollback protection). */
34
+ readonly highestVersion: number;
35
+ /** The current generation has not yet confirmed itself via `notifyReady()`. */
36
+ readonly pendingVerification: boolean;
37
+ /** Boots into an unconfirmed current generation (crash-loop detection). */
38
+ readonly bootAttempts: number;
39
+ /** All known generations, by id. */
40
+ readonly generations: Readonly<Record<string, GenerationMeta>>;
41
+ }
42
+ /** Injected storage capability: content-addressed blobs + a small state document. */
43
+ interface UpdateStorage {
44
+ /** Whether a blob with this SHA-256 is stored. */
45
+ hasBlob(sha256: string): Promise<boolean>;
46
+ /** Read a blob's bytes (throws `ASSET_MISSING` if absent). */
47
+ readBlob(sha256: string): Promise<Uint8Array>;
48
+ /** Store a blob under its SHA-256. */
49
+ writeBlob(sha256: string, data: Uint8Array): Promise<void>;
50
+ /** Read the persisted state, or `null` on a fresh install. */
51
+ readState(): Promise<UpdateState | null>;
52
+ /** Persist the state. */
53
+ writeState(state: UpdateState): Promise<void>;
54
+ }
55
+ /** The initial state for a fresh install (running the embedded build). */
56
+ declare function initialState(embeddedVersion?: number): UpdateState;
57
+ /** An in-memory {@link UpdateStorage} for tests and as a reference implementation. */
58
+ declare function createMemoryStorage(): UpdateStorage;
59
+ //#endregion
60
+ export { GenerationMeta, GenerationStatus, UpdateState, UpdateStorage, createMemoryStorage, initialState };
61
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","names":[],"sources":["../src/store.ts"],"mappings":";;AAgBA;;;;AAA4B;AAG5B;;;;;;;KAHY,gBAAA;;UAGK,cAAA;EAQkB;EAAA,SANxB,EAAA;EAUiB;EAAA,SARjB,OAAA;EAoBqC;EAAA,SAlBrC,QAAA;EAkBa;EAAA,SAhBb,MAAA,EAAQ,gBAAgB;AAAA;;UAIlB,WAAA;EAMN;EAAA,SAJA,OAAA;EAQA;EAAA,SANA,QAAA;EAQa;EAAA,SANb,cAAA;EAMqC;EAAA,SAJrC,mBAAA;EAImD;EAAA,SAFnD,YAAA;EAMmB;EAAA,SAJnB,WAAA,EAAa,QAAA,CAAS,MAAA,SAAe,cAAA;AAAA;;UAI/B,aAAA;EAMiB;EAJhC,OAAA,CAAQ,MAAA,WAAiB,OAAA;EAMJ;EAJrB,QAAA,CAAS,MAAA,WAAiB,OAAA,CAAQ,UAAA;EAMhB;EAJlB,SAAA,CAAU,MAAA,UAAgB,IAAA,EAAM,UAAA,GAAa,OAAA;EAIN;EAFvC,SAAA,IAAa,OAAA,CAAQ,WAAA;EANrB;EAQA,UAAA,CAAW,KAAA,EAAO,WAAA,GAAc,OAAA;AAAA;;iBAIlB,YAAA,CAAa,eAAA,YAAsB,WAAW;;iBAY9C,mBAAA,IAAuB,aAAa"}
package/dist/store.js ADDED
@@ -0,0 +1,55 @@
1
+ import { UpdateError } from "./errors.js";
2
+ //#region src/store.ts
3
+ /**
4
+ * Content-addressed update storage + the persisted generation state.
5
+ *
6
+ * Storage is an **injected capability** (like the CLI's `FileSystem`), so the
7
+ * client is deterministic in tests and backend-agnostic (filesystem, S3/R2, RN
8
+ * storage). Blobs are keyed by SHA-256, so identical files across updates are
9
+ * stored once and only changed hashes are ever downloaded — differential download
10
+ * falls out of content-addressing for free. {@link createMemoryStorage} is the
11
+ * in-memory reference implementation.
12
+ *
13
+ * @module
14
+ */
15
+ /** The initial state for a fresh install (running the embedded build). */
16
+ function initialState(embeddedVersion = 0) {
17
+ return {
18
+ current: null,
19
+ previous: null,
20
+ highestVersion: embeddedVersion,
21
+ pendingVerification: false,
22
+ bootAttempts: 0,
23
+ generations: {}
24
+ };
25
+ }
26
+ /** An in-memory {@link UpdateStorage} for tests and as a reference implementation. */
27
+ function createMemoryStorage() {
28
+ const blobs = /* @__PURE__ */ new Map();
29
+ let state = null;
30
+ const cloneState = (value) => value === null ? null : {
31
+ ...value,
32
+ generations: Object.fromEntries(Object.entries(value.generations).map(([id, meta]) => [id, { ...meta }]))
33
+ };
34
+ return {
35
+ hasBlob: (sha256) => Promise.resolve(blobs.has(sha256)),
36
+ readBlob: (sha256) => {
37
+ const blob = blobs.get(sha256);
38
+ if (!blob) return Promise.reject(new UpdateError("ASSET_MISSING", `blob ${sha256} not found`));
39
+ return Promise.resolve(new Uint8Array(blob));
40
+ },
41
+ writeBlob: (sha256, data) => {
42
+ blobs.set(sha256, new Uint8Array(data));
43
+ return Promise.resolve();
44
+ },
45
+ readState: () => Promise.resolve(cloneState(state)),
46
+ writeState: (next) => {
47
+ state = cloneState(next);
48
+ return Promise.resolve();
49
+ }
50
+ };
51
+ }
52
+ //#endregion
53
+ export { createMemoryStorage, initialState };
54
+
55
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","names":[],"sources":["../src/store.ts"],"sourcesContent":["/**\n * Content-addressed update storage + the persisted generation state.\n *\n * Storage is an **injected capability** (like the CLI's `FileSystem`), so the\n * client is deterministic in tests and backend-agnostic (filesystem, S3/R2, RN\n * storage). Blobs are keyed by SHA-256, so identical files across updates are\n * stored once and only changed hashes are ever downloaded — differential download\n * falls out of content-addressing for free. {@link createMemoryStorage} is the\n * in-memory reference implementation.\n *\n * @module\n */\n\nimport { UpdateError } from './errors'\n\n/** Lifecycle status of a stored generation. */\nexport type GenerationStatus = 'pending' | 'current' | 'previous' | 'failed'\n\n/** Metadata for one downloaded update generation. */\nexport interface GenerationMeta {\n /** The manifest id (also the generation id). */\n readonly id: string\n /** The manifest's monotonic version. */\n readonly version: number\n /** The verified canonical manifest JSON. */\n readonly manifest: string\n /** Where this generation sits in the lifecycle. */\n readonly status: GenerationStatus\n}\n\n/** Persisted client state: the generation pointers + rollback bookkeeping. */\nexport interface UpdateState {\n /** Active generation id, or `null` to run the embedded build. */\n readonly current: string | null\n /** Last-known-good fallback generation id (or `null` = embedded). */\n readonly previous: string | null\n /** Highest version ever applied — rejects downgrades (rollback protection). */\n readonly highestVersion: number\n /** The current generation has not yet confirmed itself via `notifyReady()`. */\n readonly pendingVerification: boolean\n /** Boots into an unconfirmed current generation (crash-loop detection). */\n readonly bootAttempts: number\n /** All known generations, by id. */\n readonly generations: Readonly<Record<string, GenerationMeta>>\n}\n\n/** Injected storage capability: content-addressed blobs + a small state document. */\nexport interface UpdateStorage {\n /** Whether a blob with this SHA-256 is stored. */\n hasBlob(sha256: string): Promise<boolean>\n /** Read a blob's bytes (throws `ASSET_MISSING` if absent). */\n readBlob(sha256: string): Promise<Uint8Array>\n /** Store a blob under its SHA-256. */\n writeBlob(sha256: string, data: Uint8Array): Promise<void>\n /** Read the persisted state, or `null` on a fresh install. */\n readState(): Promise<UpdateState | null>\n /** Persist the state. */\n writeState(state: UpdateState): Promise<void>\n}\n\n/** The initial state for a fresh install (running the embedded build). */\nexport function initialState(embeddedVersion = 0): UpdateState {\n return {\n current: null,\n previous: null,\n highestVersion: embeddedVersion,\n pendingVerification: false,\n bootAttempts: 0,\n generations: {},\n }\n}\n\n/** An in-memory {@link UpdateStorage} for tests and as a reference implementation. */\nexport function createMemoryStorage(): UpdateStorage {\n const blobs = new Map<string, Uint8Array>()\n let state: UpdateState | null = null\n\n // Clone at the boundary: callers must never hold a reference into the store's\n // internals, or they could mutate a blob after writeBlob() (breaking the\n // content-addressed integrity guarantee) or mutate state without a writeState().\n const cloneState = (value: UpdateState | null): UpdateState | null =>\n value === null\n ? null\n : {\n ...value,\n generations: Object.fromEntries(\n Object.entries(value.generations).map(([id, meta]) => [id, { ...meta }]),\n ),\n }\n\n return {\n hasBlob: (sha256) => Promise.resolve(blobs.has(sha256)),\n readBlob: (sha256) => {\n const blob = blobs.get(sha256)\n if (!blob) return Promise.reject(new UpdateError('ASSET_MISSING', `blob ${sha256} not found`))\n return Promise.resolve(new Uint8Array(blob))\n },\n writeBlob: (sha256, data) => {\n blobs.set(sha256, new Uint8Array(data))\n return Promise.resolve()\n },\n readState: () => Promise.resolve(cloneState(state)),\n writeState: (next) => {\n state = cloneState(next)\n return Promise.resolve()\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6DA,SAAgB,aAAa,kBAAkB,GAAgB;CAC7D,OAAO;EACL,SAAS;EACT,UAAU;EACV,gBAAgB;EAChB,qBAAqB;EACrB,cAAc;EACd,aAAa,CAAC;CAChB;AACF;;AAGA,SAAgB,sBAAqC;CACnD,MAAM,wBAAQ,IAAI,IAAwB;CAC1C,IAAI,QAA4B;CAKhC,MAAM,cAAc,UAClB,UAAU,OACN,OACA;EACE,GAAG;EACH,aAAa,OAAO,YAClB,OAAO,QAAQ,MAAM,WAAW,EAAE,KAAK,CAAC,IAAI,UAAU,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,CAAC,CACzE;CACF;CAEN,OAAO;EACL,UAAU,WAAW,QAAQ,QAAQ,MAAM,IAAI,MAAM,CAAC;EACtD,WAAW,WAAW;GACpB,MAAM,OAAO,MAAM,IAAI,MAAM;GAC7B,IAAI,CAAC,MAAM,OAAO,QAAQ,OAAO,IAAI,YAAY,iBAAiB,QAAQ,OAAO,WAAW,CAAC;GAC7F,OAAO,QAAQ,QAAQ,IAAI,WAAW,IAAI,CAAC;EAC7C;EACA,YAAY,QAAQ,SAAS;GAC3B,MAAM,IAAI,QAAQ,IAAI,WAAW,IAAI,CAAC;GACtC,OAAO,QAAQ,QAAQ;EACzB;EACA,iBAAiB,QAAQ,QAAQ,WAAW,KAAK,CAAC;EAClD,aAAa,SAAS;GACpB,QAAQ,WAAW,IAAI;GACvB,OAAO,QAAQ,QAAQ;EACzB;CACF;AACF"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mindees/updates",
3
+ "version": "0.1.0",
4
+ "description": "MindeesNative Pulse - signed over-the-air (OTA) updates: hash-addressed manifests, Ed25519 signing, content-addressed storage, atomic generations with crash-loop rollback.",
5
+ "license": "MIT OR Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./server": {
17
+ "types": "./dist/server.d.ts",
18
+ "import": "./dist/server.js"
19
+ },
20
+ "./sdui": {
21
+ "types": "./dist/sdui.d.ts",
22
+ "import": "./dist/sdui.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/mindees/mindees.git",
31
+ "directory": "packages/updates"
32
+ },
33
+ "dependencies": {
34
+ "@noble/curves": "2.2.0",
35
+ "@noble/hashes": "2.2.0",
36
+ "@mindees/core": "0.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "fast-check": "4.8.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsdown",
43
+ "typecheck": "tsc --noEmit"
44
+ }
45
+ }