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:
parent
c8ac6ae3fa
commit
cd373dd616
12 changed files with 1306 additions and 0 deletions
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
337
api/hub/vcgateway/API-CONTRACT.md
Normal file
337
api/hub/vcgateway/API-CONTRACT.md
Normal 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`
|
||||||
110
api/hub/vcgateway/dm/messages.php
Normal file
110
api/hub/vcgateway/dm/messages.php
Normal 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,
|
||||||
|
]);
|
||||||
89
api/hub/vcgateway/dm/send.php
Normal file
89
api/hub/vcgateway/dm/send.php
Normal 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')),
|
||||||
|
]);
|
||||||
127
api/hub/vcgateway/helpers.php
Normal file
127
api/hub/vcgateway/helpers.php
Normal 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'];
|
||||||
|
}
|
||||||
96
api/hub/vcgateway/invites/create.php
Normal file
96
api/hub/vcgateway/invites/create.php
Normal 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,
|
||||||
|
]);
|
||||||
92
api/hub/vcgateway/invites/get.php
Normal file
92
api/hub/vcgateway/invites/get.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
104
api/hub/vcgateway/invites/list.php
Normal file
104
api/hub/vcgateway/invites/list.php
Normal 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,
|
||||||
|
]);
|
||||||
48
api/hub/vcgateway/invites/revoke.php
Normal file
48
api/hub/vcgateway/invites/revoke.php
Normal 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]);
|
||||||
51
api/hub/vcgateway/schema.sql
Normal file
51
api/hub/vcgateway/schema.sql
Normal 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;
|
||||||
113
api/hub/vcgateway/visitor/auth.php
Normal file
113
api/hub/vcgateway/visitor/auth.php
Normal 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'],
|
||||||
|
]);
|
||||||
128
api/hub/vcgateway/visitor/feed.php
Normal file
128
api/hub/vcgateway/visitor/feed.php
Normal 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,
|
||||||
|
]);
|
||||||
Loading…
Add table
Reference in a new issue