From b23dc0c2b1e62a7b74c73868b43f84d413921391 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:51:53 +0900 Subject: [PATCH 01/20] add: Add session sharing database schema Add Prisma models for session sharing feature including direct user-to-user sharing, public shareable links, access logging, and user blocking. Files: - prisma/schema.prisma - prisma/migrations/20260109044634_add_session_sharing/migration.sql --- .../migration.sql | 152 ++++++++++++++++ prisma/schema.prisma | 165 +++++++++++++++--- 2 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20260109044634_add_session_sharing/migration.sql diff --git a/prisma/migrations/20260109044634_add_session_sharing/migration.sql b/prisma/migrations/20260109044634_add_session_sharing/migration.sql new file mode 100644 index 0000000..ed0d85c --- /dev/null +++ b/prisma/migrations/20260109044634_add_session_sharing/migration.sql @@ -0,0 +1,152 @@ +-- CreateEnum +CREATE TYPE "ShareAccessLevel" AS ENUM ('view', 'edit', 'admin'); + +-- CreateTable +CREATE TABLE "SessionShare" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "sharedByUserId" TEXT NOT NULL, + "sharedWithUserId" TEXT NOT NULL, + "accessLevel" "ShareAccessLevel" NOT NULL DEFAULT 'view', + "encryptedDataKey" BYTEA NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SessionShare_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SessionShareAccessLog" ( + "id" TEXT NOT NULL, + "sessionShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + + CONSTRAINT "SessionShareAccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicSessionShare" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "createdByUserId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "accessLevel" "ShareAccessLevel" NOT NULL DEFAULT 'view', + "encryptedDataKey" BYTEA NOT NULL, + "expiresAt" TIMESTAMP(3), + "maxUses" INTEGER, + "useCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PublicSessionShare_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicShareAccessLog" ( + "id" TEXT NOT NULL, + "publicShareId" TEXT NOT NULL, + "userId" TEXT, + "accessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ipAddress" TEXT, + "userAgent" TEXT, + + CONSTRAINT "PublicShareAccessLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PublicShareBlockedUser" ( + "id" TEXT NOT NULL, + "publicShareId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "blockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reason" TEXT, + + CONSTRAINT "PublicShareBlockedUser_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedWithUserId_idx" ON "SessionShare"("sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sharedByUserId_idx" ON "SessionShare"("sharedByUserId"); + +-- CreateIndex +CREATE INDEX "SessionShare_sessionId_idx" ON "SessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionShare_sessionId_sharedWithUserId_key" ON "SessionShare"("sessionId", "sharedWithUserId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_sessionShareId_idx" ON "SessionShareAccessLog"("sessionShareId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_userId_idx" ON "SessionShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "SessionShareAccessLog_accessedAt_idx" ON "SessionShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_sessionId_key" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicSessionShare_token_key" ON "PublicSessionShare"("token"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_token_idx" ON "PublicSessionShare"("token"); + +-- CreateIndex +CREATE INDEX "PublicSessionShare_sessionId_idx" ON "PublicSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_publicShareId_idx" ON "PublicShareAccessLog"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_userId_idx" ON "PublicShareAccessLog"("userId"); + +-- CreateIndex +CREATE INDEX "PublicShareAccessLog_accessedAt_idx" ON "PublicShareAccessLog"("accessedAt"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_publicShareId_idx" ON "PublicShareBlockedUser"("publicShareId"); + +-- CreateIndex +CREATE INDEX "PublicShareBlockedUser_userId_idx" ON "PublicShareBlockedUser"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PublicShareBlockedUser_publicShareId_userId_key" ON "PublicShareBlockedUser"("publicShareId", "userId"); + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sharedByUserId_fkey" FOREIGN KEY ("sharedByUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShare" ADD CONSTRAINT "SessionShare_sharedWithUserId_fkey" FOREIGN KEY ("sharedWithUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShareAccessLog" ADD CONSTRAINT "SessionShareAccessLog_sessionShareId_fkey" FOREIGN KEY ("sessionShareId") REFERENCES "SessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionShareAccessLog" ADD CONSTRAINT "SessionShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicSessionShare" ADD CONSTRAINT "PublicSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicSessionShare" ADD CONSTRAINT "PublicSessionShare_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareAccessLog" ADD CONSTRAINT "PublicShareAccessLog_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareAccessLog" ADD CONSTRAINT "PublicShareAccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareBlockedUser" ADD CONSTRAINT "PublicShareBlockedUser_publicShareId_fkey" FOREIGN KEY ("publicShareId") REFERENCES "PublicSessionShare"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PublicShareBlockedUser" ADD CONSTRAINT "PublicShareBlockedUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6349305..c11d7c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,20 +38,26 @@ model Account { /// [ImageRef] avatar Json? - Session Session[] - AccountPushToken AccountPushToken[] - TerminalAuthRequest TerminalAuthRequest[] - AccountAuthRequest AccountAuthRequest[] - UsageReport UsageReport[] - Machine Machine[] - UploadedFile UploadedFile[] - ServiceAccountToken ServiceAccountToken[] - RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") - RelationshipsTo UserRelationship[] @relation("RelationshipsTo") - Artifact Artifact[] - AccessKey AccessKey[] - UserFeedItem UserFeedItem[] - UserKVStore UserKVStore[] + Session Session[] + AccountPushToken AccountPushToken[] + TerminalAuthRequest TerminalAuthRequest[] + AccountAuthRequest AccountAuthRequest[] + UsageReport UsageReport[] + Machine Machine[] + UploadedFile UploadedFile[] + ServiceAccountToken ServiceAccountToken[] + RelationshipsFrom UserRelationship[] @relation("RelationshipsFrom") + RelationshipsTo UserRelationship[] @relation("RelationshipsTo") + Artifact Artifact[] + AccessKey AccessKey[] + UserFeedItem UserFeedItem[] + UserKVStore UserKVStore[] + SharedBySessions SessionShare[] @relation("SharedBySessions") + SharedWithSessions SessionShare[] @relation("SharedWithSessions") + SessionShareAccessLogs SessionShareAccessLog[] @relation("SessionShareAccessLogs") + PublicSessionShares PublicSessionShare[] @relation("PublicSessionShares") + PublicShareAccessLogs PublicShareAccessLog[] @relation("PublicShareAccessLogs") + PublicShareBlockedUsers PublicShareBlockedUser[] @relation("PublicShareBlockedUsers") } model TerminalAuthRequest { @@ -91,23 +97,25 @@ model AccountPushToken { // model Session { - id String @id @default(cuid()) + id String @id @default(cuid()) tag String accountId String - account Account @relation(fields: [accountId], references: [id]) + account Account @relation(fields: [accountId], references: [id]) metadata String - metadataVersion Int @default(0) + metadataVersion Int @default(0) agentState String? - agentStateVersion Int @default(0) + agentStateVersion Int @default(0) dataEncryptionKey Bytes? - seq Int @default(0) - active Boolean @default(true) - lastActiveAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + seq Int @default(0) + active Boolean @default(true) + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt messages SessionMessage[] usageReports UsageReport[] accessKeys AccessKey[] + shares SessionShare[] + publicShare PublicSessionShare? @@unique([accountId, tag]) @@index([accountId, updatedAt(sort: Desc)]) @@ -361,3 +369,114 @@ model UserKVStore { @@unique([accountId, key]) @@index([accountId]) } + +// +// Session Sharing +// + +/// Access level for session sharing +enum ShareAccessLevel { + /// Read-only access - can view session content but cannot interact + view + /// Edit access - can send messages and approve tool execution + edit + /// Admin access - can manage sharing settings and archive session + admin +} + +/// Direct session share between users (friend-to-friend sharing) +model SessionShare { + id String @id @default(cuid()) + sessionId String + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + sharedByUserId String + sharedByUser Account @relation("SharedBySessions", fields: [sharedByUserId], references: [id]) + sharedWithUserId String + sharedWithUser Account @relation("SharedWithSessions", fields: [sharedWithUserId], references: [id]) + accessLevel ShareAccessLevel @default(view) + /// NaCl Box encrypted dataEncryptionKey for the recipient + encryptedDataKey Bytes + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs SessionShareAccessLog[] + + @@unique([sessionId, sharedWithUserId]) + @@index([sharedWithUserId]) + @@index([sharedByUserId]) + @@index([sessionId]) +} + +/// Access log for direct session shares +model SessionShareAccessLog { + id String @id @default(cuid()) + sessionShareId String + sessionShare SessionShare @relation(fields: [sessionShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("SessionShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([sessionShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Public session share via shareable link +model PublicSessionShare { + id String @id @default(cuid()) + sessionId String @unique + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + createdByUserId String + createdByUser Account @relation("PublicSessionShares", fields: [createdByUserId], references: [id]) + /// Random token for URL (e.g., /share/:token) + token String @unique + accessLevel ShareAccessLevel @default(view) + /// Encrypted dataEncryptionKey for public access + encryptedDataKey Bytes + /// Optional expiration time (null = no expiration) + expiresAt DateTime? + /// Maximum number of uses (null = unlimited) + maxUses Int? + /// Current use count + useCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessLogs PublicShareAccessLog[] + blockedUsers PublicShareBlockedUser[] + + @@index([token]) + @@index([sessionId]) +} + +/// Access log for public session shares +model PublicShareAccessLog { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + /// User ID if authenticated, null for anonymous access + userId String? + user Account? @relation("PublicShareAccessLogs", fields: [userId], references: [id]) + accessedAt DateTime @default(now()) + ipAddress String? + userAgent String? + + @@index([publicShareId]) + @@index([userId]) + @@index([accessedAt]) +} + +/// Blocked users for public session shares +model PublicShareBlockedUser { + id String @id @default(cuid()) + publicShareId String + publicShare PublicSessionShare @relation(fields: [publicShareId], references: [id], onDelete: Cascade) + userId String + user Account @relation("PublicShareBlockedUsers", fields: [userId], references: [id]) + blockedAt DateTime @default(now()) + reason String? + + @@unique([publicShareId, userId]) + @@index([publicShareId]) + @@index([userId]) +} From 8f2906a1c8fccb425aecda0750582bf336658da9 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:52:51 +0900 Subject: [PATCH 02/20] add: Add session access control functions Implement access control functions for session sharing including owner checks, permission validation, and public share access verification. Files: - sources/app/share/accessControl.ts --- sources/app/share/accessControl.ts | 210 +++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 sources/app/share/accessControl.ts diff --git a/sources/app/share/accessControl.ts b/sources/app/share/accessControl.ts new file mode 100644 index 0000000..3b325d1 --- /dev/null +++ b/sources/app/share/accessControl.ts @@ -0,0 +1,210 @@ +import { db } from "@/prisma"; +import { ShareAccessLevel } from "@prisma/client"; + +/** + * Access level for session sharing (including owner) + */ +export type AccessLevel = ShareAccessLevel | 'owner'; + +/** + * Session access information for a user + */ +export interface SessionAccess { + /** User ID requesting access */ + userId: string; + /** Session ID being accessed */ + sessionId: string; + /** Access level granted to user */ + level: AccessLevel; + /** Whether user is session owner */ + isOwner: boolean; +} + +/** + * Check user's access level for a session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns Session access info, or null if no access + */ +export async function checkSessionAccess( + userId: string, + sessionId: string +): Promise { + // First check if user owns the session + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { accountId: true } + }); + + if (!session) { + return null; + } + + if (session.accountId === userId) { + return { + userId, + sessionId, + level: 'owner', + isOwner: true + }; + } + + // Check if session is shared with user + const share = await db.sessionShare.findUnique({ + where: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + select: { accessLevel: true } + }); + + if (share) { + return { + userId, + sessionId, + level: share.accessLevel, + isOwner: false + }; + } + + return null; +} + +/** + * Check if user has required access level + * + * @param access - User's session access + * @param required - Required access level + * @returns True if user has sufficient access + */ +export function requireAccessLevel( + access: SessionAccess, + required: AccessLevel +): boolean { + const levels: AccessLevel[] = ['view', 'edit', 'admin', 'owner']; + const userLevel = levels.indexOf(access.level); + const requiredLevel = levels.indexOf(required); + return userLevel >= requiredLevel; +} + +/** + * Check if user can view session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can view session + */ +export async function canViewSession( + userId: string, + sessionId: string +): Promise { + const access = await checkSessionAccess(userId, sessionId); + return access !== null; +} + +/** + * Check if user can send messages to session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can send messages + */ +export async function canSendMessages( + userId: string, + sessionId: string +): Promise { + const access = await checkSessionAccess(userId, sessionId); + if (!access) return false; + return requireAccessLevel(access, 'edit'); +} + +/** + * Check if user can manage sharing settings + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user can manage sharing + */ +export async function canManageSharing( + userId: string, + sessionId: string +): Promise { + const access = await checkSessionAccess(userId, sessionId); + if (!access) return false; + return requireAccessLevel(access, 'admin'); +} + +/** + * Check if user owns the session + * + * @param userId - User ID requesting access + * @param sessionId - Session ID to check + * @returns True if user owns the session + */ +export async function isSessionOwner( + userId: string, + sessionId: string +): Promise { + const access = await checkSessionAccess(userId, sessionId); + return access?.isOwner ?? false; +} + +/** + * Check public share access with blocking and limits + * + * @param token - Public share token + * @param userId - User ID accessing (null for anonymous) + * @returns Public share info if valid, null otherwise + */ +export async function checkPublicShareAccess( + token: string, + userId: string | null +): Promise<{ + sessionId: string; + accessLevel: ShareAccessLevel; + publicShareId: string; +} | null> { + const publicShare = await db.publicSessionShare.findUnique({ + where: { token }, + select: { + id: true, + sessionId: true, + accessLevel: true, + expiresAt: true, + maxUses: true, + useCount: true, + blockedUsers: userId ? { + where: { userId }, + select: { id: true } + } : undefined + } + }); + + if (!publicShare) { + return null; + } + + // Check if expired + if (publicShare.expiresAt && publicShare.expiresAt < new Date()) { + return null; + } + + // Check if max uses exceeded + if (publicShare.maxUses && publicShare.useCount >= publicShare.maxUses) { + return null; + } + + // Check if user is blocked + if (userId && publicShare.blockedUsers && publicShare.blockedUsers.length > 0) { + return null; + } + + return { + sessionId: publicShare.sessionId, + accessLevel: publicShare.accessLevel, + publicShareId: publicShare.id + }; +} From 4fdc386db5ccf5d47db6c6393068b3a92e282f01 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:56:55 +0900 Subject: [PATCH 03/20] feat: Add session sharing API endpoints Implement REST API endpoints for user-to-user session sharing including create, update, delete shares and list shared sessions. Files: - sources/app/api/routes/shareRoutes.ts - sources/app/api/api.ts --- sources/app/api/api.ts | 2 + sources/app/api/routes/shareRoutes.ts | 385 ++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 sources/app/api/routes/shareRoutes.ts diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index e6db9e8..3a6dc6e 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -21,6 +21,7 @@ import { enableAuthentication } from "./utils/enableAuthentication"; import { userRoutes } from "./routes/userRoutes"; import { feedRoutes } from "./routes/feedRoutes"; import { kvRoutes } from "./routes/kvRoutes"; +import { shareRoutes } from "./routes/shareRoutes"; export async function startApi() { @@ -66,6 +67,7 @@ export async function startApi() { userRoutes(typed); feedRoutes(typed); kvRoutes(typed); + shareRoutes(typed); // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts new file mode 100644 index 0000000..4385a98 --- /dev/null +++ b/sources/app/api/routes/shareRoutes.ts @@ -0,0 +1,385 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { checkSessionAccess, canManageSharing, isSessionOwner } from "@/app/share/accessControl"; +import { ShareAccessLevel } from "@prisma/client"; + +/** + * Session sharing API routes + */ +export function shareRoutes(app: Fastify) { + + /** + * Get all shares for a session (owner/admin only) + */ + app.get('/v1/sessions/:sessionId/shares', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner or admin can view shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const shares = await db.sessionShare.findMany({ + where: { sessionId }, + include: { + sharedWithUser: { + select: { + id: true, + profile: true + } + } + }, + orderBy: { createdAt: 'desc' } + }); + + return reply.send({ + shares: shares.map(share => ({ + id: share.id, + sharedWithUser: { + id: share.sharedWithUser.id, + profile: share.sharedWithUser.profile + }, + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + })) + }); + }); + + /** + * Share session with a user + */ + app.post('/v1/sessions/:sessionId/shares', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + userId: z.string(), + accessLevel: z.enum(['view', 'edit', 'admin']), + encryptedDataKey: z.string() // base64 encoded + }) + } + }, async (request, reply) => { + const ownerId = request.userId; + const { sessionId } = request.params; + const { userId, accessLevel, encryptedDataKey } = request.body; + + // Only owner or admin can create shares + if (!await canManageSharing(ownerId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Cannot share with yourself + if (userId === ownerId) { + return reply.code(400).send({ error: 'Cannot share with yourself' }); + } + + // Verify target user exists + const targetUser = await db.account.findUnique({ + where: { id: userId } + }); + + if (!targetUser) { + return reply.code(404).send({ error: 'User not found' }); + } + + // Create or update share + const share = await db.sessionShare.upsert({ + where: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + create: { + sessionId, + sharedByUserId: ownerId, + sharedWithUserId: userId, + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey: Buffer.from(encryptedDataKey, 'base64') + }, + update: { + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey: Buffer.from(encryptedDataKey, 'base64') + }, + include: { + sharedWithUser: { + select: { + id: true, + profile: true + } + } + } + }); + + return reply.send({ + share: { + id: share.id, + sharedWithUser: { + id: share.sharedWithUser.id, + profile: share.sharedWithUser.profile + }, + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + } + }); + }); + + /** + * Update share access level + */ + app.patch('/v1/sessions/:sessionId/shares/:shareId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + shareId: z.string() + }), + body: z.object({ + accessLevel: z.enum(['view', 'edit', 'admin']) + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, shareId } = request.params; + const { accessLevel } = request.body; + + // Only owner or admin can update shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const share = await db.sessionShare.update({ + where: { id: shareId, sessionId }, + data: { accessLevel: accessLevel as ShareAccessLevel }, + include: { + sharedWithUser: { + select: { + id: true, + profile: true + } + } + } + }); + + return reply.send({ + share: { + id: share.id, + sharedWithUser: { + id: share.sharedWithUser.id, + profile: share.sharedWithUser.profile + }, + accessLevel: share.accessLevel, + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + } + }); + }); + + /** + * Delete share (revoke access) + */ + app.delete('/v1/sessions/:sessionId/shares/:shareId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + shareId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, shareId } = request.params; + + // Only owner or admin can delete shares + if (!await canManageSharing(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + await db.sessionShare.delete({ + where: { id: shareId, sessionId } + }); + + return reply.send({ success: true }); + }); + + /** + * Get sessions shared with current user + */ + app.get('/v1/shares/sessions', { + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + + const shares = await db.sessionShare.findMany({ + where: { sharedWithUserId: userId }, + include: { + session: { + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + active: true, + lastActiveAt: true + } + }, + sharedByUser: { + select: { + id: true, + profile: true + } + } + }, + orderBy: { createdAt: 'desc' } + }); + + return reply.send({ + shares: shares.map(share => ({ + id: share.id, + session: { + id: share.session.id, + seq: share.session.seq, + createdAt: share.session.createdAt.getTime(), + updatedAt: share.session.updatedAt.getTime(), + active: share.session.active, + activeAt: share.session.lastActiveAt.getTime(), + metadata: share.session.metadata, + metadataVersion: share.session.metadataVersion + }, + sharedBy: { + id: share.sharedByUser.id, + profile: share.sharedByUser.profile + }, + accessLevel: share.accessLevel, + encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), + createdAt: share.createdAt.getTime(), + updatedAt: share.updatedAt.getTime() + })) + }); + }); + + /** + * Get shared session details with encrypted key + */ + app.get('/v1/shares/sessions/:sessionId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + const access = await checkSessionAccess(userId, sessionId); + if (!access) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // If owner, return without share info + if (access.isOwner) { + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + dataEncryptionKey: true, + active: true, + lastActiveAt: true + } + }); + + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); + } + + return reply.send({ + session: { + id: session.id, + seq: session.seq, + createdAt: session.createdAt.getTime(), + updatedAt: session.updatedAt.getTime(), + active: session.active, + activeAt: session.lastActiveAt.getTime(), + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion, + dataEncryptionKey: session.dataEncryptionKey ? Buffer.from(session.dataEncryptionKey).toString('base64') : null + }, + accessLevel: access.level, + isOwner: true + }); + } + + // Get share with encrypted key + const share = await db.sessionShare.findUnique({ + where: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + include: { + session: { + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true + } + } + } + }); + + if (!share) { + return reply.code(404).send({ error: 'Share not found' }); + } + + return reply.send({ + session: { + id: share.session.id, + seq: share.session.seq, + createdAt: share.session.createdAt.getTime(), + updatedAt: share.session.updatedAt.getTime(), + active: share.session.active, + activeAt: share.session.lastActiveAt.getTime(), + metadata: share.session.metadata, + metadataVersion: share.session.metadataVersion, + agentState: share.session.agentState, + agentStateVersion: share.session.agentStateVersion + }, + accessLevel: share.accessLevel, + encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), + isOwner: false + }); + }); +} From 43eb44869617ba1f6f8ce0eb6927d4af11b709b1 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:00:25 +0900 Subject: [PATCH 04/20] change: Restrict public shares to view-only access Remove accessLevel field from PublicSessionShare model to enforce read-only access for all public links. This improves security by preventing unauthorized edits via public URLs. Files: - prisma/schema.prisma - prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql - sources/app/share/accessControl.ts --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 3 +-- sources/app/share/accessControl.ts | 5 ++--- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql diff --git a/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql b/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql new file mode 100644 index 0000000..d86a8f4 --- /dev/null +++ b/prisma/migrations/20260109050001_remove_public_share_access_level/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `accessLevel` on the `PublicSessionShare` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "PublicSessionShare" DROP COLUMN "accessLevel"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c11d7c0..a5b03ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -422,7 +422,7 @@ model SessionShareAccessLog { @@index([accessedAt]) } -/// Public session share via shareable link +/// Public session share via shareable link (always view-only for security) model PublicSessionShare { id String @id @default(cuid()) sessionId String @unique @@ -431,7 +431,6 @@ model PublicSessionShare { createdByUser Account @relation("PublicSessionShares", fields: [createdByUserId], references: [id]) /// Random token for URL (e.g., /share/:token) token String @unique - accessLevel ShareAccessLevel @default(view) /// Encrypted dataEncryptionKey for public access encryptedDataKey Bytes /// Optional expiration time (null = no expiration) diff --git a/sources/app/share/accessControl.ts b/sources/app/share/accessControl.ts index 3b325d1..6c8fd72 100644 --- a/sources/app/share/accessControl.ts +++ b/sources/app/share/accessControl.ts @@ -155,6 +155,8 @@ export async function isSessionOwner( /** * Check public share access with blocking and limits * + * Public shares are always view-only for security + * * @param token - Public share token * @param userId - User ID accessing (null for anonymous) * @returns Public share info if valid, null otherwise @@ -164,7 +166,6 @@ export async function checkPublicShareAccess( userId: string | null ): Promise<{ sessionId: string; - accessLevel: ShareAccessLevel; publicShareId: string; } | null> { const publicShare = await db.publicSessionShare.findUnique({ @@ -172,7 +173,6 @@ export async function checkPublicShareAccess( select: { id: true, sessionId: true, - accessLevel: true, expiresAt: true, maxUses: true, useCount: true, @@ -204,7 +204,6 @@ export async function checkPublicShareAccess( return { sessionId: publicShare.sessionId, - accessLevel: publicShare.accessLevel, publicShareId: publicShare.id }; } From 2739f3d75e98fa05a4f37077d239bcc2a40a47e5 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:03:36 +0900 Subject: [PATCH 05/20] feat: Add public share API endpoints Implement REST API endpoints for public session sharing including create, get, delete public links, user blocking, and access logs. Public shares are always view-only. Files: - sources/app/api/routes/publicShareRoutes.ts - sources/app/api/api.ts --- sources/app/api/api.ts | 2 + sources/app/api/routes/publicShareRoutes.ts | 432 ++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 sources/app/api/routes/publicShareRoutes.ts diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 3a6dc6e..8848488 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -22,6 +22,7 @@ import { userRoutes } from "./routes/userRoutes"; import { feedRoutes } from "./routes/feedRoutes"; import { kvRoutes } from "./routes/kvRoutes"; import { shareRoutes } from "./routes/shareRoutes"; +import { publicShareRoutes } from "./routes/publicShareRoutes"; export async function startApi() { @@ -68,6 +69,7 @@ export async function startApi() { feedRoutes(typed); kvRoutes(typed); shareRoutes(typed); + publicShareRoutes(typed); // Start HTTP const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts new file mode 100644 index 0000000..16c4889 --- /dev/null +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -0,0 +1,432 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { isSessionOwner, checkPublicShareAccess } from "@/app/share/accessControl"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; + +/** + * Public session sharing API routes + * + * Public shares are always view-only for security + */ +export function publicShareRoutes(app: Fastify) { + + /** + * Create or update public share for a session + */ + app.post('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + encryptedDataKey: z.string(), // base64 encoded + expiresAt: z.number().optional(), // timestamp + maxUses: z.number().int().positive().optional() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + const { encryptedDataKey, expiresAt, maxUses } = request.body; + + // Only owner can create public shares + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Check if public share already exists + const existing = await db.publicSessionShare.findUnique({ + where: { sessionId } + }); + + let publicShare; + if (existing) { + // Update existing share + publicShare = await db.publicSessionShare.update({ + where: { sessionId }, + data: { + encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null + } + }); + } else { + // Create new share with random token + const token = randomKeyNaked(); + publicShare = await db.publicSessionShare.create({ + data: { + sessionId, + createdByUserId: userId, + token, + encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null + } + }); + } + + return reply.send({ + publicShare: { + id: publicShare.id, + token: publicShare.token, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + useCount: publicShare.useCount, + createdAt: publicShare.createdAt.getTime(), + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + + /** + * Get public share info for a session + */ + app.get('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can view public share settings + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId } + }); + + if (!publicShare) { + return reply.send({ publicShare: null }); + } + + return reply.send({ + publicShare: { + id: publicShare.id, + token: publicShare.token, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + useCount: publicShare.useCount, + createdAt: publicShare.createdAt.getTime(), + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + + /** + * Delete public share (disable public link) + */ + app.delete('/v1/sessions/:sessionId/public-share', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can delete public share + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + await db.publicSessionShare.delete({ + where: { sessionId } + }).catch(() => { + // Ignore if doesn't exist + }); + + return reply.send({ success: true }); + }); + + /** + * Access session via public share token (no auth required) + */ + app.get('/v1/public-share/:token', { + schema: { + params: z.object({ + token: z.string() + }) + } + }, async (request, reply) => { + const { token } = request.params; + + // Try to get user ID if authenticated + let userId: string | null = null; + if (request.headers.authorization) { + try { + await app.authenticate(request, reply); + userId = request.userId; + } catch { + // Not authenticated, continue as anonymous + } + } + + const access = await checkPublicShareAccess(token, userId); + if (!access) { + return reply.code(404).send({ error: 'Public share not found or expired' }); + } + + // Increment use count + await db.publicSessionShare.update({ + where: { id: access.publicShareId }, + data: { useCount: { increment: 1 } } + }); + + // Get session info + const session = await db.session.findUnique({ + where: { id: access.sessionId }, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true + } + }); + + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); + } + + // Get encrypted key + const publicShare = await db.publicSessionShare.findUnique({ + where: { id: access.publicShareId }, + select: { encryptedDataKey: true } + }); + + return reply.send({ + session: { + id: session.id, + seq: session.seq, + createdAt: session.createdAt.getTime(), + updatedAt: session.updatedAt.getTime(), + active: session.active, + activeAt: session.lastActiveAt.getTime(), + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion + }, + accessLevel: 'view', + encryptedDataKey: publicShare ? Buffer.from(publicShare.encryptedDataKey).toString('base64') : null + }); + }); + + /** + * Get blocked users for public share + */ + app.get('/v1/sessions/:sessionId/public-share/blocked-users', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + + // Only owner can view blocked users + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const blockedUsers = await db.publicShareBlockedUser.findMany({ + where: { publicShareId: publicShare.id }, + include: { + user: { + select: { + id: true, + profile: true + } + } + }, + orderBy: { blockedAt: 'desc' } + }); + + return reply.send({ + blockedUsers: blockedUsers.map(bu => ({ + id: bu.id, + user: { + id: bu.user.id, + profile: bu.user.profile + }, + reason: bu.reason, + blockedAt: bu.blockedAt.getTime() + })) + }); + }); + + /** + * Block user from public share + */ + app.post('/v1/sessions/:sessionId/public-share/blocked-users', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + userId: z.string(), + reason: z.string().optional() + }) + } + }, async (request, reply) => { + const ownerId = request.userId; + const { sessionId } = request.params; + const { userId, reason } = request.body; + + // Only owner can block users + if (!await isSessionOwner(ownerId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const blockedUser = await db.publicShareBlockedUser.create({ + data: { + publicShareId: publicShare.id, + userId, + reason: reason ?? null + }, + include: { + user: { + select: { + id: true, + profile: true + } + } + } + }); + + return reply.send({ + blockedUser: { + id: blockedUser.id, + user: { + id: blockedUser.user.id, + profile: blockedUser.user.profile + }, + reason: blockedUser.reason, + blockedAt: blockedUser.blockedAt.getTime() + } + }); + }); + + /** + * Unblock user from public share + */ + app.delete('/v1/sessions/:sessionId/public-share/blocked-users/:blockedUserId', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string(), + blockedUserId: z.string() + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId, blockedUserId } = request.params; + + // Only owner can unblock users + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + await db.publicShareBlockedUser.delete({ + where: { id: blockedUserId } + }); + + return reply.send({ success: true }); + }); + + /** + * Get access logs for public share + */ + app.get('/v1/sessions/:sessionId/public-share/access-logs', { + preHandler: app.authenticate, + schema: { + params: z.object({ + sessionId: z.string() + }), + querystring: z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50) + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + const limit = request.query?.limit || 50; + + // Only owner can view access logs + if (!await isSessionOwner(userId, sessionId)) { + return reply.code(403).send({ error: 'Forbidden' }); + } + + const publicShare = await db.publicSessionShare.findUnique({ + where: { sessionId }, + select: { id: true } + }); + + if (!publicShare) { + return reply.code(404).send({ error: 'Public share not found' }); + } + + const logs = await db.publicShareAccessLog.findMany({ + where: { publicShareId: publicShare.id }, + include: { + user: { + select: { + id: true, + profile: true + } + } + }, + orderBy: { accessedAt: 'desc' }, + take: limit + }); + + return reply.send({ + logs: logs.map(log => ({ + id: log.id, + user: log.user ? { + id: log.user.id, + profile: log.user.profile + } : null, + accessedAt: log.accessedAt.getTime(), + ipAddress: log.ipAddress, + userAgent: log.userAgent + })) + }); + }); +} From eb0034a1e0f48d56932f71c04fdec67e67d9c263 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:24:55 +0900 Subject: [PATCH 06/20] feat: Add consent-based access logging system Implement privacy-friendly access logging with explicit user consent. Public shares can require consent to view, enabling detailed IP/UA logging only when users agree. Files: - prisma/schema.prisma - prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql - prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql - sources/app/share/accessLogger.ts - sources/app/api/routes/publicShareRoutes.ts - sources/app/api/routes/shareRoutes.ts --- .../migration.sql | 2 + .../migration.sql | 9 ++ prisma/schema.prisma | 2 + sources/app/api/routes/publicShareRoutes.ts | 40 +++++++-- sources/app/api/routes/shareRoutes.ts | 6 ++ sources/app/share/accessLogger.ts | 83 +++++++++++++++++++ 6 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql create mode 100644 prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql create mode 100644 sources/app/share/accessLogger.ts diff --git a/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql b/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql new file mode 100644 index 0000000..abea51d --- /dev/null +++ b/prisma/migrations/20260109051716_add_log_access_to_public_share/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PublicSessionShare" ADD COLUMN "logAccess" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql b/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql new file mode 100644 index 0000000..6101003 --- /dev/null +++ b/prisma/migrations/20260109052146_rename_log_access_to_is_consent_required/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `logAccess` on the `PublicSessionShare` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "PublicSessionShare" DROP COLUMN "logAccess", +ADD COLUMN "isConsentRequired" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5b03ac..4957cb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -439,6 +439,8 @@ model PublicSessionShare { maxUses Int? /// Current use count useCount Int @default(0) + /// Whether user consent is required to view (enables detailed access logging) + isConsentRequired Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt accessLogs PublicShareAccessLog[] diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 16c4889..81d1577 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -3,6 +3,7 @@ import { db } from "@/storage/db"; import { z } from "zod"; import { isSessionOwner, checkPublicShareAccess } from "@/app/share/accessControl"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; /** * Public session sharing API routes @@ -23,13 +24,14 @@ export function publicShareRoutes(app: Fastify) { body: z.object({ encryptedDataKey: z.string(), // base64 encoded expiresAt: z.number().optional(), // timestamp - maxUses: z.number().int().positive().optional() + maxUses: z.number().int().positive().optional(), + isConsentRequired: z.boolean().optional() // require consent for detailed logging }) } }, async (request, reply) => { const userId = request.userId; const { sessionId } = request.params; - const { encryptedDataKey, expiresAt, maxUses } = request.body; + const { encryptedDataKey, expiresAt, maxUses, isConsentRequired } = request.body; // Only owner can create public shares if (!await isSessionOwner(userId, sessionId)) { @@ -49,7 +51,8 @@ export function publicShareRoutes(app: Fastify) { data: { encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), expiresAt: expiresAt ? new Date(expiresAt) : null, - maxUses: maxUses ?? null + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false } }); } else { @@ -62,7 +65,8 @@ export function publicShareRoutes(app: Fastify) { token, encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), expiresAt: expiresAt ? new Date(expiresAt) : null, - maxUses: maxUses ?? null + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false } }); } @@ -74,6 +78,7 @@ export function publicShareRoutes(app: Fastify) { expiresAt: publicShare.expiresAt?.getTime() ?? null, maxUses: publicShare.maxUses, useCount: publicShare.useCount, + isConsentRequired: publicShare.isConsentRequired, createdAt: publicShare.createdAt.getTime(), updatedAt: publicShare.updatedAt.getTime() } @@ -114,6 +119,7 @@ export function publicShareRoutes(app: Fastify) { expiresAt: publicShare.expiresAt?.getTime() ?? null, maxUses: publicShare.maxUses, useCount: publicShare.useCount, + isConsentRequired: publicShare.isConsentRequired, createdAt: publicShare.createdAt.getTime(), updatedAt: publicShare.updatedAt.getTime() } @@ -150,15 +156,21 @@ export function publicShareRoutes(app: Fastify) { /** * Access session via public share token (no auth required) + * + * If isConsentRequired is true, client must pass consent=true query param */ app.get('/v1/public-share/:token', { schema: { params: z.object({ token: z.string() - }) + }), + querystring: z.object({ + consent: z.coerce.boolean().optional() + }).optional() } }, async (request, reply) => { const { token } = request.params; + const { consent } = request.query || {}; // Try to get user ID if authenticated let userId: string | null = null; @@ -176,6 +188,24 @@ export function publicShareRoutes(app: Fastify) { return reply.code(404).send({ error: 'Public share not found or expired' }); } + // Check if consent is required + const publicShare = await db.publicSessionShare.findUnique({ + where: { id: access.publicShareId }, + select: { isConsentRequired: true } + }); + + if (publicShare?.isConsentRequired && !consent) { + return reply.code(403).send({ + error: 'Consent required', + requiresConsent: true + }); + } + + // Log access (only log IP/UA if consent was given) + const ipAddress = publicShare?.isConsentRequired ? getIpAddress(request.headers) : undefined; + const userAgent = publicShare?.isConsentRequired ? getUserAgent(request.headers) : undefined; + await logPublicShareAccess(access.publicShareId, userId, ipAddress, userAgent); + // Increment use count await db.publicSessionShare.update({ where: { id: access.publicShareId }, diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 4385a98..19b9cc9 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -3,6 +3,7 @@ import { db } from "@/storage/db"; import { z } from "zod"; import { checkSessionAccess, canManageSharing, isSessionOwner } from "@/app/share/accessControl"; import { ShareAccessLevel } from "@prisma/client"; +import { logSessionShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; /** * Session sharing API routes @@ -364,6 +365,11 @@ export function shareRoutes(app: Fastify) { return reply.code(404).send({ error: 'Share not found' }); } + // Log access + const ipAddress = getIpAddress(request.headers); + const userAgent = getUserAgent(request.headers); + await logSessionShareAccess(share.id, userId, ipAddress, userAgent); + return reply.send({ session: { id: share.session.id, diff --git a/sources/app/share/accessLogger.ts b/sources/app/share/accessLogger.ts new file mode 100644 index 0000000..58c72dd --- /dev/null +++ b/sources/app/share/accessLogger.ts @@ -0,0 +1,83 @@ +import { db } from "@/storage/db"; + +/** + * Log access to a direct session share + * + * @param sessionShareId - Session share ID + * @param userId - User ID who accessed + * @param ipAddress - IP address (optional) + * @param userAgent - User agent (optional) + */ +export async function logSessionShareAccess( + sessionShareId: string, + userId: string, + ipAddress?: string, + userAgent?: string +): Promise { + await db.sessionShareAccessLog.create({ + data: { + sessionShareId, + userId, + ipAddress: ipAddress ?? null, + userAgent: userAgent ?? null + } + }); +} + +/** + * Log access to a public session share + * + * @param publicShareId - Public share ID + * @param userId - User ID who accessed (null for anonymous) + * @param ipAddress - IP address (optional) + * @param userAgent - User agent (optional) + */ +export async function logPublicShareAccess( + publicShareId: string, + userId: string | null, + ipAddress?: string, + userAgent?: string +): Promise { + await db.publicShareAccessLog.create({ + data: { + publicShareId, + userId: userId ?? null, + ipAddress: ipAddress ?? null, + userAgent: userAgent ?? null + } + }); +} + +/** + * Get IP address from request + * + * @param headers - Request headers + * @returns IP address or undefined + */ +export function getIpAddress(headers: Record): string | undefined { + // Check common headers for IP address + const forwardedFor = headers['x-forwarded-for']; + if (forwardedFor) { + const ip = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + return ip.split(',')[0].trim(); + } + + const realIp = headers['x-real-ip']; + if (realIp) { + return Array.isArray(realIp) ? realIp[0] : realIp; + } + + return undefined; +} + +/** + * Get user agent from request + * + * @param headers - Request headers + * @returns User agent or undefined + */ +export function getUserAgent(headers: Record): string | undefined { + const userAgent = headers['user-agent']; + if (!userAgent) return undefined; + return Array.isArray(userAgent) ? userAgent[0] : userAgent; +} From 3a38e30766e2d09e22b7fab8aa2135a410047e0c Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:32:21 +0900 Subject: [PATCH 07/20] fix: Resolve TypeScript type errors in sharing routes Add common profile type definition and fix Buffer type casting issues. All type errors resolved. Files: - sources/app/share/types.ts - sources/app/api/routes/shareRoutes.ts - sources/app/api/routes/publicShareRoutes.ts - sources/app/share/accessControl.ts --- sources/app/api/routes/publicShareRoutes.ts | 48 ++++++--------------- sources/app/api/routes/shareRoutes.ts | 45 +++++-------------- sources/app/share/accessControl.ts | 2 +- sources/app/share/types.ts | 21 +++++++++ 4 files changed, 47 insertions(+), 69 deletions(-) create mode 100644 sources/app/share/types.ts diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 81d1577..00008e5 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isSessionOwner, checkPublicShareAccess } from "@/app/share/accessControl"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; +import { PROFILE_SELECT } from "@/app/share/types"; /** * Public session sharing API routes @@ -49,7 +50,7 @@ export function publicShareRoutes(app: Fastify) { publicShare = await db.publicSessionShare.update({ where: { sessionId }, data: { - encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), expiresAt: expiresAt ? new Date(expiresAt) : null, maxUses: maxUses ?? null, isConsentRequired: isConsentRequired ?? false @@ -63,7 +64,7 @@ export function publicShareRoutes(app: Fastify) { sessionId, createdByUserId: userId, token, - encryptedDataKey: Buffer.from(encryptedDataKey, 'base64'), + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), expiresAt: expiresAt ? new Date(expiresAt) : null, maxUses: maxUses ?? null, isConsentRequired: isConsentRequired ?? false @@ -188,10 +189,13 @@ export function publicShareRoutes(app: Fastify) { return reply.code(404).send({ error: 'Public share not found or expired' }); } - // Check if consent is required + // Check if consent is required and get encrypted key const publicShare = await db.publicSessionShare.findUnique({ where: { id: access.publicShareId }, - select: { isConsentRequired: true } + select: { + isConsentRequired: true, + encryptedDataKey: true + } }); if (publicShare?.isConsentRequired && !consent) { @@ -233,12 +237,6 @@ export function publicShareRoutes(app: Fastify) { return reply.code(404).send({ error: 'Session not found' }); } - // Get encrypted key - const publicShare = await db.publicSessionShare.findUnique({ - where: { id: access.publicShareId }, - select: { encryptedDataKey: true } - }); - return reply.send({ session: { id: session.id, @@ -289,10 +287,7 @@ export function publicShareRoutes(app: Fastify) { where: { publicShareId: publicShare.id }, include: { user: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } }, orderBy: { blockedAt: 'desc' } @@ -301,10 +296,7 @@ export function publicShareRoutes(app: Fastify) { return reply.send({ blockedUsers: blockedUsers.map(bu => ({ id: bu.id, - user: { - id: bu.user.id, - profile: bu.user.profile - }, + user: bu.user, reason: bu.reason, blockedAt: bu.blockedAt.getTime() })) @@ -352,10 +344,7 @@ export function publicShareRoutes(app: Fastify) { }, include: { user: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } } }); @@ -363,10 +352,7 @@ export function publicShareRoutes(app: Fastify) { return reply.send({ blockedUser: { id: blockedUser.id, - user: { - id: blockedUser.user.id, - profile: blockedUser.user.profile - }, + user: blockedUser.user, reason: blockedUser.reason, blockedAt: blockedUser.blockedAt.getTime() } @@ -436,10 +422,7 @@ export function publicShareRoutes(app: Fastify) { where: { publicShareId: publicShare.id }, include: { user: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } }, orderBy: { accessedAt: 'desc' }, @@ -449,10 +432,7 @@ export function publicShareRoutes(app: Fastify) { return reply.send({ logs: logs.map(log => ({ id: log.id, - user: log.user ? { - id: log.user.id, - profile: log.user.profile - } : null, + user: log.user || null, accessedAt: log.accessedAt.getTime(), ipAddress: log.ipAddress, userAgent: log.userAgent diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 19b9cc9..a18393c 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { checkSessionAccess, canManageSharing, isSessionOwner } from "@/app/share/accessControl"; import { ShareAccessLevel } from "@prisma/client"; import { logSessionShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; +import { PROFILE_SELECT } from "@/app/share/types"; /** * Session sharing API routes @@ -33,10 +34,7 @@ export function shareRoutes(app: Fastify) { where: { sessionId }, include: { sharedWithUser: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } }, orderBy: { createdAt: 'desc' } @@ -45,10 +43,7 @@ export function shareRoutes(app: Fastify) { return reply.send({ shares: shares.map(share => ({ id: share.id, - sharedWithUser: { - id: share.sharedWithUser.id, - profile: share.sharedWithUser.profile - }, + sharedWithUser: share.sharedWithUser, accessLevel: share.accessLevel, createdAt: share.createdAt.getTime(), updatedAt: share.updatedAt.getTime() @@ -108,18 +103,15 @@ export function shareRoutes(app: Fastify) { sharedByUserId: ownerId, sharedWithUserId: userId, accessLevel: accessLevel as ShareAccessLevel, - encryptedDataKey: Buffer.from(encryptedDataKey, 'base64') + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) }, update: { accessLevel: accessLevel as ShareAccessLevel, - encryptedDataKey: Buffer.from(encryptedDataKey, 'base64') + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) }, include: { sharedWithUser: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } } }); @@ -127,10 +119,7 @@ export function shareRoutes(app: Fastify) { return reply.send({ share: { id: share.id, - sharedWithUser: { - id: share.sharedWithUser.id, - profile: share.sharedWithUser.profile - }, + sharedWithUser: share.sharedWithUser, accessLevel: share.accessLevel, createdAt: share.createdAt.getTime(), updatedAt: share.updatedAt.getTime() @@ -167,10 +156,7 @@ export function shareRoutes(app: Fastify) { data: { accessLevel: accessLevel as ShareAccessLevel }, include: { sharedWithUser: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } } }); @@ -178,10 +164,7 @@ export function shareRoutes(app: Fastify) { return reply.send({ share: { id: share.id, - sharedWithUser: { - id: share.sharedWithUser.id, - profile: share.sharedWithUser.profile - }, + sharedWithUser: share.sharedWithUser, accessLevel: share.accessLevel, createdAt: share.createdAt.getTime(), updatedAt: share.updatedAt.getTime() @@ -240,10 +223,7 @@ export function shareRoutes(app: Fastify) { } }, sharedByUser: { - select: { - id: true, - profile: true - } + select: PROFILE_SELECT } }, orderBy: { createdAt: 'desc' } @@ -262,10 +242,7 @@ export function shareRoutes(app: Fastify) { metadata: share.session.metadata, metadataVersion: share.session.metadataVersion }, - sharedBy: { - id: share.sharedByUser.id, - profile: share.sharedByUser.profile - }, + sharedBy: share.sharedByUser, accessLevel: share.accessLevel, encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), createdAt: share.createdAt.getTime(), diff --git a/sources/app/share/accessControl.ts b/sources/app/share/accessControl.ts index 6c8fd72..c099a63 100644 --- a/sources/app/share/accessControl.ts +++ b/sources/app/share/accessControl.ts @@ -1,4 +1,4 @@ -import { db } from "@/prisma"; +import { db } from "@/storage/db"; import { ShareAccessLevel } from "@prisma/client"; /** diff --git a/sources/app/share/types.ts b/sources/app/share/types.ts new file mode 100644 index 0000000..410dd57 --- /dev/null +++ b/sources/app/share/types.ts @@ -0,0 +1,21 @@ +/** + * Common select for user profile information + */ +export const PROFILE_SELECT = { + id: true, + firstName: true, + lastName: true, + username: true, + avatar: true +} as const; + +/** + * User profile type (inferred from PROFILE_SELECT) + */ +export type UserProfile = { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; // JSON field +}; From 29b864e2a788a7bbc126d66ce332b6df573252d8 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:08:32 +0900 Subject: [PATCH 08/20] add: Add Socket.io event types for session sharing Define new update event types for real-time sharing notifications. Includes session-shared, share-updated, share-revoked, and public share events with corresponding builder functions. - sources/app/events/eventRouter.ts --- sources/app/events/eventRouter.ts | 180 ++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/sources/app/events/eventRouter.ts b/sources/app/events/eventRouter.ts index 6ba61fe..2971b29 100644 --- a/sources/app/events/eventRouter.ts +++ b/sources/app/events/eventRouter.ts @@ -152,6 +152,50 @@ export type UpdateEvent = { value: string | null; // null indicates deletion version: number; // -1 for deleted keys }>; +} | { + type: 'session-shared'; + sessionId: string; + shareId: string; + sharedBy: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; + }; + accessLevel: 'view' | 'edit' | 'admin'; + encryptedDataKey: string; + createdAt: number; +} | { + type: 'session-share-updated'; + sessionId: string; + shareId: string; + accessLevel: 'view' | 'edit' | 'admin'; + updatedAt: number; +} | { + type: 'session-share-revoked'; + sessionId: string; + shareId: string; +} | { + type: 'public-share-created'; + sessionId: string; + publicShareId: string; + token: string; + expiresAt: number | null; + maxUses: number | null; + isConsentRequired: boolean; + createdAt: number; +} | { + type: 'public-share-updated'; + sessionId: string; + publicShareId: string; + expiresAt: number | null; + maxUses: number | null; + isConsentRequired: boolean; + updatedAt: number; +} | { + type: 'public-share-deleted'; + sessionId: string; }; // === EPHEMERAL EVENT TYPES (Transient) === @@ -631,3 +675,139 @@ export function buildKVBatchUpdateUpdate( createdAt: Date.now() }; } + +export function buildSessionSharedUpdate(share: { + id: string; + sessionId: string; + sharedByUser: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + avatar: any | null; + }; + accessLevel: 'view' | 'edit' | 'admin'; + encryptedDataKey: Uint8Array; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-shared', + sessionId: share.sessionId, + shareId: share.id, + sharedBy: share.sharedByUser, + accessLevel: share.accessLevel, + encryptedDataKey: Buffer.from(share.encryptedDataKey).toString('base64'), + createdAt: share.createdAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildSessionShareUpdatedUpdate( + shareId: string, + sessionId: string, + accessLevel: 'view' | 'edit' | 'admin', + updatedAt: Date, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-share-updated', + sessionId, + shareId, + accessLevel, + updatedAt: updatedAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildSessionShareRevokedUpdate( + shareId: string, + sessionId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'session-share-revoked', + sessionId, + shareId + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareCreatedUpdate(publicShare: { + id: string; + sessionId: string; + token: string; + expiresAt: Date | null; + maxUses: number | null; + isConsentRequired: boolean; + createdAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-created', + sessionId: publicShare.sessionId, + publicShareId: publicShare.id, + token: publicShare.token, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + isConsentRequired: publicShare.isConsentRequired, + createdAt: publicShare.createdAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareUpdatedUpdate(publicShare: { + id: string; + sessionId: string; + expiresAt: Date | null; + maxUses: number | null; + isConsentRequired: boolean; + updatedAt: Date; +}, updateSeq: number, updateId: string): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-updated', + sessionId: publicShare.sessionId, + publicShareId: publicShare.id, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + isConsentRequired: publicShare.isConsentRequired, + updatedAt: publicShare.updatedAt.getTime() + }, + createdAt: Date.now() + }; +} + +export function buildPublicShareDeletedUpdate( + sessionId: string, + updateSeq: number, + updateId: string +): UpdatePayload { + return { + id: updateId, + seq: updateSeq, + body: { + t: 'public-share-deleted', + sessionId + }, + createdAt: Date.now() + }; +} From 07b5b33e29df61344cb74a92b3e66fe4e6d2703c Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:08:50 +0900 Subject: [PATCH 09/20] feat: Emit real-time events on session share changes Broadcast Socket.io events when sessions are shared, updated, or revoked. Shared users receive instant notifications about their access changes. - sources/app/api/routes/shareRoutes.ts --- sources/app/api/routes/shareRoutes.ts | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index a18393c..4744b8e 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -5,6 +5,9 @@ import { checkSessionAccess, canManageSharing, isSessionOwner } from "@/app/shar import { ShareAccessLevel } from "@prisma/client"; import { logSessionShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; import { PROFILE_SELECT } from "@/app/share/types"; +import { eventRouter, buildSessionSharedUpdate, buildSessionShareUpdatedUpdate, buildSessionShareRevokedUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; +import { randomKeyNaked } from "@/utils/randomKeyNaked"; /** * Session sharing API routes @@ -112,10 +115,22 @@ export function shareRoutes(app: Fastify) { include: { sharedWithUser: { select: PROFILE_SELECT + }, + sharedByUser: { + select: PROFILE_SELECT } } }); + // Emit real-time update to shared user + const updateSeq = await allocateUserSeq(userId); + const updatePayload = buildSessionSharedUpdate(share, updateSeq, randomKeyNaked(12)); + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + return reply.send({ share: { id: share.id, @@ -161,6 +176,22 @@ export function shareRoutes(app: Fastify) { } }); + // Emit real-time update to shared user + const updateSeq = await allocateUserSeq(share.sharedWithUserId); + const updatePayload = buildSessionShareUpdatedUpdate( + share.id, + share.sessionId, + share.accessLevel, + share.updatedAt, + updateSeq, + randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: share.sharedWithUserId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + return reply.send({ share: { id: share.id, @@ -192,10 +223,33 @@ export function shareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Forbidden' }); } + // Get share before deleting + const share = await db.sessionShare.findUnique({ + where: { id: shareId, sessionId } + }); + + if (!share) { + return reply.code(404).send({ error: 'Share not found' }); + } + await db.sessionShare.delete({ where: { id: shareId, sessionId } }); + // Emit real-time update to shared user + const updateSeq = await allocateUserSeq(share.sharedWithUserId); + const updatePayload = buildSessionShareRevokedUpdate( + share.id, + share.sessionId, + updateSeq, + randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: share.sharedWithUserId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + return reply.send({ success: true }); }); From 4e6d33232308c2c7a97b3d05f28b0aa9f8bb5902 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:09:07 +0900 Subject: [PATCH 10/20] feat: Emit real-time events on public share changes Broadcast Socket.io events when public links are created, updated, or deleted. Session owners receive instant notifications about their public sharing status. - sources/app/api/routes/publicShareRoutes.ts --- sources/app/api/routes/publicShareRoutes.ts | 41 +++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 00008e5..4abcce2 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -5,6 +5,8 @@ import { isSessionOwner, checkPublicShareAccess } from "@/app/share/accessContro import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; import { PROFILE_SELECT } from "@/app/share/types"; +import { eventRouter, buildPublicShareCreatedUpdate, buildPublicShareUpdatedUpdate, buildPublicShareDeletedUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; /** * Public session sharing API routes @@ -45,6 +47,8 @@ export function publicShareRoutes(app: Fastify) { }); let publicShare; + const isUpdate = !!existing; + if (existing) { // Update existing share publicShare = await db.publicSessionShare.update({ @@ -72,6 +76,18 @@ export function publicShareRoutes(app: Fastify) { }); } + // Emit real-time update to session owner + const updateSeq = await allocateUserSeq(userId); + const updatePayload = isUpdate + ? buildPublicShareUpdatedUpdate(publicShare, updateSeq, randomKeyNaked(12)) + : buildPublicShareCreatedUpdate(publicShare, updateSeq, randomKeyNaked(12)); + + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-interested-in-session', sessionId } + }); + return reply.send({ publicShare: { id: publicShare.id, @@ -146,12 +162,31 @@ export function publicShareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Forbidden' }); } - await db.publicSessionShare.delete({ + // Check if share exists + const existing = await db.publicSessionShare.findUnique({ where: { sessionId } - }).catch(() => { - // Ignore if doesn't exist }); + if (existing) { + await db.publicSessionShare.delete({ + where: { sessionId } + }); + + // Emit real-time update to session owner + const updateSeq = await allocateUserSeq(userId); + const updatePayload = buildPublicShareDeletedUpdate( + sessionId, + updateSeq, + randomKeyNaked(12) + ); + + eventRouter.emitUpdate({ + userId: userId, + payload: updatePayload, + recipientFilter: { type: 'all-interested-in-session', sessionId } + }); + } + return reply.send({ success: true }); }); From a373ee542bcf8bc7af3ef9ee959f6a7d4e38ac38 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:18:05 +0900 Subject: [PATCH 11/20] add: Add comprehensive tests for session sharing Add unit tests covering access control, logging, and event builders. Tests validate permission checks, IP extraction, consent-based logging, and Socket.io event payload construction. - sources/app/share/accessControl.spec.ts - sources/app/share/accessLogger.spec.ts - sources/app/events/sharingEvents.spec.ts - vitest.config.ts --- sources/app/events/sharingEvents.spec.ts | 189 +++++++++++++++++ sources/app/share/accessControl.spec.ts | 250 +++++++++++++++++++++++ sources/app/share/accessLogger.spec.ts | 156 ++++++++++++++ vitest.config.ts | 8 + 4 files changed, 603 insertions(+) create mode 100644 sources/app/events/sharingEvents.spec.ts create mode 100644 sources/app/share/accessControl.spec.ts create mode 100644 sources/app/share/accessLogger.spec.ts diff --git a/sources/app/events/sharingEvents.spec.ts b/sources/app/events/sharingEvents.spec.ts new file mode 100644 index 0000000..e5c8370 --- /dev/null +++ b/sources/app/events/sharingEvents.spec.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { + buildSessionSharedUpdate, + buildSessionShareUpdatedUpdate, + buildSessionShareRevokedUpdate, + buildPublicShareCreatedUpdate, + buildPublicShareUpdatedUpdate, + buildPublicShareDeletedUpdate +} from './eventRouter'; + +describe('Sharing Event Builders', () => { + describe('buildSessionSharedUpdate', () => { + it('should build session-shared update event', () => { + const share = { + id: 'share-1', + sessionId: 'session-1', + sharedByUser: { + id: 'user-owner', + firstName: 'John', + lastName: 'Doe', + username: 'johndoe', + avatar: null + }, + accessLevel: 'view' as const, + encryptedDataKey: new Uint8Array([1, 2, 3, 4]), + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildSessionSharedUpdate(share, 100, 'update-id-1'); + + expect(result).toMatchObject({ + id: 'update-id-1', + seq: 100, + body: { + t: 'session-shared', + sessionId: 'session-1', + shareId: 'share-1', + sharedBy: share.sharedByUser, + accessLevel: 'view', + encryptedDataKey: expect.any(String), + createdAt: share.createdAt.getTime() + } + }); + expect(result.createdAt).toBeGreaterThan(0); + }); + }); + + describe('buildSessionShareUpdatedUpdate', () => { + it('should build session-share-updated event', () => { + const updatedAt = new Date('2025-01-09T13:00:00Z'); + const result = buildSessionShareUpdatedUpdate( + 'share-1', + 'session-1', + 'edit', + updatedAt, + 101, + 'update-id-2' + ); + + expect(result).toMatchObject({ + id: 'update-id-2', + seq: 101, + body: { + t: 'session-share-updated', + sessionId: 'session-1', + shareId: 'share-1', + accessLevel: 'edit', + updatedAt: updatedAt.getTime() + } + }); + }); + }); + + describe('buildSessionShareRevokedUpdate', () => { + it('should build session-share-revoked event', () => { + const result = buildSessionShareRevokedUpdate( + 'share-1', + 'session-1', + 102, + 'update-id-3' + ); + + expect(result).toMatchObject({ + id: 'update-id-3', + seq: 102, + body: { + t: 'session-share-revoked', + sessionId: 'session-1', + shareId: 'share-1' + } + }); + }); + }); + + describe('buildPublicShareCreatedUpdate', () => { + it('should build public-share-created event with all fields', () => { + const publicShare = { + id: 'public-1', + sessionId: 'session-1', + token: 'abc123', + expiresAt: new Date('2025-02-09T12:00:00Z'), + maxUses: 100, + isConsentRequired: true, + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildPublicShareCreatedUpdate(publicShare, 103, 'update-id-4'); + + expect(result).toMatchObject({ + id: 'update-id-4', + seq: 103, + body: { + t: 'public-share-created', + sessionId: 'session-1', + publicShareId: 'public-1', + token: 'abc123', + expiresAt: publicShare.expiresAt.getTime(), + maxUses: 100, + isConsentRequired: true, + createdAt: publicShare.createdAt.getTime() + } + }); + }); + + it('should handle null expiration and max uses', () => { + const publicShare = { + id: 'public-2', + sessionId: 'session-2', + token: 'xyz789', + expiresAt: null, + maxUses: null, + isConsentRequired: false, + createdAt: new Date('2025-01-09T12:00:00Z') + }; + + const result = buildPublicShareCreatedUpdate(publicShare, 104, 'update-id-5'); + + expect(result.body).toMatchObject({ + expiresAt: null, + maxUses: null, + isConsentRequired: false + }); + }); + }); + + describe('buildPublicShareUpdatedUpdate', () => { + it('should build public-share-updated event', () => { + const publicShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: new Date('2025-02-10T12:00:00Z'), + maxUses: 200, + isConsentRequired: false, + updatedAt: new Date('2025-01-09T14:00:00Z') + }; + + const result = buildPublicShareUpdatedUpdate(publicShare, 105, 'update-id-6'); + + expect(result).toMatchObject({ + id: 'update-id-6', + seq: 105, + body: { + t: 'public-share-updated', + sessionId: 'session-1', + publicShareId: 'public-1', + expiresAt: publicShare.expiresAt.getTime(), + maxUses: 200, + isConsentRequired: false, + updatedAt: publicShare.updatedAt.getTime() + } + }); + }); + }); + + describe('buildPublicShareDeletedUpdate', () => { + it('should build public-share-deleted event', () => { + const result = buildPublicShareDeletedUpdate('session-1', 106, 'update-id-7'); + + expect(result).toMatchObject({ + id: 'update-id-7', + seq: 106, + body: { + t: 'public-share-deleted', + sessionId: 'session-1' + } + }); + }); + }); +}); diff --git a/sources/app/share/accessControl.spec.ts b/sources/app/share/accessControl.spec.ts new file mode 100644 index 0000000..0460ec4 --- /dev/null +++ b/sources/app/share/accessControl.spec.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { checkSessionAccess, checkPublicShareAccess, isSessionOwner, canManageSharing } from './accessControl'; +import { db } from '@/storage/db'; + +vi.mock('@/storage/db', () => ({ + db: { + session: { + findUnique: vi.fn() + }, + sessionShare: { + findUnique: vi.fn() + }, + publicSessionShare: { + findUnique: vi.fn() + } + } +})); + +describe('accessControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('checkSessionAccess', () => { + it('should return owner access when user owns the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toEqual({ + userId: 'user-1', + sessionId: 'session-1', + level: 'owner', + isOwner: true + }); + }); + + it('should return null when session does not exist', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue(null); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toBeNull(); + }); + + it('should return shared access level when session is shared with user', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'view' + } as any); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toEqual({ + userId: 'user-1', + sessionId: 'session-1', + level: 'view', + isOwner: false + }); + }); + + it('should return null when user has no access to session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue(null); + + const result = await checkSessionAccess('user-1', 'session-1'); + + expect(result).toBeNull(); + }); + }); + + describe('checkPublicShareAccess', () => { + it('should return access info for valid token', async () => { + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: null, + maxUses: null, + useCount: 5, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toEqual({ + sessionId: 'session-1', + publicShareId: 'public-1' + }); + }); + + it('should return null for invalid token', async () => { + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(null); + + const result = await checkPublicShareAccess('invalid-token', null); + + expect(result).toBeNull(); + }); + + it('should return null for expired shares', async () => { + const pastDate = new Date(Date.now() - 1000 * 60 * 60); // 1 hour ago + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: pastDate, + maxUses: null, + useCount: 0, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toBeNull(); + }); + + it('should return null when max uses reached', async () => { + const mockShare = { + id: 'public-1', + sessionId: 'session-1', + expiresAt: null, + maxUses: 10, + useCount: 10, + blockedUsers: [] + }; + + vi.mocked(db.publicSessionShare.findUnique).mockResolvedValue(mockShare as any); + + const result = await checkPublicShareAccess('valid-token', null); + + expect(result).toBeNull(); + }); + }); + + describe('isSessionOwner', () => { + it('should return true when user owns the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return false when user does not own the session', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false when session does not exist', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue(null); + + const result = await isSessionOwner('user-1', 'session-1'); + + expect(result).toBe(false); + }); + }); + + describe('canManageSharing', () => { + it('should return true for session owner', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-1' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return true for admin access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'admin' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(true); + }); + + it('should return false for view access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'view' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false for edit access level', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue({ + accessLevel: 'edit' + } as any); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + + it('should return false when user has no access', async () => { + vi.mocked(db.session.findUnique).mockResolvedValue({ + id: 'session-1', + accountId: 'user-owner' + } as any); + + vi.mocked(db.sessionShare.findUnique).mockResolvedValue(null); + + const result = await canManageSharing('user-1', 'session-1'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/sources/app/share/accessLogger.spec.ts b/sources/app/share/accessLogger.spec.ts new file mode 100644 index 0000000..a298ca6 --- /dev/null +++ b/sources/app/share/accessLogger.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { logSessionShareAccess, logPublicShareAccess, getIpAddress, getUserAgent } from './accessLogger'; +import { db } from '@/storage/db'; + +vi.mock('@/storage/db', () => ({ + db: { + sessionShareAccessLog: { + create: vi.fn() + }, + publicShareAccessLog: { + create: vi.fn() + } + } +})); + +describe('accessLogger', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('logSessionShareAccess', () => { + it('should log access with IP and user agent', async () => { + await logSessionShareAccess('share-1', 'user-1', '192.168.1.1', 'Mozilla/5.0'); + + expect(db.sessionShareAccessLog.create).toHaveBeenCalledWith({ + data: { + sessionShareId: 'share-1', + userId: 'user-1', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + } + }); + }); + + it('should log access without IP and user agent', async () => { + await logSessionShareAccess('share-1', 'user-1'); + + expect(db.sessionShareAccessLog.create).toHaveBeenCalledWith({ + data: { + sessionShareId: 'share-1', + userId: 'user-1', + ipAddress: null, + userAgent: null + } + }); + }); + }); + + describe('logPublicShareAccess', () => { + it('should log access with all fields', async () => { + await logPublicShareAccess('public-1', 'user-1', '192.168.1.1', 'Mozilla/5.0'); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: 'user-1', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0' + } + }); + }); + + it('should log anonymous access', async () => { + await logPublicShareAccess('public-1', null); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: null, + ipAddress: null, + userAgent: null + } + }); + }); + + it('should log access with consent (IP and UA present)', async () => { + await logPublicShareAccess('public-1', null, '10.0.0.1', 'Chrome/100.0'); + + expect(db.publicShareAccessLog.create).toHaveBeenCalledWith({ + data: { + publicShareId: 'public-1', + userId: null, + ipAddress: '10.0.0.1', + userAgent: 'Chrome/100.0' + } + }); + }); + }); + + describe('getIpAddress', () => { + it('should extract IP from x-forwarded-for header', () => { + const headers = { 'x-forwarded-for': '203.0.113.1, 198.51.100.1' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should handle x-forwarded-for as array', () => { + const headers = { 'x-forwarded-for': ['203.0.113.1, 198.51.100.1'] }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should extract IP from x-real-ip header', () => { + const headers = { 'x-real-ip': '203.0.113.5' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.5'); + }); + + it('should prefer x-forwarded-for over x-real-ip', () => { + const headers = { + 'x-forwarded-for': '203.0.113.1', + 'x-real-ip': '203.0.113.5' + }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + + it('should return undefined when no IP headers present', () => { + const headers = {}; + const result = getIpAddress(headers); + expect(result).toBeUndefined(); + }); + + it('should trim whitespace from IP address', () => { + const headers = { 'x-forwarded-for': ' 203.0.113.1 , 198.51.100.1' }; + const result = getIpAddress(headers); + expect(result).toBe('203.0.113.1'); + }); + }); + + describe('getUserAgent', () => { + it('should extract user agent from header', () => { + const headers = { 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }; + const result = getUserAgent(headers); + expect(result).toBe('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + }); + + it('should handle user agent as array', () => { + const headers = { 'user-agent': ['Mozilla/5.0'] }; + const result = getUserAgent(headers); + expect(result).toBe('Mozilla/5.0'); + }); + + it('should return undefined when no user agent header', () => { + const headers = {}; + const result = getUserAgent(headers); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty user agent', () => { + const headers = { 'user-agent': '' }; + const result = getUserAgent(headers); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 11345d1..b03385a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,14 @@ export default defineConfig({ globals: true, environment: 'node', include: ['**/*.test.ts', '**/*.spec.ts'], + env: { + S3_HOST: 'localhost', + S3_PORT: '9000', + S3_USE_SSL: 'false', + S3_ACCESS_KEY: 'test', + S3_SECRET_KEY: 'test', + S3_BUCKET: 'test' + } }, plugins: [tsconfigPaths()] }); \ No newline at end of file From 82584da67d5dca13e44350fa505489bc87cb37b5 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:27:11 +0900 Subject: [PATCH 12/20] update: Add session sharing documentation Document new collaboration features including direct sharing with granular access control and public link sharing with consent-based logging. - README.md --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bef4da6..9f9acdd 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,13 @@ Happy Server is the synchronization backbone for secure Claude Code clients. It ## Features - 🔐 **Zero Knowledge** - The server stores encrypted data but has no ability to decrypt it -- 🎯 **Minimal Surface** - Only essential features for secure sync, nothing more +- 🎯 **Minimal Surface** - Only essential features for secure sync, nothing more - 🕵️ **Privacy First** - No analytics, no tracking, no data mining - 📖 **Open Source** - Transparent implementation you can audit and self-host - 🔑 **Cryptographic Auth** - No passwords stored, only public key signatures - ⚡ **Real-time Sync** - WebSocket-based synchronization across all your devices - 📱 **Multi-device** - Seamless session management across phones, tablets, and computers +- 🤝 **Session Sharing** - Collaborate on conversations with granular access control - 🔔 **Push Notifications** - Notify when Claude Code finishes tasks or needs permissions (encrypted, we can't see the content) - 🌐 **Distributed Ready** - Built to scale horizontally when needed @@ -22,6 +23,22 @@ Happy Server is the synchronization backbone for secure Claude Code clients. It Your Claude Code clients generate encryption keys locally and use Happy Server as a secure relay. Messages are end-to-end encrypted before leaving your device. The server's job is simple: store encrypted blobs and sync them between your devices in real-time. +### Session Sharing + +Happy Server supports secure collaboration through two sharing methods: + +**Direct Sharing**: Share sessions with specific users by username, with three access levels: +- **View**: Read-only access to messages +- **Edit**: Can send messages but cannot manage sharing +- **Admin**: Full access including sharing management + +**Public Links**: Generate shareable URLs for broader access: +- Always read-only for security +- Optional expiration dates and usage limits +- Consent-based access logging (IP/UA only logged with explicit consent) + +All sharing maintains end-to-end encryption - encrypted data keys are distributed to authorized users, and the server never sees unencrypted content. + ## Hosting **You don't need to self-host!** Our free cloud Happy Server at `happy-api.slopus.com` is just as secure as running your own. Since all data is end-to-end encrypted before it reaches our servers, we literally cannot read your messages even if we wanted to. The encryption happens on your device, and only you have the keys. From 3691fe614e4f08d3343fab7b0cd9a4966887a6d3 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:46:38 +0900 Subject: [PATCH 13/20] change: Restrict session sharing to friends only Add friend relationship check before allowing session sharing. Users can only share sessions with friends to prevent spam and unauthorized sharing attempts. - sources/app/share/accessControl.ts - sources/app/api/routes/shareRoutes.ts - sources/app/share/accessControl.spec.ts --- sources/app/api/routes/shareRoutes.ts | 7 +++- sources/app/share/accessControl.spec.ts | 47 ++++++++++++++++++++++++- sources/app/share/accessControl.ts | 22 ++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 4744b8e..3e994de 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -1,7 +1,7 @@ import { type Fastify } from "../types"; import { db } from "@/storage/db"; import { z } from "zod"; -import { checkSessionAccess, canManageSharing, isSessionOwner } from "@/app/share/accessControl"; +import { checkSessionAccess, canManageSharing, isSessionOwner, areFriends } from "@/app/share/accessControl"; import { ShareAccessLevel } from "@prisma/client"; import { logSessionShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; import { PROFILE_SELECT } from "@/app/share/types"; @@ -93,6 +93,11 @@ export function shareRoutes(app: Fastify) { return reply.code(404).send({ error: 'User not found' }); } + // Check if users are friends + if (!await areFriends(ownerId, userId)) { + return reply.code(403).send({ error: 'Can only share with friends' }); + } + // Create or update share const share = await db.sessionShare.upsert({ where: { diff --git a/sources/app/share/accessControl.spec.ts b/sources/app/share/accessControl.spec.ts index 0460ec4..a091dd8 100644 --- a/sources/app/share/accessControl.spec.ts +++ b/sources/app/share/accessControl.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { checkSessionAccess, checkPublicShareAccess, isSessionOwner, canManageSharing } from './accessControl'; +import { checkSessionAccess, checkPublicShareAccess, isSessionOwner, canManageSharing, areFriends } from './accessControl'; import { db } from '@/storage/db'; vi.mock('@/storage/db', () => ({ @@ -12,6 +12,9 @@ vi.mock('@/storage/db', () => ({ }, publicSessionShare: { findUnique: vi.fn() + }, + userRelationship: { + findFirst: vi.fn() } } })); @@ -247,4 +250,46 @@ describe('accessControl', () => { expect(result).toBe(false); }); }); + + describe('areFriends', () => { + it('should return true when users are friends (from->to)', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue({ + fromUserId: 'user-1', + toUserId: 'user-2', + status: 'friend' + } as any); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(true); + }); + + it('should return true when users are friends (to->from)', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue({ + fromUserId: 'user-2', + toUserId: 'user-1', + status: 'friend' + } as any); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(true); + }); + + it('should return false when users are not friends', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue(null); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(false); + }); + + it('should return false when relationship is pending', async () => { + vi.mocked(db.userRelationship.findFirst).mockResolvedValue(null); + + const result = await areFriends('user-1', 'user-2'); + + expect(result).toBe(false); + }); + }); }); diff --git a/sources/app/share/accessControl.ts b/sources/app/share/accessControl.ts index c099a63..a59c8ed 100644 --- a/sources/app/share/accessControl.ts +++ b/sources/app/share/accessControl.ts @@ -152,6 +152,28 @@ export async function isSessionOwner( return access?.isOwner ?? false; } +/** + * Check if two users are friends + * + * @param userId1 - First user ID + * @param userId2 - Second user ID + * @returns True if users are friends + */ +export async function areFriends( + userId1: string, + userId2: string +): Promise { + const relationship = await db.userRelationship.findFirst({ + where: { + OR: [ + { fromUserId: userId1, toUserId: userId2, status: 'friend' }, + { fromUserId: userId2, toUserId: userId1, status: 'friend' } + ] + } + }); + return relationship !== null; +} + /** * Check public share access with blocking and limits * From 60c3b39dcb91bbc9f36c9bc7c5a3f9d58acd4895 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:57:49 +0900 Subject: [PATCH 14/20] fix: Fix race condition in public share useCount Use Prisma transaction to atomically check maxUses limit and increment useCount, preventing concurrent requests from exceeding the usage limit. - sources/app/api/routes/publicShareRoutes.ts --- sources/app/api/routes/publicShareRoutes.ts | 101 ++++++++++++++------ 1 file changed, 74 insertions(+), 27 deletions(-) diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 4abcce2..11d6021 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -1,7 +1,7 @@ import { type Fastify } from "../types"; import { db } from "@/storage/db"; import { z } from "zod"; -import { isSessionOwner, checkPublicShareAccess } from "@/app/share/accessControl"; +import { isSessionOwner } from "@/app/share/accessControl"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { logPublicShareAccess, getIpAddress, getUserAgent } from "@/app/share/accessLogger"; import { PROFILE_SELECT } from "@/app/share/types"; @@ -219,41 +219,88 @@ export function publicShareRoutes(app: Fastify) { } } - const access = await checkPublicShareAccess(token, userId); - if (!access) { - return reply.code(404).send({ error: 'Public share not found or expired' }); - } + // Use transaction to atomically check limits and increment use count + const result = await db.$transaction(async (tx) => { + // Check access and get full public share data + const publicShare = await tx.publicSessionShare.findUnique({ + where: { token }, + select: { + id: true, + sessionId: true, + expiresAt: true, + maxUses: true, + useCount: true, + isConsentRequired: true, + encryptedDataKey: true, + blockedUsers: userId ? { + where: { userId }, + select: { id: true } + } : undefined + } + }); - // Check if consent is required and get encrypted key - const publicShare = await db.publicSessionShare.findUnique({ - where: { id: access.publicShareId }, - select: { - isConsentRequired: true, - encryptedDataKey: true + if (!publicShare) { + return { error: 'Public share not found or expired' }; } - }); - if (publicShare?.isConsentRequired && !consent) { - return reply.code(403).send({ - error: 'Consent required', - requiresConsent: true + // Check if expired + if (publicShare.expiresAt && publicShare.expiresAt < new Date()) { + return { error: 'Public share not found or expired' }; + } + + // Check if max uses exceeded (before incrementing) + if (publicShare.maxUses && publicShare.useCount >= publicShare.maxUses) { + return { error: 'Public share not found or expired' }; + } + + // Check if user is blocked + if (userId && publicShare.blockedUsers && publicShare.blockedUsers.length > 0) { + return { error: 'Public share not found or expired' }; + } + + // Check consent requirement + if (publicShare.isConsentRequired && !consent) { + return { + error: 'Consent required', + requiresConsent: true, + publicShareId: publicShare.id + }; + } + + // Increment use count atomically + await tx.publicSessionShare.update({ + where: { id: publicShare.id }, + data: { useCount: { increment: 1 } } }); + + return { + success: true, + publicShareId: publicShare.id, + sessionId: publicShare.sessionId, + isConsentRequired: publicShare.isConsentRequired, + encryptedDataKey: publicShare.encryptedDataKey + }; + }); + + // Handle errors from transaction + if ('error' in result) { + if (result.requiresConsent) { + return reply.code(403).send({ + error: result.error, + requiresConsent: true + }); + } + return reply.code(404).send({ error: result.error }); } // Log access (only log IP/UA if consent was given) - const ipAddress = publicShare?.isConsentRequired ? getIpAddress(request.headers) : undefined; - const userAgent = publicShare?.isConsentRequired ? getUserAgent(request.headers) : undefined; - await logPublicShareAccess(access.publicShareId, userId, ipAddress, userAgent); - - // Increment use count - await db.publicSessionShare.update({ - where: { id: access.publicShareId }, - data: { useCount: { increment: 1 } } - }); + const ipAddress = result.isConsentRequired ? getIpAddress(request.headers) : undefined; + const userAgent = result.isConsentRequired ? getUserAgent(request.headers) : undefined; + await logPublicShareAccess(result.publicShareId, userId, ipAddress, userAgent); // Get session info const session = await db.session.findUnique({ - where: { id: access.sessionId }, + where: { id: result.sessionId }, select: { id: true, seq: true, @@ -286,7 +333,7 @@ export function publicShareRoutes(app: Fastify) { agentStateVersion: session.agentStateVersion }, accessLevel: 'view', - encryptedDataKey: publicShare ? Buffer.from(publicShare.encryptedDataKey).toString('base64') : null + encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64') }); }); From 9482937a2d1bdbaadd4716122261b5740abf0385 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:00:16 +0900 Subject: [PATCH 15/20] refactor: Add transactions to share deletion endpoints Wrap share deletion operations in transactions to ensure consistent state between database operations and real-time notifications. - sources/app/api/routes/shareRoutes.ts - sources/app/api/routes/publicShareRoutes.ts --- sources/app/api/routes/publicShareRoutes.ts | 24 +++++++++---- sources/app/api/routes/shareRoutes.ts | 38 +++++++++++++-------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 11d6021..05c656b 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -162,17 +162,27 @@ export function publicShareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Forbidden' }); } - // Check if share exists - const existing = await db.publicSessionShare.findUnique({ - where: { sessionId } - }); + // Use transaction to ensure consistent state + const deleted = await db.$transaction(async (tx) => { + // Check if share exists + const existing = await tx.publicSessionShare.findUnique({ + where: { sessionId } + }); - if (existing) { - await db.publicSessionShare.delete({ + if (!existing) { + return false; + } + + // Delete public share + await tx.publicSessionShare.delete({ where: { sessionId } }); - // Emit real-time update to session owner + return true; + }); + + // Emit real-time update to session owner (outside transaction) + if (deleted) { const updateSeq = await allocateUserSeq(userId); const updatePayload = buildPublicShareDeletedUpdate( sessionId, diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 3e994de..83684a3 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -228,29 +228,39 @@ export function shareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Forbidden' }); } - // Get share before deleting - const share = await db.sessionShare.findUnique({ - where: { id: shareId, sessionId } - }); + // Use transaction to ensure consistent state + const result = await db.$transaction(async (tx) => { + // Get share before deleting + const share = await tx.sessionShare.findUnique({ + where: { id: shareId, sessionId } + }); - if (!share) { - return reply.code(404).send({ error: 'Share not found' }); - } + if (!share) { + return { error: 'Share not found' }; + } + + // Delete share + await tx.sessionShare.delete({ + where: { id: shareId, sessionId } + }); - await db.sessionShare.delete({ - where: { id: shareId, sessionId } + return { share }; }); - // Emit real-time update to shared user - const updateSeq = await allocateUserSeq(share.sharedWithUserId); + if ('error' in result) { + return reply.code(404).send({ error: result.error }); + } + + // Emit real-time update to shared user (outside transaction) + const updateSeq = await allocateUserSeq(result.share.sharedWithUserId); const updatePayload = buildSessionShareRevokedUpdate( - share.id, - share.sessionId, + result.share.id, + result.share.sessionId, updateSeq, randomKeyNaked(12) ); eventRouter.emitUpdate({ - userId: share.sharedWithUserId, + userId: result.share.sharedWithUserId, payload: updatePayload, recipientFilter: { type: 'all-user-authenticated-connections' } }); From ed8da122ac47bb2fa641ba67e6a8edbca84645ac Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:06:18 +0900 Subject: [PATCH 16/20] feat: Add rate limiting to sharing endpoints Add rate limiting to prevent abuse of sharing functionality: - Public share access: 10 requests/minute - Share creation: 20 requests/minute - Public share creation: 10 requests/minute - sources/app/api/api.ts - sources/app/api/routes/shareRoutes.ts - sources/app/api/routes/publicShareRoutes.ts - package.json --- package.json | 1 + sources/app/api/api.ts | 3 +++ sources/app/api/routes/publicShareRoutes.ts | 12 ++++++++++++ sources/app/api/routes/shareRoutes.ts | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/package.json b/package.json index a2a4ffc..2b15520 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@date-fns/tz": "^1.2.0", "@fastify/bearer-auth": "^10.1.1", "@fastify/cors": "^10.0.1", + "@fastify/rate-limit": "^10.3.0", "@prisma/client": "^6.11.1", "@socket.io/redis-streams-adapter": "^0.2.2", "@types/jsonwebtoken": "^9.0.10", diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 8848488..56d1ffe 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -39,6 +39,9 @@ export async function startApi() { allowedHeaders: '*', methods: ['GET', 'POST', 'DELETE'] }); + app.register(import('@fastify/rate-limit'), { + global: false // Only apply to routes with explicit config + }); app.get('/', function (request, reply) { reply.send('Welcome to Happy Server!'); }); diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 05c656b..9f34ca9 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -20,6 +20,12 @@ export function publicShareRoutes(app: Fastify) { */ app.post('/v1/sessions/:sessionId/public-share', { preHandler: app.authenticate, + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, schema: { params: z.object({ sessionId: z.string() @@ -206,6 +212,12 @@ export function publicShareRoutes(app: Fastify) { * If isConsentRequired is true, client must pass consent=true query param */ app.get('/v1/public-share/:token', { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, schema: { params: z.object({ token: z.string() diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 83684a3..4443cdf 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -59,6 +59,12 @@ export function shareRoutes(app: Fastify) { */ app.post('/v1/sessions/:sessionId/shares', { preHandler: app.authenticate, + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute' + } + }, schema: { params: z.object({ sessionId: z.string() From 77e49fb8f21f5d905e6a64180d9e0e74cbd012d3 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:40:52 +0900 Subject: [PATCH 17/20] feat: Add publicKey to user profile API Adds user publicKey to UserProfile type and API responses. Required for encrypting session data keys when sharing. - sources/app/social/type.ts - sources/app/api/routes/userRoutes.ts --- sources/app/api/routes/userRoutes.ts | 3 ++- sources/app/social/type.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sources/app/api/routes/userRoutes.ts b/sources/app/api/routes/userRoutes.ts index 9da4239..011531e 100644 --- a/sources/app/api/routes/userRoutes.ts +++ b/sources/app/api/routes/userRoutes.ts @@ -179,5 +179,6 @@ const UserProfileSchema = z.object({ }).nullable(), username: z.string(), bio: z.string().nullable(), - status: RelationshipStatusSchema + status: RelationshipStatusSchema, + publicKey: z.string() }); \ No newline at end of file diff --git a/sources/app/social/type.ts b/sources/app/social/type.ts index deb5edb..355a1c4 100644 --- a/sources/app/social/type.ts +++ b/sources/app/social/type.ts @@ -16,6 +16,7 @@ export type UserProfile = { username: string; bio: string | null; status: RelationshipStatus; + publicKey: string; } export function buildUserProfile( @@ -26,6 +27,7 @@ export function buildUserProfile( username: string | null; avatar: ImageRef | null; githubUser: { profile: GitHubProfile } | null; + publicKey: string; }, status: RelationshipStatus ): UserProfile { @@ -51,6 +53,7 @@ export function buildUserProfile( avatar, username: account.username || githubProfile?.login || '', bio: githubProfile?.bio || null, - status + status, + publicKey: account.publicKey }; } \ No newline at end of file From 225c3ab9b0f483d5c726fdb7c8fa5992e16ba739 Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:54:26 +0900 Subject: [PATCH 18/20] feat: Implement server-side data key encryption for sharing Encrypt session data keys on the server using recipient public keys. Removes need for client to handle sensitive encryption keys. - sources/app/share/encryptDataKey.ts - sources/app/api/routes/shareRoutes.ts --- sources/app/api/routes/shareRoutes.ts | 36 +++++++++++++++++++++------ sources/app/share/encryptDataKey.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) create mode 100644 sources/app/share/encryptDataKey.ts diff --git a/sources/app/api/routes/shareRoutes.ts b/sources/app/api/routes/shareRoutes.ts index 4443cdf..52d7885 100644 --- a/sources/app/api/routes/shareRoutes.ts +++ b/sources/app/api/routes/shareRoutes.ts @@ -8,6 +8,7 @@ import { PROFILE_SELECT } from "@/app/share/types"; import { eventRouter, buildSessionSharedUpdate, buildSessionShareUpdatedUpdate, buildSessionShareRevokedUpdate } from "@/app/events/eventRouter"; import { allocateUserSeq } from "@/storage/seq"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; +import { encryptDataKeyForRecipient } from "@/app/share/encryptDataKey"; /** * Session sharing API routes @@ -71,14 +72,13 @@ export function shareRoutes(app: Fastify) { }), body: z.object({ userId: z.string(), - accessLevel: z.enum(['view', 'edit', 'admin']), - encryptedDataKey: z.string() // base64 encoded + accessLevel: z.enum(['view', 'edit', 'admin']) }) } }, async (request, reply) => { const ownerId = request.userId; const { sessionId } = request.params; - const { userId, accessLevel, encryptedDataKey } = request.body; + const { userId, accessLevel } = request.body; // Only owner or admin can create shares if (!await canManageSharing(ownerId, sessionId)) { @@ -90,9 +90,10 @@ export function shareRoutes(app: Fastify) { return reply.code(400).send({ error: 'Cannot share with yourself' }); } - // Verify target user exists + // Verify target user exists and get their public key const targetUser = await db.account.findUnique({ - where: { id: userId } + where: { id: userId }, + select: { id: true, publicKey: true } }); if (!targetUser) { @@ -104,6 +105,27 @@ export function shareRoutes(app: Fastify) { return reply.code(403).send({ error: 'Can only share with friends' }); } + // Get session data encryption key + const session = await db.session.findUnique({ + where: { id: sessionId }, + select: { dataEncryptionKey: true } + }); + + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); + } + + if (!session.dataEncryptionKey) { + return reply.code(400).send({ error: 'Session has no encryption key' }); + } + + // Encrypt session data key with recipient's public key + const recipientPublicKey = Buffer.from(targetUser.publicKey, 'base64'); + const encryptedDataKey = encryptDataKeyForRecipient( + session.dataEncryptionKey, + recipientPublicKey + ); + // Create or update share const share = await db.sessionShare.upsert({ where: { @@ -117,11 +139,11 @@ export function shareRoutes(app: Fastify) { sharedByUserId: ownerId, sharedWithUserId: userId, accessLevel: accessLevel as ShareAccessLevel, - encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) + encryptedDataKey }, update: { accessLevel: accessLevel as ShareAccessLevel, - encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')) + encryptedDataKey }, include: { sharedWithUser: { diff --git a/sources/app/share/encryptDataKey.ts b/sources/app/share/encryptDataKey.ts new file mode 100644 index 0000000..00b9016 --- /dev/null +++ b/sources/app/share/encryptDataKey.ts @@ -0,0 +1,35 @@ +/** + * Encryption utilities for session sharing + */ + +import nacl from 'tweetnacl'; + +/** + * Encrypt a session data key with a recipient's public key + * + * Uses X25519-XSalsa20-Poly1305 encryption with ephemeral keys + */ +export function encryptDataKeyForRecipient( + dataKey: Uint8Array, + recipientPublicKey: Uint8Array +): Uint8Array { + const ephemeralKeyPair = nacl.box.keyPair(); + const nonce = nacl.randomBytes(nacl.box.nonceLength); + + const encrypted = nacl.box( + dataKey, + nonce, + recipientPublicKey, + ephemeralKeyPair.secretKey + ); + + // Bundle: ephemeral public key (32) + nonce (24) + encrypted data + const bundle = new Uint8Array( + ephemeralKeyPair.publicKey.length + nonce.length + encrypted.length + ); + bundle.set(ephemeralKeyPair.publicKey, 0); + bundle.set(nonce, ephemeralKeyPair.publicKey.length); + bundle.set(encrypted, ephemeralKeyPair.publicKey.length + nonce.length); + + return bundle; +} From 6fec156f16212d0f1e56662d5b53b340e65c3f5d Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 02:37:11 +0900 Subject: [PATCH 19/20] feat: Support client-generated tokens for public shares Allow clients to generate tokens and encrypt data keys client-side for enhanced security. The server now accepts token parameter and uses it directly instead of generating its own. Files: - sources/app/api/routes/publicShareRoutes.ts --- sources/app/api/routes/publicShareRoutes.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 9f34ca9..0dbded8 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -31,6 +31,7 @@ export function publicShareRoutes(app: Fastify) { sessionId: z.string() }), body: z.object({ + token: z.string(), // client-generated token encryptedDataKey: z.string(), // base64 encoded expiresAt: z.number().optional(), // timestamp maxUses: z.number().int().positive().optional(), @@ -40,7 +41,7 @@ export function publicShareRoutes(app: Fastify) { }, async (request, reply) => { const userId = request.userId; const { sessionId } = request.params; - const { encryptedDataKey, expiresAt, maxUses, isConsentRequired } = request.body; + const { token, encryptedDataKey, expiresAt, maxUses, isConsentRequired } = request.body; // Only owner can create public shares if (!await isSessionOwner(userId, sessionId)) { @@ -56,7 +57,7 @@ export function publicShareRoutes(app: Fastify) { const isUpdate = !!existing; if (existing) { - // Update existing share + // Update existing share (keep the same token, update encryption and settings) publicShare = await db.publicSessionShare.update({ where: { sessionId }, data: { @@ -67,8 +68,7 @@ export function publicShareRoutes(app: Fastify) { } }); } else { - // Create new share with random token - const token = randomKeyNaked(); + // Create new share with client-provided token publicShare = await db.publicSessionShare.create({ data: { sessionId, From 446dcad4820182688ce1ac8533d4313d8f48a61f Mon Sep 17 00:00:00 2001 From: 54m <30588003+54m@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:57:35 +0900 Subject: [PATCH 20/20] feat: Return owner info for consent-required shares Include session owner profile in 403 response when consent is required. Allows client to display who is sharing before user accepts consent. - sources/app/api/routes/publicShareRoutes.ts --- sources/app/api/routes/publicShareRoutes.ts | 28 +++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/sources/app/api/routes/publicShareRoutes.ts b/sources/app/api/routes/publicShareRoutes.ts index 0dbded8..b8a23bf 100644 --- a/sources/app/api/routes/publicShareRoutes.ts +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -285,7 +285,8 @@ export function publicShareRoutes(app: Fastify) { return { error: 'Consent required', requiresConsent: true, - publicShareId: publicShare.id + publicShareId: publicShare.id, + sessionId: publicShare.sessionId }; } @@ -307,9 +308,21 @@ export function publicShareRoutes(app: Fastify) { // Handle errors from transaction if ('error' in result) { if (result.requiresConsent) { + // Get owner info even when consent is required + const session = await db.session.findUnique({ + where: { id: result.sessionId }, + select: { + owner: { + select: PROFILE_SELECT + } + } + }); + return reply.code(403).send({ error: result.error, - requiresConsent: true + requiresConsent: true, + sessionId: result.sessionId, + owner: session?.owner || null }); } return reply.code(404).send({ error: result.error }); @@ -320,7 +333,7 @@ export function publicShareRoutes(app: Fastify) { const userAgent = result.isConsentRequired ? getUserAgent(request.headers) : undefined; await logPublicShareAccess(result.publicShareId, userId, ipAddress, userAgent); - // Get session info + // Get session info with owner profile const session = await db.session.findUnique({ where: { id: result.sessionId }, select: { @@ -333,7 +346,10 @@ export function publicShareRoutes(app: Fastify) { agentState: true, agentStateVersion: true, active: true, - lastActiveAt: true + lastActiveAt: true, + owner: { + select: PROFILE_SELECT + } } }); @@ -354,8 +370,10 @@ export function publicShareRoutes(app: Fastify) { agentState: session.agentState, agentStateVersion: session.agentStateVersion }, + owner: session.owner, accessLevel: 'view', - encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64') + encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64'), + isConsentRequired: result.isConsentRequired }); });