akanjs 3.0.1 → 3.0.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/common/Logger.ts +10 -0
  5. package/dictionary/base.dictionary.ts +7 -0
  6. package/dictionary/trans.ts +7 -1
  7. package/document/documentQuery.ts +6 -1
  8. package/document/index.ts +1 -0
  9. package/document/into.ts +15 -7
  10. package/document/liveRowScope.ts +79 -0
  11. package/fetch/client/fetchClient.ts +64 -12
  12. package/fetch/client/httpClient.ts +13 -4
  13. package/index.ts +22 -0
  14. package/package.json +1 -1
  15. package/server/akanOption.ts +20 -1
  16. package/server/akanServer.ts +5 -0
  17. package/server/mcp/McpAuth.ts +3 -2
  18. package/server/mcp/McpExecutionContext.ts +15 -14
  19. package/server/mcp/McpRateLimiter.ts +18 -67
  20. package/server/proxy/webProxyRunner.ts +2 -0
  21. package/server/resolver/database.resolver.ts +28 -13
  22. package/server/resolver/signal.resolver.ts +75 -35
  23. package/server/rscWorkerHost.ts +16 -7
  24. package/server/webRouter.ts +3 -0
  25. package/service/index.ts +1 -0
  26. package/service/predefinedAdaptor/index.ts +1 -0
  27. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  28. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  29. package/service/predefinedAdaptor/sql/types.ts +14 -12
  30. package/service/rateLimiter.ts +80 -0
  31. package/signal/base.signal.ts +7 -6
  32. package/signal/endpointRateLimit.ts +159 -0
  33. package/signal/index.ts +1 -0
  34. package/signal/middleware.ts +6 -5
  35. package/signal/serializer/fetch.serializer.ts +15 -4
  36. package/signal/signalContext.ts +148 -67
  37. package/signal/slice.ts +30 -9
  38. package/signal/types.ts +8 -0
  39. package/test/signalTestRuntime.ts +2 -1
  40. package/types/base/base.d.ts +1 -1
  41. package/types/client/cookie.d.ts +1 -1
  42. package/types/common/Logger.d.ts +2 -0
  43. package/types/dictionary/base.dictionary.d.ts +1 -1
  44. package/types/dictionary/dictionary.d.ts +8 -8
  45. package/types/document/documentQuery.d.ts +5 -1
  46. package/types/document/index.d.ts +1 -0
  47. package/types/document/into.d.ts +7 -7
  48. package/types/document/liveRowScope.d.ts +9 -0
  49. package/types/fetch/client/fetchClient.d.ts +9 -1
  50. package/types/index.d.ts +32 -0
  51. package/types/server/akanOption.d.ts +7 -1
  52. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  53. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  54. package/types/server/rscWorkerHost.d.ts +3 -0
  55. package/types/service/index.d.ts +1 -0
  56. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  57. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  58. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  59. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  60. package/types/service/rateLimiter.d.ts +24 -0
  61. package/types/signal/endpointRateLimit.d.ts +37 -0
  62. package/types/signal/index.d.ts +1 -0
  63. package/types/signal/signalContext.d.ts +33 -15
  64. package/types/signal/slice.d.ts +17 -5
  65. package/types/signal/types.d.ts +8 -0
  66. package/types/test/signalTestRuntime.d.ts +1 -1
  67. package/types/ui/recipe/buttonRecipe.d.ts +2 -2
  68. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  69. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  70. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  71. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  72. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  73. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  74. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  75. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  76. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  77. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  78. package/types/webkit/RenderFailure.d.ts +6 -0
  79. package/ui/Input.tsx +7 -1
  80. package/ui/recipe/buttonRecipe.ts +1 -0
  81. package/ui/recipe/inputRecipe.ts +13 -7
  82. package/ui/styles.css +1 -0
  83. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  84. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  85. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  86. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  87. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  88. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  89. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  90. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  91. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  92. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  93. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  94. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  95. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  96. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  97. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  98. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  99. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  100. package/webkit/RenderFailure.tsx +32 -0
  101. package/webkit/RenderLayer.tsx +22 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # akanjs
2
2
 
3
+ ## 3.0.1
4
+
5
+ ### Minor Changes
6
+
7
+ - 4871ae5: The default `robots.txt` no longer disallows AI crawlers. GPTBot, ClaudeBot, PerplexityBot, Google-Extended and the rest may read public pages like any other crawler; `/api` (the configured prefix), `/_akan`, `/admin`, `/manager` and `/private` stay disallowed. An app that wants to keep AI crawlers out puts its own `robots.txt` in `public/`, which is served ahead of the generated one.
8
+
9
+ ### Patch Changes
10
+
11
+ - 4871ae5: Send crawlers the whole page. A request whose user agent names a search engine, an AI crawler or a link-preview fetcher (Googlebot, GPTBot, ClaudeBot, Yeti, facebookexternalhit and the like) now waits for every Suspense boundary and gets each one rendered in place. Before, it got the Loading fallback with the content parked in hidden segments that only a script reveals — and even `ssr: "block"` left a large boundary there. Browsers keep streaming, and the HTML result cache keeps the crawler page apart from the browser page.
12
+ - 4871ae5: Drop the `robots.txt.tsx` special route. `/robots.txt` was always answered first by `public/robots.txt` or the generated file, so the page never rendered; a `robots.txt.tsx` file is now an ordinary page under the locale, and `isSpecialRouteLeaf` is gone from `akanjs/common`. Change the file by putting your own `robots.txt` in `public/`.
13
+
3
14
  ## 3.0.0
4
15
 
5
16
  ### Minor Changes
package/base/base.ts CHANGED
@@ -27,7 +27,7 @@ export type EnumInstance<RefName extends string = string, T = string | number> =
27
27
  export const isEnum = (enumRef: Cls) => Object.getPrototypeOf(Object.getPrototypeOf(enumRef) ?? {}) === EnumPrototype;
28
28
 
29
29
  /** `type` is `String` for string values, else `Int` when every value is an integer, else `Float`. */
30
- export const enumOf = <RefName extends string, T = string | number>(
30
+ export const enumOf = <RefName extends string, const T = string | number>(
31
31
  refName: RefName,
32
32
  values: readonly T[],
33
33
  ): EnumInstance<RefName, T> => {
package/client/cookie.ts CHANGED
@@ -46,19 +46,17 @@ export const cookies = (): Map<string, { name: string; value: string }> => {
46
46
  export const setCookie = (
47
47
  key: string,
48
48
  value: string,
49
- options: CookieOptions = { path: "/", sameSite: "none", secure: true },
49
+ { path = "/", sameSite = "lax", secure = true }: CookieOptions = {},
50
50
  ) => {
51
51
  if (getEnv().side === "server") return;
52
52
  if (!pageKeepsCookies()) {
53
53
  writeCookieJar({ ...readCookieJar(), [key]: value });
54
54
  return;
55
55
  }
56
- const encoded = `${key}=${value}`;
57
- const path = options.path ? `; path=${options.path}` : "";
58
- const sameSite = options.sameSite ? `; SameSite=${options.sameSite}` : "";
59
- const secure = options.secure ? "; Secure" : "";
56
+ const pathAttr = path ? `; path=${path}` : "";
57
+ const secureAttr = secure ? "; Secure" : "";
60
58
 
61
- document.cookie = `${encoded}${path}${sameSite}${secure}`;
59
+ document.cookie = `${key}=${value}${pathAttr}; SameSite=${sameSite}${secureAttr}`;
62
60
  };
63
61
 
64
62
  /** Reads through `cookies()` on both sides: a hand-rolled `split("=")` cuts base64 padding and skips the `j:` form. */
package/common/Logger.ts CHANGED
@@ -169,6 +169,16 @@ export class Logger {
169
169
  static isPromoted(record: LogRecord) {
170
170
  return record.attrs?.flight === true || record.attrs?.debug === true;
171
171
  }
172
+ /** `value` as one line of JSON for a message, a secret-named key masked at any depth and the text cut at `max`. */
173
+ static inlineData(value: unknown, max = 500): string {
174
+ let text: string;
175
+ try {
176
+ text = JSON.stringify(value, (key, val) => (key && redactedAttrKey.test(key) ? "[redacted]" : val)) ?? "";
177
+ } catch {
178
+ text = "[unserializable]";
179
+ }
180
+ return text.length > max ? `${text.slice(0, max)}…` : text;
181
+ }
172
182
  static redactAttrs(attrs: LogAttrs): LogAttrs {
173
183
  let redacted: LogAttrs | null = null;
174
184
  for (const key of Object.keys(attrs)) {
@@ -40,10 +40,15 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
40
40
  "The server took too long to answer. Please try again in a moment.",
41
41
  "서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요.",
42
42
  ],
43
+ tooManyRequests: [
44
+ "Too many requests. Please try again in {seconds} seconds.",
45
+ "요청이 너무 많습니다. {seconds}초 후 다시 시도해주세요.",
46
+ ],
43
47
  unexpectedResponse: [
44
48
  "The server returned an unexpected response ({status}).",
45
49
  "서버가 예상하지 못한 응답을 보냈습니다. ({status})",
46
50
  ],
51
+ immutableField: ["{field} cannot be changed after creation.", "{field}은(는) 생성 후에는 바꿀 수 없습니다."],
47
52
  })
48
53
  .translate({
49
54
  somethingWrong: ["Something's wrong!", "문제가 생겼어요!"],
@@ -69,6 +74,8 @@ export const baseDictionary = serviceDictionary(["en", "ko"])
69
74
  phoneInvalidError: ["Phone is not valid", "유효하지 않은 전화번호입니다."],
70
75
  ok: ["OK", "확인"],
71
76
  cancel: ["Cancel", "취소"],
77
+ retry: ["Try again", "다시 시도"],
78
+ pageLoadFailed: ["This page could not be loaded.", "이 화면을 불러오지 못했습니다."],
72
79
  unauthorized: ["Unauthorized", "권한 없음"],
73
80
  agent: ["Agent", "에이전트"],
74
81
  agentIntro: ["Ask about this page or tell the agent what to do.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
@@ -1,5 +1,5 @@
1
1
  import type { GetStateObject, ObjectAssign, Prettify } from "akanjs/base";
2
- import { interpolateTranslation, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
2
+ import { interpolateTranslation, Logger, parseAkanI18nEnv, pathGetLoose } from "akanjs/common";
3
3
 
4
4
  import { DictionaryRegistry } from "./dictionaryRegistry";
5
5
  import type { DictModule } from "./locale";
@@ -122,6 +122,12 @@ export const makeTrans = <
122
122
  this.timestamp = option.timestamp;
123
123
  }
124
124
 
125
+ override toString() {
126
+ return this.data === undefined
127
+ ? `${this.name}: ${this.message}`
128
+ : `${this.name}: ${this.message} ${Logger.inlineData(this.data)}`;
129
+ }
130
+
125
131
  toJSON() {
126
132
  return {
127
133
  error: this.message,
@@ -101,7 +101,12 @@ export type DocumentUpdate<T = any> = {
101
101
  [K in DocumentPath<T>]?: DocumentUpdateValue;
102
102
  };
103
103
 
104
- export interface DocumentUpdateOptions {
104
+ /** Server-only: no endpoint or slice builds it from a request, so a caller can never widen a read to removed rows. */
105
+ export interface DocumentScopeOptions {
106
+ withRemoved?: boolean;
107
+ }
108
+
109
+ export interface DocumentUpdateOptions extends DocumentScopeOptions {
105
110
  upsert?: boolean;
106
111
  }
107
112
 
package/document/index.ts CHANGED
@@ -6,6 +6,7 @@ export * from "./documentQuery";
6
6
  export * from "./documentSchema";
7
7
  export * from "./filterMeta";
8
8
  export * from "./into";
9
+ export * from "./liveRowScope";
9
10
  export * from "./loaderInfo";
10
11
  export * from "./noDocumentError";
11
12
  export * from "./queryEvaluator";
package/document/into.ts CHANGED
@@ -4,7 +4,7 @@ import type { DocumentModel, QueryOf } from "akanjs/constant";
4
4
  import type { FilterCls, FilterQueryOf, FilterSortOf, SchemaOf } from ".";
5
5
  import type { CacheDatabase, QueryMethodPart } from "./database";
6
6
  import type { DataLoader } from "./dataLoader";
7
- import type { DocumentQuery, DocumentUpdateInput, DocumentUpdateOptions } from "./documentQuery";
7
+ import type { DocumentQuery, DocumentScopeOptions, DocumentUpdateInput, DocumentUpdateOptions } from "./documentQuery";
8
8
  import { type LoaderBuilder, type ModelCls, makeLoaderBuilder } from "./loaderInfo";
9
9
  import type { DocumentProjection } from "./types";
10
10
 
@@ -61,18 +61,26 @@ export type Mdl<
61
61
  > = DefaultMdlStats<Doc, _RawDoc, Partial<_RawDoc>, _RawQuery, _Projection> & {
62
62
  refName: string;
63
63
  new (data: Partial<_RawDoc> | Partial<Doc>): Doc;
64
- find(query: _RawQuery, projection?: _Projection): FindManyChain<Doc>;
65
- findOne(query: _RawQuery, projection?: _Projection): FindOneChain<Doc>;
66
- findById(id: string | undefined, projection?: _Projection): Promise<Doc | null>;
67
- count(query: _RawQuery): Promise<number>;
68
- exists(query: _RawQuery): Promise<string | null>;
64
+ find(query: _RawQuery, projection?: _Projection | null, options?: DocumentScopeOptions): FindManyChain<Doc>;
65
+ findOne(query: _RawQuery, projection?: _Projection | null, options?: DocumentScopeOptions): FindOneChain<Doc>;
66
+ findById(
67
+ id: string | undefined,
68
+ projection?: _Projection | null,
69
+ options?: DocumentScopeOptions,
70
+ ): Promise<Doc | null>;
71
+ count(query: _RawQuery, options?: DocumentScopeOptions): Promise<number>;
72
+ exists(query: _RawQuery, options?: DocumentScopeOptions): Promise<string | null>;
69
73
 
70
74
  updateOne(
71
75
  query: _RawQuery,
72
76
  update: DocumentUpdateInput<_RawDoc>,
73
77
  options?: DocumentUpdateOptions,
74
78
  ): Promise<UpdateResult>;
75
- updateMany(query: _RawQuery, update: DocumentUpdateInput<_RawDoc>): Promise<UpdateResult>;
79
+ updateMany(
80
+ query: _RawQuery,
81
+ update: DocumentUpdateInput<_RawDoc>,
82
+ options?: DocumentScopeOptions,
83
+ ): Promise<UpdateResult>;
76
84
  removeOne(query: _RawQuery): Promise<UpdateResult>;
77
85
  removeMany(query: _RawQuery): Promise<UpdateResult>;
78
86
  updateById(id: string, update: DocumentUpdateInput<_RawDoc>, options?: DocumentUpdateOptions): Promise<UpdateResult>;
@@ -0,0 +1,79 @@
1
+ import {
2
+ type DocumentQuery,
3
+ type DocumentQueryNode,
4
+ type DocumentQueryOperator,
5
+ queryOperatorKeys,
6
+ } from "./documentQuery";
7
+
8
+ type RemovedAtTest = "isNull" | "isNotNull" | "value";
9
+
10
+ /**
11
+ * Why a query can never match a live row, or null: without `withRemoved` every read and write is scoped to
12
+ * `removedAt IS NULL`. `q.raw()` is SQL this walk cannot read, so it goes unchecked.
13
+ */
14
+ export class LiveRowScope {
15
+ static conflictOf(query: DocumentQuery | undefined, negated = false): string | null {
16
+ if (!query || typeof query !== "object") return null;
17
+ if (LiveRowScope.#isNode(query)) {
18
+ if (query.kind === "all" || query.kind === "any")
19
+ return query.queries.reduce<string | null>(
20
+ (found, sub) => found ?? LiveRowScope.conflictOf(sub, negated),
21
+ null,
22
+ );
23
+ if (query.kind === "not") return LiveRowScope.conflictOf(query.query, !negated);
24
+ return null;
25
+ }
26
+ return Object.entries(query).reduce<string | null>(
27
+ (found, [path, value]) => found ?? LiveRowScope.#fieldConflict(path, value, negated),
28
+ null,
29
+ );
30
+ }
31
+
32
+ static #fieldConflict(path: string, value: unknown, negated: boolean): string | null {
33
+ if (LiveRowScope.#isNode(value) && value.kind !== "op") return LiveRowScope.conflictOf(value, negated);
34
+ if (path !== "removedAt") return null;
35
+ for (const [test, spelled] of LiveRowScope.#testsOf(value)) {
36
+ const named = negated ? `not(removedAt ${spelled})` : `removedAt ${spelled}`;
37
+
38
+ if (test === "value") return `${named} compares a value no live row holds`;
39
+ if ((test === "isNotNull") !== negated) return `${named} asks for removed rows`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ static #testsOf(value: unknown): [RemovedAtTest, string][] {
45
+ if (value === undefined) return [];
46
+ if (value === null) return [["isNull", "null"]];
47
+ if (LiveRowScope.#isNode(value) && value.kind === "op") {
48
+ const test = LiveRowScope.#testOf(value.op, value.value);
49
+ return test ? [test] : [];
50
+ }
51
+ if (value && typeof value === "object" && !Array.isArray(value)) {
52
+ const operators = value as Record<string, unknown>;
53
+ const keys = Object.keys(operators).filter((key) => queryOperatorKeys.has(key));
54
+ if (keys.length)
55
+ return keys.flatMap((key): [RemovedAtTest, string][] => {
56
+ const spelled = `{ ${key}: ${String(operators[key])} }`;
57
+
58
+ if (key === "exists") return [[operators.exists ? "isNotNull" : "isNull", spelled]];
59
+ if (key === "missing" || key === "empty") return [[operators[key] ? "isNull" : "isNotNull", spelled]];
60
+ const test = LiveRowScope.#testOf(key as DocumentQueryOperator, operators[key]);
61
+ return test ? [test] : [];
62
+ });
63
+ }
64
+ return [["value", "eq"]];
65
+ }
66
+
67
+ static #testOf(op: DocumentQueryOperator, operand: unknown): [RemovedAtTest, string] | null {
68
+ if (op === "exists") return ["isNotNull", op];
69
+ if (op === "missing" || op === "empty") return ["isNull", op];
70
+ if ((op === "eq" || op === "ne") && operand === null) return [op === "eq" ? "isNull" : "isNotNull", `${op}(null)`];
71
+
72
+ if ((op === "oneOf" || op === "notOneOf") && !(operand as unknown[] | undefined)?.length) return null;
73
+ return ["value", op];
74
+ }
75
+
76
+ static #isNode(value: unknown): value is DocumentQueryNode {
77
+ return !!value && typeof value === "object" && "kind" in value;
78
+ }
79
+ }
@@ -92,6 +92,9 @@ export class FetchClient {
92
92
  readonly #handlerStore: Record<string, FetchHandler> = {};
93
93
  readonly #handlerFactory = new Map<string, FetchHandlerFactory>();
94
94
  #sharedRegistryAppliedVersion = 0;
95
+ #clientIp: string | null = null;
96
+ static #testClientIps = false;
97
+ static #testClientIpSeq = 0;
95
98
  serializedSignal: { [key: string]: SerializedSignal } = {};
96
99
  jwt: string | null = null;
97
100
 
@@ -120,6 +123,19 @@ export class FetchClient {
120
123
  sharedClientState.proxy = null;
121
124
  sharedClientState.origin = null;
122
125
  }
126
+ /**
127
+ * In a signal test every agent reaches the test server from loopback, so an IP rate limit would count them all as
128
+ * one caller: from here on each clone names its own address (RFC 2544's benchmarking range), which loopback is
129
+ * trusted to report.
130
+ */
131
+ static useTestClientIps(enabled = true) {
132
+ FetchClient.#testClientIps = enabled;
133
+ FetchClient.#testClientIpSeq = 0;
134
+ }
135
+ static #nextTestClientIp() {
136
+ const seq = ++FetchClient.#testClientIpSeq;
137
+ return `198.${18 + ((seq >> 16) & 1)}.${(seq >> 8) & 255}.${seq & 255}`;
138
+ }
123
139
  static #resolveSharedClientProxy(origin: string, Err?: ErrorConstructor) {
124
140
  if (typeof window === "undefined") return null;
125
141
 
@@ -238,8 +254,22 @@ export class FetchClient {
238
254
  for (const ws of this.#originWs.values()) ws.destroy();
239
255
  this.#originWs.clear();
240
256
  }
241
- clone({ origin, connect = true, jwt }: { origin?: string; connect?: boolean; jwt?: string } = {}) {
257
+
258
+ clone({
259
+ origin,
260
+ connect = !(origin && typeof window === "undefined"),
261
+ jwt,
262
+ clientIp,
263
+ }: {
264
+ origin?: string;
265
+ connect?: boolean;
266
+ jwt?: string;
267
+ /** Sent as `x-real-ip`, which a server believes only from a trusted proxy — a test's loopback is one. */
268
+ clientIp?: string | null;
269
+ } = {}) {
242
270
  const instance = new FetchClient(origin ?? this.origin, {}, this.serializedSignal, this.ErrorCls);
271
+ instance.#clientIp =
272
+ clientIp !== undefined ? clientIp : FetchClient.#testClientIps ? FetchClient.#nextTestClientIp() : this.#clientIp;
243
273
  Object.entries(this.handler).forEach(([key, handler]) => {
244
274
  if (!(key in instance.handler)) instance.handler[key] = handler;
245
275
  });
@@ -265,6 +295,21 @@ export class FetchClient {
265
295
  ws.connect();
266
296
  return ws;
267
297
  }
298
+ #makeRequestHeaders(option?: FetchPolicy): Record<string, string> {
299
+ const clientIp = this.#clientIp ? { "x-real-ip": this.#clientIp } : FetchClient.#forwardedClientIp(option);
300
+ return { ...this.#makeAuthHeaders(option), ...clientIp };
301
+ }
302
+
303
+ static #forwardedClientIp(option?: FetchPolicy): Record<string, string> {
304
+ if (option?.origin) return {};
305
+ try {
306
+ if (getEnv().side !== "server") return {};
307
+ const clientIp = requestHeaders().get("x-real-ip");
308
+ return clientIp ? { "x-real-ip": clientIp } : {};
309
+ } catch {
310
+ return {};
311
+ }
312
+ }
268
313
  #makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
269
314
  if (option?.token) return { Authorization: `Bearer ${option.token}` };
270
315
  try {
@@ -299,7 +344,7 @@ export class FetchClient {
299
344
  case "query":
300
345
  return async (...argData: unknown[]) => {
301
346
  const { option, argMap, url } = requestOf(argData);
302
- const headers = this.#makeAuthHeaders(option);
347
+ const headers = this.#makeRequestHeaders(option);
303
348
  const baseUrl = this.#baseUrlOf(endpoint, option?.origin);
304
349
  const timeout = option?.timeout ?? endpoint.timeout;
305
350
 
@@ -318,7 +363,7 @@ export class FetchClient {
318
363
  const { option, argMap, url } = requestOf(argData);
319
364
  const body = HttpClient.makeBody(bodyArgs, uploadArgs, argMap);
320
365
  const response = await this.http.send(endpoint.method ?? "POST", url, body, {
321
- headers: this.#makeAuthHeaders(option),
366
+ headers: this.#makeRequestHeaders(option),
322
367
  baseUrl: this.#baseUrlOf(endpoint, option?.origin),
323
368
  timeout: option?.timeout ?? endpoint.timeout,
324
369
  });
@@ -429,16 +474,16 @@ export class FetchClient {
429
474
  modelId: `${refName}Id`,
430
475
  lightModel: `light${capRefName}`,
431
476
  };
432
- const createGuards = signal.createGuards ?? signal.cruGuards;
433
- const updateGuards = signal.updateGuards ?? signal.cruGuards;
434
- const removeGuards = signal.removeGuards ?? signal.cruGuards;
477
+ const createGuards = FetchClient.#mountedGuards(signal, "create");
478
+ const updateGuards = FetchClient.#mountedGuards(signal, "update");
479
+ const removeGuards = FetchClient.#mountedGuards(signal, "remove");
435
480
 
436
481
  const mcp = (verb: keyof NonNullable<SerializedSignal["mcp"]>) => ({
437
482
  ...(signal.mcp?.[verb] === false ? { mcp: false as const } : {}),
438
483
  ...(signal.agents?.[verb] === false ? { agents: false as const } : {}),
439
484
  });
440
485
  const endpoint: { [key: string]: SerializedEndpoint } = {};
441
- if (signal.getGuards) {
486
+ if (FetchClient.#mountedGuards(signal, "get")) {
442
487
  endpoint[names.model] = {
443
488
  type: "query",
444
489
  args: [{ type: "param", name: names.modelId, refName: "ID" }],
@@ -486,6 +531,11 @@ export class FetchClient {
486
531
  }
487
532
  return endpoint;
488
533
  }
534
+ static #mountedGuards(signal: SerializedSignal, verb: "get" | "create" | "update" | "remove") {
535
+ if (signal.crud?.[verb] === false) return undefined;
536
+ if (verb === "get") return signal.getGuards;
537
+ return signal[`${verb}Guards`] ?? signal.cruGuards;
538
+ }
489
539
  #registerModelBaseEndpoint(refName: string, signal: SerializedSignal) {
490
540
  const capRefName = capitalize(refName);
491
541
  const names = {
@@ -503,15 +553,17 @@ export class FetchClient {
503
553
  this.#setHandlerFactory(key, () => this.#makeHttpFn(key, value, signal.prefix));
504
554
  });
505
555
 
506
- const anyCruGuards = signal.cruGuards ?? signal.createGuards ?? signal.updateGuards ?? signal.removeGuards;
507
- const updateGuards = signal.updateGuards ?? signal.cruGuards;
508
- if (signal.getGuards) {
556
+ const getGuards = FetchClient.#mountedGuards(signal, "get");
557
+ const updateGuards = FetchClient.#mountedGuards(signal, "update");
558
+ const anyCruGuards =
559
+ FetchClient.#mountedGuards(signal, "create") ?? updateGuards ?? FetchClient.#mountedGuards(signal, "remove");
560
+ if (getGuards) {
509
561
  this.#setHandlerFactory(names.viewModel, () =>
510
562
  this.#makeModelHandleFn(refName, names.model, names.viewModel, `${refName}View`),
511
563
  );
512
564
  this.#setHandlerFactory(names.getModelView, () => this.#makeModelObjFn(refName, names.getModelView));
513
565
  }
514
- if (signal.getGuards && anyCruGuards) {
566
+ if (getGuards && anyCruGuards) {
515
567
  this.#setHandlerFactory(names.editModel, () =>
516
568
  this.#makeModelHandleFn(refName, names.model, names.editModel, `${refName}Edit`),
517
569
  );
@@ -547,7 +599,7 @@ export class FetchClient {
547
599
  if (parentId) formData.append(fields.parentId, parentId);
548
600
  const url = FetchClient.makeHttpUrl(cap.endpointKey, endpoint, cap.prefix, new Map());
549
601
  return await this.http.post(url, formData, {
550
- headers: this.#makeAuthHeaders(option),
602
+ headers: this.#makeRequestHeaders(option),
551
603
  baseUrl: this.#baseUrlOf(endpoint),
552
604
  });
553
605
  }) as FetchHandler,
@@ -16,6 +16,7 @@ const jsonContentType = /^application\/(?:[\w.+-]+\+)?json\b/i;
16
16
 
17
17
  const transportErrorKeyMap = {
18
18
  408: "base.error.gatewayTimeout",
19
+ 429: "base.error.tooManyRequests",
19
20
  502: "base.error.serverUnavailable",
20
21
  503: "base.error.serverUnavailable",
21
22
  504: "base.error.gatewayTimeout",
@@ -130,12 +131,12 @@ export class HttpClient {
130
131
  try {
131
132
  return (await res.json()) as unknown;
132
133
  } catch (error) {
133
- throw this.#transportError(res.status, String(error));
134
+ throw this.#transportError(res, String(error));
134
135
  }
135
136
  }
136
137
  const raw = await res.text();
137
138
  const parsed = HttpClient.#parseJson(raw);
138
- if (parsed === undefined) throw this.#transportError(res.status, raw);
139
+ if (parsed === undefined) throw this.#transportError(res, raw);
139
140
  return parsed;
140
141
  }
141
142
 
@@ -147,9 +148,17 @@ export class HttpClient {
147
148
  }
148
149
  }
149
150
 
150
- #transportError(status: number, detail: string): RestoredError {
151
+ #transportError(res: Response, detail: string): RestoredError {
152
+ const { status } = res;
151
153
  const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
152
- return this.#restoreError({ error, data: { status }, details: detail.slice(0, transportDetailLimit) }, status);
154
+ const data = status === 429 ? { status, seconds: HttpClient.#retryAfterSeconds(res) } : { status };
155
+ return this.#restoreError({ error, data, details: detail.slice(0, transportDetailLimit) }, status);
156
+ }
157
+
158
+ static #retryAfterSeconds(res: Response): number {
159
+ const header = res.headers.get("retry-after")?.trim() ?? "";
160
+ const seconds = /^\d+$/.test(header) ? Number(header) : Math.ceil((Date.parse(header) - Date.now()) / 1000);
161
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : 60;
153
162
  }
154
163
 
155
164
  #restoreError(body: unknown, fallbackStatusCode: number): RestoredError {
package/index.ts CHANGED
@@ -213,6 +213,28 @@ export interface AkanNativeDesktopConfig {
213
213
  * for a native addon signed by another team. Read when the app is signed with a team (a Developer ID).
214
214
  */
215
215
  entitlements?: Record<string, AkanNativeValue>;
216
+ /**
217
+ * macOS: the Finder window `build-desktop --installer` opens its dmg in — no toolbar or sidebar, the app's and the
218
+ * Applications link's icons on a background, the app's icon on the mounted disk. Without it the window is 660×400 pt
219
+ * on the framework's background, an arrow from the app at (180, 170) to Applications at (480, 170). A position is an
220
+ * icon's center in points from the window's top left; the default background lines up with the default positions.
221
+ */
222
+ dmg?: AkanNativeDmgConfig;
223
+ }
224
+
225
+ export interface AkanNativeDmgConfig {
226
+ /** A PNG (or TIFF) the size of the window in points, relative to the app folder; `false` for a plain window. */
227
+ background?: string | false;
228
+ /** The same picture at twice the pixels, for a Retina display. */
229
+ background2x?: string;
230
+ /** The area under the title bar, in points. Default the background's size, at 200, 120 on the screen. */
231
+ window?: { x?: number; y?: number; width?: number; height?: number };
232
+ /** Default 128. */
233
+ iconSize?: number;
234
+ /** Default 13. */
235
+ textSize?: number;
236
+ app?: { x: number; y: number };
237
+ applications?: { x: number; y: number };
216
238
  }
217
239
 
218
240
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.1",
3
+ "version": "3.0.2-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,6 +1,13 @@
1
1
  import type { BackendEnv, PromiseOrObject } from "akanjs/base";
2
2
  import type { Adaptor, AdaptorCls, LlmOption } from "akanjs/service";
3
- import type { AgentQuotaHook, AgentUsageHook, CrossSiteOption, GuardCls, MiddlewareCls } from "akanjs/signal";
3
+ import type {
4
+ AgentQuotaHook,
5
+ AgentUsageHook,
6
+ CrossSiteOption,
7
+ GuardCls,
8
+ MiddlewareCls,
9
+ RateLimitSetting,
10
+ } from "akanjs/signal";
4
11
  import type { McpServerOption } from "./akanServer";
5
12
  import type { WebProxyRegistration } from "./proxy";
6
13
  import { HostBasePathWebProxy, LocaleWebProxy } from "./proxy";
@@ -21,6 +28,7 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
21
28
  #agentUsage: AgentUsageHook | null | undefined;
22
29
  #agentQuota: AgentQuotaHook | null | undefined;
23
30
  #crossSite: CrossSiteOption | undefined;
31
+ #getRateLimit: ((env: Env) => RateLimitSetting | false) | undefined;
24
32
  use(fnOrObject: ((env: Env) => Record<string, PromiseOrObject<unknown>>) | Record<string, PromiseOrObject<unknown>>) {
25
33
  if (typeof fnOrObject === "function")
26
34
  this.#getUses.push(fnOrObject as (env: Env) => Record<string, PromiseOrObject<unknown>>);
@@ -65,6 +73,14 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
65
73
  this.#crossSite = crossSite;
66
74
  return this;
67
75
  }
76
+ /**
77
+ * Endpoint budgets: a default for endpoints that declare none, overrides by endpoint key, and who `by: "account"`
78
+ * counts. Merged in mount order, the app's last; `AKAN_RATE_LIMIT=off` still turns them all off.
79
+ */
80
+ setRateLimit(settingOrFn: RateLimitSetting | false | ((env: Env) => RateLimitSetting | false)) {
81
+ this.#getRateLimit = typeof settingOrFn === "function" ? settingOrFn : () => settingOrFn;
82
+ return this;
83
+ }
68
84
  /** Injected into the `LlmAdaptorRole` adaptor as the `llmOption` use; merged in mount order, the app's last. */
69
85
  setLlm<Option extends LlmOption>(llmOrFn: Option | ((env: Env) => Option)) {
70
86
  if (typeof llmOrFn === "function") this.#getLlms.push(llmOrFn);
@@ -99,6 +115,9 @@ export class AkanOption<Env extends BackendEnv = BackendEnv> {
99
115
  getCrossSite(): CrossSiteOption | undefined {
100
116
  return this.#crossSite;
101
117
  }
118
+ getRateLimit(env: Env): RateLimitSetting | false | undefined {
119
+ return this.#getRateLimit?.(env);
120
+ }
102
121
  getLlm(env: Env): LlmOption {
103
122
  return Object.assign({}, ...this.#getLlms.map((fn) => fn(env)));
104
123
  }
@@ -15,6 +15,7 @@ import type {
15
15
  import type { ServerSignal, ServerSignalCls, WebsocketPublishData } from "akanjs/signal";
16
16
  import { AgentMeter } from "../signal/agentMeter";
17
17
  import { CrossSiteGuard } from "../signal/CrossSiteGuard";
18
+ import { EndpointRateLimit } from "../signal/endpointRateLimit";
18
19
  import { AgentRelayAccess } from "../signal/guards";
19
20
  import { createOpenApiDocument } from "../signal/openapi";
20
21
  import { FetchSerializer } from "../signal/serializer";
@@ -189,6 +190,7 @@ export class AkanServer {
189
190
  this.env = { ...env };
190
191
  this.openapi = options?.openapi ?? this.openapi;
191
192
 
193
+ EndpointRateLimit.reset();
192
194
  libs.forEach((lib) => {
193
195
  const mcp = lib.option.getMcp(this.env);
194
196
  if (mcp !== undefined) this.setMcp(mcp);
@@ -198,7 +200,10 @@ export class AkanServer {
198
200
  if (usage !== undefined || quota !== undefined) AgentMeter.use({ usage, quota });
199
201
  const crossSite = lib.option.getCrossSite();
200
202
  if (crossSite !== undefined) CrossSiteGuard.configure(crossSite);
203
+ const rateLimit = lib.option.getRateLimit(this.env);
204
+ if (rateLimit !== undefined) EndpointRateLimit.configure(rateLimit);
201
205
  });
206
+ EndpointRateLimit.applyEnv(process.env.AKAN_RATE_LIMIT, getEnv().environment);
202
207
  this.setMcp(options?.mcp ?? this.mcp);
203
208
  this.serverMode = serverMode;
204
209
 
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { PromiseOrObject } from "akanjs/base";
3
- import { clientAddressFromHeaders } from "akanjs/common";
3
+ import { SignalContext } from "../../signal/signalContext";
4
4
  import type { HttpRoutes } from "../types";
5
5
 
6
6
  export interface McpAuthOption {
@@ -157,7 +157,8 @@ export class McpAuth {
157
157
 
158
158
  static callerKey(req: Request): string {
159
159
  const token = McpAuth.#bearer(req);
160
- if (!token) return `ip:${clientAddressFromHeaders(req.headers) ?? "anonymous"}`;
160
+
161
+ if (!token) return `ip:${SignalContext.clientIpOf(req) ?? "anonymous"}`;
161
162
  const claims = McpAuth.#claims(token);
162
163
  const id = [claims?.sid, claims?.jti, claims?.sub].find((value) => typeof value === "string" && value);
163
164
  return typeof id === "string" ? `token:${id}` : `token:${createHash("sha256").update(token).digest("base64url")}`;
@@ -19,23 +19,24 @@ export class McpExecutionContext extends HttpExecutionContext {
19
19
  this.#arguments = args;
20
20
  }
21
21
 
22
- override async getArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
22
+ override async readArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
23
23
  const declared = new Set(endpointInfo.args.filter(McpExecutionContext.#describable).map((arg) => arg.name));
24
24
  const undeclared = Object.keys(this.#arguments).find((name) => !declared.has(name));
25
25
  if (undeclared) throw new McpArgumentError(`Unknown argument "${undeclared}".`);
26
- return endpointInfo.args.map((arg) => {
27
- const value = McpExecutionContext.#lift(arg, this.#arguments[arg.name] ?? null);
28
- try {
29
- return deserialize(arg.argRef, arg.arrDepth, value, {
30
- key: arg.name,
31
- nullable: arg.option?.nullable,
32
- enum: arg.enum,
33
- });
34
- } catch {
35
-
36
- throw new McpArgumentError(McpExecutionContext.#argumentMessage(arg, value));
37
- }
38
- });
26
+ return endpointInfo.args.map((arg) => McpExecutionContext.#lift(arg, this.#arguments[arg.name] ?? null));
27
+ }
28
+
29
+ override parseArg(arg: McpArg, value: unknown): unknown {
30
+ try {
31
+ return deserialize(arg.argRef, arg.arrDepth, value, {
32
+ key: arg.name,
33
+ nullable: arg.option?.nullable,
34
+ enum: arg.enum,
35
+ });
36
+ } catch {
37
+
38
+ throw new McpArgumentError(McpExecutionContext.#argumentMessage(arg, value));
39
+ }
39
40
  }
40
41
 
41
42
  override makeResponse(result: unknown, endpointInfo: EndpointInfo) {