Add Hub Channels API — CRUD endpoints for channel management

New endpoints under /api/hub/channels/:
- create.php: Create channel with type (public/private/direct), auto-add creator as owner
- list.php: List channels with filters (type, agent membership, archived, pagination)
- get.php: Get channel by ID or Name, includes member list
- update.php: Update display name, purpose, archive status (admin/owner only)
- delete.php: Hard-delete channel (owner only), FK cascade removes members
- members.php: List channel members with agent info
- join.php: Join public channels (private requires invite)
- leave.php: Leave channel (owners blocked from leaving)

Database: Hub_Channels + Hub_ChannelMembers tables with FK cascade.
Task #59 (T51-Sub1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mike 2026-03-27 01:06:14 +00:00
parent 61c9bb8038
commit 629c7d2cef
10 changed files with 599 additions and 0 deletions

View file

@ -521,6 +521,15 @@ const PUBLIC_ROUTES = [
'/api/tasks/team/update.php',
'/api/tasks/team/active.php',
'/api/tasks/team/list.php',
// hub channels (agent-to-agent, no user auth)
'/api/hub/channels/create.php',
'/api/hub/channels/list.php',
'/api/hub/channels/get.php',
'/api/hub/channels/update.php',
'/api/hub/channels/delete.php',
'/api/hub/channels/members.php',
'/api/hub/channels/join.php',
'/api/hub/channels/leave.php',
];
/**

View file

@ -0,0 +1,91 @@
<?php
/**
* POST /api/hub/channels/create.php
*
* Create a new channel.
*
* Body:
* Name string REQUIRED unique slug (lowercase, hyphens, no spaces)
* DisplayName string optional human-readable name (defaults to Name)
* Purpose string optional channel description
* ChannelType string optional public|private|direct (default: public)
* CreatedBy string REQUIRED agent address (e.g. sprinter.payfrit.mike)
*
* Response: { OK: true, Channel: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
// --- Validate required fields ---
$name = trim($body['Name'] ?? '');
$createdBy = trim($body['CreatedBy'] ?? '');
if ($name === '') {
jsonResponse(['OK' => false, 'ERROR' => 'name_required']);
}
if ($createdBy === '') {
jsonResponse(['OK' => false, 'ERROR' => 'created_by_required']);
}
// Sanitize name: lowercase, alphanumeric + hyphens only
$name = strtolower(preg_replace('/[^a-zA-Z0-9\-]/', '', $name));
if ($name === '' || strlen($name) > 100) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_name']);
}
$displayName = trim($body['DisplayName'] ?? '') ?: $name;
$purpose = trim($body['Purpose'] ?? '');
$channelType = strtolower(trim($body['ChannelType'] ?? 'public'));
if (!in_array($channelType, ['public', 'private', 'direct'], true)) {
jsonResponse(['OK' => false, 'ERROR' => 'invalid_channel_type']);
}
// Enforce length limits
if (strlen($displayName) > 200) $displayName = substr($displayName, 0, 200);
if (strlen($purpose) > 500) $purpose = substr($purpose, 0, 500);
// --- Check uniqueness ---
$existing = queryOne("SELECT ID FROM Hub_Channels WHERE Name = ?", [$name]);
if ($existing) {
jsonResponse(['OK' => false, 'ERROR' => 'name_already_exists']);
}
// --- Insert channel ---
queryTimed(
"INSERT INTO Hub_Channels (Name, DisplayName, Purpose, ChannelType, CreatedBy)
VALUES (?, ?, ?, ?, ?)",
[$name, $displayName, $purpose, $channelType, $createdBy]
);
$channelId = (int) lastInsertId();
// --- Auto-add creator as owner ---
queryTimed(
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'owner')",
[$channelId, $createdBy]
);
// --- Fetch and return ---
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]);
jsonResponse([
'OK' => true,
'Channel' => [
'ID' => (int) $channel['ID'],
'Name' => $channel['Name'],
'DisplayName' => $channel['DisplayName'],
'Purpose' => $channel['Purpose'],
'ChannelType' => $channel['ChannelType'],
'CreatedBy' => $channel['CreatedBy'],
'IsArchived' => (bool) $channel['IsArchived'],
'CreatedAt' => toISO8601($channel['CreatedAt']),
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
'MemberCount' => 1,
],
]);

View file

@ -0,0 +1,51 @@
<?php
/**
* POST /api/hub/channels/delete.php
*
* Delete (hard-delete) a channel. Only the owner can delete.
* For soft-delete, use update.php with IsArchived=true instead.
*
* Body:
* ID int REQUIRED
* Agent string REQUIRED requesting agent (must be owner)
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$id = (int) ($body['ID'] ?? 0);
$agent = trim($body['Agent'] ?? '');
if ($id <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'id_required']);
}
if ($agent === '') {
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
}
// Verify channel exists
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
// Only owner can delete
$membership = queryOne(
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$id, $agent]
);
if (!$membership || $membership['Role'] !== 'owner') {
jsonResponse(['OK' => false, 'ERROR' => 'not_authorized_owner_only'], 403);
}
// Delete channel (FK cascade will remove members)
queryTimed("DELETE FROM Hub_Channels WHERE ID = ?", [$id]);
jsonResponse(['OK' => true]);

74
api/hub/channels/get.php Normal file
View file

@ -0,0 +1,74 @@
<?php
/**
* GET /api/hub/channels/get.php
*
* Get a single channel by ID or Name.
*
* Query params:
* ID int get by ID
* Name string get by name (if ID not provided)
*
* Response: { OK: true, Channel: { ... }, Members: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$id = (int) ($_GET['ID'] ?? 0);
$name = trim($_GET['Name'] ?? '');
if ($id <= 0 && $name === '') {
jsonResponse(['OK' => false, 'ERROR' => 'id_or_name_required']);
}
if ($id > 0) {
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
} else {
$channel = queryOne("SELECT * FROM Hub_Channels WHERE Name = ?", [$name]);
}
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
// Fetch members
$members = queryTimed(
"SELECT m.*, a.AgentName, a.AgentType, a.Role AS AgentRole
FROM Hub_ChannelMembers m
LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress
WHERE m.ChannelID = ?
ORDER BY m.JoinedAt ASC",
[(int) $channel['ID']]
);
$memberList = [];
foreach ($members as $m) {
$memberList[] = [
'AgentAddress' => $m['AgentAddress'],
'AgentName' => $m['AgentName'] ?? '',
'AgentType' => $m['AgentType'] ?? '',
'Role' => $m['Role'],
'JoinedAt' => toISO8601($m['JoinedAt']),
'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null,
];
}
jsonResponse([
'OK' => true,
'Channel' => [
'ID' => (int) $channel['ID'],
'Name' => $channel['Name'],
'DisplayName' => $channel['DisplayName'],
'Purpose' => $channel['Purpose'],
'ChannelType' => $channel['ChannelType'],
'CreatedBy' => $channel['CreatedBy'],
'IsArchived' => (bool) $channel['IsArchived'],
'CreatedAt' => toISO8601($channel['CreatedAt']),
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
'MemberCount' => count($memberList),
],
'Members' => $memberList,
]);

62
api/hub/channels/join.php Normal file
View file

@ -0,0 +1,62 @@
<?php
/**
* POST /api/hub/channels/join.php
*
* Join a channel. Public channels are open to anyone.
* Private channels require an invite (admin/owner must add via addMember).
*
* Body:
* ChannelID int REQUIRED
* Agent string REQUIRED agent address joining
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$channelId = (int) ($body['ChannelID'] ?? 0);
$agent = trim($body['Agent'] ?? '');
if ($channelId <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
}
if ($agent === '') {
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
}
// Verify channel exists and is not archived
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
if ((bool) $channel['IsArchived']) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_archived']);
}
// Private/direct channels can't be self-joined
if ($channel['ChannelType'] !== 'public') {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_public']);
}
// Check if already a member
$existing = queryOne(
"SELECT ID FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $agent]
);
if ($existing) {
jsonResponse(['OK' => true, 'Note' => 'already_member']);
}
// Add member
queryTimed(
"INSERT INTO Hub_ChannelMembers (ChannelID, AgentAddress, Role) VALUES (?, ?, 'member')",
[$channelId, $agent]
);
jsonResponse(['OK' => true]);

View file

@ -0,0 +1,51 @@
<?php
/**
* POST /api/hub/channels/leave.php
*
* Leave a channel. Owners cannot leave (must transfer ownership or delete).
*
* Body:
* ChannelID int REQUIRED
* Agent string REQUIRED agent address leaving
*
* Response: { OK: true }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$channelId = (int) ($body['ChannelID'] ?? 0);
$agent = trim($body['Agent'] ?? '');
if ($channelId <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
}
if ($agent === '') {
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
}
// Check membership
$membership = queryOne(
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $agent]
);
if (!$membership) {
jsonResponse(['OK' => false, 'ERROR' => 'not_a_member']);
}
if ($membership['Role'] === 'owner') {
jsonResponse(['OK' => false, 'ERROR' => 'owner_cannot_leave']);
}
queryTimed(
"DELETE FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$channelId, $agent]
);
jsonResponse(['OK' => true]);

77
api/hub/channels/list.php Normal file
View file

@ -0,0 +1,77 @@
<?php
/**
* GET /api/hub/channels/list.php
*
* List channels with optional filters.
*
* Query params:
* ChannelType string optional filter by type (public|private|direct)
* Agent string optional only channels this agent is a member of
* IncludeArchived 1|0 optional include archived channels (default: 0)
* Limit int optional max results (default: 50, max: 200)
* Offset int optional pagination offset (default: 0)
*
* Response: { OK: true, Channels: [...], Total: int }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelType = trim($_GET['ChannelType'] ?? '');
$agent = trim($_GET['Agent'] ?? '');
$includeArchived = ($_GET['IncludeArchived'] ?? '0') === '1';
$limit = min(max((int) ($_GET['Limit'] ?? 50), 1), 200);
$offset = max((int) ($_GET['Offset'] ?? 0), 0);
$where = [];
$params = [];
if (!$includeArchived) {
$where[] = 'c.IsArchived = 0';
}
if ($channelType !== '' && in_array($channelType, ['public', 'private', 'direct'], true)) {
$where[] = 'c.ChannelType = ?';
$params[] = $channelType;
}
$join = '';
if ($agent !== '') {
$join = 'INNER JOIN Hub_ChannelMembers m ON m.ChannelID = c.ID AND m.AgentAddress = ?';
array_unshift($params, $agent);
}
$whereClause = count($where) > 0 ? 'WHERE ' . implode(' AND ', $where) : '';
// Count total
$countSql = "SELECT COUNT(*) AS cnt FROM Hub_Channels c $join $whereClause";
$countRow = queryOne($countSql, $params);
$total = (int) ($countRow['cnt'] ?? 0);
// Fetch page
$dataSql = "SELECT c.*, (SELECT COUNT(*) FROM Hub_ChannelMembers WHERE ChannelID = c.ID) AS MemberCount
FROM Hub_Channels c $join $whereClause
ORDER BY c.CreatedAt DESC LIMIT ? OFFSET ?";
$dataParams = array_merge($params, [$limit, $offset]);
$rows = queryTimed($dataSql, $dataParams);
$channels = [];
foreach ($rows as $r) {
$channels[] = [
'ID' => (int) $r['ID'],
'Name' => $r['Name'],
'DisplayName' => $r['DisplayName'],
'Purpose' => $r['Purpose'],
'ChannelType' => $r['ChannelType'],
'CreatedBy' => $r['CreatedBy'],
'IsArchived' => (bool) $r['IsArchived'],
'CreatedAt' => toISO8601($r['CreatedAt']),
'UpdatedAt' => toISO8601($r['UpdatedAt']),
'MemberCount' => (int) $r['MemberCount'],
];
}
jsonResponse(['OK' => true, 'Channels' => $channels, 'Total' => $total]);

View file

@ -0,0 +1,54 @@
<?php
/**
* GET /api/hub/channels/members.php
*
* List members of a channel.
*
* Query params:
* ChannelID int REQUIRED
*
* Response: { OK: true, Members: [...] }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$channelId = (int) ($_GET['ChannelID'] ?? 0);
if ($channelId <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_id_required']);
}
// Verify channel exists
$channel = queryOne("SELECT ID FROM Hub_Channels WHERE ID = ?", [$channelId]);
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
$members = queryTimed(
"SELECT m.AgentAddress, m.Role, m.JoinedAt, m.LastViewedAt,
a.AgentName, a.AgentType, a.Role AS AgentRole, a.IsActive
FROM Hub_ChannelMembers m
LEFT JOIN Sprinter_Agents a ON a.FullAddress = m.AgentAddress
WHERE m.ChannelID = ?
ORDER BY m.JoinedAt ASC",
[$channelId]
);
$list = [];
foreach ($members as $m) {
$list[] = [
'AgentAddress' => $m['AgentAddress'],
'AgentName' => $m['AgentName'] ?? '',
'AgentType' => $m['AgentType'] ?? '',
'AgentRole' => $m['AgentRole'] ?? '',
'IsActive' => isset($m['IsActive']) ? (bool) $m['IsActive'] : null,
'ChannelRole' => $m['Role'],
'JoinedAt' => toISO8601($m['JoinedAt']),
'LastViewedAt' => $m['LastViewedAt'] ? toISO8601($m['LastViewedAt']) : null,
];
}
jsonResponse(['OK' => true, 'Members' => $list, 'Total' => count($list)]);

View file

@ -0,0 +1,32 @@
-- Hub Channels Schema
-- Part of Sprinter Hub Migration (Task #51, Sub-task #59)
CREATE TABLE IF NOT EXISTS Hub_Channels (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
Name VARCHAR(100) NOT NULL,
DisplayName VARCHAR(200) NOT NULL DEFAULT '',
Purpose VARCHAR(500) DEFAULT '',
ChannelType ENUM('public','private','direct') NOT NULL DEFAULT 'public',
CreatedBy VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address',
IsArchived TINYINT(1) NOT NULL DEFAULT 0,
CreatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UpdatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_channel_name (Name),
INDEX idx_type (ChannelType),
INDEX idx_created_by (CreatedBy),
INDEX idx_archived (IsArchived)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS Hub_ChannelMembers (
ID INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ChannelID INT UNSIGNED NOT NULL,
AgentAddress VARCHAR(150) NOT NULL COMMENT 'Sprinter agent address',
Role ENUM('member','admin','owner') NOT NULL DEFAULT 'member',
JoinedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
LastViewedAt DATETIME DEFAULT NULL,
UNIQUE KEY uq_channel_agent (ChannelID, AgentAddress),
INDEX idx_agent (AgentAddress),
CONSTRAINT fk_member_channel FOREIGN KEY (ChannelID) REFERENCES Hub_Channels(ID) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -0,0 +1,98 @@
<?php
/**
* POST /api/hub/channels/update.php
*
* Update a channel's metadata.
*
* Body:
* ID int REQUIRED
* DisplayName string optional
* Purpose string optional
* IsArchived bool optional
* Agent string REQUIRED requesting agent (must be admin/owner)
*
* Response: { OK: true, Channel: { ... } }
*/
require_once __DIR__ . '/../../helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonResponse(['OK' => false, 'ERROR' => 'method_not_allowed'], 405);
}
$body = readJsonBody();
$id = (int) ($body['ID'] ?? 0);
$agent = trim($body['Agent'] ?? '');
if ($id <= 0) {
jsonResponse(['OK' => false, 'ERROR' => 'id_required']);
}
if ($agent === '') {
jsonResponse(['OK' => false, 'ERROR' => 'agent_required']);
}
// Verify channel exists
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
if (!$channel) {
jsonResponse(['OK' => false, 'ERROR' => 'channel_not_found'], 404);
}
// Check permissions: must be admin or owner
$membership = queryOne(
"SELECT Role FROM Hub_ChannelMembers WHERE ChannelID = ? AND AgentAddress = ?",
[$id, $agent]
);
if (!$membership || !in_array($membership['Role'], ['admin', 'owner'], true)) {
jsonResponse(['OK' => false, 'ERROR' => 'not_authorized'], 403);
}
// Build update fields
$sets = [];
$params = [];
if (isset($body['DisplayName'])) {
$val = trim($body['DisplayName']);
if (strlen($val) > 200) $val = substr($val, 0, 200);
$sets[] = 'DisplayName = ?';
$params[] = $val;
}
if (isset($body['Purpose'])) {
$val = trim($body['Purpose']);
if (strlen($val) > 500) $val = substr($val, 0, 500);
$sets[] = 'Purpose = ?';
$params[] = $val;
}
if (isset($body['IsArchived'])) {
$sets[] = 'IsArchived = ?';
$params[] = $body['IsArchived'] ? 1 : 0;
}
if (count($sets) === 0) {
jsonResponse(['OK' => false, 'ERROR' => 'nothing_to_update']);
}
$params[] = $id;
queryTimed("UPDATE Hub_Channels SET " . implode(', ', $sets) . " WHERE ID = ?", $params);
// Fetch updated
$channel = queryOne("SELECT * FROM Hub_Channels WHERE ID = ?", [$id]);
$memberCount = queryOne("SELECT COUNT(*) AS cnt FROM Hub_ChannelMembers WHERE ChannelID = ?", [$id]);
jsonResponse([
'OK' => true,
'Channel' => [
'ID' => (int) $channel['ID'],
'Name' => $channel['Name'],
'DisplayName' => $channel['DisplayName'],
'Purpose' => $channel['Purpose'],
'ChannelType' => $channel['ChannelType'],
'CreatedBy' => $channel['CreatedBy'],
'IsArchived' => (bool) $channel['IsArchived'],
'CreatedAt' => toISO8601($channel['CreatedAt']),
'UpdatedAt' => toISO8601($channel['UpdatedAt']),
'MemberCount' => (int) ($memberCount['cnt'] ?? 0),
],
]);