Platform API
Cross-app identity verification spine
What the Platform API Does
The BENED Platform API is the identity and data spine for the entire ecosystem. It's not a user-facing app — it's the backend that powers circles, shares, video uploads, and most importantly: cross-application identity verification.
The core purpose: One identity, verified once, trusted everywhere. When you verify your identity on TradeCraft (to enable live trading), the Rental Platform honors that verification. No duplicate ID uploads, no separate KYC flows.
The Architecture
Three-Pillar Breakdown
🗄️ DATABASE: PostgreSQL │ ├─ users Keycloak user metadata (NOT auth, just profile data) ├─ identity_verifications Cross-app verification records ├─ circles Private groups with membership rules ├─ shares Content shared within circles ├─ videos Upload metadata (actual files in Backblaze B2) └─ upload_tokens Time-limited presigned upload URLs 🖥️ INTERFACE: None (headless API) │ └─ This is a pure API. No frontend. Other apps call it. ⚡ API LOGIC: Express.js + TypeScript │ ├─ /api/identity Cross-app verification check/record endpoints ├─ /api/circles Group management, membership, access control ├─ /api/shares Content posting, retrieval, permissions ├─ /api/videos Upload token generation, Backblaze integration └─ /api/users User profile CRUD (synced from Keycloak)
Why Express?
Lightweight, mature, and TypeScript-friendly. The Platform API doesn't need server-side rendering (no HTML). It's pure JSON over HTTP. Express gives us:
- Middleware chains: Auth validation → request logging → route handler
- Fast routing: Pattern matching for RESTful endpoints
- Ecosystem: Battle-tested libraries for JWT, database pools, error handling
The Identity Verification Flow
This is the killer feature. Here's how it works:
USER VERIFIES IDENTITY ON APP A (e.g., TradeCraft)
│
├─ App A calls Stripe Identity API
├─ User uploads photo ID, takes selfie
├─ Stripe verifies identity, returns verification ID
│
├─ App A records locally: identity_verified = true
│
└─ App A calls Platform API:
POST /api/identity/verify
{
keycloakId: "user-uuid",
legalName: "John Doe",
sourceApplication: "tradecraft",
stripeVerificationId: "vs_1234..."
}
USER LATER TRIES TO BOOK ON APP B (e.g., Rental Platform)
│
├─ App B checks: Is identity verified locally?
│ └─ ❌ Not in local database
│
├─ App B calls Platform API:
│ GET /api/identity/{keycloakId}
│ └─ ✅ Platform responds:
│ {
│ verified: true,
│ legalName: "John Doe",
│ sourceApplication: "tradecraft",
│ verifiedAt: "2025-01-15T10:30:00Z"
│ }
│
├─ App B syncs verification locally
│ └─ Writes to renters table: identity_verified = true
│
└─ Booking proceeds without re-verification
Why this matters: Identity verification is expensive (Stripe charges per verification) and annoying (uploading ID sucks). Do it once, use everywhere.
Circles & Shares
Before we had this API, we were building features in silos — TradeCraft had "watchlists", Rental had "favorites", Operations wanted "team docs". Every app was reinventing groups and permissions.
Circles solve this:
- Circle: A group with membership rules (public, private, invite-only)
- Share: Content posted to a circle (text, link, video, file)
- Permissions: Who can post? Who can see? Who can invite?
EXAMPLE: TradeCraft "Pro Traders" Circle
│
├─ Membership rule: Must have active Pro subscription
├─ Posts allowed by: Members only
├─ Visibility: Private (members-only)
│
└─ Shares in this circle:
├─ "My SPY scalping strategy backtest results" (text + video)
├─ "Link to my spreadsheet for position sizing" (link)
└─ "PDF: Risk management checklist" (file in B2)
Video Upload Pattern
Videos are too big to upload through the API server. We use presigned upload URLs:
1. Client requests upload token:
POST /api/videos/upload-token
{ filename: "strategy-walkthrough.mp4", filesize: 52428800 }
2. API generates Backblaze B2 presigned URL:
└─ Time-limited (15 minutes)
└─ Specific to this file
└─ Returns uploadUrl + videoId
3. Client uploads DIRECTLY to Backblaze:
PUT {uploadUrl}
[binary video data]
4. Client confirms upload complete:
POST /api/videos/{videoId}/confirm
5. Video now available via Platform API:
GET /api/videos/{videoId}
└─ Returns signed download URL (also time-limited)
Why presigned URLs? The API server never touches the video bytes. Bandwidth stays cheap, uploads are fast (direct to B2), and we don't need massive server storage.
Auth Middleware
Every API endpoint validates JWT tokens from Keycloak:
REQUEST: GET /api/identity/abc-123 Headers: Authorization: Bearer eyJhbGci... 1. Middleware extracts token 2. Fetches Keycloak's JWKS (public signing keys) 3. Verifies signature + expiration 4. Extracts user ID from 'sub' claim 5. Attaches user to request object 6. Passes to route handler RESULT: Route handler has authenticated user ID
If verification fails at any step, the request dies with 401 Unauthorized.
The Tech Stack
| Layer | Technology | Why This Choice |
|---|---|---|
| Backend | Express.js + TypeScript | Fast routing, middleware patterns, mature ecosystem. |
| Database | PostgreSQL | JSON support for flexible share metadata, ACID for identity records. |
| Auth | Keycloak (JWT validation) | Middleware validates every request, no session state on API. |
| Storage | Backblaze B2 | S3-compatible, cheap storage, presigned URL support. |
| Video Processing | None (yet) | Videos stored as-is. Future: thumbnail generation, transcoding. |
| Deployment | Docker + Nginx | Containerized Node.js, nginx reverse proxy. |
Database Schema Highlights
identity_verifications Table
CREATE TABLE identity_verifications (
id SERIAL PRIMARY KEY,
keycloak_id UUID NOT NULL,
legal_name VARCHAR(255) NOT NULL,
source_application VARCHAR(50) NOT NULL,
stripe_verification_id VARCHAR(255),
verified_at TIMESTAMP DEFAULT NOW(),
UNIQUE(keycloak_id) -- One verification per user
);
Key insight: The UNIQUE constraint on keycloak_id enforces "verify once". Multiple apps can CHECK this table, but only the first app to verify WRITES to it.
circles Table
CREATE TABLE circles (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
visibility VARCHAR(20) DEFAULT 'private',
created_by UUID NOT NULL, -- Keycloak user ID
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE circle_members (
circle_id INT REFERENCES circles(id),
user_id UUID NOT NULL, -- Keycloak user ID
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (circle_id, user_id)
);
API Endpoints (Samples)
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/identity/:keycloakId | Check if user verified (cross-app lookup) |
| POST | /api/identity/verify | Record new identity verification |
| GET | /api/circles | List circles user is member of |
| POST | /api/circles | Create new circle |
| GET | /api/circles/:id/shares | Get shares posted to circle |
| POST | /api/shares | Post new share to circle |
| POST | /api/videos/upload-token | Get presigned B2 upload URL |
| GET | /api/videos/:id | Get video metadata + signed download URL |
Error Handling Pattern
All API responses follow consistent JSON structure:
SUCCESS:
{
"data": { ... },
"message": "Identity verification recorded"
}
ERROR:
{
"error": "User not found",
"code": "USER_NOT_FOUND",
"statusCode": 404
}
This makes client-side error handling predictable across all BENED apps.
Why a Separate API?
Why not just bundle this into each app?
- Single source of truth: Identity verification status lives in ONE place
- Language independence: TradeCraft (PHP), Rental (Next.js), Operations (future Python?) all call the same API
- Scalability: API can be cached, load-balanced, scaled independently
- Security boundary: Only the API server needs Backblaze credentials, not every app
The Deployment Story
1. Code changes pushed to repository 2. Run build: npm run build (TypeScript → JavaScript) 3. Build Docker image with compiled code 4. Stop old container, start new one 5. Nginx proxies to new container 6. Zero downtime (nginx queues requests during swap)
Unlike PHP (instant file changes), Node.js requires container restarts. But the build step catches TypeScript errors before deploy, which has saved us many times.
What's NOT in the API (Yet)
- Video transcoding (videos stored as-is currently)
- Full-text search across shares
- Notification system (email/SMS when mentioned in circles)
- Analytics (who's viewing shares, engagement metrics)
These are planned, but the core identity and data sharing infrastructure had to come first.