@techie_doubts/tui.notes.2026 1.0.13 → 1.0.16-exp.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 (55) hide show
  1. package/README.md +50 -0
  2. package/dist/assets/{arc-DnT4PtPq.js → arc-CTs8vxd2.js} +1 -1
  3. package/dist/assets/{architectureDiagram-VXUJARFQ-BXrpe9bX.js → architectureDiagram-VXUJARFQ-t-K3l8e1.js} +1 -1
  4. package/dist/assets/{blockDiagram-VD42YOAC-DP5p2MqV.js → blockDiagram-VD42YOAC-DIMIytmv.js} +1 -1
  5. package/dist/assets/{c4Diagram-YG6GDRKO-Ct7uTaEZ.js → c4Diagram-YG6GDRKO-CW5YAiXl.js} +1 -1
  6. package/dist/assets/channel-DKXa9vcG.js +1 -0
  7. package/dist/assets/{chunk-4BX2VUAB-C8-wPxxJ.js → chunk-4BX2VUAB-DzXtIyrg.js} +1 -1
  8. package/dist/assets/{chunk-55IACEB6-DGtqAQIB.js → chunk-55IACEB6-BnGPND_M.js} +1 -1
  9. package/dist/assets/{chunk-B4BG7PRW-5xo8czGC.js → chunk-B4BG7PRW-BklBjgAV.js} +1 -1
  10. package/dist/assets/{chunk-DI55MBZ5-CSzvABNB.js → chunk-DI55MBZ5-BNr8lC1p.js} +1 -1
  11. package/dist/assets/{chunk-FMBD7UC4-B_0cCLSi.js → chunk-FMBD7UC4-kabs-F9i.js} +1 -1
  12. package/dist/assets/{chunk-QN33PNHL-CrWDnSsf.js → chunk-QN33PNHL-DNaN1nja.js} +1 -1
  13. package/dist/assets/{chunk-QZHKN3VN-HZLnZ-EP.js → chunk-QZHKN3VN-DMNT_UpY.js} +1 -1
  14. package/dist/assets/{chunk-TZMSLE5B-Bz9O3K8e.js → chunk-TZMSLE5B-BJ9Zykuu.js} +1 -1
  15. package/dist/assets/classDiagram-2ON5EDUG-DvvXKx22.js +1 -0
  16. package/dist/assets/classDiagram-v2-WZHVMYZB-DvvXKx22.js +1 -0
  17. package/dist/assets/{clone-D0V3NIis.js → clone-DgRSp238.js} +1 -1
  18. package/dist/assets/{cose-bilkent-S5V4N54A-Bu2u7GxM.js → cose-bilkent-S5V4N54A-LlpiAUGJ.js} +1 -1
  19. package/dist/assets/{dagre-6UL2VRFP-C2f5GIN3.js → dagre-6UL2VRFP-1CY5QFbC.js} +1 -1
  20. package/dist/assets/{diagram-PSM6KHXK-SP2awr0w.js → diagram-PSM6KHXK-CyWB67TN.js} +1 -1
  21. package/dist/assets/{diagram-QEK2KX5R-Bx9gVmHS.js → diagram-QEK2KX5R-D3s5Rahm.js} +1 -1
  22. package/dist/assets/{diagram-S2PKOQOG-DpM42zIf.js → diagram-S2PKOQOG-afP1wbQy.js} +1 -1
  23. package/dist/assets/{erDiagram-Q2GNP2WA-BCafPHnd.js → erDiagram-Q2GNP2WA-Cu5MC-vn.js} +1 -1
  24. package/dist/assets/{flowDiagram-NV44I4VS-BzDK3rTw.js → flowDiagram-NV44I4VS-Cp52X3t3.js} +1 -1
  25. package/dist/assets/{ganttDiagram-JELNMOA3-CKyPyucm.js → ganttDiagram-JELNMOA3-B-FV5NtV.js} +1 -1
  26. package/dist/assets/{gitGraphDiagram-NY62KEGX-C9dH7T48.js → gitGraphDiagram-NY62KEGX-DXjcfXw-.js} +1 -1
  27. package/dist/assets/{index-BilTouDJ.js → index-DT7P8Yy_.js} +529 -460
  28. package/dist/assets/{index-CNp6F8X7.css → index-RZ7CoTP6.css} +1 -1
  29. package/dist/assets/{infoDiagram-WHAUD3N6-CH3Av-mQ.js → infoDiagram-WHAUD3N6-6gXAcBpT.js} +1 -1
  30. package/dist/assets/{journeyDiagram-XKPGCS4Q-DIoHgl1Z.js → journeyDiagram-XKPGCS4Q-B63GyOcC.js} +1 -1
  31. package/dist/assets/{kanban-definition-3W4ZIXB7-CWLQr3gj.js → kanban-definition-3W4ZIXB7-BGUIjvVd.js} +1 -1
  32. package/dist/assets/{linear-CBhVQMz6.js → linear-CGkv28JQ.js} +1 -1
  33. package/dist/assets/{mindmap-definition-VGOIOE7T-Cxs-zpuQ.js → mindmap-definition-VGOIOE7T-QTeRbvXZ.js} +1 -1
  34. package/dist/assets/{pieDiagram-ADFJNKIX-cwQKtTba.js → pieDiagram-ADFJNKIX-BY8oRvp_.js} +1 -1
  35. package/dist/assets/{quadrantDiagram-AYHSOK5B-Bkz-9rDu.js → quadrantDiagram-AYHSOK5B-BYuVMrlE.js} +1 -1
  36. package/dist/assets/{requirementDiagram-UZGBJVZJ-WlYK0SEk.js → requirementDiagram-UZGBJVZJ-BJGSRZd3.js} +1 -1
  37. package/dist/assets/{sankeyDiagram-TZEHDZUN-C7_ihe8w.js → sankeyDiagram-TZEHDZUN-D4szE45R.js} +1 -1
  38. package/dist/assets/{sequenceDiagram-WL72ISMW-VD7vKL9M.js → sequenceDiagram-WL72ISMW-g-LURDtG.js} +1 -1
  39. package/dist/assets/{stateDiagram-FKZM4ZOC-ClBZturp.js → stateDiagram-FKZM4ZOC-DlfvisVh.js} +1 -1
  40. package/dist/assets/stateDiagram-v2-4FDKWEC3-BjHRrc7G.js +1 -0
  41. package/dist/assets/{timeline-definition-IT6M3QCI-BlnB3vyh.js → timeline-definition-IT6M3QCI-V2SgS8bM.js} +1 -1
  42. package/dist/assets/{treemap-KMMF4GRG-2Tjc4tzM.js → treemap-KMMF4GRG-BmxpkoZ5.js} +1 -1
  43. package/dist/assets/{xychartDiagram-PRI3JC2R-Dna9L0wE.js → xychartDiagram-PRI3JC2R-W9kGz4s7.js} +1 -1
  44. package/dist/index.html +2 -2
  45. package/package.json +8 -3
  46. package/server/acl-service.js +1249 -0
  47. package/server/acl-service.test.js +439 -0
  48. package/server/acl-store.js +421 -0
  49. package/server/auth.js +119 -0
  50. package/server/index.js +719 -23
  51. package/server/store.js +12 -0
  52. package/dist/assets/channel-B-LJJVbj.js +0 -1
  53. package/dist/assets/classDiagram-2ON5EDUG-B8hqCKwM.js +0 -1
  54. package/dist/assets/classDiagram-v2-WZHVMYZB-B8hqCKwM.js +0 -1
  55. package/dist/assets/stateDiagram-v2-4FDKWEC3-D0C_O_lb.js +0 -1
@@ -0,0 +1,1249 @@
1
+ import crypto from "node:crypto";
2
+ import { createBindingInput } from "./acl-store.js";
3
+
4
+ const WORKSPACE_TYPE = "workspace";
5
+ const WORKSPACE_ID = "main";
6
+
7
+ const ROLE_LEVEL = {
8
+ viewer: 1,
9
+ editor: 2,
10
+ owner: 3,
11
+ };
12
+
13
+ const ACTION_LEVEL = {
14
+ read: ROLE_LEVEL.viewer,
15
+ write: ROLE_LEVEL.editor,
16
+ manage: ROLE_LEVEL.owner,
17
+ };
18
+
19
+ const HOME_FOLDER_NAME = "Home";
20
+ const HOME_FOLDER_ID_PREFIX = "home-";
21
+
22
+ function nowMs() {
23
+ return Date.now();
24
+ }
25
+
26
+ function normalizeStatePayload(rawState) {
27
+ const state = rawState && typeof rawState === "object" ? rawState : {};
28
+ const folders = Array.isArray(state.folders)
29
+ ? state.folders
30
+ .filter((folder) => folder && folder.id != null)
31
+ .map((folder) => ({
32
+ id: String(folder.id),
33
+ name: String(folder.name || "Untitled Folder"),
34
+ parentId: folder.parentId == null ? null : String(folder.parentId),
35
+ createdAt: Number(folder.createdAt) || nowMs(),
36
+ updatedAt: Number(folder.updatedAt) || Number(folder.createdAt) || nowMs(),
37
+ }))
38
+ : [];
39
+
40
+ const folderIds = new Set(folders.map((folder) => folder.id));
41
+
42
+ const notes = Array.isArray(state.notes)
43
+ ? state.notes
44
+ .filter((note) => note && note.id != null)
45
+ .map((note) => {
46
+ const folderId = note.folderId == null ? null : String(note.folderId);
47
+ return {
48
+ id: String(note.id),
49
+ title: String(note.title || "Untitled"),
50
+ folderId: folderId && folderIds.has(folderId) ? folderId : null,
51
+ content: typeof note.content === "string" ? note.content : "",
52
+ fileName: typeof note.fileName === "string" ? note.fileName : `${String(note.id)}.md`,
53
+ shareId: typeof note.shareId === "string" ? note.shareId.trim() || null : null,
54
+ createdAt: Number(note.createdAt) || nowMs(),
55
+ updatedAt: Number(note.updatedAt) || Number(note.createdAt) || nowMs(),
56
+ deletedAt: note.deletedAt ? Number(note.deletedAt) || null : null,
57
+ };
58
+ })
59
+ : [];
60
+
61
+ const expandedFolderIds = Array.isArray(state?.ui?.expandedFolderIds)
62
+ ? [...new Set(state.ui.expandedFolderIds.map((id) => String(id)).filter((id) => folderIds.has(id)))]
63
+ : [];
64
+
65
+ return {
66
+ folders,
67
+ notes,
68
+ ui: { expandedFolderIds },
69
+ };
70
+ }
71
+
72
+ function resourceKey(type, externalId) {
73
+ return `${String(type)}:${String(externalId)}`;
74
+ }
75
+
76
+ function parseOwnerSubject(rawValue) {
77
+ const value = String(rawValue || "").trim();
78
+ if (!value) {
79
+ return null;
80
+ }
81
+ const [subjectType, ...rest] = value.split(":");
82
+ if (!subjectType || !rest.length) {
83
+ return null;
84
+ }
85
+ const subjectId = rest.join(":").trim();
86
+ if (!subjectId) {
87
+ return null;
88
+ }
89
+ return { subjectType, subjectId };
90
+ }
91
+
92
+ function toOwnerSubject(subjectType, subjectId) {
93
+ return `${subjectType}:${subjectId}`;
94
+ }
95
+
96
+ function listUserSubjectCandidates(user) {
97
+ const candidates = [];
98
+ const seen = new Set();
99
+
100
+ const userId = String(user?.userId || "").trim();
101
+ const email = String(user?.email || "").trim().toLowerCase();
102
+ const preferredUsername = String(user?.preferredUsername || "").trim();
103
+ const id = String(user?.id || "").trim();
104
+
105
+ const push = (subjectId) => {
106
+ const normalized = String(subjectId || "").trim();
107
+ if (!normalized || seen.has(normalized) || normalized === "anonymous") {
108
+ return;
109
+ }
110
+ seen.add(normalized);
111
+ candidates.push({
112
+ subjectType: "user",
113
+ subjectId: normalized,
114
+ });
115
+ };
116
+
117
+ push(email);
118
+ push(preferredUsername);
119
+ push(userId);
120
+ push(id);
121
+
122
+ return candidates;
123
+ }
124
+
125
+ function buildHomeFolderIdForSubject(subjectType, subjectId) {
126
+ const seed = `${String(subjectType)}:${String(subjectId)}`;
127
+ const digest = crypto
128
+ .createHash("sha256")
129
+ .update(seed)
130
+ .digest("hex");
131
+ return `${HOME_FOLDER_ID_PREFIX}${digest.slice(0, 32)}`;
132
+ }
133
+
134
+ function listHomeFolderIdsForUser(user) {
135
+ const ids = [];
136
+ const seen = new Set();
137
+ for (const subject of listUserSubjectCandidates(user)) {
138
+ const folderId = buildHomeFolderIdForSubject(subject.subjectType, subject.subjectId);
139
+ if (seen.has(folderId)) {
140
+ continue;
141
+ }
142
+ seen.add(folderId);
143
+ ids.push(folderId);
144
+ }
145
+ return ids;
146
+ }
147
+
148
+ function toHomeFolderName(user) {
149
+ const looksMachineId = (value) => {
150
+ const normalized = String(value || "").trim();
151
+ if (!normalized) {
152
+ return false;
153
+ }
154
+ return (
155
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(normalized) ||
156
+ /^[0-9a-f]{24,}$/i.test(normalized)
157
+ );
158
+ };
159
+
160
+ const preferredUsername = String(user?.preferredUsername || "").trim();
161
+ const email = String(user?.email || "").trim().toLowerCase();
162
+ const displayName = String(user?.displayName || "").trim();
163
+ const userId = String(user?.userId || "").trim();
164
+
165
+ const selectSource =
166
+ preferredUsername ||
167
+ (email.includes("@") ? email.split("@")[0].trim() : email) ||
168
+ (!looksMachineId(displayName) ? displayName : "") ||
169
+ (!looksMachineId(userId) ? userId : "");
170
+
171
+ if (!selectSource) {
172
+ return HOME_FOLDER_NAME;
173
+ }
174
+
175
+ const normalized = String(selectSource).split("@")[0].trim();
176
+ return normalized || HOME_FOLDER_NAME;
177
+ }
178
+
179
+ function getUserSubjectIds(user) {
180
+ const ids = new Set();
181
+
182
+ for (const candidate of listUserSubjectCandidates(user)) {
183
+ ids.add(candidate.subjectId);
184
+ ids.add(`user:${candidate.subjectId}`);
185
+ if (candidate.subjectId.includes("@")) {
186
+ ids.add(`email:${candidate.subjectId}`);
187
+ }
188
+ }
189
+
190
+ return ids;
191
+ }
192
+
193
+ function getGroupSubjectIds(user) {
194
+ const groups = Array.isArray(user?.groups) ? user.groups : [];
195
+ const ids = new Set();
196
+ for (const group of groups) {
197
+ const value = String(group || "").trim();
198
+ if (!value) {
199
+ continue;
200
+ }
201
+ ids.add(value);
202
+ ids.add(`group:${value}`);
203
+ }
204
+ return ids;
205
+ }
206
+
207
+ function matchesActorSubject(binding, actorUser) {
208
+ if (!binding || !actorUser) {
209
+ return false;
210
+ }
211
+
212
+ if (binding.subjectType === "public") {
213
+ return binding.subjectId === "*";
214
+ }
215
+
216
+ if (binding.subjectType === "user") {
217
+ return getUserSubjectIds(actorUser).has(String(binding.subjectId || "").trim());
218
+ }
219
+
220
+ if (binding.subjectType === "group") {
221
+ return getGroupSubjectIds(actorUser).has(String(binding.subjectId || "").trim());
222
+ }
223
+
224
+ return false;
225
+ }
226
+
227
+ function cloneState(state) {
228
+ return {
229
+ folders: state.folders.map((folder) => ({ ...folder })),
230
+ notes: state.notes.map((note) => ({ ...note })),
231
+ ui: {
232
+ expandedFolderIds: Array.isArray(state?.ui?.expandedFolderIds)
233
+ ? [...state.ui.expandedFolderIds]
234
+ : [],
235
+ },
236
+ };
237
+ }
238
+
239
+ function buildFolderChildrenMap(folders) {
240
+ const children = new Map();
241
+ for (const folder of folders) {
242
+ const key = folder.parentId || "__root__";
243
+ if (!children.has(key)) {
244
+ children.set(key, []);
245
+ }
246
+ children.get(key).push(folder.id);
247
+ }
248
+ return children;
249
+ }
250
+
251
+ function collectDescendantFolderIds(folderId, childrenMap) {
252
+ const visited = new Set();
253
+ const stack = [String(folderId)];
254
+
255
+ while (stack.length) {
256
+ const current = stack.pop();
257
+ if (visited.has(current)) {
258
+ continue;
259
+ }
260
+ visited.add(current);
261
+
262
+ const children = childrenMap.get(current) || [];
263
+ for (const childId of children) {
264
+ if (!visited.has(childId)) {
265
+ stack.push(childId);
266
+ }
267
+ }
268
+ }
269
+
270
+ return visited;
271
+ }
272
+
273
+ export class AclService {
274
+ constructor({ store, authModeProvider, logger = console }) {
275
+ this.store = store;
276
+ this.authModeProvider = authModeProvider;
277
+ this.logger = logger;
278
+ }
279
+
280
+ getAuthMode() {
281
+ return typeof this.authModeProvider === "function" ? this.authModeProvider() : "off";
282
+ }
283
+
284
+ async syncResourcesFromState(rawState) {
285
+ const state = normalizeStatePayload(rawState);
286
+ const now = nowMs();
287
+
288
+ const resources = [
289
+ {
290
+ type: WORKSPACE_TYPE,
291
+ externalId: WORKSPACE_ID,
292
+ parentType: null,
293
+ parentExternalId: null,
294
+ ownerSubject: null,
295
+ createdAt: now,
296
+ updatedAt: now,
297
+ },
298
+ ];
299
+
300
+ for (const folder of state.folders) {
301
+ resources.push({
302
+ type: "folder",
303
+ externalId: folder.id,
304
+ parentType: folder.parentId ? "folder" : WORKSPACE_TYPE,
305
+ parentExternalId: folder.parentId || WORKSPACE_ID,
306
+ ownerSubject: null,
307
+ createdAt: Number(folder.createdAt) || now,
308
+ updatedAt: Number(folder.updatedAt) || Number(folder.createdAt) || now,
309
+ });
310
+ }
311
+
312
+ for (const note of state.notes) {
313
+ resources.push({
314
+ type: "note",
315
+ externalId: note.id,
316
+ parentType: note.folderId ? "folder" : WORKSPACE_TYPE,
317
+ parentExternalId: note.folderId || WORKSPACE_ID,
318
+ ownerSubject: null,
319
+ createdAt: Number(note.createdAt) || now,
320
+ updatedAt: Number(note.updatedAt) || Number(note.createdAt) || now,
321
+ });
322
+ }
323
+
324
+ await this.store.replaceResources(resources);
325
+ return resources;
326
+ }
327
+
328
+ async ensureBootstrapOwner(user) {
329
+ const hasBindings = await this.store.hasBindings();
330
+ if (hasBindings) {
331
+ return false;
332
+ }
333
+
334
+ const bootstrapEmail = String(process.env.TUI_NOTES_BOOTSTRAP_OWNER_EMAIL || "")
335
+ .trim()
336
+ .toLowerCase();
337
+ const bootstrapUserId = String(process.env.TUI_NOTES_BOOTSTRAP_OWNER_USER || "").trim();
338
+
339
+ let subjectType = "";
340
+ let subjectId = "";
341
+
342
+ if (bootstrapUserId) {
343
+ subjectType = "user";
344
+ subjectId = bootstrapUserId;
345
+ } else if (bootstrapEmail) {
346
+ subjectType = "user";
347
+ subjectId = bootstrapEmail;
348
+ } else if (String(user?.email || "").trim()) {
349
+ subjectType = "user";
350
+ subjectId = String(user.email).trim().toLowerCase();
351
+ } else if (String(user?.userId || "").trim()) {
352
+ subjectType = "user";
353
+ subjectId = String(user.userId).trim();
354
+ } else {
355
+ return false;
356
+ }
357
+
358
+ await this.store.putBinding(
359
+ createBindingInput({
360
+ resourceType: WORKSPACE_TYPE,
361
+ resourceExternalId: WORKSPACE_ID,
362
+ subjectType,
363
+ subjectId,
364
+ role: "owner",
365
+ inherit: true,
366
+ createdBy: "system",
367
+ }),
368
+ );
369
+
370
+ return true;
371
+ }
372
+
373
+ getUserHomeFolderId(user, { state = null } = {}) {
374
+ const homeFolderIds = listHomeFolderIdsForUser(user);
375
+ if (!homeFolderIds.length) {
376
+ return null;
377
+ }
378
+
379
+ if (state && Array.isArray(state.folders)) {
380
+ const existing = state.folders.find((folder) => homeFolderIds.includes(String(folder?.id || "")));
381
+ if (existing?.id) {
382
+ return String(existing.id);
383
+ }
384
+
385
+ const homeFolderName = toHomeFolderName(user);
386
+ const byName = state.folders.find(
387
+ (folder) => String(folder?.name || "").trim() === homeFolderName && (folder?.parentId ?? null) === null,
388
+ );
389
+ if (byName?.id) {
390
+ return String(byName.id);
391
+ }
392
+ }
393
+
394
+ return homeFolderIds[0];
395
+ }
396
+
397
+ async ensureUserHomeFolder(rawState, user, { mode = this.getAuthMode() } = {}) {
398
+ const state = normalizeStatePayload(rawState);
399
+ const subjects = listUserSubjectCandidates(user);
400
+ if (!subjects.length || mode === "off") {
401
+ return { state, changed: false, homeFolderId: null };
402
+ }
403
+
404
+ const subjectKeySet = new Set(subjects.map((subject) => `${subject.subjectType}:${subject.subjectId}`));
405
+ const homeFolderIdCandidates = listHomeFolderIdsForUser(user);
406
+ const knownHomeIds = new Set(homeFolderIdCandidates);
407
+
408
+ const existingBindings = await this.store.listBindings();
409
+ for (const binding of existingBindings) {
410
+ if (binding.resourceType !== "folder" || binding.role !== "owner" || binding.inherit !== true) {
411
+ continue;
412
+ }
413
+ const subjectKey = `${binding.subjectType}:${binding.subjectId}`;
414
+ if (!subjectKeySet.has(subjectKey)) {
415
+ continue;
416
+ }
417
+ const resourceId = String(binding.resourceExternalId || "").trim();
418
+ if (resourceId) {
419
+ knownHomeIds.add(resourceId);
420
+ }
421
+ }
422
+
423
+ let homeFolder = state.folders.find((folder) => knownHomeIds.has(String(folder.id))) || null;
424
+ const homeFolderId = homeFolder ? String(homeFolder.id) : homeFolderIdCandidates[0];
425
+ const homeFolderName = toHomeFolderName(user);
426
+ const now = nowMs();
427
+ let changed = false;
428
+
429
+ if (!homeFolder) {
430
+ homeFolder = {
431
+ id: homeFolderId,
432
+ name: homeFolderName,
433
+ parentId: null,
434
+ createdAt: now,
435
+ updatedAt: now,
436
+ };
437
+ state.folders.push(homeFolder);
438
+ changed = true;
439
+ } else {
440
+ const shouldRename =
441
+ homeFolderName !== HOME_FOLDER_NAME ||
442
+ !String(homeFolder.name || "").trim() ||
443
+ homeFolder.name === HOME_FOLDER_NAME;
444
+ if (shouldRename && homeFolder.name !== homeFolderName) {
445
+ homeFolder.name = homeFolderName;
446
+ homeFolder.updatedAt = now;
447
+ changed = true;
448
+ }
449
+ if (homeFolder.parentId !== null) {
450
+ homeFolder.parentId = null;
451
+ homeFolder.updatedAt = now;
452
+ changed = true;
453
+ }
454
+ }
455
+
456
+ if (!state.ui.expandedFolderIds.includes(homeFolderId)) {
457
+ state.ui.expandedFolderIds.push(homeFolderId);
458
+ changed = true;
459
+ }
460
+
461
+ await this.syncResourcesFromState(state);
462
+
463
+ const currentBindings = await this.store.listBindingsForResource("folder", homeFolderId);
464
+ const ownerBindingKeys = new Set(
465
+ currentBindings
466
+ .filter((binding) => binding.role === "owner" && binding.inherit === true)
467
+ .map((binding) => `${binding.subjectType}:${binding.subjectId}`),
468
+ );
469
+
470
+ for (const subject of subjects) {
471
+ const ownerKey = `${subject.subjectType}:${subject.subjectId}`;
472
+ if (ownerBindingKeys.has(ownerKey)) {
473
+ continue;
474
+ }
475
+ await this.store.putBinding(
476
+ createBindingInput({
477
+ resourceType: "folder",
478
+ resourceExternalId: homeFolderId,
479
+ subjectType: subject.subjectType,
480
+ subjectId: subject.subjectId,
481
+ role: "owner",
482
+ inherit: true,
483
+ createdBy: "system",
484
+ }),
485
+ );
486
+ ownerBindingKeys.add(ownerKey);
487
+ changed = true;
488
+ }
489
+
490
+ return {
491
+ state,
492
+ changed,
493
+ homeFolderId,
494
+ };
495
+ }
496
+
497
+ async createSnapshot() {
498
+ const resources = await this.store.listResources();
499
+ const bindings = await this.store.listBindings();
500
+
501
+ const resourceByKey = new Map();
502
+ const bindingsByResourceKey = new Map();
503
+
504
+ for (const resource of resources) {
505
+ resourceByKey.set(resourceKey(resource.type, resource.externalId), resource);
506
+ }
507
+
508
+ for (const binding of bindings) {
509
+ const key = resourceKey(binding.resourceType, binding.resourceExternalId);
510
+ if (!bindingsByResourceKey.has(key)) {
511
+ bindingsByResourceKey.set(key, []);
512
+ }
513
+ bindingsByResourceKey.get(key).push(binding);
514
+ }
515
+
516
+ return {
517
+ resources,
518
+ bindings,
519
+ resourceByKey,
520
+ bindingsByResourceKey,
521
+ };
522
+ }
523
+
524
+ getResourcePath(snapshot, resourceType, externalId) {
525
+ const path = [];
526
+ const visited = new Set();
527
+ let key = resourceKey(resourceType, externalId);
528
+
529
+ while (key && snapshot.resourceByKey.has(key) && !visited.has(key)) {
530
+ visited.add(key);
531
+ const resource = snapshot.resourceByKey.get(key);
532
+ path.push(resource);
533
+
534
+ if (!resource.parentType || !resource.parentExternalId) {
535
+ break;
536
+ }
537
+
538
+ key = resourceKey(resource.parentType, resource.parentExternalId);
539
+ }
540
+
541
+ return path;
542
+ }
543
+
544
+ evaluate(snapshot, user, { action, resourceType, resourceExternalId, mode = this.getAuthMode() }) {
545
+ if (mode === "off") {
546
+ return {
547
+ allowed: true,
548
+ role: "owner",
549
+ observedDenied: false,
550
+ reason: "auth-off",
551
+ };
552
+ }
553
+
554
+ const required = ACTION_LEVEL[action] || ACTION_LEVEL.read;
555
+ const path = this.getResourcePath(snapshot, resourceType, resourceExternalId);
556
+
557
+ if (!path.length) {
558
+ const denied = {
559
+ allowed: false,
560
+ role: null,
561
+ observedDenied: mode === "observe",
562
+ reason: "resource-not-found",
563
+ };
564
+ if (mode === "observe") {
565
+ return { ...denied, allowed: true };
566
+ }
567
+ return denied;
568
+ }
569
+
570
+ const userSubjectIds = getUserSubjectIds(user);
571
+ const groupSubjectIds = getGroupSubjectIds(user);
572
+
573
+ let maxRoleLevel = 0;
574
+ let maxRole = null;
575
+
576
+ for (let depth = 0; depth < path.length; depth += 1) {
577
+ const resource = path[depth];
578
+ const isDirect = depth === 0;
579
+
580
+ const owner = parseOwnerSubject(resource.ownerSubject);
581
+ if (owner) {
582
+ const ownerMatched =
583
+ (owner.subjectType === "user" && userSubjectIds.has(owner.subjectId)) ||
584
+ (owner.subjectType === "group" && groupSubjectIds.has(owner.subjectId));
585
+ if (ownerMatched) {
586
+ maxRoleLevel = ROLE_LEVEL.owner;
587
+ maxRole = "owner";
588
+ break;
589
+ }
590
+ }
591
+
592
+ const key = resourceKey(resource.type, resource.externalId);
593
+ const resourceBindings = snapshot.bindingsByResourceKey.get(key) || [];
594
+
595
+ for (const binding of resourceBindings) {
596
+ if (!isDirect && binding.inherit === false) {
597
+ continue;
598
+ }
599
+
600
+ let matched = false;
601
+ if (binding.subjectType === "public") {
602
+ matched = binding.subjectId === "*";
603
+ } else if (binding.subjectType === "user") {
604
+ matched = userSubjectIds.has(binding.subjectId);
605
+ } else if (binding.subjectType === "group") {
606
+ matched = groupSubjectIds.has(binding.subjectId);
607
+ }
608
+
609
+ if (!matched) {
610
+ continue;
611
+ }
612
+
613
+ const roleLevel = ROLE_LEVEL[binding.role] || 0;
614
+ if (roleLevel > maxRoleLevel) {
615
+ maxRoleLevel = roleLevel;
616
+ maxRole = binding.role;
617
+ }
618
+ }
619
+ }
620
+
621
+ const isAllowed = maxRoleLevel >= required;
622
+ if (isAllowed) {
623
+ return {
624
+ allowed: true,
625
+ role: maxRole,
626
+ observedDenied: false,
627
+ reason: "ok",
628
+ };
629
+ }
630
+
631
+ if (mode === "observe") {
632
+ return {
633
+ allowed: true,
634
+ role: maxRole,
635
+ observedDenied: true,
636
+ reason: "insufficient-role",
637
+ };
638
+ }
639
+
640
+ return {
641
+ allowed: false,
642
+ role: maxRole,
643
+ observedDenied: false,
644
+ reason: "insufficient-role",
645
+ };
646
+ }
647
+
648
+ async getCapabilitiesForResource(user, { resourceType, resourceExternalId, mode = this.getAuthMode() }) {
649
+ const snapshot = await this.createSnapshot();
650
+ const canRead = this.evaluate(snapshot, user, {
651
+ action: "read",
652
+ resourceType,
653
+ resourceExternalId,
654
+ mode,
655
+ }).allowed;
656
+ const canWrite = this.evaluate(snapshot, user, {
657
+ action: "write",
658
+ resourceType,
659
+ resourceExternalId,
660
+ mode,
661
+ }).allowed;
662
+ const canManage = this.evaluate(snapshot, user, {
663
+ action: "manage",
664
+ resourceType,
665
+ resourceExternalId,
666
+ mode,
667
+ }).allowed;
668
+
669
+ return {
670
+ canRead,
671
+ canWrite,
672
+ canManage,
673
+ };
674
+ }
675
+
676
+ async filterStateForUser(rawState, user, { mode = this.getAuthMode() } = {}) {
677
+ const state = normalizeStatePayload(rawState);
678
+ if (mode === "off" || mode === "observe") {
679
+ return cloneState(state);
680
+ }
681
+
682
+ const snapshot = await this.createSnapshot();
683
+ const readableFolderIds = new Set();
684
+ const readableNoteIds = new Set();
685
+
686
+ for (const folder of state.folders) {
687
+ const decision = this.evaluate(snapshot, user, {
688
+ action: "read",
689
+ resourceType: "folder",
690
+ resourceExternalId: folder.id,
691
+ mode,
692
+ });
693
+ if (decision.allowed) {
694
+ readableFolderIds.add(folder.id);
695
+ }
696
+ }
697
+
698
+ for (const note of state.notes) {
699
+ const decision = this.evaluate(snapshot, user, {
700
+ action: "read",
701
+ resourceType: "note",
702
+ resourceExternalId: note.id,
703
+ mode,
704
+ });
705
+ if (decision.allowed) {
706
+ readableNoteIds.add(note.id);
707
+ if (note.folderId) {
708
+ readableFolderIds.add(note.folderId);
709
+ }
710
+ }
711
+ }
712
+
713
+ const folderById = new Map(state.folders.map((folder) => [folder.id, folder]));
714
+ for (const folderId of [...readableFolderIds]) {
715
+ let currentId = folderById.get(folderId)?.parentId || null;
716
+ while (currentId && folderById.has(currentId)) {
717
+ if (readableFolderIds.has(currentId)) {
718
+ currentId = folderById.get(currentId)?.parentId || null;
719
+ continue;
720
+ }
721
+ readableFolderIds.add(currentId);
722
+ currentId = folderById.get(currentId)?.parentId || null;
723
+ }
724
+ }
725
+
726
+ const filteredFolders = state.folders.filter((folder) => readableFolderIds.has(folder.id));
727
+ const filteredNotes = state.notes.filter((note) => readableNoteIds.has(note.id));
728
+ const filteredFolderIds = new Set(filteredFolders.map((folder) => folder.id));
729
+
730
+ return {
731
+ folders: filteredFolders,
732
+ notes: filteredNotes.map((note) => ({
733
+ ...note,
734
+ folderId: note.folderId && filteredFolderIds.has(note.folderId) ? note.folderId : null,
735
+ })),
736
+ ui: {
737
+ expandedFolderIds: (state.ui?.expandedFolderIds || []).filter((id) => filteredFolderIds.has(id)),
738
+ },
739
+ };
740
+ }
741
+
742
+ async assertAllowed(user, { action, resourceType, resourceExternalId, mode = this.getAuthMode(), snapshot = null }) {
743
+ const localSnapshot = snapshot || (await this.createSnapshot());
744
+ const decision = this.evaluate(localSnapshot, user, {
745
+ action,
746
+ resourceType,
747
+ resourceExternalId,
748
+ mode,
749
+ });
750
+
751
+ if (decision.observedDenied) {
752
+ this.logger.warn?.(
753
+ `[tui.notes.2026][auth][observe] deny would trigger: action=${action} resource=${resourceType}:${resourceExternalId} user=${user?.displayName || user?.id || "unknown"}`,
754
+ );
755
+ }
756
+
757
+ if (!decision.allowed) {
758
+ const error = new Error("Forbidden");
759
+ error.status = 403;
760
+ error.details = {
761
+ action,
762
+ resourceType,
763
+ resourceExternalId,
764
+ reason: decision.reason,
765
+ };
766
+ throw error;
767
+ }
768
+
769
+ return decision;
770
+ }
771
+
772
+ async mergeScopedStateForWrite({
773
+ currentFullState,
774
+ incomingScopedState,
775
+ user,
776
+ mode = this.getAuthMode(),
777
+ }) {
778
+ if (mode === "off" || mode === "observe") {
779
+ return normalizeStatePayload(incomingScopedState);
780
+ }
781
+
782
+ const currentNormalized = normalizeStatePayload(currentFullState);
783
+ const incomingNormalized = normalizeStatePayload(incomingScopedState);
784
+ const visibleBaseline = await this.filterStateForUser(currentNormalized, user, { mode });
785
+ const snapshot = await this.createSnapshot();
786
+
787
+ const fullState = cloneState(currentNormalized);
788
+
789
+ const fullFolderById = new Map(fullState.folders.map((folder) => [folder.id, folder]));
790
+ const fullNoteById = new Map(fullState.notes.map((note) => [note.id, note]));
791
+
792
+ const baselineFolderById = new Map(visibleBaseline.folders.map((folder) => [folder.id, folder]));
793
+ const baselineNoteById = new Map(visibleBaseline.notes.map((note) => [note.id, note]));
794
+
795
+ const incomingFolderById = new Map(incomingNormalized.folders.map((folder) => [folder.id, folder]));
796
+ const incomingNoteById = new Map(incomingNormalized.notes.map((note) => [note.id, note]));
797
+
798
+ for (const incomingFolder of incomingNormalized.folders) {
799
+ if (fullFolderById.has(incomingFolder.id) && !baselineFolderById.has(incomingFolder.id)) {
800
+ const error = new Error("Forbidden folder mutation.");
801
+ error.status = 403;
802
+ throw error;
803
+ }
804
+ }
805
+ for (const incomingNote of incomingNormalized.notes) {
806
+ if (fullNoteById.has(incomingNote.id) && !baselineNoteById.has(incomingNote.id)) {
807
+ const error = new Error("Forbidden note mutation.");
808
+ error.status = 403;
809
+ throw error;
810
+ }
811
+ }
812
+
813
+ const now = nowMs();
814
+ const protectedHomeFolderId = this.getUserHomeFolderId(user, { state: currentNormalized });
815
+
816
+ for (const [folderId, baselineFolder] of baselineFolderById.entries()) {
817
+ if (protectedHomeFolderId && folderId === protectedHomeFolderId) {
818
+ continue;
819
+ }
820
+ if (!incomingFolderById.has(folderId)) {
821
+ await this.assertAllowed(user, {
822
+ action: "write",
823
+ resourceType: "folder",
824
+ resourceExternalId: folderId,
825
+ mode,
826
+ snapshot,
827
+ });
828
+
829
+ const childrenMap = buildFolderChildrenMap(fullState.folders);
830
+ const toDelete = collectDescendantFolderIds(folderId, childrenMap);
831
+
832
+ fullState.folders = fullState.folders.filter((folder) => !toDelete.has(folder.id));
833
+
834
+ for (const note of fullState.notes) {
835
+ if (note.folderId && toDelete.has(note.folderId)) {
836
+ note.folderId = null;
837
+ if (!note.deletedAt) {
838
+ note.deletedAt = now;
839
+ }
840
+ note.updatedAt = now;
841
+ }
842
+ }
843
+
844
+ fullFolderById.clear();
845
+ for (const folder of fullState.folders) {
846
+ fullFolderById.set(folder.id, folder);
847
+ }
848
+ } else {
849
+ const incomingFolder = incomingFolderById.get(folderId);
850
+ const fullFolder = fullFolderById.get(folderId);
851
+ if (!fullFolder) {
852
+ continue;
853
+ }
854
+
855
+ const changed =
856
+ String(fullFolder.name) !== String(incomingFolder.name) ||
857
+ String(fullFolder.parentId || "") !== String(incomingFolder.parentId || "");
858
+
859
+ if (!changed) {
860
+ continue;
861
+ }
862
+
863
+ await this.assertAllowed(user, {
864
+ action: "write",
865
+ resourceType: "folder",
866
+ resourceExternalId: folderId,
867
+ mode,
868
+ snapshot,
869
+ });
870
+
871
+ const nextParentId = incomingFolder.parentId && fullFolderById.has(incomingFolder.parentId)
872
+ ? incomingFolder.parentId
873
+ : null;
874
+
875
+ fullFolder.name = incomingFolder.name;
876
+ fullFolder.parentId = nextParentId;
877
+ fullFolder.updatedAt = Number(incomingFolder.updatedAt) || now;
878
+ }
879
+ }
880
+
881
+ for (const incomingFolder of incomingNormalized.folders) {
882
+ if (fullFolderById.has(incomingFolder.id)) {
883
+ continue;
884
+ }
885
+
886
+ const parentId = incomingFolder.parentId && fullFolderById.has(incomingFolder.parentId)
887
+ ? incomingFolder.parentId
888
+ : null;
889
+
890
+ if (parentId) {
891
+ await this.assertAllowed(user, {
892
+ action: "write",
893
+ resourceType: "folder",
894
+ resourceExternalId: parentId,
895
+ mode,
896
+ snapshot,
897
+ });
898
+ } else {
899
+ await this.assertAllowed(user, {
900
+ action: "write",
901
+ resourceType: WORKSPACE_TYPE,
902
+ resourceExternalId: WORKSPACE_ID,
903
+ mode,
904
+ snapshot,
905
+ });
906
+ }
907
+
908
+ const nextFolder = {
909
+ ...incomingFolder,
910
+ parentId,
911
+ createdAt: Number(incomingFolder.createdAt) || now,
912
+ updatedAt: Number(incomingFolder.updatedAt) || now,
913
+ };
914
+ fullState.folders.push(nextFolder);
915
+ fullFolderById.set(nextFolder.id, nextFolder);
916
+ }
917
+
918
+ for (const [noteId] of baselineNoteById.entries()) {
919
+ if (incomingNoteById.has(noteId)) {
920
+ continue;
921
+ }
922
+
923
+ await this.assertAllowed(user, {
924
+ action: "write",
925
+ resourceType: "note",
926
+ resourceExternalId: noteId,
927
+ mode,
928
+ snapshot,
929
+ });
930
+
931
+ fullState.notes = fullState.notes.filter((note) => note.id !== noteId);
932
+ fullNoteById.delete(noteId);
933
+ }
934
+
935
+ for (const [noteId, incomingNote] of incomingNoteById.entries()) {
936
+ const fullNote = fullNoteById.get(noteId);
937
+ if (!fullNote) {
938
+ const folderId = incomingNote.folderId && fullFolderById.has(incomingNote.folderId)
939
+ ? incomingNote.folderId
940
+ : null;
941
+
942
+ if (folderId) {
943
+ await this.assertAllowed(user, {
944
+ action: "write",
945
+ resourceType: "folder",
946
+ resourceExternalId: folderId,
947
+ mode,
948
+ snapshot,
949
+ });
950
+ } else {
951
+ await this.assertAllowed(user, {
952
+ action: "write",
953
+ resourceType: WORKSPACE_TYPE,
954
+ resourceExternalId: WORKSPACE_ID,
955
+ mode,
956
+ snapshot,
957
+ });
958
+ }
959
+
960
+ const nextNote = {
961
+ ...incomingNote,
962
+ folderId,
963
+ createdAt: Number(incomingNote.createdAt) || now,
964
+ updatedAt: Number(incomingNote.updatedAt) || now,
965
+ deletedAt: incomingNote.deletedAt ? Number(incomingNote.deletedAt) || null : null,
966
+ shareId: typeof incomingNote.shareId === "string" ? incomingNote.shareId.trim() || null : null,
967
+ };
968
+
969
+ fullState.notes.push(nextNote);
970
+ fullNoteById.set(nextNote.id, nextNote);
971
+ continue;
972
+ }
973
+
974
+ const baselineNote = baselineNoteById.get(noteId);
975
+ const incomingShareId =
976
+ typeof incomingNote.shareId === "string" && incomingNote.shareId.trim()
977
+ ? incomingNote.shareId.trim()
978
+ : null;
979
+ const baselineShareId =
980
+ baselineNote && typeof baselineNote.shareId === "string" && baselineNote.shareId.trim()
981
+ ? baselineNote.shareId.trim()
982
+ : null;
983
+ const noteChangedAgainstBaseline =
984
+ !baselineNote ||
985
+ String(incomingNote.title || "") !== String(baselineNote.title || "") ||
986
+ String(incomingNote.folderId || "") !== String(baselineNote.folderId || "") ||
987
+ String(incomingNote.content || "") !== String(baselineNote.content || "") ||
988
+ String(incomingNote.fileName || "") !== String(baselineNote.fileName || "") ||
989
+ String(incomingNote.deletedAt || "") !== String(baselineNote.deletedAt || "") ||
990
+ incomingShareId !== baselineShareId;
991
+
992
+ if (!noteChangedAgainstBaseline) {
993
+ continue;
994
+ }
995
+
996
+ const incomingUpdatedAt = Number(incomingNote.updatedAt) || 0;
997
+ const currentUpdatedAt = Number(fullNote.updatedAt) || 0;
998
+ if (incomingUpdatedAt > 0 && currentUpdatedAt > 0 && incomingUpdatedAt < currentUpdatedAt) {
999
+ continue;
1000
+ }
1001
+
1002
+ await this.assertAllowed(user, {
1003
+ action: "write",
1004
+ resourceType: "note",
1005
+ resourceExternalId: noteId,
1006
+ mode,
1007
+ snapshot,
1008
+ });
1009
+
1010
+ const targetFolderId = incomingNote.folderId && fullFolderById.has(incomingNote.folderId)
1011
+ ? incomingNote.folderId
1012
+ : null;
1013
+
1014
+ if (targetFolderId && targetFolderId !== fullNote.folderId) {
1015
+ await this.assertAllowed(user, {
1016
+ action: "write",
1017
+ resourceType: "folder",
1018
+ resourceExternalId: targetFolderId,
1019
+ mode,
1020
+ snapshot,
1021
+ });
1022
+ }
1023
+
1024
+ fullNote.title = incomingNote.title;
1025
+ fullNote.folderId = targetFolderId;
1026
+ fullNote.content = incomingNote.content;
1027
+ fullNote.fileName = incomingNote.fileName;
1028
+ fullNote.shareId =
1029
+ incomingShareId || fullNote.shareId || null;
1030
+ fullNote.deletedAt = incomingNote.deletedAt ? Number(incomingNote.deletedAt) || null : null;
1031
+ fullNote.updatedAt = Number(incomingNote.updatedAt) || now;
1032
+ }
1033
+
1034
+ const fullFolderIds = new Set(fullState.folders.map((folder) => folder.id));
1035
+ fullState.notes = fullState.notes.map((note) => ({
1036
+ ...note,
1037
+ folderId: note.folderId && fullFolderIds.has(note.folderId) ? note.folderId : null,
1038
+ }));
1039
+
1040
+ fullState.ui = {
1041
+ expandedFolderIds: Array.isArray(currentNormalized.ui?.expandedFolderIds)
1042
+ ? [...currentNormalized.ui.expandedFolderIds]
1043
+ : [],
1044
+ };
1045
+
1046
+ return fullState;
1047
+ }
1048
+
1049
+ async grantBinding({ actorUser, bindingInput, mode = this.getAuthMode() }) {
1050
+ const resourceType = String(bindingInput?.resourceType || "").trim();
1051
+ const resourceExternalId = String(bindingInput?.resourceExternalId || "").trim();
1052
+ const role = String(bindingInput?.role || "").trim();
1053
+ const subjectType = String(bindingInput?.subjectType || "").trim();
1054
+ const subjectId = String(bindingInput?.subjectId || "").trim();
1055
+ const inherit = bindingInput?.inherit !== false;
1056
+
1057
+ if (!resourceType || !resourceExternalId || !role || !subjectType || !subjectId) {
1058
+ const error = new Error("Invalid ACL grant payload.");
1059
+ error.status = 400;
1060
+ throw error;
1061
+ }
1062
+
1063
+ if (!ROLE_LEVEL[role]) {
1064
+ const error = new Error("Unsupported role.");
1065
+ error.status = 400;
1066
+ throw error;
1067
+ }
1068
+
1069
+ if (!["user", "group", "public"].includes(subjectType)) {
1070
+ const error = new Error("Unsupported subject type.");
1071
+ error.status = 400;
1072
+ throw error;
1073
+ }
1074
+
1075
+ const snapshot = await this.createSnapshot();
1076
+ await this.assertAllowed(actorUser, {
1077
+ action: "manage",
1078
+ resourceType,
1079
+ resourceExternalId,
1080
+ mode,
1081
+ snapshot,
1082
+ });
1083
+
1084
+ const binding = await this.store.putBinding(
1085
+ createBindingInput({
1086
+ id: bindingInput?.id,
1087
+ resourceType,
1088
+ resourceExternalId,
1089
+ subjectType,
1090
+ subjectId,
1091
+ role,
1092
+ inherit,
1093
+ createdBy: actorUser?.id || actorUser?.email || "unknown",
1094
+ }),
1095
+ );
1096
+
1097
+ return binding;
1098
+ }
1099
+
1100
+ async revokeBinding({ actorUser, bindingId, mode = this.getAuthMode() }) {
1101
+ const id = String(bindingId || "").trim();
1102
+ if (!id) {
1103
+ const error = new Error("Binding id is required.");
1104
+ error.status = 400;
1105
+ throw error;
1106
+ }
1107
+
1108
+ const allBindings = await this.store.listBindings();
1109
+ const existing = allBindings.find((binding) => binding.id === id);
1110
+ if (!existing) {
1111
+ return false;
1112
+ }
1113
+
1114
+ const snapshot = await this.createSnapshot();
1115
+ await this.assertAllowed(actorUser, {
1116
+ action: "manage",
1117
+ resourceType: existing.resourceType,
1118
+ resourceExternalId: existing.resourceExternalId,
1119
+ mode,
1120
+ snapshot,
1121
+ });
1122
+
1123
+ const isSelfOwnerBinding =
1124
+ String(existing.role || "").trim().toLowerCase() === "owner" &&
1125
+ matchesActorSubject(existing, actorUser);
1126
+ if (isSelfOwnerBinding) {
1127
+ const error = new Error("Owner cannot revoke own owner access.");
1128
+ error.status = 400;
1129
+ throw error;
1130
+ }
1131
+
1132
+ return this.store.deleteBinding(id);
1133
+ }
1134
+
1135
+ async listBindingsForResource({ actorUser, resourceType, resourceExternalId, mode = this.getAuthMode() }) {
1136
+ const snapshot = await this.createSnapshot();
1137
+ await this.assertAllowed(actorUser, {
1138
+ action: "manage",
1139
+ resourceType,
1140
+ resourceExternalId,
1141
+ mode,
1142
+ snapshot,
1143
+ });
1144
+
1145
+ return this.store.listBindingsForResource(resourceType, resourceExternalId);
1146
+ }
1147
+
1148
+ async listEffectiveBindingsForResource({
1149
+ actorUser,
1150
+ resourceType,
1151
+ resourceExternalId,
1152
+ mode = this.getAuthMode(),
1153
+ }) {
1154
+ const snapshot = await this.createSnapshot();
1155
+ await this.assertAllowed(actorUser, {
1156
+ action: "manage",
1157
+ resourceType,
1158
+ resourceExternalId,
1159
+ mode,
1160
+ snapshot,
1161
+ });
1162
+
1163
+ const path = this.getResourcePath(snapshot, resourceType, resourceExternalId);
1164
+ const directBindings = await this.store.listBindingsForResource(resourceType, resourceExternalId);
1165
+ const effectiveBindings = [];
1166
+
1167
+ for (let depth = 0; depth < path.length; depth += 1) {
1168
+ const resource = path[depth];
1169
+ const key = resourceKey(resource.type, resource.externalId);
1170
+ const resourceBindings = snapshot.bindingsByResourceKey.get(key) || [];
1171
+ const isDirect = depth === 0;
1172
+
1173
+ for (const binding of resourceBindings) {
1174
+ if (!isDirect && binding.inherit === false) {
1175
+ continue;
1176
+ }
1177
+ const isSelfOwnerBinding =
1178
+ String(binding.role || "").trim().toLowerCase() === "owner" &&
1179
+ matchesActorSubject(binding, actorUser);
1180
+ effectiveBindings.push({
1181
+ ...binding,
1182
+ relation: isDirect ? "direct" : "inherited",
1183
+ sourceResourceType: resource.type,
1184
+ sourceResourceExternalId: resource.externalId,
1185
+ canRevoke: Boolean(binding.id) && !isSelfOwnerBinding,
1186
+ });
1187
+ }
1188
+ }
1189
+
1190
+ effectiveBindings.sort((left, right) => {
1191
+ const byRelation = left.relation.localeCompare(right.relation);
1192
+ if (byRelation !== 0) {
1193
+ return byRelation;
1194
+ }
1195
+ const bySubjectType = String(left.subjectType || "").localeCompare(String(right.subjectType || ""));
1196
+ if (bySubjectType !== 0) {
1197
+ return bySubjectType;
1198
+ }
1199
+ const bySubjectId = String(left.subjectId || "").localeCompare(String(right.subjectId || ""));
1200
+ if (bySubjectId !== 0) {
1201
+ return bySubjectId;
1202
+ }
1203
+ return String(left.id || "").localeCompare(String(right.id || ""));
1204
+ });
1205
+
1206
+ return {
1207
+ directBindings,
1208
+ effectiveBindings,
1209
+ };
1210
+ }
1211
+
1212
+ async getViewerContext(user, { mode = this.getAuthMode(), state = null } = {}) {
1213
+ return {
1214
+ authMode: mode,
1215
+ workspace: await this.getCapabilitiesForResource(user, {
1216
+ resourceType: WORKSPACE_TYPE,
1217
+ resourceExternalId: WORKSPACE_ID,
1218
+ mode,
1219
+ }),
1220
+ user: {
1221
+ id: user?.id || "anonymous",
1222
+ userId: user?.userId || "",
1223
+ email: user?.email || "",
1224
+ preferredUsername: user?.preferredUsername || "",
1225
+ groups: Array.isArray(user?.groups) ? user.groups : [],
1226
+ isAuthenticated: Boolean(user?.isAuthenticated),
1227
+ displayName: user?.displayName || user?.email || user?.userId || user?.id || "anonymous",
1228
+ },
1229
+ homeFolderId: this.getUserHomeFolderId(user, { state }),
1230
+ };
1231
+ }
1232
+ }
1233
+
1234
+ export function getWorkspaceResource() {
1235
+ return {
1236
+ type: WORKSPACE_TYPE,
1237
+ externalId: WORKSPACE_ID,
1238
+ };
1239
+ }
1240
+
1241
+ export function toOwnerSubjectFromUser(user) {
1242
+ if (String(user?.email || "").trim()) {
1243
+ return toOwnerSubject("user", String(user.email).trim().toLowerCase());
1244
+ }
1245
+ if (String(user?.userId || "").trim()) {
1246
+ return toOwnerSubject("user", String(user.userId).trim());
1247
+ }
1248
+ return null;
1249
+ }