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.
- package/CHANGELOG.md +11 -0
- package/base/base.ts +1 -1
- package/client/cookie.ts +4 -6
- package/common/Logger.ts +10 -0
- package/dictionary/base.dictionary.ts +7 -0
- package/dictionary/trans.ts +7 -1
- package/document/documentQuery.ts +6 -1
- package/document/index.ts +1 -0
- package/document/into.ts +15 -7
- package/document/liveRowScope.ts +79 -0
- package/fetch/client/fetchClient.ts +64 -12
- package/fetch/client/httpClient.ts +13 -4
- package/index.ts +22 -0
- package/package.json +1 -1
- package/server/akanOption.ts +20 -1
- package/server/akanServer.ts +5 -0
- package/server/mcp/McpAuth.ts +3 -2
- package/server/mcp/McpExecutionContext.ts +15 -14
- package/server/mcp/McpRateLimiter.ts +18 -67
- package/server/proxy/webProxyRunner.ts +2 -0
- package/server/resolver/database.resolver.ts +28 -13
- package/server/resolver/signal.resolver.ts +75 -35
- package/server/rscWorkerHost.ts +16 -7
- package/server/webRouter.ts +3 -0
- package/service/index.ts +1 -0
- package/service/predefinedAdaptor/index.ts +1 -0
- package/service/predefinedAdaptor/rateLimit.adaptor.ts +15 -0
- package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +119 -27
- package/service/predefinedAdaptor/sql/types.ts +14 -12
- package/service/rateLimiter.ts +80 -0
- package/signal/base.signal.ts +7 -6
- package/signal/endpointRateLimit.ts +159 -0
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +6 -5
- package/signal/serializer/fetch.serializer.ts +15 -4
- package/signal/signalContext.ts +148 -67
- package/signal/slice.ts +30 -9
- package/signal/types.ts +8 -0
- package/test/signalTestRuntime.ts +2 -1
- package/types/base/base.d.ts +1 -1
- package/types/client/cookie.d.ts +1 -1
- package/types/common/Logger.d.ts +2 -0
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/document/documentQuery.d.ts +5 -1
- package/types/document/index.d.ts +1 -0
- package/types/document/into.d.ts +7 -7
- package/types/document/liveRowScope.d.ts +9 -0
- package/types/fetch/client/fetchClient.d.ts +9 -1
- package/types/index.d.ts +32 -0
- package/types/server/akanOption.d.ts +7 -1
- package/types/server/mcp/McpExecutionContext.d.ts +4 -1
- package/types/server/mcp/McpRateLimiter.d.ts +2 -6
- package/types/server/rscWorkerHost.d.ts +3 -0
- package/types/service/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/index.d.ts +1 -0
- package/types/service/predefinedAdaptor/rateLimit.adaptor.d.ts +10 -0
- package/types/service/predefinedAdaptor/sql/SqlDocumentStore.d.ts +6 -10
- package/types/service/predefinedAdaptor/sql/types.d.ts +13 -17
- package/types/service/rateLimiter.d.ts +24 -0
- package/types/signal/endpointRateLimit.d.ts +37 -0
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +33 -15
- package/types/signal/slice.d.ts +17 -5
- package/types/signal/types.d.ts +8 -0
- package/types/test/signalTestRuntime.d.ts +1 -1
- package/types/ui/recipe/buttonRecipe.d.ts +2 -2
- package/types/ui/recipe/inputRecipe.d.ts +3 -3
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +33 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/bplist.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dmg.d.ts +59 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/dsStore.d.ts +26 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macAlias.d.ts +24 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +0 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/project.d.ts +3 -1
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +6 -1
- package/types/vendor/@akanjs/native/packages/desktop/src/deeplinks.d.ts +9 -2
- package/types/webkit/RenderFailure.d.ts +6 -0
- package/ui/Input.tsx +7 -1
- package/ui/recipe/buttonRecipe.ts +1 -0
- package/ui/recipe/inputRecipe.ts +13 -7
- package/ui/styles.css +1 -0
- package/vendor/@akanjs/native/native/desktop/src/lib.rs +8 -1
- package/vendor/@akanjs/native/native/macos/dmg/background.html +18 -0
- package/vendor/@akanjs/native/native/macos/dmg/background.png +0 -0
- package/vendor/@akanjs/native/native/macos/dmg/background@2x.png +0 -0
- package/vendor/@akanjs/native/packages/cli/src/config.ts +23 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/bplist.ts +91 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +9 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dmg.ts +331 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/dsStore.ts +191 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macAlias.ts +101 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +1 -28
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +17 -7
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +6 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +5 -2
- package/vendor/@akanjs/native/packages/desktop/src/deeplinks.ts +33 -10
- package/vendor/@akanjs/native/packages/desktop/src/host.ts +1 -1
- package/webkit/RenderFailure.tsx +32 -0
- 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
|
-
|
|
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
|
|
57
|
-
const
|
|
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 = `${
|
|
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.", "이 화면에 대해 묻거나 할 일을 지시해 보세요."],
|
package/dictionary/trans.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
433
|
-
const updateGuards = signal
|
|
434
|
-
const removeGuards = signal
|
|
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
|
|
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
|
|
507
|
-
const updateGuards = signal
|
|
508
|
-
|
|
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 (
|
|
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.#
|
|
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
|
|
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
|
|
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(
|
|
151
|
+
#transportError(res: Response, detail: string): RestoredError {
|
|
152
|
+
const { status } = res;
|
|
151
153
|
const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
|
|
152
|
-
|
|
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
package/server/akanOption.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
|
package/server/mcp/McpAuth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { PromiseOrObject } from "akanjs/base";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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) {
|