Skip to content

Commit 798e7cd

Browse files
committed
feat(api): catch-all router, modular handlers, wallet slug route
- Add api/[...path].ts dispatcher and api/_handlers/* for serverless routes - Consolidate envelope helpers under api/_lib; add api/wallet/[slug].ts - Remove legacy flat api/*.ts and api/wallet/* entrypoints - Update vercel.json, README, backlog, GCP KMS/auth docs - Add security/wallet design texts; Datadog process export script; ignore local export file Made-with: Cursor
1 parent b3558e0 commit 798e7cd

28 files changed

Lines changed: 910 additions & 150 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ app-example
4949
# local scratch / temp
5050
.tmp/
5151

52+
# Datadog: local process export (scripts/datadog-export-processes.ps1)
53+
dd-processes-sample.txt
54+
5255
.vercel
5356

5457
# Terraform / GCP (local state and SA keys)

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ Before local deploy / cloud deploy, prepare these env-backed services:
9797
- `DATABASE_URL`
9898
- `OPENAI_API_KEY`
9999
- `BOT_TOKEN` (or `TELEGRAM_BOT_TOKEN`)
100+
- Optional — **Google Cloud KMS** (wallet envelope API): **`GCP_SERVICE_ACCOUNT_JSON`** — see [below](#gcp_service_account_json-google-cloud-kms).
100101
- Pull envs locally when needed with `vercel env pull .env.local`.
101102
5. **Vercel CLI login (required for `npm run start`)**
102103
The default dev command runs **`vercel dev`** (local API). That needs a **valid Vercel CLI session**, not only project env vars in the dashboard.
@@ -105,6 +106,35 @@ Before local deploy / cloud deploy, prepare these env-backed services:
105106
- From the repo root, run **`vercel link`** if prompted so this directory is tied to your Vercel project (team/project scope).
106107
- If you see **`The specified token is not valid`**, your stored token expired or was revoked: run **`vercel login`** again to refresh it. Do not set a broken **`VERCEL_TOKEN`** in `.env` unless it is a current deploy token from the Vercel dashboard.
107108

109+
### GCP_SERVICE_ACCOUNT_JSON (Google Cloud KMS)
110+
111+
Serverless routes under [`api/`](./api) that call **Cloud KMS** (wallet envelope KEK) read credentials from the env var **`GCP_SERVICE_ACCOUNT_JSON`**. The value must be the **full JSON object** of a Google Cloud **service account key** whose identity is allowed to use the KMS crypto key (see [`infra/gcp/kms.env.example`](./infra/gcp/kms.env.example)). The app passes this JSON in-process to the Google client — **no** key file is required on Vercel.
112+
113+
**How to get the JSON**
114+
115+
1. Open [Google Cloud Console](https://console.cloud.google.com/), select project **`hyperlinksspacebot`**.
116+
2. Go to **IAM & Admin → Service Accounts** and open **`wallet-kms-unwrap@hyperlinksspacebot.iam.gserviceaccount.com`** (the account permitted for your key; adjust if you use a different project or SA).
117+
3. **Keys → Add key → Create new key → JSON**. A `.json` file downloads.
118+
119+
**CLI alternative** (with [`gcloud`](https://cloud.google.com/sdk/gcloud) installed and authorized):
120+
121+
```bash
122+
gcloud iam service-accounts keys create ./wallet-kms-unwrap-sa-key.json \
123+
--iam-account=wallet-kms-unwrap@hyperlinksspacebot.iam.gserviceaccount.com \
124+
--project=hyperlinksspacebot
125+
```
126+
127+
**What to put in `GCP_SERVICE_ACCOUNT_JSON`**
128+
129+
- The **entire file contents** of that key — the single JSON object with `"type": "service_account"`, `"client_email"`, `"private_key"`, etc. Paste into Vercel as one variable (minified or multi-line per the dashboard; the value must remain valid JSON).
130+
131+
**Where to configure**
132+
133+
- **Vercel (production):** Project → **Settings → Environment Variables** → add **`GCP_SERVICE_ACCOUNT_JSON`**, assign to **Production** (and **Preview** if needed), **Redeploy** so serverless functions see the new value.
134+
- **Local dev:** Prefer a file and `GOOGLE_APPLICATION_CREDENTIALS`, or put the same JSON string in `.env.local` — details in [`infra/gcp/backend-authentication.md`](./infra/gcp/backend-authentication.md).
135+
136+
The downloaded key is a **secret** (like a password): do **not** commit it to git; add `wallet-kms-unwrap-sa-key.json` to `.gitignore` if you keep a local copy.
137+
108138
Copy env template locally:
109139

110140
```bash

api/[...path].ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Single Vercel serverless entry for all /api/* routes (Hobby plan function limit).
3+
* Concrete handlers live in api/_handlers/* (private — not deployed as separate routes).
4+
*/
5+
6+
import aiHandler from './_handlers/ai.js';
7+
import blockchainHandler from './_handlers/blockchain.js';
8+
import botHandler from './_handlers/bot.js';
9+
import pingHandler from './_handlers/ping.js';
10+
import releasesHandler from './_handlers/releases.js';
11+
import telegramHandler from './_handlers/telegram.js';
12+
import walletEnvelopePingHandler from './_handlers/wallet-envelope-ping.js';
13+
import walletEnvelopeProbeHandler from './_handlers/wallet-envelope-probe.js';
14+
import walletEnvelopeRoundtripHandler from './_handlers/wallet-envelope-roundtrip.js';
15+
16+
type NodeRes = {
17+
setHeader(name: string, value: string): void;
18+
status(code: number): void;
19+
end(body?: string): void;
20+
};
21+
22+
type ApiHandler = (
23+
request: Request,
24+
res?: NodeRes,
25+
) => Promise<Response | void>;
26+
27+
const ROUTES: Record<string, ApiHandler> = {
28+
ping: pingHandler as ApiHandler,
29+
bot: botHandler as ApiHandler,
30+
ai: aiHandler as ApiHandler,
31+
blockchain: blockchainHandler as ApiHandler,
32+
telegram: telegramHandler as ApiHandler,
33+
releases: releasesHandler as ApiHandler,
34+
'wallet-envelope-ping': walletEnvelopePingHandler as ApiHandler,
35+
'wallet-envelope-probe': walletEnvelopeProbeHandler as ApiHandler,
36+
'wallet-envelope-roundtrip': walletEnvelopeRoundtripHandler as ApiHandler,
37+
/** Public short paths from vercel.json rewrites (request URL may still show these segments). */
38+
kmsping: walletEnvelopePingHandler as ApiHandler,
39+
kmsprobe: walletEnvelopeProbeHandler as ApiHandler,
40+
'kms-roundtrip': walletEnvelopeRoundtripHandler as ApiHandler,
41+
'kms/ping': walletEnvelopePingHandler as ApiHandler,
42+
'kms-ping': walletEnvelopePingHandler as ApiHandler,
43+
};
44+
45+
function routeKeyFromUrl(request: Request): string {
46+
const raw = request.url;
47+
if (!raw) return '';
48+
let pathname: string;
49+
try {
50+
pathname = new URL(raw).pathname;
51+
} catch {
52+
pathname = raw.split('?')[0] ?? '';
53+
}
54+
const segments = pathname
55+
.replace(/^\/api\/?/i, '')
56+
.split('/')
57+
.filter(Boolean);
58+
return segments.join('/');
59+
}
60+
61+
async function router(
62+
request: Request,
63+
res?: NodeRes,
64+
): Promise<Response | void> {
65+
const key = routeKeyFromUrl(request);
66+
const handler = ROUTES[key];
67+
if (!handler) {
68+
const body = JSON.stringify({
69+
ok: false,
70+
error: 'not_found',
71+
path: key || '(empty)',
72+
hint: 'All API routes are served from this single function; see api/_handlers/',
73+
});
74+
if (res) {
75+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
76+
res.status(404);
77+
res.end(body);
78+
return;
79+
}
80+
return new Response(body, {
81+
status: 404,
82+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
83+
});
84+
}
85+
return handler(request, res);
86+
}
87+
88+
export default router;
89+
export const GET = router;
90+
export const POST = router;
91+
export const OPTIONS = router;
92+
export const PUT = router;
93+
export const PATCH = router;
94+
export const DELETE = router;

api/ai.ts renamed to api/_handlers/ai.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* - Legacy Node style (req, res)
99
*/
1010

11-
import { transmit, type AiRequest } from "../ai/transmitter.js";
11+
import { transmit, type AiRequest } from "../../ai/transmitter.js";
1212

1313
type NodeRes = {
1414
setHeader(name: string, value: string): void;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* - Legacy Node style (req, res)
88
*/
99

10-
import { handleBlockchainRequest } from "../blockchain/router.js";
10+
import { handleBlockchainRequest } from "../../blockchain/router.js";
1111

1212
type NodeRes = {
1313
setHeader(name: string, value: string): void;

api/bot.ts renamed to api/_handlers/bot.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
2-
* Vercel API route: named GET/POST so Telegram webhook POST is handled.
3-
* Forwards to app/bot/webhook so only this file is a route (avoids 12-function limit).
2+
* Telegram webhook: GET/POST forwarded to bot/webhook.
3+
* Mounted from api/[...path].ts (single Vercel serverless function).
44
*/
55
import webhookHandler, {
66
type NodeReq,
77
type NodeRes,
8-
} from '../bot/webhook.js';
8+
} from '../../bot/webhook.js';
99

1010
async function handler(
1111
request: Request | NodeReq,
File renamed without changes.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async function handler(
3939
return new Response('Method Not Allowed', { status: 405 });
4040
}
4141
try {
42-
const { handlePost } = await import('../telegram/post.js');
42+
const { handlePost } = await import('../../telegram/post.js');
4343
const response = await handlePost(request);
4444
if (res) {
4545
res.status(response.status);
Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
*/
1010

1111
import {
12+
getKmsCredentialSource,
1213
getKmsKeyName,
1314
getKmsUsesRestTransport,
1415
hasExplicitKmsJsonCredentials,
16+
parseGcpServiceAccountJson,
1517
resolveServiceAccountKeyPath,
16-
} from './lib/envelope-env.js';
18+
} from '../_lib/envelope-env.js';
1719

1820
const JSON_HEADERS = { 'Content-Type': 'application/json' };
1921

@@ -82,14 +84,20 @@ async function handler(
8284

8385
if (url.searchParams.get('diag') === '1') {
8486
const keyPath = resolveServiceAccountKeyPath();
87+
const jsonParse = parseGcpServiceAccountJson();
8588
return sendJson(
8689
res,
8790
{
8891
ok: true,
8992
diag: true,
9093
cwd: process.cwd(),
9194
vercelEnv: process.env.VERCEL_ENV ?? null,
95+
credentialSource: getKmsCredentialSource(),
9296
hasGcpServiceAccountJson: hasExplicitKmsJsonCredentials(),
97+
gcpServiceAccountJson:
98+
jsonParse.ok ? 'ok' : jsonParse.error === 'missing' ? 'absent' : 'invalid',
99+
gcpServiceAccountJsonError:
100+
jsonParse.ok || jsonParse.error === 'missing' ? null : jsonParse.message,
93101
resolvedKeyPath: keyPath ?? null,
94102
keyFileExists: Boolean(keyPath),
95103
kmsTransport: getKmsUsesRestTransport() ? 'rest' : 'grpc',
@@ -114,7 +122,7 @@ async function handler(
114122
ok: false,
115123
error: 'wrong_route',
116124
message:
117-
'KMS lives in api/wallet-envelope-roundtrip.ts (public /api/kms-roundtrip). Call:',
125+
'KMS lives in route wallet-envelope-roundtrip / public /api/kms-roundtrip. Call:',
118126
url: dest.toString(),
119127
curl: `curl -s --max-time 120 "${dest.toString()}"`,
120128
},
@@ -127,9 +135,9 @@ async function handler(
127135
{
128136
ok: true,
129137
usage: true,
130-
handler: 'api/wallet-envelope-ping.ts',
138+
handler: 'api/[...path].ts (wallet-envelope-ping)',
131139
message:
132-
'KMS crypto is in api/wallet-envelope-roundtrip.ts; lib uses envelope-*.ts paths for vercel dev.',
140+
'KMS crypto is in route wallet-envelope-roundtrip (api/[...path].ts); shared code in api/_lib/envelope-*.ts.',
133141
try: {
134142
probe: '/api/kmsping?probe=1',
135143
diag: '/api/kmsping?diag=1',

0 commit comments

Comments
 (0)