@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.
- package/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +25 -13
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +151 -41
- package/cloud/updatePackUpload.ts +60 -23
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +46 -0
- package/executors.ts +26 -23
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -0
- package/mobile/nativeApp.ts +109 -63
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
package/mobile/nativeApp.ts
CHANGED
|
@@ -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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
|
347
|
-
//*
|
|
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,
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 =
|
|
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
|
-
|
|
467
|
-
|
|
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) {
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -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
|
|
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(
|