From 5420a9413d861b3aa8e7c2b323e5a6caca3a5463 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 29 Jul 2026 20:26:33 +0900 Subject: [PATCH] feat(line): chat-based 6-digit linking codes POST /v1/line/link-code mints a single-use 6-digit code (10-min expiry, per-user, shares cw_line_link_nonces); the webhook message handler links the sender when an unbound user sends a valid code, replies with an invalid/expired notice otherwise, and still falls back to the account-link button for non-code messages. Link-button DM text now mentions both paths. Co-Authored-By: Claude Fable 5 --- .../v1-route-input-contract.spec.ts.snap | 3 + src/v1/common/v1-route-input-contract.spec.ts | 13 ++ src/v1/line/line.controller.ts | 12 ++ src/v1/line/line.service.spec.ts | 78 +++++++++++ src/v1/line/line.service.ts | 127 +++++++++++++++--- 5 files changed, 212 insertions(+), 21 deletions(-) diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index b8a2369..5a991d0 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -1115,6 +1115,9 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "/v1/line/link": { "delete": {}, }, + "/v1/line/link-code": { + "post": {}, + }, "/v1/line/link-start": { "post": {}, }, diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index 1cf6f4f..d65dda4 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -369,6 +369,7 @@ describe('V1 Route Input Contracts', () => { 'verifyWebhookSignature', 'handleEvents', 'createLinkNonce', + 'createLinkCode', 'unlink', ]), }; @@ -888,6 +889,18 @@ describe('V1 Route Input Contracts', () => { name: 'POST /v1/line/link-start mints a nonce for the current user', url: '/v1/line/link-start', }, + { + auth: true, + expectedCall: { + args: [MOCK_USER.sub], + method: 'createLinkCode', + service: 'line', + }, + expectedStatus: 201, + method: 'post', + name: 'POST /v1/line/link-code mints a chat-linking code for the current user', + url: '/v1/line/link-code', + }, { auth: true, expectedCall: { diff --git a/src/v1/line/line.controller.ts b/src/v1/line/line.controller.ts index b4a924b..81b776d 100644 --- a/src/v1/line/line.controller.ts +++ b/src/v1/line/line.controller.ts @@ -66,6 +66,18 @@ export class LineController { return this.lineService.createLinkNonce(user.sub); } + @Post('link-code') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Mint a 6-digit code the user sends to the LINE bot to link', + }) + linkCode( + @CurrentUser() user: AuthenticatedUser, + ): Promise<{ code: string; expiresAt: string }> { + return this.lineService.createLinkCode(user.sub); + } + @Delete('link') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/src/v1/line/line.service.spec.ts b/src/v1/line/line.service.spec.ts index 3bd26df..b9d8a16 100644 --- a/src/v1/line/line.service.spec.ts +++ b/src/v1/line/line.service.spec.ts @@ -290,6 +290,61 @@ describe('LineService', () => { expect(apiClient.issueLinkToken).toHaveBeenCalledWith(LINE_USER); }); + it('links the sender when an unbound user sends a valid 6-digit code', async () => { + const isLinkedLookup = chain({ data: null, error: null }); + const profileUpdate = chain({ data: null, error: null }); + const codeLookup = chain({ + data: { nonce: '123456', user_id: 'user-1', expires_at: 'later' }, + error: null, + }); + const codeDelete = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + profiles: [isLinkedLookup, profileUpdate], + cw_line_link_nonces: [codeLookup, codeDelete], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { + type: 'message', + source: { userId: LINE_USER }, + message: { type: 'text', text: ' 123456 ' }, + }, + ]); + + expect(profileUpdate.calls).toContainEqual({ + method: 'update', + args: [{ line_id: LINE_USER }], + }); + expect(profileUpdate.calls).toContainEqual({ + method: 'eq', + args: ['id', 'user-1'], + }); + expect(apiClient.issueLinkToken).not.toHaveBeenCalled(); + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('連携が完了'); + }); + + it('replies invalid-code when the 6-digit code is unknown or expired', async () => { + const adminClient = buildAdminClient({ + profiles: [chain({ data: null, error: null })], + cw_line_link_nonces: [chain({ data: null, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { + type: 'message', + source: { userId: LINE_USER }, + message: { type: 'text', text: '999999' }, + }, + ]); + + expect(apiClient.issueLinkToken).not.toHaveBeenCalled(); + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('無効か期限切れ'); + }); + it('ignores messages from bound users', async () => { const adminClient = buildAdminClient({ profiles: [chain({ data: { id: 'user-1' }, error: null })], @@ -356,6 +411,29 @@ describe('LineService', () => { expect(row.nonce).toBe(nonce); }); + it('createLinkCode invalidates prior codes and mints a 6-digit code', async () => { + const purgeExpired = chain({ data: null, error: null }); + const purgeUser = chain({ data: null, error: null }); + const insert = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + cw_line_link_nonces: [purgeExpired, purgeUser, insert], + }); + const { service } = createService({ adminClient }); + + const { code, expiresAt } = await service.createLinkCode('user-1'); + + expect(code).toMatch(/^\d{6}$/); + expect(new Date(expiresAt).getTime()).toBeGreaterThan(Date.now()); + expect(purgeUser.calls).toContainEqual({ + method: 'eq', + args: ['user_id', 'user-1'], + }); + const insertCall = insert.calls.find((c) => c.method === 'insert'); + const row = (insertCall!.args[0] ?? {}) as Record; + expect(row.nonce).toBe(code); + expect(row.user_id).toBe('user-1'); + }); + it('unlink clears line_id for the current user', async () => { const profileUpdate = chain({ data: null, error: null }); const adminClient = buildAdminClient({ profiles: [profileUpdate] }); diff --git a/src/v1/line/line.service.ts b/src/v1/line/line.service.ts index 37a3185..a5a0f4f 100644 --- a/src/v1/line/line.service.ts +++ b/src/v1/line/line.service.ts @@ -5,26 +5,30 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { createHmac, randomBytes, randomInt, timingSafeEqual } from 'crypto'; import { SupabaseService } from '../../supabase/supabase.service'; import { LineApiClient, type LineMessage } from './line-api.client'; const APP_BASE_URL = 'https://app.cropwatch.io'; const NONCE_TTL_MS = 10 * 60 * 1000; const UNIQUE_VIOLATION = '23505'; +const LINK_CODE_PATTERN = /^\d{6}$/; +const LINK_CODE_INSERT_ATTEMPTS = 5; export interface LineWebhookEvent { type: string; source?: { type?: string; userId?: string }; link?: { result?: string; nonce?: string }; + message?: { type?: string; text?: unknown }; [key: string]: unknown; } // Bilingual DM texts (ja first, matching the alert-email convention). const DM = { linkButtonAlt: 'CropWatchアカウント連携 / Link your CropWatch account', + // Buttons-template text is capped at 160 chars — keep this tight. linkButtonText: - 'CropWatchアカウントと連携すると、アラートをLINEで受け取れます。\nLink your CropWatch account to receive alerts on LINE.', + 'アプリの6桁コードをこのトークに送るか、下のボタンで連携できます。\nSend the 6-digit code from the app here, or tap the button.', linkButtonLabel: '連携する / Link', alreadyLinked: 'このLINEアカウントは連携済みです。\nThis LINE account is already linked.', @@ -34,6 +38,8 @@ const DM = { '連携に失敗しました。このトークにメッセージを送ると、新しい連携ボタンをお送りします。\nLink failed — send this chat any message to get a new link button.', linkedElsewhere: 'このLINEアカウントは別のCropWatchアカウントに連携されています。\nThis LINE account is already linked to a different CropWatch user.', + codeInvalid: + 'この連携コードは無効か期限切れです。アプリのプロフィールページで新しいコードを取得して、もう一度お送りください。\nThat linking code is invalid or expired. Get a new code from your profile page in the app and send it again.', } as const; @Injectable() @@ -99,10 +105,8 @@ export class LineService { if (lineUserId) await this.handleAccountLink(lineUserId, event); return; case 'message': - // Recovery path: an unbound user's message re-issues the link button - // (the link token in the original DM expires after 10 minutes). if (lineUserId && !(await this.isLinked(lineUserId))) { - await this.sendLinkButton(lineUserId); + await this.handleUnboundMessage(lineUserId, event); } return; default: @@ -118,38 +122,56 @@ export class LineService { await this.sendLinkButton(lineUserId); } - private async handleAccountLink( + // Primary linking path: the profile page shows a 6-digit code, the user + // sends it in chat. Works on every device/browser combination because the + // browser and LINE never have to share a session (the official + // account-link dialog breaks whenever the link escapes LINE's in-app + // browser). Non-code messages fall back to the account-link button. + private async handleUnboundMessage( lineUserId: string, event: LineWebhookEvent, ): Promise { - if (event.link?.result !== 'ok' || !event.link.nonce) { - this.logger.warn(`LINE account link did not complete for ${lineUserId}`); + const text = + typeof event.message?.text === 'string' ? event.message.text.trim() : ''; + + if (!LINK_CODE_PATTERN.test(text)) { + await this.sendLinkButton(lineUserId); return; } const client = this.supabaseService.getAdminClient(); const nowIso = new Date().toISOString(); - const { data: nonceRow, error: nonceError } = await client + const { data: codeRow, error: codeError } = await client .from('cw_line_link_nonces') .select('nonce, user_id, expires_at') - .eq('nonce', event.link.nonce) + .eq('nonce', text) .gt('expires_at', nowIso) .maybeSingle(); - if (nonceError) { - throw new Error(`Failed to look up link nonce: ${nonceError.message}`); + if (codeError) { + throw new Error(`Failed to look up link code: ${codeError.message}`); } - if (!nonceRow) { - this.logger.warn('LINE accountLink nonce missing or expired'); - await this.pushText(lineUserId, DM.linkFailed); + if (!codeRow) { + await this.pushText(lineUserId, DM.codeInvalid); return; } + const row = codeRow as { nonce: string; user_id: string }; + await this.bindProfile(lineUserId, row.user_id, row.nonce); + } + + private async bindProfile( + lineUserId: string, + userId: string, + nonce: string, + ): Promise { + const client = this.supabaseService.getAdminClient(); + const { error: updateError } = await client .from('profiles') .update({ line_id: lineUserId }) - .eq('id', nonceRow.user_id); + .eq('id', userId); if (updateError) { if (updateError.code === UNIQUE_VIOLATION) { @@ -159,12 +181,41 @@ export class LineService { throw new Error(`Failed to bind LINE account: ${updateError.message}`); } - await client - .from('cw_line_link_nonces') - .delete() - .eq('nonce', nonceRow.nonce); + await client.from('cw_line_link_nonces').delete().eq('nonce', nonce); await this.pushText(lineUserId, DM.linked); - this.logger.log(`Linked LINE account for user ${nonceRow.user_id}`); + this.logger.log(`Linked LINE account for user ${userId}`); + } + + private async handleAccountLink( + lineUserId: string, + event: LineWebhookEvent, + ): Promise { + if (event.link?.result !== 'ok' || !event.link.nonce) { + this.logger.warn(`LINE account link did not complete for ${lineUserId}`); + return; + } + + const client = this.supabaseService.getAdminClient(); + const nowIso = new Date().toISOString(); + + const { data: nonceRow, error: nonceError } = await client + .from('cw_line_link_nonces') + .select('nonce, user_id, expires_at') + .eq('nonce', event.link.nonce) + .gt('expires_at', nowIso) + .maybeSingle(); + + if (nonceError) { + throw new Error(`Failed to look up link nonce: ${nonceError.message}`); + } + if (!nonceRow) { + this.logger.warn('LINE accountLink nonce missing or expired'); + await this.pushText(lineUserId, DM.linkFailed); + return; + } + + const row = nonceRow as { nonce: string; user_id: string }; + await this.bindProfile(lineUserId, row.user_id, row.nonce); } private async sendLinkButton(lineUserId: string): Promise { @@ -212,6 +263,40 @@ export class LineService { return { nonce }; } + // 6-digit code for the chat-based linking path. Shares the nonce table: + // codes and account-link nonces never collide (different shapes), and both + // are single-use rows with a 10-minute expiry. + async createLinkCode( + userId: string, + ): Promise<{ code: string; expiresAt: string }> { + const client = this.supabaseService.getAdminClient(); + const nowIso = new Date().toISOString(); + + await client.from('cw_line_link_nonces').delete().lt('expires_at', nowIso); + // A user re-requesting a code invalidates their previous one. + await client.from('cw_line_link_nonces').delete().eq('user_id', userId); + + const expiresAt = new Date(Date.now() + NONCE_TTL_MS).toISOString(); + for (let attempt = 1; attempt <= LINK_CODE_INSERT_ATTEMPTS; attempt += 1) { + const code = randomInt(100000, 1000000).toString(); + const { error } = await client.from('cw_line_link_nonces').insert({ + nonce: code, + user_id: userId, + expires_at: expiresAt, + }); + + if (!error) { + return { code, expiresAt }; + } + if (error.code !== UNIQUE_VIOLATION) { + throw new Error(`Failed to store link code: ${error.message}`); + } + // Collision with another user's live code — regenerate. + } + + throw new Error('Failed to allocate a unique link code'); + } + async unlink(userId: string): Promise { // line_id is deliberately excluded from the PATCH-profile whitelist; // this service method is the only authenticated write path for it.