@studious-lms/server 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/middleware/security.d.ts.map +1 -1
  2. package/dist/middleware/security.js +3 -3
  3. package/dist/middleware/security.js.map +1 -1
  4. package/dist/models/agenda.d.ts +2 -2
  5. package/dist/models/announcement.d.ts +2 -2
  6. package/dist/models/assignment.d.ts +33 -33
  7. package/dist/models/class.d.ts +2 -2
  8. package/dist/models/cloudStorage.d.ts +14 -1
  9. package/dist/models/cloudStorage.d.ts.map +1 -1
  10. package/dist/models/cloudStorage.js +123 -3
  11. package/dist/models/cloudStorage.js.map +1 -1
  12. package/dist/models/event.d.ts +3 -3
  13. package/dist/models/file.d.ts +27 -26
  14. package/dist/models/file.d.ts.map +1 -1
  15. package/dist/models/file.js +4 -39
  16. package/dist/models/file.js.map +1 -1
  17. package/dist/models/labChat.d.ts +6 -6
  18. package/dist/models/section.d.ts +1 -1
  19. package/dist/routers/_app.d.ts +354 -100
  20. package/dist/routers/_app.d.ts.map +1 -1
  21. package/dist/routers/_app.js +4 -2
  22. package/dist/routers/_app.js.map +1 -1
  23. package/dist/routers/agenda.d.ts +2 -2
  24. package/dist/routers/assignment.d.ts +31 -31
  25. package/dist/routers/class.d.ts +5 -5
  26. package/dist/routers/event.d.ts +2 -2
  27. package/dist/routers/file.d.ts +2 -2
  28. package/dist/routers/folder.d.ts +24 -0
  29. package/dist/routers/folder.d.ts.map +1 -1
  30. package/dist/routers/folder.js +27 -5
  31. package/dist/routers/folder.js.map +1 -1
  32. package/dist/routers/labChat.d.ts +2 -2
  33. package/dist/routers/newtonChat.d.ts +1 -1
  34. package/dist/routers/permissions.d.ts +120 -0
  35. package/dist/routers/permissions.d.ts.map +1 -0
  36. package/dist/routers/permissions.js +50 -0
  37. package/dist/routers/permissions.js.map +1 -0
  38. package/dist/routers/tag.d.ts +5 -5
  39. package/dist/services/agenda.d.ts +2 -2
  40. package/dist/services/assignment.d.ts +31 -31
  41. package/dist/services/class.d.ts +5 -5
  42. package/dist/services/class.js +3 -3
  43. package/dist/services/class.js.map +1 -1
  44. package/dist/services/event.d.ts +2 -2
  45. package/dist/services/file.d.ts +22 -5
  46. package/dist/services/file.d.ts.map +1 -1
  47. package/dist/services/file.js +81 -45
  48. package/dist/services/file.js.map +1 -1
  49. package/dist/services/folder.d.ts +97 -2
  50. package/dist/services/folder.d.ts.map +1 -1
  51. package/dist/services/folder.js +158 -87
  52. package/dist/services/folder.js.map +1 -1
  53. package/dist/services/labChat.d.ts +2 -2
  54. package/dist/services/permissions.d.ts +188 -0
  55. package/dist/services/permissions.d.ts.map +1 -0
  56. package/dist/services/permissions.js +661 -0
  57. package/dist/services/permissions.js.map +1 -0
  58. package/package.json +1 -1
  59. package/prisma/migrations/20260510170000_add_folder_permissions/migration.sql +93 -0
  60. package/prisma/schema.prisma +74 -0
  61. package/src/middleware/security.ts +1 -1
  62. package/src/models/cloudStorage.ts +134 -1
  63. package/src/models/file.ts +2 -37
  64. package/src/routers/_app.ts +2 -0
  65. package/src/routers/folder.ts +32 -2
  66. package/src/routers/permissions.ts +71 -0
  67. package/src/services/class.ts +1 -1
  68. package/src/services/file.ts +76 -47
  69. package/src/services/folder.ts +180 -91
  70. package/src/services/permissions.ts +778 -0
  71. package/tests/unit/services/permissions.test.ts +463 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Permissions router. Backs the "Share" dialog and the "Shared with me" view.
3
+ *
4
+ * Permission semantics live in `services/permissions.ts`; this router is just
5
+ * a thin Zod-validated tRPC surface over those helpers.
6
+ *
7
+ * v1 grants only on folders. Files inherit from their parent folder. Adding
8
+ * file-level grants later is a non-breaking, additive change.
9
+ */
10
+ import { z } from "zod";
11
+ import { createTRPCRouter, protectedProcedure } from "../trpc.js";
12
+ import {
13
+ grantPermission,
14
+ listPermissions,
15
+ listSharedWithMe,
16
+ revokePermission,
17
+ type PermissionPrincipalInput,
18
+ } from "../services/permissions.js";
19
+
20
+ const principalSchema: z.ZodType<PermissionPrincipalInput> = z.discriminatedUnion(
21
+ "kind",
22
+ [
23
+ z.object({ kind: z.literal("USER"), userId: z.string() }),
24
+ z.object({ kind: z.literal("TAG"), tagId: z.string() }),
25
+ z.object({ kind: z.literal("CLASS"), classId: z.string() }),
26
+ z.object({ kind: z.literal("CLASS_TEACHERS"), classId: z.string() }),
27
+ z.object({ kind: z.literal("CLASS_STUDENTS"), classId: z.string() }),
28
+ z.object({ kind: z.literal("DEPARTMENT"), departmentId: z.string() }),
29
+ z.object({ kind: z.literal("SCHOOL"), schoolId: z.string() }),
30
+ ]
31
+ );
32
+
33
+ const levelSchema = z.enum(["READ", "WRITE"]);
34
+
35
+ export const permissionsRouter = createTRPCRouter({
36
+ /** List all explicit grants on a folder. Caller must have WRITE. */
37
+ list: protectedProcedure
38
+ .input(z.object({ folderId: z.string() }))
39
+ .query(({ ctx, input }) =>
40
+ listPermissions(ctx.user!.id, input.folderId)
41
+ ),
42
+
43
+ /** Create a new explicit grant on a folder. */
44
+ grant: protectedProcedure
45
+ .input(
46
+ z.object({
47
+ folderId: z.string(),
48
+ principal: principalSchema,
49
+ level: levelSchema,
50
+ })
51
+ )
52
+ .mutation(({ ctx, input }) =>
53
+ grantPermission(ctx.user!.id, {
54
+ folderId: input.folderId,
55
+ principal: input.principal,
56
+ level: input.level,
57
+ })
58
+ ),
59
+
60
+ /** Remove an explicit grant. Allowed for the granter or anyone with WRITE on the folder. */
61
+ revoke: protectedProcedure
62
+ .input(z.object({ permissionId: z.string() }))
63
+ .mutation(({ ctx, input }) =>
64
+ revokePermission(ctx.user!.id, input.permissionId)
65
+ ),
66
+
67
+ /** Drive-style "Shared with me" — top-most folders explicitly granted to the caller. */
68
+ listSharedWithMe: protectedProcedure.query(({ ctx }) =>
69
+ listSharedWithMe(ctx.user!.id)
70
+ ),
71
+ });
@@ -74,7 +74,7 @@ export async function getAllClasses(userId: string) {
74
74
  schoolId: cls.schoolId ?? null,
75
75
  school: cls.school ?? null,
76
76
  department: cls.department ?? null,
77
- })),
77
+ })).filter((cls) => cls.members.some((member) => member.id === userId)),
78
78
  studentInClass: studentClasses.map((cls) => ({
79
79
  id: cls.id,
80
80
  name: cls.name,
@@ -1,11 +1,23 @@
1
1
  /**
2
- * File service – signed URLs, move, rename, delete. Handles permission checks
3
- * for assignment, submission, announcement, folder, and user-owned files.
2
+ * File service – signed URLs, move, rename, delete.
4
3
  *
5
- * The class files page calls the legacy class-scoped helpers; the unified
6
- * cloud-storage page calls the `*Scoped` / `*Generic` helpers below, which
7
- * derive permissions from the parent folder's CloudScope so the same endpoint
8
- * can serve class, school, and personal storage.
4
+ * Two access concepts coexist here, and they should not be conflated:
5
+ *
6
+ * 1. **Contextual access** — assignment, submission, announcement, and chat
7
+ * attachments. A teacher viewing a student's submission has access by
8
+ * virtue of teaching the class, not because anyone explicitly "shared"
9
+ * the file with them. These checks live inline in `getFileSignedUrl`
10
+ * below and predate the explicit-share system.
11
+ *
12
+ * 2. **Explicit-share access** — folder-scoped files in cloud storage.
13
+ * Permission flows through the central resolver in `permissions.ts`,
14
+ * which consults explicit `Permission` rows in addition to the
15
+ * scope-derived defaults. The cloud-storage helpers (`*Scoped` /
16
+ * `*Generic`) below all use this path.
17
+ *
18
+ * If you're tempted to refactor the contextual checks into the resolver,
19
+ * don't — they intentionally key off the file's domain context, not its
20
+ * filesystem location.
9
21
  */
10
22
  import { TRPCError } from "@trpc/server";
11
23
  import { getSignedUrl, deleteFile } from "../lib/googleCloudStorage.js";
@@ -23,17 +35,34 @@ import { findConversationMembership } from "../models/message.js";
23
35
  import { prisma } from "../lib/prisma.js";
24
36
  import { resolveFolderScope } from "../models/cloudStorage.js";
25
37
  import { requireFolderAccess } from "./folder.js";
38
+ import { requireFileAccess } from "./permissions.js";
26
39
 
27
40
  function isSchoolAdminForClass(classData: any, userId: string) {
28
41
  return Boolean(
29
42
  classData?.school?.memberships?.some(
30
43
  (membership: { userId: string }) => membership.userId === userId
31
- ) ||
32
- classData?.school?.users?.some((admin: { id: string }) => admin.id === userId)
44
+ )
33
45
  );
34
46
  }
35
47
 
36
- /** Generate a signed URL for file download. Validates user has access. */
48
+ /**
49
+ * Generate a signed URL for file download.
50
+ *
51
+ * Access resolution, in order — first match wins:
52
+ * 1. Owner of the file.
53
+ * 2. Member of the conversation the file is attached to (chat upload).
54
+ * 3. Contextual access via the file's class context: assignment / announcement
55
+ * / submission / annotations attached to a class the user teaches, learns
56
+ * in, or is a school admin for.
57
+ * 4. The central permissions resolver, which covers folder-scoped files —
58
+ * explicit Permission grants (USER / TAG / CLASS / DEPARTMENT / SCHOOL
59
+ * principals) plus scope defaults (school members read SCHOOL_STORAGE,
60
+ * class members read class folders, owners read personal folders).
61
+ *
62
+ * The resolver is invoked unconditionally as the last step, so files shared
63
+ * via the new Permission system reach this code path even when they have no
64
+ * matching domain context.
65
+ */
37
66
  export async function getFileSignedUrl(userId: string, fileId: string) {
38
67
  const file = await findFileById(fileId);
39
68
  if (!file) {
@@ -45,58 +74,58 @@ export async function getFileSignedUrl(userId: string, fileId: string) {
45
74
 
46
75
  let hasAccess = false;
47
76
 
48
- if (file.assignment?.class) {
49
- const isTeacher = file.assignment.class.teachers.some(
50
- (t) => t.id === userId
51
- );
52
- const isStudent = file.assignment.class.students.some(
53
- (s) => s.id === userId
54
- );
55
- const isSchoolAdmin = isSchoolAdminForClass(file.assignment.class, userId);
56
- logger.info(
57
- `Assignment file access check - userId: ${userId}, isTeacher: ${isTeacher}, isStudent: ${isStudent}, isSchoolAdmin: ${isSchoolAdmin}`
58
- );
59
- hasAccess = isTeacher || isStudent || isSchoolAdmin;
77
+ if (file.userId === userId) {
78
+ hasAccess = true;
60
79
  }
61
80
 
62
- if ((file as any).announcement?.class) {
63
- const ann = (file as any).announcement;
64
- const isTeacher = ann.class.teachers.some((t: any) => t.id === userId);
65
- const isStudent = ann.class.students.some((s: any) => s.id === userId);
66
- const isSchoolAdmin = isSchoolAdminForClass(ann.class, userId);
67
- hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
81
+ if (!hasAccess && file.conversationId) {
82
+ const membership = await findConversationMembership(file.conversationId, userId);
83
+ hasAccess = Boolean(membership);
68
84
  }
69
85
 
70
- if (file.submission?.assignment?.classId) {
86
+ if (!hasAccess && file.assignment?.class) {
87
+ const cls = file.assignment.class;
71
88
  hasAccess =
72
- hasAccess ||
73
- file.submission.studentId === userId ||
74
- file.submission.assignment.class.teachers.some((t) => t.id === userId) ||
75
- isSchoolAdminForClass(file.submission.assignment.class, userId);
89
+ cls.teachers.some((t) => t.id === userId) ||
90
+ cls.students.some((s) => s.id === userId) ||
91
+ isSchoolAdminForClass(cls, userId);
76
92
  }
77
93
 
78
- if (file.annotations?.assignment?.classId) {
94
+ if (!hasAccess && (file as any).announcement?.class) {
95
+ const cls = (file as any).announcement.class;
79
96
  hasAccess =
80
- hasAccess ||
81
- file.annotations.studentId === userId ||
82
- file.annotations.assignment.class.teachers.some((t) => t.id === userId) ||
83
- isSchoolAdminForClass(file.annotations.assignment.class, userId);
97
+ cls.teachers.some((t: { id: string }) => t.id === userId) ||
98
+ cls.students.some((s: { id: string }) => s.id === userId) ||
99
+ isSchoolAdminForClass(cls, userId);
84
100
  }
85
101
 
86
- if (file.userId === userId) {
87
- hasAccess = true;
102
+ if (!hasAccess && file.submission?.assignment?.classId) {
103
+ const cls = file.submission.assignment.class;
104
+ hasAccess =
105
+ file.submission.studentId === userId ||
106
+ cls.teachers.some((t) => t.id === userId) ||
107
+ isSchoolAdminForClass(cls, userId);
88
108
  }
89
109
 
90
- if (file.conversationId) {
91
- const membership = await findConversationMembership(file.conversationId, userId);
92
- hasAccess = hasAccess || Boolean(membership);
110
+ if (!hasAccess && file.annotations?.assignment?.classId) {
111
+ const cls = file.annotations.assignment.class;
112
+ hasAccess =
113
+ file.annotations.studentId === userId ||
114
+ cls.teachers.some((t) => t.id === userId) ||
115
+ isSchoolAdminForClass(cls, userId);
93
116
  }
94
117
 
95
- if (file.folder?.class) {
96
- const isTeacher = file.folder.class.teachers.some((t) => t.id === userId);
97
- const isStudent = file.folder.class.students.some((s) => s.id === userId);
98
- const isSchoolAdmin = isSchoolAdminForClass(file.folder.class, userId);
99
- hasAccess = hasAccess || isTeacher || isStudent || isSchoolAdmin;
118
+ // Final fallback: the explicit-share + scope-default resolver. Covers
119
+ // folder-scoped files shared via Permission grants (incl. SCHOOL/CLASS/TAG
120
+ // principals) and any institution member reading SCHOOL_STORAGE files.
121
+ // Safe to call for unfiled files — the resolver returns owner-only there.
122
+ if (!hasAccess) {
123
+ try {
124
+ await requireFileAccess(userId, fileId, "READ");
125
+ hasAccess = true;
126
+ } catch {
127
+ // resolver denied — fall through to FORBIDDEN below.
128
+ }
100
129
  }
101
130
 
102
131
  if (!hasAccess) {
@@ -28,106 +28,107 @@ import {
28
28
  type DirectFileData,
29
29
  confirmDirectUpload,
30
30
  } from "../lib/fileUpload.js";
31
-
32
- type AccessLevel = "READ" | "WRITE";
31
+ import {
32
+ type AccessLevel,
33
+ getScopeDefault,
34
+ listSharedWithMe,
35
+ requireFolderAccess as requireFolderAccessResolved,
36
+ } from "./permissions.js";
33
37
 
34
38
  interface AccessResult {
35
39
  scope: CloudScope;
36
40
  canWrite: boolean;
41
+ /**
42
+ * True when the viewer's access comes through an explicit grant rather than
43
+ * scope membership. Used by `browseFolder` to surface a "Shared with me"
44
+ * heading instead of the underlying scope's title — the grantee shouldn't
45
+ * see the folder labelled with the owner's scope.
46
+ */
47
+ viaGrant: boolean;
37
48
  }
38
49
 
50
+ /**
51
+ * Scope-default-only check: used when there is no folder id yet (browsing or
52
+ * creating at the scope root). Does *not* consult explicit grants — those only
53
+ * apply to specific folders. Explicit-grant access to sub-folders surfaces via
54
+ * the "Shared with me" virtual scope, not by being a scope member.
55
+ */
39
56
  async function requireScopeAccess(
40
57
  userId: string,
41
58
  scope: CloudScope,
42
59
  level: AccessLevel
43
60
  ): Promise<AccessResult> {
44
- switch (scope.kind) {
45
- case "CLASS": {
46
- const classData = await prisma.class.findFirst({
47
- where: { id: scope.classId },
48
- select: {
49
- id: true,
50
- schoolId: true,
51
- teachers: { where: { id: userId }, select: { id: true } },
52
- students: { where: { id: userId }, select: { id: true } },
53
- },
54
- });
55
- if (!classData) {
56
- throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
57
- }
58
- const isTeacher = classData.teachers.length > 0;
59
- const isStudent = classData.students.length > 0;
60
- const isSchoolAdmin = classData.schoolId
61
- ? Boolean(
62
- await prisma.schoolMembership.findFirst({
63
- where: {
64
- schoolId: classData.schoolId,
65
- userId,
66
- role: "ADMIN",
67
- status: "ACTIVE",
68
- },
69
- select: { id: true },
70
- })
71
- )
72
- : false;
73
- if (!isTeacher && !isStudent && !isSchoolAdmin) {
74
- throw new TRPCError({
75
- code: "FORBIDDEN",
76
- message: "You don't have access to this class",
77
- });
78
- }
79
- const canWrite = isTeacher || isSchoolAdmin;
80
- if (level === "WRITE" && !canWrite) {
81
- throw new TRPCError({
82
- code: "FORBIDDEN",
83
- message: "Only class teachers can modify class files",
84
- });
85
- }
86
- return { scope, canWrite };
87
- }
88
- case "SCHOOL_STORAGE": {
89
- const membership = await prisma.schoolMembership.findFirst({
90
- where: { schoolId: scope.schoolId, userId, status: "ACTIVE" },
91
- select: { role: true },
92
- });
93
- if (!membership) {
94
- throw new TRPCError({
95
- code: "FORBIDDEN",
96
- message: "You aren't a member of this institution",
97
- });
98
- }
99
- const canWrite =
100
- membership.role === "ADMIN" || membership.role === "TEACHER";
101
- if (level === "WRITE" && !canWrite) {
102
- throw new TRPCError({
103
- code: "FORBIDDEN",
104
- message: "Only teachers and admins can modify institution storage",
105
- });
106
- }
107
- return { scope, canWrite };
108
- }
109
- case "PERSONAL": {
110
- if (scope.userId !== userId) {
111
- throw new TRPCError({
112
- code: "FORBIDDEN",
113
- message: "You can only access your own personal storage",
114
- });
115
- }
116
- return { scope, canWrite: true };
61
+ if (scope.kind === "CLASS") {
62
+ const classExists = await prisma.class.findFirst({
63
+ where: { id: scope.classId },
64
+ select: { id: true },
65
+ });
66
+ if (!classExists) {
67
+ throw new TRPCError({ code: "NOT_FOUND", message: "Class not found" });
117
68
  }
118
69
  }
70
+ const granted = await getScopeDefault(userId, scope);
71
+ if (!granted) {
72
+ throw new TRPCError({
73
+ code: "FORBIDDEN",
74
+ message:
75
+ scope.kind === "CLASS"
76
+ ? "You don't have access to this class"
77
+ : scope.kind === "SCHOOL_STORAGE"
78
+ ? "You aren't a member of this institution"
79
+ : "You can only access your own personal storage",
80
+ });
81
+ }
82
+ if (level === "WRITE" && granted !== "WRITE") {
83
+ throw new TRPCError({
84
+ code: "FORBIDDEN",
85
+ message:
86
+ scope.kind === "CLASS"
87
+ ? "Only class teachers can modify class files"
88
+ : "Only teachers and admins can modify institution storage",
89
+ });
90
+ }
91
+ // Scope-default-only path; access cannot be via a grant here.
92
+ return { scope, canWrite: granted === "WRITE", viaGrant: false };
119
93
  }
120
94
 
95
+ /**
96
+ * Folder-level access check. Delegates to the central resolver which consults
97
+ * explicit grants in addition to the scope default. Returns the scope and a
98
+ * canWrite flag indicating whether the user has WRITE here (used by the UI to
99
+ * gate the "New folder" / "Upload" buttons during a READ-level browse).
100
+ *
101
+ * Pass `mutatesSelf: true` for actions that target the folder itself (rename,
102
+ * delete, move-this-folder). Drive-style: explicit grants don't authorize
103
+ * those — only scope-default WRITE does. Mutations of children should NOT
104
+ * pass this flag.
105
+ */
121
106
  export async function requireFolderAccess(
122
107
  userId: string,
123
108
  folderId: string,
124
- level: AccessLevel
125
- ) {
126
- const scope = await resolveFolderScope(folderId);
127
- if (!scope) {
109
+ level: AccessLevel,
110
+ opts: { mutatesSelf?: boolean } = {}
111
+ ): Promise<AccessResult> {
112
+ const result = await requireFolderAccessResolved(userId, folderId, level, opts);
113
+ if (!result.scope) {
114
+ // resolveFolderScope returns null only for missing folders; the resolver
115
+ // would have already thrown NOT_FOUND in that case.
128
116
  throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
129
117
  }
130
- return requireScopeAccess(userId, scope, level);
118
+ let canWrite = result.scopeDefault === "WRITE";
119
+ if (!canWrite && level !== "WRITE") {
120
+ // We came in with READ; check whether the user *also* has WRITE so the UI
121
+ // can show the right affordances. Cheaper to do this only when needed.
122
+ try {
123
+ await requireFolderAccessResolved(userId, folderId, "WRITE");
124
+ canWrite = true;
125
+ } catch {
126
+ canWrite = false;
127
+ }
128
+ } else if (level === "WRITE") {
129
+ canWrite = true;
130
+ }
131
+ return { scope: result.scope, canWrite, viaGrant: result.viaGrant };
131
132
  }
132
133
 
133
134
  function scopesEqual(a: CloudScope, b: CloudScope): boolean {
@@ -149,24 +150,30 @@ export async function browseFolder(
149
150
  ) {
150
151
  let scope: CloudScope;
151
152
  let folderId: string | undefined;
153
+ let canWrite: boolean;
154
+ let viaGrant: boolean;
152
155
 
153
156
  if ("folderId" in input) {
154
157
  folderId = input.folderId;
155
- const resolved = await resolveFolderScope(input.folderId);
156
- if (!resolved) {
157
- throw new TRPCError({ code: "NOT_FOUND", message: "Folder not found" });
158
- }
159
- scope = resolved;
158
+ // `requireFolderAccess` consults explicit grants in addition to the scope
159
+ // default, so users can drill into folders shared with them via "Shared
160
+ // with me" without being scope members.
161
+ const access = await requireFolderAccess(userId, folderId, "READ");
162
+ scope = access.scope;
163
+ canWrite = access.canWrite;
164
+ viaGrant = access.viaGrant;
160
165
  } else {
161
166
  scope = input.scope;
167
+ const access = await requireScopeAccess(userId, scope, "READ");
168
+ canWrite = access.canWrite;
169
+ viaGrant = false;
162
170
  }
163
171
 
164
- const access = await requireScopeAccess(userId, scope, "READ");
165
172
  const targetId = folderId ?? (await findOrCreateScopeRoot(scope)).id;
166
173
 
167
174
  const [folder, breadcrumbs] = await Promise.all([
168
175
  findFolderWithContents(targetId),
169
- listFolderBreadcrumbs(targetId),
176
+ listFolderBreadcrumbs(targetId, userId),
170
177
  ]);
171
178
 
172
179
  if (!folder) {
@@ -176,8 +183,70 @@ export async function browseFolder(
176
183
  return {
177
184
  folder,
178
185
  breadcrumbs,
179
- canWrite: access.canWrite,
186
+ canWrite,
180
187
  scope,
188
+ /**
189
+ * When the viewer's access comes through an explicit grant (not scope
190
+ * membership), the UI should label the root as "Shared with me" rather
191
+ * than the underlying scope's title. The grantee may not even be a
192
+ * member of that scope — showing "My Files" or "Class Files" would be
193
+ * misleading. Null when the viewer is a regular scope member.
194
+ */
195
+ displayRoot: viaGrant
196
+ ? { kind: "SHARED_WITH_ME" as const, name: "Shared with me" }
197
+ : null,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Drive-style "Shared with me" — surfaces every top-most folder/file the user
203
+ * has an explicit grant on (filtering out anything already covered by their
204
+ * scope defaults). Shaped like a folder so the existing folder-browser UI can
205
+ * render it without special cases.
206
+ *
207
+ * The pseudo-folder has a synthetic id (`shared-with-me`) and the foreign-key
208
+ * columns set to null — the UI should never try to mutate it directly. The
209
+ * union shape matches `browseFolder`'s return type so the same client query
210
+ * can render either.
211
+ */
212
+ export async function browseSharedWithMe(userId: string) {
213
+ const { folders } = await listSharedWithMe(userId);
214
+ return {
215
+ folder: {
216
+ id: "shared-with-me",
217
+ name: "Shared with me",
218
+ color: null,
219
+ classId: null,
220
+ schoolStorageId: null,
221
+ personalOwnerId: null,
222
+ scope: "GENERIC" as const,
223
+ parentFolderId: null,
224
+ parentFolder: null,
225
+ childFolders: folders.map((f) => ({
226
+ id: f.id,
227
+ name: f.name,
228
+ color: f.color,
229
+ _count: { files: 0, childFolders: 0 },
230
+ })),
231
+ // No file-level grants in v1 — files always live under a granted folder.
232
+ files: [] as {
233
+ id: string;
234
+ name: string;
235
+ type: string;
236
+ size: number | null;
237
+ path: string;
238
+ uploadedAt: Date | null;
239
+ user: { id: string; username: string } | null;
240
+ }[],
241
+ },
242
+ breadcrumbs: [{ id: "shared-with-me", name: "Shared with me" }],
243
+ canWrite: false,
244
+ scope: { kind: "PERSONAL" as const, userId } satisfies CloudScope,
245
+ /** Same `displayRoot` shape as `browseFolder` so the client union is uniform. */
246
+ displayRoot: {
247
+ kind: "SHARED_WITH_ME" as const,
248
+ name: "Shared with me",
249
+ },
181
250
  };
182
251
  }
183
252
 
@@ -250,7 +319,11 @@ export async function updateFolderRecord(
250
319
  message: "Cannot rename a root folder",
251
320
  });
252
321
  }
253
- await requireFolderAccess(userId, input.folderId, "WRITE");
322
+ // Rename targets the folder itself — Drive-style guard reserves this for
323
+ // scope-default WRITE (explicit grants don't authorize renaming the share).
324
+ await requireFolderAccess(userId, input.folderId, "WRITE", {
325
+ mutatesSelf: true,
326
+ });
254
327
  return renameFolder(
255
328
  input.folderId,
256
329
  input.name?.trim() ?? folder.name,
@@ -269,7 +342,8 @@ export async function deleteFolder(userId: string, folderId: string) {
269
342
  message: "Cannot delete a root folder",
270
343
  });
271
344
  }
272
- await requireFolderAccess(userId, folderId, "WRITE");
345
+ // Delete targets the folder itself — same Drive-style guard as rename.
346
+ await requireFolderAccess(userId, folderId, "WRITE", { mutatesSelf: true });
273
347
  await deleteFolderById(folderId);
274
348
  return { success: true };
275
349
  }
@@ -297,7 +371,12 @@ export async function moveFolder(
297
371
  message: "Cannot move folders between scopes",
298
372
  });
299
373
  }
300
- await requireScopeAccess(userId, sourceScope, "WRITE");
374
+ // Moving the folder targets it (and the new parent's contents). Source-side
375
+ // is mutates-self; target-side is regular WRITE on the new parent.
376
+ await requireFolderAccess(userId, input.folderId, "WRITE", {
377
+ mutatesSelf: true,
378
+ });
379
+ await requireFolderAccess(userId, input.targetParentFolderId, "WRITE");
301
380
 
302
381
  // Don't let a folder land under one of its own descendants.
303
382
  let cursor: string | null = input.targetParentFolderId;
@@ -376,6 +455,11 @@ export async function listUserScopes(userId: string) {
376
455
  orderBy: { name: "asc" },
377
456
  });
378
457
 
458
+ // Light counts for the "Shared with me" bucket so the UI can hide the entry
459
+ // when there's nothing to show. Cheap to compute — just counts top-most
460
+ // grants, not folder/file metadata.
461
+ const sharedWithMe = await listSharedWithMe(userId);
462
+
379
463
  return {
380
464
  personal: { kind: "PERSONAL" as const, userId },
381
465
  schools: memberships.map((m) => ({
@@ -385,5 +469,10 @@ export async function listUserScopes(userId: string) {
385
469
  role: m.role,
386
470
  })),
387
471
  classes,
472
+ sharedWithMe: {
473
+ kind: "SHARED_WITH_ME" as const,
474
+ folderCount: sharedWithMe.folders.length,
475
+ fileCount: sharedWithMe.files.length,
476
+ },
388
477
  };
389
478
  }