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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ node_modules/

# Build output
dist/
.next/
next-env.d.ts

# Environment
.env
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dependencies": {
"@antseed/api-adapter": "workspace:*",
"@antseed/ant-agent": "workspace:*",
"@antseed/connect-core": "workspace:*",
"@antseed/payments": "workspace:*",
"@antseed/node": "workspace:*",
"@antseed/provider-core": "workspace:*",
Expand All @@ -37,6 +38,7 @@
"ora": "^9.3.0"
},
"devDependencies": {
"@antseed/service-auto-deposit": "workspace:*",
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
Expand Down
118 changes: 118 additions & 0 deletions apps/cli/src/cli/commands/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Command } from 'commander';
import chalk from 'chalk';
import open from 'open';
import { createInterface } from 'node:readline/promises';
import {
parseRequestLink,
resolveScopeValues,
signConnectResponse,
parseManifest,
SCOPES,
type ConnectManifest,
type AutoDepositState,
} from '@antseed/connect-core';
import { getGlobalOptions } from './types.js';
import { loadCryptoContext, requireCryptoConfig } from '../payment-utils.js';
import { loadConfig } from '../../config/loader.js';
import { loadAutoDepositConnectStateReader } from '../../plugins/service-state.js';

const MANIFEST_TIMEOUT_MS = 1500;

/** Resolve the auto-deposit status to share over Connect: consent from config,
* delegation read on-chain. Best-effort: any failure reports off + undelegated. */
async function autoDepositStateForConnect(configPath: string, address: string): Promise<AutoDepositState> {
let enabled = false;
try {
const config = await loadConfig(configPath);
enabled = config.buyer?.services?.['auto-deposit']?.enabled ?? false;
const crypto = requireCryptoConfig(config);
const readAutoDepositConnectState = await loadAutoDepositConnectStateReader();
if (!readAutoDepositConnectState) {
return { enabled, delegated: false };
}
return await readAutoDepositConnectState({
chainId: crypto.chainId,
rpcUrl: crypto.rpcUrl,
address,
enabled,
});
} catch {
return { enabled, delegated: false };
}
}

/** Best-effort, display-only manifest fetch. Never a security input. */
async function fetchManifest(origin: string): Promise<ConnectManifest | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS);
try {
const res = await fetch(`${origin}/.well-known/antseed-connect.json`, {
signal: controller.signal,
redirect: 'error',
});
if (!res.ok) return null;
return parseManifest(await res.text(), origin);
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

export function registerConnectCommand(program: Command): void {
program
.command('connect')
.description('Respond to an AntSeed Connect request link, sharing signed account info on consent')
.argument('<link>', 'the antseed://connect (or https) request link')
.option('--yes', 'skip the consent prompt (non-interactive approval)', false)
.option('--print', 'print the redirect URL instead of opening a browser', false)
.action(async (link: string, options: { yes: boolean; print: boolean }) => {
try {
const globalOpts = getGlobalOptions(program);

const request = parseRequestLink(link);
const { wallet } = await loadCryptoContext(globalOpts.dataDir);
const account = request.scopes.includes('auto-deposit')
? { address: wallet.address, autoDeposit: await autoDepositStateForConnect(globalOpts.config, wallet.address) }
: wallet;
const values = resolveScopeValues(request, account);

const manifest = await fetchManifest(request.origin);

console.log('');
if (manifest) {
console.log(`${chalk.bold('App:')} ${manifest.name}`);
}
console.log(`${chalk.bold('Origin:')} ${chalk.cyan(request.origin)}`);
console.log(`${chalk.bold('Request:')} Share the following with this app:`);
for (const scope of request.scopes) {
const def = SCOPES[scope];
console.log(` ${chalk.bold(def.label)}: ${values[scope]}`);
console.log(` ${chalk.dim(def.description)}`);
}
console.log('');

if (!options.yes) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question('Approve? [y/N]: ');
rl.close();
const normalized = answer.trim().toLowerCase();
if (normalized !== 'y' && normalized !== 'yes') {
console.log(chalk.yellow('Declined. Nothing shared.'));
return;
}
}

const { fragmentUrl } = await signConnectResponse(wallet, request, values);

console.log(chalk.green('Approved. Delivering signed response to:'));
console.log(fragmentUrl);
if (!options.print) {
await open(fragmentUrl);
}
} catch (err) {
console.error(chalk.red(`Error: ${(err as Error).message}`));
process.exitCode = 1;
}
});
}
2 changes: 2 additions & 0 deletions apps/cli/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { registerDevCommand } from './commands/dev.js';
import { registerPaymentsCommand } from './commands/payments.js';
import { registerMetricsCommand } from './commands/metrics.js';
import { registerWrappedToolCommands } from './commands/wrapped-tools.js';
import { registerConnectCommand } from './commands/connect.js';

loadEnvFromFiles();

Expand All @@ -38,5 +39,6 @@ registerAgentCommand(program);
registerPaymentsCommand(program);
registerMetricsCommand(program);
registerWrappedToolCommands(program);
registerConnectCommand(program);

program.parse(process.argv);
32 changes: 32 additions & 0 deletions apps/cli/src/plugins/service-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { existsSync } from 'node:fs'
import path, { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { getPluginsDir } from './manager.js'

const AUTO_DEPOSIT_PACKAGE = '@antseed/service-auto-deposit'

type ReadAutoDepositConnectState =
typeof import('@antseed/service-auto-deposit').readAutoDepositConnectState

/**
* Resolve the auto-deposit connect-state reader from the installed plugin the
* same way loader.ts resolves any plugin: from the plugins dir, not a hard
* dependency. Returns null when the optional plugin is absent or fails to load,
* so `antseed connect` keeps working whether or not auto-deposit is installed.
*/
export async function loadAutoDepositConnectStateReader(): Promise<ReadAutoDepositConnectState | null> {
const pluginsDir = getPluginsDir()
const resolved = path.resolve(
join(pluginsDir, 'node_modules', AUTO_DEPOSIT_PACKAGE, 'dist', 'index.js'),
)
if (!resolved.startsWith(path.resolve(pluginsDir))) return null
if (!existsSync(resolved)) return null
try {
const mod = (await import(pathToFileURL(resolved).href)) as {
readAutoDepositConnectState?: ReadAutoDepositConnectState
}
return mod.readAutoDepositConnectState ?? null
} catch {
return null
}
}
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@
},
"dependencies": {
"@antseed/api-adapter": "workspace:*",
"@antseed/connect-core": "workspace:*",
"@antseed/node": "workspace:*",
"@antseed/payments": "workspace:*",
"@antseed/service-auto-deposit": "workspace:*",
"@antseed/ui": "workspace:*",
"@fastify/cors": "^10.0.0",
"@fastify/static": "^9.0.0",
Expand Down
170 changes: 170 additions & 0 deletions apps/desktop/src/main/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// AntSeed Connect deep-link handler, macOS.
// Parsing, manifest fetch, and signing all happen in the main process.
// The renderer only shows the request and returns the approval.

import { app, shell, ipcMain, type BrowserWindow } from 'electron';
import { randomUUID } from 'node:crypto';
import {
parseRequestLink,
resolveScopeValues,
signConnectResponse,
parseManifest,
SCOPES,
type ConnectRequest,
type ScopeAccount,
type AutoDepositState,
} from '@antseed/connect-core';
import type { Identity } from '@antseed/node';

export interface ConnectDeps {
getMainWindow: () => BrowserWindow | null;
ensureWindow: () => void;
ensureIdentity: () => Promise<void>;
getIdentity: () => Identity | null;
getAutoDepositState?: (address: string) => Promise<AutoDepositState | undefined>;
log?: (line: string) => void;
}

const MANIFEST_TIMEOUT_MS = 1500;

interface PendingConnect {
request: ConnectRequest;
values: Record<string, string>;
}

let ready = false;
let pendingUrl: string | null = null;
const pendingRequests = new Map<string, PendingConnect>();

/** Best-effort, display-only manifest fetch. Never a security input. */
async function fetchManifest(origin: string): Promise<{ name: string; icon?: string } | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS);
try {
const res = await fetch(`${origin}/.well-known/antseed-connect.json`, {
signal: controller.signal,
redirect: 'error',
});
if (!res.ok) return null;
const manifest = parseManifest(await res.text(), origin);
return { name: manifest.name, ...(manifest.icon ? { icon: manifest.icon } : {}) };
} catch {
return null;
} finally {
clearTimeout(timer);
}
}

async function handleConnectUrl(url: string, deps: ConnectDeps) {
let request: ConnectRequest;
try {
request = parseRequestLink(url);
} catch (err) {
deps.log?.(`[connect] rejected request link: ${(err as Error).message}`);
return;
}

deps.ensureWindow();
const win = deps.getMainWindow();
if (!win) {
deps.log?.('[connect] no window available; cannot show consent prompt');
return;
}
win.show();
win.focus();

await deps.ensureIdentity();
const identity = deps.getIdentity();
if (!identity) {
deps.log?.('[connect] identity unavailable; cannot answer request');
return;
}

let account: ScopeAccount = identity.wallet;
if (request.scopes.includes('auto-deposit') && deps.getAutoDepositState) {
const autoDeposit = await deps.getAutoDepositState(identity.wallet.address);
if (autoDeposit) account = { address: identity.wallet.address, autoDeposit };
}
const values = resolveScopeValues(request, account);
const manifest = await fetchManifest(request.origin);

const id = randomUUID();
pendingRequests.set(id, { request, values });
// Don't leak the pending entry if the window goes away before the user decides.
win.webContents.once('destroyed', () => pendingRequests.delete(id));

win.webContents.send('connect:request', {
id,
origin: request.origin,
appName: manifest?.name ?? null,
appIcon: manifest?.icon ?? null,
scopes: request.scopes.map((scope) => ({
id: scope,
label: SCOPES[scope].label,
description: SCOPES[scope].description,
value: values[scope],
})),
});
}

async function respondToConnect(
payload: { id: string; approved: boolean },
deps: ConnectDeps,
): Promise<{ ok: boolean; delivered: boolean; error?: string }> {
const pending = pendingRequests.get(payload.id);
if (!pending) {
return { ok: false, delivered: false, error: 'unknown request' };
}
pendingRequests.delete(payload.id);

if (!payload.approved) {
return { ok: true, delivered: false };
}

const identity = deps.getIdentity();
if (!identity) {
return { ok: false, delivered: false, error: 'identity unavailable' };
}

try {
const { fragmentUrl } = await signConnectResponse(identity.wallet, pending.request, pending.values);
await shell.openExternal(fragmentUrl);
return { ok: true, delivered: true };
} catch (err) {
deps.log?.(`[connect] failed to deliver response: ${(err as Error).message}`);
return { ok: false, delivered: false, error: (err as Error).message };
}
}

/**
* Register the deep-link handler. Call at module init (before app ready) so the
* `open-url` listener is in place to catch a cold-start launch. Links that
* arrive before {@link markConnectReady} are buffered.
*/
export function initConnectDeepLink(deps: ConnectDeps): void {
app.setAsDefaultProtocolClient('antseed');

app.on('open-url', (event, url) => {
event.preventDefault();
if (!ready) {
// open-url can fire before the app is ready on macOS cold start; hold it.
pendingUrl = url;
return;
}
void handleConnectUrl(url, deps);
});

ipcMain.handle('connect:respond', (_event, payload: { id: string; approved: boolean }) =>
respondToConnect(payload, deps),
);
}

/** Flush any links that arrived before the window/identity were ready. */
export function markConnectReady(deps: ConnectDeps): void {
ready = true;
if (pendingUrl !== null) {
const url = pendingUrl;
pendingUrl = null;
void handleConnectUrl(url, deps);
}
}
Loading