akanjs 3.0.0-beta.27 → 3.0.0-beta.29

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 (218) hide show
  1. package/base/baseEnv.ts +19 -5
  2. package/client/device.ts +22 -6
  3. package/client/index.ts +1 -0
  4. package/client/native/accessibility.ts +1 -0
  5. package/client/native/app-state.ts +1 -0
  6. package/client/native/app.ts +1 -0
  7. package/client/native/appearance.ts +1 -0
  8. package/client/native/auth-session.ts +1 -0
  9. package/client/native/autostart.ts +1 -0
  10. package/client/native/badge.ts +1 -0
  11. package/client/native/biometric.ts +1 -0
  12. package/client/native/browser.ts +1 -0
  13. package/client/native/camera.ts +1 -0
  14. package/client/native/clipboard.ts +1 -0
  15. package/client/native/contacts.ts +1 -0
  16. package/client/native/device.ts +1 -0
  17. package/client/native/dialog.ts +1 -0
  18. package/client/native/dock.ts +1 -0
  19. package/client/native/file-picker.ts +1 -0
  20. package/client/native/filesystem.ts +1 -0
  21. package/client/native/geolocation.ts +1 -0
  22. package/client/native/global-shortcut.ts +1 -0
  23. package/client/native/haptics.ts +1 -0
  24. package/client/native/http.ts +1 -0
  25. package/client/native/iap.ts +1 -0
  26. package/client/native/keep-awake.ts +1 -0
  27. package/client/native/keyboard.ts +1 -0
  28. package/client/native/local-notifications.ts +1 -0
  29. package/client/native/menu.ts +1 -0
  30. package/client/native/network.ts +1 -0
  31. package/client/native/opener.ts +1 -0
  32. package/client/native/preferences.ts +1 -0
  33. package/client/native/push.ts +1 -0
  34. package/client/native/screen-orientation.ts +1 -0
  35. package/client/native/screen.ts +1 -0
  36. package/client/native/secure-storage.ts +1 -0
  37. package/client/native/share.ts +1 -0
  38. package/client/native/single-instance.ts +1 -0
  39. package/client/native/splash-screen.ts +1 -0
  40. package/client/native/sqlite.ts +1 -0
  41. package/client/native/toast.ts +1 -0
  42. package/client/native/tray.ts +1 -0
  43. package/client/native/updates.ts +1 -0
  44. package/client/native/volume.ts +1 -0
  45. package/client/native/window-state.ts +1 -0
  46. package/client/native/window.ts +1 -0
  47. package/client/native.ts +31 -1
  48. package/client/resolveServerUrl.ts +11 -0
  49. package/client/router.ts +8 -0
  50. package/index.ts +115 -6
  51. package/native/desktop.ts +5 -0
  52. package/package.json +11 -1
  53. package/server/akanApp.ts +27 -5
  54. package/server/akanAppHeaders.ts +4 -1
  55. package/server/akanServer.ts +43 -13
  56. package/server/contentEncoding.ts +11 -0
  57. package/server/index.ts +1 -0
  58. package/server/lifecycle/orphanGroup.ts +21 -0
  59. package/server/nativeFile.ts +107 -0
  60. package/server/ops/appInfo.ts +3 -1
  61. package/server/ops/opsRoute.ts +3 -2
  62. package/server/routing/apiRouter.ts +26 -3
  63. package/server/routing/hostAllowlist.ts +24 -0
  64. package/server/selfExec.ts +33 -0
  65. package/server/types.tsx +3 -1
  66. package/server/webRouter.ts +7 -4
  67. package/service/ipcTypes.ts +4 -0
  68. package/signal/CrossSiteGuard.ts +7 -0
  69. package/test/signalTestRuntime.ts +8 -2
  70. package/types/base/baseEnv.d.ts +1 -0
  71. package/types/client/index.d.ts +1 -0
  72. package/types/client/native/accessibility.d.ts +1 -0
  73. package/types/client/native/app-state.d.ts +1 -0
  74. package/types/client/native/app.d.ts +1 -0
  75. package/types/client/native/appearance.d.ts +1 -0
  76. package/types/client/native/auth-session.d.ts +1 -0
  77. package/types/client/native/autostart.d.ts +1 -0
  78. package/types/client/native/badge.d.ts +1 -0
  79. package/types/client/native/biometric.d.ts +1 -0
  80. package/types/client/native/browser.d.ts +1 -0
  81. package/types/client/native/camera.d.ts +1 -0
  82. package/types/client/native/clipboard.d.ts +1 -0
  83. package/types/client/native/contacts.d.ts +1 -0
  84. package/types/client/native/device.d.ts +1 -0
  85. package/types/client/native/dialog.d.ts +1 -0
  86. package/types/client/native/dock.d.ts +1 -0
  87. package/types/client/native/file-picker.d.ts +1 -0
  88. package/types/client/native/filesystem.d.ts +1 -0
  89. package/types/client/native/geolocation.d.ts +1 -0
  90. package/types/client/native/global-shortcut.d.ts +1 -0
  91. package/types/client/native/haptics.d.ts +1 -0
  92. package/types/client/native/http.d.ts +1 -0
  93. package/types/client/native/iap.d.ts +1 -0
  94. package/types/client/native/keep-awake.d.ts +1 -0
  95. package/types/client/native/keyboard.d.ts +1 -0
  96. package/types/client/native/local-notifications.d.ts +1 -0
  97. package/types/client/native/menu.d.ts +1 -0
  98. package/types/client/native/network.d.ts +1 -0
  99. package/types/client/native/opener.d.ts +1 -0
  100. package/types/client/native/preferences.d.ts +1 -0
  101. package/types/client/native/push.d.ts +1 -0
  102. package/types/client/native/screen-orientation.d.ts +1 -0
  103. package/types/client/native/screen.d.ts +1 -0
  104. package/types/client/native/secure-storage.d.ts +1 -0
  105. package/types/client/native/share.d.ts +1 -0
  106. package/types/client/native/single-instance.d.ts +1 -0
  107. package/types/client/native/splash-screen.d.ts +1 -0
  108. package/types/client/native/sqlite.d.ts +1 -0
  109. package/types/client/native/toast.d.ts +1 -0
  110. package/types/client/native/tray.d.ts +1 -0
  111. package/types/client/native/updates.d.ts +1 -0
  112. package/types/client/native/volume.d.ts +1 -0
  113. package/types/client/native/window-state.d.ts +1 -0
  114. package/types/client/native/window.d.ts +1 -0
  115. package/types/client/native.d.ts +14 -2
  116. package/types/client/resolveServerUrl.d.ts +1 -0
  117. package/types/client/router.d.ts +3 -0
  118. package/types/index.d.ts +112 -6
  119. package/types/native/desktop.d.ts +3 -0
  120. package/types/server/akanAppHeaders.d.ts +2 -0
  121. package/types/server/index.d.ts +1 -0
  122. package/types/server/lifecycle/orphanGroup.d.ts +7 -0
  123. package/types/server/nativeFile.d.ts +10 -0
  124. package/types/server/ops/appInfo.d.ts +1 -0
  125. package/types/server/routing/apiRouter.d.ts +3 -1
  126. package/types/server/routing/hostAllowlist.d.ts +8 -0
  127. package/types/server/selfExec.d.ts +8 -0
  128. package/types/server/types.d.ts +3 -1
  129. package/types/service/ipcTypes.d.ts +16 -0
  130. package/types/signal/CrossSiteGuard.d.ts +1 -0
  131. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  132. package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +73 -4
  133. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +48 -0
  134. package/types/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.d.ts +8 -0
  135. package/types/vendor/@akanjs/native/packages/cli/src/lib/prepare.d.ts +8 -2
  136. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +10 -0
  137. package/types/vendor/@akanjs/native/packages/cli/src/lib/publish.d.ts +32 -0
  138. package/types/vendor/@akanjs/native/packages/cli/src/lib/updates.d.ts +26 -0
  139. package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.d.ts +10 -1
  140. package/types/vendor/@akanjs/native/packages/cli/src/platforms/macos.d.ts +5 -0
  141. package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +53 -0
  142. package/types/vendor/@akanjs/native/packages/desktop/src/dispatcher.d.ts +8 -1
  143. package/types/vendor/@akanjs/native/packages/desktop/src/grants.d.ts +11 -0
  144. package/types/vendor/@akanjs/native/packages/desktop/src/main.d.ts +11 -0
  145. package/types/vendor/@akanjs/native/packages/desktop/src/paths.d.ts +13 -2
  146. package/types/vendor/@akanjs/native/packages/desktop/src/plugin.d.ts +44 -3
  147. package/types/vendor/@akanjs/native/packages/desktop/src/relaunch.d.ts +40 -0
  148. package/types/vendor/@akanjs/native/packages/desktop/src/server.d.ts +97 -0
  149. package/types/vendor/@akanjs/native/plugins/app/src/index.d.ts +12 -0
  150. package/types/vendor/@akanjs/native/plugins/file-picker/src/index.d.ts +20 -1
  151. package/types/vendor/@akanjs/native/plugins/push/src/index.d.ts +9 -0
  152. package/types/vendor/@akanjs/native/plugins/updates/src/desktop.d.ts +34 -0
  153. package/types/vendor/@akanjs/native/plugins/updates/src/index.d.ts +18 -6
  154. package/types/vendor/@akanjs/native/plugins/volume/src/common.d.ts +10 -0
  155. package/types/vendor/@akanjs/native/plugins/volume/src/desktop.d.ts +18 -0
  156. package/types/vendor/@akanjs/native/plugins/volume/src/index.d.ts +28 -0
  157. package/types/vendor/@akanjs/native/plugins/volume/src.d.ts +1 -0
  158. package/types/webkit/csrFrameDump.d.ts +42 -0
  159. package/types/webkit/nativeNavigation.d.ts +1 -0
  160. package/types/webkit/nativePurchase.d.ts +6 -2
  161. package/types/webkit/nativeUpdates.d.ts +8 -0
  162. package/ui/Agent/Attach.tsx +6 -2
  163. package/ui/CsrImage.tsx +3 -2
  164. package/ui/System/CSR.tsx +4 -0
  165. package/vendor/@akanjs/native/native/android/src/com/akanjs/runtime/AkanNativeActivity.kt +2 -0
  166. package/vendor/@akanjs/native/native/desktop/Cargo.toml +1 -1
  167. package/vendor/@akanjs/native/native/desktop/src/lib.rs +161 -25
  168. package/vendor/@akanjs/native/native/desktop/src/volume.rs +184 -0
  169. package/vendor/@akanjs/native/native/desktop/src/win/mod.rs +3 -1
  170. package/vendor/@akanjs/native/native/desktop/src/win/volume.rs +71 -0
  171. package/vendor/@akanjs/native/packages/cli/src/api.ts +162 -12
  172. package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +5 -3
  173. package/vendor/@akanjs/native/packages/cli/src/commands/update.ts +17 -71
  174. package/vendor/@akanjs/native/packages/cli/src/config.ts +41 -0
  175. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -2
  176. package/vendor/@akanjs/native/packages/cli/src/lib/desktop-server.ts +58 -0
  177. package/vendor/@akanjs/native/packages/cli/src/lib/hmr.ts +2 -0
  178. package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +10 -2
  179. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +38 -2
  180. package/vendor/@akanjs/native/packages/cli/src/lib/publish.ts +121 -0
  181. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +87 -5
  182. package/vendor/@akanjs/native/packages/cli/src/platforms/android.ts +1 -0
  183. package/vendor/@akanjs/native/packages/cli/src/platforms/desktop-update.ts +52 -25
  184. package/vendor/@akanjs/native/packages/cli/src/platforms/desktop.ts +16 -0
  185. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +44 -1
  186. package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +601 -0
  187. package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +2 -0
  188. package/vendor/@akanjs/native/packages/desktop/src/dispatcher.ts +29 -2
  189. package/vendor/@akanjs/native/packages/desktop/src/grants.ts +47 -0
  190. package/vendor/@akanjs/native/packages/desktop/src/host.ts +109 -11
  191. package/vendor/@akanjs/native/packages/desktop/src/main.ts +59 -11
  192. package/vendor/@akanjs/native/packages/desktop/src/paths.ts +38 -4
  193. package/vendor/@akanjs/native/packages/desktop/src/plugin.ts +46 -3
  194. package/vendor/@akanjs/native/packages/desktop/src/relaunch.ts +236 -0
  195. package/vendor/@akanjs/native/packages/desktop/src/server.ts +610 -0
  196. package/vendor/@akanjs/native/plugins/app/android/AppPlugin.kt +14 -0
  197. package/vendor/@akanjs/native/plugins/app/native-plugin.json +3 -3
  198. package/vendor/@akanjs/native/plugins/app/src/desktop.ts +10 -0
  199. package/vendor/@akanjs/native/plugins/app/src/index.ts +12 -2
  200. package/vendor/@akanjs/native/plugins/app/src/web.ts +3 -0
  201. package/vendor/@akanjs/native/plugins/file-picker/src/desktop.ts +39 -2
  202. package/vendor/@akanjs/native/plugins/file-picker/src/index.ts +36 -1
  203. package/vendor/@akanjs/native/plugins/push/android/PushPlugin.kt +9 -1
  204. package/vendor/@akanjs/native/plugins/push/ios/PushPlugin.swift +4 -0
  205. package/vendor/@akanjs/native/plugins/push/src/index.ts +10 -0
  206. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +758 -447
  207. package/vendor/@akanjs/native/plugins/updates/src/index.ts +18 -6
  208. package/vendor/@akanjs/native/plugins/volume/android/VolumePlugin.kt +107 -0
  209. package/vendor/@akanjs/native/plugins/volume/native-plugin.json +14 -0
  210. package/vendor/@akanjs/native/plugins/volume/src/common.ts +28 -0
  211. package/vendor/@akanjs/native/plugins/volume/src/desktop.ts +185 -0
  212. package/vendor/@akanjs/native/plugins/volume/src/index.ts +32 -0
  213. package/webkit/RenderLayer.tsx +71 -6
  214. package/webkit/csrFrameDump.ts +67 -0
  215. package/webkit/nativeNavigation.ts +17 -0
  216. package/webkit/nativePurchase.ts +12 -2
  217. package/webkit/nativeUpdates.ts +55 -0
  218. package/webkit/useCsrValues.ts +27 -0
package/server/akanApp.ts CHANGED
@@ -4,8 +4,9 @@ import path from "node:path";
4
4
  import { Logger } from "akanjs/common";
5
5
  import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
6
6
  import { getApiPrefix, getWsPrefix, normalizeRoutePrefix, resetEnvCache } from "../base/baseEnv";
7
+ import { CrossSiteGuard } from "../signal/CrossSiteGuard";
7
8
  import { isTraceEnabled } from "../signal/trace";
8
- import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
+ import { AKAN_CHILD_HOST, makeAkanChildProxyHeaders } from "./akanAppHeaders";
9
10
  import type { BuilderCsrReq, BuilderCsrRes, BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
10
11
  import { compressResponse, encodedFileResponse } from "./contentEncoding";
11
12
  import { isPortInUseError } from "./lifecycle/portInUse";
@@ -19,6 +20,8 @@ import { RotatingLogWriter } from "./logging/rotatingLogWriter";
19
20
  import { AppInfo } from "./ops/appInfo";
20
21
  import type { OpsRoute } from "./ops/opsRoute";
21
22
  import { ProcessMetricsCollector } from "./processMetricsCollector";
23
+ import { HostAllowlist } from "./routing/hostAllowlist";
24
+ import { SelfExec } from "./selfExec";
22
25
  import { resolveStaticPath } from "./staticPath";
23
26
  import { getWebConfigFromEnv } from "./types";
24
27
 
@@ -139,6 +142,7 @@ export class AkanApp {
139
142
  #logControl: LogControlSocket | null = null;
140
143
  #logStream: LogStreamRoute | null = null;
141
144
  #ops: OpsRoute | null = null;
145
+ #hostAllowlist = HostAllowlist.fromEnv();
142
146
  static readonly #ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
143
147
  #gatewayMetrics: AkanMetricsReport = {};
144
148
  #proxyHopCount = 0;
@@ -149,6 +153,7 @@ export class AkanApp {
149
153
  #stopping = false;
150
154
 
151
155
  constructor(serverPathOrOptions: string | AkanAppOptions = "./server", options: AkanAppOptions = {}) {
156
+ SelfExec.adopt();
152
157
  const resolvedOptions = typeof serverPathOrOptions === "string" ? options : serverPathOrOptions;
153
158
  const serverPath = typeof serverPathOrOptions === "string" ? serverPathOrOptions : "./server";
154
159
  this.#serverPath = AkanApp.#resolveServerPath(resolvedOptions.serverPath ?? serverPath);
@@ -235,6 +240,10 @@ export class AkanApp {
235
240
  process.exit(await OpsCommand.run(process.argv.slice(3)));
236
241
  }
237
242
  if (this.#solo) return await this.#startSolo();
243
+ if (SelfExec.carried)
244
+ throw new Error(
245
+ "A desktop app's server runs in one process, and this one was asked for a gateway and replicas, which would start with the `bun` on the user's PATH: by `replica` or `solo: false` in main.ts, or by AKAN_SOLO=false, an AKAN_REPLICA other than one traffic replica, or AKAN_COMMAND_TYPE=start in its env. Leave them out of the app the desktop build carries.",
246
+ );
238
247
  Logger.role = "gateway";
239
248
  await this.#prepareRuntimeDir();
240
249
  await this.#startLogHub();
@@ -579,6 +588,7 @@ export class AkanApp {
579
588
  this.#server = Bun.serve({
580
589
  idleTimeout: 0,
581
590
  port: this.#port,
591
+ hostname: process.env.AKAN_LISTEN_HOST || undefined,
582
592
  fetch: (req, server) => this.#handleFetch(req, server),
583
593
  websocket: {
584
594
  idleTimeout: 0,
@@ -595,6 +605,7 @@ export class AkanApp {
595
605
  }
596
606
 
597
607
  async #handleFetch(req: Request, server: Bun.Server<GatewayWsData>): Promise<Response | undefined> {
608
+ if (this.#hostAllowlist && !this.#hostAllowlist.allows(req)) return this.#hostAllowlist.refuse();
598
609
  const url = new URL(req.url);
599
610
  if (url.pathname === "/_akan/app/health") return Response.json(this.#getHealthStatus());
600
611
  if (url.pathname === "/_akan/app/metrics") return Response.json(this.#getMetricsStatus());
@@ -638,6 +649,12 @@ export class AkanApp {
638
649
  const upstream = child?.upstream ? (child.wsUpstream ?? this.#getChildUpstream(child.idx, child.role).ws) : null;
639
650
  if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
640
651
  const url = new URL(req.url);
652
+
653
+ try {
654
+ CrossSiteGuard.assertOrigin(req, url, url.pathname === "/_akan/hmr" ? "hmr" : "websocket");
655
+ } catch {
656
+ return new Response("Forbidden", { status: 403 });
657
+ }
641
658
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
642
659
  headers: makeAkanChildProxyHeaders(req, child.idx, server.requestIP(req)),
643
660
  } as unknown as string[]);
@@ -926,8 +943,10 @@ export class AkanApp {
926
943
  async #proxyResponse(req: Request, upstreamRes: Response): Promise<Response> {
927
944
  const headers = new Headers(upstreamRes.headers);
928
945
 
929
- headers.delete("content-encoding");
930
- headers.delete("content-length");
946
+ if (headers.has("content-encoding")) {
947
+ headers.delete("content-encoding");
948
+ headers.delete("content-length");
949
+ }
931
950
  this.#rewriteInternalLocation(headers);
932
951
  const proxied = new Response(upstreamRes.body, {
933
952
  status: upstreamRes.status,
@@ -947,7 +966,8 @@ export class AkanApp {
947
966
  if (!location) return;
948
967
  try {
949
968
  const parsed = new URL(location);
950
- if (parsed.hostname === "akan-child") headers.set("location", `${parsed.pathname}${parsed.search}${parsed.hash}`);
969
+ if (parsed.hostname === AKAN_CHILD_HOST)
970
+ headers.set("location", `${parsed.pathname}${parsed.search}${parsed.hash}`);
951
971
  } catch {
952
972
  }
953
973
  }
@@ -1074,6 +1094,7 @@ export class AkanApp {
1074
1094
  child.upstream = message.upstream;
1075
1095
  child.wsUpstream = message.wsUpstream;
1076
1096
  child.healthPath = message.healthPath;
1097
+ if (message.crossSite) CrossSiteGuard.configure(message.crossSite);
1077
1098
  child.lastPongAtMono = performance.now();
1078
1099
  child.restartAttempts = 0;
1079
1100
 
@@ -1084,7 +1105,8 @@ export class AkanApp {
1084
1105
  this.#federationChildCache = null;
1085
1106
 
1086
1107
  const trafficChildren = [...this.#children.values()].filter((item) => item.role !== "batch");
1087
- if (child.role !== "batch" && trafficChildren.every((item) => item.ready)) {
1108
+
1109
+ if (!this.#stopping && child.role !== "batch" && trafficChildren.every((item) => item.ready)) {
1088
1110
  process.send?.({ type: "backend-ready", pid: process.pid } satisfies AkanIpcMessage);
1089
1111
  }
1090
1112
  if ([...this.#children.values()].every((item) => item.ready)) {
@@ -11,6 +11,9 @@ const HOP_BY_HOP_HEADERS = new Set([
11
11
  "upgrade",
12
12
  ]);
13
13
 
14
+ /** The Host every request the gateway hands a replica carries; a browser never sends it to a replica's port. */
15
+ export const AKAN_CHILD_HOST = "akan-child";
16
+
14
17
  export interface ProxyClientPeer {
15
18
  address: string;
16
19
  port: number;
@@ -44,6 +47,6 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
44
47
  if (!headers.has("x-request-id") && process.env.AKAN_BENCH_SKIP_REQUEST_ID !== "1") {
45
48
  headers.set("x-request-id", crypto.randomUUID());
46
49
  }
47
- headers.set("host", "akan-child");
50
+ headers.set("host", AKAN_CHILD_HOST);
48
51
  return headers;
49
52
  }
@@ -19,11 +19,13 @@ import { AgentRelayAccess } from "../signal/guards";
19
19
  import { createOpenApiDocument } from "../signal/openapi";
20
20
  import { FetchSerializer } from "../signal/serializer";
21
21
  import { SignalContext } from "../signal/signalContext";
22
+ import { AKAN_CHILD_HOST } from "./akanAppHeaders";
22
23
  import type { AkanLib, AkanLibProps } from "./akanLib";
23
24
  import { BinaryPubsub } from "./binaryPubsub";
24
25
  import { DevtoolsRouter } from "./devtools";
25
26
  import { DiLifecycle } from "./di/diLifecycle";
26
27
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
28
+ import { OrphanGroup } from "./lifecycle/orphanGroup";
27
29
  import { isPortInUseError } from "./lifecycle/portInUse";
28
30
  import { resolveRuntimeDir } from "./lifecycle/runtimeDir";
29
31
  import { ShutdownManager } from "./lifecycle/shutdownManager";
@@ -42,6 +44,7 @@ import { WebProxyRunner } from "./proxy";
42
44
  import { SignalResolver } from "./resolver";
43
45
  import { type ApiRouteInputs, ApiRouter } from "./routing/apiRouter";
44
46
  import type { AppWsData } from "./routing/appWsData";
47
+ import { HostAllowlist } from "./routing/hostAllowlist";
45
48
  import { createSoloAppRoutes } from "./routing/soloAppRoutes";
46
49
  import {
47
50
  getWebConfigFromEnv,
@@ -167,6 +170,8 @@ export class AkanServer {
167
170
  #ops: OpsRoute | null | undefined;
168
171
  #lastMetrics: AkanMetricsReport = {};
169
172
  #stopping: Promise<void> | null = null;
173
+ #parentGone: boolean = false;
174
+ #orphanExiting = false;
170
175
  constructor(
171
176
  name = "AkanServer",
172
177
  env: BackendEnv = {},
@@ -392,7 +397,10 @@ export class AkanServer {
392
397
  }),
393
398
  data: {},
394
399
  } as Bun.WebSocketHandler<AppWsData | HmrWsData>;
395
- const buildRoutes = (upgradeAppWs: ApiRouteInputs["upgradeAppWs"]) =>
400
+
401
+ const hostAllowlist = unix ? null : HostAllowlist.fromEnv();
402
+ const hostname = process.env.AKAN_LISTEN_HOST || undefined;
403
+ const buildRoutes = (upgradeAppWs: ApiRouteInputs["upgradeAppWs"], allowlist: HostAllowlist | null) =>
396
404
  ApiRouter.buildRoutes({
397
405
  prefix: this.prefix,
398
406
  websocketPrefix: this.websocketPrefix,
@@ -402,19 +410,25 @@ export class AkanServer {
402
410
  renderEnvRoutes,
403
411
  upgradeAppWs,
404
412
  webProxyRunner,
413
+ hostAllowlist: allowlist,
405
414
  });
406
415
  this.#server = Bun.serve({
407
416
  idleTimeout: 0,
408
- ...(unix ? { unix } : { port }),
409
- routes: buildRoutes((req, data) => this.#server?.upgrade(req, { data }) ?? false),
417
+ ...(unix ? { unix } : { port, hostname }),
418
+ routes: buildRoutes((req, data) => this.#server?.upgrade(req, { data }) ?? false, hostAllowlist),
410
419
  websocket: websocketHandlers,
411
420
  } as Parameters<typeof Bun.serve>[0]);
412
421
  if (unix && process.env.AKAN_CHILD_WS_PORT) {
413
422
  const preferredWsPort = Number(process.env.AKAN_CHILD_WS_PORT);
423
+
414
424
  const wsServeOptions = (port: number) => ({
415
425
  idleTimeout: 0,
416
426
  port,
417
- routes: buildRoutes((req, data) => this.#wsServer?.upgrade(req, { data }) ?? false),
427
+ hostname: "127.0.0.1",
428
+ routes: buildRoutes(
429
+ (req, data) => this.#wsServer?.upgrade(req, { data }) ?? false,
430
+ new HostAllowlist([AKAN_CHILD_HOST]),
431
+ ),
418
432
  websocket: websocketHandlers,
419
433
  });
420
434
  try {
@@ -459,7 +473,7 @@ export class AkanServer {
459
473
  this.#di.registerSchedule(this.serverMode);
460
474
  this.logger.verbose(`🚀 ${this.name} is running on ${unix ? `unix://${unix}` : `port ${port}`}`);
461
475
  const wsPort = this.#wsServer?.port;
462
- process.send?.({
476
+ this.#sendReady({
463
477
  type: "ready",
464
478
  pid: process.pid,
465
479
  replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
@@ -467,7 +481,8 @@ export class AkanServer {
467
481
  upstream: unix ? { type: "unix", socketPath: unix } : { type: "tcp", host: "127.0.0.1", port: Number(port) },
468
482
  wsUpstream: typeof wsPort === "number" ? { type: "tcp", host: "127.0.0.1", port: wsPort } : undefined,
469
483
  healthPath: "/_akan/app/child-health",
470
- } satisfies AkanIpcMessage);
484
+ crossSite: CrossSiteGuard.option(),
485
+ });
471
486
  await this.#di.runSchedulerInit();
472
487
  ShutdownManager.register(this.logger, () => this.stop());
473
488
  return this;
@@ -476,7 +491,10 @@ export class AkanServer {
476
491
  async start({ listen, web }: { listen?: boolean; web?: AkanWebOption } = {}) {
477
492
  const isNoListenCommand = process.env.AKAN_COMMAND_TYPE === "script" || process.env.AKAN_COMMAND_TYPE === "console";
478
493
  const shouldListen = (listen ?? !isNoListenCommand) && this.serverMode !== "batch";
494
+
495
+ if (shouldListen || !isNoListenCommand) this.#registerParentIpc();
479
496
  await this.init({ routes: shouldListen, web });
497
+ if (this.#parentGone) return this;
480
498
  if (!shouldListen) {
481
499
  const websocket = this.#di.getWebsocketAdaptor();
482
500
  if (websocket)
@@ -486,20 +504,18 @@ export class AkanServer {
486
504
  Logger.role = this.serverMode;
487
505
  this.#metricsTimer ??= ProcessMetricsCollector.startReporting(() => this.#reportMetrics());
488
506
  this.#di.registerSchedule(this.serverMode);
489
- this.#registerParentIpc();
490
507
  await this.#startLogTransport();
491
- process.send?.({
508
+ this.#sendReady({
492
509
  type: "ready",
493
510
  pid: process.pid,
494
511
  replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
495
512
  role: this.serverMode,
496
- } satisfies AkanIpcMessage);
513
+ });
497
514
  await this.#di.runSchedulerInit();
498
515
  ShutdownManager.register(this.logger, () => this.stop());
499
516
  }
500
517
  return this;
501
518
  }
502
- this.#registerParentIpc();
503
519
  return this.listen();
504
520
  }
505
521
  stop(): Promise<void> {
@@ -546,14 +562,28 @@ export class AkanServer {
546
562
  #registerParentIpc() {
547
563
  process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
548
564
  process.on("disconnect", () => this.#handleParentDisconnect());
565
+
566
+ if (process.send && !process.connected) this.#handleParentDisconnect();
567
+ }
568
+
569
+ #sendReady(message: AkanIpcMessage) {
570
+ if (process.send?.(message) === false) this.#handleParentDisconnect();
549
571
  }
550
572
 
551
573
  #handleParentDisconnect() {
574
+ if (this.#parentGone) return;
575
+ this.#parentGone = true;
552
576
  this.logger.warn("Parent IPC channel closed; shutting down to avoid an orphaned replica");
553
- setTimeout(() => process.exit(1), this.shutdownTimeoutMs + 1_000);
577
+ setTimeout(() => this.#exitOrphaned(1), this.shutdownTimeoutMs + 1_000);
554
578
  void this.stop()
555
- .then(() => process.exit(0))
556
- .catch(() => process.exit(1));
579
+ .then(() => this.#exitOrphaned(0))
580
+ .catch(() => this.#exitOrphaned(1));
581
+ }
582
+
583
+ #exitOrphaned(code: number) {
584
+ if (this.#orphanExiting) return;
585
+ this.#orphanExiting = true;
586
+ OrphanGroup.exit(code);
557
587
  }
558
588
 
559
589
  #handleIpcMessage(message: AkanIpcMessage) {
@@ -32,6 +32,8 @@ const GZIP_LEVEL = 6;
32
32
  /** Under a KB the framing bytes and the call cost more than the repetition they remove. */
33
33
  const MIN_COMPRESS_BYTES = 1024;
34
34
 
35
+ const MAX_COMPRESS_BYTES = 4 * 1024 * 1024;
36
+
35
37
  const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
36
38
  encoding === "br"
37
39
  ? brotliCompressSync(bytes, {
@@ -42,8 +44,17 @@ const compressBody = (bytes: Uint8Array, encoding: ContentEncoding) =>
42
44
  })
43
45
  : gzipSync(bytes, { level: GZIP_LEVEL });
44
46
 
47
+ const isWholeBody = (req: Request, response: Response) =>
48
+ !req.headers.has("range") &&
49
+ !response.headers.has("content-range") &&
50
+ response.status >= 200 &&
51
+ response.status < 300 &&
52
+ response.status !== 206;
53
+
45
54
  export const compressResponse = async (req: Request, response: Response): Promise<Response> => {
46
55
  if (response.headers.has("content-encoding") || !response.body) return response;
56
+ if (!isWholeBody(req, response)) return response;
57
+ if (Number(response.headers.get("content-length")) > MAX_COMPRESS_BYTES) return response;
47
58
  if (!isCompressibleContentType(response.headers.get("content-type") ?? "")) return response;
48
59
  if (process.env.AKAN_HTTP_COMPRESS === "false" || process.env.AKAN_HTTP_COMPRESS === "0") return response;
49
60
  const acceptEncoding = req.headers.get("accept-encoding") ?? "";
package/server/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./console";
7
7
  export * from "./decorators";
8
8
  export * from "./devtools";
9
9
  export type { ChangeBatch, ChangeKind } from "./hmr/wsHub";
10
+ export * from "./nativeFile";
10
11
  export * from "./oauth";
11
12
  export * from "./processMetricsCollector";
12
13
  export * from "./proxy";
@@ -0,0 +1,21 @@
1
+ import { SelfExec } from "../selfExec";
2
+
3
+ export class OrphanGroup {
4
+ static exit(code: number, { leads = SelfExec.carried && process.platform !== "win32", graceMs = 1_000 } = {}) {
5
+ if (!leads) process.exit(code);
6
+ process.removeAllListeners("SIGTERM");
7
+ process.on("SIGTERM", () => undefined);
8
+ OrphanGroup.#signal("SIGTERM");
9
+ setTimeout(() => {
10
+ OrphanGroup.#signal("SIGKILL");
11
+ process.exit(code);
12
+ }, graceMs);
13
+ }
14
+
15
+ static #signal(signal: NodeJS.Signals) {
16
+ try {
17
+ process.kill(-process.pid, signal);
18
+ } catch {
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,107 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { getEnv } from "akanjs/base";
6
+ import type { AkanIpcMessage } from "akanjs/service";
7
+
8
+ export type NativeFileMode = "read" | "write" | "folder";
9
+
10
+ type Resolved = Extract<AkanIpcMessage, { type: "file.resolved" }>;
11
+
12
+ export class NativeFile {
13
+ static timeoutMs = 5_000;
14
+ static operationMode: () => string = () => getEnv().operationMode;
15
+ static readonly #waiting = new Map<string, (answer: Resolved) => void>();
16
+ static #listener: ((message: unknown) => void) | null = null;
17
+
18
+ /** The path behind `grant`, which the user must have given for `mode`. */
19
+ static async resolve(grant: string, mode: NativeFileMode): Promise<string> {
20
+ const found = grant.startsWith("dev:") ? NativeFile.#devGrant(grant) : await NativeFile.#ask(grant);
21
+ if (found.mode !== mode) throw new Error(`The file was granted to ${found.mode}, not to ${mode}.`);
22
+ return found.path;
23
+ }
24
+
25
+ /** A path inside a folder grant; one that climbs out of the folder is refused. */
26
+ static async resolveIn(grant: string, relative: string): Promise<string> {
27
+ const folder = await NativeFile.resolve(grant, "folder");
28
+ const target = path.resolve(folder, relative);
29
+ NativeFile.#assertInside(folder, target, relative);
30
+
31
+ NativeFile.#assertInside(NativeFile.#realPath(folder), NativeFile.#realPath(target), relative);
32
+ return target;
33
+ }
34
+
35
+ static #assertInside(folder: string, target: string, relative: string) {
36
+ const inside = path.relative(folder, target);
37
+ if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside))
38
+ throw new Error(`${relative} is outside the folder the user granted.`);
39
+ }
40
+
41
+ static #realPath(target: string): string {
42
+ const rest: string[] = [];
43
+ for (let current = target; ; current = path.dirname(current)) {
44
+ try {
45
+ return path.join(realpathSync.native(current), ...rest);
46
+ } catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" || path.dirname(current) === current) throw error;
48
+ if (NativeFile.#exists(current)) throw new Error(`${target} is a link to nothing.`);
49
+ rest.unshift(path.basename(current));
50
+ }
51
+ }
52
+ }
53
+
54
+ static #exists(entry: string) {
55
+ try {
56
+ lstatSync(entry);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ static #ask(grant: string): Promise<{ path: string; mode: NativeFileMode }> {
64
+ const send = process.send?.bind(process);
65
+ if (!send) return Promise.reject(new Error("A file grant resolves only inside the desktop app that gave it."));
66
+ NativeFile.#listen();
67
+ const id = crypto.randomUUID();
68
+ return new Promise((resolve, reject) => {
69
+ const timer = setTimeout(() => {
70
+ NativeFile.#waiting.delete(id);
71
+ reject(new Error("The desktop app did not answer for the file grant."));
72
+ }, NativeFile.timeoutMs);
73
+ NativeFile.#waiting.set(id, (answer) => {
74
+ clearTimeout(timer);
75
+ NativeFile.#waiting.delete(id);
76
+ if (answer.path && answer.mode) resolve({ path: answer.path, mode: answer.mode });
77
+ else reject(new Error(answer.error ?? "The desktop app refused the file grant."));
78
+ });
79
+ send({ type: "file.resolve", id, grant } satisfies AkanIpcMessage);
80
+ });
81
+ }
82
+
83
+ static #listen() {
84
+ if (NativeFile.#listener) return;
85
+ NativeFile.#listener = (message) => {
86
+ const answer = message as AkanIpcMessage | null;
87
+ if (answer?.type === "file.resolved") NativeFile.#waiting.get(answer.id)?.(answer);
88
+ };
89
+ process.on("message", NativeFile.#listener);
90
+ }
91
+
92
+ static #devGrant(grant: string): { path: string; mode: NativeFileMode } {
93
+
94
+ if (NativeFile.operationMode() !== "local")
95
+ throw new Error("A dev build's file grant resolves only on akan start, in operationMode local.");
96
+ const [, mode, encoded = "", signature = ""] = grant.split(":");
97
+ if (mode !== "read" && mode !== "write" && mode !== "folder") throw new Error("The file grant is malformed.");
98
+ const key = readFileSync(
99
+ process.env.AKAN_NATIVE_DEV_GRANT_KEY || path.join(homedir(), ".akan", "native", "dev-file-grant.key"),
100
+ );
101
+ const expected = createHmac("sha256", key).update(`${mode}:${encoded}`).digest();
102
+ const given = Buffer.from(signature, "base64url");
103
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
104
+ throw new Error("The file grant was not signed by this computer's dev build.");
105
+ return { path: Buffer.from(encoded, "base64url").toString(), mode };
106
+ }
107
+ }
@@ -7,6 +7,7 @@ export interface AppPublicInfo {
7
7
  repoName: string;
8
8
  environment: string;
9
9
  operationMode: string;
10
+ workspaceRoot?: string;
10
11
  }
11
12
 
12
13
  export interface AppDetailInfo extends AppPublicInfo {
@@ -39,7 +40,8 @@ export class AppInfo {
39
40
 
40
41
  static public(): AppPublicInfo {
41
42
  const { appName, repoName, environment, operationMode } = getEnv();
42
- return { appName, repoName, environment, operationMode };
43
+ const workspaceRoot = operationMode === "local" ? process.env.AKAN_WORKSPACE_ROOT : undefined;
44
+ return { appName, repoName, environment, operationMode, ...(workspaceRoot ? { workspaceRoot } : {}) };
43
45
  }
44
46
 
45
47
  static detail(proc: AppProcessInfo): AppDetailInfo {
@@ -1,5 +1,6 @@
1
1
  import { Logger } from "akanjs/common";
2
2
  import { getEnv } from "../../base/baseEnv";
3
+ import { SelfExec } from "../selfExec";
3
4
  import type { AppDetailInfo } from "./appInfo";
4
5
  import { OpsTokenVerifier } from "./opsToken";
5
6
  import { type SnapshotJobRequest, SnapshotJobs } from "./snapshotJobs";
@@ -65,8 +66,8 @@ export class OpsRoute {
65
66
  const args = ["ops", "snapshot", "--id", request.id, "--out", dir, "--db", sources.main, "--json"];
66
67
  if (sources.solid) args.push("--solid-db", sources.solid);
67
68
  if (request.includeSolid) args.push("--include-solid");
68
- const proc = Bun.spawn([process.execPath, Bun.main, ...args], {
69
- env: process.env,
69
+ const proc = Bun.spawn(SelfExec.command(Bun.main, ...args), {
70
+ env: SelfExec.env(),
70
71
  stdout: "pipe",
71
72
  stderr: "pipe",
72
73
  });
@@ -8,6 +8,7 @@ import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
8
8
  import { SignalResolver } from "../resolver";
9
9
  import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
10
10
  import { AppWsData } from "./appWsData";
11
+ import type { HostAllowlist } from "./hostAllowlist";
11
12
 
12
13
  export interface HmrStateSource {
13
14
  readonly state: {
@@ -34,6 +35,7 @@ export interface ApiRouteInputs {
34
35
  renderEnvRoutes: HttpRoutes;
35
36
  upgradeAppWs: (req: Request, data: AppWsData) => boolean;
36
37
  webProxyRunner?: WebProxyRunner | null;
38
+ hostAllowlist?: HostAllowlist | null;
37
39
  }
38
40
 
39
41
  type RouteValue = NonNullHttpRoutes[keyof NonNullHttpRoutes];
@@ -62,6 +64,7 @@ export class ApiRouter {
62
64
  renderEnvRoutes,
63
65
  upgradeAppWs,
64
66
  webProxyRunner,
67
+ hostAllowlist,
65
68
  }: ApiRouteInputs): NonNullHttpRoutes {
66
69
  const endpointEntries = Object.entries(routes ?? {}).map(
67
70
  ([p, handler]) =>
@@ -76,6 +79,12 @@ export class ApiRouter {
76
79
  const endpointPaths = new Set([...endpointEntries.map(([path]) => path), ...builtinEntries.map(([path]) => path)]);
77
80
  const routeTable = {
78
81
  [`${prefix}${websocketPrefix}` as "/api/ws"]: (req) => {
82
+
83
+ try {
84
+ CrossSiteGuard.assertOrigin(req, new URL(req.url), "websocket");
85
+ } catch {
86
+ return new Response("Forbidden", { status: 403 });
87
+ }
79
88
  const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
80
89
  if (upgraded) return;
81
90
  return new Response("Failed to upgrade to WebSocket", { status: 500 });
@@ -84,9 +93,10 @@ export class ApiRouter {
84
93
  ...Object.fromEntries(builtinEntries),
85
94
  ...(renderEnvRoutes ?? {}),
86
95
  } as NonNullHttpRoutes;
87
- return webProxyRunner
96
+ const served = webProxyRunner
88
97
  ? ApiRouter.#wrapRoutesWithWebProxy(routeTable, webProxyRunner, prefix, endpointPaths)
89
98
  : routeTable;
99
+ return hostAllowlist ? ApiRouter.#guardHosts(served, hostAllowlist) : served;
90
100
  }
91
101
 
92
102
  static buildWebsocketHandlers({
@@ -214,6 +224,15 @@ export class ApiRouter {
214
224
  ) as NonNullHttpRoutes;
215
225
  }
216
226
 
227
+ static #guardHosts(routes: NonNullHttpRoutes, allowlist: HostAllowlist): NonNullHttpRoutes {
228
+ return Object.fromEntries(
229
+ Object.entries(routes).map(([path, route]) => [
230
+ path,
231
+ ApiRouter.#mapRoute(route, (handler) => (req) => (allowlist.allows(req) ? handler(req) : allowlist.refuse())),
232
+ ]),
233
+ ) as NonNullHttpRoutes;
234
+ }
235
+
217
236
  static #isApiRoute(path: string, apiPrefix: string): boolean {
218
237
  const normalized = apiPrefix.replace(/\/$/, "");
219
238
  return path === normalized || path.startsWith(`${normalized}/`);
@@ -242,12 +261,16 @@ export class ApiRouter {
242
261
 
243
262
  static #mapRoute(route: RouteValue, wrap: (handler: RouteHandler) => RouteHandler): RouteValue {
244
263
  if (typeof route === "function") return wrap(route as RouteHandler) as RouteValue;
245
- if (route instanceof Response) return wrap(() => route) as RouteValue;
264
+ if (route instanceof Response) return wrap(() => route.clone()) as RouteValue;
246
265
  if (!route || typeof route !== "object") return route;
247
266
  return Object.fromEntries(
248
267
  Object.entries(route).map(([method, handler]) => [
249
268
  method,
250
- typeof handler === "function" ? wrap(handler as RouteHandler) : handler,
269
+ typeof handler === "function"
270
+ ? wrap(handler as RouteHandler)
271
+ : handler instanceof Response
272
+ ? wrap(() => handler.clone())
273
+ : handler,
251
274
  ]),
252
275
  ) as RouteValue;
253
276
  }
@@ -0,0 +1,24 @@
1
+
2
+
3
+ export class HostAllowlist {
4
+ readonly #hosts: Set<string>;
5
+
6
+ constructor(hosts: Iterable<string>) {
7
+ this.#hosts = new Set([...hosts].map((host) => host.trim().toLowerCase()).filter(Boolean));
8
+ }
9
+
10
+ /** `AKAN_ALLOWED_HOSTS`: comma-separated `host:port` entries; unset or blank serves every Host. */
11
+ static fromEnv(env: Record<string, string | undefined> = process.env): HostAllowlist | null {
12
+ const allowlist = new HostAllowlist((env.AKAN_ALLOWED_HOSTS ?? "").split(","));
13
+ return allowlist.#hosts.size ? allowlist : null;
14
+ }
15
+
16
+ allows(req: Request): boolean {
17
+ const host = req.headers.get("host")?.trim().toLowerCase();
18
+ return !!host && this.#hosts.has(host);
19
+ }
20
+
21
+ refuse(): Response {
22
+ return new Response("Forbidden", { status: 403 });
23
+ }
24
+ }
@@ -0,0 +1,33 @@
1
+ interface SelfExecGlobal {
2
+ [key: symbol]: string | undefined;
3
+ }
4
+
5
+ export class SelfExec {
6
+
7
+ static readonly #key = Symbol.for("akan.selfExec.beBun");
8
+
9
+ static get #beBun(): string | undefined {
10
+ return (globalThis as unknown as SelfExecGlobal)[SelfExec.#key];
11
+ }
12
+
13
+ static adopt() {
14
+ const value = process.env.BUN_BE_BUN;
15
+ if (value === undefined) return;
16
+ (globalThis as unknown as SelfExecGlobal)[SelfExec.#key] = value;
17
+ delete process.env.BUN_BE_BUN;
18
+ }
19
+
20
+ /** A desktop app's carried server: only its shell starts the app executable as Bun. */
21
+ static get carried(): boolean {
22
+ return SelfExec.#beBun !== undefined;
23
+ }
24
+
25
+ static env(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
26
+ const beBun = SelfExec.#beBun;
27
+ return beBun === undefined ? env : { ...env, BUN_BE_BUN: beBun };
28
+ }
29
+
30
+ static command(script: string, ...args: string[]): string[] {
31
+ return [process.execPath, ...process.execArgv, script, ...args];
32
+ }
33
+ }
package/server/types.tsx CHANGED
@@ -125,7 +125,9 @@ export const getWebConfigFromEnv = (): AkanWebConfig => {
125
125
 
126
126
  export interface MobileDeepLinkAssociation {
127
127
  targetName: string;
128
- appId: string;
128
+ /** Null when the target names no id for the platform; that platform then gets no association. */
129
+ iosAppId: string | null;
130
+ androidAppId: string | null;
129
131
  domains: string[];
130
132
  iosTeamId?: string;
131
133
  androidSha256CertFingerprints?: string[];