Information Security — Milestone Proposal
Status: In Progress · April 2026
Defines the security baseline required before the platform can handle real-money transactions at scale. Covers data protection, access control, secrets management, and threat modelling.
IS-4, IS-5, IS-6, IS-7, IS-9, IS-10, IS-11, IS-12 implemented. IS-1, IS-2, IS-3, IS-8 remain. IS-9/IS-11 transition gaps closed (resolveActor, campaigns public_id, session-submissions guard) — strict enforcement requires deploying
REQUIRE_SESSION_AUTH=true+SESSION_SECRET.
Problem
The platform manages USDC escrow, streamer KYC documents, wallet addresses, and operator API credentials. Current security posture is functional but informal — adequate for a closed beta, insufficient for open production.
Key gaps identified:
- No formal threat model
- Secrets stored in
.envfiles with no rotation policy - No audit log for sensitive operations (KYC status changes, deal funding, payout confirms)
- No rate-limiting on auth or sensitive endpoints
- KYC documents stored in Cloudflare R2 with no access expiry on presigned URLs
- No security headers policy (CSP, HSTS, referrer policy)
- No penetration test or external security review
- All routes lack server-side auth middleware —
actor_idis trusted from the request body - Sequential integer IDs in URLs enable resource enumeration and cross-operator probing
- Write endpoints lack row-level ownership checks
- Admin-only routes have no server-enforced gate
Proposed Milestone — M-IS
A dedicated security milestone to be completed before opening the platform beyond the current invite-only cohort.
IS-1 · Threat Model
- Document assets, trust boundaries, and attack surfaces
- Cover: operator portal, streamer portal, API, smart contract, R2 storage, Pusher channel auth
- Output: threat model doc + risk register with severity ratings
IS-2 · Secrets & Credentials
- Migrate all secrets from
.envfiles to a secrets manager (Doppler or Cloudflare secrets) - Define rotation schedule for: R2 keys, Pusher secrets, Resend API key, operator API keys
- Audit all places secrets are logged or emitted in error responses
IS-3 · Audit Log
- Add an
audit_logtable:(id, actor_type, actor_id, action, target_type, target_id, metadata JSONB, created_at) - Log: KYC status changes, deal funding, payout confirms, allocation creation, operator account provisioning
- Expose read-only audit trail in the admin panel
IS-4 · Rate Limiting & Brute-Force Protection done
- ✅
POST /api/auth/send-pin— 3 requests / 10 min per email - ✅
POST /api/auth/verify-pin— 5 attempts / 15 min per email - Global API rate limit per IP for unauthenticated endpoints (pending)
IS-5 · R2 Document Security done
- ✅ KYC documents are API-proxied (never presigned) — access is gated per-request
- ✅
Cache-Control: private, no-storeon download responses — no browser caching - R2 bucket public-access policy audit (pending)
IS-6 · Security Headers done
- ✅
hono/secure-headersmiddleware applied globally on the API (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Frontend (Cloudflare Pages) security headers pending
_headersconfig
IS-7 · Dependency Audit & SBOM done
- ✅
npm audit --audit-level=highadded todeploy-api.yml(advisory — non-blocking until baseline is clean) - SBOM generation for Docker image (pending)
IS-8 · External Penetration Test
- Engage an external security firm for a scoped pentest covering the API and smart contract
- Resolve all critical and high findings before public launch
- Smart contract formal verification or third-party audit (separate from Forge test suite)
IS-9 · Authentication Middleware done (transition mode)
Problem. Every route handler today receives actor_id from the request body and trusts it without server-side verification. Any caller can impersonate any operator or streamer.
Approach.
- On successful PIN verification (
POST /api/auth/verify-pin), issue a signed session token (HMAC-SHA256 over{ actor_type, actor_id, iat, exp }using aSESSION_SECRETenv var). Return it as aSet-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strictheader and as a JSON field for mobile/SPA usage. - Add a
verifySession(req, env)helper that reads the cookie (orAuthorization: Bearerheader), verifies the HMAC, and returns{ actor_type, actor_id }ornull. - Add a Hono middleware that runs before all non-public routes, calls
verifySession, and returns401on failure. - Remove all
actor_id/operator_id/streamer_idfrom request bodies on protected routes — derive them exclusively from the verified session.
Public routes (exempt from middleware): POST /api/auth/*, GET /api/campaigns, GET /api/campaigns/:id, GET /api/streamers (directory), GET /api/streamers/:u (public profile fields only), POST /api/webhooks/*, GET /api/oauth/*.
Migration path. Can be deployed feature-flagged: new clients send the session cookie, old clients continue to pass actor_id in body during a transition window. Once all clients updated, remove the body fallback.
Implementation status (April 2026):
- ✅
session.ts—issueSession,verifySession,sessionCookieHeader,resolveActor - ✅ Session cookie issued on PIN verify, kick-token, and impersonate/redeem auth flows
- ✅ Global Hono middleware enforces session when
REQUIRE_SESSION_AUTH=true - ✅
SESSION_SECRETabsent → no-op (transition mode active) - ✅
resolveActor()helper — session wins; body fallback in transition mode; null in strict mode - ✅ All write handlers use
resolveActor(offers PATCH, deals PATCH + POST /deal, negotiations force-agree, campaigns POST, applications POST apply) - ⏳ Remaining: set
REQUIRE_SESSION_AUTH=true+SESSION_SECRETin production env once frontend is confirmed sending session cookies on all write requests
IS-10 · Opaque Public Identifiers done (core tables)
Problem. All resource IDs are sequential integers. An attacker can enumerate every deal, offer, and submission by incrementing the URL integer, discover total platform activity, and cross-reference with missing auth guards to act on foreign resources.
Design. Add a public_id TEXT UNIQUE NOT NULL column to each affected table. Generate on insert via a PostgreSQL function using the Crockford base-32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ — excludes I, L, O, U to prevent visual confusion). 256 mod 32 = 0 gives perfectly uniform distribution with no rejection sampling. The internal INTEGER primary key is retained for all foreign keys and joins — never exposed in URLs.
| Table | Prefix | Example | Status |
|---|---|---|---|
offers | OFF- | OFF-A3BF9K | ✅ done |
negotiations | NEG- | NEG-2WBTZ6 | ✅ done |
deals | D- | D-K9X3F2 | ✅ done |
session_submissions | SS- | SS-P1KV9C | ✅ done |
campaigns | C- | C-4NHJX8 | ✅ done (migration 0094) |
Implementation status (April 2026):
- ✅
generate_public_id(prefix TEXT)PostgreSQL function (migration 0093) - ✅
public_idcolumns added, backfilled, NOT NULL + UNIQUE indexed, DEFAULT set on all five tables - ✅
publicId.ts—resolvePublicId()resolver; all route regexes updated - ✅ Frontend
idtypes changednumber → stringfor Offer, Negotiation, Deal, SessionSubmission, CampaignApplication, Campaign - ✅ Migration
0094_campaign_public_id.sql—C-prefix on campaigns table
| Table | Prefix | Example | Status |
|---|---|---|---|
offers | OFF- | OFF-A3BF9K | ✅ done |
negotiations | NEG- | NEG-2WBTZ6 | ✅ done |
deals | D- | D-K9X3F2 | ✅ done |
session_submissions | SS- | SS-P1KV9C | ✅ done |
campaigns | C- | C-4NHJX8 | ✅ done (migration 0094) |
IS-11 · Row-Level Ownership Guards done (transition mode)
Problem. Even where actor_id is checked, the check trusts the client-supplied value. Once IS-9 is in place (session-derived actor), ownership checks need to be added where missing.
Affected write routes and their required check:
| Route | Check | Status |
|---|---|---|
PUT /api/operators/:slug | Session actor is that operator | ✅ |
PUT /api/streamers/:u | Session actor is that streamer | ✅ |
POST /api/streamers/:u/addresses | Session is that streamer | ✅ |
DELETE /api/streamers/:u/addresses/:id | Session is that streamer | ✅ |
DELETE /api/streamers/:u/channels/:id | Session is that streamer | ✅ |
POST /api/streamers/:u/videos | Session is that streamer | ✅ |
DELETE /api/streamers/:u/videos/:id | Session is that streamer | ✅ |
POST /api/campaigns | Session is operator (resolveActor) | ✅ |
PUT /api/campaigns/:id | Session operator owns campaign | ✅ |
DELETE /api/campaigns/:id | Session operator owns campaign | ✅ |
POST /api/campaigns/:id/offers | Session actor matches initiated_by party | ✅ |
PATCH /api/offers/:id | Session wins via resolveActor | ✅ |
PATCH /api/negotiations/:id/terms | Session actor is the named party | ✅ |
POST /api/negotiations/:id/agree | Session actor is the named party | ✅ |
POST /api/negotiations/:id/force-agree | Session wins via resolveActor | ✅ |
POST /api/negotiations/:id/cancel | Session actor is party to negotiation | ✅ |
POST /api/negotiations/:id/deal | Session wins via resolveActor | ✅ |
PATCH /api/deals/:id | Session wins via resolveActor | ✅ |
POST /api/campaigns/:id/applications | Session wins via resolveActor | ✅ |
PUT /api/session-submissions/:id | Session operator owns the offer | ✅ |
Returns 404 rather than 403 on ownership failure — a 403 confirms the resource exists, which itself leaks information.
All 20 write routes are fully guarded. resolveActor() in session.ts provides the canonical session-first / body-fallback / strict-reject logic across handlers.
IS-12 · Admin Route Protection done
Problem. Admin-only routes (POST /api/auth/impersonate, operator/streamer list endpoints, stream session list, webhook subscription management) are accessible without any authentication today.
Approach. Add X-Admin-Secret header validation to admin-designated routes. The secret is already defined as ADMIN_SECRET in env (declared in index.ts) but never checked in handlers.
function requireAdmin(req: Request, env: Env): Response | null {
const secret = req.headers.get("X-Admin-Secret");
if (!secret || secret !== env.ADMIN_SECRET)
return errorResponse("Forbidden", 403);
return null;
}Routes requiring admin gate:
| Route | Reason | Status |
|---|---|---|
POST /api/auth/impersonate | Creates impersonation tokens for any user | ✅ |
GET /api/operators (full list) | Internal only — public endpoint exposes all operator names/emails | ✅ |
GET/POST /api/invitations (all) | Creates invitation links | ✅ |
GET /api/streams (global list) | Internal analytics | ✅ |
GET /api/streams/:id/probes | Internal stream probe data | ✅ |
DELETE /api/webhooks/kick/subscriptions/:id | Removes platform tracking | ✅ |
POST /api/webhooks/kick/subscriptions/verify | Triggers Kick API calls | ✅ |
POST /api/webhooks/kick/add-unregistered | Adds new tracked streamers | ✅ |
The admin frontend already sends X-Admin-Secret from VITE_ADMIN_SECRET. Returns 501 when ADMIN_SECRET env var is unconfigured, 403 otherwise.
Prioritisation
| Item | Priority | Effort | Status |
|---|---|---|---|
| IS-9 Auth middleware | Critical | Medium | ✅ done — needs REQUIRE_SESSION_AUTH=true env flip |
| IS-11 Row-level ownership guards | Critical | Medium | ✅ done — all 20 routes guarded |
| IS-12 Admin route protection | Critical | Low | ✅ done |
| IS-10 Opaque public IDs | High | Medium | ✅ done — all tables including campaigns |
| IS-2 Secrets management | High | Low | ⏳ not started |
| IS-3 Audit log | High | Medium | ⏳ not started |
| IS-1 Threat model | Medium | Medium | ⏳ not started |
| IS-8 External pentest | Required before launch | High | ⏳ not started |
Remaining work to close the IS-9/10/11 transition
- Flip strict mode — set
REQUIRE_SESSION_AUTH=trueandSESSION_SECRETin production env once the frontend is confirmed to be sending session cookies on all write requests. This converts IS-9 and IS-11 from "guarded when session present" to "always enforced". All code gaps are now closed; this is the only remaining step.
IS-2 (Secrets), IS-3 (Audit log), and IS-8 (Pentest) are the remaining pre-launch milestones.
Out of Scope
- End-to-end encryption of chat / negotiation messages (no chat feature planned)
- SOC 2 certification (V2 consideration)
- Bug bounty programme (post-launch)