@akanjs/devkit 3.1.1-rc.9 → 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 (188) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/agentAssets.test.ts +65 -0
  3. package/agentAssets.ts +166 -0
  4. package/agentsIndex.ts +4 -4
  5. package/akanApp/akanApp.host.test.ts +12 -0
  6. package/akanApp/akanApp.host.ts +12 -0
  7. package/akanConfig/akanConfig.test.ts +105 -0
  8. package/akanConfig/akanConfig.ts +21 -1
  9. package/akanConfig/productionLockfile.test.ts +31 -0
  10. package/akanConfig/productionLockfile.ts +2 -0
  11. package/akanContext.ts +129 -4
  12. package/akanMcpContract.test.ts +28 -1
  13. package/akanMcpContract.ts +51 -3
  14. package/applicationBuildRunner.test.ts +53 -0
  15. package/applicationBuildRunner.ts +7 -4
  16. package/artifact/implicitRootLayout.ts +4 -2
  17. package/biome.base.json +4 -1
  18. package/cloud/globalConfig.test.ts +34 -0
  19. package/cloud/globalConfig.ts +2 -2
  20. package/codeAgent/agent/AkanCodeEnv.ts +26 -0
  21. package/codeAgent/agent/AkanCodeServices.ts +5 -2
  22. package/codeAgent/agent/CodeAgent.ts +11 -0
  23. package/codeAgent/agent/CodeAgentGate.ts +156 -10
  24. package/codeAgent/agent/CodeAgentProxy.ts +5 -4
  25. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
  26. package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
  27. package/codeAgent/agent/SubagentPool.ts +5 -0
  28. package/codeAgent/agent/WorkspaceTrust.ts +200 -0
  29. package/codeAgent/agent/akanCodePaths.ts +8 -1
  30. package/codeAgent/agent/akanSystemPrompt.ts +5 -1
  31. package/codeAgent/agent/codeAgent.test.ts +41 -2
  32. package/codeAgent/agent/codeAgentContract.test.ts +119 -0
  33. package/codeAgent/agent/codeAgentHosts.ts +30 -0
  34. package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
  35. package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
  36. package/codeAgent/agent/stringArg.ts +1 -1
  37. package/codeAgent/feedback/PreviewFeedback.ts +2 -2
  38. package/codeAgent/feedback/VerifyFeedback.ts +3 -4
  39. package/codeAgent/index.ts +2 -2
  40. package/codeAgent/skills/akan-agent/SKILL.md +43 -0
  41. package/codeAgent/skills/akan-code/SKILL.md +38 -11
  42. package/codeAgent/skills/akan-debug/SKILL.md +41 -6
  43. package/codeAgent/skills/akan-deps/SKILL.md +1 -2
  44. package/codeAgent/skills/akan-err/SKILL.md +2 -1
  45. package/codeAgent/skills/akan-mcp/SKILL.md +40 -0
  46. package/codeAgent/skills/akan-page/SKILL.md +44 -0
  47. package/codeAgent/skills/akan-query/SKILL.md +39 -0
  48. package/codeAgent/skills/akan-runtime/SKILL.md +43 -0
  49. package/codeAgent/skills/akan-scaffold/SKILL.md +16 -6
  50. package/codeAgent/skills/akan-transport/SKILL.md +40 -0
  51. package/codeAgent/skills/akan-validate/SKILL.md +13 -11
  52. package/codeAgent/tools/AkanCli.ts +2 -1
  53. package/codeAgent/tools/AkanCodePlugins.ts +5 -1
  54. package/codeAgent/tools/AkanEditScope.ts +8 -3
  55. package/codeAgent/tools/AkanToolPack.ts +4 -10
  56. package/codeAgent/tools/AkanVerifier.ts +13 -31
  57. package/codeAgent/tools/McpServerConfig.ts +6 -4
  58. package/codeAgent/tools/McpSignIn.ts +7 -7
  59. package/codeAgent/tools/McpTokenStore.ts +63 -16
  60. package/codeAgent/tools/McpToolPack.ts +8 -1
  61. package/codeAgent/tools/WebToolPack.ts +50 -6
  62. package/codeAgent/tools/akanCodePlugins.test.ts +130 -60
  63. package/codeAgent/tools/mcpOAuth.test.ts +50 -14
  64. package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
  65. package/commandDecorators/command.ts +4 -1
  66. package/commandDecorators/workspaceVersionCheck.test.ts +17 -7
  67. package/commandDecorators/workspaceVersionCheck.ts +4 -1
  68. package/commentDensityScanner.test.ts +121 -0
  69. package/commentDensityScanner.ts +101 -0
  70. package/componentSizeScanner.test.ts +36 -0
  71. package/componentSizeScanner.ts +52 -0
  72. package/componentWriteChainScanner.test.ts +137 -0
  73. package/componentWriteChainScanner.ts +104 -0
  74. package/conventionSet.test.ts +73 -0
  75. package/conventionSet.ts +67 -0
  76. package/createTunnel.test.ts +200 -2
  77. package/createTunnel.ts +80 -6
  78. package/csrE2e/ssrRegistry.e2e.test.ts +6 -1
  79. package/deprecatedCommentScanner.test.ts +54 -0
  80. package/deprecatedCommentScanner.ts +30 -0
  81. package/endpointReach.test.ts +58 -0
  82. package/endpointReach.ts +80 -0
  83. package/envSeed.test.ts +90 -0
  84. package/envSeed.ts +68 -0
  85. package/executors.test.ts +19 -5
  86. package/executors.ts +59 -4
  87. package/fileSys.test.ts +30 -2
  88. package/fileSys.ts +14 -0
  89. package/formSetterScanner.test.ts +44 -1
  90. package/formSetterScanner.ts +53 -8
  91. package/frontendBuild/csrArtifactBuilder.ts +7 -0
  92. package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
  93. package/frontendBuild/mcpAppViewBuilder.ts +7 -1
  94. package/frontendBuild/optionalPeers.test.ts +132 -0
  95. package/index.ts +1 -0
  96. package/integration/devStability.integration.test.ts +9 -5
  97. package/integration/ssrMemoryProbe.ts +6 -0
  98. package/kindBranchScanner.test.ts +89 -0
  99. package/kindBranchScanner.ts +158 -0
  100. package/lint/__fixtures__/no-oversized-list-limit/bad.tsx +18 -0
  101. package/lint/__fixtures__/no-oversized-list-limit/good.tsx +23 -0
  102. package/lint/no-oversized-list-limit.grit +73 -0
  103. package/lint/no-unguarded-endpoint.grit +5 -5
  104. package/linter.test.ts +12 -0
  105. package/linter.ts +35 -12
  106. package/maintainabilityBaseline.test.ts +280 -0
  107. package/maintainabilityBaseline.ts +220 -0
  108. package/maintainabilityContract.test.ts +846 -0
  109. package/maintainabilityContract.ts +404 -0
  110. package/maintainabilityExceptionScanner.test.ts +90 -0
  111. package/maintainabilityExceptionScanner.ts +85 -0
  112. package/maintainabilityLimits.ts +60 -0
  113. package/maintainabilityReport.test.ts +130 -0
  114. package/maintainabilityReport.ts +153 -0
  115. package/mobile/desktopRelease.test.ts +1 -1
  116. package/mobile/desktopRelease.ts +3 -1
  117. package/mobile/desktopServerStage.test.ts +1 -0
  118. package/mobile/desktopServerStage.ts +3 -1
  119. package/mobile/nativeApp.test.ts +20 -5
  120. package/mobile/nativeApp.ts +4 -1
  121. package/mobile/nativeConfig.test.ts +209 -5
  122. package/mobile/nativeConfig.ts +127 -6
  123. package/mobile/nativePublicEnv.test.ts +2 -0
  124. package/mobile/nativePublicEnv.ts +13 -0
  125. package/package.json +4 -2
  126. package/platformTest/LinuxDockerTarget.ts +3 -0
  127. package/{codeAgent/feedback → preview}/PreviewChrome.ts +13 -7
  128. package/preview/PreviewView.ts +219 -0
  129. package/preview/index.ts +2 -0
  130. package/preview/previewView.test.ts +76 -0
  131. package/prompter.ts +7 -0
  132. package/qualityScanner.test.ts +75 -0
  133. package/qualityScanner.ts +17 -1
  134. package/routeSourceValidator.test.ts +11 -2
  135. package/routeSourceValidator.ts +14 -2
  136. package/serviceLoopCallScanner.test.ts +56 -0
  137. package/serviceLoopCallScanner.ts +122 -0
  138. package/shellOnlyEnv.test.ts +65 -0
  139. package/shellOnlyEnv.ts +54 -0
  140. package/signalMutationIndex.test.ts +15 -0
  141. package/signalMutationIndex.ts +23 -82
  142. package/sourceComments.test.ts +45 -0
  143. package/sourceComments.ts +70 -0
  144. package/sshHostKeys.ts +59 -0
  145. package/storeAccessScanner.test.ts +84 -0
  146. package/storeAccessScanner.ts +71 -0
  147. package/storeSource.ts +160 -0
  148. package/storeWriteScanner.test.ts +292 -0
  149. package/storeWriteScanner.ts +50 -0
  150. package/subspace.test.ts +10 -0
  151. package/subspaceClone.ts +3 -1
  152. package/subspaceConfig.ts +6 -0
  153. package/testOnlyExportScanner.test.ts +83 -0
  154. package/testOnlyExportScanner.ts +124 -0
  155. package/transforms/barrelImportsPlugin.ts +10 -4
  156. package/transforms/mcpAppPageTrimmer.test.ts +8 -2
  157. package/transforms/mcpAppPageTrimmer.ts +5 -20
  158. package/transforms/pureLazyMarker.ts +1 -1
  159. package/typeChecker.ts +32 -0
  160. package/typecheck/projectTypecheck.test.ts +18 -0
  161. package/typecheck/projectTypecheck.ts +29 -5
  162. package/typecheck/typecheck.proc.ts +6 -0
  163. package/typecheck/typecheckFailure.ts +13 -0
  164. package/types.ts +1 -0
  165. package/workflow/artifacts.ts +16 -2
  166. package/workflow/executor.ts +22 -7
  167. package/workflow/guardCatalog.test.ts +59 -0
  168. package/workflow/guardCatalog.ts +108 -0
  169. package/workflow/index.ts +4 -0
  170. package/workflow/interactionScaffold.ts +122 -0
  171. package/workflow/mutationScaffold.ts +533 -0
  172. package/workflow/mutationSource.test.ts +108 -0
  173. package/workflow/mutationSource.ts +219 -0
  174. package/workflow/plan.ts +82 -0
  175. package/workflow/render.ts +19 -4
  176. package/workflow/source.test.ts +185 -0
  177. package/workflow/source.ts +151 -24
  178. package/workflow/sourceAst.ts +10 -48
  179. package/workflow/types.ts +18 -1
  180. package/workspaceLayout.ts +2 -0
  181. package/writePath.test.ts +109 -0
  182. package/writePath.ts +219 -0
  183. package/writePlacementScanner.test.ts +77 -0
  184. package/writePlacementScanner.ts +50 -0
  185. package/writingActionIndex.test.ts +99 -0
  186. package/writingActionIndex.ts +63 -0
  187. package/codeAgent/feedback/PreviewView.ts +0 -95
  188. /package/{codeAgent/feedback → preview}/previewChrome.test.ts +0 -0
@@ -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
@@ -43,12 +43,14 @@ describe("NativePublicEnv", () => {
43
43
  AKAN_PUBLIC_MAP_TOKEN: "x",
44
44
  AKAN_PUBLIC_PORT: 8282,
45
45
  AKAN_PUBLIC_NOTE: "line\nbreak",
46
+ AKAN_PUBLIC_SIGNER: "-----BEGIN EC PRIVATE KEY-----\\nMHc\\n-----END EC PRIVATE KEY-----",
46
47
  }),
47
48
  ).toEqual([
48
49
  "AKAN_PUBLIC_lower is not an AKAN_PUBLIC_[A-Z0-9_] name",
49
50
  "AKAN_PUBLIC_MAP_TOKEN names a token, which a page bundle must not carry",
50
51
  "AKAN_PUBLIC_PORT is not a string",
51
52
  "AKAN_PUBLIC_NOTE holds a control character",
53
+ "AKAN_PUBLIC_SIGNER holds a private key",
52
54
  ]);
53
55
  const many = Object.fromEntries(Array.from({ length: 257 }, (_, idx) => [`AKAN_PUBLIC_K${idx}`, "v"]));
54
56
  expect(NativePublicEnv.problems(many)).toEqual(["257 keys, more than 256"]);
@@ -16,6 +16,7 @@ export class NativePublicEnv {
16
16
  "AKAN_PUBLIC_RENDER_ENV",
17
17
  "AKAN_PUBLIC_DEFAULT_LOCALE",
18
18
  "AKAN_PUBLIC_LOCALES",
19
+ "AKAN_PUBLIC_LOCALE_PREFIX",
19
20
  ];
20
21
  //? Akan Cloud's own limits on a recorded env: a build it would refuse fails here, before anything is signed.
21
22
  static readonly #keyPattern = /^AKAN_PUBLIC_[A-Z0-9_]{1,128}$/;
@@ -46,6 +47,7 @@ export class NativePublicEnv {
46
47
  const word = NativePublicEnv.#secretWords.find((secret) => key.includes(secret));
47
48
  if (word) return [`${key} names a ${word.toLowerCase()}, which a page bundle must not carry`];
48
49
  if (typeof value !== "string") return [`${key} is not a string`];
50
+ if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(value)) return [`${key} holds a private key`];
49
51
  // biome-ignore lint/suspicious/noControlCharactersInRegex: the control characters are what is refused
50
52
  if (/[\u0000-\u001f\u007f]/.test(value)) return [`${key} holds a control character`];
51
53
  return [];
@@ -71,6 +73,17 @@ export class NativePublicEnv {
71
73
  return NativePublicEnv.of((await file.json()) as Record<string, unknown>);
72
74
  }
73
75
 
76
+ /** Every `AKAN_PUBLIC_*` the last `akan build` inlined, derived keys included, else the ones this process has. */
77
+ static async inlined(app: App): Promise<PublicEnv> {
78
+ const file = Bun.file(CsrArtifactBuilder.publicEnvPath(app));
79
+ const env = (await file.exists()) ? ((await file.json()) as Record<string, unknown>) : process.env;
80
+ return Object.fromEntries(
81
+ Object.entries(env).flatMap(([key, value]) =>
82
+ key.startsWith("AKAN_PUBLIC_") && typeof value === "string" ? [[key, value]] : [],
83
+ ),
84
+ );
85
+ }
86
+
74
87
  /** A bundle.json's record, or null when it has none (a build from before the record, or no file at all). */
75
88
  static async recordedIn(bundleFile: string): Promise<PublicEnv | null> {
76
89
  const file = Bun.file(bundleFile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.1.1-rc.9",
3
+ "version": "3.1.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -34,12 +34,14 @@
34
34
  "./incrementalBuilder": "./incrementalBuilder/index.ts",
35
35
  "./mobile": "./mobile/index.ts",
36
36
  "./pipeline": "./pipeline/index.ts",
37
+ "./preview": "./preview/index.ts",
37
38
  "./transforms": "./transforms/index.ts",
38
39
  "./workflow": "./workflow/index.ts",
39
40
  "./*.ts": "./*.ts",
40
41
  "./*": "./*.ts"
41
42
  },
42
43
  "dependencies": {
44
+ "@akanjs/language": "3.1.2-rc.0",
43
45
  "@earendil-works/pi-agent-core": "1.3.0",
44
46
  "@earendil-works/pi-ai": "1.3.0",
45
47
  "@earendil-works/pi-codemode": "1.3.0",
@@ -48,7 +50,7 @@
48
50
  "@earendil-works/pi-tui": "1.3.0",
49
51
  "@inquirer/prompts": "^8.7.3",
50
52
  "@tailwindcss/node": "^4.3.3",
51
- "akanjs": "3.1.1-rc.9",
53
+ "akanjs": "3.1.2-rc.0",
52
54
  "chalk": "^6.0.1",
53
55
  "commander": "^15.0.0",
54
56
  "dayjs": "^1.11.23",
@@ -40,6 +40,9 @@ export class LinuxDockerTarget extends PlatformTestTarget {
40
40
  "run",
41
41
  "-d",
42
42
  "--rm",
43
+ //? An init as PID 1 reaps orphans as a real host does; under `sleep` a killed orphan stays a zombie that still
44
+ //? answers kill(pid, 0).
45
+ "--init",
43
46
  "--name",
44
47
  container,
45
48
  "--cpus",
@@ -2,6 +2,8 @@ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ export type PreviewEngine = "webkit" | "chrome";
6
+
5
7
  // Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
6
8
  export class PreviewChrome {
7
9
  /** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
@@ -30,7 +32,7 @@ export class PreviewChrome {
30
32
  "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
31
33
  ];
32
34
 
33
- static get backend(): "webkit" | "chrome" {
35
+ static get backend(): PreviewEngine {
34
36
  const declared = process.env[PreviewChrome.backendEnvKey];
35
37
  if (declared === "webkit" || declared === "chrome") return declared;
36
38
  return process.platform === "darwin" ? "webkit" : "chrome";
@@ -53,11 +55,15 @@ export class PreviewChrome {
53
55
  }
54
56
 
55
57
  static get available() {
56
- return PreviewChrome.backend === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
58
+ return PreviewChrome.isAvailable();
59
+ }
60
+
61
+ static isAvailable(engine: PreviewEngine = PreviewChrome.backend) {
62
+ return engine === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
57
63
  }
58
64
 
59
- static backendOption() {
60
- if (PreviewChrome.backend === "webkit") return "webkit";
65
+ static backendOption(engine: PreviewEngine = PreviewChrome.backend) {
66
+ if (engine === "webkit") return "webkit";
61
67
  // Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
62
68
  const argv = ["--disable-dev-shm-usage"];
63
69
  // Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
@@ -70,9 +76,9 @@ export class PreviewChrome {
70
76
  "apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
71
77
  ];
72
78
 
73
- static unavailableReason() {
74
- if (PreviewChrome.available) return undefined;
75
- if (PreviewChrome.backend === "webkit")
79
+ static unavailableReason(engine: PreviewEngine = PreviewChrome.backend) {
80
+ if (PreviewChrome.isAvailable(engine)) return undefined;
81
+ if (engine === "webkit")
76
82
  return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
77
83
  return [
78
84
  "The page preview needs a browser and this machine has none on PATH.",