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. 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/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/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/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 6349305..4957cb1 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,115 @@ 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 (always view-only for security) +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 + /// 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) + /// Whether user consent is required to view (enables detailed access logging) + isConsentRequired Boolean @default(false) + 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]) +} diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index e6db9e8..56d1ffe 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -21,6 +21,8 @@ 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"; +import { publicShareRoutes } from "./routes/publicShareRoutes"; export async function startApi() { @@ -37,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!'); }); @@ -66,6 +71,8 @@ export async function startApi() { userRoutes(typed); 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..b8a23bf --- /dev/null +++ b/sources/app/api/routes/publicShareRoutes.ts @@ -0,0 +1,564 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +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"; +import { eventRouter, buildPublicShareCreatedUpdate, buildPublicShareUpdatedUpdate, buildPublicShareDeletedUpdate } from "@/app/events/eventRouter"; +import { allocateUserSeq } from "@/storage/seq"; + +/** + * 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, + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + 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(), + isConsentRequired: z.boolean().optional() // require consent for detailed logging + }) + } + }, async (request, reply) => { + const userId = request.userId; + const { sessionId } = request.params; + const { token, encryptedDataKey, expiresAt, maxUses, isConsentRequired } = 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; + const isUpdate = !!existing; + + if (existing) { + // Update existing share (keep the same token, update encryption and settings) + publicShare = await db.publicSessionShare.update({ + where: { sessionId }, + data: { + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false + } + }); + } else { + // Create new share with client-provided token + publicShare = await db.publicSessionShare.create({ + data: { + sessionId, + createdByUserId: userId, + token, + encryptedDataKey: new Uint8Array(Buffer.from(encryptedDataKey, 'base64')), + expiresAt: expiresAt ? new Date(expiresAt) : null, + maxUses: maxUses ?? null, + isConsentRequired: isConsentRequired ?? false + } + }); + } + + // 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, + token: publicShare.token, + expiresAt: publicShare.expiresAt?.getTime() ?? null, + maxUses: publicShare.maxUses, + useCount: publicShare.useCount, + isConsentRequired: publicShare.isConsentRequired, + 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, + isConsentRequired: publicShare.isConsentRequired, + 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' }); + } + + // 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) { + return false; + } + + // Delete public share + await tx.publicSessionShare.delete({ + where: { sessionId } + }); + + return true; + }); + + // Emit real-time update to session owner (outside transaction) + if (deleted) { + 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 }); + }); + + /** + * 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', { + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute' + } + }, + 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; + if (request.headers.authorization) { + try { + await app.authenticate(request, reply); + userId = request.userId; + } catch { + // Not authenticated, continue as anonymous + } + } + + // 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 + } + }); + + if (!publicShare) { + return { error: 'Public share not found or expired' }; + } + + // 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, + sessionId: publicShare.sessionId + }; + } + + // 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) { + // 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, + sessionId: result.sessionId, + owner: session?.owner || null + }); + } + return reply.code(404).send({ error: result.error }); + } + + // Log access (only log IP/UA if consent was given) + 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 with owner profile + const session = await db.session.findUnique({ + where: { id: result.sessionId }, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + owner: { + select: PROFILE_SELECT + } + } + }); + + 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 + }, + owner: session.owner, + accessLevel: 'view', + encryptedDataKey: Buffer.from(result.encryptedDataKey).toString('base64'), + isConsentRequired: result.isConsentRequired + }); + }); + + /** + * 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: PROFILE_SELECT + } + }, + orderBy: { blockedAt: 'desc' } + }); + + return reply.send({ + blockedUsers: blockedUsers.map(bu => ({ + id: bu.id, + user: bu.user, + 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: PROFILE_SELECT + } + } + }); + + return reply.send({ + blockedUser: { + id: blockedUser.id, + user: blockedUser.user, + 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: PROFILE_SELECT + } + }, + orderBy: { accessedAt: 'desc' }, + take: limit + }); + + return reply.send({ + logs: logs.map(log => ({ + id: log.id, + 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 new file mode 100644 index 0000000..52d7885 --- /dev/null +++ b/sources/app/api/routes/shareRoutes.ts @@ -0,0 +1,465 @@ +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +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"; +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 + */ +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: PROFILE_SELECT + } + }, + orderBy: { createdAt: 'desc' } + }); + + return reply.send({ + shares: shares.map(share => ({ + id: share.id, + sharedWithUser: share.sharedWithUser, + 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, + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute' + } + }, + schema: { + params: z.object({ + sessionId: z.string() + }), + body: z.object({ + userId: z.string(), + accessLevel: z.enum(['view', 'edit', 'admin']) + }) + } + }, async (request, reply) => { + const ownerId = request.userId; + const { sessionId } = request.params; + const { userId, accessLevel } = 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 and get their public key + const targetUser = await db.account.findUnique({ + where: { id: userId }, + select: { id: true, publicKey: true } + }); + + if (!targetUser) { + 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' }); + } + + // 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: { + sessionId_sharedWithUserId: { + sessionId, + sharedWithUserId: userId + } + }, + create: { + sessionId, + sharedByUserId: ownerId, + sharedWithUserId: userId, + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey + }, + update: { + accessLevel: accessLevel as ShareAccessLevel, + encryptedDataKey + }, + 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, + sharedWithUser: share.sharedWithUser, + 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: PROFILE_SELECT + } + } + }); + + // 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, + sharedWithUser: share.sharedWithUser, + 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' }); + } + + // 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 { error: 'Share not found' }; + } + + // Delete share + await tx.sessionShare.delete({ + where: { id: shareId, sessionId } + }); + + return { share }; + }); + + 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( + result.share.id, + result.share.sessionId, + updateSeq, + randomKeyNaked(12) + ); + eventRouter.emitUpdate({ + userId: result.share.sharedWithUserId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + 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: PROFILE_SELECT + } + }, + 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: share.sharedByUser, + 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' }); + } + + // 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, + 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 + }); + }); +} 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/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() + }; +} 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..a091dd8 --- /dev/null +++ b/sources/app/share/accessControl.spec.ts @@ -0,0 +1,295 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { checkSessionAccess, checkPublicShareAccess, isSessionOwner, canManageSharing, areFriends } from './accessControl'; +import { db } from '@/storage/db'; + +vi.mock('@/storage/db', () => ({ + db: { + session: { + findUnique: vi.fn() + }, + sessionShare: { + findUnique: vi.fn() + }, + publicSessionShare: { + findUnique: vi.fn() + }, + userRelationship: { + findFirst: 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); + }); + }); + + 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 new file mode 100644 index 0000000..a59c8ed --- /dev/null +++ b/sources/app/share/accessControl.ts @@ -0,0 +1,231 @@ +import { db } from "@/storage/db"; +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 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 + * + * 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 + */ +export async function checkPublicShareAccess( + token: string, + userId: string | null +): Promise<{ + sessionId: string; + publicShareId: string; +} | null> { + const publicShare = await db.publicSessionShare.findUnique({ + where: { token }, + select: { + id: true, + sessionId: 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, + publicShareId: publicShare.id + }; +} 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/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; +} 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; +} 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 +}; 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 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