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>
This commit is contained in:
Mike 2026-03-27 22:34:47 +00:00
parent c8ac6ae3fa
commit cd373dd616
12 changed files with 1306 additions and 0 deletions

View file

@ -558,6 +558,17 @@ const PUBLIC_ROUTES = [
'/api/hub/pins/pin.php', '/api/hub/pins/pin.php',
'/api/hub/pins/unpin.php', '/api/hub/pins/unpin.php',
'/api/hub/pins/list.php', '/api/hub/pins/list.php',
// vc gateway - invite management (agent auth)
'/api/hub/vcgateway/invites/create.php',
'/api/hub/vcgateway/invites/revoke.php',
'/api/hub/vcgateway/invites/list.php',
'/api/hub/vcgateway/invites/get.php',
// vc gateway - visitor endpoints (visitor token auth)
'/api/hub/vcgateway/visitor/auth.php',
'/api/hub/vcgateway/visitor/feed.php',
// vc gateway - DM (visitor token auth)
'/api/hub/vcgateway/dm/send.php',
'/api/hub/vcgateway/dm/messages.php',
]; ];
/** /**

View file

@ -0,0 +1,337 @@
# 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`

View file

@ -0,0 +1,110 @@
<?php
/**
* GET /api/hub/vcgateway/dm/messages.php
*
* List messages in the visitor's DM channel with the host.
* Authenticated via X-Visitor-Token header.
*
* Query params:
* Before int optional Cursor: messages before this ID
* After int optional Cursor: messages after this ID
* Limit int optional Max messages (default: 50, max: 100)
*
* Response:
* OK, Messages[], DMChannelID, HasMore
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$visitor = requireVisitorAuth();
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
// No DM channel yet = no messages
if (!$dmChannelId) {
jsonResponse([
'OK' => true,
'Messages' => [],
'DMChannelID' => null,
'HasMore' => false,
]);
}
// Pagination
$before = (int)($_GET['Before'] ?? 0);
$after = (int)($_GET['After'] ?? 0);
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
$params = [$dmChannelId];
if ($before > 0) {
$where[] = 'm.ID < ?';
$params[] = $before;
}
if ($after > 0) {
$where[] = 'm.ID > ?';
$params[] = $after;
}
$whereClause = implode(' AND ', $where);
$fetchLimit = $limit + 1;
$order = ($after > 0) ? 'ASC' : 'DESC';
$rows = queryTimed(
"SELECT m.ID, m.SenderAddress, m.Content, m.IsEdited, m.CreatedAt
FROM Hub_Messages m
WHERE $whereClause
ORDER BY m.ID $order
LIMIT $fetchLimit",
$params
);
$hasMore = count($rows) > $limit;
if ($hasMore) {
array_pop($rows);
}
if ($after > 0) {
$rows = array_reverse($rows);
}
$visitorId = (int)$visitor['ID'];
$messages = [];
foreach ($rows as $row) {
// Determine if sender is visitor or host
$isVisitor = str_starts_with($row['SenderAddress'], 'visitor:');
$senderName = $isVisitor ? $visitor['DisplayName'] : $row['SenderAddress'];
if (!$isVisitor) {
// Resolve agent name
$agent = queryOne(
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
[$row['SenderAddress']]
);
if ($agent) {
$senderName = $agent['AgentName'];
}
}
$messages[] = [
'ID' => (int)$row['ID'],
'SenderAddress' => $row['SenderAddress'],
'SenderName' => $senderName,
'IsVisitor' => $isVisitor,
'Content' => $row['Content'],
'IsEdited' => (bool)$row['IsEdited'],
'CreatedAt' => toISO8601($row['CreatedAt']),
];
}
jsonResponse([
'OK' => true,
'Messages' => $messages,
'DMChannelID' => $dmChannelId,
'HasMore' => $hasMore,
]);

View file

@ -0,0 +1,89 @@
<?php
/**
* POST /api/hub/vcgateway/dm/send.php
*
* Send a DM from visitor to host. Rate-limited.
* Creates the DM channel on first message.
* Authenticated via X-Visitor-Token header.
*
* Body:
* Content string required Message text (max 4000 chars)
*
* Response:
* OK, MessageID, DMChannelID, CreatedAt
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$visitor = requireVisitorAuth();
$body = readJsonBody();
$content = trim($body['Content'] ?? '');
if (empty($content)) {
jsonResponse(['OK' => false, 'ERROR' => 'content_required'], 400);
}
if (strlen($content) > 4000) {
jsonResponse(['OK' => false, 'ERROR' => 'content_too_long', 'MaxLength' => 4000], 400);
}
// Rate limit check
checkVisitorRateLimit((int)$visitor['ID']);
$visitorId = (int)$visitor['ID'];
$hostAddress = $visitor['HostAddress'];
$dmChannelId = $visitor['DMChannelID'] ? (int)$visitor['DMChannelID'] : null;
// Create DM channel if it doesn't exist yet
if (!$dmChannelId) {
$channelName = 'vc-dm-visitor-' . $visitorId;
$displayName = 'VC DM: ' . $visitor['DisplayName'];
queryTimed(
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
VALUES (?, ?, ?, 'direct', ?)",
[
$channelName,
$displayName,
'VC Gateway DM between visitor and host',
$hostAddress,
]
);
$dmChannelId = (int)lastInsertId();
// Add the host as a member
queryTimed(
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role)
VALUES (?, ?, 'owner')",
[$dmChannelId, $hostAddress]
);
// Link the DM channel to the visitor
queryTimed(
"UPDATE Hub_Visitors SET DMChannelID = ? WHERE ID = ?",
[$dmChannelId, $visitorId]
);
}
// Send the message
// Visitor sender address format: "visitor:{visitorId}:{displayName}"
$senderAddress = 'visitor:' . $visitorId . ':' . $visitor['DisplayName'];
queryTimed(
"INSERT INTO Hub_Messages (ChannelID, SenderAddress, Content)
VALUES (?, ?, ?)",
[$dmChannelId, $senderAddress, $content]
);
$messageId = (int)lastInsertId();
jsonResponse([
'OK' => true,
'MessageID' => $messageId,
'DMChannelID' => $dmChannelId,
'CreatedAt' => toISO8601(date('Y-m-d H:i:s')),
]);

View file

@ -0,0 +1,127 @@
<?php
/**
* VC Gateway Helpers
*
* Shared functions for the VC Gateway module.
* Handles visitor authentication, rate limiting, and invite link validation.
*/
require_once __DIR__ . '/../../helpers.php';
// Rate limit: max messages per window
define('VCGW_RATE_LIMIT_MAX', 10); // 10 messages
define('VCGW_RATE_LIMIT_WINDOW_SEC', 300); // per 5-minute window
/**
* Authenticate a visitor via X-Visitor-Token header.
* Returns the visitor row (with invite link data) or aborts with 401.
*/
function requireVisitorAuth(): array {
$token = headerValue('X-Visitor-Token');
if (empty($token)) {
jsonResponse(['OK' => false, 'ERROR' => 'visitor_token_required'], 401);
}
$visitor = queryOne(
"SELECT v.*, il.Token AS InviteToken, il.AllowedChannels, il.HostAddress,
il.IsRevoked AS InviteRevoked, il.ExpiresAt AS InviteExpiresAt
FROM Hub_Visitors v
JOIN Hub_InviteLinks il ON il.ID = v.InviteLinkID
WHERE v.VisitorToken = ?
LIMIT 1",
[$token]
);
if (!$visitor) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_visitor_token'], 401);
}
// Check if the underlying invite link is still valid
if ($visitor['InviteRevoked']) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
}
if ($visitor['InviteExpiresAt'] && strtotime($visitor['InviteExpiresAt']) < time()) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
}
// Update last active
queryTimed("UPDATE Hub_Visitors SET LastActiveAt = NOW() WHERE ID = ?", [$visitor['ID']]);
return $visitor;
}
/**
* Get the allowed channel IDs for a visitor.
*/
function getVisitorAllowedChannels(array $visitor): array {
$channels = json_decode($visitor['AllowedChannels'], true);
return is_array($channels) ? array_map('intval', $channels) : [];
}
/**
* Check if a visitor can read a specific channel.
*/
function visitorCanReadChannel(array $visitor, int $channelId): bool {
$allowed = getVisitorAllowedChannels($visitor);
return in_array($channelId, $allowed, true);
}
/**
* Check rate limit for a visitor. Aborts with 429 if exceeded.
*/
function checkVisitorRateLimit(int $visitorId): void {
// Current window start (rounded to 5-min intervals)
$windowSec = VCGW_RATE_LIMIT_WINDOW_SEC;
$windowStart = date('Y-m-d H:i:s', (int)(floor(time() / $windowSec) * $windowSec));
$row = queryOne(
"SELECT MessageCount FROM Hub_VisitorRateLimit
WHERE VisitorID = ? AND WindowStart = ?",
[$visitorId, $windowStart]
);
$currentCount = $row ? (int)$row['MessageCount'] : 0;
if ($currentCount >= VCGW_RATE_LIMIT_MAX) {
$resetAt = (int)(floor(time() / $windowSec) * $windowSec) + $windowSec;
jsonResponse([
'OK' => false,
'ERROR' => 'rate_limit_exceeded',
'Limit' => VCGW_RATE_LIMIT_MAX,
'WindowSeconds' => $windowSec,
'ResetsAt' => toISO8601(date('Y-m-d H:i:s', $resetAt)),
], 429);
}
// Upsert the count
queryTimed(
"INSERT INTO Hub_VisitorRateLimit (VisitorID, WindowStart, MessageCount)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE MessageCount = MessageCount + 1",
[$visitorId, $windowStart]
);
}
/**
* Require that the caller is a Sprinter agent (via X-Agent-Address header).
* Returns the agent address string.
*/
function requireAgentAuth(): string {
$address = headerValue('X-Agent-Address');
if (empty($address)) {
jsonResponse(['OK' => false, 'ERROR' => 'agent_address_required'], 401);
}
// Verify the agent exists in Sprinter_Agents
$agent = queryOne(
"SELECT ID, FullAddress FROM Sprinter_Agents WHERE FullAddress = ? AND IsActive = 1 LIMIT 1",
[$address]
);
if (!$agent) {
jsonResponse(['OK' => false, 'ERROR' => 'agent_not_found'], 401);
}
return $agent['FullAddress'];
}

View file

@ -0,0 +1,96 @@
<?php
/**
* POST /api/hub/vcgateway/invites/create.php
*
* Create an invite link for VC Gateway.
* Requires agent auth (X-Agent-Address header).
*
* Body:
* Label string optional Human label (e.g. "Sequoia - Partner X")
* AllowedChannels int[] required Array of Hub_Channels.ID the visitor can read
* HostAddress string required Sprinter agent address for DM target
* ExpiresAt string optional ISO8601 expiration datetime (null = never)
* MaxUses int optional Max times this link can be used (0 = unlimited)
*
* Response:
* OK, ID, Token, InviteURL
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$agentAddress = requireAgentAuth();
$body = readJsonBody();
// Validate required fields
$allowedChannels = $body['AllowedChannels'] ?? null;
$hostAddress = trim($body['HostAddress'] ?? '');
if (!is_array($allowedChannels) || empty($allowedChannels)) {
jsonResponse(['OK' => false, 'ERROR' => 'allowed_channels_required'], 400);
}
if (empty($hostAddress)) {
jsonResponse(['OK' => false, 'ERROR' => 'host_address_required'], 400);
}
// Validate all channel IDs exist
$channelIds = array_map('intval', $allowedChannels);
$placeholders = implode(',', array_fill(0, count($channelIds), '?'));
$channels = queryTimed(
"SELECT ID FROM Hub_Channels WHERE ID IN ($placeholders)",
$channelIds
);
$foundIds = array_column($channels, 'ID');
$missing = array_diff($channelIds, array_map('intval', $foundIds));
if (!empty($missing)) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_ids', 'InvalidIDs' => array_values($missing)], 400);
}
// Optional fields
$label = trim($body['Label'] ?? '');
if (strlen($label) > 255) {
$label = substr($label, 0, 255);
}
$expiresAt = null;
if (!empty($body['ExpiresAt'])) {
$dt = strtotime($body['ExpiresAt']);
if ($dt === false || $dt <= time()) {
jsonResponse(['OK' => false, 'ERROR' => 'expires_at_must_be_future'], 400);
}
$expiresAt = date('Y-m-d H:i:s', $dt);
}
$maxUses = max(0, (int)($body['MaxUses'] ?? 0));
// Generate a URL-safe token
$token = bin2hex(random_bytes(24)); // 48 chars, URL-safe
queryTimed(
"INSERT INTO Hub_InviteLinks (Token, Label, AllowedChannels, HostAddress, ExpiresAt, MaxUses, CreatedBy)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[
$token,
$label,
json_encode($channelIds),
$hostAddress,
$expiresAt,
$maxUses,
$agentAddress,
]
);
$id = (int)lastInsertId();
$inviteUrl = baseUrl() . '/vc/' . $token;
jsonResponse([
'OK' => true,
'ID' => $id,
'Token' => $token,
'InviteURL' => $inviteUrl,
]);

View file

@ -0,0 +1,92 @@
<?php
/**
* GET /api/hub/vcgateway/invites/get.php
*
* Get a single invite link by ID or Token.
* Requires agent auth (X-Agent-Address header).
*
* Query params:
* ID int optional Invite link ID
* Token string optional Invite link token
*
* Response:
* OK, Link (object)
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$agentAddress = requireAgentAuth();
$id = (int)($_GET['ID'] ?? 0);
$token = trim($_GET['Token'] ?? '');
if ($id <= 0 && empty($token)) {
jsonResponse(['OK' => false, 'ERROR' => 'id_or_token_required'], 400);
}
if ($id > 0) {
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE ID = ?", [$id]);
} else {
$row = queryOne("SELECT * FROM Hub_InviteLinks WHERE Token = ?", [$token]);
}
if (!$row) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
}
// Get visitor count
$vcRow = queryOne(
"SELECT COUNT(*) AS cnt FROM Hub_Visitors WHERE InviteLinkID = ?",
[(int)$row['ID']]
);
// Compute status
$computedStatus = 'active';
if ($row['IsRevoked']) {
$computedStatus = 'revoked';
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
$computedStatus = 'expired';
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
$computedStatus = 'exhausted';
}
// Get visitors using this link
$visitors = queryTimed(
"SELECT ID, DisplayName, CreatedAt, LastActiveAt
FROM Hub_Visitors WHERE InviteLinkID = ?
ORDER BY CreatedAt DESC",
[(int)$row['ID']]
);
$visitorList = [];
foreach ($visitors as $v) {
$visitorList[] = [
'ID' => (int)$v['ID'],
'DisplayName' => $v['DisplayName'],
'CreatedAt' => toISO8601($v['CreatedAt']),
'LastActiveAt' => toISO8601($v['LastActiveAt']),
];
}
jsonResponse([
'OK' => true,
'Link' => [
'ID' => (int)$row['ID'],
'Token' => $row['Token'],
'Label' => $row['Label'],
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
'HostAddress' => $row['HostAddress'],
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
'MaxUses' => (int)$row['MaxUses'],
'UseCount' => (int)$row['UseCount'],
'VisitorCount' => (int)($vcRow['cnt'] ?? 0),
'Status' => $computedStatus,
'CreatedBy' => $row['CreatedBy'],
'CreatedAt' => toISO8601($row['CreatedAt']),
'Visitors' => $visitorList,
],
]);

View file

@ -0,0 +1,104 @@
<?php
/**
* GET /api/hub/vcgateway/invites/list.php
*
* List invite links. Optionally filter by status.
* Requires agent auth (X-Agent-Address header).
*
* Query params:
* Status string optional "active" | "revoked" | "expired" | "all" (default: "all")
* Limit int optional Max results (default: 50, max: 200)
* Offset int optional Pagination offset (default: 0)
*
* Response:
* OK, Links[], Total
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$agentAddress = requireAgentAuth();
$status = strtolower(trim($_GET['Status'] ?? 'all'));
$limit = min(200, max(1, (int)($_GET['Limit'] ?? 50)));
$offset = max(0, (int)($_GET['Offset'] ?? 0));
// Build WHERE clause
$where = [];
$params = [];
switch ($status) {
case 'active':
$where[] = 'il.IsRevoked = 0';
$where[] = '(il.ExpiresAt IS NULL OR il.ExpiresAt > NOW())';
$where[] = '(il.MaxUses = 0 OR il.UseCount < il.MaxUses)';
break;
case 'revoked':
$where[] = 'il.IsRevoked = 1';
break;
case 'expired':
$where[] = 'il.IsRevoked = 0';
$where[] = 'il.ExpiresAt IS NOT NULL AND il.ExpiresAt <= NOW()';
break;
case 'all':
default:
// No filter
break;
}
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
// Count total
$countRow = queryOne(
"SELECT COUNT(*) AS cnt FROM Hub_InviteLinks il $whereClause",
$params
);
$total = (int)($countRow['cnt'] ?? 0);
// Fetch links
$rows = queryTimed(
"SELECT il.*,
(SELECT COUNT(*) FROM Hub_Visitors v WHERE v.InviteLinkID = il.ID) AS VisitorCount
FROM Hub_InviteLinks il
$whereClause
ORDER BY il.CreatedAt DESC
LIMIT $limit OFFSET $offset",
$params
);
$links = [];
foreach ($rows as $row) {
// Determine computed status
$computedStatus = 'active';
if ($row['IsRevoked']) {
$computedStatus = 'revoked';
} elseif ($row['ExpiresAt'] && strtotime($row['ExpiresAt']) <= time()) {
$computedStatus = 'expired';
} elseif ($row['MaxUses'] > 0 && $row['UseCount'] >= $row['MaxUses']) {
$computedStatus = 'exhausted';
}
$links[] = [
'ID' => (int)$row['ID'],
'Token' => $row['Token'],
'Label' => $row['Label'],
'AllowedChannels' => json_decode($row['AllowedChannels'], true),
'HostAddress' => $row['HostAddress'],
'ExpiresAt' => $row['ExpiresAt'] ? toISO8601($row['ExpiresAt']) : null,
'MaxUses' => (int)$row['MaxUses'],
'UseCount' => (int)$row['UseCount'],
'VisitorCount' => (int)$row['VisitorCount'],
'Status' => $computedStatus,
'CreatedBy' => $row['CreatedBy'],
'CreatedAt' => toISO8601($row['CreatedAt']),
];
}
jsonResponse([
'OK' => true,
'Links' => $links,
'Total' => $total,
]);

View file

@ -0,0 +1,48 @@
<?php
/**
* POST /api/hub/vcgateway/invites/revoke.php
*
* Revoke an invite link. All visitor sessions using this link become invalid.
* Requires agent auth (X-Agent-Address header).
*
* Body:
* ID int required The invite link ID to revoke
*
* Response:
* OK
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$agentAddress = requireAgentAuth();
$body = readJsonBody();
$id = (int)($body['ID'] ?? 0);
if ($id <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'id_required'], 400);
}
// Verify the link exists and belongs to this agent (or they created it)
$link = queryOne(
"SELECT ID, CreatedBy, IsRevoked FROM Hub_InviteLinks WHERE ID = ?",
[$id]
);
if (!$link) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_not_found'], 404);
}
if ($link['IsRevoked']) {
jsonResponse(['OK' => false, 'ERROR' => 'already_revoked'], 400);
}
queryTimed(
"UPDATE Hub_InviteLinks SET IsRevoked = 1 WHERE ID = ?",
[$id]
);
jsonResponse(['OK' => true]);

View file

@ -0,0 +1,51 @@
-- VC Gateway Schema
-- Invite links, visitor sessions, and DM support for anonymous visitors.
-- Part of Sprinter Hub - supports fundraising / investor access.
-- Invite links with expiration and channel scoping
CREATE TABLE IF NOT EXISTS Hub_InviteLinks (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
Token VARCHAR(64) NOT NULL COMMENT 'URL-safe token for link',
Label VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Human label e.g. investor name',
AllowedChannels JSON NOT NULL COMMENT 'Array of Hub_Channels.ID the visitor can read',
HostAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address of the host (DM target)',
ExpiresAt DATETIME NULL COMMENT 'NULL = never expires',
MaxUses INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 = unlimited',
UseCount INT UNSIGNED NOT NULL DEFAULT 0,
IsRevoked TINYINT(1) NOT NULL DEFAULT 0,
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Agent who created the link',
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_invite_token (Token),
INDEX idx_created_by (CreatedBy),
INDEX idx_revoked (IsRevoked),
INDEX idx_expires (ExpiresAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Visitor sessions - created when someone authenticates via invite link
CREATE TABLE IF NOT EXISTS Hub_Visitors (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
InviteLinkID INT UNSIGNED NOT NULL,
VisitorToken VARCHAR(64) NOT NULL COMMENT 'Session token for visitor API calls',
DisplayName VARCHAR(100) NOT NULL DEFAULT 'Visitor',
DMChannelID INT UNSIGNED NULL COMMENT 'Hub_Channels.ID for visitor<->host DM',
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
LastActiveAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_visitor_token (VisitorToken),
INDEX idx_invite_link (InviteLinkID),
INDEX idx_dm_channel (DMChannelID),
CONSTRAINT fk_visitor_invite FOREIGN KEY (InviteLinkID) REFERENCES Hub_InviteLinks(ID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Rate limiting: track visitor message counts per sliding window
CREATE TABLE IF NOT EXISTS Hub_VisitorRateLimit (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
VisitorID INT UNSIGNED NOT NULL,
WindowStart DATETIME NOT NULL,
MessageCount INT UNSIGNED NOT NULL DEFAULT 1,
UNIQUE KEY uq_visitor_window (VisitorID, WindowStart),
CONSTRAINT fk_ratelimit_visitor FOREIGN KEY (VisitorID) REFERENCES Hub_Visitors(ID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -0,0 +1,113 @@
<?php
/**
* POST /api/hub/vcgateway/visitor/auth.php
*
* Authenticate as a visitor using an invite link token.
* Creates a visitor session and returns a VisitorToken for subsequent API calls.
* No user account or Sprinter agent required.
*
* Body:
* InviteToken string required The invite link token from the URL
* DisplayName string optional Visitor's display name (default: "Visitor")
*
* Response:
* OK, VisitorToken, VisitorID, DisplayName, AllowedChannels[], HostAddress
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$inviteToken = trim($body['InviteToken'] ?? '');
if (empty($inviteToken)) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_token_required'], 400);
}
// Look up the invite link
$link = queryOne(
"SELECT * FROM Hub_InviteLinks WHERE Token = ? LIMIT 1",
[$inviteToken]
);
if (!$link) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_invite_token'], 404);
}
// Check if revoked
if ($link['IsRevoked']) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_revoked'], 403);
}
// Check expiration
if ($link['ExpiresAt'] && strtotime($link['ExpiresAt']) < time()) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_expired'], 403);
}
// Check max uses
if ($link['MaxUses'] > 0 && $link['UseCount'] >= $link['MaxUses']) {
jsonResponse(['OK' => false, 'ERROR' => 'invite_exhausted'], 403);
}
// Sanitize display name
$displayName = trim($body['DisplayName'] ?? '');
if (empty($displayName)) {
$displayName = 'Visitor';
}
$displayName = substr($displayName, 0, 100);
// Strip any HTML/script
$displayName = htmlspecialchars($displayName, ENT_QUOTES, 'UTF-8');
// Generate visitor token
$visitorToken = bin2hex(random_bytes(24));
// Create the visitor session
queryTimed(
"INSERT INTO Hub_Visitors (InviteLinkID, VisitorToken, DisplayName)
VALUES (?, ?, ?)",
[(int)$link['ID'], $visitorToken, $displayName]
);
$visitorId = (int)lastInsertId();
// Increment use count on the invite link
queryTimed(
"UPDATE Hub_InviteLinks SET UseCount = UseCount + 1 WHERE ID = ?",
[(int)$link['ID']]
);
// Return the session info
$allowedChannels = json_decode($link['AllowedChannels'], true);
// Fetch channel details for the allowed channels
$channelDetails = [];
if (!empty($allowedChannels)) {
$placeholders = implode(',', array_fill(0, count($allowedChannels), '?'));
$channels = queryTimed(
"SELECT ID, Name, DisplayName, Purpose, ChannelType
FROM Hub_Channels
WHERE ID IN ($placeholders) AND IsArchived = 0",
array_map('intval', $allowedChannels)
);
foreach ($channels as $ch) {
$channelDetails[] = [
'ID' => (int)$ch['ID'],
'Name' => $ch['Name'],
'DisplayName' => $ch['DisplayName'],
'Purpose' => $ch['Purpose'],
'ChannelType' => $ch['ChannelType'],
];
}
}
jsonResponse([
'OK' => true,
'VisitorToken' => $visitorToken,
'VisitorID' => $visitorId,
'DisplayName' => $displayName,
'AllowedChannels' => $channelDetails,
'HostAddress' => $link['HostAddress'],
]);

View file

@ -0,0 +1,128 @@
<?php
/**
* GET /api/hub/vcgateway/visitor/feed.php
*
* Read-only message feed for a visitor's allowed channels.
* Authenticated via X-Visitor-Token header (not user token).
*
* Query params:
* ChannelID int required Channel to read messages from
* Before int optional Cursor: return messages before this ID
* After int optional Cursor: return messages after this ID
* Limit int optional Max messages (default: 50, max: 100)
*
* Response:
* OK, Messages[], Channel (metadata), HasMore
*/
require_once __DIR__ . '/../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$visitor = requireVisitorAuth();
$channelId = (int)($_GET['ChannelID'] ?? 0);
if ($channelId <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required'], 400);
}
// Verify visitor has access to this channel
if (!visitorCanReadChannel($visitor, $channelId)) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_allowed'], 403);
}
// Verify channel exists and is not archived
$channel = queryOne(
"SELECT ID, Name, DisplayName, Purpose, ChannelType
FROM Hub_Channels WHERE ID = ? AND IsArchived = 0",
[$channelId]
);
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
// Pagination
$before = (int)($_GET['Before'] ?? 0);
$after = (int)($_GET['After'] ?? 0);
$limit = min(100, max(1, (int)($_GET['Limit'] ?? 50)));
// Build query - read only non-deleted messages
$where = ['m.ChannelID = ?', 'm.IsDeleted = 0'];
$params = [$channelId];
if ($before > 0) {
$where[] = 'm.ID < ?';
$params[] = $before;
}
if ($after > 0) {
$where[] = 'm.ID > ?';
$params[] = $after;
}
$whereClause = implode(' AND ', $where);
// Fetch one extra to check HasMore
$fetchLimit = $limit + 1;
// Order: newest first for Before cursor, oldest first for After cursor
$order = ($after > 0) ? 'ASC' : 'DESC';
$rows = queryTimed(
"SELECT m.ID, m.SenderAddress, m.Content, m.ParentID,
m.IsEdited, m.CreatedAt, m.UpdatedAt
FROM Hub_Messages m
WHERE $whereClause
ORDER BY m.ID $order
LIMIT $fetchLimit",
$params
);
$hasMore = count($rows) > $limit;
if ($hasMore) {
array_pop($rows);
}
// If we fetched in ASC order, reverse for consistent newest-first
if ($after > 0) {
$rows = array_reverse($rows);
}
// Format messages (read-only: no edit/delete capabilities for visitors)
$messages = [];
foreach ($rows as $row) {
// Resolve sender display name
$senderName = $row['SenderAddress'];
// Try to get agent name
$agent = queryOne(
"SELECT AgentName FROM Sprinter_Agents WHERE FullAddress = ? LIMIT 1",
[$row['SenderAddress']]
);
if ($agent) {
$senderName = $agent['AgentName'];
}
$messages[] = [
'ID' => (int)$row['ID'],
'SenderAddress' => $row['SenderAddress'],
'SenderName' => $senderName,
'Content' => $row['Content'],
'ParentID' => $row['ParentID'] ? (int)$row['ParentID'] : null,
'IsEdited' => (bool)$row['IsEdited'],
'CreatedAt' => toISO8601($row['CreatedAt']),
];
}
jsonResponse([
'OK' => true,
'Channel' => [
'ID' => (int)$channel['ID'],
'Name' => $channel['Name'],
'DisplayName' => $channel['DisplayName'],
'Purpose' => $channel['Purpose'],
],
'Messages' => $messages,
'HasMore' => $hasMore,
]);