Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,15 @@ 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"
);
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)
Expand Down Expand Up @@ -328,7 +329,11 @@ export async function buildApp(options: BuildAppOptions) {
runId: `activation-welcome-${userId}-web-register-intro`
});
await deliverText({
text: `点这里完成注册:${webRegistrationUrl}`,
text: "这是微信里的同一个 TOMEET 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信",
runId: `activation-welcome-${userId}-web-register-continuity`
});
await deliverText({
text: `点这里为当前账号添加网页登录:${webRegistrationUrl}`,
runId: `activation-welcome-${userId}-web-register-link`
});
}
Expand Down
74 changes: 68 additions & 6 deletions apps/api/src/wechat-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ 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;
Expand All @@ -58,7 +59,6 @@ async function setup(
new MockMatchmakingIntelligence()
)
: undefined;
const provisionedUserId = integration?.provisionedUserId ?? randomUUID();
const accountProvisioner = {
provision: vi.fn(async () => ({
userId: provisionedUserId,
Expand Down Expand Up @@ -156,11 +156,17 @@ 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 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信"
);
expect(texts[6]).toMatch(
/^点这里为当前账号添加网页登录:https:\/\/tomeet\.chat\/register#claim=[A-Za-z0-9_-]{43}$/u
);
const token = texts[5]?.match(/#claim=([A-Za-z0-9_-]{43})$/u)?.[1];
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",
Expand All @@ -176,8 +182,14 @@ 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"]
}
});
expect(await store.getLatestMatchRequestForUser(provisionedUserId))
.toEqual(existingWechatMatch);

const repeated = await app.inject({
method: "POST",
Expand All @@ -191,6 +203,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 = {
Expand Down Expand Up @@ -234,7 +296,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);
});

Expand Down
22 changes: 20 additions & 2 deletions apps/api/src/wechat-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -582,7 +596,11 @@ export function registerWechatRoutes(
"email_password",
"phone_password",
"google"
]
],
accountContinuity: {
mode: "upgrade_existing_wechat_user",
preserves: ["conversation", "profile", "matching"]
}
};
}
);
Expand Down
31 changes: 28 additions & 3 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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"
Expand All @@ -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]
Expand Down
38 changes: 29 additions & 9 deletions docs/wechat-web-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<one-time-token>
这是微信里的同一个 TOMEET 账号,网页只用于注册和加好友;Agent 对话和发起匹配仍在微信

点这里为当前账号添加网页登录:https://tomeet.chat/register#claim=<one-time-token>
```

The claim is random, single-use, and valid for 15 minutes by default. It is
Expand Down Expand Up @@ -45,6 +47,14 @@ 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 账号
网页用于账号资料、线下加好友和必吃榜;Agent 对话和发起匹配仍在微信
注册不会新建第二个用户,也不会重置微信里的画像或正在进行的匹配
```

### Email and password

```ts
Expand Down Expand Up @@ -77,25 +87,35 @@ 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 账号`.
- 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.
- 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.
- 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

Expand Down
8 changes: 8 additions & 0 deletions packages/data/src/memory-wechat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export class MemoryWechatStore implements WechatConnectionStore {
return structuredClone(claim);
}

async getWechatWebClaim(tokenHash: string): Promise<WechatWebClaim | null> {
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<WechatWebClaim | null> {
const claim = [...this.webClaims.values()].find((item) => item.tokenHash === tokenHash);
if (!claim || claim.consumedAt) {
Expand Down
12 changes: 12 additions & 0 deletions packages/data/src/supabase-wechat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ export class SupabaseWechatStore implements WechatConnectionStore {
return mapWebClaim(data as JsonRow);
}

async getWechatWebClaim(tokenHash: string): Promise<WechatWebClaim | null> {
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<WechatWebClaim | null> {
const now = new Date().toISOString();
const { data, error } = await this.client
Expand Down
1 change: 1 addition & 0 deletions packages/data/src/wechat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {

export interface WechatConnectionStore {
createWechatWebClaim(input: CreateWechatWebClaimInput): Promise<WechatWebClaim>;
getWechatWebClaim(tokenHash: string): Promise<WechatWebClaim | null>;
consumeWechatWebClaim(tokenHash: string): Promise<WechatWebClaim | null>;
createWechatSession(input: CreateWechatSessionInput): Promise<WechatConnectionSession>;
getWechatSession(sessionId: string): Promise<WechatConnectionSession | null>;
Expand Down
Loading