@taicode/common-server 3.8.0 → 4.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/output/app/auth.test.d.ts +2 -0
  2. package/output/app/auth.test.d.ts.map +1 -0
  3. package/output/app/auth.test.js +148 -0
  4. package/output/app/boot.d.ts +39 -0
  5. package/output/app/boot.d.ts.map +1 -0
  6. package/output/app/boot.js +140 -0
  7. package/output/app/config.test.d.ts +2 -0
  8. package/output/app/config.test.d.ts.map +1 -0
  9. package/output/app/config.test.js +132 -0
  10. package/output/app/context.d.ts +70 -0
  11. package/output/app/context.d.ts.map +1 -0
  12. package/output/app/context.js +73 -0
  13. package/output/app/core/cookie.d.ts +25 -0
  14. package/output/app/core/cookie.d.ts.map +1 -0
  15. package/output/app/core/cookie.js +22 -0
  16. package/output/app/core/identity.d.ts +12 -0
  17. package/output/app/core/identity.d.ts.map +1 -0
  18. package/output/app/core/identity.js +14 -0
  19. package/output/app/core/index.d.ts +44 -0
  20. package/output/app/core/index.d.ts.map +1 -0
  21. package/output/app/core/index.js +57 -0
  22. package/output/app/core/logging.d.ts +13 -0
  23. package/output/app/core/logging.d.ts.map +1 -0
  24. package/output/app/core/logging.js +29 -0
  25. package/output/app/core/openapi.d.ts +17 -0
  26. package/output/app/core/openapi.d.ts.map +1 -0
  27. package/output/app/core/openapi.js +42 -0
  28. package/output/app/core/request-id.d.ts +36 -0
  29. package/output/app/core/request-id.d.ts.map +1 -0
  30. package/output/app/core/request-id.js +66 -0
  31. package/output/app/core/response.d.ts +41 -0
  32. package/output/app/core/response.d.ts.map +1 -0
  33. package/output/app/core/response.js +89 -0
  34. package/output/app/define-server.negative-cases.d.ts +40 -0
  35. package/output/app/define-server.negative-cases.d.ts.map +1 -0
  36. package/output/app/define-server.negative-cases.js +268 -0
  37. package/output/app/define-server.test.js +86 -288
  38. package/output/app/entries.d.ts +29 -0
  39. package/output/app/entries.d.ts.map +1 -0
  40. package/output/app/entries.js +64 -0
  41. package/output/app/env.d.ts +39 -4
  42. package/output/app/env.d.ts.map +1 -1
  43. package/output/app/env.js +32 -4
  44. package/output/app/env.test.js +67 -21
  45. package/output/app/error-contract.test.d.ts +2 -0
  46. package/output/app/error-contract.test.d.ts.map +1 -0
  47. package/output/app/error-contract.test.js +227 -0
  48. package/output/app/errors.d.ts +20 -0
  49. package/output/app/errors.d.ts.map +1 -0
  50. package/output/app/errors.js +51 -0
  51. package/output/app/generate-openapi.test.d.ts +2 -0
  52. package/output/app/generate-openapi.test.d.ts.map +1 -0
  53. package/output/app/generate-openapi.test.js +123 -0
  54. package/output/app/index.d.ts +15 -15
  55. package/output/app/index.d.ts.map +1 -1
  56. package/output/app/index.js +18 -9
  57. package/output/app/lifecycle-logging.test.d.ts +2 -0
  58. package/output/app/lifecycle-logging.test.d.ts.map +1 -0
  59. package/output/app/lifecycle-logging.test.js +155 -0
  60. package/output/app/lifecycle.d.ts +70 -0
  61. package/output/app/lifecycle.d.ts.map +1 -0
  62. package/output/app/lifecycle.js +99 -0
  63. package/output/app/lifecycle.test.d.ts +2 -0
  64. package/output/app/lifecycle.test.d.ts.map +1 -0
  65. package/output/app/lifecycle.test.js +152 -0
  66. package/output/app/middleware/auth.d.ts +71 -0
  67. package/output/app/middleware/auth.d.ts.map +1 -0
  68. package/output/app/middleware/auth.js +190 -0
  69. package/output/app/middleware/context.d.ts +13 -0
  70. package/output/app/middleware/context.d.ts.map +1 -0
  71. package/output/app/middleware/context.js +15 -0
  72. package/output/app/middleware/cors.d.ts +14 -0
  73. package/output/app/middleware/cors.d.ts.map +1 -0
  74. package/output/app/middleware/cors.js +30 -0
  75. package/output/app/middleware/index.d.ts +17 -0
  76. package/output/app/middleware/index.d.ts.map +1 -0
  77. package/output/app/middleware/index.js +16 -0
  78. package/output/app/middleware/rate-limit.d.ts +24 -0
  79. package/output/app/middleware/rate-limit.d.ts.map +1 -0
  80. package/output/app/middleware/rate-limit.js +46 -0
  81. package/output/app/middleware-order.test.d.ts +2 -0
  82. package/output/app/middleware-order.test.d.ts.map +1 -0
  83. package/output/app/middleware-order.test.js +148 -0
  84. package/output/app/permission-operations.d.ts +48 -0
  85. package/output/app/permission-operations.d.ts.map +1 -0
  86. package/output/app/permission-operations.js +77 -0
  87. package/output/app/permission-operations.test.d.ts +2 -0
  88. package/output/app/permission-operations.test.d.ts.map +1 -0
  89. package/output/app/permission-operations.test.js +72 -0
  90. package/output/app/permission-registration.test.d.ts +2 -0
  91. package/output/app/permission-registration.test.d.ts.map +1 -0
  92. package/output/app/permission-registration.test.js +138 -0
  93. package/output/app/redis.d.ts +45 -0
  94. package/output/app/redis.d.ts.map +1 -0
  95. package/output/app/redis.js +61 -0
  96. package/output/app/registered-operations.test.d.ts +2 -0
  97. package/output/app/registered-operations.test.d.ts.map +1 -0
  98. package/output/app/registered-operations.test.js +57 -0
  99. package/output/app/request-id.test.d.ts +2 -0
  100. package/output/app/request-id.test.d.ts.map +1 -0
  101. package/output/app/request-id.test.js +130 -0
  102. package/output/app/resources.test.d.ts +2 -0
  103. package/output/app/resources.test.d.ts.map +1 -0
  104. package/output/app/resources.test.js +199 -0
  105. package/output/app/shutdown.d.ts +17 -4
  106. package/output/app/shutdown.d.ts.map +1 -1
  107. package/output/app/shutdown.js +27 -12
  108. package/output/app/shutdown.test.js +78 -124
  109. package/output/app/signal.d.ts +4 -4
  110. package/output/app/signal.d.ts.map +1 -1
  111. package/output/app/signal.js +3 -3
  112. package/output/app/smoke.test.d.ts +2 -0
  113. package/output/app/smoke.test.d.ts.map +1 -0
  114. package/output/app/smoke.test.js +210 -0
  115. package/output/app/test-support.d.ts +21 -0
  116. package/output/app/test-support.d.ts.map +1 -0
  117. package/output/app/test-support.js +24 -0
  118. package/output/app/type-augment.d.ts +34 -0
  119. package/output/app/type-augment.d.ts.map +1 -0
  120. package/output/app/type-augment.js +7 -0
  121. package/output/app/types.d.ts +205 -126
  122. package/output/app/types.d.ts.map +1 -1
  123. package/output/app/types.js +2 -1
  124. package/package.json +10 -6
  125. package/output/app/assemble.d.ts +0 -16
  126. package/output/app/assemble.d.ts.map +0 -1
  127. package/output/app/assemble.js +0 -124
  128. package/output/app/database.d.ts +0 -36
  129. package/output/app/database.d.ts.map +0 -1
  130. package/output/app/database.js +0 -40
  131. package/output/app/database.test.d.ts +0 -2
  132. package/output/app/database.test.d.ts.map +0 -1
  133. package/output/app/database.test.js +0 -66
  134. package/output/app/default-modules.d.ts +0 -23
  135. package/output/app/default-modules.d.ts.map +0 -1
  136. package/output/app/default-modules.js +0 -40
  137. package/output/app/define-server.d.ts +0 -12
  138. package/output/app/define-server.d.ts.map +0 -1
  139. package/output/app/define-server.js +0 -289
  140. package/output/app/module.d.ts +0 -88
  141. package/output/app/module.d.ts.map +0 -1
  142. package/output/app/module.js +0 -34
  143. package/output/app/steps.d.ts +0 -17
  144. package/output/app/steps.d.ts.map +0 -1
  145. package/output/app/steps.js +0 -41
@@ -0,0 +1,148 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { defineServer, startServer } from './entries.js';
3
+ import { defaultMiddlewares } from './middleware/index.js';
4
+ /**
5
+ * 中间件数组序(N1 §1 / §4)。
6
+ *
7
+ * `middlewares(ctx)` 返回**有序数组**,框架**严格按数组序**注册(无键、无枚举、无内置相对顺序魔法);
8
+ * `rateLimit` **不在**默认预设内。中间件单元是 `{ name?, register(app) }`:**不参与生命周期**。
9
+ */
10
+ const fakeMiddlewareContext = {
11
+ app: {},
12
+ log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
13
+ services: {},
14
+ config: {},
15
+ };
16
+ describe('ctx 可见性(N1 §2)', () => {
17
+ it('MiddlewareContext 有 app / log / services / config;没有 resources / env / disposer', () => {
18
+ expectTypeOf().toHaveProperty('app');
19
+ expectTypeOf().toHaveProperty('services');
20
+ expectTypeOf().toHaveProperty('config');
21
+ // @ts-expect-error 中间件看不到 resources(禁直连数据层)
22
+ void 0..resources;
23
+ // @ts-expect-error 中间件看不到 env
24
+ void 0..env;
25
+ // @ts-expect-error 中间件看不到 disposer(disposer 只属 services)
26
+ void 0..disposer;
27
+ });
28
+ });
29
+ describe('默认预设', () => {
30
+ it('defaultMiddlewares 为 [cors, context, auth],且不含 rateLimit', () => {
31
+ const units = defaultMiddlewares(fakeMiddlewareContext);
32
+ expect(units.map((unit) => unit.name)).toEqual(['cors', 'context', 'auth']);
33
+ expect(units.map((unit) => unit.name)).not.toContain('rateLimit');
34
+ });
35
+ });
36
+ describe('数组序即注册序', () => {
37
+ it('严格按数组序执行每一项注册的钩子', async () => {
38
+ const calls = [];
39
+ const makeUnit = (name) => ({
40
+ name,
41
+ register: (app) => {
42
+ app.addHook('preValidation', (_request, _reply, done) => {
43
+ calls.push(name);
44
+ done();
45
+ });
46
+ },
47
+ });
48
+ const server = defineServer({
49
+ app: { service: 'middleware-order', title: 'Middleware Order', version: '1.0.0' },
50
+ resources: () => ({}),
51
+ config: () => ({}),
52
+ services: () => ({}),
53
+ middlewares: () => [makeUnit('third'), makeUnit('first'), makeUnit('second')],
54
+ routers: (ctx) => {
55
+ ctx.app.get('/ping', () => ({ pong: true }));
56
+ },
57
+ });
58
+ const app = await startServer(server, { listen: false });
59
+ const response = await app.inject({ method: 'GET', url: '/ping' });
60
+ expect(response.statusCode).toBe(200);
61
+ expect(calls).toEqual(['third', 'first', 'second']);
62
+ await app.close();
63
+ });
64
+ it('ctx.services 与 services 槽位产物是同一个实例(M5 强类型)', async () => {
65
+ const servicesValue = { user: { find: (id) => `user:${id}` } };
66
+ let seen;
67
+ let seenFind;
68
+ const server = defineServer({
69
+ app: { service: 'middleware-services', title: 'Middleware Services', version: '1.0.0' },
70
+ resources: () => ({}),
71
+ config: () => ({}),
72
+ services: () => servicesValue,
73
+ middlewares: (ctx) => {
74
+ seen = ctx.services;
75
+ seenFind = ctx.services.user.find;
76
+ return [];
77
+ },
78
+ routers: (ctx) => {
79
+ ctx.app.get('/ping', () => ({ pong: true }));
80
+ },
81
+ });
82
+ const app = await startServer(server, { listen: false });
83
+ expect(seen).toBe(servicesValue);
84
+ expect(seenFind).toBe(servicesValue.user.find);
85
+ await app.close();
86
+ });
87
+ it('注册的钩子返回 reply → 终止链(后续单元不执行)', async () => {
88
+ const calls = [];
89
+ const server = defineServer({
90
+ app: { service: 'middleware-terminate', title: 'Middleware Terminate', version: '1.0.0' },
91
+ resources: () => ({}),
92
+ config: () => ({}),
93
+ services: () => ({}),
94
+ middlewares: () => [
95
+ {
96
+ name: 'short',
97
+ register: (app) => {
98
+ app.addHook('preValidation', (_request, reply, done) => {
99
+ calls.push('short');
100
+ void reply.status(418).send({ stopped: true });
101
+ done();
102
+ });
103
+ },
104
+ },
105
+ {
106
+ name: 'after',
107
+ register: (app) => {
108
+ app.addHook('preValidation', (_request, _reply, done) => {
109
+ calls.push('after');
110
+ done();
111
+ });
112
+ },
113
+ },
114
+ ],
115
+ routers: (ctx) => {
116
+ ctx.app.get('/ping', () => ({ pong: true }));
117
+ },
118
+ });
119
+ const app = await startServer(server, { listen: false });
120
+ const response = await app.inject({ method: 'GET', url: '/ping' });
121
+ expect(response.statusCode).toBe(418);
122
+ expect(calls).toEqual(['short']);
123
+ await app.close();
124
+ });
125
+ it('出 spec 路径也注册中间件(保证 schema 一致)', async () => {
126
+ const { generateOpenApi } = await import('./entries.js');
127
+ const calls = [];
128
+ const server = defineServer({
129
+ app: { service: 'middleware-openapi', title: 'Middleware OpenApi', version: '1.0.0' },
130
+ resources: () => ({}),
131
+ config: () => ({}),
132
+ services: () => ({}),
133
+ middlewares: () => [
134
+ {
135
+ name: 'probe',
136
+ register: () => {
137
+ calls.push('register:probe');
138
+ },
139
+ },
140
+ ],
141
+ routers: (ctx) => {
142
+ ctx.app.get('/ping', () => ({ pong: true }));
143
+ },
144
+ });
145
+ await generateOpenApi(server);
146
+ expect(calls).toEqual(['register:probe']);
147
+ });
148
+ });
@@ -0,0 +1,48 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ /**
3
+ * 权限操作提取(设计 §5.2)——**框架内置**。
4
+ *
5
+ * 两条来源:
6
+ *
7
+ * 1. **路由注册表**(`installRegisteredOperations` / `readRegisteredOperations`):core 装配期
8
+ * 在 root 挂 `onRoute` 钩子,采集每条路由的 `{ method, url, operationId, summary, description, tags }`。
9
+ * `auth` 的权限自注册只读这张表,**不依赖 `app.swagger()`**(swagger 只负责出文档)。
10
+ * 2. **OpenAPI 文档提取**(`extractPermissionOperations`):从 `paths` 提取。
11
+ * 各仓的 `onReady` 自注册(如 account 作为签发方走进程内调用)用它与 `app.swagger()` 配合,
12
+ * **替代各仓自写实现**(已从公共面导出)。
13
+ *
14
+ * 目录元素形状(`{ operation, summary?, description?, tags? }`)与各仓原自写实现一致:
15
+ *
16
+ * - 缺 `operationId` 的条目(如 `parameters` / vendor 扩展 / 文档路由)**跳过**;
17
+ * - 缺 `paths` / 空文档 → 返回 `[]`;
18
+ * - `summary` / `description` / `tags` 缺省分别归一为 `''` / `''` / `[]`。
19
+ */
20
+ /** 单条权限操作(对齐 account `POST /permission/operation/register` 的 `operations` 元素) */
21
+ export interface PermissionOperation {
22
+ operation: string;
23
+ summary: string;
24
+ description: string;
25
+ tags: string[];
26
+ }
27
+ /** 单条已注册路由的采集项(`onRoute` 期收集;`operationId` 缺省表示该路由未声明) */
28
+ export interface RegisteredOperation {
29
+ method: string;
30
+ url: string;
31
+ operationId?: string;
32
+ summary?: string;
33
+ description?: string;
34
+ tags?: string[];
35
+ }
36
+ /**
37
+ * 在 root 挂 `onRoute` 钩子,收集框架自身可见的**路由注册表**(设计 §5.2)。
38
+ *
39
+ * 必须在**路由注册之前**(core 装配期)调用:钩子挂在 root,随后 `routers` 阶段注册的路由逐条被采集。
40
+ * 该表与 swagger 解耦:swagger 只负责出文档,`auth` 的权限自注册只读这张表。
41
+ */
42
+ export declare function installRegisteredOperations(app: FastifyInstance): void;
43
+ /** 读取 root `onRoute` 采集的路由注册表;未安装(未走框架装配)时返回空数组。 */
44
+ export declare function readRegisteredOperations(app: FastifyInstance): RegisteredOperation[];
45
+ /** 从路由注册表筛出声明了 `operationId` 的项,归一为权限操作目录元素 */
46
+ export declare function toPermissionOperations(operations: readonly RegisteredOperation[]): PermissionOperation[];
47
+ export declare function extractPermissionOperations(openApiSpec: unknown): PermissionOperation[];
48
+ //# sourceMappingURL=permission-operations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-operations.d.ts","sourceRoot":"","sources":["../../source/app/permission-operations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,SAAS,CAAA;AAE3D;;;;;;;;;;;;;;;;;GAiBG;AAEH,iFAAiF;AACjF,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,EAAE,CAAA;CACf;AAeD,0DAA0D;AAC1D,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAChB;AAaD;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,eAAe,GAAG,IAAI,CA+BtE;AAED,oDAAoD;AACpD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,eAAe,GAAG,mBAAmB,EAAE,CAIpF;AAED,+CAA+C;AAC/C,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,SAAS,mBAAmB,EAAE,GAAG,mBAAmB,EAAE,CAcxG;AAED,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,OAAO,GAAG,mBAAmB,EAAE,CAoBvF"}
@@ -0,0 +1,77 @@
1
+ /** 路由注册表在 `app` 上的存放键(Symbol,不进公共契约) */
2
+ const REGISTERED_OPERATIONS = Symbol.for('@taicode/common-server/registered-operations');
3
+ /**
4
+ * 在 root 挂 `onRoute` 钩子,收集框架自身可见的**路由注册表**(设计 §5.2)。
5
+ *
6
+ * 必须在**路由注册之前**(core 装配期)调用:钩子挂在 root,随后 `routers` 阶段注册的路由逐条被采集。
7
+ * 该表与 swagger 解耦:swagger 只负责出文档,`auth` 的权限自注册只读这张表。
8
+ */
9
+ export function installRegisteredOperations(app) {
10
+ const collected = [];
11
+ Object.defineProperty(app, REGISTERED_OPERATIONS, {
12
+ value: collected,
13
+ enumerable: false,
14
+ configurable: true,
15
+ });
16
+ app.addHook('onRoute', (routeOptions) => {
17
+ const routeConfig = (routeOptions.config ?? {});
18
+ // 与 `@fastify/swagger` 同口径:Fastify 默认为 GET 自动生成 HEAD 路由;未显式
19
+ // `config.swagger.exposeHeadRoute = true` 时跳过,避免与 GET 重复(权限目录保持同源)。
20
+ if (routeOptions.method === 'HEAD' && routeConfig.swagger?.exposeHeadRoute !== true) {
21
+ return;
22
+ }
23
+ const schema = (routeOptions.schema ?? {});
24
+ const methods = Array.isArray(routeOptions.method)
25
+ ? routeOptions.method
26
+ : [routeOptions.method];
27
+ for (const method of methods) {
28
+ collected.push({
29
+ method,
30
+ url: routeOptions.url,
31
+ operationId: schema.operationId,
32
+ summary: schema.summary,
33
+ description: schema.description,
34
+ tags: schema.tags,
35
+ });
36
+ }
37
+ });
38
+ }
39
+ /** 读取 root `onRoute` 采集的路由注册表;未安装(未走框架装配)时返回空数组。 */
40
+ export function readRegisteredOperations(app) {
41
+ return (app[REGISTERED_OPERATIONS] ?? []);
42
+ }
43
+ /** 从路由注册表筛出声明了 `operationId` 的项,归一为权限操作目录元素 */
44
+ export function toPermissionOperations(operations) {
45
+ const result = [];
46
+ for (const entry of operations) {
47
+ if (!entry.operationId) {
48
+ continue;
49
+ }
50
+ result.push({
51
+ operation: entry.operationId,
52
+ summary: entry.summary ?? '',
53
+ description: entry.description ?? '',
54
+ tags: entry.tags ?? [],
55
+ });
56
+ }
57
+ return result;
58
+ }
59
+ export function extractPermissionOperations(openApiSpec) {
60
+ // 出 spec 路径拿到的文档是未知形态;只按结构取字段,缺什么就跳过什么
61
+ const spec = (openApiSpec ?? {});
62
+ const operations = [];
63
+ for (const pathItem of Object.values(spec.paths ?? {})) {
64
+ for (const operation of Object.values(pathItem ?? {})) {
65
+ if (!operation?.operationId) {
66
+ continue;
67
+ }
68
+ operations.push({
69
+ operation: operation.operationId,
70
+ summary: operation.summary ?? '',
71
+ description: operation.description ?? '',
72
+ tags: operation.tags ?? [],
73
+ });
74
+ }
75
+ }
76
+ return operations;
77
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=permission-operations.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-operations.test.d.ts","sourceRoot":"","sources":["../../source/app/permission-operations.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { extractPermissionOperations } from './permission-operations.js';
3
+ /**
4
+ * `extractPermissionOperations`(设计 §5.2,框架内置)契约。
5
+ *
6
+ * 口径与各仓原自写实现一致:逐 path → 逐 method,缺 `operationId` 跳过;缺 `paths` / 空文档 → `[]`;
7
+ * `summary` / `description` / `tags` 缺省归一为 `''` / `''` / `[]`。
8
+ */
9
+ describe('extractPermissionOperations(设计 §5.2)', () => {
10
+ it('提取 operationId 与可选元信息(summary / description / tags)', () => {
11
+ expect(extractPermissionOperations({
12
+ paths: {
13
+ '/release/list': {
14
+ post: {
15
+ operationId: 'listRelease',
16
+ summary: 'List releases',
17
+ description: 'Returns available releases.',
18
+ tags: ['Release'],
19
+ },
20
+ },
21
+ },
22
+ })).toEqual([
23
+ {
24
+ operation: 'listRelease',
25
+ summary: 'List releases',
26
+ description: 'Returns available releases.',
27
+ tags: ['Release'],
28
+ },
29
+ ]);
30
+ });
31
+ it('可选元信息缺省归一,且跳过无 operationId 的条目', () => {
32
+ expect(extractPermissionOperations({
33
+ paths: {
34
+ '/release/create': {
35
+ post: { operationId: 'createRelease' },
36
+ parameters: { description: 'Shared parameters' },
37
+ },
38
+ },
39
+ })).toEqual([
40
+ {
41
+ operation: 'createRelease',
42
+ summary: '',
43
+ description: '',
44
+ tags: [],
45
+ },
46
+ ]);
47
+ });
48
+ it('缺 paths 时返回空数组', () => {
49
+ expect(extractPermissionOperations({})).toEqual([]);
50
+ });
51
+ it('空文档 / 非对象入参返回空数组', () => {
52
+ expect(extractPermissionOperations({ paths: {} })).toEqual([]);
53
+ expect(extractPermissionOperations(undefined)).toEqual([]);
54
+ });
55
+ it('多路径 + 多方法:按 paths → methods 的遍历序全量提取', () => {
56
+ expect(extractPermissionOperations({
57
+ paths: {
58
+ '/widget/list': {
59
+ get: { operationId: 'listWidget', tags: ['Widget'] },
60
+ post: { operationId: 'queryWidget' },
61
+ },
62
+ '/widget/create': {
63
+ post: { operationId: 'createWidget', summary: 'Create widget' },
64
+ },
65
+ },
66
+ })).toEqual([
67
+ { operation: 'listWidget', summary: '', description: '', tags: ['Widget'] },
68
+ { operation: 'queryWidget', summary: '', description: '', tags: [] },
69
+ { operation: 'createWidget', summary: 'Create widget', description: '', tags: [] },
70
+ ]);
71
+ });
72
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=permission-registration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-registration.test.d.ts","sourceRoot":"","sources":["../../source/app/permission-registration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,138 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { defineServer, generateOpenApi, startServer } from './entries.js';
3
+ import { defaultMiddlewares } from './middleware/index.js';
4
+ import { createAuthMiddleware } from './middleware/auth.js';
5
+ /** 注入假 fetch:记录请求,并按 `responder` 返回最小 Response 形状 */
6
+ function stubFetch(responder) {
7
+ const requests = [];
8
+ const fetchStub = vi.fn(async (input, init) => {
9
+ const url = typeof input === 'string' ? input : input.toString();
10
+ requests.push({
11
+ url,
12
+ method: init?.method,
13
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
14
+ });
15
+ const { status, payload } = responder(url);
16
+ return {
17
+ ok: status >= 200 && status < 300,
18
+ status,
19
+ json: async () => payload,
20
+ };
21
+ });
22
+ vi.stubGlobal('fetch', fetchStub);
23
+ return { requests, fetchStub };
24
+ }
25
+ const listWidgetSchema = {
26
+ operationId: 'listWidget',
27
+ summary: 'List widgets',
28
+ tags: ['Widget'],
29
+ };
30
+ const createWidgetSchema = {
31
+ operationId: 'createWidget',
32
+ };
33
+ function createServer(configuration, authOptions) {
34
+ return defineServer({
35
+ app: { service: 'widgets', title: 'Widgets Service', version: '2.3.4' },
36
+ resources: () => ({}),
37
+ config: () => configuration,
38
+ // 默认预设去掉 auth,换上带选项的 auth(设计 §5.2 预设只是内容)
39
+ middlewares: (ctx) => [...defaultMiddlewares(ctx).slice(0, -1), createAuthMiddleware(ctx, authOptions)],
40
+ services: () => ({}),
41
+ routers: (ctx) => {
42
+ // 公开路由(不走 verify);本用例只关心 onReady 的自注册
43
+ ctx.app.get('/widget/list', { schema: listWidgetSchema, config: { accessControls: ['NoLoginRequired'] } }, () => ({ items: [] }));
44
+ ctx.app.post('/widget/create', { schema: createWidgetSchema, config: { accessControls: ['NoLoginRequired'] } }, () => ({ id: 'w-1' }));
45
+ },
46
+ });
47
+ }
48
+ afterEach(() => {
49
+ vi.unstubAllGlobals();
50
+ });
51
+ describe('权限操作自注册(设计 §5.2 / §10-11)', () => {
52
+ it('onReady 阶段向 /permission/operation/register POST 正确请求体(service / version / operations)', async () => {
53
+ const { requests, fetchStub } = stubFetch(() => ({ status: 200, payload: { status: 'SUCCESS', data: {} } }));
54
+ const app = await startServer(createServer({ account: { enabled: true, serviceUrl: 'http://account.local' } }), { listen: false });
55
+ try {
56
+ expect(fetchStub).toHaveBeenCalledTimes(1);
57
+ expect(requests).toHaveLength(1);
58
+ expect(requests[0].url).toBe('http://account.local/permission/operation/register');
59
+ expect(requests[0].method).toBe('POST');
60
+ expect(requests[0].body).toEqual({
61
+ service: 'widgets',
62
+ version: '2.3.4',
63
+ operations: [
64
+ { operation: 'listWidget', summary: 'List widgets', description: '', tags: ['Widget'] },
65
+ { operation: 'createWidget', summary: '', description: '', tags: [] },
66
+ ],
67
+ });
68
+ }
69
+ finally {
70
+ await app.close();
71
+ }
72
+ });
73
+ it('account 无 serviceUrl 时不注册(自然排除 account 自身)', async () => {
74
+ const { fetchStub } = stubFetch(() => ({ status: 200, payload: { status: 'SUCCESS' } }));
75
+ const app = await startServer(createServer({ account: { enabled: false } }), { listen: false });
76
+ try {
77
+ expect(fetchStub).not.toHaveBeenCalled();
78
+ }
79
+ finally {
80
+ await app.close();
81
+ }
82
+ });
83
+ it('registerPermissions: false 时不注册', async () => {
84
+ const { fetchStub } = stubFetch(() => ({ status: 200, payload: { status: 'SUCCESS' } }));
85
+ const app = await startServer(createServer({ account: { serviceUrl: 'http://account.local' } }, { registerPermissions: false }), { listen: false });
86
+ try {
87
+ expect(fetchStub).not.toHaveBeenCalled();
88
+ }
89
+ finally {
90
+ await app.close();
91
+ }
92
+ });
93
+ it('注册失败(HTTP 非 2xx)不致命:startServer 仍 resolve、服务可用', async () => {
94
+ const { fetchStub } = stubFetch(() => ({ status: 500, payload: { status: 'UNKNOWN_ERROR', message: 'boom' } }));
95
+ const app = await startServer(createServer({ account: { serviceUrl: 'http://account.local' } }), { listen: false });
96
+ try {
97
+ expect(fetchStub).toHaveBeenCalledTimes(1);
98
+ const response = await app.inject({ method: 'GET', url: '/widget/list' });
99
+ expect(response.statusCode).toBe(200);
100
+ }
101
+ finally {
102
+ await app.close();
103
+ }
104
+ });
105
+ it('HTTP ok 但信封非 SUCCESS 也不致命(成功判定与 /gateway/verify 同口径)', async () => {
106
+ const { fetchStub } = stubFetch(() => ({ status: 200, payload: { status: 'INVALID_INPUT', message: 'bad' } }));
107
+ const app = await startServer(createServer({ account: { serviceUrl: 'http://account.local' } }), { listen: false });
108
+ try {
109
+ expect(fetchStub).toHaveBeenCalledTimes(1);
110
+ const response = await app.inject({ method: 'GET', url: '/widget/list' });
111
+ expect(response.statusCode).toBe(200);
112
+ }
113
+ finally {
114
+ await app.close();
115
+ }
116
+ });
117
+ it('fetch 抛错(网络不可达)也不致命', async () => {
118
+ const fetchStub = vi.fn(async () => {
119
+ throw new Error('network unreachable');
120
+ });
121
+ vi.stubGlobal('fetch', fetchStub);
122
+ const app = await startServer(createServer({ account: { serviceUrl: 'http://account.local' } }), { listen: false });
123
+ try {
124
+ expect(fetchStub).toHaveBeenCalledTimes(1);
125
+ const response = await app.inject({ method: 'GET', url: '/widget/list' });
126
+ expect(response.statusCode).toBe(200);
127
+ }
128
+ finally {
129
+ await app.close();
130
+ }
131
+ });
132
+ it('出 spec 路径(generateOpenApi 不调 loadConfig)不注册', async () => {
133
+ const { fetchStub } = stubFetch(() => ({ status: 200, payload: { status: 'SUCCESS' } }));
134
+ const spec = (await generateOpenApi(createServer({ account: { serviceUrl: 'http://account.local' } })));
135
+ expect(fetchStub).not.toHaveBeenCalled();
136
+ expect(Object.keys(spec.paths ?? {})).toEqual(expect.arrayContaining(['/widget/list', '/widget/create']));
137
+ });
138
+ });
@@ -0,0 +1,45 @@
1
+ import { RedisCache } from '../redis-cache/index.js';
2
+ import type { ConfigEnv } from './env.js';
3
+ import type { LoggerLike, RedisHandle } from './types.js';
4
+ /**
5
+ * 引导期 Redis / cache helper(N2 §2.13)。
6
+ *
7
+ * 在 `resources(ctx)` 槽位里按需调用(框架自己**不**创建 Redis):
8
+ *
9
+ * ```ts
10
+ * resources: (ctx) => {
11
+ * const redis = createRedis(ctx.env, ctx.log) // 无 REDIS_URL → undefined
12
+ * return { redis, cache: createCache({ env: ctx.env, prefix: 'account', redis }) }
13
+ * }
14
+ * ```
15
+ *
16
+ * `REDIS_URL` 由 helper 直接读 `env`(固定约定),**不**取自 `config`;`redis` / `cache` 一律是
17
+ * **惰性包装**:构造时不建连、不 ping,首次真正使用才建连 —— 「出 spec 不连库/Redis」结构性成立。
18
+ */
19
+ /** 一级/二级缓存默认 TTL(秒),与 `RedisCache` 默认一致 */
20
+ export declare const DEFAULT_REDIS_CACHE_TTL_SECONDS = 300;
21
+ /**
22
+ * 构造 Redis 句柄(惰性包装:不建连、不 ping)。
23
+ *
24
+ * 无 `REDIS_URL` 时返回 `undefined` —— 资源包里写成 `redis: RedisHandle | undefined` 即可
25
+ * (显式可选,运行时缺省)。
26
+ */
27
+ export declare function createRedis(env: ConfigEnv, log?: LoggerLike): RedisHandle | undefined;
28
+ /** `createCache` 入参 */
29
+ export interface CacheSetupParams {
30
+ env: ConfigEnv;
31
+ /** 键前缀(通常形如 `<service>:`),避免不同服务 / 环境互相污染 */
32
+ prefix: string;
33
+ /** 复用既有 Redis 句柄(**推荐**:一条连接同时给 cache 与其它消费者) */
34
+ redis?: RedisHandle;
35
+ log?: LoggerLike;
36
+ }
37
+ /**
38
+ * 构造缓存(前缀由调用方给出)。
39
+ *
40
+ * - 传 `redis` → 复用同一个客户端;
41
+ * - 未传 `redis` 但有 `REDIS_URL` → 自建客户端(由 `RedisCache` 自己持有并断开);
42
+ * - 都没有 → 纯进程内一级缓存(不建连)。
43
+ */
44
+ export declare function createCache(params: CacheSetupParams): RedisCache;
45
+ //# sourceMappingURL=redis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../source/app/redis.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,UAAU,EAAE,MAAM,yBAAyB,CAAA;AAEvE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAEzD;;;;;;;;;;;;;;GAcG;AAEH,2CAA2C;AAC3C,eAAO,MAAM,+BAA+B,MAAM,CAAA;AAElD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,UAAU,GAAG,WAAW,GAAG,SAAS,CAmBrF;AAED,uBAAuB;AACvB,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,SAAS,CAAA;IACd,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAA;IACd,iDAAiD;IACjD,KAAK,CAAC,EAAE,WAAW,CAAA;IACnB,GAAG,CAAC,EAAE,UAAU,CAAA;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CAYhE"}
@@ -0,0 +1,61 @@
1
+ import { createRedisClient, RedisCache } from '../redis-cache/index.js';
2
+ /**
3
+ * 引导期 Redis / cache helper(N2 §2.13)。
4
+ *
5
+ * 在 `resources(ctx)` 槽位里按需调用(框架自己**不**创建 Redis):
6
+ *
7
+ * ```ts
8
+ * resources: (ctx) => {
9
+ * const redis = createRedis(ctx.env, ctx.log) // 无 REDIS_URL → undefined
10
+ * return { redis, cache: createCache({ env: ctx.env, prefix: 'account', redis }) }
11
+ * }
12
+ * ```
13
+ *
14
+ * `REDIS_URL` 由 helper 直接读 `env`(固定约定),**不**取自 `config`;`redis` / `cache` 一律是
15
+ * **惰性包装**:构造时不建连、不 ping,首次真正使用才建连 —— 「出 spec 不连库/Redis」结构性成立。
16
+ */
17
+ /** 一级/二级缓存默认 TTL(秒),与 `RedisCache` 默认一致 */
18
+ export const DEFAULT_REDIS_CACHE_TTL_SECONDS = 300;
19
+ /**
20
+ * 构造 Redis 句柄(惰性包装:不建连、不 ping)。
21
+ *
22
+ * 无 `REDIS_URL` 时返回 `undefined` —— 资源包里写成 `redis: RedisHandle | undefined` 即可
23
+ * (显式可选,运行时缺省)。
24
+ */
25
+ export function createRedis(env, log) {
26
+ const redisUrl = env.REDIS_URL;
27
+ if (!redisUrl) {
28
+ log?.debug({}, '[redis] REDIS_URL not configured; cache falls back to in-process L1');
29
+ return undefined;
30
+ }
31
+ const client = createRedisClient({ url: redisUrl });
32
+ return {
33
+ client,
34
+ dispose: async () => {
35
+ if (client.isOpen) {
36
+ await client.quit().catch(() => undefined);
37
+ }
38
+ else {
39
+ client.destroy();
40
+ }
41
+ },
42
+ };
43
+ }
44
+ /**
45
+ * 构造缓存(前缀由调用方给出)。
46
+ *
47
+ * - 传 `redis` → 复用同一个客户端;
48
+ * - 未传 `redis` 但有 `REDIS_URL` → 自建客户端(由 `RedisCache` 自己持有并断开);
49
+ * - 都没有 → 纯进程内一级缓存(不建连)。
50
+ */
51
+ export function createCache(params) {
52
+ if (params.redis) {
53
+ return new RedisCache({ prefix: params.prefix, redisClient: params.redis.client });
54
+ }
55
+ const redisUrl = params.env.REDIS_URL;
56
+ if (redisUrl) {
57
+ return new RedisCache({ prefix: params.prefix, redisUrl });
58
+ }
59
+ params.log?.debug({}, '[redis] REDIS_URL not configured; cache falls back to in-process L1');
60
+ return new RedisCache({ prefix: params.prefix });
61
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=registered-operations.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registered-operations.test.d.ts","sourceRoot":"","sources":["../../source/app/registered-operations.test.ts"],"names":[],"mappings":""}