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