akanjs 3.0.0-alpha.71 → 3.0.0-alpha.72

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 (186) hide show
  1. package/client/cookie.ts +21 -34
  2. package/client/csrTypes.ts +0 -12
  3. package/client/frameConfig.ts +13 -0
  4. package/common/Logger.ts +13 -6
  5. package/common/TrustedProxy.ts +134 -0
  6. package/common/clamp.ts +2 -0
  7. package/common/dynamicRecord.ts +12 -0
  8. package/common/index.ts +5 -1
  9. package/common/isValidDate.ts +1 -1
  10. package/common/requestOrigin.ts +32 -0
  11. package/common/{httpClient.ts → restClient.ts} +16 -9
  12. package/constant/types.ts +22 -0
  13. package/dictionary/base.dictionary.ts +4 -0
  14. package/dictionary/trans.ts +24 -5
  15. package/document/dataLoader.ts +0 -4
  16. package/document/filterMeta.ts +2 -2
  17. package/document/loaderInfo.ts +4 -1
  18. package/fetch/client/fetchClient.ts +65 -88
  19. package/fetch/client/fetchHandle.ts +57 -0
  20. package/fetch/client/httpClient.ts +42 -14
  21. package/fetch/client/sliceInitHandle.ts +101 -0
  22. package/fetch/fetchType/appliedReturn.type.ts +64 -1
  23. package/fetch/fetchType/sliceFetch.type.ts +5 -5
  24. package/fetch/requestStorage.ts +19 -5
  25. package/index.ts +16 -2
  26. package/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
  27. package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
  28. package/package.json +1 -1
  29. package/server/akanAppHeaders.ts +10 -4
  30. package/server/akanOption.ts +14 -1
  31. package/server/akanServer.ts +3 -0
  32. package/server/cachePolicy.ts +2 -11
  33. package/server/decorators.ts +12 -3
  34. package/server/devtools/constantSerializer.ts +8 -1
  35. package/server/di/diLifecycle.ts +21 -1
  36. package/server/di/resolveHierarchy.ts +3 -3
  37. package/server/mcp/McpDispatcher.ts +8 -2
  38. package/server/resolver/database.resolver.ts +13 -8
  39. package/server/resolver/signal.resolver.ts +3 -3
  40. package/server/routeTreeBuilder.ts +1 -15
  41. package/server/routing/apiRouter.ts +4 -5
  42. package/server/routing/appWsData.ts +9 -6
  43. package/server/staticPath.ts +39 -0
  44. package/server/webRouter.ts +35 -41
  45. package/service/adapt.ts +1 -5
  46. package/service/predefinedAdaptor/cache.adaptor.ts +8 -4
  47. package/service/predefinedAdaptor/database.adaptor.ts +37 -1766
  48. package/service/predefinedAdaptor/sql/PendingStoreEnsures.ts +22 -0
  49. package/service/predefinedAdaptor/sql/QueryCompiler.ts +225 -0
  50. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +812 -0
  51. package/service/predefinedAdaptor/sql/UpdateCompiler.ts +67 -0
  52. package/service/predefinedAdaptor/sql/dialect/postgres.ts +151 -0
  53. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +134 -0
  54. package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -0
  55. package/service/predefinedAdaptor/sql/driver/libsql.ts +37 -0
  56. package/service/predefinedAdaptor/sql/driver/postgres.ts +42 -0
  57. package/service/predefinedAdaptor/sql/types.ts +251 -0
  58. package/service/predefinedAdaptor/sql/values.ts +91 -0
  59. package/signal/CrossSiteGuard.ts +71 -0
  60. package/signal/SignalFailure.ts +51 -0
  61. package/signal/endpoint.ts +2 -2
  62. package/signal/exception.ts +43 -6
  63. package/signal/index.ts +2 -0
  64. package/signal/intercept.ts +5 -2
  65. package/signal/internal.ts +10 -2
  66. package/signal/serializer/fetch.serializer.ts +11 -1
  67. package/signal/signalContext.ts +25 -28
  68. package/signal/slice.ts +1 -1
  69. package/store/SliceRequest.ts +23 -0
  70. package/store/action.ts +177 -114
  71. package/store/agent/AgentBridge.ts +3 -1
  72. package/store/agentic/StExposeBuilder.ts +7 -4
  73. package/store/agentic/StStateBuilder.ts +9 -4
  74. package/store/agentic/StToolBuilder.ts +21 -7
  75. package/store/store.ts +1 -1
  76. package/store/storeInstance.ts +3 -3
  77. package/store/storeRegistry.ts +1 -1
  78. package/types/client/cookie.d.ts +13 -2
  79. package/types/client/csrTypes.d.ts +0 -1
  80. package/types/client/frameConfig.d.ts +1 -0
  81. package/types/common/TrustedProxy.d.ts +24 -0
  82. package/types/common/clamp.d.ts +2 -0
  83. package/types/common/dynamicRecord.d.ts +14 -0
  84. package/types/common/index.d.ts +5 -1
  85. package/types/common/requestOrigin.d.ts +14 -0
  86. package/types/common/restClient.d.ts +27 -0
  87. package/types/constant/types.d.ts +13 -0
  88. package/types/dictionary/base.dictionary.d.ts +1 -1
  89. package/types/dictionary/dictionary.d.ts +8 -8
  90. package/types/document/dataLoader.d.ts +0 -3
  91. package/types/document/filterMeta.d.ts +2 -2
  92. package/types/document/loaderInfo.d.ts +2 -2
  93. package/types/fetch/client/fetchHandle.d.ts +24 -0
  94. package/types/fetch/client/httpClient.d.ts +2 -0
  95. package/types/fetch/client/sliceInitHandle.d.ts +29 -0
  96. package/types/fetch/fetchType/appliedReturn.type.d.ts +38 -1
  97. package/types/fetch/fetchType/sliceFetch.type.d.ts +5 -5
  98. package/types/fetch/requestStorage.d.ts +14 -0
  99. package/types/index.d.ts +17 -2
  100. package/types/server/akanOption.d.ts +8 -1
  101. package/types/server/decorators.d.ts +12 -2
  102. package/types/server/di/diLifecycle.d.ts +9 -1
  103. package/types/server/routeTreeBuilder.d.ts +1 -2
  104. package/types/server/routing/appWsData.d.ts +7 -3
  105. package/types/server/staticPath.d.ts +13 -0
  106. package/types/service/adapt.d.ts +1 -1
  107. package/types/service/predefinedAdaptor/database.adaptor.d.ts +15 -407
  108. package/types/service/predefinedAdaptor/sql/PendingStoreEnsures.d.ts +6 -0
  109. package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +28 -0
  110. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +127 -0
  111. package/types/service/predefinedAdaptor/sql/UpdateCompiler.d.ts +17 -0
  112. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +27 -0
  113. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +27 -0
  114. package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +16 -0
  115. package/types/service/predefinedAdaptor/sql/driver/libsql.d.ts +17 -0
  116. package/types/service/predefinedAdaptor/sql/driver/postgres.d.ts +17 -0
  117. package/types/service/predefinedAdaptor/sql/types.d.ts +226 -0
  118. package/types/service/predefinedAdaptor/sql/values.d.ts +13 -0
  119. package/types/signal/CrossSiteGuard.d.ts +44 -0
  120. package/types/signal/SignalFailure.d.ts +28 -0
  121. package/types/signal/exception.d.ts +38 -54774
  122. package/types/signal/index.d.ts +2 -0
  123. package/types/signal/intercept.d.ts +3 -3
  124. package/types/signal/internal.d.ts +5 -1
  125. package/types/signal/serializer/fetch.serializer.d.ts +7 -1
  126. package/types/store/SliceRequest.d.ts +16 -0
  127. package/types/store/agentic/StToolBuilder.d.ts +10 -1
  128. package/types/ui/Agent/Menu.d.ts +1 -1
  129. package/types/ui/Field/Choice.d.ts +52 -0
  130. package/types/ui/Field/Collection.d.ts +51 -0
  131. package/types/ui/Field/DateField.d.ts +32 -0
  132. package/types/ui/Field/Label.d.ts +9 -0
  133. package/types/ui/Field/NumberField.d.ts +43 -0
  134. package/types/ui/Field/Relation.d.ts +80 -0
  135. package/types/ui/Field/Text.d.ts +101 -0
  136. package/types/ui/Field/index.d.ts +50 -0
  137. package/types/ui/Field.d.ts +1 -412
  138. package/types/ui/Load/Edit_Client.d.ts +3 -1
  139. package/types/ui/Load/Stream.d.ts +22 -0
  140. package/types/ui/Load/index.d.ts +2 -0
  141. package/types/ui/System/Root.d.ts +4 -0
  142. package/types/ui/index.d.ts +1 -1
  143. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  144. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  145. package/types/webkit/useCamera.d.ts +10 -1
  146. package/types/webkit/usePurchase.d.ts +4 -0
  147. package/ui/Agent/Menu.tsx +2 -2
  148. package/ui/Data/TableList.tsx +1 -1
  149. package/ui/DraggableList.tsx +1 -2
  150. package/ui/Field/Choice.tsx +172 -0
  151. package/ui/Field/Collection.tsx +293 -0
  152. package/ui/Field/DateField.tsx +138 -0
  153. package/ui/Field/Label.tsx +31 -0
  154. package/ui/Field/NumberField.tsx +169 -0
  155. package/ui/Field/Relation.tsx +387 -0
  156. package/ui/Field/Text.tsx +360 -0
  157. package/ui/Field/index.tsx +70 -0
  158. package/ui/Input.tsx +3 -3
  159. package/ui/Load/Edit.tsx +2 -2
  160. package/ui/Load/Edit_Client.tsx +11 -3
  161. package/ui/Load/Pagination.tsx +25 -25
  162. package/ui/Load/Stream.tsx +38 -0
  163. package/ui/Load/Units.tsx +50 -37
  164. package/ui/Load/View.tsx +25 -17
  165. package/ui/Load/index.ts +2 -0
  166. package/ui/Model/EditModal.tsx +7 -7
  167. package/ui/Signal/Message.tsx +7 -7
  168. package/ui/Signal/PubSub.tsx +4 -1
  169. package/ui/System/Client.tsx +0 -2
  170. package/ui/System/Gtag.tsx +1 -1
  171. package/ui/System/Root.tsx +4 -0
  172. package/ui/index.ts +1 -1
  173. package/ui/overlayPosition.ts +1 -2
  174. package/webkit/useCamera.tsx +22 -6
  175. package/webkit/useCodepush.tsx +2 -2
  176. package/webkit/useCsrValues.ts +1 -3
  177. package/webkit/useFetch.ts +4 -3
  178. package/webkit/useFrameRuntime.ts +14 -7
  179. package/webkit/usePurchase.tsx +4 -1
  180. package/fetch/serializer/fetch.serializer.ts +0 -142
  181. package/fetch/serializer/index.ts +0 -1
  182. package/types/common/httpClient.d.ts +0 -20
  183. package/types/fetch/serializer/fetch.serializer.d.ts +0 -14
  184. package/types/fetch/serializer/index.d.ts +0 -1
  185. package/types/fetch/serializer.d.ts +0 -1
  186. package/ui/Field.tsx +0 -1639
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.71",
3
+ "version": "3.0.0-alpha.72",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,4 +1,4 @@
1
- import { normalizeIpAddress } from "akanjs/common";
1
+ import { TrustedProxy } from "akanjs/common";
2
2
 
3
3
  const HOP_BY_HOP_HEADERS = new Set([
4
4
  "connection",
@@ -23,10 +23,16 @@ export function makeAkanChildProxyHeaders(req: Request, childIdx: number, peer?:
23
23
  for (const key of HOP_BY_HOP_HEADERS) headers.delete(key);
24
24
  const forwardedFor = headers.get("x-forwarded-for");
25
25
 
26
- const clientAddress = headers.get("x-real-ip") ?? (peer ? normalizeIpAddress(peer.address) : "127.0.0.1");
26
+ const clientAddress = TrustedProxy.clientAddress(headers, peer?.address);
27
27
  const host = headers.get("host");
28
- headers.set("x-real-ip", clientAddress);
29
- headers.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${clientAddress}` : clientAddress);
28
+
29
+ if (clientAddress) {
30
+ headers.set("x-real-ip", clientAddress);
31
+ headers.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${clientAddress}` : clientAddress);
32
+ } else {
33
+ headers.delete("x-real-ip");
34
+ headers.delete("x-forwarded-for");
35
+ }
30
36
  if (peer && !headers.has("x-forwarded-port")) headers.set("x-forwarded-port", String(peer.port));
31
37
  headers.set("x-forwarded-host", headers.get("x-forwarded-host") ?? host ?? new URL(req.url).host);
32
38
  headers.set(
@@ -1,6 +1,6 @@
1
1
  import type { BackendEnv, PromiseOrObject } from "akanjs/base";
2
2
  import type { Adaptor, AdaptorCls, LlmOption } from "akanjs/service";
3
- import type { GuardCls, MiddlewareCls } from "akanjs/signal";
3
+ import type { CrossSiteOption, GuardCls, MiddlewareCls } from "akanjs/signal";
4
4
  import type { McpServerOption } from "./akanServer";
5
5
  import type { WebProxyRegistration } from "./proxy";
6
6
  import { HostBasePathWebProxy, LocaleWebProxy } from "./proxy";
@@ -22,6 +22,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
22
22
  readonly #getLlms: ((env: Env) => LlmOption)[] = [];
23
23
  #mcp: boolean | McpServerOption | undefined;
24
24
  #agentAccess: GuardCls | GuardCls[] | null | undefined;
25
+ #crossSite: CrossSiteOption | undefined;
25
26
  constructor() {
26
27
  this.#getUses = [];
27
28
  }
@@ -62,6 +63,15 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
62
63
  this.#agentAccess = guards;
63
64
  return this;
64
65
  }
66
+ /**
67
+ * Which other origins may drive a mutation, on top of the one serving the request and the native shells.
68
+ * Needed only by a browser client hosted somewhere else — a separate admin domain, a partner embed. The gate
69
+ * itself is on by default and `{ enabled: false }` is for an API no browser reaches.
70
+ */
71
+ setCrossSite(crossSite: CrossSiteOption) {
72
+ this.#crossSite = crossSite;
73
+ return this;
74
+ }
65
75
  /** Settings for whichever adaptor fills `LlmAdaptorRole`, injected into it as the `llmOption` use. */
66
76
  setLlm(llmOrFn: LlmOption | ((env: Env) => LlmOption)) {
67
77
  if (typeof llmOrFn === "function") this.#getLlms.push(llmOrFn);
@@ -87,6 +97,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
87
97
  getAgentAccess(): GuardCls | GuardCls[] | null | undefined {
88
98
  return this.#agentAccess;
89
99
  }
100
+ getCrossSite(): CrossSiteOption | undefined {
101
+ return this.#crossSite;
102
+ }
90
103
  getLlm(env: Env): LlmOption {
91
104
  return Object.assign({}, ...this.#getLlms.map((fn) => fn(env)));
92
105
  }
@@ -13,6 +13,7 @@ import type {
13
13
  SolidConfig,
14
14
  } from "akanjs/service";
15
15
  import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs/signal";
16
+ import { CrossSiteGuard } from "../signal/CrossSiteGuard";
16
17
  import { AgentRelayAccess } from "../signal/guards";
17
18
  import { createOpenApiDocument } from "../signal/openapi";
18
19
  import { FetchSerializer } from "../signal/serializer";
@@ -198,6 +199,8 @@ export class AkanServer {
198
199
  if (mcp !== undefined) this.setMcp(mcp);
199
200
  const agentAccess = lib.option.getAgentAccess();
200
201
  if (agentAccess !== undefined) AgentRelayAccess.use(agentAccess);
202
+ const crossSite = lib.option.getCrossSite();
203
+ if (crossSite !== undefined) CrossSiteGuard.configure(crossSite);
201
204
  });
202
205
  this.setMcp(options?.mcp ?? this.mcp);
203
206
  this.serverMode = serverMode;
@@ -1,3 +1,4 @@
1
+ import { originFromRequest } from "akanjs/common";
1
2
  import { type AkanDynamicUsage, type AkanRequestPolicy, parseCookieHeader } from "akanjs/fetch";
2
3
 
3
4
  export const DEFAULT_ROUTE_CACHE_TTL_SECONDS = 30;
@@ -93,17 +94,7 @@ export function combineMinRevalidate(...values: Array<number | false | null | un
93
94
  }
94
95
 
95
96
  export function getClientFacingOrigin(request: Request, url = new URL(request.url)): string {
96
- const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
97
- const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
98
- const host = forwardedHost ?? request.headers.get("host")?.split(",")[0]?.trim();
99
- const proto = forwardedProto ?? url.protocol.slice(0, -1);
100
- if (host && proto) {
101
- try {
102
- return new URL(`${proto}://${host}`).origin;
103
- } catch {
104
- }
105
- }
106
- return url.origin;
97
+ return originFromRequest(request.headers, url);
107
98
  }
108
99
 
109
100
  export function isPublicRouteCacheableRequest(request: Request): boolean {
@@ -1,4 +1,8 @@
1
-
1
+ /**
2
+ * Method decorators for a server class: `@Transaction`, `@Try`, `@CacheMethod`. Kept because `libs/util`'s
3
+ * storage adaptors use `@Try` on their remote calls; new code follows the adaptor rule instead
4
+ * (`catch` → `logger.error` → `return null`).
5
+ */
2
6
 
3
7
  type DecoratedInstance = {
4
8
  logger?: { warn?: (message: string) => void; trace?: (message: string) => void };
@@ -50,8 +54,13 @@ export const Transaction = (): MethodDecorator => {
50
54
  }) as MethodDecorator;
51
55
  };
52
56
 
53
- /** Method decorator that caches method results for the given timeout in milliseconds. */
54
- export const Cache = (timeout = 1000, getCacheKey?: (...args: unknown[]) => string): MethodDecorator => {
57
+ /**
58
+ * Method decorator that caches method results for the given timeout in milliseconds.
59
+ *
60
+ * Not `Cache` — `akanjs/signal` exports a `Cache` **middleware class**, and an app importing both barrels got
61
+ * one name meaning two things.
62
+ */
63
+ export const CacheMethod = (timeout = 1000, getCacheKey?: (...args: unknown[]) => string): MethodDecorator => {
55
64
  return ((_target: unknown, key: string, descriptor: PropertyDescriptor) => {
56
65
  const originMethod = descriptor.value as (this: unknown, ...args: unknown[]) => unknown;
57
66
  const cacheMap = new Map<string, unknown>();
@@ -66,7 +66,14 @@ export class ConstantSerializer {
66
66
  values[key] = DevtoolsJson.toSafe(value);
67
67
  }
68
68
 
69
- return { models, scalars, enums, values, primitives: PrimitiveRegistry.getNames().sort(), relations };
69
+ return {
70
+ models,
71
+ scalars,
72
+ enums,
73
+ values,
74
+ primitives: PrimitiveRegistry.getNames().sort((a, b) => a.localeCompare(b)),
75
+ relations,
76
+ };
70
77
  }
71
78
 
72
79
  static #serializeView(modelRef: ConstantCls | undefined, modelType: ConstantType): ConstantModelView {
@@ -267,8 +267,28 @@ export class DiLifecycle {
267
267
  return selected;
268
268
  }
269
269
 
270
- /** Run every init stage in dependency order and collect the generated routes. */
270
+ /**
271
+ * Run every init stage in dependency order and collect the generated routes.
272
+ *
273
+ * A stage runs its tasks in parallel and reports every failure, which means the ones that *succeeded*
274
+ * alongside a failure are live: connections opened, timers armed, `onInit` done. The error then propagates and
275
+ * the process usually exits, so this rarely mattered — but a caller that catches and retries (a test harness,
276
+ * a dev restart) accumulated them. `destroyAll` already walks the stages in reverse and skips what was never
277
+ * registered, so the wind-down is the one that already exists.
278
+ */
271
279
  async initializeAll(): Promise<SignalRoutes> {
280
+ try {
281
+ return await this.#initializeAll();
282
+ } catch (error) {
283
+ await this.destroyAll().catch((destroyError: unknown) => {
284
+
285
+ this.logger.warn(`Failed to unwind a partial init: ${reasonMessage(destroyError)}`);
286
+ });
287
+ throw error;
288
+ }
289
+ }
290
+
291
+ async #initializeAll(): Promise<SignalRoutes> {
272
292
  await this.#initializeUses();
273
293
  await this.#initializeAdaptor();
274
294
  await this.#initializeServerSignal();
@@ -37,8 +37,8 @@ export function topologicalStages<T extends DependencyNode>(graph: Map<string, T
37
37
 
38
38
  const next: string[] = [];
39
39
  for (const key of queue) {
40
- for (const dependent of dependents.get(key)!) {
41
- const newDegree = inDegree.get(dependent)! - 1;
40
+ for (const dependent of dependents.get(key) ?? []) {
41
+ const newDegree = (inDegree.get(dependent) ?? 0) - 1;
42
42
  inDegree.set(dependent, newDegree);
43
43
  if (newDegree === 0) next.push(dependent);
44
44
  }
@@ -47,7 +47,7 @@ export function topologicalStages<T extends DependencyNode>(graph: Map<string, T
47
47
  }
48
48
 
49
49
  if (processed !== graph.size) {
50
- const remaining = [...graph.keys()].filter((k) => inDegree.get(k)! > 0);
50
+ const remaining = [...graph.keys()].filter((k) => (inDegree.get(k) ?? 0) > 0);
51
51
  const cycle = traceCycle(graph, remaining);
52
52
  throw new Error(`Circular dependency detected: ${cycle.join(" → ")}`);
53
53
  }
@@ -6,6 +6,7 @@ import { NoDocumentError } from "akanjs/document";
6
6
  import type { InjectRegistry, LiveRegistry } from "akanjs/service";
7
7
  import type { Endpoint, EndpointCls } from "../../signal/endpoint";
8
8
  import type { EndpointInfo } from "../../signal/endpointInfo";
9
+ import { Exception } from "../../signal/exception";
9
10
  import type { GuardCls } from "../../signal/guard";
10
11
  import {
11
12
  McpDocument,
@@ -277,9 +278,14 @@ export class McpDispatcher {
277
278
  if (!modelType) return value;
278
279
  try {
279
280
  return mask(ConstantRegistry.getModelRef(refName, modelType), value);
280
- } catch {
281
+ } catch (error) {
281
282
 
282
- return value;
283
+ McpDispatcher.logger.error(
284
+ `MCP could not mask a "${refName}" (${modelType}) result, so it was not sent: ${
285
+ error instanceof Error ? error.message : String(error)
286
+ }`,
287
+ );
288
+ throw new Exception.Error("The server failed to complete this request.");
283
289
  }
284
290
  }
285
291
 
@@ -1,6 +1,6 @@
1
1
  import type { PromiseOrObject } from "akanjs/base";
2
2
  import { applyMixins, capitalize } from "akanjs/common";
3
- import { type ConstantModel, DEFAULT_PAGE_SIZE, type QueryOf } from "akanjs/constant";
3
+ import { type ConstantModel, type QueryOf, resolvePageLimit, resolvePageSkip } from "akanjs/constant";
4
4
  import {
5
5
  assertFilterFitsCrud,
6
6
  CacheDatabase,
@@ -32,7 +32,7 @@ import {
32
32
  DatabaseAdaptorRole,
33
33
  type DocumentStore,
34
34
  } from "akanjs/service";
35
- import { getCurrentTrace, traceDataLoaderBatch } from "akanjs/signal";
35
+ import { Exception, getCurrentTrace, traceDataLoaderBatch } from "akanjs/signal";
36
36
 
37
37
  /**
38
38
  * Times a store query and records it against the active request trace (no-op when
@@ -57,13 +57,18 @@ export class DatabaseResolver {
57
57
  ): { adaptor: AdaptorCls<DatabaseInstance>; schema: DocumentSchema } {
58
58
  const [modelName, className]: [string, string] = [database.refName, capitalize(database.refName)];
59
59
 
60
- const resolveSort = (sortKey?: string | null) =>
61
- sortKey ? (getFilterSortByKey(database.filter, sortKey) as { [key: string]: 1 | -1 }) : null;
60
+ const resolveSort = (sortKey?: string | null): { [key: string]: 1 | -1 } | null => {
61
+ if (!sortKey) return null;
62
+ const sort = getFilterSortByKey(database.filter, sortKey) as { [key: string]: 1 | -1 } | undefined;
63
+ if (!sort) throw new Exception.BadRequest(`Unknown sort key for ${modelName}: ${sortKey}`);
64
+ return sort;
65
+ };
62
66
  const getListQuery = (query?: QueryOf<any>, queryOption?: ListQueryOption) => {
63
67
  const find = query ?? {};
64
68
  const sort = resolveSort(queryOption?.sort);
65
- const skip = Number(queryOption?.skip ?? 0);
66
- const limit = queryOption?.limit === null ? DEFAULT_PAGE_SIZE : Number(queryOption?.limit ?? 0);
69
+ const skip = resolvePageSkip(queryOption?.skip);
70
+
71
+ const limit = queryOption?.limit === undefined ? 0 : resolvePageLimit(queryOption.limit);
67
72
  const select = queryOption?.select;
68
73
  const sample = queryOption?.sample;
69
74
  return { find, sort, skip, limit, select, sample };
@@ -71,7 +76,7 @@ export class DatabaseResolver {
71
76
  const getFindQuery = (query?: QueryOf<any>, queryOption?: FindQueryOption) => {
72
77
  const find = query ?? {};
73
78
  const sort = resolveSort(queryOption?.sort);
74
- const skip = Number(queryOption?.skip ?? 0);
79
+ const skip = resolvePageSkip(queryOption?.skip);
75
80
  const select = queryOption?.select;
76
81
  const sample = queryOption?.sample ?? false;
77
82
  return { find, sort, skip, select, sample };
@@ -316,7 +321,7 @@ export class DatabaseResolver {
316
321
  return this.__get(id);
317
322
  }
318
323
  async __load(id?: string) {
319
- return (id ? await this.__loader.load(id) : null) as any | null;
324
+ return (id ? await this.__loader.load(id) : null) as unknown;
320
325
  }
321
326
  async [`load${className}`](id?: string) {
322
327
  return this.__load(id);
@@ -13,7 +13,7 @@ import {
13
13
  SLICE_META,
14
14
  } from "akanjs/base";
15
15
  import { capitalize, Logger } from "akanjs/common";
16
- import { deserialize, serialize } from "akanjs/constant";
16
+ import { deserialize, resolvePageLimit, resolvePageSkip, serialize } from "akanjs/constant";
17
17
  import { documentQueryHelper } from "akanjs/document";
18
18
  import {
19
19
  type AkanJob,
@@ -275,8 +275,8 @@ export class SignalResolver {
275
275
  ._addInternalArgs(sliceInfo.internalArgs)
276
276
  .exec(async function (this: any, ...requestArgs: any) {
277
277
  const args = requestArgs.slice(0, argLength);
278
- const skip = Number(requestArgs[argLength] ?? 0);
279
- const limit = Number(requestArgs[argLength + 1] ?? 20);
278
+ const skip = resolvePageSkip(requestArgs[argLength]);
279
+ const limit = resolvePageLimit(requestArgs[argLength + 1]);
280
280
  const sort = requestArgs[argLength + 2] ?? "latest";
281
281
  const internalArgs = requestArgs.slice(argLength + 3);
282
282
  const query = assertSliceQuery(
@@ -3,7 +3,6 @@ import type {
3
3
  LayoutModule,
4
4
  LayoutProps,
5
5
  PageProps,
6
- PageState,
7
6
  PathRoute,
8
7
  ResolveHead,
9
8
  Route,
@@ -20,26 +19,13 @@ import {
20
19
  routeSegmentToTreePath,
21
20
  } from "akanjs/common";
22
21
  import { createElement } from "react";
23
- import { validatePageConfig } from "../client/frameConfig";
22
+ import { defaultPageState, validatePageConfig } from "../client/frameConfig";
24
23
  import { resolveHeadExport, resolveMetadataHead } from "./metadata";
25
24
 
26
25
  type RouteModuleKindWithOverrides = "page" | "layout" | "overrides";
27
26
 
28
27
  export type PagesContext = Record<string, () => Promise<RouteModule>>;
29
28
 
30
- export const defaultPageState: PageState = {
31
- transition: "none",
32
- topSafeArea: 0,
33
- bottomSafeArea: 0,
34
- topInset: 0,
35
- bottomInset: 0,
36
- gesture: true,
37
- cache: false,
38
- ssr: "stream",
39
- topSafeAreaColor: "transparent",
40
- bottomSafeAreaColor: "transparent",
41
- };
42
-
43
29
  export interface RouteModuleCacheStats {
44
30
  moduleCount: number;
45
31
  loadedModuleCount: number;
@@ -1,7 +1,7 @@
1
1
  import { dayjs } from "akanjs/base";
2
2
  import { type Logger, websocketAuthContract } from "akanjs/common";
3
3
  import type { InjectRegistry } from "akanjs/service";
4
- import { Exception, SignalContext, type WebsocketReqData } from "akanjs/signal";
4
+ import { Exception, SignalContext, SignalFailure, type WebsocketReqData } from "akanjs/signal";
5
5
  import { compressResponse } from "../contentEncoding";
6
6
  import type { HmrWsData, HmrWsHub } from "../hmr/wsHub";
7
7
  import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
@@ -166,12 +166,11 @@ export class ApiRouter {
166
166
  ws.send(JSON.stringify({ ...error.toJSON(), timestamp: new Date().toISOString() }));
167
167
  return;
168
168
  }
169
- const errMsg = error instanceof Error ? error.message : String(error);
170
169
  if (!SignalContext.wasReported(error)) {
171
- logger.error(errMsg);
172
- console.error(error);
170
+ logger.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
173
171
  }
174
- ws.send(JSON.stringify({ error: errMsg, statusCode: 500, timestamp: new Date().toISOString(), at: dayjs() }));
172
+
173
+ ws.send(JSON.stringify({ ...SignalFailure.body(error), at: dayjs() }));
175
174
  }
176
175
  },
177
176
  close: (ws) => {
@@ -1,4 +1,4 @@
1
- import { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, normalizeIpAddress } from "akanjs/common";
1
+ import { clientAddressFromHeaders, clientPortFromHeaders, forwardedHeaders, TrustedProxy } from "akanjs/common";
2
2
 
3
3
  const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
4
4
 
@@ -36,14 +36,11 @@ export class AppWsData {
36
36
  else data.headers.delete("cookie");
37
37
  }
38
38
  data.account = undefined;
39
- data.resolvedAuthorization = undefined;
40
39
  }
41
40
  createdAt: number;
42
41
  headers: Headers;
43
42
  cookies: Bun.CookieMap;
44
43
  account?: unknown;
45
- /** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
46
- resolvedAuthorization?: string;
47
44
  /**
48
45
  * Identity of this connection, minted here so every app socket carries one from its first frame and
49
46
  * adaptors and endpoints only ever read it. Per-connection and process-local — a reconnect gets a new
@@ -51,7 +48,13 @@ export class AppWsData {
51
48
  * It outlives a credential swap on purpose: the socket is still the same socket.
52
49
  */
53
50
  socketId: string;
54
- /** The caller's address as the nearest proxy recorded it, or null when nothing did. */
51
+ /**
52
+ * The caller's address as the nearest proxy recorded it, or null when nothing did.
53
+ *
54
+ * The handshake headers alone cannot say whether a proxy wrote them or the client did, so this trusts them —
55
+ * a socket that reached a child came through the gateway, which already resolved the question. `ipOf` is what
56
+ * an endpoint should read: it has the peer, so it can decide.
57
+ */
55
58
  get ip(): string | null {
56
59
  return clientAddressFromHeaders(this.headers);
57
60
  }
@@ -61,7 +64,7 @@ export class AppWsData {
61
64
  }
62
65
  /** The address to answer on: what a proxy recorded, else this socket's own peer. */
63
66
  ipOf(ws: Bun.ServerWebSocket<unknown>): string | null {
64
- return this.ip ?? (ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null);
67
+ return TrustedProxy.clientAddress(this.headers, ws.remoteAddress);
65
68
  }
66
69
  constructor(headers: Headers) {
67
70
  this.createdAt = Date.now();
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * The file a URL path names inside one directory, or `null` when it names something outside it.
6
+ *
7
+ * Every static route the web router serves goes through here, so this is the single place a request path stops
8
+ * being a string and becomes a file. The order matters: percent-decoding first (so `%2e%2e` is seen as `..`),
9
+ * then a NUL check (a truncating byte in a path), then `path.resolve` to collapse the traversal, then
10
+ * containment against the base **with a separator** — without it `/public-secrets` passes a `/public` check.
11
+ *
12
+ * `path.resolve` works on the string and does not follow links, so a symlink inside the tree can still point
13
+ * out of it. Callers check existence after this, so a path that resolves to nothing is handed back and 404s
14
+ * there; only a link that really leads outside is refused.
15
+ */
16
+ export const resolveStaticPath = (baseDir: string, urlPath: string): string | null => {
17
+ let decoded: string;
18
+ try {
19
+ decoded = decodeURIComponent(urlPath);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (decoded.includes("\0")) return null;
24
+ const normalizedBase = path.resolve(baseDir);
25
+ const rel = decoded.replace(/^[/\\]+/, "");
26
+ const resolved = path.resolve(normalizedBase, rel);
27
+ if (resolved === normalizedBase) return resolved;
28
+ const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
29
+ if (!resolved.startsWith(baseWithSep)) return null;
30
+ try {
31
+
32
+ const realBase = fs.realpathSync(normalizedBase);
33
+ const realBaseWithSep = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
34
+ const real = fs.realpathSync(resolved);
35
+ if (real !== realBase && !real.startsWith(realBaseWithSep)) return null;
36
+ } catch {
37
+ }
38
+ return resolved;
39
+ };
@@ -55,6 +55,7 @@ import { type RscRedirectMethod, type RscRedirectStatus, type RscRenderResult, R
55
55
  import { createDefaultSitemapXml, getSitemapBasePath } from "./sitemap";
56
56
  import { SsrFromRscRenderer } from "./ssrFromRscRenderer";
57
57
  import type { RscTraceMetadata, SsrManifest } from "./ssrTypes";
58
+ import { resolveStaticPath } from "./staticPath";
58
59
  import { createSubRouteIndexResponse, createSystemPageResponse, getSystemPageHomeHref } from "./systemPages";
59
60
  import { type BaseBuildArtifact, type HttpRoutes, type RenderState, resolveWebConfig } from "./types";
60
61
 
@@ -411,16 +412,14 @@ export class WebRouter {
411
412
  <script type="module" src="/csr.js"></script>
412
413
  </body>
413
414
  </html>`;
414
- return new Response(this.#withCsrHmr(htmlText), {
415
- headers: { "Content-Type": "text/html; charset=utf-8" },
416
- });
415
+ return new Response(this.#withCsrHmr(htmlText), { headers: WebRouter.#htmlResponseHeaders(200) });
417
416
  },
418
417
  }
419
418
  : {}),
420
419
  [`${clientServePrefix}/*`]: async (req) => {
421
420
  this.#requestStats.staticAsset += 1;
422
421
  const url = new URL(req.url);
423
- const filePath = WebRouter.#safeResolve(clientOutputDir, url.pathname.slice(clientServePrefix.length + 1));
422
+ const filePath = resolveStaticPath(clientOutputDir, url.pathname.slice(clientServePrefix.length + 1));
424
423
  if (!filePath) return new Response("Not Found", { status: 404 });
425
424
  return WebRouter.#fileResponse(req, filePath, {
426
425
  contentType: Bun.file(filePath).type || "application/javascript",
@@ -431,7 +430,7 @@ export class WebRouter {
431
430
  this.#requestStats.staticAsset += 1;
432
431
  const url = new URL(req.url);
433
432
  if (this.#prodMode) {
434
- const filePath = WebRouter.#safeResolve(this.#artifactDir, url.pathname.slice("/_akan/".length));
433
+ const filePath = resolveStaticPath(this.#artifactDir, url.pathname.slice("/_akan/".length));
435
434
  if (filePath) {
436
435
  return WebRouter.#fileResponse(req, filePath, {
437
436
  contentType: "text/css; charset=utf-8",
@@ -449,7 +448,7 @@ export class WebRouter {
449
448
  "/_akan/fonts/*": (req) => {
450
449
  this.#requestStats.staticAsset += 1;
451
450
  const url = new URL(req.url);
452
- const filePath = WebRouter.#safeResolve(this.#artifactDir, url.pathname.slice("/_akan/".length));
451
+ const filePath = resolveStaticPath(this.#artifactDir, url.pathname.slice("/_akan/".length));
453
452
  if (!filePath) return new Response("Not Found", { status: 404 });
454
453
  return WebRouter.#fileResponse(req, filePath, {
455
454
  contentType: Bun.file(filePath).type || "font/woff2",
@@ -524,10 +523,10 @@ export class WebRouter {
524
523
  this.#requestStats.staticAsset += 1;
525
524
  const firebaseConfig = await WebRouter.#resolveFirebaseClientConfig();
526
525
  return new Response(WebRouter.#createFirebaseMessagingServiceWorker(firebaseConfig), {
527
- headers: {
528
- "Content-Type": "application/javascript; charset=utf-8",
529
- "Cache-Control": "no-store",
530
- },
526
+ headers: WebRouter.#baseAssetHeaders({
527
+ contentType: "application/javascript; charset=utf-8",
528
+ cacheControl: "no-store",
529
+ }),
531
530
  });
532
531
  },
533
532
  "/*": async (req) => {
@@ -544,12 +543,10 @@ export class WebRouter {
544
543
  const csrHtml = await this.#resolveCsrHtml(csrOutputDir, url.pathname);
545
544
  if (!csrHtml) return this.#csrUnavailableResponse(url.pathname);
546
545
  const html = await Bun.file(csrHtml).text();
547
- return new Response(this.#withCsrHmr(html), {
548
- headers: { "Content-Type": "text/html; charset=utf-8" },
549
- });
546
+ return new Response(this.#withCsrHmr(html), { headers: WebRouter.#htmlResponseHeaders(200) });
550
547
  }
551
548
 
552
- const csrAssetPath = path.extname(url.pathname) ? WebRouter.#safeResolve(csrOutputDir, url.pathname) : null;
549
+ const csrAssetPath = path.extname(url.pathname) ? resolveStaticPath(csrOutputDir, url.pathname) : null;
553
550
  if (csrAssetPath && (await Bun.file(csrAssetPath).exists())) {
554
551
  this.#requestStats.staticAsset += 1;
555
552
  return WebRouter.#fileResponse(req, csrAssetPath, {
@@ -559,7 +556,7 @@ export class WebRouter {
559
556
  }
560
557
  }
561
558
 
562
- const filePath = WebRouter.#safeResolve(publicDir, url.pathname);
559
+ const filePath = resolveStaticPath(publicDir, url.pathname);
563
560
  if (filePath) {
564
561
  if (await Bun.file(filePath).exists()) {
565
562
  this.#requestStats.staticAsset += 1;
@@ -607,12 +604,9 @@ export class WebRouter {
607
604
  const htmlCacheEntry = htmlCacheDecision.entry;
608
605
  const cachedHtml = htmlCacheEntry ? this.#getCachedHtml(htmlCacheEntry.key) : null;
609
606
  if (cachedHtml) {
610
- return new Response(cachedHtml, {
611
- headers: {
612
- "Content-Type": "text/html; charset=utf-8",
613
- "X-Akan-Cache": "HIT",
614
- },
615
- });
607
+ const cachedHeaders = WebRouter.#htmlResponseHeaders(200);
608
+ cachedHeaders.set("X-Akan-Cache", "HIT");
609
+ return new Response(cachedHtml, { headers: cachedHeaders });
616
610
  }
617
611
  const rscResult = await this.#rsc.renderWithMeta(req, {
618
612
  clientManifest: manifest.clientManifest,
@@ -993,7 +987,7 @@ export class WebRouter {
993
987
  static #htmlResponseHeaders(status: number): Headers {
994
988
  const headers = new Headers({ "Content-Type": "text/html; charset=utf-8" });
995
989
  if (status >= 400) headers.set("Cache-Control", "no-store");
996
- return headers;
990
+ return WebRouter.#applySecurityHeaders(headers, { html: true });
997
991
  }
998
992
  #withCsrHmr(html: string): string {
999
993
  if (this.#prodMode) return html;
@@ -1249,7 +1243,7 @@ self.addEventListener("notificationclick", (event) => {
1249
1243
  i18n: artifact.i18n,
1250
1244
  });
1251
1245
  const filename = basePath ? `${basePath}.html` : "index.html";
1252
- const filePath = WebRouter.#safeResolve(csrOutputDir, filename);
1246
+ const filePath = resolveStaticPath(csrOutputDir, filename);
1253
1247
  return filePath && fs.existsSync(filePath) ? filePath : null;
1254
1248
  }
1255
1249
 
@@ -1334,10 +1328,27 @@ self.addEventListener("notificationclick", (event) => {
1334
1328
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
1335
1329
  }
1336
1330
 
1331
+ /**
1332
+ * Headers every response carries, whatever it is.
1333
+ *
1334
+ * `nosniff` because `Bun.file().type` falls back to `application/octet-stream` for an extension it does not
1335
+ * know, and a sniffing browser then guesses again — on a `public/` tree the app serves from its own origin,
1336
+ * that guess is script execution. `Referrer-Policy` because route paths carry ids.
1337
+ *
1338
+ * `X-Frame-Options` is on the HTML only: framing an image or a stylesheet means nothing, and the header is
1339
+ * what stops a page authenticated by a `SameSite=None` cookie from being clickjacked inside somebody else's.
1340
+ */
1341
+ static #applySecurityHeaders(headers: Headers, { html = false } = {}): Headers {
1342
+ headers.set("X-Content-Type-Options", "nosniff");
1343
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
1344
+ if (html) headers.set("X-Frame-Options", "SAMEORIGIN");
1345
+ return headers;
1346
+ }
1347
+
1337
1348
  static #baseAssetHeaders(options: { contentType: string; cacheControl?: string }): Headers {
1338
1349
  const headers = new Headers({ "Content-Type": options.contentType });
1339
1350
  if (options.cacheControl) headers.set("Cache-Control", options.cacheControl);
1340
- return headers;
1351
+ return WebRouter.#applySecurityHeaders(headers);
1341
1352
  }
1342
1353
 
1343
1354
  static #weakEtag(size: number, mtimeMs: number): string {
@@ -1359,21 +1370,4 @@ self.addEventListener("notificationclick", (event) => {
1359
1370
  const sinceMs = Date.parse(ifModifiedSince);
1360
1371
  return Number.isFinite(sinceMs) && sinceMs >= lastModifiedMs;
1361
1372
  }
1362
-
1363
- static #safeResolve(baseDir: string, urlPath: string): string | null {
1364
- let decoded: string;
1365
- try {
1366
- decoded = decodeURIComponent(urlPath);
1367
- } catch {
1368
- return null;
1369
- }
1370
- if (decoded.includes("\0")) return null;
1371
- const normalizedBase = path.resolve(baseDir);
1372
- const rel = decoded.replace(/^[/\\]+/, "");
1373
- const resolved = path.resolve(normalizedBase, rel);
1374
- if (resolved === normalizedBase) return resolved;
1375
- const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : normalizedBase + path.sep;
1376
- if (!resolved.startsWith(baseWithSep)) return null;
1377
- return resolved;
1378
- }
1379
1373
  }
package/service/adapt.ts CHANGED
@@ -8,11 +8,7 @@ export interface Adaptor {
8
8
  onDestroy(): Promise<void> | void;
9
9
  }
10
10
 
11
- export type AdaptorCls<
12
- Methods = any,
13
-
14
- InjectMap extends Record<string, InjectInfo> = {},
15
- > = Cls<
11
+ export type AdaptorCls<Methods = any, InjectMap extends Record<string, InjectInfo> = Record<never, never>> = Cls<
16
12
  Methods & ExtractInjectInfoObject<InjectMap> & Adaptor,
17
13
  { readonly [INJECT_META]: InjectMap; readonly refName: string }
18
14
  >;