payfrit-api/api/hub/vcgateway/API-CONTRACT.md
Mike cd373dd616 Add VC Gateway endpoints for invite links, visitor auth, DM, and rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:34:52 +00:00

337 lines
7.4 KiB
Markdown

# VC Gateway — API Contract
**Module:** `/api/hub/vcgateway/`
**Owner:** @mike (backend), @netasha (frontend)
**Status:** Implemented on dev (2026-03-27)
---
## Overview
The VC Gateway enables anonymous visitors (e.g. investors) to:
1. Authenticate via a shareable invite link (no account needed)
2. Read messages from scoped channels (read-only)
3. Send DMs to the host (rate-limited)
Two auth models:
- **Agent auth** (`X-Agent-Address` header) — for team members managing invite links
- **Visitor auth** (`X-Visitor-Token` header) — for anonymous visitors using the gateway
---
## Endpoints
### Invite Management (Agent Auth)
#### `POST /api/hub/vcgateway/invites/create.php`
Create an invite link.
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
**Body:**
```json
{
"AllowedChannels": [1, 5, 12],
"HostAddress": "sprinter.payfrit.john",
"Label": "Sequoia - Partner X",
"ExpiresAt": "2026-04-15T00:00:00Z",
"MaxUses": 3
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| AllowedChannels | int[] | ✅ | Hub channel IDs visitor can read |
| HostAddress | string | ✅ | Sprinter agent address for DM target |
| Label | string | ❌ | Human label (max 255 chars) |
| ExpiresAt | string | ❌ | ISO8601 datetime; null = never |
| MaxUses | int | ❌ | 0 = unlimited (default) |
**Response:**
```json
{
"OK": true,
"ID": 1,
"Token": "a1b2c3d4e5f6...",
"InviteURL": "https://dev.payfrit.com/vc/a1b2c3d4e5f6..."
}
```
---
#### `POST /api/hub/vcgateway/invites/revoke.php`
Revoke an invite link. All visitor sessions become invalid.
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
**Body:**
```json
{ "ID": 1 }
```
**Response:**
```json
{ "OK": true }
```
---
#### `GET /api/hub/vcgateway/invites/list.php`
List invite links with optional status filter.
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
**Query params:**
| Param | Type | Default | Options |
|-------|------|---------|---------|
| Status | string | all | `active`, `revoked`, `expired`, `all` |
| Limit | int | 50 | max 200 |
| Offset | int | 0 | |
**Response:**
```json
{
"OK": true,
"Links": [
{
"ID": 1,
"Token": "a1b2c3...",
"Label": "Sequoia - Partner X",
"AllowedChannels": [1, 5, 12],
"HostAddress": "sprinter.payfrit.john",
"ExpiresAt": "2026-04-15T00:00:00Z",
"MaxUses": 3,
"UseCount": 1,
"VisitorCount": 1,
"Status": "active",
"CreatedBy": "sprinter.payfrit.john",
"CreatedAt": "2026-03-27T20:00:00Z"
}
],
"Total": 1
}
```
**Status values:** `active`, `revoked`, `expired`, `exhausted`
---
#### `GET /api/hub/vcgateway/invites/get.php`
Get a single invite link with its visitors.
**Headers:** `X-Agent-Address: sprinter.payfrit.john`
**Query params:** `ID=1` or `Token=a1b2c3...`
**Response:** Same as list item, plus a `Visitors` array:
```json
{
"OK": true,
"Link": {
"...": "same fields as list",
"Visitors": [
{
"ID": 1,
"DisplayName": "Alex Chen",
"CreatedAt": "2026-03-27T21:00:00Z",
"LastActiveAt": "2026-03-27T21:15:00Z"
}
]
}
}
```
---
### Visitor Endpoints (Visitor Auth)
#### `POST /api/hub/vcgateway/visitor/auth.php`
Authenticate as a visitor. No auth header needed — uses the invite token.
**Body:**
```json
{
"InviteToken": "a1b2c3d4e5f6...",
"DisplayName": "Alex Chen"
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| InviteToken | string | ✅ | Token from invite URL |
| DisplayName | string | ❌ | Default: "Visitor" (max 100 chars) |
**Response:**
```json
{
"OK": true,
"VisitorToken": "x9y8z7...",
"VisitorID": 1,
"DisplayName": "Alex Chen",
"AllowedChannels": [
{
"ID": 1,
"Name": "general",
"DisplayName": "General",
"Purpose": "Team discussion",
"ChannelType": "public"
}
],
"HostAddress": "sprinter.payfrit.john"
}
```
**Save `VisitorToken` — use it as `X-Visitor-Token` header for all subsequent calls.**
**Error codes:** `invalid_invite_token`, `invite_revoked`, `invite_expired`, `invite_exhausted`
---
#### `GET /api/hub/vcgateway/visitor/feed.php`
Read-only message feed for an allowed channel.
**Headers:** `X-Visitor-Token: x9y8z7...`
**Query params:**
| Param | Type | Required | Notes |
|-------|------|----------|-------|
| ChannelID | int | ✅ | Must be in visitor's AllowedChannels |
| Before | int | ❌ | Cursor: messages before this ID |
| After | int | ❌ | Cursor: messages after this ID |
| Limit | int | ❌ | Default 50, max 100 |
**Response:**
```json
{
"OK": true,
"Channel": {
"ID": 1,
"Name": "general",
"DisplayName": "General",
"Purpose": "Team discussion"
},
"Messages": [
{
"ID": 100,
"SenderAddress": "sprinter.payfrit.john",
"SenderName": "john",
"Content": "Let's discuss the roadmap",
"ParentID": null,
"IsEdited": false,
"CreatedAt": "2026-03-27T20:30:00Z"
}
],
"HasMore": true
}
```
**Error codes:** `channel_id_required`, `channel_not_allowed`, `channel_not_found`
---
### DM Endpoints (Visitor Auth)
#### `POST /api/hub/vcgateway/dm/send.php`
Send a DM from visitor to host. Rate-limited (10 messages per 5 minutes).
**Headers:** `X-Visitor-Token: x9y8z7...`
**Body:**
```json
{ "Content": "Hi, I had a question about your Series A terms." }
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| Content | string | ✅ | Max 4000 chars |
**Response:**
```json
{
"OK": true,
"MessageID": 201,
"DMChannelID": 15,
"CreatedAt": "2026-03-27T21:00:00Z"
}
```
**Rate limit error (HTTP 429):**
```json
{
"OK": false,
"ERROR": "rate_limit_exceeded",
"Limit": 10,
"WindowSeconds": 300,
"ResetsAt": "2026-03-27T21:05:00Z"
}
```
The DM channel is auto-created on first message. The host sees DMs in their normal Hub message list (channel type: `direct`).
---
#### `GET /api/hub/vcgateway/dm/messages.php`
List messages in the visitor's DM channel.
**Headers:** `X-Visitor-Token: x9y8z7...`
**Query params:**
| Param | Type | Required | Notes |
|-------|------|----------|-------|
| Before | int | ❌ | Cursor: messages before this ID |
| After | int | ❌ | Cursor: messages after this ID |
| Limit | int | ❌ | Default 50, max 100 |
**Response:**
```json
{
"OK": true,
"Messages": [
{
"ID": 201,
"SenderAddress": "visitor:1:Alex Chen",
"SenderName": "Alex Chen",
"IsVisitor": true,
"Content": "Hi, I had a question about your Series A terms.",
"IsEdited": false,
"CreatedAt": "2026-03-27T21:00:00Z"
},
{
"ID": 205,
"SenderAddress": "sprinter.payfrit.john",
"SenderName": "john",
"IsVisitor": false,
"Content": "Sure, happy to discuss. What are you thinking?",
"IsEdited": false,
"CreatedAt": "2026-03-27T21:02:00Z"
}
],
"DMChannelID": 15,
"HasMore": false
}
```
Returns empty `Messages` array if no DM channel exists yet.
---
## Architecture Notes
- **Tables:** `Hub_InviteLinks`, `Hub_Visitors`, `Hub_VisitorRateLimit`
- **DM channels** use existing `Hub_Channels` (type: `direct`) and `Hub_Messages`
- **Rate limiting:** 10 messages per 5-minute sliding window, stored in `Hub_VisitorRateLimit`
- **Visitor sender format:** `visitor:{id}:{displayName}` in `SenderAddress`
- **Host replies** use normal Hub message send (existing endpoint), targeting the DM channel
- **Revoking a link** invalidates all visitor sessions derived from it
- **Schema:** `/api/hub/vcgateway/schema.sql`