mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
100
web/lib/collaboration/permissions.ts
Normal file
100
web/lib/collaboration/permissions.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { CollabRole } from "./socket";
|
||||
import type { LinkExpiry, ShareLink } from "./types";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// ─── Role Hierarchy ───────────────────────────────────────────────────────────
|
||||
|
||||
const ROLE_RANK: Record<CollabRole, number> = {
|
||||
owner: 3,
|
||||
collaborator: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
|
||||
export function hasPermission(role: CollabRole, required: CollabRole): boolean {
|
||||
return ROLE_RANK[role] >= ROLE_RANK[required];
|
||||
}
|
||||
|
||||
export function canSendMessages(role: CollabRole): boolean {
|
||||
return hasPermission(role, "collaborator");
|
||||
}
|
||||
|
||||
export function canApproveTools(
|
||||
role: CollabRole,
|
||||
policy: "owner-only" | "any-collaborator",
|
||||
isOwner: boolean
|
||||
): boolean {
|
||||
if (policy === "owner-only") return isOwner;
|
||||
return hasPermission(role, "collaborator");
|
||||
}
|
||||
|
||||
export function canManageAccess(role: CollabRole): boolean {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
export function canTransferOwnership(role: CollabRole): boolean {
|
||||
return role === "owner";
|
||||
}
|
||||
|
||||
export function canAddAnnotations(role: CollabRole): boolean {
|
||||
return hasPermission(role, "collaborator");
|
||||
}
|
||||
|
||||
export function canChangeRole(
|
||||
actorRole: CollabRole,
|
||||
targetRole: CollabRole
|
||||
): boolean {
|
||||
// Owner can change any role; collaborators cannot manage access at all
|
||||
return actorRole === "owner" && targetRole !== "owner";
|
||||
}
|
||||
|
||||
// ─── Share Links ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EXPIRY_MS: Record<LinkExpiry, number | null> = {
|
||||
"1h": 60 * 60 * 1_000,
|
||||
"24h": 24 * 60 * 60 * 1_000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1_000,
|
||||
never: null,
|
||||
};
|
||||
|
||||
export function createShareLink(
|
||||
sessionId: string,
|
||||
role: CollabRole,
|
||||
expiry: LinkExpiry,
|
||||
createdBy: string
|
||||
): ShareLink {
|
||||
const expiryMs = EXPIRY_MS[expiry];
|
||||
return {
|
||||
id: nanoid(12),
|
||||
sessionId,
|
||||
role,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: expiryMs !== null ? Date.now() + expiryMs : null,
|
||||
createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLinkExpired(link: ShareLink): boolean {
|
||||
return link.expiresAt !== null && Date.now() > link.expiresAt;
|
||||
}
|
||||
|
||||
export function buildShareUrl(linkId: string, role: CollabRole): string {
|
||||
const base =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://localhost:3000";
|
||||
return `${base}/join/${linkId}?role=${role}`;
|
||||
}
|
||||
|
||||
export function labelForRole(role: CollabRole): string {
|
||||
return { owner: "Owner", collaborator: "Collaborator", viewer: "Viewer" }[
|
||||
role
|
||||
];
|
||||
}
|
||||
|
||||
export function descriptionForRole(role: CollabRole): string {
|
||||
return {
|
||||
owner: "Full control — can send messages, approve tools, and manage access",
|
||||
collaborator: "Can send messages and approve tool use",
|
||||
viewer: "Read-only — can watch the session in real-time",
|
||||
}[role];
|
||||
}
|
||||
115
web/lib/collaboration/presence.ts
Normal file
115
web/lib/collaboration/presence.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { CollabUser } from "./socket";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CursorState {
|
||||
userId: string;
|
||||
position: number;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
users: Map<string, CollabUser>;
|
||||
cursors: Map<string, CursorState>;
|
||||
typing: Set<string>; // user IDs currently typing
|
||||
}
|
||||
|
||||
// ─── Factory ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createPresenceState(): PresenceState {
|
||||
return {
|
||||
users: new Map(),
|
||||
cursors: new Map(),
|
||||
typing: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Updaters (all return new state, immutable) ───────────────────────────────
|
||||
|
||||
export function presenceAddUser(
|
||||
state: PresenceState,
|
||||
user: CollabUser
|
||||
): PresenceState {
|
||||
return { ...state, users: new Map(state.users).set(user.id, user) };
|
||||
}
|
||||
|
||||
export function presenceRemoveUser(
|
||||
state: PresenceState,
|
||||
userId: string
|
||||
): PresenceState {
|
||||
const users = new Map(state.users);
|
||||
users.delete(userId);
|
||||
const cursors = new Map(state.cursors);
|
||||
cursors.delete(userId);
|
||||
const typing = new Set(state.typing);
|
||||
typing.delete(userId);
|
||||
return { users, cursors, typing };
|
||||
}
|
||||
|
||||
export function presenceSyncUsers(
|
||||
state: PresenceState,
|
||||
users: CollabUser[]
|
||||
): PresenceState {
|
||||
const map = new Map<string, CollabUser>();
|
||||
users.forEach((u) => map.set(u.id, u));
|
||||
return { ...state, users: map };
|
||||
}
|
||||
|
||||
export function presenceUpdateCursor(
|
||||
state: PresenceState,
|
||||
userId: string,
|
||||
position: number,
|
||||
selectionStart?: number,
|
||||
selectionEnd?: number
|
||||
): PresenceState {
|
||||
const cursor: CursorState = {
|
||||
userId,
|
||||
position,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return { ...state, cursors: new Map(state.cursors).set(userId, cursor) };
|
||||
}
|
||||
|
||||
export function presenceSetTyping(
|
||||
state: PresenceState,
|
||||
userId: string,
|
||||
isTyping: boolean
|
||||
): PresenceState {
|
||||
const typing = new Set(state.typing);
|
||||
if (isTyping) {
|
||||
typing.add(userId);
|
||||
} else {
|
||||
typing.delete(userId);
|
||||
}
|
||||
return { ...state, typing };
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const USER_COLORS = [
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#06b6d4",
|
||||
"#3b82f6",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
export function getUserColor(index: number): string {
|
||||
return USER_COLORS[index % USER_COLORS.length];
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
321
web/lib/collaboration/socket.ts
Normal file
321
web/lib/collaboration/socket.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
// ─── Role & User ────────────────────────────────────────────────────────────
|
||||
|
||||
export type CollabRole = "owner" | "collaborator" | "viewer";
|
||||
|
||||
export interface CollabUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
color: string; // hex color assigned per session
|
||||
role: CollabRole;
|
||||
}
|
||||
|
||||
// ─── Event Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type CollabEventType =
|
||||
| "message_added"
|
||||
| "message_streaming"
|
||||
| "tool_use_pending"
|
||||
| "tool_use_approved"
|
||||
| "tool_use_denied"
|
||||
| "user_joined"
|
||||
| "user_left"
|
||||
| "cursor_update"
|
||||
| "typing_start"
|
||||
| "typing_stop"
|
||||
| "presence_sync"
|
||||
| "annotation_added"
|
||||
| "annotation_resolved"
|
||||
| "annotation_reply"
|
||||
| "role_changed"
|
||||
| "access_revoked"
|
||||
| "ownership_transferred"
|
||||
| "session_state"
|
||||
| "error";
|
||||
|
||||
interface BaseCollabEvent {
|
||||
type: CollabEventType;
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface MessageAddedEvent extends BaseCollabEvent {
|
||||
type: "message_added";
|
||||
message: {
|
||||
id: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MessageStreamingEvent extends BaseCollabEvent {
|
||||
type: "message_streaming";
|
||||
messageId: string;
|
||||
delta: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface ToolUsePendingEvent extends BaseCollabEvent {
|
||||
type: "tool_use_pending";
|
||||
toolUseId: string;
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export interface ToolUseApprovedEvent extends BaseCollabEvent {
|
||||
type: "tool_use_approved";
|
||||
toolUseId: string;
|
||||
approvedBy: CollabUser;
|
||||
}
|
||||
|
||||
export interface ToolUseDeniedEvent extends BaseCollabEvent {
|
||||
type: "tool_use_denied";
|
||||
toolUseId: string;
|
||||
deniedBy: CollabUser;
|
||||
}
|
||||
|
||||
export interface UserJoinedEvent extends BaseCollabEvent {
|
||||
type: "user_joined";
|
||||
user: CollabUser;
|
||||
}
|
||||
|
||||
export interface UserLeftEvent extends BaseCollabEvent {
|
||||
type: "user_left";
|
||||
user: CollabUser;
|
||||
}
|
||||
|
||||
export interface CursorUpdateEvent extends BaseCollabEvent {
|
||||
type: "cursor_update";
|
||||
position: number;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
}
|
||||
|
||||
export interface TypingStartEvent extends BaseCollabEvent {
|
||||
type: "typing_start";
|
||||
user: CollabUser;
|
||||
}
|
||||
|
||||
export interface TypingStopEvent extends BaseCollabEvent {
|
||||
type: "typing_stop";
|
||||
user: CollabUser;
|
||||
}
|
||||
|
||||
export interface PresenceSyncEvent extends BaseCollabEvent {
|
||||
type: "presence_sync";
|
||||
users: CollabUser[];
|
||||
}
|
||||
|
||||
export interface AnnotationAddedEvent extends BaseCollabEvent {
|
||||
type: "annotation_added";
|
||||
annotation: {
|
||||
id: string;
|
||||
messageId: string;
|
||||
parentId?: string;
|
||||
text: string;
|
||||
author: CollabUser;
|
||||
createdAt: number;
|
||||
resolved: boolean;
|
||||
replies: AnnotationReply[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnnotationResolvedEvent extends BaseCollabEvent {
|
||||
type: "annotation_resolved";
|
||||
annotationId: string;
|
||||
resolved: boolean;
|
||||
resolvedBy: CollabUser;
|
||||
}
|
||||
|
||||
export interface AnnotationReplyEvent extends BaseCollabEvent {
|
||||
type: "annotation_reply";
|
||||
annotationId: string;
|
||||
reply: AnnotationReply;
|
||||
}
|
||||
|
||||
export interface AnnotationReply {
|
||||
id: string;
|
||||
text: string;
|
||||
author: CollabUser;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RoleChangedEvent extends BaseCollabEvent {
|
||||
type: "role_changed";
|
||||
targetUserId: string;
|
||||
newRole: CollabRole;
|
||||
}
|
||||
|
||||
export interface AccessRevokedEvent extends BaseCollabEvent {
|
||||
type: "access_revoked";
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface OwnershipTransferredEvent extends BaseCollabEvent {
|
||||
type: "ownership_transferred";
|
||||
newOwnerId: string;
|
||||
previousOwnerId: string;
|
||||
}
|
||||
|
||||
export interface SessionStateEvent extends BaseCollabEvent {
|
||||
type: "session_state";
|
||||
users: CollabUser[];
|
||||
pendingToolUseIds: string[];
|
||||
toolApprovalPolicy: "owner-only" | "any-collaborator";
|
||||
}
|
||||
|
||||
export interface ErrorEvent extends BaseCollabEvent {
|
||||
type: "error";
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type CollabEvent =
|
||||
| MessageAddedEvent
|
||||
| MessageStreamingEvent
|
||||
| ToolUsePendingEvent
|
||||
| ToolUseApprovedEvent
|
||||
| ToolUseDeniedEvent
|
||||
| UserJoinedEvent
|
||||
| UserLeftEvent
|
||||
| CursorUpdateEvent
|
||||
| TypingStartEvent
|
||||
| TypingStopEvent
|
||||
| PresenceSyncEvent
|
||||
| AnnotationAddedEvent
|
||||
| AnnotationResolvedEvent
|
||||
| AnnotationReplyEvent
|
||||
| RoleChangedEvent
|
||||
| AccessRevokedEvent
|
||||
| OwnershipTransferredEvent
|
||||
| SessionStateEvent
|
||||
| ErrorEvent;
|
||||
|
||||
// Outgoing-only events (client → server) that don't need timestamp/sessionId
|
||||
export type OutgoingEvent = Omit<CollabEvent, "timestamp">;
|
||||
|
||||
type EventHandler<T extends CollabEvent = CollabEvent> = (event: T) => void;
|
||||
|
||||
// ─── CollabSocket ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class CollabSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers = new Map<CollabEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly maxReconnectAttempts = 5;
|
||||
private readonly baseDelay = 1000;
|
||||
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private wsUrl = "";
|
||||
|
||||
readonly sessionId: string;
|
||||
readonly token: string;
|
||||
isConnected = false;
|
||||
onConnectionChange?: (connected: boolean) => void;
|
||||
|
||||
constructor(sessionId: string, token: string) {
|
||||
this.sessionId = sessionId;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
connect(wsUrl: string): void {
|
||||
this.wsUrl = wsUrl;
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
const url = new URL(wsUrl);
|
||||
url.searchParams.set("sessionId", this.sessionId);
|
||||
url.searchParams.set("token", this.token);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.onConnectionChange?.(true);
|
||||
this.startPing();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data as string) as CollabEvent;
|
||||
this.dispatch(data);
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected = false;
|
||||
this.onConnectionChange?.(false);
|
||||
this.stopPing();
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Set max attempts to prevent reconnect
|
||||
this.reconnectAttempts = this.maxReconnectAttempts;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopPing();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
send(event: OutgoingEvent): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify({ ...event, timestamp: Date.now() }));
|
||||
}
|
||||
|
||||
on<T extends CollabEvent>(
|
||||
type: T["type"],
|
||||
handler: EventHandler<T>
|
||||
): () => void {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, new Set());
|
||||
}
|
||||
this.handlers.get(type)!.add(handler as EventHandler);
|
||||
return () => this.off(type, handler);
|
||||
}
|
||||
|
||||
off<T extends CollabEvent>(type: T["type"], handler: EventHandler<T>): void {
|
||||
this.handlers.get(type)?.delete(handler as EventHandler);
|
||||
}
|
||||
|
||||
private dispatch(event: CollabEvent): void {
|
||||
this.handlers.get(event.type)?.forEach((h) => h(event));
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.pingInterval = setInterval(() => {
|
||||
this.ws?.send(JSON.stringify({ type: "ping" }));
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
||||
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimer = setTimeout(() => this.connect(this.wsUrl), delay);
|
||||
}
|
||||
}
|
||||
37
web/lib/collaboration/types.ts
Normal file
37
web/lib/collaboration/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CollabUser, AnnotationReply } from "./socket";
|
||||
|
||||
// ─── Annotation ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CollabAnnotation {
|
||||
id: string;
|
||||
messageId: string;
|
||||
parentId?: string; // for threaded top-level grouping (unused in v1)
|
||||
text: string;
|
||||
author: CollabUser;
|
||||
createdAt: number;
|
||||
resolved: boolean;
|
||||
replies: AnnotationReply[];
|
||||
}
|
||||
|
||||
// ─── Pending Tool Use ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PendingToolUse {
|
||||
id: string; // toolUseId
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
messageId: string;
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
// ─── Share Link ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type LinkExpiry = "1h" | "24h" | "7d" | "never";
|
||||
|
||||
export interface ShareLink {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: import("./socket").CollabRole;
|
||||
createdAt: number;
|
||||
expiresAt: number | null; // null = never expires
|
||||
createdBy: string;
|
||||
}
|
||||
Reference in New Issue
Block a user