Skip to content
Open
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
76 changes: 76 additions & 0 deletions apps/desktop/src/main/__tests__/cloud-billing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
handlers: new Map<string, () => unknown>(),
fetch: vi.fn(),
getCookie: vi.fn(() => 'better-auth.session=token'),
getSession: vi.fn(),
}));

vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: () => unknown) => mocks.handlers.set(channel, handler),
},
}));

vi.mock('../cloud-auth/client', () => ({
authClient: {
getCookie: mocks.getCookie,
getSession: mocks.getSession,
},
CLOUD_API_URL: 'https://api.linkcode.test',
}));

const summary = {
denomination: 'nano_usd',
availableAmount: '12500000000',
reservedAmount: '500000000',
displayBalance: { currency: 'USD', amount: '12.50' },
};

beforeEach(() => {
mocks.handlers.clear();
mocks.fetch.mockReset();
mocks.getSession.mockReset();
mocks.getSession.mockResolvedValue({
data: { session: { activeOrganizationId: 'org/1' } },
error: null,
});
vi.stubGlobal('fetch', mocks.fetch);
});

afterEach(() => vi.unstubAllGlobals());

describe('desktop Cloud billing bridge', () => {
it('reads and validates the active organization balance with the main-process session', async () => {
mocks.fetch.mockResolvedValue(Response.json(summary, { status: 200, statusText: 'OK' }));
const { registerCloudBillingBridge } = await import('../cloud-auth/billing');
const { CLOUD_GET_BILLING_SUMMARY_CHANNEL } = await import('../../shared/cloud');
registerCloudBillingBridge();

await expect(mocks.handlers.get(CLOUD_GET_BILLING_SUMMARY_CHANNEL)?.()).resolves.toEqual(
summary,
);
expect(mocks.fetch).toHaveBeenCalledWith(
'https://api.linkcode.test/organizations/org%2F1/billing/summary',
{ headers: { cookie: 'better-auth.session=token' } },
);
});

it('returns null without an active organization', async () => {
mocks.getSession.mockResolvedValue({ data: { session: {} }, error: null });
const { getCloudBillingSummary } = await import('../cloud-auth/billing');

await expect(getCloudBillingSummary()).resolves.toBeNull();
expect(mocks.fetch).not.toHaveBeenCalled();
});

it('rejects a non-integer nano-USD balance', async () => {
mocks.fetch.mockResolvedValue(
Response.json({ ...summary, availableAmount: '12.50' }, { status: 200 }),
);
const { getCloudBillingSummary } = await import('../cloud-auth/billing');

await expect(getCloudBillingSummary()).rejects.toThrow();
});
});
33 changes: 33 additions & 0 deletions apps/desktop/src/main/cloud-auth/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CloudBillingSummary } from '@linkcode/workbench';
import { ipcMain } from 'electron';
import { z } from 'zod';
import { CLOUD_GET_BILLING_SUMMARY_CHANNEL } from '../../shared/cloud';
import { authClient, CLOUD_API_URL } from './client';

const billingSummarySchema = z.object({
denomination: z.literal('nano_usd'),
availableAmount: z.string().regex(/^-?\d+$/),
reservedAmount: z.string().regex(/^\d+$/),
displayBalance: z.object({
currency: z.literal('USD'),
amount: z.string(),
}),
});

export async function getCloudBillingSummary(): Promise<CloudBillingSummary | null> {
const session = await authClient.getSession();
if (session.error) throw new Error(session.error.message);
const organizationId = session.data?.session.activeOrganizationId;
if (!organizationId) return null;

const res = await fetch(
`${CLOUD_API_URL}/organizations/${encodeURIComponent(organizationId)}/billing/summary`,
{ headers: { cookie: authClient.getCookie() } },
);
if (!res.ok) throw new Error(`getCloudBillingSummary: ${res.status} ${res.statusText}`);
return billingSummarySchema.parse(await res.json());
}

export function registerCloudBillingBridge(): void {
ipcMain.handle(CLOUD_GET_BILLING_SUMMARY_CHANNEL, () => getCloudBillingSummary());
}
4 changes: 3 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Sentry from '@sentry/electron/main';
import { app, BrowserWindow, dialog, Menu } from 'electron';
import { DESKTOP_SPAN_NAMES, DESKTOP_TRANSACTION_NAMES } from '../sentry-privacy';
import { applyThemePreference } from './appearance';
import { registerCloudBillingBridge } from './cloud-auth/billing';
import { authClient, setupCloudAuth } from './cloud-auth/client';
import { registerCloudImBridge } from './cloud-auth/im';
import { registerCloudTunnelBridge } from './cloud-auth/tunnel';
Expand Down Expand Up @@ -62,8 +63,9 @@ if (app.requestSingleInstanceLock()) {
// Wire the LinkCode Cloud auth protocol + IPC bridges. Must run BEFORE app is ready: the plugin
// registers a privileged scheme via protocol.registerSchemesAsPrivileged, which throws once ready.
setupCloudAuth();
// Cloud data bridges (online hosts, IM Channel). Not scheme-related, but registered here
// Cloud data bridges (billing, online hosts, IM Channel). Not scheme-related, but registered here
// alongside the rest of the cloud wiring; ipcMain.handle is safe before the app is ready.
registerCloudBillingBridge();
registerCloudTunnelBridge();
registerCloudImBridge();

Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { contextBridge, ipcRenderer } from 'electron';
import {
CLOUD_CLAIM_DEEP_LINK_CHANNEL,
CLOUD_CREATE_GATEWAY_KEY_CHANNEL,
CLOUD_GET_BILLING_SUMMARY_CHANNEL,
CLOUD_IM_BINDINGS_CHANNEL,
CLOUD_IM_CREATE_BINDING_CHANNEL,
CLOUD_IM_DELETE_BINDING_CHANNEL,
Expand Down Expand Up @@ -54,10 +55,11 @@ contextBridge.exposeInMainWorld('linkcodeConfig', configBridge);
// coexists with the bridge above.
setupRenderer();

// Cloud data bridge: the renderer lists the account's online hosts through main (which holds the
// keychain session). Kept off the SystemBridge — it's cloud-account data, not a window/OS capability.
// Cloud data bridge: main holds the keychain session for these account-scoped requests. Kept off
// the SystemBridge — this is Cloud account data, not a window/OS capability.
contextBridge.exposeInMainWorld('linkcodeCloud', {
listHosts: () => ipcRenderer.invoke(CLOUD_LIST_HOSTS_CHANNEL),
billingSummary: () => ipcRenderer.invoke(CLOUD_GET_BILLING_SUMMARY_CHANNEL),
claimDeepLink: () => ipcRenderer.invoke(CLOUD_CLAIM_DEEP_LINK_CHANNEL),
openHostedBilling: () => ipcRenderer.invoke(CLOUD_OPEN_HOSTED_BILLING_CHANNEL),
createGatewayKey: (name: string) => ipcRenderer.invoke(CLOUD_CREATE_GATEWAY_KEY_CHANNEL, name),
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/renderer/src/cloud-auth/bridges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* process's inferred types across the process boundary, so the vendor's stable shape is mirrored here.
*/

import type { CloudHost, CloudImSource } from '@linkcode/workbench';
import type { CloudBillingSummary, CloudHost, CloudImSource } from '@linkcode/workbench';
import { traceRendererIpc } from '../ipc';

/** The authenticated user, as normalized by the electron plugin. Extra IdP fields are preserved. */
Expand All @@ -31,6 +31,8 @@ export interface CloudDataBridges {
linkcodeCloud: {
/** Lists the signed-in account's online hosts; main attaches the session and validates. */
listHosts: () => Promise<CloudHost[]>;
/** Reads the active organization's balance; null means the session has no active organization. */
billingSummary: () => Promise<CloudBillingSummary | null>;
/**
* Re-asserts this app as the scheme's OS default so the OAuth callback routes back here;
* called right before a sign-in. Resolves to whether the OS accepted it.
Expand All @@ -50,6 +52,8 @@ const cloudSource = window.linkcodeCloud;
/** First-party cloud IPC with fixed span names and no payload/result attributes. */
export const cloudDataBridge: CloudDataBridges['linkcodeCloud'] = {
listHosts: () => traceRendererIpc('cloud.list-hosts', () => cloudSource.listHosts()),
billingSummary: () =>
traceRendererIpc('cloud.get-billing-summary', () => cloudSource.billingSummary()),
claimDeepLink: () => traceRendererIpc('cloud.claim-deep-link', () => cloudSource.claimDeepLink()),
openHostedBilling: () =>
traceRendererIpc('cloud.open-hosted-billing', () => cloudSource.openHostedBilling()),
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/src/cloud-auth/use-cloud-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function openAccountCenter(): void {

export interface CloudAccountView {
account: CloudAccount | null;
loaded: boolean;
authenticating: boolean;
signIn: () => void;
signOut: () => void;
Expand All @@ -32,9 +33,10 @@ export interface CloudAccountView {
* issuing a throttled bust token to re-request the same URL.
*/
export function useCloudAccount(): CloudAccountView {
const { user, authenticating, signIn, signOut } = useCloudAuthStore(
const { user, loaded, authenticating, signIn, signOut } = useCloudAuthStore(
useShallow((state) => ({
user: state.user,
loaded: state.loaded,
authenticating: state.authenticating,
signIn: state.signIn,
signOut: state.signOut,
Expand All @@ -54,7 +56,7 @@ export function useCloudAccount(): CloudAccountView {
? { name: user.name, email: user.email, image: bustAvatar(user.image, avatarBust) }
: null;

return { account, authenticating, signIn, signOut, manageAccount: openAccountCenter };
return { account, loaded, authenticating, signIn, signOut, manageAccount: openAccountCenter };
}

/** Appends a focus-scoped cache-bust token to the stable avatar URL so a new avatar re-renders. */
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/renderer/src/settings/billing-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import type { BillingBalanceView } from '@linkcode/ui';
import { BillingSettingsPanel } from '@linkcode/ui';
import { useCloudBillingSummary } from '@linkcode/workbench';
import { cloudDataBridge } from '../cloud-auth/bridges';
import { useCloudAccount } from '../cloud-auth/use-cloud-account';

const getBillingSummary = () => cloudDataBridge.billingSummary();

export function BillingTab(): React.ReactNode {
const cloud = useCloudAccount();
const summary = useCloudBillingSummary(cloud.account?.email, getBillingSummary);
let balance: BillingBalanceView;
if (!cloud.loaded) balance = { status: 'loading' };
else if (!cloud.account) balance = { status: 'signed-out' };
else if (summary.data === undefined) {
balance = { status: summary.error === undefined ? 'loading' : 'error' };
} else if (summary.data === null) balance = { status: 'missing-organization' };
else {
balance = {
status: 'ready',
amount: summary.data.displayBalance.amount,
currency: summary.data.displayBalance.currency,
};
}

return (
<BillingSettingsPanel
balance={balance}
onSignIn={cloud.signIn}
onOpenBilling={() => {
void cloudDataBridge.openHostedBilling();
}}
Expand Down
16 changes: 8 additions & 8 deletions apps/desktop/src/renderer/src/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,14 @@ export function SettingsView(): React.ReactNode {
active: category === 'notifications',
onClick: () => setCategory('notifications'),
},
{
key: 'billing',
icon: <CreditCardIcon className="size-4" />,
label: t('tabs.billing'),
keywords: searchKeywords.billing,
active: category === 'billing',
onClick: () => setCategory('billing'),
},
],
},
{
Expand All @@ -151,14 +159,6 @@ export function SettingsView(): React.ReactNode {
active: category === 'providers',
onClick: () => setCategory('providers'),
},
{
key: 'billing',
icon: <CreditCardIcon className="size-4" />,
label: t('tabs.billing'),
keywords: searchKeywords.billing,
active: category === 'billing',
onClick: () => setCategory('billing'),
},
{
key: 'plugins',
icon: <PuzzleIcon className="size-4" />,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/shared/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
*/
export const CLOUD_LIST_HOSTS_CHANNEL = 'linkcode.cloud.list-hosts';

// Reads the active Cloud organization's credits summary in main, where the session is held.
export const CLOUD_GET_BILLING_SUMMARY_CHANNEL = 'linkcode.cloud.get-billing-summary';

// Re-asserts this app as the OS default handler for the channel's `linkcode(-dev)://` scheme; the
// renderer invokes it right before a sign-in so the OAuth deep-link callback comes back here.
export const CLOUD_CLAIM_DEEP_LINK_CHANNEL = 'linkcode.cloud.claim-deep-link';
Expand Down
12 changes: 10 additions & 2 deletions apps/webview/e2e/browser-smoke.e2e.mts
Original file line number Diff line number Diff line change
Expand Up @@ -352,15 +352,23 @@ async function verifyProductionEntry(browser: Browser): Promise<void> {
},
{ daemonUrl: daemon.origin },
);
await context.route('**/auth/get-session', async (route) => {
await route.fulfill({ body: 'null', contentType: 'application/json', status: 200 });
});
const page = await context.newPage();
monitorApplicationErrors(page, server.origin, appErrors);
await page.goto(server.origin, { waitUntil: 'domcontentloaded' });
await page.locator('#root > *').waitFor();
await page.getByRole('link', { name: 'Open settings' }).click();
await page.waitForURL(`${server.origin}/settings`);
await page.goto(`${server.origin}/settings/billing`, { waitUntil: 'domcontentloaded' });
await page.getByText('LinkCode does not read or process billing or checkout data.').waitFor();
await page.getByRole('button', { name: 'Manage on the web' }).waitFor();
await page
.getByText(
'To manage top-ups, orders, subscriptions, and checkout, use LinkCode Cloud on the web.',
)
.waitFor();
await page.getByText('Sign in to LinkCode Cloud to view your balance.').waitFor();
await page.getByRole('button', { name: 'Sign in to LinkCode Cloud' }).waitFor();
await page.getByRole('link', { name: 'Back' }).waitFor();
await page.getByRole('link', { name: 'Back' }).click();
await page.waitForURL(`${server.origin}/`);
Expand Down
48 changes: 48 additions & 0 deletions apps/webview/src/cloud/__tests__/billing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));

vi.mock('../auth', () => ({ CLOUD_API_URL: 'https://api.linkcode.test' }));

import { fetchCloudBillingSummary } from '../billing';

afterEach(() => {
vi.unstubAllGlobals();
mocks.fetch.mockReset();
});

describe('browser Cloud billing source', () => {
it('fetches and validates the organization balance with the session cookie', async () => {
const summary = {
denomination: 'nano_usd',
availableAmount: '-500000000',
reservedAmount: '0',
displayBalance: { currency: 'USD', amount: '-0.50' },
};
mocks.fetch.mockResolvedValue(Response.json(summary, { status: 200 }));
vi.stubGlobal('fetch', mocks.fetch);

await expect(fetchCloudBillingSummary('org/1')).resolves.toEqual(summary);
expect(mocks.fetch).toHaveBeenCalledWith(
'https://api.linkcode.test/organizations/org%2F1/billing/summary',
{ credentials: 'include' },
);
});

it('rejects a malformed raw balance', async () => {
mocks.fetch.mockResolvedValue(
Response.json(
{
denomination: 'nano_usd',
availableAmount: 12_500_000_000,
reservedAmount: '0',
displayBalance: { currency: 'USD', amount: '12.50' },
},
{ status: 200 },
),
);
vi.stubGlobal('fetch', mocks.fetch);

await expect(fetchCloudBillingSummary('org_1')).rejects.toThrow();
});
});
2 changes: 2 additions & 0 deletions apps/webview/src/cloud/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { organizationClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';

/**
Expand All @@ -13,6 +14,7 @@ export const authClient = createAuthClient({
// The API mounts better-auth at /auth, not the client default /api/auth.
basePath: '/auth',
fetchOptions: { credentials: 'include' },
plugins: [organizationClient()],
});

/**
Expand Down
14 changes: 14 additions & 0 deletions apps/webview/src/cloud/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { CloudBillingSummary } from '@linkcode/workbench';
import { CloudBillingSummarySchema } from '@linkcode/workbench';
import { CLOUD_API_URL } from './auth';

export async function fetchCloudBillingSummary(
organizationId: string,
): Promise<CloudBillingSummary> {
const res = await fetch(
`${CLOUD_API_URL}/organizations/${encodeURIComponent(organizationId)}/billing/summary`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error(`fetchCloudBillingSummary: ${res.status} ${res.statusText}`);
return CloudBillingSummarySchema.parse(await res.json());
}
Loading
Loading