akanjs 3.0.0 → 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 (127) hide show
  1. package/CHANGELOG.md +3670 -0
  2. package/base/base.ts +1 -1
  3. package/client/cookie.ts +4 -6
  4. package/client/csrTypes.ts +2 -2
  5. package/common/Logger.ts +10 -0
  6. package/common/index.ts +0 -1
  7. package/common/routeConvention.ts +1 -9
  8. package/dictionary/base.dictionary.ts +7 -0
  9. package/dictionary/trans.ts +7 -1
  10. package/document/documentQuery.ts +6 -1
  11. package/document/index.ts +1 -0
  12. package/document/into.ts +15 -7
  13. package/document/liveRowScope.ts +79 -0
  14. package/fetch/client/fetchClient.ts +64 -12
  15. package/fetch/client/httpClient.ts +13 -4
  16. package/index.ts +22 -0
  17. package/package.json +1 -1
  18. package/server/akanOption.ts +20 -1
  19. package/server/akanServer.ts +5 -0
  20. package/server/cachePolicy.ts +12 -2
  21. package/server/crawler.ts +6 -0
  22. package/server/head.tsx +0 -4
  23. package/server/mcp/McpAuth.ts +3 -2
  24. package/server/mcp/McpExecutionContext.ts +15 -14
  25. package/server/mcp/McpRateLimiter.ts +18 -67
  26. package/server/proxy/webProxyRunner.ts +2 -0
  27. package/server/resolver/database.resolver.ts +28 -13
  28. package/server/resolver/signal.resolver.ts +75 -35
  29. package/server/robots.ts +1 -23
  30. package/server/routeTreeBuilder.ts +4 -2
  31. package/server/rscWorker.tsx +12 -24
  32. package/server/rscWorkerHost.ts +16 -7
  33. package/server/ssrFromRscRenderer.tsx +19 -15
  34. package/server/ssrTypes.ts +9 -0
  35. package/server/webRouter.ts +9 -2
  36. package/service/index.ts +1 -0
  37. package/service/predefinedAdaptor/index.ts +1 -0
  38. package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
  39. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
  40. package/service/predefinedAdaptor/sql/types.ts +14 -12
  41. package/service/rateLimiter.ts +80 -0
  42. package/signal/base.signal.ts +7 -6
  43. package/signal/endpointRateLimit.ts +159 -0
  44. package/signal/index.ts +1 -0
  45. package/signal/middleware.ts +6 -5
  46. package/signal/serializer/fetch.serializer.ts +15 -4
  47. package/signal/signalContext.ts +148 -67
  48. package/signal/slice.ts +30 -9
  49. package/signal/types.ts +8 -0
  50. package/test/signalTestRuntime.ts +2 -1
  51. package/types/base/base.d.ts +1 -1
  52. package/types/client/cookie.d.ts +1 -1
  53. package/types/client/csrTypes.d.ts +2 -2
  54. package/types/common/Logger.d.ts +2 -0
  55. package/types/common/index.d.ts +1 -1
  56. package/types/common/routeConvention.d.ts +0 -2
  57. package/types/dictionary/base.dictionary.d.ts +1 -1
  58. package/types/dictionary/dictionary.d.ts +8 -8
  59. package/types/document/documentQuery.d.ts +5 -1
  60. package/types/document/index.d.ts +1 -0
  61. package/types/document/into.d.ts +7 -7
  62. package/types/document/liveRowScope.d.ts +9 -0
  63. package/types/fetch/client/fetchClient.d.ts +9 -1
  64. package/types/index.d.ts +32 -0
  65. package/types/server/akanOption.d.ts +7 -1
  66. package/types/server/cachePolicy.d.ts +2 -1
  67. package/types/server/crawler.d.ts +1 -0
  68. package/types/server/head.d.ts +0 -3
  69. package/types/server/mcp/McpExecutionContext.d.ts +4 -1
  70. package/types/server/mcp/McpRateLimiter.d.ts +2 -6
  71. package/types/server/rscWorkerHost.d.ts +3 -0
  72. package/types/server/ssrFromRscRenderer.d.ts +3 -1
  73. package/types/server/ssrTypes.d.ts +8 -0
  74. package/types/service/index.d.ts +1 -0
  75. package/types/service/predefinedAdaptor/index.d.ts +1 -0
  76. package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
  77. package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
  78. package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
  79. package/types/service/rateLimiter.d.ts +24 -0
  80. package/types/signal/endpointRateLimit.d.ts +37 -0
  81. package/types/signal/index.d.ts +1 -0
  82. package/types/signal/signalContext.d.ts +33 -15
  83. package/types/signal/slice.d.ts +17 -5
  84. package/types/signal/types.d.ts +8 -0
  85. package/types/test/signalTestRuntime.d.ts +1 -1
  86. package/types/ui/recipe/badgeRecipe.d.ts +2 -2
  87. package/types/ui/recipe/buttonRecipe.d.ts +4 -4
  88. package/types/ui/recipe/inputRecipe.d.ts +3 -3
  89. package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
  90. package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
  91. package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
  92. package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
  93. package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
  94. package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
  95. package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
  96. package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
  97. package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
  98. package/types/webkit/RenderFailure.d.ts +6 -0
  99. package/types/webkit/useFrameRuntime.d.ts +0 -1
  100. package/ui/Input.tsx +7 -1
  101. package/ui/recipe/buttonRecipe.ts +1 -0
  102. package/ui/recipe/inputRecipe.ts +13 -7
  103. package/ui/styles.css +1 -0
  104. package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
  105. package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
  106. package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
  107. package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
  108. package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
  109. package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +2 -1
  110. package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
  111. package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
  112. package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
  113. package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
  114. package/vendor/@akanjs/native/packages/cli/src/lib/exec.ts +11 -1
  115. package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
  116. package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
  117. package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
  118. package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
  119. package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
  120. package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
  121. package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
  122. package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
  123. package/vendor/use-agentic/CHANGELOG.md +663 -0
  124. package/webkit/CsrRouteTable.ts +0 -2
  125. package/webkit/RenderFailure.tsx +32 -0
  126. package/webkit/RenderLayer.tsx +22 -2
  127. package/webkit/useCsrValues.ts +21 -11
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Any,
3
3
  type BackendEnv,
4
+ Binary,
4
5
  type Cls,
5
6
  FIELD_META,
6
7
  INTERNAL_META,
@@ -21,8 +22,9 @@ import type { Internal, InternalCls, InternalInfo, MiddlewareCls } from ".";
21
22
  import { CrossSiteGuard } from "./CrossSiteGuard";
22
23
  import { EndpointCache } from "./endpointCache";
23
24
  import type { ArgInfo, EndpointInfo, EndpointType } from "./endpointInfo";
25
+ import { EndpointRateLimit } from "./endpointRateLimit";
24
26
  import { Exception, isExceptionLike } from "./exception";
25
- import { type GuardCls, guardOf } from "./guard";
27
+ import { type GuardCls, type GuardScope, guardOf } from "./guard";
26
28
  import { SignalFailure } from "./SignalFailure";
27
29
  import { getCurrentTrace, runTraced, SignalTrace, type TraceOrigin, traceSpan } from "./trace";
28
30
 
@@ -46,6 +48,21 @@ type MiddlewareHandler = (context: SignalContext, next: () => Promise<unknown>)
46
48
  /** Request-scoped: the outermost `resolveReturn` starts one and every recursion threads it down. */
47
49
  type ResolveCache = Map<ConstantFieldTypeInput, Map<string, Promise<unknown>>>;
48
50
 
51
+ interface SignalContextOption<Ctx, Env extends BackendEnv> {
52
+ endpointInfo: EndpointInfo;
53
+ adaptor: Adaptor;
54
+ registry: InjectRegistry;
55
+ env: Env;
56
+ live: LiveRegistry;
57
+ middleware: Map<string, MiddlewareCls>;
58
+ /** A caller-built context: MCP's arguments arrive as one named object, not in a URL. */
59
+ ctx?: Ctx;
60
+ /** Defaults to the transport. */
61
+ origin?: TraceOrigin;
62
+ /** Unset, the context joins the current trace or opens one that `exec()` closes. */
63
+ trace?: SignalTrace | null;
64
+ }
65
+
49
66
  export class SignalContext<
50
67
  Ctx extends HttpExecutionContext | WebSocketExecutionContext = HttpExecutionContext | WebSocketExecutionContext,
51
68
  Env extends BackendEnv = BackendEnv,
@@ -57,40 +74,22 @@ export class SignalContext<
57
74
  adaptor: Adaptor;
58
75
  /** `mcp` for a call through the MCP endpoint. Held here, not on the trace: a null trace would let a guard fail open. */
59
76
  readonly origin: TraceOrigin;
60
- args: unknown[] = [];
61
77
  internalArgs: unknown[] = [];
62
78
  trace: SignalTrace | null = null;
79
+ readonly #request: Bun.BunRequest | WebSocketRequest;
80
+ readonly #option: SignalContextOption<Ctx, Env>;
81
+ #rawArgs: unknown[] | null = null;
82
+ #args: unknown[] | null = null;
63
83
  #registry: InjectRegistry;
64
84
  #env: Env;
65
85
  #live: LiveRegistry;
66
86
  #middleware: Map<string, MiddlewareCls>;
67
87
  static #reported = new WeakSet<object>();
68
- constructor(
69
- key: string,
70
- reqOrWsReq: Bun.BunRequest | WebSocketRequest,
71
- {
72
- endpointInfo,
73
- adaptor,
74
- registry,
75
- env,
76
- live,
77
- middleware,
78
- ctx,
79
- origin,
80
- }: {
81
- endpointInfo: EndpointInfo;
82
- adaptor: Adaptor;
83
- registry: InjectRegistry;
84
- env: Env;
85
- live: LiveRegistry;
86
- middleware: Map<string, MiddlewareCls>;
87
- /** A caller-built context: MCP's arguments arrive as one named object, not in a URL. */
88
- ctx?: Ctx;
89
- /** Defaults to the transport. */
90
- origin?: TraceOrigin;
91
- },
92
- ) {
88
+ constructor(key: string, reqOrWsReq: Bun.BunRequest | WebSocketRequest, option: SignalContextOption<Ctx, Env>) {
89
+ const { endpointInfo, adaptor, registry, env, live, middleware, ctx, origin, trace } = option;
93
90
  this.key = key;
91
+ this.#request = reqOrWsReq;
92
+ this.#option = option;
94
93
  this.transport = httpEndpointTypes.has(endpointInfo.type) ? "http" : "websocket";
95
94
  this.origin = origin ?? this.transport;
96
95
  this.endpointInfo = endpointInfo;
@@ -103,7 +102,10 @@ export class SignalContext<
103
102
  this.#live = live;
104
103
  this.#middleware = middleware;
105
104
 
106
- this.trace = getCurrentTrace() ?? SignalTrace.create(key, endpointInfo.type, origin ?? this.transport);
105
+ this.trace =
106
+ trace !== undefined
107
+ ? trace
108
+ : (getCurrentTrace() ?? SignalTrace.create(key, endpointInfo.type, origin ?? this.transport));
107
109
  if (this.trace && this.transport === "http")
108
110
  this.trace.applyDebugHeader((reqOrWsReq as { headers?: Headers }).headers?.get?.("x-akan-debug"));
109
111
  }
@@ -120,28 +122,61 @@ export class SignalContext<
120
122
  if (!service) throw new Exception.Error(`Service "${refName}" not found in live registry`);
121
123
  return service as T;
122
124
  }
125
+ /** Reads the request but parses no argument: that waits for the account guards in `exec()`. */
123
126
  async init() {
124
127
 
125
128
  if (this.endpointInfo.type === "mutation" && this.transport === "http") {
126
129
  const httpCtx = this.getHttpContext();
127
130
  CrossSiteGuard.assertOrigin(httpCtx.req, httpCtx.url, this.key);
128
131
  }
132
+
133
+ if (!this.#isLeavingRoom()) await EndpointRateLimit.admit(this, "ip");
129
134
  const start = this.trace ? performance.now() : 0;
130
- this.args = await this.ctx.getArgs(this.endpointInfo);
131
- this.trace?.recordSpan("argParse", performance.now() - start);
135
+ this.#rawArgs = await this.ctx.readArgs(this.endpointInfo);
136
+ this.trace?.recordSpan("argRead", performance.now() - start);
132
137
  return this;
133
138
  }
139
+ /** Parsed on first read, so a guard or middleware that reads one still gets it; a parser runs at most once per call. */
140
+ get args(): unknown[] {
141
+ this.#args ??= this.#parseArgs();
142
+ return this.#args;
143
+ }
144
+ set args(args: unknown[]) {
145
+ this.#args = args;
146
+ }
147
+ #parseArgs(): unknown[] {
148
+ const raw = this.#rawArgs;
149
+ if (!raw) return [];
150
+ const start = this.trace ? performance.now() : 0;
151
+ const args = this.endpointInfo.args.map((arg, idx) => this.ctx.parseArg(arg, raw[idx]));
152
+ this.trace?.recordSpan("argParse", performance.now() - start);
153
+ return args;
154
+ }
155
+ #isLeavingRoom() {
156
+ return this.transport === "websocket" && this.getWebSocketContext().eventType === "unsubscribe";
157
+ }
134
158
 
135
- async #checkGuards() {
159
+ async #checkGuards(scope?: GuardScope) {
136
160
  for (const GuardCls of this.endpointInfo.signalOption.guards ?? []) {
161
+ if (scope && (GuardCls.scope === "account" ? "account" : "resource") !== scope) continue;
137
162
  if (!(await guardOf(GuardCls).canPass(this)))
138
163
  throw new Exception.Forbidden(`Access denied by guard: ${GuardCls.name}`);
139
164
  }
140
165
  }
141
- /** Re-checks the guards of a subscribed room outside a request; only global middlewares (account resolution) run. */
166
+ /**
167
+ * Re-checks the guards of a subscribed room outside a request; only global middlewares (account resolution) run. On
168
+ * a fresh context, so a guard that memoizes per context cannot replay the verdict it gave at subscribe.
169
+ */
142
170
  async authorize(): Promise<boolean> {
171
+ const fresh = new SignalContext<Ctx, Env>(this.key, this.#request, {
172
+ ...this.#option,
173
+ ctx: undefined,
174
+ trace: null,
175
+ });
176
+ fresh.#rawArgs = this.#rawArgs;
177
+ fresh.#args = this.#args;
143
178
  try {
144
- await this.#withMiddleware(async () => await this.#checkGuards(), { endpointMiddlewares: false })();
179
+ await fresh.#withMiddleware(async () => await fresh.#checkGuards(), { endpointMiddlewares: false })();
145
180
  return true;
146
181
  } catch {
147
182
  return false;
@@ -239,11 +274,16 @@ export class SignalContext<
239
274
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
240
275
  const coreExec = async () => {
241
276
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
277
+ await EndpointRateLimit.admit(this, "account");
242
278
  if (this.trace) {
243
- await traceSpan("guards", () => this.#checkGuards());
279
+ await traceSpan("guards", () => this.#checkGuards("account"));
244
280
  const account = this.get<{ id?: unknown }>("account");
245
281
  if (typeof account?.id === "string") this.trace.setAttr("userId", account.id);
246
- } else await this.#checkGuards();
282
+ } else await this.#checkGuards("account");
283
+
284
+ this.#args ??= this.#parseArgs();
285
+ if (this.trace) await traceSpan("resourceGuards", () => this.#checkGuards("resource"));
286
+ else await this.#checkGuards("resource");
247
287
  if (this.endpointInfo.internalArgs.length > 0) {
248
288
  this.internalArgs = await Promise.all(
249
289
  this.endpointInfo.internalArgs.map((arg) => {
@@ -271,11 +311,21 @@ export class SignalContext<
271
311
  };
272
312
  if (!this.trace) {
273
313
  const resolved = await SignalContext.resolveReturn(result, resolveOption);
274
- return this.ctx.makeResponse(this.#settleUndefined(resolved), this.endpointInfo);
314
+ return this.ctx.makeResponse(this.#settle(resolved), this.endpointInfo);
275
315
  }
276
316
  const resolved = await traceSpan("resolveReturn", () => SignalContext.resolveReturn(result, resolveOption));
277
- return await traceSpan("serialize", async () =>
278
- this.ctx.makeResponse(this.#settleUndefined(resolved), this.endpointInfo),
317
+ return await traceSpan("serialize", async () => this.ctx.makeResponse(this.#settle(resolved), this.endpointInfo));
318
+ }
319
+ #settle(resolved: unknown) {
320
+ this.#refuseBytesThroughAny(resolved);
321
+ return this.#settleUndefined(resolved);
322
+ }
323
+
324
+ #refuseBytesThroughAny(resolved: unknown) {
325
+ if (this.endpointInfo.returns.returnRef !== Any) return;
326
+ if (!(resolved instanceof ArrayBuffer) && !ArrayBuffer.isView(resolved)) return;
327
+ throw new Error(
328
+ `${this.endpointInfo.type} ${this.key} returns bytes through Any, which JSON cannot carry: declare Binary as its return`,
279
329
  );
280
330
  }
281
331
 
@@ -332,12 +382,17 @@ export class SignalContext<
332
382
  path: endpointInfo.getPath(key),
333
383
  timestamp: new Date().toISOString(),
334
384
  }),
335
- { status: error.statusCode, headers: { "Content-Type": "application/json" } },
385
+ { status: error.statusCode, headers: SignalContext.#exceptionHeaders(error) },
336
386
  );
337
387
  }
338
388
  return SignalFailure.response(error, { path: endpointInfo.getPath(key) });
339
389
  }
340
390
  }
391
+ static #exceptionHeaders(error: { statusCode: number }): Record<string, string> {
392
+ const seconds = (error as { data?: { seconds?: unknown } }).data?.seconds;
393
+ if (error.statusCode !== 429 || typeof seconds !== "number") return { "Content-Type": "application/json" };
394
+ return { "Content-Type": "application/json", "Retry-After": String(Math.max(1, Math.ceil(seconds))) };
395
+ }
341
396
  static async resolveReturn(
342
397
  value: unknown,
343
398
  {
@@ -573,16 +628,24 @@ export class SignalContext<
573
628
  * `::ffff:`; `null`, never a placeholder, when neither is known.
574
629
  */
575
630
  getClientIp(): string | null {
576
- if (this.transport === "http") {
577
- const { req } = this.getHttpContext();
578
-
579
- const peer = SignalContext.#httpPeer?.(req);
580
- return TrustedProxy.clientAddress(req.headers, peer === undefined ? undefined : (peer?.address ?? null));
581
- }
631
+ if (this.transport === "http") return SignalContext.clientIpOf(this.getHttpContext().req);
582
632
  const { ws } = this.getWebSocketContext<{ headers?: Headers }>();
583
633
  if (!ws.data.headers) return ws.remoteAddress ? normalizeIpAddress(ws.remoteAddress) : null;
584
634
  return TrustedProxy.clientAddress(ws.data.headers, ws.remoteAddress);
585
635
  }
636
+ static #carriedIps = new WeakMap<Request, string | null>();
637
+ /** A request a proxy rebuilt has no socket behind it for `requestIP`, so it keeps the address its original resolved. */
638
+ static carryClientIp(target: Request, source: Request) {
639
+ if (target !== source) SignalContext.#carriedIps.set(target, SignalContext.clientIpOf(source));
640
+ }
641
+ /** `getClientIp` for a request no context wraps, as the MCP router and the page renderer hold one. */
642
+ static clientIpOf(req: Request): string | null {
643
+ const carried = SignalContext.#carriedIps.get(req);
644
+ if (carried !== undefined) return carried;
645
+
646
+ const peer = SignalContext.#httpPeer?.(req);
647
+ return TrustedProxy.clientAddress(req.headers, peer === undefined ? undefined : (peer?.address ?? null));
648
+ }
586
649
  /** The caller's source port as the nearest proxy recorded it, else this socket's own. */
587
650
  getClientPort(): number | null {
588
651
  if (this.transport === "http") {
@@ -668,6 +731,11 @@ export class HttpExecutionContext<Appended = unknown> {
668
731
  }
669
732
  }
670
733
  async getArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
734
+ const raw = await this.readArgs(endpointInfo);
735
+ return endpointInfo.args.map((arg, idx) => this.parseArg(arg, raw[idx]));
736
+ }
737
+ /** Each argument as the request carries it; `parseArg` turns one into its declared type. */
738
+ async readArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
671
739
  if (endpointInfo.args.length === 0) return [];
672
740
  this.params = this.req.params;
673
741
  const hasBodyArgs = endpointInfo.args.some((arg) => arg.type === "body");
@@ -694,29 +762,42 @@ export class HttpExecutionContext<Appended = unknown> {
694
762
  }
695
763
  }
696
764
 
697
- const args = endpointInfo.args.map((arg) => {
765
+ return endpointInfo.args.map((arg) => {
698
766
  switch (arg.type) {
699
767
  case "param":
700
- return deserializeArg(arg, this.params[arg.name]);
768
+ return this.params[arg.name];
701
769
  case "body":
702
- if (arg.argRef === Upload) return this.body[arg.name];
703
- return deserializeArg(arg, this.body[arg.name]);
704
- case "search": {
705
- const raw = arg.arrDepth ? this.url.searchParams.getAll(arg.name) : this.url.searchParams.get(arg.name);
706
- const value = arg.argRef === Any ? HttpExecutionContext.#parseAny(arg.name, raw) : raw;
707
- const result = deserializeArg(arg, value);
708
- this.searchParams[arg.name] = result;
709
- return result;
710
- }
770
+ return this.body[arg.name];
771
+ case "search":
772
+ return arg.arrDepth ? this.url.searchParams.getAll(arg.name) : this.url.searchParams.get(arg.name);
711
773
  default:
712
774
  return undefined;
713
775
  }
714
776
  });
715
- return args;
777
+ }
778
+ parseArg(arg: ArgInfo, raw: unknown): unknown {
779
+ switch (arg.type) {
780
+ case "param":
781
+ return deserializeArg(arg, raw);
782
+ case "body":
783
+ return arg.argRef === Upload ? raw : deserializeArg(arg, raw);
784
+ case "search": {
785
+ const value =
786
+ arg.argRef === Any ? HttpExecutionContext.#parseAny(arg.name, raw as string | string[] | null) : raw;
787
+ const result = deserializeArg(arg, value);
788
+ this.searchParams[arg.name] = result;
789
+ return result;
790
+ }
791
+ default:
792
+ return undefined;
793
+ }
716
794
  }
717
795
  makeResponse(result: unknown, endpointInfo: EndpointInfo) {
718
796
  if (result instanceof Response) return result;
719
797
  if (endpointInfo.returns.arrDepth === 0 && PrimitiveRegistry.has(endpointInfo.returns.returnRef as Cls)) {
798
+
799
+ if (endpointInfo.returns.returnRef === Binary && result instanceof Uint8Array)
800
+ return this.res.json(Binary.serializeValue(result));
720
801
  return this.res.json(result);
721
802
  }
722
803
  const value = serialize(endpointInfo.returns.returnRef, endpointInfo.returns.arrDepth, result, "object", {
@@ -740,16 +821,16 @@ export class WebSocketExecutionContext<Appended = unknown> {
740
821
  this.eventType = wsReq.eventType;
741
822
  }
742
823
  async getArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
743
- const args = endpointInfo.args.map((arg, idx) => {
744
- switch (arg.type) {
745
- case "msg":
746
- case "room":
747
- return deserializeArg(arg, this.data[idx]);
748
- default:
749
- return undefined;
750
- }
751
- });
752
- return args;
824
+ const raw = await this.readArgs(endpointInfo);
825
+ return endpointInfo.args.map((arg, idx) => this.parseArg(arg, raw[idx]));
826
+ }
827
+ async readArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
828
+ return endpointInfo.args.map((arg, idx) =>
829
+ arg.type === "msg" || arg.type === "room" ? this.data[idx] : undefined,
830
+ );
831
+ }
832
+ parseArg(arg: ArgInfo, raw: unknown): unknown {
833
+ return arg.type === "msg" || arg.type === "room" ? deserializeArg(arg, raw) : undefined;
753
834
  }
754
835
  makeResponse(result: unknown, endpointInfo: EndpointInfo) {
755
836
  return serialize(endpointInfo.returns.returnRef, endpointInfo.returns.arrDepth, result, "object", {
package/signal/slice.ts CHANGED
@@ -39,6 +39,7 @@ export type SliceCls<
39
39
  updateGuards: GuardCls[];
40
40
  removeGuards: GuardCls[];
41
41
  mcp: ResolvedSliceMcp;
42
+ crud: ResolvedSliceCrud;
42
43
  };
43
44
 
44
45
  /** `root` is absent: it rides on the root slice itself. */
@@ -49,14 +50,26 @@ export interface ResolvedSliceMcp {
49
50
  remove: boolean;
50
51
  }
51
52
 
53
+ /** Which generated verbs the server mounts: a verb whose guards resolve to none (or `false`) has no route at all. */
54
+ export interface ResolvedSliceCrud {
55
+ get: boolean;
56
+ create: boolean;
57
+ update: boolean;
58
+ remove: boolean;
59
+ }
60
+
52
61
  interface RootSliceOption {
62
+ /**
63
+ * `false` (or no guard at all) mounts no generated endpoint for that verb, which is the only way to take one off
64
+ * HTTP: `mcp: { cru: false }` merely keeps it off the agent shelf. `create`/`update`/`remove` fall back to `cru`.
65
+ */
53
66
  guards?: {
54
67
  root?: GuardCls | GuardCls[];
55
- get?: GuardCls | GuardCls[];
56
- cru?: GuardCls | GuardCls[];
57
- create?: GuardCls | GuardCls[];
58
- update?: GuardCls | GuardCls[];
59
- remove?: GuardCls | GuardCls[];
68
+ get?: GuardCls | GuardCls[] | false;
69
+ cru?: GuardCls | GuardCls[] | false;
70
+ create?: GuardCls | GuardCls[] | false;
71
+ update?: GuardCls | GuardCls[] | false;
72
+ remove?: GuardCls | GuardCls[] | false;
60
73
  };
61
74
  /**
62
75
  * Keyed like `guards`, for the root slice and generated CRUD only; `create`/`update`/`remove` fall back to `cru`
@@ -148,14 +161,21 @@ export function slice<
148
161
  if (!srv.cnst || !srv.db) throw new Error("cnst and db are required");
149
162
  const filterRef = srv.db.filter;
150
163
  const init = buildSlice(srv.srv.refName, srv.cnst.input, srv.cnst.full, srv.cnst.light, srv.cnst.insight, filterRef);
151
- const toGuards = (guard?: GuardCls | GuardCls[]) => (guard ? (Array.isArray(guard) ? guard : [guard]) : []);
164
+ const toGuards = (guard?: GuardCls | GuardCls[] | false) => (guard ? (Array.isArray(guard) ? guard : [guard]) : []);
152
165
  const rootGuards = toGuards(option.guards?.root);
153
166
  const getGuards = toGuards(option.guards?.get);
154
167
  const cruGuards = toGuards(option.guards?.cru);
155
168
 
156
- const createGuards = option.guards?.create ? toGuards(option.guards.create) : cruGuards;
157
- const updateGuards = option.guards?.update ? toGuards(option.guards.update) : cruGuards;
158
- const removeGuards = option.guards?.remove ? toGuards(option.guards.remove) : cruGuards;
169
+ const createGuards = option.guards?.create !== undefined ? toGuards(option.guards.create) : cruGuards;
170
+ const updateGuards = option.guards?.update !== undefined ? toGuards(option.guards.update) : cruGuards;
171
+ const removeGuards = option.guards?.remove !== undefined ? toGuards(option.guards.remove) : cruGuards;
172
+
173
+ const crud: ResolvedSliceCrud = {
174
+ get: getGuards.length > 0,
175
+ create: createGuards.length > 0,
176
+ update: updateGuards.length > 0,
177
+ remove: removeGuards.length > 0,
178
+ };
159
179
  const mcpOption =
160
180
  typeof option.mcp === "boolean" ? { root: option.mcp, get: option.mcp, cru: option.mcp } : option.mcp;
161
181
  const cruMcp = mcpOption?.cru !== false;
@@ -179,6 +199,7 @@ export function slice<
179
199
  static updateGuards = updateGuards;
180
200
  static removeGuards = removeGuards;
181
201
  static mcp = mcp;
202
+ static crud = crud;
182
203
 
183
204
  static [SLICE_META] = Object.assign(
184
205
  {},
package/signal/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DefaultPrimitiveName, UnCls } from "akanjs/base";
2
2
  import type { ServiceModel } from "akanjs/service";
3
+ import type { RateLimitBudget } from "./endpointRateLimit";
3
4
  import type { GuardCls } from "./guard";
4
5
  import type { MiddlewareCls } from "./middleware";
5
6
  import type { SliceCls } from "./slice";
@@ -94,6 +95,11 @@ export interface SignalOption<Response = any, Nullable extends boolean = false,
94
95
  * internal argument may carry one (those answer per caller), and the lookup runs after the guards.
95
96
  */
96
97
  cache?: number;
98
+ /**
99
+ * Calls per caller per window, refused with 429 `base.error.tooManyRequests`. `by: "ip"` is counted before the body
100
+ * is read, `by: "account"` after the middlewares and before the guards; `false` exempts it from the app's default.
101
+ */
102
+ rateLimit?: RateLimitBudget | false;
97
103
  guards?: GuardCls[];
98
104
  middlewares?: MiddlewareCls[];
99
105
  prefix?: false | string;
@@ -181,6 +187,8 @@ export interface SerializedSignal {
181
187
  removeGuards?: string[];
182
188
  /** Generated CRUD verbs kept off the agent shelf, carried here because `FetchClient.getBaseEndpoint` synthesizes them. */
183
189
  mcp?: SerializedSignalMcp;
190
+ /** Generated CRUD verbs the server does not mount at all, so no client synthesizes them. */
191
+ crud?: SerializedSignalMcp;
184
192
  /** Which generated CRUD verbs a person-only guard protects, by the same verb map; only the `false` keys travel. */
185
193
  agents?: SerializedSignalMcp;
186
194
  }
@@ -1,5 +1,5 @@
1
1
  import type { BackendEnv, BaseEnv } from "akanjs/base";
2
- import type { FetchProxy } from "akanjs/fetch";
2
+ import { FetchClient, type FetchProxy } from "akanjs/fetch";
3
3
  import type { AkanLib } from "akanjs/server";
4
4
  import { type TestEnv, TestServer, type TestServerOptions } from "./testServer";
5
5
 
@@ -113,6 +113,7 @@ export const setupSignalTestTarget = async <Fetch = FetchProxy>(
113
113
  storage: "memory",
114
114
  ...resolvedOptions,
115
115
  });
116
+ FetchClient.useTestClientIps();
116
117
  await testServer.init();
117
118
  currentContext = {
118
119
  fetch: target.fetch,
@@ -23,7 +23,7 @@ export type EnumInstance<RefName extends string = string, T = string | number> =
23
23
  /** True for a `class X extends enumOf(...) {}`, not for the bare `enumOf(...)` result. */
24
24
  export declare const isEnum: (enumRef: Cls) => boolean;
25
25
  /** `type` is `String` for string values, else `Int` when every value is an integer, else `Float`. */
26
- export declare const enumOf: <RefName extends string, T = string | number>(refName: RefName, values: readonly T[]) => EnumInstance<RefName, T>;
26
+ export declare const enumOf: <RefName extends string, const T = string | number>(refName: RefName, values: readonly T[]) => EnumInstance<RefName, T>;
27
27
  export declare class DataList<Light extends {
28
28
  id: string;
29
29
  }> {
@@ -9,7 +9,7 @@ export declare const cookies: () => Map<string, {
9
9
  name: string;
10
10
  value: string;
11
11
  }>;
12
- export declare const setCookie: (key: string, value: string, options?: CookieOptions) => void;
12
+ export declare const setCookie: (key: string, value: string, { path, sameSite, secure }?: CookieOptions) => void;
13
13
  /** Reads through `cookies()` on both sides: a hand-rolled `split("=")` cuts base64 padding and skips the `j:` form. */
14
14
  export declare const getCookie: (key: string) => string | undefined;
15
15
  export declare const removeCookie: (key: string, options?: {
@@ -113,6 +113,8 @@ export interface RouteRender {
113
113
  resolveHead?: ResolveHead;
114
114
  getPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
115
115
  getLayoutPageConfig?: () => PromiseOrObject<PageConfig | undefined>;
116
+ /** A root layout's `theme`, readable before it renders. */
117
+ getLayoutTheme?: () => PromiseOrObject<string | undefined>;
116
118
  /** The `page()` chain behind a page render, when it was declared as one — what a page prompt is read off. */
117
119
  getRouteDefinition?: () => PromiseOrObject<RouteDefinition | undefined>;
118
120
  }
@@ -176,7 +178,6 @@ export interface Route {
176
178
  /** Synthetic layout render from a `_overrides.tsx` at this node; wraps the subtree in a UI-override provider. */
177
179
  renderOverrides?: RouteRender;
178
180
  pageIncludesOwnLayout?: boolean;
179
- isSpecialRoute?: boolean;
180
181
  loader?: () => unknown;
181
182
  pageState?: PageState;
182
183
  pageConfigChain?: PageConfig[];
@@ -327,7 +328,6 @@ export interface PathRoute {
327
328
  renderRootLayouts: RouteRender[];
328
329
  renderLayouts: RouteRender[];
329
330
  resolveHead?: ResolveHead;
330
- isSpecialRoute?: boolean;
331
331
  }
332
332
  export type FrameSlotScope = "page" | "layout";
333
333
  export type FrameSlotType = "topInset" | "bottomInset";
@@ -77,6 +77,8 @@ export declare class Logger {
77
77
  static formatAttrs(attrs: LogAttrs): string;
78
78
  /** A flight-recorder or `x-akan-debug` record passes every floor between here and the reader. */
79
79
  static isPromoted(record: LogRecord): boolean;
80
+ /** `value` as one line of JSON for a message, a secret-named key masked at any depth and the text cut at `max`. */
81
+ static inlineData(value: unknown, max?: number): string;
80
82
  static redactAttrs(attrs: LogAttrs): LogAttrs;
81
83
  static emit({ level, name, message, context, attrs }: LoggerEmitInput): void;
82
84
  /** Writes a failed request's flight-recorder records past the console level, each marked `flight`. */
@@ -34,7 +34,7 @@ export { randomPick, randomPicks } from "./randomPick.d.ts";
34
34
  export { hostFromRequest, isJsonContentType, originFromRequest } from "./requestOrigin.d.ts";
35
35
  export { RestClient, type RestClientOptions, type RestRequestOptions } from "./restClient.d.ts";
36
36
  export { round } from "./round.d.ts";
37
- export { assertUniqueRoutePatterns, compareRouteSpecificity, getPageSourceFileViolation, getRouteExports, isRouteSourceFile, isSpecialRouteLeaf, LAYOUT_ROUTE_EXPORTS, matchRoutePattern, normalizeRoutePattern, PAGE_ROUTE_EXPORTS, type ParsedRouteModuleKey, parseRouteModuleKey, RESERVED_ROUTE_CONFIG_EXPORTS, ROOT_LAYOUT_ROUTE_EXPORTS, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
37
+ export { assertUniqueRoutePatterns, compareRouteSpecificity, getPageSourceFileViolation, getRouteExports, isRouteSourceFile, LAYOUT_ROUTE_EXPORTS, matchRoutePattern, normalizeRoutePattern, PAGE_ROUTE_EXPORTS, type ParsedRouteModuleKey, parseRouteModuleKey, RESERVED_ROUTE_CONFIG_EXPORTS, ROOT_LAYOUT_ROUTE_EXPORTS, type RouteModuleKind, routeSegmentToPatternPart, routeSegmentToTreePath, tryParseRouteModuleKey, type ValidatePageSourceFileOptions, type ValidateSubRoutePageKeyOptions, validatePageSourceFile, validateSubRoutePageKey, } from "./routeConvention.d.ts";
38
38
  export { sleep } from "./sleep.d.ts";
39
39
  export { getBasePathFromPathname, parseBasePaths, parseSubRouteHosts, resolveSubRouteHosts } from "./subRoute.d.ts";
40
40
  export { TrustedProxy } from "./TrustedProxy.d.ts";
@@ -16,7 +16,6 @@ export interface ParsedRouteModuleKey {
16
16
  ext: string;
17
17
  leaf: string;
18
18
  pattern: string;
19
- isSpecialRoute: boolean;
20
19
  isInternalRootLayout: boolean;
21
20
  }
22
21
  export interface ValidateSubRoutePageKeyOptions {
@@ -32,7 +31,6 @@ export declare function getPageSourceFileViolation(filePath: string): string | n
32
31
  export declare function validatePageSourceFile(filePath: string, options?: ValidatePageSourceFileOptions): boolean;
33
32
  export declare function validateSubRoutePageKey(key: string, basePaths: Iterable<string>, options?: ValidateSubRoutePageKeyOptions): void;
34
33
  export declare function parseRouteModuleKey(key: string): ParsedRouteModuleKey;
35
- export declare function isSpecialRouteLeaf(leaf: string): boolean;
36
34
  export declare function tryParseRouteModuleKey(key: string): ParsedRouteModuleKey | null;
37
35
  export declare function normalizeRoutePattern(segments: string[]): string;
38
36
  export declare function routeSegmentToPatternPart(segment: string): string;
@@ -1 +1 @@
1
- export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", "serverUnreachable" | "serverUnavailable" | "gatewayTimeout" | "unexpectedResponse", "edit" | "error" | "ok" | "approve" | "remove" | "unauthorized" | "save" | "refresh" | "password" | "agent" | "skip" | "stop" | "send" | "success" | "somethingWrong" | "connecting" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "cameraPromptHeader" | "cameraPromptPhoto" | "cameraPromptPicture" | "cameraPromptCancel" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentToolCard" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachReading" | "agentReferenceRemove" | "agentReferenceFailed" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentQueue" | "agentQueuePlaceholder" | "agentQueued" | "agentQueueEdit" | "agentQueueCancel" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentTokensOf" | "agentCompactsAt" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "noSelection" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "draftConflict" | "draftApplied" | "draftRestore" | "draftDiscard" | "draftStartOver" | "new" | "view">;
1
+ export declare const baseDictionary: import("./dictInfo.d.ts").ServiceDictInfo<[string, string], "ping" | "pingBody" | "pingParam" | "pingQuery" | "wsPing" | "pubsubPing", "serverUnreachable" | "serverUnavailable" | "gatewayTimeout" | "tooManyRequests" | "unexpectedResponse" | "immutableField", "edit" | "error" | "ok" | "retry" | "approve" | "remove" | "unauthorized" | "save" | "refresh" | "password" | "agent" | "skip" | "stop" | "send" | "success" | "somethingWrong" | "connecting" | "connected" | "serverDisconnected" | "refreshing" | "tryReconnecting" | "serverHasProblem" | "checkServerStatus" | "cameraPromptHeader" | "cameraPromptPhoto" | "cameraPromptPicture" | "cameraPromptCancel" | "failed" | "processing" | "processed" | "noData" | "invalidValueError" | "emailInvalidError" | "phoneInvalidError" | "cancel" | "pageLoadFailed" | "agentIntro" | "agentPlaceholder" | "agentClear" | "agentQuestion" | "agentToolCard" | "agentAnswer" | "agentListen" | "agentVoiceFailed" | "agentAttach" | "agentAttachRemove" | "agentAttachReading" | "agentReferenceRemove" | "agentReferenceFailed" | "agentAttachTooLarge" | "agentAttachUnsupported" | "agentAttachDuplicate" | "agentAttachTooMany" | "agentAttachTooMuch" | "agentAnswerNeeded" | "agentQueue" | "agentQueuePlaceholder" | "agentQueued" | "agentQueueEdit" | "agentQueueCancel" | "agentToolDone" | "agentToolFailed" | "agentToolRunning" | "agentToolResult" | "agentTokens" | "agentTokensOf" | "agentCompactsAt" | "agentSummarizing" | "agentContinue" | "agentKeepGoing" | "agentCmdNew" | "agentCmdRetry" | "agentCmdCompact" | "agentCmdCopy" | "agentCmdHelp" | "agentCmdTools" | "agentHelpIntro" | "agentHelpNote" | "agentNothingToRetry" | "agentNothingToCompact" | "agentCompacted" | "agentSummary" | "agentBusy" | "agentCopied" | "agentCopyFailed" | "agentToolsHead" | "agentToolsState" | "decline" | "confirmClose" | "textTooShortError" | "textTooLongError" | "selectTooShortError" | "selectTooLongError" | "numberTooSmallError" | "numberTooBigError" | "passwordNotMatchError" | "selectDateError" | "priceUnit" | "passwordConfirm" | "noOptions" | "noSelection" | "addModel" | "createModel" | "createSuccess" | "updateModel" | "removeModel" | "updateSuccess" | "removeSuccess" | "sureToRemove" | "irreversibleOps" | "typeNameToRemove" | "yesRemove" | "removeMsg" | "confirmMsg" | "perPage" | "cardView" | "tableView" | "exportCsv" | "exportJson" | "actions" | "draftConflict" | "draftApplied" | "draftRestore" | "draftDiscard" | "draftStartOver" | "new" | "view">;