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
@@ -0,0 +1,35 @@
# Diagnose: Generated Wallet Support Request

> **Status:** Placeholder — not yet actionable
> **Created:** 2026-07-27
> **Part of:** bridgelet-product-audit knowledge-base initiative

## Summary

This runbook covers diagnosis and response for a future support request related to the `generated` wallet type — a Stellar keypair created client-side by `generateNewWallet()` in `frontend/lib/wallet.ts`.

## Current State

`generateNewWallet()` is **currently unused in the UI**. It exists in the codebase as an exported utility but is not wired into any user-facing flow. Therefore, **no real incidents of this type have occurred**, and this runbook has no real incidents to describe at this time.

## Open Questions

The following questions must be answered before this runbook can be meaningfully completed:

| # | Question | Why it matters |
|---|----------|----------------|
| 1 | **Storage location** — If `generateNewWallet()` is wired into the UI, where will the generated secret key be stored (if at all)? Browser memory? `localStorage`? Encrypted export? | Determines the attack surface and recovery path. |
| 2 | **Recovery mechanism** — If the user loses access to the generated key (tab closed, device lost), is there any recovery path? Is the key backed up server-side? | Without recovery, support requests will require fund return or re-issuance. |
| 3 | **Transmission** — Is the secret key ever transmitted off-device? If so, over what channel and with what protections? | Directly impacts whether a leaked key is a user-side or system-side incident. |
| 4 | **User communication** — How is the user instructed to handle the secret key? Is there an onboarding step, a warning, a copy-to-clipboard flow? | Determines whether a support request is likely user error or a product gap. |

## Recommended Next Steps

1. **Revisit this runbook** as a required step when wiring `generateNewWallet()` into the UI for the first time.
2. Complete the open questions table above during the design phase of that work.
3. Add incident response steps (triage, containment, user communication) once the flow is live.

## References

- `frontend/lib/wallet.ts` — `generateNewWallet()` implementation (T-15 in security model)
- `docs/security-model.mdx` — Threat T-15: "Private key leak from generated wallet"
11 changes: 7 additions & 4 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DevToolbar } from '@/components/dev-toolbar';
import { MockProvider } from '@/components/mock-provider';
import { ServiceWorkerRegister } from '@/components/service-worker-register';
import { ThemeProvider } from '@/components/theme-provider';
import { ToastProvider } from '@/components/toast-provider';

export const metadata: Metadata = {
title: 'Bridgelet Payments',
Expand Down Expand Up @@ -44,10 +45,12 @@ export default function RootLayout({ children }: RootLayoutProps) {
</head>
<body>
<ThemeProvider>
<ServiceWorkerRegister />
{children}
{isDev && <DevToolbar />}
{isDev && <MockProvider />}
<ToastProvider>
<ServiceWorkerRegister />
{children}
{isDev && <DevToolbar />}
{isDev && <MockProvider />}
</ToastProvider>
</ThemeProvider>
</body>
</html>
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/page-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import Logo from './logo';
import { SiteFooter } from './site-footer';
import { ThemeToggle } from './theme-toggle';

type PageShellProps = {
Expand Down Expand Up @@ -39,6 +40,7 @@ export function PageShell({ title, description, children, footer }: PageShellPro
</section>
</main>
{footer}
<SiteFooter />
</div>
);
}
13 changes: 13 additions & 0 deletions frontend/components/site-footer.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SiteFooter } from './site-footer';

const meta: Meta<typeof SiteFooter> = {
title: 'Components/SiteFooter',
component: SiteFooter,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof SiteFooter>;

export const Default: Story = {};
56 changes: 56 additions & 0 deletions frontend/components/site-footer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { SiteFooter } from './site-footer';

describe('SiteFooter', () => {
it('renders a footer landmark', () => {
render(<SiteFooter />);
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
});

it('renders the project name', () => {
render(<SiteFooter />);
expect(screen.getByText('bridgelet')).toBeInTheDocument();
});

it('renders the MIT license note', () => {
render(<SiteFooter />);
expect(screen.getByText(/released under the mit license/i)).toBeInTheDocument();
});

it('renders the Stellar ecosystem tagline', () => {
render(<SiteFooter />);
expect(screen.getByText(/built for the stellar ecosystem/i)).toBeInTheDocument();
});

it('renders a GitHub link pointing to the repo', () => {
render(<SiteFooter />);
const link = screen.getByRole('link', { name: /github/i });
expect(link).toHaveAttribute('href', 'https://github.com/bridgelet-org/bridgelet');
expect(link).toHaveAttribute('target', '_blank');
});

it('renders a Docs link', () => {
render(<SiteFooter />);
expect(screen.getByRole('link', { name: /docs/i })).toHaveAttribute('href', '/docs');
});

it('renders a Security Policy link', () => {
render(<SiteFooter />);
const link = screen.getByRole('link', { name: /security policy/i });
expect(link).toHaveAttribute(
'href',
'https://github.com/bridgelet-org/bridgelet/blob/main/SECURITY.md',
);
});

it('renders a Contributing link', () => {
render(<SiteFooter />);
const link = screen.getByRole('link', { name: /contributing/i });
expect(link).toHaveAttribute(
'href',
'https://github.com/bridgelet-org/bridgelet/blob/main/CONTRIBUTING.md',
);
});
});
55 changes: 55 additions & 0 deletions frontend/components/site-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Link from 'next/link';

const REPO_URL = 'https://github.com/bridgelet-org/bridgelet';

export function SiteFooter() {
return (
<footer
role="contentinfo"
className="border-t border-slate-200 bg-white px-6 py-8 dark:border-slate-800 dark:bg-slate-900"
>
<div className="mx-auto flex max-w-3xl flex-col items-center gap-6 text-sm text-slate-600 dark:text-slate-400">
<nav aria-label="Footer links" className="flex flex-wrap justify-center gap-x-6 gap-y-2">
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-slate-900 dark:hover:text-white"
>
GitHub
</a>
<Link
href="/docs"
className="transition hover:text-slate-900 dark:hover:text-white"
>
Docs
</Link>
<a
href={`${REPO_URL}/blob/main/SECURITY.md`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-slate-900 dark:hover:text-white"
>
Security Policy
</a>
<a
href={`${REPO_URL}/blob/main/CONTRIBUTING.md`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-slate-900 dark:hover:text-white"
>
Contributing
</a>
</nav>

<div className="flex flex-col items-center gap-1 text-center">
<span className="font-medium text-slate-900 dark:text-white">bridgelet</span>
<span>Released under the MIT License.</span>
<span className="text-slate-500 dark:text-slate-500">
Built for the Stellar ecosystem.
</span>
</div>
</div>
</footer>
);
}
4 changes: 4 additions & 0 deletions frontend/components/toast-notification.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ export const Error: Story = {
export const Info: Story = {
args: { message: 'Your claim link will expire in 24 hours.', variant: 'info' },
};

export const Warning: Story = {
args: { message: 'Your session will expire in 5 minutes.', variant: 'warning' },
};
6 changes: 4 additions & 2 deletions frontend/components/toast-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from 'react';

export type ToastVariant = 'success' | 'error' | 'info';
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';

type ToastNotificationProps = {
message: string;
Expand All @@ -15,19 +15,21 @@ type ToastNotificationProps = {
const VARIANT_STYLES: Record<ToastVariant, string> = {
success: 'bg-emerald-50 border-emerald-200 text-emerald-900',
error: 'bg-red-50 border-red-200 text-red-900',
warning: 'bg-amber-50 border-amber-200 text-amber-900',
info: 'bg-blue-50 border-blue-200 text-blue-900',
};

const ICONS: Record<ToastVariant, string> = {
success: '✓',
error: '✕',
warning: '!',
info: 'i',
};

export function ToastNotification({
message,
variant = 'info',
duration = 4000,
duration = 5000,
onDismiss,
}: ToastNotificationProps) {
const [visible, setVisible] = useState(true);
Expand Down
32 changes: 32 additions & 0 deletions frontend/components/toast-provider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ToastProvider, useToast } from './toast-provider';
import { ToastNotification } from './toast-notification';

function DemoButtons() {
const { showToast } = useToast();
return (
<div className="flex flex-wrap gap-2">
<button onClick={() => showToast('Payment sent successfully.', 'success')}>Success</button>
<button onClick={() => showToast('Transaction failed.', 'error')}>Error</button>
<button onClick={() => showToast('Claim link expires soon.', 'warning')}>Warning</button>
<button onClick={() => showToast('Processing your request…', 'info')}>Info</button>
</div>
);
}

const meta: Meta<typeof ToastProvider> = {
title: 'Components/ToastProvider',
component: ToastProvider,
tags: ['autodocs'],
};
export default meta;

type Story = StoryObj<typeof ToastProvider>;

export const Default: Story = {
render: () => (
<ToastProvider>
<DemoButtons />
</ToastProvider>
),
};
96 changes: 96 additions & 0 deletions frontend/components/toast-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
import { ToastProvider, useToast } from './toast-provider';

function ToastTrigger() {
const { showToast } = useToast();
return (
<div>
<button onClick={() => showToast('Success message', 'success')}>Show Success</button>
<button onClick={() => showToast('Error message', 'error')}>Show Error</button>
<button onClick={() => showToast('Warning message', 'warning')}>Show Warning</button>
<button onClick={() => showToast('Info message')}>Show Info</button>
</div>
);
}

describe('ToastProvider', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

it('renders children', () => {
render(
<ToastProvider>
<div>Child content</div>
</ToastProvider>,
);
expect(screen.getByText('Child content')).toBeInTheDocument();
});

it('throws when useToast is used outside provider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
function Bad() {
useToast();
return null;
}
expect(() => render(<Bad />)).toThrow('useToast must be used within a ToastProvider');
spy.mockRestore();
});

it('shows a toast when showToast is called', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<ToastProvider>
<ToastTrigger />
</ToastProvider>,
);
await user.click(screen.getByRole('button', { name: /show success/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Success message');
});

it('shows the correct variant styling', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<ToastProvider>
<ToastTrigger />
</ToastProvider>,
);
await user.click(screen.getByRole('button', { name: /show error/i }));
const alert = screen.getByRole('alert');
expect(alert.className).toContain('bg-red-50');
});

it('dismisses a toast manually', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<ToastProvider>
<ToastTrigger />
</ToastProvider>,
);
await user.click(screen.getByRole('button', { name: /show info/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Info message');

await user.click(screen.getByRole('button', { name: /dismiss notification/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});

it('auto-dismisses after 5 seconds', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(
<ToastProvider>
<ToastTrigger />
</ToastProvider>,
);
await user.click(screen.getByRole('button', { name: /show success/i }));
expect(screen.getByRole('alert')).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(5000);
});

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
Loading
Loading