New broker endpoints for CLI auth sync flow (POST /cli-sync), member profile management, and mesh settings. Includes JWT verification for dashboard-issued sync tokens. DB schema adds member profile fields and mesh policy columns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
7.7 KiB
TypeScript
285 lines
7.7 KiB
TypeScript
/**
|
|
* Member profile REST API handlers.
|
|
*
|
|
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
|
* GET /mesh/:meshId/members — list all members with online status
|
|
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
|
*
|
|
* These are standalone handler functions. Route wiring happens in index.ts.
|
|
*/
|
|
|
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
import { db } from "./db";
|
|
import {
|
|
mesh as meshTable,
|
|
meshMember as memberTable,
|
|
presence as presenceTable,
|
|
} from "@turbostarter/db/schema/mesh";
|
|
|
|
// --- Types ---
|
|
|
|
export interface MemberProfileUpdate {
|
|
displayName?: string;
|
|
roleTag?: string;
|
|
groups?: Array<{ name: string; role?: string }>;
|
|
messageMode?: "push" | "inbox" | "off";
|
|
}
|
|
|
|
export interface MemberPermissionUpdate {
|
|
permission?: "admin" | "member"; // only admins can change this
|
|
}
|
|
|
|
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
|
|
|
interface SelfEditablePolicy {
|
|
displayName: boolean;
|
|
roleTag: boolean;
|
|
groups: boolean;
|
|
messageMode: boolean;
|
|
}
|
|
|
|
// --- Handlers ---
|
|
|
|
/**
|
|
* Update a member's profile fields.
|
|
*
|
|
* Authorization:
|
|
* - If caller is the target member: check mesh.selfEditable for each field
|
|
* - If caller is a mesh admin: allow all fields
|
|
* - permission field: admin-only always
|
|
*
|
|
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
|
*/
|
|
export async function updateMemberProfile(
|
|
meshId: string,
|
|
memberId: string,
|
|
callerMemberId: string, // from auth header or WS connection
|
|
updates: MemberUpdateRequest,
|
|
): Promise<
|
|
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
|
| { ok: false; error: string }
|
|
> {
|
|
// 1. Load mesh for selfEditable policy
|
|
const [m] = await db
|
|
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
|
.from(meshTable)
|
|
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
|
|
|
if (!m) return { ok: false, error: "mesh not found" };
|
|
|
|
// 2. Load caller's member row to check permission
|
|
const [caller] = await db
|
|
.select({ id: memberTable.id, role: memberTable.role })
|
|
.from(memberTable)
|
|
.where(
|
|
and(
|
|
eq(memberTable.id, callerMemberId),
|
|
eq(memberTable.meshId, meshId),
|
|
isNull(memberTable.revokedAt),
|
|
),
|
|
);
|
|
|
|
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
|
|
|
const isAdmin = caller.role === "admin";
|
|
const isSelf = callerMemberId === memberId;
|
|
|
|
if (!isAdmin && !isSelf) {
|
|
return {
|
|
ok: false,
|
|
error: "not authorized — only admins or self can edit",
|
|
};
|
|
}
|
|
|
|
// 3. Check self-edit permissions for non-admin self-edits
|
|
const policy: SelfEditablePolicy =
|
|
(m.selfEditable as SelfEditablePolicy) ?? {
|
|
displayName: true,
|
|
roleTag: true,
|
|
groups: true,
|
|
messageMode: true,
|
|
};
|
|
|
|
const rejected: string[] = [];
|
|
if (!isAdmin && isSelf) {
|
|
if (updates.displayName !== undefined && !policy.displayName)
|
|
rejected.push("displayName");
|
|
if (updates.roleTag !== undefined && !policy.roleTag)
|
|
rejected.push("roleTag");
|
|
if (updates.groups !== undefined && !policy.groups)
|
|
rejected.push("groups");
|
|
if (updates.messageMode !== undefined && !policy.messageMode)
|
|
rejected.push("messageMode");
|
|
if (updates.permission !== undefined) rejected.push("permission");
|
|
}
|
|
|
|
if (rejected.length > 0) {
|
|
return {
|
|
ok: false,
|
|
error: `admin-managed fields: ${rejected.join(", ")}`,
|
|
};
|
|
}
|
|
|
|
// 4. Build update set
|
|
const set: Record<string, unknown> = {};
|
|
const changes: MemberProfileUpdate = {};
|
|
|
|
if (updates.displayName !== undefined) {
|
|
set.displayName = updates.displayName;
|
|
changes.displayName = updates.displayName;
|
|
}
|
|
if (updates.roleTag !== undefined) {
|
|
set.roleTag = updates.roleTag;
|
|
changes.roleTag = updates.roleTag;
|
|
}
|
|
if (updates.groups !== undefined) {
|
|
set.defaultGroups = updates.groups;
|
|
changes.groups = updates.groups;
|
|
}
|
|
if (updates.messageMode !== undefined) {
|
|
set.messageMode = updates.messageMode;
|
|
changes.messageMode = updates.messageMode;
|
|
}
|
|
if (updates.permission !== undefined && isAdmin) {
|
|
set.role = updates.permission;
|
|
}
|
|
|
|
if (Object.keys(set).length === 0) {
|
|
return { ok: false, error: "no fields to update" };
|
|
}
|
|
|
|
// 5. Update member row
|
|
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
|
|
|
// 6. Read back the updated member
|
|
const [updated] = await db
|
|
.select()
|
|
.from(memberTable)
|
|
.where(eq(memberTable.id, memberId));
|
|
|
|
if (!updated) return { ok: false, error: "member not found after update" };
|
|
|
|
return {
|
|
ok: true,
|
|
member: {
|
|
id: updated.id,
|
|
displayName: updated.displayName,
|
|
roleTag: updated.roleTag,
|
|
groups: updated.defaultGroups,
|
|
messageMode: updated.messageMode,
|
|
permission: updated.role,
|
|
dashboardUserId: updated.dashboardUserId,
|
|
joinedAt: updated.joinedAt,
|
|
lastSeenAt: updated.lastSeenAt,
|
|
},
|
|
changes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* List all members of a mesh with online status.
|
|
*/
|
|
export async function listMeshMembers(
|
|
meshId: string,
|
|
): Promise<
|
|
| { ok: true; members: Array<Record<string, unknown>> }
|
|
| { ok: false; error: string }
|
|
> {
|
|
// Verify mesh exists
|
|
const [m] = await db
|
|
.select({ id: meshTable.id })
|
|
.from(meshTable)
|
|
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
|
|
|
if (!m) return { ok: false, error: "mesh not found" };
|
|
|
|
// Get all non-revoked members
|
|
const members = await db
|
|
.select()
|
|
.from(memberTable)
|
|
.where(
|
|
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
|
);
|
|
|
|
// Early return for empty member list (avoids invalid SQL IN clause)
|
|
if (members.length === 0) {
|
|
return { ok: true, members: [] };
|
|
}
|
|
|
|
// Get active presences for online status
|
|
const activePresences = await db
|
|
.select({
|
|
memberId: presenceTable.memberId,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(presenceTable)
|
|
.where(
|
|
and(
|
|
isNull(presenceTable.disconnectedAt),
|
|
sql`${presenceTable.memberId} IN (${sql.join(
|
|
members.map((m) => sql`${m.id}`),
|
|
sql`, `,
|
|
)})`,
|
|
),
|
|
)
|
|
.groupBy(presenceTable.memberId);
|
|
|
|
const onlineMap = new Map(
|
|
activePresences.map((p) => [p.memberId, p.count]),
|
|
);
|
|
|
|
return {
|
|
ok: true,
|
|
members: members.map((member) => ({
|
|
id: member.id,
|
|
displayName: member.displayName,
|
|
roleTag: member.roleTag,
|
|
groups: member.defaultGroups,
|
|
messageMode: member.messageMode,
|
|
permission: member.role,
|
|
dashboardUserId: member.dashboardUserId,
|
|
joinedAt: member.joinedAt?.toISOString(),
|
|
lastSeenAt: member.lastSeenAt?.toISOString(),
|
|
online: onlineMap.has(member.id),
|
|
sessionCount: onlineMap.get(member.id) ?? 0,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update mesh settings (currently: selfEditable policy).
|
|
* Admin-only.
|
|
*/
|
|
export async function updateMeshSettings(
|
|
meshId: string,
|
|
callerMemberId: string,
|
|
settings: { selfEditable?: SelfEditablePolicy },
|
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
// Check caller is admin
|
|
const [caller] = await db
|
|
.select({ role: memberTable.role })
|
|
.from(memberTable)
|
|
.where(
|
|
and(
|
|
eq(memberTable.id, callerMemberId),
|
|
eq(memberTable.meshId, meshId),
|
|
isNull(memberTable.revokedAt),
|
|
),
|
|
);
|
|
|
|
if (!caller || caller.role !== "admin") {
|
|
return { ok: false, error: "admin access required" };
|
|
}
|
|
|
|
const set: Record<string, unknown> = {};
|
|
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
|
|
|
if (Object.keys(set).length === 0) {
|
|
return { ok: false, error: "no settings to update" };
|
|
}
|
|
|
|
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
|
|
|
return { ok: true };
|
|
}
|