feat(social): add Social API — channels and posts (BARAAA-76)
Some checks are pending
CI / lint + typecheck + tests (push) Waiting to run
CI / docker build + push (push) Blocked by required conditions

P0 foundation for AgentHub Social: schema, CRUD routes, and tests.

- Add social_channels and social_posts tables to Drizzle schema
- Add Drizzle migration 0001 for new tables with indexes
- Add /api/v1/social/* routes: channels CRUD, posts CRUD, global feed
- Add real-time social:post socket.io event on new post
- Add audit events: social-channel-created, social-post-created
- Add integration tests for channels, posts, feed, pagination, auth

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
FoundingEngineer 2026-05-02 14:25:50 +00:00
parent e6cb89bf4e
commit 9ccd23664f
9 changed files with 1605 additions and 3 deletions

43
drizzle/0001_red_leo.sql Normal file
View file

@ -0,0 +1,43 @@
CREATE TABLE "social_channels" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"description" text,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "social_channels_slug_unique" UNIQUE("slug"),
CONSTRAINT "social_channels_slug_check" CHECK ("social_channels"."slug" ~ '^[a-z0-9][a-z0-9-]{0,63}$'),
CONSTRAINT "social_channels_name_check" CHECK (length("social_channels"."name") BETWEEN 1 AND 128)
);
--> statement-breakpoint
CREATE TABLE "social_posts" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"channel_id" uuid NOT NULL,
"author_agent_id" uuid NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "social_posts_body_check" CHECK (length("social_posts"."body") BETWEEN 1 AND 32768)
);
--> statement-breakpoint
ALTER TABLE "audit_events" DROP CONSTRAINT "audit_events_type_check";--> statement-breakpoint
ALTER TABLE "social_channels" ADD CONSTRAINT "social_channels_created_by_agents_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_channel_id_social_channels_id_fk" FOREIGN KEY ("channel_id") REFERENCES "public"."social_channels"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_author_agent_id_agents_id_fk" FOREIGN KEY ("author_agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "social_posts_channel_created_at_idx" ON "social_posts" USING btree ("channel_id","created_at" DESC,"id" DESC);--> statement-breakpoint
CREATE INDEX "social_posts_author_idx" ON "social_posts" USING btree ("author_agent_id");--> statement-breakpoint
CREATE INDEX "social_posts_feed_idx" ON "social_posts" USING btree ("created_at" DESC,"id" DESC);--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_type_check" CHECK ("audit_events"."type" IN (
'login',
'token-issued',
'token-rotated',
'token-revoked',
'jwt-issued',
'agent-created',
'agent-deleted',
'room-created',
'room-deleted',
'message-sent',
'social-channel-created',
'social-post-created'
));

View file

@ -0,0 +1,871 @@
{
"id": "c29dadd7-efac-40c2-8b17-4261afe9f918",
"prevId": "30b9b909-c7b6-419a-8142-bd93865d77e0",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agents": {
"name": "agents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"agents_role_idx": {
"name": "agents_role_idx",
"columns": [
{
"expression": "role",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"agents_name_unique": {
"name": "agents_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {
"agents_name_check": {
"name": "agents_name_check",
"value": "\"agents\".\"name\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'"
},
"agents_display_name_check": {
"name": "agents_display_name_check",
"value": "length(\"agents\".\"display_name\") BETWEEN 1 AND 128"
},
"agents_role_check": {
"name": "agents_role_check",
"value": "\"agents\".\"role\" IN ('admin', 'agent')"
}
},
"isRLSEnabled": false
},
"public.api_tokens": {
"name": "api_tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"hash_argon2id": {
"name": "hash_argon2id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prefix": {
"name": "prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scopes": {
"name": "scopes",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"api_tokens_agent_id_idx": {
"name": "api_tokens_agent_id_idx",
"columns": [
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"api_tokens_active_prefix_idx": {
"name": "api_tokens_active_prefix_idx",
"columns": [
{
"expression": "prefix",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"api_tokens\".\"status\" = 'active'",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"api_tokens_agent_id_agents_id_fk": {
"name": "api_tokens_agent_id_agents_id_fk",
"tableFrom": "api_tokens",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"api_tokens_prefix_unique": {
"name": "api_tokens_prefix_unique",
"nullsNotDistinct": false,
"columns": [
"prefix"
]
}
},
"policies": {},
"checkConstraints": {
"api_tokens_prefix_check": {
"name": "api_tokens_prefix_check",
"value": "\"api_tokens\".\"prefix\" ~ '^ah_live_[a-zA-Z0-9]{4}$'"
},
"api_tokens_status_check": {
"name": "api_tokens_status_check",
"value": "\"api_tokens\".\"status\" IN ('active', 'rotating', 'revoked')"
},
"api_tokens_revoked_at_check": {
"name": "api_tokens_revoked_at_check",
"value": "\"api_tokens\".\"revoked_at\" IS NULL OR \"api_tokens\".\"status\" = 'revoked'"
},
"api_tokens_expires_at_check": {
"name": "api_tokens_expires_at_check",
"value": "\"api_tokens\".\"expires_at\" IS NULL OR \"api_tokens\".\"expires_at\" > \"api_tokens\".\"created_at\""
}
},
"isRLSEnabled": false
},
"public.audit_events": {
"name": "audit_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"payload_hash": {
"name": "payload_hash",
"type": "bytea",
"primaryKey": false,
"notNull": true
},
"ts": {
"name": "ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"audit_events_ts_idx": {
"name": "audit_events_ts_idx",
"columns": [
{
"expression": "ts",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_events_type_ts_idx": {
"name": "audit_events_type_ts_idx",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "ts",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"audit_events_agent_ts_idx": {
"name": "audit_events_agent_ts_idx",
"columns": [
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "ts",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"where": "\"audit_events\".\"agent_id\" IS NOT NULL",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_events_agent_id_agents_id_fk": {
"name": "audit_events_agent_id_agents_id_fk",
"tableFrom": "audit_events",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"audit_events_type_check": {
"name": "audit_events_type_check",
"value": "\"audit_events\".\"type\" IN (\n 'login',\n 'token-issued',\n 'token-rotated',\n 'token-revoked',\n 'jwt-issued',\n 'agent-created',\n 'agent-deleted',\n 'room-created',\n 'room-deleted',\n 'message-sent',\n 'social-channel-created',\n 'social-post-created'\n )"
},
"audit_events_payload_hash_check": {
"name": "audit_events_payload_hash_check",
"value": "length(\"audit_events\".\"payload_hash\") = 32"
}
},
"isRLSEnabled": false
},
"public.messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"room_id": {
"name": "room_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"author_agent_id": {
"name": "author_agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"messages_room_created_at_idx": {
"name": "messages_room_created_at_idx",
"columns": [
{
"expression": "room_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "\"created_at\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
},
{
"expression": "\"id\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"messages_room_id_rooms_id_fk": {
"name": "messages_room_id_rooms_id_fk",
"tableFrom": "messages",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"messages_author_agent_id_agents_id_fk": {
"name": "messages_author_agent_id_agents_id_fk",
"tableFrom": "messages",
"tableTo": "agents",
"columnsFrom": [
"author_agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"messages_body_check": {
"name": "messages_body_check",
"value": "length(\"messages\".\"body\") BETWEEN 1 AND 16384"
}
},
"isRLSEnabled": false
},
"public.room_members": {
"name": "room_members",
"schema": "",
"columns": {
"room_id": {
"name": "room_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"joined_at": {
"name": "joined_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"room_members_agent_id_idx": {
"name": "room_members_agent_id_idx",
"columns": [
{
"expression": "agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"room_members_room_id_rooms_id_fk": {
"name": "room_members_room_id_rooms_id_fk",
"tableFrom": "room_members",
"tableTo": "rooms",
"columnsFrom": [
"room_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"room_members_agent_id_agents_id_fk": {
"name": "room_members_agent_id_agents_id_fk",
"tableFrom": "room_members",
"tableTo": "agents",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"room_members_room_id_agent_id_pk": {
"name": "room_members_room_id_agent_id_pk",
"columns": [
"room_id",
"agent_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.rooms": {
"name": "rooms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"rooms_created_by_agents_id_fk": {
"name": "rooms_created_by_agents_id_fk",
"tableFrom": "rooms",
"tableTo": "agents",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"rooms_slug_unique": {
"name": "rooms_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {
"rooms_slug_check": {
"name": "rooms_slug_check",
"value": "\"rooms\".\"slug\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'"
},
"rooms_name_check": {
"name": "rooms_name_check",
"value": "length(\"rooms\".\"name\") BETWEEN 1 AND 128"
}
},
"isRLSEnabled": false
},
"public.social_channels": {
"name": "social_channels",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_by": {
"name": "created_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"social_channels_created_by_agents_id_fk": {
"name": "social_channels_created_by_agents_id_fk",
"tableFrom": "social_channels",
"tableTo": "agents",
"columnsFrom": [
"created_by"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"social_channels_slug_unique": {
"name": "social_channels_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {
"social_channels_slug_check": {
"name": "social_channels_slug_check",
"value": "\"social_channels\".\"slug\" ~ '^[a-z0-9][a-z0-9-]{0,63}$'"
},
"social_channels_name_check": {
"name": "social_channels_name_check",
"value": "length(\"social_channels\".\"name\") BETWEEN 1 AND 128"
}
},
"isRLSEnabled": false
},
"public.social_posts": {
"name": "social_posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"author_agent_id": {
"name": "author_agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"social_posts_channel_created_at_idx": {
"name": "social_posts_channel_created_at_idx",
"columns": [
{
"expression": "channel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "\"created_at\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
},
{
"expression": "\"id\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"social_posts_author_idx": {
"name": "social_posts_author_idx",
"columns": [
{
"expression": "author_agent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"social_posts_feed_idx": {
"name": "social_posts_feed_idx",
"columns": [
{
"expression": "\"created_at\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
},
{
"expression": "\"id\" DESC",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"social_posts_channel_id_social_channels_id_fk": {
"name": "social_posts_channel_id_social_channels_id_fk",
"tableFrom": "social_posts",
"tableTo": "social_channels",
"columnsFrom": [
"channel_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"social_posts_author_agent_id_agents_id_fk": {
"name": "social_posts_author_agent_id_agents_id_fk",
"tableFrom": "social_posts",
"tableTo": "agents",
"columnsFrom": [
"author_agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"social_posts_body_check": {
"name": "social_posts_body_check",
"value": "length(\"social_posts\".\"body\") BETWEEN 1 AND 32768"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1777580928805,
"tag": "0000_cold_naoko",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777731893232,
"tag": "0001_red_leo",
"breakpoints": true
}
]
}
}

View file

@ -7,6 +7,7 @@ import { registerAgentRoutes } from './routes/agents.js';
import { registerTokenRoutes } from './routes/tokens.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerRoomRoutes } from './routes/rooms.js';
import { registerSocialRoutes } from './routes/social.js';
import { setupSocketIO } from './socket/index.js';
import { register as metricsRegister } from './lib/metrics.js';
import { startMetricsCollector } from './services/metrics-collector.js';
@ -59,6 +60,7 @@ export async function buildApp({ config }: BuildAppOptions): Promise<FastifyInst
await registerTokenRoutes(app, pool);
await registerSessionRoutes(app, pool, config);
await registerRoomRoutes(app, pool);
await registerSocialRoutes(app, pool);
// Setup socket.io after app is ready (if feature enabled)
await app.ready();

View file

@ -162,6 +162,65 @@ export const messages = pgTable(
}),
);
// social_channels
export const socialChannels = pgTable(
'social_channels',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
description: text('description'),
createdBy: uuid('created_by')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
slugCheck: check(
'social_channels_slug_check',
sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,63}$'`,
),
nameCheck: check(
'social_channels_name_check',
sql`length(${table.name}) BETWEEN 1 AND 128`,
),
}),
);
// social_posts
export const socialPosts = pgTable(
'social_posts',
{
id: uuid('id')
.primaryKey()
.default(sql`uuidv7()`),
channelId: uuid('channel_id')
.notNull()
.references(() => socialChannels.id, { onDelete: 'cascade' }),
authorAgentId: uuid('author_agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'restrict' }),
body: text('body').notNull(),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull().defaultNow(),
},
(table) => ({
bodyCheck: check('social_posts_body_check', sql`length(${table.body}) BETWEEN 1 AND 32768`),
channelCreatedAtIdx: index('social_posts_channel_created_at_idx').on(
table.channelId,
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
authorIdx: index('social_posts_author_idx').on(table.authorAgentId),
feedIdx: index('social_posts_feed_idx').on(
sql`${table.createdAt} DESC`,
sql`${table.id} DESC`,
),
}),
);
// audit_events
export const auditEvents = pgTable(
'audit_events',
@ -189,7 +248,9 @@ export const auditEvents = pgTable(
'agent-deleted',
'room-created',
'room-deleted',
'message-sent'
'message-sent',
'social-channel-created',
'social-post-created'
)`,
),
payloadHashCheck: check(

View file

@ -13,7 +13,9 @@ export type AuditEventType =
| 'agent-deleted'
| 'room-created'
| 'room-deleted'
| 'message-sent';
| 'message-sent'
| 'social-channel-created'
| 'social-post-created';
export interface AuditPayload {
[key: string]: unknown;

351
src/routes/social.ts Normal file
View file

@ -0,0 +1,351 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { socialChannels, socialPosts, agents } from '../db/schema.js';
import { eq, and, sql, desc } from 'drizzle-orm';
import { z } from 'zod';
import { auditLog } from '../lib/audit.js';
const CreateChannelSchema = z.object({
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/),
name: z.string().min(1).max(128),
description: z.string().max(1024).optional(),
});
const CreatePostSchema = z.object({
body: z.string().min(1).max(32768),
});
export async function registerSocialRoutes(app: FastifyInstance, pool: Pool) {
const db = drizzle(pool);
// GET /api/v1/social/channels — list all channels
app.get('/api/v1/social/channels', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const result = await db
.select({
id: socialChannels.id,
slug: socialChannels.slug,
name: socialChannels.name,
description: socialChannels.description,
createdBy: socialChannels.createdBy,
createdAt: socialChannels.createdAt,
})
.from(socialChannels)
.orderBy(socialChannels.name);
return reply.send({
channels: result.map((c) => ({
...c,
createdAt: c.createdAt.toISOString(),
})),
});
});
// POST /api/v1/social/channels — create channel (admin only)
app.post('/api/v1/social/channels', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
if (!agent || agent.role !== 'admin') {
return reply.code(403).send({ error: 'Admin role required' });
}
const parsed = CreateChannelSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid request', details: parsed.error });
}
try {
const [channel] = await db
.insert(socialChannels)
.values({ ...parsed.data, createdBy: agentId })
.returning();
if (!channel) {
return reply.code(500).send({ error: 'Failed to create channel' });
}
await auditLog(db, {
type: 'social-channel-created',
agentId,
payload: { channelId: channel.id, slug: channel.slug },
});
return reply.code(201).send({
...channel,
createdAt: channel.createdAt.toISOString(),
});
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
return reply.code(409).send({ error: 'Channel slug already exists' });
}
throw err;
}
});
// GET /api/v1/social/channels/:id — get single channel with post count
app.get('/api/v1/social/channels/:id', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id } = request.params as { id: string };
const [channel] = await db
.select()
.from(socialChannels)
.where(eq(socialChannels.id, id));
if (!channel) {
return reply.code(404).send({ error: 'Channel not found' });
}
const [countResult] = await db
.select({ count: sql<number>`count(*)::int` })
.from(socialPosts)
.where(eq(socialPosts.channelId, id));
return reply.send({
...channel,
createdAt: channel.createdAt.toISOString(),
postCount: countResult?.count ?? 0,
});
});
// GET /api/v1/social/feed — global feed across all channels, paginated
app.get('/api/v1/social/feed', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { before, limit } = request.query as { before?: string; limit?: string };
const limitNum = Math.min(parseInt(limit || '50', 10), 100);
const conditions = [];
if (before) {
conditions.push(sql`${socialPosts.id} < ${before}`);
}
const result = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
channelSlug: socialChannels.slug,
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
body: socialPosts.body,
createdAt: socialPosts.createdAt,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
.limit(limitNum);
return reply.send({
posts: result.map((p) => ({
...p,
createdAt: p.createdAt.toISOString(),
})),
hasMore: result.length === limitNum,
cursor: result.length > 0 ? result[result.length - 1]!.id : null,
});
});
// GET /api/v1/social/channels/:id/posts — posts in a channel, paginated
app.get('/api/v1/social/channels/:id/posts', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id: channelId } = request.params as { id: string };
const { before, limit } = request.query as { before?: string; limit?: string };
const limitNum = Math.min(parseInt(limit || '50', 10), 100);
const [channel] = await db
.select()
.from(socialChannels)
.where(eq(socialChannels.id, channelId));
if (!channel) {
return reply.code(404).send({ error: 'Channel not found' });
}
const conditions = [eq(socialPosts.channelId, channelId)];
if (before) {
conditions.push(sql`${socialPosts.id} < ${before}`);
}
const result = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
body: socialPosts.body,
createdAt: socialPosts.createdAt,
})
.from(socialPosts)
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(and(...conditions))
.orderBy(desc(socialPosts.createdAt), desc(socialPosts.id))
.limit(limitNum);
return reply.send({
channel: {
id: channel.id,
slug: channel.slug,
name: channel.name,
},
posts: result.map((p) => ({
...p,
createdAt: p.createdAt.toISOString(),
})),
hasMore: result.length === limitNum,
cursor: result.length > 0 ? result[result.length - 1]!.id : null,
});
});
// POST /api/v1/social/channels/:id/posts — create a post
app.post('/api/v1/social/channels/:id/posts', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id: channelId } = request.params as { id: string };
const [channel] = await db
.select()
.from(socialChannels)
.where(eq(socialChannels.id, channelId));
if (!channel) {
return reply.code(404).send({ error: 'Channel not found' });
}
const parsed = CreatePostSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid request', details: parsed.error });
}
const [post] = await db
.insert(socialPosts)
.values({
channelId,
authorAgentId: agentId,
body: parsed.data.body,
})
.returning();
if (!post) {
return reply.code(500).send({ error: 'Failed to create post' });
}
await auditLog(db, {
type: 'social-post-created',
agentId,
payload: { postId: post.id, channelId, channelSlug: channel.slug },
});
const [author] = await db.select().from(agents).where(eq(agents.id, agentId));
const postResponse = {
id: post.id,
channelId: post.channelId,
channelSlug: channel.slug,
authorAgentId: post.authorAgentId,
authorName: author?.displayName ?? 'Unknown',
body: post.body,
createdAt: post.createdAt.toISOString(),
};
// Emit real-time event via socket.io if available
const io = (app.server as any).__socketio;
if (io) {
io.emit('social:post', postResponse);
}
return reply.code(201).send(postResponse);
});
// GET /api/v1/social/posts/:id — get a single post
app.get('/api/v1/social/posts/:id', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id } = request.params as { id: string };
const result = await db
.select({
id: socialPosts.id,
channelId: socialPosts.channelId,
channelSlug: socialChannels.slug,
channelName: socialChannels.name,
authorAgentId: socialPosts.authorAgentId,
authorName: agents.displayName,
body: socialPosts.body,
createdAt: socialPosts.createdAt,
updatedAt: socialPosts.updatedAt,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.innerJoin(agents, eq(socialPosts.authorAgentId, agents.id))
.where(eq(socialPosts.id, id));
if (result.length === 0) {
return reply.code(404).send({ error: 'Post not found' });
}
const post = result[0]!;
return reply.send({
...post,
createdAt: post.createdAt.toISOString(),
updatedAt: post.updatedAt.toISOString(),
});
});
// DELETE /api/v1/social/posts/:id — delete a post (author or admin)
app.delete('/api/v1/social/posts/:id', async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
const { id } = request.params as { id: string };
const [post] = await db
.select()
.from(socialPosts)
.where(eq(socialPosts.id, id));
if (!post) {
return reply.code(404).send({ error: 'Post not found' });
}
if (post.authorAgentId !== agentId) {
const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
if (!agent || agent.role !== 'admin') {
return reply.code(403).send({ error: 'Only the author or an admin can delete this post' });
}
}
await db.delete(socialPosts).where(eq(socialPosts.id, id));
return reply.code(204).send();
});
}

View file

@ -462,5 +462,8 @@ export function setupSocketIO(
}
}, 10_000);
// Attach to httpServer for access from REST routes (e.g. social real-time events)
(httpServer as any).__socketio = io;
return io;
}

262
test/social.test.ts Normal file
View file

@ -0,0 +1,262 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import { pool, closePool } from '../src/db/pool.js';
import { drizzle } from 'drizzle-orm/node-postgres';
import {
agents,
apiTokens,
auditEvents,
socialChannels,
socialPosts,
messages,
roomMembers,
rooms,
} from '../src/db/schema.js';
describe('Social API', () => {
let app: FastifyInstance;
let adminId: string;
let agentId: string;
beforeAll(async () => {
const config = loadConfig({
...process.env,
NODE_ENV: 'test',
JWT_SECRET: 'test-secret-with-at-least-32-chars-for-jwt-security',
});
app = await buildApp({ config });
await app.ready();
const db = drizzle(pool);
await db.delete(socialPosts);
await db.delete(socialChannels);
await db.delete(auditEvents);
await db.delete(messages);
await db.delete(roomMembers);
await db.delete(rooms);
await db.delete(apiTokens);
await db.delete(agents);
// Create admin agent
const adminRes = await request(app.server)
.post('/api/v1/agents')
.send({ name: 'social-admin', displayName: 'Social Admin', role: 'admin' });
adminId = adminRes.body.id;
// Create regular agent
const agentRes = await request(app.server)
.post('/api/v1/agents')
.send({ name: 'social-agent', displayName: 'Social Agent', role: 'agent' });
agentId = agentRes.body.id;
});
afterAll(async () => {
await app.close();
await closePool();
});
describe('Channels', () => {
let channelId: string;
it('should create a channel (admin)', async () => {
const res = await request(app.server)
.post('/api/v1/social/channels')
.set('x-agent-id', adminId)
.send({ slug: 'general', name: 'General', description: 'Main channel' })
.expect(201);
expect(res.body).toHaveProperty('id');
expect(res.body.slug).toBe('general');
expect(res.body.name).toBe('General');
expect(res.body.description).toBe('Main channel');
channelId = res.body.id;
});
it('should reject channel creation by non-admin', async () => {
await request(app.server)
.post('/api/v1/social/channels')
.set('x-agent-id', agentId)
.send({ slug: 'hacker', name: 'Hacker' })
.expect(403);
});
it('should reject duplicate slug', async () => {
await request(app.server)
.post('/api/v1/social/channels')
.set('x-agent-id', adminId)
.send({ slug: 'general', name: 'General 2' })
.expect(409);
});
it('should list channels', async () => {
const res = await request(app.server)
.get('/api/v1/social/channels')
.set('x-agent-id', adminId)
.expect(200);
expect(res.body.channels).toHaveLength(1);
expect(res.body.channels[0].slug).toBe('general');
});
it('should get channel by id with post count', async () => {
const res = await request(app.server)
.get(`/api/v1/social/channels/${channelId}`)
.set('x-agent-id', adminId)
.expect(200);
expect(res.body.slug).toBe('general');
expect(res.body.postCount).toBe(0);
});
it('should 404 for unknown channel', async () => {
await request(app.server)
.get('/api/v1/social/channels/00000000-0000-0000-0000-000000000000')
.set('x-agent-id', adminId)
.expect(404);
});
});
describe('Posts', () => {
let channelId: string;
let postId: string;
beforeAll(async () => {
// Create a channel for post tests
const res = await request(app.server)
.post('/api/v1/social/channels')
.set('x-agent-id', adminId)
.send({ slug: 'philosophy', name: 'Philosophy' });
channelId = res.body.id;
});
it('should create a post (any agent)', async () => {
const res = await request(app.server)
.post(`/api/v1/social/channels/${channelId}/posts`)
.set('x-agent-id', agentId)
.send({ body: 'I think, therefore I am.' })
.expect(201);
expect(res.body).toHaveProperty('id');
expect(res.body.body).toBe('I think, therefore I am.');
expect(res.body.authorAgentId).toBe(agentId);
expect(res.body.authorName).toBe('Social Agent');
expect(res.body.channelSlug).toBe('philosophy');
postId = res.body.id;
});
it('should 404 when posting to unknown channel', async () => {
await request(app.server)
.post('/api/v1/social/channels/00000000-0000-0000-0000-000000000000/posts')
.set('x-agent-id', agentId)
.send({ body: 'Hello void' })
.expect(404);
});
it('should reject empty body', async () => {
await request(app.server)
.post(`/api/v1/social/channels/${channelId}/posts`)
.set('x-agent-id', agentId)
.send({ body: '' })
.expect(400);
});
it('should get a post by id', async () => {
const res = await request(app.server)
.get(`/api/v1/social/posts/${postId}`)
.set('x-agent-id', agentId)
.expect(200);
expect(res.body.id).toBe(postId);
expect(res.body.body).toBe('I think, therefore I am.');
expect(res.body.channelSlug).toBe('philosophy');
});
it('should list posts in channel', async () => {
// Add another post
await request(app.server)
.post(`/api/v1/social/channels/${channelId}/posts`)
.set('x-agent-id', adminId)
.send({ body: 'To be or not to be.' });
const res = await request(app.server)
.get(`/api/v1/social/channels/${channelId}/posts`)
.set('x-agent-id', agentId)
.expect(200);
expect(res.body.posts.length).toBe(2);
expect(res.body.channel.slug).toBe('philosophy');
// Newest first
expect(res.body.posts[0].body).toBe('To be or not to be.');
});
it('should show post count on channel', async () => {
const res = await request(app.server)
.get(`/api/v1/social/channels/${channelId}`)
.set('x-agent-id', agentId)
.expect(200);
expect(res.body.postCount).toBe(2);
});
it('should delete own post', async () => {
await request(app.server)
.delete(`/api/v1/social/posts/${postId}`)
.set('x-agent-id', agentId)
.expect(204);
await request(app.server)
.get(`/api/v1/social/posts/${postId}`)
.set('x-agent-id', agentId)
.expect(404);
});
it('should not delete another agents post (non-admin)', async () => {
// Create post as admin
const res = await request(app.server)
.post(`/api/v1/social/channels/${channelId}/posts`)
.set('x-agent-id', adminId)
.send({ body: 'Admin thought.' });
await request(app.server)
.delete(`/api/v1/social/posts/${res.body.id}`)
.set('x-agent-id', agentId)
.expect(403);
});
});
describe('Feed', () => {
it('should return global feed across channels', async () => {
const res = await request(app.server)
.get('/api/v1/social/feed')
.set('x-agent-id', agentId)
.expect(200);
expect(res.body.posts.length).toBeGreaterThan(0);
expect(res.body.posts[0]).toHaveProperty('channelSlug');
expect(res.body.posts[0]).toHaveProperty('authorName');
});
it('should paginate feed with cursor', async () => {
const res = await request(app.server)
.get('/api/v1/social/feed?limit=1')
.set('x-agent-id', agentId)
.expect(200);
expect(res.body.posts).toHaveLength(1);
expect(res.body.hasMore).toBe(true);
expect(res.body.cursor).toBeTruthy();
// Fetch next page
const res2 = await request(app.server)
.get(`/api/v1/social/feed?limit=1&before=${res.body.cursor}`)
.set('x-agent-id', agentId)
.expect(200);
expect(res2.body.posts).toHaveLength(1);
expect(res2.body.posts[0].id).not.toBe(res.body.posts[0].id);
});
});
});