akanjs 3.1.0-rc.21 → 3.1.0-rc.23

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 (119) hide show
  1. package/client/cookie.ts +5 -0
  2. package/client/csrLocation.ts +17 -72
  3. package/client/csrTypes.ts +19 -4
  4. package/client/route/PageDefinition.ts +103 -10
  5. package/client/route/RouteDefinition.ts +44 -3
  6. package/client/route/RouteGuards.ts +64 -0
  7. package/client/route/index.ts +1 -0
  8. package/client/route/resolveRouteModule.ts +8 -0
  9. package/client/route/routeArgs.ts +9 -0
  10. package/client/router.ts +11 -0
  11. package/common/authCookie.ts +13 -0
  12. package/common/authToken.ts +1 -12
  13. package/common/index.ts +2 -8
  14. package/common/routeConvention.ts +2 -0
  15. package/constant/inputParser.ts +1 -1
  16. package/dictionary/base.dictionary.ts +10 -0
  17. package/document/documentQuery.ts +21 -0
  18. package/document/liveRowScope.ts +4 -8
  19. package/document/queryEvaluator.ts +5 -8
  20. package/fetch/appScope.ts +54 -0
  21. package/fetch/client/fetchClient.ts +46 -29
  22. package/fetch/index.ts +1 -0
  23. package/package.json +1 -1
  24. package/server/mcp/McpApps.ts +44 -50
  25. package/server/mcp/McpPages.ts +93 -19
  26. package/server/mcp/McpRouter.ts +14 -11
  27. package/server/ops/migrateJobs.ts +145 -0
  28. package/server/ops/opsRoute.ts +39 -7
  29. package/server/ops/opsToken.ts +7 -2
  30. package/server/routeElementComposer.tsx +33 -6
  31. package/server/routeTreeBuilder.ts +5 -0
  32. package/server/rscClient.tsx +20 -0
  33. package/server/rscPagePrompts.ts +66 -16
  34. package/server/rscWorker.tsx +33 -11
  35. package/server/rscWorkerHost.ts +12 -1
  36. package/server/webRouter.ts +31 -27
  37. package/service/liveSyncHub.ts +2 -1
  38. package/service/predefinedAdaptor/sql/QueryCompiler.ts +3 -7
  39. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +14 -2
  40. package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -3
  41. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +2 -2
  42. package/service/predefinedAdaptor/sql/types.ts +2 -0
  43. package/service/predefinedAdaptor/sql/values.ts +19 -0
  44. package/signal/mcp/mcpProtocol.ts +7 -0
  45. package/signal/mcp/pagePrompt.ts +18 -1
  46. package/types/client/csrLocation.d.ts +3 -7
  47. package/types/client/csrTypes.d.ts +22 -3
  48. package/types/client/route/PageDefinition.d.ts +21 -4
  49. package/types/client/route/RouteDefinition.d.ts +4 -0
  50. package/types/client/route/RouteGuards.d.ts +15 -0
  51. package/types/client/route/index.d.ts +1 -0
  52. package/types/client/route/resolveRouteModule.d.ts +2 -0
  53. package/types/client/route/routeArgs.d.ts +5 -0
  54. package/types/client/route/routeBuilders.d.ts +1 -1
  55. package/types/client/router.d.ts +3 -0
  56. package/types/common/authCookie.d.ts +5 -0
  57. package/types/common/authToken.d.ts +0 -5
  58. package/types/common/index.d.ts +2 -1
  59. package/types/dictionary/base.dictionary.d.ts +1 -1
  60. package/types/dictionary/dictionary.d.ts +8 -8
  61. package/types/document/documentQuery.d.ts +1 -0
  62. package/types/fetch/appScope.d.ts +18 -0
  63. package/types/fetch/client/fetchClient.d.ts +1 -1
  64. package/types/fetch/index.d.ts +1 -0
  65. package/types/server/mcp/McpApps.d.ts +14 -8
  66. package/types/server/mcp/McpPages.d.ts +6 -3
  67. package/types/server/ops/migrateJobs.d.ts +36 -0
  68. package/types/server/ops/opsRoute.d.ts +4 -1
  69. package/types/server/ops/opsToken.d.ts +3 -0
  70. package/types/server/routeElementComposer.d.ts +10 -1
  71. package/types/server/rscPagePrompts.d.ts +3 -1
  72. package/types/server/rscWorkerHost.d.ts +2 -1
  73. package/types/server/webRouter.d.ts +3 -0
  74. package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +0 -1
  75. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +1 -1
  76. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -1
  77. package/types/service/predefinedAdaptor/sql/types.d.ts +2 -0
  78. package/types/service/predefinedAdaptor/sql/values.d.ts +1 -0
  79. package/types/signal/mcp/mcpProtocol.d.ts +6 -0
  80. package/types/signal/mcp/pagePrompt.d.ts +16 -0
  81. package/types/ui/Layout/FrameSlotPortal.d.ts +1 -1
  82. package/types/ui/System/App.d.ts +17 -0
  83. package/types/ui/System/index.d.ts +2 -0
  84. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  85. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  86. package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +1 -1
  87. package/types/webkit/CsrGuards.d.ts +25 -0
  88. package/types/webkit/CsrRouteTable.d.ts +6 -0
  89. package/types/webkit/CsrStack.d.ts +6 -0
  90. package/types/webkit/McpAppBoot.d.ts +32 -0
  91. package/types/webkit/McpAppBridge.d.ts +10 -1
  92. package/types/webkit/McpAppFetchBridge.d.ts +3 -1
  93. package/types/webkit/McpAppView.d.ts +4 -1
  94. package/types/webkit/PageNotFound.d.ts +5 -0
  95. package/types/webkit/RenderLayer.d.ts +1 -3
  96. package/types/webkit/index.d.ts +1 -0
  97. package/ui/Layout/BottomInset.tsx +2 -11
  98. package/ui/Layout/FrameSlotPortal.tsx +1 -3
  99. package/ui/Layout/Navbar.tsx +7 -22
  100. package/ui/Layout/TopInset.tsx +3 -4
  101. package/ui/System/App.tsx +43 -0
  102. package/ui/System/CSR.tsx +1 -10
  103. package/ui/System/index.tsx +3 -0
  104. package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +1 -1
  105. package/webkit/CsrGuards.ts +112 -0
  106. package/webkit/CsrRouteTable.ts +71 -16
  107. package/webkit/CsrStack.ts +29 -1
  108. package/webkit/McpAppBoot.tsx +129 -0
  109. package/webkit/McpAppBridge.ts +30 -6
  110. package/webkit/McpAppFetchBridge.ts +6 -1
  111. package/webkit/McpAppView.ts +39 -28
  112. package/webkit/PageNotFound.tsx +24 -0
  113. package/webkit/RenderLayer.tsx +98 -41
  114. package/webkit/bootCsr.tsx +4 -43
  115. package/webkit/index.ts +1 -0
  116. package/webkit/useCsrValues.ts +55 -6
  117. package/webkit/useLocation.ts +21 -19
  118. package/types/webkit/EmbeddedLayout.d.ts +0 -10
  119. package/webkit/EmbeddedLayout.tsx +0 -82
@@ -1,20 +1,9 @@
1
1
  import { getEnv } from "akanjs/base";
2
+ import { legacyAuthTokenKey } from "./authCookie";
2
3
  import { decodeJwtPayload } from "./jwtDecode";
3
4
 
4
- /** The pre-scoping key. Read as a fallback, never written; drop it once deployments have rotated past it. */
5
- export const legacyAuthTokenKey = "jwt";
6
-
7
5
  export const authTokenKey = (): string => `${legacyAuthTokenKey}:${getEnv().appName}`;
8
6
 
9
- /** Matches both spellings so a signout can sweep a jar written before and after the key was scoped. */
10
- export const isAuthTokenKey = (key: string): boolean =>
11
- key === legacyAuthTokenKey || key.startsWith(`${legacyAuthTokenKey}:`);
12
-
13
- const authCookiePattern = new RegExp(`(?:^|;\\s*)${legacyAuthTokenKey}(?::[^=\\s;]+)?=`);
14
-
15
- export const cookieHeaderHasAuthToken = (cookieHeader: string | null | undefined): boolean =>
16
- !!cookieHeader && authCookiePattern.test(cookieHeader);
17
-
18
7
  export const isOwnAuthToken = (jwt: string): boolean => {
19
8
  const { appName, environment } = getEnv();
20
9
  try {
package/common/index.ts CHANGED
@@ -1,12 +1,6 @@
1
1
  export { applyMixins, getAllPropertyDescriptors } from "./applyMixins";
2
- export {
3
- authTokenKey,
4
- cookieHeaderHasAuthToken,
5
- isAuthTokenKey,
6
- isOwnAuthToken,
7
- legacyAuthTokenKey,
8
- readAuthToken,
9
- } from "./authToken";
2
+ export { cookieHeaderHasAuthToken, isAuthTokenKey, legacyAuthTokenKey } from "./authCookie";
3
+ export { authTokenKey, isOwnAuthToken, readAuthToken } from "./authToken";
10
4
  export { Backoff, type BackoffPolicy } from "./backoff";
11
5
  export { bearerTokenOf } from "./bearerTokenOf";
12
6
  export { CodeAgentClient, type CodeAgentTransport } from "./CodeAgentClient";
@@ -16,6 +16,7 @@ export const PAGE_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
16
16
  "head",
17
17
  "generateHead",
18
18
  "Loading",
19
+ "guard",
19
20
  ]);
20
21
  export const LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([...PAGE_ROUTE_EXPORTS, "NotFound", "Error"]);
21
22
  export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
@@ -27,6 +28,7 @@ export const ROOT_LAYOUT_ROUTE_EXPORTS: ReadonlySet<string> = new Set([
27
28
  "wsConnect",
28
29
  "layoutStyle",
29
30
  "viewport",
31
+ "McpAppRoot",
30
32
  ]);
31
33
  /** Root-layout exports that are plain config rather than components, so a PascalCase check cannot allow them. */
32
34
  export const RESERVED_ROUTE_CONFIG_EXPORTS: ReadonlySet<string> = new Set(
@@ -45,12 +45,12 @@ export class InputParser {
45
45
  ]);
46
46
 
47
47
  static parse(argRef: Cls, arrDepth: number, value: unknown, option: ParseOption) {
48
- if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
49
48
  const { key, nullable = false } = option;
50
49
  if (value === null || value === undefined) {
51
50
  if (nullable) return null;
52
51
  throw new InvalidInputError(key, null);
53
52
  }
53
+ if (PrimitiveRegistry.has(argRef)) return deserialize(argRef, arrDepth, value, option);
54
54
  return InputParser.#walk(value, arrDepth, key, (item, path) => InputParser.#value(argRef, item, path));
55
55
  }
56
56
 
@@ -65,6 +65,14 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
65
65
  "{endpoint} is closed: the slice's guards name none that allows it.",
66
66
  "{endpoint}이(가) 닫혀 있습니다. 슬라이스 guards에 이 동작을 허용하는 guard를 지정해야 합니다.",
67
67
  ],
68
+ mutationInApp: [
69
+ "The {app} app called {endpoint} while it loads, and an app may load with queries only.",
70
+ "{app} 앱이 불러오는 중에 {endpoint}을(를) 호출했습니다. 앱은 쿼리로만 불러올 수 있습니다.",
71
+ ],
72
+ readOnlyDeployment: [
73
+ "This server is read-only, so {endpoint} cannot run here.",
74
+ "읽기 전용 서버라 {endpoint}을(를) 실행할 수 없습니다.",
75
+ ],
68
76
  })
69
77
  .translate({
70
78
  somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
@@ -92,6 +100,8 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
92
100
  cancel: ["Cancel", "취소"],
93
101
  retry: ["Try again", "다시 시도"],
94
102
  pageLoadFailed: ["This page could not be loaded.", "이 화면을 불러오지 못했습니다."],
103
+ pageNotFound: ["This page does not exist.", "없는 화면입니다."],
104
+ goBack: ["Go back", "돌아가기"],
95
105
  unauthorized: ["Unauthorized", "권한 없음"],
96
106
  agent: ["Agent", "에이전트"],
97
107
  agentIntro: ["Ask about this page or tell the agent what to do.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
@@ -144,6 +144,27 @@ export type DocumentQuery<T = any> =
144
144
  [K in DocumentPath<T>]?: DocumentQueryValue;
145
145
  };
146
146
 
147
+ export const isDocumentQueryNode = (value: unknown): value is DocumentQueryNode => {
148
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
149
+ const node = value as Record<string, unknown>;
150
+ const fits = (...keys: string[]) => Object.keys(node).every((key) => key === "kind" || keys.includes(key));
151
+ switch (node.kind) {
152
+ case "all":
153
+ case "any":
154
+ return Array.isArray(node.queries) && fits("queries");
155
+ case "not":
156
+ return !!node.query && typeof node.query === "object" && fits("query");
157
+ case "op":
158
+ return typeof node.op === "string" && queryOperatorKeys.has(node.op) && fits("op", "value");
159
+ case "raw":
160
+ return typeof node.sql === "string" && Array.isArray(node.params) && fits("sql", "params");
161
+ case "search":
162
+ return typeof node.text === "string" && fits("text", "columns", "prefix", "weights");
163
+ default:
164
+ return false;
165
+ }
166
+ };
167
+
147
168
  export interface SqlFragment {
148
169
  sql: string;
149
170
  params: unknown[];
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  type DocumentQuery,
3
- type DocumentQueryNode,
4
3
  type DocumentQueryOperator,
4
+ isDocumentQueryNode,
5
5
  queryOperatorKeys,
6
6
  } from "./documentQuery";
7
7
 
@@ -14,7 +14,7 @@ type RemovedAtTest = "isNull" | "isNotNull" | "value";
14
14
  export class LiveRowScope {
15
15
  static conflictOf(query: DocumentQuery | undefined, negated = false): string | null {
16
16
  if (!query || typeof query !== "object") return null;
17
- if (LiveRowScope.#isNode(query)) {
17
+ if (isDocumentQueryNode(query)) {
18
18
  if (query.kind === "all" || query.kind === "any")
19
19
  return query.queries.reduce<string | null>(
20
20
  (found, sub) => found ?? LiveRowScope.conflictOf(sub, negated),
@@ -30,7 +30,7 @@ export class LiveRowScope {
30
30
  }
31
31
 
32
32
  static #fieldConflict(path: string, value: unknown, negated: boolean): string | null {
33
- if (LiveRowScope.#isNode(value) && value.kind !== "op") return LiveRowScope.conflictOf(value, negated);
33
+ if (isDocumentQueryNode(value) && value.kind !== "op") return LiveRowScope.conflictOf(value, negated);
34
34
  if (path !== "removedAt") return null;
35
35
  for (const [test, spelled] of LiveRowScope.#testsOf(value)) {
36
36
  const named = negated ? `not(removedAt ${spelled})` : `removedAt ${spelled}`;
@@ -44,7 +44,7 @@ export class LiveRowScope {
44
44
  static #testsOf(value: unknown): [RemovedAtTest, string][] {
45
45
  if (value === undefined) return [];
46
46
  if (value === null) return [["isNull", "null"]];
47
- if (LiveRowScope.#isNode(value) && value.kind === "op") {
47
+ if (isDocumentQueryNode(value) && value.kind === "op") {
48
48
  const test = LiveRowScope.#testOf(value.op, value.value);
49
49
  return test ? [test] : [];
50
50
  }
@@ -72,8 +72,4 @@ export class LiveRowScope {
72
72
  if ((op === "oneOf" || op === "notOneOf") && !(operand as unknown[] | undefined)?.length) return null;
73
73
  return ["value", op];
74
74
  }
75
-
76
- static #isNode(value: unknown): value is DocumentQueryNode {
77
- return !!value && typeof value === "object" && "kind" in value;
78
- }
79
75
  }
@@ -4,6 +4,7 @@ import {
4
4
  type DocumentQueryNode,
5
5
  type DocumentQueryOperator,
6
6
  encodeDocumentValue,
7
+ isDocumentQueryNode,
7
8
  queryOperatorKeys,
8
9
  sanitizeJson,
9
10
  } from "./documentQuery";
@@ -49,7 +50,7 @@ export class DocumentQueryEvaluator {
49
50
  */
50
51
  static unevaluableReason(query: DocumentQuery | undefined): string | null {
51
52
  if (!query || typeof query !== "object") return null;
52
- if (DocumentQueryEvaluator.#isNode(query)) {
53
+ if (isDocumentQueryNode(query)) {
53
54
  if (query.kind === "raw") return "q.raw() carries SQL that only the database can run";
54
55
  if (query.kind === "search") return "q.search() compiles to a full-text JOIN with no in-memory equivalent";
55
56
  if (query.kind === "all" || query.kind === "any")
@@ -70,7 +71,7 @@ export class DocumentQueryEvaluator {
70
71
  if (!value || typeof value !== "object") return null;
71
72
  if (baseDocumentColumns.has(path)) return null;
72
73
  const keyAbsence = `${path}: exists/missing asks whether the key is in the stored JSON, which a document in hand cannot answer — use q.empty()`;
73
- if (DocumentQueryEvaluator.#isNode(value)) {
74
+ if (isDocumentQueryNode(value)) {
74
75
  if (value.kind === "op") return value.op === "exists" || value.op === "missing" ? keyAbsence : null;
75
76
  return DocumentQueryEvaluator.unevaluableReason(value as DocumentQuery);
76
77
  }
@@ -94,7 +95,7 @@ export class DocumentQueryEvaluator {
94
95
 
95
96
  evaluate(query: DocumentQuery | undefined, row: DocumentRowView): boolean {
96
97
  if (!query || typeof query !== "object") return true;
97
- if (DocumentQueryEvaluator.#isNode(query)) return this.#node(query, row);
98
+ if (isDocumentQueryNode(query)) return this.#node(query, row);
98
99
  const entries = Object.entries(query);
99
100
  if (!entries.length) return true;
100
101
  return entries.every(([path, value]) => this.#field(path, value, row));
@@ -118,7 +119,7 @@ export class DocumentQueryEvaluator {
118
119
  }
119
120
 
120
121
  #field(path: string, value: unknown, row: DocumentRowView): boolean {
121
- if (DocumentQueryEvaluator.#isNode(value)) {
122
+ if (isDocumentQueryNode(value)) {
122
123
  if (value.kind !== "op") return this.evaluate({ [path]: value } as DocumentQuery, row);
123
124
  return this.#operator(path, value.op, value.value, row);
124
125
  }
@@ -290,8 +291,4 @@ export class DocumentQueryEvaluator {
290
291
  static #foldAscii(text: string) {
291
292
  return text.replace(/[A-Z]/g, (char) => char.toLowerCase());
292
293
  }
293
-
294
- static #isNode(value: unknown): value is DocumentQueryNode {
295
- return !!value && typeof value === "object" && "kind" in value;
296
- }
297
294
  }
@@ -0,0 +1,54 @@
1
+ interface AppScopeStorage {
2
+ run<T>(app: string, load: () => T): T;
3
+ getStore(): string | undefined;
4
+ }
5
+
6
+ declare global {
7
+ var __AKAN_APP_SCOPE__: AppScopeStorage | undefined;
8
+ }
9
+
10
+ /**
11
+ * Marks the run of a page's `.app()` load, which may only query: an MCP view replays those queries from the tool
12
+ * result's snapshot, so a write in the load would run once for the tool call and again when the view draws.
13
+ */
14
+ export class AppScope {
15
+ static readonly #storage: AppScopeStorage = AppScope.#shared();
16
+
17
+ static run<T>(app: string, load: () => T): T {
18
+ return AppScope.#storage.run(app, load);
19
+ }
20
+
21
+ /** The app whose load is running here, if any. */
22
+ static current(): string | undefined {
23
+ return AppScope.#storage.getStore();
24
+ }
25
+
26
+ static #shared(): AppScopeStorage {
27
+ globalThis.__AKAN_APP_SCOPE__ ??= AppScope.#createStorage();
28
+ return globalThis.__AKAN_APP_SCOPE__;
29
+ }
30
+
31
+ static #createStorage(): AppScopeStorage {
32
+ if (typeof window === "undefined") {
33
+ try {
34
+ const { AsyncLocalStorage } = require("node:async_hooks") as typeof import("node:async_hooks");
35
+ const als = new AsyncLocalStorage<string>();
36
+ return { run: (app, load) => als.run(app, load), getStore: () => als.getStore() };
37
+ } catch {}
38
+ }
39
+
40
+ let current: string | undefined;
41
+ return {
42
+ run(app, load) {
43
+ const previous = current;
44
+ current = app;
45
+ try {
46
+ return load();
47
+ } finally {
48
+ current = previous;
49
+ }
50
+ },
51
+ getStore: () => current,
52
+ };
53
+ }
54
+ }
@@ -20,6 +20,7 @@ import type {
20
20
  ServiceSignal,
21
21
  } from "akanjs/signal";
22
22
  import { agentTurnConstant } from "../agentTurn";
23
+ import { AppScope } from "../appScope";
23
24
  import type { ClientSignal, FetchClientType, FetchSignalInput, MergeAllFetchTypes, SliceMeta } from "../fetchType";
24
25
  import {
25
26
  claimRequestQuery,
@@ -30,7 +31,7 @@ import {
30
31
  import type { GetSliceMetaObjFromDatabaseSignals } from "../types";
31
32
  import { FetchHandle } from "./fetchHandle";
32
33
  import { HttpClient } from "./httpClient";
33
- import type { ErrorConstructor, RestoredError } from "./remoteError";
34
+ import { type ErrorConstructor, type RestoredError, restoreRemoteError } from "./remoteError";
34
35
  import { SliceInitHandle } from "./sliceInitHandle";
35
36
  import { expandQueryArgs, normalizeQueryArgs } from "./sliceQueryArgs";
36
37
  import { WsClient, type WsClientOptions } from "./wsClient";
@@ -440,8 +441,8 @@ export class FetchClient {
440
441
  const payload = claim.owned ? response : FetchClient.#deepCopy(response);
441
442
  return parseReturn(payload, { crystalize: option?.crystalize ?? true });
442
443
  };
443
- case "mutation":
444
- return async (...argData: unknown[]) => {
444
+ case "mutation": {
445
+ const send = async (...argData: unknown[]) => {
445
446
  const { option, argMap, url } = requestOf(argData);
446
447
  const bridge = this.#bridgeOf(option?.origin);
447
448
  if (bridge)
@@ -458,6 +459,12 @@ export class FetchClient {
458
459
  });
459
460
  return parseReturn(response, { crystalize: option?.crystalize ?? true });
460
461
  };
462
+
463
+ return (...argData: unknown[]) => {
464
+ this.#refuseInApp(key);
465
+ return send(...argData);
466
+ };
467
+ }
461
468
  default:
462
469
  throw new Error(`Unsupported endpoint type: ${endpoint.type}`);
463
470
  }
@@ -476,6 +483,7 @@ export class FetchClient {
476
483
  const parseReturn = this.#makeReturnParser(endpoint.returns);
477
484
  const wrappedListeners = new WeakMap<(data: unknown) => void, (data: unknown) => void>();
478
485
  return (...argData: unknown[]) => {
486
+ this.#refuseInApp(key);
479
487
 
480
488
  if (FetchClient.bridged) return () => undefined;
481
489
  const args = argData.slice(0, roomArgLength);
@@ -507,6 +515,7 @@ export class FetchClient {
507
515
  const msgArgLength = msgArgs.length;
508
516
  const serializerMap = this.#makeArgSerializer(endpoint.args);
509
517
  return (...argData: unknown[]) => {
518
+ this.#refuseInApp(key);
510
519
  if (FetchClient.bridged) return;
511
520
  const args = argData.slice(0, msgArgLength);
512
521
  const fetchPolicy = argData[msgArgLength] as FetchPolicy | undefined;
@@ -536,6 +545,12 @@ export class FetchClient {
536
545
  }
537
546
  }
538
547
 
548
+ #refuseInApp(endpoint: string) {
549
+ const app = AppScope.current();
550
+ if (app)
551
+ throw restoreRemoteError({ error: "base.error.mutationInApp", data: { app, endpoint } }, 400, this.ErrorCls);
552
+ }
553
+
539
554
  #bridgeOf(origin?: string) {
540
555
  return origin || this.#foreign ? null : FetchClient.bridge;
541
556
  }
@@ -707,32 +722,34 @@ export class FetchClient {
707
722
  );
708
723
  }
709
724
 
710
- this.#setHandlerFactory(
711
- names.addModelFiles,
712
- () =>
713
- (async (fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
714
-
715
- if (this.#bridgeOf())
716
- throw new Error("File upload is unavailable in an MCP App view: its host relays tool calls, not files.");
717
- const cap = resolveFileUploadCapability(this.serializedSignal);
718
- const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
719
- if (!cap || !endpoint)
720
- throw new Error(
721
- "File upload is not configured. Mark an upload mutation with { fileUpload: true } (e.g. shared FileEndpoint.addFiles).",
722
- );
723
- const { fields, buildMetas } = fileUploadContract;
724
- const formData = new FormData();
725
- for (let i = 0; i < fileList.length; i++) formData.append(fields.files, fileList[i]);
726
- formData.append(fields.metas, JSON.stringify(buildMetas(fileList)));
727
- formData.append(fields.type, refName);
728
- if (parentId) formData.append(fields.parentId, parentId);
729
- const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
730
- return await this.http.post(url, formData, {
731
- headers: this.#makeRequestHeaders(option),
732
- baseUrl: this.#baseUrlOf(endpoint),
733
- });
734
- }) as FetchHandler,
735
- );
725
+ this.#setHandlerFactory(names.addModelFiles, () => {
726
+ const upload = async (fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
727
+
728
+ if (this.#bridgeOf())
729
+ throw new Error("File upload is unavailable in an MCP App view: its host relays tool calls, not files.");
730
+ const cap = resolveFileUploadCapability(this.serializedSignal);
731
+ const endpoint = cap ? this.serializedSignal[cap.refName]?.endpoint[cap.endpointKey] : undefined;
732
+ if (!cap || !endpoint)
733
+ throw new Error(
734
+ "File upload is not configured. Mark an upload mutation with { fileUpload: true } (e.g. shared FileEndpoint.addFiles).",
735
+ );
736
+ const { fields, buildMetas } = fileUploadContract;
737
+ const formData = new FormData();
738
+ for (let i = 0; i < fileList.length; i++) formData.append(fields.files, fileList[i]);
739
+ formData.append(fields.metas, JSON.stringify(buildMetas(fileList)));
740
+ formData.append(fields.type, refName);
741
+ if (parentId) formData.append(fields.parentId, parentId);
742
+ const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
743
+ return await this.http.post(url, formData, {
744
+ headers: this.#makeRequestHeaders(option),
745
+ baseUrl: this.#baseUrlOf(endpoint),
746
+ });
747
+ };
748
+ return ((fileList: FileList | File[], parentId?: string, option?: FetchPolicy) => {
749
+ this.#refuseInApp(names.addModelFiles);
750
+ return upload(fileList, parentId, option);
751
+ }) as FetchHandler;
752
+ });
736
753
  }
737
754
 
738
755
  #makeModelObjFn(refName: string, callerKey: string) {
package/fetch/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./agentTurn";
2
+ export * from "./appScope";
2
3
  export * from "./client";
3
4
  export * from "./fetchType";
4
5
  export * from "./requestStorage";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.21",
3
+ "version": "3.1.0-rc.23",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -14,23 +14,29 @@ import {
14
14
  import type { PagePromptEntry, PagePromptRecord, PagePromptRun } from "../../signal/mcp/pagePrompt";
15
15
  import { McpAppShell } from "./McpAppShell";
16
16
 
17
- /** The CSR shell a view boots from: one single-file HTML per basePath, read from the build that serves the web. */
17
+ /** The single-file view each `.app()` boots from, read from the build that serves the web. */
18
18
  export interface McpAppShellSource {
19
- readonly basePaths: readonly string[];
20
- /** `""` is the app without a basePath; `null` when this build has no single-file shell for it. */
21
- read(basePath: string): Promise<string | null>;
19
+ /** `null` when this build has no view for the app. */
20
+ read(app: string): Promise<string | null>;
21
+ }
22
+
23
+ interface McpAppsOption {
24
+ /** Every write the view sends is refused before it leaves, as the deployment lists no write to relay it to. */
25
+ readOnly?: boolean;
22
26
  }
23
27
 
24
28
  export class McpApps {
25
29
  static readonly scheme = "ui://";
26
-
27
- static readonly rootSegment = "app";
28
30
  static readonly #tokenClaims = new Set(["iss", "aud", "exp", "nbf", "iat", "jti", "client_id", "scope"]);
31
+
32
+ static readonly #appUri = /^ui:\/\/([^/]+)\/apps\/([A-Za-z0-9_-]{1,64})$/;
29
33
 
30
34
  readonly #shell: McpAppShellSource | null;
35
+ readonly #readOnly: boolean;
31
36
 
32
- constructor(shell: McpAppShellSource | null) {
37
+ constructor(shell: McpAppShellSource | null, { readOnly = false }: McpAppsOption = {}) {
33
38
  this.#shell = shell;
39
+ this.#readOnly = readOnly;
34
40
  }
35
41
 
36
42
  get rendersUi() {
@@ -53,18 +59,19 @@ export class McpApps {
53
59
  ]),
54
60
  );
55
61
  const required = entry.arguments.filter((arg) => arg.required).map((arg) => arg.name);
62
+ const inputSchema = {
63
+ type: "object" as const,
64
+ properties,
65
+ ...(required.length ? { required } : {}),
66
+ additionalProperties: false,
67
+ };
68
+ const tool = { name: entry.name, description: entry.description, inputSchema };
69
+
70
+ if (entry.kind === "prompt") return { ...tool, annotations: { readOnlyHint: false, openWorldHint: false } };
56
71
  return {
57
- name: entry.name,
58
- description: entry.description,
59
- inputSchema: {
60
- type: "object",
61
- properties,
62
- ...(required.length ? { required } : {}),
63
- additionalProperties: false,
64
- },
65
-
66
- annotations: { readOnlyHint: false, openWorldHint: false },
67
- ...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(this.#basePathOf(entry.pattern))) } : {}),
72
+ ...tool,
73
+ annotations: { readOnlyHint: true, openWorldHint: false },
74
+ ...(this.#shell ? { _meta: this.#toolMetaOf(this.uriOf(entry.name)) } : {}),
68
75
  };
69
76
  }
70
77
 
@@ -74,30 +81,30 @@ export class McpApps {
74
81
 
75
82
  resourcesOf(entries: PagePromptEntry[]): McpResource[] {
76
83
  if (!this.#shell) return [];
77
- const { appName } = getEnv();
78
- const basePaths = [...new Set(entries.map((entry) => this.#basePathOf(entry.pattern)))];
79
- return basePaths.map((basePath) => ({
80
- uri: this.uriOf(basePath),
81
- name: basePath || appName,
82
- description: `The ${basePath || appName} screens, rendered in place of a tool result.`,
83
- mimeType: MCP_UI_MIME_TYPE,
84
- }));
84
+ return entries
85
+ .filter((entry) => entry.kind === "app")
86
+ .map((entry) => ({
87
+ uri: this.uriOf(entry.name),
88
+ name: entry.name,
89
+ description: entry.description,
90
+ mimeType: MCP_UI_MIME_TYPE,
91
+ }));
85
92
  }
86
93
 
87
- uriOf(basePath: string) {
88
- const root = `${McpApps.scheme}${getEnv().appName.toLowerCase()}/${McpApps.rootSegment}`;
89
- return basePath ? `${root}/${basePath}` : root;
94
+ uriOf(app: string) {
95
+ return `${McpApps.scheme}${getEnv().appName.toLowerCase()}/apps/${app}`;
90
96
  }
91
97
 
92
98
  async read(uri: string, origin: string): Promise<McpResourceContents | null> {
93
- const shell = this.#shell;
94
- const basePath = shell ? this.#basePathOfUri(uri) : null;
95
- if (!shell || basePath === null) return null;
96
- const html = await shell.read(basePath);
99
+ const match = McpApps.#appUri.exec(uri);
100
+ const app = match?.[1]?.toLowerCase() === getEnv().appName.toLowerCase() ? match[2] : undefined;
101
+ if (!this.#shell || !app) return null;
102
+ const html = await this.#shell.read(app);
97
103
  return html ? McpAppShell.contentsOf(uri, html, origin) : null;
98
104
  }
99
105
 
100
106
  resultOf(
107
+ entry: PagePromptEntry,
101
108
  run: Extract<PagePromptRun, { ok: true }>,
102
109
  messages: PromptMessage[],
103
110
  webOrigin: string,
@@ -107,14 +114,15 @@ export class McpApps {
107
114
  const href = `${url.pathname}${url.search}`;
108
115
  const content: PromptContent[] = [
109
116
  ...messages.map((message) => message.content),
110
- { type: "text", text: `Open this screen in a browser: ${webOrigin}${href}` },
117
+ { type: "text", text: `Open the full screen: ${webOrigin}${href}` },
111
118
  ];
112
- if (!this.#shell || !forView) return { content, isError: false };
119
+ if (entry.kind === "prompt" || !this.#shell || !forView) return { content, isError: false };
113
120
  const _meta = {
114
121
  [MCP_APP_META.snapshot]: McpApps.#snapshotOf(run.records),
115
122
  [MCP_APP_META.account]: forView.account,
123
+ ...(this.#readOnly ? { [MCP_APP_META.readOnly]: true } : {}),
116
124
  };
117
- return { content, structuredContent: { href }, _meta, isError: false };
125
+ return { content, structuredContent: { href, ...run.route }, _meta, isError: false };
118
126
  }
119
127
 
120
128
  static accountOf(req: Request): Record<string, unknown> | null {
@@ -128,20 +136,6 @@ export class McpApps {
128
136
  }
129
137
  }
130
138
 
131
- #basePathOf(pattern: string) {
132
- const [, , segment] = pattern.split("/");
133
- return segment && this.#shell?.basePaths.includes(segment) ? segment : "";
134
- }
135
-
136
- #basePathOfUri(uri: string) {
137
- const match = /^ui:\/\/([^/]+)\/([^?#]*)$/i.exec(uri);
138
- if (!match || match[1]?.toLowerCase() !== getEnv().appName.toLowerCase()) return null;
139
- const [root, basePath, ...rest] = (match[2] ?? "").split("/");
140
- if (root !== McpApps.rootSegment || rest.length) return null;
141
- if (!basePath) return "";
142
- return this.#shell?.basePaths.includes(basePath) ? basePath : null;
143
- }
144
-
145
139
  static #snapshotOf(records: PagePromptRecord[]): McpAppSnapshotRecord[] {
146
140
  return records.flatMap(({ key, args, value }) => (value === undefined ? [] : [{ key, args, value }]));
147
141
  }