@h-ai/serv 0.1.0-alpha.61 → 0.1.0-alpha.62
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.en.md +12 -8
- package/README.ja.md +12 -8
- package/README.md +12 -8
- package/dist/features/ai.d.ts +1 -1
- package/dist/features/iam.d.ts +2 -1
- package/dist/features/storage.d.ts +1 -1
- package/dist/index.d.ts +13 -8
- package/dist/index.js +83 -9
- package/dist/index.js.map +1 -1
- package/dist/{serv-context-CkbS0M9G.d.ts → serv-context-CWzyrsdq.d.ts} +14 -6
- package/package.json +10 -10
package/README.en.md
CHANGED
|
@@ -84,6 +84,8 @@ const handler = serv.toFetch(app)
|
|
|
84
84
|
export default { fetch: handler }
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
Node `serv.listen()` supplies the socket peer address. Forwarded headers are ignored by default. Only proxy IPs/CIDRs in `http.trustedProxies` may supply them: XFF is walked from right to left until the first untrusted address; content to the left of that address is ignored. An invalid or excessively long trusted chain leaves IP undefined rather than promoting the proxy to client identity. Proxies must sanitize or append forwarded headers. Fetch platforms explicitly provide a trusted peer through `app.fetch(request, { remoteAddress: platformPeerAddress })`; without metadata IP is undefined. Do not copy arbitrary headers into metadata. Custom `createContext(input)` forwards the complete input to preserve resolved `input.ip`. Internal RPC checks the connection peer.
|
|
88
|
+
|
|
87
89
|
### Audio WebSocket: tickets and server authorization
|
|
88
90
|
|
|
89
91
|
Audio connections require short-lived, one-time tickets. Issue them through an authenticated HTTP endpoint (recommended: `iam.ticket.issue`) and consume atomically in `verifyTicket` (recommended: `iam.ticket.consume`). Authentication runs at connection time with a separate timeout; no business frames are processed beforehand. The framework stores the returned `ServSession` and optional `grant`, then calls `authorize` to approve paid parameters:
|
|
@@ -152,6 +154,7 @@ When using `@h-ai/core` and `ServConfigSchema` with `config/_serv.yml`, transpor
|
|
|
152
154
|
```yaml
|
|
153
155
|
# config/_serv.yml
|
|
154
156
|
http:
|
|
157
|
+
trustedProxies: []
|
|
155
158
|
apiPrefix: /api/v1
|
|
156
159
|
|
|
157
160
|
transport:
|
|
@@ -205,10 +208,10 @@ const app = serv.createApp({
|
|
|
205
208
|
|
|
206
209
|
Pass the top-level `iam` handle to `serv.createApp` to derive:
|
|
207
210
|
|
|
208
|
-
- **Access token verification**: `iam.
|
|
211
|
+
- **Access token verification**: `iam.auth.verifyToken` fills `context.session` on every request without caching.
|
|
209
212
|
- **Refresh rotation**: with `refreshCookie`, `iam.session.refresh` becomes the httpOnly-cookie refresh callback.
|
|
210
213
|
|
|
211
|
-
**Context factory precedence**: explicit `createContext` > `verifyToken` > `iam.
|
|
214
|
+
**Context factory precedence**: explicit `createContext` > `verifyToken` > `iam.auth.verifyToken` > metadata-only default.
|
|
212
215
|
|
|
213
216
|
```ts
|
|
214
217
|
// 推荐:传入 iam,一行打开认证与 Cookie 刷新
|
|
@@ -233,7 +236,7 @@ const app = serv.createApp({
|
|
|
233
236
|
createContext: async ({ request }) => {
|
|
234
237
|
const base = serv.parseRequestContext({ request })
|
|
235
238
|
// 手动填充 session(serv 不再自动填充)
|
|
236
|
-
const result = await iam.
|
|
239
|
+
const result = await iam.auth.verifyToken(base.accessToken ?? '')
|
|
237
240
|
return {
|
|
238
241
|
...base,
|
|
239
242
|
session: result.success ? result.data : undefined,
|
|
@@ -339,16 +342,16 @@ To reuse `Bearer token -> session` and add tenant, workspace, or source-system f
|
|
|
339
342
|
prefer `serv.buildAuthContextFactory()` to assemble a custom `createContext`:
|
|
340
343
|
|
|
341
344
|
```ts
|
|
342
|
-
const baseContext = serv.buildAuthContextFactory(token => iam.
|
|
345
|
+
const baseContext = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
|
|
343
346
|
|
|
344
347
|
const app = serv.createApp({
|
|
345
348
|
contract,
|
|
346
349
|
procedures,
|
|
347
|
-
createContext: async (
|
|
348
|
-
const context = await baseContext(
|
|
350
|
+
createContext: async (input) => {
|
|
351
|
+
const context = await baseContext(input)
|
|
349
352
|
return {
|
|
350
353
|
...context,
|
|
351
|
-
tenantId: request.headers.get('x-tenant-id') ?? null,
|
|
354
|
+
tenantId: input.request.headers.get('x-tenant-id') ?? null,
|
|
352
355
|
}
|
|
353
356
|
},
|
|
354
357
|
})
|
|
@@ -436,7 +439,7 @@ const app = serv.createApp({
|
|
|
436
439
|
&& security.isNativeOrigin(request.headers.get('origin') ?? ''),
|
|
437
440
|
},
|
|
438
441
|
},
|
|
439
|
-
// ✅ 无需再传 verifyToken,iam.
|
|
442
|
+
// ✅ 无需再传 verifyToken,iam.auth.verifyToken 自动用于填充 context.session
|
|
440
443
|
})
|
|
441
444
|
```
|
|
442
445
|
|
|
@@ -518,6 +521,7 @@ server:
|
|
|
518
521
|
port: 3000
|
|
519
522
|
|
|
520
523
|
http:
|
|
524
|
+
trustedProxies: []
|
|
521
525
|
apiPrefix: /api/v1
|
|
522
526
|
openapi:
|
|
523
527
|
path: /openapi.json
|
package/README.ja.md
CHANGED
|
@@ -84,6 +84,8 @@ const handler = serv.toFetch(app)
|
|
|
84
84
|
export default { fetch: handler }
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
Node `serv.listen()` は socket の接続元を自動で渡し、既定では転送ヘッダーを無視します。`http.trustedProxies` の IP/CIDR のみ信頼し、XFF を右から左へ調べ、最初の非信頼アドレスで停止します。その左側は無視します。信頼チェーンが不正・長すぎる場合は IP 未定義となり、代理をクライアントとして扱いません。代理は転送ヘッダーを清掃・追加してください。Fetch は `app.fetch(request, { remoteAddress: platformPeerAddress })` で信頼できる接続元を渡します。メタデータなしは IP 未定義です。任意のヘッダーをコピーしないでください。独自 `createContext(input)` は input 全体を再利用し、解決済み `input.ip` を保持します。内部 RPC は接続元で制限します。
|
|
88
|
+
|
|
87
89
|
### Audio WebSocket:ticket とサーバー認可
|
|
88
90
|
|
|
89
91
|
音声接続には短期・単回 ticket が必要です。認証済み HTTP endpoint で発行(推奨:`iam.ticket.issue`)し、`verifyTicket` でアトミックに消費します(推奨:`iam.ticket.consume`)。接続直後に独立 timeout で認証し、認証前に業務フレームを処理しません。返された `ServSession` と任意の `grant` を保存してから `authorize` で有料パラメーターを確認します:
|
|
@@ -152,6 +154,7 @@ await apiClient.init({
|
|
|
152
154
|
```yaml
|
|
153
155
|
# config/_serv.yml
|
|
154
156
|
http:
|
|
157
|
+
trustedProxies: []
|
|
155
158
|
apiPrefix: /api/v1
|
|
156
159
|
|
|
157
160
|
transport:
|
|
@@ -205,10 +208,10 @@ const app = serv.createApp({
|
|
|
205
208
|
|
|
206
209
|
`serv.createApp` にトップレベルの `iam` を渡すと、次を自動構成します:
|
|
207
210
|
|
|
208
|
-
- **access token 検証**:各リクエストで `iam.
|
|
211
|
+
- **access token 検証**:各リクエストで `iam.auth.verifyToken` を呼び `context.session` を設定(キャッシュなし)。
|
|
209
212
|
- **refresh token の更新**:`refreshCookie` 有効時、`iam.session.refresh` を httpOnly cookie 更新処理に使用。
|
|
210
213
|
|
|
211
|
-
**context の優先順位**:明示的 `createContext` > `verifyToken` > `iam.
|
|
214
|
+
**context の優先順位**:明示的 `createContext` > `verifyToken` > `iam.auth.verifyToken` > メタデータのみの既定解析。
|
|
212
215
|
|
|
213
216
|
```ts
|
|
214
217
|
// 推荐:传入 iam,一行打开认证与 Cookie 刷新
|
|
@@ -233,7 +236,7 @@ const app = serv.createApp({
|
|
|
233
236
|
createContext: async ({ request }) => {
|
|
234
237
|
const base = serv.parseRequestContext({ request })
|
|
235
238
|
// 手动填充 session(serv 不再自动填充)
|
|
236
|
-
const result = await iam.
|
|
239
|
+
const result = await iam.auth.verifyToken(base.accessToken ?? '')
|
|
237
240
|
return {
|
|
238
241
|
...base,
|
|
239
242
|
session: result.success ? result.data : undefined,
|
|
@@ -339,16 +342,16 @@ const app = serv.createApp({
|
|
|
339
342
|
`serv.buildAuthContextFactory()` で独自の `createContext` を構成してください:
|
|
340
343
|
|
|
341
344
|
```ts
|
|
342
|
-
const baseContext = serv.buildAuthContextFactory(token => iam.
|
|
345
|
+
const baseContext = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
|
|
343
346
|
|
|
344
347
|
const app = serv.createApp({
|
|
345
348
|
contract,
|
|
346
349
|
procedures,
|
|
347
|
-
createContext: async (
|
|
348
|
-
const context = await baseContext(
|
|
350
|
+
createContext: async (input) => {
|
|
351
|
+
const context = await baseContext(input)
|
|
349
352
|
return {
|
|
350
353
|
...context,
|
|
351
|
-
tenantId: request.headers.get('x-tenant-id') ?? null,
|
|
354
|
+
tenantId: input.request.headers.get('x-tenant-id') ?? null,
|
|
352
355
|
}
|
|
353
356
|
},
|
|
354
357
|
})
|
|
@@ -436,7 +439,7 @@ const app = serv.createApp({
|
|
|
436
439
|
&& security.isNativeOrigin(request.headers.get('origin') ?? ''),
|
|
437
440
|
},
|
|
438
441
|
},
|
|
439
|
-
// ✅ 无需再传 verifyToken,iam.
|
|
442
|
+
// ✅ 无需再传 verifyToken,iam.auth.verifyToken 自动用于填充 context.session
|
|
440
443
|
})
|
|
441
444
|
```
|
|
442
445
|
|
|
@@ -518,6 +521,7 @@ server:
|
|
|
518
521
|
port: 3000
|
|
519
522
|
|
|
520
523
|
http:
|
|
524
|
+
trustedProxies: []
|
|
521
525
|
apiPrefix: /api/v1
|
|
522
526
|
openapi:
|
|
523
527
|
path: /openapi.json
|
package/README.md
CHANGED
|
@@ -84,6 +84,8 @@ const handler = serv.toFetch(app)
|
|
|
84
84
|
export default { fetch: handler }
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
+
Node `serv.listen()` 自动提供 socket 对端地址;默认忽略 `X-Forwarded-For` / `X-Real-IP`。只有 `http.trustedProxies` 中列出的代理 IP/CIDR 才能提供转发头,XFF 从右向左停在首个不可信地址,忽略其左侧内容;可信链无法解析或过长时 IP 为 undefined,不能回退为代理身份。代理必须清洗或追加转发头。Fetch 平台通过 `app.fetch(request, { remoteAddress: platformPeerAddress })` 显式提供可信对端,缺失时 IP 为 undefined,不能从任意请求头复制。自定义 `createContext(input)` 复用完整 input,保留框架已解析的 `input.ip`。内部 RPC 仍按连接对端限制。
|
|
88
|
+
|
|
87
89
|
### Audio WebSocket:ticket 与服务端授权
|
|
88
90
|
|
|
89
91
|
语音入口必须使用短期、一次性 ticket。应用通过已登录 HTTP endpoint 签发 ticket(推荐 `iam.ticket.issue`),并在 `verifyTicket` 中原子消费(推荐 `iam.ticket.consume`);连接建立即完成鉴权(独立预鉴权超时),未鉴权不处理任何业务帧。框架保存返回的 `ServSession` 与可选 `grant`,再调用 `authorize` 确认付费参数:
|
|
@@ -152,6 +154,7 @@ await apiClient.init({
|
|
|
152
154
|
```yaml
|
|
153
155
|
# config/_serv.yml
|
|
154
156
|
http:
|
|
157
|
+
trustedProxies: []
|
|
155
158
|
apiPrefix: /api/v1
|
|
156
159
|
|
|
157
160
|
transport:
|
|
@@ -205,10 +208,10 @@ const app = serv.createApp({
|
|
|
205
208
|
|
|
206
209
|
`serv.createApp` 推荐直接传入顶层 `iam` 句柄,自动派生两件事:
|
|
207
210
|
|
|
208
|
-
- **access token 校验**:每个请求调用 `iam.
|
|
211
|
+
- **access token 校验**:每个请求调用 `iam.auth.verifyToken` 填充 `context.session`(不缓存)。
|
|
209
212
|
- **refresh token 轮换**:启用 `refreshCookie` 后,自动使用 `iam.session.refresh` 作为 httpOnly cookie 刷新回调。
|
|
210
213
|
|
|
211
|
-
**上下文工厂优先级**:显式 `createContext` > `verifyToken` > `iam.
|
|
214
|
+
**上下文工厂优先级**:显式 `createContext` > `verifyToken` > `iam.auth.verifyToken` > 默认仅解析元数据。
|
|
212
215
|
|
|
213
216
|
```ts
|
|
214
217
|
// 推荐:传入 iam,一行打开认证与 Cookie 刷新
|
|
@@ -233,7 +236,7 @@ const app = serv.createApp({
|
|
|
233
236
|
createContext: async ({ request }) => {
|
|
234
237
|
const base = serv.parseRequestContext({ request })
|
|
235
238
|
// 手动填充 session(serv 不再自动填充)
|
|
236
|
-
const result = await iam.
|
|
239
|
+
const result = await iam.auth.verifyToken(base.accessToken ?? '')
|
|
237
240
|
return {
|
|
238
241
|
...base,
|
|
239
242
|
session: result.success ? result.data : undefined,
|
|
@@ -339,16 +342,16 @@ const app = serv.createApp({
|
|
|
339
342
|
优先用 `serv.buildAuthContextFactory()` 组装自定义 `createContext`:
|
|
340
343
|
|
|
341
344
|
```ts
|
|
342
|
-
const baseContext = serv.buildAuthContextFactory(token => iam.
|
|
345
|
+
const baseContext = serv.buildAuthContextFactory(token => iam.auth.verifyToken(token))
|
|
343
346
|
|
|
344
347
|
const app = serv.createApp({
|
|
345
348
|
contract,
|
|
346
349
|
procedures,
|
|
347
|
-
createContext: async (
|
|
348
|
-
const context = await baseContext(
|
|
350
|
+
createContext: async (input) => {
|
|
351
|
+
const context = await baseContext(input)
|
|
349
352
|
return {
|
|
350
353
|
...context,
|
|
351
|
-
tenantId: request.headers.get('x-tenant-id') ?? null,
|
|
354
|
+
tenantId: input.request.headers.get('x-tenant-id') ?? null,
|
|
352
355
|
}
|
|
353
356
|
},
|
|
354
357
|
})
|
|
@@ -436,7 +439,7 @@ const app = serv.createApp({
|
|
|
436
439
|
&& security.isNativeOrigin(request.headers.get('origin') ?? ''),
|
|
437
440
|
},
|
|
438
441
|
},
|
|
439
|
-
// ✅ 无需再传 verifyToken,iam.
|
|
442
|
+
// ✅ 无需再传 verifyToken,iam.auth.verifyToken 自动用于填充 context.session
|
|
440
443
|
})
|
|
441
444
|
```
|
|
442
445
|
|
|
@@ -518,6 +521,7 @@ server:
|
|
|
518
521
|
port: 3000
|
|
519
522
|
|
|
520
523
|
http:
|
|
524
|
+
trustedProxies: []
|
|
521
525
|
apiPrefix: /api/v1
|
|
522
526
|
openapi:
|
|
523
527
|
path: /openapi.json
|
package/dist/features/ai.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as _orpc_server from '@orpc/server';
|
|
|
2
2
|
import * as zod_v4_core from 'zod/v4/core';
|
|
3
3
|
import * as zod from 'zod';
|
|
4
4
|
import { AIFunctions } from '@h-ai/ai';
|
|
5
|
-
import { S as ServContext } from '../serv-context-
|
|
5
|
+
import { S as ServContext } from '../serv-context-CWzyrsdq.js';
|
|
6
6
|
import '@h-ai/core';
|
|
7
7
|
|
|
8
8
|
/** AI 默认 procedures 依赖。 */
|
package/dist/features/iam.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as zod from 'zod';
|
|
|
4
4
|
import * as _orpc_contract from '@orpc/contract';
|
|
5
5
|
import { IamFunctions } from '@h-ai/iam';
|
|
6
6
|
export { TokenPair } from '@h-ai/iam';
|
|
7
|
-
import { S as ServContext } from '../serv-context-
|
|
7
|
+
import { S as ServContext } from '../serv-context-CWzyrsdq.js';
|
|
8
8
|
import '@h-ai/core';
|
|
9
9
|
|
|
10
10
|
/** IAM 默认 procedures 依赖。 */
|
|
@@ -480,6 +480,7 @@ declare function createIamProcedures(deps: IamProcedureDeps): {
|
|
|
480
480
|
list: _orpc_server.Procedure<ServContext & {}, any, zod.ZodObject<{
|
|
481
481
|
page: zod.ZodOptional<zod.ZodCoercedNumber<unknown>>;
|
|
482
482
|
pageSize: zod.ZodOptional<zod.ZodCoercedNumber<unknown>>;
|
|
483
|
+
roleCode: zod.ZodOptional<zod.ZodString>;
|
|
483
484
|
search: zod.ZodOptional<zod.ZodString>;
|
|
484
485
|
enabled: zod.ZodPipe<zod.ZodOptional<zod.ZodEnum<{
|
|
485
486
|
true: "true";
|
|
@@ -2,7 +2,7 @@ import * as _orpc_server from '@orpc/server';
|
|
|
2
2
|
import * as zod_v4_core from 'zod/v4/core';
|
|
3
3
|
import * as zod from 'zod';
|
|
4
4
|
import { StorageFunctions } from '@h-ai/storage';
|
|
5
|
-
import { S as ServContext } from '../serv-context-
|
|
5
|
+
import { S as ServContext } from '../serv-context-CWzyrsdq.js';
|
|
6
6
|
import '@h-ai/core';
|
|
7
7
|
|
|
8
8
|
/** Storage 默认 procedures 依赖。 */
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { HaiResult, ValidationFormError } from '@h-ai/core';
|
|
|
3
3
|
import { AIFunctions, AudioWsStartMessage, AudioFormat } from '@h-ai/ai';
|
|
4
4
|
import { AnyContractRouter, OpenAPI } from '@orpc/contract';
|
|
5
5
|
import { ProcedureHandler, ErrorMap, Meta, ProcedureHandlerOptions, ORPCErrorConstructorMap, Router, ContractRouter, Context, ContractProcedure, InferSchemaInput, InferSchemaOutput, UnlaziedRouter } from '@orpc/server';
|
|
6
|
-
import { a as ServSession, S as ServContext, b as ServIam, R as RefreshCookieConfig, C as CreateServContext, p as parseRequestContext,
|
|
7
|
-
export {
|
|
6
|
+
import { a as ServSession, S as ServContext, b as ServIam, R as RefreshCookieConfig, C as CreateServContext, c as ServRequestMetadata, p as parseRequestContext, d as buildAuthContextFactory } from './serv-context-CWzyrsdq.js';
|
|
7
|
+
export { e as CreateServContextInput, N as NativeRefreshTokenTransportConfig, f as RefreshTokenPair } from './serv-context-CWzyrsdq.js';
|
|
8
8
|
import { MiddlewareHandler } from 'hono';
|
|
9
9
|
import { z, ZodType } from 'zod';
|
|
10
10
|
import { CryptoFunctions, TransportKeyStore } from '@h-ai/crypto';
|
|
@@ -128,6 +128,8 @@ interface ServCorsRuntimeConfig {
|
|
|
128
128
|
}
|
|
129
129
|
/** ServHttpApp 的 HTTP 挂载配置。 */
|
|
130
130
|
interface ServHttpConfig {
|
|
131
|
+
/** 可信任的连接代理 IP/CIDR;默认空数组,不采纳转发头。 */
|
|
132
|
+
readonly trustedProxies: readonly string[];
|
|
131
133
|
readonly apiPrefix: `/api/${string}`;
|
|
132
134
|
readonly openapi: false | ServOpenAPIHttpConfig;
|
|
133
135
|
readonly docs: false | ServDocsHttpConfig;
|
|
@@ -151,6 +153,7 @@ type ServTransportRuntimeConfigInput = Partial<{
|
|
|
151
153
|
}>;
|
|
152
154
|
/** `serv.createApp` 接受的 HTTP 配置入参类型。 */
|
|
153
155
|
type ServHttpConfigInput = Partial<{
|
|
156
|
+
readonly trustedProxies: readonly string[];
|
|
154
157
|
readonly apiPrefix: `/api/${string}`;
|
|
155
158
|
readonly openapi: false | Partial<ServOpenAPIHttpConfig>;
|
|
156
159
|
readonly docs: false | Partial<ServDocsHttpConfig>;
|
|
@@ -171,6 +174,7 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
|
|
|
171
174
|
port: z.ZodDefault<z.ZodNumber>;
|
|
172
175
|
}, z.core.$strip>>;
|
|
173
176
|
http: z.ZodOptional<z.ZodObject<{
|
|
177
|
+
trustedProxies: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
174
178
|
apiPrefix: z.ZodDefault<z.ZodCustom<`/api/${string}`, `/api/${string}`>>;
|
|
175
179
|
openapi: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
|
|
176
180
|
path: z.ZodDefault<z.ZodCustom<`/${string}`, `/${string}`>>;
|
|
@@ -217,6 +221,7 @@ declare const ServConfigSchema: z.ZodPipe<z.ZodObject<{
|
|
|
217
221
|
port: number;
|
|
218
222
|
} | undefined;
|
|
219
223
|
http?: {
|
|
224
|
+
trustedProxies: string[];
|
|
220
225
|
apiPrefix: `/api/${string}`;
|
|
221
226
|
openapi: false | {
|
|
222
227
|
path: `/${string}`;
|
|
@@ -327,7 +332,7 @@ interface ServMiddlewareMount {
|
|
|
327
332
|
* - `request`:测试 / 本地直连时发起 in-process 请求,避免消费方 import Hono。
|
|
328
333
|
*/
|
|
329
334
|
interface ServHttpApp {
|
|
330
|
-
readonly fetch: (request: Request) => Response | Promise<Response>;
|
|
335
|
+
readonly fetch: (request: Request, metadata?: ServRequestMetadata) => Response | Promise<Response>;
|
|
331
336
|
readonly request: (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
|
|
332
337
|
}
|
|
333
338
|
/**
|
|
@@ -336,13 +341,13 @@ interface ServHttpApp {
|
|
|
336
341
|
* **认证 / 会话填充设计**(遵循最小知识原则):
|
|
337
342
|
*
|
|
338
343
|
* - **`iam`**(推荐):单一顶层引用。提供后 serv 自动:
|
|
339
|
-
* 1. 使用 `iam.
|
|
344
|
+
* 1. 使用 `iam.auth.verifyToken` 填充 `context.session` → route guard 生效
|
|
340
345
|
* 2. 若同时启用 `refreshCookie`,使用 `iam.session.refresh` 实现 refresh token 轮换
|
|
341
346
|
*
|
|
342
347
|
* - **`refreshCookie`**(可选):浏览器默认用 httpOnly cookie;受信原生客户端可配置 body 通道。与 `iam` 正交:
|
|
343
348
|
* `iam` 决定 *谁* 验证 token,`refreshCookie` 决定 refresh token *怎么* 传输。
|
|
344
349
|
*
|
|
345
|
-
* - **`verifyToken`**(高级):不使用 IAM 模块的逆脱口。优先于 `iam.
|
|
350
|
+
* - **`verifyToken`**(高级):不使用 IAM 模块的逆脱口。优先于 `iam.auth.verifyToken`。
|
|
346
351
|
*
|
|
347
352
|
* - **`createContext`**(高级):完全接管上下文构造,设置后 serv **不再**自动填充 session,
|
|
348
353
|
* 由调用方负责。
|
|
@@ -362,7 +367,7 @@ interface CreateServAppOptions<TContract extends AnyContractRouter, TProcedures
|
|
|
362
367
|
*/
|
|
363
368
|
readonly middlewares?: readonly ServMiddlewareMount[];
|
|
364
369
|
/**
|
|
365
|
-
* IAM 模块引用(推荐)。提供后 serv 自动使用 `iam.
|
|
370
|
+
* IAM 模块引用(推荐)。提供后 serv 自动使用 `iam.auth.verifyToken` 填充 session,
|
|
366
371
|
* 以及在 `refreshCookie` 启用时使用 `iam.session.refresh` 实现 cookie 刷新。
|
|
367
372
|
*
|
|
368
373
|
* 类型上要求 {@link ServIam}(`iam` 可以直接传入,结构类型保证兼容)。
|
|
@@ -374,7 +379,7 @@ interface CreateServAppOptions<TContract extends AnyContractRouter, TProcedures
|
|
|
374
379
|
*/
|
|
375
380
|
readonly refreshCookie?: RefreshCookieConfig;
|
|
376
381
|
/**
|
|
377
|
-
* 高级:自定义访问令牌校验回调,覆盖 `iam.
|
|
382
|
+
* 高级:自定义访问令牌校验回调,覆盖 `iam.auth.verifyToken`。
|
|
378
383
|
* 不使用 IAM 模块的场景下使用。
|
|
379
384
|
*/
|
|
380
385
|
readonly verifyToken?: (token: string) => Promise<HaiResult<ServSession>>;
|
|
@@ -915,4 +920,4 @@ declare const serv: {
|
|
|
915
920
|
m: (key: "$schema" | "serv_validationFailed" | "serv_validationRequired" | "serv_validationInvalid" | "serv_validationInvalidType" | "serv_validationStringMin" | "serv_validationStringMax" | "serv_validationNumberMin" | "serv_validationNumberMax" | "serv_validationArrayMin" | "serv_validationArrayMax" | "serv_validationTooSmall" | "serv_validationTooBig" | "serv_validationEmail" | "serv_validationUrl" | "serv_validationUuid" | "serv_validationEnum" | "serv_errorUnauthorized" | "serv_errorForbidden" | "serv_errorInternal" | "serv_storageKeyRequired" | "serv_storageKeyTooLong" | "serv_storageKeyIllegalChars" | "serv_storageKeyDotSegments" | "serv_iamCannotDeleteCurrentUser" | "serv_transportInvalidPayload" | "serv_transportUnknownClient" | "serv_transportClientIdRequired" | "serv_transportClientKeyNotFound" | "serv_transportDecryptFailed" | "serv_transportEncryptFailed" | "serv_transportKeyExchangeFailed", options?: _h_ai_core.MessageOptions) => string;
|
|
916
921
|
};
|
|
917
922
|
|
|
918
|
-
export { type AudioTicketGrant, type AudioTicketVerification, type AuthenticatedServContext, type AuthorizedAudioRequest, type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServAudioConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServCorsRuntimeConfig, type ServCorsRuntimeConfigInput, type ServDocsHttpConfig, type ServFetchHandler, type ServHealthHttpConfig, type ServHttpApp, type ServHttpConfig, type ServHttpConfigInput, ServIam, type ServImplementer, type ServListenOptions, type ServMiddleware, type ServMiddlewareFactory, type ServMiddlewareMount, type ServNodeServer, type ServOpenAPIHttpConfig, type ServProcedureHandler, type ServProcedureOptions, type ServRouteHandler, type ServRoutePath, type ServRouterBuilder, type ServRpcHttpConfig, type ServRuntimeSecurityPolicy, type ServRuntimeSecurityPolicyInput, type ServServerConfig, type ServServerConfigInput, ServSession, type ServStorageAssetsConfig, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
|
|
923
|
+
export { type AudioTicketGrant, type AudioTicketVerification, type AuthenticatedServContext, type AuthorizedAudioRequest, type CreateServAppOptions, CreateServContext, type GenerateOpenAPISpecOptions, RefreshCookieConfig, type ServAudioConfig, type ServConfig, type ServConfigInput, ServConfigSchema, ServContext, type ServCorsConfig, type ServCorsRuntimeConfig, type ServCorsRuntimeConfigInput, type ServDocsHttpConfig, type ServFetchHandler, type ServHealthHttpConfig, type ServHttpApp, type ServHttpConfig, type ServHttpConfigInput, ServIam, type ServImplementer, type ServListenOptions, type ServMiddleware, type ServMiddlewareFactory, type ServMiddlewareMount, type ServNodeServer, type ServOpenAPIHttpConfig, type ServProcedureHandler, type ServProcedureOptions, ServRequestMetadata, type ServRouteHandler, type ServRoutePath, type ServRouterBuilder, type ServRpcHttpConfig, type ServRuntimeSecurityPolicy, type ServRuntimeSecurityPolicyInput, type ServServerConfig, type ServServerConfigInput, ServSession, type ServStorageAssetsConfig, type ServTransportConfig, type ServTransportRuntimeConfig, type ServTransportRuntimeConfigInput, type ServValidationFailureBody, serv };
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { RPCHandler } from '@orpc/server/fetch';
|
|
|
8
8
|
import { Hono } from 'hono';
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import { Buffer } from 'buffer';
|
|
11
|
+
import { BlockList, isIP } from 'net';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { getCookie } from 'hono/cookie';
|
|
13
14
|
import { readFile } from 'fs/promises';
|
|
@@ -520,6 +521,70 @@ function toArrayBuffer(data) {
|
|
|
520
521
|
new Uint8Array(buffer).set(data);
|
|
521
522
|
return buffer;
|
|
522
523
|
}
|
|
524
|
+
function readRemoteAddress(metadata) {
|
|
525
|
+
if (!metadata || typeof metadata !== "object" || !("remoteAddress" in metadata) || typeof metadata.remoteAddress !== "string")
|
|
526
|
+
return void 0;
|
|
527
|
+
return normalizeClientIp(metadata.remoteAddress);
|
|
528
|
+
}
|
|
529
|
+
function normalizeClientIp(value) {
|
|
530
|
+
if (!value)
|
|
531
|
+
return void 0;
|
|
532
|
+
const address = value.trim();
|
|
533
|
+
if (!isIP(address))
|
|
534
|
+
return void 0;
|
|
535
|
+
const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
536
|
+
return mapped?.[1] ?? address;
|
|
537
|
+
}
|
|
538
|
+
function addProxy(blockList, entry) {
|
|
539
|
+
const [address, prefix, extra] = entry.split("/");
|
|
540
|
+
const family = address ? isIP(address) : 0;
|
|
541
|
+
if (!family || extra !== void 0)
|
|
542
|
+
throw new Error("trustedProxies entries must be IP addresses or CIDRs");
|
|
543
|
+
const type = family === 4 ? "ipv4" : "ipv6";
|
|
544
|
+
if (prefix === void 0) {
|
|
545
|
+
blockList.addAddress(address, type);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const bits = Number(prefix);
|
|
549
|
+
if (!/^\d+$/.test(prefix) || bits < 0 || bits > (family === 4 ? 32 : 128))
|
|
550
|
+
throw new Error("Invalid trustedProxies CIDR prefix");
|
|
551
|
+
blockList.addSubnet(address, bits, type);
|
|
552
|
+
}
|
|
553
|
+
function isTrustedProxyEntry(entry) {
|
|
554
|
+
try {
|
|
555
|
+
addProxy(new BlockList(), entry);
|
|
556
|
+
return true;
|
|
557
|
+
} catch {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function createClientIpResolver(trustedProxies) {
|
|
562
|
+
const proxies = new BlockList();
|
|
563
|
+
for (const entry of trustedProxies)
|
|
564
|
+
addProxy(proxies, entry);
|
|
565
|
+
const trusted = (address) => proxies.check(address, isIP(address) === 4 ? "ipv4" : "ipv6");
|
|
566
|
+
return (request, remoteAddress) => {
|
|
567
|
+
const peer = normalizeClientIp(remoteAddress);
|
|
568
|
+
if (!peer || !trusted(peer))
|
|
569
|
+
return peer;
|
|
570
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
571
|
+
if (forwarded === null) {
|
|
572
|
+
const realIp = request.headers.get("x-real-ip");
|
|
573
|
+
return realIp === null ? peer : normalizeClientIp(realIp);
|
|
574
|
+
}
|
|
575
|
+
const chain = forwarded.split(",");
|
|
576
|
+
if (chain.length > 32)
|
|
577
|
+
return void 0;
|
|
578
|
+
let current = peer;
|
|
579
|
+
for (let index = chain.length - 1; index >= 0 && trusted(current); index--) {
|
|
580
|
+
const address = normalizeClientIp(chain[index]);
|
|
581
|
+
if (!address)
|
|
582
|
+
return void 0;
|
|
583
|
+
current = address;
|
|
584
|
+
}
|
|
585
|
+
return current;
|
|
586
|
+
};
|
|
587
|
+
}
|
|
523
588
|
var DEFAULT_LOCALE = "zh-CN";
|
|
524
589
|
function resolveRequestLocale(headers) {
|
|
525
590
|
const get = (name) => {
|
|
@@ -586,6 +651,9 @@ function requireInternalRPC(config) {
|
|
|
586
651
|
};
|
|
587
652
|
}
|
|
588
653
|
async function getRequestRemoteAddress(c) {
|
|
654
|
+
const remoteAddress = readRemoteAddress(c.env);
|
|
655
|
+
if (remoteAddress)
|
|
656
|
+
return remoteAddress;
|
|
589
657
|
const getConnInfo = await resolveGetConnInfo();
|
|
590
658
|
if (!getConnInfo)
|
|
591
659
|
return void 0;
|
|
@@ -676,6 +744,7 @@ var CorsRuntimeConfigInputSchema = z.object({
|
|
|
676
744
|
credentials: z.boolean().default(false)
|
|
677
745
|
});
|
|
678
746
|
var ServHttpConfigInputSchema = z.object({
|
|
747
|
+
trustedProxies: z.array(z.string().trim().refine(isTrustedProxyEntry, "Expected an IP address or CIDR")).default([]),
|
|
679
748
|
apiPrefix: ApiPrefixSchema.default("/api/v1"),
|
|
680
749
|
openapi: OpenAPIHttpConfigInputSchema.default(false),
|
|
681
750
|
docs: DocsHttpConfigInputSchema.default(false),
|
|
@@ -719,8 +788,6 @@ function resolveServTransportRuntimeConfig(input = false) {
|
|
|
719
788
|
return TransportRuntimeConfigInputSchema.parse(input);
|
|
720
789
|
}
|
|
721
790
|
var HEADER_REQUEST_ID = "x-request-id";
|
|
722
|
-
var HEADER_FORWARDED_FOR = "x-forwarded-for";
|
|
723
|
-
var HEADER_REAL_IP = "x-real-ip";
|
|
724
791
|
var HEADER_AUTHORIZATION = "authorization";
|
|
725
792
|
var HEADER_USER_AGENT = "user-agent";
|
|
726
793
|
function extractBearerToken(value) {
|
|
@@ -734,8 +801,7 @@ function extractBearerToken(value) {
|
|
|
734
801
|
function parseRequestContext(input) {
|
|
735
802
|
const headers = input.request.headers;
|
|
736
803
|
const requestId = headers.get(HEADER_REQUEST_ID) ?? core.id.uuid();
|
|
737
|
-
const
|
|
738
|
-
const ip = headers.get(HEADER_REAL_IP) ?? forwardedFor?.split(",")[0]?.trim();
|
|
804
|
+
const ip = normalizeClientIp(Object.hasOwn(input, "ip") ? input.ip : input.remoteAddress);
|
|
739
805
|
const locale = resolveRequestLocale(headers);
|
|
740
806
|
const accessToken = extractBearerToken(headers.get(HEADER_AUTHORIZATION));
|
|
741
807
|
return {
|
|
@@ -1160,8 +1226,10 @@ function createApp(options) {
|
|
|
1160
1226
|
core.i18n.coreM("core_errorNotFound", { locale: resolveRequestLocale(c.req.raw.headers) })
|
|
1161
1227
|
), 404));
|
|
1162
1228
|
const iamRef = options.iam;
|
|
1163
|
-
const verifyToken = options.verifyToken ?? (iamRef ? (token) => iamRef.
|
|
1164
|
-
const
|
|
1229
|
+
const verifyToken = options.verifyToken ?? (iamRef ? (token) => iamRef.auth.verifyToken(token) : void 0);
|
|
1230
|
+
const contextFactory = options.createContext ?? (verifyToken ? buildAuthContextFactory(verifyToken) : parseRequestContext);
|
|
1231
|
+
const resolveIp = createClientIpResolver(http.trustedProxies);
|
|
1232
|
+
const createContext = (input) => contextFactory({ ...input, ip: resolveIp(input.request, input.remoteAddress) });
|
|
1165
1233
|
let cachedSpec;
|
|
1166
1234
|
const getSpec = () => {
|
|
1167
1235
|
cachedSpec ??= generateSpec(options.contract, { apiPrefix: http.apiPrefix });
|
|
@@ -1224,7 +1292,7 @@ function createApp(options) {
|
|
|
1224
1292
|
});
|
|
1225
1293
|
app.get(docs.path, async (c) => {
|
|
1226
1294
|
if (docs.requireAuth) {
|
|
1227
|
-
const context = await createContext({ request: c.req.raw });
|
|
1295
|
+
const context = await createContext({ request: c.req.raw, remoteAddress: readRemoteAddress(c.env) });
|
|
1228
1296
|
if (!context.session)
|
|
1229
1297
|
return c.json(buildHaiErrorBody(HaiCommonError.UNAUTHORIZED, servM("serv_errorUnauthorized", { locale: resolveRequestLocale(c.req.raw.headers) })), 401);
|
|
1230
1298
|
}
|
|
@@ -1254,7 +1322,7 @@ function mountRPCRoutes(app, procedures, rpcConfig, createContext) {
|
|
|
1254
1322
|
app.on([...API_METHODS], `${prefix}/*`, (c, next) => handleORPC(c, next, prefix, handler, createContext));
|
|
1255
1323
|
}
|
|
1256
1324
|
async function handleORPC(c, next, prefix, handler, createContext) {
|
|
1257
|
-
const context = await createContext({ request: c.req.raw });
|
|
1325
|
+
const context = await createContext({ request: c.req.raw, remoteAddress: readRemoteAddress(c.env) });
|
|
1258
1326
|
const { matched, response } = await handler.handle(c.req.raw, { prefix, context });
|
|
1259
1327
|
if (!matched) {
|
|
1260
1328
|
await next();
|
|
@@ -1321,7 +1389,13 @@ function listen(app, options = {}) {
|
|
|
1321
1389
|
const port = options.port ?? DEFAULT_SERV_PORT;
|
|
1322
1390
|
const audioServer = audioWsServers.get(app);
|
|
1323
1391
|
const server = serve(
|
|
1324
|
-
{
|
|
1392
|
+
{
|
|
1393
|
+
// 保留原 bindings 对象,WebSocket 升级状态由 Node adapter 在同一对象上共享。
|
|
1394
|
+
fetch: (request, bindings) => app.fetch(request, Object.assign(bindings, { remoteAddress: bindings.incoming.socket.remoteAddress })),
|
|
1395
|
+
hostname: host,
|
|
1396
|
+
port,
|
|
1397
|
+
websocket: audioServer ? { server: audioServer } : void 0
|
|
1398
|
+
},
|
|
1325
1399
|
options.onListening
|
|
1326
1400
|
);
|
|
1327
1401
|
const handle = {
|