@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,122 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 3.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 39d5902: Akan.js 3.1.1. Read **Upgrade notes** before deploying an app that runs 3.1.0: sessions, proxies, the status routes and a few boot checks change, and `akan lint` gains gates a workspace adopts with a baseline.
8
+
9
+ **Security**
10
+
11
+ - **Build files.** In 3.1.0, `/_akan/styles/` and `/_akan/fonts/` resolved an encoded `..` against the whole build folder, so a request such as `/_akan/fonts/..%2fserver%2fpages-<hash>.js` could read any file in it, the server bundle and `base-artifact.json` included, through the gateway too. They now serve only from the build's `styles/` and `fonts/` folders. Upgrade any internet-facing 3.1.0 deployment.
12
+ - **Session cookie.** Outside local the session cookie is `__Host-jwt:<appName>`, a name a browser accepts only from the app's own host over HTTPS at `Path=/`, so a sibling subdomain or a parent domain can no longer plant one. A request that carries the session cookie more than once reads as signed out — on the server, in SSR fetches, on websockets and in the browser — and an `Authorization: Bearer` header still wins. The server expires the duplicated copies of an unprefixed session cookie it can name (a local server's `jwt:<appName>`, or a name a library adds with `SessionCookies.watch(name)` from `akanjs/common`) and logs the name, the count and the host, never a value. A cookie name is trimmed of space and tab only, as RFC 6265 does, so a name padded with other whitespace never reads as a `__Host-` cookie. Outside local, `libs/shared` moves its SSO state, sign-up token and refresh cookies to `__Host-` names too, and reads a refresh cookie sent twice as absent.
13
+ - **SSO origin.** `libs/shared` no longer builds the provider's `redirect_uri` from a cookie the sign-in page set. The server takes the origin from the request, trusting `x-forwarded-host` and `x-forwarded-proto` only from a trusted hop, and outside local refuses a host that is not the app's own with `shared.error.invalidSsoOrigin`. The native SSO start uses the same origin and check.
14
+ - **Status routes.** Outside local, `/_akan/app/health` answers an anonymous caller only `{ status }` (`running`, or `stopping` while it drains) with 200, and `/_akan/app/metrics` answers 401. `Authorization: Bearer <AKAN_STATUS_TOKEN>` unlocks both in full; `AKAN_LOG_STREAM_TOKEN` works too, and a token containing whitespace unlocks nothing. Both answers are `cache-control: no-store`; `/_akan/app/info` and `/_akan/bench/ping` stay public. Detail stays open wherever `AKAN_PUBLIC_ENV` is `local`, and `akan build` bakes in the workspace's own value, so never expose an image built with `local`.
15
+ - **Client address.** `AKAN_TRUSTED_PROXIES` names the proxies whose forwarded headers count. Unset, it is loopback and the private and link-local ranges, as before, and the caller is `X-Real-IP`, else the leftmost `X-Forwarded-For` entry. Set to a list of addresses or CIDRs, `private`, or `none` (loopback alone), it replaces those ranges: `X-Forwarded-For` is walked from the right past every trusted hop, and `X-Real-IP` counts only when `X-Forwarded-For` is absent or its nearest entry is no address. `*` alone trusts every peer; beside other entries it is ignored with a warning, as 3.1.0 ignored it. A forwarded value counts only when it is an IP address, and `Ip`, `context.getClientIp()` and the websocket address report it in one spelling, with no port or IPv6 zone and an IPv4-mapped IPv6 address as IPv4: `IpLiteral.parse(text)` from `akanjs/common` gives an address your code stores or compares the same spelling. The gateway and a solo server take `X-Forwarded-Host`, `-Proto` and `-Port` only from a trusted proxy. We recommend listing your load balancer's and ingress controller's own addresses: `private` lets any peer at a private address, such as a pod or a VPN user, name any caller, so pair it with a NetworkPolicy that admits only the ingress.
16
+ - **In-page agent requests.** The browser writes a `runAgentTurn` request's instructions, tools and transcript, so a caller past `setAgentAccess` could send a hand-built turn and use the app's LLM key as a plain LLM. `option.setAgentRequestPolicy((request, { account, ip }) => request)` sees each turn after the access guard and before the quota hook and the model, with tool names as the page published them, and returns the request to send — a server-fixed lead to the instructions, a tool allowlist, caps, a per-turn `model` — or throws an `Err` to refuse it (`agent.error.requestRefused` is new) before any stream opens. Compaction turns pass through it too, with no tools and the summarizer's instructions. Attachments, references and tool results are folded into the prompt after the policy runs, so a size cap counts them itself. Without a policy nothing changes.
17
+ - **CLI sign-in.** `akan login` prints a code such as `BCDF-GHJK` beside the sign-in URL: type it on the sign-in page to approve, and never type a code that did not come from your own terminal. The URL and its QR code carry only a login id, `RemoteAuthCode.loginIdOf(deviceCode)` from `akanjs/common`; the device code the CLI polls with is never in the link, so neither the code nor the session can be taken from it. A cloud serving `/remoteAuth` itself asks for the code: it accepts a poll only by a device code in the UUID form the CLI mints, registers a short-lived pending login under `loginIdOf` it, rate-limits that anonymous poll, approves only when `RemoteAuthCode.matches(deviceCode, typed)` holds, expires the login after three wrong codes, and hands the session out once. A cloud that does not still signs the CLI in, without the code.
18
+
19
+ **Upgrade notes**
20
+
21
+ - **Sessions.** Every visitor of a server-rendered page signs in once more; a CSR bundle keeps the token it stored until its `libs/shared` refresh fails. Native apps keep `jwt:<appName>` in secure storage and stay signed in. The `jwt:<appName>` and bare `jwt` cookies and the bare `jwt` secret-storage key are no longer read, and sign-in, sign-out and `initAuth` delete them; code that reads the cookie by a hard-coded name calls `authTokenKey()`. The app shell no longer signs a visitor in from a `?jwt=` query parameter, and `initAuth` no longer writes back a cookie it only read. `cookies()` from `akanjs/fetch` and `getCookie` / `cookies()` from `akanjs/client` read a name sent more than once as unset, percent-decode values, and no longer unwrap `j:` JSON values; the new `cookieJar()` in both returns every occurrence, with `read(name)`, `all(name)` and `readJson(name)`. `setCookie` escapes whitespace, control characters, `;`, `,`, `"`, `\` and a lone `%`, so `getCookie` reads the value back; a `%XX` its caller encoded is written unchanged. `removeCookie` writes `Secure` when it deletes a `__Host-` or `__Secure-` cookie. Deprecated: `parseCookieHeader`, `readAuthToken` (use `readSessionToken(CookieJar.parse(header))`), `cookieHeaderHasAuthToken` and `AppWsData.cookies` (use `jar`).
22
+ - **SSO hosts.** Outside local, SSO accepts only the app's own hosts: the `routes` domains in `akan.config.ts`, `AKAN_SUB_ROUTE_HOSTS`, `<app>-<environment>.<serveDomain>`, the `hostname` option or `HOST_NAME` (`BackendEnv.hostname` now also takes a list, such as the apex and `www`, whose first name is the one links, the issuer and mail use), `AKAN_ALLOWED_HOSTS` (which, once set, also refuses every host it leaves out) and `AppHosts.declare(hosts)` from `akanjs/common`. Declare every host users sign in on before upgrading. A build with `web: false` records no `routes` domains, so list its hosts in `AKAN_ALLOWED_HOSTS`. A sign-in or sign-up left unfinished across the upgrade starts again, and a web session cannot be refreshed past it. `getSsoOrigin`, `NativeSso.publicOrigin` and `shared.error.invalidSsoCallbackMissingOrigin` are removed, and `readRefreshTokenCookie` takes the request.
23
+ - **Proxies.** A proxy at a public address that terminates TLS or rewrites `Host`, such as a CDN, must be in `AKAN_TRUSTED_PROXIES` even where you never set it: otherwise the app reads `http` and the inner host, so MCP OAuth metadata, the SSO `redirect_uri` and absolute URLs come out wrong and browser mutations are refused as cross-site. If you already set the list, a private-range ingress it does not name is no longer trusted (add it), and the caller now comes from the right-hand walk of `X-Forwarded-For`, not `X-Real-IP`, so your proxy must set or append `X-Forwarded-For`.
24
+ - **Status routes.** A metrics scraper or monitor that reads the health or metrics body sends `Authorization: Bearer <AKAN_STATUS_TOKEN>`. Probes that read only the status code — the chart's probes, the desktop shell, `akan` commands — need nothing.
25
+ - **Writes during a render.** `fetch.<mutation>()` or a socket `message` sent while a page renders now throws `base.error.mutationInRender` as the call is made, before anything is sent, so a `.catch()` on its promise does not swallow it. On the server that covers a route's `.render()`, `.guard()`, `.head()` and `.loading()`, a layout's `.notFound()` and `.error()`, any component rendered inside them, and the page behind a `.prompt()`; in a CSR bundle, a route stage's synchronous part. Move the write to a user action, or pass `{ duringRender: true }` as the call's option (`fetch.prepareSession(id, { duringRender: true })`) only for a write harmless to repeat and safe for anyone following a link to trigger; an OAuth callback page that completes a connection needs it and must check that its `state` was issued to the signed-in user. `.app()` loads still refuse every mutation. `akan quality` reports these calls as `akan.ssr.render-mutation`, reading a route file's stages and the functions it calls in that file.
26
+ - **Boot checks.** An app now fails to boot where 3.1.0 served one of two declarations and dropped the other; rename one side of each pair the error names.
27
+ - One refName with two shapes: scalar fields, enum values, or a model declared under a lib's refName without building on the lib's model (extending through `lib/__lib/lib.constant.ts` stays legal). A field's `secret`, `hidden` or `visual` kind is part of its shape. Likely hits: an app enum or scalar sharing a `libs/shared` name with other values, and an app model that leaves out one of the lib model's layers. Libs disabled with `disableLibs` are not checked, and a lib's own `agentTurn` scalar with other fields is refused, naming `akanjs` as the other owner. When a lib and the app both declare a refName, the app's declaration now wins in every registry.
28
+ - One `fetch.*` name claimed twice (`[DI:fetch]`): a mounted generated endpoint, a slice's `xListInY` / `initXInY`, or a custom query, mutation, pubsub or message; and a pubsub and a message sharing a name (`[DI:socket]`). A native or desktop build already shipped still calls the old name.
29
+ - Two internal jobs of one kind under one name (`[DI:internal]`): two `initialize`, two `destroy`, two `process`, or two of `cron`, `interval` and `timeout`. Renaming a `process` job moves it to a new queue, so drain the old one first; renaming a cron or interval starts a new lease.
30
+ - **Service methods.** `transaction` and `afterCommit` are now methods of every service: rename a service method of either name. A custom `DatabaseAdaptor` implements `afterCommit(fn)`.
31
+ - **WebSocket on first use.** The socket opens at a page's first subscribe or message instead of at load. An app that relied on it being open at load — reading `fetch.ws.connected`, listening for `"connect"`, or expecting a push before it subscribes — adds `.wsConnect(true)` to its root layout.
32
+ - **Dictionaries.** `System.Provider` no longer takes a `dictionary` prop: remove it from a hand-written root layout. `Translator.withDefaultLocale` is removed; the build fills a locale's missing keys from the default locale.
33
+ - **MCP.** An app slice that writes no `mcp` map now inherits every verb its lib's slice took off the shelf, root `mcp: false` included. An app extending a `libs/shared` model whose slice says `mcp: { cru: false }` (such as `admin` or `notification`) stops publishing those generated writes on `/mcp`; write `mcp: { cru: true }` on the app's slice to publish them again. HTTP is unchanged.
34
+ - **Slices on the client.** An app's slice that extends a lib's replaces the lib's guards, `mcp`, person-only and `crud` maps whole on the client too, as the server mounts them. A verb the app sets to `None` no longer gets a client handler, so `fetch.createX` for it is undefined instead of answering 403.
35
+ - **Library sort keys.** An app filter built from a library's filter carries the library's sort keys in its type, so an app dictionary that does not spread the library's dictionaries into `modelDictionary(...)` translates them in `.sort()`.
36
+ - **Pubsub `null`.** Publishing `null` or `undefined` to a pubsub that is not `{ nullable: true }` throws an error naming the pubsub.
37
+ - **Compression.** HTML, RSC and other text responses compressed as they stream carry no `Content-Length`, and a strong `ETag` on one becomes weak. `AKAN_HTTP_COMPRESS=0` still turns compression off.
38
+ - **Desktop updates.** Install matching `akanjs` and `@akanjs/cli` (native build API 0.13.0). A self-hosted feed must serve `<os>-<arch>/<channel>/app.json`. Renaming a native target cuts its installed desktop apps off updates. Before you revoke a key or sign with a spare, publish a release signed with the first key, so apps built with 3.1.0 move to a 3.1.1 binary. `native.updates.publicKeys` refuses a non-canonical or small-order Ed25519 key, which would verify signatures nobody made: replace it with one `akan update-keygen` prints. A desktop app now updates itself by default; set `native.updates.automatic: false` to keep checking, downloading and applying in the app's own `updates` calls.
39
+ - **`@akanjs/devkit`.** `CloudApi.getRemoteAuthToken` and `CloudApi.getRemoteSelf` throw on a failed request and give up after 20 seconds; `null` now means only "not approved yet" and "no sign-in from this session". `uploadRelease`, an unused helper importable only as `@akanjs/devkit/uploadRelease`, is removed. The native build types come from `akanjs/native/api` and `akanjs/native/config`, not the unpublished `@akanjs/native/api`.
40
+ - **Workspaces.** An existing workspace adds `@akanjs/cli` at its `akanjs` version (`bun add -d @akanjs/cli@<version>`), so `bunx akan` runs Akan's CLI and not the unrelated npm package `akan`. A `local/` folder an earlier version left inside an app or library is no longer written; delete it.
41
+ - **Lint gates.** `akan lint` now fails in an app or library that already breaks the size and write rules under **New**, until `akan quality baseline` records what is there: run it once and commit the `akan.baseline.json` files it writes. A CI job that runs `akan lint` needs the default branch's history (`actions/checkout` with `fetch-depth: 0`); in a shallow clone the gate fails rather than pass unchecked. A literal list limit above 500 (`no-oversized-list-limit`) is a lint error the baseline does not record, so lower it. `akan typecheck` now also checks an app's `script/` files, which nothing imports.
42
+ - **Agent guide.** Run `akan agent install` and commit what it writes: `AGENTS.md`, the scoped `AGENTS.md` files, `.claude/rules/akan/`, `.claude/skills/akan-*`, `.agents/skills/akan-*` and `.cursor/rules/akan-*.mdc`. Your `CLAUDE.md` and `.cursor/rules/akan.mdc` pointers are kept. Nothing reads `docs/AI-DEVELOPMENT.md` or `docs/GENERATED.md` anymore, so a workspace created before this release can delete them; `akan doctor` reports them.
43
+
44
+ **New**
45
+
46
+ - **Navigation guards.** `router.addNavigationGuard(guard)` keeps a page with unsaved work. `guard({ kind, from, to })` is asked before every in-app move — `push`, `replace`, `back`, a `Link`, a deep link, the browser's back and forward, a native back button or swipe, and the in-page agent's `navigate` and `goBack` — and returns the function that removes it. `useNavigationGuard(guard)` from `akanjs/webkit` registers one for the page that is on screen. Answering `false`, throwing or rejecting keeps the page, and `router.navigation()`, which now also follows `back()`, rejects with `NavigationRefused`. A refused browser back or forward is undone before the page redraws, and an async guard that later allows it replays the move. Guards answer one at a time, and a move started while one decides is refused. Redirects, `notFound()`, `setLang` and moves off the origin are not asked; a closing tab is `beforeunload`'s.
47
+ - **Transactions.** Every service has `this.transaction(fn)` and `this.afterCommit(fn)`. `transaction` runs `fn` as one database transaction over every model and returns its value, typed; when `fn` throws it rolls back and rethrows that error. A transaction opened inside another joins it with no savepoint, so what the inner one wrote before throwing still commits if the outer catches the error. On Postgres a failed statement aborts the whole transaction even when caught, so `transaction` rolls back and rethrows it. On SQLite it holds the database's only write lock, so keep `fn` short and never wait on the network inside it. `afterCommit(fn)` runs `fn` after the commit and before `transaction` resolves, never after a rollback, and at once outside a transaction; a callback that throws is logged, not thrown. Cache, `memory`, outside calls and `listenPost` listeners, live sync included, are not part of the transaction: the listeners run after the commit. A task the transaction started without awaiting never joins it once it has ended. `@Transaction()` from `akanjs/server` is deprecated in favour of `this.transaction`.
48
+ - **Writes that keep `updatedAt`.** `{ touch: false }` leaves `updatedAt` as stored, so bookkeeping such as a lease heartbeat or a mirrored snapshot no longer reads as an edit in `updatedAt` periods and sorts. Query-level writes take it in their options (`updateOne`, `updateMany`, `updateById`) or as `.set(update, { touch: false })` on the generated `update<Filter>` / `updateOne<Filter>`, and run no hooks, as before. The document path takes it as `doc.save({ touch: false })`, `pickAndWrite(id, data, { touch: false })` and `pickOneAndWrite(query, data, { touch: false })`, with save hooks and live sync running as usual.
49
+ - **Conditional `upsertById`.** `upsertById(data, { where })` writes over a stored row only when that row matches `where`, and never over a removed row unless `withRemoved: true` is passed too; an id no row holds is inserted. It is one statement on SQLite and Postgres, so a writer whose lease lapsed cannot land over a newer row, or bring back a removed one, between its check and its write. With `where` it answers `updateOne`'s result: `modifiedCount` is 0 when the stored row did not match, and `upsertedId` is set when it inserted. `where: {}` writes over any live row and refuses a removed one. Without `where`, `upsertById` writes as before.
50
+ - **Env transform.** `option.transformEnv(fn)` in a lib's or app's `lib/option.ts` rewrites the server env once, as the server is built and before any option, lib or `env(...)` injection reads it. It runs in every replica, in `bun main.js ops …` and in the test server; each lib's transform runs in mount order and the app's last, each given what the one before returned. `fn` gets a copy, so code that imports the env module itself still reads its values.
51
+ - **MCP curation and tests.** `option.setMcp()` takes `excludeSignals` and `excludeEndpoints`, which keep a whole signal or single entries, generated ones included, off `/mcp`'s `tools/list`, `tools/call` and views while HTTP still serves them. The lists from every lib and the app add up. The boot log reports each exclusion and warns about a name that matches nothing served. `akanjs/test` exports `getServedMcpCatalogue({ as, ui })`, the tools and prompts the running test server's `/mcp` lists a guest or a test agent's account across every page, with `findTool`, `undescribed` and `listingCost`; `ui: true` gives the listing of a client that draws MCP Apps views. `TestServer#servedMcpCatalogue` does the same for a server you start.
52
+ - **In-page agent.** The agent is told that `navigate` and `goBack` replace the screen's tools, which arrive on its next turn, so a navigation ends the turn. An unknown tool name is answered with the tools listed now, noting when the screen changed under the call. `navigate` reports where a redirect or the not-found page actually took it, and answers a path with no route with the paths the screen links to. `OpenaiLlm` with `host` set to `https://api.openai.com/v1` keeps image input, as with no host, and `OpenaiLlm.acceptsOf(llmOption)` lets an app's own chat-completions adaptor claim the same.
53
+ - **Faster pages.** Server-rendered HTML and RSC flight responses are compressed with brotli, or gzip for a client that takes only gzip, as they stream; the shell still reaches the browser before the render ends, and a disconnect still cancels the render. The akanjs.com home page drops from 444 KB to about 48 KB. Behind the gateway each replica compresses for the client's `Accept-Encoding`, and the gateway relays its bytes. Text responses from the web routes — `public/` files without a `.br`/`.gz` sidecar, `robots.txt`, `sitemap.xml`, the CSR shell — are compressed the same way, and a page in the HTML result cache keeps its compressed bytes, which `AKAN_HTML_RESULT_CACHE_MAX_BYTES` now counts. Every compressible response varies on `Accept-Encoding`, compressed or not. A page no longer carries its locale's dictionary in the RSC flight: a build writes one immutable module per locale, `/_akan/dict/<lang>-<hash>.js`, which the page preloads and a navigation into another locale loads before it commits. `akanjs/common` and `tailwind-merge` load once from the import map, recipes build on `tailwind-variants/lite`, and a server-rendered page no longer loads react-spring or the native plugins. A font in `rootLayout().fonts([...])` takes `preload: false`, loading when text first uses it. A `public/` file is served with `stale-while-revalidate=86400` beside its 5-minute `max-age`.
54
+ - **UI exports.** `akanjs/ui` exports `SelectLanguage`, `ThemeToggle` and `DevModeToggle` by name; import them that way in a `"use client"` file, where `System.ThemeToggle` bundles the whole app shell. `akanjs/client` exports `isNativeApp`, `isNativeShell`, `nativePlatform`, `desktopPlatform` and `openExternalUrl`. `fetch.instance.connectOnUse()` is the socket's new default mode.
55
+ - **View modal in the URL.** `Data.ListContainer` and `Model.AdminPanel` take `viewInUrl`, which keeps the open view modal in the URL as `?action=view&modelId=<id>` beside the page's other params: a link carrying them opens that record whether or not the loaded page lists it, a link to another record replaces the open one, and an id that fails to load closes the modal and drops them. They also take `renderViewTitle`, the view's own title (`false` drops its title bar). The view's bottom bar carries the record's `actions` other than "view", with labels: "edit" opens the edit form and goes back to the view when it closes or saves, "remove" asks the row's confirm and then closes the view, and a function form is asked with the record and its place on the loaded page (-1 when unlisted); `viewActions={false}` turns the bar off. The container draws one view modal, not one per row. `Model.ViewModal` draws a title bar only when given `renderTitle` and an action bar only when `renderAction` answers something other than `null`, and without an `id` opens for whichever record `view<Model>` loads. `Data.Item` cards are more compact.
56
+ - **Desktop updates, protocol v2.** Desktop apps built with 3.1.1 read `<url>/<os>-<arch>/<channel>/app.json`, the signed envelope phones read: they trust every key of `native.updates.publicKeys` except `revokedKeys`, apply the revocations a release carries, and take only releases made for their own native target. `akan publish-update --revoke` and `--reset-sequence` work for desktop releases. For apps built with 3.1.0, `publish-update` still writes `<channel>.json` and `.sig` when the first key signs, and warns when a release is invisible to them.
57
+ - **Desktop apps update themselves.** About 30 seconds after it starts and every hour after that, a desktop app looks for a release on its channel and downloads it in the background. As the app quits, the download moves into place without a relaunch, and the next launch runs it on trial, so a release that fails still rolls back. A quit while a release is on trial installs nothing, a debug build never updates itself, and on Windows and Linux only one running copy of an app without single-instance updates itself. `native.updates.automatic: false`, or a target's `updates.automatic`, leaves it all to the app's own `updates` calls. `updates.download()` called while a download runs, the background one included, now answers with that download instead of rejecting with `CANCELLED`.
58
+ - **`akan push-update --platform desktop`** (or `macos`, `windows`, `linux`, with `--arch arm64|x64`) uploads to the Akan Cloud project the release `publish-update` last signed, unchanged: the envelope, the archive and the patches it names, the old pair when it is the same release, and a `release.json` naming the CLI version, the build time and the app's signer (the macOS Team ID, the Windows Authenticode subject, or none). The envelope must verify against the target's keys. The command prints whether the project published, staged or rejected the release, and the URL installed apps read. A desktop release can be up to 1 GiB.
59
+ - **Desktop secrets.** A desktop app's carried server keeps a secret the user gives it, such as their own API key, with `NativeSecret.set(key, value)`, `get(key)` and `remove(key)` from `akanjs/server`, which ask the shell that started it. The shell keeps them encrypted in the server's data folder, with the key in the macOS Keychain, the Windows Credential Manager or the Linux Secret Service, under a store of the server's own (a debug build has its own), apart from the page's `secretStorage`, so no page script reads them. On macOS without a Team ID, any program of the same user can read that key through `security`. A read during the server's boot waits for the window, so read a secret when a request needs it. Anywhere but a carried server, `NativeSecret.available` is false and every call rejects without sending anything.
60
+ - **`akan test <target> [files]`** runs only the comma-separated test files or folders you name, read from the target or the workspace root; it is the way to run one signal test. Outside production, a server or test started inside `apps/<app>` or `libs/<lib>` without `AKAN_RUNTIME_DIR` keeps its sockets and logs in the workspace root's `local/<apps|libs>/<name>/runtime`, where `akan test` and `akan start` put them, and leaves a live dev server's log socket alone.
61
+ - **Locked images.** `akan build` writes a `bun.lock` beside the image's `package.json`, cut down from the workspace's lockfile to what the server needs with every locked version and the root's registry `overrides` and `resolutions` kept, and the generated Dockerfile installs it with `bun install --production --frozen-lockfile`. A workspace without a text `bun.lock` gets a warning and the unlocked install as before; a hand-written `docker` string adds `--frozen-lockfile` itself, and `patchedDependencies` and a pin to a path or a workspace package are not carried.
62
+ - **`akan create-workspace`** puts `@akanjs/cli` in the new workspace's `devDependencies` at its own version, and `akan update` moves it with `akanjs` and `@akanjs/devkit`. `--dir` is resolved against the current directory, so `--dir /abs/path` creates `/abs/path/<name>`.
63
+ - **`akan login`** saves the session as soon as the cloud hands it out and reads the account with retries, so a failed read no longer loses it. Network errors are retried with backoff and reported, a 4xx refusal other than 408 or 429 ends the wait with the cloud's reason, and a session the cloud does not take is cleared with a message to sign in again.
64
+ - **Version mismatch.** When `akan`'s version differs from the workspace's `akanjs`, the warning names a command that fixes it: the same command through the matching CLI (`bunx -p @akanjs/cli@<akanjs version> akan …`, or `bunx akan …` when the workspace pins that CLI), plus `akan update` when the CLI is the newer one. It no longer prints for `akan update`, `akan create-workspace`, `--help` or `--version`.
65
+ - **`akan verify [targets]`** syncs, lints with fixes, typechecks, fast-builds and checks SSR over what git reports as changed, in one run, and prints one header line plus one line per diagnostic, repeats folded, errors first, at most 20 (`--max <n>`, `0` for all). With no target it covers the changed files under `apps/`, `libs/` and `pkgs/`, typechecking a changed library too and a framework package against its own `tsconfig.json`. The build runs only when nothing failed before it. Each code comment added since the last commit is an `akan.comment.added` warning. `--files` replaces the git scope, `--lint-only` and `--no-build` shorten the run, `--format json` prints one JSON line, and it exits 0 on a pass, 1 on a failure and 2 on an unknown target. `akan code` runs it for its `akan_verify` tool and end-of-turn check, so a typecheck error now reaches the model, and `akan workflow apply` and MCP `run_validation` validate with it, typecheck and build included.
66
+ - **Agent guide.** `AGENTS.md` keeps only the rules that apply everywhere, under 12 KB instead of about 83 KB, and lists 16 path rules and 16 skills that load only when they apply: Claude Code reads `.claude/rules/akan/<area>.md` and Cursor `.cursor/rules/akan-<area>.mdc` when a matching file is touched, and the procedures sit in `.claude/skills/akan-*` and `.agents/skills/akan-*`, whose `references/` hold the full guidelines. A rule a lint plugin or gate enforces names its check, and the Work Loop checks with `akan verify` and, after a page, UI or runtime change, `akan preview`. `akan agent install` gains the targets `rules`, `skills` and `hooks`, and `--check` writes nothing, lists the files install would create, change or remove, and exits 1 if there are any; it ignores the version stamp, so it fits a CI step. A generated file carries `<!-- akan:generated -->`: a file without it is yours, never removed and overwritten only with `--force`, and generated files a newer CLI no longer produces are removed. `akan doctor` warns with `agent-guide-oversized` when `AGENTS.md` plus the largest app or library `AGENTS.md` pass 80 % of the 32 KiB Codex reads, and with `agent-guide-leftover` for the old `docs/` guides. A new workspace's `.gitignore` keeps `.claude/settings.local.json`, `.claude/worktrees/` and `.claude/scheduled_tasks.lock` out of git. The `workspaceOnboarding` guideline is removed.
67
+ - **Claude Code hooks.** `akan agent install hooks` adds opt-in hooks to `.claude/settings.json`, keeping every other hook there: an edit to a generated file is refused with a pointer to its source, an edited file is linted with fixes and a remaining error sent back, and before the session stops what it edited is checked with `akan verify`, a failure or an added comment sent back once. `all` never installs them.
68
+ - **`akan preview <app> [paths]`** opens each page in a headless browser and prints one verdict line per page (`ok`, `warn` or `FAIL`) with its status, title, load time and where a redirect landed, then its uncaught errors, the server errors logged while it loaded, console errors and warnings, and layout problems; it exits 1 when a page failed. A page fails on a 4xx or 5xx, an empty body, the server's error page, the dev build overlay, an uncaught or console error, a server error or a timeout, and warns on a console warning, a page wider than the viewport, text the colour of its background or a dropped daisyUI colour class. It reuses this checkout's dev server on the app's port, or starts `akan start <app>` and stops it afterwards (`--keep` leaves it running; its output goes to `local/apps/<app>/runtime/preview/server.log`). Paths are comma-separated, a path without a locale gets the default one, and `--changed` adds the pages uncommitted page and layout changes reach, `/` when no route changed, at most 8. `--csr` boots each page as a native app does, `--desktop` uses 1280×800 instead of 390×844, `--screenshot` saves a PNG, `--timeout` defaults to 60 seconds and `--format json` prints the full report. The engine is Bun.WebView: WebKit on macOS, so nothing is downloaded, and an installed Chrome or Chromium elsewhere (`--engine`). `@akanjs/devkit/preview` exports `PreviewView` and `PreviewChrome`, and the `akan code` preview check now reports console warnings too.
69
+ - **Client errors in the dev log.** In `akan start`, every page the dev server serves, in a browser, a CSR boot or a native shell, reports its uncaught errors and unhandled rejections, logged as `ClientReport` error records naming the page, its boot and a native shell's platform and target, so `akan logs` and the dev terminal show them. A page reports at most 50, with control characters stripped.
70
+ - **`akan context --app <app>`** lists the app's routes: path, file, search keys, and whether a guard applies, its own or a layout's. Past 60 routes the largest folders fold into one line each; `--format json` lists every route.
71
+ - **Size and write gates.** For an app or library, `akan lint` fails on what no check caught before, and each failure names its rule and, where one exists, the `akan workflow plan …` command that builds the right shape:
72
+ - **Size** (`akan.size.file`, `akan.size.component`): a `Zone`, `Util`, `Template`, `Unit`, `View`, `*.store.ts`, or `ui/` / `webkit/` file over 400 lines; a service or document over 500; an abstract over 300; a component or hook over 250.
73
+ - **Store reach** (`akan.component.store-access`): a component or hook reaching `st` more than 16 times, form setters aside.
74
+ - **Where a write starts** (`akan.write.placement`): a `fetch.<mutation>` outside a `*.store.ts`, or a writing `st.do.*` in `ui/` or `webkit/`, which take the write as a prop or hook argument instead.
75
+ - **Chained writes** (`akan.store.multi-write`, `akan.store.root-write-chain`, `akan.component.write-chain`): two or more writes, or one per item in a loop or a `map`, sent by one store action, store `static` or component or hook handler, directly, through a sibling action or through another store via `RootStore`. Exclusive paths count apart, and a retry or a poll counts its write once.
76
+ - **Per-item service calls** (`akan.service.call-in-loop`): a service method an `endpoint` or `slice` reaches that calls a service, a model or a generated query once per item. `internal` jobs, counted loops and a `get<Model>` / `load<Model>` in a `map` callback, which the DataLoader batches, are spared.
77
+ - **Baselines.** `akan quality baseline` writes an `akan.baseline.json` at each app or library root that needs one, recording each breach under its rule with what was measured (`--force` rewrites one). After that `akan lint` fails only on what grows or is new, and lowers an entry as its code shrinks. It refuses a baseline that adds or raises an entry against its copy at the default branch's fork point, and with `--no-fix` one above what the code measures. A size, store-reach or per-item entry may be added or raised when its key carries a `reasons` line; the write rules take no exceptions. `akan quality scan` counts each app's adopted debt and reasoned exceptions (`akan.baseline.exceptions`), and `libs/shared` and `libs/util` ship their own baselines.
78
+ - **Write-path workflows.** `akan workflow plan add-mutation` builds a whole write path in one apply: the document chain method, the service method, the mutation, a one-line store action, the dictionary entry and signal test stubs. `--guard` is required and checked against the guards the target reaches, `None` refused, and a name another reachable module claims is refused as the server would refuse it at boot. `--with <ref>.<method>` runs other writes inside the mutation's `this.transaction()`, `--after <ref>.<method>` runs side effects in `this.afterCommit()`, and either is refused when the workflow cannot supply its arguments. Under a guard that only checks sign-in, the service method gets a `TODO` for the acting user's right to the record and the tests a refusal to fill in; a `Public` mutation gets a rate limit. `akan workflow plan create-interaction` creates a `webkit/use<X>.tsx` hook over a pure `common/<x>.ts` class and its test, for drag, selection, keyboard and layout logic that would otherwise grow inside a component.
79
+ - **Lists past 500 rows.** A client list asked for more than 500 rows is still served 500, and the server now logs it once per list. The lint rule `no-oversized-list-limit` fails on a literal limit above 500 in `fetch.<model>List<Suffix>(…, skip, limit)`, in `{ limit }` passed to `fetch.init…`, `st.do.init…` or `st.do.refresh…`, and in `st.do.setLimitOf…`. A store list asked for more than 500 reads `hasMoreOf…` as true after a full 500, so `loadMoreOf…` fetches the rest instead of stopping.
80
+ - **`akan quality scan`** also warns about a `lib/` module or `ui/` entry whose comments pass 1 % of its lines once it holds five (`akan.comment.density`), a `// deprecated` comment that should be a `/** @deprecated */` tag (`akan.comment.deprecated-marker`), an export in an app's `common/`, `webkit/`, `srvkit/` or `ui/` that only tests use (`akan.global.test-only-export`), and a `*Kind` / `*Type` / `*Mode` enum value compared in three or more files outside its module (`akan.global.kind-branch`).
81
+
82
+ **Fixes**
83
+
84
+ - `save()` on a document of a model with a `field.secret` field, or one read with a `select`, writes only the fields it changed, as for any other document, and after `refresh()` too. It wrote back every field the document held, undoing what another write changed in between, such as a counter's increment or a status move. A field the read left out is written only when you set it. Outside production, saving a document that was never read, which still writes every field it holds, logs a warning once per model.
85
+ - A pubsub publishes `0`, `false` and `""`, which it dropped with `Failed to serialize data`; a `{ nullable: true }` pubsub (or one returning `Any`) delivers `null`, and publishing `undefined` to it delivers `null`.
86
+ - An endpoint's answer keeps the `""` a nullable `String` field holds, so a field declared `field(String, { default: "" })`, in a model or an embedded scalar, reaches the client as `""` rather than `null`. An argument or input left `""` is still sent as not given.
87
+ - A `router.push`, `router.replace` or `router.enterDeepLink` called before the router is initialized, as from a page's mount effect, waits for it instead of throwing `Router is not initialized`.
88
+ - `fetch.ws` listeners on `"connect"` and `"disconnect"` fire. The `.reconnect()` overlay runs only where `AKAN_PUBLIC_ENV` is `local`.
89
+ - In the browser, a Korean `RecentTime` or `dayjs().locale("ko")` no longer falls back to English after hydration.
90
+ - A server-rendered page's client components read their own request's locale and path while other requests render, and an `x-locale` header counts only when it names one of the app's locales.
91
+ - `usePageLocation()` on an SSR page reads the URL's query, so `?filter=` links, the dashboard's selected tile and `viewInUrl` see it. It read none there: the SSR page wrapper carried no search params and never followed a shallow replace. The server render now reads the request's query and the base store starts from the address bar, so the first browser render matches it; the pathname stays the one the page was rendered for.
92
+ - A HEAD request to a page, or a request the page redirects after its render has started, no longer logs `[SSR] render failed`. The status and `Location` it answers are unchanged.
93
+ - The built-in 404 page hydrates without a hydration error in an app whose root layout sets `.theme("dark")` or `.theme("light")`, which put a `data-theme` on `<html>` the 404 document never named.
94
+ - The `Clipboard` button from `akanjs/ui` has an accessible name, `base.copy`.
95
+ - A toast shown with a `duration` of 0 or less, such as `msg.loading(key, { key, duration: 0 })`, stays until a toast with its `key` replaces it or the user closes it. It disappeared at once.
96
+ - In `Data.ListContainer` and `Model.AdminPanel`, editing a record the loaded page does not list opens the edit form, where it opened nothing. A dialog whose title or action slot goes away no longer keeps showing the last one.
97
+ - A streamed API response is no longer buffered whole before it is sent: from the producer's first pause it streams out compressed. A body complete when the handler returns is compressed with its length as before, and a stream that passes 4 MiB before its first pause is sent uncompressed. `application/wasm` is compressible, and a `.br`/`.gz` sidecar beside a `.wasm` in `public/` is served.
98
+ - A plain GET on the websocket path answers `426 Upgrade Required` instead of `500`, at the gateway too, so a crawler or an uptime probe no longer counts as a server failure.
99
+ - A fetch client created after another in the same process, such as a lib's on the server, finds the endpoints the earlier one registered.
100
+ - A service declared with `serve(..., { serverMode })` is enabled or not by the `SERVER_MODE` in effect when the server boots, not when its module is imported.
101
+ - A desktop app whose carried server gives up during a trial before the window opens quits into the previous release instead of waiting behind the alert, and until it restarts it offers, downloads and applies nothing, so it can no longer move the rolled-back release back. A rollback whose file moves failed keeps the release it moves back, and the tries it counted, for the next start. An app is never offered the release it was built from.
102
+ - A macOS desktop app no longer takes minutes to download an update while its window is hidden or covered: `download()` holds an App Nap activity until it ends, through the shell's new `activity.begin` / `activity.end` ops, which still allow idle system sleep.
103
+ - In `akan code`, a sub-agent started by `task` runs on the model the session is on, the one `--model` or `/model` chose, with that model's `models.json` entry. It ran on `deepseek/deepseek-flash` whenever DeepSeek had a key.
104
+ - `akan push-update` exits 2 when the project rejects a release or refuses it with a `release.error.*` key, for phone packs too, and every 400 refusal prints the data it carries.
105
+ - A workspace that typechecks code reaching `@akanjs/devkit/akanConfig`, `/pipeline` or `/mobile` no longer fails with `Cannot find module '@akanjs/native/api'`.
106
+ - `akan lint`, `akan typecheck` and every sync that formats generated files run where only Bun is installed, and `akan lint <app>` passes for an app whose libs carry env modules.
107
+ - A new app's `tsconfig.json` no longer names the Next.js TS plugin or `.next/types`, a new workspace's `bunfig.toml` no longer loads `bun-plugin-tailwind`, and `akan doctor` warns (`config-leftover`) about those lines in an existing workspace.
108
+ - When a desktop app quits and its carried server outlasts the grace, the shell waits for the killed server to exit, so on Windows the server's files are no longer still locked after the quit.
109
+ - On Windows, `akan verify-dist-package` names a dist file with `/` and leaves out its `node_modules`, as on macOS and Linux.
110
+ - In `akan start`, a tab's hot-update socket connects through the dev gateway again. Under Bun 1.4.4 the replica refused it, so a save no longer reached an open tab.
111
+ - A desktop or iOS build no longer stops when the file `android.googleServices` names is missing. Only an Android build reads it, and that build still stops without it.
112
+ - On macOS, `akan start` measures the dev builder against `AKAN_BUILDER_MAX_RSS_MB` by the memory it holds, as the builder reports it. Pages it already freed no longer count, so a builder that gave memory back is no longer replaced, and a fresh one no longer makes a low ceiling look unmeetable.
113
+ - A workspace, app, library, package, module, service, scalar, CRUD page or migration the CLI creates is written as Biome prints it, as are the `.mcp.json` and `.cursor/mcp.json` that `akan mcp-install` writes, so the first `akan lint` no longer rewrites files nobody has touched.
114
+ - A new app in a workspace created with `--libs true` imports `Auth` from `@libs/shared/ui`. Its root layout imported `@shared/ui`, a name nothing in the workspace resolves.
115
+ - `akan workflow apply` of `add-enum-field`, or of `add-field --type enum`, works on the sample `task` module a new workspace ships, where it failed with `primitive-post-edit-dictionary-verify-failed` and wrote nothing: the enum block landed inside the dictionary's header comment. `akan workflow plan add-field` takes `--surfaces template,unit` and `--include-in-light`, the inputs `akan workflow explain add-field` lists, and explain prints each camelCase input's flag beside it.
116
+
117
+ - Updated dependencies [39d5902]
118
+ - akanjs@3.1.1
119
+
3
120
  ## 3.1.0
4
121
 
5
122
  ### Minor Changes
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fsPromise from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { AgentAssets } from "./agentAssets";
6
+
7
+ describe("AgentAssets", () => {
8
+ test("packs whole H2 sections into parts under the limit, never splitting at a fenced heading", () => {
9
+ const section = (name: string, size: number) => `## ${name}\n${"x".repeat(size)}`;
10
+ const fenced = "## C\n```md\n## not a heading\n```";
11
+ const parts = AgentAssets.split(
12
+ ["# Title", section("A", 20_000), section("B", 7_000), fenced, section("D", 10_000)].join("\n"),
13
+ );
14
+ expect(parts.map((part) => part.headings)).toEqual([["A", "B", "C"], ["D"]]);
15
+ expect(parts.every((part) => part.text.length <= AgentAssets.referenceLimit)).toBe(true);
16
+ expect(parts[0]?.text).toStartWith("# Title\n## A");
17
+ });
18
+
19
+ test("ships the public skills under both roots with their guidelines once, inside every size budget", async () => {
20
+ const assets = await AgentAssets.skills();
21
+ const names = Object.keys(AgentAssets.skillReferences);
22
+ const skills = assets.filter((asset) => asset.path.endsWith("/SKILL.md"));
23
+ expect(names.length).toBeLessThanOrEqual(16);
24
+ const byName = (a: string, b: string) => a.localeCompare(b);
25
+ expect(skills.map((asset) => asset.path).sort(byName)).toEqual(
26
+ AgentAssets.skillRoots.flatMap((root) => names.map((name) => `${root}/${name}/SKILL.md`)).sort(byName),
27
+ );
28
+ expect(assets.some((asset) => asset.path.includes("akan-code"))).toBe(false);
29
+
30
+ const descriptions = skills
31
+ .filter((asset) => asset.path.startsWith(".agents/"))
32
+ .map((asset) => {
33
+ const described: RegExpExecArray | null = /description:\s*"?(.+?)"?\n/.exec(asset.content);
34
+ return described?.[1] ?? "";
35
+ });
36
+ expect(descriptions.join("").length).toBeLessThanOrEqual(4_000);
37
+ for (const skill of skills)
38
+ expect([skill.path, skill.content.split("\n").length <= 500]).toEqual([skill.path, true]);
39
+
40
+ const references = assets.filter((asset) => asset.path.includes("/references/"));
41
+ expect(references.every((asset) => asset.path.startsWith(".agents/skills/"))).toBe(true);
42
+ for (const reference of references) {
43
+ expect(reference.content.length).toBeLessThanOrEqual(AgentAssets.referenceLimit + 100);
44
+ expect(skills.find((skill) => skill.content.includes(`\`${reference.path}\``))).toBeDefined();
45
+ }
46
+ expect(references.filter((asset) => asset.path.includes("/agentRule")).length).toBeGreaterThan(1);
47
+ });
48
+
49
+ test("names no rule or skill file that a new workspace's .gitignore would keep out of git", async () => {
50
+ const assets = [...(await AgentAssets.rules()), ...(await AgentAssets.skills())];
51
+ const root = await fsPromise.mkdtemp(path.join(os.tmpdir(), "akan-agent-ignore-"));
52
+ const template = path.join(import.meta.dir, "../cli/templates/workspaceRoot/.gitignore.template");
53
+ await fsPromise.copyFile(template, path.join(root, ".gitignore"));
54
+ Bun.spawnSync(["git", "init", "-q"], { cwd: root });
55
+ const proc = Bun.spawn(["git", "check-ignore", "--no-index", "--stdin"], {
56
+ cwd: root,
57
+ stdin: new Blob([assets.map((asset) => asset.path).join("\n")]),
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ });
61
+ const [ignored, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
62
+ await fsPromise.rm(root, { recursive: true, force: true });
63
+ expect({ exitCode, ignored: ignored.split("\n").filter(Boolean) }).toEqual({ exitCode: 1, ignored: [] });
64
+ });
65
+ });
package/agentAssets.ts ADDED
@@ -0,0 +1,166 @@
1
+ import fsPromise from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { akanCodePaths } from "./codeAgent/agent/akanCodePaths";
4
+ import { type ConventionRule, ConventionSet } from "./conventionSet";
5
+ import { Prompter } from "./prompter";
6
+
7
+ export interface AgentAsset {
8
+ path: string;
9
+ content: string;
10
+ }
11
+
12
+ interface ReferencePart {
13
+ text: string;
14
+ headings: string[];
15
+ }
16
+
17
+ export class AgentAssets {
18
+ static readonly marker = "<!-- akan:generated -->";
19
+ static readonly ruleDir = ".claude/rules/akan";
20
+ static readonly skillRoots = [".claude/skills", ".agents/skills"] as const;
21
+ //? Claude Code cuts a shell command's output at 30,000 characters, and an agent without a file reader `cat`s a part.
22
+ static readonly referenceLimit = 28_000;
23
+ static readonly ownedGlobs = [
24
+ ".claude/rules/akan/*.md",
25
+ ".cursor/rules/akan-*.mdc",
26
+ ".claude/skills/akan-*/**",
27
+ ".agents/skills/akan-*/**",
28
+ ] as const;
29
+ static readonly skillReferences = {
30
+ "akan-validate": [],
31
+ "akan-scaffold": ["workspaceRecipes"],
32
+ "akan-module": ["moduleOverview", "modelConstant", "modelDictionary", "fieldRule"],
33
+ "akan-scalar": ["scalarModule", "scalarConstant", "scalarDictionary"],
34
+ "akan-service": ["modelService", "modelSignal", "modelDocument"],
35
+ "akan-store": ["modelStore"],
36
+ "akan-ui": [
37
+ "ssrRule",
38
+ "cssRule",
39
+ "componentRule",
40
+ "recipeRule",
41
+ "modelTemplate",
42
+ "modelUnit",
43
+ "modelView",
44
+ "modelZone",
45
+ "modelUtil",
46
+ "framework",
47
+ ],
48
+ "akan-err": [],
49
+ "akan-deps": [],
50
+ "akan-debug": [],
51
+ "akan-page": ["pageRule"],
52
+ "akan-query": ["queryRule"],
53
+ "akan-transport": ["transportRule"],
54
+ "akan-runtime": ["runtimeRule"],
55
+ "akan-mcp": ["mcpRule"],
56
+ "akan-agent": ["agentRule"],
57
+ } as const;
58
+
59
+ static async rules({ appendix = {} }: { appendix?: Record<string, string> } = {}) {
60
+ const conventions = await Prompter.getConventionSet();
61
+ return conventions.rules.flatMap((rule) => AgentAssets.#ruleFiles(rule, appendix[rule.name]));
62
+ }
63
+
64
+ static rulePath(name: string) {
65
+ return `${AgentAssets.ruleDir}/${name}.md`;
66
+ }
67
+
68
+ static #ruleFiles(rule: ConventionRule, appendix?: string): AgentAsset[] {
69
+ const globs = rule.paths.flatMap((glob) => ConventionSet.expand(glob));
70
+ const body = appendix ? `${rule.body}\n\n${appendix.trim()}` : rule.body;
71
+ const claude = `---
72
+ description: ${rule.description}
73
+ paths:
74
+ ${globs.map((glob) => ` - "${glob}"`).join("\n")}
75
+ ---
76
+
77
+ ${AgentAssets.marker}
78
+
79
+ ${body}
80
+ `;
81
+ const cursor = `---
82
+ description: ${rule.description}
83
+ globs: ${globs.join(",")}
84
+ alwaysApply: false
85
+ ---
86
+
87
+ ${AgentAssets.marker}
88
+
89
+ @${AgentAssets.rulePath(rule.name)}
90
+ `;
91
+ return [
92
+ { path: AgentAssets.rulePath(rule.name), content: claude },
93
+ { path: `.cursor/rules/akan-${rule.name}.mdc`, content: cursor },
94
+ ];
95
+ }
96
+
97
+ static async skills() {
98
+ const dir = akanCodePaths.builtinSkillsDir();
99
+ if (!dir) return [];
100
+ const assets: AgentAsset[] = [];
101
+ for (const [name, guidelines] of Object.entries(AgentAssets.skillReferences)) {
102
+ const source = await fsPromise.readFile(path.join(dir, name, "SKILL.md"), "utf-8");
103
+ const index: string[] = [];
104
+ for (const guideline of guidelines) {
105
+ const parts = AgentAssets.split(await Prompter.getInstruction(guideline));
106
+ parts.forEach((part, idx) => {
107
+ const file = parts.length === 1 ? `${guideline}.md` : `${guideline}-${idx + 1}.md`;
108
+ const referencePath = `.agents/skills/${name}/references/${file}`;
109
+ assets.push({ path: referencePath, content: `${AgentAssets.marker}\n\n${part.text.trim()}\n` });
110
+ index.push(`- \`${referencePath}\` — ${AgentAssets.#summarize(part.headings)}`);
111
+ });
112
+ }
113
+ const skill = AgentAssets.#skillFile(source, index);
114
+ for (const root of AgentAssets.skillRoots) assets.push({ path: `${root}/${name}/SKILL.md`, content: skill });
115
+ }
116
+ return assets;
117
+ }
118
+
119
+ static split(markdown: string): ReferencePart[] {
120
+ const sections: ReferencePart[] = [];
121
+ let fenced = false;
122
+ for (const line of markdown.trim().split("\n")) {
123
+ if (line.startsWith("```")) fenced = !fenced;
124
+ const heading = !fenced && line.startsWith("## ") ? line.slice(3).trim() : null;
125
+ const last = sections.at(-1);
126
+ if (heading !== null || !last) sections.push({ text: line, headings: heading === null ? [] : [heading] });
127
+ else last.text += `\n${line}`;
128
+ }
129
+ const parts: ReferencePart[] = [];
130
+ for (const section of sections) {
131
+ const last = parts.at(-1);
132
+ if (last && last.text.length + section.text.length + 1 <= AgentAssets.referenceLimit) {
133
+ last.text += `\n${section.text}`;
134
+ last.headings.push(...section.headings);
135
+ } else parts.push({ text: section.text, headings: [...section.headings] });
136
+ }
137
+ return parts;
138
+ }
139
+
140
+ static #summarize(headings: string[]) {
141
+ if (headings.length === 0) return "the whole guideline";
142
+ const shown = headings.slice(0, 5).join(" · ");
143
+ return headings.length > 5 ? `${shown} · …` : shown;
144
+ }
145
+
146
+ static #skillFile(source: string, index: string[]) {
147
+ const match: RegExpExecArray | null = /^---\n[\s\S]*?\n---\n/.exec(source);
148
+ const front = match?.[0] ?? "";
149
+ const body = source.slice(front.length).trim();
150
+ const references = index.length
151
+ ? `\n\n## References\n\nThe full guidelines, split where each part fits one read; \`akan guideline show <name>\` prints the same text.\n\n${index.join("\n")}`
152
+ : "";
153
+ return `${front}\n${AgentAssets.marker}\n\n${body}${references}\n`;
154
+ }
155
+
156
+ static async generatedFiles(workspaceRoot: string) {
157
+ const found: string[] = [];
158
+ for (const pattern of AgentAssets.ownedGlobs) {
159
+ for (const file of new Bun.Glob(pattern).scanSync({ cwd: workspaceRoot, dot: true, onlyFiles: true })) {
160
+ const content = await Bun.file(path.join(workspaceRoot, file)).text();
161
+ if (content.includes(AgentAssets.marker)) found.push(file.split(path.sep).join("/"));
162
+ }
163
+ }
164
+ return found.sort((a, b) => a.localeCompare(b));
165
+ }
166
+ }
package/agentsIndex.ts CHANGED
@@ -119,16 +119,16 @@ export const renderScopeAgentBlock = (scope: AgentsIndexScope, recipes: RecipeIn
119
119
  const scopePath = `${scope.type}s/${scope.name}`;
120
120
  const intro = `## Recipes In Scope
121
121
 
122
- UI recipes importable from \`${scopePath}\` code, **in addition to** the framework recipes indexed in the root
123
- \`AGENTS.md\` \`## Recipes\`. Same contract: import by exact name, then \`<name>(variants?, className?)\` — the second
122
+ UI recipes importable from \`${scopePath}\` code, **in addition to** the framework recipes indexed in
123
+ \`.claude/rules/akan/style.md\`. Same contract: import by exact name, then \`<name>(variants?, className?)\` — the second
124
124
  arg merges internally and takes an array too, so never wrap it in \`cn()\`. \`*\` marks the default, \`key?\` is a
125
125
  boolean flag. Do not guess recipe names or import paths; this index is regenerated by \`akan sync ${scope.name}\`
126
126
  and verified by \`akan lint ${scope.name}\`.`;
127
127
  if (recipes.length === 0) {
128
128
  return `${intro}
129
129
 
130
- No scope recipes yet. Before inlining a repeated surface (card, box, tile, …), reuse a framework recipe from the
131
- root \`AGENTS.md\` or author one as \`${scopePath}/ui/Recipe/<name>.ts\` (one recipe per file, re-exported from that
130
+ No scope recipes yet. Before inlining a repeated surface (card, box, tile, …), reuse a framework recipe from
131
+ \`.claude/rules/akan/style.md\` or author one as \`${scopePath}/ui/Recipe/<name>.ts\` (one recipe per file, re-exported from that
132
132
  folder's \`index.ts\`) — see the \`recipeRule\` guideline.`;
133
133
  }
134
134
  return `${intro}
@@ -13,6 +13,18 @@ describe("readProcessRssBytes", () => {
13
13
  expect(rssBytes).toBeLessThan(64 * 1024 * 1024 * 1024);
14
14
  });
15
15
 
16
+ // The builder's own report triggers a recycle and this reading settles it, so the two must agree.
17
+ test.skipIf(process.platform !== "darwin")("reads what the process reports once it frees memory", async () => {
18
+ let held: number[][] | null = Array.from({ length: 64 }, (_, idx) => new Array<number>(500_000).fill(idx + 0.5));
19
+ expect(held.length).toBe(64);
20
+ held = null;
21
+ Bun.gc(true);
22
+ await Bun.sleep(500);
23
+ const rssBytes = await AkanAppHost.readProcessRssBytes(process.pid);
24
+ if (rssBytes === null) throw new Error("expected to read this process's own rss");
25
+ expect(Math.abs(rssBytes - process.memoryUsage.rss())).toBeLessThan(64 * 1024 * 1024);
26
+ });
27
+
16
28
  // A 0 would read as "settled below the ceiling" and cancel a recycle that should happen.
17
29
  test("returns null for a pid that does not exist", async () => {
18
30
  expect(await AkanAppHost.readProcessRssBytes(2_147_483_646)).toBeNull();
@@ -651,6 +651,13 @@ export class AkanAppHost {
651
651
  ["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
652
652
  AkanAppHost.#tasklistRssKb,
653
653
  );
654
+ if (process.platform === "darwin") {
655
+ const footprintBytes = await AkanAppHost.#readRssKbVia(
656
+ ["footprint", "-p", String(pid), "-f", "bytes"],
657
+ AkanAppHost.#footprintKb,
658
+ );
659
+ if (footprintBytes !== null) return footprintBytes;
660
+ }
654
661
  const status = await Bun.file(`/proc/${pid}/status`)
655
662
  .text()
656
663
  .catch(() => null);
@@ -687,6 +694,11 @@ export class AkanAppHost {
687
694
  const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
688
695
  return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
689
696
  }
697
+ // The footprint is what `process.memoryUsage.rss()` reports on macOS; `ps` also counts pages freed for reuse.
698
+ static #footprintKb(output: string): number {
699
+ const bytes = /Footprint: (\d+) B\b/.exec(output)?.[1];
700
+ return bytes ? Number(bytes) / 1024 : Number.NaN;
701
+ }
690
702
  #cancelRssRecycle(): void {
691
703
  this.#rssRecycleReason = null;
692
704
  this.#rssRecycleOver = null;
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { AppExecutor, WorkspaceExecutor } from "../executors";
7
+ import { NativeConfig } from "../mobile/nativeConfig";
7
8
  import type { PackageJson } from "../types";
8
9
  import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
9
10
  import type { LibConfigInput } from "./types";
@@ -165,6 +166,23 @@ describe("AkanAppConfig", () => {
165
166
  expect(unlocked).not.toContain("bun.lock");
166
167
  });
167
168
 
169
+ test("writes a Dockerfile for a local build that refuses docker build without an explicit build arg", () => {
170
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
171
+ const local = config.dockerfileFor("local");
172
+ const guard = local.split("\n").find((line) => line.includes("AKAN_ALLOW_LOCAL_IMAGE") && line.startsWith("RUN "));
173
+ if (!guard) throw new Error("expected the local image guard");
174
+ const run = (allow?: string) =>
175
+ Bun.spawnSync(["sh", "-c", guard.slice("RUN ".length)], {
176
+ env: { PATH: process.env.PATH ?? "", ...(allow ? { AKAN_ALLOW_LOCAL_IMAGE: allow } : {}) },
177
+ });
178
+
179
+ expect(local).toContain(`FROM ${pinnedBunImage}\nARG AKAN_ALLOW_LOCAL_IMAGE\nRUN if`);
180
+ expect(config.dockerfileFor("main")).not.toContain("AKAN_ALLOW_LOCAL_IMAGE");
181
+ expect(run().exitCode).toBe(1);
182
+ expect(run().stderr.toString()).toContain("--build-arg AKAN_ALLOW_LOCAL_IMAGE=1");
183
+ expect(run("1").exitCode).toBe(0);
184
+ });
185
+
168
186
  test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
169
187
  const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
170
188
 
@@ -220,6 +238,17 @@ describe("AkanAppConfig", () => {
220
238
  expect(config.dockerfile).toContain(`COPY . .\n${envBlock}\nCMD ["bun","main.js"]`);
221
239
  });
222
240
 
241
+ test('publishes localePrefix "as-needed" to the bundles and the image, and leaves the default out of the image', () => {
242
+ const asNeeded = new AkanAppConfig(app, [], packageJson, { i18n: { localePrefix: "as-needed" } }, baseDevEnv);
243
+ expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
244
+ expect(asNeeded.getProductionEnv().AKAN_PUBLIC_LOCALE_PREFIX).toBe("as-needed");
245
+ expect(asNeeded.dockerfile).toContain("ENV AKAN_PUBLIC_LOCALE_PREFIX=as-needed");
246
+
247
+ const always = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
248
+ expect(process.env.AKAN_PUBLIC_LOCALE_PREFIX).toBe("always");
249
+ expect(always.getProductionEnv()).not.toHaveProperty("AKAN_PUBLIC_LOCALE_PREFIX");
250
+ });
251
+
223
252
  test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
224
253
  //? 9adfb95c's output for this config, less the blank lines its empty interpolations left and its floating tag.
225
254
  const before = [
@@ -317,6 +346,36 @@ describe("AkanAppConfig", () => {
317
346
  expect(() => brokenConfig.getProductionPackageJson()).toThrow("Dependency missing-lib not found");
318
347
  });
319
348
 
349
+ test("carries the root's overrides and resolutions, so the image resolves what the workspace locked", () => {
350
+ const pinned = new AkanAppConfig(
351
+ app,
352
+ [],
353
+ { ...packageJson, overrides: { "js-tokens": "2.0.0" }, resolutions: { ms: "2.0.0" } },
354
+ {},
355
+ baseDevEnv,
356
+ );
357
+ expect(pinned.getProductionPackageJson()).toMatchObject({
358
+ overrides: { "js-tokens": "2.0.0" },
359
+ resolutions: { ms: "2.0.0" },
360
+ });
361
+ const local = new AkanAppConfig(
362
+ app,
363
+ [],
364
+ {
365
+ ...packageJson,
366
+ overrides: { "js-tokens": "2.0.0", patched: "file:./patches/patched", shared: "workspace:*" },
367
+ resolutions: { ms: "link:../ms" },
368
+ },
369
+ {},
370
+ baseDevEnv,
371
+ ).getProductionPackageJson();
372
+ expect(local.overrides).toEqual({ "js-tokens": "2.0.0" });
373
+ expect(local).not.toHaveProperty("resolutions");
374
+ const unpinned = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).getProductionPackageJson();
375
+ expect(unpinned).not.toHaveProperty("overrides");
376
+ expect(unpinned).not.toHaveProperty("resolutions");
377
+ });
378
+
320
379
  test("falls back to akanjs package versions for built-in runtime dependencies", () => {
321
380
  const runtimeDependencies = {
322
381
  ...akanPackageJson.dependencies,
@@ -649,6 +708,52 @@ describe("AkanAppConfig", () => {
649
708
  ).toThrow("native.updates in apps/portal/akan.config.ts has no url or publicKeys.");
650
709
  });
651
710
 
711
+ test("takes the page policy, the opener's links and the release checks from akan.config.ts into the native build", () => {
712
+ const updates = { url: "https://releases.example.com/portal", publicKey: "key=", expiresIn: 86_400 };
713
+ const native = new AkanAppConfig(
714
+ app,
715
+ [],
716
+ packageJson,
717
+ {
718
+ native: {
719
+ csp: { "connect-src": ["https://upload.example.com"] },
720
+ openUrls: ["https://*.example.com/*"],
721
+ updates,
722
+ targets: {
723
+ default: {},
724
+ kiosk: { csp: false, openUrls: ["*"], updates: { windowsSigners: ["AB12"] } },
725
+ },
726
+ },
727
+ },
728
+ baseDevEnv,
729
+ ).native;
730
+ const build = (name: string) =>
731
+ NativeConfig.build({
732
+ appPath: "/repo/apps/portal",
733
+ target: native.targets[name] as NonNullable<(typeof native.targets)[string]>,
734
+ webDir: "/web",
735
+ contributions: [],
736
+ locales: ["en"],
737
+ platform: "windows",
738
+ publicEnv: { AKAN_PUBLIC_APP_NAME: "portal", AKAN_PUBLIC_ENV: "main", AKAN_PUBLIC_SERVE_DOMAIN: "example.com" },
739
+ }).config;
740
+ const opener = (config: ReturnType<typeof build>) =>
741
+ config.capabilities?.[0]?.permissions.find((permission) => JSON.stringify(permission).includes("opener"));
742
+
743
+ const store = build("default");
744
+ expect(store.security?.csp).toMatchObject({
745
+ "connect-src": expect.arrayContaining(["https://upload.example.com"]),
746
+ });
747
+ expect(opener(store)).toEqual({ identifier: "opener:default", allow: [{ url: "https://*.example.com/*" }] });
748
+ expect(store.updates).toMatchObject({ expiresIn: 86_400 });
749
+ expect(store.updates).not.toHaveProperty("windowsSigners");
750
+
751
+ const kiosk = build("kiosk");
752
+ expect(kiosk.security).toBeUndefined();
753
+ expect(opener(kiosk)).toBe("opener:default");
754
+ expect(kiosk.updates).toMatchObject({ expiresIn: 86_400, windowsSigners: ["AB12"] });
755
+ });
756
+
652
757
  test("refuses updates holding both publicKey and publicKeys, a target's own key beside the section's list included", () => {
653
758
  const url = "https://releases.example.com/portal";
654
759
  const resolve = (native: object) =>