diff --git a/api/helpers.php b/api/helpers.php index 268f51c..d864c8f 100644 --- a/api/helpers.php +++ b/api/helpers.php @@ -558,6 +558,17 @@ const PUBLIC_ROUTES = [ '/api/hub/pins/pin.php', '/api/hub/pins/unpin.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', ]; /** diff --git a/api/hub/vcgateway/API-CONTRACT.md b/api/hub/vcgateway/API-CONTRACT.md new file mode 100644 index 0000000..4394f73 --- /dev/null +++ b/api/hub/vcgateway/API-CONTRACT.md @@ -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` diff --git a/api/hub/vcgateway/dm/messages.php b/api/hub/vcgateway/dm/messages.php new file mode 100644 index 0000000..e87e86e --- /dev/null +++ b/api/hub/vcgateway/dm/messages.php @@ -0,0 +1,110 @@ + 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, +]); diff --git a/api/hub/vcgateway/dm/send.php b/api/hub/vcgateway/dm/send.php new file mode 100644 index 0000000..5f97410 --- /dev/null +++ b/api/hub/vcgateway/dm/send.php @@ -0,0 +1,89 @@ + 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')), +]); diff --git a/api/hub/vcgateway/helpers.php b/api/hub/vcgateway/helpers.php new file mode 100644 index 0000000..92771a3 --- /dev/null +++ b/api/hub/vcgateway/helpers.php @@ -0,0 +1,127 @@ + 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']; +} diff --git a/api/hub/vcgateway/invites/create.php b/api/hub/vcgateway/invites/create.php new file mode 100644 index 0000000..7d2e27d --- /dev/null +++ b/api/hub/vcgateway/invites/create.php @@ -0,0 +1,96 @@ + 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, +]); diff --git a/api/hub/vcgateway/invites/get.php b/api/hub/vcgateway/invites/get.php new file mode 100644 index 0000000..4a36bce --- /dev/null +++ b/api/hub/vcgateway/invites/get.php @@ -0,0 +1,92 @@ + 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, + ], +]); diff --git a/api/hub/vcgateway/invites/list.php b/api/hub/vcgateway/invites/list.php new file mode 100644 index 0000000..922c5d8 --- /dev/null +++ b/api/hub/vcgateway/invites/list.php @@ -0,0 +1,104 @@ + 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, +]); diff --git a/api/hub/vcgateway/invites/revoke.php b/api/hub/vcgateway/invites/revoke.php new file mode 100644 index 0000000..064bcdf --- /dev/null +++ b/api/hub/vcgateway/invites/revoke.php @@ -0,0 +1,48 @@ + 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]); diff --git a/api/hub/vcgateway/schema.sql b/api/hub/vcgateway/schema.sql new file mode 100644 index 0000000..7ec62a5 --- /dev/null +++ b/api/hub/vcgateway/schema.sql @@ -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; diff --git a/api/hub/vcgateway/visitor/auth.php b/api/hub/vcgateway/visitor/auth.php new file mode 100644 index 0000000..438dcd6 --- /dev/null +++ b/api/hub/vcgateway/visitor/auth.php @@ -0,0 +1,113 @@ + 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'], +]); diff --git a/api/hub/vcgateway/visitor/feed.php b/api/hub/vcgateway/visitor/feed.php new file mode 100644 index 0000000..c6377f5 --- /dev/null +++ b/api/hub/vcgateway/visitor/feed.php @@ -0,0 +1,128 @@ + 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, +]);