akanjs 3.0.0-alpha.71 → 3.0.0-alpha.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/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/akanApp.ts +3 -19
  30. package/server/akanAppHeaders.ts +10 -4
  31. package/server/akanOption.ts +14 -1
  32. package/server/akanServer.ts +3 -0
  33. package/server/cachePolicy.ts +2 -11
  34. package/server/decorators.ts +12 -3
  35. package/server/devtools/constantSerializer.ts +8 -1
  36. package/server/di/diLifecycle.ts +21 -1
  37. package/server/di/resolveHierarchy.ts +3 -3
  38. package/server/imageOptimizer.ts +2 -17
  39. package/server/mcp/McpDispatcher.ts +8 -2
  40. package/server/resolver/database.resolver.ts +13 -8
  41. package/server/resolver/signal.resolver.ts +3 -3
  42. package/server/routeTreeBuilder.ts +1 -15
  43. package/server/routing/apiRouter.ts +4 -5
  44. package/server/routing/appWsData.ts +9 -6
  45. package/server/staticPath.ts +34 -0
  46. package/server/webRouter.ts +35 -41
  47. package/service/adapt.ts +1 -5
  48. package/service/predefinedAdaptor/cache.adaptor.ts +8 -4
  49. package/service/predefinedAdaptor/database.adaptor.ts +37 -1766
  50. package/service/predefinedAdaptor/sql/PendingStoreEnsures.ts +22 -0
  51. package/service/predefinedAdaptor/sql/QueryCompiler.ts +225 -0
  52. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +812 -0
  53. package/service/predefinedAdaptor/sql/UpdateCompiler.ts +67 -0
  54. package/service/predefinedAdaptor/sql/dialect/postgres.ts +151 -0
  55. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +134 -0
  56. package/service/predefinedAdaptor/sql/driver/bunSqlite.ts +29 -0
  57. package/service/predefinedAdaptor/sql/driver/libsql.ts +37 -0
  58. package/service/predefinedAdaptor/sql/driver/postgres.ts +42 -0
  59. package/service/predefinedAdaptor/sql/types.ts +251 -0
  60. package/service/predefinedAdaptor/sql/values.ts +91 -0
  61. package/signal/CrossSiteGuard.ts +71 -0
  62. package/signal/SignalFailure.ts +51 -0
  63. package/signal/endpoint.ts +2 -2
  64. package/signal/exception.ts +43 -6
  65. package/signal/index.ts +2 -0
  66. package/signal/intercept.ts +5 -2
  67. package/signal/internal.ts +10 -2
  68. package/signal/serializer/fetch.serializer.ts +11 -1
  69. package/signal/signalContext.ts +25 -28
  70. package/signal/slice.ts +1 -1
  71. package/store/SliceRequest.ts +23 -0
  72. package/store/action.ts +177 -114
  73. package/store/agent/AgentBridge.ts +3 -1
  74. package/store/agentic/StExposeBuilder.ts +7 -4
  75. package/store/agentic/StStateBuilder.ts +9 -4
  76. package/store/agentic/StToolBuilder.ts +21 -7
  77. package/store/store.ts +1 -1
  78. package/store/storeInstance.ts +3 -3
  79. package/store/storeRegistry.ts +1 -1
  80. package/types/client/cookie.d.ts +13 -2
  81. package/types/client/csrTypes.d.ts +0 -1
  82. package/types/client/frameConfig.d.ts +1 -0
  83. package/types/common/TrustedProxy.d.ts +24 -0
  84. package/types/common/clamp.d.ts +2 -0
  85. package/types/common/dynamicRecord.d.ts +14 -0
  86. package/types/common/index.d.ts +5 -1
  87. package/types/common/requestOrigin.d.ts +14 -0
  88. package/types/common/restClient.d.ts +27 -0
  89. package/types/constant/types.d.ts +13 -0
  90. package/types/dictionary/base.dictionary.d.ts +1 -1
  91. package/types/dictionary/dictionary.d.ts +8 -8
  92. package/types/document/dataLoader.d.ts +0 -3
  93. package/types/document/filterMeta.d.ts +2 -2
  94. package/types/document/loaderInfo.d.ts +2 -2
  95. package/types/fetch/client/fetchHandle.d.ts +24 -0
  96. package/types/fetch/client/httpClient.d.ts +2 -0
  97. package/types/fetch/client/sliceInitHandle.d.ts +29 -0
  98. package/types/fetch/fetchType/appliedReturn.type.d.ts +38 -1
  99. package/types/fetch/fetchType/sliceFetch.type.d.ts +5 -5
  100. package/types/fetch/requestStorage.d.ts +14 -0
  101. package/types/index.d.ts +17 -2
  102. package/types/server/akanOption.d.ts +8 -1
  103. package/types/server/decorators.d.ts +12 -2
  104. package/types/server/di/diLifecycle.d.ts +9 -1
  105. package/types/server/routeTreeBuilder.d.ts +1 -2
  106. package/types/server/routing/appWsData.d.ts +7 -3
  107. package/types/server/staticPath.d.ts +17 -0
  108. package/types/service/adapt.d.ts +1 -1
  109. package/types/service/predefinedAdaptor/database.adaptor.d.ts +15 -407
  110. package/types/service/predefinedAdaptor/sql/PendingStoreEnsures.d.ts +6 -0
  111. package/types/service/predefinedAdaptor/sql/QueryCompiler.d.ts +28 -0
  112. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +127 -0
  113. package/types/service/predefinedAdaptor/sql/UpdateCompiler.d.ts +17 -0
  114. package/types/service/predefinedAdaptor/sql/dialect/postgres.d.ts +27 -0
  115. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +27 -0
  116. package/types/service/predefinedAdaptor/sql/driver/bunSqlite.d.ts +16 -0
  117. package/types/service/predefinedAdaptor/sql/driver/libsql.d.ts +17 -0
  118. package/types/service/predefinedAdaptor/sql/driver/postgres.d.ts +17 -0
  119. package/types/service/predefinedAdaptor/sql/types.d.ts +226 -0
  120. package/types/service/predefinedAdaptor/sql/values.d.ts +13 -0
  121. package/types/signal/CrossSiteGuard.d.ts +44 -0
  122. package/types/signal/SignalFailure.d.ts +28 -0
  123. package/types/signal/exception.d.ts +38 -54774
  124. package/types/signal/index.d.ts +2 -0
  125. package/types/signal/intercept.d.ts +3 -3
  126. package/types/signal/internal.d.ts +5 -1
  127. package/types/signal/serializer/fetch.serializer.d.ts +7 -1
  128. package/types/store/SliceRequest.d.ts +16 -0
  129. package/types/store/agentic/StToolBuilder.d.ts +10 -1
  130. package/types/ui/Agent/Menu.d.ts +1 -1
  131. package/types/ui/Field/Choice.d.ts +52 -0
  132. package/types/ui/Field/Collection.d.ts +51 -0
  133. package/types/ui/Field/DateField.d.ts +32 -0
  134. package/types/ui/Field/Label.d.ts +9 -0
  135. package/types/ui/Field/NumberField.d.ts +43 -0
  136. package/types/ui/Field/Relation.d.ts +80 -0
  137. package/types/ui/Field/Text.d.ts +101 -0
  138. package/types/ui/Field/index.d.ts +50 -0
  139. package/types/ui/Field.d.ts +1 -412
  140. package/types/ui/Load/Edit_Client.d.ts +3 -1
  141. package/types/ui/Load/Stream.d.ts +22 -0
  142. package/types/ui/Load/index.d.ts +2 -0
  143. package/types/ui/System/Root.d.ts +4 -0
  144. package/types/ui/index.d.ts +1 -1
  145. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  146. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  147. package/types/webkit/useCamera.d.ts +10 -1
  148. package/types/webkit/usePurchase.d.ts +4 -0
  149. package/ui/Agent/Menu.tsx +2 -2
  150. package/ui/Data/TableList.tsx +1 -1
  151. package/ui/DraggableList.tsx +1 -2
  152. package/ui/Field/Choice.tsx +172 -0
  153. package/ui/Field/Collection.tsx +293 -0
  154. package/ui/Field/DateField.tsx +138 -0
  155. package/ui/Field/Label.tsx +31 -0
  156. package/ui/Field/NumberField.tsx +169 -0
  157. package/ui/Field/Relation.tsx +387 -0
  158. package/ui/Field/Text.tsx +360 -0
  159. package/ui/Field/index.tsx +70 -0
  160. package/ui/Input.tsx +3 -3
  161. package/ui/Load/Edit.tsx +2 -2
  162. package/ui/Load/Edit_Client.tsx +11 -3
  163. package/ui/Load/Pagination.tsx +25 -25
  164. package/ui/Load/Stream.tsx +38 -0
  165. package/ui/Load/Units.tsx +50 -37
  166. package/ui/Load/View.tsx +25 -17
  167. package/ui/Load/index.ts +2 -0
  168. package/ui/Model/EditModal.tsx +7 -7
  169. package/ui/Signal/Message.tsx +7 -7
  170. package/ui/Signal/PubSub.tsx +4 -1
  171. package/ui/System/Client.tsx +0 -2
  172. package/ui/System/Gtag.tsx +1 -1
  173. package/ui/System/Root.tsx +4 -0
  174. package/ui/index.ts +1 -1
  175. package/ui/overlayPosition.ts +1 -2
  176. package/webkit/useCamera.tsx +22 -6
  177. package/webkit/useCodepush.tsx +2 -2
  178. package/webkit/useCsrValues.ts +1 -3
  179. package/webkit/useFetch.ts +4 -3
  180. package/webkit/useFrameRuntime.ts +14 -7
  181. package/webkit/usePurchase.tsx +4 -1
  182. package/fetch/serializer/fetch.serializer.ts +0 -142
  183. package/fetch/serializer/index.ts +0 -1
  184. package/types/common/httpClient.d.ts +0 -20
  185. package/types/fetch/serializer/fetch.serializer.d.ts +0 -14
  186. package/types/fetch/serializer/index.d.ts +0 -1
  187. package/types/fetch/serializer.d.ts +0 -1
  188. package/ui/Field.tsx +0 -1639
package/client/cookie.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getEnv } from "akanjs/base";
2
2
  import { decodeJwtPayload, Logger } from "akanjs/common";
3
3
  import type { Account } from "akanjs/fetch";
4
- import { cookies as serverCookies, headers as serverHeaders } from "akanjs/fetch";
4
+ import { parseCookieHeader, cookies as serverCookies, headers as serverHeaders } from "akanjs/fetch";
5
5
  import { loadCapacitorCore } from "./capacitor";
6
6
  import { storage } from "./storage";
7
7
  import { fetch } from "./useClient";
@@ -12,23 +12,6 @@ interface CookieOptions {
12
12
  secure?: boolean;
13
13
  }
14
14
 
15
- function parseCookieHeader(cookieHeader: string): Map<string, { name: string; value: string }> {
16
- const entries = cookieHeader
17
- .split(";")
18
- .map((c) => c.trim())
19
- .filter(Boolean)
20
- .map((c) => {
21
- const eqIdx = c.indexOf("=");
22
- if (eqIdx === -1) return null;
23
- const name = c.slice(0, eqIdx).trim();
24
- const raw = c.slice(eqIdx + 1).trim();
25
- const value = typeof raw === "string" && raw.startsWith("j:") ? (JSON.parse(raw.slice(2)) as string) : raw;
26
- return [name, { name, value }] as const;
27
- })
28
- .filter((e): e is NonNullable<typeof e> => e !== null);
29
- return new Map(entries);
30
- }
31
-
32
15
  export const cookies = (): Map<string, { name: string; value: string }> => {
33
16
  if (getEnv().side === "server") return serverCookies();
34
17
  return parseCookieHeader(document.cookie);
@@ -53,21 +36,18 @@ export const setCookie = (
53
36
  .catch(() => undefined);
54
37
  };
55
38
 
56
- export const getCookie = (key: string): string | undefined => {
57
- if (getEnv().side === "server") return cookies().get(key)?.value;
58
- else
59
- return document.cookie
60
- .split(";")
61
- .find((c) => c.trim().startsWith(`${key}=`))
62
- ?.split("=")[1];
63
- };
39
+ /**
40
+ * Reads through `cookies()` on both sides, which is the only way the two agree: a hand-rolled
41
+ * `split("=")[1]` truncates a value at its first `=` (base64 padding) and never decodes the `j:` form the
42
+ * server branch does. Capacitor's own docs say to read `document.cookie`, and `cookies()` does.
43
+ */
44
+ export const getCookie = (key: string): string | undefined => cookies().get(key)?.value;
64
45
 
65
46
  export const removeCookie = (key: string, options: { path: string } = { path: "/" }) => {
66
- if (getEnv().side === "server") return cookies().delete(key);
67
- else {
68
-
69
- document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
70
- }
47
+
48
+ if (getEnv().side === "server") return;
49
+
50
+ document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${options.path};`;
71
51
  };
72
52
  export const headers = (): Map<string, string> => {
73
53
  if (getEnv().side !== "server") return new Map();
@@ -77,9 +57,15 @@ export const headers = (): Map<string, string> => {
77
57
  export const getHeader = (key: string): string | undefined => {
78
58
  return headers().get(key);
79
59
  };
80
- /** Decodes the current JWT into account data when it belongs to this app/environment. */
60
+ /**
61
+ * Decodes the current JWT into account data when it belongs to this app/environment.
62
+ *
63
+ * The two credentials read here are exactly the two the server honours — the `jwt` cookie and
64
+ * `Authorization: Bearer` (`AccountMiddleware`). It used to fall back to a bare `jwt` *header*, which nothing
65
+ * sends and no guard accepts, so a request carrying one read as signed in here and anonymous at the endpoint.
66
+ */
81
67
  export const getAccount = <AddData = unknown>(): Account<AddData> => {
82
- const jwt = getCookie("jwt") ?? getHeader("jwt");
68
+ const jwt = getCookie("jwt") ?? getHeader("authorization")?.replace(/^Bearer\s+/i, "");
83
69
  const defaultAccount = { appName: getEnv().appName, environment: getEnv().environment } as Account<AddData>;
84
70
  if (!jwt) return defaultAccount;
85
71
  const account = decodeJwtPayload<Account<AddData>>(jwt);
@@ -105,7 +91,8 @@ interface InitAuthOption {
105
91
  export const initAuth = ({ jwt }: InitAuthOption = {}) => {
106
92
  const token = jwt ?? cookies().get("jwt")?.value;
107
93
  if (token) setAuth({ jwt: token });
108
- Logger.verbose(`JWT set from cookie: ${token}`);
94
+
95
+ Logger.verbose(`JWT ${token ? "restored from cookie" : "not found in cookie"}`);
109
96
  };
110
97
 
111
98
  export const resetAuth = () => {
@@ -262,18 +262,6 @@ export type PageState = CsrState & {
262
262
  topInset: number;
263
263
  bottomInset: number;
264
264
  };
265
- export const defaultPageState: PageState = {
266
- transition: "none",
267
- topSafeArea: 0,
268
- bottomSafeArea: 0,
269
- topInset: 0,
270
- bottomInset: 0,
271
- gesture: true,
272
- cache: false,
273
- ssr: "stream",
274
- topSafeAreaColor: "var(--color-background, Canvas)",
275
- bottomSafeAreaColor: "var(--color-background, Canvas)",
276
- };
277
265
 
278
266
  export interface Location {
279
267
  href: string;
@@ -120,6 +120,19 @@ export function getExplicitPageConfigKeys(configChain: PageConfig[] = []): Parti
120
120
  return explicitKeys;
121
121
  }
122
122
 
123
+ export const defaultPageState: PageState = {
124
+ transition: "none",
125
+ topSafeArea: 0,
126
+ bottomSafeArea: 0,
127
+ topInset: 0,
128
+ bottomInset: 0,
129
+ gesture: true,
130
+ cache: false,
131
+ ssr: "stream",
132
+ topSafeAreaColor: "var(--color-background, Canvas)",
133
+ bottomSafeAreaColor: "var(--color-background, Canvas)",
134
+ };
135
+
123
136
  export function resolvePageState({
124
137
  configChain = [],
125
138
  path,
package/common/Logger.ts CHANGED
@@ -81,17 +81,24 @@ const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`,
81
81
 
82
82
  const redactedAttrKey = /password|passwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key/i;
83
83
 
84
+ /**
85
+ * `process.env?.X` guards only `env` being absent, never `process` itself — an undeclared `process` is a
86
+ * ReferenceError, and at module scope that takes the whole client bundle with it. This is the most widely
87
+ * imported file in `common/`, and the unguarded form works today only because the browser build ships a shim.
88
+ */
89
+ const envValue = (key: string): string | undefined => (typeof process === "undefined" ? undefined : process.env?.[key]);
90
+
84
91
  /** Log-level aware logger used by Akan runtime, CLI, and application services. */
85
92
  export class Logger {
86
93
  /** `AKAN_LOG_STDOUT_LEVEL` names what the container's stdout carries; without it the console level is that. */
87
94
  static level: LogLevel = Logger.#levelFromEnv(
88
- process.env?.AKAN_LOG_STDOUT_LEVEL,
89
- Logger.#levelFromEnv(process.env?.AKAN_PUBLIC_LOG_LEVEL, "info"),
95
+ envValue("AKAN_LOG_STDOUT_LEVEL"),
96
+ Logger.#levelFromEnv(envValue("AKAN_PUBLIC_LOG_LEVEL"), "info"),
90
97
  );
91
- static fileLevel: LogLevel = Logger.#levelFromEnv(process.env?.AKAN_LOG_FILE_LEVEL, "trace");
92
- static format: LogFormat = Logger.#formatFromEnv(process.env?.AKAN_LOG_FORMAT);
98
+ static fileLevel: LogLevel = Logger.#levelFromEnv(envValue("AKAN_LOG_FILE_LEVEL"), "trace");
99
+ static format: LogFormat = Logger.#formatFromEnv(envValue("AKAN_LOG_FORMAT"));
93
100
  /** Which process this is (`gateway`, `all`, `batch`, `rsc-worker`); the owner sets it at boot. */
94
- static role: string | null = process.env?.SERVER_MODE ?? null;
101
+ static role: string | null = envValue("SERVER_MODE") ?? null;
95
102
  /**
96
103
  * Whether this process writes its own lines to the terminal. Off in every server process of an ndjson
97
104
  * deployment: the hub owner writes the one JSON stream, and a text line from anyone would corrupt it.
@@ -388,7 +395,7 @@ export class Logger {
388
395
  else console[outputStream === "error" ? "error" : "log"](msg.trim());
389
396
  }
390
397
  static {
391
- if (process.env?.AKAN_PUBLIC_LOG_LEVEL === "log" && typeof window === "undefined")
398
+ if (envValue("AKAN_PUBLIC_LOG_LEVEL") === "log" && typeof window === "undefined")
392
399
  Logger.warn("AKAN_PUBLIC_LOG_LEVEL=log is deprecated and now means info; set AKAN_PUBLIC_LOG_LEVEL=info.");
393
400
  }
394
401
  }
@@ -0,0 +1,134 @@
1
+ import { normalizeIpAddress } from "./clientAddress";
2
+
3
+ /**
4
+ * Whether the hop that connected is allowed to name the caller.
5
+ *
6
+ * `x-real-ip` and `x-forwarded-for` are just request headers: anyone can send them. They are the truth only when
7
+ * the peer that sent them is a proxy we put there — otherwise a client forges its own address and walks past
8
+ * every `.with(Ip)` guard, rate limit and audit line at once.
9
+ *
10
+ * The default trusts a private, loopback or link-local peer and nothing else, because that is exactly the shape
11
+ * of a proxy on the same network: an ingress, a service mesh, the dev machine. A gateway reachable from the
12
+ * internet sees a public peer, so a header from there is dropped rather than believed. `AKAN_TRUSTED_PROXIES`
13
+ * takes a comma-separated CIDR list for the case the proxy is not private — and `*` for "the deployment
14
+ * guarantees nothing untrusted can reach this port", which is the old behaviour, written down.
15
+ */
16
+ export class TrustedProxy {
17
+ static #cidrs: { bytes: Uint8Array; bits: number }[] | null = null;
18
+ static #trustAll: boolean | null = null;
19
+
20
+ static configure(list: string | null | undefined) {
21
+ TrustedProxy.#trustAll = list?.trim() === "*";
22
+ TrustedProxy.#cidrs = (list ?? "")
23
+ .split(",")
24
+ .map((entry) => entry.trim())
25
+ .filter((entry) => entry && entry !== "*")
26
+ .flatMap((entry) => {
27
+ const parsed = TrustedProxy.#parseCidr(entry);
28
+ return parsed ? [parsed] : [];
29
+ });
30
+ }
31
+
32
+ static reset() {
33
+ TrustedProxy.#cidrs = null;
34
+ TrustedProxy.#trustAll = null;
35
+ }
36
+
37
+ static #load() {
38
+ if (TrustedProxy.#cidrs === null) TrustedProxy.configure(process.env.AKAN_TRUSTED_PROXIES);
39
+ }
40
+
41
+ static isTrusted(address: string | null | undefined): boolean {
42
+ if (!address) return false;
43
+ TrustedProxy.#load();
44
+ if (TrustedProxy.#trustAll) return true;
45
+ const bytes = TrustedProxy.#toBytes(normalizeIpAddress(address));
46
+ if (!bytes) return false;
47
+ if (TrustedProxy.#isLocalRange(bytes)) return true;
48
+ return (TrustedProxy.#cidrs ?? []).some((cidr) => TrustedProxy.#inRange(bytes, cidr));
49
+ }
50
+
51
+ /**
52
+ * The caller's address as recorded by a proxy, but only when the peer is one. Falls back to the peer itself,
53
+ * which is the right answer for a process nothing is proxying, and `null` when there is no peer either.
54
+ */
55
+ static clientAddress(headers: Headers, peerAddress: string | null | undefined): string | null {
56
+ if (TrustedProxy.isTrusted(peerAddress)) {
57
+ const realIp = headers.get("x-real-ip")?.trim();
58
+ if (realIp) return normalizeIpAddress(realIp);
59
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
60
+ if (forwarded) return normalizeIpAddress(forwarded);
61
+ }
62
+ return peerAddress ? normalizeIpAddress(peerAddress) : null;
63
+ }
64
+
65
+ /** `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, and their IPv6 counterparts `::1`, `fc00::/7`, `fe80::/10`. */
66
+ static #isLocalRange(bytes: Uint8Array): boolean {
67
+ if (bytes.length === 4) {
68
+ const [a = 0, b = 0] = bytes;
69
+ if (a === 10 || a === 127) return true;
70
+ if (a === 192 && b === 168) return true;
71
+ if (a === 172 && b >= 16 && b <= 31) return true;
72
+ return a === 169 && b === 254;
73
+ }
74
+ const [first = 0, second = 0] = bytes;
75
+ if ((first & 0xfe) === 0xfc) return true;
76
+ if (first === 0xfe && (second & 0xc0) === 0x80) return true;
77
+ return bytes.every((byte, idx) => (idx === 15 ? byte === 1 : byte === 0));
78
+ }
79
+
80
+ static #parseCidr(entry: string): { bytes: Uint8Array; bits: number } | null {
81
+ const [address, prefix] = entry.split("/");
82
+ const bytes = TrustedProxy.#toBytes(address ?? "");
83
+ if (!bytes) return null;
84
+ const bits = prefix === undefined ? bytes.length * 8 : Number(prefix);
85
+ if (!Number.isInteger(bits) || bits < 0 || bits > bytes.length * 8) return null;
86
+ return { bytes, bits };
87
+ }
88
+
89
+ static #inRange(bytes: Uint8Array, cidr: { bytes: Uint8Array; bits: number }): boolean {
90
+ if (bytes.length !== cidr.bytes.length) return false;
91
+ const wholeBytes = cidr.bits >> 3;
92
+ for (let idx = 0; idx < wholeBytes; idx += 1) if (bytes[idx] !== cidr.bytes[idx]) return false;
93
+ const restBits = cidr.bits & 7;
94
+ if (!restBits) return true;
95
+ const mask = 0xff << (8 - restBits);
96
+ return ((bytes[wholeBytes] ?? 0) & mask) === ((cidr.bytes[wholeBytes] ?? 0) & mask);
97
+ }
98
+
99
+ static #toBytes(address: string): Uint8Array | null {
100
+ if (address.includes(".") && !address.includes(":")) {
101
+ const parts = address.split(".");
102
+ if (parts.length !== 4) return null;
103
+ const bytes = new Uint8Array(4);
104
+ for (const [idx, part] of parts.entries()) {
105
+ const value = Number(part);
106
+ if (!/^\d{1,3}$/.test(part) || value > 255) return null;
107
+ bytes[idx] = value;
108
+ }
109
+ return bytes;
110
+ }
111
+ return address.includes(":") ? TrustedProxy.#ipv6ToBytes(address) : null;
112
+ }
113
+
114
+ static #ipv6ToBytes(address: string): Uint8Array | null {
115
+
116
+ const bare = address.split("%")[0] ?? "";
117
+ const halves = bare.split("::");
118
+ if (halves.length > 2) return null;
119
+ const groupsOf = (text: string) => (text ? text.split(":").filter((group) => group !== "") : []);
120
+ const head = groupsOf(halves[0] ?? "");
121
+ const tail = halves.length === 2 ? groupsOf(halves[1] ?? "") : [];
122
+ const filled = halves.length === 2 ? 8 - head.length - tail.length : 0;
123
+ if (filled < 0 || head.length + tail.length + filled !== 8) return null;
124
+ const groups = [...head, ...new Array(filled).fill("0"), ...tail];
125
+ const bytes = new Uint8Array(16);
126
+ for (const [idx, group] of groups.entries()) {
127
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
128
+ const value = Number.parseInt(group, 16);
129
+ bytes[idx * 2] = value >> 8;
130
+ bytes[idx * 2 + 1] = value & 0xff;
131
+ }
132
+ return bytes;
133
+ }
134
+ }
@@ -0,0 +1,2 @@
1
+ /** Constrains a value to `[min, max]`. */
2
+ export const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * A generated surface read by a name computed at runtime.
3
+ *
4
+ * The framework's glue layers dispatch by name: a store action reaches `fetch[key]`, a resolver reaches
5
+ * `service[method]`, a `Load.*` component reads `init[names.pageOfModel]`. None of those keys exist in a type,
6
+ * because the type is generated from the model — so every one of them used to be spelled `as any`, which the
7
+ * guide bans and which also silences the *value* type on the way out.
8
+ *
9
+ * This says the same thing about the index and nothing about the value: `as DynamicRecord` to index, then the
10
+ * read carries its own `as T`. One alias so a reader can tell name-based dispatch from an untyped object.
11
+ */
12
+ export type DynamicRecord<Value = unknown> = { [key: string]: Value };
package/common/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { applyMixins } from "./applyMixins";
2
2
  export { capitalize } from "./capitalize";
3
+ export { clamp } from "./clamp";
3
4
  export {
4
5
  clientAddressFromHeaders,
5
6
  clientPortFromHeaders,
@@ -7,6 +8,7 @@ export {
7
8
  normalizeIpAddress,
8
9
  } from "./clientAddress";
9
10
  export { deepObjectify } from "./deepObjectify";
11
+ export type { DynamicRecord } from "./dynamicRecord";
10
12
  export {
11
13
  type FileUploadCapability,
12
14
  fileUploadContract,
@@ -16,7 +18,6 @@ export { formatNumber } from "./formatNumber";
16
18
  export { formatPhone } from "./formatPhone";
17
19
  export { getAllPropertyDescriptors } from "./getAllPropertyDescriptors";
18
20
  export { type AkanHmrPhase, getAkanHmrPhase, isAkanHmrApplying } from "./hmrPhase";
19
- export { HttpClient } from "./httpClient";
20
21
  export { isDayjs } from "./isDayjs";
21
22
  export { isEmail } from "./isEmail";
22
23
  export { isPhoneNumber } from "./isPhoneNumber";
@@ -72,6 +73,8 @@ export { pathSet } from "./pathSet";
72
73
  export { plainFieldsOf } from "./plainFieldsOf";
73
74
  export { randomPick } from "./randomPick";
74
75
  export { randomPicks } from "./randomPicks";
76
+ export { isJsonContentType, originFromRequest } from "./requestOrigin";
77
+ export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient";
75
78
  export {
76
79
  assertUniqueRoutePatterns,
77
80
  compareRouteSpecificity,
@@ -99,6 +102,7 @@ export {
99
102
  export { sleep } from "./sleep";
100
103
  export { splitVersion } from "./splitVersion";
101
104
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute";
105
+ export { TrustedProxy } from "./TrustedProxy";
102
106
  export type * from "./types";
103
107
  export {
104
108
  type WebsocketAuthAckData,
@@ -10,5 +10,5 @@ export const isValidDate = (d: string | Date | Dayjs) => {
10
10
  if (typeof d === "string") {
11
11
  return dayjs(d, format).isValid();
12
12
  } else if (isDayjs(d)) return d.isValid();
13
- else return d instanceof Date && !isNaN(d.getTime());
13
+ else return d instanceof Date && !Number.isNaN(d.getTime());
14
14
  };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The origin a caller actually saw, and whether a body claims to be JSON. Both read only headers and a URL, so
3
+ * they answer the same way for the cache layer, the CSRF gate, and anything else that has to compare origins.
4
+ */
5
+ export const originFromRequest = (headers: Headers, url: URL): string => {
6
+
7
+ const forwardedProto = headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
8
+ const forwardedHost = headers.get("x-forwarded-host")?.split(",")[0]?.trim();
9
+ const host = forwardedHost ?? headers.get("host")?.split(",")[0]?.trim();
10
+ const proto = forwardedProto ?? url.protocol.slice(0, -1);
11
+ if (host && proto) {
12
+ try {
13
+ return new URL(`${proto}://${host}`).origin;
14
+ } catch {
15
+ }
16
+ }
17
+ return url.origin;
18
+ };
19
+
20
+ /**
21
+ * Whether a request body may be read as JSON.
22
+ *
23
+ * `Request.json()` parses whatever bytes it holds regardless of this header, and the three content types a
24
+ * cross-site form can send — `text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data` — are
25
+ * exactly the ones a browser sends with **no preflight**. Requiring the JSON type is therefore what puts a
26
+ * preflight in front of every mutation that carries a body.
27
+ */
28
+ export const isJsonContentType = (contentType: string | null): boolean => {
29
+ if (!contentType) return false;
30
+ const essence = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
31
+ return essence === "application/json" || essence.endsWith("+json");
32
+ };
@@ -1,39 +1,46 @@
1
- export interface HttpClientOptions {
1
+ /**
2
+ * A plain REST client for a service that is not an Akan API: base url, headers, timeout, JSON in and out.
3
+ *
4
+ * Not `akanjs/fetch`'s `HttpClient`, which builds an Akan endpoint's path from its serialized args and restores
5
+ * an `Err` from the response. Two classes of the same name in one package meant an app importing both barrels
6
+ * got a collision, so this one is named for what it is.
7
+ */
8
+ export interface RestClientOptions {
2
9
  baseUrl?: string;
3
10
  headers?: HeadersInit;
4
11
  timeout?: number;
5
12
  }
6
13
 
7
- export interface HttpRequestOptions extends Omit<RequestInit, "body" | "headers" | "method"> {
14
+ export interface RestRequestOptions extends Omit<RequestInit, "body" | "headers" | "method"> {
8
15
  headers?: HeadersInit;
9
16
  timeout?: number;
10
17
  }
11
18
 
12
- export class HttpClient {
19
+ export class RestClient {
13
20
  readonly baseUrl: string;
14
21
  readonly headers: Headers;
15
22
  readonly timeout: number | undefined;
16
23
 
17
- constructor(options: string | HttpClientOptions = {}) {
24
+ constructor(options: string | RestClientOptions = {}) {
18
25
  const normalized = typeof options === "string" ? { baseUrl: options } : options;
19
26
  this.baseUrl = normalized.baseUrl?.replace(/\/+$/, "") ?? "";
20
27
  this.headers = new Headers(normalized.headers);
21
28
  this.timeout = normalized.timeout;
22
29
  }
23
30
 
24
- async get<Returns>(url: string, options?: HttpRequestOptions): Promise<Returns> {
31
+ async get<Returns>(url: string, options?: RestRequestOptions): Promise<Returns> {
25
32
  return await this.#request<Returns>("GET", url, undefined, options);
26
33
  }
27
34
 
28
- async post<Returns>(url: string, data?: unknown, options?: HttpRequestOptions): Promise<Returns> {
35
+ async post<Returns>(url: string, data?: unknown, options?: RestRequestOptions): Promise<Returns> {
29
36
  return await this.#request<Returns>("POST", url, data, options);
30
37
  }
31
38
 
32
- async put<Returns>(url: string, data?: unknown, options?: HttpRequestOptions): Promise<Returns> {
39
+ async put<Returns>(url: string, data?: unknown, options?: RestRequestOptions): Promise<Returns> {
33
40
  return await this.#request<Returns>("PUT", url, data, options);
34
41
  }
35
42
 
36
- async delete<Returns>(url: string, options?: HttpRequestOptions): Promise<Returns> {
43
+ async delete<Returns>(url: string, options?: RestRequestOptions): Promise<Returns> {
37
44
  return await this.#request<Returns>("DELETE", url, undefined, options);
38
45
  }
39
46
 
@@ -41,7 +48,7 @@ export class HttpClient {
41
48
  method: string,
42
49
  url: string,
43
50
  data?: unknown,
44
- options: HttpRequestOptions = {},
51
+ options: RestRequestOptions = {},
45
52
  ): Promise<Returns> {
46
53
  const { body, headers } = this.#makeBody(data);
47
54
  const timeout = options.timeout ?? this.timeout;
package/constant/types.ts CHANGED
@@ -104,6 +104,28 @@ export interface ProtoPatch {
104
104
  }
105
105
 
106
106
  export const DEFAULT_PAGE_SIZE = 20;
107
+ /**
108
+ * The most rows one list request may take, whatever it asks for.
109
+ *
110
+ * A page size arrives from the client, so without a ceiling anyone who may read one page may read the whole
111
+ * table in a single request — and `LIMIT 0` or `LIMIT -1` reaches SQLite as "no limit at all", so the two ways
112
+ * of asking for nothing were the two ways of asking for everything. `resolvePageLimit` is the one place that
113
+ * decides; a caller wanting more pages asks for more pages.
114
+ */
115
+ export const MAX_PAGE_SIZE = 500;
116
+
117
+ /** Clamps a caller-supplied page size into `[1, MAX_PAGE_SIZE]`, falling back to `DEFAULT_PAGE_SIZE`. */
118
+ export const resolvePageLimit = (limit: unknown, fallback: number = DEFAULT_PAGE_SIZE): number => {
119
+ const asked = Math.trunc(Number(limit));
120
+ if (!Number.isFinite(asked) || asked <= 0) return Math.min(fallback, MAX_PAGE_SIZE);
121
+ return Math.min(asked, MAX_PAGE_SIZE);
122
+ };
123
+
124
+ /** Clamps an offset to a non-negative integer; a negative `OFFSET` is silently read as zero by the database. */
125
+ export const resolvePageSkip = (skip: unknown): number => {
126
+ const asked = Math.trunc(Number(skip));
127
+ return Number.isFinite(asked) && asked > 0 ? asked : 0;
128
+ };
107
129
  export type NonFunctionalKeys<T> = {
108
130
  [K in keyof T]: T[K] extends (...args: never[]) => unknown ? never : K;
109
131
  }[keyof T];
@@ -54,6 +54,10 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
54
54
  tryReconnecting: ["Try reconnecting to the server", "서버에 다시 연결중입니다."],
55
55
  serverHasProblem: ["Maybe your server is having a problem.", "서버에 문제가 있을 수 있습니다."],
56
56
  checkServerStatus: ["Please check your server status.", "서버 상태를 확인해주세요."],
57
+ cameraPromptHeader: ["Choose a photo", "사진을 선택해주세요"],
58
+ cameraPromptPhoto: ["Choose from library", "앨범에서 선택하기"],
59
+ cameraPromptPicture: ["Take a photo", "사진 찍기"],
60
+ cameraPromptCancel: ["Cancel", "취소"],
57
61
  success: ["Success", "성공"],
58
62
  failed: ["Failed", "실패"],
59
63
  processing: ["Processing...", "처리중..."],
@@ -67,12 +67,31 @@ export type ErrConstructor<ErrorKey extends string> = {
67
67
  Conflict: new (key: ErrorKey, data?: TranslationData, option?: ErrRestoreOption) => ErrInstance;
68
68
  };
69
69
 
70
+ /**
71
+ * The toast API, before anything has claimed it.
72
+ *
73
+ * `<Messages/>` assigns the real implementations onto this object when it mounts, so a call before that — a
74
+ * store action on the first frame — has nowhere to go. It stays a no-op rather than throwing (a dropped toast
75
+ * must not take the render with it) and says so once, because a message that silently never appeared is
76
+ * otherwise indistinguishable from one the user missed. Server code reaches this object too, through
77
+ * `akanjs/dictionary`, and there nothing ever assigns over it.
78
+ */
79
+ const unclaimed = (level: string) => () => {
80
+ if (unclaimed.warned) return null;
81
+ unclaimed.warned = true;
82
+ console.warn(
83
+ `msg.${level}() was called before <Messages/> mounted, so nothing was shown. Mount it in a layout, or move the call into an event handler.`,
84
+ );
85
+ return null;
86
+ };
87
+ unclaimed.warned = false;
88
+
70
89
  export const msg = {
71
- info: () => null,
72
- success: () => null,
73
- error: () => null,
74
- warning: () => null,
75
- loading: () => null,
90
+ info: unclaimed("info"),
91
+ success: unclaimed("success"),
92
+ error: unclaimed("error"),
93
+ warning: unclaimed("warning"),
94
+ loading: unclaimed("loading"),
76
95
  } as {
77
96
  info: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
78
97
  success: (key: TransMessage<Record<string, unknown>>, option?: TransMessageOption) => void;
@@ -1,9 +1,5 @@
1
1
  import type { QueryOf } from "akanjs/constant";
2
2
 
3
- export const Id = String;
4
- export const ObjectId = String;
5
- export const Mixed = Object;
6
-
7
3
  type LoaderItem = Record<string, unknown>;
8
4
  type LoaderModel = {
9
5
  find: (query: QueryOf<unknown>) => Promise<LoaderItem[]> | { then: Promise<LoaderItem[]>["then"] };
@@ -176,8 +176,8 @@ export type BaseFilterQueryKey = "any";
176
176
  export type BaseFilterKey = BaseFilterSortKey | BaseFilterQueryKey;
177
177
 
178
178
  export type FilterInstance<
179
- Query extends { [key: string]: FilterInfo } = {},
180
- Sort extends { [key: string]: unknown } = {},
179
+ Query extends { [key: string]: FilterInfo } = Record<never, never>,
180
+ Sort extends { [key: string]: unknown } = Record<never, never>,
181
181
  > = {
182
182
  query: Query;
183
183
  sort: Sort;
@@ -4,7 +4,10 @@ import type { Loader, SchemaOf } from ".";
4
4
 
5
5
  type LoaderType = "field" | "arrayField" | "query";
6
6
 
7
- export type ModelCls<Statics = {}, LoaderMap extends { [key: string]: LoaderInfo } = {}> = Cls<
7
+ export type ModelCls<
8
+ Statics = Record<never, never>,
9
+ LoaderMap extends { [key: string]: LoaderInfo } = Record<never, never>,
10
+ > = Cls<
8
11
  Statics & ExtractLoaderInfoObject<LoaderMap>,
9
12
  {
10
13
  [LOADER_META]: LoaderMap;