diff --git a/.env.example b/.env.example
index 7a035657..9afd0f54 100644
--- a/.env.example
+++ b/.env.example
@@ -1,6 +1,15 @@
# Steem RPC node(s). Comma-separated for failover (e.g. https://api.steemit.com,https://api.steem.fans)
STEEM_RPC_URL=https://api.steemit.com
+# Database (MySQL) for account recovery records
+# Drizzle ORM connects via this URL.
+# DATABASE_URL=mysql://user:password@127.0.0.1:3306/wallet_dev
+
+# Conveyor credentials for account recovery (kingdom.recovery_account)
+# Production values are provisioned by Steemit ops; leave empty for dev.
+# CONVEYOR_USERNAME=steem
+# CONVEYOR_POSTING_WIF=5J...
+
# Session and CSRF secrets (generate strong values in production)
# SESSION_SECRET=your-session-secret-here
CSRF_SECRET=your-csrf-secret-change-in-production
diff --git a/docs/ACCOUNT_RECOVERY.md b/docs/ACCOUNT_RECOVERY.md
new file mode 100644
index 00000000..03aadfc0
--- /dev/null
+++ b/docs/ACCOUNT_RECOVERY.md
@@ -0,0 +1,232 @@
+# Account Recovery
+
+Complete account recovery flow for steemitwallet.com, spanning three services:
+**wallet** (Next.js), **turtle** (Go admin backend), and **kingdom** (on-chain operation service via jussi).
+
+---
+
+## Overview
+
+Account recovery allows a user whose owner key was compromised to regain control
+of their account. It is a two-step process defined by the Steem blockchain:
+
+1. **`request_account_recovery`** — Broadcast by the recovery account (e.g. @steem),
+ sets a new owner authority on-chain.
+2. **`recover_account`** — Broadcast by the user, signed with their old owner key,
+ proves they owned the previous authority.
+
+Between these two on-chain ops, the user must demonstrate ownership of the old key
+and choose a new password. The entire flow ensures **private keys never leave the
+user's browser**.
+
+---
+
+## Architecture
+
+```
+User Browser Wallet (Next.js) turtle kingdom
+ │ │ │ │
+ │ Step 1: Submit │ │ │
+ │ recovery request │ │ │
+ │─────────────────────>│ │ │
+ │ │ Insert arecs │ │
+ │ │ (status=open) │ │
+ │ │ │ │
+ │ │ turtle admin │ │
+ │ │ reviews & │ │
+ │ │ approves: │ │
+ │ │ status=confirmed, │ │
+ │ │ validation_code │ │
+ │ │ set, email sent │ │
+ │ │ │ │
+ │ Step 2: Click │ │ │
+ │ email link │ │ │
+ │─────────────────────>│ │ │
+ │ │ │ │
+ │ Verify code │ │ │
+ │─────────────────────>│ │ │
+ │<─account_name────────│ │ │
+ │ │ │ │
+ │ Submit old+new pwd │ │ │
+ │ (CSRF protected) │ │ │
+ │─────────────────────>│ │ │
+ │ │ │ │
+ │ │ kingdom.recovery_ │ │
+ │ │ account (via jussi)│ │
+ │ │─────────────────────────────────────────>│
+ │ │ │ request_account │
+ │ │ │ _recovery on-chain
+ │ │<─────────────────────────────────────────│
+ │ │ │ │
+ │ │ Update arecs │ │
+ │ │ status=closed │ │
+ │ │ │ │
+ │<─────ok──────────────│ │ │
+ │ │ │ │
+ │ Sign recover_account│ │ │
+ │ locally (old key) │ │ │
+ │ │ │ │
+ │ Broadcast signed tx │ │ │
+ │─────────────────────>│ │ │
+ │ │ Relay to │ │
+ │ │ steemd via │ │
+ │ │ condenser_api │ │
+ │ │ .broadcast_ │ │
+ │ │ transaction │ │
+ │<─────success─────────│ │ │
+```
+
+---
+
+## Step 1: User submits recovery request
+
+**Frontend:** `src/components/wallet/recover-account-step-1-page.tsx`
+**API:** `POST /api/recovery/request`
+
+1. User enters account name, recent owner password/key, and email.
+2. Frontend derives the owner public key from the password **locally** and checks
+ it against the on-chain owner history (`apiClient.getOwnerHistory`).
+3. If the key matches a recent owner authority, the frontend submits the request
+ to `POST /api/recovery/request` with CSRF protection.
+4. Server inserts an `arecs` row with `status='open'`.
+
+At this point the request waits for a turtle admin to approve it (set `status='confirmed'`
+and generate a `validation_code`). The admin also sends a recovery email containing
+a link like `https://steemitwallet.com/account_recovery_confirmation/{code}`.
+
+---
+
+## Step 2: User confirms recovery
+
+**Frontend:** `src/components/wallet/recover-account-confirmation-page.tsx`
+**Page route:** `/[locale]/account_recovery_confirmation/[code]`
+
+### 2a. Verify code
+
+**API:** `GET /api/recovery/verify/[code]`
+
+- Looks up `arecs` by `validation_code`.
+- Must be `status='confirmed'` (admin approved).
+- Returns `account_name` to the frontend.
+
+### 2b. Submit recovery
+
+**API:** `POST /api/recovery/confirm` (CSRF protected)
+
+1. Frontend validates the old password against on-chain owner history **locally**
+ (key never leaves the browser).
+2. Frontend sends `code`, `account_name`, `old_owner_key` (pub), `new_owner_key` (pub),
+ and `new_owner_authority` to the server.
+3. Server calls `SteemService.requestAccountRecovery()` which invokes
+ `kingdom.recovery_account` via `steem.api.signedCallAsync`. This uses
+ `CONVEYOR_USERNAME` / `CONVEYOR_POSTING_WIF` credentials to sign the JSON-RPC
+ request. Kingdom then broadcasts `request_account_recovery` on-chain.
+4. Server updates `arecs` → `status='closed'`, records `old_owner_key`, `new_owner_key`,
+ `request_submitted_at`.
+
+### 2c. Broadcast recover_account (client-side signing)
+
+**API:** `POST /api/broadcast/recover-account`
+
+1. Frontend calls `SteemSigner.signRecoverAccount(account, oldPassword, newPassword)`
+ **locally**. This derives the old and new owner private keys from the passwords,
+ constructs a `recover_account` operation, and signs it with the old owner key.
+2. The signed transaction is sent to the server relay endpoint.
+3. Server validates the transaction format (op must be `recover_account`) and
+ broadcasts via `condenser_api.broadcast_transaction`.
+
+---
+
+## Database: `arecs` table
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | INT AUTO_INCREMENT PK | Row ID |
+| `user_id` | INT NULL | Legacy user FK |
+| `uid` | VARCHAR(64) NULL | Session UID |
+| `contact_email` | VARCHAR(255) | User's email |
+| `account_name` | VARCHAR(255) | Steem account name |
+| `owner_key` | VARCHAR(255) | Current owner public key (submitted at step 1) |
+| `old_owner_key` | TEXT NULL | Old owner public key (filled at step 2) |
+| `new_owner_key` | TEXT NULL | New owner public key (filled at step 2) |
+| `memo_key` | TEXT NULL | Memo key |
+| `provider` | VARCHAR(32) | Auth provider (e.g. `email`) |
+| `remote_ip` | VARCHAR(64) | Client IP |
+| `status` | ENUM(open, confirmed, expired, closed) | Request lifecycle |
+| `email_confirmation_code` | VARCHAR(255) NULL | Email verification code |
+| `validation_code` | VARCHAR(255) NULL | 20-hex-char code in the recovery email link |
+| `request_submitted_at` | DATETIME NULL | When step 2 was completed |
+| `created_at` | DATETIME DEFAULT CURRENT_TIMESTAMP | Row creation time |
+| `updated_at` | DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | Last update time |
+
+### Status lifecycle
+
+```
+open → confirmed → processing → closed
+ ↘ expired
+```
+
+- `open`: User submitted step 1, awaiting admin review.
+- `confirmed`: Admin approved, `validation_code` generated, email sent.
+- `processing`: User submitted step 2 confirm; server has claimed the record atomically (CAS). If `kingdom.recovery_account` fails, the record stays in `processing`. **Records stuck in `processing` for >1 hour should be manually reset to `confirmed` by ops** to allow retry.
+- `expired`: Code expired (timeout, not currently enforced automatically).
+- `closed`: User completed step 2, recovery done. The `validation_code` is now
+ single-use (cannot be used again).
+
+---
+
+## Environment Variables
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `DATABASE_URL` | Yes | MySQL connection URI for Drizzle ORM |
+| `CONVEYOR_USERNAME` | Yes (prod) | Account name used to sign `kingdom.recovery_account` JSON-RPC call |
+| `CONVEYOR_POSTING_WIF` | Yes (prod) | Posting WIF of the conveyor account |
+| `STEEM_RPC_URL` | Yes | Steem RPC endpoint (jussi), routes `kingdom.*` namespace to kingdom service |
+
+---
+
+## API Endpoints
+
+| Method | Path | Purpose | Auth |
+|--------|------|---------|------|
+| POST | `/api/recovery/request` | Submit step 1 recovery request | CSRF |
+| GET | `/api/recovery/verify/[code]` | Validate code, return account name | None |
+| POST | `/api/recovery/confirm` | Step 2: call kingdom + close arecs | CSRF |
+| POST | `/api/broadcast/recover-account` | Relay signed `recover_account` tx | CSRF |
+
+---
+
+## Key Files
+
+| File | Description |
+|------|-------------|
+| `src/app/api/recovery/request/route.ts` | Step 1 API |
+| `src/app/api/recovery/verify/[code]/route.ts` | Code verification API |
+| `src/app/api/recovery/confirm/route.ts` | Step 2 API (kingdom + DB update) |
+| `src/app/api/broadcast/recover-account/route.ts` | Transaction relay |
+| `src/components/wallet/recover-account-step-1-page.tsx` | Step 1 frontend |
+| `src/components/wallet/recover-account-confirmation-page.tsx` | Step 2 frontend |
+| `src/app/[locale]/account_recovery_confirmation/[code]/page.tsx` | Step 2 page route |
+| `src/lib/steem/server.ts` | `SteemService.requestAccountRecovery()` |
+| `src/lib/steem/client.ts` | `SteemSigner.signRecoverAccount()`, `apiClient.*` |
+| `src/lib/db/schema/index.ts` | Drizzle schema for `arecs` |
+| `drizzle/0000_polite_warhawk.sql` | Migration SQL |
+| `tests/unit/recovery-request-route.test.ts` | Step 1 tests |
+| `tests/unit/recovery-verify-route.test.ts` | Verify route tests |
+| `tests/unit/recovery-confirm-route.test.ts` | Confirm route tests |
+
+---
+
+## Security Considerations
+
+1. **Private keys never leave the browser.** All key derivation (`steem.auth.toWif`)
+ and transaction signing (`SteemSigner.signTransaction`) happen client-side.
+2. **CSRF protection** on all POST endpoints (`verifyCSRF` middleware).
+3. **Rate limiting** on all recovery endpoints to prevent abuse.
+4. **Code validation** — 20-hex-char format enforced server-side.
+5. **Owner key format validation** — strict base58 regex `^STM[1-9A-HJ-NP-Za-km-z]{50}$` (excludes 0/O/I/l).
+6. **Single-use codes** — `status` changes to `closed` after successful step 2,
+ preventing replay.
+7. **Account name verification** — Server checks that the account name from the
+ arecs record matches the submitted one.
diff --git a/docs/DATABASE.md b/docs/DATABASE.md
new file mode 100644
index 00000000..bc18bfcf
--- /dev/null
+++ b/docs/DATABASE.md
@@ -0,0 +1,279 @@
+# Database — Drizzle ORM + MySQL
+
+This document describes the database layer built on Drizzle ORM with MySQL (MariaDB), replacing the legacy Sequelize-based backend.
+
+## Architecture
+
+```
+Next.js Server → Drizzle ORM → mysql2 driver → MySQL (Docker / RDS)
+```
+
+| Layer | Component | Purpose |
+|-------|-----------|---------|
+| ORM | Drizzle ORM (`drizzle-orm`) | Type-safe query builder, schema definitions |
+| Driver | mysql2 (`mysql2`) | MySQL protocol implementation, connection pooling |
+| Database | MySQL 8 / MariaDB 11 | Persistent storage |
+| Migration | drizzle-kit | Schema introspection, migration generation, push |
+
+---
+
+## Project Structure
+
+```
+src/lib/db/
+├── index.ts # Connection pool singleton (server-side)
+└── schema/
+ └── index.ts # Drizzle schema definitions (table mappings)
+
+drizzle/
+├── 0000_*.sql # Generated SQL migration files
+└── meta/
+ ├── _journal.json # Migration journal
+ └── *.json # Schema snapshots
+
+drizzle.config.ts # drizzle-kit configuration
+```
+
+---
+
+## Connection Pool Singleton
+
+**File:** `src/lib/db/index.ts`
+
+Follows the same singleton pattern as `src/lib/cache/redis.ts`:
+
+```typescript
+import { drizzle } from 'drizzle-orm/mysql2';
+import mysql from 'mysql2/promise';
+import * as schema from './schema';
+
+let db: DrizzleDb | null = null;
+let pool: mysql.Pool | null = null;
+let dbUnavailable = false;
+
+export function getDb() { /* lazy-init pool on first call */ }
+export async function closeDb() { /* graceful shutdown */ }
+export function resetDb() { /* test cleanup */ }
+```
+
+- **Lazy initialization**: pool is created on the first `getDb()` call
+- **Graceful degradation**: returns `null` if `DATABASE_URL` is not set or connection fails
+- **Connection limit**: 5 connections (adjust for production based on instance count)
+- **Environment variable**: `DATABASE_URL` (e.g. `mysql://user:pass@host/db`)
+
+---
+
+## Schema Definitions
+
+**File:** `src/lib/db/schema/index.ts`
+
+Tables are defined using Drizzle's MySQL column types. Each table maps to a legacy wallet-legacy table:
+
+```typescript
+import { mysqlTable, int, varchar, text, datetime, index } from 'drizzle-orm/mysql-core';
+
+export const arecs = mysqlTable(
+ 'arecs',
+ {
+ id: int('id').autoincrement().primaryKey(),
+ contactEmail: varchar('contact_email', { length: 256 }).notNull(),
+ accountName: varchar('account_name', { length: 64 }).notNull(),
+ status: varchar('status', { length: 32 }).default('open'),
+ // ... more columns
+ },
+ (table) => ({
+ idxAccountName: index('idx_arecs_account_name').on(table.accountName),
+ })
+);
+```
+
+**Naming convention:**
+- JS/TS property: camelCase (`contactEmail`)
+- DB column: snake_case (`contact_email`)
+- Index prefix: `idx_
_` (`idx_arecs_account_name`)
+
+---
+
+## Local Development
+
+### Prerequisites
+
+- MySQL / MariaDB running on `127.0.0.1:3306` (Docker recommended)
+- `DATABASE_URL` set in `.env.local`:
+ ```
+ DATABASE_URL=mysql://root:12345678@127.0.0.1/wallet_dev
+ ```
+
+### Initial Setup
+
+```bash
+# Create database and apply migrations
+DATABASE_URL='mysql://root:12345678@127.0.0.1/wallet_dev' pnpm exec drizzle-kit push
+
+# Verify
+mysql -u root -p12345678 -h 127.0.0.1 wallet_dev -e "SHOW TABLES;"
+```
+
+### Adding a New Table
+
+1. Add table definition to `src/lib/db/schema/index.ts`
+2. Generate migration:
+ ```bash
+ DATABASE_URL='mysql://root:12345678@127.0.0.1/wallet_dev' pnpm exec drizzle-kit generate
+ ```
+3. Apply migration:
+ ```bash
+ DATABASE_URL='mysql://root:12345678@127.0.0.1/wallet_dev' pnpm exec drizzle-kit push
+ ```
+4. Verify:
+ ```bash
+ mysql -u root -p12345678 -h 127.0.0.1 wallet_dev -e "DESCRIBE ;"
+ ```
+
+### Syncing Schema from Database
+
+```bash
+# Pull current database schema and show diff
+DATABASE_URL='mysql://root:12345678@127.0.0.1/wallet_dev' pnpm exec drizzle-kit diff
+
+# Push local schema changes to database (destructive!)
+DATABASE_URL='mysql://root:12345678@127.0.0.1/wallet_dev' pnpm exec drizzle-kit push
+```
+
+---
+
+## Legacy Schema Mapping
+
+Tables ported from wallet-legacy:
+
+| Legacy Table | Legacy Model | Drizzle Schema | Status |
+|--------------|--------------|----------------|--------|
+| `arecs` | `AccountRecoveryRequest` | `src/lib/db/schema/index.ts` | ✅ Migrated |
+| `users` | `User` | — | ⏳ Pending |
+| `accounts` | `Account` | — | ⏳ Pending |
+| `identities` | `Identity` | — | ⏳ Pending |
+
+Schema sources:
+- `~/workspace/wallet-legacy/src/db/migrations/` (Sequelize migrations)
+- `~/workspace/wallet-legacy/src/db/models/` (Sequelize model definitions)
+
+---
+
+## Query Examples
+
+### Using Drizzle ORM (recommended)
+
+```typescript
+import { eq, and } from 'drizzle-orm';
+import { getDb } from '@/lib/db';
+import { arecs } from '@/lib/db/schema';
+
+const db = getDb();
+if (!db) throw new Error('DB unavailable');
+
+// Find first match
+const existing = await db.query.arecs.findFirst({
+ where: and(
+ eq(arecs.accountName, 'alice'),
+ eq(arecs.contactEmail, 'alice@example.com'),
+ eq(arecs.status, 'open')
+ ),
+});
+
+// Insert
+await db.insert(arecs).values({
+ contactEmail: 'alice@example.com',
+ accountName: 'alice',
+ ownerKey: 'STM6xxx',
+ status: 'open',
+});
+
+// Update
+await db.update(arecs)
+ .set({ status: 'confirmed' })
+ .where(eq(arecs.id, 1));
+```
+
+### Raw SQL (when needed)
+
+```typescript
+import { sql } from 'drizzle-orm';
+const db = getDb()!;
+
+const [rows] = await db.execute(
+ sql`SELECT id, account_name FROM arecs WHERE status = 'open'`
+);
+```
+
+---
+
+## Testing
+
+The DB module is a singleton, so tests mock `@/lib/db`:
+
+```typescript
+vi.mock('@/lib/db', () => ({
+ getDb: () => mockDb,
+}));
+
+const mockDb = {
+ query: {
+ arecs: { findFirst: vi.fn() },
+ },
+ insert: vi.fn().mockReturnValue({ values: vi.fn() }),
+};
+```
+
+For integration tests, use `resetDb()` to clear the singleton state between tests.
+
+---
+
+## Production Considerations
+
+### Connection Pool Sizing
+
+Default: 5 connections. For production:
+- 1 connection per concurrent request + buffer
+- Ensure MySQL `max_connections` exceeds total pool size across all instances
+- Monitor with `SHOW PROCESSLIST`
+
+### SSL/TLS
+
+For AWS RDS, add SSL options to the connection pool:
+
+```typescript
+pool = mysql.createPool({
+ uri: url,
+ ssl: { rejectUnauthorized: true },
+ // ...
+});
+```
+
+### Health Check
+
+Check database connectivity via `getDb()` — returns `null` if unavailable:
+
+```typescript
+const health = getDb() ? 'connected' : 'disconnected';
+```
+
+---
+
+## Environment Variables
+
+| Variable | Required | Description |
+|----------|----------|-------------|
+| `DATABASE_URL` | Yes (for DB features) | MySQL connection URI (`mysql://user:pass@host/db`) |
+
+Example `.env.local`:
+```
+DATABASE_URL=mysql://root:12345678@127.0.0.1/wallet_dev
+```
+
+---
+
+## Migration History
+
+| Date | Commit | Change |
+|------|--------|--------|
+| 2026-05-28 | 696039a7 | Initial Drizzle ORM integration, `arecs` table |
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 00000000..d40a8c1e
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'drizzle-kit';
+
+export default defineConfig({
+ dialect: 'mysql',
+ schema: './src/lib/db/schema/index.ts',
+ out: './drizzle',
+ dbCredentials: {
+ url: process.env.DATABASE_URL ?? 'mysql://root:12345678@127.0.0.1/wallet_dev',
+ },
+});
diff --git a/drizzle/0000_polite_warhawk.sql b/drizzle/0000_polite_warhawk.sql
new file mode 100644
index 00000000..1739b456
--- /dev/null
+++ b/drizzle/0000_polite_warhawk.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `arecs` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `user_id` int,
+ `uid` varchar(64),
+ `contact_email` varchar(256) NOT NULL,
+ `account_name` varchar(64) NOT NULL,
+ `owner_key` text,
+ `old_owner_key` text,
+ `new_owner_key` text,
+ `memo_key` text,
+ `provider` varchar(64),
+ `email_confirmation_code` varchar(64),
+ `validation_code` varchar(64),
+ `request_submitted_at` datetime,
+ `remote_ip` varchar(45),
+ `status` varchar(32) DEFAULT 'open',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `arecs_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE INDEX `idx_arecs_account_name` ON `arecs` (`account_name`);--> statement-breakpoint
+CREATE INDEX `idx_arecs_contact_email` ON `arecs` (`contact_email`);--> statement-breakpoint
+CREATE INDEX `idx_arecs_uid` ON `arecs` (`uid`);--> statement-breakpoint
+CREATE INDEX `idx_arecs_validation_code` ON `arecs` (`validation_code`);
\ No newline at end of file
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 00000000..6d4e9454
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,179 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "58fe4e34-2df1-48c5-abed-2c0d2c9162a1",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "tables": {
+ "arecs": {
+ "name": "arecs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "uid": {
+ "name": "uid",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contact_email": {
+ "name": "contact_email",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_name": {
+ "name": "account_name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_key": {
+ "name": "owner_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "old_owner_key": {
+ "name": "old_owner_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "new_owner_key": {
+ "name": "new_owner_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "memo_key": {
+ "name": "memo_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_confirmation_code": {
+ "name": "email_confirmation_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "validation_code": {
+ "name": "validation_code",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_submitted_at": {
+ "name": "request_submitted_at",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "remote_ip": {
+ "name": "remote_ip",
+ "type": "varchar(45)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'open'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'2026-05-28 10:00:05.815'"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "datetime",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'2026-05-28 10:00:05.815'"
+ }
+ },
+ "indexes": {
+ "idx_arecs_account_name": {
+ "name": "idx_arecs_account_name",
+ "columns": [
+ "account_name"
+ ],
+ "isUnique": false
+ },
+ "idx_arecs_contact_email": {
+ "name": "idx_arecs_contact_email",
+ "columns": [
+ "contact_email"
+ ],
+ "isUnique": false
+ },
+ "idx_arecs_uid": {
+ "name": "idx_arecs_uid",
+ "columns": [
+ "uid"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "arecs_id": {
+ "name": "arecs_id",
+ "columns": [
+ "id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 00000000..3523cbd1
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,13 @@
+{
+ "version": "7",
+ "dialect": "mysql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "5",
+ "when": 1779962405843,
+ "tag": "0000_polite_warhawk",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/next.config.ts b/next.config.ts
index ef81ac16..11440049 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -16,6 +16,9 @@ const nextConfig: NextConfig = {
// Compress responses
compress: true,
+ // Exclude mysql2 from client-side bundling (server-only native module)
+ serverExternalPackages: ['mysql2'],
+
async redirects() {
return [
{ source: '/faq.html', destination: '/faq', permanent: true },
diff --git a/package.json b/package.json
index fd83ee04..9759f989 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,10 @@
"@steemit/steem-js": "^1.0.19",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "drizzle-orm": "^0.45.2",
"ioredis": "^5.10.1",
"lucide-react": "^1.8.0",
+ "mysql2": "^3.22.4",
"next": "16.2.4",
"next-intl": "^4.9.1",
"radix-ui": "^1.4.3",
@@ -50,6 +52,7 @@
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.4",
+ "drizzle-kit": "^0.31.10",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.4",
"jsdom": "^24.1.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 364b46b8..40e5d8eb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -20,12 +20,18 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ drizzle-orm:
+ specifier: ^0.45.2
+ version: 0.45.2(mysql2@3.22.4(@types/node@25.6.0))
ioredis:
specifier: ^5.10.1
version: 5.10.1
lucide-react:
specifier: ^1.8.0
version: 1.8.0(react@19.2.5)
+ mysql2:
+ specifier: ^3.22.4
+ version: 3.22.4(@types/node@25.6.0)
next:
specifier: 16.2.4
version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -95,10 +101,13 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^6.0.1
- version: 6.0.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
+ version: 6.0.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))
'@vitest/coverage-v8':
specifier: ^4.1.4
version: 4.1.4(vitest@4.1.4)
+ drizzle-kit:
+ specifier: ^0.31.10
+ version: 0.31.10
eslint:
specifier: ^9.39.4
version: 9.39.4(jiti@2.6.1)
@@ -116,7 +125,7 @@ importers:
version: 6.0.3
vitest:
specifier: ^4.1.4
- version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
+ version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))
packages:
@@ -308,6 +317,9 @@ packages:
resolution: {integrity: sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q==}
hasBin: true
+ '@drizzle-team/brocli@0.10.2':
+ resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
+
'@ecies/ciphers@0.2.6':
resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==}
engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'}
@@ -323,162 +335,614 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@esbuild-kit/core-utils@3.3.2':
+ resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
+ deprecated: 'Merged into tsx: https://tsx.is'
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
+ '@esbuild/aix-ppc64@0.28.0':
+ resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.18.20':
+ resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
'@esbuild/android-arm64@0.27.7':
resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm64@0.28.0':
+ resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.18.20':
+ resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.27.7':
resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
+ '@esbuild/android-arm@0.28.0':
+ resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.18.20':
+ resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
'@esbuild/android-x64@0.27.7':
resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
+ '@esbuild/android-x64@0.28.0':
+ resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.18.20':
+ resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
'@esbuild/darwin-arm64@0.27.7':
resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
+ '@esbuild/darwin-arm64@0.28.0':
+ resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.18.20':
+ resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
'@esbuild/darwin-x64@0.27.7':
resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
+ '@esbuild/darwin-x64@0.28.0':
+ resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@esbuild/freebsd-arm64@0.27.7':
resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
+ '@esbuild/freebsd-arm64@0.28.0':
+ resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.18.20':
+ resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
'@esbuild/freebsd-x64@0.27.7':
resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
+ '@esbuild/freebsd-x64@0.28.0':
+ resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.18.20':
+ resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
'@esbuild/linux-arm64@0.27.7':
resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
+ '@esbuild/linux-arm64@0.28.0':
+ resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.18.20':
+ resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
'@esbuild/linux-arm@0.27.7':
resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
+ '@esbuild/linux-arm@0.28.0':
+ resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.18.20':
+ resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
'@esbuild/linux-ia32@0.27.7':
resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-ia32@0.28.0':
+ resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.18.20':
+ resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.27.7':
resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
+ '@esbuild/linux-loong64@0.28.0':
+ resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.18.20':
+ resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
'@esbuild/linux-mips64el@0.27.7':
resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
+ '@esbuild/linux-mips64el@0.28.0':
+ resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.18.20':
+ resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
'@esbuild/linux-ppc64@0.27.7':
resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
+ '@esbuild/linux-ppc64@0.28.0':
+ resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.18.20':
+ resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
'@esbuild/linux-riscv64@0.27.7':
resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
+ '@esbuild/linux-riscv64@0.28.0':
+ resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.18.20':
+ resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
'@esbuild/linux-s390x@0.27.7':
resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
+ '@esbuild/linux-s390x@0.28.0':
+ resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.18.20':
+ resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
'@esbuild/linux-x64@0.27.7':
resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
+ '@esbuild/linux-x64@0.28.0':
+ resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@esbuild/netbsd-arm64@0.27.7':
resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
+ '@esbuild/netbsd-arm64@0.28.0':
+ resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.18.20':
+ resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
'@esbuild/netbsd-x64@0.27.7':
resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
+ '@esbuild/netbsd-x64@0.28.0':
+ resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@esbuild/openbsd-arm64@0.27.7':
resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
+ '@esbuild/openbsd-arm64@0.28.0':
+ resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.18.20':
+ resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
'@esbuild/openbsd-x64@0.27.7':
resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
+ '@esbuild/openbsd-x64@0.28.0':
+ resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
'@esbuild/openharmony-arm64@0.27.7':
resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
+ '@esbuild/openharmony-arm64@0.28.0':
+ resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.18.20':
+ resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
'@esbuild/sunos-x64@0.27.7':
resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
+ '@esbuild/sunos-x64@0.28.0':
+ resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.18.20':
+ resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
'@esbuild/win32-arm64@0.27.7':
resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.27.7':
- resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ '@esbuild/win32-arm64@0.28.0':
+ resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.18.20':
+ resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.27.7':
+ resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.0':
+ resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
+ '@esbuild/win32-x64@0.18.20':
+ resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@esbuild/win32-x64@0.27.7':
resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
+ '@esbuild/win32-x64@0.28.0':
+ resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -2442,6 +2906,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ aws-ssl-profiles@1.1.2:
+ resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
+ engines: {node: '>= 6.0.0'}
+
axe-core@4.11.3:
resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==}
engines: {node: '>=4'}
@@ -2510,6 +2978,9 @@ packages:
bs58@6.0.0:
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+ buffer-from@1.1.2:
+ resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -2872,6 +3343,102 @@ packages:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
+ drizzle-kit@0.31.10:
+ resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
+ hasBin: true
+
+ drizzle-orm@0.45.2:
+ resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==}
+ peerDependencies:
+ '@aws-sdk/client-rds-data': '>=3'
+ '@cloudflare/workers-types': '>=4'
+ '@electric-sql/pglite': '>=0.2.0'
+ '@libsql/client': '>=0.10.0'
+ '@libsql/client-wasm': '>=0.10.0'
+ '@neondatabase/serverless': '>=0.10.0'
+ '@op-engineering/op-sqlite': '>=2'
+ '@opentelemetry/api': ^1.4.1
+ '@planetscale/database': '>=1.13'
+ '@prisma/client': '*'
+ '@tidbcloud/serverless': '*'
+ '@types/better-sqlite3': '*'
+ '@types/pg': '*'
+ '@types/sql.js': '*'
+ '@upstash/redis': '>=1.34.7'
+ '@vercel/postgres': '>=0.8.0'
+ '@xata.io/client': '*'
+ better-sqlite3: '>=7'
+ bun-types: '*'
+ expo-sqlite: '>=14.0.0'
+ gel: '>=2'
+ knex: '*'
+ kysely: '*'
+ mysql2: '>=2'
+ pg: '>=8'
+ postgres: '>=3'
+ prisma: '*'
+ sql.js: '>=1'
+ sqlite3: '>=5'
+ peerDependenciesMeta:
+ '@aws-sdk/client-rds-data':
+ optional: true
+ '@cloudflare/workers-types':
+ optional: true
+ '@electric-sql/pglite':
+ optional: true
+ '@libsql/client':
+ optional: true
+ '@libsql/client-wasm':
+ optional: true
+ '@neondatabase/serverless':
+ optional: true
+ '@op-engineering/op-sqlite':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@prisma/client':
+ optional: true
+ '@tidbcloud/serverless':
+ optional: true
+ '@types/better-sqlite3':
+ optional: true
+ '@types/pg':
+ optional: true
+ '@types/sql.js':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/postgres':
+ optional: true
+ '@xata.io/client':
+ optional: true
+ better-sqlite3:
+ optional: true
+ bun-types:
+ optional: true
+ expo-sqlite:
+ optional: true
+ gel:
+ optional: true
+ knex:
+ optional: true
+ kysely:
+ optional: true
+ mysql2:
+ optional: true
+ pg:
+ optional: true
+ postgres:
+ optional: true
+ prisma:
+ optional: true
+ sql.js:
+ optional: true
+ sqlite3:
+ optional: true
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -2958,11 +3525,26 @@ packages:
es-toolkit@1.47.0:
resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
+ esbuild@0.18.20:
+ resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
hasBin: true
+ esbuild@0.28.0:
+ resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -3268,6 +3850,9 @@ packages:
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
+ generate-function@2.3.1:
+ resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
+
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -3641,6 +4226,9 @@ packages:
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+ is-property@1.0.2:
+ resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
+
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -3927,6 +4515,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru.min@1.1.4:
+ resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
+ engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
+
lucide-react@1.8.0:
resolution: {integrity: sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==}
peerDependencies:
@@ -4165,6 +4757,16 @@ packages:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
+ mysql2@3.22.4:
+ resolution: {integrity: sha512-CtXYlmL7ZamiYKbmqkamQHWJROUHSfm+f3kByzGfknw7kW51mcB2ouMUqYq1XfYxbXmnWo6RhPydx6OCqdgcmQ==}
+ engines: {node: '>= 8.0'}
+ peerDependencies:
+ '@types/node': '>= 8'
+
+ named-placeholders@1.1.6:
+ resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==}
+ engines: {node: '>=8.0.0'}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -4822,6 +5424,9 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ source-map-support@0.5.21:
+ resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
+
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
@@ -4829,6 +5434,10 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ sql-escaper@1.3.3:
+ resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
+ engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
+
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -5043,6 +5652,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tsx@4.22.3:
+ resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@@ -5662,6 +6276,8 @@ snapshots:
which: 4.0.0
yocto-spinner: 1.1.0
+ '@drizzle-team/brocli@0.10.2': {}
+
'@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)':
dependencies:
'@noble/ciphers': 1.3.0
@@ -5682,84 +6298,316 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@esbuild-kit/core-utils@3.3.2':
+ dependencies:
+ esbuild: 0.18.20
+ source-map-support: 0.5.21
+
+ '@esbuild-kit/esm-loader@2.6.5':
+ dependencies:
+ '@esbuild-kit/core-utils': 3.3.2
+ get-tsconfig: 4.14.0
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
'@esbuild/aix-ppc64@0.27.7':
optional: true
+ '@esbuild/aix-ppc64@0.28.0':
+ optional: true
+
+ '@esbuild/android-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
'@esbuild/android-arm64@0.27.7':
optional: true
+ '@esbuild/android-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/android-arm@0.18.20':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
'@esbuild/android-arm@0.27.7':
optional: true
+ '@esbuild/android-arm@0.28.0':
+ optional: true
+
+ '@esbuild/android-x64@0.18.20':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
'@esbuild/android-x64@0.27.7':
optional: true
+ '@esbuild/android-x64@0.28.0':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
'@esbuild/darwin-arm64@0.27.7':
optional: true
+ '@esbuild/darwin-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/darwin-x64@0.18.20':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
'@esbuild/darwin-x64@0.27.7':
optional: true
+ '@esbuild/darwin-x64@0.28.0':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/freebsd-arm64@0.27.7':
optional: true
+ '@esbuild/freebsd-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
'@esbuild/freebsd-x64@0.27.7':
optional: true
+ '@esbuild/freebsd-x64@0.28.0':
+ optional: true
+
+ '@esbuild/linux-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
'@esbuild/linux-arm64@0.27.7':
optional: true
+ '@esbuild/linux-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/linux-arm@0.18.20':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
'@esbuild/linux-arm@0.27.7':
optional: true
+ '@esbuild/linux-arm@0.28.0':
+ optional: true
+
+ '@esbuild/linux-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
'@esbuild/linux-ia32@0.27.7':
optional: true
+ '@esbuild/linux-ia32@0.28.0':
+ optional: true
+
+ '@esbuild/linux-loong64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
'@esbuild/linux-loong64@0.27.7':
optional: true
+ '@esbuild/linux-loong64@0.28.0':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.18.20':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
'@esbuild/linux-mips64el@0.27.7':
optional: true
+ '@esbuild/linux-mips64el@0.28.0':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
'@esbuild/linux-ppc64@0.27.7':
optional: true
+ '@esbuild/linux-ppc64@0.28.0':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
'@esbuild/linux-riscv64@0.27.7':
optional: true
+ '@esbuild/linux-riscv64@0.28.0':
+ optional: true
+
+ '@esbuild/linux-s390x@0.18.20':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
'@esbuild/linux-s390x@0.27.7':
optional: true
+ '@esbuild/linux-s390x@0.28.0':
+ optional: true
+
+ '@esbuild/linux-x64@0.18.20':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
'@esbuild/linux-x64@0.27.7':
optional: true
+ '@esbuild/linux-x64@0.28.0':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/netbsd-arm64@0.27.7':
optional: true
+ '@esbuild/netbsd-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
'@esbuild/netbsd-x64@0.27.7':
optional: true
+ '@esbuild/netbsd-x64@0.28.0':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
'@esbuild/openbsd-arm64@0.27.7':
optional: true
+ '@esbuild/openbsd-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.18.20':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
'@esbuild/openbsd-x64@0.27.7':
optional: true
+ '@esbuild/openbsd-x64@0.28.0':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
'@esbuild/openharmony-arm64@0.27.7':
optional: true
+ '@esbuild/openharmony-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/sunos-x64@0.18.20':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
'@esbuild/sunos-x64@0.27.7':
optional: true
+ '@esbuild/sunos-x64@0.28.0':
+ optional: true
+
+ '@esbuild/win32-arm64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
'@esbuild/win32-arm64@0.27.7':
optional: true
+ '@esbuild/win32-arm64@0.28.0':
+ optional: true
+
+ '@esbuild/win32-ia32@0.18.20':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
'@esbuild/win32-ia32@0.27.7':
optional: true
+ '@esbuild/win32-ia32@0.28.0':
+ optional: true
+
+ '@esbuild/win32-x64@0.18.20':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
'@esbuild/win32-x64@0.27.7':
optional: true
+ '@esbuild/win32-x64@0.28.0':
+ optional: true
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@@ -7440,10 +8288,10 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
- '@vitejs/plugin-react@6.0.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
+ '@vitejs/plugin-react@6.0.1(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
- vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)
'@vitest/coverage-v8@4.1.4(vitest@4.1.4)':
dependencies:
@@ -7457,7 +8305,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
+ vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))
'@vitest/expect@4.1.4':
dependencies:
@@ -7468,14 +8316,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))':
+ '@vitest/mocker@4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.4(@types/node@25.6.0)(typescript@6.0.3)
- vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)
'@vitest/pretty-format@4.1.4':
dependencies:
@@ -7643,6 +8491,8 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ aws-ssl-profiles@1.1.2: {}
+
axe-core@4.11.3: {}
axobject-query@4.1.0: {}
@@ -7712,6 +8562,8 @@ snapshots:
dependencies:
base-x: 5.0.1
+ buffer-from@1.1.2: {}
+
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
@@ -8014,6 +8866,17 @@ snapshots:
dotenv@17.4.2: {}
+ drizzle-kit@0.31.10:
+ dependencies:
+ '@drizzle-team/brocli': 0.10.2
+ '@esbuild-kit/esm-loader': 2.6.5
+ esbuild: 0.25.12
+ tsx: 4.22.3
+
+ drizzle-orm@0.45.2(mysql2@3.22.4(@types/node@25.6.0)):
+ optionalDependencies:
+ mysql2: 3.22.4(@types/node@25.6.0)
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -8169,6 +9032,60 @@ snapshots:
es-toolkit@1.47.0: {}
+ esbuild@0.18.20:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.18.20
+ '@esbuild/android-arm64': 0.18.20
+ '@esbuild/android-x64': 0.18.20
+ '@esbuild/darwin-arm64': 0.18.20
+ '@esbuild/darwin-x64': 0.18.20
+ '@esbuild/freebsd-arm64': 0.18.20
+ '@esbuild/freebsd-x64': 0.18.20
+ '@esbuild/linux-arm': 0.18.20
+ '@esbuild/linux-arm64': 0.18.20
+ '@esbuild/linux-ia32': 0.18.20
+ '@esbuild/linux-loong64': 0.18.20
+ '@esbuild/linux-mips64el': 0.18.20
+ '@esbuild/linux-ppc64': 0.18.20
+ '@esbuild/linux-riscv64': 0.18.20
+ '@esbuild/linux-s390x': 0.18.20
+ '@esbuild/linux-x64': 0.18.20
+ '@esbuild/netbsd-x64': 0.18.20
+ '@esbuild/openbsd-x64': 0.18.20
+ '@esbuild/sunos-x64': 0.18.20
+ '@esbuild/win32-arm64': 0.18.20
+ '@esbuild/win32-ia32': 0.18.20
+ '@esbuild/win32-x64': 0.18.20
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@@ -8198,6 +9115,35 @@ snapshots:
'@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7
+ esbuild@0.28.0:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.0
+ '@esbuild/android-arm': 0.28.0
+ '@esbuild/android-arm64': 0.28.0
+ '@esbuild/android-x64': 0.28.0
+ '@esbuild/darwin-arm64': 0.28.0
+ '@esbuild/darwin-x64': 0.28.0
+ '@esbuild/freebsd-arm64': 0.28.0
+ '@esbuild/freebsd-x64': 0.28.0
+ '@esbuild/linux-arm': 0.28.0
+ '@esbuild/linux-arm64': 0.28.0
+ '@esbuild/linux-ia32': 0.28.0
+ '@esbuild/linux-loong64': 0.28.0
+ '@esbuild/linux-mips64el': 0.28.0
+ '@esbuild/linux-ppc64': 0.28.0
+ '@esbuild/linux-riscv64': 0.28.0
+ '@esbuild/linux-s390x': 0.28.0
+ '@esbuild/linux-x64': 0.28.0
+ '@esbuild/netbsd-arm64': 0.28.0
+ '@esbuild/netbsd-x64': 0.28.0
+ '@esbuild/openbsd-arm64': 0.28.0
+ '@esbuild/openbsd-x64': 0.28.0
+ '@esbuild/openharmony-arm64': 0.28.0
+ '@esbuild/sunos-x64': 0.28.0
+ '@esbuild/win32-arm64': 0.28.0
+ '@esbuild/win32-ia32': 0.28.0
+ '@esbuild/win32-x64': 0.28.0
+
escalade@3.2.0: {}
escape-html@1.0.3: {}
@@ -8627,6 +9573,10 @@ snapshots:
fuzzysort@3.1.0: {}
+ generate-function@2.3.1:
+ dependencies:
+ is-property: 1.0.2
+
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
@@ -9035,6 +9985,8 @@ snapshots:
is-promise@4.0.0: {}
+ is-property@1.0.2: {}
+
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -9292,6 +10244,8 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lru.min@1.1.4: {}
+
lucide-react@1.8.0(react@19.2.5):
dependencies:
react: 19.2.5
@@ -9740,6 +10694,22 @@ snapshots:
mute-stream@3.0.0: {}
+ mysql2@3.22.4(@types/node@25.6.0):
+ dependencies:
+ '@types/node': 25.6.0
+ aws-ssl-profiles: 1.1.2
+ denque: 2.1.0
+ generate-function: 2.3.1
+ iconv-lite: 0.7.2
+ long: 5.3.2
+ lru.min: 1.1.4
+ named-placeholders: 1.1.6
+ sql-escaper: 1.3.3
+
+ named-placeholders@1.1.6:
+ dependencies:
+ lru.min: 1.1.4
+
nanoid@3.3.11: {}
nanoid@3.3.12: {}
@@ -10634,10 +11604,17 @@ snapshots:
source-map-js@1.2.1: {}
+ source-map-support@0.5.21:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
+ sql-escaper@1.3.3: {}
+
stable-hash@0.0.5: {}
stackback@0.0.2: {}
@@ -10859,6 +11836,12 @@ snapshots:
tslib@2.8.1: {}
+ tsx@4.22.3:
+ dependencies:
+ esbuild: 0.28.0
+ optionalDependencies:
+ fsevents: 2.3.3
+
tw-animate-css@1.4.0: {}
type-check@0.4.0:
@@ -11083,7 +12066,7 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0):
+ vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -11096,11 +12079,12 @@ snapshots:
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
+ tsx: 4.22.3
- vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)):
+ vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@24.1.3)(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)):
dependencies:
'@vitest/expect': 4.1.4
- '@vitest/mocker': 4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))
+ '@vitest/mocker': 4.1.4(msw@2.13.4(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
@@ -11117,7 +12101,7 @@ snapshots:
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)
+ vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
diff --git a/src/app/[locale]/account_recovery_confirmation/[code]/page.tsx b/src/app/[locale]/account_recovery_confirmation/[code]/page.tsx
new file mode 100644
index 00000000..0c21892a
--- /dev/null
+++ b/src/app/[locale]/account_recovery_confirmation/[code]/page.tsx
@@ -0,0 +1,10 @@
+import { RecoverAccountConfirmationPage } from '@/components/wallet/recover-account-confirmation-page';
+
+export default async function AccountRecoveryConfirmationPage({
+ params,
+}: {
+ params: Promise<{ code: string }>;
+}) {
+ const { code } = await params;
+ return ;
+}
diff --git a/src/app/[locale]/recover_account_step_1/page.tsx b/src/app/[locale]/recover_account_step_1/page.tsx
index b443e22d..ce6103db 100644
--- a/src/app/[locale]/recover_account_step_1/page.tsx
+++ b/src/app/[locale]/recover_account_step_1/page.tsx
@@ -1,5 +1,5 @@
-import { StaticPlaceholderPage } from '@/components/layout/static-placeholder-page';
+import { RecoverAccountStep1Page } from '@/components/wallet/recover-account-step-1-page';
export default function RecoverAccountPage() {
- return ;
+ return ;
}
diff --git a/src/app/api/broadcast/recover-account/route.ts b/src/app/api/broadcast/recover-account/route.ts
new file mode 100644
index 00000000..46d9777b
--- /dev/null
+++ b/src/app/api/broadcast/recover-account/route.ts
@@ -0,0 +1,129 @@
+// POST /api/broadcast/recover-account
+// Broadcast a signed recover_account transaction (step 2 of account recovery)
+import { NextRequest, NextResponse } from 'next/server';
+import { steem } from '@steemit/steem-js';
+import { eq } from 'drizzle-orm';
+import { SteemService } from '@/lib/steem/server';
+import { verifyCSRF, rateLimit } from '@/lib/middleware';
+import { getDb } from '@/lib/db';
+import { arecs } from '@/lib/db/schema';
+import type { SignedTransaction } from '@/lib/steem/types';
+
+interface RecoverAccountOperation {
+ account_to_recover: string;
+ new_owner_authority: {
+ weight_threshold: number;
+ account_auths: [string, number][];
+ key_auths: [string, number][];
+ };
+ recent_owner_authority: {
+ weight_threshold: number;
+ account_auths: [string, number][];
+ key_auths: [string, number][];
+ };
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const csrfError = await verifyCSRF(request);
+ if (csrfError) return csrfError;
+
+ const rateLimitError = await rateLimit(request, 'broadcast', {
+ maxRequests: 3,
+ windowSeconds: 60,
+ });
+ if (rateLimitError) return rateLimitError;
+
+ const { signedTx } = (await request.json()) as {
+ signedTx: SignedTransaction;
+ };
+
+ if (!signedTx) {
+ return NextResponse.json(
+ { error: 'Missing signed transaction' },
+ { status: 400 }
+ );
+ }
+
+ const isValid = await SteemService.verifySignature(signedTx);
+ if (!isValid) {
+ return NextResponse.json({ error: 'Invalid transaction format' }, { status: 400 });
+ }
+
+ const op0 = signedTx.operations?.[0];
+ if (!Array.isArray(op0) || op0[0] !== 'recover_account') {
+ return NextResponse.json(
+ { error: 'Invalid transaction: expected recover_account operation' },
+ { status: 400 }
+ );
+ }
+
+ // Deep validate the operation body structure
+ const opBody = op0[1] as unknown as RecoverAccountOperation;
+ if (
+ !opBody ||
+ typeof opBody.account_to_recover !== 'string' ||
+ !opBody.new_owner_authority ||
+ !opBody.recent_owner_authority ||
+ typeof opBody.new_owner_authority.weight_threshold !== 'number' ||
+ !Array.isArray(opBody.new_owner_authority.key_auths) ||
+ typeof opBody.recent_owner_authority.weight_threshold !== 'number' ||
+ !Array.isArray(opBody.recent_owner_authority.key_auths)
+ ) {
+ return NextResponse.json(
+ { error: 'Invalid recover_account operation body' },
+ { status: 400 }
+ );
+ }
+
+ // Cross-check against DB: the account must have a closed recovery record
+ // with a matching new_owner_key
+ const db = getDb();
+ if (db) {
+ const newKey = opBody.new_owner_authority.key_auths?.[0]?.[0];
+ if (!newKey) {
+ return NextResponse.json(
+ { error: 'Invalid new_owner_authority: missing key_auth' },
+ { status: 400 }
+ );
+ }
+
+ const record = await db.query.arecs.findFirst({
+ where: eq(arecs.accountName, opBody.account_to_recover),
+ columns: { id: true, status: true, newOwnerKey: true },
+ });
+
+ if (!record || record.status !== 'closed') {
+ return NextResponse.json(
+ { error: 'No confirmed recovery request found for this account' },
+ { status: 400 }
+ );
+ }
+
+ if (!record.newOwnerKey || record.newOwnerKey !== newKey) {
+ return NextResponse.json(
+ { error: 'new_owner_key does not match recovery record' },
+ { status: 400 }
+ );
+ }
+ }
+
+ const txForBroadcast = steem.auth.normalizeTransactionForBroadcast(
+ signedTx as unknown as Record
+ ) as unknown as SignedTransaction;
+
+ const result = await SteemService.broadcastTransaction(txForBroadcast);
+
+ return NextResponse.json({ success: true, result });
+ } catch (error) {
+ console.error('Broadcast recover-account error:', error);
+ const message = error instanceof Error ? error.message : String(error);
+ return NextResponse.json(
+ {
+ error: 'Failed to broadcast transaction',
+ details: message,
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/query/owner-history/route.ts b/src/app/api/query/owner-history/route.ts
new file mode 100644
index 00000000..deac1a2f
--- /dev/null
+++ b/src/app/api/query/owner-history/route.ts
@@ -0,0 +1,21 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { SteemService } from '@/lib/steem/server';
+import { rateLimit } from '@/lib/middleware';
+
+export async function GET(request: NextRequest) {
+ const rateLimitError = await rateLimit(request, 'query', { maxRequests: 30, windowSeconds: 60 });
+ if (rateLimitError) return rateLimitError;
+
+ const username = new URL(request.url).searchParams.get('username')?.trim().toLowerCase();
+ if (!username) {
+ return NextResponse.json({ error: 'username required' }, { status: 400 });
+ }
+
+ try {
+ const history = await SteemService.getOwnerHistory(username);
+ return NextResponse.json({ success: true, history });
+ } catch (error) {
+ console.error('owner-history error:', error);
+ return NextResponse.json({ error: 'Failed to fetch owner history' }, { status: 503 });
+ }
+}
diff --git a/src/app/api/recovery/confirm/route.ts b/src/app/api/recovery/confirm/route.ts
new file mode 100644
index 00000000..b19af602
--- /dev/null
+++ b/src/app/api/recovery/confirm/route.ts
@@ -0,0 +1,135 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { eq, and } from 'drizzle-orm';
+import { verifyCSRF, rateLimit } from '@/lib/middleware';
+import { getDb } from '@/lib/db';
+import { arecs } from '@/lib/db/schema';
+
+export async function POST(request: NextRequest) {
+ const csrfError = await verifyCSRF(request);
+ if (csrfError) return csrfError;
+
+ const rateLimitError = await rateLimit(request, 'recovery_confirm', {
+ maxRequests: 5,
+ windowSeconds: 300,
+ });
+ if (rateLimitError) return rateLimitError;
+
+ const body = (await request.json()) as {
+ code?: string;
+ account_name?: string;
+ old_owner_key?: string;
+ new_owner_key?: string;
+ new_owner_authority?: {
+ weight_threshold: number;
+ account_auths: [string, number][];
+ key_auths: [string, number][];
+ };
+ };
+
+ if (
+ !body.code ||
+ !body.account_name ||
+ !body.old_owner_key ||
+ !body.new_owner_key ||
+ !body.new_owner_authority
+ ) {
+ return NextResponse.json(
+ { status: 'error', error: 'Missing fields' },
+ { status: 400 }
+ );
+ }
+
+ // Validate confirmation code format (20 hex chars)
+ if (!/^[0-9a-f]{20}$/i.test(body.code)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid confirmation code' },
+ { status: 400 }
+ );
+ }
+
+ // Validate owner key formats (base58 chars only)
+ const stmKeyRegex = /^STM[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{50}$/;
+ if (!stmKeyRegex.test(body.old_owner_key) || !stmKeyRegex.test(body.new_owner_key)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid owner key format' },
+ { status: 400 }
+ );
+ }
+
+ const db = getDb();
+ if (!db) {
+ return NextResponse.json(
+ { status: 'error', error: 'Service unavailable' },
+ { status: 503 }
+ );
+ }
+
+ try {
+ // Step 1: Atomically claim the record by setting status to 'processing'.
+ // This prevents TOCTOU races — only one request will win the CAS.
+ const result = await db
+ .update(arecs)
+ .set({ status: 'processing' })
+ .where(
+ and(
+ eq(arecs.validationCode, body.code),
+ eq(arecs.accountName, body.account_name),
+ eq(arecs.status, 'confirmed')
+ )
+ );
+
+ // Drizzle mysql2 returns { affectedRows: number } for raw updates
+ const affected = (result as unknown as { affectedRows?: number }).affectedRows;
+ if (!affected || affected === 0) {
+ return NextResponse.json(
+ { status: 'error', error: 'Recovery request not found or already processed' },
+ { status: 400 }
+ );
+ }
+
+ // Step 1b: Cross-validate old_owner_key against the DB record.
+ // This ensures the client-submitted key matches the original request.
+ const record = await db.query.arecs.findFirst({
+ where: eq(arecs.validationCode, body.code),
+ columns: { id: true, ownerKey: true },
+ });
+ if (!record || (record.ownerKey && record.ownerKey !== body.old_owner_key)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Owner key mismatch' },
+ { status: 400 }
+ );
+ }
+
+ // Step 2: Call kingdom.recovery_account (broadcasts request_account_recovery on-chain).
+ // If this fails, the record stays in 'processing' and won't be re-processed.
+ const { SteemService } = await import('@/lib/steem/server');
+ await SteemService.requestAccountRecovery({
+ account_to_recover: body.account_name,
+ new_owner_authority: body.new_owner_authority,
+ });
+
+ // Step 3: Mark as closed — success
+ await db
+ .update(arecs)
+ .set({
+ oldOwnerKey: body.old_owner_key,
+ newOwnerKey: body.new_owner_key,
+ requestSubmittedAt: new Date(),
+ status: 'closed',
+ })
+ .where(and(eq(arecs.validationCode, body.code), eq(arecs.accountName, body.account_name)));
+
+ console.info('Account recovery confirmed:', {
+ code: body.code,
+ account_name: body.account_name,
+ });
+
+ return NextResponse.json({ status: 'ok' });
+ } catch (err) {
+ console.error('Recovery confirm failed:', err);
+ return NextResponse.json(
+ { status: 'error', error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/recovery/request/route.ts b/src/app/api/recovery/request/route.ts
new file mode 100644
index 00000000..d29906cf
--- /dev/null
+++ b/src/app/api/recovery/request/route.ts
@@ -0,0 +1,103 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { eq, and } from 'drizzle-orm';
+import { verifyCSRF, rateLimit } from '@/lib/middleware';
+import { getDb } from '@/lib/db';
+import { arecs } from '@/lib/db/schema';
+
+export async function POST(request: NextRequest) {
+ const csrfError = await verifyCSRF(request);
+ if (csrfError) return csrfError;
+
+ const rateLimitError = await rateLimit(request, 'recovery', { maxRequests: 5, windowSeconds: 300 });
+ if (rateLimitError) return rateLimitError;
+
+ const body = (await request.json()) as {
+ contact_email?: string;
+ account_name?: string;
+ owner_key?: string;
+ };
+
+ if (!body.contact_email || !body.account_name || !body.owner_key) {
+ return NextResponse.json({ status: 'error', error: 'Missing fields' }, { status: 400 });
+ }
+
+ // Normalize and validate account_name (Steem account rules: lowercase, 3-16 chars, starts with letter)
+ const accountName = body.account_name.trim().toLowerCase();
+ if (!/^[a-z][a-z0-9.-]{2,15}$/.test(accountName)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid account name format' },
+ { status: 400 }
+ );
+ }
+
+ // Normalize email (lowercase + trim) to prevent duplicate bypass
+ const contactEmail = body.contact_email.trim().toLowerCase();
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contactEmail)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid email format' },
+ { status: 400 }
+ );
+ }
+
+ // Validate owner_key format: must be a Steem public key (STM + base58, 53 chars)
+ if (!/^STM[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{50}$/.test(body.owner_key)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid owner key format' },
+ { status: 400 }
+ );
+ }
+
+ const db = getDb();
+ if (!db) {
+ console.error('Database unavailable for recovery request');
+ return NextResponse.json(
+ { status: 'error', error: 'Service unavailable' },
+ { status: 503 }
+ );
+ }
+
+ try {
+ // Check for duplicate (same account_name + contact_email, status='open')
+ const existing = await db.query.arecs.findFirst({
+ where: and(
+ eq(arecs.accountName, accountName),
+ eq(arecs.contactEmail, contactEmail),
+ eq(arecs.status, 'open')
+ ),
+ });
+
+ if (existing) {
+ return NextResponse.json({ status: 'duplicate' });
+ }
+
+ // Extract client IP
+ const remoteIp =
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ request.headers.get('x-real-ip') ||
+ null;
+
+ // Insert new recovery request
+ await db.insert(arecs).values({
+ uid: null, // not available without login session
+ contactEmail,
+ accountName,
+ ownerKey: body.owner_key,
+ provider: 'email',
+ remoteIp,
+ status: 'open',
+ });
+
+ console.info('Recovery request created:', {
+ account_name: accountName,
+ contact_email: contactEmail,
+ });
+
+ return NextResponse.json({ status: 'ok' });
+ } catch (err) {
+ console.error('Recovery request failed:', err);
+ return NextResponse.json(
+ { status: 'error', error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/recovery/verify/[code]/route.ts b/src/app/api/recovery/verify/[code]/route.ts
new file mode 100644
index 00000000..23d330ea
--- /dev/null
+++ b/src/app/api/recovery/verify/[code]/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { eq } from 'drizzle-orm';
+import { rateLimit } from '@/lib/middleware';
+import { getDb } from '@/lib/db';
+import { arecs } from '@/lib/db/schema';
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ code: string }> }
+) {
+ const rateLimitError = await rateLimit(request, 'recovery_verify', {
+ maxRequests: 20,
+ windowSeconds: 300,
+ });
+ if (rateLimitError) return rateLimitError;
+
+ const { code } = await params;
+
+ if (!code || !/^[0-9a-f]{20}$/i.test(code)) {
+ return NextResponse.json(
+ { status: 'error', error: 'Invalid confirmation code' },
+ { status: 400 }
+ );
+ }
+
+ const db = getDb();
+ if (!db) {
+ return NextResponse.json(
+ { status: 'error', error: 'Service unavailable' },
+ { status: 503 }
+ );
+ }
+
+ try {
+ const arec = await db.query.arecs.findFirst({
+ where: eq(arecs.validationCode, code),
+ columns: { id: true, accountName: true, status: true },
+ });
+
+ if (!arec) {
+ return NextResponse.json(
+ { status: 'error', error: 'Confirmation code not found' },
+ { status: 404 }
+ );
+ }
+
+ if (arec.status !== 'confirmed') {
+ return NextResponse.json(
+ { status: 'error', error: 'Recovery request has not been approved yet' },
+ { status: 400 }
+ );
+ }
+
+ return NextResponse.json({
+ status: 'ok',
+ account_name: arec.accountName,
+ });
+ } catch (err) {
+ console.error('Recovery verify failed:', err);
+ return NextResponse.json(
+ { status: 'error', error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/wallet/recover-account-confirmation-page.tsx b/src/components/wallet/recover-account-confirmation-page.tsx
new file mode 100644
index 00000000..802a230b
--- /dev/null
+++ b/src/components/wallet/recover-account-confirmation-page.tsx
@@ -0,0 +1,254 @@
+'use client';
+
+import { FormEvent, useEffect, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Loader2 } from 'lucide-react';
+import { StaticPageShell } from '@/components/layout/static-page-shell';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { apiClient, SteemSigner } from '@/lib/steem/client';
+
+function passwordToOwnerPubKey(username: string, password: string): string {
+ const raw = password.trim();
+ if (SteemSigner.isValidPrivateKey(raw)) {
+ return SteemSigner.privateKeyToPublicKey(raw);
+ }
+ const ownerWif = SteemSigner.derivePrivateKeyFromPassword(username, raw, 'owner');
+ return SteemSigner.privateKeyToPublicKey(ownerWif);
+}
+
+export function RecoverAccountConfirmationPage({ code }: { code: string }) {
+ const t = useTranslations('wallet.recoverAccountConfirmationPage');
+ const tWallet = useTranslations('wallet');
+
+ const [accountName, setAccountName] = useState(null);
+ const [verifyError, setVerifyError] = useState(null);
+ const [verifying, setVerifying] = useState(true);
+
+ const [oldPassword, setOldPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [oldPasswordError, setOldPasswordError] = useState(null);
+ const [newPasswordError, setNewPasswordError] = useState(null);
+ const [progress, setProgress] = useState(null);
+ const [success, setSuccess] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ // Verify code on mount
+ useEffect(() => {
+ let cancelled = false;
+ apiClient
+ .verifyRecoveryCode(code)
+ .then((res) => {
+ if (cancelled) return;
+ if (res.status === 'ok' && res.account_name) {
+ setAccountName(res.account_name);
+ } else {
+ setVerifyError(res.error || t('invalidCode'));
+ }
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setVerifyError(err instanceof Error ? err.message : t('unknownError'));
+ })
+ .finally(() => {
+ if (!cancelled) setVerifying(false);
+ });
+ return () => { cancelled = true; };
+ }, [code, t]);
+
+ const canSubmit =
+ accountName &&
+ oldPassword.trim().length > 0 &&
+ newPassword.trim().length > 0 &&
+ !oldPasswordError &&
+ !newPasswordError &&
+ !progress;
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!accountName) return;
+
+ const name = accountName;
+ const oldPwd = oldPassword.trim();
+ const newPwd = newPassword.trim();
+
+ setSubmitError(null);
+ setProgress(t('checkingOwner'));
+
+ try {
+ // Verify old owner key is in recent owner history
+ const oldOwnerPub = passwordToOwnerPubKey(name, oldPwd);
+ const ownerHistoryRes = await apiClient.getOwnerHistory(name);
+ const history = ownerHistoryRes.history ?? [];
+ const oldOwnerMatch = history.some(
+ (row) => row.previous_owner_authority?.key_auths?.[0]?.[0] === oldOwnerPub
+ );
+
+ if (!oldOwnerMatch) {
+ setOldPasswordError(t('oldPasswordNotInHistory'));
+ return;
+ }
+
+ setProgress(t('submittingRecovery'));
+
+ // Derive new owner key
+ const newOwnerPub = passwordToOwnerPubKey(name, newPwd);
+ const newOwnerAuthority = {
+ weight_threshold: 1,
+ account_auths: [] as [string, number][],
+ key_auths: [[newOwnerPub, 1]] as [string, number][],
+ };
+
+ // Call server confirm endpoint
+ const res = await apiClient.confirmAccountRecovery({
+ code,
+ account_name: name,
+ old_owner_key: oldOwnerPub,
+ new_owner_key: newOwnerPub,
+ new_owner_authority: newOwnerAuthority,
+ });
+
+ if (res.status !== 'ok') {
+ setSubmitError(res.error || t('unknownError'));
+ return;
+ }
+
+ // Sign recover_account operation client-side, then broadcast via server relay
+ try {
+ const { signedTx } = await SteemSigner.signRecoverAccount(name, oldPwd, newPwd);
+ const broadcastRes = await apiClient.broadcastRecoverAccountTx(signedTx);
+ if (!broadcastRes.success) {
+ console.warn('recover_account broadcast returned error:', broadcastRes.error);
+ }
+ } catch (broadcastErr) {
+ // Server already recorded the recovery, but broadcast failed.
+ // The user can retry broadcast later. Don't block the success UI.
+ console.warn('Client-side recover_account broadcast failed:', broadcastErr);
+ }
+
+ setSuccess(true);
+ } catch (err) {
+ setSubmitError(err instanceof Error ? err.message : t('unknownError'));
+ } finally {
+ setProgress(null);
+ }
+ };
+
+ // Loading state: verifying code
+ if (verifying) {
+ return (
+
+
+
+ {t('verifying')}
+
+
+ );
+ }
+
+ // Error state: code invalid
+ if (verifyError || !accountName) {
+ return (
+
+
+
+ {verifyError || t('invalidCode')}
+
+
+
+ );
+ }
+
+ // Success state
+ if (success) {
+ return (
+
+
+
+ );
+ }
+
+ // Main form
+ return (
+
+
+
+ );
+}
diff --git a/src/components/wallet/recover-account-step-1-page.tsx b/src/components/wallet/recover-account-step-1-page.tsx
new file mode 100644
index 00000000..85b04ce3
--- /dev/null
+++ b/src/components/wallet/recover-account-step-1-page.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import { FormEvent, useMemo, useRef, useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Loader2 } from 'lucide-react';
+import { StaticPageShell } from '@/components/layout/static-page-shell';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { apiClient, SteemSigner } from '@/lib/steem/client';
+import type { OwnerHistoryEntry } from '@/lib/steem/types';
+
+const emailRegex =
+ /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/;
+
+// 2016-07-14 — the Steem hack event date. Accounts whose last owner key update
+// predates this cannot use the stolen-account recovery flow.
+const JULY_14_HACK_MS = Date.UTC(2016, 6, 14, 0, 0, 0, 0);
+
+function parseSteemDateMs(raw: string | undefined): number | null {
+ if (!raw) return null;
+ const iso = raw.endsWith('Z') ? raw : `${raw}Z`;
+ const ms = new Date(iso).getTime();
+ return Number.isFinite(ms) ? ms : null;
+}
+
+function passwordToOwnerPubKey(username: string, passwordOrKey: string): string {
+ const raw = passwordOrKey.trim();
+ if (SteemSigner.isValidPrivateKey(raw)) {
+ return SteemSigner.privateKeyToPublicKey(raw);
+ }
+ const ownerWif = SteemSigner.derivePrivateKeyFromPassword(username, raw, 'owner');
+ return SteemSigner.privateKeyToPublicKey(ownerWif);
+}
+
+export function RecoverAccountStep1Page() {
+ const t = useTranslations('wallet.recoverAccountStep1Page');
+ const tWallet = useTranslations('wallet');
+
+ const [accountName, setAccountName] = useState('');
+ const [accountError, setAccountError] = useState(null);
+ const [recentPassword, setRecentPassword] = useState('');
+ const [passwordError, setPasswordError] = useState(null);
+ const [progress, setProgress] = useState(null);
+ const [step, setStep] = useState<'verify' | 'email' | 'done'>('verify');
+
+ const [email, setEmail] = useState('');
+ const [emailError, setEmailError] = useState(null);
+
+ const validateAccountVersion = useRef(0);
+
+ const normalizedName = accountName.trim().toLowerCase();
+
+ const derivedOwnerPub = useMemo(() => {
+ if (!normalizedName || !recentPassword.trim()) return null;
+ try {
+ return passwordToOwnerPubKey(normalizedName, recentPassword);
+ } catch {
+ return null;
+ }
+ }, [normalizedName, recentPassword]);
+
+ const canBegin =
+ normalizedName.length > 0 &&
+ recentPassword.trim().length > 0 &&
+ !accountError &&
+ !passwordError &&
+ !progress;
+
+ const canSubmitEmail =
+ step === 'email' &&
+ email.trim().length > 0 &&
+ !emailError &&
+ !progress;
+
+ const validateAccount = async (name: string) => {
+ setAccountError(null);
+ if (!name) return;
+ const version = Date.now();
+ validateAccountVersion.current = version;
+ const res = await apiClient.getAccounts([name], { fresh: true });
+ if (validateAccountVersion.current !== version) return;
+ const account = res.accounts?.[0];
+ if (!account) {
+ setAccountError(t('accountNotFound'));
+ return;
+ }
+
+ const lastOwnerUpdateMs = parseSteemDateMs(account.last_owner_update);
+ const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
+ if (
+ lastOwnerUpdateMs !== null &&
+ lastOwnerUpdateMs < Math.max(thirtyDaysAgo, JULY_14_HACK_MS)
+ ) {
+ setAccountError(t('unableToRecoverNotChangedRecently'));
+ }
+ };
+
+ const validateOwnerWasUsedRecently = async (name: string, passwordOrKey: string) => {
+ const pub = passwordToOwnerPubKey(name, passwordOrKey);
+ const ownerHistoryRes = await apiClient.getOwnerHistory(name);
+ const history: OwnerHistoryEntry[] = ownerHistoryRes.history ?? [];
+ return history.some((row) => row.previous_owner_authority?.key_auths?.[0]?.[0] === pub);
+ };
+
+ const onBeginRecovery = async (e: FormEvent) => {
+ e.preventDefault();
+ const name = normalizedName;
+ const pwd = recentPassword.trim();
+ if (!name || !pwd) return;
+
+ setPasswordError(null);
+ setProgress(t('checkingOwner'));
+ try {
+ const ok = await validateOwnerWasUsedRecently(name, pwd);
+ if (!ok) {
+ setPasswordError(t('passwordNotUsedInLastDays'));
+ return;
+ }
+ setStep('email');
+ } catch (err) {
+ const message = err instanceof Error ? err.message : t('unknownError');
+ setPasswordError(message);
+ } finally {
+ setProgress(null);
+ }
+ };
+
+ const onSubmitEmail = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!derivedOwnerPub) return;
+ setProgress(t('submittingRequest'));
+ try {
+ const res = await apiClient.initiateAccountRecoveryWithEmail({
+ contact_email: email.trim().toLowerCase(),
+ account_name: normalizedName,
+ owner_key: derivedOwnerPub,
+ });
+ if (res.status === 'duplicate') {
+ setEmailError(t('requestAlreadySubmitted'));
+ return;
+ }
+ if (res.status !== 'ok') {
+ setEmailError(res.error || t('unknownError'));
+ return;
+ }
+ setStep('done');
+ } catch (err) {
+ const message = err instanceof Error ? err.message : t('unknownError');
+ setEmailError(message);
+ } finally {
+ setProgress(null);
+ }
+ };
+
+ return (
+
+
+
{t('intro')}
+
+ {step === 'verify' && (
+
+ )}
+
+ {step === 'email' && (
+
+ )}
+
+ {step === 'done' && (
+
+ {t('thanksForSubmitting')}
+
+ )}
+
+
+ );
+}
+
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 413c8910..00e32230 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -462,6 +462,39 @@
"votersProxy": "proxy: {proxy}",
"votersEmpty": "No voters found.",
"votersLoadFailed": "Failed to load voters."
+ },
+ "recoverAccountStep1Page": {
+ "intro": "If your account was stolen, you can begin the recovery process by proving ownership with a recent owner password or owner private key. If it matches your account's recent owner history, you can submit a recovery request using your email.",
+ "accountName": "Account name",
+ "recentPassword": "Recent password / owner key",
+ "beginRecovery": "Begin recovery",
+ "checkingOwner": "Checking account owner…",
+ "submittingRequest": "Submitting recovery request…",
+ "passwordNotUsedInLastDays": "This password/key does not appear in the recent owner history for this account.",
+ "enterEmailToVerify": "Enter your email address so we can verify your identity and follow up about the recovery request.",
+ "email": "Email",
+ "emailNotValid": "Please enter a valid email address.",
+ "continueWithEmail": "Continue with email",
+ "thanksForSubmitting": "Thanks for submitting your recovery request. Please check your email for next steps.",
+ "requestAlreadySubmitted": "A recovery request for this account and email already exists.",
+ "accountNotFound": "Account name was not found.",
+ "unableToRecoverNotChangedRecently": "Unable to recover this account because it has not changed owner keys recently.",
+ "unknownError": "Unknown error"
+ },
+ "recoverAccountConfirmationPage": {
+ "intro": "Enter your old and new passwords to complete the account recovery. Your old password must match a recent owner key on file.",
+ "verifying": "Verifying recovery code…",
+ "invalidCode": "This recovery link is invalid or has expired.",
+ "accountName": "Account name",
+ "oldPassword": "Old password",
+ "newPassword": "New password",
+ "checkingOwner": "Verifying old owner key…",
+ "submittingRecovery": "Submitting recovery…",
+ "oldPasswordNotInHistory": "This password does not match any recent owner key for this account.",
+ "submit": "Recover account",
+ "successMessage": "Your account has been successfully recovered. You can now log in with your new password.",
+ "goToLogin": "Go to login",
+ "unknownError": "An unknown error occurred."
}
},
"navigation": {
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 86214b75..a7d74987 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -453,6 +453,39 @@
"votersProxy": "proxy: {proxy}",
"votersEmpty": "No se encontraron votantes.",
"votersLoadFailed": "No se pudieron cargar los votantes."
+ },
+ "recoverAccountStep1Page": {
+ "intro": "Si su cuenta fue robada, puede iniciar el proceso de recuperación demostrando la propiedad con una contraseña de owner reciente o una clave privada de owner. Si coincide con el historial reciente de owner de su cuenta, puede enviar una solicitud de recuperación usando su correo electrónico.",
+ "accountName": "Nombre de cuenta",
+ "recentPassword": "Contraseña reciente / clave de owner",
+ "beginRecovery": "Iniciar recuperación",
+ "checkingOwner": "Verificando owner de la cuenta…",
+ "submittingRequest": "Enviando solicitud de recuperación…",
+ "passwordNotUsedInLastDays": "Esta contraseña/clave no aparece en el historial reciente de owner de esta cuenta.",
+ "enterEmailToVerify": "Ingrese su dirección de correo electrónico para que podamos verificar su identidad y hacer seguimiento a la solicitud de recuperación.",
+ "email": "Correo electrónico",
+ "emailNotValid": "Por favor ingrese una dirección de correo electrónico válida.",
+ "continueWithEmail": "Continuar con correo electrónico",
+ "thanksForSubmitting": "Gracias por enviar su solicitud de recuperación. Por favor revise su correo electrónico para los siguientes pasos.",
+ "requestAlreadySubmitted": "Ya existe una solicitud de recuperación para esta cuenta y correo electrónico.",
+ "accountNotFound": "Nombre de cuenta no encontrado.",
+ "unableToRecoverNotChangedRecently": "No se puede recuperar esta cuenta porque no ha cambiado las claves de owner recientemente.",
+ "unknownError": "Error desconocido"
+ },
+ "recoverAccountConfirmationPage": {
+ "intro": "Ingrese su contraseña anterior y la nueva para completar la recuperación de la cuenta. Su contraseña anterior debe coincidir con una clave de owner reciente.",
+ "verifying": "Verificando código de recuperación…",
+ "invalidCode": "Este enlace de recuperación no es válido o ha expirado.",
+ "accountName": "Nombre de cuenta",
+ "oldPassword": "Contraseña anterior",
+ "newPassword": "Nueva contraseña",
+ "checkingOwner": "Verificando clave de owner anterior…",
+ "submittingRecovery": "Enviando recuperación…",
+ "oldPasswordNotInHistory": "Esta contraseña no coincide con ninguna clave de owner reciente de esta cuenta.",
+ "submit": "Recuperar cuenta",
+ "successMessage": "Su cuenta ha sido recuperada exitosamente. Ahora puede iniciar sesión con su nueva contraseña.",
+ "goToLogin": "Ir a iniciar sesión",
+ "unknownError": "Ocurrió un error desconocido."
}
},
"navigation": {
diff --git a/src/i18n/messages/zh.json b/src/i18n/messages/zh.json
index c55b999e..2b30b90c 100644
--- a/src/i18n/messages/zh.json
+++ b/src/i18n/messages/zh.json
@@ -462,6 +462,39 @@
"votersProxy": "代理:{proxy}",
"votersEmpty": "暂无投票者。",
"votersLoadFailed": "加载投票者失败。"
+ },
+ "recoverAccountStep1Page": {
+ "intro": "如果您的账户被盗,您可以通过提供最近的 owner 密码或 owner 私钥来证明所有权,从而开始恢复流程。如果与您账户最近的 owner 历史记录匹配,您可以使用电子邮件提交恢复请求。",
+ "accountName": "账户名",
+ "recentPassword": "最近的密码 / owner 密钥",
+ "beginRecovery": "开始恢复",
+ "checkingOwner": "正在验证账户 owner…",
+ "submittingRequest": "正在提交恢复请求…",
+ "passwordNotUsedInLastDays": "此密码/密钥未出现在该账户最近的 owner 历史记录中。",
+ "enterEmailToVerify": "请输入您的电子邮件地址,以便我们验证您的身份并跟进恢复请求。",
+ "email": "电子邮件",
+ "emailNotValid": "请输入有效的电子邮件地址。",
+ "continueWithEmail": "使用电子邮件继续",
+ "thanksForSubmitting": "感谢您提交恢复请求。请查收电子邮件获取后续步骤。",
+ "requestAlreadySubmitted": "该账户和电子邮件的恢复请求已存在。",
+ "accountNotFound": "未找到该账户名。",
+ "unableToRecoverNotChangedRecently": "无法恢复此账户,因为该账户最近未更改 owner 密钥。",
+ "unknownError": "未知错误"
+ },
+ "recoverAccountConfirmationPage": {
+ "intro": "输入旧密码和新密码以完成账户恢复。旧密码必须与该账户最近的 owner 密钥匹配。",
+ "verifying": "正在验证恢复码…",
+ "invalidCode": "此恢复链接无效或已过期。",
+ "accountName": "账户名",
+ "oldPassword": "旧密码",
+ "newPassword": "新密码",
+ "checkingOwner": "正在验证旧 owner 密钥…",
+ "submittingRecovery": "正在提交恢复…",
+ "oldPasswordNotInHistory": "此密码与该账户最近的 owner 密钥不匹配。",
+ "submit": "恢复账户",
+ "successMessage": "您的账户已成功恢复,现在可以使用新密码登录。",
+ "goToLogin": "前往登录",
+ "unknownError": "发生未知错误。"
}
},
"navigation": {
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
new file mode 100644
index 00000000..6fcde006
--- /dev/null
+++ b/src/lib/db/index.ts
@@ -0,0 +1,63 @@
+// Drizzle ORM database connection singleton
+// Mirrors the pattern in src/lib/cache/redis.ts
+
+import { drizzle } from 'drizzle-orm/mysql2';
+import mysql from 'mysql2/promise';
+import * as schema from './schema';
+
+import { MySql2Database } from 'drizzle-orm/mysql2';
+
+type DrizzleDb = MySql2Database & { $client: mysql.Pool };
+
+let db: DrizzleDb | null = null;
+let pool: mysql.Pool | null = null;
+let dbUnavailable = false;
+
+export function getDb() {
+ if (db) return db;
+
+ const url = process.env.DATABASE_URL;
+ if (!url) {
+ if (!dbUnavailable) {
+ dbUnavailable = true;
+ console.warn('DATABASE_URL not set; database features disabled');
+ }
+ return null;
+ }
+
+ try {
+ pool = mysql.createPool({
+ uri: url,
+ waitForConnections: true,
+ connectionLimit: 5,
+ queueLimit: 0,
+ enableKeepAlive: true,
+ });
+
+ db = drizzle(pool, { schema, mode: 'default' });
+ // Reset flag on successful creation (allows recovery after transient failures)
+ dbUnavailable = false;
+ return db;
+ } catch (err) {
+ // Do NOT permanently mark as unavailable — next call may succeed
+ console.error('Failed to create database connection:', err);
+ return null;
+ }
+}
+
+/** Close the pool (for tests / graceful shutdown) */
+export async function closeDb(): Promise {
+ if (pool) {
+ await pool.end();
+ pool = null;
+ db = null;
+ dbUnavailable = false;
+ }
+}
+
+/** Reset singleton state (for tests) */
+export function resetDb(): void {
+ db = null;
+ pool = null;
+ dbUnavailable = false;
+}
diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts
new file mode 100644
index 00000000..9be4058c
--- /dev/null
+++ b/src/lib/db/schema/index.ts
@@ -0,0 +1,40 @@
+import {
+ mysqlTable,
+ int,
+ varchar,
+ text,
+ datetime,
+ index,
+} from 'drizzle-orm/mysql-core';
+
+export const arecs = mysqlTable(
+ 'arecs',
+ {
+ id: int('id').autoincrement().primaryKey(),
+ userId: int('user_id'),
+ uid: varchar('uid', { length: 64 }),
+ contactEmail: varchar('contact_email', { length: 256 }).notNull(),
+ accountName: varchar('account_name', { length: 64 }).notNull(),
+ ownerKey: text('owner_key'),
+ oldOwnerKey: text('old_owner_key'),
+ newOwnerKey: text('new_owner_key'),
+ memoKey: text('memo_key'),
+ provider: varchar('provider', { length: 64 }),
+ emailConfirmationCode: varchar('email_confirmation_code', { length: 64 }),
+ validationCode: varchar('validation_code', { length: 64 }),
+ requestSubmittedAt: datetime('request_submitted_at'),
+ remoteIp: varchar('remote_ip', { length: 45 }),
+ status: varchar('status', { length: 32 }).default('open'),
+ createdAt: datetime('created_at').notNull().default(new Date()),
+ updatedAt: datetime('updated_at')
+ .notNull()
+ .default(new Date())
+ .$onUpdate(() => new Date()),
+ },
+ (table) => ({
+ idxAccountName: index('idx_arecs_account_name').on(table.accountName),
+ idxContactEmail: index('idx_arecs_contact_email').on(table.contactEmail),
+ idxUid: index('idx_arecs_uid').on(table.uid),
+ idxValidationCode: index('idx_arecs_validation_code').on(table.validationCode),
+ })
+);
diff --git a/src/lib/steem/client.ts b/src/lib/steem/client.ts
index 7507aea4..70834515 100644
--- a/src/lib/steem/client.ts
+++ b/src/lib/steem/client.ts
@@ -10,6 +10,7 @@ import type {
SteemAccount,
GlobalProperties,
BroadcastResult,
+ OwnerHistoryEntry,
} from './types';
import { buildAccountCreateOperation } from '@/lib/wallet/community';
@@ -475,6 +476,55 @@ export class SteemSigner {
return await this.signTransaction([normalized], [ownerKey]);
}
+ /**
+ * Sign a recover_account operation (client-side).
+ * Derives owner keys from passwords and signs with the OLD owner private key.
+ * The signed transaction must then be broadcast via apiClient.broadcastRecoverAccountTx().
+ *
+ * Prerequisite: The admin must have already broadcast `request_account_recovery`
+ * on-chain (via Conveyor / turtle) so that the new_owner_authority is set.
+ */
+ static async signRecoverAccount(
+ accountToRecover: string,
+ oldPassword: string,
+ newPassword: string
+ ): Promise<{ signedTx: SignedTransaction; oldOwnerPub: string; newOwnerPub: string }> {
+ // Derive WIF private keys — handle both passwords and raw WIF keys
+ const oldOwnerPriv = SteemSigner.isValidPrivateKey(oldPassword)
+ ? oldPassword
+ : steem.auth.toWif(accountToRecover, oldPassword, 'owner');
+ const newOwnerPriv = SteemSigner.isValidPrivateKey(newPassword)
+ ? newPassword
+ : steem.auth.toWif(accountToRecover, newPassword, 'owner');
+
+ // Derive public keys for the authority objects
+ const oldOwnerPub = steem.auth.getPublicKey(oldOwnerPriv);
+ const newOwnerPub = steem.auth.getPublicKey(newOwnerPriv);
+
+ const recentOwnerAuthority = {
+ weight_threshold: 1,
+ account_auths: [] as [string, number][],
+ key_auths: [[oldOwnerPub, 1]] as [string, number][],
+ };
+ const newOwnerAuthority = {
+ weight_threshold: 1,
+ account_auths: [] as [string, number][],
+ key_auths: [[newOwnerPub, 1]] as [string, number][],
+ };
+
+ const operation: Operation = [
+ 'recover_account',
+ {
+ account_to_recover: accountToRecover,
+ new_owner_authority: newOwnerAuthority,
+ recent_owner_authority: recentOwnerAuthority,
+ },
+ ];
+
+ const signedTx = await this.signTransaction([operation], [oldOwnerPriv]);
+ return { signedTx, oldOwnerPub, newOwnerPub };
+ }
+
/**
* Get public key from private key
*/
@@ -769,6 +819,71 @@ export const apiClient = {
const response = await fetch(`/api/query/history?${params.toString()}`);
return response.json();
},
+ async getOwnerHistory(
+ username: string
+ ): Promise<{ success?: boolean; history?: OwnerHistoryEntry[]; error?: string }> {
+ const response = await fetch(`/api/query/owner-history?username=${encodeURIComponent(username)}`);
+ return response.json();
+ },
+
+ async initiateAccountRecoveryWithEmail(payload: {
+ contact_email: string;
+ account_name: string;
+ owner_key: string;
+ }): Promise<{ status: 'ok' | 'duplicate' | 'error'; error?: string }> {
+ const response = await fetch('/api/recovery/request', {
+ method: 'POST',
+ headers: withCSRFHeader({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(payload),
+ });
+ return response.json();
+ },
+
+ /**
+ * Verify a recovery confirmation code
+ */
+ async verifyRecoveryCode(
+ code: string
+ ): Promise<{ status: 'ok' | 'error'; account_name?: string; error?: string }> {
+ const response = await fetch(`/api/recovery/verify/${encodeURIComponent(code)}`);
+ return response.json();
+ },
+
+ /**
+ * Confirm account recovery (step 2 — submit new owner keys)
+ */
+ async confirmAccountRecovery(payload: {
+ code: string;
+ account_name: string;
+ old_owner_key: string;
+ new_owner_key: string;
+ new_owner_authority: {
+ weight_threshold: number;
+ account_auths: [string, number][];
+ key_auths: [string, number][];
+ };
+ }): Promise<{ status: 'ok' | 'error'; error?: string }> {
+ const response = await fetch('/api/recovery/confirm', {
+ method: 'POST',
+ headers: withCSRFHeader({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(payload),
+ });
+ return response.json();
+ },
+
+ /**
+ * Broadcast a signed recover_account transaction via server relay
+ */
+ async broadcastRecoverAccountTx(
+ signedTx: unknown
+ ): Promise<{ success: boolean; error?: string; details?: string }> {
+ const response = await fetch('/api/broadcast/recover-account', {
+ method: 'POST',
+ headers: withCSRFHeader({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ signedTx }),
+ });
+ return response.json();
+ },
/**
* Get witnesses list
diff --git a/src/lib/steem/server.ts b/src/lib/steem/server.ts
index 62803f2f..2b813ab9 100644
--- a/src/lib/steem/server.ts
+++ b/src/lib/steem/server.ts
@@ -33,6 +33,7 @@ import type {
ProposalOrderBy,
ProposalOrderDirection,
ProposalStatus,
+ OwnerHistoryEntry,
} from './types';
// Steem configuration from environment; support multiple URLs for failover
@@ -90,6 +91,22 @@ export class SteemService {
});
}
+ /**
+ * Get owner key change history (condenser_api.get_owner_history).
+ */
+ static async getOwnerHistory(account: string): Promise {
+ return withFailover(async () => {
+ ensureConfigured();
+ const api = steem.api as unknown as {
+ getOwnerHistoryAsync: (name: string) => Promise;
+ };
+ return (await api.getOwnerHistoryAsync(account)) ?? [];
+ }).catch((error) => {
+ console.error('Error fetching owner history:', error);
+ throw new Error(`Failed to fetch owner history: ${(error as Error).message}`);
+ });
+ }
+
/**
* Get account history
*/
@@ -531,6 +548,53 @@ export class SteemService {
});
}
+ /**
+ * Request account recovery via Conveyor (kingdom.recovery_account).
+ * Broadcasts a `request_account_recovery` operation signed by the
+ * recovery account's posting key. This must happen **before** the
+ * client submits the `recover_account` operation.
+ *
+ * Requires CONVEYOR_USERNAME and CONVEYOR_POSTING_WIF env vars.
+ */
+ static async requestAccountRecovery(payload: {
+ account_to_recover: string;
+ new_owner_authority: {
+ weight_threshold: number;
+ account_auths: [string, number][];
+ key_auths: [string, number][];
+ };
+ }): Promise {
+ const conveyorUsername = process.env.CONVEYOR_USERNAME;
+ const conveyorWif = process.env.CONVEYOR_POSTING_WIF;
+
+ if (!conveyorUsername || !conveyorWif) {
+ throw new Error(
+ 'CONVEYOR_USERNAME / CONVEYOR_POSTING_WIF not configured'
+ );
+ }
+
+ return withFailover(async () => {
+ ensureConfigured();
+ const api = steem.api as unknown as {
+ signedCallAsync?: (
+ method: string,
+ params: unknown[],
+ account: string,
+ key: string
+ ) => Promise;
+ };
+ if (typeof api.signedCallAsync !== 'function') {
+ throw new Error('steem.api.signedCallAsync is not available');
+ }
+ await api.signedCallAsync(
+ 'kingdom.recovery_account',
+ payload as unknown as unknown[],
+ conveyorUsername,
+ conveyorWif
+ );
+ }) as Promise;
+ }
+
/**
* Broadcast a signed transaction
*/
diff --git a/src/lib/steem/types.ts b/src/lib/steem/types.ts
index 3cbb85bf..e7f25f7a 100644
--- a/src/lib/steem/types.ts
+++ b/src/lib/steem/types.ts
@@ -63,6 +63,12 @@ export interface SignedTransaction {
export type Operation = [string, Record];
+export interface OwnerHistoryEntry {
+ previous_owner_authority?: {
+ key_auths?: [string, number][];
+ };
+}
+
export interface BroadcastResult {
id: string;
block_num: number;
diff --git a/tests/unit/broadcast-recover-account-route.test.ts b/tests/unit/broadcast-recover-account-route.test.ts
new file mode 100644
index 00000000..766333fe
--- /dev/null
+++ b/tests/unit/broadcast-recover-account-route.test.ts
@@ -0,0 +1,232 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { POST } from '@/app/api/broadcast/recover-account/route';
+import { NextRequest } from 'next/server';
+
+// Mock middleware
+vi.mock('@/lib/middleware', () => ({
+ verifyCSRF: vi.fn().mockResolvedValue(null),
+ rateLimit: vi.fn().mockResolvedValue(null),
+}));
+
+// Mock SteemService
+vi.mock('@/lib/steem/server', () => ({
+ SteemService: {
+ verifySignature: vi.fn().mockResolvedValue(true),
+ broadcastTransaction: vi.fn().mockResolvedValue({ id: 'tx123' }),
+ },
+}));
+
+// Mock steem-js
+vi.mock('@steemit/steem-js', () => ({
+ steem: {
+ auth: {
+ normalizeTransactionForBroadcast: vi.fn((tx: unknown) => tx),
+ },
+ },
+}));
+
+// Valid Steem public key (STM + exactly 50 base58 chars = 53 chars total)
+const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+const VALID_KEY_A = 'STM' + B58.slice(0, 50);
+const VALID_KEY_B = 'STM' + B58.slice(1, 51);
+
+const mockFindFirst = vi.fn();
+const mockDb = {
+ query: {
+ arecs: {
+ findFirst: mockFindFirst,
+ },
+ },
+};
+const mockGetDb = vi.fn().mockReturnValue(mockDb);
+
+vi.mock('@/lib/db', () => ({
+ getDb: () => vi.mocked(mockGetDb)(),
+}));
+
+function makeRequest(body: Record): NextRequest {
+ return new NextRequest('http://localhost/api/broadcast/recover-account', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'test-token' },
+ body: JSON.stringify(body),
+ });
+}
+
+function makeSignedTx(overrides?: Partial<{
+ account_to_recover: string;
+ newKey: string;
+ recentKey: string;
+}>) {
+ const newKey = overrides?.newKey ?? VALID_KEY_B;
+ const recentKey = overrides?.recentKey ?? VALID_KEY_A;
+ return {
+ operations: [
+ [
+ 'recover_account',
+ {
+ account_to_recover: overrides?.account_to_recover ?? 'alice',
+ new_owner_authority: {
+ weight_threshold: 1,
+ account_auths: [],
+ key_auths: [[newKey, 1]],
+ },
+ recent_owner_authority: {
+ weight_threshold: 1,
+ account_auths: [],
+ key_auths: [[recentKey, 1]],
+ },
+ },
+ ],
+ ],
+ signatures: ['sig123'],
+ };
+}
+
+describe('POST /api/broadcast/recover-account', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetDb.mockReturnValue(mockDb);
+ // Default: DB has a matching closed record
+ mockFindFirst.mockResolvedValue({
+ id: 1,
+ status: 'closed',
+ newOwnerKey: VALID_KEY_B,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('broadcasts valid recover_account transaction', async () => {
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(data.success).toBe(true);
+ expect(res.status).toBe(200);
+ });
+
+ it('returns 400 when signedTx is missing', async () => {
+ const req = makeRequest({});
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Missing signed transaction');
+ });
+
+ it('returns 400 when signature verification fails', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.verifySignature).mockResolvedValueOnce(false);
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid transaction format');
+ });
+
+ it('returns 400 when first operation is not recover_account', async () => {
+ const badTx = {
+ operations: [['account_update', {}]],
+ signatures: ['sig'],
+ };
+ const req = makeRequest({ signedTx: badTx });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain('expected recover_account');
+ });
+
+ it('returns 400 when operation body is invalid', async () => {
+ const badTx = {
+ operations: [['recover_account', { account_to_recover: 'alice' }]],
+ signatures: ['sig'],
+ };
+ const req = makeRequest({ signedTx: badTx });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid recover_account operation body');
+ });
+
+ it('returns 400 when new_owner_authority has no key_auth', async () => {
+ const badTx = {
+ operations: [
+ [
+ 'recover_account',
+ {
+ account_to_recover: 'alice',
+ new_owner_authority: {
+ weight_threshold: 1,
+ account_auths: [],
+ key_auths: [],
+ },
+ recent_owner_authority: {
+ weight_threshold: 1,
+ account_auths: [],
+ key_auths: [[VALID_KEY_A, 1]],
+ },
+ },
+ ],
+ ],
+ signatures: ['sig'],
+ };
+ const req = makeRequest({ signedTx: badTx });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid new_owner_authority: missing key_auth');
+ });
+
+ it('returns 400 when DB has no closed recovery record', async () => {
+ mockFindFirst.mockResolvedValue(undefined);
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain('No confirmed recovery request');
+ });
+
+ it('returns 400 when DB record status is not closed', async () => {
+ mockFindFirst.mockResolvedValue({ id: 1, status: 'processing', newOwnerKey: VALID_KEY_B });
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 400 when newOwnerKey does not match', async () => {
+ mockFindFirst.mockResolvedValue({ id: 1, status: 'closed', newOwnerKey: VALID_KEY_A });
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain('does not match');
+ });
+
+ it('returns 400 when newOwnerKey is null in DB', async () => {
+ mockFindFirst.mockResolvedValue({ id: 1, status: 'closed', newOwnerKey: null });
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toContain('does not match');
+ });
+
+ it('returns 500 when broadcastTransaction throws', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.broadcastTransaction).mockRejectedValueOnce(
+ new Error('Network error')
+ );
+
+ const req = makeRequest({ signedTx: makeSignedTx() });
+ const res = await POST(req);
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.error).toBe('Failed to broadcast transaction');
+ });
+});
diff --git a/tests/unit/health-route.test.ts b/tests/unit/health-route.test.ts
index 60f61ded..4a4716cd 100644
--- a/tests/unit/health-route.test.ts
+++ b/tests/unit/health-route.test.ts
@@ -78,6 +78,16 @@ describe('GET /api/health', () => {
expect(body.checks.steem.error).toBe('Connection refused');
});
+ it('returns degraded when probe throws', async () => {
+ mockGetSteemHealthStale.mockResolvedValue(null);
+ mockCheckSteemNodeHealth.mockRejectedValue(new Error('probe boom'));
+
+ const res = await GET();
+ expect(res.status).toBe(503);
+ const body = await res.json();
+ expect(body.checks.steem.error).toBe('probe boom');
+ });
+
it('serves stale cache when probe lock is held', async () => {
mockGetSteemHealthStale.mockResolvedValue({
healthy: false,
diff --git a/tests/unit/owner-history-route.test.ts b/tests/unit/owner-history-route.test.ts
new file mode 100644
index 00000000..a5eb6943
--- /dev/null
+++ b/tests/unit/owner-history-route.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { GET } from '@/app/api/query/owner-history/route';
+import { NextRequest } from 'next/server';
+import type { OwnerHistoryEntry } from '@/lib/steem/types';
+
+// Mock rate limit middleware
+vi.mock('@/lib/middleware', () => ({
+ rateLimit: vi.fn().mockResolvedValue(null),
+}));
+
+// Mock SteemService
+vi.mock('@/lib/steem/server', () => ({
+ SteemService: {
+ getOwnerHistory: vi.fn(),
+ },
+}));
+
+describe('GET /api/query/owner-history', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function makeRequest(username: string): NextRequest {
+ return new NextRequest(`http://localhost/api/query/owner-history?username=${encodeURIComponent(username)}`);
+ }
+
+ it('returns owner history for valid username', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ const mockHistory: OwnerHistoryEntry[] = [
+ { previous_owner_authority: { key_auths: [['STMxxx', 1] as [string, number]] } },
+ ];
+ vi.mocked(SteemService.getOwnerHistory).mockResolvedValueOnce(mockHistory);
+
+ const req = makeRequest('alice');
+ const res = await GET(req);
+ const data = await res.json();
+
+ expect(data.success).toBe(true);
+ expect(data.history).toHaveLength(1);
+ expect(res.status).toBe(200);
+ });
+
+ it('returns 400 when username is missing', async () => {
+ const req = new NextRequest('http://localhost/api/query/owner-history');
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('username required');
+ });
+
+ it('returns 400 when username is only whitespace', async () => {
+ const req = makeRequest(' ');
+ const res = await GET(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('username required');
+ });
+
+ it('trims and lowercases username', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.getOwnerHistory).mockResolvedValueOnce([]);
+
+ const req = makeRequest(' Alice ');
+ const res = await GET(req);
+ expect(res.status).toBe(200);
+ expect(SteemService.getOwnerHistory).toHaveBeenCalledWith('alice');
+ });
+
+ it('returns 503 when SteemService throws', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.getOwnerHistory).mockRejectedValueOnce(
+ new Error('RPC timeout')
+ );
+
+ const req = makeRequest('alice');
+ const res = await GET(req);
+ expect(res.status).toBe(503);
+ const data = await res.json();
+ expect(data.error).toBe('Failed to fetch owner history');
+ });
+
+ it('returns empty array when no history', async () => {
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.getOwnerHistory).mockResolvedValueOnce([]);
+
+ const req = makeRequest('alice');
+ const res = await GET(req);
+ const data = await res.json();
+
+ expect(data.success).toBe(true);
+ expect(data.history).toEqual([]);
+ });
+});
diff --git a/tests/unit/recover-owner-history-route.test.ts b/tests/unit/recover-owner-history-route.test.ts
new file mode 100644
index 00000000..09c59641
--- /dev/null
+++ b/tests/unit/recover-owner-history-route.test.ts
@@ -0,0 +1,51 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mockRateLimit = vi.fn();
+vi.mock('@/lib/middleware', () => ({
+ rateLimit: (...args: unknown[]) => mockRateLimit(...args),
+}));
+
+const mockGetOwnerHistory = vi.fn();
+vi.mock('@/lib/steem/server', () => ({
+ SteemService: {
+ getOwnerHistory: (...args: unknown[]) => mockGetOwnerHistory(...args),
+ },
+}));
+
+import { GET } from '@/app/api/query/owner-history/route';
+
+describe('GET /api/query/owner-history', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockRateLimit.mockResolvedValue(null);
+ });
+
+ it('returns 400 when username missing', async () => {
+ const res = await GET({ url: 'http://test/api/query/owner-history' } as never);
+ expect(res.status).toBe(400);
+ });
+
+ it('returns history on success', async () => {
+ mockGetOwnerHistory.mockResolvedValue([{ foo: 'bar' }]);
+ const res = await GET({ url: 'http://test/api/query/owner-history?username= ALICE ' } as never);
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.success).toBe(true);
+ expect(Array.isArray(body.history)).toBe(true);
+ expect(mockGetOwnerHistory).toHaveBeenCalledWith('alice');
+ });
+
+ it('returns 503 on service error', async () => {
+ mockGetOwnerHistory.mockRejectedValue(new Error('boom'));
+ const res = await GET({ url: 'http://test/api/query/owner-history?username=alice' } as never);
+ expect(res.status).toBe(503);
+ });
+
+ it('short-circuits when rate limited', async () => {
+ mockRateLimit.mockResolvedValue(new Response('rl', { status: 429 }));
+ const res = await GET({ url: 'http://test/api/query/owner-history?username=alice' } as never);
+ expect(res.status).toBe(429);
+ expect(mockGetOwnerHistory).not.toHaveBeenCalled();
+ });
+});
+
diff --git a/tests/unit/recovery-confirm-route.test.ts b/tests/unit/recovery-confirm-route.test.ts
new file mode 100644
index 00000000..97b4e736
--- /dev/null
+++ b/tests/unit/recovery-confirm-route.test.ts
@@ -0,0 +1,228 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { POST } from '@/app/api/recovery/confirm/route';
+import { NextRequest, NextResponse } from 'next/server';
+
+// Mock the CSRF and rate limit middleware
+vi.mock('@/lib/middleware', () => ({
+ verifyCSRF: vi.fn().mockResolvedValue(null),
+ rateLimit: vi.fn().mockResolvedValue(null),
+}));
+
+// Mock the SteemService requestAccountRecovery
+vi.mock('@/lib/steem/server', () => ({
+ SteemService: {
+ requestAccountRecovery: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+// Valid Steem public key (STM + exactly 50 base58 chars = 53 chars total)
+const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
+const VALID_KEY_A = 'STM' + B58.slice(0, 50);
+const VALID_KEY_B = 'STM' + B58.slice(1, 51);
+
+const mockFindFirst = vi.fn();
+let mockUpdateFn: ReturnType;
+
+const mockDb = {
+ query: {
+ arecs: {
+ findFirst: mockFindFirst,
+ },
+ },
+ get update() {
+ return mockUpdateFn;
+ },
+};
+const mockGetDb = vi.fn().mockReturnValue(mockDb);
+
+vi.mock('@/lib/db', () => ({
+ getDb: () => vi.mocked(mockGetDb)(),
+}));
+
+const VALID_CODE = '5bc350832943043e8a82';
+
+function makeRequest(body: Record): NextRequest {
+ return new NextRequest('http://localhost/api/recovery/confirm', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'test-token' },
+ body: JSON.stringify(body),
+ });
+}
+
+function setupUpdateMocks(firstResult: unknown, secondResult?: unknown) {
+ const chain1: Record> = {};
+ chain1.where = vi.fn().mockResolvedValue(firstResult);
+ chain1.set = vi.fn().mockReturnValue({ where: chain1.where });
+
+ const chain2: Record> = {};
+ chain2.where = vi.fn().mockResolvedValue(secondResult ?? undefined);
+ chain2.set = vi.fn().mockReturnValue({ where: chain2.where });
+
+ mockUpdateFn = vi.fn()
+ .mockReturnValueOnce({ set: chain1.set })
+ .mockReturnValueOnce({ set: chain2.set });
+}
+
+describe('POST /api/recovery/confirm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetDb.mockReturnValue(mockDb);
+ // Default: findFirst returns a record matching old_owner_key
+ mockFindFirst.mockResolvedValue({ id: 1, ownerKey: VALID_KEY_A });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const validPayload = {
+ code: VALID_CODE,
+ account_name: 'alice',
+ old_owner_key: VALID_KEY_A,
+ new_owner_key: VALID_KEY_B,
+ new_owner_authority: {
+ weight_threshold: 1,
+ account_auths: [] as [string, number][],
+ key_auths: [[VALID_KEY_B, 1]] as [string, number][],
+ },
+ };
+
+ it('returns ok for valid confirmed recovery (atomic CAS)', async () => {
+ setupUpdateMocks({ affectedRows: 1 });
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(data.status).toBe('ok');
+ expect(res.status).toBe(200);
+ expect(mockUpdateFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('short-circuits when CSRF verification fails', async () => {
+ const { verifyCSRF } = await import('@/lib/middleware');
+ vi.mocked(verifyCSRF).mockResolvedValueOnce(
+ NextResponse.json({ error: 'Invalid CSRF' }, { status: 403 })
+ );
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(403);
+ });
+
+ it('short-circuits when rate limited', async () => {
+ const { rateLimit } = await import('@/lib/middleware');
+ vi.mocked(rateLimit).mockResolvedValueOnce(
+ NextResponse.json({ error: 'Too many requests' }, { status: 429 })
+ );
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(429);
+ });
+
+ it('returns 400 for missing fields', async () => {
+ const req = makeRequest({ code: VALID_CODE });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(data.error).toBe('Missing fields');
+ });
+
+ it('returns 400 for invalid code format', async () => {
+ const req = makeRequest({ ...validPayload, code: 'not-hex!' });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid confirmation code');
+ });
+
+ it('returns 400 for invalid old owner key format', async () => {
+ const req = makeRequest({ ...validPayload, old_owner_key: 'bad-key' });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid owner key format');
+ });
+
+ it('returns 400 for invalid new owner key format', async () => {
+ const req = makeRequest({ ...validPayload, new_owner_key: 'bad-key' });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Invalid owner key format');
+ });
+
+ it('returns 400 when atomic update claims 0 rows (already processed / not found)', async () => {
+ setupUpdateMocks({ affectedRows: 0 });
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Recovery request not found or already processed');
+ expect(mockUpdateFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns 400 when old_owner_key does not match DB record (owner key mismatch)', async () => {
+ setupUpdateMocks({ affectedRows: 1 });
+ // DB has a different ownerKey
+ mockFindFirst.mockResolvedValue({ id: 1, ownerKey: VALID_KEY_B });
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.error).toBe('Owner key mismatch');
+ });
+
+ it('allows confirm when DB ownerKey is null (legacy records)', async () => {
+ setupUpdateMocks({ affectedRows: 1 });
+ mockFindFirst.mockResolvedValue({ id: 1, ownerKey: null });
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(data.status).toBe('ok');
+ expect(res.status).toBe(200);
+ });
+
+ it('returns 503 when database is unavailable', async () => {
+ mockGetDb.mockReturnValue(null);
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(503);
+ const data = await res.json();
+ expect(data.error).toBe('Service unavailable');
+ });
+
+ it('returns 500 when requestAccountRecovery throws', async () => {
+ setupUpdateMocks({ affectedRows: 1 });
+
+ const { SteemService } = await import('@/lib/steem/server');
+ vi.mocked(SteemService.requestAccountRecovery).mockRejectedValueOnce(
+ new Error('Kingdom unreachable')
+ );
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ });
+
+ it('returns 500 when database update throws', async () => {
+ mockUpdateFn = vi.fn().mockImplementation(() => {
+ throw new Error('Connection lost');
+ });
+
+ const req = makeRequest(validPayload);
+ const res = await POST(req);
+ expect(res.status).toBe(500);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ });
+});
diff --git a/tests/unit/recovery-request-route.test.ts b/tests/unit/recovery-request-route.test.ts
new file mode 100644
index 00000000..eda4cb46
--- /dev/null
+++ b/tests/unit/recovery-request-route.test.ts
@@ -0,0 +1,182 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { POST } from '@/app/api/recovery/request/route';
+import { NextRequest } from 'next/server';
+
+// Mock the CSRF and rate limit middleware
+vi.mock('@/lib/middleware', () => ({
+ verifyCSRF: vi.fn().mockResolvedValue(null),
+ rateLimit: vi.fn().mockResolvedValue(null),
+}));
+
+// Mock the Drizzle db module
+const mockFindFirst = vi.fn();
+const mockInsertValues = vi.fn();
+const mockInsert = vi.fn().mockReturnValue({ values: mockInsertValues });
+const mockDb = {
+ query: {
+ arecs: {
+ findFirst: mockFindFirst,
+ },
+ },
+ insert: mockInsert,
+};
+const mockGetDb = vi.fn().mockReturnValue(mockDb);
+
+vi.mock('@/lib/db', () => ({
+ getDb: () => vi.mocked(mockGetDb)(),
+}));
+
+function makeRequest(body: Record): NextRequest {
+ return new NextRequest('http://localhost/api/recovery/request', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 'test-token' },
+ body: JSON.stringify(body),
+ });
+}
+
+describe('POST /api/recovery/request', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetDb.mockReturnValue(mockDb);
+ mockFindFirst.mockResolvedValue(undefined);
+ mockInsertValues.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // Valid Steem public key (STM + 50 base58 chars = 53 chars total)
+ const VALID_OWNER_KEY = 'STM123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqr';
+
+ it('returns ok for valid new request', async () => {
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('ok');
+ expect(res.status).toBe(200);
+ expect(mockFindFirst).toHaveBeenCalledOnce();
+ expect(mockInsert).toHaveBeenCalledOnce();
+ expect(mockInsertValues).toHaveBeenCalledOnce();
+ });
+
+ it('returns duplicate for existing open request', async () => {
+ mockFindFirst.mockResolvedValueOnce({ id: 1, status: 'open' });
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('duplicate');
+ expect(res.status).toBe(200);
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 for missing fields', async () => {
+ const req = makeRequest({ contact_email: 'test@example.com' });
+ const res = await POST(req);
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ });
+
+ it('returns 400 for invalid owner_key format', async () => {
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'alice',
+ owner_key: 'not-a-valid-key',
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(data.error).toBe('Invalid owner key format');
+ expect(res.status).toBe(400);
+ expect(mockFindFirst).not.toHaveBeenCalled();
+ });
+
+ it('returns 400 for invalid email format', async () => {
+ const req = makeRequest({
+ contact_email: 'not-an-email',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(data.error).toBe('Invalid email format');
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 400 for invalid account_name format', async () => {
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'a', // too short
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(data.error).toBe('Invalid account name format');
+ expect(res.status).toBe(400);
+ });
+
+ it('trims account_name whitespace', async () => {
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: ' alice ',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('ok');
+ // Verify insert was called with trimmed name
+ const insertCall = mockInsertValues.mock.calls[0];
+ expect(insertCall).toBeDefined();
+ const inserted = insertCall![0] as { accountName: string };
+ expect(inserted.accountName).toBe('alice');
+ });
+
+ it('normalizes email to lowercase for duplicate check', async () => {
+ const req = makeRequest({
+ contact_email: 'TEST@EXAMPLE.COM',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('ok');
+ expect(mockFindFirst).toHaveBeenCalledOnce();
+ });
+
+ it('returns 503 when database is unavailable', async () => {
+ mockGetDb.mockReturnValue(null);
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(503);
+ });
+
+ it('returns 500 when database throws', async () => {
+ mockFindFirst.mockRejectedValueOnce(new Error('Connection lost'));
+ const req = makeRequest({
+ contact_email: 'test@example.com',
+ account_name: 'alice',
+ owner_key: VALID_OWNER_KEY,
+ });
+ const res = await POST(req);
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(500);
+ });
+});
diff --git a/tests/unit/recovery-verify-route.test.ts b/tests/unit/recovery-verify-route.test.ts
new file mode 100644
index 00000000..cb8baf65
--- /dev/null
+++ b/tests/unit/recovery-verify-route.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { GET } from '@/app/api/recovery/verify/[code]/route';
+
+// Mock rate limit middleware
+vi.mock('@/lib/middleware', () => ({
+ rateLimit: vi.fn().mockResolvedValue(null),
+}));
+
+// Mock the Drizzle db module
+const mockFindFirst = vi.fn();
+const mockDb = {
+ query: {
+ arecs: {
+ findFirst: mockFindFirst,
+ },
+ },
+};
+const mockGetDb = vi.fn().mockReturnValue(mockDb);
+
+vi.mock('@/lib/db', () => ({
+ getDb: () => vi.mocked(mockGetDb)(),
+}));
+
+function makeRequest(code: string): Request {
+ return new Request(`http://localhost/api/recovery/verify/${code}`);
+}
+
+// Cast to any to pass dynamic route params
+type GETWithParams = (req: Request, ctx: { params: Promise<{ code: string }> }) => Promise;
+
+const VALID_CODE = '5bc350832943043e8a82'; // 20 hex chars
+
+describe('GET /api/recovery/verify/[code]', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockGetDb.mockReturnValue(mockDb);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('returns account_name for valid confirmed code', async () => {
+ mockFindFirst.mockResolvedValueOnce({
+ id: 1,
+ accountName: 'alice',
+ status: 'confirmed',
+ });
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('ok');
+ expect(data.account_name).toBe('alice');
+ expect(res.status).toBe(200);
+ });
+
+ it('returns error for non-existent code', async () => {
+ mockFindFirst.mockResolvedValueOnce(undefined);
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('error');
+ expect(data.error).toBe('Confirmation code not found');
+ expect(res.status).toBe(404);
+ });
+
+ it('returns error for already used (closed) code', async () => {
+ mockFindFirst.mockResolvedValueOnce({
+ id: 2,
+ accountName: 'bob',
+ status: 'closed',
+ });
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(400);
+ });
+
+ it('returns error for open (not yet confirmed) code', async () => {
+ mockFindFirst.mockResolvedValueOnce({
+ id: 3,
+ accountName: 'charlie',
+ status: 'open',
+ });
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(400);
+ });
+
+ it('returns 400 for invalid code format', async () => {
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest('bad-code'),
+ { params: Promise.resolve({ code: 'bad-code' }) }
+ );
+ const data = await res.json();
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(400);
+ expect(data.error).toBe('Invalid confirmation code');
+ });
+
+ it('returns 503 when database is unavailable', async () => {
+ mockGetDb.mockReturnValue(null);
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(503);
+ });
+
+ it('returns 500 when database throws', async () => {
+ mockFindFirst.mockRejectedValueOnce(new Error('Connection lost'));
+
+ const res = await (GET as unknown as GETWithParams)(
+ makeRequest(VALID_CODE),
+ { params: Promise.resolve({ code: VALID_CODE }) }
+ );
+ const data = await res.json();
+
+ expect(data.status).toBe('error');
+ expect(res.status).toBe(500);
+ });
+});
diff --git a/tests/unit/steem-client-recover.test.ts b/tests/unit/steem-client-recover.test.ts
new file mode 100644
index 00000000..4aab1143
--- /dev/null
+++ b/tests/unit/steem-client-recover.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { apiClient } from '@/lib/steem/client';
+
+describe('steem client recover helpers', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('getOwnerHistory calls owner-history endpoint', async () => {
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ success: true, history: [] })));
+ (globalThis as unknown as { fetch: unknown }).fetch = fetchMock;
+
+ const res = await apiClient.getOwnerHistory('Alice');
+ expect(res.success).toBe(true);
+ expect(fetchMock).toHaveBeenCalledWith('/api/query/owner-history?username=Alice');
+ });
+
+ it('initiateAccountRecoveryWithEmail posts with correct structure', async () => {
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ status: 'ok' })));
+ (globalThis as unknown as { fetch: unknown }).fetch = fetchMock;
+
+ const payload = { contact_email: 'a@example.com', account_name: 'alice', owner_key: 'STMxxxx' };
+ const res = await apiClient.initiateAccountRecoveryWithEmail(payload);
+ expect(res.status).toBe('ok');
+
+ // Verify the call includes POST method, JSON content type, and body
+ const call = fetchMock.mock.calls.at(0);
+ expect(call).toBeDefined();
+ const [url, opts] = call as unknown as [string, RequestInit];
+ expect(url).toBe('/api/recovery/request');
+ expect(opts.method).toBe('POST');
+ expect(opts.headers).toHaveProperty('Content-Type', 'application/json');
+ // withCSRFHeader may or may not add X-CSRF-Token depending on cookie availability
+ expect(opts.body).toBe(JSON.stringify(payload));
+ });
+});