Skip to content

Commit 8d0c72c

Browse files
dengzhaofunclaude
andauthored
refactor(server): 统一 C 端 client routes 到 header + middleware 认证 (#36)
把 21 个业务模块的 client-routes.ts 从 body/query 内嵌 endUserId + userHash 迁移到 header + middleware 认证,和 invite 模块保持一致。 做了什么 - 每个 client router 挂载 requireClientCredential + requireClientUser 两个 中间件,由 middleware 验证 x-api-key / x-end-user-id / x-user-hash - handler 内删除所有 clientCredentialService.verifyRequest() 调用 - 统一用 c.get("clientCredential")!.organizationId 取 orgId,c.var.endUserId! 取调用者 id - validators 移除 Client* schema 的 endUserId / userHash 字段;删除仅承载 调用者身份的 param / query schema - 删除 "/users/{endUserId}/..." 这类调用者自身的冗余路径段,如 GET /check-in/users/{endUserId}/state → GET /check-in/state - 把 11 处重复的 authHeaders OpenAPI schema 提取到 middleware/client-auth-headers.ts,各模块 import 复用 - check-in 的 client-routes.test.ts 同步迁移到 header 版请求 为什么 - middleware 集中验证,handler 不再重复调用 - 身份走 header、业务载荷走 body,符合 REST 惯例(类比 Authorization) - GET 请求不再把 endUserId 拼进 query → 不泄漏到日志/代理 - /users/{endUserId}/... 变成单点路径,语义更清晰 破坏性变更(产品未上线,无兼容层) - 客户端必须改为通过 HTTP header 发起:x-api-key、x-end-user-id、 x-user-hash(devMode 下可省略 x-user-hash) - 若干路径去掉了 /users/{endUserId} 段,详见 spec 文档 顺手修了 invite 模块 3 处 pre-existing 类型错误,让 check-types 通过: - z.record(z.unknown()) → z.record(z.string(), z.unknown())(Zod v4) - client-routes.test.ts 4 处 body 类型收紧 验证 - pnpm --filter=server check-types 通过 - pnpm --filter=server test 通过(45 个文件 / 719 个用例,本地 pg) 设计文档:docs/superpowers/specs/2026-04-19-client-auth-middleware-migration.md Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 10b93bd commit 8d0c72c

41 files changed

Lines changed: 802 additions & 1650 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Shared OpenAPI header schema for C-end client routes.
3+
*
4+
* Every client router uses the middleware pair `requireClientCredential`
5+
* + `requireClientUser`, and therefore every route in such a router
6+
* accepts the same three headers. Declaring them once here keeps the
7+
* docs consistent and avoids drift across modules.
8+
*
9+
* Usage:
10+
*
11+
* import { clientAuthHeaders } from "../../middleware/client-auth-headers";
12+
*
13+
* router.openapi(
14+
* createRoute({
15+
* request: { headers: clientAuthHeaders, ... },
16+
* ...
17+
* }),
18+
* async (c) => { ... },
19+
* );
20+
*/
21+
22+
import { z } from "@hono/zod-openapi";
23+
24+
export const clientAuthHeaders = z.object({
25+
"x-api-key": z.string().openapi({
26+
description: "Publishable key (cpk_...)",
27+
}),
28+
"x-end-user-id": z.string().openapi({
29+
description: "End user's opaque id",
30+
}),
31+
"x-user-hash": z.string().optional().openapi({
32+
description:
33+
"HMAC-SHA256(endUserId, clientSecret). Required unless dev mode is enabled.",
34+
}),
35+
});

apps/server/src/modules/activity/client-routes.ts

Lines changed: 24 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,34 @@
11
/**
22
* C-end client routes for the activity module.
33
*
4-
* The admin surface at `/api/activity` already exposes every operation;
5-
* this file exposes the subset that *end users* trigger directly (list,
6-
* view, join, claim milestone) protected by client credential + HMAC.
4+
* Mounted at /api/client/activity. Auth pattern:
75
*
8-
* Activity state is checked inside the service; these routes are a
9-
* thin auth + serialization layer.
6+
* requireClientCredential — validates x-api-key (cpk_...), populates c.var.clientCredential
7+
* requireClientUser — reads x-end-user-id + x-user-hash headers, verifies HMAC,
8+
* populates c.var.endUserId
9+
*
10+
* Handlers read orgId from c.get("clientCredential")!.organizationId and endUserId from
11+
* c.var.endUserId!. No inline verifyRequest calls; no auth fields in body or query.
1012
*/
1113

1214
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
1315
import type { ContentfulStatusCode } from "hono/utils/http-status";
1416

1517
import type { HonoEnv } from "../../env";
1618
import { requireClientCredential } from "../../middleware/require-client-credential";
17-
import { clientCredentialService } from "../client-credentials";
19+
import { requireClientUser } from "../../middleware/require-client-user";
1820
import { ModuleError } from "./errors";
1921
import { activityService } from "./index";
2022
import {
2123
ActivityConfigResponseSchema,
24+
ClaimMilestoneClientBody,
2225
ErrorResponseSchema,
2326
} from "./validators";
2427

2528
const TAG = "Activity (Client)";
2629

30+
import { clientAuthHeaders as authHeaders } from "../../middleware/client-auth-headers";
31+
2732
const errorResponses = {
2833
400: {
2934
description: "Bad request",
@@ -46,6 +51,7 @@ const errorResponses = {
4651
export const activityClientRouter = new OpenAPIHono<HonoEnv>();
4752

4853
activityClientRouter.use("*", requireClientCredential);
54+
activityClientRouter.use("*", requireClientUser);
4955

5056
activityClientRouter.onError((err, c) => {
5157
if (err instanceof ModuleError) {
@@ -61,27 +67,6 @@ const AliasParam = z.object({
6167
alias: z.string().min(1).openapi({ param: { name: "alias", in: "path" } }),
6268
});
6369

64-
const ClientViewQuery = z.object({
65-
endUserId: z
66-
.string()
67-
.min(1)
68-
.max(256)
69-
.openapi({ param: { name: "endUserId", in: "query" } }),
70-
userHash: z
71-
.string()
72-
.optional()
73-
.openapi({ param: { name: "userHash", in: "query" } }),
74-
});
75-
76-
const ClientActionBody = z.object({
77-
endUserId: z.string().min(1).max(256),
78-
userHash: z.string().optional(),
79-
});
80-
81-
const ClaimMilestoneClientBody = ClientActionBody.extend({
82-
milestoneAlias: z.string().min(1).max(64),
83-
});
84-
8570
// ─── List currently visible activities ─────────────────────────
8671

8772
activityClientRouter.openapi(
@@ -91,7 +76,7 @@ activityClientRouter.openapi(
9176
tags: [TAG],
9277
summary:
9378
"List activities currently visible to the caller (teasing / active / settling / ended).",
94-
request: { query: ClientViewQuery },
79+
request: { headers: authHeaders },
9580
responses: {
9681
200: {
9782
description: "OK",
@@ -107,14 +92,7 @@ activityClientRouter.openapi(
10792
},
10893
}),
10994
async (c) => {
110-
const publishableKey = c.req.header("x-api-key")!;
111-
const { endUserId, userHash } = c.req.valid("query");
112-
await clientCredentialService.verifyRequest(
113-
publishableKey,
114-
endUserId,
115-
userHash,
116-
);
117-
const orgId = c.var.session!.activeOrganizationId!;
95+
const orgId = c.get("clientCredential")!.organizationId;
11896
const now = new Date();
11997
const rows = await activityService.listActivities(orgId);
12098
// Only return things the player can see — anything past visible_at
@@ -176,7 +154,7 @@ activityClientRouter.openapi(
176154
path: "/{alias}",
177155
tags: [TAG],
178156
summary: "Single-round-trip view of an activity for the caller.",
179-
request: { params: AliasParam, query: ClientViewQuery },
157+
request: { headers: authHeaders, params: AliasParam },
180158
responses: {
181159
200: {
182160
description: "OK",
@@ -188,15 +166,9 @@ activityClientRouter.openapi(
188166
},
189167
}),
190168
async (c) => {
191-
const publishableKey = c.req.header("x-api-key")!;
192-
const { endUserId, userHash } = c.req.valid("query");
193-
await clientCredentialService.verifyRequest(
194-
publishableKey,
195-
endUserId,
196-
userHash,
197-
);
169+
const orgId = c.get("clientCredential")!.organizationId;
170+
const endUserId = c.var.endUserId!;
198171
const { alias } = c.req.valid("param");
199-
const orgId = c.var.session!.activeOrganizationId!;
200172
const view = await activityService.getActivityForUser({
201173
organizationId: orgId,
202174
activityIdOrAlias: alias,
@@ -215,8 +187,8 @@ activityClientRouter.openapi(
215187
tags: [TAG],
216188
summary: "Enrol in an activity.",
217189
request: {
190+
headers: authHeaders,
218191
params: AliasParam,
219-
body: { content: { "application/json": { schema: ClientActionBody } } },
220192
},
221193
responses: {
222194
200: {
@@ -229,15 +201,9 @@ activityClientRouter.openapi(
229201
},
230202
}),
231203
async (c) => {
232-
const publishableKey = c.req.header("x-api-key")!;
233-
const { endUserId, userHash } = c.req.valid("json");
234-
await clientCredentialService.verifyRequest(
235-
publishableKey,
236-
endUserId,
237-
userHash,
238-
);
204+
const orgId = c.get("clientCredential")!.organizationId;
205+
const endUserId = c.var.endUserId!;
239206
const { alias } = c.req.valid("param");
240-
const orgId = c.var.session!.activeOrganizationId!;
241207
const row = await activityService.join({
242208
organizationId: orgId,
243209
activityIdOrAlias: alias,
@@ -256,6 +222,7 @@ activityClientRouter.openapi(
256222
tags: [TAG],
257223
summary: "Claim an activity milestone reward.",
258224
request: {
225+
headers: authHeaders,
259226
params: AliasParam,
260227
body: {
261228
content: {
@@ -274,15 +241,10 @@ activityClientRouter.openapi(
274241
},
275242
}),
276243
async (c) => {
277-
const publishableKey = c.req.header("x-api-key")!;
278-
const { endUserId, userHash, milestoneAlias } = c.req.valid("json");
279-
await clientCredentialService.verifyRequest(
280-
publishableKey,
281-
endUserId,
282-
userHash,
283-
);
244+
const orgId = c.get("clientCredential")!.organizationId;
245+
const endUserId = c.var.endUserId!;
246+
const { milestoneAlias } = c.req.valid("json");
284247
const { alias } = c.req.valid("param");
285-
const orgId = c.var.session!.activeOrganizationId!;
286248
const result = await activityService.claimMilestone({
287249
organizationId: orgId,
288250
activityIdOrAlias: alias,

apps/server/src/modules/activity/validators.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ export const ClaimMilestoneBody = z
185185
})
186186
.openapi("ActivityClaimMilestone");
187187

188+
export const ClaimMilestoneClientBody = z
189+
.object({
190+
milestoneAlias: z.string().min(1).max(64),
191+
})
192+
.openapi("ActivityClaimMilestoneClient");
193+
188194
const DurationSpecSchema = z.object({
189195
teaseSeconds: z.number().int().nonnegative(),
190196
activeSeconds: z.number().int().positive(),

apps/server/src/modules/announcement/client-routes.ts

Lines changed: 27 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,40 @@
11
/**
22
* C-end client routes for the announcement module.
33
*
4-
* Protected by `requireClientCredential` (cpk_ publishable key in
5-
* x-api-key). HMAC verification of endUserId is done inline via the
6-
* client credential service — same pattern as banner/mail client routes.
4+
* Mounted at /api/client/announcement. Auth pattern:
5+
*
6+
* requireClientCredential — validates x-api-key (cpk_...), populates c.var.clientCredential
7+
* requireClientUser — reads x-end-user-id + x-user-hash headers, verifies HMAC,
8+
* populates c.var.endUserId
9+
*
10+
* Handlers read orgId from c.get("clientCredential")!.organizationId and endUserId from
11+
* c.var.endUserId!. No inline verifyRequest calls; no auth fields in body or query.
712
*
813
* Surface:
9-
* GET /active?endUserId=... → currently-visible list
10-
* POST /{alias}/impression { endUserId } → fire-and-forget event
11-
* POST /{alias}/click { endUserId } → fire-and-forget event
14+
* GET /active → currently-visible list
15+
* POST /{alias}/impression → fire-and-forget event
16+
* POST /{alias}/click → fire-and-forget event
1217
*/
1318

1419
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
20+
import { z } from "@hono/zod-openapi";
1521
import type { ContentfulStatusCode } from "hono/utils/http-status";
1622

1723
import type { HonoEnv } from "../../env";
1824
import { ModuleError } from "../../lib/errors";
1925
import { requireClientCredential } from "../../middleware/require-client-credential";
20-
import { clientCredentialService } from "../client-credentials";
26+
import { requireClientUser } from "../../middleware/require-client-user";
2127
import { announcementService } from "./index";
2228
import {
2329
AliasParamSchema,
24-
ClientAckBodySchema,
2530
ClientAnnouncementListResponseSchema,
26-
ClientListQuerySchema,
2731
ErrorResponseSchema,
2832
} from "./validators";
2933

3034
const TAG = "Announcement (Client)";
3135

36+
import { clientAuthHeaders as authHeaders } from "../../middleware/client-auth-headers";
37+
3238
const errorResponses = {
3339
400: {
3440
description: "Bad request",
@@ -47,6 +53,7 @@ const errorResponses = {
4753
export const announcementClientRouter = new OpenAPIHono<HonoEnv>();
4854

4955
announcementClientRouter.use("*", requireClientCredential);
56+
announcementClientRouter.use("*", requireClientUser);
5057

5158
announcementClientRouter.onError((err, c) => {
5259
if (err instanceof ModuleError) {
@@ -68,7 +75,9 @@ announcementClientRouter.openapi(
6875
path: "/active",
6976
tags: [TAG],
7077
summary: "List currently-visible announcements for an end user",
71-
request: { query: ClientListQuerySchema },
78+
request: {
79+
headers: authHeaders,
80+
},
7281
responses: {
7382
200: {
7483
description: "OK",
@@ -82,17 +91,8 @@ announcementClientRouter.openapi(
8291
},
8392
}),
8493
async (c) => {
85-
const publishableKey = c.req.header("x-api-key")!;
86-
const { endUserId } = c.req.valid("query");
87-
const userHash = c.req.header("x-user-hash");
88-
89-
await clientCredentialService.verifyRequest(
90-
publishableKey,
91-
endUserId,
92-
userHash,
93-
);
94-
95-
const orgId = c.var.session!.activeOrganizationId!;
94+
const orgId = c.get("clientCredential")!.organizationId;
95+
const endUserId = c.var.endUserId!;
9696
const items = await announcementService.getActiveForClient(
9797
orgId,
9898
endUserId,
@@ -108,29 +108,18 @@ announcementClientRouter.openapi(
108108
tags: [TAG],
109109
summary: "Record an impression for an announcement",
110110
request: {
111+
headers: authHeaders,
111112
params: AliasParamSchema,
112-
body: {
113-
content: { "application/json": { schema: ClientAckBodySchema } },
114-
},
115113
},
116114
responses: {
117115
204: { description: "Recorded" },
118116
...errorResponses,
119117
},
120118
}),
121119
async (c) => {
122-
const publishableKey = c.req.header("x-api-key")!;
120+
const orgId = c.get("clientCredential")!.organizationId;
121+
const endUserId = c.var.endUserId!;
123122
const { alias } = c.req.valid("param");
124-
const { endUserId } = c.req.valid("json");
125-
const userHash = c.req.header("x-user-hash");
126-
127-
await clientCredentialService.verifyRequest(
128-
publishableKey,
129-
endUserId,
130-
userHash,
131-
);
132-
133-
const orgId = c.var.session!.activeOrganizationId!;
134123
await announcementService.recordImpression(orgId, alias, endUserId);
135124
return c.body(null, 204);
136125
},
@@ -143,29 +132,18 @@ announcementClientRouter.openapi(
143132
tags: [TAG],
144133
summary: "Record a CTA click for an announcement",
145134
request: {
135+
headers: authHeaders,
146136
params: AliasParamSchema,
147-
body: {
148-
content: { "application/json": { schema: ClientAckBodySchema } },
149-
},
150137
},
151138
responses: {
152139
204: { description: "Recorded" },
153140
...errorResponses,
154141
},
155142
}),
156143
async (c) => {
157-
const publishableKey = c.req.header("x-api-key")!;
144+
const orgId = c.get("clientCredential")!.organizationId;
145+
const endUserId = c.var.endUserId!;
158146
const { alias } = c.req.valid("param");
159-
const { endUserId } = c.req.valid("json");
160-
const userHash = c.req.header("x-user-hash");
161-
162-
await clientCredentialService.verifyRequest(
163-
publishableKey,
164-
endUserId,
165-
userHash,
166-
);
167-
168-
const orgId = c.var.session!.activeOrganizationId!;
169147
await announcementService.recordClick(orgId, alias, endUserId);
170148
return c.body(null, 204);
171149
},

0 commit comments

Comments
 (0)