Skill: asgcompute/agentcard (version 0.2.0)

--- name: asgcard description: Virtual MasterCards for AI agents — crypto payments, USDC wallet, create and manage virtual payment cards autonomously via x402 protocol on Stellar blockchain. version: 1.0.8 emoji: 💳 homepage: https://asgcard.dev metadata: openclaw: requires: env: - ASG_CARD_WALLET_SECRET bins: [] primaryEnv: ASG_CARD_WALLET_SECRET os: ["macos", "linux", "windows"] --- # Agent Card — Payment Skill Give your AI agent a virtual MasterCard. Agent Card lets agents autonomously create, fund, and manage virtual MasterCard cards by paying in USDC on the Stellar blockchain. ## What It Does - **Create cards** — Issue virtual MasterCards with per-card spend limits - **Fund cards** — Top up existing cards with USDC - **Manage cards** — List, freeze, unfreeze, and inspect card details - **On-chain payments** — Every transaction uses the x402 protocol on Stellar with verifiable on-chain proof ## Setup ```bash npx @asgcard/cli onboard -y ``` This creates a Stellar wallet (`~/.asgcard/wallet.json`), configures the MCP server, and installs the payment skill. Your agent is ready to pay in under 30 seconds. ## MCP Tools (9 available) | Tool | Description | |------|-------------| | `get_wallet_status` | Wallet address, USDC balance, readiness | | `create_card` | Create virtual MasterCard (x402 payment) | | `fund_card` | Top up existing card | | `list_cards` | List all wallet cards | | `get_card` | Card summary | | `get_card_details` | PAN, CVV, expiry (nonce-protected) | | `freeze_card` | Freeze a card | | `unfreeze_card` | Re-enable a card | | `get_pricing` | Current tier pricing | ## Use Cases - Pay for API credits (Anthropic, OpenAI, Google Cloud) - Provision cloud infrastructure (DigitalOcean, Vercel) - Buy domains, SaaS subscriptions, and developer tools - Any merchant that accepts MasterCard ## Links - [Documentation](https://asgcard.dev/docs) - [npm SDK](https://npmjs.com/package/@asgcard/sdk) - [GitHub](https://github.com/ASGCompute/asgcard-public)

path: CONTRIBUTING.md
content: # Contributing to Agent Card

Thank you for your interest in contributing to Agent Card! 🎉

## Quick Links

- [Issues](https://github.com/ASGCompute/asgcard-public/issues) — Report bugs or request features
- [Docs](https://asgcard.dev/docs) — API documentation
- [Security Policy](SECURITY.md) — Report vulnerabilities responsibly

## How to Contribute

### Reporting Bugs

1. Search [existing issues](https://github.com/ASGCompute/asgcard-public/issues) first
2. Open a new issue using the **Bug Report** template
3. Include reproduction steps, expected vs. actual behavior, and environment details

### Suggesting Features

1. Open an issue using the **Feature Request** template
2. Describe the use case and expected behavior
3. Tag with `enhancement`

### Code Contributions

This is a **public mirror** of an internal monorepo. We accept PRs for:

- Documentation improvements
- Bug fixes with tests
- New MCP tool implementations
- SDK improvements
- CLI enhancements

#### Workflow

1. Fork the repo
2. Create a feature branch: `git checkout -b feat/my-feature`
3. Make your changes
4. Run tests: `npm test`
5. Commit with a clear message: `feat: add card spending limits`
6. Push and open a PR

#### Commit Convention

We use [Conventional Commits](https://www.conventionalcommits.org/):

- `feat:` — New feature
- `fix:` — Bug fix
- `docs:` — Documentation only
- `refactor:` — Code change that neither fixes a bug nor adds a feature
- `test:` — Adding or updating tests
- `chore:` — Maintenance tasks

### Good First Issues

Look for issues labeled [`good first issue`](https://github.com/ASGCompute/asgcard-public/labels/good%20first%20issue) — these are curated for newcomers.

## Development Setup

```bash
git clone https://github.com/ASGCompute/asgcard-public.git
cd asgcard-public
npm install
npm run build
```

## Code of Conduct

Be respectful, constructive, and inclusive. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).

## License

By contributing, you agree that your contributions will be licensed under the MIT License.


path: PUBLIC_REPO_MANIFEST.md
content: # Public Repository Manifest

This repository (`ASGCompute/asgcard-public`) is a **read-only public mirror** of the private operational repository.

## Included

| Directory | Contents |
|---|---|
| `api/src/` | API source (Express, x402 middleware, routes) |
| `api/__tests__/` | Unit and integration tests |
| `api/scripts/` | Automation scripts (preflight, E2E) |
| `sdk/src/` | SDK source (`@asgcard/sdk`, Stellar x402 v2) |
| `cli/src/` | CLI source (`@asgcard/cli`, terminal card management) |
| `mcp-server/src/` | MCP Server source (`@asgcard/mcp-server`, AI agent tool server) |
| `web/src/` | Frontend source (landing, docs page) |
| `web/public/` | Static assets (openapi.json, docs.md, icons) |
| `web/docs/` | Docs page entry |
| `docs/adr/` | Architecture Decision Records |
| `.github/workflows/` | CI (gitleaks, secret scan, content guardrail) |
| Root | README, LICENSE (MIT), CONTRIBUTING, AUDIT, SECURITY |

## Excluded (never published)

| Category | Description |
|---|---|
| **Secrets / env files** | All `.env` variants, API keys, tokens |
| **Debug / test scripts** | One-off E2E scripts, webhook test scripts |
| **Ops reports** | Preflight reports, baseline snapshots |
| **Deploy config** | Vercel project tokens and settings |
| **Internal ops docs** | Runbooks, evaluation docs, POC specs |
| **Internal planning** | Execution context, grant docs, comms packs |
| **Partner proposals** | All partner-specific proposal pages and routes |
| **Financial data** | Spreadsheets, financial models |
| **Build artifacts** | `dist/`, `node_modules/`, temp files |

## Synchronization

Changes flow: private repo → staging → push to public. Never push directly.

## Security

- Gitleaks runs on every push and PR
- Content guardrail CI blocks internal terms and validates structure
- Report vulnerabilities to `[REDACTED_SECRET]` (see SECURITY.md)


path: README.md
content: 
<p align="center">
  <a href="https://npmjs.com/package/@asgcard/sdk"><img src="https://img.shields.io/npm/v/@asgcard/sdk?label=sdk" alt="npm"></a>
  <a href="https://npmjs.com/package/@asgcard/cli"><img src="https://img.shields.io/npm/v/@asgcard/cli?label=cli" alt="cli"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License"></a>
  <a href="https://api.asgcard.dev/health"><img src="https://img.shields.io/badge/API-live-brightgreen" alt="API Status"></a>
  <a href="https://asgcard.dev/docs"><img src="https://img.shields.io/badge/Docs-asgcard.dev-blue" alt="Docs"></a>
</p>

<p align="center">
  <a href="#quick-start">Quick Start</a> ·
  <a href="https://asgcard.dev/docs">Docs</a> ·
  <a href="https://asgcard.dev">Website</a> ·
  <a href="https://x.com/asgcardx402">Twitter</a> ·
  <a href="SECURITY.md">Security</a>
</p>

---

> **Public mirror** — this repo is a read-only mirror of the internal monorepo.
> For issues and feature requests use [GitHub Issues](https://github.com/ASGCompute/asgcard-public/issues).
> For code contributions see [CONTRIBUTING.md](CONTRIBUTING.md).

# Agent Card

Agent Card is an **agent-first** virtual card platform. AI agents programmatically issue and manage MasterCard virtual cards, paying in USDC via the **x402** protocol on **Stellar**.

<div align="center">
<table>
  <tr>
    <td align="center"><strong>Works<br/>with</strong></td>
    <td align="center"><a href="https://openai.com/index/codex/"><img src=".github/assets/logos/codex.svg" width="32" alt="Codex" /></a><br/><sub>Codex</sub></td>
    <td align="center"><a href="https://claude.ai/code"><img src=".github/assets/logos/claude.svg" width="32" alt="Claude Code" /></a><br/><sub>Claude Code</sub></td>
    <td align="center"><a href="https://cursor.com"><img src=".github/assets/logos/cursor.svg" width="32" alt="Cursor" /></a><br/><sub>Cursor</sub></td>
    <td align="center"><a href="https://openclaw.ai"><img src=".github/assets/logos/openclaw.svg" width="32" alt="OpenClaw" /></a><br/><sub>OpenClaw</sub></td>
    <td align="center"><a href="https://modelcontextprotocol.io"><img src=".github/assets/logos/mcp.svg" width="32" alt="Any MCP Client" /></a><br/><sub>Any MCP</sub></td>
  </tr>
</table>

<table>
  <tr>
    <td align="center"><strong>Built<br/>with</strong></td>
    <td align="center"><a href="https://stellar.org"><img src=".github/assets/logos/stellar.svg" width="32" alt="Stellar" /></a><br/><sub>Stellar</sub></td>
    <td align="center"><a href="https://www.circle.com/usdc"><img src=".github/assets/logos/circle.svg" width="32" alt="Circle USDC" /></a><br/><sub>Circle USDC</sub></td>
    <td align="center"><a href="https://www.mastercard.com"><img src=".github/assets/logos/mastercard.svg" width="32" alt="MasterCard" /></a><br/><sub>MasterCard</sub></td>
  </tr>
</table>

<em>If it speaks MCP, it can spend.</em>

</div>

## 🎬 Demo

<p align="center">
  <img src=".github/assets/demo.gif" alt="Agent Card CLI Demo" width="600">
</p>

> 📹 [Watch the full product video](https://youtu.be/zEq3HGhwrY8)

## Agent Card is right for you if

- ✅ Your AI agent needs to **pay for things** — hosting, domains, APIs, SaaS
- ✅ You want a virtual MasterCard issued **programmatically**
- ✅ You want your agent to manage cards **autonomously via MCP**
- ✅ You want to pay in **USDC** without touching fiat banking
- ✅ You need transparent, **on-chain proof** of every payment

## Quick Start

### For Codex

```bash
npx @asgcard/cli onboard -y --client codex
```

### For Claude Code

```bash
npx @asgcard/cli onboard -y --client claude
```

### For Cursor

```bash
npx @asgcard/cli onboard -y --client cursor
```

### Using the SDK directly

```bash
npm install @asgcard/sdk
```

### Via ClawHub

```bash
npx clawhub@latest install agentcard
```

The onboarding flow creates a Stellar wallet (`~/.asgcard/wallet.json`), configures MCP, installs the agent skill, and prints the next step.

> **Note:** If you already have a wallet, run `npx @asgcard/cli doctor` to verify your setup.

## How It Works

1. **Agent requests a card** → API returns `402 Payment Required` with USDC amount
2. **Agent signs a Stellar USDC transfer** via the SDK
3. **x402 Facilitator verifies and settles** the payment on-chain
4. **API issues a real MasterCard** via the card issuer
5. **Card details returned** in the response

Live pricing: [`GET https://api.asgcard.dev/pricing`](https://api.asgcard.dev/pricing) · Full docs: [asgcard.dev/docs](https://asgcard.dev/docs)

## MCP Server (9 tools)

`@asgcard/mcp-server` exposes **9 tools** via the Model Context Protocol. The MCP server reads your Stellar key from `~/.asgcard/wallet.json` — **no env vars needed** in client configs.

| Tool | What it does |
|------|--------------|
| `get_wallet_status` | Wallet address, USDC balance, readiness |
| `create_card` | Create virtual MasterCard (x402 payment) |
| `fund_card` | Top up existing card |
| `list_cards` | List all wallet cards |
| `get_card` | Card summary |
| `get_card_details` | PAN, CVV, expiry (nonce-protected) |
| `freeze_card` / `unfreeze_card` | Freeze or re-enable a card |
| `get_pricing` | Current tier pricing |

## SDK

```typescript
import { ASGCardClient } from "@asgcard/sdk";

const client = new ASGCardClient({
  privateKey: "S...",  // Stellar secret key
  rpcUrl: "https://mainnet.sorobanrpc.com"
});

const card = await client.createCard({
  amount: 10,
  nameOnCard: "AI Agent",
  email: "[REDACTED_SECRET]"
});
```

See [`/sdk`](sdk/) for full API reference.

## Repository Structure

This is a monorepo. Most users should use **`npx @asgcard/cli`** or **`npm install @asgcard/sdk`** — cloning is only needed for contributing.

| Directory | Package |
|-----------|---------|
| `/api` | ASG Card API (Express + x402 + wallet auth) |
| `/sdk` | `@asgcard/sdk` — TypeScript client |
| `/cli` | `@asgcard/cli` — CLI + onboarding |
| `/mcp-server` | `@asgcard/mcp-server` — MCP server (9 tools) |
| `/web` | Marketing website (asgcard.dev) |
| `/docs` | Documentation and ADRs |

## Security

- 🔒 AES-256-GCM encryption at rest for card details
- 🔑 Stellar private key **never leaves your machine** (`~/.asgcard/wallet.json`)
- 🛡️ Nonce-based anti-replay protection
- ✅ Wallet signature authentication — no API keys
- 📋 [Security Policy](SECURITY.md) · [Technical Overview](TECHNICAL_OVERVIEW.md)

## Community

- [GitHub Issues](https://github.com/ASGCompute/asgcard-public/issues) — bugs and feature requests
- [asgcard.dev](https://asgcard.dev) — docs and website
- [Twitter/X](https://x.com/asgcardx402) — updates

## License

MIT © 2025 ASG Compute


path: SECURITY.md
content: # Security Policy

## Supported Versions

| Version | Supported |
|---------|-----------|
| 0.3.x   | ✅ Current |

## Reporting a Vulnerability

If you discover a security vulnerability, please report it responsibly.

**DO NOT** open a public GitHub issue for security vulnerabilities.

### How to Report

1. Email: **<[REDACTED_SECRET]>**
2. Include:
   - Description of the vulnerability
   - Steps to reproduce
   - Potential impact
   - Suggested fix (if any)

### Response Timeline

| Action | SLA |
|--------|-----|
| Acknowledgement | 24 hours |
| Initial assessment | 48 hours |
| Fix deployed | 7 business days (critical) |

### Scope

In scope:

- API endpoints (`api.asgcard.dev`)
- x402 payment flow
- Wallet signature authentication
- SDK (`@asgcard/sdk`)

Out of scope:

- Stellar network issues
- Third-party facilitator bugs
- Social engineering

## Security Practices

- All secrets managed via Vercel environment variables (never committed)
- HMAC webhook signature verification
- Ed25519 wallet signature authentication
- Kill-switch and rollback capability
- Gitleaks CI scanning on every push


path: TECHNICAL_OVERVIEW.md
content: # ASG Card — Technical Overview

## Platform

ASG Card is an agent-focused virtual card platform built on the Stellar network. It uses the [x402 payment protocol](https://www.x402.org/) to enable AI agents to autonomously create and fund virtual cards using USDC.

## Stack

| Layer | Technology |
|---|---|
| **Network** | Stellar mainnet (pubnet) |
| **Asset** | USDC (Centre/Stellar) |
| **Payment Protocol** | x402 v2 (`X-PAYMENT` header, `PaymentPayload`) |
| **Facilitator** | OpenZeppelin Channels (hosted verify + settle) |
| **API** | Express.js on Vercel Serverless |
| **Auth** | Ed25519 wallet signature |
| **Database** | PostgreSQL (Supabase) |
| **SDK** | `@asgcard/sdk` (TypeScript) |

## Security

- HMAC webhook signature verification
- Anti-replay with idempotency keys + DB deduplication
- Kill-switch (`ROLLOUT_ENABLED`) for instant rollback
- Rate limiting on sensitive endpoints
- Gitleaks CI on every push

## API

- **Base URL:** `https://api.asgcard.dev`
- **OpenAPI:** [asgcard.dev/openapi.json](https://asgcard.dev/openapi.json)
- **Docs:** [asgcard.dev/docs](https://asgcard.dev/docs)

See [SECURITY.md](./SECURITY.md) for vulnerability reporting.


path: api/__tests__/persistence.test.ts
content: /**
 * Persistence integration tests.
 *
 * These run against the configured REPO_MODE repositories.
 * In CI/default: inmemory. With DATABASE_URL: postgres.
 *
 * Tests cover:
 *   - Card CRUD (create, findById, findByWallet, addBalance, updateStatus)
 *   - Payment anti-replay (recordPayment, findByTxHash, concurrent dupes)
 *   - Payment settle transitions (settled, settle_failed, verify_failed)
 *   - Webhook idempotency (store, findByKey, concurrent dupes)
 */
import { describe, it, expect, afterAll } from "vitest";
import { cardRepository, paymentRepository, webhookEventRepository } from "../src/repositories/runtime";
import type { CardDetails, TierAmount } from "../src/types/domain";

// Pool cleanup for postgres mode
afterAll(async () => {
    if (process.env.REPO_MODE === "postgres") {
        const { closePool } = await import("../src/db/db");
        await closePool();
    }
});

// ── Test fixtures ──────────────────────────────────────────

const testDetails: CardDetails = {
    cardNumber: "4111111111111111",
    expiryMonth: 12,
    expiryYear: 2028,
    cvv: "123",
    billingAddress: {
        street: "123 Test St",
        city: "Testville",
        state: "TS",
        zip: "12345",
        country: "US"
    }
};

// ── Card Repository Tests ──────────────────────────────────

describe("CardRepository", () => {
    let createdCardId: string;

    it("create() → returns StoredCard with correct fields", async () => {
        const card = await cardRepository.create({
            walletAddress: "GTEST1111111111111111111111111111111111111111111111111111",
            nameOnCard: "Test User",
            email: "[REDACTED_SECRET]",
            initialAmountUsd: 25,
            tierAmount: 25 as TierAmount,
            txHash: `tx_card_create_${Date.now()}`,
            details: testDetails
        });

        expect(card.cardId).toMatch(/^card_/);
        expect(card.walletAddress).toBe("GTEST1111111111111111111111111111111111111111111111111111");
        expect(card.nameOnCard).toBe("Test User");
        expect(card.email).toBe("[REDACTED_SECRET]");
        expect(card.balance).toBe(25);
        expect(card.initialAmountUsd).toBe(25);
        expect(card.status).toBe("active");
        expect(card.details.cardNumber).toBe("4111111111111111");
        expect(card.createdAt).toBeTruthy();

        createdCardId = card.cardId;
    });

    it("findById() → returns card by ID", async () => {
        const card = await cardRepository.findById(createdCardId);
        expect(card).toBeDefined();
        expect(card!.cardId).toBe(createdCardId);
    });

    it("findById() → returns undefined for non-existent ID", async () => {
        const card = await cardRepository.findById("card_nonexistent");
        expect(card).toBeUndefined();
    });

    it("findByWallet() → returns cards for wallet", async () => {
        const cards = await cardRepository.findByWallet(
            "GTEST1111111111111111111111111111111111111111111111111111"
        );
        expect(cards.length).toBeGreaterThanOrEqual(1);
        expect(cards.some((c) => c.cardId === createdCardId)).toBe(true);
    });

    it("addBalance() → increases balance", async () => {
        const ok = await cardRepository.addBalance(createdCardId, 50);
        expect(ok).toBe(true);

        const card = await cardRepository.findById(createdCardId);
        expect(card!.balance).toBe(75); // 25 + 50
    });

    it("updateStatus() → freezes card", async () => {
        const ok = await cardRepository.updateStatus(createdCardId, "frozen");
        expect(ok).toBe(true);

        const card = await cardRepository.findById(createdCardId);
        expect(card!.status).toBe("frozen");
    });

    it("updateStatus() → unfreezes card", async () => {
        const ok = await cardRepository.updateStatus(createdCardId, "active");
        expect(ok).toBe(true);

        const card = await cardRepository.findById(createdCardId);
        expect(card!.status).toBe("active");
    });
});

// ── Payment Repository Tests ───────────────────────────────

describe("PaymentRepository", () => {
    const testTxHash = `tx_persist_test_${Date.now()}`;

    it("recordPayment() → stores payment", async () => {
        const { record: payment, inserted } = await paymentRepository.recordPayment({
            txHash: testTxHash,
            payer: "GPAYER111111111111111111111111111111111111111111111111111",
            amount: "25000000",
            tierAmount: 25 as TierAmount,
            status: "proof_received"
        });

        expect(inserted).toBe(true);
        expect(payment.id).toBeTruthy();
        expect(payment.txHash).toBe(testTxHash);
        expect(payment.status).toBe("proof_received");
    });

    it("findByTxHash() → returns existing payment", async () => {
        const payment = await paymentRepository.findByTxHash(testTxHash);
        expect(payment).toBeDefined();
        expect(payment!.txHash).toBe(testTxHash);
    });

    it("findByTxHash() → returns undefined for unknown hash", async () => {
        const payment = await paymentRepository.findByTxHash("tx_unknown_hash");
        expect(payment).toBeUndefined();
    });

    it("anti-replay: duplicate txHash returns existing record with inserted=false", async () => {
        const { record: duplicate, inserted } = await paymentRepository.recordPayment({
            txHash: testTxHash,
            payer: "GPAYER111111111111111111111111111111111111111111111111111",
            amount: "25000000",
            tierAmount: 25 as TierAmount,
            status: "verified"
        });

        expect(inserted).toBe(false);
        // Should return the ORIGINAL record with proof_received status
        expect(duplicate.txHash).toBe(testTxHash);
        expect(duplicate.status).toBe("proof_received");
    });

    it("concurrent anti-replay: exactly 1 inserted, rest duplicates", async () => {
        const concurrentHash = `tx_concurrent_${Date.now()}`;
        const basePayment = {
            txHash: concurrentHash,
            payer: "GPAYER111111111111111111111111111111111111111111111111111",
            amount: "50000000",
            tierAmount: 50 as TierAmount,
            status: "proof_received" as const
        };

        // Fire 5 concurrent inserts with the same txHash
        const results = await Promise.all([
            paymentRepository.recordPayment(basePayment),
            paymentRepository.recordPayment(basePayment),
            paymentRepository.recordPayment(basePayment),
            paymentRepository.recordPayment(basePayment),
            paymentRepository.recordPayment(basePayment)
        ]);

        // All should resolve to the same txHash
        const hashes = new Set(results.map((r) => r.record.txHash));
        expect(hashes.size).toBe(1);
        expect(hashes.has(concurrentHash)).toBe(true);

        // Exactly 1 inserted, rest are duplicates
        const insertedCount = results.filter((r) => r.inserted).length;
        const duplicateCount = results.filter((r) => !r.inserted).length;
        expect(insertedCount).toBe(1);
        expect(duplicateCount).toBe(4);
    });

    it("markSettled() → transitions to settled", async () => {
        const ok = await paymentRepository.markSettled(testTxHash, "settle_abc123");
        expect(ok).toBe(true);

        const payment = await paymentRepository.findByTxHash(testTxHash);
        expect(payment!.status).toBe("settled");
        expect(payment!.settleId).toBe("settle_abc123");
    });

    it("markFailed() → transitions to settle_failed", async () => {
        const failHash = `tx_fail_${Date.now()}`;
        await paymentRepository.recordPayment({
            txHash: failHash,
            payer: "GPAYER111111111111111111111111111111111111111111111111111",
            amount: "10000000",
            tierAmount: 10 as TierAmount,
            status: "verified"
        });

        const ok = await paymentRepository.markFailed(failHash, "settle_failed");
        expect(ok).toBe(true);

        const payment = await paymentRepository.findByTxHash(failHash);
        expect(payment!.status).toBe("settle_failed");
    });
});

// ── Webhook Event Repository Tests ─────────────────────────

describe("WebhookEventRepository", () => {
    const testKey = `evt_persist_test_${Date.now()}`;

    it("store() → persists webhook event", async () => {
        const { record: event, inserted } = await webhookEventRepository.store({
            idempotencyKey: testKey,
            eventType: "card.created",
            payload: { foo: "bar" }
        });

        expect(inserted).toBe(true);
        expect(event.id).toBeTruthy();
        expect(event.idempotencyKey).toBe(testKey);
        expect(event.eventType).toBe("card.created");
    });

    it("findByIdempotencyKey() → returns stored event", async () => {
        const event = await webhookEventRepository.findByIdempotencyKey(testKey);
        expect(event).toBeDefined();
        expect(event!.idempotencyKey).toBe(testKey);
    });

    it("findByIdempotencyKey() → returns undefined for unknown key", async () => {
        const event = await webhookEventRepository.findByIdempotencyKey("unknown_key");
        expect(event).toBeUndefined();
    });

    it("idempotency: duplicate key returns existing record with inserted=false", async () => {
        const { record: duplicate, inserted } = await webhookEventRepository.store({
            idempotencyKey: testKey,
            eventType: "card.funded",
            payload: { baz: "qux" }
        });

        expect(inserted).toBe(false);
        // Should return original with card.created type
        expect(duplicate.idempotencyKey).toBe(testKey);
        expect(duplicate.eventType).toBe("card.created");
    });

    it("concurrent idempotency: exactly 1 inserted, rest duplicates", async () => {
        const concurrentKey = `evt_concurrent_${Date.now()}`;
        const baseEvent = {
            idempotencyKey: concurrentKey,
            eventType: "card.transaction",
            payload: { amount: 42 }
        };

        // Fire 5 concurrent stores with the same idempotencyKey
        const results = await Promise.all([
            webhookEventRepository.store(baseEvent),
            webhookEventRepository.store(baseEvent),
            webhookEventRepository.store(baseEvent),
            webhookEventRepository.store(baseEvent),
            webhookEventRepository.store(baseEvent)
        ]);

        // All should resolve to the same key
        const keys = new Set(results.map((r) => r.record.idempotencyKey));
        expect(keys.size).toBe(1);
        expect(keys.has(concurrentKey)).toBe(true);

        // Exactly 1 inserted, rest are duplicates
        const insertedCount = results.filter((r) => r.inserted).length;
        const duplicateCount = results.filter((r) => !r.inserted).length;
        expect(insertedCount).toBe(1);
        expect(duplicateCount).toBe(4);
    });
});


path: api/__tests__/setup.ts
content: /**
 * Test setup — load environment variables before any test imports env.ts
 */
import "dotenv/config";


path: api/__tests__/webhook-and-verify.test.ts
content: import crypto from "node:crypto";
import http from "node:http";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";

const app = createApp();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-placeholder-webhook-secret";

// ── Native HTTP helper for webhook tests ──────────────────
// supertest re-encodes Buffer bodies, breaking HMAC verification.
// We use native http.request to send bytes exactly as signed.

let server: http.Server;
let basePort: number;

beforeAll(async () => {
    await new Promise<void>((resolve) => {
        server = app.listen(0, () => {
            const addr = server.address() as { port: number };
            basePort = addr.port;
            resolve();
        });
    });
});

afterAll(async () => {
    await new Promise<void>((resolve) => {
        server.close(() => resolve());
    });
});

const postWebhook = (
    payload: string,
    headers: Record<string, string>
): Promise<{ status: number; body: Record<string, unknown> }> =>
    new Promise((resolve, reject) => {
        const options: http.RequestOptions = {
            hostname: "localhost",
            port: basePort,
            path: "/webhooks/4payments",
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Content-Length": Buffer.byteLength(payload),
                ...headers
            }
        };
        const req = http.request(options, (res) => {
            let data = "";
            res.on("data", (chunk) => { data += chunk; });
            res.on("end", () => {
                resolve({
                    status: res.statusCode ?? 0,
                    body: JSON.parse(data)
                });
            });
        });
        req.on("error", reject);
        req.write(payload);
        req.end();
    });

const signPayload = (payload: string, [REDACTED_SECRET]): string =>
    crypto.createHmac("sha256", secret).update(payload).digest("hex");

// ── Webhook HMAC Tests ─────────────────────────────────────

describe("Webhook — HMAC Verification", () => {
    it("POST /webhooks/4payments with valid webhook-sign → 200", async () => {
        const payload = JSON.stringify({
            type: "card.created",
            id: "evt_hmac_001",
            data: { card_id: "card_test" }
        });
        const sig = signPayload(payload, WEBHOOK_SECRET);

        const res = await postWebhook(payload, { "webhook-sign": sig });

        expect(res.status).toBe(200);
        expect(res.body.status).toBe("accepted");
        expect(res.body.type).toBe("card.created");
    });

    it("POST /webhooks/4payments with X-Webhook-Signature (fallback) → 200", async () => {
        const payload = JSON.stringify({
            type: "card.funded",
            id: "evt_hmac_002",
            data: {}
        });
        const sig = signPayload(payload, WEBHOOK_SECRET);

        const res = await postWebhook(payload, { "X-Webhook-Signature": sig });

        expect(res.status).toBe(200);
        expect(res.body.status).toBe("accepted");
    });

    it("POST /webhooks/4payments with invalid signature → 401", async () => {
        const payload = JSON.stringify({ type: "test", id: "evt_hmac_003" });

        const res = await postWebhook(payload, { "webhook-sign": "deadbeef" });

        expect(res.status).toBe(401);
        expect(res.body.error).toBe("Invalid webhook signature");
    });

    it("POST /webhooks/4payments without signature header → 401", async () => {
        const payload = JSON.stringify({ type: "test", id: "evt_hmac_004" });

        const res = await postWebhook(payload, {});

        expect(res.status).toBe(401);
        expect((res.body.error as string)).toContain("Missing webhook signature");
    });

    it("POST /webhooks/4payments idempotency — duplicate event → already_processed", async () => {
        const payload = JSON.stringify({
            type: "card.status_change",
            id: "evt_idempotent_native_001",
            data: {}
        });
        const sig = signPayload(payload, WEBHOOK_SECRET);

        // First call
        const res1 = await postWebhook(payload, { "webhook-sign": sig });
        expect(res1.status).toBe(200);
        expect(res1.body.status).toBe("accepted");

        // Second call with same idempotency key
        const res2 = await postWebhook(payload, { "webhook-sign": sig });
        expect(res2.status).toBe(200);
        expect(res2.body.status).toBe("already_processed");
    });
});

// ── x402 Fail-Closed Test (uses supertest — fine for JSON routes) ──

describe("x402 Verify — Fail-Closed", () => {
    it("POST /cards/create/tier/25 with bogus X-Payment → rejected (401 or 503)", async () => {
        const payment = {
            scheme: "exact",
            network: "stellar:pubnet",
            payload: {
                txHash: "abc123valid",
                authorization: {
                    from: process.env.STELLAR_TREASURY_ADDRESS,
                    to: process.env.STELLAR_TREASURY_ADDRESS,
                    value: "32500000"
                }
            }
        };
        const encoded = Buffer.from(JSON.stringify(payment)).toString("base64");

        const res = await request(app)
            .post("/cards/create/tier/25")
            .set("X-Payment", encoded);

        // Fail-closed: facilitator rejects bogus tx (401) or is unreachable (503).
        // Either way the request MUST NOT proceed to the paid handler.
        expect([401, 503]).toContain(res.status);
        expect(res.body.error).toBeDefined();
    });
});


path: api/__tests__/x402-challenge.test.ts
content: import { describe, it, expect } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";

const app = createApp();

describe("x402 Challenge — Create Tiers", () => {
    const createTiers = [10, 25, 50, 100, 200, 500] as const;
    const expectedAtomicAmounts: Record<number, string> = {
        10: "17200000",   // totalCost = 17.2
        25: "32500000",   // totalCost = 32.5
        50: "58000000",   // totalCost = 58.0
        100: "110000000", // totalCost = 110.0
        200: "214000000", // totalCost = 214.0
        500: "522000000"  // totalCost = 522.0
    };

    for (const amount of createTiers) {
        it(`POST /cards/create/tier/${amount} → 402 with correct challenge`, async () => {
            const res = await request(app)
                .post(`/cards/create/tier/${amount}`)
                .expect(402);

            expect(res.body).toHaveProperty("x402Version", 2);
            expect(res.body.accepts).toHaveLength(1);

            const accept = res.body.accepts[0];
            expect(accept.scheme).toBe("exact");
            expect(accept.network).toBe("stellar:pubnet");
            expect(accept.asset).toContain("USDC:");
            expect(accept.asset).toContain("GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN");
            expect(accept.amount).toBe(expectedAtomicAmounts[amount]);
            expect(accept.payTo).toMatch(/^G[A-Z2-7]{55}$/);
            expect(accept.maxTimeoutSeconds).toBe(300);
            expect(res.body.resource.url).toContain(`/cards/create/tier/${amount}`);
            expect(res.body.resource.description).toContain(`$${amount}`);
        });
    }
});

describe("x402 Challenge — Fund Tiers", () => {
    const fundTiers = [10, 25, 50, 100, 200, 500] as const;
    const expectedAtomicAmounts: Record<number, string> = {
        10: "14200000",   // totalCost = 14.2
        25: "29500000",   // totalCost = 29.5
        50: "55000000",   // totalCost = 55.0
        100: "107000000", // totalCost = 107.0
        200: "211000000", // totalCost = 211.0
        500: "519000000"  // totalCost = 519.0
    };

    for (const amount of fundTiers) {
        it(`POST /cards/fund/tier/${amount} → 402 with correct challenge`, async () => {
            const res = await request(app)
                .post(`/cards/fund/tier/${amount}`)
                .expect(402);

            expect(res.body).toHaveProperty("x402Version", 2);
            expect(res.body.accepts[0].amount).toBe(expectedAtomicAmounts[amount]);
            expect(res.body.resource.url).toContain(`/cards/fund/tier/${amount}`);
        });
    }
});

describe("x402 Challenge — Error Cases", () => {
    it("POST /cards/create/tier/999 → 400 (unsupported tier)", async () => {
        const res = await request(app)
            .post("/cards/create/tier/999")
            .expect(400);

        expect(res.body.error).toBe("Unsupported tier amount");
    });

    it("POST /cards/fund/tier/42 → 400 (unsupported tier)", async () => {
        const res = await request(app)
            .post("/cards/fund/tier/42")
            .expect(400);

        expect(res.body.error).toBe("Unsupported tier amount");
    });

    it("POST /cards/create/tier/25 with malformed X-Payment → 401", async () => {
        const res = await request(app)
            .post("/cards/create/tier/25")
            .set("X-Payment", "not-valid-json-or-base64")
            .expect(401);

        expect(res.body.error).toBe("Invalid X-PAYMENT header: expected x402 v2 PaymentPayload");
    });

    it("POST /cards/create/tier/25 with wrong network in X-Payment → 401", async () => {
        const payment = {
            x402Version: 2,
            accepted: {
                scheme: "exact",
                network: "eip155:1" // wrong network
            },
            payload: {
                transaction: "xyz"
            }
        };
        const encoded = Buffer.from(JSON.stringify(payment)).toString("base64");
        const res = await request(app)
            .post("/cards/create/tier/25")
            .set("X-Payment", encoded)
            .expect(401);

        expect(res.body.error).toBe("Unsupported payment scheme or network");
    });
});

describe("Public Endpoints", () => {
    it("GET /health → 200 with status ok", async () => {
        const res = await request(app)
            .get("/health")
            .expect(200);

        expect(res.body.status).toBe("ok");
        expect(res.body.version).toBeDefined();
        expect(res.body.timestamp).toBeDefined();
    });

    it("GET /pricing → 200 with 6 creation + 6 funding tiers", async () => {
        const res = await request(app)
            .get("/pricing")
            .expect(200);

        expect(res.body.creation.tiers).toHaveLength(6);
        expect(res.body.funding.tiers).toHaveLength(6);
    });

    it("GET /cards/tiers → 200 with breakdown", async () => {
        const res = await request(app)
            .get("/cards/tiers")
            .expect(200);

        expect(res.body.creation).toBeDefined();
        expect(res.body.creation[0].breakdown).toBeDefined();
    });

    it("GET /nonexistent → 404", async () => {
        await request(app)
            .get("/nonexistent")
            .expect(404);
    });
});

describe("Wallet Auth Errors", () => {
    it("GET /wallet → 401 without auth headers", async () => {
        const res = await request(app)
            .get("/cards")
            .expect(401);

        expect(res.body.error).toBe("Missing wallet authentication headers");
    });

    it("GET /wallet → 401 with invalid timestamp", async () => {
        const res = await request(app)
            .get("/cards")
            .set("X-WALLET-ADDRESS", "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
            .set("X-WALLET-SIGNATURE", "dGVzdA==")
            .set("X-WALLET-TIMESTAMP", "not-a-number")
            .expect(401);

        expect(res.body.error).toBe("Invalid wallet timestamp");
    });

    it("GET /wallet → 401 with expired timestamp", async () => {
        const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
        const res = await request(app)
            .get("/cards")
            .set("X-WALLET-ADDRESS", "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
            .set("X-WALLET-SIGNATURE", "dGVzdA==")
            .set("X-WALLET-TIMESTAMP", String(oldTimestamp))
            .expect(401);

        expect(res.body.error).toBe("Wallet timestamp outside accepted window");
    });
});


path: api/package.json
content: {
  "name": "@asgcard/api",
  "version": "0.1.0",
  "private": true,
  "type": "commonjs",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "typecheck": "tsc -p tsconfig.json --noEmit",
    "test": "vitest run",
    "test:watch": "vitest",
    "preflight": "node scripts/preflight.js",
    "e2e:mainnet": "node scripts/e2e-mainnet.js",
    "e2e:mainnet:live": "node scripts/e2e-mainnet.js --live"
  },
  "dependencies": {
    "@stellar/stellar-sdk": "^14.5.0",
    "bs58": "^6.0.0",
    "cors": "^2.8.5",
    "dotenv": "^17.3.1",
    "express": "^4.21.2",
    "pg": "^8.19.0",
    "tweetnacl": "^1.0.3",
    "zod": "^4.1.5"
  },
  "devDependencies": {
    "@types/cors": "^2.8.18",
    "@types/express": "^4.17.21",
    "@types/node": "^22.15.30",
    "@types/pg": "^8.16.0",
    "@types/supertest": "^7.2.0",
    "supertest": "^7.2.2",
    "tsx": "^4.20.5",
    "typescript": "^5.8.3",
    "vitest": "^4.0.18"
  }
}

path: api/scripts/e2e-mainnet.js
content: #!/usr/bin/env node
/**
 * MNET-113: Final E2E Automation Script
 *
 * Runs 2 test cases:
 *   A) Success path:  402 → X-PAYMENT (mock) → 201 (actually tests facilitator response format)
 *   B) Success path:  Webhook event → accepted
 *   C) Replay path:   Same idempotency_key → already_processed (DB dedup proof)
 *
 * Note: Full payment E2E (real Stellar tx) requires a funded treasury and a Stellar SDK
 * wallet with USDC. This script tests the API contract and idempotency layer.
 * Run AFTER treasury funding with a real Stellar wallet for full live E2E.
 *
 * Usage:
 *   npm run e2e:mainnet
 *   npm run e2e:mainnet -- --live (adds Horizon on-chain check)
 */

'use strict';
const crypto = require('crypto');
const https = require('https');
const fs = require('fs');

const API_BASE = process.env.API_BASE || 'https://api.asgcard.dev';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET
    || (() => { console.error('❌ WEBHOOK_SECRET required'); process.exit(1); })();
const LIVE = process.argv.includes('--live');

const results = [];
let passed = 0, failed = 0;

// ── Helpers ───────────────────────────────────────────────────
function request(opts, body = null) {
    return new Promise((resolve, reject) => {
        const url = new URL(opts.path || '/', API_BASE);
        const req = https.request({
            hostname: url.hostname,
            path: url.pathname + (url.search || ''),
            method: opts.method || 'GET',
            headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }
        }, res => {
            let data = '';
            res.on('data', d => data += d);
            res.on('end', () => {
                try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
                catch { resolve({ status: res.statusCode, body: data }); }
            });
        });
        req.on('error', reject);
        if (body) req.write(typeof body === 'string' ? body : JSON.stringify(body));
        req.end();
    });
}

function hmac(secret, data) {
    return crypto.createHmac('sha256', secret).update(Buffer.from(data)).digest('hex');
}

async function test(name, fn) {
    process.stdout.write(`  ⏳ ${name} … `);
    try {
        const { ok, detail } = await fn();
        if (ok) {
            console.log(`✅ PASS${detail ? ' — ' + detail : ''}`);
            passed++;
            results.push({ name, passed: true, detail });
        } else {
            console.log(`❌ FAIL — ${detail}`);
            failed++;
            results.push({ name, passed: false, detail });
        }
    } catch (e) {
        console.log(`❌ ERROR — ${e.message}`);
        failed++;
        results.push({ name, passed: false, detail: e.message });
    }
}

// ── Test Suite ─────────────────────────────────────────────────

async function runE2E() {
    const ts = new Date().toISOString();
    console.log(`\n🚀 ASG Card Mainnet E2E Test Suite`);
    console.log(`   API: ${API_BASE}`);
    console.log(`   Time: ${ts}`);
    console.log(`   Mode: ${LIVE ? 'LIVE (Horizon check)' : 'CONTRACT (no real tx)'}\n`);

    // ── A: API Contract Layer ──
    console.log('【A】API Contract Tests');

    await test('GET /health → 200 ok', async () => {
        const r = await request({ path: '/health' });
        return { ok: r.status === 200 && r.body.status === 'ok', detail: `v${r.body.version}` };
    });

    await test('GET /supported → stellar:pubnet x402v2', async () => {
        const r = await request({ path: '/supported' });
        const kind = r.body?.facilitator?.kinds?.find(k => k.network === 'stellar:pubnet' && k.x402Version === 2);
        return { ok: !!kind, detail: kind ? `fees_sponsored=${kind.extra?.areFeesSponsored}` : 'not found' };
    });

    await test('POST /cards/create/tier/10 without payment → 402 challenge', async () => {
        const r = await request({ path: '/cards/create/tier/10', method: 'POST' }, { nameOnCard: 'E2E TEST', email: '[REDACTED_SECRET]' });
        const net = r.body?.accepts?.[0]?.network;
        const ver = r.body?.x402Version;
        return { ok: r.status === 402 && net === 'stellar:pubnet' && ver === 2, detail: `status=${r.status} network=${net} x402v=${ver}` };
    });

    await test('POST /cards/create/tier/10 with bad payment → 401', async () => {
        const badPayment = Buffer.from(JSON.stringify({ x402Version: 2, accepted: { scheme: 'exact', network: 'eip155:1' }, payload: { transaction: 'fake' } })).toString('base64');
        const r = await request({ path: '/cards/create/tier/10', method: 'POST', headers: { 'X-PAYMENT': badPayment } }, { nameOnCard: 'E2E TEST', email: '[REDACTED_SECRET]' });
        return { ok: r.status === 401, detail: `status=${r.status} error="${r.body?.error}"` };
    });

    // ── B: Webhook Idempotency ──
    console.log('\n【B】Webhook Idempotency / Replay Proof');

    const idemKey = `e2e-mainnet-${Date.now()}`;
    const webhookPayload = JSON.stringify({
        type: 'card.funded',
        idempotency_key: idemKey,
        data: { card_id: 'c_e2e_001', amount: 1000, tx_hash: `e2e_tx_${Date.now()}` }
    });
    const sig = hmac(WEBHOOK_SECRET, webhookPayload);
    const whHeaders = { 'webhook-sign': sig };

    await test('Webhook event → 200 accepted (new event)', async () => {
        const r = await request({ path: '/webhooks/4payments', method: 'POST', headers: whHeaders }, webhookPayload);
        return { ok: r.status === 200 && r.body?.status === 'accepted', detail: `status=${r.body?.status}` };
    });

    await test('Webhook duplicate (same key) → 200 already_processed (replay blocked)', async () => {
        const r = await request({ path: '/webhooks/4payments', method: 'POST', headers: { 'webhook-sign': sig } }, webhookPayload);
        return { ok: r.status === 200 && r.body?.status === 'already_processed', detail: `status=${r.body?.status}` };
    });

    await test('Webhook with invalid signature → 401 (HMAC guard)', async () => {
        const r = await request({ path: '/webhooks/4payments', method: 'POST', headers: { 'webhook-sign': 'badhash' } }, webhookPayload);
        return { ok: r.status === 401, detail: `status=${r.status}` };
    });

    // ── C: Kill-switch (non-destructive check) ──
    console.log('\n【C】Kill-switch Status');

    await test('ROLLOUT_ENABLED=true inferred (API serves 402 not 503)', async () => {
        const r = await request({ path: '/cards/create/tier/10', method: 'POST' }, { nameOnCard: 'E2E KS TEST', email: '[REDACTED_SECRET]' });
        // 402 = rollout active; 503 = kill-switch engaged
        return { ok: r.status === 402, detail: `Got ${r.status} — expected 402 (live rollout)` };
    });

    // ── D: Horizon on-chain check (--live only) ──
    if (LIVE) {
        console.log('\n【D】On-chain Horizon Check (--live)');
        await test('Treasury account exists on mainnet Horizon', async () => {
            const TREASURY = process.env.STELLAR_TREASURY_ADDRESS || 'GBQL4G3MUIQTNSSC7X3FR534RUOKPV4NBZOBPP43SLWU7BXYD6VAW5BZ';
            const r = await new Promise((resolve) => {
                https.get(`https://horizon.stellar.org/accounts/${TREASURY}`, res => {
                    let body = ''; res.on('data', d => body += d);
                    res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(body) }); } catch { resolve({ status: res.statusCode, body }); } });
                }).on('error', e => resolve({ status: 0, body: e.message }));
            });
            const usdc = (r.body?.balances || []).find(b => b.asset_code === 'USDC');
            return { ok: r.status === 200 && !!usdc, detail: r.status === 200 ? `USDC bal: $${usdc?.balance || 0}` : `Horizon ${r.status}` };
        });
    }

    // ── Summary ────────────────────────────────────────────────
    console.log(`\n${'─'.repeat(50)}`);
    console.log(`  Results: ${passed} passed / ${failed} failed / ${passed + failed} total`);

    const allPass = failed === 0;
    console.log(`  Decision: ${allPass ? '🟢 E2E PASS — ready for live traffic' : '🔴 E2E FAIL — fix issues before go-live'}\n`);

    // Write JSON report
    const report = {
        timestamp: ts,
        mode: LIVE ? 'live' : 'contract',
        passed, failed, total: passed + failed,
        allPass,
        tests: results
    };
    const outPath = `./e2e-report-${Date.now()}.json`;
    fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
    console.log(`  📄 Report saved → ${outPath}\n`);

    process.exit(allPass ? 0 : 1);
}

runE2E().catch(e => { console.error(e); process.exit(1); });


path: api/scripts/preflight.js
content: #!/usr/bin/env node
/**
 * MNET-112: Pre-Funding Readiness Script
 *
 * Checks:
 *   1. Stellar account exists on mainnet
 *   2. USDC trustline established
 *   3. USDC balance >= threshold (default: $200)
 *   4. API health endpoint reachable
 *   5. /supported returns stellar:pubnet x402v2
 *
 * Usage:
 *   node scripts/preflight.js [--threshold=200] [--out=./report.json]
 *   npm run preflight
 */

'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');

// ── Config ────────────────────────────────────────────────────
const TREASURY = process.env.STELLAR_TREASURY_ADDRESS
    || 'GBQL4G3MUIQTNSSC7X3FR534RUOKPV4NBZOBPP43SLWU7BXYD6VAW5BZ';
const HORIZON = process.env.STELLAR_HORIZON_URL
    || 'https://horizon.stellar.org';
const USDC_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
const API_BASE = process.env.API_BASE || 'https://api.asgcard.dev';
const THRESHOLD = parseFloat((process.argv.find(a => a.startsWith('--threshold=')) || '--threshold=200').split('=')[1]);
const OUT_FILE = (process.argv.find(a => a.startsWith('--out=')) || '--out=./preflight-report.json').split('=')[1];

// ── Helpers ───────────────────────────────────────────────────
function get(url) {
    return new Promise((resolve, reject) => {
        https.get(url, res => {
            let body = '';
            res.on('data', d => body += d);
            res.on('end', () => {
                try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
                catch { resolve({ status: res.statusCode, body }); }
            });
        }).on('error', reject);
    });
}

function getApi(path) {
    return get(API_BASE + path);
}

function check(name, passed, detail = '') {
    const icon = passed ? '✅' : '❌';
    console.log(`  ${icon} ${name}${detail ? ': ' + detail : ''}`);
    return { name, passed, detail };
}

// ── Checks ────────────────────────────────────────────────────
async function checkAccount() {
    const { status, body } = await get(`${HORIZON}/accounts/${TREASURY}`);
    if (status !== 200) return check('Account exists on mainnet', false, `Horizon ${status} — account not found/funded`);
    // Check XLM balance
    const xlm = (body.balances || []).find(b => b.asset_type === 'native');
    const xlmBal = xlm ? parseFloat(xlm.balance) : 0;
    return check('Account exists on mainnet', true, `XLM balance: ${xlmBal}`);
}

async function checkTrustline(horizonAccount) {
    if (!horizonAccount) return check('USDC trustline exists', false, 'Cannot check — account missing');
    const { status, body } = await get(`${HORIZON}/accounts/${TREASURY}`);
    if (status !== 200) return check('USDC trustline exists', false, 'Horizon error');
    const usdc = (body.balances || []).find(b => b.asset_code === 'USDC' && b.asset_issuer === USDC_ISSUER);
    if (!usdc) return check('USDC trustline exists', false, `No USDC trustline for issuer ${USDC_ISSUER}`);
    return check('USDC trustline exists', true, `limit: ${usdc.limit}`);
}

async function checkUsdcBalance() {
    const { status, body } = await get(`${HORIZON}/accounts/${TREASURY}`);
    if (status !== 200) return { check: check(`USDC balance >= $${THRESHOLD}`, false, 'Cannot check — account missing'), balance: 0 };
    const usdc = (body.balances || []).find(b => b.asset_code === 'USDC' && b.asset_issuer === USDC_ISSUER);
    const bal = usdc ? parseFloat(usdc.balance) : 0;
    return {
        check: check(`USDC balance >= $${THRESHOLD}`, bal >= THRESHOLD, `$${bal.toFixed(2)}`),
        balance: bal
    };
}

async function checkApiHealth() {
    try {
        const { status, body } = await getApi('/health');
        if (status !== 200 || body.status !== 'ok') return check('API health', false, `status=${status}`);
        return check('API health', true, `v${body.version}`);
    } catch (e) {
        return check('API health', false, e.message);
    }
}

async function checkFacilitator() {
    try {
        const { status, body } = await getApi('/supported');
        const kinds = body?.facilitator?.kinds || [];
        const mainnet = kinds.find(k => k.network === 'stellar:pubnet' && k.x402Version === 2);
        if (!mainnet) return check('/supported → stellar:pubnet x402v2', false, JSON.stringify(kinds[0] || {}));
        return check('/supported → stellar:pubnet x402v2', true, `fees_sponsored=${mainnet.extra?.areFeesSponsored}`);
    } catch (e) {
        return check('/supported → stellar:pubnet x402v2', false, e.message);
    }
}

async function check402Challenge() {
    try {
        const { status, body } = await new Promise((resolve, reject) => {
            const req = https.request({
                hostname: new URL(API_BASE).hostname,
                path: '/cards/create/tier/10',
                method: 'POST',
                headers: { 'Content-Type': 'application/json', 'Content-Length': 2 }
            }, res => {
                let body = '';
                res.on('data', d => body += d);
                res.on('end', () => {
                    try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
                    catch { resolve({ status: res.statusCode, body }); }
                });
            });
            req.on('error', reject);
            req.write('{}');
            req.end();
        });
        if (status !== 402) return check('x402 challenge returns HTTP 402', false, `Got ${status}`);
        const net = body?.accepts?.[0]?.network || 'missing';
        const ver = body?.x402Version;
        return check('x402 challenge returns HTTP 402', true, `network=${net} version=${ver}`);
    } catch (e) {
        return check('x402 challenge returns HTTP 402', false, e.message);
    }
}

// ── Main ──────────────────────────────────────────────────────
async function main() {
    const ts = new Date().toISOString();
    console.log(`\n🔍 ASG Card Mainnet Pre-Funding Readiness Check`);
    console.log(`   Treasury: ${TREASURY}`);
    console.log(`   Threshold: $${THRESHOLD} USDC`);
    console.log(`   Time: ${ts}\n`);

    const results = {};

    results.apiHealth = await checkApiHealth();
    results.facilitator = await checkFacilitator();
    results.x402Challenge = await check402Challenge();
    results.accountExists = await checkAccount();
    results.trustline = await checkTrustline(results.accountExists.passed);
    const { check: balCheck, balance } = await checkUsdcBalance();
    results.usdcBalance = balCheck;

    const checks = Object.values(results);
    const passed = checks.filter(c => c.passed).length;
    const total = checks.length;
    const allPass = passed === total;

    // Technical gates (excluding treasury funding)
    const techGates = ['apiHealth', 'facilitator', 'x402Challenge'];
    const techPass = techGates.every(k => results[k].passed);

    console.log(`\n${'─'.repeat(50)}`);
    console.log(`  Total: ${passed}/${total} checks passed`);
    console.log(`  Tech gates (no treasury): ${techPass ? '✅ READY' : '❌ NOT READY'}`);
    console.log(`  Treasury funded: ${results.usdcBalance.passed ? '✅ YES' : '❌ NO — fund before E2E'}`);
    console.log(`  READINESS: ${allPass ? '🟢 FULL GO' : techPass ? '🟡 CONDITIONAL GO (fund treasury)' : '🔴 NOT READY'}`);

    // ── Report output ──────────────────────────────────────────
    const report = {
        timestamp: ts,
        treasury: TREASURY,
        threshold: THRESHOLD,
        checks: results,
        summary: {
            passed, total, allPass, techPass,
            usdcBalance: balance,
            readiness: allPass ? 'FULL_GO' : techPass ? 'CONDITIONAL_GO' : 'NOT_READY'
        }
    };

    const outPath = path.resolve(OUT_FILE);
    fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
    console.log(`\n  📄 Report saved → ${outPath}`);

    // ── Markdown summary ───────────────────────────────────────
    const mdLines = [
        `# Preflight Report — ${ts}`,
        '',
        `| Check | Status | Detail |`,
        `|---|---|---|`,
        ...checks.map(c => `| ${c.name} | ${c.passed ? '✅ PASS' : '❌ FAIL'} | ${c.detail || ''} |`),
        '',
        `## Decision: ${report.summary.readiness}`,
        '',
        allPass
            ? `All checks pass. Ready for full E2E and live traffic.`
            : techPass
                ? `Technical gates pass. **Fund treasury** then re-run: \`npm run preflight\``
                : `Critical failures. Fix before proceeding.`,
    ];
    const mdPath = outPath.replace('.json', '.md');
    fs.writeFileSync(mdPath, mdLines.join('\n'));
    console.log(`  📄 Markdown → ${mdPath}\n`);

    process.exit(allPass ? 0 : techPass ? 2 : 1);
}

main().catch(e => { console.error(e); process.exit(1); });


path: api/src/app.ts
content: import cors from "cors";
import express from "express";
import { paidRouter } from "./routes/paid";
import { publicRouter } from "./routes/public";
import { walletRouter } from "./routes/wallet";
import { webhookRouter } from "./routes/webhook";
import { opsRouter } from "./routes/ops";
import { env } from "./config/env";
import { httpLogger, appLogger } from "./utils/logger";

export const createApp = async () => {
  const app = express();

  app.use(cors());
  app.use(httpLogger);

  // Webhook route needs raw body for HMAC — mount BEFORE json parser
  app.use("/webhooks", express.raw({ type: "application/json" }), webhookRouter);

  // All other routes use json parser
  app.use(express.json());

  app.use(publicRouter);
  app.use("/cards", paidRouter);
  app.use("/cards", walletRouter);
  app.use("/ops", opsRouter);

  // ── Telegram Bot (feature-flagged) ─────────────────────────
  if (env.TG_BOT_ENABLED === "true") {
    const { botRouter } = await import("./modules/bot");
    app.use("/bot", botRouter);
    appLogger.info("[APP] Telegram bot module enabled → /bot/*");
  }

  // ── Owner Portal (feature-flagged) ─────────────────────────
  if (env.OWNER_PORTAL_ENABLED === "true") {
    const { portalRouter } = await import("./modules/portal");
    app.use("/portal", portalRouter);
    appLogger.info("[APP] Owner portal module enabled → /portal/*");
  }

  // ── Admin Bot (feature-flagged) ────────────────────────────
  if (env.ADMIN_BOT_ENABLED === "true") {
    const { adminRouter } = await import("./modules/admin");
    app.use("/admin", adminRouter);
    appLogger.info("[APP] Admin bot module enabled → /admin/*");
    // Note: startup notification removed — fires on every Vercel cold start
  }

  app.use((_req, res) => {
    res.status(404).json({ error: "Not Found" });
  });

  return app;
};


path: api/src/config/env.ts
content: import { z } from "zod";

// ── Stellar G-address format ──────────────────────────────
const stellarAddress = z
  .string()
  .regex(/^G[A-Z2-7]{55}$/, "Must be a valid Stellar public key (G...)");

// ── Environment modes ─────────────────────────────────────
const nodeEnv = z.enum(["development", "staging", "production", "test"]).default("development");
const logLevel = z.enum(["debug", "info", "warn", "error"]).default("info");

const envSchema = z.object({
  PORT: z.coerce.number().default(3000),
  API_VERSION: z.string().default("0.3.1"),
  NODE_ENV: nodeEnv,
  LOG_LEVEL: logLevel,

  // ── Stellar-native config (primary) ──────────────────────
  STELLAR_NETWORK: z.string().default("stellar:pubnet"),
  STELLAR_HORIZON_URL: z
    .string()
    .url()
    .default("https://horizon.stellar.org"),
  STELLAR_USDC_ASSET: z
    .string()
    .default("CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"),

  // ── MANDATORY in all environments (no unsafe defaults) ───
  STELLAR_TREASURY_ADDRESS: stellarAddress,
  FACILITATOR_URL: z.string().url(),
  FACILITATOR_API_KEY: z.string().min(1),
  WEBHOOK_SECRET: z.string().min(1),
  WEBHOOK_SECRET_PREVIOUS: z.string().optional(),

  // ── Facilitator tuning ───────────────────────────────────
  FACILITATOR_TIMEOUT_MS: z.coerce.number().default(8000),
  FACILITATOR_MAX_RETRIES: z.coerce.number().default(2),

  // ── Repository mode ─────────────────────────────────────
  REPO_MODE: z.enum(["inmemory", "postgres"]).default("inmemory"),
  DATABASE_URL: z.string().optional(),
  CARD_DETAILS_KEY: z.string().optional(), // base64-encoded 32 bytes, validated below

  // ── Legacy Solana fallback (backward compat, removed in M2) ──
  SOLANA_NETWORK: z.string().optional(),
  SOLANA_RPC_URL: z.string().optional(),
  USDC_MINT: z.string().optional(),
  TREASURY_PUBKEY: z.string().optional(),

  // ── Rollout gate (staged traffic control) ──────────────
  ROLLOUT_ENABLED: z.enum(["true", "false"]).default("true"),
  ROLLOUT_PCT: z.coerce.number().min(0).max(100).default(100),

  // ── Ops dashboard security ─────────────────────────────
  OPS_API_KEY: z.string().optional(),
  OPS_IP_ALLOWLIST: z.string().optional(),           // comma-separated CIDRs/IPs

  // ── Telegram Bot (@ASGCardbot) ──────────────────────────
  TG_BOT_ENABLED: z.enum(["true", "false"]).default("false"),
  TG_BOT_TOKEN: z.string().optional(),
  TG_WEBHOOK_SECRET: z.string().optional(),

  // ── Owner Portal ───────────────────────────────────────
  OWNER_PORTAL_ENABLED: z.enum(["true", "false"]).default("false"),

  // ── Bot Alerts (event notifications to TG) ─────────────
  BOT_ALERTS_ENABLED: z.enum(["true", "false"]).default("false"),

  // ── Admin Bot (ops notifications to admin TG chat) ─────
  ADMIN_BOT_ENABLED: z.enum(["true", "false"]).default("false"),
  ADMIN_BOT_TOKEN: z.string().optional(),
  ADMIN_CHAT_ID: z.string().optional(),

  // ── Agent Details (REALIGN: nonce + anti-replay for card details) ──
  AGENT_DETAILS_ENABLED: z.enum(["true", "false"]).default("true"),
  DETAILS_READ_LIMIT_PER_HOUR: z.coerce.number().default(5),
});

// ── Fail-fast startup validation ──────────────────────────
let env: z.infer<typeof envSchema>;

try {
  env = envSchema.parse(process.env);

  // ── Conditional validation for postgres mode ────────────
  if (env.REPO_MODE === "postgres") {
    if (!env.DATABASE_URL) {
      throw new Error(
        "DATABASE_URL is required when REPO_MODE=postgres"
      );
    }
    if (!env.CARD_DETAILS_KEY) {
      throw new Error(
        "CARD_DETAILS_KEY is required when REPO_MODE=postgres. " +
        'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"'
      );
    }
    // Validate key byte length (must be exactly 32 bytes when decoded from base64)
    const keyBuf = Buffer.from(env.CARD_DETAILS_KEY, "base64");
    if (keyBuf.length !== 32) {
      throw new Error(
        `CARD_DETAILS_KEY must be exactly 32 bytes (got ${keyBuf.length}). ` +
        'Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"'
      );
    }
  }
} catch (error) {
  if (error instanceof z.ZodError) {
    const missing = error.issues
      .map((i) => `  • ${i.path.join(".")}: ${i.message}`)
      .join("\n");
    const message =
      `\n❌ ASG Card API — environment validation failed:\n${missing}\n\n` +
      `Hint: copy api/.env.example to api/.env and fill in required values.\n`;
    // In production, log and exit; in test, throw for assertions
    if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
      console.error(message);
      process.exit(1);
    }
    throw new Error(message);
  }
  // Re-throw non-Zod errors (e.g. our conditional validation errors)
  if (typeof process !== "undefined" && process.env.NODE_ENV !== "test") {
    console.error(`\n❌ ${(error as Error).message}\n`);
    process.exit(1);
  }
  throw error;
}

export { env };


path: api/src/config/pricing.ts
content: import type { CreationTier, FundingTier, TierAmount } from "../types/domain";

export const SUPPORTED_TIERS: TierAmount[] = [10, 25, 50, 100, 200, 500];

export const CREATION_TIERS: CreationTier[] = [
  {
    loadAmount: 10,
    issuanceFee: 3.0,
    topUpFee: 2.2,
    serviceFee: 2.0,
    totalCost: 17.2,
    endpoint: "/cards/create/tier/10"
  },
  {
    loadAmount: 25,
    issuanceFee: 3.0,
    topUpFee: 2.5,
    serviceFee: 2.0,
    totalCost: 32.5,
    endpoint: "/cards/create/tier/25"
  },
  {
    loadAmount: 50,
    issuanceFee: 3.0,
    topUpFee: 3.0,
    serviceFee: 2.0,
    totalCost: 58.0,
    endpoint: "/cards/create/tier/50"
  },
  {
    loadAmount: 100,
    issuanceFee: 3.0,
    topUpFee: 4.0,
    serviceFee: 3.0,
    totalCost: 110.0,
    endpoint: "/cards/create/tier/100"
  },
  {
    loadAmount: 200,
    issuanceFee: 3.0,
    topUpFee: 6.0,
    serviceFee: 5.0,
    totalCost: 214.0,
    endpoint: "/cards/create/tier/200"
  },
  {
    loadAmount: 500,
    issuanceFee: 3.0,
    topUpFee: 12.0,
    serviceFee: 7.0,
    totalCost: 522.0,
    endpoint: "/cards/create/tier/500"
  }
];

export const FUNDING_TIERS: FundingTier[] = [
  {
    fundAmount: 10,
    topUpFee: 2.2,
    serviceFee: 2.0,
    totalCost: 14.2,
    endpoint: "/cards/fund/tier/10"
  },
  {
    fundAmount: 25,
    topUpFee: 2.5,
    serviceFee: 2.0,
    totalCost: 29.5,
    endpoint: "/cards/fund/tier/25"
  },
  {
    fundAmount: 50,
    topUpFee: 3.0,
    serviceFee: 2.0,
    totalCost: 55.0,
    endpoint: "/cards/fund/tier/50"
  },
  {
    fundAmount: 100,
    topUpFee: 4.0,
    serviceFee: 3.0,
    totalCost: 107.0,
    endpoint: "/cards/fund/tier/100"
  },
  {
    fundAmount: 200,
    topUpFee: 6.0,
    serviceFee: 5.0,
    totalCost: 211.0,
    endpoint: "/cards/fund/tier/200"
  },
  {
    fundAmount: 500,
    topUpFee: 12.0,
    serviceFee: 7.0,
    totalCost: 519.0,
    endpoint: "/cards/fund/tier/500"
  }
];

/**
 * Convert USD to Stellar USDC atomic units (7 decimal places).
 * Stellar uses 7 decimals for all assets (including USDC).
 * $1.00 = 10_000_000 atomic | $17.20 = 172_000_000 atomic
 */
export const toAtomicUsdc = (usd: number): string =>
  Math.round(usd * 10_000_000).toString();

export const findCreationTier = (amount: number): CreationTier | undefined =>
  CREATION_TIERS.find((tier) => tier.loadAmount === amount);

export const findFundingTier = (amount: number): FundingTier | undefined =>
  FUNDING_TIERS.find((tier) => tier.fundAmount === amount);


path: api/src/db/db.ts
content: import { appLogger } from "../utils/logger";
import { Pool, type PoolConfig, type QueryResultRow } from "pg";

let pool: Pool | null = null;

/**
 * Get or create the singleton Postgres pool.
 * Only call when REPO_MODE=postgres (i.e. DATABASE_URL is set).
 */
export function getPool(): Pool {
    if (!pool) {
        const databaseUrl = process.env.DATABASE_URL;
        if (!databaseUrl) {
            throw new Error(
                "DATABASE_URL is required when REPO_MODE=postgres"
            );
        }

        const config: PoolConfig = {
            connectionString: databaseUrl,
            max: 10,
            idleTimeoutMillis: 30_000,
            connectionTimeoutMillis: 5_000
        };

        // Disable SSL verification for local/testing (Supabase handles SSL)
        if (
            process.env.NODE_ENV === "development" ||
            process.env.NODE_ENV === "test"
        ) {
            config.ssl = databaseUrl.includes("localhost")
                ? false
                : { rejectUnauthorized: false };
        }

        pool = new Pool(config);

        pool.on("error", (err) => {
            appLogger.error({ err: err.message }, "[DB] Unexpected pool error:");
        });
    }

    return pool;
}

/**
 * Convenience query function.
 */
export async function query<T extends QueryResultRow = QueryResultRow>(
    text: string,
    params?: unknown[]
): Promise<T[]> {
    const result = await getPool().query<T>(text, params);
    return result.rows;
}

/**
 * Gracefully close the pool (for test cleanup / shutdown).
 */
export async function closePool(): Promise<void> {
    if (pool) {
        await pool.end();
        pool = null;
    }
}


path: api/src/db/migrations/001_create_tables.sql
content: -- ASG Card — Database Migration 001: Core Tables
-- Applies to: PostgreSQL / Supabase
-- Created: 2026-02-27
-- Ticket: PLAT-003

-- ── Enable required extensions ─────────────────────────────
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- ── Cards ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cards (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  card_id         TEXT UNIQUE NOT NULL,
  wallet_address  TEXT NOT NULL,
  name_on_card    TEXT NOT NULL,
  email           TEXT NOT NULL,
  balance         NUMERIC(12, 2) NOT NULL DEFAULT 0,
  initial_amount  NUMERIC(12, 2) NOT NULL,
  status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'frozen')),
  details_encrypted BYTEA,  -- AES-256-GCM encrypted card details
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_cards_wallet ON cards (wallet_address);
CREATE INDEX idx_cards_status ON cards (status);

-- ── Payments ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS payments (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tx_hash     TEXT UNIQUE NOT NULL,
  payer       TEXT NOT NULL,
  amount      TEXT NOT NULL,       -- atomic USDC (string to avoid floating point)
  tier_amount INTEGER NOT NULL,
  status      TEXT NOT NULL DEFAULT 'proof_received'
                CHECK (status IN (
                  'proof_received', 'verified', 'settled',
                  'settle_failed', 'verify_failed'
                )),
  settle_id   TEXT,
  card_id     TEXT REFERENCES cards(card_id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_payments_payer ON payments (payer);
CREATE INDEX idx_payments_status ON payments (status);
CREATE INDEX idx_payments_card ON payments (card_id);

-- ── Webhook Events ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS webhook_events (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  idempotency_key  TEXT UNIQUE NOT NULL,
  event_type       TEXT NOT NULL,
  payload          JSONB NOT NULL,
  processed_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_webhook_events_type ON webhook_events (event_type);

-- ── Updated-at trigger ─────────────────────────────────────
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER set_updated_at_cards
  BEFORE UPDATE ON cards
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

CREATE TRIGGER set_updated_at_payments
  BEFORE UPDATE ON payments
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();


path: api/src/db/migrations/002_api_metrics.sql
content: -- ASG Card — Migration 002: API Metrics (observability)
-- Non-blocking, append-only event log for rollout monitoring
-- Created: 2026-02-27

CREATE TABLE IF NOT EXISTS api_metrics (
  id          BIGSERIAL PRIMARY KEY,
  event_type  TEXT NOT NULL,
  latency_ms  INTEGER,
  source      TEXT,             -- e.g. '4payments' for webhook source identification
  metadata    JSONB DEFAULT '{}',
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Fast aggregation queries for /ops/metrics
CREATE INDEX idx_metrics_type_time ON api_metrics (event_type, created_at DESC);
CREATE INDEX idx_metrics_created   ON api_metrics (created_at DESC);


path: api/src/db/migrations/003_bot_tables.sql
content: -- ============================================================
-- 003_bot_tables.sql — ASGAgentBot + Owner Cabinet
-- 5 tables for Telegram binding, event processing, audit
-- ============================================================

-- 1. Owner <-> Telegram binding
CREATE TABLE IF NOT EXISTS owner_telegram_links (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_wallet    TEXT NOT NULL,
  telegram_user_id BIGINT NOT NULL,
  chat_id         BIGINT NOT NULL,
  linked_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  revoked_at      TIMESTAMPTZ,
  status          TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active','revoked')),
  UNIQUE (owner_wallet, telegram_user_id)
);

-- 2. One-time link tokens (stored hashed)
CREATE TABLE IF NOT EXISTS telegram_link_tokens (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token_hash      TEXT NOT NULL UNIQUE,
  owner_wallet    TEXT NOT NULL,
  scope           TEXT NOT NULL DEFAULT 'telegram_link',
  expires_at      TIMESTAMPTZ NOT NULL,
  consumed_at     TIMESTAMPTZ,
  created_by_ip   TEXT,
  status          TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending','consumed','expired','revoked'))
);

-- 3. Bot event processing (idempotency + replay control)
CREATE TABLE IF NOT EXISTS bot_events (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source          TEXT NOT NULL,
  event_type      TEXT NOT NULL,
  payload_hash    TEXT NOT NULL,
  idempotency_key TEXT NOT NULL UNIQUE,
  processed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  delivery_status TEXT NOT NULL DEFAULT 'pending'
    CHECK (delivery_status IN ('pending','delivered','failed','skipped'))
);

-- 4. Bot message delivery log (correlation + tracking)
CREATE TABLE IF NOT EXISTS bot_messages (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id         BIGINT NOT NULL,
  template_key    TEXT NOT NULL,
  correlation_id  TEXT,
  telegram_msg_id BIGINT,
  sent_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  status          TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending','sent','failed'))
);

-- 5. Authz audit trail
CREATE TABLE IF NOT EXISTS authz_audit_log (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  actor_type  TEXT NOT NULL,
  actor_id    TEXT NOT NULL,
  action      TEXT NOT NULL,
  resource_id TEXT,
  decision    TEXT NOT NULL CHECK (decision IN ('allow','deny')),
  reason      TEXT,
  ip_address  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- ── Indexes ──────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_links_wallet ON owner_telegram_links(owner_wallet) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_links_tg ON owner_telegram_links(telegram_user_id) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_tokens_hash ON telegram_link_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_tokens_wallet ON telegram_link_tokens(owner_wallet) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_bot_events_key ON bot_events(idempotency_key);
CREATE INDEX IF NOT EXISTS idx_bot_messages_chat ON bot_messages(chat_id, sent_at DESC);
CREATE INDEX IF NOT EXISTS idx_bot_messages_corr ON bot_messages(correlation_id) WHERE correlation_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_actor ON authz_audit_log(actor_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_action ON authz_audit_log(action, created_at DESC);

-- ── RLS (tables owned by service role, no direct user access) ──
ALTER TABLE owner_telegram_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE telegram_link_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE bot_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE bot_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE authz_audit_log ENABLE ROW LEVEL SECURITY;


path: api/src/db/migrations/004_agent_access.sql
content: -- Migration 004: Agent access control
-- REALIGN-005: Owner can revoke agent access to card details
-- REALIGN-003: Nonce dedup table for anti-replay

-- ── Agent nonce dedup table ────────────────────────────────
CREATE TABLE IF NOT EXISTS agent_nonces (
  nonce       TEXT PRIMARY KEY,
  wallet      TEXT NOT NULL,
  card_id     TEXT NOT NULL,
  created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_nonces_created ON agent_nonces(created_at);
CREATE INDEX IF NOT EXISTS idx_nonces_wallet ON agent_nonces(wallet);

-- ── Add details_revoked column to cards ────────────────────
ALTER TABLE cards ADD COLUMN IF NOT EXISTS details_revoked BOOLEAN DEFAULT false;

-- ── Enable RLS on new table ────────────────────────────────
ALTER TABLE agent_nonces ENABLE ROW LEVEL SECURITY;


path: api/src/db/migrations/005_nonce_retention.sql
content: -- Migration 005: Nonce retention TTL cleanup
-- Prevents agent_nonces from growing indefinitely.
-- Deletes nonces older than retention window (default 24h) in safe batches.

-- Function: cleanup_expired_nonces(retention_hours, batch_size)
CREATE OR REPLACE FUNCTION cleanup_expired_nonces(
    retention_hours INT DEFAULT 24,
    batch_limit INT DEFAULT 10000
)
RETURNS TABLE(deleted_count BIGINT) AS $$
BEGIN
    RETURN QUERY
    WITH expired AS (
        SELECT nonce
        FROM agent_nonces
        WHERE created_at < now() - (retention_hours || ' hours')::INTERVAL
        ORDER BY created_at ASC
        LIMIT batch_limit
        FOR UPDATE SKIP LOCKED
    ),
    deleted AS (
        DELETE FROM agent_nonces
        WHERE nonce IN (SELECT nonce FROM expired)
        RETURNING 1
    )
    SELECT COUNT(*)::BIGINT AS deleted_count FROM deleted;
END;
$$ LANGUAGE plpgsql;

-- Optional: pg_cron schedule (requires pg_cron extension, run manually if unavailable)
-- SELECT cron.schedule('nonce-cleanup', '0 * * * *', $$SELECT cleanup_expired_nonces(24, 10000)$$);

COMMENT ON FUNCTION cleanup_expired_nonces IS
    'Batch-deletes expired agent nonces. Safe for concurrent use (SKIP LOCKED). '
    'Call via /ops/nonce-cleanup or pg_cron.';


path: api/src/db/migrations/006_card_reveal_tokens.sql
content: -- ============================================================
-- 006_card_reveal_tokens.sql — Secure one-time card reveal links
-- P0-2 FIX: Table was referenced in myCards.ts but never created
-- ============================================================

-- One-time reveal tokens for secure PAN/CVV display
CREATE TABLE IF NOT EXISTS card_reveal_tokens (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token           TEXT NOT NULL UNIQUE,
  card_id         TEXT NOT NULL,
  wallet_address  TEXT NOT NULL,
  expires_at      TIMESTAMPTZ NOT NULL,
  consumed_at     TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  status          TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending','consumed','expired'))
);

-- ── Indexes ──────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_reveal_token ON card_reveal_tokens(token) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_reveal_card ON card_reveal_tokens(card_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_reveal_expires ON card_reveal_tokens(expires_at) WHERE status = 'pending';

-- ── RLS ──────────────────────────────────────────────────────
ALTER TABLE card_reveal_tokens ENABLE ROW LEVEL SECURITY;

-- ── Also fix bot_events: add created_at column alias for metrics queries (P1-4) ──
-- The table uses 'processed_at' but metricsService.ts queries 'created_at'
DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name = 'bot_events' AND column_name = 'created_at'
  ) THEN
    ALTER TABLE bot_events ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now();
  END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_bot_events_created ON bot_events(created_at);


path: api/src/db/migrations/007_api_activity.sql
content: -- Migration: Add daily active agents tracking
-- Lightweight table to track unique API callers per day for DAA metric.

CREATE TABLE IF NOT EXISTS api_activity (
    id            SERIAL PRIMARY KEY,
    wallet_address TEXT NOT NULL,
    request_date  DATE NOT NULL DEFAULT CURRENT_DATE,
    request_count INT NOT NULL DEFAULT 1,
    first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_seen_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (wallet_address, request_date)
);

-- Index for fast DAA lookups
CREATE INDEX IF NOT EXISTS idx_api_activity_date ON api_activity (request_date);

-- Upsert function: call on each API request to track activity
-- Usage: SELECT upsert_api_activity('GWALLET...');
CREATE OR REPLACE FUNCTION upsert_api_activity(p_wallet TEXT)
RETURNS VOID AS $$
BEGIN
  INSERT INTO api_activity (wallet_address, request_date, request_count)
  VALUES (p_wallet, CURRENT_DATE, 1)
  ON CONFLICT (wallet_address, request_date)
  DO UPDATE SET request_count = api_activity.request_count + 1,
                last_seen_at = NOW();
END;
$$ LANGUAGE plpgsql;


path: api/src/db/migrations/008_last_four.sql
content: -- Migration: Add last_four column to cards table
-- Stores unencrypted last 4 digits for display (non-sensitive).

ALTER TABLE cards ADD COLUMN IF NOT EXISTS last_four TEXT;

-- Index for potential future lookups
CREATE INDEX IF NOT EXISTS idx_cards_last_four ON cards (last_four);


path: api/src/db/migrations/009_profile_fields.sql
content: -- Migration: Add email and phone to owner_telegram_links for profile management

ALTER TABLE owner_telegram_links
  ADD COLUMN IF NOT EXISTS email TEXT,
  ADD COLUMN IF NOT EXISTS phone TEXT;


path: api/src/index.ts
content: import { appLogger } from "./utils/logger";
import "dotenv/config";
import { createApp } from "./app";
import { env } from "./config/env";

(async () => {
  const app = await createApp();

  app.listen(env.PORT, () => {
    appLogger.info(`ASG Card API listening on http://localhost:${env.PORT}`);
  });
})();


path: api/src/middleware/agentDetailsMiddleware.ts
content: import { appLogger } from "../utils/logger";
import type { Request, Response, NextFunction } from "express";
import crypto from "node:crypto";
import { cardRepository } from "../repositories/runtime";
import { env } from "../config/env";

/**
 * REALIGN-003: Nonce + anti-replay + rate-limit middleware
 * for agent card details access.
 *
 * - Requires X-AGENT-NONCE header (UUID v4 format)
 * - Rejects if nonce was already used (anti-replay)
 * - Rate limit: controlled by DETAILS_READ_LIMIT_PER_HOUR
 */

const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export const requireAgentNonce = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    const nonce = req.header("X-AGENT-NONCE");

    if (!nonce) {
        res.status(400).json({
            error: "Missing X-AGENT-NONCE header",
            hint: "Generate a UUID v4 nonce for each details request"
        });
        return;
    }

    // Validate UUID v4 format
    if (!UUID_V4_REGEX.test(nonce)) {
        res.status(400).json({ error: "X-AGENT-NONCE must be UUID v4 format" });
        return;
    }

    const cardId = req.params.cardId;
    const walletAddress = (req as any).walletContext?.address;

    if (!cardId || !walletAddress) {
        // Fallback if applied improperly, though wallet auth ensures context
        res.status(500).json({ error: "Context missing for nonce tracking" });
        return;
    }

    try {
        const result = await cardRepository.recordNonceAndCheckRateLimit(
            walletAddress,
            cardId,
            nonce,
            env.DETAILS_READ_LIMIT_PER_HOUR
        );

        if (!result.allowed) {
            if (result.reason === 'replay') {
                res.status(409).json({
                    error: "Nonce already used (replay detected)",
                    code: "REPLAY_REJECTED"
                });
                return;
            }
            if (result.reason === 'rate_limit') {
                res.status(429).json({
                    error: `Card details rate limit exceeded (${env.DETAILS_READ_LIMIT_PER_HOUR} requests/hour)`,
                    retryAfterSeconds: result.retryAfterSeconds
                });
                return;
            }
        }
        next();
    } catch (e) {
        appLogger.error({ err: e }, "Nonce Tracking Error:");
        res.status(500).json({ error: "Internal server error" });
    }
};

/**
 * Hash a nonce for audit logging (don't store raw nonces in logs)
 */
export const hashNonce = (nonce: string): string =>
    crypto.createHash("sha256").update(nonce).digest("hex").slice(0, 16);


path: api/src/middleware/walletAuth.ts
content: import type { RequestHandler } from "express";
import { StrKey } from "@stellar/stellar-sdk";
import nacl from "tweetnacl";
import { query } from "../db/db";

/** Lightweight DAA tracking — upsert one row per wallet per day */
async function trackApiActivity(walletAddress: string): Promise<void> {
    await query(
        `INSERT INTO api_activity (wallet_address, request_date, request_count)
         VALUES ($1, CURRENT_DATE, 1)
         ON CONFLICT (wallet_address, request_date)
         DO UPDATE SET request_count = api_activity.request_count + 1,
                       last_seen_at = NOW()`,
        [walletAddress]
    );
}

const MAX_CLOCK_DRIFT_SECONDS = 300;

/**
 * Try base64 first (canonical), fall back to base58 for backward compat.
 * Per P0 decision: base64 is the canonical format going forward.
 */
const decodeSignature = (signatureStr: string): Uint8Array => {
  // Try base64 first (canonical)
  try {
    const buf = Buffer.from(signatureStr, "base64");
    if (buf.length === 64) return new Uint8Array(buf);
  } catch {
    // not base64
  }

  // Fallback: base58 (legacy Solana-era format)
  try {
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const bs58Default = require("bs58").default as { decode: (input: string) => Uint8Array };
    const decoded = bs58Default.decode(signatureStr);
    if (decoded.length === 64) return new Uint8Array(decoded);
  } catch {
    // not base58 either
  }

  throw new Error("Signature must be base64 (preferred) or base58 encoded");
};

export const requireWalletAuth: RequestHandler = (req, res, next) => {
  const address = req.header("X-WALLET-ADDRESS");
  const signatureEncoded = req.header("X-WALLET-SIGNATURE");
  const timestampHeader = req.header("X-WALLET-TIMESTAMP");

  if (!address || !signatureEncoded || !timestampHeader) {
    res.status(401).json({ error: "Missing wallet authentication headers" });
    return;
  }

  const timestamp = Number(timestampHeader);
  if (!Number.isFinite(timestamp)) {
    res.status(401).json({ error: "Invalid wallet timestamp" });
    return;
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > MAX_CLOCK_DRIFT_SECONDS) {
    res.status(401).json({ error: "Wallet timestamp outside accepted window" });
    return;
  }

  try {
    // Validate Stellar address with full checksum + version byte check
    if (!StrKey.isValidEd25519PublicKey(address)) {
      res.status(401).json({ error: "Invalid Stellar public key" });
      return;
    }

    const pubkeyBytes = StrKey.decodeEd25519PublicKey(address);
    const signature = decodeSignature(signatureEncoded);
    const message = new TextEncoder().encode(`asgcard-auth:${timestamp}`);
    const verified = nacl.sign.detached.verify(message, signature, pubkeyBytes);

    if (!verified) {
      res.status(401).json({ error: "Invalid wallet signature" });
      return;
    }

    req.walletContext = {
      address,
      timestamp
    };

    // Track DAA (Daily Active Agents) — fire-and-forget
    trackApiActivity(address).catch(() => {});

    next();
  } catch {
    res.status(401).json({ error: "Invalid wallet authentication payload" });
  }
};


path: api/src/middleware/x402.ts
content: import type { Request, Response, NextFunction } from "express";
import crypto from "node:crypto";
import { env } from "../config/env";
import {
  findCreationTier,
  findFundingTier,
  toAtomicUsdc
} from "../config/pricing";
import { paymentService } from "../services/paymentService";
import { emitMetric, startTimer } from "../services/metrics";
import type {
  PaymentPayload,
  PaymentRequired,
  PaymentRequirements,
} from "../types/x402";

type PaidPurpose = "create" | "fund";

const getChallengeDescription = (purpose: PaidPurpose, amount: number): string => {
  if (purpose === "create") {
    return `Create ASG Card with $${amount} load`;
  }
  return `Fund ASG Card with $${amount}`;
};

// ── Rollout gate ─────────────────────────────────────────────

/**
 * Deterministic hash-based rollout check.
 * Uses SHA-256(payer) mod 100 < ROLLOUT_PCT.
 * Requires a valid payer identifier; requests without one are excluded
 * from the paid path (never routed through gate).
 */
function isInRollout(payer: string): boolean {
  // Kill-switch — immediate off
  if (env.ROLLOUT_ENABLED !== "true") return false;

  // ROLLOUT_PCT=0 — all gated
  if (env.ROLLOUT_PCT <= 0) return false;

  // ROLLOUT_PCT >= 100 — all pass
  if (env.ROLLOUT_PCT >= 100) return true;

  // Deterministic: SHA-256(payer) mod 100 < ROLLOUT_PCT
  const hash = crypto.createHash("sha256").update(payer).digest();
  const bucket = hash.readUInt16BE(0) % 100;
  return bucket < env.ROLLOUT_PCT;
}

/**
 * Build x402 v2 PaymentRequired (402 response)
 */
const buildChallenge = (
  req: Request,
  amount: number,
  requiredAtomic: string,
  purpose: PaidPurpose
): PaymentRequired => ({
  x402Version: 2,
  resource: {
    url: `https://${req.get("host")}${req.originalUrl}`,
    description: getChallengeDescription(purpose, amount),
    mimeType: "application/json",
  },
  accepts: [
    {
      scheme: "exact",
      network: env.STELLAR_NETWORK,
      amount: requiredAtomic,
      payTo: env.STELLAR_TREASURY_ADDRESS,
      maxTimeoutSeconds: 300,
      asset: env.STELLAR_USDC_ASSET,
      extra: { areFeesSponsored: true },
    }
  ]
});

/**
 * Parse v2 PaymentPayload from X-PAYMENT header.
 * Header value is Base64-encoded JSON: {x402Version, accepted, payload:{transaction}}
 */
const parsePaymentPayload = (headerValue: string): PaymentPayload | null => {
  try {
    // Try raw JSON first
    let parsed: unknown;
    try {
      parsed = JSON.parse(headerValue);
    } catch {
      // Try base64 decode
      const decoded = Buffer.from(headerValue, "base64").toString("utf8");
      parsed = JSON.parse(decoded);
    }

    const pp = parsed as PaymentPayload;

    // Validate v2 structure
    if (pp.x402Version !== 2) return null;
    if (!pp.accepted || !pp.payload) return null;
    if (!("transaction" in pp.payload) || typeof pp.payload.transaction !== "string") return null;

    return pp;
  } catch {
    return null;
  }
};

export const requireX402Payment = (purpose: PaidPurpose) => {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    const elapsed = startTimer();
    const metricEvent = purpose === "create" ? "request_create" as const : "request_fund" as const;

    const amount = Number(req.params.amount);
    const tier = purpose === "create" ? findCreationTier(amount) : findFundingTier(amount);

    if (!tier) {
      res.status(400).json({ error: "Unsupported tier amount" });
      return;
    }

    const requiredAtomic = toAtomicUsdc(tier.totalCost);

    // ── Check for payment header (canonical X-PAYMENT, legacy X-Payment) ──
    const paymentHeader = req.header("X-PAYMENT") ?? req.header("X-Payment");

    if (!paymentHeader) {
      res.status(402).json(buildChallenge(req, amount, requiredAtomic, purpose));
      return;
    }

    // ── Parse v2 PaymentPayload ──────────────────────
    const paymentPayload = parsePaymentPayload(paymentHeader);

    if (!paymentPayload) {
      res.status(401).json({ error: "Invalid X-PAYMENT header: expected x402 v2 PaymentPayload" });
      return;
    }

    // ── Validate accepted requirements match ours ────
    const accepted = paymentPayload.accepted;
    if (accepted.scheme !== "exact" || accepted.network !== env.STELLAR_NETWORK) {
      res.status(401).json({ error: "Unsupported payment scheme or network" });
      return;
    }

    if (accepted.payTo !== env.STELLAR_TREASURY_ADDRESS) {
      res.status(401).json({ error: "Payment recipient mismatch" });
      return;
    }

    // ── Rollout gate ─────────────────────────────────
    // Use payTo as deterministic payer identifier
    const payerIdentifier = accepted.payTo;
    if (!isInRollout(payerIdentifier)) {
      const reason = env.ROLLOUT_ENABLED !== "true"
        ? "rollout_kill_switch"
        : "rollout_gated";
      emitMetric({
        eventType: reason === "rollout_kill_switch" ? "rollout_kill_switch" : "rollout_gated",
        metadata: { pct: env.ROLLOUT_PCT }
      });
      res.status(503)
        .set("Retry-After", "300")
        .json({ error: "Service temporarily unavailable", retryAfter: 300 });
      return;
    }

    // ── Build server-side PaymentRequirements ────────
    const paymentRequirements: PaymentRequirements = {
      scheme: "exact",
      network: env.STELLAR_NETWORK,
      amount: requiredAtomic,
      payTo: env.STELLAR_TREASURY_ADDRESS,
      maxTimeoutSeconds: 300,
      asset: env.STELLAR_USDC_ASSET,
      extra: { areFeesSponsored: true },
    };

    // ── Verify + Settle via facilitator ──────────────
    const result = await paymentService.verifyAndSettle({
      paymentPayload,
      paymentRequirements,
      payer: accepted.payTo,  // will be overridden by facilitator's payer field
      tierAmount: amount as 10 | 25 | 50 | 100 | 200 | 500,
    });

    if (!result.valid) {
      const isFacilitatorDown = result.error?.includes("Payment verification failed:");
      const statusCode = isFacilitatorDown ? 503 : 401;
      emitMetric({
        eventType: metricEvent,
        latencyMs: elapsed(),
        metadata: { status: statusCode, error: result.error?.substring(0, 100) }
      });
      res.status(statusCode).json({
        error: result.error ?? "Payment verification failed"
      });
      return;
    }

    // ── Success: emit metric + populate context ─────
    emitMetric({
      eventType: metricEvent,
      latencyMs: elapsed(),
      metadata: { status: 201 }
    });

    req.paymentContext = {
      payer: result.settleResponse?.payer ?? "",
      txHash: result.settleResponse?.transaction ?? "",
      atomicAmount: requiredAtomic,
      tierAmount: amount as 10 | 25 | 50 | 100 | 200 | 500,
      totalCostUsd: tier.totalCost
    };

    next();
  };
};


path: api/src/modules/admin/adminBot.ts
content: /**
 * Admin Bot — sends operational notifications to admin Telegram chat.
 *
 * A separate bot from @ASGCardbot. Receives all system events:
 * - 4payments webhook events (transactions, card issues, fees)
 * - Card creation & funding
 * - New account bindings
 * - Error alerts
 * - Bot command activity
 *
 * @module modules/admin
 */

import { TelegramClient } from "../bot/telegramClient";
import { env } from "../../config/env";
import { appLogger } from "../../utils/logger";

// ── Singleton ──────────────────────────────────────────────

let adminClient: TelegramClient | null = null;

function getAdminClient(): TelegramClient | null {
    if (env.ADMIN_BOT_ENABLED !== "true" || !env.ADMIN_BOT_TOKEN) return null;
    if (!adminClient) {
        adminClient = new TelegramClient(env.ADMIN_BOT_TOKEN);
    }
    return adminClient;
}

function getAdminChatIds(): number[] {
    if (!env.ADMIN_CHAT_ID) return [];
    return env.ADMIN_CHAT_ID.split(",").map((id) => Number(id.trim())).filter((id) => !isNaN(id) && id !== 0);
}

// ── Public API ─────────────────────────────────────────────

export const AdminBot = {
    /**
     * Send a raw message to the admin chat.
     * Safe to call even if admin bot is disabled — silently skips.
     */
    async send(text: string, parseMode: "HTML" | "Markdown" = "HTML"): Promise<void> {
        const client = getAdminClient();
        const chatIds = getAdminChatIds();
        if (!client || chatIds.length === 0) return;

        for (const chatId of chatIds) {
            try {
                await client.sendMessage({
                    chat_id: chatId,
                    text,
                    parse_mode: parseMode,
                });
            } catch (err) {
                appLogger.error({ err, chatId }, "[AdminBot] Failed to send message");
            }
        }
    },

    // ── Card Events ────────────────────────────────────────

    async cardCreated(data: {
        cardId: string;
        wallet: string;
        tier: number;
        balance: number;
        last4: string;
    }): Promise<void> {
        await this.send(
            `🆕 <b>Card Created</b>\n` +
            `├ Card: <code>xxxx ${data.last4}</code>\n` +
            `├ Tier: $${data.tier}\n` +
            `├ Balance: $${data.balance.toFixed(2)}\n` +
            `├ Wallet: <code>${data.wallet.slice(0, 8)}…${data.wallet.slice(-4)}</code>\n` +
            `└ ID: <code>${data.cardId.slice(0, 8)}</code>`
        );
    },

    async cardFunded(data: {
        cardId: string;
        amount: number;
        newBalance: number;
        last4: string;
        txHash: string;
    }): Promise<void> {
        await this.send(
            `💰 <b>Card Funded</b>\n` +
            `├ Card: <code>xxxx ${data.last4}</code>\n` +
            `├ Amount: +$${data.amount.toFixed(2)}\n` +
            `├ New Balance: $${data.newBalance.toFixed(2)}\n` +
            `└ TX: <code>${data.txHash.slice(0, 12)}…</code>`
        );
    },

    async cardFrozen(cardId: string, last4: string, by: string): Promise<void> {
        await this.send(
            `❄️ <b>Card Frozen</b>\n` +
            `├ Card: <code>xxxx ${last4}</code>\n` +
            `├ By: <code>${by}</code>\n` +
            `└ ID: <code>${cardId.slice(0, 8)}</code>`
        );
    },

    async cardUnfrozen(cardId: string, last4: string, by: string): Promise<void> {
        await this.send(
            `🔥 <b>Card Unfrozen</b>\n` +
            `├ Card: <code>xxxx ${last4}</code>\n` +
            `├ By: <code>${by}</code>\n` +
            `└ ID: <code>${cardId.slice(0, 8)}</code>`
        );
    },

    // ── Webhook Events (from 4payments) ────────────────────

    async webhookReceived(data: {
        type: string;
        idempotencyKey: string;
        payload: Record<string, unknown>;
    }): Promise<void> {
        const summary = JSON.stringify(data.payload, null, 0).slice(0, 200);
        await this.send(
            `📨 <b>4P Webhook</b>: <code>${data.type}</code>\n` +
            `├ Key: <code>${data.idempotencyKey.slice(0, 16)}</code>\n` +
            `└ <pre>${escapeHtml(summary)}</pre>`
        );
    },

    async webhookDuplicate(_type: string, _key: string): Promise<void> {
        // Silently skip — duplicates are normal idempotency (not worth admin noise)
    },

    async webhookSigFailure(ip: string): Promise<void> {
        await this.send(
            `🚫 <b>Webhook Signature Failed</b>\n` +
            `└ IP: <code>${ip}</code>`
        );
    },

    // ── Account Linking ────────────────────────────────────

    async accountLinked(data: {
        wallet: string;
        telegramUserId: number;
        username?: string;
    }): Promise<void> {
        const user = data.username ? `@${data.username}` : `ID:${data.telegramUserId}`;
        await this.send(
            `🔗 <b>Account Linked</b>\n` +
            `├ TG: ${escapeHtml(user)}\n` +
            `└ Wallet: <code>${data.wallet.slice(0, 8)}…${data.wallet.slice(-4)}</code>`
        );
    },

    // ── Transaction Alerts (mirror) ────────────────────────

    async transaction(data: {
        type: string;
        last4: string;
        amount: number;
        merchant?: string;
        balance?: number;
        reason?: string;
    }): Promise<void> {
        const icon = data.type.includes("decline") ? "❌" :
                     data.type.includes("refund") ? "↩️" :
                     data.type.includes("fee") ? "💸" : "💳";
        await this.send(
            `${icon} <b>Transaction</b>: ${escapeHtml(data.type)}\n` +
            `├ Card: xxxx ${data.last4}\n` +
            `├ Amount: $${data.amount.toFixed(2)}\n` +
            (data.merchant ? `├ Merchant: ${escapeHtml(data.merchant)}\n` : "") +
            (data.balance != null ? `├ Balance: $${data.balance.toFixed(2)}\n` : "") +
            (data.reason ? `├ Reason: ${escapeHtml(data.reason)}\n` : "") +
            `└ Type: <code>${data.type}</code>`
        );
    },

    // ── Bot Commands ───────────────────────────────────────

    async botCommand(userId: number, command: string, username?: string): Promise<void> {
        // Only log financially-relevant commands, skip routine ones
        const routineCommands = ["/start", "/mycards", "/help", "/faq"];
        const cmd = command.split(" ")[0].toLowerCase();
        if (routineCommands.includes(cmd)) return;

        const user = username ? `@${username}` : `ID:${userId}`;
        await this.send(
            `🤖 <b>Bot Command</b>\n` +
            `├ User: ${escapeHtml(user)}\n` +
            `└ Cmd: <code>${escapeHtml(command)}</code>`
        );
    },

    async rateLimited(userId: number): Promise<void> {
        await this.send(
            `⚠️ <b>Rate Limited</b>\n` +
            `└ User ID: <code>${userId}</code>`
        );
    },

    // ── Errors ─────────────────────────────────────────────

    async error(context: string, error: Error | string): Promise<void> {
        const msg = typeof error === "string" ? error : error.message;
        await this.send(
            `🔴 <b>Error</b>: ${escapeHtml(context)}\n` +
            `└ <pre>${escapeHtml(msg.slice(0, 500))}</pre>`
        );
    },

    // ── System ─────────────────────────────────────────────

    // startup() removed — fires on every Vercel cold start, creating spam
    // deploy() removed — not called from anywhere

    /** Expose the underlying TelegramClient for advanced usage (e.g. admin webhook) */
    getClient: getAdminClient,
    getChatIds: getAdminChatIds,
};

// ── Helpers ────────────────────────────────────────────────

import { escapeHtml } from "../../utils/html";


path: api/src/modules/admin/index.ts
content: /**
 * Admin Module — public interface
 *
 * Admin bot for operational notifications.
 * Separate from @ASGCardbot — uses its own token.
 *
 * @module modules/admin
 */
export { adminRouter } from "./webhook";
export { AdminBot } from "./adminBot";


path: api/src/modules/admin/statusCollector.ts
content: /**
 * Admin Status Collector — gathers system metrics for /status command.
 *
 * Data sources:
 * - Stellar Horizon API → Treasury USDC balance
 * - 4Payments API → Account balance
 * - PostgreSQL → Cards, clients, webhooks, revenue
 *
 * All queries are fail-safe: return null on error to avoid crashing /status.
 *
 * @module modules/admin/statusCollector
 */

import { env } from "../../config/env";
import { query } from "../../db/db";
import { getFourPaymentsClient } from "../../services/fourPaymentsClient";
import { appLogger } from "../../utils/logger";

// ── Types ──────────────────────────────────────────────────

export interface AdminStatus {
    finances: {
        treasuryUsdc: number | null;
        fourPaymentsBalance: number | null;
        totalRevenue: number | null;
        totalVolume: number | null;
    };
    clients: {
        total: number | null;
        linked: number | null;
        daaToday: number | null;
        daa7d: number | null;
    };
    cards: {
        total: number | null;
        active: number | null;
        frozen: number | null;
        last24h: number | null;
    };
    system: {
        uptimeSeconds: number;
        memoryMb: number;
        nodeVersion: string;
        webhooks24h: number | null;
        lastWebhookAgo: string | null;
        errors24h: number | null;
    };
}

// ── Collectors ─────────────────────────────────────────────

/**
 * Fetch Treasury USDC balance from Stellar Horizon API.
 * GET {HORIZON_URL}/accounts/{address}
 */
async function getTreasuryBalance(): Promise<number | null> {
    try {
        const url = `${env.STELLAR_HORIZON_URL}/accounts/${env.STELLAR_TREASURY_ADDRESS}`;
        const res = await fetch(url, {
            headers: { Accept: "application/json" },
            signal: AbortSignal.timeout(5000),
        });

        if (!res.ok) return null;

        const data = (await res.json()) as {
            balances: Array<{
                asset_type: string;
                asset_code?: string;
                asset_issuer?: string;
                balance: string;
            }>;
        };

        // Find USDC balance (classic or SAC)
        const usdc = data.balances.find(
            (b) =>
                b.asset_code === "USDC" &&
                (b.asset_type === "credit_alphanum4" || b.asset_type === "credit_alphanum12")
        );

        return usdc ? parseFloat(usdc.balance) : 0;
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] Treasury balance fetch failed");
        return null;
    }
}

/**
 * Fetch 4Payments account balance.
 */
async function get4PaymentsBalance(): Promise<number | null> {
    try {
        const fp = getFourPaymentsClient();
        const result = await fp.getAccountBalance();
        return result.balance;
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] 4Payments balance fetch failed");
        return null;
    }
}

/**
 * Card statistics from the cards table.
 */
async function getCardStats(): Promise<{
    total: number | null;
    active: number | null;
    frozen: number | null;
    last24h: number | null;
    uniqueWallets: number | null;
    totalVolume: number | null;
    totalRevenue: number | null;
}> {
    try {
        const rows = await query<{
            total: string;
            active: string;
            frozen: string;
            last24h: string;
            unique_wallets: string;
            total_volume: string;
            total_revenue: string;
        }>(`
            SELECT
                COUNT(*) as total,
                COUNT(*) FILTER (WHERE status = 'active') as active,
                COUNT(*) FILTER (WHERE status = 'frozen') as frozen,
                COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as last24h,
                COUNT(DISTINCT wallet_address) as unique_wallets,
                COALESCE(SUM(initial_amount), 0) as total_volume,
                COALESCE(SUM(balance - initial_amount), 0) as total_revenue
            FROM cards
        `);

        const r = rows[0];
        return {
            total: parseInt(r.total, 10),
            active: parseInt(r.active, 10),
            frozen: parseInt(r.frozen, 10),
            last24h: parseInt(r.last24h, 10),
            uniqueWallets: parseInt(r.unique_wallets, 10),
            totalVolume: parseFloat(r.total_volume),
            totalRevenue: parseFloat(r.total_revenue),
        };
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] Card stats query failed");
        return {
            total: null,
            active: null,
            frozen: null,
            last24h: null,
            uniqueWallets: null,
            totalVolume: null,
            totalRevenue: null,
        };
    }
}

/**
 * Linked Telegram accounts count.
 */
async function getLinkedAccounts(): Promise<number | null> {
    try {
        const rows = await query<{ count: string }>(
            `SELECT COUNT(*) as count FROM owner_telegram_links WHERE status = 'active'`
        );
        return parseInt(rows[0].count, 10);
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] Telegram bindings query failed");
        return null;
    }
}

/**
 * Webhook event stats from last 24 hours.
 */
async function getWebhookStats(): Promise<{
    count24h: number | null;
    lastAgo: string | null;
    errors24h: number | null;
}> {
    try {
        const rows = await query<{
            total: string;
            last_received: string | null;
        }>(`
            SELECT
                COUNT(*) FILTER (WHERE received_at >= NOW() - INTERVAL '24 hours') as total,
                MAX(received_at) as last_received
            FROM webhook_events
        `);

        const r = rows[0];
        const count24h = parseInt(r.total, 10);

        let lastAgo: string | null = null;
        if (r.last_received) {
            const diffMs = Date.now() - new Date(r.last_received).getTime();
            const mins = Math.floor(diffMs / 60000);
            if (mins < 60) {
                lastAgo = `${mins}m ago`;
            } else {
                const hours = Math.floor(mins / 60);
                lastAgo = `${hours}h ${mins % 60}m ago`;
            }
        }

        return { count24h, lastAgo, errors24h: 0 };
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] Webhook stats query failed");
        return { count24h: null, lastAgo: null, errors24h: null };
    }
}

/**
 * Daily Active Agents — unique wallets active today and in last 7 days.
 */
async function getDailyActiveAgents(): Promise<{ today: number | null; last7d: number | null }> {
    try {
        const rows = await query<{ daa_today: string; daa_7d: string }>(`
            SELECT
                COUNT(DISTINCT wallet_address) FILTER (WHERE request_date = CURRENT_DATE) as daa_today,
                COUNT(DISTINCT wallet_address) FILTER (WHERE request_date >= CURRENT_DATE - INTERVAL '7 days') as daa_7d
            FROM api_activity
        `);
        return {
            today: parseInt(rows[0].daa_today, 10),
            last7d: parseInt(rows[0].daa_7d, 10),
        };
    } catch (err) {
        appLogger.error({ err }, "[StatusCollector] DAA query failed");
        return { today: null, last7d: null };
    }
}

// ── Main Collector ─────────────────────────────────────────

/**
 * Collect all status data in parallel. Each source is independent
 * and fail-safe — a failed source returns null, doesn't block others.
 */
export async function collectStatus(): Promise<AdminStatus> {
    const [
        treasuryUsdc,
        fpBalance,
        cardStats,
        linkedAccounts,
        webhookStats,
        daa,
    ] = await Promise.all([
        getTreasuryBalance(),
        get4PaymentsBalance(),
        getCardStats(),
        getLinkedAccounts(),
        getWebhookStats(),
        getDailyActiveAgents(),
    ]);

    return {
        finances: {
            treasuryUsdc,
            fourPaymentsBalance: fpBalance,
            totalRevenue: cardStats.totalRevenue,
            totalVolume: cardStats.totalVolume,
        },
        clients: {
            total: cardStats.uniqueWallets,
            linked: linkedAccounts,
            daaToday: daa.today,
            daa7d: daa.last7d,
        },
        cards: {
            total: cardStats.total,
            active: cardStats.active,
            frozen: cardStats.frozen,
            last24h: cardStats.last24h,
        },
        system: {
            uptimeSeconds: process.uptime(),
            memoryMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
            nodeVersion: process.version,
            webhooks24h: webhookStats.count24h,
            lastWebhookAgo: webhookStats.lastAgo,
            errors24h: webhookStats.errors24h,
        },
    };
}

// ── Formatter ──────────────────────────────────────────────

function fmt(n: number | null, prefix = "$"): string {
    if (n === null) return "⚠️ N/A";
    if (prefix === "$") return `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
    return n.toLocaleString("en-US");
}

function fmtNum(n: number | null): string {
    if (n === null) return "⚠️ N/A";
    return n.toLocaleString("en-US");
}

/**
 * Format collected status data as a Telegram HTML message.
 */
export function formatStatusMessage(s: AdminStatus): string {
    const hours = Math.floor(s.system.uptimeSeconds / 3600);
    const mins = Math.floor((s.system.uptimeSeconds % 3600) / 60);

    return (
        `📊 <b>ASG Card — System Status</b>\n\n` +

        `💰 <b>Finances</b>\n` +
        `├ Treasury: ${s.finances.treasuryUsdc !== null ? fmt(s.finances.treasuryUsdc, "$") + " USDC" : "⚠️ N/A"}\n` +
        `├ 4Payments: ${fmt(s.finances.fourPaymentsBalance)}\n` +
        `├ Revenue: ${fmt(s.finances.totalRevenue)}\n` +
        `└ Volume: ${fmt(s.finances.totalVolume)}\n\n` +

        `👥 <b>Clients & Cards</b>\n` +
        `├ Wallets: ${fmtNum(s.clients.total)}\n` +
        `├ TG Linked: ${fmtNum(s.clients.linked)}\n` +
        `├ DAA (today): ${fmtNum(s.clients.daaToday)}\n` +
        `├ DAA (7d): ${fmtNum(s.clients.daa7d)}\n` +
        `├ Cards total: ${fmtNum(s.cards.total)}\n` +
        `├ Active: ${fmtNum(s.cards.active)}\n` +
        `├ Frozen: ${fmtNum(s.cards.frozen)}\n` +
        `└ Cards 24h: +${fmtNum(s.cards.last24h)}\n\n` +

        `🔗 <b>System</b>\n` +
        `├ Uptime: ${hours}h ${mins}m\n` +
        `├ Memory: ${s.system.memoryMb} MB\n` +
        `├ Node: ${s.system.nodeVersion}\n` +
        `├ Webhooks 24h: ${fmtNum(s.system.webhooks24h)}\n` +
        `├ Last webhook: ${s.system.lastWebhookAgo ?? "never"}\n` +
        `└ Errors 24h: ${fmtNum(s.system.errors24h)}`
    );
}


path: api/src/modules/admin/webhook.ts
content: /**
 * Admin Bot Webhook — handles Telegram updates for the admin bot.
 *
 * Minimal: only handles /start (to get chat_id) and /status.
 *
 * @module modules/admin/webhook
 */

import crypto from "node:crypto";
import { Router } from "express";
import { env } from "../../config/env";
import { TelegramClient } from "../bot/telegramClient";
import type { TgUpdate } from "../bot/telegramClient";
import { appLogger } from "../../utils/logger";
import { collectStatus, formatStatusMessage } from "./statusCollector";

export const adminRouter = Router();

let adminClient: TelegramClient | null = null;

function getClient(): TelegramClient {
    if (!adminClient) {
        if (!env.ADMIN_BOT_TOKEN) throw new Error("ADMIN_BOT_TOKEN required");
        adminClient = new TelegramClient(env.ADMIN_BOT_TOKEN);
    }
    return adminClient;
}

// ── Helpers ─────────────────────────────────────────────────

function safeEqual(a: string, b: string): boolean {
    const bufA = Buffer.from(a, "utf-8");
    const bufB = Buffer.from(b, "utf-8");
    if (bufA.length !== bufB.length) return false;
    return crypto.timingSafeEqual(bufA, bufB);
}

/** Derive admin webhook secret (must match what setup endpoint registers). */
function getAdminWebhookSecret(): string | null {
    return env.TG_WEBHOOK_SECRET ? `admin_${env.TG_WEBHOOK_SECRET}` : null;
}

// ── Webhook endpoint ───────────────────────────────────────

adminRouter.post("/telegram/webhook", async (req, res) => {
    // 1. Verify Telegram secret token (fail-closed)
    const expectedSecret = getAdminWebhookSecret();
    if (expectedSecret) {
        const headerToken = req.header("X-Telegram-Bot-Api-Secret-Token");
        if (!headerToken || !safeEqual(headerToken, expectedSecret)) {
            appLogger.warn(
                { ip: req.ip },
                "[AdminBot] Invalid webhook signature — rejecting"
            );
            res.status(401).json({ error: "Invalid webhook secret" });
            return;
        }
    }

    // 2. Parse update
    const update = req.body as TgUpdate;

    if (!update || !update.update_id) {
        res.status(400).json({ error: "Invalid update" });
        return;
    }

    try {
        const client = getClient();

        // ── Handle Callback Queries (button clicks) ──────────
        if (update.callback_query) {
            const cbq = update.callback_query;
            const cbChatId = cbq.message?.chat.id;
            if (!cbChatId || !cbq.data) {
                res.status(200).json({ ok: true });
                return;
            }

            // Acknowledge
            await client.answerCallbackQuery({ callback_query_id: cbq.id });

            if (cbq.data === "admin_status") {
                await client.sendMessage({
                    chat_id: cbChatId,
                    text: "⏳ Collecting status…",
                    parse_mode: "HTML",
                });
                const status = await collectStatus();
                const message = formatStatusMessage(status);
                await client.sendMessage({
                    chat_id: cbChatId,
                    text: message,
                    parse_mode: "HTML",
                    reply_markup: adminActionsKeyboard(),
                });
            } else if (cbq.data === "admin_help") {
                await sendHelpMessage(client, cbChatId);
            }

            res.status(200).json({ ok: true });
            return;
        }

        // ── Handle Text Messages (commands) ──────────────────
        const msg = update.message;

        if (msg?.text && msg.from) {
            const chatId = msg.chat.id;
            const text = msg.text.trim();

            if (text === "/start") {
                await client.sendMessage({
                    chat_id: chatId,
                    text:
                        `🛡️ <b>ASG Card Admin Bot</b>\n\n` +
                        `Your Chat ID: <code>${chatId}</code>\n\n` +
                        `Set this as <code>ADMIN_CHAT_ID</code> in your environment to receive notifications.\n\n` +
                        `<i>Only important events are pushed: transactions, errors, card operations.</i>`,
                    parse_mode: "HTML",
                    reply_markup: adminActionsKeyboard(),
                });
            } else if (text === "/status") {
                await client.sendMessage({
                    chat_id: chatId,
                    text: "⏳ Collecting status…",
                    parse_mode: "HTML",
                });

                const status = await collectStatus();
                const message = formatStatusMessage(status);

                await client.sendMessage({
                    chat_id: chatId,
                    text: message,
                    parse_mode: "HTML",
                    reply_markup: adminActionsKeyboard(),
                });
            } else if (text === "/help") {
                await sendHelpMessage(client, chatId);
            }
        }
    } catch (error) {
        appLogger.error({ err: error }, "[AdminBot] Update handling error");
    }

    res.status(200).json({ ok: true });
});

// ── Admin Keyboards ────────────────────────────────────────

function adminActionsKeyboard(): { inline_keyboard: { text: string; callback_data?: string; url?: string }[][] } {
    return {
        inline_keyboard: [
            [
                { text: "📊 Status", callback_data: "admin_status" },
                { text: "🔄 Refresh", callback_data: "admin_status" },
            ],
            [
                { text: "🌐 Dashboard", url: "https://asgcard.dev" },
                { text: "📖 API Docs", url: "https://docs.asgcard.dev" },
            ],
            [
                { text: "❓ Help", callback_data: "admin_help" },
            ],
        ],
    };
}

async function sendHelpMessage(client: TelegramClient, chatId: number): Promise<void> {
    await client.sendMessage({
        chat_id: chatId,
        text:
            `🛡️ <b>Admin Bot Commands</b>\n\n` +
            `/start — Setup info + Chat ID\n` +
            `/status — 📊 System status & metrics\n` +
            `/help — ❓ Show this message\n\n` +
            `<b>Auto-notifications:</b>\n` +
            `• 💳 Card operations (create, fund, freeze)\n` +
            `• 💰 Transactions (charges, declines, refunds)\n` +
            `• 🔗 Account linking events\n` +
            `• 🔴 Errors & webhook failures\n\n` +
            `<i>Or use the buttons below:</i>`,
        parse_mode: "HTML",
        reply_markup: adminActionsKeyboard(),
    });
}

// ── Setup endpoint (register webhook) ──────────────────────

adminRouter.post("/telegram/setup", async (req, res) => {
    const opsKey = env.OPS_API_KEY;
    if (!opsKey || req.header("X-Ops-Key") !== opsKey) {
        res.status(401).json({ error: "Unauthorized" });
        return;
    }

    try {
        const client = getClient();

        await client.setMyCommands([
            { command: "start", description: "Get chat ID & setup" },
            { command: "status", description: "📊 System status" },
            { command: "help", description: "Show commands" },
        ]);

        const baseUrl = process.env.API_BASE_URL ?? `https://${req.hostname}`;
        const webhookUrl = `${baseUrl}/admin/telegram/webhook`;
        // Generate admin webhook secret if not set
        const [REDACTED_SECRET]
            ? `admin_${env.TG_WEBHOOK_SECRET}`
            : crypto.randomUUID();

        await client.setWebhook(webhookUrl, secret, ["message", "callback_query"]);

        res.json({
            status: "ok",
            webhook: webhookUrl,
            commands: ["start", "status", "help"],
            note: "Send /start to the admin bot to get your ADMIN_CHAT_ID",
        });
    } catch (error) {
        res.status(500).json({ error: (error as Error).message });
    }
});


path: api/src/modules/authz/auditService.ts
content: import { appLogger } from "../../utils/logger";
/**
 * Audit Service — structured logging for all sensitive actions.
 *
 * Records who/when/what/result/ip to authz_audit_log table.
 * Fail-open: audit failures never block the main operation.
 *
 * @module modules/authz/auditService
 */

import { query } from "../../db/db";

// ── Types ──────────────────────────────────────────────────

export type ActorType = "telegram_user" | "wallet_owner" | "system";
export type Decision = "allow" | "deny";

export interface AuditEntry {
    actorType: ActorType;
    actorId: string;
    action: string;
    resourceId?: string;
    decision: Decision;
    reason?: string;
    ipAddress?: string;
}

// ── Service ────────────────────────────────────────────────

export class AuditService {
    /** Log a sensitive action. Never throws — audit failures are logged to stderr. */
    static async log(entry: AuditEntry): Promise<void> {
        try {
            await query(
                `INSERT INTO authz_audit_log
           (actor_type, actor_id, action, resource_id, decision, reason, ip_address)
         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
                [
                    entry.actorType,
                    entry.actorId,
                    entry.action,
                    entry.resourceId ?? null,
                    entry.decision,
                    entry.reason ?? null,
                    entry.ipAddress ?? null,
                ]
            );
        } catch (error) {
            // Audit failures must never block the main operation
            appLogger.error({ err: error }, "[AUDIT] Failed to write audit log");
        }
    }

    /** Query recent audit entries for an actor. */
    static async getByActor(actorId: string, limit = 50): Promise<AuditEntry[]> {
        const rows = await query<{
            actor_type: string;
            actor_id: string;
            action: string;
            resource_id: string | null;
            decision: string;
            reason: string | null;
            ip_address: string | null;
            created_at: string;
        }>(
            `SELECT actor_type, actor_id, action, resource_id, decision, reason, ip_address, created_at
       FROM authz_audit_log
       WHERE actor_id = $1
       ORDER BY created_at DESC
       LIMIT $2`,
            [actorId, limit]
        );

        return rows.map((r) => ({
            actorType: r.actor_type as ActorType,
            actorId: r.actor_id,
            action: r.action,
            resourceId: r.resource_id ?? undefined,
            decision: r.decision as Decision,
            reason: r.reason ?? undefined,
            ipAddress: r.ip_address ?? undefined,
        }));
    }
}


path: api/src/modules/authz/index.ts
content: /**
 * Authz/Audit Module — public interface
 *
 * Owner-only authorization, audit trail, rate limiting.
 *
 * @module modules/authz
 */
export { requireOwnerBinding } from "./ownerPolicy";
export { AuditService } from "./auditService";


path: api/src/modules/authz/ownerPolicy.ts
content: /**
 * Owner Policy — middleware that checks Telegram user binding.
 *
 * Extracts telegram_user_id from the TG update context,
 * looks up active binding in owner_telegram_links,
 * attaches ownerWallet to request or denies with audit.
 *
 * @module modules/authz/ownerPolicy
 */

import { query } from "../../db/db";
import { AuditService } from "./auditService";

// ── Types ──────────────────────────────────────────────────

export interface OwnerContext {
    ownerWallet: string;
    telegramUserId: number;
    chatId: number;
}

// ── Binding Lookup ─────────────────────────────────────────

/**
 * Look up active owner binding for a Telegram user.
 * Returns null if no active binding exists.
 */
export async function findActiveBinding(
    telegramUserId: number
): Promise<OwnerContext | null> {
    const rows = await query<{
        owner_wallet: string;
        telegram_user_id: string;
        chat_id: string;
    }>(
        `SELECT owner_wallet, telegram_user_id, chat_id
     FROM owner_telegram_links
     WHERE telegram_user_id = $1 AND status = 'active'
     LIMIT 1`,
        [telegramUserId]
    );

    if (rows.length === 0) return null;

    return {
        ownerWallet: rows[0].owner_wallet,
        telegramUserId: Number(rows[0].telegram_user_id),
        chatId: Number(rows[0].chat_id),
    };
}

/**
 * Middleware-style check: verify Telegram user has active binding.
 * Returns OwnerContext or null (with audit log on deny).
 */
export async function requireOwnerBinding(
    telegramUserId: number,
    action: string
): Promise<OwnerContext | null> {
    const binding = await findActiveBinding(telegramUserId);

    if (!binding) {
        await AuditService.log({
            actorType: "telegram_user",
            actorId: String(telegramUserId),
            action,
            decision: "deny",
            reason: "no_active_binding",
        });
        return null;
    }

    await AuditService.log({
        actorType: "telegram_user",
        actorId: String(telegramUserId),
        action,
        decision: "allow",
        reason: `bound_to_${binding.ownerWallet}`,
    });

    return binding;
}


path: api/src/modules/bot/commands/faq.ts
content: /**
 * FAQ command handler.
 *
 * @module modules/bot/commands/faq
 */

import type { TelegramClient } from "../telegramClient";
import { faqMessage } from "../templates";
import { persistentMenu } from "../keyboards";

export async function handleFaqCommand(
    client: TelegramClient,
    chatId: number
): Promise<void> {
    await client.sendMessage({
        chat_id: chatId,
        text: faqMessage(),
        parse_mode: "HTML",
        reply_markup: persistentMenu(),
        disable_web_page_preview: false,
    });
}


path: api/src/modules/bot/commands/fund.ts
content: /**
 * Fund command handler — shows fund options and deep-links to SDK/API.
 *
 * Since funding requires x402 payment (on-chain USDC), the bot
 * provides card selection + funding info with deep-link to the API.
 *
 * @module modules/bot/commands/fund
 */

import type { TelegramClient } from "../telegramClient";
import { requireOwnerBinding } from "../../authz/ownerPolicy";
import { cardService, HttpError } from "../../../services/cardService";
import { persistentMenu } from "../keyboards";
import { FUNDING_TIERS } from "../../../config/pricing";

// ── Types ──────────────────────────────────────────────────

interface FundTier {
    label: string;
    amount: number;
    totalCost: number;
}

// ── Fund Command ───────────────────────────────────────────

export async function handleFundCommand(
    client: TelegramClient,
    chatId: number,
    userId: number
): Promise<void> {
    // Verify binding
    const owner = await requireOwnerBinding(userId, "fund_cards");

    if (!owner) {
        await client.sendMessage({
            chat_id: chatId,
            text: "🔐 You need to link your wallet first.\nUse the portal at https://asgcard.dev to connect your Telegram.",
            reply_markup: persistentMenu(),
        });
        return;
    }

    try {
        const cards = await cardService.listCards(owner.ownerWallet);

        if (cards.length === 0) {
            await client.sendMessage({
                chat_id: chatId,
                text: "📭 You don't have any cards yet.\n\nCreate one using the SDK:\n<code>client.createCard({ amount: 50, nameOnCard: 'AI', email: '[REDACTED_SECRET]' })</code>",
                parse_mode: "HTML",
                reply_markup: persistentMenu(),
            });
            return;
        }

        // Build card selection keyboard for funding
        const buttons = cards
            .filter((c) => c.status === "active")
            .map((c) => [
                {
                    text: `💳 xxxx ${c.lastFour} — $${c.balance.toFixed(2)}`,
                    callback_data: `fund_select:${c.cardId}`,
                },
            ]);

        if (buttons.length === 0) {
            await client.sendMessage({
                chat_id: chatId,
                text: "❄️ All your cards are frozen. Unfreeze a card first to fund it.",
                reply_markup: persistentMenu(),
            });
            return;
        }

        await client.sendMessage({
            chat_id: chatId,
            text: "<b>💰 Fund a Card</b>\n\nSelect a card to see funding options:",
            parse_mode: "HTML",
            reply_markup: { inline_keyboard: buttons },
        });
    } catch (error) {
        const msg =
            error instanceof HttpError
                ? `⚠️ ${error.message}`
                : "⚠️ Something went wrong. Please try again.";
        await client.sendMessage({ chat_id: chatId, text: msg });
    }
}

// ── Fund Callbacks ─────────────────────────────────────────

export async function handleFundCallback(
    client: TelegramClient,
    chatId: number,
    userId: number,
    data: string
): Promise<void> {
    const parts = data.split(":");
    const action = parts[0];
    const cardId = parts[1];
    if (!cardId) return;

    const owner = await requireOwnerBinding(userId, "fund_card_action");
    if (!owner) {
        await client.sendMessage({
            chat_id: chatId,
            text: "🔐 Session expired. Please /start again.",
        });
        return;
    }

    if (action === "fund_select") {
        await showFundTiers(client, chatId, owner.ownerWallet, cardId);
    } else if (action === "fund_info") {
        const amount = Number(parts[2]);
        await showFundDetails(client, chatId, owner.ownerWallet, cardId, amount);
    }
}

// ── Fund Details (tier selected) ───────────────────────────

async function showFundDetails(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string,
    amount: number
): Promise<void> {
    try {
        const tier = FUNDING_TIERS.find((t) => t.fundAmount === amount);
        if (!tier) {
            await client.sendMessage({
                chat_id: chatId,
                text: "⚠️ Invalid funding tier.",
            });
            return;
        }

        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        const fundEndpoint = `https://api.asgcard.dev${tier.endpoint}`;

        await client.sendMessage({
            chat_id: chatId,
            text:
                `<b>💰 Fund Card xxxx ${last4}</b>\n\n` +
                `<b>Load Amount:</b> $${tier.fundAmount.toFixed(2)}\n` +
                `<b>Top-up Fee:</b> $${tier.topUpFee.toFixed(2)}\n` +
                `<b>Service Fee:</b> $${tier.serviceFee.toFixed(2)}\n` +
                `<b>Total USDC Cost:</b> $${tier.totalCost.toFixed(2)}\n\n` +
                `<i>Payment: USDC on Stellar via x402 protocol</i>\n\n` +
                `To fund this card, use the SDK or API:\n` +
                `<code>POST ${tier.endpoint}</code>\n` +
                `with the card ID and x402 payment header.\n\n` +
                `📖 <a href="https://docs.asgcard.dev/api/fund">Documentation</a>`,
            parse_mode: "HTML",
            reply_markup: {
                inline_keyboard: [
                    [
                        {
                            text: "⬅️ Back to tiers",
                            callback_data: `fund_select:${cardId}`,
                        },
                    ],
                ],
            },
        });
    } catch (error) {
        const msg =
            error instanceof HttpError
                ? `⚠️ ${error.message}`
                : "⚠️ Something went wrong.";
        await client.sendMessage({ chat_id: chatId, text: msg });
    }
}

// ── Fund Tiers Display ─────────────────────────────────────

async function showFundTiers(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string
): Promise<void> {
    try {
        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        // Build tier buttons from FUNDING_TIERS config
        const fundTiers: FundTier[] = FUNDING_TIERS.map((t) => ({
            label: `$${t.fundAmount}`,
            amount: t.fundAmount,
            totalCost: t.totalCost,
        }));

        const tierButtons = fundTiers.map((tier) => [
            {
                text: `${tier.label} (cost: $${tier.totalCost.toFixed(2)})`,
                callback_data: `fund_info:${cardId}:${tier.amount}`,
            },
        ]);

        await client.sendMessage({
            chat_id: chatId,
            text:
                `<b>💰 Fund Card xxxx ${last4}</b>\n` +
                `Current Balance: <b>$${result.card.balance.toFixed(2)}</b>\n\n` +
                `Select a funding amount:\n` +
                `<i>Payment is via USDC on Stellar (x402 protocol).</i>\n` +
                `<i>Use the SDK for programmatic funding.</i>`,
            parse_mode: "HTML",
            reply_markup: { inline_keyboard: tierButtons },
        });
    } catch (error) {
        const msg =
            error instanceof HttpError
                ? `⚠️ ${error.message}`
                : "⚠️ Something went wrong.";
        await client.sendMessage({ chat_id: chatId, text: msg });
    }
}


path: api/src/modules/bot/commands/myCards.ts
content: /**
 * My Cards command handler — card list + card actions.
 *
 * CypherHQ-style layout: header message + individual card messages
 * with action buttons. Pagination deletes old messages.
 *
 * @module modules/bot/commands/myCards
 */

import type { TelegramClient } from "../telegramClient";
import { requireOwnerBinding } from "../../authz/ownerPolicy";
import { AuditService } from "../../authz/auditService";
import { cardService, HttpError } from "../../../services/cardService";
import {
    noBindingMessage,
    cardFrozenMessage,
    cardUnfrozenMessage,
} from "../templates";
import {
    cardActionsKeyboard,
    persistentMenu,
} from "../keyboards";
import { escapeHtml } from "../../../utils/html";

// ── Constants ──────────────────────────────────────────────
const CARDS_PER_PAGE = 5;

// ── My Cards ───────────────────────────────────────────────

export async function handleMyCardsCommand(
    client: TelegramClient,
    chatId: number,
    userId: number,
    page: number = 1,
    editMessageId?: number
): Promise<void> {
    // 1. Verify binding
    const owner = await requireOwnerBinding(userId, "my_cards");
    if (!owner) {
        await client.sendMessage({
            chat_id: chatId,
            text: noBindingMessage(),
            parse_mode: "HTML",
            reply_markup: persistentMenu(),
        });
        return;
    }

    // 2. Fetch all cards from cardService
    const allCards = await cardService.listCards(owner.ownerWallet);

    if (allCards.length === 0) {
        const text =
            `<b>💳 My Cards</b>\n\n` +
            `You don't have any cards yet.\n` +
            `Create one at <a href="https://asgcard.dev">asgcard.dev</a> or via the API.`;
        if (editMessageId) {
            await client.editMessageText({ chat_id: chatId, message_id: editMessageId, text, parse_mode: "HTML" });
        } else {
            await client.sendMessage({ chat_id: chatId, text, parse_mode: "HTML" });
        }
        return;
    }

    // 3. Pagination
    const totalPages = Math.ceil(allCards.length / CARDS_PER_PAGE);
    const safePage = Math.max(1, Math.min(page, totalPages));
    const start = (safePage - 1) * CARDS_PER_PAGE;
    const pageCards = allCards.slice(start, start + CARDS_PER_PAGE);

    // 4. Total balance (across ALL cards)
    const totalBalance = allCards.reduce((sum, c) => sum + c.balance, 0);

    // 5. Header text
    const headerText =
        `<b>ASG Card Account</b>\n\n` +
        `<b>ASG Card</b> Account Balance (USD): <b>$${totalBalance.toFixed(2)}</b>\n\n` +
        `Please select a card`;

    // 6. Inline keyboard: one button per card (card list)
    const keyboard: { text: string; callback_data: string }[][] = [];

    for (const card of pageCards) {
        const statusIcon = card.status === "frozen" ? "❄️" : "💳";
        keyboard.push([
            {
                text: `${statusIcon} ASG Virtual Card - xxxx ${card.lastFour}`,
                callback_data: `card_select:${card.cardId}`,
            },
        ]);
    }

    // Navigation row (if more than one page)
    if (totalPages > 1) {
        const navRow: { text: string; callback_data: string }[] = [];
        if (safePage > 1) {
            navRow.push({ text: "◀️ Prev", callback_data: `cards_page:${safePage - 1}` });
        }
        navRow.push({ text: `${safePage}/${totalPages}`, callback_data: "noop" });
        if (safePage < totalPages) {
            navRow.push({ text: "▶️ Next", callback_data: `cards_page:${safePage + 1}` });
        }
        keyboard.push(navRow);
    }

    const reply_markup = { inline_keyboard: keyboard };

    // 7. Edit existing message (pagination) or send new one
    if (editMessageId) {
        await client.editMessageText({
            chat_id: chatId,
            message_id: editMessageId,
            text: headerText,
            parse_mode: "HTML",
            reply_markup,
        });
    } else {
        await client.sendMessage({
            chat_id: chatId,
            text: headerText,
            parse_mode: "HTML",
            reply_markup,
        });
    }
}

// ── Card Callback Router ───────────────────────────────────

export async function handleCardCallback(
    client: TelegramClient,
    chatId: number,
    userId: number,
    data: string
): Promise<void> {
    const parts = data.split(":");
    const action = parts[0];
    const cardId = parts[1];
    if (!cardId) return;

    // Verify binding for every action
    const owner = await requireOwnerBinding(userId, action);
    if (!owner) {
        await client.sendMessage({
            chat_id: chatId,
            text: noBindingMessage(),
            parse_mode: "HTML",
        });
        return;
    }

    switch (action) {
        case "card_select":
            await handleCardSelect(client, chatId, owner.ownerWallet, cardId);
            break;
        case "card_freeze":
            await handleCardFreeze(client, chatId, owner.ownerWallet, cardId);
            break;
        case "card_unfreeze":
            await handleCardUnfreeze(client, chatId, owner.ownerWallet, cardId);
            break;
        case "card_reveal":
            await handleCardReveal(client, chatId, owner.ownerWallet, userId, cardId);
            break;
        case "card_statement":
            await handleCardStatement(client, chatId, owner.ownerWallet, cardId);
            break;
        case "card_statement_page": {
            const page = parseInt(parts[2] ?? "1", 10);
            await handleCardStatement(client, chatId, owner.ownerWallet, cardId, page);
            break;
        }
        case "confirm": {
            const confirmAction = parts[2];
            if (confirmAction === "freeze") {
                await handleCardFreeze(client, chatId, owner.ownerWallet, cardId);
            } else if (confirmAction === "unfreeze") {
                await handleCardUnfreeze(client, chatId, owner.ownerWallet, cardId);
            }
            break;
        }
        case "cancel":
            await client.sendMessage({
                chat_id: chatId,
                text: "❌ Action cancelled.",
            });
            break;
    }
}

// ── Card Select (show actions) ─────────────────────────────

async function handleCardSelect(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string
): Promise<void> {
    try {
        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        await client.sendMessage({
            chat_id: chatId,
            text: `💳 ASG Virtual Card - xxxx ${last4}`,
            reply_markup: cardActionsKeyboard(cardId, result.card.status),
        });
    } catch (error) {
        await sendCardError(client, chatId, error);
    }
}

// ── Freeze ─────────────────────────────────────────────────

async function handleCardFreeze(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string
): Promise<void> {
    try {
        await cardService.setCardStatus(wallet, cardId, "frozen");

        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        await AuditService.log({
            actorType: "telegram_user",
            actorId: wallet,
            action: "card_frozen",
            resourceId: cardId,
            decision: "allow",
        });

        await client.sendMessage({
            chat_id: chatId,
            text: cardFrozenMessage(last4),
            parse_mode: "HTML",
            reply_markup: cardActionsKeyboard(cardId, "frozen"),
        });
    } catch (error) {
        await sendCardError(client, chatId, error);
    }
}

// ── Unfreeze ───────────────────────────────────────────────

async function handleCardUnfreeze(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string
): Promise<void> {
    try {
        await cardService.setCardStatus(wallet, cardId, "active");

        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        await AuditService.log({
            actorType: "telegram_user",
            actorId: wallet,
            action: "card_unfrozen",
            resourceId: cardId,
            decision: "allow",
        });

        await client.sendMessage({
            chat_id: chatId,
            text: cardUnfrozenMessage(last4),
            parse_mode: "HTML",
            reply_markup: cardActionsKeyboard(cardId, "active"),
        });
    } catch (error) {
        await sendCardError(client, chatId, error);
    }
}

// ── Reveal (secure auto-deleting message with PCI data) ──

async function handleCardReveal(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    userId: number,
    cardId: string
): Promise<void> {
    try {
        // Verify card belongs to wallet
        const result = await cardService.getCard(wallet, cardId);
        const fpId = result.card.fourPaymentsId;

        if (!fpId) {
            await client.sendMessage({
                chat_id: chatId,
                text: "⚠️ Card details unavailable — no payment provider link.",
                parse_mode: "HTML",
            });
            return;
        }

        // Fetch sensitive data from 4Payments
        const { getFourPaymentsClient } = await import("../../../services/fourPaymentsClient");
        const fpClient = getFourPaymentsClient();
        const sensitive = await fpClient.getSensitiveInfo(fpId);

        await AuditService.log({
            actorType: "telegram_user",
            actorId: String(userId),
            action: "card_reveal_viewed",
            resourceId: cardId,
            decision: "allow",
        });

        // Format card number with spaces (handle number-as-integer from API)
        const cardNum = String(sensitive.number);
        const formatted = cardNum.replace(/(.{4})/g, "$1 ").trim();

        const text =
            `🔐 <b>Card Details</b>\n\n` +
            `💳 <tg-spoiler>${formatted}</tg-spoiler>\n` +
            `📅 Exp: <tg-spoiler>${sensitive.expire}</tg-spoiler>\n` +
            `🔑 CVV: <tg-spoiler>${sensitive.cvc}</tg-spoiler>\n\n` +
            `<i>Tap to reveal · Press delete when done</i>`;

        const msgId = await client.sendMessage({
            chat_id: chatId,
            text,
            parse_mode: "HTML",
            reply_markup: {
                inline_keyboard: [
                    [{ text: "🗑 Delete", callback_data: `card_delete_msg:${0}` }],
                ],
            },
        });

        // Update the delete button callback_data with actual message ID
        if (msgId) {
            await client.editMessageText({
                chat_id: chatId,
                message_id: msgId,
                text,
                parse_mode: "HTML",
                reply_markup: {
                    inline_keyboard: [
                        [{ text: "🗑 Delete", callback_data: `card_delete_msg:${msgId}` }],
                    ],
                },
            });
        }
    } catch (error) {
        await sendCardError(client, chatId, error);
    }
}

// ── Statement ──────────────────────────────────────────────

async function handleCardStatement(
    client: TelegramClient,
    chatId: number,
    wallet: string,
    cardId: string,
    page = 1
): Promise<void> {
    try {
        // Verify ownership
        const result = await cardService.getCard(wallet, cardId);
        const last4 = result.card.lastFour;

        // Fetch real statement data
        const { StatementService } = await import("../services/statementService");
        const statement = await StatementService.getStatement(wallet, cardId, page);
        const message = StatementService.formatStatementMessage(last4, statement);

        // Build pagination keyboard if needed
        const keyboard = statement.hasMore
            ? {
                inline_keyboard: [
                    [
                        {
                            text: `📄 Page ${page + 1} →`,
                            callback_data: `card_statement_page:${cardId}:${page + 1}`,
                        },
                    ],
                ],
            }
            : undefined;

        await client.sendMessage({
            chat_id: chatId,
            text: message,
            parse_mode: "HTML",
            reply_markup: keyboard,
        });
    } catch (error) {
        await sendCardError(client, chatId, error);
    }
}

// ── Error helper ───────────────────────────────────────────

async function sendCardError(
    client: TelegramClient,
    chatId: number,
    error: unknown
): Promise<void> {
    const message =
        error instanceof HttpError
            ? `⚠️ ${escapeHtml(error.message)}`
            : "⚠️ Something went wrong. Please try again.";

    await client.sendMessage({
        chat_id: chatId,
        text: message,
        parse_mode: "HTML",
    });
}


path: api/src/modules/bot/commands/profile.ts
content: /**
 * Profile command handler — view and edit email/phone.
 *
 * /profile shows current profile info with Edit buttons.
 * Tapping Edit Email or Edit Phone triggers a conversational flow
 * where the bot asks for the new value and saves it.
 *
 * @module modules/bot/commands/profile
 */

import type { TelegramClient } from "../telegramClient";
import { requireOwnerBinding } from "../../authz/ownerPolicy";
import { query } from "../../../db/db";
import {
    noBindingMessage,
} from "../templates";
import { persistentMenu } from "../keyboards";
import { escapeHtml } from "../../../utils/html";

// ── Pending edits (in-memory, per chat) ───────────────────
// On Vercel serverless this resets on cold start, but that's acceptable —
// worst case user has to tap Edit again.
const pendingEdits = new Map<number, { field: "email" | "phone"; userId: number }>();

// ── Profile Command ────────────────────────────────────────

export async function handleProfileCommand(
    client: TelegramClient,
    chatId: number,
    userId: number
): Promise<void> {
    const owner = await requireOwnerBinding(userId, "profile");
    if (!owner) {
        await client.sendMessage({
            chat_id: chatId,
            text: noBindingMessage(),
            parse_mode: "HTML",
            reply_markup: persistentMenu(),
        });
        return;
    }

    // Fetch current profile data
    const rows = await query<{ email: string | null; phone: string | null }>(
        `SELECT email, phone FROM owner_telegram_links
         WHERE telegram_user_id = $1 AND status = 'active' LIMIT 1`,
        [userId]
    );

    const profile = rows[0] || { email: null, phone: null };

    const emailDisplay = profile.email || "—  not set";
    const phoneDisplay = profile.phone || "—  not set";

    const text =
        `👤 <b>Profile</b>\n\n` +
        `📧 Email: <b>${escapeHtml(emailDisplay)}</b>\n` +
        `📱 Phone: <b>${escapeHtml(phoneDisplay)}</b>\n\n` +
        `<i>Tap a button to update</i>`;

    await client.sendMessage({
        chat_id: chatId,
        text,
        parse_mode: "HTML",
        reply_markup: {
            inline_keyboard: [
                [
                    { text: "📧 Edit Email", callback_data: "profile_edit:email" },
                    { text: "📱 Edit Phone", callback_data: "profile_edit:phone" },
                ],
            ],
        },
    });
}

// ── Profile Callback ───────────────────────────────────────

export async function handleProfileCallback(
    client: TelegramClient,
    chatId: number,
    userId: number,
    data: string
): Promise<void> {
    if (data === "profile_edit:email") {
        pendingEdits.set(chatId, { field: "email", userId });
        await client.sendMessage({
            chat_id: chatId,
            text: "📧 Send me your new <b>email address</b>:",
            parse_mode: "HTML",
            reply_markup: {
                inline_keyboard: [
                    [{ text: "❌ Cancel", callback_data: "profile_cancel" }],
                ],
            },
        });
    } else if (data === "profile_edit:phone") {
        pendingEdits.set(chatId, { field: "phone", userId });
        await client.sendMessage({
            chat_id: chatId,
            text: "📱 Send me your new <b>phone number</b>:",
            parse_mode: "HTML",
            reply_markup: {
                inline_keyboard: [
                    [{ text: "❌ Cancel", callback_data: "profile_cancel" }],
                ],
            },
        });
    } else if (data === "profile_cancel") {
        pendingEdits.delete(chatId);
        await client.sendMessage({
            chat_id: chatId,
            text: "❌ Edit cancelled.",
        });
    }
}

// ── Handle Text Input (for pending edits) ──────────────────

export async function handleProfileInput(
    client: TelegramClient,
    chatId: number,
    userId: number,
    text: string
): Promise<boolean> {
    const pending = pendingEdits.get(chatId);
    if (!pending || pending.userId !== userId) return false;

    const { field } = pending;
    pendingEdits.delete(chatId);

    // Basic validation
    const value = text.trim();

    if (field === "email") {
        // Simple email validation
        if (!value.includes("@") || !value.includes(".")) {
            await client.sendMessage({
                chat_id: chatId,
                text: "⚠️ Invalid email format. Please try again via /profile.",
            });
            return true;
        }
    }

    if (field === "phone") {
        // Allow digits, +, spaces, dashes, parens
        const cleaned = value.replace(/[\s\-\(\)]/g, "");
        if (!/^\+?\d{7,15}$/.test(cleaned)) {
            await client.sendMessage({
                chat_id: chatId,
                text: "⚠️ Invalid phone format. Use international format like +1234567890. Try again via /profile.",
            });
            return true;
        }
    }

    // Update profile in DB
    const column = field === "email" ? "email" : "phone";
    await query(
        `UPDATE owner_telegram_links SET ${column} = $1 WHERE telegram_user_id = $2 AND status = 'active'`,
        [value, userId]
    );

    // Reverse sync: if email changed, update all cards for this wallet
    if (field === "email") {
        try {
            const walletRows = await query<{ owner_wallet: string }>(
                `SELECT owner_wallet FROM owner_telegram_links WHERE telegram_user_id = $1 AND status = 'active' LIMIT 1`,
                [userId]
            );
            if (walletRows.length > 0) {
                await query(
                    `UPDATE cards SET email = $1 WHERE wallet_address = $2`,
                    [value, walletRows[0].owner_wallet]
                );
            }
        } catch (err) {
            // Non-fatal — profile is updated, card sync is best-effort
            console.error("[profile] Cards email sync failed:", err);
        }
    }

    const icon = field === "email" ? "📧" : "📱";
    await client.sendMessage({
        chat_id: chatId,
        text: `✅ ${icon} ${field === "email" ? "Email" : "Phone"} updated to <b>${escapeHtml(value)}</b>`,
        parse_mode: "HTML",
    });

    return true;
}


path: api/src/modules/bot/commands/start.ts
content: /**
 * /start command handler.
 *
 * Handles:
 * 1. /start lnk_<token> → consume token, create binding
 * 2. /start (no token) → welcome message with link instructions
 *
 * @module modules/bot/commands/start
 */

import type { TelegramClient } from "../telegramClient";
import { LinkService } from "../../portal/linkService";
import { AdminBot } from "../../admin/adminBot";
import {
    welcomeMessage,
    linkSuccessMessage,
    linkFailedMessage,
} from "../templates";
import { persistentMenu } from "../keyboards";

export async function handleStartCommand(
    client: TelegramClient,
    chatId: number,
    userId: number,
    token?: string
): Promise<void> {
    // Deep-link: /start lnk_<token>
    if (token && token.startsWith("lnk_")) {
        const result = await LinkService.consumeToken(token, userId, chatId);

        if (result.success && result.ownerWallet) {
            const walletShort =
                result.ownerWallet.substring(0, 6) +
                "..." +
                result.ownerWallet.substring(result.ownerWallet.length - 4);

            await client.sendMessage({
                chat_id: chatId,
                text: linkSuccessMessage(walletShort),
                parse_mode: "HTML",
                reply_markup: persistentMenu(),
            });

            // Notify admin bot
            AdminBot.accountLinked({
                wallet: result.ownerWallet,
                telegramUserId: userId,
                username: undefined,
            }).catch(() => {});
        } else {
            await client.sendMessage({
                chat_id: chatId,
                text: linkFailedMessage(result.error ?? "unknown"),
                parse_mode: "HTML",
                reply_markup: persistentMenu(),
            });
        }
        return;
    }

    // Plain /start — welcome
    await client.sendMessage({
        chat_id: chatId,
        text: welcomeMessage(),
        parse_mode: "HTML",
        reply_markup: persistentMenu(),
    });
}


path: api/src/modules/bot/commands/support.ts
content: /**
 * Support command handler.
 *
 * @module modules/bot/commands/support
 */

import type { TelegramClient } from "../telegramClient";
import { supportMessage } from "../templates";
import { persistentMenu } from "../keyboards";

export async function handleSupportCommand(
    client: TelegramClient,
    chatId: number
): Promise<void> {
    await client.sendMessage({
        chat_id: chatId,
        text: supportMessage(),
        parse_mode: "HTML",
        reply_markup: persistentMenu(),
        disable_web_page_preview: true,
    });
}


path: api/src/modules/bot/index.ts
content: /**
 * Bot Gateway Module — public interface
 *
 * Handles Telegram webhook, bot commands, inline keyboards,
 * and message rendering. All bot functionality is accessed
 * through this module's exported router.
 *
 * @module modules/bot
 */
export { botRouter } from "./webhook";
export { TelegramClient } from "./telegramClient";


path: api/src/modules/bot/keyboards.ts
content: /**
 * Inline keyboard builders — persistent menu + card actions.
 *
 * @module modules/bot/keyboards
 */

import type { TgInlineButton, TgReplyMarkup } from "./telegramClient";

// ── Persistent Bottom Menu (Reply Keyboard) ────────────────

export function persistentMenu(): TgReplyMarkup {
    return {
        keyboard: [
            [{ text: "💳 My Cards" }, { text: "👤 Profile" }],
            [{ text: "❓ FAQ's" }, { text: "🧑‍💻 Support" }],
        ],
        resize_keyboard: true,
        one_time_keyboard: false,
    };
}

// ── Card Selection ─────────────────────────────────────────

export function cardSelectionKeyboard(
    cards: { cardId: string; last4: string; status: string }[]
): TgReplyMarkup {
    const buttons: TgInlineButton[][] = cards.map((c) => [
        {
            text: `💳 ASG Virtual Card - xxxx ${c.last4}`,
            callback_data: `card_select:${c.cardId}`,
        },
    ]);

    return { inline_keyboard: buttons };
}

// ── Card Actions ───────────────────────────────────────────

export function cardActionsKeyboard(
    cardId: string,
    currentStatus: "active" | "frozen"
): TgReplyMarkup {
    const freezeBtn: TgInlineButton =
        currentStatus === "active"
            ? { text: "❄️ Freeze Card", callback_data: `card_freeze:${cardId}` }
            : { text: "🔥 Unfreeze Card", callback_data: `card_unfreeze:${cardId}` };

    return {
        inline_keyboard: [
            [{ text: "👁 Card Reveal", callback_data: `card_reveal:${cardId}` }],
            [
                freezeBtn,
                { text: "📊 Statement", callback_data: `card_statement:${cardId}` },
            ],
        ],
    };
}

// ── Confirm / Cancel ───────────────────────────────────────

export function confirmKeyboard(
    action: string,
    resourceId: string
): TgReplyMarkup {
    return {
        inline_keyboard: [
            [
                { text: "✅ Confirm", callback_data: `confirm:${action}:${resourceId}` },
                { text: "❌ Cancel", callback_data: `cancel:${action}:${resourceId}` },
            ],
        ],
    };
}


path: api/src/modules/bot/services/alertService.ts
content: /**
 * Alert Delivery Service — routes 4payments webhook events to Telegram users.
 *
 * Flow: 4payments webhook → event parsed → find card owner → find TG binding → send alert.
 *
 * @module modules/bot/services/alertService
 */

import { query } from "../../../db/db";
import { appLogger } from "../../../utils/logger";
import {
    chargeAlertMessage,
    declineAlertMessage,
    refundAlertMessage,
    loadAlertMessage,
} from "../templates";

// ── Types ──────────────────────────────────────────────────

export interface CardEvent {
    type: string;
    cardId?: string;
    externalCardId?: string;
    fourPaymentsId?: string;
    amount?: number;
    merchant?: string;
    balance?: number;
    reason?: string;
    txnId?: string;
    last4?: string;
    payload: Record<string, unknown>;
}

// ── Service ────────────────────────────────────────────────

export class AlertService {
    /**
     * Process a 4payments webhook event and deliver as Telegram alert.
     * Returns true if alert was delivered, false if no binding found or event type ignored.
     */
    static async deliverAlert(
        event: CardEvent,
        sendMessage: (chatId: number, text: string) => Promise<void>
    ): Promise<boolean> {
        // Extract card identifiers from the event
        const fpId =
            event.fourPaymentsId ??
            (event.payload.card_id as string) ??
            (event.payload.cardId as string) ??
            null;

        const extId =
            event.externalCardId ??
            (event.payload.external_card_id as string) ??
            (event.payload.externalCardId as string) ??
            null;

        if (!fpId && !extId) {
            appLogger.warn({ event: event.type }, "[AlertService] No card ID in event, skipping");
            return false;
        }

        // Look up the card and its owner wallet
        let ownerWallet: string | null = null;
        let last4 = event.last4 ?? "????";

        if (fpId) {
            const rows = await query<{ wallet_address: string; card_number_last4: string }>(
                `SELECT wallet_address, 
                        COALESCE(RIGHT(card_number, 4), 'XXXX') as card_number_last4
                 FROM cards 
                 WHERE four_payments_id = $1 
                 LIMIT 1`,
                [fpId]
            ).catch(() => []);
            if (rows.length > 0) {
                ownerWallet = rows[0].wallet_address;
                last4 = rows[0].card_number_last4 || last4;
            }
        }

        if (!ownerWallet && extId) {
            const rows = await query<{ wallet_address: string; card_number_last4: string }>(
                `SELECT wallet_address,
                        COALESCE(RIGHT(card_number, 4), 'XXXX') as card_number_last4
                 FROM cards 
                 WHERE card_id = $1 
                 LIMIT 1`,
                [extId]
            ).catch(() => []);
            if (rows.length > 0) {
                ownerWallet = rows[0].wallet_address;
                last4 = rows[0].card_number_last4 || last4;
            }
        }

        if (!ownerWallet) {
            appLogger.warn({ fpId, extId }, "[AlertService] Card not found in DB, skipping alert");
            return false;
        }

        // Find Telegram binding for this wallet
        const bindings = await query<{ chat_id: string }>(
            `SELECT chat_id 
             FROM owner_telegram_links 
             WHERE owner_wallet = $1 AND status = 'active'`,
            [ownerWallet]
        ).catch(() => []);

        if (bindings.length === 0) {
            appLogger.info({ ownerWallet }, "[AlertService] No TG binding for wallet, skipping");
            return false;
        }

        const chatId = Number(bindings[0].chat_id);

        // Build alert message based on event type
        const amount = event.amount ?? (event.payload.amount as number) ?? 0;
        const merchant =
            event.merchant ??
            (event.payload.merchant as string) ??
            (event.payload.merchant_name as string) ??
            "Unknown";
        const balance = event.balance ?? (event.payload.balance as number) ?? 0;
        const reason = event.reason ?? (event.payload.reason as string) ?? "Unknown";
        const txnId =
            event.txnId ??
            (event.payload.transaction_id as string) ??
            (event.payload.id as string) ??
            "N/A";

        let message: string;

        switch (event.type) {
            case "card.transaction":
            case "card.charge":
            case "TRANSACTION":
                message = chargeAlertMessage(last4, amount, merchant, balance, txnId);
                break;

            case "card.decline":
            case "card.declined":
                message = declineAlertMessage(last4, amount, merchant, reason, balance, txnId);
                break;

            case "card.refund":
            case "card.reversal":
                message = refundAlertMessage(amount, merchant, txnId, balance);
                break;

            case "card.load":
            case "card.funded":
            case "card.topup":
                message = loadAlertMessage(last4, amount, balance);
                break;

            case "CARD-ISSUE":
            case "card.created":
                message = `🎉 New ASG Card xxxx ${last4} has been issued and is ready to use!`;
                break;

            case "EXTRAFEE":
            case "card.fee":
                message = `💰 Fee of $${amount.toFixed(2)} applied to card xxxx ${last4}. Balance: $${balance.toFixed(2)}`;
                break;

            default:
                appLogger.info({ type: event.type }, "[AlertService] Unhandled event type");
                return false;
        }

        // Record alert in bot_events for metrics
        await query(
            `INSERT INTO bot_events (idempotency_key, event_type, payload_hash, delivery_status)
             VALUES ($1, $2, $3, 'pending')
             ON CONFLICT (idempotency_key) DO NOTHING`,
            [`${event.type}:${fpId ?? extId}:${txnId}`, event.type, txnId]
        ).catch(() => {});

        // Deliver alert
        try {
            await sendMessage(chatId, message);

            // Update delivery status
            await query(
                `UPDATE bot_events 
                 SET delivery_status = 'delivered' 
                 WHERE idempotency_key = $1`,
                [`${event.type}:${fpId ?? extId}:${txnId}`]
            ).catch(() => {});

            appLogger.info({ chatId, type: event.type }, "[AlertService] Alert delivered");
            return true;
        } catch (error) {
            appLogger.error({ err: error, chatId }, "[AlertService] Failed to deliver alert");

            await query(
                `UPDATE bot_events 
                 SET delivery_status = 'failed' 
                 WHERE idempotency_key = $1`,
                [`${event.type}:${fpId ?? extId}:${txnId}`]
            ).catch(() => {});

            return false;
        }
    }
}


path: api/src/modules/bot/services/metricsService.ts
content: /**
 * Bot/Portal Ops Metrics — aggregated metrics for monitoring dashboards.
 *
 * Exposes:
 *   - link success/deny rate
 *   - reveal-link usage
 *   - alert delivery latency p95
 *   - bot command usage counts
 *
 * @module modules/bot/services/metricsService
 */

import { query } from "../../../db/db";

// ── Types ──────────────────────────────────────────────────

export interface BotMetrics {
    links: {
        totalIssued: number;
        totalConsumed: number;
        totalExpired: number;
        totalRevoked: number;
        successRate: number;  // consumed / (consumed + expired + revoked)
        denyRate: number;     // denied decisions from audit log
    };
    reveals: {
        totalRequested: number;
        last24h: number;
    };
    alerts: {
        totalDelivered: number;
        totalFailed: number;
        totalSkipped: number;
        deliveryLatencyP95Ms: number | null;
    };
    audit: {
        totalActions: number;
        allowCount: number;
        denyCount: number;
        denyRate: number;
    };
    period: {
        from: string;
        to: string;
    };
}

// ── Service ────────────────────────────────────────────────

export class MetricsService {
    /**
     * Collect aggregated metrics for ops dashboard.
     * Defaults to last 24 hours.
     */
    static async collect(hoursBack = 24): Promise<BotMetrics> {
        const since = new Date(Date.now() - hoursBack * 3600_000).toISOString();
        const now = new Date().toISOString();

        // ── Link metrics ─────────────────────────────────────
        const linkData = await query<{
            status: string;
            count: string;
        }>(
            `SELECT status, COUNT(*) as count
       FROM telegram_link_tokens
       WHERE created_at >= $1
       GROUP BY status`,
            [since]
        ).catch(() => []);

        const linkCounts: Record<string, number> = {};
        for (const row of linkData) {
            linkCounts[row.status] = parseInt(row.count, 10);
        }

        const consumed = linkCounts["consumed"] ?? 0;
        const expired = linkCounts["expired"] ?? 0;
        const revoked = linkCounts["revoked"] ?? 0;
        const pending = linkCounts["pending"] ?? 0;
        const totalTokens = consumed + expired + revoked + pending;
        const denominator = consumed + expired + revoked;

        // ── Reveal metrics ───────────────────────────────────
        const revealData = await query<{ total: string; last24h: string }>(
            `SELECT
         COUNT(*) as total,
         COUNT(*) FILTER (WHERE created_at >= $1) as last24h
       FROM authz_audit_log
       WHERE action = 'card_reveal_requested'`,
            [since]
        ).catch(() => [{ total: "0", last24h: "0" }]);

        const reveals = revealData[0] ?? { total: "0", last24h: "0" };

        // ── Alert delivery metrics ───────────────────────────
        const alertData = await query<{
            delivery_status: string;
            count: string;
        }>(
            `SELECT delivery_status, COUNT(*) as count
       FROM bot_events
       WHERE created_at >= $1
       GROUP BY delivery_status`,
            [since]
        ).catch(() => []);

        const alertCounts: Record<string, number> = {};
        for (const row of alertData) {
            alertCounts[row.delivery_status] = parseInt(row.count, 10);
        }

        // Latency P95: time between event creation and message delivery
        const latencyData = await query<{ p95_ms: string }>(
            `SELECT
         PERCENTILE_CONT(0.95) WITHIN GROUP (
           ORDER BY EXTRACT(EPOCH FROM (bm.created_at - be.created_at)) * 1000
         ) as p95_ms
       FROM bot_events be
       JOIN bot_messages bm ON bm.correlation_id = be.idempotency_key
       WHERE be.created_at >= $1
         AND bm.status = 'sent'`,
            [since]
        ).catch(() => [{ p95_ms: null }]);

        const p95Ms = latencyData[0]?.p95_ms
            ? parseFloat(latencyData[0].p95_ms)
            : null;

        // ── Audit metrics ────────────────────────────────────
        const auditData = await query<{
            decision: string;
            count: string;
        }>(
            `SELECT decision, COUNT(*) as count
       FROM authz_audit_log
       WHERE created_at >= $1
       GROUP BY decision`,
            [since]
        ).catch(() => []);

        const auditCounts: Record<string, number> = {};
        for (const row of auditData) {
            auditCounts[row.decision] = parseInt(row.count, 10);
        }

        const allowCount = auditCounts["allow"] ?? 0;
        const denyCount = auditCounts["deny"] ?? 0;
        const totalAudit = allowCount + denyCount;

        return {
            links: {
                totalIssued: totalTokens,
                totalConsumed: consumed,
                totalExpired: expired,
                totalRevoked: revoked,
                successRate: denominator > 0 ? consumed / denominator : 0,
                denyRate: denominator > 0 ? (expired + revoked) / denominator : 0,
            },
            reveals: {
                totalRequested: parseInt(reveals.total, 10),
                last24h: parseInt(reveals.last24h, 10),
            },
            alerts: {
                totalDelivered: alertCounts["delivered"] ?? 0,
                totalFailed: alertCounts["failed"] ?? 0,
                totalSkipped: alertCounts["skipped"] ?? 0,
                deliveryLatencyP95Ms: p95Ms,
            },
            audit: {
                totalActions: totalAudit,
                allowCount,
                denyCount,
                denyRate: totalAudit > 0 ? denyCount / totalAudit : 0,
            },
            period: {
                from: since,
                to: now,
            },
        };
    }
}


path: api/src/modules/bot/services/statementService.ts
content: /**
 * Statement Service — queries real transaction history from bot_events + webhook_events.
 *
 * Supports pagination, status mapping: pending/declined/settled/reversed.
 *
 * @module modules/bot/services/statementService
 */

import { query } from "../../../db/db";

// ── Types ──────────────────────────────────────────────────

export type TransactionStatus = "pending" | "declined" | "settled" | "reversed";

export interface StatementEntry {
    txnId: string;
    date: string;
    type: string;
    merchant: string;
    amount: number;
    status: TransactionStatus;
    last4: string;
}

export interface StatementPage {
    items: StatementEntry[];
    total: number;
    page: number;
    pageSize: number;
    hasMore: boolean;
}

// ── Event-Type → Status Mapping ────────────────────────────

const EVENT_STATUS_MAP: Record<string, TransactionStatus> = {
    "card.transaction": "settled",
    "card.charge": "settled",
    "card.authorization": "pending",
    "card.authorized": "pending",
    "card.decline": "declined",
    "card.refund": "reversed",
    "card.reversal": "reversed",
    "card.load": "settled",
    "card.funded": "settled",
};

function mapEventStatus(eventType: string): TransactionStatus {
    return EVENT_STATUS_MAP[eventType] ?? "pending";
}

// ── Service ────────────────────────────────────────────────

export class StatementService {
    /**
     * Retrieve statement for a specific card, with pagination.
     * Pulls from bot_events table (which tracks all card events).
     */
    static async getStatement(
        walletAddress: string,
        cardId: string,
        page = 1,
        pageSize = 10
    ): Promise<StatementPage> {
        const offset = (page - 1) * pageSize;

        // Escape SQL LIKE special chars to prevent pattern injection (P0 #3)
        const safeCardId = cardId.replace(/[%_\\]/g, "\\$&");

        // Count total events for this card
        const countResult = await query<{ count: string }>(
            `SELECT COUNT(*) as count
       FROM bot_events be
       WHERE be.idempotency_key LIKE $1
         AND be.delivery_status != 'skipped'`,
            [`%:${safeCardId}:%`]
        );

        const total = parseInt(countResult[0]?.count ?? "0", 10);

        // Fetch paginated events with merchant/amount via LEFT JOIN (no N+1)
        const rows = await query<{
            idempotency_key: string;
            event_type: string;
            payload_hash: string;
            created_at: string;
            merchant: string;
            amount: number;
            card_last4: string;
        }>(
            `SELECT
               be.idempotency_key,
               be.event_type,
               be.payload_hash,
               be.created_at,
               COALESCE(
                 we.payload->>'merchant',
                 we.payload->>'merchant_name',
                 'Unknown'
               ) as merchant,
               COALESCE(
                 (we.payload->>'amount')::numeric,
                 (we.payload->>'transaction_amount')::numeric,
                 0
               ) as amount,
               COALESCE(
                 we.payload->>'card_last4',
                 RIGHT(we.payload->>'card_number', 4),
                 '????'
               ) as card_last4
             FROM bot_events be
             LEFT JOIN webhook_events we
               ON we.event_type = be.event_type
              AND we.idempotency_key = be.idempotency_key
             WHERE be.idempotency_key LIKE $1
               AND be.delivery_status != 'skipped'
             ORDER BY be.created_at DESC
             LIMIT $2 OFFSET $3`,
            [`%:${safeCardId}:%`, pageSize, offset]
        );

        // Build statement entries
        const items: StatementEntry[] = rows.map((row) => {
            const parts = row.idempotency_key.split(":");
            const txnId = parts[2] ?? row.payload_hash.substring(0, 12);

            return {
                txnId,
                date: row.created_at,
                type: row.event_type.replace("card.", ""),
                merchant: row.merchant,
                amount: Number(row.amount),
                status: mapEventStatus(row.event_type),
                last4: row.card_last4,
            };
        });

        return {
            items,
            total,
            page,
            pageSize,
            hasMore: offset + pageSize < total,
        };
    }

    /**
     * Format statement page as Telegram HTML message.
     */
    static formatStatementMessage(
        last4: string,
        statement: StatementPage
    ): string {
        if (statement.items.length === 0) {
            return (
                `📊 <b>Statement</b> — Card xxxx ${last4}\n\n` +
                `No transactions found.\n\n` +
                `<i>Page ${statement.page} of ${Math.max(1, Math.ceil(statement.total / statement.pageSize))}</i>`
            );
        }

        const statusIcon: Record<TransactionStatus, string> = {
            settled: "✅",
            pending: "⏳",
            declined: "❌",
            reversed: "↩️",
        };

        const lines = statement.items.map((item) => {
            const icon = statusIcon[item.status] ?? "❔";
            const date = new Date(item.date).toLocaleDateString("en-US", {
                month: "short",
                day: "numeric",
            });
            const sign = ["reversed", "declined"].includes(item.status) ? "" : "-";
            return `${icon} ${date} │ ${sign}$${item.amount.toFixed(2)} │ ${item.merchant}\n   <i>${item.status}</i> • <code>${item.txnId.substring(0, 10)}</code>`;
        });

        const totalPages = Math.max(1, Math.ceil(statement.total / statement.pageSize));

        return (
            `📊 <b>Statement</b> — Card xxxx ${last4}\n\n` +
            lines.join("\n\n") +
            `\n\n<i>Page ${statement.page}/${totalPages} (${statement.total} total)</i>`
        );
    }
}


path: api/src/modules/bot/telegramClient.ts
content: import { appLogger } from "../../utils/logger";
/**
 * Telegram Bot API client — minimal wrapper using native fetch.
 * No external dependencies.
 *
 * @module modules/bot/telegramClient
 */

// ── Types ──────────────────────────────────────────────────

export interface TgSendMessageParams {
    chat_id: number;
    text: string;
    parse_mode?: "HTML" | "Markdown" | "MarkdownV2";
    reply_markup?: TgReplyMarkup;
    disable_web_page_preview?: boolean;
}

export interface TgAnswerCallbackParams {
    callback_query_id: string;
    text?: string;
    show_alert?: boolean;
}

export interface TgInlineButton {
    text: string;
    callback_data?: string;
    url?: string;
}

export type TgReplyMarkup =
    | { inline_keyboard: TgInlineButton[][] }
    | { keyboard: { text: string }[][]; resize_keyboard?: boolean; one_time_keyboard?: boolean };

export interface TgUpdate {
    update_id: number;
    message?: TgMessage;
    callback_query?: TgCallbackQuery;
}

export interface TgMessage {
    message_id: number;
    from?: TgUser;
    chat: TgChat;
    text?: string;
    date: number;
}

export interface TgCallbackQuery {
    id: string;
    from: TgUser;
    message?: TgMessage;
    data?: string;
}

export interface TgUser {
    id: number;
    is_bot: boolean;
    first_name: string;
    last_name?: string;
    username?: string;
}

export interface TgChat {
    id: number;
    type: "private" | "group" | "supergroup" | "channel";
}

export interface TgApiResponse<T = unknown> {
    ok: boolean;
    result?: T;
    description?: string;
    error_code?: number;
}

// ── Client ─────────────────────────────────────────────────

export class TelegramClient {
    private readonly baseUrl: string;

    constructor(private readonly [REDACTED_SECRET]) {
        this.baseUrl = `https://api.telegram.org/bot${token}`;
    }

    /** Send a text message to a chat. Returns message_id on success. */
    async sendMessage(params: TgSendMessageParams): Promise<number | null> {
        const resp = await this.call<TgMessage>("sendMessage", params);
        return resp?.message_id ?? null;
    }

    /** Answer a callback query (removes "loading" spinner). */
    async answerCallbackQuery(params: TgAnswerCallbackParams): Promise<boolean> {
        const resp = await this.call<boolean>("answerCallbackQuery", params);
        return resp ?? false;
    }

    /** Edit an existing message text. */
    async editMessageText(params: {
        chat_id: number;
        message_id: number;
        text: string;
        parse_mode?: string;
        reply_markup?: TgReplyMarkup;
    }): Promise<boolean> {
        const resp = await this.call("editMessageText", params);
        return resp !== null;
    }

    /** Delete a message from chat. */
    async deleteMessage(chatId: number, messageId: number): Promise<boolean> {
        const resp = await this.call("deleteMessage", { chat_id: chatId, message_id: messageId });
        return resp !== null;
    }

    /** Set bot commands for the menu. */
    async setMyCommands(commands: { command: string; description: string }[]): Promise<boolean> {
        const resp = await this.call<boolean>("setMyCommands", { commands });
        return resp ?? false;
    }

    /** Register a webhook URL. */
    async setWebhook(url: string, secretToken?: string, allowedUpdates?: string[]): Promise<boolean> {
        const params: Record<string, unknown> = { url };
        if (secretToken) params.secret_token = secretToken;
        if (allowedUpdates) params.allowed_updates = allowedUpdates;
        const resp = await this.call<boolean>("setWebhook", params);
        return resp ?? false;
    }

    /** Get current webhook info (for health checks). */
    async getWebhookInfo(): Promise<{
        url: string;
        pending_update_count: number;
        last_error_date?: number;
        last_error_message?: string;
    }> {
        const resp = await this.call<{
            url: string;
            pending_update_count: number;
            last_error_date?: number;
            last_error_message?: string;
        }>("getWebhookInfo", {});
        return resp ?? { url: "", pending_update_count: 0 };
    }

    /** Remove the webhook. */
    async deleteWebhook(): Promise<boolean> {
        const resp = await this.call<boolean>("deleteWebhook", {});
        return resp ?? false;
    }

    /** Low-level API call. */
    private async call<T>(method: string, body: object): Promise<T | null> {
        try {
            const resp = await fetch(`${this.baseUrl}/${method}`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(body),
            });

            const data = (await resp.json()) as TgApiResponse<T>;

            if (!data.ok) {
                appLogger.error({ err: data.description ?? "unknown error" }, `[TG] ${method} failed:`);
                return null;
            }

            return data.result ?? null;
        } catch (error) {
            appLogger.error({ err: error }, `[TG] ${method} error`);
            return null;
        }
    }
}


path: api/src/modules/notify/eventRouter.ts
content: /**
 * Event Router — hooks into webhook processing to dispatch notifications.
 *
 * Called after webhook events are accepted and stored.
 *
 * @module modules/notify/eventRouter
 */

import { NotifyService, type CardEvent } from "./notifyService";
import { query } from "../../db/db";

/**
 * Process a webhook event for potential Telegram notification.
 * Called from the main webhook handler after the event is stored.
 */
export async function routeCardEvent(
    eventType: string,
    payload: Record<string, unknown>
): Promise<void> {
    // Extract card/wallet info from webhook payload
    const cardId = (payload.card_id ?? payload.cardId ?? "") as string;
    if (!cardId) return;

    // Look up wallet from card
    const cards = await query<{ wallet_address: string; card_number_last4: string }>(
        `SELECT wallet_address,
            RIGHT(card_number, 4) as card_number_last4
     FROM cards WHERE card_id = $1 LIMIT 1`,
        [cardId]
    );

    if (cards.length === 0) return;

    const card = cards[0];

    const event: CardEvent = {
        eventType,
        cardId,
        walletAddress: card.wallet_address,
        payload: {
            last4: card.card_number_last4,
            amount: payload.amount as number | undefined,
            merchant: payload.merchant as string | undefined,
            balance: payload.available_balance as number | undefined,
            txnId: payload.transaction_id as string | undefined,
            reason: payload.decline_reason as string | undefined,
            newBalance: payload.new_balance as number | undefined,
        },
    };

    await NotifyService.processEvent(event);
}

// Re-export for convenience
export { NotifyService };


path: api/src/modules/notify/index.ts
content: /**
 * Notify Module — public interface
 *
 * Event-to-Telegram notification delivery with dedupe and retry.
 *
 * @module modules/notify
 */
export { NotifyService } from "./notifyService";
export { routeCardEvent } from "./eventRouter";


path: api/src/modules/portal/agentAccess.ts
content: import { Router } from "express";
import { requireWalletAuth } from "../../middleware/walletAuth";
import { cardService, HttpError } from "../../services/cardService";

/**
 * REALIGN-005: Owner can revoke/restore agent access to card details.
 * Owner remains secondary control plane — doesn't block card creation
 * or basic agent flow, but can cut off details access.
 */
export const agentAccessRouter = Router();

agentAccessRouter.use(requireWalletAuth);

// POST /portal/cards/:cardId/revoke-details
agentAccessRouter.post("/cards/:cardId/revoke-details", async (req, res) => {
    if (!req.walletContext) {
        res.status(401).json({ error: "Wallet auth required" });
        return;
    }

    try {
        const result = await cardService.setDetailsRevoked(
            req.walletContext.address,
            req.params.cardId,
            true
        );
        res.json(result);
    } catch (error) {
        if (error instanceof HttpError) {
            res.status(error.status).json({ error: error.message });
            return;
        }
        res.status(500).json({ error: "Internal server error" });
    }
});

// POST /portal/cards/:cardId/restore-details
agentAccessRouter.post("/cards/:cardId/restore-details", async (req, res) => {
    if (!req.walletContext) {
        res.status(401).json({ error: "Wallet auth required" });
        return;
    }

    try {
        const result = await cardService.setDetailsRevoked(
            req.walletContext.address,
            req.params.cardId,
            false
        );
        res.json(result);
    } catch (error) {
        if (error instanceof HttpError) {
            res.status(error.status).json({ error: error.message });
            return;
        }
        res.status(500).json({ error: "Internal server error" });
    }
});


path: api/src/modules/portal/index.ts
content: /**
 * Portal Module — public interface
 *
 * Owner Cabinet: wallet login, Telegram link management.
 * All portal functionality is accessed through this module's exported router.
 *
 * @module modules/portal
 */
export { portalRouter } from "./routes";
export { LinkService } from "./linkService";


path: api/src/repositories/inMemoryPaymentRepo.ts
content: import crypto from "node:crypto";
import type { PaymentRecord, PaymentRepository } from "./types";

export class InMemoryPaymentRepository implements PaymentRepository {
    private payments = new Map<string, PaymentRecord>();

    async recordPayment(
        payment: Omit<PaymentRecord, "id" | "createdAt" | "updatedAt">
    ): Promise<{ record: PaymentRecord; inserted: boolean }> {
        // Match Postgres ON CONFLICT (tx_hash) DO NOTHING semantics
        const existing = this.payments.get(payment.txHash);
        if (existing) return { record: existing, inserted: false };

        const record: PaymentRecord = {
            ...payment,
            id: `pay_${crypto.randomUUID().slice(0, 8)}`,
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString()
        };
        this.payments.set(record.txHash, record);
        return { record, inserted: true };
    }

    async findByTxHash(txHash: string): Promise<PaymentRecord | undefined> {
        return this.payments.get(txHash);
    }

    async markSettled(txHash: string, settleId: string): Promise<boolean> {
        const payment = this.payments.get(txHash);
        if (!payment) return false;
        payment.status = "settled";
        payment.settleId = settleId;
        payment.updatedAt = new Date().toISOString();
        return true;
    }

    async markFailed(
        txHash: string,
        status: "settle_failed" | "verify_failed"
    ): Promise<boolean> {
        const payment = this.payments.get(txHash);
        if (!payment) return false;
        payment.status = status;
        payment.updatedAt = new Date().toISOString();
        return true;
    }
}


path: api/src/types/domain.ts
content: export type TierAmount = 10 | 25 | 50 | 100 | 200 | 500;

export interface CreationTier {
  loadAmount: TierAmount;
  issuanceFee: number;
  topUpFee: number;
  serviceFee: number;
  totalCost: number;
  endpoint: string;
}

export interface FundingTier {
  fundAmount: TierAmount;
  topUpFee: number;
  serviceFee: number;
  totalCost: number;
  endpoint: string;
}

export interface CardBillingAddress {
  street: string;
  city: string;
  state: string;
  zip: string;
  country: string;
}

export interface CardDetails {
  cardNumber: string;
  expiryMonth: number;
  expiryYear: number;
  cvv: string;
  maskedCardNumber?: string;
  billingAddress: CardBillingAddress;
}

export interface StoredCard {
  cardId: string;
  walletAddress: string;
  nameOnCard: string;
  email: string;
  balance: number;
  initialAmountUsd: number;
  status: "active" | "frozen";
  createdAt: string;
  updatedAt: string;
  details: CardDetails;
  fourPaymentsId?: string;
}


path: api/src/vercel.ts
content: import { createApp } from "./app";

const app = createApp();

export default app;


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)