feat(broker+cli): topics — conversation scope within a mesh (v0.2.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds the third axis of mesh organization: mesh = trust boundary,
group = identity tag, topic = conversation scope. Topic-tagged
messages filter delivery by topic_member rows and persist to a
topic_message history table for back-scroll on reconnect.

Schema (additive):
- mesh.topic, mesh.topic_member, mesh.topic_message tables
- topic_visibility (public|private|dm) and topic_member_role
  (lead|member|observer) enums
- migration 0022_topics.sql, hand-written following project convention
  (drizzle journal has been drifting since 0011)

Broker:
- 10 helpers (createTopic, listTopics, findTopicByName, joinTopic,
  leaveTopic, topicMembers, getMemberTopicIds, appendTopicMessage,
  topicHistory, markTopicRead)
- drainForMember matches "#<topicId>" target_specs via member's
  topic memberships
- 7 WS handlers (topic_create/list/join/leave/members/history/mark_read)
  + resolveTopicId helper accepting id-or-name
- handleSend auto-persists topic-tagged messages to history

CLI:
- claudemesh topic create/list/join/leave/members/history/read
- claudemesh send "#deploys" "..." resolves topic name to id
- bundled skill teaches Claude the DM/group/topic decision matrix
- policy-classify recognizes topic create/join/leave as writes

Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 01:53:42 +01:00
parent b4f457fceb
commit 1afae7a507
12 changed files with 1741 additions and 196 deletions

View File

@@ -0,0 +1,66 @@
-- Topics — conversational primitive within a mesh (v0.2.0).
--
-- Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
--
-- Mesh = trust boundary. Group = identity tag. Topic = conversation scope.
-- Three orthogonal axes; topics complement (don't replace) groups.
--
-- Three new tables in the `mesh` pg-schema:
-- * mesh.topic — named topic per mesh (unique on mesh_id, name)
-- * mesh.topic_member — per-member subscriptions, with last_read_at
-- * mesh.topic_message — persistent encrypted history (used for human-
-- touched topics; agent-only topics may opt out)
--
-- Two new pg enums:
-- * mesh.topic_visibility = public | private | dm
-- * mesh.topic_member_role = lead | member | observer
--
-- Additive — no breaking changes to existing tables. Safe to deploy before
-- CLI/broker code knows about topics; the routing layer falls back to the
-- existing peer/group/* targeting until topic-tagged messages arrive.
CREATE TYPE "mesh"."topic_visibility" AS ENUM ('public', 'private', 'dm');
CREATE TYPE "mesh"."topic_member_role" AS ENUM ('lead', 'member', 'observer');
CREATE TABLE IF NOT EXISTS "mesh"."topic" (
"id" text PRIMARY KEY NOT NULL,
"mesh_id" text NOT NULL REFERENCES "mesh"."mesh"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"name" text NOT NULL,
"description" text,
"visibility" "mesh"."topic_visibility" NOT NULL DEFAULT 'public',
"created_by_member_id" text REFERENCES "mesh"."member"("id") ON DELETE SET NULL ON UPDATE CASCADE,
"created_at" timestamp DEFAULT now() NOT NULL,
"archived_at" timestamp
);
CREATE UNIQUE INDEX IF NOT EXISTS "topic_mesh_name_unique"
ON "mesh"."topic" ("mesh_id", "name");
CREATE TABLE IF NOT EXISTS "mesh"."topic_member" (
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"role" "mesh"."topic_member_role" NOT NULL DEFAULT 'member',
"joined_at" timestamp DEFAULT now() NOT NULL,
"last_read_at" timestamp
);
CREATE UNIQUE INDEX IF NOT EXISTS "topic_member_unique"
ON "mesh"."topic_member" ("topic_id", "member_id");
CREATE INDEX IF NOT EXISTS "topic_member_by_member"
ON "mesh"."topic_member" ("member_id");
CREATE TABLE IF NOT EXISTS "mesh"."topic_message" (
"id" text PRIMARY KEY NOT NULL,
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"sender_member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"sender_session_pubkey" text,
"nonce" text NOT NULL,
"ciphertext" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
-- Composite index for the common access pattern: load topic history
-- ordered by time. Drives the web chat panel's infinite-scroll fetch.
CREATE INDEX IF NOT EXISTS "topic_message_by_topic_time"
ON "mesh"."topic_message" ("topic_id", "created_at");

View File

@@ -58,11 +58,10 @@ export const presenceStatusEnum = meshSchema.enum("presence_status", [
"dnd",
]);
export const presenceStatusSourceEnum = meshSchema.enum("presence_status_source", [
"hook",
"manual",
"jsonl",
]);
export const presenceStatusSourceEnum = meshSchema.enum(
"presence_status_source",
["hook", "manual", "jsonl"],
);
export const messagePriorityEnum = meshSchema.enum("message_priority", [
"now",
@@ -120,12 +119,19 @@ export const mesh = meshSchema.table("mesh", {
* Per-mesh policy controlling which profile fields members can edit
* about themselves. Admins can always edit anyone's profile regardless.
*/
selfEditable: jsonb().$type<{
displayName: boolean;
roleTag: boolean;
groups: boolean;
messageMode: boolean;
}>().default({ displayName: true, roleTag: true, groups: true, messageMode: true }),
selfEditable: jsonb()
.$type<{
displayName: boolean;
roleTag: boolean;
groups: boolean;
messageMode: boolean;
}>()
.default({
displayName: true,
roleTag: true,
groups: true,
messageMode: true,
}),
createdAt: timestamp().defaultNow().notNull(),
archivedAt: timestamp(),
});
@@ -141,43 +147,46 @@ export const mesh = meshSchema.table("mesh", {
* one of the two on collision. Unique TS name + short DB name is the
* cleanest trade-off.
*/
export const meshMember = meshSchema.table("member", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
userId: text().references(() => user.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
peerPubkey: text().notNull(),
displayName: text().notNull(),
role: meshRoleEnum().notNull().default("member"),
/** Free-text role label visible to peers (not to be confused with `role` which is the permission enum). */
roleTag: text(),
/** Persistent group memberships set via dashboard or CLI profile command. */
defaultGroups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
/** Delivery preference: push (real-time), inbox (held), off (manual poll). */
messageMode: text().default("push"),
/** Links this mesh member to a dashboard OAuth user (Payload CMS user.id). */
dashboardUserId: text(),
joinedAt: timestamp().defaultNow().notNull(),
lastSeenAt: timestamp(),
revokedAt: timestamp(),
/**
* Per-peer capability grants — which peer pubkeys can send this member
* which kinds of messages. Empty object = use defaults (read + dm +
* broadcast + state-read). Empty array for a specific pubkey = blocked.
* See .artifacts/specs/2026-04-15-per-peer-capabilities.md.
*/
peerGrants: jsonb()
.$type<Record<string, string[]>>()
.notNull()
.default({}),
}, (table) => [
index("member_dashboard_user_idx").on(table.dashboardUserId),
index("member_peer_grants_gin_idx").using("gin", table.peerGrants),
]);
export const meshMember = meshSchema.table(
"member",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
userId: text().references(() => user.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
peerPubkey: text().notNull(),
displayName: text().notNull(),
role: meshRoleEnum().notNull().default("member"),
/** Free-text role label visible to peers (not to be confused with `role` which is the permission enum). */
roleTag: text(),
/** Persistent group memberships set via dashboard or CLI profile command. */
defaultGroups: jsonb()
.$type<{ name: string; role?: string }[]>()
.default([]),
/** Delivery preference: push (real-time), inbox (held), off (manual poll). */
messageMode: text().default("push"),
/** Links this mesh member to a dashboard OAuth user (Payload CMS user.id). */
dashboardUserId: text(),
joinedAt: timestamp().defaultNow().notNull(),
lastSeenAt: timestamp(),
revokedAt: timestamp(),
/**
* Per-peer capability grants — which peer pubkeys can send this member
* which kinds of messages. Empty object = use defaults (read + dm +
* broadcast + state-read). Empty array for a specific pubkey = blocked.
* See .artifacts/specs/2026-04-15-per-peer-capabilities.md.
*/
peerGrants: jsonb().$type<Record<string, string[]>>().notNull().default({}),
},
(table) => [
index("member_dashboard_user_idx").on(table.dashboardUserId),
index("member_peer_grants_gin_idx").using("gin", table.peerGrants),
],
);
/**
* Invite tokens used to join a mesh via shareable URL.
@@ -206,12 +215,14 @@ export const invite = meshSchema.table("invite", {
usedCount: integer().notNull().default(0),
role: meshRoleEnum().notNull().default("member"),
/** Pre-configured profile values applied to new members on join. */
preset: jsonb().$type<{
displayName?: string;
roleTag?: string;
groups?: Array<{ name: string; role?: string }>;
messageMode?: string;
}>().default({}),
preset: jsonb()
.$type<{
displayName?: string;
roleTag?: string;
groups?: { name: string; role?: string }[];
messageMode?: string;
}>()
.default({}),
expiresAt: timestamp().notNull(),
createdBy: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
@@ -239,25 +250,29 @@ export const invite = meshSchema.table("invite", {
* `code` references an underlying mesh.invite row that will be minted
* on send; when the recipient lands on /i/{code} they claim the real invite.
*/
export const pendingInvite = meshSchema.table("pending_invite", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
email: text().notNull(),
/** The short code of the underlying `mesh.invite.code` row this email links to. */
code: text().notNull(),
sentAt: timestamp().defaultNow().notNull(),
acceptedAt: timestamp(),
revokedAt: timestamp(),
createdBy: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
}, (table) => [
index("pending_invite_email_idx").on(table.email),
index("pending_invite_mesh_idx").on(table.meshId),
]);
export const pendingInvite = meshSchema.table(
"pending_invite",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
email: text().notNull(),
/** The short code of the underlying `mesh.invite.code` row this email links to. */
code: text().notNull(),
sentAt: timestamp().defaultNow().notNull(),
acceptedAt: timestamp(),
revokedAt: timestamp(),
createdBy: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
},
(table) => [
index("pending_invite_email_idx").on(table.email),
index("pending_invite_mesh_idx").on(table.meshId),
],
);
/**
* Signed, hash-chained audit log. NEVER stores message content — every
@@ -294,7 +309,10 @@ export const auditLog = meshSchema.table("audit_log", {
export const presence = meshSchema.table("presence", {
id: text().primaryKey().notNull().$defaultFn(generateId),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
sessionId: text().notNull(),
sessionPubkey: text(),
@@ -305,7 +323,7 @@ export const presence = meshSchema.table("presence", {
statusSource: presenceStatusSourceEnum().notNull().default("jsonl"),
statusUpdatedAt: timestamp().defaultNow().notNull(),
summary: text(),
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
groups: jsonb().$type<{ name: string; role?: string }[]>().default([]),
connectedAt: timestamp().defaultNow().notNull(),
lastPingAt: timestamp().defaultNow().notNull(),
disconnectedAt: timestamp(),
@@ -326,7 +344,10 @@ export const messageQueue = meshSchema.table("message_queue", {
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
senderMemberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
senderSessionPubkey: text(),
targetSpec: text().notNull(),
@@ -474,7 +495,10 @@ export const meshContext = meshSchema.table(
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
memberId: text().references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" }),
memberId: text().references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
}),
presenceId: text().references(() => presence.id, { onDelete: "cascade" }),
peerName: text(),
summary: text().notNull(),
@@ -580,11 +604,16 @@ export const meshWebhook = meshSchema.table(
secret: text().notNull(),
active: boolean().notNull().default(true),
createdBy: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
},
(table) => [uniqueIndex("webhook_mesh_name_idx").on(table.meshId, table.name)],
(table) => [
uniqueIndex("webhook_mesh_name_idx").on(table.meshId, table.name),
],
);
export const meshService = meshSchema.table(
@@ -618,7 +647,9 @@ export const meshService = meshSchema.table(
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [uniqueIndex("service_mesh_name_idx").on(table.meshId, table.name)],
(table) => [
uniqueIndex("service_mesh_name_idx").on(table.meshId, table.name),
],
);
export const meshVaultEntry = meshSchema.table(
@@ -641,7 +672,13 @@ export const meshVaultEntry = meshSchema.table(
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [uniqueIndex("vault_entry_mesh_member_key_idx").on(table.meshId, table.memberId, table.key)],
(table) => [
uniqueIndex("vault_entry_mesh_member_key_idx").on(
table.meshId,
table.memberId,
table.key,
),
],
);
export const meshWebhookRelations = relations(meshWebhook, ({ one }) => ({
@@ -668,7 +705,10 @@ export const scheduledMessage = meshSchema.table("scheduled_message", {
/** Nullable — the presence that created it may be gone after a restart. */
presenceId: text(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
to: text().notNull(),
message: text().notNull(),
@@ -683,19 +723,24 @@ export const scheduledMessage = meshSchema.table("scheduled_message", {
createdAt: timestamp().defaultNow().notNull(),
});
export const scheduledMessageRelations = relations(scheduledMessage, ({ one }) => ({
mesh: one(mesh, {
fields: [scheduledMessage.meshId],
references: [mesh.id],
export const scheduledMessageRelations = relations(
scheduledMessage,
({ one }) => ({
mesh: one(mesh, {
fields: [scheduledMessage.meshId],
references: [mesh.id],
}),
member: one(meshMember, {
fields: [scheduledMessage.memberId],
references: [meshMember.id],
}),
}),
member: one(meshMember, {
fields: [scheduledMessage.memberId],
references: [meshMember.id],
}),
}));
);
export const selectScheduledMessageSchema = createSelectSchema(scheduledMessage);
export const insertScheduledMessageSchema = createInsertSchema(scheduledMessage);
export const selectScheduledMessageSchema =
createSelectSchema(scheduledMessage);
export const insertScheduledMessageSchema =
createInsertSchema(scheduledMessage);
export type SelectScheduledMessage = typeof scheduledMessage.$inferSelect;
export type InsertScheduledMessage = typeof scheduledMessage.$inferInsert;
@@ -736,40 +781,44 @@ export const meshMemberRelations = relations(meshMember, ({ one, many }) => ({
*
* Explicit rows override these defaults (allow or deny).
*/
export const meshPermission = meshSchema.table("permission", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade" })
.notNull(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade" })
.notNull(),
/** Invite other users to the mesh. */
canInvite: boolean().notNull().default(false),
/** Deploy/undeploy MCP services. */
canDeployMcp: boolean().notNull().default(false),
/** Upload/delete shared files. */
canManageFiles: boolean().notNull().default(false),
/** Read/write vault secrets. */
canManageVault: boolean().notNull().default(false),
/** Create/manage URL watches. */
canManageWatches: boolean().notNull().default(false),
/** Create/manage webhooks. */
canManageWebhooks: boolean().notNull().default(false),
/** Write shared state (read is always allowed). */
canWriteState: boolean().notNull().default(true),
/** Send messages to peers. */
canSend: boolean().notNull().default(true),
/** Use deployed MCP tools. */
canUseTools: boolean().notNull().default(true),
/** Delete the mesh entirely (owner only). */
canDeleteMesh: boolean().notNull().default(false),
/** Manage other members' permissions. */
canManagePermissions: boolean().notNull().default(false),
updatedAt: timestamp().defaultNow().notNull(),
}, (table) => [
uniqueIndex("permission_member_mesh_idx").on(table.meshId, table.memberId),
]);
export const meshPermission = meshSchema.table(
"permission",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade" })
.notNull(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade" })
.notNull(),
/** Invite other users to the mesh. */
canInvite: boolean().notNull().default(false),
/** Deploy/undeploy MCP services. */
canDeployMcp: boolean().notNull().default(false),
/** Upload/delete shared files. */
canManageFiles: boolean().notNull().default(false),
/** Read/write vault secrets. */
canManageVault: boolean().notNull().default(false),
/** Create/manage URL watches. */
canManageWatches: boolean().notNull().default(false),
/** Create/manage webhooks. */
canManageWebhooks: boolean().notNull().default(false),
/** Write shared state (read is always allowed). */
canWriteState: boolean().notNull().default(true),
/** Send messages to peers. */
canSend: boolean().notNull().default(true),
/** Use deployed MCP tools. */
canUseTools: boolean().notNull().default(true),
/** Delete the mesh entirely (owner only). */
canDeleteMesh: boolean().notNull().default(false),
/** Manage other members' permissions. */
canManagePermissions: boolean().notNull().default(false),
updatedAt: timestamp().defaultNow().notNull(),
},
(table) => [
uniqueIndex("permission_member_mesh_idx").on(table.meshId, table.memberId),
],
);
export const meshPermissionRelations = relations(meshPermission, ({ one }) => ({
mesh: one(mesh, {
@@ -792,22 +841,43 @@ export type InsertMeshPermission = typeof meshPermission.$inferInsert;
*/
export const DEFAULT_PERMISSIONS = {
owner: {
canInvite: true, canDeployMcp: true, canManageFiles: true,
canManageVault: true, canManageWatches: true, canManageWebhooks: true,
canWriteState: true, canSend: true, canUseTools: true,
canDeleteMesh: true, canManagePermissions: true,
canInvite: true,
canDeployMcp: true,
canManageFiles: true,
canManageVault: true,
canManageWatches: true,
canManageWebhooks: true,
canWriteState: true,
canSend: true,
canUseTools: true,
canDeleteMesh: true,
canManagePermissions: true,
},
admin: {
canInvite: true, canDeployMcp: true, canManageFiles: true,
canManageVault: true, canManageWatches: true, canManageWebhooks: true,
canWriteState: true, canSend: true, canUseTools: true,
canDeleteMesh: false, canManagePermissions: true,
canInvite: true,
canDeployMcp: true,
canManageFiles: true,
canManageVault: true,
canManageWatches: true,
canManageWebhooks: true,
canWriteState: true,
canSend: true,
canUseTools: true,
canDeleteMesh: false,
canManagePermissions: true,
},
member: {
canInvite: false, canDeployMcp: false, canManageFiles: false,
canManageVault: false, canManageWatches: false, canManageWebhooks: false,
canWriteState: true, canSend: true, canUseTools: true,
canDeleteMesh: false, canManagePermissions: false,
canInvite: false,
canDeployMcp: false,
canManageFiles: false,
canManageVault: false,
canManageWatches: false,
canManageWebhooks: false,
canWriteState: true,
canSend: true,
canUseTools: true,
canDeleteMesh: false,
canManagePermissions: false,
},
} as const;
@@ -844,7 +914,10 @@ export const inviteRelations = relations(invite, ({ one }) => ({
export const pendingInviteRelations = relations(pendingInvite, ({ one }) => ({
mesh: one(mesh, { fields: [pendingInvite.meshId], references: [mesh.id] }),
inviter: one(user, { fields: [pendingInvite.createdBy], references: [user.id] }),
inviter: one(user, {
fields: [pendingInvite.createdBy],
references: [user.id],
}),
}));
export const auditLogRelations = relations(auditLog, ({ one }) => ({
@@ -997,14 +1070,31 @@ export const peerState = meshSchema.table(
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
memberId: text()
.references(() => meshMember.id, { onDelete: "cascade", onUpdate: "cascade" })
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
groups: jsonb().$type<Array<{ name: string; role?: string }>>().default([]),
profile: jsonb().$type<{ avatar?: string; title?: string; bio?: string; capabilities?: string[] }>().default({}),
groups: jsonb().$type<{ name: string; role?: string }[]>().default([]),
profile: jsonb()
.$type<{
avatar?: string;
title?: string;
bio?: string;
capabilities?: string[];
}>()
.default({}),
visible: boolean().notNull().default(true),
lastSummary: text(),
lastDisplayName: text(),
cumulativeStats: jsonb().$type<{ messagesIn: number; messagesOut: number; toolCalls: number; errors: number }>().default({ messagesIn: 0, messagesOut: 0, toolCalls: 0, errors: 0 }),
cumulativeStats: jsonb()
.$type<{
messagesIn: number;
messagesOut: number;
toolCalls: number;
errors: number;
}>()
.default({ messagesIn: 0, messagesOut: 0, toolCalls: 0, errors: 0 }),
lastSeenAt: timestamp(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp().defaultNow().notNull(),
@@ -1052,8 +1142,14 @@ export type InsertMeshSkill = typeof meshSkill.$inferInsert;
export const meshServiceRelations = relations(meshService, ({ one }) => ({
mesh: one(mesh, { fields: [meshService.meshId], references: [mesh.id] }),
sourceFile: one(meshFile, { fields: [meshService.sourceFileId], references: [meshFile.id] }),
deployer: one(meshMember, { fields: [meshService.deployedBy], references: [meshMember.id] }),
sourceFile: one(meshFile, {
fields: [meshService.sourceFileId],
references: [meshFile.id],
}),
deployer: one(meshMember, {
fields: [meshService.deployedBy],
references: [meshMember.id],
}),
}));
export const selectMeshServiceSchema = createSelectSchema(meshService);
@@ -1063,7 +1159,10 @@ export type InsertMeshService = typeof meshService.$inferInsert;
export const meshVaultEntryRelations = relations(meshVaultEntry, ({ one }) => ({
mesh: one(mesh, { fields: [meshVaultEntry.meshId], references: [mesh.id] }),
member: one(meshMember, { fields: [meshVaultEntry.memberId], references: [meshMember.id] }),
member: one(meshMember, {
fields: [meshVaultEntry.memberId],
references: [meshMember.id],
}),
}));
export const selectMeshVaultEntrySchema = createSelectSchema(meshVaultEntry);
@@ -1134,31 +1233,35 @@ export const deviceCodeStatusEnum = meshSchema.enum("device_code_status", [
* Device codes for CLI → browser → CLI OAuth flow.
* CLI creates a code, browser approves it, CLI polls until approved.
*/
export const deviceCode = meshSchema.table("device_code", {
id: text().primaryKey().notNull().$defaultFn(generateId),
/** Random 16-char code used by CLI to poll (secret, never shown to user). */
deviceCode: text().notNull().unique(),
/** Human-readable code shown in both terminal and browser for visual confirmation. */
userCode: text().notNull(),
/** URL-safe session identifier (clm_sess_..., 32 chars). Not secret — appears in browser URL. */
sessionId: text().notNull().unique(),
status: deviceCodeStatusEnum().notNull().default("pending"),
/** Filled on approve — the authenticated user. */
userId: text().references(() => user.id, { onDelete: "cascade" }),
/** Device info from CLI request. */
hostname: text(),
platform: text(),
arch: text(),
ipAddress: text(),
/** Signed JWT session token — filled on approve. */
sessionToken: text(),
createdAt: timestamp().defaultNow().notNull(),
approvedAt: timestamp(),
expiresAt: timestamp().notNull(),
}, (table) => [
index("device_code_status_idx").on(table.status),
index("device_code_user_code_idx").on(table.userCode),
]);
export const deviceCode = meshSchema.table(
"device_code",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
/** Random 16-char code used by CLI to poll (secret, never shown to user). */
deviceCode: text().notNull().unique(),
/** Human-readable code shown in both terminal and browser for visual confirmation. */
userCode: text().notNull(),
/** URL-safe session identifier (clm_sess_..., 32 chars). Not secret — appears in browser URL. */
sessionId: text().notNull().unique(),
status: deviceCodeStatusEnum().notNull().default("pending"),
/** Filled on approve — the authenticated user. */
userId: text().references(() => user.id, { onDelete: "cascade" }),
/** Device info from CLI request. */
hostname: text(),
platform: text(),
arch: text(),
ipAddress: text(),
/** Signed JWT session token — filled on approve. */
sessionToken: text(),
createdAt: timestamp().defaultNow().notNull(),
approvedAt: timestamp(),
expiresAt: timestamp().notNull(),
},
(table) => [
index("device_code_status_idx").on(table.status),
index("device_code_user_code_idx").on(table.userCode),
],
);
export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({
user: one(user, {
@@ -1176,26 +1279,30 @@ export type InsertDeviceCode = typeof deviceCode.$inferInsert;
* Persistent CLI session records — one per authenticated device.
* Enables dashboard "Signed in on N devices" view and per-device revocation.
*/
export const cliSession = meshSchema.table("cli_session", {
id: text().primaryKey().notNull().$defaultFn(generateId),
userId: text()
.references(() => user.id, { onDelete: "cascade" })
.notNull(),
/** Which device-code auth created this session. */
deviceCodeId: text().references(() => deviceCode.id),
hostname: text(),
platform: text(),
arch: text(),
/** SHA-256 hash of the JWT for revocation lookup. */
tokenHash: text().notNull(),
lastSeenAt: timestamp().defaultNow(),
createdAt: timestamp().defaultNow().notNull(),
/** NULL until user revokes from dashboard. */
revokedAt: timestamp(),
}, (table) => [
index("cli_session_user_idx").on(table.userId),
index("cli_session_token_hash_idx").on(table.tokenHash),
]);
export const cliSession = meshSchema.table(
"cli_session",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
userId: text()
.references(() => user.id, { onDelete: "cascade" })
.notNull(),
/** Which device-code auth created this session. */
deviceCodeId: text().references(() => deviceCode.id),
hostname: text(),
platform: text(),
arch: text(),
/** SHA-256 hash of the JWT for revocation lookup. */
tokenHash: text().notNull(),
lastSeenAt: timestamp().defaultNow(),
createdAt: timestamp().defaultNow().notNull(),
/** NULL until user revokes from dashboard. */
revokedAt: timestamp(),
},
(table) => [
index("cli_session_user_idx").on(table.userId),
index("cli_session_token_hash_idx").on(table.tokenHash),
],
);
export const cliSessionRelations = relations(cliSession, ({ one }) => ({
user: one(user, {
@@ -1212,3 +1319,167 @@ export const selectCliSessionSchema = createSelectSchema(cliSession);
export const insertCliSessionSchema = createInsertSchema(cliSession);
export type SelectCliSession = typeof cliSession.$inferSelect;
export type InsertCliSession = typeof cliSession.$inferInsert;
/* ────────────────────────────────────────────────────────────────────────
* Topics (v0.2.0) — conversational primitive within a mesh.
*
* Mesh = trust boundary. Group = identity tag. Topic = conversation scope.
* Three orthogonal axes; topics complement (don't replace) groups.
*
* Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
* ──────────────────────────────────────────────────────────────────────── */
export const topicVisibilityEnum = meshSchema.enum("topic_visibility", [
"public", // any mesh member can join
"private", // invite-only
"dm", // 1:1, autocreated when two peers DM
]);
export const topicMemberRoleEnum = meshSchema.enum("topic_member_role", [
"lead",
"member",
"observer",
]);
/**
* A topic is a named conversation scope within a mesh. Messages, state,
* memory, and files can be topic-scoped. Membership controls delivery
* (broker filters topic-tagged messages by topic_member rows).
*/
export const meshTopic = meshSchema.table(
"topic",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(), // unique within mesh; e.g. "deploys"
description: text(),
visibility: topicVisibilityEnum().notNull().default("public"),
createdByMemberId: text().references(() => meshMember.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
createdAt: timestamp().defaultNow().notNull(),
archivedAt: timestamp(),
},
(t) => [uniqueIndex("topic_mesh_name_unique").on(t.meshId, t.name)],
);
/**
* Per-member topic membership. last_read_at drives unread counts in the
* web chat UI; role is advisory (lead/member/observer) like meshGroup.
*/
export const meshTopicMember = meshSchema.table(
"topic_member",
{
topicId: text()
.references(() => meshTopic.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
memberId: text()
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
role: topicMemberRoleEnum().notNull().default("member"),
joinedAt: timestamp().defaultNow().notNull(),
lastReadAt: timestamp(),
},
(t) => [
uniqueIndex("topic_member_unique").on(t.topicId, t.memberId),
index("topic_member_by_member").on(t.memberId),
],
);
/**
* Topic-scoped persistent message history. Direct messages (DMs) stay
* ephemeral via message_queue by design — this table only persists
* messages addressed to a topic, so humans (and agents that opt in) can
* see history when they reconnect.
*
* Ciphertext is encrypted to the topic's symmetric key (held by every
* topic member). Server cannot read content; it can only filter delivery
* by topic membership.
*/
export const meshTopicMessage = meshSchema.table(
"topic_message",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
topicId: text()
.references(() => meshTopic.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
senderMemberId: text()
.references(() => meshMember.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
senderSessionPubkey: text(),
nonce: text().notNull(),
ciphertext: text().notNull(),
createdAt: timestamp().defaultNow().notNull(),
},
(t) => [index("topic_message_by_topic_time").on(t.topicId, t.createdAt)],
);
export const meshTopicRelations = relations(meshTopic, ({ one, many }) => ({
mesh: one(mesh, { fields: [meshTopic.meshId], references: [mesh.id] }),
createdBy: one(meshMember, {
fields: [meshTopic.createdByMemberId],
references: [meshMember.id],
}),
members: many(meshTopicMember),
messages: many(meshTopicMessage),
}));
export const meshTopicMemberRelations = relations(
meshTopicMember,
({ one }) => ({
topic: one(meshTopic, {
fields: [meshTopicMember.topicId],
references: [meshTopic.id],
}),
member: one(meshMember, {
fields: [meshTopicMember.memberId],
references: [meshMember.id],
}),
}),
);
export const meshTopicMessageRelations = relations(
meshTopicMessage,
({ one }) => ({
topic: one(meshTopic, {
fields: [meshTopicMessage.topicId],
references: [meshTopic.id],
}),
sender: one(meshMember, {
fields: [meshTopicMessage.senderMemberId],
references: [meshMember.id],
}),
}),
);
export const selectMeshTopicSchema = createSelectSchema(meshTopic);
export const insertMeshTopicSchema = createInsertSchema(meshTopic);
export type SelectMeshTopic = typeof meshTopic.$inferSelect;
export type InsertMeshTopic = typeof meshTopic.$inferInsert;
export const selectMeshTopicMemberSchema = createSelectSchema(meshTopicMember);
export const insertMeshTopicMemberSchema = createInsertSchema(meshTopicMember);
export type SelectMeshTopicMember = typeof meshTopicMember.$inferSelect;
export type InsertMeshTopicMember = typeof meshTopicMember.$inferInsert;
export const selectMeshTopicMessageSchema =
createSelectSchema(meshTopicMessage);
export const insertMeshTopicMessageSchema =
createInsertSchema(meshTopicMessage);
export type SelectMeshTopicMessage = typeof meshTopicMessage.$inferSelect;
export type InsertMeshTopicMessage = typeof meshTopicMessage.$inferInsert;