@akanjs/devkit 3.1.0-rc.0 → 3.1.0-rc.2

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.
Files changed (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +25 -13
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +151 -41
  16. package/cloud/updatePackUpload.ts +60 -23
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +46 -0
  24. package/executors.ts +26 -23
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -0
  35. package/mobile/nativeApp.ts +109 -63
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -1,7 +1,7 @@
1
- import { rm } from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import type { AkanNativeConfig } from "@akanjs/native/config";
4
3
  import type { NativeEnv } from "akanjs";
4
+ import { toError } from "akanjs/common";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { Executor } from "../executors";
7
7
  import { type DesktopArch, DesktopBin } from "./desktopBin";
@@ -17,13 +17,16 @@ import { NativeApi, type NativeBuildApiModule } from "./nativeApi";
17
17
  import { NativeAppLine, type NativeAppLineRead } from "./nativeAppLine";
18
18
  import { NativeConfig } from "./nativeConfig";
19
19
  import { NativePluginFolders } from "./nativePluginFolders";
20
+ import { NativePublicEnv, type PublicEnv } from "./nativePublicEnv";
20
21
  import { NativeWebDir } from "./nativeWebDir";
21
22
  import { ServerBundleSecrets } from "./serverBundleSecrets";
23
+ import { type NativeUpdatePack, UpdatePackFolder } from "./updatePackFolder";
22
24
 
23
25
  type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
24
26
  type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
25
27
  type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
26
28
  type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
29
+ type PhoneRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
27
30
 
28
31
  export interface NativeRunOptions {
29
32
  device?: DeviceSelector;
@@ -56,25 +59,6 @@ export interface NativeDevOptions extends NativeRunOptions {
56
59
  lang: string;
57
60
  }
58
61
 
59
- /** `<out>/pack.json`: what a signer needs of a pack, read instead of the log. */
60
- export interface NativeUpdatePack {
61
- schema: 1;
62
- app: string;
63
- target: string;
64
- platform: MobilePlatform;
65
- env: NativeEnv;
66
- channel: string;
67
- appId: string;
68
- appVersion: string;
69
- nativeApi: string;
70
- /** bundle.json `web.hash`: the web files as served, the part of a bundle id that names its content. */
71
- webHash: string;
72
- files: number;
73
- size: number;
74
- out: string;
75
- compat?: { against: string; compatible: boolean; problems: string[] };
76
- }
77
-
78
62
  /** One native target of an app on the native runtime (a phone, or this computer as a desktop app): where it builds,
79
63
  * what it ships, and the calls into the API. */
80
64
  export class NativeApp {
@@ -224,16 +208,23 @@ export class NativeApp {
224
208
  NativeApp.#assertServerPlatform(platform, server);
225
209
  NativeApp.assertInstaller(platform, installer);
226
210
  await this.assembleWeb(platform);
211
+ const publicEnv = await this.#publicEnv();
227
212
  const { api, config } = await this.prepare(platform, server, arch);
228
213
  const distribution = await this.#distribution(api, platform, profile, { sign, notarize }, server);
229
- return await api.build({
230
- ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
231
- profile,
232
- ...(arch ? { arch } : {}),
233
- ...(platform === "windows" ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } } : {}),
234
- ...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
235
- ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
236
- });
214
+ return await this.#recorded(
215
+ this.outDir(platform),
216
+ publicEnv,
217
+ api.build({
218
+ ...this.#task(platform, config, { env: await this.#runtimeEnv(server) }),
219
+ profile,
220
+ ...(arch ? { arch } : {}),
221
+ ...(platform === "windows"
222
+ ? { windows: { ...(installer ? { installer } : {}), ...distribution.windows } }
223
+ : {}),
224
+ ...(platform === "macos" ? { macos: { ...distribution.macos, ...(installer ? { dmg: true } : {}) } } : {}),
225
+ ...(platform === "linux" && installer ? { linux: { appImage: true } } : {}),
226
+ }),
227
+ );
237
228
  }
238
229
 
239
230
  async run(
@@ -302,12 +293,17 @@ export class NativeApp {
302
293
 
303
294
  async releaseIos({ teamId, adHoc = false }: { teamId?: string; adHoc?: boolean } = {}) {
304
295
  await this.assembleWeb("ios");
296
+ const publicEnv = await this.#publicEnv();
305
297
  const { api, config } = await this.prepare("ios");
306
- return await api.release({
307
- ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
308
- platform: "ios",
309
- signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
310
- });
298
+ return await this.#recorded(
299
+ this.outDir("ios"),
300
+ publicEnv,
301
+ api.release({
302
+ ...this.#task("ios", config, { env: await this.#runtimeEnv() }),
303
+ platform: "ios",
304
+ signing: { ...(teamId ? { teamId } : {}), ...(adHoc ? { distribution: "ad-hoc" as const } : {}) },
305
+ }),
306
+ );
311
307
  }
312
308
 
313
309
  /** Where publishUpdate writes: upload this folder as the target's updates.url. */
@@ -324,7 +320,15 @@ export class NativeApp {
324
320
  arch,
325
321
  sign = false,
326
322
  notarize = false,
327
- }: { channel?: string; server?: DesktopServerBundle; arch?: DesktopArch; sign?: boolean; notarize?: boolean } = {},
323
+ revoke,
324
+ resetSequence,
325
+ }: {
326
+ channel?: string;
327
+ server?: DesktopServerBundle;
328
+ arch?: DesktopArch;
329
+ sign?: boolean;
330
+ notarize?: boolean;
331
+ } & PhoneRevocation = {},
328
332
  ) {
329
333
  NativeApp.#assertServerPlatform(platform, server);
330
334
  this.#assertUpdates();
@@ -340,11 +344,14 @@ export class NativeApp {
340
344
  ...(arch ? { arch } : {}),
341
345
  ...(distribution.macos ? { macos: distribution.macos } : {}),
342
346
  ...(distribution.windows ? { windows: distribution.windows } : {}),
347
+ ...(revoke?.length ? { revoke } : {}),
348
+ ...(resetSequence ? { resetSequence } : {}),
343
349
  });
344
350
  }
345
351
 
346
- //* Only what the command asked for is signed or uploaded; a signed app's server is searched for the workspace's
347
- //* secret values first, and the one line naming the identity and the service comes right before the build.
352
+ //* Only what the command asked for is signed or uploaded. A release's server is searched for the workspace's secret
353
+ //* values first, signed or not: an unsigned app is handed out too, and an update is published to every install. The
354
+ //* one line naming the identity and the service comes right before the build.
348
355
  async #distribution(
349
356
  api: NativeBuildApiModule,
350
357
  platform: NativePlatform,
@@ -353,29 +360,40 @@ export class NativeApp {
353
360
  server: DesktopServerBundle | undefined,
354
361
  ) {
355
362
  const distribution = api.requestedDistribution(platform, profile, request);
363
+ if (server && profile === "release") await this.#assertServerClean(server);
356
364
  if (!distribution.notice) {
357
365
  if (distribution.unused.length && (platform === "macos" || platform === "windows"))
358
366
  this.app.logger.info(`${distribution.unused.join(", ")} set: this build is not signed without --sign.`);
359
367
  return distribution;
360
368
  }
361
- if (server) {
362
- const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
363
- server.dir,
364
- );
365
- if (unread.length)
366
- this.app.logger.warn(`Not searched for secrets, as they could not be evaluated: ${unread.join(", ")}.`);
367
- }
368
369
  this.app.logger.warn(distribution.notice);
369
370
  return distribution;
370
371
  }
371
372
 
373
+ async #assertServerClean(server: DesktopServerBundle) {
374
+ const unread = await new ServerBundleSecrets(this.app.workspace.workspaceRoot, this.app.cwdPath).assertClean(
375
+ server.dir,
376
+ );
377
+ if (unread.length)
378
+ throw new Error(
379
+ `The server this app carries was not searched for the secret values of ${unread.join(", ")}, which could not be evaluated with no app env set, so the release stops here.`,
380
+ );
381
+ }
382
+
372
383
  /**
373
384
  * What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
374
- * another app's, and a desktop release whose server presence is not its channel's previous release's.
385
+ * another app's, a desktop release whose server presence is not its channel's previous release's, and a phone release
386
+ * whose revocations no phone would take.
375
387
  */
376
388
  async assertPublishable(
377
389
  platform: NativePlatform,
378
- { channel, server = false, arch }: { channel?: string; server?: boolean; arch?: DesktopArch } = {},
390
+ {
391
+ channel,
392
+ server = false,
393
+ arch,
394
+ revoke,
395
+ resetSequence,
396
+ }: { channel?: string; server?: boolean; arch?: DesktopArch } & PhoneRevocation = {},
379
397
  ) {
380
398
  this.#assertUpdates();
381
399
  const [api, { config }] = await Promise.all([
@@ -392,14 +410,13 @@ export class NativeApp {
392
410
  server,
393
411
  ...(channel ? { channel } : {}),
394
412
  ...(arch ? { arch } : {}),
413
+ ...(revoke?.length ? { revoke } : {}),
414
+ ...(resetSequence ? { resetSequence } : {}),
395
415
  });
396
416
  } catch (error) {
397
- throw new Error(
398
- `Native target '${this.target.name}': ${error instanceof Error ? error.message : String(error)}`,
399
- {
400
- cause: error,
401
- },
402
- );
417
+ throw new Error(`Native target '${this.target.name}': ${toError(error).message}`, {
418
+ cause: error,
419
+ });
403
420
  }
404
421
  }
405
422
 
@@ -422,29 +439,38 @@ export class NativeApp {
422
439
  async releaseAndroid({ formats = ["aab"] }: { formats?: ("aab" | "apk")[] } = {}) {
423
440
  const signing = NativeApp.androidSigning();
424
441
  await this.assembleWeb("android");
442
+ const publicEnv = await this.#publicEnv();
425
443
  const { api, config } = await this.prepare("android");
426
- return await api.release({
427
- ...this.#task("android", config, { env: await this.#runtimeEnv() }),
428
- platform: "android",
429
- signing,
430
- formats,
431
- });
444
+ return await this.#recorded(
445
+ this.outDir("android"),
446
+ publicEnv,
447
+ api.release({
448
+ ...this.#task("android", config, { env: await this.#runtimeEnv() }),
449
+ platform: "android",
450
+ signing,
451
+ formats,
452
+ }),
453
+ );
432
454
  }
433
455
 
434
456
  /** An unsigned OTA update of this target's web bundle for `platform`, and the record `pack.json` keeps of it. */
435
457
  async packUpdate(platform: MobilePlatform, { out }: { out?: string } = {}) {
436
458
  await this.assembleWeb(platform);
459
+ const publicEnv = await this.#publicEnv();
437
460
  const { api, config } = await this.prepare(platform);
438
461
  if (!config.updates)
439
462
  throw new Error(
440
463
  `Native target '${this.target.name}' takes no updates: add native.updates: { url, publicKey } to akan.config.ts first.`,
441
464
  );
442
465
  const dir = path.resolve(out ?? path.join(this.updatesDir, platform));
443
- //? A pack that fails must not leave the previous pack's record behind for whoever reads the exit code.
444
- await Promise.all(["pack.json", "compat.json"].map((name) => rm(path.join(dir, name), { force: true })));
445
- const packed = await api.packUpdate({ ...this.#task(platform, config), platform, out: dir });
466
+ await UpdatePackFolder.clear(dir);
467
+ const packed = await this.#recorded(
468
+ dir,
469
+ publicEnv,
470
+ api.packUpdate({ ...this.#task(platform, config), platform, out: dir }),
471
+ );
446
472
  const channel = config.updates.channel ?? "production";
447
- const bundle = (await Bun.file(path.join(packed.out, "bundle.json")).json()) as { web: { hash: string } };
473
+ const bundle = await UpdatePackFolder.readBundle(path.join(packed.out, UpdatePackFolder.bundleFile));
448
474
  const pack: NativeUpdatePack = {
449
475
  schema: 1,
450
476
  app: this.app.name,
@@ -456,6 +482,7 @@ export class NativeApp {
456
482
  appVersion: packed.manifest.version,
457
483
  nativeApi: packed.manifest.nativeApi ?? "",
458
484
  webHash: bundle.web.hash,
485
+ ...(bundle.web.publicEnvHash ? { publicEnvHash: bundle.web.publicEnvHash } : {}),
459
486
  files: packed.manifest.files.length,
460
487
  size: packed.manifest.files.reduce((sum, file) => sum + file.size, 0),
461
488
  out: packed.out,
@@ -463,8 +490,27 @@ export class NativeApp {
463
490
  return { ...packed, channel, pack };
464
491
  }
465
492
 
466
- static async writePack(pack: NativeUpdatePack) {
467
- await Bun.write(path.join(pack.out, "pack.json"), `${JSON.stringify(pack, null, 2)}\n`);
493
+ //* Checked before anything is built or signed: a target with updates would ship a binary Akan Cloud cannot update.
494
+ async #publicEnv() {
495
+ const publicEnv = await NativePublicEnv.built(this.app);
496
+ if (!publicEnv) return null;
497
+ const problems = NativePublicEnv.problems(publicEnv);
498
+ if (!problems.length) return publicEnv;
499
+ const message = `The web build inlines public env an update cannot carry:\n- ${problems.join("\n- ")}`;
500
+ if (this.target.config.updates) throw new Error(message);
501
+ this.app.logger.warn(`${message}\nbundle.json records none, so no update can be checked against this build.`);
502
+ return null;
503
+ }
504
+
505
+ //? After the build: the native CLI writes bundle.json, and a phone build with updates rewrites it once more.
506
+ async #recorded<T>(dir: string, publicEnv: PublicEnv | null, build: Promise<T>) {
507
+ const result = await build;
508
+ const file = path.join(dir, UpdatePackFolder.bundleFile);
509
+ if (!publicEnv || !(await Bun.file(file).exists())) return result;
510
+ const bundle = await UpdatePackFolder.readBundle(file);
511
+ const web = { ...bundle.web, publicEnv, publicEnvHash: NativePublicEnv.hash(publicEnv) };
512
+ await Bun.write(file, `${JSON.stringify({ ...bundle, web }, null, 2)}\n`);
513
+ return result;
468
514
  }
469
515
 
470
516
  static async compareBundles(appDir: string, shipped: string, bundle: string) {
@@ -261,7 +261,8 @@ export class NativeConfig {
261
261
  };
262
262
  }
263
263
 
264
- //? akanConfig refuses a target whose merged updates lack url or a key; the type still has them optional.
264
+ //? akanConfig refuses a target whose merged updates lack url or a key, or hold both key forms; the type still has
265
+ //? them optional.
265
266
  static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
266
267
  const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
267
268
  const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
@@ -0,0 +1,131 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { WorkspaceExecutor } from "../executors";
5
+ import { tempDirs, writeText } from "../testHelpers";
6
+ import { NativePublicEnv } from "./nativePublicEnv";
7
+
8
+ const makeTempRoot = tempDirs("akan-native-public-env-");
9
+
10
+ const recorded = {
11
+ AKAN_PUBLIC_ENV: "main",
12
+ AKAN_PUBLIC_REPO_NAME: "softs2",
13
+ AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
14
+ };
15
+
16
+ describe("NativePublicEnv", () => {
17
+ test("keeps the AKAN_PUBLIC_* the app sets, sorted, and leaves out what akan.config.ts derives", () => {
18
+ expect(
19
+ Object.keys(
20
+ NativePublicEnv.of({
21
+ AKAN_PUBLIC_SERVE_DOMAIN: "akamir.com",
22
+ AKAN_PUBLIC_APP_NAME: "lu",
23
+ AKAN_PUBLIC_BASE_PATHS: "",
24
+ AKAN_PUBLIC_RENDER_ENV: "csr",
25
+ AKAN_PUBLIC_ENV: "main",
26
+ JWT_SECRET: "never",
27
+ }),
28
+ ),
29
+ ).toEqual(["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_SERVE_DOMAIN"]);
30
+ });
31
+
32
+ test("hashes the sorted map as JSON, whatever order it was written in", () => {
33
+ const reversed = Object.fromEntries(Object.entries(recorded).reverse());
34
+ const expected = createHash("sha256").update(JSON.stringify(recorded)).digest("hex");
35
+ expect(NativePublicEnv.hash(reversed)).toBe(expected);
36
+ });
37
+
38
+ test("refuses what Akan Cloud refuses in a recorded env", () => {
39
+ expect(NativePublicEnv.problems(recorded)).toEqual([]);
40
+ expect(
41
+ NativePublicEnv.problems({
42
+ AKAN_PUBLIC_lower: "x",
43
+ AKAN_PUBLIC_MAP_TOKEN: "x",
44
+ AKAN_PUBLIC_PORT: 8282,
45
+ AKAN_PUBLIC_NOTE: "line\nbreak",
46
+ }),
47
+ ).toEqual([
48
+ "AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
49
+ "AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
50
+ "AKAN_PUBLIC_PORT is not a string",
51
+ "AKAN_PUBLIC_NOTE holds a control character",
52
+ ]);
53
+ const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
54
+ expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
55
+ expect(NativePublicEnv.problems({ AKAN_PUBLIC_BIG: "x".repeat(70_000) })[0]).toContain("bytes, more than 65536");
56
+ });
57
+
58
+ test("names the keys two maps disagree on, either side missing included", () => {
59
+ expect(
60
+ NativePublicEnv.diff(recorded, { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com", AKAN_PUBLIC_X: "1" }),
61
+ ).toEqual(["AKAN_PUBLIC_SERVE_DOMAIN", "AKAN_PUBLIC_X"]);
62
+ });
63
+
64
+ test("loads --public-env from a JSON map or from a store build's bundle.json, and refuses the rest", async () => {
65
+ const root = await makeTempRoot();
66
+ const map = path.join(root, "store.env.json");
67
+ await writeText(map, JSON.stringify(recorded));
68
+ expect(await NativePublicEnv.load(map)).toEqual(recorded);
69
+
70
+ const bundle = path.join(root, "bundle.json");
71
+ await writeText(bundle, JSON.stringify({ schema: 1, web: { hash: "h", files: 1, publicEnv: recorded } }));
72
+ expect(await NativePublicEnv.load(bundle)).toEqual(recorded);
73
+ expect(await NativePublicEnv.recordedIn(bundle)).toEqual(recorded);
74
+
75
+ const older = path.join(root, "older.json");
76
+ await writeText(older, JSON.stringify({ schema: 1, web: { hash: "h", files: 1 } }));
77
+ await expect(NativePublicEnv.load(older)).rejects.toThrow("recorded no public env");
78
+ expect(await NativePublicEnv.recordedIn(older)).toBeNull();
79
+ expect(await NativePublicEnv.recordedIn(path.join(root, "missing.json"))).toBeNull();
80
+
81
+ const derived = path.join(root, "derived.json");
82
+ await writeText(derived, JSON.stringify({ ...recorded, AKAN_PUBLIC_APP_NAME: "lu" }));
83
+ await expect(NativePublicEnv.load(derived)).rejects.toThrow("AKAN_PUBLIC_APP_NAME, which akan.config.ts decides");
84
+
85
+ const secret = path.join(root, "secret.json");
86
+ await writeText(secret, JSON.stringify({ AKAN_PUBLIC_API_PASSWORD: "x" }));
87
+ await expect(NativePublicEnv.load(secret)).rejects.toThrow("names a password");
88
+ await expect(NativePublicEnv.load(path.join(root, "nope.json"))).rejects.toThrow("does not exist");
89
+ });
90
+
91
+ test("says which keys keep a pack out of a store build that recorded its env, and nothing when it recorded none", async () => {
92
+ const root = await makeTempRoot();
93
+ const shipped = path.join(root, "store.json");
94
+ const pack = path.join(root, "pack.json");
95
+ await writeText(shipped, JSON.stringify({ schema: 1, web: { publicEnv: recorded } }));
96
+ await writeText(
97
+ pack,
98
+ JSON.stringify({ schema: 1, web: { publicEnv: { ...recorded, AKAN_PUBLIC_SERVE_DOMAIN: "try.akanjs.com" } } }),
99
+ );
100
+ expect(await NativePublicEnv.problemAgainst(shipped, pack)).toBe(
101
+ `public env AKAN_PUBLIC_SERVE_DOMAIN differs from the store build's (pass --public-env ${shipped} to repeat it)`,
102
+ );
103
+ expect(await NativePublicEnv.problemAgainst(shipped, shipped)).toBeNull();
104
+ expect(await NativePublicEnv.problemAgainst(pack.replace("pack", "none"), pack)).toBeNull();
105
+ });
106
+
107
+ describe("pin", () => {
108
+ const saved = { ...process.env };
109
+ afterEach(() => {
110
+ for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key];
111
+ Object.assign(process.env, saved);
112
+ WorkspaceExecutor.publicEnvPin = null;
113
+ });
114
+
115
+ test("leaves exactly the pinned values and the derived ones, and outranks the root .env", async () => {
116
+ const root = await makeTempRoot();
117
+ const envFile = path.join(root, ".env");
118
+ await writeText(
119
+ envFile,
120
+ "AKAN_PUBLIC_REPO_NAME=from-dotenv\nAKAN_PUBLIC_SERVE_DOMAIN=try.akanjs.com\nAKAN_PUBLIC_LOG_LEVEL=debug\n",
121
+ );
122
+ Object.assign(process.env, { AKAN_PUBLIC_LOG_LEVEL: "debug", AKAN_PUBLIC_APP_NAME: "lu" });
123
+ NativePublicEnv.pin(recorded);
124
+ expect(NativePublicEnv.of(process.env)).toEqual(recorded);
125
+ expect(process.env.AKAN_PUBLIC_APP_NAME).toBe("lu");
126
+ const base = WorkspaceExecutor.getBaseDevEnv(envFile);
127
+ expect(base.repoName).toBe("softs2");
128
+ expect(base.serveDomain).toBe("akamir.com");
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { App } from "../commandDecorators";
3
+ import { WorkspaceExecutor } from "../executors";
4
+ import { CsrArtifactBuilder } from "../frontendBuild/csrArtifactBuilder";
5
+
6
+ export type PublicEnv = Record<string, string>;
7
+
8
+ //* The `AKAN_PUBLIC_*` a native web build inlines: an update has to repeat them, because the API host and the env an
9
+ //* installed binary talks to live there and nowhere else. Keys akan.config.ts derives are left out, as Akan Cloud does.
10
+ export class NativePublicEnv {
11
+ static readonly derivedKeys = [
12
+ "AKAN_PUBLIC_APP_NAME",
13
+ "AKAN_PUBLIC_BASE_PATHS",
14
+ "AKAN_PUBLIC_API_PREFIX",
15
+ "AKAN_PUBLIC_WS_PREFIX",
16
+ "AKAN_PUBLIC_RENDER_ENV",
17
+ "AKAN_PUBLIC_DEFAULT_LOCALE",
18
+ "AKAN_PUBLIC_LOCALES",
19
+ ];
20
+ //? Akan Cloud's own limits on a recorded env: a build it would refuse fails here, before anything is signed.
21
+ static readonly #keyPattern = /^AKAN_PUBLIC_[A-Z0-9_]{1,128}$/;
22
+ static readonly #secretWords = ["SECRET", "TOKEN", "PASSWORD", "PRIVATE", "CREDENTIAL"];
23
+ static readonly #maxKeys = 256;
24
+ static readonly #maxBytes = 64 * 1024;
25
+
26
+ static of(env: Record<string, unknown>): PublicEnv {
27
+ return Object.fromEntries(
28
+ Object.keys(env)
29
+ .filter((key) => key.startsWith("AKAN_PUBLIC_") && !NativePublicEnv.derivedKeys.includes(key))
30
+ .sort()
31
+ .map((key) => [key, String(env[key])]),
32
+ );
33
+ }
34
+
35
+ static hash(publicEnv: PublicEnv) {
36
+ return createHash("sha256")
37
+ .update(JSON.stringify(NativePublicEnv.of(publicEnv)))
38
+ .digest("hex");
39
+ }
40
+
41
+ static problems(publicEnv: Record<string, unknown>) {
42
+ const keys = Object.keys(publicEnv);
43
+ const problems = keys.flatMap((key) => {
44
+ const value = publicEnv[key];
45
+ if (!NativePublicEnv.#keyPattern.test(key)) return [`${key} is not an AKAN_PUBLIC_[A-Z0-9_] name`];
46
+ const word = NativePublicEnv.#secretWords.find((secret) => key.includes(secret));
47
+ if (word) return [`${key} names a ${word.toLowerCase()}, which a page bundle must not carry`];
48
+ if (typeof value !== "string") return [`${key} is not a string`];
49
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is refused
50
+ if (/[\u0000-\u001f\u007f]/.test(value)) return [`${key} holds a control character`];
51
+ return [];
52
+ });
53
+ if (keys.length > NativePublicEnv.#maxKeys)
54
+ problems.push(`${keys.length} keys, more than ${NativePublicEnv.#maxKeys}`);
55
+ const bytes = Buffer.byteLength(JSON.stringify(publicEnv));
56
+ if (bytes > NativePublicEnv.#maxBytes) problems.push(`${bytes} bytes, more than ${NativePublicEnv.#maxBytes}`);
57
+ return problems;
58
+ }
59
+
60
+ /** The keys whose values differ, or that only one side has. */
61
+ static diff(a: PublicEnv, b: PublicEnv) {
62
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])]
63
+ .filter((key) => a[key] !== b[key])
64
+ .sort((x, y) => (x < y ? -1 : x > y ? 1 : 0));
65
+ }
66
+
67
+ /** What the app's last `akan build` inlined, or null when it built no CSR bundle. */
68
+ static async built(app: App): Promise<PublicEnv | null> {
69
+ const file = Bun.file(CsrArtifactBuilder.publicEnvPath(app));
70
+ if (!(await file.exists())) return null;
71
+ return NativePublicEnv.of((await file.json()) as Record<string, unknown>);
72
+ }
73
+
74
+ /** A bundle.json's record, or null when it has none (a build from before the record, or no file at all). */
75
+ static async recordedIn(bundleFile: string): Promise<PublicEnv | null> {
76
+ const file = Bun.file(bundleFile);
77
+ if (!(await file.exists())) return null;
78
+ const bundle = (await file.json()) as { web?: { publicEnv?: PublicEnv } };
79
+ return bundle.web?.publicEnv ?? null;
80
+ }
81
+
82
+ /** `--public-env`: a JSON map of the values, or the bundle.json of the store build that recorded them. */
83
+ static async load(file: string): Promise<PublicEnv> {
84
+ const source = Bun.file(file);
85
+ if (!(await source.exists())) throw new Error(`--public-env ${file} does not exist.`);
86
+ const json = (await source.json().catch(() => null)) as Record<string, unknown> | null;
87
+ if (!json || typeof json !== "object" || Array.isArray(json))
88
+ throw new Error(`--public-env ${file} is not a JSON object of AKAN_PUBLIC_* values or a bundle.json.`);
89
+ const isBundle = json.schema === 1 && typeof json.web === "object";
90
+ const map = isBundle ? (json.web as { publicEnv?: Record<string, unknown> }).publicEnv : json;
91
+ if (!map) throw new Error(`--public-env ${file} is a bundle.json from a build that recorded no public env.`);
92
+ const derived = Object.keys(map).filter((key) => NativePublicEnv.derivedKeys.includes(key));
93
+ if (derived.length)
94
+ throw new Error(`--public-env ${file} sets ${derived.join(", ")}, which akan.config.ts decides.`);
95
+ const problems = NativePublicEnv.problems(map);
96
+ if (problems.length) throw new Error(`--public-env ${file} is refused:\n- ${problems.join("\n- ")}`);
97
+ return NativePublicEnv.of(map);
98
+ }
99
+
100
+ //* Exactly these values and the ones akan.config.ts derives: the shell's and `.env`'s other AKAN_PUBLIC_* are dropped.
101
+ static pin(publicEnv: PublicEnv) {
102
+ for (const key of Object.keys(process.env))
103
+ if (key.startsWith("AKAN_PUBLIC_") && !NativePublicEnv.derivedKeys.includes(key)) delete process.env[key];
104
+ Object.assign(process.env, publicEnv);
105
+ WorkspaceExecutor.publicEnvPin = publicEnv;
106
+ }
107
+
108
+ /** Why a pack's bundle cannot ship to the store build `shipped`, as far as the public env goes. */
109
+ static async problemAgainst(shipped: string, bundle: string) {
110
+ const [before, now] = await Promise.all([NativePublicEnv.recordedIn(shipped), NativePublicEnv.recordedIn(bundle)]);
111
+ if (!before) return null;
112
+ const differs = NativePublicEnv.diff(before, now ?? {});
113
+ if (!differs.length) return null;
114
+ return `public env ${differs.join(", ")} differs from the store build's (pass --public-env ${shipped} to repeat it)`;
115
+ }
116
+ }
@@ -47,6 +47,85 @@ describe("ServerBundleSecrets", () => {
47
47
  for (const secret of ["main-mail-pass", "0f9e8d7c", "MIIEvQ"]) expect(message).not.toContain(secret);
48
48
  });
49
49
 
50
+ test("passes a server whose vendor and lib code hold a token endpoint and a default password the env files hold too", async () => {
51
+ const { root, appDir, bundle } = await workspace();
52
+ await write(
53
+ path.join(root, "libs/shared/env/env.server.testing.ts"),
54
+ 'export const env = { rootAdminInfo: { accountId: "admin@example.com", password: "admin1234" }, iapVerify: { google: { token_uri: "https://oauth2.googleapis.com/token" } } };\n',
55
+ );
56
+ await write(
57
+ path.join(root, "libs/shared/lib/option.ts"),
58
+ 'export const rootAdmin = { accountId: "admin@mydomain.com", password: "admin1234" };\n',
59
+ );
60
+ await write(
61
+ path.join(root, "libs/shared/env/env.server.example.ts"),
62
+ 'export const env = { pass: "main-mail-pass" };\n',
63
+ );
64
+ await write(path.join(bundle, "chunk-vendor.js"), 'const tokenUrl = "https://oauth2.googleapis.com/token";\n');
65
+ await write(path.join(bundle, "chunk-shared.js"), 'const fallback = { password: "admin1234" };\n');
66
+ expect(await new ServerBundleSecrets(root, appDir).assertClean(bundle)).toEqual([]);
67
+
68
+ await write(path.join(bundle, "chunk-mail.js"), 'const pass = "main-mail-pass";\n');
69
+ await expect(new ServerBundleSecrets(root, appDir).assertClean(bundle)).rejects.toThrow(
70
+ "chunk-mail.js holds apps/demo/env/env.server.main.ts mailer.auth.pass",
71
+ );
72
+ });
73
+
74
+ test("still refuses a long secret the code holds as well, a webhook and a URL's password", async () => {
75
+ const { root, appDir, bundle } = await workspace();
76
+ const hook = "https://hooks.slack.com/services/T0/B0/AbCdEf0123456789XyZ";
77
+ await write(
78
+ path.join(appDir, "env/env.server.develop.ts"),
79
+ `export const env = { slack: { webhookUrl: "${hook}" }, database: { postgres: { url: "postgres://app:pg-pass-0123@db/app" } } };\n`,
80
+ );
81
+ await write(
82
+ path.join(root, "libs/util/srvkit/fallback.ts"),
83
+ `export const jwt = "lib-jwt-0123456789";\nexport const hook = "${hook}";\n`,
84
+ );
85
+ await write(
86
+ path.join(bundle, "server.js"),
87
+ `const jwt = "lib-jwt-0123456789"; const hook = "${hook}"; const pg = "postgres://app:" + (process.env.PG ?? "pg-pass-0123") + "@db/app";\n`,
88
+ );
89
+ const message = await new ServerBundleSecrets(root, appDir)
90
+ .assertClean(bundle)
91
+ .then(() => "")
92
+ .catch((error: Error) => error.message);
93
+ expect(message).toContain("server.js holds libs/util/env/env.server.testing.ts security.jwtSecret");
94
+ expect(message).toContain("server.js holds apps/demo/env/env.server.develop.ts slack.webhookUrl");
95
+ expect(message).toContain("server.js holds apps/demo/env/env.server.develop.ts database.postgres.url");
96
+ expect(message).toContain(
97
+ "A value a profile build still holds sits in code or a file the server carries: take it out there.",
98
+ );
99
+ for (const secret of ["lib-jwt-0123456789", "hooks.slack.com", "pg-pass-0123"])
100
+ expect(message).not.toContain(secret);
101
+ });
102
+
103
+ test("finds a secret with non-ASCII characters in the bundle Bun printed, and in a file copied as UTF-8", async () => {
104
+ const { root, appDir, bundle } = await workspace();
105
+ const secrets = { pass: "pässwörd-ünïcode-1234", apiKey: `비밀 "it's" ${"😀"}-5678` };
106
+ await write(
107
+ path.join(appDir, "env/env.server.debug.ts"),
108
+ `export const env = { mailer: ${JSON.stringify(secrets)} };\n`,
109
+ );
110
+ const source = path.join(root, "carried.ts");
111
+ await write(source, `export const mailer = ${JSON.stringify(secrets)};\n`);
112
+ const built = await Bun.build({ entrypoints: [source], outdir: bundle, target: "bun", minify: true });
113
+ expect(built.success).toBe(true);
114
+ expect(await Bun.file(path.join(bundle, "carried.js")).text()).not.toContain("ä");
115
+ const message = await new ServerBundleSecrets(root, appDir)
116
+ .assertClean(bundle)
117
+ .then(() => "")
118
+ .catch((error: Error) => error.message);
119
+ expect(message).toContain("carried.js holds apps/demo/env/env.server.debug.ts mailer.pass");
120
+ expect(message).toContain("carried.js holds apps/demo/env/env.server.debug.ts mailer.apiKey");
121
+
122
+ await Bun.write(path.join(bundle, "carried.js"), "\n");
123
+ await Bun.write(path.join(bundle, "settings.json"), JSON.stringify({ pass: secrets.pass }));
124
+ await expect(new ServerBundleSecrets(root, appDir).assertClean(bundle)).rejects.toThrow(
125
+ "settings.json holds apps/demo/env/env.server.debug.ts mailer.pass",
126
+ );
127
+ });
128
+
50
129
  test("names an env file it could not evaluate instead of passing it silently", async () => {
51
130
  const { root, appDir, bundle } = await workspace();
52
131
  await write(