← Back to Projects

Platform API

Cross-app identity verification spine

Live

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:

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:

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?

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)

These are planned, but the core identity and data sharing infrastructure had to come first.