diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1d9c3a29 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel in-progress runs for the same PR/branch when a new push lands. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + js: + name: JS (${{ matrix.package }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: + - "." # root tooling package (lint-staged + simple-git-hooks + prettier) + - frontend + - mythos + - dbSchema + - sdk/sdk-typescript + - playground/merchant-express + - playground/merchant-fastify + - playground/merchant-hono + - playground/merchant-nextjs + - scripts/test-transaction + defaults: + run: + working-directory: ${{ matrix.package }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + # Corepack reads each package.json's `packageManager` pin and uses + # exactly that pnpm version, regardless of any globally-installed pnpm. + - name: Enable Corepack + run: corepack enable + + # The load-bearing line of this whole workflow: refuses to mutate the + # lockfile and exits non-zero if package.json drifted from it. This is + # what catches PRs that would otherwise silently rewrite the lockfile. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Format check + run: pnpm format:check + + # --if-present skips silently for packages without a lint/build script. + - name: Lint + run: pnpm run --if-present lint + + - name: Build + run: pnpm run --if-present build + + go: + name: Go + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + + - name: gofmt + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then + echo "Files need gofmt:" + echo "$out" + exit 1 + fi + + - name: go vet + run: go vet ./... + + - name: go build + run: go build ./... diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..9fcf1a38 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,50 @@ +# Dependencies +node_modules/ + +# Build outputs +.next/ +dist/ +build/ +out/ +.turbo/ +coverage/ + +# Lockfiles (managed by package managers) +pnpm-lock.yaml +package-lock.json +yarn.lock + +# Minified +*.min.js +*.min.css + +# Non-JS/TS roots — formatted by their own tools (gofmt, ruff/black, etc.) +backend/ +dbSchema/drizzle/ +sdk/python/ +playground/main.py +playground/pyproject.toml +playground/merchant-fastapi/ +playground/merchant-jokes/ +playground/merchant_server/ +playground/e2e_test/ + +# Python virtualenvs and cache anywhere in the tree +**/.venv/ +**/__pycache__/ +**/*.dist-info/ + +# Generated / vendored +**/.next/** +**/dist/** + +# Existing CI workflows (out of this PR's reformat scope; touch in a dedicated cleanup) +.github/workflows/deploy-sdks.yml +.github/workflows/publish-python-sdk.yml +.github/workflows/publish-ts-sdk.yml + +# Working/plan docs not committed to the repo +CRITICAL_SECURITY_ISSUES.md +DOC_INCONSISTENCIES.md +H8_PART_A_PLAN.md +TOOLING_HYGIENE_PLAN.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..5162f5e7 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100, + "trailingComma": "all", + "arrowParens": "always" +} diff --git a/CLAUDE.md b/CLAUDE.md index 88d7624f..000af439 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,15 +10,15 @@ For protocol details see [Sangria-Overview.md](Sangria-Overview.md). For archite ## Repository Map -| Directory | What | Stack | -|---|---|---| -| `backend/` | Orchestration API — accounts, payments, settlement, withdrawals | Go 1.25, Fiber v3, pgx/pgxpool | -| `dbSchema/` | Database schema — single source of truth | Drizzle ORM (TypeScript), PostgreSQL | -| `frontend/` | Docs site + merchant dashboard | Next.js 16, React 19, Tailwind 4 | -| `sdk/sdk-typescript/` | TypeScript merchant SDK (`@sangria-sdk/core`) | TypeScript, adapters for Express/Fastify/Hono | -| `sdk/python/` | Python merchant SDK (`sangria-core`) | Python 3.10+, httpx, FastAPI adapter | -| `playground/` | Example merchant servers + e2e test client | Express, Fastify, Hono, FastAPI, uv | -| `mythos/` | Internal admin dashboard | Next.js 16, WorkOS AuthKit, port 3001 | +| Directory | What | Stack | +| --------------------- | --------------------------------------------------------------- | --------------------------------------------- | +| `backend/` | Orchestration API — accounts, payments, settlement, withdrawals | Go 1.25, Fiber v3, pgx/pgxpool | +| `dbSchema/` | Database schema — single source of truth | Drizzle ORM (TypeScript), PostgreSQL | +| `frontend/` | Docs site + merchant dashboard | Next.js 16, React 19, Tailwind 4 | +| `sdk/sdk-typescript/` | TypeScript merchant SDK (`@sangria-sdk/core`) | TypeScript, adapters for Express/Fastify/Hono | +| `sdk/python/` | Python merchant SDK (`sangria-core`) | Python 3.10+, httpx, FastAPI adapter | +| `playground/` | Example merchant servers + e2e test client | Express, Fastify, Hono, FastAPI, uv | +| `mythos/` | Internal admin dashboard | Next.js 16, WorkOS AuthKit, port 3001 | Per-package build/test commands are in each package's own CLAUDE.md where one exists. See `backend/CLAUDE.md`, `dbSchema/CLAUDE.md`, `frontend/CLAUDE.md`, `sdk/sdk-typescript/CLAUDE.md`. @@ -26,12 +26,12 @@ Per-package build/test commands are in each package's own CLAUDE.md where one ex Sangria runs two strictly separated environments — different Postgres databases, different CDP/facilitator endpoints, different WorkOS tenants, different chain contexts (Base Sepolia for dev, Base mainnet for prod). -| Scope | Dev convention | Prod convention | -|---|---|---| -| Backend | Loads `.env` (copy from `.env.example`) | Railway runtime env vars | -| dbSchema | `pnpm push:dev` (loads `.env.dev`) | `pnpm push:prd` (loads `.env.prd`) | -| Frontend | `.env` / `.env.local` | Railway runtime env vars | -| Playground | `.env` with CDP testnet keys, Base Sepolia | Never runs against prod | +| Scope | Dev convention | Prod convention | +| ---------- | ------------------------------------------ | ---------------------------------- | +| Backend | Loads `.env` (copy from `.env.example`) | Railway runtime env vars | +| dbSchema | `pnpm push:dev` (loads `.env.dev`) | `pnpm push:prd` (loads `.env.prd`) | +| Frontend | `.env` / `.env.local` | Railway runtime env vars | +| Playground | `.env` with CDP testnet keys, Base Sepolia | Never runs against prod | **Default is always dev.** `go run .`, `pnpm dev`, and all local commands hit the dev environment. Production configs are for CI/CD and production runtimes only. @@ -53,26 +53,31 @@ Use these terms consistently: ## Non-Negotiable Principles ### Schema + - **Schema lives in Drizzle.** Any schema change starts in `dbSchema/schema.ts`. Go code is a consumer, never the author. Never hand-write SQL DDL. - **Enforce correctness at the database layer** (NOT NULL, unique, FK, CHECK). Never rely on caller discipline. - Schema conventions (column types, naming, FK rules) live in `dbSchema/CLAUDE.md`. ### Money & Ledger + - **Double-entry bookkeeping for all USDC->USD flows.** Every movement debits and credits named accounts. The ledger is the source of truth for balances. - **x402 settle is NOT HTTP-idempotent.** Persist intent before calling. Treat ambiguous HTTP responses as UNRESOLVED (not failed) and reconcile against on-chain state. Never release a fiat payout on HTTP 200 alone. - **EIP-3009 nonces give on-chain idempotency but do not solve HTTP-layer ambiguity.** Don't conflate the two. - Amount representation is defined in § Product Vocabulary (microunits). ### Code + - **Atomic admin checks.** Permission checks and mutations should be in the same SQL query to prevent TOCTOU races. - **Email normalization.** Always `strings.TrimSpace(strings.ToLower(email))` before storing or matching. - **Sentinel errors.** Use package-level `var Err... = errors.New(...)` for typed error handling. ### Security + - **CSRF Protection is automatic.** Frontend components use standard `internalFetch()` calls — never manual CSRF token handling. The fetch wrapper (`lib/fetch.ts`) automatically injects tokens. Backend validates via `auth.CSRFMiddleware()`. - **Use secure fetch wrapper.** Import `{ internalFetch } from "@/lib/fetch"` instead of global `fetch` for automatic CSRF protection on state-changing requests. ### SDK + - **SDK surface is a product.** Breaking changes to `@sangria-sdk/core` or `sangria-core` need explicit justification. - Match idioms of each host framework (Express middleware vs Fastify plugin vs FastAPI dependency) rather than forcing a single abstraction. - Keep TypeScript and Python SDK behavior in lockstep. If you add a feature to one, either add it to the other or explicitly document why it's language-specific. @@ -80,6 +85,7 @@ Use these terms consistently: - **Bump SDK versions in `deployment/SDK_VERSIONS.md`** — it's the single source of truth. CI auto-bumps the patch version if you forget, but explicit edits communicate intent (patch = fix, minor = feature, major = breaking per semver). Never hand-edit `sdk/sdk-typescript/package.json#version` or `sdk/python/pyproject.toml#version` — CI overwrites them at publish time. See `deployment/DEPLOYMENT.md` for the full flow. ### Process + - Ask clarifying questions before architectural changes. - Prefer principled reasoning over "what to change" — explain the why. - Match existing patterns in the codebase; flag inconsistencies rather than silently homogenizing. @@ -105,9 +111,11 @@ Applies to both `frontend/` (merchant portal) and `mythos/` (admin dashboard) ### Proxy routes (`app/api/**/route.ts`) - **URL-encode every dynamic segment** before interpolating into the backend path: + ```ts return proxyToBackend("POST", `/admin/withdrawals/${encodeURIComponent(id)}/approve`, { body }); ``` + Raw `${id}` lets a caller inject `/` or `..` to reach a different backend route with the authenticated bearer token attached. - **CSRF Protection**: Pass the request object to `proxyToBackend()` for automatic CSRF token extraction: diff --git a/README.md b/README.md index f6df5245..f3e8ad62 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ make down # Stop all services Requires Docker. Services: backend on `:8080`, frontend on `:3000`, mythos (admin) on `:3001`. +### One-time setup for new contributors + +The repo pins `pnpm@10.15.1` via the `packageManager` field in every `package.json`. Use Corepack (ships with Node 20+) so every developer runs the exact same pnpm version regardless of what's globally installed: + +```bash +corepack enable # one-time, makes the `pnpm` binary respect packageManager pins +pnpm install # at repo root — installs pre-commit tooling and sets up git hooks +``` + +The root `pnpm install` configures a pre-commit hook (`simple-git-hooks` + `lint-staged`) that runs Prettier on staged files. Skip with `SKIP_SIMPLE_GIT_HOOKS=1 git commit ...` if needed. CI also runs `pnpm install --frozen-lockfile`, `pnpm format:check`, and `pnpm build` (where present) on every PR — so any lockfile drift or unformatted code fails before review. + --- ## Quick Start @@ -42,7 +53,7 @@ app.get( fixedPrice(sangria, { price: 0.01, description: "Premium content" }), (req, res) => { res.json({ data: "premium content", tx: req.sangria?.transaction }); - } + }, ); app.listen(3000); diff --git a/backend/sangria-backend b/backend/sangria-backend deleted file mode 100755 index bd0ad380..00000000 Binary files a/backend/sangria-backend and /dev/null differ diff --git a/dbSchema/CLAUDE.md b/dbSchema/CLAUDE.md index 99b28d35..87afc8e9 100644 --- a/dbSchema/CLAUDE.md +++ b/dbSchema/CLAUDE.md @@ -28,4 +28,4 @@ pnpm studio:prd # Open Drizzle Studio browser (prod) - Some account rows have a nullable org-scope column — system-level rows leave it `NULL`, org-scoped rows set it. Check the schema before assuming non-null. - WorkOS IDs use TEXT columns, not UUID, to avoid cast issues with external identifiers. - The migration directory (`drizzle/`) has a single initial migration. Subsequent schema changes are applied via `drizzle-kit push` rather than generated migrations. -- **Do not use `BigInt(0)` as a default on `bigint` columns.** drizzle-kit 0.31.10 can't `JSON.stringify` a BigInt during its schema-diff phase and `pnpm push:dev`/`push:prd` will crash with `TypeError: Do not know how to serialize a BigInt`. Use `` .default(sql`0`) `` instead — same on-disk behaviour (DEFAULT 0 coerced to bigint by Postgres), no serialization bug. Auto-fixers occasionally "correct" it back to `BigInt(0)` because that looks more natural — don't accept that change. +- **Do not use `BigInt(0)` as a default on `bigint` columns.** drizzle-kit 0.31.10 can't `JSON.stringify` a BigInt during its schema-diff phase and `pnpm push:dev`/`push:prd` will crash with `TypeError: Do not know how to serialize a BigInt`. Use ``.default(sql`0`)`` instead — same on-disk behaviour (DEFAULT 0 coerced to bigint by Postgres), no serialization bug. Auto-fixers occasionally "correct" it back to `BigInt(0)` because that looks more natural — don't accept that change. diff --git a/dbSchema/README.md b/dbSchema/README.md index e0d1991f..69088421 100644 --- a/dbSchema/README.md +++ b/dbSchema/README.md @@ -28,15 +28,15 @@ DATABASE_URL=postgres://user:pass@prd-host:5432/dbname?sslmode=require ## Commands -| Command | What it does | -|---|---| -| `pnpm push` | Push schema to **dev** (default) | -| `pnpm push:dev` | Push schema to dev | -| `pnpm push:prd` | Push schema to prod | -| `pnpm generate` | Generate migration SQL files (saved to `./drizzle/`) | -| `pnpm studio` | Open Drizzle Studio for **dev** (default) | -| `pnpm studio:dev` | Open Drizzle Studio for dev | -| `pnpm studio:prd` | Open Drizzle Studio for prod | +| Command | What it does | +| ----------------- | ---------------------------------------------------- | +| `pnpm push` | Push schema to **dev** (default) | +| `pnpm push:dev` | Push schema to dev | +| `pnpm push:prd` | Push schema to prod | +| `pnpm generate` | Generate migration SQL files (saved to `./drizzle/`) | +| `pnpm studio` | Open Drizzle Studio for **dev** (default) | +| `pnpm studio:dev` | Open Drizzle Studio for dev | +| `pnpm studio:prd` | Open Drizzle Studio for prod | `pnpm push` compares `schema.ts` against the live database and applies any differences. It defaults to the dev environment. @@ -46,70 +46,72 @@ Defined in `schema.ts`. All tables use UUID primary keys with `defaultRandom()`. ### Enums -| Enum | Values | -|---|---| -| `transaction_status` | pending, confirmed, failed | -| `direction` | DEBIT, CREDIT | -| `currency` | USD, USDC, ETH | -| `account_type` | ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE | -| `network` | base, base-sepolia, polygon, solana, solana-devnet | -| `withdrawal_status` | pending_approval, approved, processing, completed, failed, reversed, canceled | -| `api_key_status` | active, pending, inactive | -| `invitation_status` | pending, accepted, declined, expired | +| Enum | Values | +| -------------------- | ----------------------------------------------------------------------------- | +| `transaction_status` | pending, confirmed, failed | +| `direction` | DEBIT, CREDIT | +| `currency` | USD, USDC, ETH | +| `account_type` | ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE | +| `network` | base, base-sepolia, polygon, solana, solana-devnet | +| `withdrawal_status` | pending_approval, approved, processing, completed, failed, reversed, canceled | +| `api_key_status` | active, pending, inactive | +| `invitation_status` | pending, accepted, declined, expired | ### Core Tables **organizations** — multi-tenant business entities -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| name | varchar(255) | Organization name | -| is_personal | boolean | True for auto-created personal orgs | -| created_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| ----------- | -------------- | ----------------------------------- | +| id | uuid | Primary key | +| name | varchar(255) | Organization name | +| is_personal | boolean | True for auto-created personal orgs | +| created_at | timestamp (tz) | Default now() | **users** — WorkOS identities -| Column | Type | Notes | -|---|---|---| -| workos_id | text | Primary key | -| owner | text | Display name or email | -| created_at | timestamp (tz) | Default now() | -| updated_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| ---------- | -------------- | --------------------- | +| workos_id | text | Primary key | +| owner | text | Display name or email | +| created_at | timestamp (tz) | Default now() | +| updated_at | timestamp (tz) | Default now() | **organization_members** — user-organization relationships -| Column | Type | Notes | -|---|---|---| -| user_id | text | FK → users.workos_id | -| organization_id | uuid | FK → organizations.id | -| is_admin | boolean | Admin permissions within org | -| joined_at | timestamp (tz) | When user joined org | +| Column | Type | Notes | +| --------------- | -------------- | ---------------------------- | +| user_id | text | FK → users.workos_id | +| organization_id | uuid | FK → organizations.id | +| is_admin | boolean | Admin permissions within org | +| joined_at | timestamp (tz) | When user joined org | Primary key: `(user_id, organization_id)` — users can only be in each organization once. **organization_invitations** — two-phase invitation processing -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| organization_id | uuid | FK → organizations.id | -| inviter_user_id | text | FK → users.workos_id (admin who sent invite) | -| invitee_email | varchar(255) | Email being invited | -| invitee_user_id | text | FK → users.workos_id (set when user exists and is added to org) | -| status | invitation_status | pending, accepted, declined, expired | -| message | text | Optional welcome message | -| invitation_token | varchar(255) | Secure token for email links | -| expires_at | timestamp (tz) | 7 days from creation | -| created_at | timestamp (tz) | Default now() | -| accepted_at | timestamp (tz) | When invitation was accepted | -| declined_at | timestamp (tz) | When invitation was declined | +| Column | Type | Notes | +| ---------------- | ----------------- | --------------------------------------------------------------- | +| id | uuid | Primary key | +| organization_id | uuid | FK → organizations.id | +| inviter_user_id | text | FK → users.workos_id (admin who sent invite) | +| invitee_email | varchar(255) | Email being invited | +| invitee_user_id | text | FK → users.workos_id (set when user exists and is added to org) | +| status | invitation_status | pending, accepted, declined, expired | +| message | text | Optional welcome message | +| invitation_token | varchar(255) | Secure token for email links | +| expires_at | timestamp (tz) | 7 days from creation | +| created_at | timestamp (tz) | Default now() | +| accepted_at | timestamp (tz) | When invitation was accepted | +| declined_at | timestamp (tz) | When invitation was declined | **Two-Phase Invitation Flow**: + 1. **Accept**: User accepts invitation via token-only flow (`POST /accept-invitation`). System marks as `status='accepted'` without requiring user details upfront. 2. **Process**: When user signs in via WorkOS, `ProcessAcceptedInvitations` automatically finds accepted invitations with `invitee_user_id IS NULL`, adds user to organizations, and sets `invitee_user_id` to complete the flow. **Technical Notes**: + - Email normalization: All emails stored as lowercase for consistent matching - Connection management: Processing uses pool operations instead of transactions to avoid "conn busy" errors - Error handling: Failed invitation processing is logged but doesn't block user creation @@ -117,122 +119,127 @@ Primary key: `(user_id, organization_id)` — users can only be in each organiza **admins** — access control list for Sangria staff -| Column | Type | Notes | -|---|---|---| -| user_id | text | Primary key, FK → users.workos_id | -| created_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| ---------- | -------------- | --------------------------------- | +| user_id | text | Primary key, FK → users.workos_id | +| created_at | timestamp (tz) | Default now() | ### Financial Engine **accounts** — double-entry ledger accounts -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| name | varchar(255) | Account name | -| type | account_type | ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE | -| currency | currency | USD, USDC, ETH | -| organization_id | uuid | FK → organizations.id (nullable) | -| created_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| --------------- | -------------- | ------------------------------------------ | +| id | uuid | Primary key | +| name | varchar(255) | Account name | +| type | account_type | ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE | +| currency | currency | USD, USDC, ETH | +| organization_id | uuid | FK → organizations.id (nullable) | +| created_at | timestamp (tz) | Default now() | **transactions** — idempotency envelopes for ledger writes -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| idempotency_key | varchar(255) | NOT NULL, UNIQUE | -| status | transaction_status | Default 'confirmed' (pending, confirmed, failed) | -| tx_hash | varchar(255) | Nullable, blockchain tx hash (set on confirm) | -| created_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| --------------- | ------------------ | ------------------------------------------------ | +| id | uuid | Primary key | +| idempotency_key | varchar(255) | NOT NULL, UNIQUE | +| status | transaction_status | Default 'confirmed' (pending, confirmed, failed) | +| tx_hash | varchar(255) | Nullable, blockchain tx hash (set on confirm) | +| created_at | timestamp (tz) | Default now() | **ledger_entries** — append-only journal lines -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| transaction_id | uuid | FK → transactions.id | -| currency | currency | Must match account currency | -| amount | bigint | Microunits, CHECK > 0 | -| direction | direction | DEBIT or CREDIT | -| account_id | uuid | FK → accounts.id | +| Column | Type | Notes | +| -------------- | --------- | --------------------------- | +| id | uuid | Primary key | +| transaction_id | uuid | FK → transactions.id | +| currency | currency | Must match account currency | +| amount | bigint | Microunits, CHECK > 0 | +| direction | direction | DEBIT or CREDIT | +| account_id | uuid | FK → accounts.id | ### Business Operations **merchants** — API keys for x402 payments -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| organization_id | uuid | FK → organizations.id | -| api_key | text | bcrypt hash of full key | -| key_id | varchar(8) | For O(1) indexed lookup | -| name | varchar(255) | Human-readable name | -| status | api_key_status | active, pending, inactive (default: active) | -| last_used_at | timestamp (tz) | Nullable | -| created_at | timestamp (tz) | Default now() | +| Column | Type | Notes | +| --------------- | -------------- | ------------------------------------------- | +| id | uuid | Primary key | +| organization_id | uuid | FK → organizations.id | +| api_key | text | bcrypt hash of full key | +| key_id | varchar(8) | For O(1) indexed lookup | +| name | varchar(255) | Human-readable name | +| status | api_key_status | active, pending, inactive (default: active) | +| last_used_at | timestamp (tz) | Nullable | +| created_at | timestamp (tz) | Default now() | Constraints: Unique API key, indexed by organization and key_id. **crypto_wallets** — Sangria-owned CDP wallet pool -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| address | varchar(255) | On-chain address | -| network | network | Which chain | -| account_id | uuid | FK → accounts.id (USDC ASSET) | +| Column | Type | Notes | +| ------------ | -------------- | -------------------------------- | +| id | uuid | Primary key | +| address | varchar(255) | On-chain address | +| network | network | Which chain | +| account_id | uuid | FK → accounts.id (USDC ASSET) | | last_used_at | timestamp (tz) | For LRU selection, default epoch | -| created_at | timestamp (tz) | Default now() | +| created_at | timestamp (tz) | Default now() | Constraints: `UNIQUE(address, network)`, `UNIQUE(account_id)` **withdrawals** — merchant payout requests -| Column | Type | Notes | -|---|---|---| -| id | uuid | Primary key | -| merchant_id | uuid | FK → merchants.id | -| amount | bigint | Microunits, CHECK > 0 | -| fee | bigint | Fee deducted | -| net_amount | bigint | amount - fee | -| status | withdrawal_status | Default pending_approval | -| debit_transaction_id | uuid | FK → transactions.id | -| completion_transaction_id | uuid | FK → transactions.id | -| reversal_transaction_id | uuid | FK → transactions.id | -| failure_code | varchar(100) | Nullable | -| failure_message | text | Nullable | -| reviewed_by | text | Admin who approved/rejected | -| reviewed_at | timestamp (tz) | When approved/rejected | -| review_note | text | Optional admin note | -| completed_by | text | Admin who completed the withdrawal | -| failed_by | text | Admin who marked the withdrawal as failed | -| idempotency_key | varchar(255) | UNIQUE | -| created_at + per-status timestamps | timestamp (tz) | approved_at, completed_at, etc. | +| Column | Type | Notes | +| ---------------------------------- | ----------------- | ----------------------------------------- | +| id | uuid | Primary key | +| merchant_id | uuid | FK → merchants.id | +| amount | bigint | Microunits, CHECK > 0 | +| fee | bigint | Fee deducted | +| net_amount | bigint | amount - fee | +| status | withdrawal_status | Default pending_approval | +| debit_transaction_id | uuid | FK → transactions.id | +| completion_transaction_id | uuid | FK → transactions.id | +| reversal_transaction_id | uuid | FK → transactions.id | +| failure_code | varchar(100) | Nullable | +| failure_message | text | Nullable | +| reviewed_by | text | Admin who approved/rejected | +| reviewed_at | timestamp (tz) | When approved/rejected | +| review_note | text | Optional admin note | +| completed_by | text | Admin who completed the withdrawal | +| failed_by | text | Admin who marked the withdrawal as failed | +| idempotency_key | varchar(255) | UNIQUE | +| created_at + per-status timestamps | timestamp (tz) | approved_at, completed_at, etc. | ## Multi-Tenancy Architecture Sangria uses an organization-based multi-tenancy model: ### Organization Structure + - **Organizations** are the main business entities - **Users** can belong to multiple organizations with different roles - **Personal Organizations** are auto-created for each user (is_personal=true) - **Organization Admins** can manage API keys and invite other users ### Access Control + - **API Keys** belong to organizations, not individual users - **Accounts** and **Transactions** are scoped to organizations - **Admins** (Sangria staff) have cross-organization access - **Organization Members** can be regular users or admins within that org ### Data Isolation + All business data is scoped to organizations: + - Merchants (API keys) → organization_id - Accounts → organization_id - Crypto Wallets → account_id → organization_id - Withdrawals → merchant_id → organization_id ### API Key Workflow + 1. **Creation**: Users create API keys within an organization context 2. **Status**: Admin users get `active` keys immediately, members get `pending` keys 3. **Approval**: Organization admins can approve/reject `pending` keys from their org diff --git a/dbSchema/package.json b/dbSchema/package.json index 6d0edbf3..2dddcd67 100644 --- a/dbSchema/package.json +++ b/dbSchema/package.json @@ -9,7 +9,9 @@ "generate": "dotenv -e .env.dev -- drizzle-kit generate", "studio": "dotenv -e .env.dev -- drizzle-kit studio", "studio:dev": "dotenv -e .env.dev -- drizzle-kit studio", - "studio:prd": "dotenv -e .env.prd -- drizzle-kit studio" + "studio:prd": "dotenv -e .env.prd -- drizzle-kit studio", + "format": "prettier --write . --ignore-path ../.prettierignore", + "format:check": "prettier --check . --ignore-path ../.prettierignore" }, "dependencies": { "drizzle-orm": "^0.39.0", @@ -20,6 +22,12 @@ "dotenv": "^17.3.1", "dotenv-cli": "^11.0.0", "drizzle-kit": "^0.31.10", + "prettier": "^3.8.1", "typescript": "^5.7.0" + }, + "packageManager": "pnpm@10.15.1", + "engines": { + "node": ">=20", + "pnpm": ">=10" } } diff --git a/dbSchema/pnpm-lock.yaml b/dbSchema/pnpm-lock.yaml index 867d1bfa..d3fc8198 100644 --- a/dbSchema/pnpm-lock.yaml +++ b/dbSchema/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + prettier: + specifier: ^3.8.1 + version: 3.8.3 typescript: specifier: ^5.7.0 version: 5.9.3 @@ -687,6 +690,11 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1146,6 +1154,8 @@ snapshots: dependencies: xtend: 4.0.2 + prettier@3.8.3: {} + resolve-pkg-maps@1.0.0: {} shebang-command@2.0.0: diff --git a/dbSchema/schema.ts b/dbSchema/schema.ts index 383381f5..16608f90 100644 --- a/dbSchema/schema.ts +++ b/dbSchema/schema.ts @@ -39,9 +39,7 @@ export const organizations = pgTable( id: uuid().primaryKey().defaultRandom(), name: varchar({ length: 255 }).notNull(), isPersonal: boolean("is_personal").notNull().default(false), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [index("organizations_name_idx").on(table.name)], ); @@ -50,12 +48,8 @@ export const organizations = pgTable( export const users = pgTable("users", { workosId: text("workos_id").primaryKey(), owner: text().notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); // --------------------------------------------------------------------------- @@ -71,9 +65,7 @@ export const organizationMembers = pgTable( .notNull() .references(() => organizations.id), isAdmin: boolean("is_admin").notNull().default(false), - joinedAt: timestamp("joined_at", { withTimezone: true }) - .notNull() - .defaultNow(), + joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ // Composite primary key - user can only be in each organization once @@ -91,9 +83,7 @@ export const admins = pgTable("admins", { userId: text("user_id") .primaryKey() .references(() => users.workosId), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); // --------------------------------------------------------------------------- @@ -108,9 +98,7 @@ export const accounts = pgTable( type: accountTypeEnum().notNull(), currency: currencyEnum().notNull(), organizationId: uuid("organization_id").references(() => organizations.id), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index("idx_accounts_organization_id").on(table.organizationId), @@ -132,9 +120,7 @@ export const transactions = pgTable( idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull(), status: transactionStatusEnum().notNull().default("confirmed"), txHash: varchar("tx_hash", { length: 255 }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ unique("uq_idempotency_key").on(table.idempotencyKey), @@ -210,11 +196,7 @@ export const networkEnum = pgEnum("network", [ // Merchants — API keys for businesses receiving payments through x402 // --------------------------------------------------------------------------- -export const apiKeyStatusEnum = pgEnum("api_key_status", [ - "active", - "pending", - "inactive", -]); +export const apiKeyStatusEnum = pgEnum("api_key_status", ["active", "pending", "inactive"]); export const merchants = pgTable( "merchants", @@ -228,9 +210,7 @@ export const merchants = pgTable( name: varchar({ length: 255 }).notNull(), status: apiKeyStatusEnum().notNull().default("pending"), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index("idx_merchants_organization_id").on(table.organizationId), @@ -252,20 +232,13 @@ export const cryptoWallets = pgTable( accountId: uuid("account_id") .notNull() .references(() => accounts.id), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }) - .notNull() - .default(new Date(0)), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().default(new Date(0)), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ index("idx_crypto_wallets_last_used_at").on(table.lastUsedAt), index("idx_crypto_wallets_network").on(table.network), - unique("uq_crypto_wallets_address_network").on( - table.address, - table.network, - ), + unique("uq_crypto_wallets_address_network").on(table.address, table.network), unique("uq_crypto_wallets_account_id").on(table.accountId), ], ); @@ -284,22 +257,18 @@ export const withdrawals = pgTable( // Money amount: bigint({ mode: "bigint" }).notNull(), - fee: bigint({ mode: "bigint" }).notNull().default(sql`0`), + fee: bigint({ mode: "bigint" }) + .notNull() + .default(sql`0`), netAmount: bigint("net_amount", { mode: "bigint" }).notNull(), // Status lifecycle status: withdrawalStatusEnum().notNull().default("pending_approval"), // Ledger transaction references - debitTransactionId: uuid("debit_transaction_id").references( - () => transactions.id, - ), - completionTransactionId: uuid("completion_transaction_id").references( - () => transactions.id, - ), - reversalTransactionId: uuid("reversal_transaction_id").references( - () => transactions.id, - ), + debitTransactionId: uuid("debit_transaction_id").references(() => transactions.id), + completionTransactionId: uuid("completion_transaction_id").references(() => transactions.id), + reversalTransactionId: uuid("reversal_transaction_id").references(() => transactions.id), // Failure info failureCode: varchar("failure_code", { length: 100 }), @@ -318,9 +287,7 @@ export const withdrawals = pgTable( idempotencyKey: varchar("idempotency_key", { length: 255 }).notNull(), // Per-status timestamps - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), approvedAt: timestamp("approved_at", { withTimezone: true }), processedAt: timestamp("processed_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), @@ -358,9 +325,7 @@ export const organizationInvitations = pgTable( expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), // 7 days from creation // Timestamps - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), acceptedAt: timestamp("accepted_at", { withTimezone: true }), declinedAt: timestamp("declined_at", { withTimezone: true }), }, diff --git a/deployment/DEPLOYMENT.md b/deployment/DEPLOYMENT.md index 4114823e..00f831b7 100644 --- a/deployment/DEPLOYMENT.md +++ b/deployment/DEPLOYMENT.md @@ -24,23 +24,30 @@ deployment/ ### Option A: Manual Version Control (Recommended) #### Step 1: Update Versions + Edit `deployment/SDK_VERSIONS.md`: + ```markdown ## TypeScript SDK (@sangria-sdk/core) + VERSION: 0.2.0 DESCRIPTION: Added webhook support and improved error handling ## Python SDK (sangria-core) + VERSION: 0.1.1 DESCRIPTION: Fixed timeout issue in payment processing ``` #### Step 2: Make SDK Changes + Edit your SDK code in: + - `sdk/sdk-typescript/` for TypeScript changes - `sdk/python/` for Python changes #### Step 3: Push Both + ```bash git add . git commit -m "feat: add webhook support and fix timeout" @@ -50,11 +57,14 @@ git push origin main ### Option B: Auto-Bump Versions (Quick Fixes) #### Step 1: Just Make SDK Changes + Edit your SDK code in: + - `sdk/sdk-typescript/` for TypeScript changes - `sdk/python/` for Python changes #### Step 2: Push (Without Version Update) + ```bash git add . git commit -m "fix: typo in error message" @@ -62,12 +72,14 @@ git push origin main ``` #### Step 3: Automatic Patch Bump + - System detects SDK changes without version update - Auto-increments patch version (0.1.0 → 0.1.1) - Commits the version bump to `SDK_VERSIONS.md` - Proceeds with deployment ### Step 4: Automatic Deployment + - `deploy-sdks.yml` detects which SDKs changed - Auto-bumps versions if needed (Option B only) - Calls reusable workflows: `publish-ts-sdk.yml` and/or `publish-python-sdk.yml` @@ -79,8 +91,10 @@ git push origin main ## 🔄 Workflow Types ### 🤖 Automatic Deployment (`deploy-sdks.yml`) + **Triggers:** Push to main with changes in `sdk/` or `deployment/SDK_VERSIONS.md` **Process:** + 1. Detects which SDKs have code changes 2. Auto-bumps patch versions if SDK changed but versions didn't 3. Calls individual publish workflows for changed SDKs @@ -90,19 +104,24 @@ git push origin main **Auto-bump:** SDK changes without version update → Patch version auto-incremented ### 🎛️ Manual Deployment (`publish-*-sdk.yml`) + **Triggers:** Manual workflow dispatch from GitHub Actions UI **Process:** + 1. Run individual workflows directly 2. Deploy without needing code changes 3. Choose dry run mode if desired **Use cases:** + - Republish with same code - Deploy after fixing secrets - Test deployment process ### 📞 Reusable Workflows + Both individual workflows support: + - `workflow_call` - Called by main deployment workflow - `workflow_dispatch` - Manual execution from GitHub UI - `test_mode` input - Dry run option @@ -110,15 +129,18 @@ Both individual workflows support: ## ⚙️ Setup (One Time) **Required GitHub Secrets:** + - `NPM_TOKEN` - For npm publishing - `PYPI_TOKEN` - For PyPI publishing **Get NPM Token:** + 1. Go to [npmjs.com](https://www.npmjs.com/settings/tokens) 2. Create "Automation" token 3. Add as `NPM_TOKEN` secret **Get PyPI Token:** + 1. Go to [pypi.org](https://pypi.org/manage/account/token/) 2. Create API token 3. Add as `PYPI_TOKEN` secret @@ -126,6 +148,7 @@ Both individual workflows support: ## 📋 Version Guidelines **Semantic Versioning:** + - **Patch** (0.1.0 → 0.1.1): Bug fixes, documentation - **Minor** (0.1.0 → 0.2.0): New features, backward compatible - **Major** (0.1.0 → 1.0.0): Breaking changes @@ -133,12 +156,14 @@ Both individual workflows support: ## 🔍 When Deployment Happens **Automatic (deploy-sdks.yml):** + - Triggers on push to main with SDK changes - Detects which SDKs changed - Auto-bumps patch versions if no manual version update - Calls individual publish workflows for changed SDKs -**Manual (publish-*-sdk.yml):** +**Manual (publish-\*-sdk.yml):** + - Run individual workflows from GitHub Actions UI - Deploy without needing code changes - Useful for republishing with same code @@ -148,16 +173,19 @@ Both individual workflows support: ## 🤖 Auto-Version Bump Details **When it happens:** + - SDK code changes detected (`sdk/sdk-typescript/` or `sdk/python/`) - No version changes in `deployment/SDK_VERSIONS.md` **What it does:** + - Increments patch version (e.g., 0.1.5 → 0.1.6) - Updates `SDK_VERSIONS.md` automatically - Commits the change with message: "chore: auto-bump patch versions for SDK changes" - Continues with normal deployment process **Use for:** + - Bug fixes and small updates - Quick patches without manual version management - Ensures every SDK change gets a unique version @@ -169,15 +197,18 @@ Watch at: `https://github.com/GTG-Labs/sangria-net/actions` ## 🛠️ Troubleshooting **NPM publish fails:** + - Check `NPM_TOKEN` is valid - Ensure version hasn't been published before - Verify package name access **PyPI publish fails:** + - Check `PYPI_TOKEN` is valid - Ensure version hasn't been published before - Verify package name access **Version parsing fails:** + - Check `SDK_VERSIONS.md` format matches exactly - Ensure `VERSION:` and `DESCRIPTION:` labels are present diff --git a/frontend/.prettierrc b/frontend/.prettierrc deleted file mode 100644 index 1ca87ab7..00000000 --- a/frontend/.prettierrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "singleQuote": false -} diff --git a/frontend/README.md b/frontend/README.md index f2c7b5c6..a826b0ed 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -7,10 +7,12 @@ A Next.js frontend application with enterprise-grade security for handling real ## 🚀 Quick Start ### Prerequisites + - **Node.js 18+** (LTS recommended) - **pnpm** (required - do not use npm or yarn) ### Installation + ```bash git clone [repository] cd frontend @@ -18,6 +20,7 @@ pnpm install ``` ### Environment Setup + ```bash cp .env.example .env.local # Fill in required environment variables — see § Environment Variables below. @@ -28,6 +31,7 @@ cp .env.example .env.local **Narrow exception.** Some env vars are read internally by third-party libraries we don't control (e.g. WorkOS AuthKit reads `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, `WORKOS_COOKIE_PASSWORD` from `process.env` itself). These cannot be routed through our schema and so are listed in the env table below with `Validated: No` for operator visibility. **Do not invent additional exceptions.** If you find yourself wanting to add a new var outside `lib/env.ts`, add it to the schema instead. If a genuine library-internal var is added, document it in this section's table with a one-line rationale and a `// TODO: see lib/env.ts` comment at any related call site so future readers know why it bypasses the schema. ### Development + ```bash pnpm run dev # Start development server pnpm run build # Production build (test before deploy) @@ -40,6 +44,7 @@ Open [http://localhost:3000](http://localhost:3000) to view the application. ## 🏗️ Architecture ### Technology Stack + - **Framework**: Next.js 16.1.6 with App Router - **Language**: TypeScript (strict mode) - **Styling**: Tailwind CSS 4.x @@ -82,12 +87,14 @@ This application implements enterprise-grade security appropriate for financial ## 💰 Financial Operations ### Withdrawal System + - **Amount Validation**: Regex-based validation blocking scientific notation - **Balance Verification**: Real-time balance checks - **Security**: CSRF protection + input sanitization - **Precision**: Microunit handling prevents floating-point errors ### Transaction Tracking + - **Real-time Updates**: Live transaction monitoring - **Block Explorer**: Transparent on-chain verification - **Audit Trail**: Complete transaction history @@ -115,6 +122,7 @@ export async function POST(request: NextRequest) { On the client, use `internalFetch` from `lib/fetch.ts` (not bare `fetch`) — it auto-attaches the `X-CSRF-Token` header for state-changing methods. #### 2. Input Validation (REQUIRED) + All user inputs must use Zod schemas: ```typescript @@ -128,14 +136,15 @@ if (!result.success) { ``` #### 3. Client-side CSRF (REQUIRED) + All forms must include CSRF tokens: ```typescript import { internalFetch } from "@/lib/fetch"; -const response = await internalFetch('/api/endpoint', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, +const response = await internalFetch("/api/endpoint", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(formData), }); ``` @@ -143,6 +152,7 @@ const response = await internalFetch('/api/endpoint', { ## 🧪 Testing & Quality ### Pre-deployment Checklist + - [ ] `pnpm run build` - Must pass without errors - [ ] `pnpm run lint` - Must pass without warnings - [ ] `pnpm run type-check` - Must pass without errors @@ -151,6 +161,7 @@ const response = await internalFetch('/api/endpoint', { - [ ] No hardcoded secrets or credentials ### Security Testing + ```bash pnpm audit # Check for vulnerable dependencies pnpm audit --audit-level high # Critical/high vulnerabilities only @@ -159,10 +170,12 @@ pnpm audit --audit-level high # Critical/high vulnerabilities only ## 🚨 Critical Security Notes ### Recently Fixed (April 2026) + - **CSRF Protection Bypass**: All API routes now properly validate CSRF tokens - **Financial Transaction Security**: All money operations require CSRF validation ### Security Requirements + 1. **Never skip CSRF validation** on state-changing operations 2. **Always validate inputs** using Zod schemas from `/lib/validation.ts` 3. **Use microunit precision** for all currency calculations @@ -178,25 +191,27 @@ pnpm audit --audit-level high # Critical/high vulnerabilities only App-managed env vars (those whose `Validated` column is `Yes`) are checked at build time by `lib/env.ts` via `@t3-oss/env-nextjs` + Zod — `pnpm build` fails on any missing or malformed value, so no silent localhost fallbacks reach production. The remaining vars are consumed directly by libraries (e.g. WorkOS AuthKit reads `WORKOS_CLIENT_ID` / `WORKOS_API_KEY` from `process.env` itself); they're listed here for operator visibility but aren't in the schema. -| Variable | Required | Scope | Validated | Description | -|---|---|---|---|---| -| `BACKEND_URL` | Yes | Server | Yes | Go backend base URL (e.g. `https://api.getsangria.com`). Must be a valid URL. | -| `BASE_URL` | Yes | Server | Yes | Public URL of this app, used by WorkOS AuthKit's OAuth callback (`app/auth/callback/route.ts`). Must be a valid URL. | -| `NEXT_PUBLIC_WORKOS_REDIRECT_URI` | Yes | Client | Yes | WorkOS redirect URI, inlined at build time. Must be a valid URL. | -| `WORKOS_CLIENT_ID` | Yes | Server | No | WorkOS client ID. Consumed by AuthKit internally; not in `lib/env.ts`. | -| `WORKOS_API_KEY` | Yes | Server | No | WorkOS API key. Consumed by AuthKit internally; not in `lib/env.ts`. | -| `WORKOS_COOKIE_PASSWORD` | Yes | Server | No | 32-byte secret used by AuthKit to encrypt session cookies. Consumed internally. Generate via `openssl rand -base64 32`. | +| Variable | Required | Scope | Validated | Description | +| --------------------------------- | -------- | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------- | +| `BACKEND_URL` | Yes | Server | Yes | Go backend base URL (e.g. `https://api.getsangria.com`). Must be a valid URL. | +| `BASE_URL` | Yes | Server | Yes | Public URL of this app, used by WorkOS AuthKit's OAuth callback (`app/auth/callback/route.ts`). Must be a valid URL. | +| `NEXT_PUBLIC_WORKOS_REDIRECT_URI` | Yes | Client | Yes | WorkOS redirect URI, inlined at build time. Must be a valid URL. | +| `WORKOS_CLIENT_ID` | Yes | Server | No | WorkOS client ID. Consumed by AuthKit internally; not in `lib/env.ts`. | +| `WORKOS_API_KEY` | Yes | Server | No | WorkOS API key. Consumed by AuthKit internally; not in `lib/env.ts`. | +| `WORKOS_COOKIE_PASSWORD` | Yes | Server | No | 32-byte secret used by AuthKit to encrypt session cookies. Consumed internally. Generate via `openssl rand -base64 32`. | -**Adding a new var:** edit `lib/env.ts` — add it to the appropriate `server` or `client` schema block *and* to the `runtimeEnv` mapping (Next.js's build-time inlining requires the literal `process.env.NEXT_PUBLIC_X` reference there). Do not add `process.env` reads elsewhere. +**Adding a new var:** edit `lib/env.ts` — add it to the appropriate `server` or `client` schema block _and_ to the `runtimeEnv` mapping (Next.js's build-time inlining requires the literal `process.env.NEXT_PUBLIC_X` reference there). Do not add `process.env` reads elsewhere. ## 🚀 Deployment ### Build for Production + ```bash pnpm run build # Creates optimized standalone build ``` ### Deployment Checklist + - [ ] All environment variables configured - [ ] Build passes without errors - [ ] Security headers properly configured @@ -205,10 +220,12 @@ pnpm run build # Creates optimized standalone build ## 📞 Support ### Security Issues + - Review [SECURITY.md](./SECURITY.md) for security guidelines - Follow incident response procedures for vulnerabilities ### Development Issues + - Ensure using `pnpm` (not npm/yarn) - Check TypeScript strict mode compliance - Verify CSRF protection on new endpoints diff --git a/frontend/app/(marketing)/blog/[slug]/page.tsx b/frontend/app/(marketing)/blog/[slug]/page.tsx index a8117450..e779862a 100644 --- a/frontend/app/(marketing)/blog/[slug]/page.tsx +++ b/frontend/app/(marketing)/blog/[slug]/page.tsx @@ -9,11 +9,7 @@ export function generateStaticParams() { return getAllSlugs().map((slug) => ({ slug })); } -export async function generateMetadata({ - params, -}: { - params: Promise<{ slug: string }>; -}) { +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = getPostBySlug(slug); if (!post) return {}; @@ -24,11 +20,7 @@ export async function generateMetadata({ }; } -export default async function BlogPostPage({ - params, -}: { - params: Promise<{ slug: string }>; -}) { +export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = getPostBySlug(slug); if (!post) notFound(); @@ -57,9 +49,7 @@ export default async function BlogPostPage({

{post.title}

-

- {post.description} -

+

{post.description}


diff --git a/frontend/app/(marketing)/blog/page.tsx b/frontend/app/(marketing)/blog/page.tsx index 53e819c4..9e63963c 100644 --- a/frontend/app/(marketing)/blog/page.tsx +++ b/frontend/app/(marketing)/blog/page.tsx @@ -40,7 +40,8 @@ export default function BlogPage() {

- {post.author} · {new Date(post.date).toLocaleDateString("en-US", { + {post.author} ·{" "} + {new Date(post.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", diff --git a/frontend/app/(marketing)/docs/[[...slug]]/page.tsx b/frontend/app/(marketing)/docs/[[...slug]]/page.tsx index c57f49d9..4352f656 100644 --- a/frontend/app/(marketing)/docs/[[...slug]]/page.tsx +++ b/frontend/app/(marketing)/docs/[[...slug]]/page.tsx @@ -1,17 +1,10 @@ import { source } from "@/lib/source"; -import { - DocsBody, - DocsDescription, - DocsPage, - DocsTitle, -} from "fumadocs-ui/layouts/docs/page"; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page"; import { notFound } from "next/navigation"; import { getMDXComponents } from "@/components/mdx"; import type { Metadata } from "next"; -export default async function Page(props: { - params: Promise<{ slug?: string[] }>; -}) { +export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params; const page = source.getPage(params.slug); if (!page) notFound(); diff --git a/frontend/app/(marketing)/docs/docs-layout-client.tsx b/frontend/app/(marketing)/docs/docs-layout-client.tsx index 0358c321..9b29c601 100644 --- a/frontend/app/(marketing)/docs/docs-layout-client.tsx +++ b/frontend/app/(marketing)/docs/docs-layout-client.tsx @@ -7,20 +7,12 @@ import type { Root, Separator } from "fumadocs-core/page-tree"; function DocsSidebarSeparator({ item }: { item: Separator }) { return (

- - {item.name} - + {item.name}
); } -export default function DocsLayoutClient({ - tree, - children, -}: { - tree: Root; - children: ReactNode; -}) { +export default function DocsLayoutClient({ tree, children }: { tree: Root; children: ReactNode }) { return ( {children} - ); + return {children}; } diff --git a/frontend/app/(marketing)/page.tsx b/frontend/app/(marketing)/page.tsx index 553a6da5..a591ee32 100644 --- a/frontend/app/(marketing)/page.tsx +++ b/frontend/app/(marketing)/page.tsx @@ -21,9 +21,8 @@ export default async function Home() {

- Sangria is a drop-in SDK that integrates with your backend and - allows you to monetize your endpoints so agents can call and pay - for them. + Sangria is a drop-in SDK that integrates with your backend and allows you to + monetize your endpoints so agents can call and pay for them.

@@ -37,9 +36,7 @@ export default async function Home() { ) : ( - - Sign Up → - + Sign Up → )} ([]); @@ -56,7 +55,6 @@ export default function APIKeysContent() { setShowCreateForm(false); }; - const fetchAPIKeys = async (showLoading = true, signal?: AbortSignal) => { if (showLoading) { setLoading(true); @@ -73,13 +71,9 @@ export default function APIKeysContent() { setApiKeys(Array.isArray(keys) ? keys : []); setError(null); // Clear any previous errors } else { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown error" })); + const errorData = await response.json().catch(() => ({ error: "Unknown error" })); console.error("API Keys fetch failed:", response.status, errorData); - setError( - errorData.error || `Failed to load API keys (${response.status})`, - ); + setError(errorData.error || `Failed to load API keys (${response.status})`); setApiKeys([]); } } catch (err) { @@ -137,11 +131,7 @@ export default function APIKeysContent() { }; const revokeAPIKey = async (keyId: string) => { - if ( - !confirm( - "Are you sure you want to revoke this API key? This action cannot be undone.", - ) - ) { + if (!confirm("Are you sure you want to revoke this API key? This action cannot be undone.")) { return; } @@ -162,7 +152,7 @@ export default function APIKeysContent() { }; const approveAPIKey = async (keyId: string) => { - setApprovalLoading(prev => new Set(prev).add(keyId)); + setApprovalLoading((prev) => new Set(prev).add(keyId)); try { const response = await internalFetch(`/api/backend/api-keys/${keyId}/approve`, { @@ -179,7 +169,7 @@ export default function APIKeysContent() { } catch { setError("Failed to approve API key"); } finally { - setApprovalLoading(prev => { + setApprovalLoading((prev) => { const newSet = new Set(prev); newSet.delete(keyId); return newSet; @@ -188,7 +178,7 @@ export default function APIKeysContent() { }; const rejectAPIKey = async (keyId: string) => { - setApprovalLoading(prev => new Set(prev).add(keyId)); + setApprovalLoading((prev) => new Set(prev).add(keyId)); try { const response = await internalFetch(`/api/backend/api-keys/${keyId}/reject`, { @@ -205,7 +195,7 @@ export default function APIKeysContent() { } catch { setError("Failed to reject API key"); } finally { - setApprovalLoading(prev => { + setApprovalLoading((prev) => { const newSet = new Set(prev); newSet.delete(keyId); return newSet; @@ -295,13 +285,14 @@ export default function APIKeysContent() {

{selectedOrg.isAdmin ? ( <> - Admin privileges: Your API keys are automatically activated upon creation. - You can also approve or reject pending keys from other team members. + Admin privileges: Your API keys are automatically activated + upon creation. You can also approve or reject pending keys from other team + members. ) : ( <> - Member privileges: New API keys require admin approval before they become active. - Contact your organization admin to approve pending keys. + Member privileges: New API keys require admin approval before + they become active. Contact your organization admin to approve pending keys. )}

@@ -321,11 +312,7 @@ export default function APIKeysContent() {
- +

- - This is the only time you'll see your API key. - {" "} - Copy it now and store it securely. For security reasons, we - cannot show it again. + This is the only time you'll see your API key. Copy it now and + store it securely. For security reasons, we cannot show it again.

{newKeyResult} @@ -381,15 +365,10 @@ export default function APIKeysContent() { {showCreateForm && (
-

- Create New API Key -

+

Create New API Key

-
@@ -459,30 +439,30 @@ export default function APIKeysContent() { {apiKeys.map((key, index) => ( -
- {key.name} -
+
{key.name}
Created {new Date(key.created_at).toLocaleDateString()}
- {key.status === API_KEY_STATUS.ACTIVE ? 'Active' : - key.status === API_KEY_STATUS.PENDING ? 'Pending' : 'Inactive'} + {key.status === API_KEY_STATUS.ACTIVE + ? "Active" + : key.status === API_KEY_STATUS.PENDING + ? "Pending" + : "Inactive"} - {key.last_used_at - ? new Date(key.last_used_at).toLocaleDateString() - : "Never"} + {key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : "Never"} {new Date(key.created_at).toLocaleDateString()} @@ -535,7 +515,9 @@ export default function APIKeysContent() { {/* Show status message for pending keys (non-admin users) */} {key.status === API_KEY_STATUS.PENDING && !selectedOrg?.isAdmin && ( - Awaiting approval + + Awaiting approval + )}
diff --git a/frontend/app/(portal)/dashboard/api-keys/page.tsx b/frontend/app/(portal)/dashboard/api-keys/page.tsx index a8c3eff2..976cabd4 100644 --- a/frontend/app/(portal)/dashboard/api-keys/page.tsx +++ b/frontend/app/(portal)/dashboard/api-keys/page.tsx @@ -5,4 +5,4 @@ export default async function APIKeysPage() { await withAuth({ ensureSignedIn: true }); return ; -} \ No newline at end of file +} diff --git a/frontend/app/(portal)/dashboard/members/OrganizationMembersContent.tsx b/frontend/app/(portal)/dashboard/members/OrganizationMembersContent.tsx index 63341751..8b1c6404 100644 --- a/frontend/app/(portal)/dashboard/members/OrganizationMembersContent.tsx +++ b/frontend/app/(portal)/dashboard/members/OrganizationMembersContent.tsx @@ -37,14 +37,16 @@ export default function OrganizationMembersContent() { mode: "onChange", }); - // Secure submit with rate limiting - const secureSubmit = useSecureSubmit(async (data: InviteData) => { - await handleInviteInternal(data); - }, { - maxAttempts: 3, // Max 3 invitations per minute - rateLimitWindow: 60000, // 1 minute window - }); + const secureSubmit = useSecureSubmit( + async (data: InviteData) => { + await handleInviteInternal(data); + }, + { + maxAttempts: 3, // Max 3 invitations per minute + rateLimitWindow: 60000, // 1 minute window + }, + ); useEffect(() => { if (selectedOrgId) { @@ -73,7 +75,9 @@ export default function OrganizationMembersContent() { if (!selectedOrgId) return; try { - const response = await internalFetch(`/api/backend/organizations/${selectedOrgId}/members`, { signal }); + const response = await internalFetch(`/api/backend/organizations/${selectedOrgId}/members`, { + signal, + }); if (response.ok) { const data = await response.json(); // Only update state if the fetch wasn't aborted @@ -83,7 +87,7 @@ export default function OrganizationMembersContent() { } } catch (err) { // Ignore abort errors - if (err instanceof Error && err.name === 'AbortError') { + if (err instanceof Error && err.name === "AbortError") { return; } console.error("Failed to fetch members:", err); @@ -103,16 +107,19 @@ export default function OrganizationMembersContent() { setSubmitError(null); try { - const response = await internalFetch(`/api/backend/organizations/${selectedOrgId}/invitations`, { - method: "POST", - headers: { - "Content-Type": "application/json", + const response = await internalFetch( + `/api/backend/organizations/${selectedOrgId}/invitations`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: data.email, + message: data.message || null, + }), }, - body: JSON.stringify({ - email: data.email, - message: data.message || null, - }), - }); + ); if (response.ok) { reset(); @@ -140,16 +147,22 @@ export default function OrganizationMembersContent() { }; const handleRemoveMember = async (memberUserId: string, memberName: string) => { - if (!selectedOrgId || !confirm(`Are you sure you want to remove ${memberName} from this organization?`)) { + if ( + !selectedOrgId || + !confirm(`Are you sure you want to remove ${memberName} from this organization?`) + ) { return; } - setRemovingMembers(prev => new Set(prev).add(memberUserId)); + setRemovingMembers((prev) => new Set(prev).add(memberUserId)); try { - const response = await internalFetch(`/api/backend/organizations/${selectedOrgId}/members/${memberUserId}`, { - method: "DELETE", - }); + const response = await internalFetch( + `/api/backend/organizations/${selectedOrgId}/members/${memberUserId}`, + { + method: "DELETE", + }, + ); if (response.ok) { fetchMembers(); // Refresh the members list @@ -161,7 +174,7 @@ export default function OrganizationMembersContent() { console.error("Error removing member:", err); alert("Failed to remove member"); } finally { - setRemovingMembers(prev => { + setRemovingMembers((prev) => { const newSet = new Set(prev); newSet.delete(memberUserId); return newSet; @@ -197,7 +210,9 @@ export default function OrganizationMembersContent() {

Organization Members

- {selectedOrg ? `Managing members for ${selectedOrg.name}` : "Manage your team members and their permissions"} + {selectedOrg + ? `Managing members for ${selectedOrg.name}` + : "Manage your team members and their permissions"}

{selectedOrg?.isAdmin && ( @@ -227,9 +242,7 @@ export default function OrganizationMembersContent() { {/* Invite Form - Only show for admins */} {isInviting && selectedOrg?.isAdmin && (
-

- Invite New Member -

+

Invite New Member

-