@akanjs/devkit 3.1.1 → 3.1.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/akanConfig/akanConfig.test.ts +75 -0
  2. package/akanConfig/akanConfig.ts +11 -1
  3. package/akanMcpContract.test.ts +28 -1
  4. package/akanMcpContract.ts +51 -3
  5. package/applicationBuildRunner.test.ts +36 -0
  6. package/applicationBuildRunner.ts +4 -3
  7. package/artifact/implicitRootLayout.ts +4 -2
  8. package/cloud/globalConfig.test.ts +34 -0
  9. package/cloud/globalConfig.ts +2 -2
  10. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  11. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  12. package/codeAgent/agent/CodeAgent.ts +7 -0
  13. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  14. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  15. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  16. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  17. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  18. package/codeAgent/agent/akanCodePaths.ts +8 -1
  19. package/codeAgent/agent/akanSystemPrompt.ts +4 -0
  20. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  21. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  22. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  23. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  24. package/codeAgent/agent/stringArg.ts +1 -1
  25. package/codeAgent/skills/akan-agent/SKILL.md +8 -1
  26. package/codeAgent/skills/akan-code/SKILL.md +37 -11
  27. package/codeAgent/skills/akan-debug/SKILL.md +2 -1
  28. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  29. package/codeAgent/skills/akan-query/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
  31. package/codeAgent/tools/McpServerConfig.ts +6 -4
  32. package/codeAgent/tools/McpSignIn.ts +7 -7
  33. package/codeAgent/tools/McpTokenStore.ts +63 -16
  34. package/codeAgent/tools/McpToolPack.ts +8 -1
  35. package/codeAgent/tools/WebToolPack.ts +50 -6
  36. package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
  37. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  38. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  39. package/commandDecorators/command.ts +4 -1
  40. package/createTunnel.test.ts +200 -2
  41. package/createTunnel.ts +80 -6
  42. package/envSeed.test.ts +90 -0
  43. package/envSeed.ts +68 -0
  44. package/executors.test.ts +19 -1
  45. package/executors.ts +41 -2
  46. package/fileSys.test.ts +30 -2
  47. package/fileSys.ts +14 -0
  48. package/formSetterScanner.test.ts +44 -1
  49. package/formSetterScanner.ts +53 -8
  50. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  51. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  52. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  53. package/frontendBuild/optionalPeers.test.ts +132 -0
  54. package/integration/ssrMemoryProbe.ts +6 -0
  55. package/lint/no-unguarded-endpoint.grit +5 -5
  56. package/maintainabilityReport.ts +1 -1
  57. package/mobile/desktopServerStage.test.ts +1 -0
  58. package/mobile/desktopServerStage.ts +3 -1
  59. package/mobile/nativeApp.test.ts +20 -5
  60. package/mobile/nativeApp.ts +4 -1
  61. package/mobile/nativeConfig.test.ts +209 -5
  62. package/mobile/nativeConfig.ts +127 -6
  63. package/mobile/nativePublicEnv.test.ts +2 -0
  64. package/mobile/nativePublicEnv.ts +13 -0
  65. package/package.json +3 -2
  66. package/shellOnlyEnv.test.ts +65 -0
  67. package/shellOnlyEnv.ts +54 -0
  68. package/signalMutationIndex.ts +22 -81
  69. package/sshHostKeys.ts +59 -0
  70. package/storeSource.ts +9 -8
  71. package/subspace.test.ts +10 -0
  72. package/subspaceClone.ts +3 -1
  73. package/subspaceConfig.ts +6 -0
  74. package/transforms/barrelImportsPlugin.ts +10 -4
  75. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  76. package/transforms/mcpAppPageTrimmer.ts +5 -20
  77. package/transforms/pureLazyMarker.ts +1 -1
  78. package/workflow/mutationScaffold.ts +35 -21
  79. package/workflow/plan.ts +28 -0
  80. package/workflow/sourceAst.ts +10 -48
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -93,6 +94,7 @@ export class SsrMemoryProbe {
93
94
  }
94
95
 
95
96
  readonly #options: SsrMemoryProbeOptions;
97
+ readonly #statusToken = randomBytes(32).toString("hex");
96
98
  #gatewayPid = 0;
97
99
 
98
100
  constructor(options: SsrMemoryProbeOptions) {
@@ -119,6 +121,8 @@ export class SsrMemoryProbe {
119
121
  SERVER_MODE: "federation",
120
122
  ...(basePaths ? { AKAN_PUBLIC_BASE_PATHS: basePaths } : {}),
121
123
  PORT: String(port),
124
+ // A built app run outside `akan start` keeps the health detail and metrics behind this token.
125
+ AKAN_STATUS_TOKEN: this.#statusToken,
122
126
  AKAN_MEMORY_LOG: "1",
123
127
  AKAN_MEMORY_LOG_INTERVAL_MS: String(this.#options.metricsIntervalMs),
124
128
  ...(cacheOff ? { AKAN_HTML_RESULT_CACHE: "0", AKAN_RSC_RESULT_CACHE: "0" } : {}),
@@ -315,6 +319,7 @@ export class SsrMemoryProbe {
315
319
  async #fetchChildMetrics(): Promise<Array<Record<string, number>>> {
316
320
  try {
317
321
  const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/metrics`, {
322
+ headers: { authorization: `Bearer ${this.#statusToken}` },
318
323
  signal: AbortSignal.timeout(5_000),
319
324
  });
320
325
  if (!res.ok) return [];
@@ -393,6 +398,7 @@ export class SsrMemoryProbe {
393
398
  while (Date.now() < deadline) {
394
399
  try {
395
400
  const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/health`, {
401
+ headers: { authorization: `Bearer ${this.#statusToken}` },
396
402
  signal: AbortSignal.timeout(2_000),
397
403
  });
398
404
  if (res.ok) {
@@ -1,8 +1,8 @@
1
1
  engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
- // `SignalContext` checks `signalOption.guards ?? []`, so an endpoint, a named slice, or a slice's root query declaring
5
- // none is open to every HTTP and websocket caller. An option the rule cannot read, a variable or a spread, is left alone.
4
+ // `SignalContext` refuses an endpoint, a named slice, or a slice's root query declaring no guards, and outside local
5
+ // operation the server does not boot with one. An option the rule cannot read, a variable or a spread, is left alone.
6
6
  or {
7
7
  JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
8
8
  or {
@@ -32,7 +32,7 @@ or {
32
32
  },
33
33
  register_diagnostic(
34
34
  span = $callee,
35
- message = "No `guards`, so every HTTP and websocket caller reaches this endpoint: a missing or empty `guards` fails open, which is also why MCP refuses it. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
35
+ message = "No `guards`, so every call to this endpoint is refused with 403, and outside local operation the server does not boot. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
36
36
  severity = "warn"
37
37
  )
38
38
  },
@@ -47,7 +47,7 @@ or {
47
47
  },
48
48
  register_diagnostic(
49
49
  span = $sliceOption,
50
- message = "No `guards` on this slice, so every HTTP and websocket caller reaches its root query and generated reads and writes: a missing guard fails open. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
50
+ message = "No `guards` on this slice, so it mounts no root list or insight and none of its generated reads and writes. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
51
51
  severity = "warn"
52
52
  )
53
53
  },
@@ -64,7 +64,7 @@ or {
64
64
  },
65
65
  register_diagnostic(
66
66
  span = $guardMap,
67
- message = "No `root` guard on this slice, so its root query lists any filter to every HTTP and websocket caller: a missing or empty `root` fails open. Write `root: Admin`.",
67
+ message = "No `root` guard on this slice. Left out, it mounts no root list or insight; `root: []` refuses every caller with 403, and outside local operation the server does not boot. Write `root: Admin`, or `root: Public` for anonymous access.",
68
68
  severity = "warn"
69
69
  )
70
70
  }
@@ -41,7 +41,7 @@ export class MaintainabilityReport {
41
41
  "akan.write.placement":
42
42
  "A server write leaves the browser through one store action, started by one module component. Move a `fetch.<mutation>` into a store action, and hand `ui/` and `webkit/` code the write as an `onX` prop or hook argument from the module's `Zone` or `Util`: they stay free of any one model, and an agent or another screen reaches the write through the store.",
43
43
  "akan.service.call-in-loop":
44
- "Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
44
+ "Load the set once (`load<Model>Many(ids)`, one filter query) or write it once (`create<Model>Many(rows)`, one batch method), then loop in memory: a call per item is a round trip per item, so the endpoint slows as its input grows.",
45
45
  "akan.baseline.growth":
46
46
  "Restore the entry from the default branch, then split the file or move the writes instead: `akan lint` lowers an entry as the code shrinks and never raises one.",
47
47
  };
@@ -145,6 +145,7 @@ describe("DesktopServerStage", () => {
145
145
  ];
146
146
  const imageOnly = [
147
147
  "Dockerfile",
148
+ "akan.env.json",
148
149
  "package.json",
149
150
  "rscWorker.js",
150
151
  "console.js",
@@ -2,6 +2,7 @@ import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { toError } from "akanjs/common";
4
4
  import type { ServerPolicy } from "akanjs/native/api";
5
+ import { BUILD_ENV_FILE } from "akanjs/server/lifecycle/buildEnv";
5
6
  import type { AkanAppConfig, NativeEnv } from "../akanConfig";
6
7
  import type { App } from "../commandDecorators";
7
8
  import type { PackageJson } from "../types";
@@ -26,9 +27,10 @@ export interface DesktopServerRun extends ServerPolicy {
26
27
  export class DesktopServerStage {
27
28
  //? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR
28
29
  //? artifact, and `install` wrote its own package.json and bun.lock. The rest of dist is the server's: its chunks,
29
- //? a `.node`, a `.wasm`.
30
+ //? a `.node`, a `.wasm`. The build's env file would hand it the PORT and AKAN_LOG_TO_FILE its launcher leaves out.
30
31
  static readonly uncarried = new Set([
31
32
  "Dockerfile",
33
+ BUILD_ENV_FILE,
32
34
  "package.json",
33
35
  "bun.lock",
34
36
  "rscWorker.js",
@@ -313,18 +313,33 @@ describe("NativeApp", () => {
313
313
  expect(await injected()).toContain('"indexPath":"/mobile"');
314
314
  });
315
315
 
316
- test("builds its config from the app's locales and the plugins' native contributions", async () => {
316
+ test("builds its config from the app's locales, the plugins' native contributions and the inlined public env", async () => {
317
317
  const push: AkanPlugin = { name: "push-notification", native: { permission: "push", plugins: ["push"] } };
318
- const app = new NativeApp(
319
- fakeApp([push, { name: "other" }]),
320
- target({ basePath: undefined, permissions: ["push"] }),
321
- );
318
+ const root = await makeTempRoot();
319
+ const portal = { ...fakeApp([push, { name: "other" }]), dist: { cwdPath: path.join(root, "dist/apps/portal") } };
320
+ await CsrArtifactBuilder.writePublicEnv(portal as unknown as App, {
321
+ "process.env.AKAN_PUBLIC_APP_NAME": '"portal"',
322
+ "process.env.AKAN_PUBLIC_ENV": '"main"',
323
+ "process.env.AKAN_PUBLIC_SERVE_DOMAIN": '"akamir.com"',
324
+ "process.env.AKAN_PUBLIC_SERVER_URL": '"http://192.168.0.5:8080/api"',
325
+ });
326
+ const app = new NativeApp(portal as unknown as App, target({ basePath: undefined, permissions: ["push"] }));
322
327
 
323
328
  const { config, warnings } = await app.config({ platform: "android" });
324
329
 
325
330
  expect(warnings).toEqual([]);
326
331
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
327
332
  expect(config.web.dir).toBe(path.join(nativeDir, "default/web"));
333
+ const csp = config.security?.csp as Record<string, string[]> | undefined;
334
+ expect(csp?.["connect-src"]).toEqual([
335
+ "'self'",
336
+ "blob:",
337
+ "data:",
338
+ "https:",
339
+ "wss:",
340
+ "http://192.168.0.5:8080",
341
+ "ws://192.168.0.5:8080",
342
+ ]);
328
343
  });
329
344
 
330
345
  test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
@@ -124,10 +124,11 @@ export class NativeApp {
124
124
  stageBin?: boolean;
125
125
  arch?: DesktopArch;
126
126
  }) {
127
- const [appConfig, plugins, nativePlugins] = await Promise.all([
127
+ const [appConfig, plugins, nativePlugins, publicEnv] = await Promise.all([
128
128
  this.app.getConfig(),
129
129
  this.app.collectPlugins(),
130
130
  NativePluginFolders.of(this.app),
131
+ NativePublicEnv.inlined(this.app),
131
132
  ]);
132
133
  const desktop = platform === "macos" || platform === "windows" || platform === "linux";
133
134
  const carried = desktop && stageBin ? await new DesktopBin(this.app, appConfig).stage(this.binDir, arch) : [];
@@ -137,9 +138,11 @@ export class NativeApp {
137
138
  webDir: this.web.dir,
138
139
  contributions: plugins.flatMap((plugin) => (plugin.native ? [plugin.native] : [])),
139
140
  locales: appConfig.i18n.locales,
141
+ i18n: appConfig.i18n,
140
142
  env: this.env,
141
143
  platform,
142
144
  nativePlugins,
145
+ publicEnv,
143
146
  ...(server ? { desktopServer: server } : {}),
144
147
  ...(carried.length ? { desktopBin: this.binDir } : {}),
145
148
  });
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import { generateKeyPairSync } from "node:crypto";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { buildCsp, cspHash, parseCsp } from "../../native/packages/cli/src/lib/csp";
6
+ import { rewriteDevHtml } from "../../native/packages/cli/src/lib/hmr";
5
7
  import type { AkanNativeTarget } from "../akanConfig";
6
8
  import { tempDirs } from "../testHelpers";
7
9
  import { NativeApi } from "./nativeApi";
@@ -43,6 +45,19 @@ const adminTarget: AkanNativeTarget = {
43
45
  },
44
46
  };
45
47
 
48
+ const cloudEnv = { AKAN_PUBLIC_APP_NAME: "minimal", AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "example.com" };
49
+ const policy = (extra: Record<string, string[]> = {}) =>
50
+ Object.fromEntries(
51
+ Object.entries(NativeConfig.defaultCsp).map(([directive, sources]) => [
52
+ directive,
53
+ [...sources, ...(extra[directive] ?? [])],
54
+ ]),
55
+ );
56
+ const permissionOf = (id: string) =>
57
+ id === "opener"
58
+ ? { identifier: "opener:default", allow: NativeConfig.defaultOpenUrls.map((url) => ({ url })) }
59
+ : `${id}:default`;
60
+
46
61
  const ed25519Key = () =>
47
62
  generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
48
63
  const updatesKey = ed25519Key();
@@ -102,6 +117,7 @@ describe("NativeConfig.build", () => {
102
117
  contributions: [{ permission: "push", plugins: ["push"] }],
103
118
  locales: ["en", "ko"],
104
119
  platform: "android",
120
+ publicEnv: cloudEnv,
105
121
  });
106
122
 
107
123
  expect(warnings).toEqual([]);
@@ -113,9 +129,10 @@ describe("NativeConfig.build", () => {
113
129
  {
114
130
  identifier: "app",
115
131
  description: "The plugins minimal ships, each with its default permissions",
116
- permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map((plugin) => `${plugin}:default`),
132
+ permissions: [...NativeConfig.basePlugins, "single-instance", "push"].map(permissionOf),
117
133
  },
118
134
  ],
135
+ security: { csp: policy() },
119
136
  deepLinks: { schemes: ["minimal"], domains: ["example.com"] },
120
137
  android: {
121
138
  debugAppIdSuffix: ".debug",
@@ -144,11 +161,10 @@ describe("NativeConfig.build", () => {
144
161
  {
145
162
  identifier: "app",
146
163
  description: "The plugins Portal Admin ships, each with its default permissions",
147
- permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"].map(
148
- (plugin) => `${plugin}:default`,
149
- ),
164
+ permissions: [...NativeConfig.basePlugins, "camera", "geolocation", "contacts", "iap"].map(permissionOf),
150
165
  },
151
166
  ],
167
+ security: { csp: policy() },
152
168
  usageDescriptions: {
153
169
  NSCameraUsageDescription: "Portal Admin requires access to the camera to take photos.",
154
170
  NSPhotoLibraryAddUsageDescription: "Portal Admin requires access to the photo library to take photos.",
@@ -172,6 +188,22 @@ describe("NativeConfig.build", () => {
172
188
  });
173
189
  });
174
190
 
191
+ test('lists a basePath link with no locale segment for the default locale under localePrefix "as-needed"', () => {
192
+ const { config } = NativeConfig.build({
193
+ appPath: "/repo/apps/portal",
194
+ target: adminTarget,
195
+ webDir: "/web",
196
+ contributions: [],
197
+ locales: ["en", "ko"],
198
+ i18n: { defaultLocale: "en", localePrefix: "as-needed" },
199
+ platform: "android",
200
+ });
201
+
202
+ expect(config.deepLinks).toEqual({
203
+ domains: [{ host: "portal.example", pathPrefixes: ["/admin", "/en/admin", "/ko/admin"] }],
204
+ });
205
+ });
206
+
175
207
  test("a plugin that claims a permission replaces the builtin one and adds its Android entries", () => {
176
208
  const { config } = NativeConfig.build({
177
209
  appPath: "/repo/apps/portal",
@@ -362,7 +394,7 @@ describe("NativeConfig.build", () => {
362
394
 
363
395
  expect(config.plugins).toEqual([...NativeConfig.basePlugins, "./vendor/led-panel", kiosk]);
364
396
  expect(config.capabilities?.[0]?.permissions).toEqual(
365
- [...NativeConfig.basePlugins, "led-panel", "kiosk"].map((id) => `${id}:default`),
397
+ [...NativeConfig.basePlugins, "led-panel", "kiosk"].map(permissionOf),
366
398
  );
367
399
  await mkdir(path.join(root, "web"), { recursive: true });
368
400
  await writeFile(path.join(root, "web/index.html"), "<html><head></head><body></body></html>");
@@ -493,6 +525,178 @@ describe("NativeConfig.build", () => {
493
525
  expect(build()).toEqual({ ...updates, target });
494
526
  });
495
527
 
528
+ describe("the page's content security policy", () => {
529
+ const build = (
530
+ target: Partial<AkanNativeTarget> = {},
531
+ more: Partial<Parameters<typeof NativeConfig.build>[0]> = {},
532
+ ) =>
533
+ NativeConfig.build({
534
+ appPath: "/repo/apps/minimal",
535
+ target: { ...minimalTarget, ...target },
536
+ webDir: "/web",
537
+ contributions: [],
538
+ locales: ["en"],
539
+ platform: "ios",
540
+ publicEnv: cloudEnv,
541
+ ...more,
542
+ }).config.security?.csp as Record<string, string[]> | string | undefined;
543
+
544
+ test("lets a local build reach the dev server on any port, and a carried server on loopback", () => {
545
+ const local = build({}, { publicEnv: { AKAN_PUBLIC_ENV: "local" } }) as Record<string, string[]>;
546
+ expect(local["connect-src"]).toEqual([
547
+ ...(NativeConfig.defaultCsp["connect-src"] ?? []),
548
+ "http://localhost:*",
549
+ "ws://localhost:*",
550
+ ]);
551
+ expect(local["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'"]);
552
+ const desktopServer = { dir: "/srv", entry: "main.js", env: {} };
553
+ const carried = build({}, { platform: "macos", desktopServer }) as Record<string, string[]>;
554
+ expect(carried["connect-src"]).toContain("http://127.0.0.1:*");
555
+ expect(carried["connect-src"]).toContain("ws://127.0.0.1:*");
556
+ });
557
+
558
+ test("a dev page under the default policy reaches the dev gateway's socket, on this computer or over the LAN", () => {
559
+ const csp = build({}, { publicEnv: { AKAN_PUBLIC_ENV: "local" } });
560
+ const devPolicy = (wsOrigin?: string) => {
561
+ const { html } = rewriteDevHtml(
562
+ '<!doctype html><html><head><meta charset="utf-8"></head><body><script type="module" src="/_akan/client/main.js"></script></body></html>',
563
+ {
564
+ platform: "ios",
565
+ port: 4321,
566
+ hmrPath: "/_akan/hmr",
567
+ wsPaths: ["/ws"],
568
+ ...(wsOrigin ? { wsOrigin } : {}),
569
+ csp,
570
+ },
571
+ );
572
+ const content = /http-equiv="Content-Security-Policy" content="([^"]*)"/.exec(html)?.[1] ?? "";
573
+ return parseCsp(content.replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
574
+ };
575
+
576
+ const loopback = devPolicy();
577
+ expect(loopback.get("connect-src")).toEqual(
578
+ expect.arrayContaining(["'self'", "http://localhost:*", "ws://localhost:*", "ws://127.0.0.1:4321"]),
579
+ );
580
+ expect(loopback.get("script-src")).toEqual(expect.arrayContaining(["'self'", "blob:"]));
581
+ const lan = devPolicy("ws://192.168.0.12:4321");
582
+ expect(lan.get("connect-src")).toEqual(
583
+ expect.arrayContaining(["'self'", "ws://192.168.0.12:4321", "http://192.168.0.12:4321"]),
584
+ );
585
+ expect(lan.get("object-src")).toEqual(["'none'"]);
586
+ });
587
+
588
+ test("reaches any https or wss origin and loads https images, media, fonts and stylesheets, while scripts stay the app's", () => {
589
+ const csp = build() as Record<string, string[]>;
590
+ expect(csp["connect-src"]).toEqual(["'self'", "blob:", "data:", "https:", "wss:"]);
591
+ for (const directive of ["img-src", "media-src", "font-src"]) expect(csp[directive]).toContain("https:");
592
+ expect(csp["style-src"]).toEqual(["'self'", "'unsafe-inline'", "https:"]);
593
+ expect(csp["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'"]);
594
+ });
595
+
596
+ test("names a pinned AKAN_PUBLIC_SERVER_URL only when it is plain http", () => {
597
+ const connect = (url: string) =>
598
+ (build({}, { publicEnv: { ...cloudEnv, AKAN_PUBLIC_SERVER_URL: url } }) as Record<string, string[]>)[
599
+ "connect-src"
600
+ ];
601
+ expect(connect("https://api.example.com/x")).toEqual(["'self'", "blob:", "data:", "https:", "wss:"]);
602
+ expect(connect("http://192.168.0.5:8080/api")).toEqual([
603
+ "'self'",
604
+ "blob:",
605
+ "data:",
606
+ "https:",
607
+ "wss:",
608
+ "http://192.168.0.5:8080",
609
+ "ws://192.168.0.5:8080",
610
+ ]);
611
+ });
612
+
613
+ test("a directive map adds to the default, a string replaces it, and false ships none", () => {
614
+ const added = build({
615
+ csp: { "script-src": "https://sdk.example.com", "object-src": ["https://embed.example.com"] },
616
+ }) as Record<string, string[]>;
617
+ expect(added["script-src"]).toEqual(["'self'", "'wasm-unsafe-eval'", "https://sdk.example.com"]);
618
+ expect(added["object-src"]).toEqual(["https://embed.example.com"]);
619
+ expect(build({ csp: "strict" })).toBe("strict");
620
+ expect(build({ csp: false })).toBeUndefined();
621
+ });
622
+
623
+ test("a directive the default lacks starts from its CSP fallback, so the bundle and init.js still run", () => {
624
+ const csp = build({
625
+ csp: {
626
+ "script-src": "https://sdk.example.com",
627
+ "script-src-elem": ["https://cdn.example.com"],
628
+ "style-src-attr": "https://styles.example.com",
629
+ "manifest-src": ["https://manifest.example.com"],
630
+ "upgrade-insecure-requests": [],
631
+ },
632
+ }) as Record<string, string[]>;
633
+ expect(csp["script-src-elem"]).toEqual([
634
+ "'self'",
635
+ "'wasm-unsafe-eval'",
636
+ "https://sdk.example.com",
637
+ "https://cdn.example.com",
638
+ ]);
639
+ expect(csp["style-src-attr"]).toEqual(["'self'", "'unsafe-inline'", "https:", "https://styles.example.com"]);
640
+ expect(csp["manifest-src"]).toEqual(["'self'", "https://manifest.example.com"]);
641
+ expect(csp["upgrade-insecure-requests"]).toEqual([]);
642
+
643
+ const page =
644
+ '<html><head><meta charset="utf-8"><script src="/__akan_native/init.js"></script></head><body><script type="module">boot()</script></body></html>';
645
+ expect(parseCsp(buildCsp(csp, page).policy).get("script-src-elem")).toEqual(
646
+ expect.arrayContaining(["'self'", cspHash("boot()")]),
647
+ );
648
+ });
649
+
650
+ test("directive names ignore case, and a default-src entry reaches every fetch directive but object-src", () => {
651
+ const csp = build({
652
+ csp: { "Connect-Src": ["https://upload.example.com"], "default-src": "https://cdn.example.com" },
653
+ }) as Record<string, string[]>;
654
+ expect(Object.keys(csp)).not.toContain("Connect-Src");
655
+ expect(csp["connect-src"]).toEqual([
656
+ "'self'",
657
+ "blob:",
658
+ "data:",
659
+ "https:",
660
+ "wss:",
661
+ "https://cdn.example.com",
662
+ "https://upload.example.com",
663
+ ]);
664
+ expect(csp["default-src"]).toEqual(["'self'", "https://cdn.example.com"]);
665
+ for (const directive of ["script-src", "style-src", "img-src", "font-src", "worker-src", "frame-src"])
666
+ expect(csp[directive]).toContain("https://cdn.example.com");
667
+ expect(csp["object-src"]).toEqual(["'none'"]);
668
+ expect(csp["base-uri"]).toEqual(["'self'"]);
669
+ });
670
+
671
+ test("refuses an empty policy string and names csp: false", () => {
672
+ expect(() => build({ csp: "" })).toThrow("`csp: false`");
673
+ expect(() => build({ csp: " " })).toThrow("`csp: false`");
674
+ });
675
+ });
676
+
677
+ test("opener opens https, mailto and tel links unless the app names its own", () => {
678
+ const opener = (openUrls?: string[]) =>
679
+ NativeConfig.build({
680
+ appPath: "/repo/apps/minimal",
681
+ target: { ...minimalTarget, ...(openUrls ? { openUrls } : {}) },
682
+ webDir: "/web",
683
+ contributions: [],
684
+ locales: ["en"],
685
+ platform: "android",
686
+ publicEnv: cloudEnv,
687
+ }).config.capabilities?.[0]?.permissions.find((permission) => JSON.stringify(permission).includes("opener"));
688
+
689
+ expect(opener()).toEqual({
690
+ identifier: "opener:default",
691
+ allow: [{ url: "https://*:*" }, { url: "mailto:*" }, { url: "tel:*" }],
692
+ });
693
+ expect(opener(["https://*.example.com/*"])).toEqual({
694
+ identifier: "opener:default",
695
+ allow: [{ url: "https://*.example.com/*" }],
696
+ });
697
+ expect(opener(["*"])).toBe("opener:default");
698
+ });
699
+
496
700
  test("keeps a file name the runtime accepts", () => {
497
701
  expect(NativeConfig.fileNameOf("minimal")).toBe("minimal");
498
702
  expect(NativeConfig.fileNameOf("my app!")).toBe("my-app");
@@ -8,7 +8,7 @@ import type {
8
8
  NativeEnv,
9
9
  NativePermission,
10
10
  } from "akanjs";
11
- import { trimSlashes } from "akanjs/common";
11
+ import { type AkanI18nConfig, LocalePath, trimSlashes } from "akanjs/common";
12
12
  import type { AkanNativeConfig } from "akanjs/native/config";
13
13
  import type { DesktopServerBundle } from "./desktopServerStage";
14
14
  import { type NativePlatform, resolveAppId } from "./mobileTarget";
@@ -24,6 +24,7 @@ export interface NativeConfigInput {
24
24
  /** What the app's and its libs' plugins declare. */
25
25
  contributions: AkanPluginNativeConfig[];
26
26
  locales: readonly string[];
27
+ i18n?: Pick<AkanI18nConfig, "defaultLocale" | "localePrefix">;
27
28
  /** The backend the binary talks to; an updates channel left unnamed follows it. */
28
29
  env?: NativeEnv;
29
30
  platform: NativePlatform;
@@ -33,6 +34,8 @@ export interface NativeConfigInput {
33
34
  nativePlugins?: NativePluginFolder[];
34
35
  /** A desktop build's staged `bin` executables (DesktopBin). */
35
36
  desktopBin?: string;
37
+ /** The `AKAN_PUBLIC_*` the web build inlined, derived keys included. */
38
+ publicEnv?: Record<string, string>;
36
39
  }
37
40
 
38
41
  export interface NativeConfigResult {
@@ -58,6 +61,42 @@ export class NativeConfig {
58
61
  "secure-storage",
59
62
  "haptics",
60
63
  ] as const;
64
+ //? Only scripts stay strict ('self', inline ones by hash): injected script must not reach the native bridge.
65
+ static readonly defaultCsp: Readonly<Record<string, readonly string[]>> = {
66
+ "default-src": ["'self'"],
67
+ "script-src": ["'self'", "'wasm-unsafe-eval'"],
68
+ "style-src": ["'self'", "'unsafe-inline'", "https:"],
69
+ "img-src": ["'self'", "blob:", "data:", "https:"],
70
+ "media-src": ["'self'", "blob:", "data:", "https:"],
71
+ "font-src": ["'self'", "data:", "https:"],
72
+ "connect-src": ["'self'", "blob:", "data:", "https:", "wss:"],
73
+ "worker-src": ["'self'", "blob:"],
74
+ "frame-src": ["'self'", "https:"],
75
+ "object-src": ["'none'"],
76
+ "base-uri": ["'self'"],
77
+ "form-action": ["'self'"],
78
+ };
79
+ static readonly #fetchDirectives: readonly string[] = [
80
+ "child-src",
81
+ "connect-src",
82
+ "fenced-frame-src",
83
+ "font-src",
84
+ "frame-src",
85
+ "img-src",
86
+ "manifest-src",
87
+ "media-src",
88
+ "object-src",
89
+ "prefetch-src",
90
+ "script-src",
91
+ "script-src-elem",
92
+ "script-src-attr",
93
+ "style-src",
94
+ "style-src-elem",
95
+ "style-src-attr",
96
+ "worker-src",
97
+ ];
98
+ //? Also bounds the links the shell hands to the OS; on mobile, openExternalUrl sends http(s) to the browser plugin.
99
+ static readonly defaultOpenUrls: readonly string[] = ["https://*:*", "mailto:*", "tel:*"];
61
100
  //* Used when no plugin of the app or its libs claims the permission; speech has no native plugin yet.
62
101
  static readonly builtinContributions: { [permission in NativePermission]: AkanPluginNativeConfig } = {
63
102
  camera: {
@@ -95,11 +134,13 @@ export class NativeConfig {
95
134
  webDir,
96
135
  contributions,
97
136
  locales,
137
+ i18n,
98
138
  env,
99
139
  platform,
100
140
  desktopServer,
101
141
  nativePlugins = [],
102
142
  desktopBin,
143
+ publicEnv = {},
103
144
  }: NativeConfigInput): NativeConfigResult {
104
145
  const warnings: string[] = [];
105
146
  const applied = (target.permissions ?? []).flatMap((permission) => {
@@ -189,6 +230,8 @@ export class NativeConfig {
189
230
  backgroundColorDark: typeof background === "string" ? undefined : background?.dark,
190
231
  devtools: target.devtools ? true : undefined,
191
232
  });
233
+ const csp = NativeConfig.#csp(target, NativeConfig.#plainOrigins(publicEnv, env, !!desktopServer));
234
+ const openUrls = target.openUrls ?? NativeConfig.defaultOpenUrls;
192
235
  const config: AkanNativeConfig = {
193
236
  app: {
194
237
  id: resolveAppId(target.appId, platform),
@@ -205,12 +248,17 @@ export class NativeConfig {
205
248
  {
206
249
  identifier: "app",
207
250
  description: `The plugins ${target.appName} ships, each with its default permissions`,
208
- permissions: pluginIds.map((id) => `${id}:default`),
251
+ permissions: pluginIds.map((id) =>
252
+ id === "opener" && !openUrls.includes("*")
253
+ ? { identifier: "opener:default", allow: openUrls.map((url) => ({ url })) }
254
+ : `${id}:default`,
255
+ ),
209
256
  },
210
257
  ],
258
+ ...(csp ? { security: { csp } } : {}),
211
259
  ...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
212
260
  ...(target.deepLinks?.schemes?.length || target.deepLinks?.domains?.length
213
- ? { deepLinks: NativeConfig.#deepLinks(target, locales) }
261
+ ? { deepLinks: NativeConfig.#deepLinks(target, locales, i18n) }
214
262
  : {}),
215
263
  ...(native ? { native } : {}),
216
264
  ...(updates ? { updates } : {}),
@@ -237,6 +285,69 @@ export class NativeConfig {
237
285
  return { config, warnings };
238
286
  }
239
287
 
288
+ static #csp({ name, csp: setting }: AkanNativeTarget, plainOrigins: string[]) {
289
+ if (setting === false) return undefined;
290
+ if (typeof setting === "string") {
291
+ if (!setting.trim())
292
+ throw new Error(
293
+ `Native target '${name}': csp "" is no policy; write \`csp: false\` to ship the page without one.`,
294
+ );
295
+ return setting;
296
+ }
297
+ const policy: Record<string, string[]> = Object.fromEntries(
298
+ Object.entries(NativeConfig.defaultCsp).map(([directive, sources]) => [directive, [...sources]]),
299
+ );
300
+ const add = (directive: string, sources: readonly string[]) => {
301
+ const list = policy[directive] ?? [];
302
+ policy[directive] = [...new Set([...list.filter((source) => source !== "'none'"), ...sources])];
303
+ };
304
+ add("connect-src", [...plainOrigins, ...plainOrigins.map((origin) => origin.replace(/^http/, "ws"))]);
305
+ add("img-src", plainOrigins);
306
+ add("media-src", plainOrigins);
307
+ const asked = new Map<string, string[]>();
308
+ for (const [key, sources] of Object.entries(setting ?? {})) {
309
+ const directive = key.toLowerCase();
310
+ const list = typeof sources === "string" ? sources.split(/\s+/).filter(Boolean) : sources;
311
+ asked.set(directive, [...(asked.get(directive) ?? []), ...list]);
312
+ }
313
+ //? Each fetch directive the default names overrides default-src, so a default-src entry has to reach every one.
314
+ const everywhere = asked.get("default-src") ?? [];
315
+ asked.delete("default-src");
316
+ if (everywhere.length)
317
+ for (const directive of Object.keys(policy))
318
+ if (
319
+ directive === "default-src" ||
320
+ (directive !== "object-src" && NativeConfig.#fetchDirectives.includes(directive))
321
+ )
322
+ add(directive, everywhere);
323
+ const variant = /^(script|style)-src-(elem|attr)$/;
324
+ //? A directive the default lacks starts from its CSP fallback, else naming it would drop 'self' and the hashes.
325
+ for (const [directive, sources] of [...asked].sort(
326
+ ([a], [b]) => Number(variant.test(a)) - Number(variant.test(b)),
327
+ )) {
328
+ const from = variant.test(directive)
329
+ ? directive.replace(/-(elem|attr)$/, "")
330
+ : NativeConfig.#fetchDirectives.includes(directive)
331
+ ? "default-src"
332
+ : null;
333
+ policy[directive] ??= [...(from ? (policy[from] ?? []) : [])];
334
+ add(directive, sources);
335
+ }
336
+ return policy;
337
+ }
338
+
339
+ //? https: and wss: cover a cloud API, so only plain-http origins are named; a carried server's port comes at launch.
340
+ static #plainOrigins(publicEnv: Record<string, string | undefined>, env: NativeEnv | undefined, carried: boolean) {
341
+ const origins = new Set<string>();
342
+ const pinned = publicEnv.AKAN_PUBLIC_SERVER_URL;
343
+ if (pinned && URL.canParse(pinned) && new URL(pinned).protocol === "http:") origins.add(new URL(pinned).origin);
344
+ const environment = publicEnv.AKAN_PUBLIC_ENV ?? env ?? "debug";
345
+ const mode = publicEnv.AKAN_PUBLIC_OPERATION_MODE ?? (environment === "local" ? "local" : "cloud");
346
+ if (mode === "local") origins.add("http://localhost:*");
347
+ if (carried) origins.add("http://127.0.0.1:*");
348
+ return [...origins];
349
+ }
350
+
240
351
  static #icon(
241
352
  icon: NonNullable<AkanNativeTarget["icon"]>,
242
353
  abs: (relative: string) => string,
@@ -310,10 +421,20 @@ export class NativeConfig {
310
421
  return Object.assign({}, ...contributed, own);
311
422
  }
312
423
 
313
- //* Every route sits under /:lang, so an app link limited to a basePath lists it once per locale.
314
- static #deepLinks(target: AkanNativeTarget, locales: readonly string[]) {
424
+ //* An app link limited to a basePath lists it per locale, plus the unprefixed URL `"as-needed"` gives the default one.
425
+ static #deepLinks(
426
+ target: AkanNativeTarget,
427
+ locales: readonly string[],
428
+ i18n: Pick<AkanI18nConfig, "defaultLocale" | "localePrefix"> = { defaultLocale: "" },
429
+ ) {
315
430
  const basePath = target.basePath ? trimSlashes(target.basePath) : "";
316
- const pathPrefixes = basePath ? locales.map((locale) => `/${locale}/${basePath}`) : undefined;
431
+ const pathPrefixes = basePath
432
+ ? [
433
+ ...new Set(
434
+ locales.flatMap((locale) => [LocalePath.join(locale, `/${basePath}`, i18n), `/${locale}/${basePath}`]),
435
+ ),
436
+ ]
437
+ : undefined;
317
438
  return {
318
439
  ...(target.deepLinks?.schemes?.length ? { schemes: target.deepLinks.schemes } : {}),
319
440
  ...(target.deepLinks?.domains?.length