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
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
},
Expand Down
13 changes: 13 additions & 0 deletions src/v1/common/v1-route-input-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@
'verifyWebhookSignature',
'handleEvents',
'createLinkNonce',
'createLinkCode',
'unlink',
]),
};
Expand Down Expand Up @@ -888,6 +889,18 @@
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: {
Expand Down Expand Up @@ -1037,7 +1050,7 @@
];

it.each(successCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1053 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1053 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down Expand Up @@ -1075,7 +1088,7 @@
});

it.each(rejectionCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1091 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1091 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down
12 changes: 12 additions & 0 deletions src/v1/line/line.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
78 changes: 78 additions & 0 deletions src/v1/line/line.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })],
Expand Down Expand Up @@ -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<string, unknown>;
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] });
Expand Down
127 changes: 106 additions & 21 deletions src/v1/line/line.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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<void> {
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<void> {
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) {
Expand All @@ -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<void> {
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<void> {
Expand Down Expand Up @@ -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<void> {
// line_id is deliberately excluded from the PATCH-profile whitelist;
// this service method is the only authenticated write path for it.
Expand Down
Loading