Skip to content

Commit 15255c3

Browse files
feat(spaces): Shared Spaces — collaborative folders with owner-pays storage
A new distinct space type: an area owned by one user and shared with a group as EDITOR (read/write) or VIEWER (read-only). Reuses the existing server-side AES-256-GCM pipeline unchanged — no new cryptography. The only new thing is a centralized authorization gate (getSpaceAccess) that replaces the personal Drive's `ownerId === me` filter for shared content. - Schema: SharedSpace + SharedSpaceMember (+ SpaceRole enum); Folder and FileObject gain a nullable spaceId. Space content carries the owner's ownerId (so the owner's quota is charged) and is reachable only through the /spaces routes. - Owner-pays: every file in a space is billed to the space owner via the existing quota accounting. - Lifecycle: deleting a space either hard-deletes its content (freeing the owner's quota) or transfers ownership — and the storage cost — to the earliest-joined member. - Isolation: personal Drive, search, REST API, WebDAV, shares, trash and account export all filter spaceId = null, so shared content never leaks into a personal listing. - Web: "Espaces Partagés" nav + /spaces list and /spaces/[id] browser (folders, upload with progress, download, rename, delete, members and lifecycle management). FR/EN i18n at parity. - Tests: integration coverage for owner-pays, role enforcement, non-member denial, isolation, transfer and delete-cascade. Also fixes a stale assertion in the quick-code duplicate test (the route deliberately returns a generic 403, not 409). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNvAeoRFGJkRnaBGwMstZ9
1 parent f87affa commit 15255c3

27 files changed

Lines changed: 1695 additions & 57 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ the source.
2424

2525
- **Drive** — browse, upload (streaming), download and delete files and folders, with a
2626
per-user storage quota and a deployment-wide cap.
27+
- **Shared Spaces** — collaborative areas owned by one user and shared with a group as
28+
*editor* (read/write) or *viewer* (read-only). The owner pays for the storage; deleting a
29+
space either wipes it or transfers it (and its storage cost) to the longest-standing member.
30+
Server-side encrypted only — no zero-knowledge, which can't be shared.
2731
- **Quick-Upload** — open a temporary drop zone on any device by entering an active code,
2832
no full login required. Optional password, expiry and usage limit per code.
2933
- **Remote-Upload** — paste a link and the server fetches the file directly, so a phone on
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
-- CreateEnum
2+
CREATE TYPE "SpaceRole" AS ENUM ('EDITOR', 'VIEWER');
3+
4+
-- CreateTable
5+
CREATE TABLE "SharedSpace" (
6+
"id" TEXT NOT NULL,
7+
"name" TEXT NOT NULL,
8+
"ownerId" TEXT NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "SharedSpace_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "SharedSpaceMember" (
16+
"id" TEXT NOT NULL,
17+
"spaceId" TEXT NOT NULL,
18+
"userId" TEXT NOT NULL,
19+
"role" "SpaceRole" NOT NULL DEFAULT 'VIEWER',
20+
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21+
22+
CONSTRAINT "SharedSpaceMember_pkey" PRIMARY KEY ("id")
23+
);
24+
25+
-- AlterTable
26+
ALTER TABLE "Folder" ADD COLUMN "spaceId" TEXT;
27+
28+
-- AlterTable
29+
ALTER TABLE "FileObject" ADD COLUMN "spaceId" TEXT;
30+
31+
-- CreateIndex
32+
CREATE INDEX "SharedSpace_ownerId_idx" ON "SharedSpace"("ownerId");
33+
34+
-- CreateIndex
35+
CREATE INDEX "SharedSpaceMember_spaceId_idx" ON "SharedSpaceMember"("spaceId");
36+
37+
-- CreateIndex
38+
CREATE INDEX "SharedSpaceMember_userId_idx" ON "SharedSpaceMember"("userId");
39+
40+
-- CreateIndex
41+
CREATE UNIQUE INDEX "SharedSpaceMember_spaceId_userId_key" ON "SharedSpaceMember"("spaceId", "userId");
42+
43+
-- CreateIndex
44+
CREATE INDEX "Folder_spaceId_idx" ON "Folder"("spaceId");
45+
46+
-- CreateIndex
47+
CREATE INDEX "FileObject_spaceId_idx" ON "FileObject"("spaceId");
48+
49+
-- AddForeignKey
50+
ALTER TABLE "SharedSpace" ADD CONSTRAINT "SharedSpace_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
51+
52+
-- AddForeignKey
53+
ALTER TABLE "SharedSpaceMember" ADD CONSTRAINT "SharedSpaceMember_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "SharedSpace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
54+
55+
-- AddForeignKey
56+
ALTER TABLE "SharedSpaceMember" ADD CONSTRAINT "SharedSpaceMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
57+
58+
-- AddForeignKey
59+
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "SharedSpace"("id") ON DELETE CASCADE ON UPDATE CASCADE;
60+
61+
-- AddForeignKey
62+
ALTER TABLE "FileObject" ADD CONSTRAINT "FileObject_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "SharedSpace"("id") ON DELETE CASCADE ON UPDATE CASCADE;

‎apps/api/prisma/schema.prisma‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ enum ShareAccess {
4545
AUTHENTICATED
4646
}
4747

48+
// Role of a member inside a Shared Space. The space OWNER is not a member row — ownership
49+
// lives on SharedSpace.ownerId. EDITOR can upload/rename/delete; VIEWER is read-only.
50+
enum SpaceRole {
51+
EDITOR
52+
VIEWER
53+
}
54+
4855
model User {
4956
id String @id @default(cuid())
5057
email String @unique
@@ -75,10 +82,46 @@ model User {
7582
recoveryCodes RecoveryCode[]
7683
apiTokens ApiToken[]
7784
webhooks Webhook[]
85+
ownedSpaces SharedSpace[] @relation("SpaceOwner")
86+
spaceMemberships SharedSpaceMember[]
7887
7988
@@index([email])
8089
}
8190

91+
// A Shared Space: a collaborative area whose folders & files are server-side encrypted (never
92+
// Zero-Knowledge) and reachable by a group of users. Exactly one OWNER (User) pays for the
93+
// storage of everything inside — every Folder/FileObject in the space carries ownerId = the
94+
// space owner, so the existing per-user quota accounting charges the owner automatically.
95+
// Access for everyone else is granted through SharedSpaceMember rows checked by a single
96+
// authorization helper; the personal Drive's `ownerId === me` filter never applies here.
97+
model SharedSpace {
98+
id String @id @default(cuid())
99+
name String
100+
ownerId String
101+
owner User @relation("SpaceOwner", fields: [ownerId], references: [id], onDelete: Cascade)
102+
createdAt DateTime @default(now())
103+
104+
members SharedSpaceMember[]
105+
folders Folder[]
106+
files FileObject[]
107+
108+
@@index([ownerId])
109+
}
110+
111+
model SharedSpaceMember {
112+
id String @id @default(cuid())
113+
spaceId String
114+
space SharedSpace @relation(fields: [spaceId], references: [id], onDelete: Cascade)
115+
userId String
116+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
117+
role SpaceRole @default(VIEWER)
118+
joinedAt DateTime @default(now())
119+
120+
@@unique([spaceId, userId])
121+
@@index([spaceId])
122+
@@index([userId])
123+
}
124+
82125
// One-time 2FA recovery codes, stored as SHA-256 hashes.
83126
model RecoveryCode {
84127
id String @id @default(cuid())
@@ -114,6 +157,10 @@ model Folder {
114157
parentId String?
115158
parent Folder? @relation("FolderChildren", fields: [parentId], references: [id], onDelete: Cascade)
116159
children Folder[] @relation("FolderChildren")
160+
// When set, this folder lives inside a Shared Space (and is reachable only through the
161+
// /spaces routes, never the personal Drive). null = a normal personal folder.
162+
spaceId String?
163+
space SharedSpace? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
117164
name String
118165
isZeroKnowledge Boolean @default(false)
119166
// Per-vault random salt for the client's PBKDF2 key derivation (ZK folders only).
@@ -133,6 +180,7 @@ model Folder {
133180
134181
@@unique([ownerId, parentId, name])
135182
@@index([ownerId])
183+
@@index([spaceId])
136184
@@index([deletedAt])
137185
}
138186

@@ -142,6 +190,10 @@ model FileObject {
142190
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
143191
folderId String?
144192
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
193+
// Mirrors the owning folder's `spaceId`: set when the file lives in a Shared Space. null =
194+
// a normal personal file. ownerId stays the space owner so the file counts against their quota.
195+
spaceId String?
196+
space SharedSpace? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
145197
name String
146198
sizeBytes BigInt
147199
mimeType String @default("application/octet-stream")
@@ -177,6 +229,7 @@ model FileObject {
177229
178230
@@index([ownerId])
179231
@@index([folderId])
232+
@@index([spaceId])
180233
@@index([deletedAt])
181234
}
182235

‎apps/api/src/routes/account.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
5353

5454
if (body.targetFolderId) {
5555
const folder = await prisma.folder.findFirst({
56-
where: { id: body.targetFolderId, ownerId: req.user!.id },
56+
where: { id: body.targetFolderId, ownerId: req.user!.id, spaceId: null },
5757
});
5858
if (!folder) return reply.code(404).send({ error: 'Target folder not found' });
5959
if (folder.isZeroKnowledge) {
@@ -117,7 +117,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
117117
throw err;
118118
}
119119
if (body.folderId) {
120-
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id } });
120+
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id, spaceId: null } });
121121
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
122122
}
123123
const hook = await prisma.webhook.create({
@@ -164,7 +164,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
164164
if (!body) return;
165165

166166
if (body.folderId) {
167-
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id } });
167+
const folder = await prisma.folder.findFirst({ where: { id: body.folderId, ownerId: req.user!.id, spaceId: null } });
168168
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
169169
if (folder.isZeroKnowledge) return reply.code(400).send({ error: 'A vault cannot be an API target' });
170170
}
@@ -240,8 +240,8 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
240240
const userId = req.user!.id;
241241
const [user, folders, files, shares, sessions, logs] = await Promise.all([
242242
prisma.user.findUniqueOrThrow({ where: { id: userId } }),
243-
prisma.folder.findMany({ where: { ownerId: userId } }),
244-
prisma.fileObject.findMany({ where: { ownerId: userId } }),
243+
prisma.folder.findMany({ where: { ownerId: userId, spaceId: null } }),
244+
prisma.fileObject.findMany({ where: { ownerId: userId, spaceId: null } }),
245245
prisma.shareLink.findMany({ where: { ownerId: userId } }),
246246
prisma.session.findMany({
247247
where: { userId },

‎apps/api/src/routes/admin.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
238238

239239
if (body.targetFolderId) {
240240
const folder = await prisma.folder.findFirst({
241-
where: { id: body.targetFolderId, ownerId: req.user!.id },
241+
where: { id: body.targetFolderId, ownerId: req.user!.id, spaceId: null },
242242
});
243243
if (!folder) return reply.code(404).send({ error: 'Target folder not found' });
244244
if (folder.isZeroKnowledge) {

‎apps/api/src/routes/api-v1.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const apiV1Routes: FastifyPluginAsync = async (app) => {
2828
// GET /folders — list the user's normal folders.
2929
app.get('/folders', { preHandler: app.tokenAuth('read') }, async (req) => {
3030
const folders = await prisma.folder.findMany({
31-
where: { ownerId: req.user!.id },
31+
where: { ownerId: req.user!.id, spaceId: null },
3232
orderBy: { name: 'asc' },
3333
});
3434
return { folders: folders.map(toPublicFolder) };
@@ -42,7 +42,7 @@ export const apiV1Routes: FastifyPluginAsync = async (app) => {
4242
const parentId = typeof body.parentId === 'string' ? body.parentId : null;
4343

4444
if (parentId) {
45-
const parent = await prisma.folder.findFirst({ where: { id: parentId, ownerId: req.user!.id } });
45+
const parent = await prisma.folder.findFirst({ where: { id: parentId, ownerId: req.user!.id, spaceId: null } });
4646
if (!parent) return reply.code(404).send({ error: 'Parent folder not found' });
4747
if (parent.isZeroKnowledge) return reply.code(400).send({ error: 'Vault folders are not accessible via the API' });
4848
}
@@ -61,11 +61,11 @@ export const apiV1Routes: FastifyPluginAsync = async (app) => {
6161
const target = folderId ?? null;
6262
if (!folderAllowed(req, target)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
6363
if (target) {
64-
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id } });
64+
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id, spaceId: null } });
6565
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
6666
}
6767
const files = await prisma.fileObject.findMany({
68-
where: { ownerId: req.user!.id, folderId: target, encMode: 'SERVER', deletedAt: null },
68+
where: { ownerId: req.user!.id, spaceId: null, folderId: target, encMode: 'SERVER', deletedAt: null },
6969
orderBy: { name: 'asc' },
7070
});
7171
return { files: files.map(toPublicFile) };
@@ -78,7 +78,7 @@ export const apiV1Routes: FastifyPluginAsync = async (app) => {
7878
if (!folderAllowed(req, target)) return reply.code(403).send({ error: 'Token is restricted to another folder' });
7979

8080
if (target) {
81-
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id } });
81+
const folder = await prisma.folder.findFirst({ where: { id: target, ownerId: req.user!.id, spaceId: null } });
8282
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
8383
if (folder.isZeroKnowledge) return reply.code(400).send({ error: 'Vault folders are not accessible via the API' });
8484
}
@@ -111,7 +111,7 @@ export const apiV1Routes: FastifyPluginAsync = async (app) => {
111111
app.get('/files/:id/download', { preHandler: app.tokenAuth('read') }, async (req, reply) => {
112112
const { id } = req.params as { id: string };
113113
const file = await prisma.fileObject.findFirst({
114-
where: { id, ownerId: req.user!.id, encMode: 'SERVER', deletedAt: null },
114+
where: { id, ownerId: req.user!.id, spaceId: null, encMode: 'SERVER', deletedAt: null },
115115
});
116116
if (!file) return reply.code(404).send({ error: 'File not found' });
117117
if (!folderAllowed(req, file.folderId)) return reply.code(403).send({ error: 'Token is restricted to another folder' });

‎apps/api/src/routes/files.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
1818
app.get('/', async (req) => {
1919
const { folderId } = req.query as { folderId?: string };
2020
const files = await prisma.fileObject.findMany({
21-
where: { ownerId: req.user!.id, folderId: folderId ?? null, deletedAt: null },
21+
where: { ownerId: req.user!.id, spaceId: null, folderId: folderId ?? null, deletedAt: null },
2222
orderBy: { createdAt: 'desc' },
2323
});
2424
return { files: files.map(toPublicFile) };
@@ -34,6 +34,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
3434
const files = await prisma.fileObject.findMany({
3535
where: {
3636
ownerId: req.user!.id,
37+
spaceId: null,
3738
encMode: 'SERVER',
3839
deletedAt: null,
3940
name: { contains: term, mode: 'insensitive' },
@@ -50,7 +51,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
5051

5152
if (folderId) {
5253
const folder = await prisma.folder.findFirst({
53-
where: { id: folderId, ownerId: req.user!.id },
54+
where: { id: folderId, ownerId: req.user!.id, spaceId: null },
5455
});
5556
if (!folder) return reply.code(404).send({ error: 'Folder not found' });
5657
if (folder.isZeroKnowledge) {
@@ -94,7 +95,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
9495
app.get('/:id/download', async (req, reply) => {
9596
const { id } = req.params as { id: string };
9697
const file = await prisma.fileObject.findFirst({
97-
where: { id, ownerId: req.user!.id },
98+
where: { id, ownerId: req.user!.id, spaceId: null },
9899
});
99100
if (!file) return reply.code(404).send({ error: 'File not found' });
100101
if (file.encMode === 'ZK') {
@@ -118,15 +119,15 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
118119
const body = parseOr400(reply, updateFileSchema, req.body);
119120
if (!body) return;
120121

121-
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id } });
122+
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id, spaceId: null } });
122123
if (!file) return reply.code(404).send({ error: 'File not found' });
123124
if (file.encMode === 'ZK') {
124125
return reply.code(400).send({ error: 'Vault files are managed through the vault API' });
125126
}
126127

127128
if (body.folderId !== undefined && body.folderId !== null) {
128129
const folder = await prisma.folder.findFirst({
129-
where: { id: body.folderId, ownerId: req.user!.id },
130+
where: { id: body.folderId, ownerId: req.user!.id, spaceId: null },
130131
});
131132
if (!folder) return reply.code(404).send({ error: 'Target folder not found' });
132133
if (folder.isZeroKnowledge) {
@@ -148,7 +149,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
148149
// GET /files/:id/versions — list retained prior versions (newest first).
149150
app.get('/:id/versions', async (req, reply) => {
150151
const { id } = req.params as { id: string };
151-
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id } });
152+
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id, spaceId: null } });
152153
if (!file) return reply.code(404).send({ error: 'File not found' });
153154
const versions = await prisma.fileVersion.findMany({
154155
where: { fileId: id },
@@ -168,7 +169,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
168169
// GET /files/:id/versions/:versionId/download — decrypt and stream a past version.
169170
app.get('/:id/versions/:versionId/download', async (req, reply) => {
170171
const { id, versionId } = req.params as { id: string; versionId: string };
171-
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id } });
172+
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id, spaceId: null } });
172173
if (!file) return reply.code(404).send({ error: 'File not found' });
173174
const version = await prisma.fileVersion.findFirst({ where: { id: versionId, fileId: id } });
174175
if (!version) return reply.code(404).send({ error: 'Version not found' });
@@ -183,7 +184,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
183184
// content is first snapshotted as a new version, so nothing is lost (a metadata swap).
184185
app.post('/:id/versions/:versionId/restore', async (req, reply) => {
185186
const { id, versionId } = req.params as { id: string; versionId: string };
186-
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id } });
187+
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id, spaceId: null } });
187188
if (!file) return reply.code(404).send({ error: 'File not found' });
188189
const version = await prisma.fileVersion.findFirst({ where: { id: versionId, fileId: id } });
189190
if (!version) return reply.code(404).send({ error: 'Version not found' });
@@ -224,7 +225,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
224225
// POST /files/:id/virustotal — on-demand hash lookup (no file contents sent).
225226
app.post('/:id/virustotal', async (req, reply) => {
226227
const { id } = req.params as { id: string };
227-
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id } });
228+
const file = await prisma.fileObject.findFirst({ where: { id, ownerId: req.user!.id, spaceId: null } });
228229
if (!file) return reply.code(404).send({ error: 'File not found' });
229230
if (!file.sha256) return reply.code(400).send({ error: 'File has no hash to look up' });
230231
if (!app.ctx.virustotal.enabled) {

0 commit comments

Comments
 (0)