Skip to content

Commit 3e29802

Browse files
ralyodioclaude
andcommitted
Add CoinPay OAuth + x402 payments, and BYO-SQLite storage config
- packages/payments: x402 protocol primitives, CoinPay wallet contract, PaymentProcessor (network/asset match + maxAmount guard); 6 tests - auth: CoinPay OAuth provider (Auth Code + PKCE), hosted defaults + self-hosted overrides, x402 scopes; 4 tests - storage: resolveStorageConfig — managed Turso cloud (backups) by default, or bring-your-own SQLite (local file / libSQL replica / own server, self-hosted, user-managed backups); 6 tests - .env.example documents DB + CoinPay config; real .env gitignored - PRD: Payments + Storage sections, payments package added Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1c0f5d6 commit 3e29802

20 files changed

Lines changed: 807 additions & 27 deletions

.env.example

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copy to .env and fill in. .env is gitignored.
2+
#
3+
# --- Storage ---------------------------------------------------------------
4+
# Option A: managed cloud database (Turso) — has managed backups.
5+
TRONBROWSER_DB_URL=libsql://your-db.turso.io
6+
TRONBROWSER_DB_AUTH_TOKEN=your-turso-token
7+
#
8+
# Option B: bring your own SQLite — you own your data (and your backups).
9+
# Local file:
10+
# TRONBROWSER_DB_PATH=/home/you/.tronbrowser/db.sqlite
11+
# Or your own libSQL server:
12+
# TRONBROWSER_DB_URL=libsql://db.your-server.example
13+
# TRONBROWSER_DB_AUTH_TOKEN=your-token
14+
#
15+
# --- CoinPay OAuth (x402 payments) ----------------------------------------
16+
COINPAY_CLIENT_ID=
17+
COINPAY_REDIRECT_URI=tronbrowser://oauth/coinpay
18+
# Override only for self-hosted CoinPay:
19+
# COINPAY_AUTHORIZE_URL=https://coinpay.profullstack.com/oauth/authorize
20+
# COINPAY_TOKEN_URL=https://coinpay.profullstack.com/oauth/token

docs/tronbrowser-prd.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ packages/
5151
- storage
5252
- sdk
5353
- plugins
54+
- payments
5455
- ui
5556
- shared
5657

@@ -122,6 +123,32 @@ Lifecycle:
122123
- update
123124
- uninstall
124125

126+
# Payments
127+
128+
CoinPay OAuth + x402.
129+
130+
Auth:
131+
- CoinPay OAuth (Authorization Code + PKCE)
132+
- Scopes: wallet:read, payments:x402
133+
- Self-hosted CoinPay overridable
134+
135+
x402 (HTTP 402 Payment Required):
136+
- Parse payment requirements from 402 responses
137+
- Pay from the user's CoinPay global wallet addresses (match network + asset)
138+
- Custodial keys (never leave CoinPay)
139+
- Per-request maxAmount guard; budgets/ledger via agent runtime
140+
141+
Package: packages/payments (x402 + CoinPay wallet + PaymentProcessor)
142+
143+
# Storage
144+
145+
SQLite/libSQL, self-hostable.
146+
147+
- Default: managed cloud DB (Turso) — managed backups + replication
148+
- Bring your own: local SQLite file, local libSQL replica, or your own libSQL server (self-hosted, user-owned, user-managed backups)
149+
- Object storage: Cloudflare R2
150+
- Config via TRONBROWSER_DB_URL / TRONBROWSER_DB_AUTH_TOKEN / TRONBROWSER_DB_PATH
151+
125152
# Sync
126153

127154
Objects:

packages/auth/README.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
# @tronbrowser/auth
22

3-
Authentication and session primitives
3+
Authentication and session primitives, including **CoinPay OAuth** sign-in.
44

5-
> Status: **stub** — interfaces defined, implementation pending. Part of milestone **M0 (Monorepo)**.
5+
CoinPay OAuth lets a user connect their CoinPay account so TronBrowser can
6+
authorize x402 payments (see [`@tronbrowser/payments`](../payments)) from their
7+
CoinPay global wallet addresses. Keys stay custodial in CoinPay.
68

7-
## Install
9+
```ts
10+
import { CoinPayOAuthProvider } from '@tronbrowser/auth';
811

9-
```bash
10-
pnpm add @tronbrowser/auth
12+
const provider = new CoinPayOAuthProvider({
13+
clientId: process.env.COINPAY_CLIENT_ID!,
14+
redirectUri: 'tronbrowser://oauth/coinpay',
15+
// authorizeUrl/tokenUrl default to hosted CoinPay; override for self-hosted.
16+
});
17+
const url = provider.authorizeUrl(state, codeChallenge); // PKCE
1118
```
1219

13-
## Scripts
20+
## Modules
1421

15-
- `pnpm build` — compile TypeScript to `dist/`
16-
- `pnpm typecheck` — type-check without emitting
17-
- `pnpm test` — run unit tests (vitest)
22+
- `oauth.ts` — generic OAuth 2.0 (Authorization Code + PKCE) contracts
23+
- `coinpay-oauth.ts``CoinPayOAuthProvider`, defaults, self-hosted overrides
1824

19-
See the [PRD](../../docs/tronbrowser-prd.md) for the overall architecture.
25+
Scopes requested: `wallet:read`, `payments:x402`. Token exchange/refresh land in M2.
26+
27+
See the [PRD](../../docs/tronbrowser-prd.md) §Payments.

packages/auth/src/coinpay-oauth.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* CoinPay OAuth provider. Lets a user sign into TronBrowser with their CoinPay
3+
* account so the browser can authorize x402 payments (see @tronbrowser/payments)
4+
* from their CoinPay global wallet addresses.
5+
*
6+
* Endpoints default to CoinPay's hosted service and are overridable for
7+
* self-hosted CoinPay deployments.
8+
*/
9+
10+
import {
11+
isExpired,
12+
type OAuthConfig,
13+
type OAuthProvider,
14+
type OAuthTokens,
15+
} from './oauth.js';
16+
17+
export const COINPAY_DEFAULTS = {
18+
authorizeUrl: 'https://coinpay.profullstack.com/oauth/authorize',
19+
tokenUrl: 'https://coinpay.profullstack.com/oauth/token',
20+
/** Scopes needed to read wallet addresses and authorize x402 payments. */
21+
scopes: ['wallet:read', 'payments:x402'],
22+
} as const;
23+
24+
export interface CoinPayOAuthConfig {
25+
clientId: string;
26+
redirectUri: string;
27+
/** Override for self-hosted CoinPay; defaults to the hosted service. */
28+
authorizeUrl?: string;
29+
tokenUrl?: string;
30+
scopes?: string[];
31+
}
32+
33+
/** Resolves user-supplied config against CoinPay defaults. */
34+
export function resolveCoinPayConfig(cfg: CoinPayOAuthConfig): OAuthConfig {
35+
return {
36+
clientId: cfg.clientId,
37+
redirectUri: cfg.redirectUri,
38+
authorizeUrl: cfg.authorizeUrl ?? COINPAY_DEFAULTS.authorizeUrl,
39+
tokenUrl: cfg.tokenUrl ?? COINPAY_DEFAULTS.tokenUrl,
40+
scopes: cfg.scopes ?? [...COINPAY_DEFAULTS.scopes],
41+
};
42+
}
43+
44+
/**
45+
* CoinPay OAuth provider (Authorization Code + PKCE). Network calls are stubbed
46+
* until M2; URL construction is implemented and tested now.
47+
*/
48+
export class CoinPayOAuthProvider implements OAuthProvider {
49+
readonly config: OAuthConfig;
50+
51+
constructor(config: CoinPayOAuthConfig) {
52+
this.config = resolveCoinPayConfig(config);
53+
}
54+
55+
authorizeUrl(state: string, codeChallenge: string): string {
56+
const u = new URL(this.config.authorizeUrl);
57+
u.searchParams.set('response_type', 'code');
58+
u.searchParams.set('client_id', this.config.clientId);
59+
u.searchParams.set('redirect_uri', this.config.redirectUri);
60+
u.searchParams.set('scope', this.config.scopes.join(' '));
61+
u.searchParams.set('state', state);
62+
u.searchParams.set('code_challenge', codeChallenge);
63+
u.searchParams.set('code_challenge_method', 'S256');
64+
return u.toString();
65+
}
66+
67+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
68+
async exchangeCode(_code: string, _codeVerifier: string): Promise<OAuthTokens> {
69+
throw new Error('CoinPayOAuthProvider.exchangeCode: not implemented (M2)');
70+
}
71+
72+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
73+
async refresh(_refreshToken: string): Promise<OAuthTokens> {
74+
throw new Error('CoinPayOAuthProvider.refresh: not implemented (M2)');
75+
}
76+
}
77+
78+
export { isExpired };

packages/auth/src/index.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
CoinPayOAuthProvider,
4+
resolveCoinPayConfig,
5+
COINPAY_DEFAULTS,
6+
isExpired,
7+
} from './index.js';
8+
9+
describe('CoinPay OAuth', () => {
10+
it('defaults to the hosted CoinPay endpoints and x402 scopes', () => {
11+
const cfg = resolveCoinPayConfig({ clientId: 'abc', redirectUri: 'tronbrowser://cb' });
12+
expect(cfg.authorizeUrl).toBe(COINPAY_DEFAULTS.authorizeUrl);
13+
expect(cfg.scopes).toContain('payments:x402');
14+
});
15+
16+
it('allows self-hosted CoinPay overrides', () => {
17+
const cfg = resolveCoinPayConfig({
18+
clientId: 'abc',
19+
redirectUri: 'tronbrowser://cb',
20+
authorizeUrl: 'https://pay.example.com/oauth/authorize',
21+
});
22+
expect(cfg.authorizeUrl).toBe('https://pay.example.com/oauth/authorize');
23+
});
24+
25+
it('builds a PKCE authorize URL', () => {
26+
const provider = new CoinPayOAuthProvider({
27+
clientId: 'abc',
28+
redirectUri: 'tronbrowser://cb',
29+
});
30+
const url = new URL(provider.authorizeUrl('state123', 'challenge456'));
31+
expect(url.searchParams.get('response_type')).toBe('code');
32+
expect(url.searchParams.get('client_id')).toBe('abc');
33+
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
34+
expect(url.searchParams.get('state')).toBe('state123');
35+
});
36+
37+
it('detects expiry with skew', () => {
38+
const tokens = { accessToken: 'x', expiresAt: 1000, tokenType: 'Bearer' };
39+
expect(isExpired(tokens, 980)).toBe(true); // within 30s skew
40+
expect(isExpired(tokens, 900)).toBe(false);
41+
});
42+
});

packages/auth/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/**
22
* @tronbrowser/auth
3-
* Authentication and session primitives
4-
*
5-
* Stub — see ../../docs/tronbrowser-prd.md
3+
* Authentication and session primitives, including CoinPay OAuth.
64
*/
5+
76
export const PACKAGE_NAME = '@tronbrowser/auth' as const;
7+
8+
export * from './oauth.js';
9+
export * from './coinpay-oauth.js';

packages/auth/src/oauth.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Generic OAuth 2.0 (Authorization Code + PKCE) contracts shared by providers.
3+
*/
4+
5+
export interface OAuthConfig {
6+
clientId: string;
7+
/** Where the provider redirects after consent. */
8+
redirectUri: string;
9+
authorizeUrl: string;
10+
tokenUrl: string;
11+
scopes: string[];
12+
}
13+
14+
export interface OAuthTokens {
15+
accessToken: string;
16+
refreshToken?: string;
17+
/** Unix seconds at which accessToken expires. */
18+
expiresAt: number;
19+
tokenType: string;
20+
scope?: string;
21+
}
22+
23+
/** Authorization-code provider with PKCE. */
24+
export interface OAuthProvider {
25+
/** Builds the URL to send the user to for consent. */
26+
authorizeUrl(state: string, codeChallenge: string): string;
27+
/** Exchanges an authorization code for tokens. */
28+
exchangeCode(code: string, codeVerifier: string): Promise<OAuthTokens>;
29+
/** Refreshes an expired access token. */
30+
refresh(refreshToken: string): Promise<OAuthTokens>;
31+
}
32+
33+
/** True when the token is expired or within `skewSeconds` of expiring. */
34+
export function isExpired(tokens: OAuthTokens, nowSeconds: number, skewSeconds = 30): boolean {
35+
return nowSeconds >= tokens.expiresAt - skewSeconds;
36+
}

packages/payments/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# @tronbrowser/payments
2+
3+
x402 (HTTP 402 "Payment Required") payment processing over the user's **CoinPay**
4+
global wallet addresses.
5+
6+
> Status: **stub** — interfaces + pure logic defined, network calls pending.
7+
> Added alongside M2 (AI sidebar) work; depends conceptually on CoinPay OAuth in
8+
> [`@tronbrowser/auth`](../auth).
9+
10+
## How it works
11+
12+
1. A request hits a resource that responds `402` with payment requirements.
13+
2. `PaymentProcessor` parses the requirements and picks one of the user's CoinPay
14+
wallet addresses matching the required **network + asset**.
15+
3. CoinPay (custodial — keys never leave CoinPay) authorizes/signs the payment.
16+
4. The processor returns the `X-PAYMENT` header to retry the original request.
17+
18+
```ts
19+
import { PaymentProcessor } from '@tronbrowser/payments';
20+
21+
const processor = new PaymentProcessor(coinPayWallet);
22+
const { headers } = await processor.process(await res.json(), { maxAmount: 1_000_000n });
23+
const paid = await fetch(url, { headers: { ...headers } });
24+
```
25+
26+
## Modules
27+
28+
| File | Contents |
29+
| --- | --- |
30+
| `x402.ts` | Protocol primitives: `parsePaymentRequired`, `encode/decodePaymentHeader`, header constants |
31+
| `coinpay.ts` | `CoinPayWallet` contract, `CoinPayAddress`, `selectAddress()` |
32+
| `processor.ts` | `PaymentProcessor` — one payment end to end, with an optional `maxAmount` guard |
33+
34+
## Privacy & safety
35+
36+
- Keys are custodial in CoinPay; TronBrowser only holds a short-lived OAuth token.
37+
- `maxAmount` caps per-request spend; agent budgets/ledger live in the agent runtime.
38+
- Nothing is paid automatically without a wallet that matches network + asset.
39+
40+
See the [PRD](../../docs/tronbrowser-prd.md) §Payments.

packages/payments/package.json

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@tronbrowser/payments",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "x402 payment processing over the user's CoinPay global wallet addresses",
6+
"type": "module",
7+
"main": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsc -p tsconfig.json",
17+
"typecheck": "tsc -p tsconfig.json --noEmit",
18+
"test": "vitest run --passWithNoTests",
19+
"lint": "echo \"[lint] payments: stub\""
20+
},
21+
"devDependencies": {
22+
"typescript": "^5.6.3",
23+
"vitest": "^2.1.4"
24+
}
25+
}

packages/payments/src/coinpay.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* CoinPay wallet integration for x402.
3+
*
4+
* The user authenticates with CoinPay (OAuth — see `@tronbrowser/auth`) and
5+
* TronBrowser holds a short-lived access token. CoinPay exposes the user's
6+
* "global wallet addresses" (one or more per network) and authorizes payments
7+
* against them. Keys stay custodial in CoinPay; TronBrowser never sees them.
8+
*/
9+
10+
import type { PaymentRequirements, PaymentPayload } from './x402.js';
11+
12+
/** Minimal token source; satisfied by `@tronbrowser/auth` without coupling. */
13+
export interface AccessTokenProvider {
14+
getAccessToken(): Promise<string>;
15+
}
16+
17+
/** One of the user's CoinPay global wallet addresses. */
18+
export interface CoinPayAddress {
19+
/** Network id matching x402 PaymentRequirements.network. */
20+
network: string;
21+
address: string;
22+
/** Asset symbols this address can spend, e.g. ["USDC", "ETH"]. */
23+
assets: string[];
24+
}
25+
26+
/** Result of asking CoinPay to authorize a payment for an x402 requirement. */
27+
export interface CoinPayAuthorization {
28+
payload: PaymentPayload;
29+
/** CoinPay's internal reference for the authorization. */
30+
reference: string;
31+
}
32+
33+
/** Client over the CoinPay wallet API. Implementation lands post-stub. */
34+
export interface CoinPayWallet {
35+
/** Lists the user's global wallet addresses across networks. */
36+
listAddresses(): Promise<CoinPayAddress[]>;
37+
/** Atomic-unit balance of `asset` on `network`. */
38+
getBalance(network: string, asset: string): Promise<string>;
39+
/** Asks CoinPay to sign/authorize a payment satisfying `req`. */
40+
authorize(req: PaymentRequirements, from: CoinPayAddress): Promise<CoinPayAuthorization>;
41+
}
42+
43+
/**
44+
* Picks the user's wallet address that can satisfy a payment requirement
45+
* (matching network + asset). Returns undefined when none qualifies. Pure.
46+
*/
47+
export function selectAddress(
48+
addresses: CoinPayAddress[],
49+
req: Pick<PaymentRequirements, 'network' | 'asset'>,
50+
): CoinPayAddress | undefined {
51+
return addresses.find(
52+
(a) => a.network === req.network && a.assets.includes(req.asset),
53+
);
54+
}

0 commit comments

Comments
 (0)