diff --git a/src/app/commands/create.test.ts b/src/app/commands/create.test.ts index 2a51cd3..29a9c62 100644 --- a/src/app/commands/create.test.ts +++ b/src/app/commands/create.test.ts @@ -44,6 +44,7 @@ describe("makeCreateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockAddApp).mockResolvedValueOnce({ liffId: "12345" }); @@ -85,6 +86,7 @@ describe("makeCreateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); const command = makeCreateCommand(); diff --git a/src/app/commands/create.ts b/src/app/commands/create.ts index 40ef6d8..8b0ac17 100644 --- a/src/app/commands/create.ts +++ b/src/app/commands/create.ts @@ -10,16 +10,16 @@ type CreateAppOptions = { }; export const createLiffApp = async (options: CreateAppOptions) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channel = await resolveChannel(options?.channelId); + if (!channel?.accessToken) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channel.accessToken, + baseUrl: channel.apiBaseUrl, }); const { liffId } = await client.addApp({ view: { diff --git a/src/app/commands/delete.test.ts b/src/app/commands/delete.test.ts index ca67c40..e6a666b 100644 --- a/src/app/commands/delete.test.ts +++ b/src/app/commands/delete.test.ts @@ -49,6 +49,7 @@ describe("makeDeleteCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(inquire.prompt).mockResolvedValue({ confirmDelete: true }); @@ -105,6 +106,7 @@ describe("makeDeleteCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(inquire.prompt).mockResolvedValue({ confirmDelete: false }); diff --git a/src/app/commands/delete.ts b/src/app/commands/delete.ts index ee97eb0..4693b09 100644 --- a/src/app/commands/delete.ts +++ b/src/app/commands/delete.ts @@ -7,8 +7,8 @@ const deleteAction = async (options: { channelId?: string; liffId: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channel = await resolveChannel(options?.channelId); + if (!channel?.accessToken) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); @@ -25,8 +25,8 @@ const deleteAction = async (options: { if (!confirmDelete) return; const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channel.accessToken, + baseUrl: channel.apiBaseUrl, }); console.info(`Deleting LIFF app...`); await client.deleteApp(options.liffId); diff --git a/src/app/commands/list.test.ts b/src/app/commands/list.test.ts index 7fde1bd..3d51413 100644 --- a/src/app/commands/list.test.ts +++ b/src/app/commands/list.test.ts @@ -36,6 +36,7 @@ describe("makeListCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockFetchApps).mockResolvedValueOnce({ apps: [ diff --git a/src/app/commands/list.ts b/src/app/commands/list.ts index 8240965..11a9002 100644 --- a/src/app/commands/list.ts +++ b/src/app/commands/list.ts @@ -3,16 +3,16 @@ import { LiffApiClient } from "../../api/liff.js"; import { resolveChannel } from "../../channel/resolveChannel.js"; const listAction = async (options: { channelId?: string }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channel = await resolveChannel(options?.channelId); + if (!channel?.accessToken) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channel.accessToken, + baseUrl: channel.apiBaseUrl, }); const { apps } = await client.fetchApps(); diff --git a/src/app/commands/update.test.ts b/src/app/commands/update.test.ts index 7bb4f6a..41ba6d0 100644 --- a/src/app/commands/update.test.ts +++ b/src/app/commands/update.test.ts @@ -45,6 +45,7 @@ describe("makeUpdateCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); diff --git a/src/app/commands/update.ts b/src/app/commands/update.ts index 8e7b464..fd87291 100644 --- a/src/app/commands/update.ts +++ b/src/app/commands/update.ts @@ -9,16 +9,16 @@ const updateAction = async (options: { endpointUrl?: string; viewType?: string; }) => { - const accessToken = (await resolveChannel(options?.channelId))?.accessToken; - if (!accessToken) { + const channel = await resolveChannel(options?.channelId); + if (!channel?.accessToken) { throw new Error(`Access token not found. Please provide a valid channel ID or set the current channel first. `); } const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channel.accessToken, + baseUrl: channel.apiBaseUrl, }); await client.updateApp(options.liffId, { view: { diff --git a/src/channel/commands/add.test.ts b/src/channel/commands/add.test.ts index c1e6480..cb0f0c5 100644 --- a/src/channel/commands/add.test.ts +++ b/src/channel/commands/add.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import inquire from "inquirer"; import { makeAddCommand } from "./add.js"; -import { upsertChannel } from "../stores/channels.js"; +import { getChannel, upsertChannel } from "../stores/channels.js"; import { renewAccessToken } from "../renewAccessToken.js"; vi.mock("inquirer"); @@ -45,13 +45,83 @@ describe("add", () => { accessToken: "access_token", expiresIn: 3600, issuedAt: now, + apiBaseUrl: "https://api.line.me", }); const command = makeAddCommand(); await command.parseAsync(["_", "add", "123"]); - expect(renewAccessToken).toHaveBeenCalledWith("123", "secret", now); + expect(renewAccessToken).toHaveBeenCalledWith( + "123", + "secret", + now, + "https://api.line.me", + ); expect(console.info).toHaveBeenCalledWith("Channel 123 is now added."); }); + + it("should use the specified API base URL", async () => { + const now = Date.now(); + vi.setSystemTime(now); + + vi.mocked(inquire.prompt).mockResolvedValue({ channelSecret: "secret" }); + vi.mocked(renewAccessToken).mockResolvedValue({ + secret: "secret", + accessToken: "access_token", + expiresIn: 3600, + issuedAt: now, + apiBaseUrl: "https://api.example.com", + }); + + const command = makeAddCommand(); + await command.parseAsync([ + "_", + "add", + "123", + "--api-base-url", + "https://api.example.com", + ]); + + expect(renewAccessToken).toHaveBeenCalledWith( + "123", + "secret", + now, + "https://api.example.com", + ); + }); + + it("should keep the registered API base URL when the option is omitted", async () => { + const now = Date.now(); + vi.setSystemTime(now); + + vi.mocked(inquire.prompt).mockResolvedValue({ channelSecret: "secret" }); + vi.mocked(getChannel).mockReturnValue({ + secret: "secret", + accessToken: "access_token", + expiresIn: 3600, + issuedAt: now, + apiBaseUrl: "https://api.example.com", + }); + + const command = makeAddCommand(); + await command.parseAsync(["_", "add", "123"]); + + expect(renewAccessToken).toHaveBeenCalledWith( + "123", + "secret", + now, + "https://api.example.com", + ); + }); + + it("should throw an error for an invalid API base URL", async () => { + vi.mocked(inquire.prompt).mockResolvedValue({ channelSecret: "secret" }); + + const command = makeAddCommand(); + await expect( + command.parseAsync(["_", "add", "123", "--api-base-url", "not-a-url"]), + ).rejects.toThrowError("Invalid API base URL: not-a-url"); + expect(renewAccessToken).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/channel/commands/add.ts b/src/channel/commands/add.ts index ad0c0da..f9c8b4e 100644 --- a/src/channel/commands/add.ts +++ b/src/channel/commands/add.ts @@ -2,14 +2,29 @@ import { createCommand } from "commander"; import inquirer from "inquirer"; import { renewAccessToken } from "../renewAccessToken.js"; +import { DEFAULT_API_BASE_URL, getChannel } from "../stores/channels.js"; -export const addAction: (channelId?: string) => Promise = async ( - channelId, -) => { +export const addAction: ( + channelId: string | undefined, + options?: { apiBaseUrl?: string }, +) => Promise = async (channelId, options = {}) => { if (!channelId) { throw new Error("Channel ID is required."); } + // When --api-base-url is omitted, keep the URL already registered for the + // channel so that re-running `channel add` doesn't silently reset it. + const apiBaseUrl = + options.apiBaseUrl ?? + getChannel(channelId)?.apiBaseUrl ?? + DEFAULT_API_BASE_URL; + + try { + new URL(apiBaseUrl); + } catch { + throw new Error(`Invalid API base URL: ${apiBaseUrl}`); + } + const { channelSecret } = await inquirer.prompt<{ channelSecret: string }>([ { type: "password", @@ -19,7 +34,7 @@ export const addAction: (channelId?: string) => Promise = async ( }, ]); - await renewAccessToken(channelId, channelSecret, Date.now()); + await renewAccessToken(channelId, channelSecret, Date.now(), apiBaseUrl); console.info(`Channel ${channelId} is now added.`); }; @@ -29,6 +44,10 @@ export const makeAddCommand = () => { add .description("Register a LIFF channel that you want to manage") .argument("[channelId]", "The channel ID to add") + .option( + "--api-base-url ", + "The base URL of the LIFF and auth API. This option is intended for LINE internal use only.", + ) .action(addAction); return add; }; diff --git a/src/channel/commands/use.test.ts b/src/channel/commands/use.test.ts index deb83bf..1e24678 100644 --- a/src/channel/commands/use.test.ts +++ b/src/channel/commands/use.test.ts @@ -46,6 +46,7 @@ describe("makeUseCommand", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); const command = makeUseCommand(); diff --git a/src/channel/renewAccessToken.test.ts b/src/channel/renewAccessToken.test.ts index 2fdeee3..eab4b3e 100644 --- a/src/channel/renewAccessToken.test.ts +++ b/src/channel/renewAccessToken.test.ts @@ -35,21 +35,29 @@ describe("renewAccessToken", () => { const accessToken = "new_access_token"; const expiresIn = 3600; const issuedAt = Date.now(); + const apiBaseUrl = "https://api.example.com"; vi.mocked(mockFetchStatelessChannelAccessToken).mockResolvedValue({ token_type: "Bearer", access_token: accessToken, expires_in: expiresIn, }); - vi.mocked(upsertChannel).mockResolvedValue({ + vi.mocked(upsertChannel).mockReturnValue({ secret: channelSecret, accessToken, expiresIn, issuedAt, + apiBaseUrl, }); - const result = await renewAccessToken(channelId, channelSecret, issuedAt); + const result = await renewAccessToken( + channelId, + channelSecret, + issuedAt, + apiBaseUrl, + ); + expect(AuthApiClient).toHaveBeenCalledWith({ baseUrl: apiBaseUrl }); expect( vi.mocked(mockFetchStatelessChannelAccessToken), ).toHaveBeenCalledWith({ @@ -62,12 +70,14 @@ describe("renewAccessToken", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, ); expect(result).toStrictEqual({ secret: channelSecret, accessToken, expiresIn, issuedAt, + apiBaseUrl, }); }); diff --git a/src/channel/renewAccessToken.ts b/src/channel/renewAccessToken.ts index b047cf6..7aa4b15 100644 --- a/src/channel/renewAccessToken.ts +++ b/src/channel/renewAccessToken.ts @@ -1,13 +1,18 @@ import { AuthApiClient } from "../api/auth.js"; -import { ChannelInfo, upsertChannel } from "./stores/channels.js"; +import { + ChannelInfo, + DEFAULT_API_BASE_URL, + upsertChannel, +} from "./stores/channels.js"; export const renewAccessToken = async ( channelId: string, channelSecret: string, issuedAt: number, + apiBaseUrl: string = DEFAULT_API_BASE_URL, ): Promise => { const client = new AuthApiClient({ - baseUrl: "https://api.line.me", + baseUrl: apiBaseUrl, }); const res = await client.fetchStatelessChannelAccessToken({ channelId: channelId, @@ -22,5 +27,6 @@ export const renewAccessToken = async ( res.access_token, res.expires_in, issuedAt, + apiBaseUrl, ); }; diff --git a/src/channel/resolveChannel.test.ts b/src/channel/resolveChannel.test.ts index 9476a4b..62d1bde 100644 --- a/src/channel/resolveChannel.test.ts +++ b/src/channel/resolveChannel.test.ts @@ -43,6 +43,7 @@ describe("resolveChannel", () => { accessToken: "access_token", expiresIn: 3600, issuedAt: Date.now(), + apiBaseUrl: "https://api.line.me", }; vi.mocked(getCurrentChannelId).mockReturnValueOnce(currentChannelId); vi.mocked(getChannel).mockReturnValueOnce(channelData); @@ -60,6 +61,7 @@ describe("resolveChannel", () => { accessToken: "access_token", expiresIn: 3600, issuedAt: Date.now(), + apiBaseUrl: "https://api.line.me", }; vi.mocked(getChannel).mockReturnValueOnce(channelData); @@ -78,6 +80,7 @@ describe("resolveChannel", () => { accessToken: "access_token", expiresIn: 3600, issuedAt: now - 3600 * 1000 - 1, + apiBaseUrl: "https://api.example.com", }; vi.mocked(getChannel).mockReturnValueOnce(channelData); @@ -87,11 +90,17 @@ describe("resolveChannel", () => { accessToken: "new_access_token", expiresIn: 3600, issuedAt: now, + apiBaseUrl: "https://api.example.com", }); const result = await resolveChannel(); expect(result).not.toStrictEqual(channelData); - expect(renewAccessToken).toHaveBeenCalledWith(channelId, "secret", now); + expect(renewAccessToken).toHaveBeenCalledWith( + channelId, + "secret", + now, + "https://api.example.com", + ); }); }); diff --git a/src/channel/resolveChannel.ts b/src/channel/resolveChannel.ts index 7ed98f8..fa51aa7 100644 --- a/src/channel/resolveChannel.ts +++ b/src/channel/resolveChannel.ts @@ -24,5 +24,10 @@ export const resolveChannel = async ( if (now - issuedAt < expiresIn * 1000) { return channel; } - return renewAccessToken(currentChannelId, channel.secret, now); + return renewAccessToken( + currentChannelId, + channel.secret, + now, + channel.apiBaseUrl, + ); }; diff --git a/src/channel/stores/channels.test.ts b/src/channel/stores/channels.test.ts index 90ec9fe..b7c1655 100644 --- a/src/channel/stores/channels.test.ts +++ b/src/channel/stores/channels.test.ts @@ -36,6 +36,7 @@ describe("channels", () => { const accessToken = "access_token"; const expiresIn = 3600; const issuedAt = 1000; + const apiBaseUrl = "https://api.line.me"; vi.mocked(mockConf.get).mockReturnValueOnce({}); @@ -45,6 +46,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, ); expect(mockConf.set).toHaveBeenCalledWith("channels", { @@ -53,6 +55,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, }, }); expect(channel).toStrictEqual({ @@ -60,6 +63,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, }); }); @@ -69,6 +73,7 @@ describe("channels", () => { const accessToken = "access_token"; const expiresIn = 3600; const issuedAt = 1000; + const apiBaseUrl = "https://api.example.com"; vi.mocked(mockConf.get).mockReturnValueOnce({ "123": { @@ -76,6 +81,7 @@ describe("channels", () => { accessToken: "old", expiresIn: 10, issuedAt: 900, + apiBaseUrl: "https://api.line.me", }, }); @@ -85,6 +91,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, ); expect(mockConf.set).toHaveBeenCalledWith("channels", { @@ -93,6 +100,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, }, }); expect(channel).toStrictEqual({ @@ -100,6 +108,7 @@ describe("channels", () => { accessToken, expiresIn, issuedAt, + apiBaseUrl, }); }); }); @@ -108,9 +117,11 @@ describe("channels", () => { it("should get a channel", () => { const channelId = "123"; const channelData = { + secret: "secret", accessToken: "access_token", expiresIn: 3600, issuedAt: 1000, + apiBaseUrl: "https://api.example.com", }; vi.mocked(mockConf.get).mockReturnValueOnce({ @@ -121,6 +132,26 @@ describe("channels", () => { expect(result).toStrictEqual(channelData); }); + it("should fall back to the default API base URL for legacy channels", () => { + const channelId = "123"; + const channelData = { + secret: "secret", + accessToken: "access_token", + expiresIn: 3600, + issuedAt: 1000, + }; + + vi.mocked(mockConf.get).mockReturnValueOnce({ + [channelId]: channelData, + }); + + const result = getChannel(channelId); + expect(result).toStrictEqual({ + ...channelData, + apiBaseUrl: "https://api.line.me", + }); + }); + it("should return undefined if there are no channels", () => { const channelId = "123"; diff --git a/src/channel/stores/channels.ts b/src/channel/stores/channels.ts index 5591a85..9391f87 100644 --- a/src/channel/stores/channels.ts +++ b/src/channel/stores/channels.ts @@ -8,6 +8,8 @@ const packageJson: { await fs.readFile(new URL("../../../package.json", import.meta.url), "utf8"), ); +export const DEFAULT_API_BASE_URL = "https://api.line.me"; + const channelsSchema = { currentChannelId: { type: "string", @@ -32,6 +34,10 @@ const channelsSchema = { description: "The milliseconds timestamp when the access token was issued", }, + apiBaseUrl: { + type: "string", + description: "The base URL of the LIFF and auth API for the channel", + }, }, }, }; @@ -41,6 +47,7 @@ export type ChannelInfo = { accessToken: string; expiresIn: number; issuedAt: number; + apiBaseUrl: string; }; type ChannelConfig = { @@ -64,6 +71,7 @@ export const upsertChannel = ( accessToken: string, expiresIn: number, issuedAt: number, + apiBaseUrl: string, ): ChannelInfo => { const channels = store.get("channels") || {}; channels[channelId] = { @@ -71,6 +79,7 @@ export const upsertChannel = ( accessToken, expiresIn, issuedAt, + apiBaseUrl, }; store.set("channels", channels); @@ -82,7 +91,10 @@ export const getChannel = (channelId: string): ChannelInfo | undefined => { if (!channels || !channels[channelId]) { return; } - return channels[channelId]; + return { + ...channels[channelId], + apiBaseUrl: channels[channelId].apiBaseUrl ?? DEFAULT_API_BASE_URL, + }; }; export const setCurrentChannel = (channelId: string): void => { diff --git a/src/init/initAction.test.ts b/src/init/initAction.test.ts index 5ca9b04..27dddce 100644 --- a/src/init/initAction.test.ts +++ b/src/init/initAction.test.ts @@ -56,6 +56,7 @@ describe("initAction", () => { accessToken: "accessToken", expiresIn: 1000, issuedAt: 2000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(makeOptions).mockResolvedValue(TEST_OPTIONS); diff --git a/src/serve/serveAction.test.ts b/src/serve/serveAction.test.ts index c05d3dc..4ea3011 100644 --- a/src/serve/serveAction.test.ts +++ b/src/serve/serveAction.test.ts @@ -67,6 +67,7 @@ describe("serveAction", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); @@ -93,6 +94,7 @@ describe("serveAction", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); @@ -141,6 +143,7 @@ describe("serveAction", () => { expiresIn: 3600, secret: "secret", issuedAt: 1000, + apiBaseUrl: "https://api.line.me", }); vi.mocked(mockUpdateApp).mockResolvedValueOnce(); diff --git a/src/serve/serveAction.ts b/src/serve/serveAction.ts index bbc16e4..3f46f12 100644 --- a/src/serve/serveAction.ts +++ b/src/serve/serveAction.ts @@ -33,9 +33,8 @@ export const serveAction = async ( liffAppProxy: ProxyInterface, liffInspectorProxy: ProxyInterface, ) => { - const accessToken = (await resolveChannel(getCurrentChannelId())) - ?.accessToken; - if (!accessToken) { + const channel = await resolveChannel(getCurrentChannelId()); + if (!channel?.accessToken) { throw new Error(`Access token not found. Please set the current channel first. `); @@ -56,8 +55,8 @@ export const serveAction = async ( liffUrl.pathname = options.liffId; const client = new LiffApiClient({ - token: accessToken, - baseUrl: "https://api.line.me", + token: channel.accessToken, + baseUrl: channel.apiBaseUrl, }); if (wssUrl) { httpsUrl.searchParams.set("li.origin", wssUrl.toString()); diff --git a/src/setup.test.ts b/src/setup.test.ts index 7bf26fa..bf943dc 100644 --- a/src/setup.test.ts +++ b/src/setup.test.ts @@ -50,12 +50,12 @@ Commands: Manage LIFF channels Options: - -h, --help display help for command + -h, --help display help for command Commands: - add [channelId] Register a LIFF channel that you want to manage - use [channelId] Set the default LIFF channel to use - help [command] display help for command + add [options] [channelId] Register a LIFF channel that you want to manage + use [channelId] Set the default LIFF channel to use + help [command] display help for command `); });