@akanjs/devkit 3.1.1-rc.8 → 3.1.1-rc.9

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.
@@ -102,8 +102,9 @@ export class CloudUpdates {
102
102
  const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
103
103
  if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
104
104
  const manifestUrl =
105
- typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
106
- const what = `${label} (${release.manifest.bundle}, ${release.payloads.length + 3} files)`;
105
+ typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}/app.json`;
106
+ const files = 1 + release.manifestFiles.length + release.payloads.length;
107
+ const what = `${label} (${release.manifest.bundle}, ${files} files)`;
107
108
  Logger.rawLog(
108
109
  record.status === "published"
109
110
  ? `Published ${what}. Installed apps read ${manifestUrl}.`
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createHash, generateKeyPairSync, sign } from "node:crypto";
3
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import * as nativeApi from "akanjs/native/api";
5
6
  import { AkanAppConfig } from "../akanConfig";
6
7
  import { tempDirs } from "../testHelpers";
7
8
  import type { DesktopArch } from "./desktopBin";
@@ -13,12 +14,18 @@ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes
13
14
 
14
15
  const keyOf = () => {
15
16
  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
17
+ const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
18
+ const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
16
19
  return {
17
- publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"),
18
- sign: (bytes: string) => `${sign(null, Buffer.from(bytes), privateKey).toString("base64")}\n`,
20
+ publicKey: raw,
21
+ keyId: nativeApi.keyIdOf(raw),
22
+ sign: (bytes: string) => `${signature(bytes)}\n`,
23
+ seal: (bytes: string) =>
24
+ `${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
19
25
  };
20
26
  };
21
27
  const key = keyOf();
28
+ const spare = keyOf();
22
29
  const appId = "com.akancad.app";
23
30
 
24
31
  interface Published {
@@ -35,12 +42,18 @@ const publish = async ({
35
42
  channel = "main",
36
43
  app = platform === "macos" ? "Akancad.app" : "akancad",
37
44
  gz = "the release's gzip tar",
45
+ target = "default",
46
+ signer = key,
47
+ pair = "same",
38
48
  }: {
39
49
  platform?: DesktopPlatform;
40
50
  arch?: DesktopArch;
41
51
  channel?: string;
42
52
  app?: string;
43
53
  gz?: string;
54
+ target?: string;
55
+ signer?: ReturnType<typeof keyOf>;
56
+ pair?: "same" | "earlier" | "none";
44
57
  } = {}) => {
45
58
  const updatesDir = await makeDir();
46
59
  const dir = path.join(updatesDir, `${platform}-${arch}`);
@@ -68,9 +81,17 @@ const publish = async ({
68
81
  patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
69
82
  server: true,
70
83
  };
71
- const bytes = `${JSON.stringify(manifest, null, 2)}\n`;
72
- await writeFile(path.join(dir, `${channel}.json`), bytes);
73
- await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
84
+ await mkdir(path.join(dir, channel), { recursive: true });
85
+ await writeFile(
86
+ path.join(dir, channel, "app.json"),
87
+ signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
88
+ );
89
+ if (pair !== "none") {
90
+ const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
91
+ const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
92
+ await writeFile(path.join(dir, `${channel}.json`), bytes);
93
+ await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
94
+ }
74
95
  return { updatesDir, dir, tarSha, delta, build } satisfies Published;
75
96
  };
76
97
 
@@ -80,10 +101,30 @@ const read = (
80
101
  platform = "macos",
81
102
  arch = "arm64",
82
103
  channel = "main",
83
- publicKey = key.publicKey,
104
+ publicKeys = [key.publicKey, spare.publicKey],
105
+ revokedKeys = [],
106
+ target = "default",
84
107
  id = appId,
85
- }: { platform?: DesktopPlatform; arch?: DesktopArch; channel?: string; publicKey?: string; id?: string } = {},
86
- ) => DesktopRelease.read(updatesDir, { platform, arch, channel, appId: id, publicKey });
108
+ }: {
109
+ platform?: DesktopPlatform;
110
+ arch?: DesktopArch;
111
+ channel?: string;
112
+ publicKeys?: string[];
113
+ revokedKeys?: string[];
114
+ target?: string;
115
+ id?: string;
116
+ } = {},
117
+ ) =>
118
+ DesktopRelease.read(updatesDir, {
119
+ platform,
120
+ arch,
121
+ channel,
122
+ appId: id,
123
+ target,
124
+ publicKeys,
125
+ revokedKeys,
126
+ api: nativeApi,
127
+ });
87
128
 
88
129
  const entriesOf = (archive: string) =>
89
130
  Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
@@ -122,7 +163,8 @@ describe("DesktopRelease", () => {
122
163
  const published = await publish();
123
164
  const { dir, tarSha, delta } = published;
124
165
  await publish({ channel: "develop" }).then(async (other) => {
125
- for (const name of ["develop.json", "develop.json.sig"])
166
+ await mkdir(path.join(dir, "develop"), { recursive: true });
167
+ for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
126
168
  await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
127
169
  });
128
170
  await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
@@ -133,6 +175,7 @@ describe("DesktopRelease", () => {
133
175
 
134
176
  expect(entries).toEqual([
135
177
  "./release.json",
178
+ "./main/app.json",
136
179
  "./main.json",
137
180
  "./main.json.sig",
138
181
  `./app/${tarSha}.tar.gz`,
@@ -152,24 +195,24 @@ describe("DesktopRelease", () => {
152
195
  expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
153
196
  expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
154
197
  const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
155
- expect(files.slice(0, 3)).toEqual(["./release.json", "./main.json", "./main.json.sig"]);
156
- expect(files.slice(3).every((name) => name.startsWith("./app/"))).toBe(true);
157
- expect(files.slice(3)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
198
+ expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
199
+ expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
200
+ expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
158
201
  expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
159
202
  });
160
203
 
161
- test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
204
+ test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
162
205
  const published = await publish();
163
206
  const release = await read(published);
164
207
  const record = release.record({ teamId: "ABCDE12345" });
165
208
 
166
209
  const out = await release.withArchive(record, extract);
167
210
 
168
- for (const name of ["main.json", "main.json.sig"])
211
+ for (const name of ["main/app.json", "main.json", "main.json.sig"])
169
212
  expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
170
213
  expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
171
214
  expect(record).toEqual({
172
- schema: 1,
215
+ schema: 2,
173
216
  cli: AkanAppConfig.akanVersion,
174
217
  platform: "macos",
175
218
  arch: "arm64",
@@ -192,22 +235,50 @@ describe("DesktopRelease", () => {
192
235
  expect(await Bun.file(kept).exists()).toBe(false);
193
236
  });
194
237
 
195
- test("refuses a manifest the target's first key did not sign, before reading anything it names", async () => {
238
+ test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
196
239
  const published = await publish();
240
+ const envelope = path.join(published.dir, "main", "app.json");
241
+ const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
197
242
 
198
- await expect(read(published, { publicKey: keyOf().publicKey })).rejects.toThrow(
199
- "main.json.sig does not verify with updates.publicKeys[0], the one key installed apps trust",
243
+ await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
244
+ await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
245
+ });
246
+
247
+ test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
248
+ const entriesFor = async (published: Published) => {
249
+ const release = await read(published);
250
+ return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
251
+ };
252
+ const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
253
+
254
+ const bySpare = await publish({ signer: spare, pair: "earlier" });
255
+ expect(await entriesFor(bySpare)).toEqual([
256
+ "./release.json",
257
+ "./main/app.json",
258
+ tail(bySpare),
259
+ `./app/${bySpare.delta}`,
260
+ ]);
261
+ const unpaired = await publish({ pair: "none" });
262
+ expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
263
+ const forged = await publish();
264
+ await writeFile(
265
+ path.join(forged.dir, "main.json.sig"),
266
+ spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
200
267
  );
268
+ expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
201
269
  });
202
270
 
203
271
  test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
204
272
  const published = await publish();
205
273
 
206
274
  await expect(read(published, { id: "com.other" })).rejects.toThrow(
207
- "is not a release of com.other for macos-arm64 on channel main.",
275
+ "is not a release of com.other target default for macos-arm64 on channel main.",
276
+ );
277
+ await expect(read(published, { target: "pilot" })).rejects.toThrow(
278
+ `is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
208
279
  );
209
280
  await expect(read(published, { channel: "develop" })).rejects.toThrow(
210
- `${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
281
+ `${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
211
282
  );
212
283
  await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
213
284
  });
@@ -219,9 +290,9 @@ describe("DesktopRelease", () => {
219
290
  const send = () => release.withArchive(release.record(null), async () => "sent");
220
291
 
221
292
  await writeFile(archive, "the release's gzip tar!");
222
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
293
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
223
294
  await writeFile(archive, "the release's gzip taR");
224
- await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
295
+ await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
225
296
  });
226
297
  });
227
298
 
@@ -1,4 +1,3 @@
1
- import { createPublicKey, verify } from "node:crypto";
2
1
  import { mkdtemp, readFile, rm } from "node:fs/promises";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
@@ -7,14 +6,18 @@ import type { UpdateManifest } from "akanjs/native/api";
7
6
  import { AkanAppConfig } from "../akanConfig";
8
7
  import type { DesktopArch } from "./desktopBin";
9
8
  import type { DesktopPlatform } from "./mobileTarget";
9
+ import type { NativeBuildApiModule } from "./nativeApi";
10
10
  import { TarWriter } from "./tarWriter";
11
11
  import { UpdateUploader } from "./updateUploader";
12
12
 
13
13
  export type DesktopSigner = { teamId: string } | { subject: string } | null;
14
14
 
15
- /** `release.json`, the one file of a desktop release archive `publish-update` did not write. */
15
+ /**
16
+ * `release.json`, the one file of a desktop release archive `publish-update` did not write. Schema 2: the archive holds
17
+ * the release's envelope, and its v1 pair only when that is the same release.
18
+ */
16
19
  export interface DesktopReleaseRecord {
17
- schema: 1;
20
+ schema: 2;
18
21
  cli: string;
19
22
  platform: DesktopPlatform;
20
23
  arch: DesktopArch;
@@ -37,10 +40,15 @@ interface DesktopReleasePayload {
37
40
  sha256: string;
38
41
  }
39
42
 
40
- //* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed manifest and the
41
- //* files it names, packed as they are, so the same folder uploaded anywhere else stays valid.
43
+ interface DesktopReleaseManifestFile {
44
+ name: string;
45
+ bytes: Uint8Array<ArrayBuffer>;
46
+ }
47
+
48
+ //* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed envelope, the v1
49
+ //* pair of the same release, and the files the manifest names, packed as they are, so the same folder uploaded anywhere
50
+ //* else stays valid.
42
51
  export class DesktopRelease {
43
- static readonly #ed25519Spki = Buffer.from("302a300506032b6570032100", "hex");
44
52
  //? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
45
53
  //? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
46
54
  static readonly #authenticode = `$ErrorActionPreference = 'Stop'
@@ -56,8 +64,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
56
64
  readonly platform: DesktopPlatform,
57
65
  readonly arch: DesktopArch,
58
66
  readonly channel: string,
59
- readonly manifestBytes: Uint8Array<ArrayBuffer>,
60
- readonly signatureBytes: Uint8Array<ArrayBuffer>,
67
+ readonly manifestFiles: DesktopReleaseManifestFile[],
61
68
  ) {}
62
69
 
63
70
  static async read(
@@ -67,39 +74,48 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
67
74
  arch,
68
75
  channel,
69
76
  appId,
70
- publicKey,
71
- }: { platform: DesktopPlatform; arch: DesktopArch; channel: string; appId: string; publicKey: string },
77
+ target,
78
+ publicKeys,
79
+ revokedKeys = [],
80
+ api,
81
+ }: {
82
+ platform: DesktopPlatform;
83
+ arch: DesktopArch;
84
+ channel: string;
85
+ appId: string;
86
+ target?: string;
87
+ publicKeys: readonly string[];
88
+ revokedKeys?: readonly string[];
89
+ api: Pick<NativeBuildApiModule, "openEnvelope" | "verifyManifest">;
90
+ },
72
91
  ) {
73
92
  const folder = `${platform}-${arch}`;
74
93
  const dir = path.join(updatesDir, folder);
75
- const manifestPath = path.join(dir, `${channel}.json`);
76
- const [manifestBytes, signatureBytes] = await Promise.all([
77
- readFile(manifestPath).catch(() => null),
78
- readFile(`${manifestPath}.sig`).catch(() => null),
79
- ]);
80
- if (!manifestBytes || !signatureBytes)
81
- throw new Error(
82
- `${manifestBytes ? `${manifestPath}.sig` : manifestPath} is missing: run \`akan publish-update\` first.`,
83
- );
84
- if (!DesktopRelease.#verifies(manifestBytes, signatureBytes, publicKey))
85
- throw new Error(
86
- `${manifestPath}.sig does not verify with updates.publicKeys[0], the one key installed apps trust: publish the release again.`,
87
- );
88
- const manifest = DesktopRelease.#parse(manifestBytes);
94
+ const envelopeName = `${channel}/app.json`;
95
+ const envelopePath = path.join(dir, channel, "app.json");
96
+ const envelope = await readFile(envelopePath).catch(() => null);
97
+ if (!envelope) throw new Error(`${envelopePath} is missing: run \`akan publish-update\` first.`);
98
+ const opened = DesktopRelease.#open(api, envelope, publicKeys, revokedKeys);
99
+ if (typeof opened === "string") throw new Error(`${envelopePath}: ${opened}; publish the release again.`);
100
+ const manifest = DesktopRelease.#parse(opened);
89
101
  if (
90
- manifest?.schema !== 1 ||
102
+ manifest?.schema !== 2 ||
91
103
  manifest.kind !== "app" ||
92
104
  manifest.app !== appId ||
93
105
  manifest.platform !== platform ||
94
106
  manifest.arch !== arch ||
95
107
  manifest.channel !== channel ||
108
+ manifest.target !== target ||
96
109
  typeof manifest.bundle !== "string" ||
97
110
  typeof manifest.sequence !== "number" ||
98
111
  typeof manifest.build !== "number" ||
99
112
  !manifest.archive
100
113
  )
101
- throw new Error(`${manifestPath} is not a release of ${appId} for ${folder} on channel ${channel}.`);
114
+ throw new Error(
115
+ `${envelopePath} is not a release of ${appId}${target ? ` target ${target}` : ""} for ${folder} on channel ${channel}.`,
116
+ );
102
117
  const { archive, bundle, sequence, build } = manifest;
118
+ const legacy = await DesktopRelease.#legacyPair(api, dir, channel, publicKeys[0], manifest);
103
119
  const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
104
120
  const expected = new Map(
105
121
  [{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
@@ -118,20 +134,41 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
118
134
  platform,
119
135
  arch,
120
136
  channel,
121
- manifestBytes,
122
- signatureBytes,
137
+ [{ name: envelopeName, bytes: envelope }, ...legacy],
138
+ );
139
+ }
140
+
141
+ //? An app built before protocol v2 reads the pair, signed with the first key only; a pair of an earlier release is
142
+ //? left out, since it would publish that release again to those apps.
143
+ static async #legacyPair(
144
+ api: Pick<NativeBuildApiModule, "verifyManifest">,
145
+ dir: string,
146
+ channel: string,
147
+ publicKey: string | undefined,
148
+ manifest: Partial<UpdateManifest>,
149
+ ): Promise<DesktopReleaseManifestFile[]> {
150
+ const [bytes, signature] = await Promise.all(
151
+ [`${channel}.json`, `${channel}.json.sig`].map((name) => readFile(path.join(dir, name)).catch(() => null)),
123
152
  );
153
+ if (!bytes || !signature || !publicKey) return [];
154
+ const v1 = DesktopRelease.#parse(bytes);
155
+ if (v1?.schema !== 1 || v1.bundle !== manifest.bundle || v1.sequence !== manifest.sequence) return [];
156
+ if (!api.verifyManifest(bytes, new TextDecoder().decode(signature).trim(), publicKey)) return [];
157
+ return [
158
+ { name: `${channel}.json`, bytes },
159
+ { name: `${channel}.json.sig`, bytes: signature },
160
+ ];
124
161
  }
125
162
 
126
163
  record(signer: DesktopSigner): DesktopReleaseRecord {
127
164
  const { platform, arch, channel } = this;
128
165
  const builtAt = new Date(this.manifest.build * 1000).toISOString();
129
- return { schema: 1, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
166
+ return { schema: 2, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
130
167
  }
131
168
 
132
169
  //* Akan Cloud refuses every push in any other shape: uncompressed, regular files under `./` names, no directory
133
- //* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json and the manifest pair before
134
- //* any `app/` entry. What the manifest names is checked against it as it is copied.
170
+ //* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json, the envelope and the v1 pair
171
+ //* before any `app/` entry. What the manifest names is checked against it as it is copied.
135
172
  async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
136
173
  const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
137
174
  try {
@@ -139,11 +176,10 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
139
176
  const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
140
177
  try {
141
178
  await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
142
- await tar.add(`./${this.channel}.json`, new Blob([this.manifestBytes]));
143
- await tar.add(`./${this.channel}.json.sig`, new Blob([this.signatureBytes]));
179
+ for (const { name, bytes } of this.manifestFiles) await tar.add(`./${name}`, new Blob([bytes]));
144
180
  for (const { name, size, sha256 } of this.payloads) {
145
181
  const file = Bun.file(path.join(this.dir, name));
146
- const what = `${path.join(this.dir, name)} is not the file ${this.channel}.json names`;
182
+ const what = `${path.join(this.dir, name)} is not the file ${this.channel}/app.json names`;
147
183
  if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
148
184
  if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
149
185
  }
@@ -170,7 +206,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
170
206
  if (this.platform === "linux") return { signer: null, unread: null };
171
207
  const app = this.manifest.files?.[0]?.path;
172
208
  if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
173
- return { signer: null, unread: `${this.channel}.json names no app folder` };
209
+ return { signer: null, unread: `${this.channel}/app.json names no app folder` };
174
210
  if (this.platform === "macos" && host !== "darwin")
175
211
  return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
176
212
  if (this.platform === "windows" && host !== "win32")
@@ -228,16 +264,16 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
228
264
  return { signer: subject ? { subject } : null, unread: null };
229
265
  }
230
266
 
231
- static #verifies(manifest: Uint8Array, signature: Uint8Array, publicKey: string) {
267
+ static #open(
268
+ api: Pick<NativeBuildApiModule, "openEnvelope">,
269
+ envelope: Uint8Array,
270
+ publicKeys: readonly string[],
271
+ revokedKeys: readonly string[],
272
+ ): Uint8Array | string {
232
273
  try {
233
- const key = createPublicKey({
234
- key: Buffer.concat([DesktopRelease.#ed25519Spki, Buffer.from(publicKey, "base64")]),
235
- format: "der",
236
- type: "spki",
237
- });
238
- return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
239
- } catch {
240
- return false;
274
+ return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
275
+ } catch (error) {
276
+ return toError(error).message;
241
277
  }
242
278
  }
243
279
 
@@ -42,6 +42,9 @@ describe("NativeApi", () => {
42
42
  });
43
43
 
44
44
  test("refuses an API older than the one the build calls into, and another major, before anything builds", async () => {
45
+ await expect(NativeApi.load(await installVendoredApi("0.12.0"))).rejects.toThrow(
46
+ "install matching versions of akanjs and @akanjs/cli",
47
+ );
45
48
  await expect(NativeApi.load(await installVendoredApi("0.10.0"))).rejects.toThrow(
46
49
  "install matching versions of akanjs and @akanjs/cli",
47
50
  );
@@ -51,6 +54,6 @@ describe("NativeApi", () => {
51
54
  expect((await NativeApi.load(await installVendoredApi(NativeApi.minimumVersion))).API_VERSION).toBe(
52
55
  NativeApi.minimumVersion,
53
56
  );
54
- expect((await NativeApi.load(await installVendoredApi("0.12.0"))).API_VERSION).toBe("0.12.0");
57
+ expect((await NativeApi.load(await installVendoredApi("0.13.4"))).API_VERSION).toBe("0.13.4");
55
58
  });
56
59
  });
@@ -6,9 +6,9 @@ export type NativeBuildApiModule = typeof NativeBuildApi;
6
6
  export class NativeApi {
7
7
  //* The build API versions itself; a major this devkit was not written against is refused before anything builds.
8
8
  static readonly supportedMajor = 0;
9
- //? An update release's `revoke` and `resetSequence` arrived in 0.12.0, and an older API would publish without them;
10
- //? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
11
- static readonly minimumVersion = "0.12.0";
9
+ //? A desktop release's envelope and `updates.target` arrived in 0.13.0, and an older API refuses the key; an update
10
+ //? release's `revoke` and `resetSequence` in 0.12.0; `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
11
+ static readonly minimumVersion = "0.13.0";
12
12
 
13
13
  /** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
14
14
  static entryOf(appDir: string): string {
@@ -341,8 +341,8 @@ describe("NativeApp", () => {
341
341
  (await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
342
342
  .config.updates;
343
343
 
344
- expect(await built("main")).toEqual({ ...updates, channel: "main" });
345
- expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
344
+ expect(await built("main")).toEqual({ ...updates, channel: "main", target: "default" });
345
+ expect(await built("debug")).toEqual({ ...updates, channel: "debug", target: "default" });
346
346
  });
347
347
 
348
348
  test("a desktop app is this computer's platform", () => {
@@ -27,7 +27,7 @@ type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
27
27
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
28
28
  type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
29
29
  type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
30
- type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
30
+ type ReleaseRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
31
31
 
32
32
  export interface NativeRunOptions {
33
33
  device?: DeviceSelector;
@@ -315,17 +315,24 @@ export class NativeApp {
315
315
  //? The channel falls back as publishUpdate's does, so a push with the flags of the publish finds what it wrote.
316
316
  async publishedRelease(platform: DesktopPlatform, { arch, channel }: { arch: DesktopArch; channel?: string | null }) {
317
317
  this.#assertUpdates();
318
- const { config } = await this.config({ platform, stageBin: false });
319
- const publicKey = config.updates?.publicKeys?.[0] ?? config.updates?.publicKey;
320
- if (!config.updates || !publicKey) throw new Error(`Native target '${this.target.name}' has no updates key.`);
318
+ const [api, { config }] = await Promise.all([
319
+ NativeApi.load(this.app.cwdPath),
320
+ this.config({ platform, stageBin: false }),
321
+ ]);
322
+ const { updates } = config;
323
+ const publicKeys = updates?.publicKeys ?? (updates?.publicKey ? [updates.publicKey] : []);
324
+ if (!updates || !publicKeys.length) throw new Error(`Native target '${this.target.name}' has no updates key.`);
321
325
  const release = await DesktopRelease.read(this.updatesDir, {
322
326
  platform,
323
327
  arch,
324
- channel: channel ?? config.updates.channel ?? "production",
328
+ channel: channel ?? updates.channel ?? "production",
325
329
  appId: config.app.id,
326
- publicKey,
330
+ ...(updates.target ? { target: updates.target } : {}),
331
+ publicKeys,
332
+ revokedKeys: updates.revokedKeys ?? [],
333
+ api,
327
334
  });
328
- return { release, url: config.updates.url, exeName: config.app.fileName ?? "" };
335
+ return { release, url: updates.url, exeName: config.app.fileName ?? "" };
329
336
  }
330
337
 
331
338
  /** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
@@ -345,7 +352,7 @@ export class NativeApp {
345
352
  arch?: DesktopArch;
346
353
  sign?: boolean;
347
354
  notarize?: boolean;
348
- } & PhoneRevocation = {},
355
+ } & ReleaseRevocation = {},
349
356
  ) {
350
357
  NativeApp.#assertServerPlatform(platform, server);
351
358
  this.#assertUpdates();
@@ -399,8 +406,8 @@ export class NativeApp {
399
406
 
400
407
  /**
401
408
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
402
- * another app's, a desktop release whose server presence is not its channel's previous release's, and a phone release
403
- * whose revocations no phone would take.
409
+ * another app's, a desktop release whose server presence is not its channel's previous release's, and a release whose
410
+ * revocations no app would take.
404
411
  */
405
412
  async assertPublishable(
406
413
  platform: NativePlatform,
@@ -410,7 +417,7 @@ export class NativeApp {
410
417
  arch,
411
418
  revoke,
412
419
  resetSequence,
413
- }: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
420
+ }: { channel?: string; server?: boolean; arch?: DesktopArch } & ReleaseRevocation = {},
414
421
  ) {
415
422
  this.#assertUpdates();
416
423
  const [api, { config }] = await Promise.all([
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { generateKeyPairSync } from "node:crypto";
2
3
  import { mkdir, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import type { AkanNativeTarget } from "../akanConfig";
@@ -42,7 +43,9 @@ const adminTarget: AkanNativeTarget = {
42
43
  },
43
44
  };
44
45
 
45
- const updatesKey = Buffer.alloc(32, 7).toString("base64");
46
+ const ed25519Key = () =>
47
+ generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
48
+ const updatesKey = ed25519Key();
46
49
 
47
50
  describe("NativeConfig.build", () => {
48
51
  test("hands the dmg window over with its pictures resolved against the app folder", () => {
@@ -224,19 +227,22 @@ describe("NativeConfig.build", () => {
224
227
  url,
225
228
  publicKey: updatesKey,
226
229
  channel: "develop",
230
+ target: "admin",
227
231
  });
228
232
  expect(build({ url, publicKey: updatesKey, channel: "beta", readyTimeout: 15_000 }, "main").updates).toEqual({
229
233
  url,
230
234
  publicKey: updatesKey,
231
235
  channel: "beta",
232
236
  readyTimeout: 15_000,
237
+ target: "admin",
233
238
  });
234
- expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
235
- const spare = Buffer.alloc(32, 8).toString("base64");
239
+ expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey, target: "admin" });
240
+ const spare = ed25519Key();
236
241
  expect(build({ url, publicKeys: [updatesKey, spare] }, "main").updates).toEqual({
237
242
  url,
238
243
  publicKeys: [updatesKey, spare],
239
244
  channel: "main",
245
+ target: "admin",
240
246
  });
241
247
  const report = { url: "https://cloud.example.com/api/ota/report/abcdefghijklmnop" };
242
248
  expect(build({ url, publicKey: updatesKey, report }, "main").updates).toEqual({
@@ -244,6 +250,7 @@ describe("NativeConfig.build", () => {
244
250
  publicKey: updatesKey,
245
251
  channel: "main",
246
252
  report,
253
+ target: "admin",
247
254
  });
248
255
  expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
249
256
  expect(build(undefined, "main").plugins).not.toContain("updates");
@@ -457,7 +464,7 @@ describe("NativeConfig.build", () => {
457
464
  });
458
465
 
459
466
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "updates"]);
460
- expect(config.updates).toEqual(updates);
467
+ expect(config.updates).toEqual({ ...updates, target: minimalTarget.name });
461
468
  });
462
469
 
463
470
  test("an updates channel left unnamed is the backend env the binary is built for", () => {
@@ -479,10 +486,11 @@ describe("NativeConfig.build", () => {
479
486
  ...(env ? { env } : {}),
480
487
  }).config.updates;
481
488
 
482
- expect(build("debug")).toEqual({ ...updates, channel: "debug" });
483
- expect(build("main")).toEqual({ ...updates, channel: "main" });
484
- expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot" });
485
- expect(build()).toEqual(updates);
489
+ const target = minimalTarget.name;
490
+ expect(build("debug")).toEqual({ ...updates, channel: "debug", target });
491
+ expect(build("main")).toEqual({ ...updates, channel: "main", target });
492
+ expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot", target });
493
+ expect(build()).toEqual({ ...updates, target });
486
494
  });
487
495
 
488
496
  test("keeps a file name the runtime accepts", () => {
@@ -267,7 +267,7 @@ export class NativeConfig {
267
267
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
268
268
  const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
269
269
  const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
270
- return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}) } : undefined;
270
+ return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}), target: target.name } : undefined;
271
271
  }
272
272
 
273
273
  /** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
@@ -83,6 +83,40 @@ describe("UpdateUploader", () => {
83
83
  );
84
84
  });
85
85
 
86
+ test("uploads a desktop release's envelope, with the v1 pair only when the pair is that release", async () => {
87
+ const manifest = { kind: "app", bundle: "1.3.0-3000", archive: { url: `app/${sha("c")}.tar.gz` }, patches: [] };
88
+ await inUpdatesDir(
89
+ {
90
+ [`macos-arm64/app/${sha("c")}.tar.gz`]: "tar",
91
+ "macos-arm64/main/app.json": envelope(manifest),
92
+ "macos-arm64/main.json": JSON.stringify({ ...manifest, bundle: "1.2.0-2000" }),
93
+ "macos-arm64/main.json.sig": "sig",
94
+ "macos-arm64/beta/app.json": envelope({ ...manifest, bundle: "1.4.0-4000" }),
95
+ "macos-arm64/beta.json": JSON.stringify({ ...manifest, bundle: "1.4.0-4000" }),
96
+ "macos-arm64/beta.json.sig": "sig",
97
+ },
98
+ async (updatesDir) => {
99
+ const dir = path.join(updatesDir, "macos-arm64");
100
+ const upload = async (channel: string, bundle: string) => {
101
+ const { bucket, writes } = fakeBucket();
102
+ await new UpdateUploader(bucket, "").upload(updatesDir, { dir, channel, bundle });
103
+ return writes.map(({ key }) => key);
104
+ };
105
+
106
+ expect(await upload("main", "1.3.0-3000")).toEqual([
107
+ `macos-arm64/app/${sha("c")}.tar.gz`,
108
+ "macos-arm64/main/app.json",
109
+ ]);
110
+ expect(await upload("beta", "1.4.0-4000")).toEqual([
111
+ `macos-arm64/app/${sha("c")}.tar.gz`,
112
+ "macos-arm64/beta.json.sig",
113
+ "macos-arm64/beta.json",
114
+ "macos-arm64/beta/app.json",
115
+ ]);
116
+ },
117
+ );
118
+ });
119
+
86
120
  test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
87
121
  await inUpdatesDir(
88
122
  {
@@ -51,12 +51,8 @@ export class UpdateUploader {
51
51
  //* APIs, which a later publish from another machine may have replaced.
52
52
  static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
53
53
  const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
54
- const desktop = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
- if (desktop?.bundle === bundle) {
56
- const archive = desktop.archive?.url;
57
- const patches = UpdateUploader.#field(desktop.patches, "url");
58
- return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
59
- }
54
+ const v1 = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
55
+ const pair = v1?.bundle === bundle ? [`${channel}.json.sig`, `${channel}.json`] : [];
60
56
  const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
61
57
  for (const name of names.filter((name) => name.endsWith(".json"))) {
62
58
  const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
@@ -65,6 +61,8 @@ export class UpdateUploader {
65
61
  ? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
66
62
  : null;
67
63
  if (manifest?.bundle !== bundle) continue;
64
+ if (manifest.kind === "app")
65
+ return [...UpdateUploader.#appFiles(manifest), ...pair, `${channel}/${name}`].map(keyOf);
68
66
  const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
69
67
  if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
70
68
  throw new Error(
@@ -72,9 +70,14 @@ export class UpdateUploader {
72
70
  );
73
71
  return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
74
72
  }
73
+ if (v1 && pair.length) return [...UpdateUploader.#appFiles(v1), ...pair].map(keyOf);
75
74
  throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
76
75
  }
77
76
 
77
+ static #appFiles(manifest: Partial<UpdateManifest>): unknown[] {
78
+ return [manifest.archive?.url, ...UpdateUploader.#field(manifest.patches, "url")];
79
+ }
80
+
78
81
  static #keyOf(updatesDir: string, dir: string, file: unknown) {
79
82
  const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
80
83
  const key = path.relative(updatesDir, resolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.8",
3
+ "version": "3.1.1-rc.9",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "@earendil-works/pi-tui": "1.3.0",
49
49
  "@inquirer/prompts": "^8.7.3",
50
50
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.8",
51
+ "akanjs": "3.1.1-rc.9",
52
52
  "chalk": "^6.0.1",
53
53
  "commander": "^15.0.0",
54
54
  "dayjs": "^1.11.23",