@qilin-agent/client-connection 3.1.5 → 3.1.7
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/README.i18n.yaml +4 -4
- package/README.md +6 -4
- package/README.zh.md +6 -4
- package/lib/client.js +20 -0
- package/lib/index.js +255 -69
- package/lib/types/browser-auth.d.ts +32 -4
- package/lib/types/client/index.d.ts +17 -1
- package/lib/types/client/socket.d.ts +50 -0
- package/lib/types/connect-protocol.d.ts +6 -0
- package/lib/types/http-bridge.d.ts +3 -1
- package/lib/types/index.d.ts +6 -4
- package/lib/types/peer.d.ts +36 -0
- package/lib/types/rpc-host.d.ts +26 -6
- package/lib/types/rpc.d.ts +42 -11
- package/package.json +18 -18
- package/lib/types/operator-peer.d.ts +0 -23
package/README.i18n.yaml
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
en: ef04350968741ba3
|
|
19
19
|
zh: abcded86153ddd9b
|
|
20
20
|
/qilin-agent-client-connection/browser-authentication-and-request-trust:
|
|
21
|
-
en:
|
|
22
|
-
zh:
|
|
21
|
+
en: e0384068ca1d7bdc
|
|
22
|
+
zh: 4bfba70fcfd4644f
|
|
23
23
|
/qilin-agent-client-connection/connection-generation:
|
|
24
24
|
en: d260c2ac53a86c8f
|
|
25
25
|
zh: 3f1de43f27afd85e
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
en: ca75c51c89c2c9b0
|
|
31
31
|
zh: de54f3467ef3b9be
|
|
32
32
|
/qilin-agent-client-connection/known-limitations-and-deferred-work:
|
|
33
|
-
en:
|
|
34
|
-
zh:
|
|
33
|
+
en: 8cae222e65d57f54
|
|
34
|
+
zh: e5b9d49db39be732
|
|
35
35
|
/qilin-agent-client-connection/known-limitations-and-deferred-work/dev-note:
|
|
36
36
|
en: e67f633dc6fe7773
|
|
37
37
|
zh: e88aeb617ffb74ea
|
package/README.md
CHANGED
|
@@ -38,11 +38,13 @@ Every Host RPC method and WebSocket stream requires one browser session; there i
|
|
|
38
38
|
|
|
39
39
|
`ctx.connection.session` is the seat where an authentication capability installs an account-session authority. `install(authority)` seats one `ConnectionSessionAuthority` and returns the disposer releasing it; a second installation fails the installing plugin's load, because two session authorities cannot compose. The authority owns three verdicts: `authorizeIndex` owns the browser response for a gated index request, `isPublicApiRequest` marks the requests that belong to the authentication surface itself and need no session, and `verify` checks the request's session. While an authority claims the seat, `requestRejection` requires `verify` for every `/api` request it does not declare public, and `authorizeIndex` runs the launch-token exchange first — cleaning the printed URL into the entry redirect — then serves a gated index only for a verified session. Without an authority the device cookie stays the only browser credential, which is what a deployment with the gate disabled keeps.
|
|
40
40
|
|
|
41
|
-
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$QILIN_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict
|
|
41
|
+
The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$QILIN_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`, and carry `Secure` exactly when the listener that received the index request serves TLS; only that listener's own protocol decides, never a `publicUrl` announcement or a forwarded header.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP cookies cannot authenticate to a TLS listener even on the same hostname and port. Default ports normalize under the listener's scheme. Existing HTTP cookie audiences remain unchanged.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `qilin web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md).
|
|
46
|
+
|
|
47
|
+
Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Kylin scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `Peer` is the concrete `PeerScope`: a composition without Connection, such as the Gateway's in-process carrier, constructs one with the same contract.
|
|
46
48
|
|
|
47
49
|
Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. WebSocket stream ownership remains with API Gateway.
|
|
48
50
|
|
|
@@ -72,7 +74,7 @@ None; this package neither assembles nor sends a provider request.
|
|
|
72
74
|
<a id="known-limitations-and-deferred-work"></a>
|
|
73
75
|
|
|
74
76
|
- **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota.
|
|
75
|
-
- **
|
|
77
|
+
- **A plain-HTTP listener exposes the bearer cookie in transit** — such a deployment cannot mark the browser cookie `Secure`, so serving the same authority over a network can leak the session cookie; configure TLS on the Web carrier ([`qilin-host-webserver`](../../host/webserver/README.md)) to have the cookie marked `Secure` and confined to TLS.
|
|
76
78
|
- **The device cookie has no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `qilin` revokes every session.
|
|
77
79
|
|
|
78
80
|
|
package/README.zh.md
CHANGED
|
@@ -38,11 +38,13 @@ kind: "package-reference"
|
|
|
38
38
|
|
|
39
39
|
`ctx.connection.session` 是认证能力安置账户会话 authority 的席位。`install(authority)` 安置唯一的 `ConnectionSessionAuthority` 并返回释放该席位的 disposer;第二次安置会让安装方插件加载失败,因为两个会话 authority 无法组合。authority 拥有三项判定:`authorizeIndex` 拥有受门禁 index 请求的浏览器响应,`isPublicApiRequest` 标记属于认证表层本身、无需会话的请求,`verify` 校验请求的会话。authority 占据席位期间,`requestRejection` 对该 authority 未声明为公开的每个 `/api` 请求都要求 `verify` 通过;`authorizeIndex` 先执行启动令牌交换——把打印出的 URL 清理为入口重定向——再只为通过校验的会话提供受门禁 index。没有 authority 时,设备 cookie 仍是唯一的浏览器凭据,这正是禁用门禁的部署所保留的行为。
|
|
40
40
|
|
|
41
|
-
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$QILIN_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict
|
|
41
|
+
cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$QILIN_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`,并且只在接收 index 请求的监听器以 TLS 服务时才带 `Secure`;由该监听器自身的协议决定,绝不由 `publicUrl` 公告或转发头决定。
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
HTTPS cookie 的名称与签名 audience 都包含 `https:` scheme,因此 HTTP cookie 无法向 TLS 监听器认证,即便 hostname 和端口相同。默认端口按监听器的 scheme 归一化。既有 HTTP cookie audience 保持不变。
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器自身的 bind IP 字面量(任意端口),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,而不是转发头或 `Origin`。若附带 HTTP(S) `Origin`,它必须指名同一 authority,Host 按该 Origin 的 scheme 解析以支持 HTTP 上游之前的 HTTPS 代理;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`qilin web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。
|
|
46
|
+
|
|
47
|
+
每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Kylin scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`Peer` 是 `PeerScope` 的具体实现:没有 Connection 的组合(例如 Gateway 的进程内载体)按同一约定构造一个。
|
|
46
48
|
|
|
47
49
|
通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。WebSocket 流仍由 API Gateway 负责。
|
|
48
50
|
|
|
@@ -72,7 +74,7 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation
|
|
|
72
74
|
<a id="known-limitations-and-deferred-work"></a>
|
|
73
75
|
|
|
74
76
|
- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。
|
|
75
|
-
-
|
|
77
|
+
- **明文 HTTP 监听器会在传输中暴露 bearer cookie**:这类部署无法给浏览器 cookie 加 `Secure`,经网络服务同一 authority 可能泄露会话 cookie;在 Web 载体上配置 TLS([`qilin-host-webserver`](../../host/webserver/README.zh.md))即可让 cookie 带 `Secure` 并被限定在 TLS 内。
|
|
76
78
|
- **设备 cookie 没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `qilin` 会撤销全部会话。
|
|
77
79
|
|
|
78
80
|
|
package/lib/client.js
CHANGED
|
@@ -1292,6 +1292,23 @@ window.__ModuleLoader__.load({
|
|
|
1292
1292
|
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1293
1293
|
}
|
|
1294
1294
|
//#endregion
|
|
1295
|
+
//#region lib/types/client/socket.js
|
|
1296
|
+
/** Physical socket selection for browser, desktop, and authenticated Node connections. */
|
|
1297
|
+
/**
|
|
1298
|
+
* Resolve a logical channel against the explicit Host or the page's document base.
|
|
1299
|
+
* @param path - absolute logical channel path.
|
|
1300
|
+
* @param factory - authenticated socket factory; omission uses the browser API.
|
|
1301
|
+
* @param baseUrl - Host base URL, required outside a document.
|
|
1302
|
+
* @returns a socket owned by the calling channel.
|
|
1303
|
+
*/
|
|
1304
|
+
function openConnectionWebSocket(path, factory = (url) => new WebSocket(url), baseUrl) {
|
|
1305
|
+
const base = baseUrl ?? (typeof document === "undefined" ? void 0 : document.baseURI);
|
|
1306
|
+
if (base === void 0) throw new Error("connection: WebSocket carrier requires streamBaseUrl outside a document");
|
|
1307
|
+
const url = new URL(path.slice(1), base);
|
|
1308
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
1309
|
+
return factory(url.href);
|
|
1310
|
+
}
|
|
1311
|
+
//#endregion
|
|
1295
1312
|
//#region lib/types/client/index.js
|
|
1296
1313
|
/** Required services (none — this is the wire root). */
|
|
1297
1314
|
const inject = [];
|
|
@@ -1322,6 +1339,7 @@ window.__ModuleLoader__.load({
|
|
|
1322
1339
|
const pageLocation = options.location;
|
|
1323
1340
|
const transport = options.transport;
|
|
1324
1341
|
const recovery = options.recovery ?? {};
|
|
1342
|
+
const interactions = options.interactions ?? (pageLocation === void 0 ? "none" : "answer");
|
|
1325
1343
|
const rpc = transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream);
|
|
1326
1344
|
let generationSource;
|
|
1327
1345
|
let owner;
|
|
@@ -1357,6 +1375,8 @@ window.__ModuleLoader__.load({
|
|
|
1357
1375
|
publishState(void 0);
|
|
1358
1376
|
};
|
|
1359
1377
|
const handle = {
|
|
1378
|
+
interactions,
|
|
1379
|
+
openWebSocket: (path) => openConnectionWebSocket(path, transport?.createWebSocket, transport?.streamBaseUrl),
|
|
1360
1380
|
isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),
|
|
1361
1381
|
generation: {
|
|
1362
1382
|
getSnapshot: () => generation,
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,11 @@ import { createScope } from "@qilin-agent/scope";
|
|
|
13
13
|
/** Route prefix owning every api request (`/api` and `/api/<anything>`). */
|
|
14
14
|
const API_PATH = "/api";
|
|
15
15
|
//#endregion
|
|
16
|
+
//#region lib/types/connect-protocol.js
|
|
17
|
+
/** API connection inputs shared by the Host and its clients. */
|
|
18
|
+
/** Launch-token exchange route for API clients. */
|
|
19
|
+
const CONNECT_PATH = "/connect";
|
|
20
|
+
//#endregion
|
|
16
21
|
//#region lib/types/http-bridge.js
|
|
17
22
|
/**
|
|
18
23
|
* node:http ↔ WHATWG fetch bridge for the /api transport (host side of the
|
|
@@ -29,9 +34,10 @@ const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024;
|
|
|
29
34
|
* @param req - incoming node:http request.
|
|
30
35
|
* @param res - node:http response the bridge writes and owns to completion.
|
|
31
36
|
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
|
37
|
+
* @param peer - Peer the carrier admitted the request as.
|
|
32
38
|
* @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
|
|
33
39
|
*/
|
|
34
|
-
async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
40
|
+
async function bridge(req, res, apiHandler, peer, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES) {
|
|
35
41
|
const abort = new AbortController();
|
|
36
42
|
res.on("close", () => {
|
|
37
43
|
if (!res.writableEnded) abort.abort();
|
|
@@ -79,7 +85,7 @@ async function bridge(req, res, apiHandler, maxRequestBodyBytes = DEFAULT_MAX_RE
|
|
|
79
85
|
signal: abort.signal,
|
|
80
86
|
duplex: "half"
|
|
81
87
|
});
|
|
82
|
-
const response = await apiHandler.fetch(request);
|
|
88
|
+
const response = await apiHandler.fetch(request, peer);
|
|
83
89
|
const requestUnread = bodyMode === "streaming" && !req.readableEnded;
|
|
84
90
|
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
85
91
|
res.writeHead(response.status, requestUnread ? {
|
|
@@ -297,12 +303,13 @@ function header(headers, name) {
|
|
|
297
303
|
const value = headers[name];
|
|
298
304
|
return typeof value === "string" ? value : void 0;
|
|
299
305
|
}
|
|
300
|
-
/**
|
|
301
|
-
function
|
|
306
|
+
/** Protocol-scoped cookie audience; HTTP retains its existing authority-only spelling. */
|
|
307
|
+
function requestAudience(headers, secure) {
|
|
302
308
|
const host = header(headers, "host");
|
|
303
309
|
if (host === void 0) return void 0;
|
|
304
310
|
try {
|
|
305
|
-
|
|
311
|
+
const url = new URL(`${secure ? "https:" : "http:"}//${host}`);
|
|
312
|
+
return secure ? url.origin : url.host;
|
|
306
313
|
} catch {
|
|
307
314
|
return;
|
|
308
315
|
}
|
|
@@ -337,8 +344,9 @@ function cookieValue(headerValue, name) {
|
|
|
337
344
|
}
|
|
338
345
|
}
|
|
339
346
|
/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */
|
|
340
|
-
function sessionCookie(name, value, expiresAt, maxAgeSeconds) {
|
|
341
|
-
|
|
347
|
+
function sessionCookie(name, value, expiresAt, maxAgeSeconds, secure) {
|
|
348
|
+
const attributes = `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
|
|
349
|
+
return secure ? `${attributes}; Secure` : attributes;
|
|
342
350
|
}
|
|
343
351
|
function signature(secret, body) {
|
|
344
352
|
return createHmac("sha256", secret).update(body).digest();
|
|
@@ -359,12 +367,12 @@ function writeEntryRedirect(res) {
|
|
|
359
367
|
}
|
|
360
368
|
function encodeCookie(payload, secret) {
|
|
361
369
|
const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
362
|
-
return `
|
|
370
|
+
return `v${payload.version}.${body}.${encodeBase64Url(signature(secret, body))}`;
|
|
363
371
|
}
|
|
364
372
|
function decodeCookie(value, secret) {
|
|
365
373
|
const parts = value.split(".");
|
|
366
374
|
const [version, body, encodedSignature] = parts;
|
|
367
|
-
if (parts.length !== 3 || version !== "v1" || body === void 0 || encodedSignature === void 0) return;
|
|
375
|
+
if (parts.length !== 3 || version !== "v1" && version !== "v2" || body === void 0 || encodedSignature === void 0) return;
|
|
368
376
|
const actualSignature = decodeBase64Url(encodedSignature);
|
|
369
377
|
if (actualSignature === void 0) return void 0;
|
|
370
378
|
const expectedSignature = signature(secret, body);
|
|
@@ -377,9 +385,26 @@ function decodeCookie(value, secret) {
|
|
|
377
385
|
} catch {
|
|
378
386
|
return;
|
|
379
387
|
}
|
|
380
|
-
if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION || typeof decoded.authority !== "string" || !Number.isSafeInteger(decoded.issuedAt) || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
|
|
381
|
-
|
|
382
|
-
|
|
388
|
+
if (!isRecord(decoded) || decoded.version !== COOKIE_PAYLOAD_VERSION && decoded.version !== 2 || version !== `v${String(decoded.version)}` || typeof decoded.authority !== "string" || typeof decoded.issuedAt !== "number" || !Number.isSafeInteger(decoded.issuedAt) || typeof decoded.expiresAt !== "number" || !Number.isSafeInteger(decoded.expiresAt)) return void 0;
|
|
389
|
+
const payload = {
|
|
390
|
+
authority: decoded.authority,
|
|
391
|
+
issuedAt: decoded.issuedAt,
|
|
392
|
+
expiresAt: decoded.expiresAt
|
|
393
|
+
};
|
|
394
|
+
if (decoded.version === COOKIE_PAYLOAD_VERSION) {
|
|
395
|
+
if (decoded.displayName !== void 0 && !boundedDisplayName(decoded.displayName)) return void 0;
|
|
396
|
+
return {
|
|
397
|
+
version: COOKIE_PAYLOAD_VERSION,
|
|
398
|
+
...payload,
|
|
399
|
+
displayName: decoded.displayName
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (typeof decoded.id !== "string" || decoded.id.length === 0) return void 0;
|
|
403
|
+
return {
|
|
404
|
+
version: 2,
|
|
405
|
+
...payload,
|
|
406
|
+
id: decoded.id
|
|
407
|
+
};
|
|
383
408
|
}
|
|
384
409
|
/** Accept a display name the launching host attached: trimmed, bounded, control-free. */
|
|
385
410
|
function boundedDisplayName(value) {
|
|
@@ -457,20 +482,24 @@ var BrowserAuth = class BrowserAuth {
|
|
|
457
482
|
* 401 response.
|
|
458
483
|
* @param req - incoming entry or configured-index request.
|
|
459
484
|
* @param res - response owned when this method returns false.
|
|
485
|
+
* @param secure - whether the listener receiving this request serves TLS; the
|
|
486
|
+
* minted cookie carries `Secure` when it does. Only that listener's own
|
|
487
|
+
* protocol decides, never a `publicUrl` announcement or a forwarded header:
|
|
488
|
+
* those describe a hop this process does not terminate.
|
|
460
489
|
* @returns true only when the caller may serve index.html.
|
|
461
490
|
*/
|
|
462
|
-
authorizeIndex(req, res) {
|
|
491
|
+
authorizeIndex(req, res, secure = false) {
|
|
463
492
|
const url = requestUrl(req);
|
|
464
493
|
if (url.searchParams.getAll(TOKEN_QUERY).length > 0) {
|
|
465
|
-
if (this.exchangeToken(req, res)) return false;
|
|
466
|
-
if (req.method === "GET" && url.pathname === "/workspace" && this.isAuthenticated(req)) {
|
|
494
|
+
if (this.exchangeToken(req, res, secure)) return false;
|
|
495
|
+
if (req.method === "GET" && url.pathname === "/workspace" && this.isAuthenticated(req, secure)) {
|
|
467
496
|
writeEntryRedirect(res);
|
|
468
497
|
return false;
|
|
469
498
|
}
|
|
470
499
|
this.writeUnauthorized(req, res);
|
|
471
500
|
return false;
|
|
472
501
|
}
|
|
473
|
-
if (this.isAuthenticated(req)) return true;
|
|
502
|
+
if (this.isAuthenticated(req, secure)) return true;
|
|
474
503
|
this.writeUnauthorized(req, res);
|
|
475
504
|
return false;
|
|
476
505
|
}
|
|
@@ -479,12 +508,14 @@ var BrowserAuth = class BrowserAuth {
|
|
|
479
508
|
* entry path: the handoff that turns the printed URL into a browser session.
|
|
480
509
|
* @param req - incoming request; its only token input is the query.
|
|
481
510
|
* @param res - response owned when the exchange applies.
|
|
511
|
+
* @param secure - whether the listener receiving this request serves TLS; the
|
|
512
|
+
* minted cookie carries `Secure` and an `https:`-scoped audience when it does.
|
|
482
513
|
* @returns true when a valid token was exchanged and the redirect written.
|
|
483
514
|
*/
|
|
484
|
-
exchangeToken(req, res) {
|
|
515
|
+
exchangeToken(req, res, secure = false) {
|
|
485
516
|
const url = requestUrl(req);
|
|
486
517
|
const tokens = url.searchParams.getAll(TOKEN_QUERY);
|
|
487
|
-
const authority =
|
|
518
|
+
const authority = requestAudience(req.headers, secure);
|
|
488
519
|
if (req.method !== "GET" || url.pathname !== "/workspace" || tokens.length !== 1 || authority === void 0 || !tokenMatches(tokens.join(""), this.launchToken)) return false;
|
|
489
520
|
const requested = url.searchParams.get(DISPLAY_NAME_QUERY)?.trim();
|
|
490
521
|
const issuedAt = Date.now();
|
|
@@ -500,18 +531,84 @@ var BrowserAuth = class BrowserAuth {
|
|
|
500
531
|
"cache-control": "no-store",
|
|
501
532
|
"location": WEB_ENTRY_PATH,
|
|
502
533
|
"referrer-policy": "no-referrer",
|
|
503
|
-
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3))
|
|
534
|
+
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3), secure)
|
|
504
535
|
});
|
|
505
536
|
res.end();
|
|
506
537
|
return true;
|
|
507
538
|
}
|
|
508
539
|
/**
|
|
540
|
+
* Exchange the launch token for an independent API operator; omitted interactions default to none.
|
|
541
|
+
* @param req - trusted GET request on /connect with at most one interactions declaration.
|
|
542
|
+
* @param res - response owned to completion, redirecting to the API mux on success.
|
|
543
|
+
* @param secure - whether the receiving listener serves TLS.
|
|
544
|
+
* @param publish - synchronously create the operator before exposing its signed credential.
|
|
545
|
+
*/
|
|
546
|
+
authorizeConnect(req, res, secure, publish) {
|
|
547
|
+
const url = requestUrl(req);
|
|
548
|
+
const declarations = url.searchParams.getAll("interactions");
|
|
549
|
+
const interactions = declarations[0] ?? "none";
|
|
550
|
+
if (declarations.length > 1 || interactions !== "answer" && interactions !== "none") {
|
|
551
|
+
res.writeHead(400, {
|
|
552
|
+
"cache-control": "no-store",
|
|
553
|
+
"content-type": "text/plain; charset=utf-8"
|
|
554
|
+
});
|
|
555
|
+
res.end("interactions must be declared once as answer or none\n");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const tokens = url.searchParams.getAll(TOKEN_QUERY);
|
|
559
|
+
const authority = requestAudience(req.headers, secure);
|
|
560
|
+
if (req.method !== "GET" || url.pathname !== "/connect" || tokens.length !== 1 || authority === void 0 || !tokenMatches(tokens.join(""), this.launchToken)) {
|
|
561
|
+
this.writeUnauthorized(req, res);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const issuedAt = Date.now();
|
|
565
|
+
const expiresAt = issuedAt + this.maxAgeMilliseconds;
|
|
566
|
+
const value = encodeCookie({
|
|
567
|
+
version: 2,
|
|
568
|
+
authority,
|
|
569
|
+
id: publish(interactions),
|
|
570
|
+
issuedAt,
|
|
571
|
+
expiresAt
|
|
572
|
+
}, this.secret);
|
|
573
|
+
res.writeHead(302, {
|
|
574
|
+
"cache-control": "no-store",
|
|
575
|
+
"location": "./api/remote.mux",
|
|
576
|
+
"referrer-policy": "no-referrer",
|
|
577
|
+
"set-cookie": sessionCookie(cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1e3), secure)
|
|
578
|
+
});
|
|
579
|
+
res.end();
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
509
582
|
* Verify the authority-bound browser cookie on a Host request.
|
|
510
583
|
* @param request - request headers carrying Host and Cookie.
|
|
584
|
+
* @param secure - whether the receiving listener serves TLS; only a cookie
|
|
585
|
+
* minted for that same protocol's audience authenticates.
|
|
511
586
|
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
|
|
512
587
|
*/
|
|
513
|
-
isAuthenticated(request) {
|
|
514
|
-
return this.
|
|
588
|
+
isAuthenticated(request, secure = false) {
|
|
589
|
+
return this.authenticate(request, secure)?.version === COOKIE_PAYLOAD_VERSION;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Verify an API credential without creating or reviving its operator.
|
|
593
|
+
* @param request - request headers carrying Host and Cookie.
|
|
594
|
+
* @param secure - whether the receiving listener uses TLS.
|
|
595
|
+
* @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
|
|
596
|
+
*/
|
|
597
|
+
authenticateApi(request, secure = false) {
|
|
598
|
+
const payload = this.authenticate(request, secure);
|
|
599
|
+
return payload?.version === 2 ? payload.id : void 0;
|
|
600
|
+
}
|
|
601
|
+
authenticate(request, secure) {
|
|
602
|
+
const authority = requestAudience(request.headers, secure);
|
|
603
|
+
const rawCookie = header(request.headers, "cookie");
|
|
604
|
+
if (authority === void 0 || rawCookie === void 0) return void 0;
|
|
605
|
+
const value = cookieValue(rawCookie, cookieName(authority));
|
|
606
|
+
if (value === void 0) return void 0;
|
|
607
|
+
const payload = decodeCookie(value, this.secret);
|
|
608
|
+
if (payload === void 0 || payload.authority !== authority) return void 0;
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
if (payload.issuedAt > now || payload.expiresAt <= now || payload.expiresAt <= payload.issuedAt || payload.expiresAt - payload.issuedAt > this.maxAgeMilliseconds) return void 0;
|
|
611
|
+
return payload;
|
|
515
612
|
}
|
|
516
613
|
/**
|
|
517
614
|
* Read the device session behind a Host request: its authentication fact and
|
|
@@ -519,21 +616,16 @@ var BrowserAuth = class BrowserAuth {
|
|
|
519
616
|
* identity channel for deployments without the account gate, and the gate's
|
|
520
617
|
* own status route stays authoritative wherever that is mounted.
|
|
521
618
|
* @param request - request headers carrying Host and Cookie.
|
|
619
|
+
* @param secure - whether the receiving listener serves TLS; the cookie
|
|
620
|
+
* audience is scoped to that protocol.
|
|
522
621
|
* @returns the session fact and display name of the request's device session.
|
|
523
622
|
*/
|
|
524
|
-
identity(request) {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
if (authority === void 0 || rawCookie === void 0) return UNIDENTIFIED;
|
|
528
|
-
const value = cookieValue(rawCookie, cookieName(authority));
|
|
529
|
-
if (value === void 0) return UNIDENTIFIED;
|
|
530
|
-
const payload = decodeCookie(value, this.secret);
|
|
531
|
-
if (payload === void 0 || payload.authority !== authority) return UNIDENTIFIED;
|
|
532
|
-
const now = Date.now();
|
|
533
|
-
const authenticated = payload.issuedAt <= now && payload.expiresAt > now && payload.expiresAt > payload.issuedAt && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds;
|
|
623
|
+
identity(request, secure = false) {
|
|
624
|
+
const payload = this.authenticate(request, secure);
|
|
625
|
+
if (payload === void 0 || payload.version !== COOKIE_PAYLOAD_VERSION) return UNIDENTIFIED;
|
|
534
626
|
return {
|
|
535
|
-
authenticated,
|
|
536
|
-
accountName:
|
|
627
|
+
authenticated: true,
|
|
628
|
+
accountName: payload.displayName ?? null
|
|
537
629
|
};
|
|
538
630
|
}
|
|
539
631
|
writeUnauthorized(req, res) {
|
|
@@ -611,26 +703,39 @@ const serverResponseSchema = z$1.object({
|
|
|
611
703
|
/** Either Connection RPC envelope direction. */
|
|
612
704
|
const rpcMessageSchema = z$1.discriminatedUnion("type", [clientRequestSchema, serverResponseSchema]);
|
|
613
705
|
//#endregion
|
|
614
|
-
//#region lib/types/
|
|
706
|
+
//#region lib/types/peer.js
|
|
615
707
|
/**
|
|
616
|
-
* The
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* @module @qilin-agent/client-connection/src/
|
|
708
|
+
* The Peer: one party admitted to this Host. Its creator fixes the principal
|
|
709
|
+
* it speaks for and owns its lifetime; Gateway hands it to each Remote call as
|
|
710
|
+
* `invocation.peer`.
|
|
711
|
+
* @module @qilin-agent/client-connection/src/peer
|
|
620
712
|
*/
|
|
621
713
|
/**
|
|
622
|
-
*
|
|
714
|
+
* One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
|
623
715
|
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
|
624
716
|
* listeners registered through `peer.ctx` and nobody else.
|
|
625
717
|
*/
|
|
626
|
-
var
|
|
718
|
+
var Peer = class {
|
|
627
719
|
id = randomUUID();
|
|
628
720
|
ctx;
|
|
721
|
+
principal;
|
|
722
|
+
interactions;
|
|
723
|
+
signal;
|
|
629
724
|
scope;
|
|
630
|
-
/**
|
|
631
|
-
|
|
725
|
+
/**
|
|
726
|
+
* @param owner - Context the scope fiber hangs under; disposing it disposes the Peer.
|
|
727
|
+
* @param options - the principal fixed for this Peer's lifetime.
|
|
728
|
+
*/
|
|
729
|
+
constructor(owner, options) {
|
|
730
|
+
this.principal = options.principal;
|
|
731
|
+
this.interactions = options.interactions ?? "answer";
|
|
632
732
|
this.scope = createScope(owner, this);
|
|
633
733
|
this.ctx = this.scope.ctx;
|
|
734
|
+
const lifetime = new AbortController();
|
|
735
|
+
this.signal = lifetime.signal;
|
|
736
|
+
this.ctx.effect(() => () => {
|
|
737
|
+
lifetime.abort(/* @__PURE__ */ new Error(`Peer ${this.id} was disposed`));
|
|
738
|
+
}, "client-connection: Peer lifetime");
|
|
634
739
|
}
|
|
635
740
|
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
|
636
741
|
dispose() {
|
|
@@ -654,8 +759,11 @@ const AUTH_STATUS_PATH = "/api/auth/status";
|
|
|
654
759
|
var HostConnectionService = class extends Service {
|
|
655
760
|
trustedHosts;
|
|
656
761
|
browserAuth;
|
|
657
|
-
/** The
|
|
658
|
-
|
|
762
|
+
/** The Peer every authenticated browser connection shares. */
|
|
763
|
+
browserPeer;
|
|
764
|
+
peers = /* @__PURE__ */ new Map();
|
|
765
|
+
owner;
|
|
766
|
+
disposed = false;
|
|
659
767
|
interceptors = /* @__PURE__ */ new Map();
|
|
660
768
|
fetchRoutes = /* @__PURE__ */ new Map();
|
|
661
769
|
sessionAuthority;
|
|
@@ -669,8 +777,14 @@ var HostConnectionService = class extends Service {
|
|
|
669
777
|
super(ctx, "connection");
|
|
670
778
|
this.trustedHosts = trustedHosts;
|
|
671
779
|
this.browserAuth = browserAuth;
|
|
672
|
-
this.
|
|
673
|
-
|
|
780
|
+
this.owner = ctx;
|
|
781
|
+
this.browserPeer = new Peer(ctx, { principal: { kind: "owner" } });
|
|
782
|
+
ctx.effect(() => async () => {
|
|
783
|
+
this.disposed = true;
|
|
784
|
+
const peers = [this.browserPeer, ...Array.from(this.peers.values(), (record) => record.peer)];
|
|
785
|
+
this.peers.clear();
|
|
786
|
+
await Promise.all(peers.map((peer) => peer.dispose()));
|
|
787
|
+
}, "client-connection: Peer scopes");
|
|
674
788
|
}
|
|
675
789
|
/** Generic channel registry scoped to the Context reading this service. */
|
|
676
790
|
get rpc() {
|
|
@@ -702,27 +816,89 @@ var HostConnectionService = class extends Service {
|
|
|
702
816
|
* cookie minted by the launch-token exchange.
|
|
703
817
|
*/
|
|
704
818
|
requestRejection(request) {
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
const authority = this.sessionAuthority;
|
|
708
|
-
if (authority === void 0) return this.browserAuth.isAuthenticated(request) ? void 0 : 401;
|
|
709
|
-
if (authority.isPublicApiRequest(request)) return void 0;
|
|
710
|
-
return authority.verify(request) ? void 0 : 401;
|
|
819
|
+
const admission = this.admit(request);
|
|
820
|
+
return "rejection" in admission ? admission.rejection : void 0;
|
|
711
821
|
}
|
|
712
|
-
/**
|
|
822
|
+
/** Browser requests share an owner Peer; the account-session authority admits its own bearers. */
|
|
713
823
|
admit(request) {
|
|
714
|
-
|
|
715
|
-
|
|
824
|
+
if (!this.isTrusted(request)) return { rejection: 403 };
|
|
825
|
+
if (this.disposed) return { rejection: 401 };
|
|
826
|
+
const secure = this.owner.get("webServer")?.protocol === "https:";
|
|
827
|
+
const authority = this.sessionAuthority;
|
|
828
|
+
if (authority !== void 0) {
|
|
829
|
+
if (authority.isPublicApiRequest(request) || authority.verify(request)) return { peer: this.browserPeer };
|
|
830
|
+
return { rejection: 401 };
|
|
831
|
+
}
|
|
832
|
+
if (this.browserAuth.isAuthenticated(request, secure)) return { peer: this.browserPeer };
|
|
833
|
+
const id = this.browserAuth.authenticateApi(request, secure);
|
|
834
|
+
const record = id === void 0 ? void 0 : this.peers.get(id);
|
|
835
|
+
return record === void 0 ? { rejection: 401 } : { peer: record.peer };
|
|
836
|
+
}
|
|
837
|
+
/** Create one in-process Peer owned by the Context that read this service. */
|
|
838
|
+
createPeer(options) {
|
|
839
|
+
return new Peer(this.ctx, options);
|
|
840
|
+
}
|
|
841
|
+
/** Claim API socket ownership synchronously; callers release on termination or failed upgrade. */
|
|
842
|
+
admitConnection(request) {
|
|
843
|
+
const admission = this.admit(request);
|
|
844
|
+
if ("rejection" in admission) return admission;
|
|
845
|
+
const record = this.peers.get(admission.peer.id);
|
|
846
|
+
if (record === void 0) return {
|
|
847
|
+
...admission,
|
|
848
|
+
release: () => Promise.resolve()
|
|
849
|
+
};
|
|
850
|
+
if (record.ownsSocket) return { rejection: 409 };
|
|
851
|
+
record.ownsSocket = true;
|
|
852
|
+
let released;
|
|
853
|
+
return {
|
|
854
|
+
peer: record.peer,
|
|
855
|
+
release: () => {
|
|
856
|
+
if (released !== void 0) return released;
|
|
857
|
+
this.peers.delete(record.peer.id);
|
|
858
|
+
released = record.peer.dispose();
|
|
859
|
+
return released;
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
isTrusted(request) {
|
|
864
|
+
const carrier = this.owner.get("webServer");
|
|
865
|
+
return isTrustedApiRequest(request, this.trustedHosts, carrier?.host, carrier?.protocol ?? "http:");
|
|
866
|
+
}
|
|
867
|
+
publishPeer(interactions) {
|
|
868
|
+
const peer = new Peer(this.owner, {
|
|
869
|
+
principal: { kind: "owner" },
|
|
870
|
+
interactions
|
|
871
|
+
});
|
|
872
|
+
this.peers.set(peer.id, {
|
|
873
|
+
peer,
|
|
874
|
+
ownsSocket: false
|
|
875
|
+
});
|
|
876
|
+
return peer.id;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Authenticate and publish a credential before sending the connect redirect.
|
|
880
|
+
* @param request - launch-token request on /connect.
|
|
881
|
+
* @param response - response owned to completion.
|
|
882
|
+
*/
|
|
883
|
+
authorizeConnect(request, response) {
|
|
884
|
+
if (!this.isTrusted(request) || this.disposed) {
|
|
885
|
+
response.writeHead(this.disposed ? 401 : 403);
|
|
886
|
+
response.end(this.disposed ? "unauthorized" : "forbidden");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
this.browserAuth.authorizeConnect(request, response, this.owner.get("webServer")?.protocol === "https:", (interactions) => this.publishPeer(interactions));
|
|
716
890
|
}
|
|
717
891
|
/**
|
|
718
892
|
* Authenticate an index request: the launch-token exchange first, then the
|
|
719
893
|
* installed account-session authority, or the device cookie when no
|
|
720
|
-
* authority claims the seat.
|
|
894
|
+
* authority claims the seat. Native TLS listeners mint `Secure` device
|
|
895
|
+
* cookies; the installed gate owns its own cookie security.
|
|
721
896
|
*/
|
|
722
897
|
authorizeIndex(request, response) {
|
|
898
|
+
const secure = this.ctx.get("webServer")?.protocol === "https:";
|
|
723
899
|
const authority = this.sessionAuthority;
|
|
724
|
-
if (authority === void 0) return this.browserAuth.authorizeIndex(request, response);
|
|
725
|
-
if (this.browserAuth.exchangeToken(request, response)) return false;
|
|
900
|
+
if (authority === void 0) return this.browserAuth.authorizeIndex(request, response, secure);
|
|
901
|
+
if (this.browserAuth.exchangeToken(request, response, secure)) return false;
|
|
726
902
|
return authority.authorizeIndex(request, response);
|
|
727
903
|
}
|
|
728
904
|
/** Add this process's launch token, and an optional display name, to the clean application URL. */
|
|
@@ -740,15 +916,18 @@ var HostConnectionService = class extends Service {
|
|
|
740
916
|
const route = this.fetchRoutes.get(url.pathname);
|
|
741
917
|
return route?.methods.has(method) === true ? route.requestBody : "buffered";
|
|
742
918
|
},
|
|
743
|
-
fetch: (request) => {
|
|
919
|
+
fetch: (request, peer) => {
|
|
744
920
|
const pathname = new URL(request.url).pathname;
|
|
745
921
|
const route = this.fetchRoutes.get(pathname);
|
|
746
|
-
if (route?.methods.has(request.method) === true)
|
|
922
|
+
if (route?.methods.has(request.method) === true) {
|
|
923
|
+
if (peer.principal.kind !== "owner") return Promise.resolve(new Response("forbidden", { status: 403 }));
|
|
924
|
+
return route.fetch(request);
|
|
925
|
+
}
|
|
747
926
|
if (request.method === "GET" && pathname === AUTH_STATUS_PATH) return Promise.resolve(this.deviceStatusAnswer(request));
|
|
748
927
|
const endpoint = endpointFromPath(channel, pathname);
|
|
749
928
|
const interceptor = this.interceptors.get(channel);
|
|
750
929
|
if (endpoint === void 0 || interceptor === void 0 || !interceptor.matches(endpoint)) return Promise.resolve(new Response("not found", { status: 404 }));
|
|
751
|
-
return interceptor.fetchHandler.fetch(request);
|
|
930
|
+
return interceptor.fetchHandler.fetch(request, peer);
|
|
752
931
|
}
|
|
753
932
|
};
|
|
754
933
|
}
|
|
@@ -759,7 +938,7 @@ var HostConnectionService = class extends Service {
|
|
|
759
938
|
* the menu without any identity channel.
|
|
760
939
|
*/
|
|
761
940
|
deviceStatusAnswer(request) {
|
|
762
|
-
const identity = this.browserAuth.identity(request);
|
|
941
|
+
const identity = this.browserAuth.identity(request, this.ctx.get("webServer")?.protocol === "https:");
|
|
763
942
|
return Response.json({
|
|
764
943
|
enabled: false,
|
|
765
944
|
needsSetup: false,
|
|
@@ -796,7 +975,7 @@ var HostConnectionService = class extends Service {
|
|
|
796
975
|
}
|
|
797
976
|
register(owner, channel, handler) {
|
|
798
977
|
assertChannel(channel);
|
|
799
|
-
const fetchHandler = rpcFetchHandler(channel, handler
|
|
978
|
+
const fetchHandler = rpcFetchHandler(channel, handler);
|
|
800
979
|
const route = {
|
|
801
980
|
kind: "prefix",
|
|
802
981
|
path: channel,
|
|
@@ -807,7 +986,7 @@ var HostConnectionService = class extends Service {
|
|
|
807
986
|
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
808
987
|
return;
|
|
809
988
|
}
|
|
810
|
-
await bridge(req, res, fetchHandler);
|
|
989
|
+
await bridge(req, res, fetchHandler, admission.peer);
|
|
811
990
|
}
|
|
812
991
|
};
|
|
813
992
|
return owner.effect(() => owner.webServer.register(route), `client-connection: ${channel} rpc channel`);
|
|
@@ -816,7 +995,7 @@ var HostConnectionService = class extends Service {
|
|
|
816
995
|
if (channel !== "/api") throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`);
|
|
817
996
|
const interceptor = {
|
|
818
997
|
matches,
|
|
819
|
-
fetchHandler: rpcFetchHandler(channel, handler
|
|
998
|
+
fetchHandler: rpcFetchHandler(channel, handler)
|
|
820
999
|
};
|
|
821
1000
|
return owner.effect(() => {
|
|
822
1001
|
if (this.interceptors.has(channel)) throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`);
|
|
@@ -827,10 +1006,10 @@ var HostConnectionService = class extends Service {
|
|
|
827
1006
|
}, `client-connection: ${channel} rpc interceptor`);
|
|
828
1007
|
}
|
|
829
1008
|
};
|
|
830
|
-
function rpcFetchHandler(channel, handler
|
|
1009
|
+
function rpcFetchHandler(channel, handler) {
|
|
831
1010
|
return {
|
|
832
1011
|
requestBodyMode: () => "buffered",
|
|
833
|
-
async fetch(request) {
|
|
1012
|
+
async fetch(request, peer) {
|
|
834
1013
|
const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
|
|
835
1014
|
if (request.method !== "POST" || endpoint === void 0) return new Response("not found", { status: 404 });
|
|
836
1015
|
if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return new Response("content type must be application/json", { status: 415 });
|
|
@@ -970,14 +1149,21 @@ async function apply(ctx, config) {
|
|
|
970
1149
|
res.end(admission.rejection === 401 ? "unauthorized" : "forbidden");
|
|
971
1150
|
return;
|
|
972
1151
|
}
|
|
973
|
-
await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, maxRequestBodyBytes));
|
|
1152
|
+
await webCtx.waterfall("connection/request", req, res, () => bridge(req, res, fetchHandler, admission.peer, maxRequestBodyBytes));
|
|
974
1153
|
}
|
|
975
1154
|
};
|
|
976
1155
|
webCtx.effect(() => webCtx.webServer.register(route), "client-connection: /api route");
|
|
1156
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
1157
|
+
kind: "exact",
|
|
1158
|
+
path: CONNECT_PATH,
|
|
1159
|
+
handler: (req, res) => {
|
|
1160
|
+
connection.authorizeConnect(req, res);
|
|
1161
|
+
}
|
|
1162
|
+
}), "client-connection: /connect route");
|
|
977
1163
|
});
|
|
978
1164
|
ctx.inject(["attachments"], (attachmentCtx) => {
|
|
979
1165
|
assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes);
|
|
980
1166
|
});
|
|
981
1167
|
}
|
|
982
1168
|
//#endregion
|
|
983
|
-
export { API_PATH, Config, HostConnectionService,
|
|
1169
|
+
export { API_PATH, CONNECT_PATH, Config, HostConnectionService, Peer, RpcId, WEB_ENTRY_PATH, apply, clientRequestSchema, inject, name, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, transportError };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Browser-session authentication for the Host Connection carrier. */
|
|
2
|
+
import type { PeerId } from '@qilin-agent/typert-protocol';
|
|
3
|
+
import { type ConnectionInteractions } from './connect-protocol.ts';
|
|
2
4
|
import type { CredentialProvider } from '@qilin-agent/credentials';
|
|
3
5
|
import type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionTrustRequest } from './rpc.ts';
|
|
4
6
|
/**
|
|
@@ -34,32 +36,58 @@ export declare class BrowserAuth {
|
|
|
34
36
|
* 401 response.
|
|
35
37
|
* @param req - incoming entry or configured-index request.
|
|
36
38
|
* @param res - response owned when this method returns false.
|
|
39
|
+
* @param secure - whether the listener receiving this request serves TLS; the
|
|
40
|
+
* minted cookie carries `Secure` when it does. Only that listener's own
|
|
41
|
+
* protocol decides, never a `publicUrl` announcement or a forwarded header:
|
|
42
|
+
* those describe a hop this process does not terminate.
|
|
37
43
|
* @returns true only when the caller may serve index.html.
|
|
38
44
|
*/
|
|
39
|
-
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean;
|
|
45
|
+
authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure?: boolean): boolean;
|
|
40
46
|
/**
|
|
41
47
|
* Mint the device cookie for one valid launch token and redirect to the clean
|
|
42
48
|
* entry path: the handoff that turns the printed URL into a browser session.
|
|
43
49
|
* @param req - incoming request; its only token input is the query.
|
|
44
50
|
* @param res - response owned when the exchange applies.
|
|
51
|
+
* @param secure - whether the listener receiving this request serves TLS; the
|
|
52
|
+
* minted cookie carries `Secure` and an `https:`-scoped audience when it does.
|
|
45
53
|
* @returns true when a valid token was exchanged and the redirect written.
|
|
46
54
|
*/
|
|
47
|
-
exchangeToken(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean;
|
|
55
|
+
exchangeToken(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure?: boolean): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Exchange the launch token for an independent API operator; omitted interactions default to none.
|
|
58
|
+
* @param req - trusted GET request on /connect with at most one interactions declaration.
|
|
59
|
+
* @param res - response owned to completion, redirecting to the API mux on success.
|
|
60
|
+
* @param secure - whether the receiving listener serves TLS.
|
|
61
|
+
* @param publish - synchronously create the operator before exposing its signed credential.
|
|
62
|
+
*/
|
|
63
|
+
authorizeConnect(req: ConnectionIndexRequest, res: ConnectionIndexResponse, secure: boolean, publish: (interactions: ConnectionInteractions) => PeerId): void;
|
|
48
64
|
/**
|
|
49
65
|
* Verify the authority-bound browser cookie on a Host request.
|
|
50
66
|
* @param request - request headers carrying Host and Cookie.
|
|
67
|
+
* @param secure - whether the receiving listener serves TLS; only a cookie
|
|
68
|
+
* minted for that same protocol's audience authenticates.
|
|
51
69
|
* @returns true only for an unexpired cookie signed by this activation's loaded secret.
|
|
52
70
|
*/
|
|
53
|
-
isAuthenticated(request: ConnectionTrustRequest): boolean;
|
|
71
|
+
isAuthenticated(request: ConnectionTrustRequest, secure?: boolean): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Verify an API credential without creating or reviving its operator.
|
|
74
|
+
* @param request - request headers carrying Host and Cookie.
|
|
75
|
+
* @param secure - whether the receiving listener uses TLS.
|
|
76
|
+
* @returns the signed operator id, or undefined for invalid, expired, or browser cookies.
|
|
77
|
+
*/
|
|
78
|
+
authenticateApi(request: ConnectionTrustRequest, secure?: boolean): PeerId | undefined;
|
|
79
|
+
private authenticate;
|
|
54
80
|
/**
|
|
55
81
|
* Read the device session behind a Host request: its authentication fact and
|
|
56
82
|
* the operator display name the launching host attached, if any. This is the
|
|
57
83
|
* identity channel for deployments without the account gate, and the gate's
|
|
58
84
|
* own status route stays authoritative wherever that is mounted.
|
|
59
85
|
* @param request - request headers carrying Host and Cookie.
|
|
86
|
+
* @param secure - whether the receiving listener serves TLS; the cookie
|
|
87
|
+
* audience is scoped to that protocol.
|
|
60
88
|
* @returns the session fact and display name of the request's device session.
|
|
61
89
|
*/
|
|
62
|
-
identity(request: ConnectionTrustRequest): {
|
|
90
|
+
identity(request: ConnectionTrustRequest, secure?: boolean): {
|
|
63
91
|
readonly authenticated: boolean;
|
|
64
92
|
readonly accountName: string | null;
|
|
65
93
|
};
|
|
@@ -3,6 +3,10 @@ import type { Context } from '@qilin-agent/kylin';
|
|
|
3
3
|
import { type ConnectionRecoveryConfig, type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, type ConnectionState } from './connection.ts';
|
|
4
4
|
import { type RpcFetch, type RpcStreamOpen } from './rpc.ts';
|
|
5
5
|
import type { ClientConnectionRpc } from '../rpc.ts';
|
|
6
|
+
import type { ConnectionInteractions } from '../connect-protocol.ts';
|
|
7
|
+
import { type ConnectionWebSocket, type ConnectionWebSocketFactory } from './socket.ts';
|
|
8
|
+
export type { ConnectionInteractions } from '../connect-protocol.ts';
|
|
9
|
+
export type { ConnectionWebSocket, ConnectionWebSocketFactory } from './socket.ts';
|
|
6
10
|
declare module '@qilin-agent/kylin' {
|
|
7
11
|
interface Events {
|
|
8
12
|
/**
|
|
@@ -66,7 +70,9 @@ export interface ClientTransportHooks {
|
|
|
66
70
|
* transport can set this; served pages never carry the global at all.
|
|
67
71
|
*/
|
|
68
72
|
ownsHost?: boolean;
|
|
69
|
-
/**
|
|
73
|
+
/** Authenticated WebSocket factory; omission uses the browser API and its cookie jar. */
|
|
74
|
+
createWebSocket?: ConnectionWebSocketFactory;
|
|
75
|
+
/** Host base URL for desktop or Node sockets; omission uses the document base. */
|
|
70
76
|
streamBaseUrl?: string;
|
|
71
77
|
}
|
|
72
78
|
/** Browser location fields used to classify loopback authority. */
|
|
@@ -75,6 +81,8 @@ export interface ConnectionLocation {
|
|
|
75
81
|
}
|
|
76
82
|
/** Instance-local inputs for installing a Connection service. */
|
|
77
83
|
export interface ConnectionInstallOptions {
|
|
84
|
+
/** Forwarded-event participation; omission selects answer for a page and none otherwise. */
|
|
85
|
+
readonly interactions?: ConnectionInteractions;
|
|
78
86
|
/** Explicit physical carrier; omit for the browser HTTP + WebSocket carrier. */
|
|
79
87
|
readonly transport?: ClientTransportHooks;
|
|
80
88
|
/** Reconnect timing overrides; omitted fields use controller defaults. */
|
|
@@ -87,6 +95,8 @@ export interface ConnectionInstallOptions {
|
|
|
87
95
|
* and reset callbacks; Connection stays independent of downstream domain state.
|
|
88
96
|
*/
|
|
89
97
|
export interface ConnectionHandle {
|
|
98
|
+
/** Forwarded-event participation selected at installation. */
|
|
99
|
+
readonly interactions: ConnectionInteractions;
|
|
90
100
|
/**
|
|
91
101
|
* Whether the privileged surface is reachable: the page authority is
|
|
92
102
|
* loopback, the transport declares the page owns the Host
|
|
@@ -99,6 +109,12 @@ export interface ConnectionHandle {
|
|
|
99
109
|
readonly state: ConnectionStateSource;
|
|
100
110
|
/** Generic logical RPC channels over the same Connection transport. */
|
|
101
111
|
readonly rpc: ClientConnectionRpc;
|
|
112
|
+
/**
|
|
113
|
+
* Create a socket on the selected carrier; the channel owns framing and closing.
|
|
114
|
+
* @param path - absolute logical channel path, resolved beneath the Host mount.
|
|
115
|
+
* @returns a newly connecting socket using the installed authentication factory.
|
|
116
|
+
*/
|
|
117
|
+
openWebSocket(path: string): ConnectionWebSocket;
|
|
102
118
|
/** Reset retry progression and replace the current attempt immediately. */
|
|
103
119
|
reconnect(): void;
|
|
104
120
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Physical socket selection for browser, desktop, and authenticated Node connections. */
|
|
2
|
+
/** Socket events consumed by the Gateway on browser and Node carriers. */
|
|
3
|
+
export interface ConnectionWebSocketEventMap {
|
|
4
|
+
readonly open: object;
|
|
5
|
+
readonly error: object;
|
|
6
|
+
readonly close: object;
|
|
7
|
+
readonly message: {
|
|
8
|
+
readonly data: unknown;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** Text WebSocket operations shared by the browser API and Node carriers. */
|
|
12
|
+
export interface ConnectionWebSocket {
|
|
13
|
+
/** Standard WebSocket state, from CONNECTING (0) through CLOSED (3). */
|
|
14
|
+
readonly readyState: number;
|
|
15
|
+
/** @param data - complete text frame to send. */
|
|
16
|
+
send(data: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* @param code - protocol close code.
|
|
19
|
+
* @param reason - peer-facing close explanation.
|
|
20
|
+
*/
|
|
21
|
+
close(code?: number, reason?: string): void;
|
|
22
|
+
/**
|
|
23
|
+
* @param event - physical socket event.
|
|
24
|
+
* @param listener - callback for that event.
|
|
25
|
+
* @param options - whether the callback is removed after its first delivery.
|
|
26
|
+
*/
|
|
27
|
+
addEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void, options?: {
|
|
28
|
+
readonly once?: boolean;
|
|
29
|
+
}): void;
|
|
30
|
+
/**
|
|
31
|
+
* @param event - physical socket event.
|
|
32
|
+
* @param listener - previously registered callback.
|
|
33
|
+
*/
|
|
34
|
+
removeEventListener<Event extends keyof ConnectionWebSocketEventMap>(event: Event, listener: (event: ConnectionWebSocketEventMap[Event]) => void): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create an authenticated socket; its consumer owns framing and closing.
|
|
38
|
+
* @param url - absolute WebSocket URL beneath the selected Host mount.
|
|
39
|
+
* @returns a newly connecting socket, before it emits open or failure.
|
|
40
|
+
*/
|
|
41
|
+
export type ConnectionWebSocketFactory = (url: string) => ConnectionWebSocket;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a logical channel against the explicit Host or the page's document base.
|
|
44
|
+
* @param path - absolute logical channel path.
|
|
45
|
+
* @param factory - authenticated socket factory; omission uses the browser API.
|
|
46
|
+
* @param baseUrl - Host base URL, required outside a document.
|
|
47
|
+
* @returns a socket owned by the calling channel.
|
|
48
|
+
*/
|
|
49
|
+
export declare function openConnectionWebSocket(path: string, factory?: ConnectionWebSocketFactory, baseUrl?: string): ConnectionWebSocket;
|
|
50
|
+
//# sourceMappingURL=socket.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** API connection inputs shared by the Host and its clients. */
|
|
2
|
+
/** Whether a Peer participates in forwarded human interactions. */
|
|
3
|
+
export type ConnectionInteractions = 'answer' | 'none';
|
|
4
|
+
/** Launch-token exchange route for API clients. */
|
|
5
|
+
export declare const CONNECT_PATH = "/connect";
|
|
6
|
+
//# sourceMappingURL=connect-protocol.d.ts.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* web carrier; the fetch-shaped handler itself is transport-agnostic).
|
|
4
4
|
*/
|
|
5
5
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
import type { PeerScope } from '@qilin-agent/typert-protocol';
|
|
6
7
|
import type { ConnectionFetchHandler } from './rpc.ts';
|
|
7
8
|
/** Default carrier cap for all HTTP RPC bodies: sized for the default
|
|
8
9
|
* aggregate image limit (200 MiB) after base64 expansion plus envelope
|
|
@@ -15,7 +16,8 @@ export declare const DEFAULT_MAX_REQUEST_BODY_BYTES: number;
|
|
|
15
16
|
* @param req - incoming node:http request.
|
|
16
17
|
* @param res - node:http response the bridge writes and owns to completion.
|
|
17
18
|
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
|
19
|
+
* @param peer - Peer the carrier admitted the request as.
|
|
18
20
|
* @param maxRequestBodyBytes - maximum bytes buffered for a buffered route.
|
|
19
21
|
*/
|
|
20
|
-
export declare function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, maxRequestBodyBytes?: number): Promise<void>;
|
|
22
|
+
export declare function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: ConnectionFetchHandler, peer: PeerScope, maxRequestBodyBytes?: number): Promise<void>;
|
|
21
23
|
//# sourceMappingURL=http-bridge.d.ts.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -3,10 +3,12 @@ import type { Context } from '@qilin-agent/kylin';
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import z from '@qilin-agent/schemastery';
|
|
5
5
|
import { type ConnectionRecoveryConfig } from './recovery-config.ts';
|
|
6
|
-
export type { PeerAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionSessionAuthority, HostConnectionSession, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
|
|
7
|
-
export type { PeerId, PeerScope, RemoteInvocation } from '@qilin-agent/typert-protocol';
|
|
6
|
+
export type { PeerAdmission, ConnectionAdmission, ConnectionFetchMethod, ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionSessionAuthority, HostConnectionSession, ConnectionRpcEndpointMatcher, ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, RpcMessage, ServerResponse, } from './rpc.ts';
|
|
7
|
+
export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@qilin-agent/typert-protocol';
|
|
8
8
|
export { RpcId, transportError } from './rpc.ts';
|
|
9
|
-
export {
|
|
9
|
+
export { Peer } from './peer.ts';
|
|
10
|
+
export type { PeerOptions } from './peer.ts';
|
|
11
|
+
export { CONNECT_PATH, type ConnectionInteractions } from './connect-protocol.ts';
|
|
10
12
|
export { clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcResultSchema, serverResponseSchema, } from './rpc-schema.ts';
|
|
11
13
|
export { HostConnectionService } from './rpc-host.ts';
|
|
12
14
|
export { API_PATH } from './api-path.ts';
|
|
@@ -41,7 +43,7 @@ export interface ConnectionConfig {
|
|
|
41
43
|
* bind. An entry that is not a bare, canonical authority fails plugin load.
|
|
42
44
|
*/
|
|
43
45
|
trustedHosts?: string[];
|
|
44
|
-
/** Absolute browser
|
|
46
|
+
/** Absolute browser and API cookie lifetime in days. Default: 30. */
|
|
45
47
|
cookieMaxAgeDays?: number;
|
|
46
48
|
/** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */
|
|
47
49
|
maxRequestBodyBytes?: number;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Peer: one party admitted to this Host. Its creator fixes the principal
|
|
3
|
+
* it speaks for and owns its lifetime; Gateway hands it to each Remote call as
|
|
4
|
+
* `invocation.peer`.
|
|
5
|
+
* @module @qilin-agent/client-connection/src/peer
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@qilin-agent/kylin';
|
|
8
|
+
import type { PeerId, PeerScope, Principal } from '@qilin-agent/typert-protocol';
|
|
9
|
+
/** Construction options for one {@link Peer}. */
|
|
10
|
+
export interface PeerOptions {
|
|
11
|
+
/** Caller identity every call through this Peer speaks for. */
|
|
12
|
+
readonly principal: Principal;
|
|
13
|
+
/** Omission permits an event stream; non-owner Peers still cannot answer waterfalls. */
|
|
14
|
+
readonly interactions?: 'answer' | 'none';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
|
18
|
+
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
|
19
|
+
* listeners registered through `peer.ctx` and nobody else.
|
|
20
|
+
*/
|
|
21
|
+
export declare class Peer implements PeerScope {
|
|
22
|
+
readonly id: PeerId;
|
|
23
|
+
readonly ctx: Context;
|
|
24
|
+
readonly principal: Principal;
|
|
25
|
+
readonly interactions: 'answer' | 'none';
|
|
26
|
+
readonly signal: AbortSignal;
|
|
27
|
+
private readonly scope;
|
|
28
|
+
/**
|
|
29
|
+
* @param owner - Context the scope fiber hangs under; disposing it disposes the Peer.
|
|
30
|
+
* @param options - the principal fixed for this Peer's lifetime.
|
|
31
|
+
*/
|
|
32
|
+
constructor(owner: Context, options: PeerOptions);
|
|
33
|
+
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
|
34
|
+
dispose(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=peer.d.ts.map
|
package/lib/types/rpc-host.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
|
2
2
|
import { Context, Service } from '@qilin-agent/kylin';
|
|
3
|
-
import type { PeerScope } from '@qilin-agent/typert-protocol';
|
|
3
|
+
import type { PeerScope, Principal } from '@qilin-agent/typert-protocol';
|
|
4
|
+
import type { ConnectionInteractions } from './connect-protocol.ts';
|
|
4
5
|
import type { BrowserAuth } from './browser-auth.ts';
|
|
5
|
-
import type { PeerAdmission, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchHandler, HostConnectionFetch, HostConnectionSession, ConnectionRequestRejection, ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc } from './rpc.ts';
|
|
6
|
+
import type { PeerAdmission, ConnectionAdmission, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchHandler, HostConnectionFetch, HostConnectionSession, ConnectionRequestRejection, ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc } from './rpc.ts';
|
|
6
7
|
declare module '@qilin-agent/kylin' {
|
|
7
8
|
interface Context {
|
|
8
9
|
/** Host Connection transport and RPC registrations. */
|
|
@@ -13,8 +14,11 @@ declare module '@qilin-agent/kylin' {
|
|
|
13
14
|
export declare class HostConnectionService extends Service implements HostConnectionHandle {
|
|
14
15
|
private readonly trustedHosts;
|
|
15
16
|
private readonly browserAuth;
|
|
16
|
-
/** The
|
|
17
|
-
readonly
|
|
17
|
+
/** The Peer every authenticated browser connection shares. */
|
|
18
|
+
readonly browserPeer: PeerScope;
|
|
19
|
+
private readonly peers;
|
|
20
|
+
private readonly owner;
|
|
21
|
+
private disposed;
|
|
18
22
|
private readonly interceptors;
|
|
19
23
|
private readonly fetchRoutes;
|
|
20
24
|
private sessionAuthority;
|
|
@@ -39,12 +43,28 @@ export declare class HostConnectionService extends Service implements HostConnec
|
|
|
39
43
|
* cookie minted by the launch-token exchange.
|
|
40
44
|
*/
|
|
41
45
|
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection;
|
|
42
|
-
/**
|
|
46
|
+
/** Browser requests share an owner Peer; the account-session authority admits its own bearers. */
|
|
43
47
|
admit(request: ConnectionTrustRequest): PeerAdmission;
|
|
48
|
+
/** Create one in-process Peer owned by the Context that read this service. */
|
|
49
|
+
createPeer(options: {
|
|
50
|
+
readonly principal: Principal;
|
|
51
|
+
readonly interactions?: ConnectionInteractions;
|
|
52
|
+
}): PeerScope;
|
|
53
|
+
/** Claim API socket ownership synchronously; callers release on termination or failed upgrade. */
|
|
54
|
+
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission;
|
|
55
|
+
private isTrusted;
|
|
56
|
+
private publishPeer;
|
|
57
|
+
/**
|
|
58
|
+
* Authenticate and publish a credential before sending the connect redirect.
|
|
59
|
+
* @param request - launch-token request on /connect.
|
|
60
|
+
* @param response - response owned to completion.
|
|
61
|
+
*/
|
|
62
|
+
authorizeConnect(request: ConnectionIndexRequest, response: ConnectionIndexResponse): void;
|
|
44
63
|
/**
|
|
45
64
|
* Authenticate an index request: the launch-token exchange first, then the
|
|
46
65
|
* installed account-session authority, or the device cookie when no
|
|
47
|
-
* authority claims the seat.
|
|
66
|
+
* authority claims the seat. Native TLS listeners mint `Secure` device
|
|
67
|
+
* cookies; the installed gate owns its own cookie security.
|
|
48
68
|
*/
|
|
49
69
|
authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean;
|
|
50
70
|
/** Add this process's launch token, and an optional display name, to the clean application URL. */
|
package/lib/types/rpc.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
|
2
2
|
import type { Branded } from '@qilin-agent/brand';
|
|
3
|
-
import type { PeerScope } from '@qilin-agent/typert-protocol';
|
|
3
|
+
import type { PeerScope, Principal } from '@qilin-agent/typert-protocol';
|
|
4
4
|
/** Correlation id minted by a caller and echoed by the Connection response. */
|
|
5
5
|
export type RpcId = Branded<'rpc-id'>;
|
|
6
6
|
/**
|
|
@@ -74,15 +74,23 @@ export interface ConnectionIndexResponse {
|
|
|
74
74
|
writeHead(status: number, headers?: Readonly<Record<string, string>>): unknown;
|
|
75
75
|
end(body?: string): unknown;
|
|
76
76
|
}
|
|
77
|
-
/** Outcome of admitting one request: the
|
|
77
|
+
/** Outcome of admitting one request: the Peer it speaks for, or the status refusing it. */
|
|
78
78
|
export type PeerAdmission = {
|
|
79
79
|
readonly peer: PeerScope;
|
|
80
80
|
} | {
|
|
81
81
|
readonly rejection: 401 | 403;
|
|
82
82
|
};
|
|
83
|
+
/** Socket admission claims API ownership until its carrier calls release, including failed upgrades. */
|
|
84
|
+
export type ConnectionAdmission = {
|
|
85
|
+
readonly peer: PeerScope;
|
|
86
|
+
/** Remove and dispose an API Peer; browser releases leave the shared browser Peer intact. */
|
|
87
|
+
readonly release: () => Promise<void>;
|
|
88
|
+
} | {
|
|
89
|
+
readonly rejection: 401 | 403 | 409;
|
|
90
|
+
};
|
|
83
91
|
/**
|
|
84
92
|
* Handler invoked after Connection has decoded the transport envelope.
|
|
85
|
-
* `peer` is the Peer the
|
|
93
|
+
* `peer` is the Peer the carrier admitted or the in-process caller supplied.
|
|
86
94
|
*/
|
|
87
95
|
export type ConnectionRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal, peer: PeerScope) => Promise<ConnectionRpcResult<unknown>>;
|
|
88
96
|
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
|
@@ -99,7 +107,11 @@ export interface ConnectionFetchRoute {
|
|
|
99
107
|
readonly methods: readonly ConnectionFetchMethod[];
|
|
100
108
|
/** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */
|
|
101
109
|
readonly requestBody: ConnectionRequestBodyMode;
|
|
102
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Handle one request after the physical carrier has applied its trust and
|
|
112
|
+
* authentication policy. Only owner Peers reach an exact route; the shared
|
|
113
|
+
* handler answers 403 for every other principal.
|
|
114
|
+
*/
|
|
103
115
|
readonly fetch: (request: Request) => Promise<Response>;
|
|
104
116
|
}
|
|
105
117
|
/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */
|
|
@@ -174,8 +186,8 @@ export interface HostConnectionHandle {
|
|
|
174
186
|
readonly rpc: HostConnectionRpc;
|
|
175
187
|
/** Exact Fetch routes for streaming or browser-native responses. */
|
|
176
188
|
readonly fetch: HostConnectionFetch;
|
|
177
|
-
/** The
|
|
178
|
-
readonly
|
|
189
|
+
/** The owner Peer every authenticated browser connection shares. */
|
|
190
|
+
readonly browserPeer: PeerScope;
|
|
179
191
|
/** Account-session gate seat; unclaimed keeps device-token authentication. */
|
|
180
192
|
readonly session: HostConnectionSession;
|
|
181
193
|
/**
|
|
@@ -197,14 +209,32 @@ export interface HostConnectionHandle {
|
|
|
197
209
|
*/
|
|
198
210
|
requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection;
|
|
199
211
|
/**
|
|
200
|
-
* Admit
|
|
201
|
-
* operator, or it is refused with that status.
|
|
212
|
+
* Admit an HTTP request against its signed browser or API credential and live Peer.
|
|
202
213
|
* @param request - request headers from the HTTP or upgrade request.
|
|
203
|
-
* @returns the
|
|
214
|
+
* @returns the credential's Peer, or the rejection status.
|
|
204
215
|
*/
|
|
205
216
|
admit(request: ConnectionTrustRequest): PeerAdmission;
|
|
217
|
+
/**
|
|
218
|
+
* Create an independent in-process Peer, disposed with the Context that read `ctx.connection`.
|
|
219
|
+
* @param options - fixed principal and optional interaction participation (default `answer`).
|
|
220
|
+
* @returns a new Peer distinct from every other Peer.
|
|
221
|
+
*/
|
|
222
|
+
createPeer(options: {
|
|
223
|
+
readonly principal: Principal;
|
|
224
|
+
readonly interactions?: 'answer' | 'none';
|
|
225
|
+
}): PeerScope;
|
|
226
|
+
/**
|
|
227
|
+
* Claim a physical socket for an API Peer; browser sockets share their owner Peer.
|
|
228
|
+
* @param request - authenticated upgrade request headers.
|
|
229
|
+
* @returns the Peer and idempotent release, or 409 for duplicate API ownership.
|
|
230
|
+
* The carrier releases on socket termination or failed upgrade; release removes
|
|
231
|
+
* API admission synchronously without cancelling independent HTTP calls.
|
|
232
|
+
*/
|
|
233
|
+
admitConnection(request: ConnectionTrustRequest): ConnectionAdmission;
|
|
206
234
|
/**
|
|
207
235
|
* Authenticate one frontend index request, owning a token redirect or 401.
|
|
236
|
+
* The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
|
|
237
|
+
* which only that listener's protocol decides.
|
|
208
238
|
* @param request - root or configured-index HTTP request.
|
|
209
239
|
* @param response - response owned when the result is false.
|
|
210
240
|
* @returns true only when the frontend may serve index.html.
|
|
@@ -232,9 +262,10 @@ export interface ConnectionFetchHandler {
|
|
|
232
262
|
/**
|
|
233
263
|
* Dispatch one already-authenticated request.
|
|
234
264
|
* @param request - Fetch request below the shared channel.
|
|
235
|
-
* @
|
|
265
|
+
* @param peer - Peer to dispatch as; required, never defaulted.
|
|
266
|
+
* @returns the registered response, a 403 response for an exact route and a non-owner Peer, or a 404 response.
|
|
236
267
|
*/
|
|
237
|
-
fetch(request: Request): Promise<Response>;
|
|
268
|
+
fetch(request: Request, peer: PeerScope): Promise<Response>;
|
|
238
269
|
}
|
|
239
270
|
/** Client caller for logical RPC channels carried by the current transport. */
|
|
240
271
|
export interface ClientConnectionRpc {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilin-agent/client-connection",
|
|
3
3
|
"description": "Authenticated RPC transport and generation lifecycle",
|
|
4
|
-
"version": "3.1.
|
|
4
|
+
"version": "3.1.7",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"zod": "^4.4.3",
|
|
41
|
-
"@qilin-agent/credentials": "3.1.
|
|
42
|
-
"@qilin-agent/schemastery": "~3.18.
|
|
41
|
+
"@qilin-agent/credentials": "3.1.7",
|
|
42
|
+
"@qilin-agent/schemastery": "~3.18.5"
|
|
43
43
|
},
|
|
44
44
|
"files": [
|
|
45
45
|
"lib/index.js",
|
|
@@ -47,22 +47,22 @@
|
|
|
47
47
|
"lib/types/**/*.d.ts"
|
|
48
48
|
],
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@qilin-agent/kylin": "~4.0.
|
|
51
|
-
"@qilin-agent/scope": "3.1.
|
|
50
|
+
"@qilin-agent/kylin": "~4.0.5",
|
|
51
|
+
"@qilin-agent/scope": "3.1.7"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@qilin-agent/kylin": "~4.0.
|
|
55
|
-
"@qilin-agent/brand": "3.1.
|
|
56
|
-
"@qilin-agent/attachment": "3.1.
|
|
57
|
-
"@qilin-agent/
|
|
58
|
-
"@qilin-agent/
|
|
59
|
-
"@qilin-agent/session": "3.1.
|
|
60
|
-
"@qilin-agent/typert-protocol": "3.1.
|
|
61
|
-
"@qilin-agent/
|
|
62
|
-
"@qilin-agent/tool-todo": "3.1.
|
|
63
|
-
"@qilin-agent/commands": "3.1.
|
|
64
|
-
"@qilin-agent/
|
|
65
|
-
"@qilin-agent/
|
|
66
|
-
"@qilin-agent/
|
|
54
|
+
"@qilin-agent/kylin": "~4.0.5",
|
|
55
|
+
"@qilin-agent/brand": "3.1.7",
|
|
56
|
+
"@qilin-agent/attachment": "3.1.7",
|
|
57
|
+
"@qilin-agent/llm": "3.1.7",
|
|
58
|
+
"@qilin-agent/scope": "3.1.7",
|
|
59
|
+
"@qilin-agent/session": "3.1.7",
|
|
60
|
+
"@qilin-agent/typert-protocol": "3.1.7",
|
|
61
|
+
"@qilin-agent/host-webserver": "3.1.7",
|
|
62
|
+
"@qilin-agent/tool-todo": "3.1.7",
|
|
63
|
+
"@qilin-agent/commands": "3.1.7",
|
|
64
|
+
"@qilin-agent/settings": "3.1.7",
|
|
65
|
+
"@qilin-agent/host-directory-picker": "3.1.7",
|
|
66
|
+
"@qilin-agent/util-values": "3.1.7"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The operator Peer: the one party this Host answers to. Connection owns it
|
|
3
|
-
* for its own lifetime, admits every request as it, and hands it to each
|
|
4
|
-
* Remote call as `invocation.peer`.
|
|
5
|
-
* @module @qilin-agent/client-connection/src/operator-peer
|
|
6
|
-
*/
|
|
7
|
-
import type { Context } from '@qilin-agent/kylin';
|
|
8
|
-
import type { PeerId, PeerScope } from '@qilin-agent/typert-protocol';
|
|
9
|
-
/**
|
|
10
|
-
* The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)`
|
|
11
|
-
* returns it and events dispatched with `scopeTarget(subject, peer)` reach
|
|
12
|
-
* listeners registered through `peer.ctx` and nobody else.
|
|
13
|
-
*/
|
|
14
|
-
export declare class OperatorPeer implements PeerScope {
|
|
15
|
-
readonly id: PeerId;
|
|
16
|
-
readonly ctx: Context;
|
|
17
|
-
private readonly scope;
|
|
18
|
-
/** @param owner - Connection plugin context the scope fiber hangs under. */
|
|
19
|
-
constructor(owner: Context);
|
|
20
|
-
/** Tear down every connection-lifetime registration; racing calls share one completion. */
|
|
21
|
-
dispose(): Promise<void>;
|
|
22
|
-
}
|
|
23
|
-
//# sourceMappingURL=operator-peer.d.ts.map
|