From 264ccba301a0731bed412755be89c7643370cf19 Mon Sep 17 00:00:00 2001 From: zs-andy Date: Sun, 26 Jul 2026 03:38:29 +0800 Subject: [PATCH 1/2] fix(auth): keep Web registration on the WeChat profile --- apps/api/src/app.ts | 9 ++- apps/api/src/wechat-routes.test.ts | 88 ++++++++++++++++++++-- apps/api/src/wechat-routes.ts | 22 +++++- docs/openapi.yaml | 31 +++++++- docs/wechat-web-registration.md | 31 ++++++-- packages/data/src/memory-wechat-store.ts | 8 ++ packages/data/src/supabase-wechat-store.ts | 12 +++ packages/data/src/wechat-store.ts | 1 + 8 files changed, 182 insertions(+), 20 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7d75ab9..69fcc32 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -154,6 +154,7 @@ export async function buildApp(options: BuildAppOptions) { app.addHook("preValidation", async (request) => { if (!options.verifyAccessToken || request.method === "OPTIONS") return; const path = request.url.split("?", 1)[0]; + const hasAuthorization = typeof request.headers.authorization === "string"; const isWechatSessionCreation = request.method === "POST" && ( path === "/wechat/connect/sessions" || path === "/wechat/connect/sessions/demo" @@ -161,7 +162,7 @@ export async function buildApp(options: BuildAppOptions) { if ( path === "/health" || path === "/ready" - || path === "/auth/wechat/claim" + || (path === "/auth/wechat/claim" && !hasAuthorization) || path?.startsWith("/internal/") || (path?.startsWith("/wechat/connect/sessions") && !isWechatSessionCreation) || (isWechatSessionCreation && !request.headers.authorization) @@ -328,7 +329,11 @@ export async function buildApp(options: BuildAppOptions) { runId: `activation-welcome-${userId}-web-register-intro` }); await deliverText({ - text: `点这里完成注册:${webRegistrationUrl}`, + text: "这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留", + runId: `activation-welcome-${userId}-web-register-continuity` + }); + await deliverText({ + text: `点这里为当前账号添加网页登录:${webRegistrationUrl}`, runId: `activation-welcome-${userId}-web-register-link` }); } diff --git a/apps/api/src/wechat-routes.test.ts b/apps/api/src/wechat-routes.test.ts index 0a0b82f..4179bc1 100644 --- a/apps/api/src/wechat-routes.test.ts +++ b/apps/api/src/wechat-routes.test.ts @@ -46,9 +46,13 @@ async function setup( }); const store = new MemoryStore(); const wechatStore = new MemoryWechatStore(store); + const provisionedUserId = integration?.provisionedUserId ?? randomUUID(); const verifyAccessToken = vi.fn(async (accessToken: string) => { const userId = integration?.userByToken?.[accessToken]; if (userId) return userId; + if (integration?.webRegistration && accessToken === "anonymous-access-token") { + return provisionedUserId; + } throw new Error("WeChat route unexpectedly required a bearer token"); }); const inlineProcessor = integration?.processJobsInline @@ -58,7 +62,6 @@ async function setup( new MockMatchmakingIntelligence() ) : undefined; - const provisionedUserId = integration?.provisionedUserId ?? randomUUID(); const accountProvisioner = { provision: vi.fn(async () => ({ userId: provisionedUserId, @@ -156,10 +159,13 @@ describe("WeChat one-time QR onboarding", () => { const texts = sentMessageTexts(sentMessages); expect(texts.slice(0, 4)).toEqual(adventurexWelcomeBubbles.zh); expect(texts[4]).toBe("想在网页上和别人线下加好友吗,有机会上TOMEET“必吃榜”!"); - expect(texts[5]).toMatch( - /^点这里完成注册:https:\/\/tomeet\.chat\/register#claim=[A-Za-z0-9_-]{43}$/u + expect(texts[5]).toBe( + "这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留" ); - const token = texts[5]?.match(/#claim=([A-Za-z0-9_-]{43})$/u)?.[1]; + expect(texts[6]).toMatch( + /^点这里为当前账号添加网页登录:https:\/\/tomeet\.chat\/register#claim=[A-Za-z0-9_-]{43}$/u + ); + const token = texts[6]?.match(/#claim=([A-Za-z0-9_-]{43})$/u)?.[1]; expect(token).toEqual(expect.any(String)); const claim = await app.inject({ @@ -176,8 +182,28 @@ describe("WeChat one-time QR onboarding", () => { accessToken: "anonymous-access-token", refreshToken: "anonymous-refresh-token" }, - registrationMethods: ["email_password", "phone_password", "google"] + registrationMethods: ["email_password", "phone_password", "google"], + accountContinuity: { + mode: "upgrade_existing_wechat_user", + preserves: ["conversation", "profile", "matching"] + } + }); + + const webMatch = await app.inject({ + method: "POST", + url: "/match-requests", + headers: { authorization: "Bearer anonymous-access-token" }, + payload: { + userId: provisionedUserId, + intent: { rawText: "想在网页继续微信里的匹配" } + } + }); + expect(webMatch.statusCode).toBe(202); + expect(webMatch.json().matchRequest).toMatchObject({ userId: provisionedUserId }); + const wechatMatch = await store.createMatchRequest(provisionedUserId, { + rawText: "从微信继续匹配" }); + expect(wechatMatch.requestId).toBe(webMatch.json().matchRequest.requestId); const repeated = await app.inject({ method: "POST", @@ -191,6 +217,56 @@ describe("WeChat one-time QR onboarding", () => { expect(JSON.stringify(sentMessages)).not.toContain("anonymous-refresh-token"); }); + it("keeps the claim intact when the browser is signed in to a different account", async () => { + const provisionedUserId = randomUUID(); + const otherUserId = randomUUID(); + const { app, sentMessages } = await setup([{ + status: "confirmed", + bot_token: "account-switch-bot-secret", + ilink_bot_id: "account-switch-bot", + baseurl: "https://ilink-api.example.com", + ilink_user_id: "wechat-owner-account-switch" + }], undefined, undefined, undefined, { + webRegistration: true, + provisionedUserId, + userByToken: { "other-account-token": otherUserId } + }); + + const created = (await app.inject({ + method: "POST", + url: "/wechat/connect/sessions", + payload: {} + })).json(); + await app.inject({ + method: "GET", + url: `/wechat/connect/sessions/${created.sessionId}`, + headers: { "x-wechat-session-token": created.sessionToken } + }); + const token = sentMessageTexts(sentMessages)[6]?.match( + /#claim=([A-Za-z0-9_-]{43})$/u + )?.[1]; + expect(token).toEqual(expect.any(String)); + + const conflict = await app.inject({ + method: "POST", + url: "/auth/wechat/claim", + headers: { authorization: "Bearer other-account-token" }, + payload: { token } + }); + expect(conflict.statusCode).toBe(409); + expect(conflict.json()).toMatchObject({ + error: "wechat_web_account_switch_required" + }); + + const afterSignOut = await app.inject({ + method: "POST", + url: "/auth/wechat/claim", + payload: { token } + }); + expect(afterSignOut.statusCode).toBe(200); + expect(afterSignOut.json()).toMatchObject({ userId: provisionedUserId }); + }); + it("keeps one Web user and one welcome when the same WeChat account scans different QR codes", async () => { const owner = "wechat-owner-multiple-qr-codes"; const confirmed = { @@ -234,7 +310,7 @@ describe("WeChat one-time QR onboarding", () => { .toMatchObject({ userId: provisionedUserId }); expect(accountProvisioner.provision).toHaveBeenCalledTimes(1); expect(accountProvisioner.discard).not.toHaveBeenCalled(); - expect(sentMessageTexts(sentMessages)).toHaveLength(6); + expect(sentMessageTexts(sentMessages)).toHaveLength(7); expect(sentMessageTexts(sentMessages).slice(0, 4)).toEqual(adventurexWelcomeBubbles.zh); }); diff --git a/apps/api/src/wechat-routes.ts b/apps/api/src/wechat-routes.ts index 772adb2..6789cb7 100644 --- a/apps/api/src/wechat-routes.ts +++ b/apps/api/src/wechat-routes.ts @@ -559,7 +559,21 @@ export function registerWechatRoutes( const { token } = z.object({ token: z.string().regex(/^[A-Za-z0-9_-]{43}$/u) }).parse(request.body ?? {}); - const claim = await runtime.store.consumeWechatWebClaim(hashSessionToken(token)); + const tokenHash = hashSessionToken(token); + const pendingClaim = await runtime.store.getWechatWebClaim(tokenHash); + if (!pendingClaim) { + return reply.code(401).send({ + error: "wechat_web_claim_invalid", + message: "注册链接无效、已使用或已过期" + }); + } + if (request.authUserId && request.authUserId !== pendingClaim.userId) { + return reply.code(409).send({ + error: "wechat_web_account_switch_required", + message: "当前浏览器已登录其他 TOMEET 账号。请先确认退出当前账号,再继续使用微信里的同一个账号;聊天、画像和匹配状态都会保留。" + }); + } + const claim = await runtime.store.consumeWechatWebClaim(tokenHash); if (!claim) { return reply.code(401).send({ error: "wechat_web_claim_invalid", @@ -582,7 +596,11 @@ export function registerWechatRoutes( "email_password", "phone_password", "google" - ] + ], + accountContinuity: { + mode: "upgrade_existing_wechat_user", + preserves: ["conversation", "profile", "matching"] + } }; } ); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7479aa2..9b9d230 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -815,8 +815,12 @@ paths: in the JSON body. A successful response returns the Supabase anonymous session that already owns the WeChat profile. The client must call supabase.auth.setSession before upgrading that same user with email and - password, phone and password, or a linked Google identity. - security: [] + password, phone and password, or a linked Google identity. If the browser + already has a session, send its optional Bearer token; a different account + receives a non-consuming 409 account-switch response. + security: + - {} + - bearerAuth: [] requestBody: required: true content: @@ -847,6 +851,12 @@ paths: application/json: schema: $ref: "#/components/schemas/WechatSessionError" + "409": + description: The browser is signed in to a different TOMEET account; the claim is not consumed + content: + application/json: + schema: + $ref: "#/components/schemas/WechatSessionError" "429": $ref: "#/components/responses/RateLimited" "503": @@ -1195,7 +1205,7 @@ components: WechatWebClaimResponse: type: object additionalProperties: false - required: [userId, session, registrationMethods] + required: [userId, session, registrationMethods, accountContinuity] properties: userId: $ref: "#/components/schemas/Uuid" @@ -1218,6 +1228,21 @@ components: - const: google minItems: 3 maxItems: 3 + accountContinuity: + type: object + additionalProperties: false + required: [mode, preserves] + properties: + mode: + const: upgrade_existing_wechat_user + preserves: + type: array + prefixItems: + - const: conversation + - const: profile + - const: matching + minItems: 3 + maxItems: 3 WechatSessionError: type: object required: [error, message] diff --git a/docs/wechat-web-registration.md b/docs/wechat-web-registration.md index 95161f2..bd46b31 100644 --- a/docs/wechat-web-registration.md +++ b/docs/wechat-web-registration.md @@ -5,12 +5,14 @@ user first and uses that user's UUID as the canonical TOMEET `user_id`. The WeChat identity, conversation, memory, matchmaking state, and future Web login therefore belong to the same account without moving data after registration. -The activation welcome appends two bubbles: +The activation welcome appends three bubbles: ```text 想在网页上和别人线下加好友吗,有机会上TOMEET“必吃榜”! -点这里完成注册:https://tomeet.chat/register#claim= +这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留 + +点这里为当前账号添加网页登录:https://tomeet.chat/register#claim= ``` The claim is random, single-use, and valid for 15 minutes by default. It is @@ -45,6 +47,13 @@ await supabase.auth.setSession({ At this point the browser is signed in as the same anonymous Supabase user that owns the WeChat profile. The page should present these upgrade choices: +Use this primary explanation above the choices: + +```text +这是你微信里的同一个 TOMEET 账号 +注册只是增加一种网页登录方式,不会新建账号;聊天记录、个人画像和正在进行的匹配都会保留 +``` + ### Email and password ```ts @@ -87,15 +96,23 @@ update `registration_status` and redirect to the Agent page. ## UX and conflict rules -- The page title is `创建你的 TOMEET 账号`. -- Explain that registration automatically links the current WeChat and keeps - its conversation and profile. -- If the browser already has a different Supabase user, require an explicit - account-switch confirmation before redeeming the claim. +- The page title is `为微信里的 TOMEET 添加网页登录`, not `创建另一个账号`. +- Explain that registration upgrades the existing WeChat user and keeps its + conversation, profile, active match request, room, and matching history. +- The claim response includes `accountContinuity.mode=upgrade_existing_wechat_user` + and `preserves=[conversation,profile,matching]`; render this as user-facing copy, + not as technical account IDs. +- If the browser already has a different Supabase user, send its Bearer token when + redeeming. The API returns `409 wechat_web_account_switch_required` without + consuming the claim. Ask the user to confirm signing out, then retry without the + old token. Never silently replace the current browser session. - An invalid, consumed, or expired claim receives the same generic error. Tell the user to return to WeChat and request a fresh registration link. - Never create a second `public.users` row during registration. Registration upgrades the anonymous `auth.users` row whose UUID was returned by the claim. +- Email, phone, and Google must be attached with `updateUser` or `linkIdentity`. + Do not call a fresh sign-up/sign-in flow, because that would create or enter a + different Supabase user and split the user's matching state. ## Runtime configuration diff --git a/packages/data/src/memory-wechat-store.ts b/packages/data/src/memory-wechat-store.ts index 9b9c734..9aeb632 100644 --- a/packages/data/src/memory-wechat-store.ts +++ b/packages/data/src/memory-wechat-store.ts @@ -38,6 +38,14 @@ export class MemoryWechatStore implements WechatConnectionStore { return structuredClone(claim); } + async getWechatWebClaim(tokenHash: string): Promise { + const claim = [...this.webClaims.values()].find((item) => item.tokenHash === tokenHash); + if (!claim || claim.consumedAt || new Date(claim.expiresAt).getTime() <= Date.now()) { + return null; + } + return structuredClone(claim); + } + async consumeWechatWebClaim(tokenHash: string): Promise { const claim = [...this.webClaims.values()].find((item) => item.tokenHash === tokenHash); if (!claim || claim.consumedAt) { diff --git a/packages/data/src/supabase-wechat-store.ts b/packages/data/src/supabase-wechat-store.ts index 292696c..a3602c8 100644 --- a/packages/data/src/supabase-wechat-store.ts +++ b/packages/data/src/supabase-wechat-store.ts @@ -114,6 +114,18 @@ export class SupabaseWechatStore implements WechatConnectionStore { return mapWebClaim(data as JsonRow); } + async getWechatWebClaim(tokenHash: string): Promise { + const { data, error } = await this.client + .from("wechat_web_claims") + .select("*") + .eq("token_hash", tokenHash) + .is("consumed_at", null) + .gt("expires_at", new Date().toISOString()) + .maybeSingle(); + if (error) this.throwError("Read WeChat Web claim", error); + return data ? mapWebClaim(data as JsonRow) : null; + } + async consumeWechatWebClaim(tokenHash: string): Promise { const now = new Date().toISOString(); const { data, error } = await this.client diff --git a/packages/data/src/wechat-store.ts b/packages/data/src/wechat-store.ts index 52ee106..db231c3 100644 --- a/packages/data/src/wechat-store.ts +++ b/packages/data/src/wechat-store.ts @@ -11,6 +11,7 @@ import type { export interface WechatConnectionStore { createWechatWebClaim(input: CreateWechatWebClaimInput): Promise; + getWechatWebClaim(tokenHash: string): Promise; consumeWechatWebClaim(tokenHash: string): Promise; createWechatSession(input: CreateWechatSessionInput): Promise; getWechatSession(sessionId: string): Promise; From 5eda7669bad5ec8baf12a1632e5ad0618a39c7f6 Mon Sep 17 00:00:00 2001 From: zs-andy Date: Sun, 26 Jul 2026 03:42:23 +0800 Subject: [PATCH 2/2] fix(docs): keep matching flows WeChat-only --- apps/api/src/app.ts | 2 +- apps/api/src/wechat-routes.test.ts | 26 ++++++-------------------- docs/wechat-web-registration.md | 11 +++++++---- 3 files changed, 14 insertions(+), 25 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 69fcc32..cbc7e5f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -329,7 +329,7 @@ export async function buildApp(options: BuildAppOptions) { runId: `activation-welcome-${userId}-web-register-intro` }); await deliverText({ - text: "这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留", + text: "这是微信里的同一个 TOMEET 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信", runId: `activation-welcome-${userId}-web-register-continuity` }); await deliverText({ diff --git a/apps/api/src/wechat-routes.test.ts b/apps/api/src/wechat-routes.test.ts index 4179bc1..1e5713c 100644 --- a/apps/api/src/wechat-routes.test.ts +++ b/apps/api/src/wechat-routes.test.ts @@ -50,9 +50,6 @@ async function setup( const verifyAccessToken = vi.fn(async (accessToken: string) => { const userId = integration?.userByToken?.[accessToken]; if (userId) return userId; - if (integration?.webRegistration && accessToken === "anonymous-access-token") { - return provisionedUserId; - } throw new Error("WeChat route unexpectedly required a bearer token"); }); const inlineProcessor = integration?.processJobsInline @@ -160,13 +157,16 @@ describe("WeChat one-time QR onboarding", () => { expect(texts.slice(0, 4)).toEqual(adventurexWelcomeBubbles.zh); expect(texts[4]).toBe("想在网页上和别人线下加好友吗,有机会上TOMEET“必吃榜”!"); expect(texts[5]).toBe( - "这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留" + "这是微信里的同一个 TOMEET 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信" ); expect(texts[6]).toMatch( /^点这里为当前账号添加网页登录:https:\/\/tomeet\.chat\/register#claim=[A-Za-z0-9_-]{43}$/u ); const token = texts[6]?.match(/#claim=([A-Za-z0-9_-]{43})$/u)?.[1]; expect(token).toEqual(expect.any(String)); + const existingWechatMatch = await store.createMatchRequest(provisionedUserId, { + rawText: "在微信里开始匹配" + }); const claim = await app.inject({ method: "POST", @@ -188,22 +188,8 @@ describe("WeChat one-time QR onboarding", () => { preserves: ["conversation", "profile", "matching"] } }); - - const webMatch = await app.inject({ - method: "POST", - url: "/match-requests", - headers: { authorization: "Bearer anonymous-access-token" }, - payload: { - userId: provisionedUserId, - intent: { rawText: "想在网页继续微信里的匹配" } - } - }); - expect(webMatch.statusCode).toBe(202); - expect(webMatch.json().matchRequest).toMatchObject({ userId: provisionedUserId }); - const wechatMatch = await store.createMatchRequest(provisionedUserId, { - rawText: "从微信继续匹配" - }); - expect(wechatMatch.requestId).toBe(webMatch.json().matchRequest.requestId); + expect(await store.getLatestMatchRequestForUser(provisionedUserId)) + .toEqual(existingWechatMatch); const repeated = await app.inject({ method: "POST", diff --git a/docs/wechat-web-registration.md b/docs/wechat-web-registration.md index bd46b31..6b977ec 100644 --- a/docs/wechat-web-registration.md +++ b/docs/wechat-web-registration.md @@ -10,7 +10,7 @@ The activation welcome appends three bubbles: ```text 想在网页上和别人线下加好友吗,有机会上TOMEET“必吃榜”! -这是你微信里的同一个 TOMEET 账号,注册只会添加网页登录方式,聊天、画像和匹配都会保留 +这是微信里的同一个 TOMEET 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信 点这里为当前账号添加网页登录:https://tomeet.chat/register#claim= ``` @@ -51,7 +51,8 @@ Use this primary explanation above the choices: ```text 这是你微信里的同一个 TOMEET 账号 -注册只是增加一种网页登录方式,不会新建账号;聊天记录、个人画像和正在进行的匹配都会保留 +网页用于账号资料、线下加好友和必吃榜;Agent 对话和发起匹配仍在微信 +注册不会新建第二个用户,也不会重置微信里的画像或正在进行的匹配 ``` ### Email and password @@ -86,19 +87,21 @@ verified. await supabase.auth.linkIdentity({ provider: "google", options: { - redirectTo: `${window.location.origin}/api/auth/callback?next=/agent` + redirectTo: `${window.location.origin}/api/auth/callback?next=/` } }); ``` Supabase manual identity linking must be enabled. After the OAuth callback, -update `registration_status` and redirect to the Agent page. +update `registration_status` and redirect to the Web social home page. ## UX and conflict rules - The page title is `为微信里的 TOMEET 添加网页登录`, not `创建另一个账号`. - Explain that registration upgrades the existing WeChat user and keeps its conversation, profile, active match request, room, and matching history. +- Do not show Agent chat, start-match buttons, matching options, or matching + controls on Web. Those product flows remain WeChat-only. - The claim response includes `accountContinuity.mode=upgrade_existing_wechat_user` and `preserves=[conversation,profile,matching]`; render this as user-facing copy, not as technical account IDs.