diff --git a/AGENTS.md b/AGENTS.md index d575b80a..36abe864 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ This is a **TypeScript monorepo** for applications on the Tempo blockchain appli | `apps/tokenlist` | Token registry API | | `apps/contract-verification` | Smart contract verification | | `apps/og` | OpenGraph image generation | +| `apps/tempo-admin` | Internal admin tools (mainnet faucet with Okta auth) | ## Commands diff --git a/apps/tempo-admin/.env.example b/apps/tempo-admin/.env.example new file mode 100644 index 00000000..344b99bc --- /dev/null +++ b/apps/tempo-admin/.env.example @@ -0,0 +1,4 @@ +OKTA_ISSUER=https://your-org.okta.com/oauth2/default +OKTA_CLIENT_ID=your-okta-client-id +FAUCET_PRIVATE_KEY=0x... +TEMPO_RPC_URL=https://rpc.tempo.xyz diff --git a/apps/tempo-admin/env.d.ts b/apps/tempo-admin/env.d.ts new file mode 100644 index 00000000..186a9ada --- /dev/null +++ b/apps/tempo-admin/env.d.ts @@ -0,0 +1,18 @@ +interface EnvironmentVariables { + readonly OKTA_ISSUER: string + readonly OKTA_CLIENT_ID: string + readonly FAUCET_PRIVATE_KEY: string + readonly TEMPO_RPC_URL: string +} + +interface ImportMetaEnv extends EnvironmentVariables {} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + +declare namespace NodeJS { + interface ProcessEnv extends EnvironmentVariables { + readonly NODE_ENV: 'development' | 'production' | 'test' + } +} diff --git a/apps/tempo-admin/migrations/0001_create_faucet_dispensations/up.sql b/apps/tempo-admin/migrations/0001_create_faucet_dispensations/up.sql new file mode 100644 index 00000000..3ea5a4b7 --- /dev/null +++ b/apps/tempo-admin/migrations/0001_create_faucet_dispensations/up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS faucet_dispensations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + recipient TEXT NOT NULL, + amount TEXT NOT NULL, + purpose TEXT NOT NULL, + tx_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_dispensations_email ON faucet_dispensations(email); +CREATE INDEX idx_dispensations_created_at ON faucet_dispensations(created_at); +CREATE INDEX idx_dispensations_status ON faucet_dispensations(status); diff --git a/apps/tempo-admin/package.json b/apps/tempo-admin/package.json new file mode 100644 index 00000000..e0fad60b --- /dev/null +++ b/apps/tempo-admin/package.json @@ -0,0 +1,30 @@ +{ + "name": "tempo-admin", + "private": true, + "scripts": { + "build": "echo 'noop'", + "check": "pnpm check:biome && pnpm check:types", + "check:biome": "biome check --write .", + "check:types": "tsgo --project tsconfig.json --noEmit", + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "format": "biome format --write .", + "gen:types": "test -f .env || cp .env.example .env; CLOUDFLARE_ENV= wrangler types", + "test": "vitest --run" + }, + "dependencies": { + "@hono/zod-validator": "catalog:", + "hono": "catalog:", + "ox": "catalog:", + "viem": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "catalog:", + "@cloudflare/workers-types": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + "wrangler": "catalog:" + } +} diff --git a/apps/tempo-admin/src/index.ts b/apps/tempo-admin/src/index.ts new file mode 100644 index 00000000..e56f04ed --- /dev/null +++ b/apps/tempo-admin/src/index.ts @@ -0,0 +1,126 @@ +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import type { Address } from 'viem' +import { isAddress } from 'viem' +import * as z from 'zod' +import { + confirmDispensation, + createPendingDispensation, + failDispensation, + getDailyTotal, + getDispensations, +} from './lib/db.js' +import { dispenseFunds, getFaucetBalance } from './lib/faucet.js' +import type { OktaUser } from './lib/okta.js' +import { oktaAuth } from './lib/okta.js' + +const MAX_DISPENSE_AMOUNT = 10 +const DAILY_LIMIT = 50 + +type AppEnv = { + Variables: { user: OktaUser } +} + +const app = new Hono() + +app.use( + '*', + cors({ + origin: ['https://admin.tempo.xyz'], + allowMethods: ['GET', 'POST', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], + maxAge: 86400, + }), +) + +app.use('/api/*', oktaAuth) + +app.get('/health', (c) => c.json({ status: 'ok' })) + +const amountRegex = /^\d+(\.\d{1,18})?$/ + +app.post( + '/api/faucet/dispense', + zValidator( + 'json', + z.object({ + recipient: z.string().refine((v) => isAddress(v), 'Invalid address'), + amount: z + .string() + .refine((v) => amountRegex.test(v), 'Invalid amount format') + .refine( + (v) => Number(v) > 0 && Number(v) <= MAX_DISPENSE_AMOUNT, + `Amount must be between 0 and ${MAX_DISPENSE_AMOUNT}`, + ), + purpose: z + .string() + .min(1, 'Purpose is required') + .max(500, 'Purpose must be 500 characters or less'), + }), + ), + async (c) => { + const { recipient, amount, purpose } = c.req.valid('json') + const user = c.get('user') + + const dailyTotal = await getDailyTotal(user.email) + if (dailyTotal + Number(amount) > DAILY_LIMIT) { + return c.json( + { + error: `Daily limit of ${DAILY_LIMIT} TEMPO exceeded. Used today: ${dailyTotal}`, + }, + 429, + ) + } + + const record = await createPendingDispensation({ + email: user.email, + recipient, + amount, + purpose, + }) + + try { + const txHash = await dispenseFunds({ + recipient: recipient as Address, + amount, + }) + + await confirmDispensation(record.id, txHash) + + return c.json({ ...record, tx_hash: txHash, status: 'confirmed' }, 201) + } catch (err) { + const message = err instanceof Error ? err.message : 'Transaction failed' + await failDispensation(record.id, message) + return c.json({ error: message }, 500) + } + }, +) + +app.get( + '/api/faucet/history', + zValidator( + 'query', + z.object({ + limit: z.optional(z.coerce.number().int().min(1).max(100)), + offset: z.optional(z.coerce.number().int().min(0)), + }), + ), + async (c) => { + const { limit, offset } = c.req.valid('query') + const user = c.get('user') + const dispensations = await getDispensations({ + email: user.email, + limit, + offset, + }) + return c.json(dispensations) + }, +) + +app.get('/api/faucet/balance', async (c) => { + const balance = await getFaucetBalance() + return c.json({ balance }) +}) + +export default app diff --git a/apps/tempo-admin/src/lib/db.ts b/apps/tempo-admin/src/lib/db.ts new file mode 100644 index 00000000..8913e3c4 --- /dev/null +++ b/apps/tempo-admin/src/lib/db.ts @@ -0,0 +1,91 @@ +import { env } from 'cloudflare:workers' + +export type Dispensation = { + id: number + email: string + recipient: string + amount: string + purpose: string + tx_hash: string | null + status: 'pending' | 'confirmed' | 'failed' + error: string | null + created_at: string +} + +export async function createPendingDispensation(params: { + email: string + recipient: string + amount: string + purpose: string +}): Promise { + const result = await env.DB.prepare( + "INSERT INTO faucet_dispensations (email, recipient, amount, purpose, status) VALUES (?, ?, ?, ?, 'pending') RETURNING *", + ) + .bind(params.email, params.recipient, params.amount, params.purpose) + .first() + + if (!result) { + throw new Error('Failed to create dispensation record') + } + + return result +} + +export async function confirmDispensation( + id: number, + txHash: string, +): Promise { + await env.DB.prepare( + "UPDATE faucet_dispensations SET tx_hash = ?, status = 'confirmed' WHERE id = ?", + ) + .bind(txHash, id) + .run() +} + +export async function failDispensation( + id: number, + error: string, +): Promise { + await env.DB.prepare( + "UPDATE faucet_dispensations SET status = 'failed', error = ? WHERE id = ?", + ) + .bind(error, id) + .run() +} + +export async function getDailyTotal(email: string): Promise { + const result = await env.DB.prepare( + "SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) as total FROM faucet_dispensations WHERE email = ? AND status = 'confirmed' AND created_at >= datetime('now', '-1 day')", + ) + .bind(email) + .first<{ total: number }>() + + return result?.total ?? 0 +} + +export async function getDispensations(params?: { + email?: string | undefined + limit?: number | undefined + offset?: number | undefined +}): Promise { + const limit = params?.limit ?? 50 + const offset = params?.offset ?? 0 + + if (params?.email) { + const { results } = await env.DB.prepare( + 'SELECT * FROM faucet_dispensations WHERE email = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', + ) + .bind(params.email, limit, offset) + .all() + + return results + } + + const { results } = await env.DB.prepare( + 'SELECT * FROM faucet_dispensations ORDER BY created_at DESC LIMIT ? OFFSET ?', + ) + .bind(limit, offset) + .all() + + return results +} diff --git a/apps/tempo-admin/src/lib/faucet.ts b/apps/tempo-admin/src/lib/faucet.ts new file mode 100644 index 00000000..6030f38a --- /dev/null +++ b/apps/tempo-admin/src/lib/faucet.ts @@ -0,0 +1,43 @@ +import { env } from 'cloudflare:workers' +import { + type Address, + createWalletClient, + http, + parseEther, + publicActions, +} from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempo } from 'viem/chains' + +export async function dispenseFunds(params: { + recipient: Address + amount: string +}): Promise { + const account = privateKeyToAccount(env.FAUCET_PRIVATE_KEY as `0x${string}`) + + const client = createWalletClient({ + account, + chain: tempo, + transport: http(env.TEMPO_RPC_URL), + }).extend(publicActions) + + const hash = await client.sendTransaction({ + to: params.recipient, + value: parseEther(params.amount), + }) + + return hash +} + +export async function getFaucetBalance(): Promise { + const account = privateKeyToAccount(env.FAUCET_PRIVATE_KEY as `0x${string}`) + + const client = createWalletClient({ + account, + chain: tempo, + transport: http(env.TEMPO_RPC_URL), + }).extend(publicActions) + + const balance = await client.getBalance({ address: account.address }) + return balance.toString() +} diff --git a/apps/tempo-admin/src/lib/okta.ts b/apps/tempo-admin/src/lib/okta.ts new file mode 100644 index 00000000..ef7e5369 --- /dev/null +++ b/apps/tempo-admin/src/lib/okta.ts @@ -0,0 +1,152 @@ +import { env } from 'cloudflare:workers' +import type { Context, Next } from 'hono' +import { createMiddleware } from 'hono/factory' + +type JwksKey = { + kty: string + kid: string + use: string + alg: string + n: string + e: string +} + +type JwksResponse = { + keys: JwksKey[] +} + +type OktaClaims = { + sub: string + email: string + iss: string + aud: string + exp: number + iat: number +} + +let jwksCache: { keys: JwksKey[]; fetchedAt: number } | undefined + +async function getJwks(): Promise { + const now = Date.now() + if (jwksCache && now - jwksCache.fetchedAt < 3600_000) { + return jwksCache.keys + } + + const response = await fetch(`${env.OKTA_ISSUER}/v1/keys`) + if (!response.ok) { + throw new Error(`Failed to fetch JWKS: ${response.status}`) + } + + const data = (await response.json()) as JwksResponse + jwksCache = { keys: data.keys, fetchedAt: now } + return data.keys +} + +function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + '=', + ) + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes.buffer +} + +async function importJwk(jwk: JwksKey): Promise { + return crypto.subtle.importKey( + 'jwk', + { kty: jwk.kty, n: jwk.n, e: jwk.e, alg: jwk.alg, ext: true }, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['verify'], + ) +} + +async function verifyOktaToken(token: string): Promise { + const parts = token.split('.') + if (parts.length !== 3) { + throw new Error('Invalid JWT format') + } + + const [headerB64, payloadB64, signatureB64] = parts + const header = JSON.parse( + new TextDecoder().decode(base64UrlToArrayBuffer(headerB64)), + ) as { + kid: string + alg: string + } + + if (header.alg !== 'RS256') { + throw new Error('Unsupported algorithm') + } + + const keys = await getJwks() + const key = keys.find((k) => k.kid === header.kid) + if (!key) { + throw new Error('No matching key found in JWKS') + } + + const cryptoKey = await importJwk(key) + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`) + const signature = base64UrlToArrayBuffer(signatureB64) + + const valid = await crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + cryptoKey, + signature, + data, + ) + if (!valid) { + throw new Error('Invalid token signature') + } + + const payload = JSON.parse( + new TextDecoder().decode(base64UrlToArrayBuffer(payloadB64)), + ) as OktaClaims + + const now = Math.floor(Date.now() / 1000) + if (payload.exp < now) { + throw new Error('Token expired') + } + + if (payload.iss !== env.OKTA_ISSUER) { + throw new Error('Invalid issuer') + } + + if (payload.aud !== env.OKTA_CLIENT_ID) { + throw new Error('Invalid audience') + } + + return payload +} + +export type OktaUser = { + email: string + sub: string +} + +export const oktaAuth = createMiddleware<{ + Variables: { user: OktaUser } +}>(async (c: Context, next: Next) => { + const authHeader = c.req.header('Authorization') + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Missing or invalid Authorization header' }, 401) + } + + const token = authHeader.slice(7) + + try { + const claims = await verifyOktaToken(token) + if (!claims.email) { + return c.json({ error: 'Token missing email claim' }, 401) + } + c.set('user', { email: claims.email, sub: claims.sub }) + await next() + } catch { + return c.json({ error: 'Authentication failed' }, 401) + } +}) diff --git a/apps/tempo-admin/tsconfig.json b/apps/tempo-admin/tsconfig.json new file mode 100644 index 00000000..6621c7e0 --- /dev/null +++ b/apps/tempo-admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types", "node"], + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "checkJs": false + }, + "files": ["env.d.ts", "worker-configuration.d.ts"], + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/apps/tempo-admin/wrangler.jsonc b/apps/tempo-admin/wrangler.jsonc new file mode 100644 index 00000000..fd10cc02 --- /dev/null +++ b/apps/tempo-admin/wrangler.jsonc @@ -0,0 +1,46 @@ +{ + "$schema": "https://esm.sh/wrangler/config-schema.json", + "name": "tempo-admin", + "compatibility_date": "2025-12-17", + "compatibility_flags": ["nodejs_compat"], + "main": "./src/index.ts", + "workers_dev": true, + "preview_urls": true, + "keep_vars": true, + "observability": { + "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + "persist": true + } + }, + "d1_databases": [ + { + "binding": "DB", + "migrations_dir": "migrations", + "database_name": "TEMPO-ADMIN-DB", + "database_id": "00000000-0000-0000-0000-000000000000" + } + ], + "vars": { + "TEMPO_RPC_URL": "https://rpc.tempo.xyz" + }, + "env": { + "production": { + "name": "tempo-admin", + "routes": [ + { + "pattern": "admin.tempo.xyz", + "zone_name": "tempo.xyz", + "custom_domain": true + } + ], + "workers_dev": false, + "vars": { + "TEMPO_RPC_URL": "https://rpc.tempo.xyz" + } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9172813..fc0d4601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -727,6 +727,43 @@ importers: specifier: 'catalog:' version: 4.61.1(@cloudflare/workers-types@4.20260131.0) + apps/tempo-admin: + dependencies: + '@hono/zod-validator': + specifier: 'catalog:' + version: 0.7.6(hono@4.11.7)(zod@4.3.6) + hono: + specifier: 'catalog:' + version: 4.11.7 + ox: + specifier: 'catalog:' + version: 0.12.0(typescript@5.9.3)(zod@4.3.6) + viem: + specifier: 'catalog:' + version: 2.45.1(typescript@5.9.3)(zod@4.3.6) + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@cloudflare/vitest-pool-workers': + specifier: 'catalog:' + version: 0.12.8(@cloudflare/workers-types@4.20260131.0)(@vitest/runner@3.2.4)(@vitest/snapshot@3.2.4)(vitest@3.2.4) + '@cloudflare/workers-types': + specifier: 'catalog:' + version: 4.20260131.0 + '@types/node': + specifier: 'catalog:' + version: 25.2.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + wrangler: + specifier: 'catalog:' + version: 4.61.1(@cloudflare/workers-types@4.20260131.0) + apps/tokenlist: dependencies: hono: