@techie_doubts/tui.notes.2026 1.0.14 → 1.0.16-exp.1

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-Dw2FFQpg.js → arc-PCH7RH34.js} +1 -1
  3. package/dist/assets/{architectureDiagram-VXUJARFQ-CufEy62L.js → architectureDiagram-VXUJARFQ-CJCgNTQY.js} +1 -1
  4. package/dist/assets/{blockDiagram-VD42YOAC-BnqXETC-.js → blockDiagram-VD42YOAC-Dr6BuZdb.js} +1 -1
  5. package/dist/assets/{c4Diagram-YG6GDRKO-CRRqnF8Q.js → c4Diagram-YG6GDRKO-Cujs6tue.js} +1 -1
  6. package/dist/assets/channel-CcerkviR.js +1 -0
  7. package/dist/assets/{chunk-4BX2VUAB-BKdUMn0k.js → chunk-4BX2VUAB-BcD-NEr2.js} +1 -1
  8. package/dist/assets/{chunk-55IACEB6-B8U-4L0g.js → chunk-55IACEB6-CuADq_qH.js} +1 -1
  9. package/dist/assets/{chunk-B4BG7PRW-DiSlffG3.js → chunk-B4BG7PRW-CQeb3pmx.js} +1 -1
  10. package/dist/assets/{chunk-DI55MBZ5-DoFW5sIs.js → chunk-DI55MBZ5-CLozoy-P.js} +1 -1
  11. package/dist/assets/{chunk-FMBD7UC4-G-X5BCqS.js → chunk-FMBD7UC4-DTHBR6EW.js} +1 -1
  12. package/dist/assets/{chunk-QN33PNHL-D_F0QBne.js → chunk-QN33PNHL-ChElDE3F.js} +1 -1
  13. package/dist/assets/{chunk-QZHKN3VN-yGAa1Z7-.js → chunk-QZHKN3VN-BOuzHqeF.js} +1 -1
  14. package/dist/assets/{chunk-TZMSLE5B-gDf9m9hb.js → chunk-TZMSLE5B-BWbpFvic.js} +1 -1
  15. package/dist/assets/classDiagram-2ON5EDUG-DuvJikuL.js +1 -0
  16. package/dist/assets/classDiagram-v2-WZHVMYZB-DuvJikuL.js +1 -0
  17. package/dist/assets/{clone-CtQPqq7L.js → clone-CZ5tMkEs.js} +1 -1
  18. package/dist/assets/{cose-bilkent-S5V4N54A-DWUmsMRp.js → cose-bilkent-S5V4N54A-BrtvxZJ8.js} +1 -1
  19. package/dist/assets/{dagre-6UL2VRFP-B2BazWXF.js → dagre-6UL2VRFP-DLURHD-1.js} +1 -1
  20. package/dist/assets/{diagram-PSM6KHXK-BpBMH0Ts.js → diagram-PSM6KHXK-D5gDu3Jy.js} +1 -1
  21. package/dist/assets/{diagram-QEK2KX5R-DT4ajz1c.js → diagram-QEK2KX5R-DHhyRXC6.js} +1 -1
  22. package/dist/assets/{diagram-S2PKOQOG-DFOpAZP9.js → diagram-S2PKOQOG-DB2mF9n1.js} +1 -1
  23. package/dist/assets/{erDiagram-Q2GNP2WA-C5AMhI0K.js → erDiagram-Q2GNP2WA-CuYjTYDE.js} +1 -1
  24. package/dist/assets/{flowDiagram-NV44I4VS-CeGyvCMA.js → flowDiagram-NV44I4VS-BTmXRhk3.js} +1 -1
  25. package/dist/assets/{ganttDiagram-JELNMOA3-uVJ-OV6s.js → ganttDiagram-JELNMOA3-Dj8216Bt.js} +1 -1
  26. package/dist/assets/{gitGraphDiagram-NY62KEGX-BrhQjAjl.js → gitGraphDiagram-NY62KEGX-AfR_zz8Y.js} +1 -1
  27. package/dist/assets/{index-CNp6F8X7.css → index-8Yfq90k_.css} +1 -1
  28. package/dist/assets/{index-c2yhXVIu.js → index-CORObQTV.js} +594 -517
  29. package/dist/assets/{infoDiagram-WHAUD3N6-CRdDYgIS.js → infoDiagram-WHAUD3N6-B6ZVQAG8.js} +1 -1
  30. package/dist/assets/{journeyDiagram-XKPGCS4Q-Yqs1G7Ur.js → journeyDiagram-XKPGCS4Q-BoG7zgLk.js} +1 -1
  31. package/dist/assets/{kanban-definition-3W4ZIXB7-GIkJLZdc.js → kanban-definition-3W4ZIXB7-CGkltrpk.js} +1 -1
  32. package/dist/assets/{linear-Bx_xGOD8.js → linear-ZFsx-qcY.js} +1 -1
  33. package/dist/assets/{mindmap-definition-VGOIOE7T-Bh9_I7_C.js → mindmap-definition-VGOIOE7T-DS3-vFjB.js} +1 -1
  34. package/dist/assets/{pieDiagram-ADFJNKIX-_IIkl2Vj.js → pieDiagram-ADFJNKIX-DS4SKK5L.js} +1 -1
  35. package/dist/assets/{quadrantDiagram-AYHSOK5B-BeN-mDm1.js → quadrantDiagram-AYHSOK5B-BqSmZSFq.js} +1 -1
  36. package/dist/assets/{requirementDiagram-UZGBJVZJ-DTuux7i8.js → requirementDiagram-UZGBJVZJ-MQtqXGHx.js} +1 -1
  37. package/dist/assets/{sankeyDiagram-TZEHDZUN-BnEwJfWi.js → sankeyDiagram-TZEHDZUN-DPxJ0i0v.js} +1 -1
  38. package/dist/assets/{sequenceDiagram-WL72ISMW-CKDNxA0n.js → sequenceDiagram-WL72ISMW-0yXSvFmS.js} +1 -1
  39. package/dist/assets/{stateDiagram-FKZM4ZOC-B5NsJSxj.js → stateDiagram-FKZM4ZOC-e_TqYmQe.js} +1 -1
  40. package/dist/assets/stateDiagram-v2-4FDKWEC3-B85_wtn8.js +1 -0
  41. package/dist/assets/{timeline-definition-IT6M3QCI-90xY8Nw6.js → timeline-definition-IT6M3QCI-mfjyRSAU.js} +1 -1
  42. package/dist/assets/{treemap-KMMF4GRG-CT9Jf2NQ.js → treemap-KMMF4GRG-CxwWdtjF.js} +1 -1
  43. package/dist/assets/{xychartDiagram-PRI3JC2R-CLAphPTr.js → xychartDiagram-PRI3JC2R-DzSzO4xZ.js} +1 -1
  44. package/dist/index.html +2 -2
  45. package/package.json +10 -5
  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-DrL0fpY9.js +0 -1
  53. package/dist/assets/classDiagram-2ON5EDUG-BHSxN04i.js +0 -1
  54. package/dist/assets/classDiagram-v2-WZHVMYZB-BHSxN04i.js +0 -1
  55. package/dist/assets/stateDiagram-v2-4FDKWEC3-DvbILQz6.js +0 -1
package/server/index.js CHANGED
@@ -11,8 +11,17 @@ import {
11
11
  getTrashDir,
12
12
  replaceState,
13
13
  } from "./store.js";
14
+ import { authContextMiddleware, getAuthMode, requireAuthentication } from "./auth.js";
15
+ import { createAclStore } from "./acl-store.js";
16
+ import { AclService, getWorkspaceResource } from "./acl-service.js";
14
17
 
15
18
  const app = express();
19
+ const aclStore = await createAclStore({ storageRootDir: getStorageRootDir() });
20
+ const aclService = new AclService({
21
+ store: aclStore,
22
+ authModeProvider: getAuthMode,
23
+ logger: console,
24
+ });
16
25
 
17
26
  const PORT = Number(process.env.TUI_NOTES_API_PORT || 8787);
18
27
  const HOST = process.env.TUI_NOTES_API_HOST || "127.0.0.1";
@@ -27,6 +36,14 @@ const MAX_MEDIA_UPLOAD_BYTES =
27
36
  const AUDIO_EXTENSIONS = new Set(["m4a", "mp3", "wav", "ogg", "opus", "webm", "aac", "flac", "oga", "mp4"]);
28
37
  const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv", "avi", "mkv"]);
29
38
  const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "bmp", "ico"]);
39
+ const SSE_KEEPALIVE_MS = 25_000;
40
+ const PRESENCE_TTL_MS = 12_000;
41
+ const PRESENCE_SWEEP_MS = 4_000;
42
+ const sseClients = new Set();
43
+ const presenceByClientKey = new Map();
44
+ let sseEventId = 0;
45
+ const STATE_CLIENT_ID_HEADER = "x-tui-client-id";
46
+ const DEBUG_SYNC = process.env.TUI_NOTES_DEBUG_SYNC === "1";
30
47
 
31
48
  function sanitizePathSegment(value) {
32
49
  const segment = String(value || "").trim();
@@ -110,11 +127,6 @@ function isAllowedMediaExtension(filePath, requestedKind) {
110
127
  return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext) || IMAGE_EXTENSIONS.has(ext);
111
128
  }
112
129
 
113
- function resolveNoteById(noteId) {
114
- const state = getHydratedState();
115
- return state.notes.find((note) => String(note.id) === String(noteId)) || null;
116
- }
117
-
118
130
  function getNoteMediaDirectoryAbsolutePath(note) {
119
131
  const noteFileName = normalizeNoteFileName(note);
120
132
  const noteDirRelative = path.posix.dirname(noteFileName) === "." ? "" : path.posix.dirname(noteFileName);
@@ -282,12 +294,307 @@ function resolveServeDistMode() {
282
294
  return fs.existsSync(DIST_INDEX_FILE);
283
295
  }
284
296
 
297
+ function getRequestAuthContext(req) {
298
+ return req.authContext || {
299
+ mode: getAuthMode(),
300
+ user: {
301
+ id: "anonymous",
302
+ userId: "",
303
+ email: "",
304
+ preferredUsername: "",
305
+ groups: [],
306
+ isAuthenticated: false,
307
+ displayName: "anonymous",
308
+ },
309
+ isAuthenticated: false,
310
+ isEnforced: getAuthMode() === "enforce",
311
+ isObserve: getAuthMode() === "observe",
312
+ isOff: getAuthMode() === "off",
313
+ };
314
+ }
315
+
316
+ function ensureAuthOrReject(req, res) {
317
+ const context = getRequestAuthContext(req);
318
+ if (context.isEnforced && !context.isAuthenticated) {
319
+ res.status(401).json({ message: "Authentication required." });
320
+ return null;
321
+ }
322
+ return context;
323
+ }
324
+
325
+ async function ensureAclStateFromState(fullState, req) {
326
+ let nextState = fullState;
327
+ await aclService.syncResourcesFromState(nextState);
328
+ const context = getRequestAuthContext(req);
329
+ if (context?.isAuthenticated) {
330
+ const preservedMeta =
331
+ nextState && typeof nextState === "object" && nextState._meta && typeof nextState._meta === "object"
332
+ ? { ...nextState._meta }
333
+ : null;
334
+ await aclService.ensureBootstrapOwner(context.user);
335
+ const ensuredHome = await aclService.ensureUserHomeFolder(nextState, context.user, {
336
+ mode: context.mode || getAuthMode(),
337
+ });
338
+ nextState = {
339
+ ...ensuredHome.state,
340
+ ...(preservedMeta ? { _meta: preservedMeta } : {}),
341
+ };
342
+ if (ensuredHome.changed) {
343
+ nextState = replaceState({
344
+ ...nextState,
345
+ _meta: nextState?._meta,
346
+ });
347
+ broadcastStateRevision(nextState);
348
+ }
349
+ }
350
+ await aclService.syncResourcesFromState(nextState);
351
+ return nextState;
352
+ }
353
+
354
+ async function toScopedStateForRequest(fullState, req) {
355
+ const context = getRequestAuthContext(req);
356
+ const scoped = await aclService.filterStateForUser(fullState, context.user, {
357
+ mode: context.mode || getAuthMode(),
358
+ });
359
+ return {
360
+ ...scoped,
361
+ _meta: fullState?._meta,
362
+ };
363
+ }
364
+
365
+ function writeSseEvent(response, eventName, payload) {
366
+ const id = String(++sseEventId);
367
+ response.write(`id: ${id}\n`);
368
+ response.write(`event: ${eventName}\n`);
369
+ response.write(`data: ${JSON.stringify(payload)}\n\n`);
370
+ }
371
+
372
+ function closeSseClient(clientEntry) {
373
+ if (!clientEntry) {
374
+ return;
375
+ }
376
+ const response = clientEntry.response;
377
+ try {
378
+ response?.end();
379
+ } catch (_endError) {
380
+ // Ignore close errors for dead sockets.
381
+ }
382
+ sseClients.delete(clientEntry);
383
+ }
384
+
385
+ function debugSyncLog(message, extra = null) {
386
+ if (!DEBUG_SYNC) {
387
+ return;
388
+ }
389
+ if (extra && typeof extra === "object") {
390
+ console.log("[tui.notes.2026][sync]", message, extra);
391
+ return;
392
+ }
393
+ console.log("[tui.notes.2026][sync]", message);
394
+ }
395
+
396
+ function resolveStateSourceClientId(req) {
397
+ const fromHeader = normalizePresenceClientId(req.get(STATE_CLIENT_ID_HEADER));
398
+ if (fromHeader) {
399
+ return fromHeader;
400
+ }
401
+ return normalizePresenceClientId(req.body?._meta?.clientId);
402
+ }
403
+
404
+ function broadcastStateRevision(nextState, { sourceClientId = "" } = {}) {
405
+ if (!nextState || typeof nextState !== "object") {
406
+ return;
407
+ }
408
+
409
+ const revision = Number(nextState?._meta?.revision);
410
+ if (!Number.isFinite(revision) || revision < 0) {
411
+ return;
412
+ }
413
+
414
+ const payload = {
415
+ revision,
416
+ updatedAt: Number(nextState?._meta?.updatedAt) || Date.now(),
417
+ clientId: normalizePresenceClientId(sourceClientId),
418
+ };
419
+
420
+ for (const client of sseClients) {
421
+ try {
422
+ writeSseEvent(client.response, "state-updated", payload);
423
+ } catch (_error) {
424
+ closeSseClient(client);
425
+ }
426
+ }
427
+ }
428
+
429
+ function broadcastPermissionsChanged(payload = {}) {
430
+ const data = {
431
+ updatedAt: Date.now(),
432
+ ...payload,
433
+ };
434
+
435
+ for (const client of sseClients) {
436
+ try {
437
+ writeSseEvent(client.response, "permissions-changed", data);
438
+ } catch (_error) {
439
+ closeSseClient(client);
440
+ }
441
+ }
442
+ }
443
+
444
+ function normalizePresenceClientId(rawValue) {
445
+ const value = String(rawValue || "").trim();
446
+ if (!value || value.length > 160) {
447
+ return "";
448
+ }
449
+ if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
450
+ return "";
451
+ }
452
+ return value;
453
+ }
454
+
455
+ function normalizePresenceMode(rawValue) {
456
+ const value = String(rawValue || "").trim().toLowerCase();
457
+ return value === "markdown" ? "markdown" : "wysiwyg";
458
+ }
459
+
460
+ function normalizePresenceOffset(rawValue) {
461
+ const value = Number(rawValue);
462
+ if (!Number.isFinite(value)) {
463
+ return 0;
464
+ }
465
+ return Math.max(0, Math.floor(value));
466
+ }
467
+
468
+ function getPresenceUserKey(user) {
469
+ const id = String(user?.id || "").trim();
470
+ const email = String(user?.email || "").trim().toLowerCase();
471
+ const userId = String(user?.userId || "").trim();
472
+ return id || email || userId || "anonymous";
473
+ }
474
+
475
+ function getPresenceClientKey(user, clientId) {
476
+ return `${getPresenceUserKey(user)}::${clientId}`;
477
+ }
478
+
479
+ function listPresenceParticipantsForNote(noteId) {
480
+ const now = Date.now();
481
+ const participants = [];
482
+
483
+ for (const [entryKey, entry] of presenceByClientKey.entries()) {
484
+ if (!entry?.noteId) {
485
+ continue;
486
+ }
487
+ if (now - Number(entry.updatedAt || 0) > PRESENCE_TTL_MS) {
488
+ presenceByClientKey.delete(entryKey);
489
+ continue;
490
+ }
491
+ if (entry.noteId !== noteId) {
492
+ continue;
493
+ }
494
+ participants.push({
495
+ clientId: entry.clientId,
496
+ userKey: entry.userKey,
497
+ displayName: entry.displayName,
498
+ mode: entry.mode,
499
+ anchor: entry.anchor,
500
+ head: entry.head,
501
+ updatedAt: entry.updatedAt,
502
+ });
503
+ }
504
+
505
+ return participants.sort((left, right) =>
506
+ String(left.displayName || left.userKey).localeCompare(
507
+ String(right.displayName || right.userKey),
508
+ undefined,
509
+ { sensitivity: "base" },
510
+ ));
511
+ }
512
+
513
+ async function canClientReadPresenceNote(clientEntry, noteId) {
514
+ if (!noteId) {
515
+ return true;
516
+ }
517
+ const mode = clientEntry?.mode || getAuthMode();
518
+ if (mode === "off" || mode === "observe") {
519
+ return true;
520
+ }
521
+ try {
522
+ const capabilities = await aclService.getCapabilitiesForResource(clientEntry.user, {
523
+ resourceType: "note",
524
+ resourceExternalId: noteId,
525
+ mode,
526
+ });
527
+ return Boolean(capabilities.canRead);
528
+ } catch (_error) {
529
+ return false;
530
+ }
531
+ }
532
+
533
+ async function broadcastPresenceForNote(noteId) {
534
+ const payload = {
535
+ noteId: noteId || null,
536
+ participants: noteId ? listPresenceParticipantsForNote(noteId) : [],
537
+ updatedAt: Date.now(),
538
+ };
539
+
540
+ for (const client of sseClients) {
541
+ try {
542
+ if (noteId) {
543
+ const canRead = await canClientReadPresenceNote(client, noteId);
544
+ if (!canRead) {
545
+ continue;
546
+ }
547
+ }
548
+ writeSseEvent(client.response, "presence-updated", payload);
549
+ } catch (_error) {
550
+ closeSseClient(client);
551
+ }
552
+ }
553
+ }
554
+
555
+ function removePresenceForClient(user, clientId) {
556
+ const normalizedClientId = normalizePresenceClientId(clientId);
557
+ if (!normalizedClientId) {
558
+ return;
559
+ }
560
+ const presenceKey = getPresenceClientKey(user, normalizedClientId);
561
+ const existing = presenceByClientKey.get(presenceKey);
562
+ if (!existing) {
563
+ return;
564
+ }
565
+ presenceByClientKey.delete(presenceKey);
566
+ if (existing.noteId) {
567
+ void broadcastPresenceForNote(existing.noteId);
568
+ }
569
+ }
570
+
571
+ function sweepStalePresenceEntries() {
572
+ const now = Date.now();
573
+ const changedNoteIds = new Set();
574
+ for (const [entryKey, entry] of presenceByClientKey.entries()) {
575
+ if (now - Number(entry.updatedAt || 0) <= PRESENCE_TTL_MS) {
576
+ continue;
577
+ }
578
+ presenceByClientKey.delete(entryKey);
579
+ if (entry?.noteId) {
580
+ changedNoteIds.add(entry.noteId);
581
+ }
582
+ }
583
+ for (const noteId of changedNoteIds) {
584
+ void broadcastPresenceForNote(noteId);
585
+ }
586
+ }
587
+
588
+ const presenceSweepTimer = setInterval(sweepStalePresenceEntries, PRESENCE_SWEEP_MS);
589
+ presenceSweepTimer.unref?.();
590
+
285
591
  const shouldServeDist = resolveServeDistMode();
286
592
  if (shouldServeDist && fs.existsSync(DIST_DIR)) {
287
593
  app.use(express.static(DIST_DIR));
288
594
  }
289
595
 
290
596
  app.use(express.json({ limit: "25mb" }));
597
+ app.use(authContextMiddleware);
291
598
  app.use("/api", (_req, res, next) => {
292
599
  res.set("Cache-Control", "no-store");
293
600
  next();
@@ -296,18 +603,320 @@ app.use("/api", (_req, res, next) => {
296
603
  app.get("/api/health", (_req, res) => {
297
604
  res.json({
298
605
  ok: true,
606
+ authMode: getAuthMode(),
299
607
  storageRootDir: getStorageRootDir(),
300
608
  notesDir: getNotesDir(),
301
609
  trashDir: getTrashDir(),
302
610
  });
303
611
  });
304
612
 
305
- app.get("/api/state", (_req, res) => {
306
- const state = getHydratedState();
307
- res.json(state);
613
+ app.get("/api/me", requireAuthentication, async (req, res, next) => {
614
+ try {
615
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
616
+ const context = getRequestAuthContext(req);
617
+ const viewerContext = await aclService.getViewerContext(context.user, {
618
+ mode: context.mode,
619
+ state: fullState,
620
+ });
621
+ res.json(viewerContext);
622
+ } catch (error) {
623
+ next(error);
624
+ }
625
+ });
626
+
627
+ app.get("/api/me/capabilities", requireAuthentication, async (req, res, next) => {
628
+ try {
629
+ const resourceTypeRaw = String(req.query?.type || "").trim().toLowerCase();
630
+ const resourceExternalIdRaw = String(req.query?.externalId || "").trim();
631
+ const resource = resourceTypeRaw && resourceExternalIdRaw
632
+ ? { type: resourceTypeRaw, externalId: resourceExternalIdRaw }
633
+ : getWorkspaceResource();
634
+
635
+ await ensureAclStateFromState(getHydratedState(), req);
636
+
637
+ const context = getRequestAuthContext(req);
638
+ const capabilities = await aclService.getCapabilitiesForResource(context.user, {
639
+ resourceType: resource.type,
640
+ resourceExternalId: resource.externalId,
641
+ mode: context.mode,
642
+ });
643
+
644
+ res.json({
645
+ resourceType: resource.type,
646
+ resourceExternalId: resource.externalId,
647
+ ...capabilities,
648
+ });
649
+ } catch (error) {
650
+ next(error);
651
+ }
652
+ });
653
+
654
+ app.get("/api/state", requireAuthentication, async (req, res, next) => {
655
+ try {
656
+ const context = ensureAuthOrReject(req, res);
657
+ if (!context) {
658
+ return;
659
+ }
660
+
661
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
662
+ const scopedState = await toScopedStateForRequest(fullState, req);
663
+ res.json(scopedState);
664
+ } catch (error) {
665
+ next(error);
666
+ }
667
+ });
668
+
669
+ app.get("/api/acl/resource/:resourceType/:resourceExternalId", requireAuthentication, async (req, res, next) => {
670
+ try {
671
+ const context = ensureAuthOrReject(req, res);
672
+ if (!context) {
673
+ return;
674
+ }
675
+
676
+ const resourceType = String(req.params?.resourceType || "").trim();
677
+ const resourceExternalId = String(req.params?.resourceExternalId || "").trim();
678
+ const effectiveRaw = String(req.query?.effective || "").trim().toLowerCase();
679
+ const includeEffective = effectiveRaw === "1" || effectiveRaw === "true" || effectiveRaw === "yes";
680
+ if (!resourceType || !resourceExternalId) {
681
+ res.status(400).json({ message: "resourceType and resourceExternalId are required." });
682
+ return;
683
+ }
684
+
685
+ await ensureAclStateFromState(getHydratedState(), req);
686
+ let bindings = [];
687
+ let effectiveBindings = [];
688
+ if (includeEffective) {
689
+ const result = await aclService.listEffectiveBindingsForResource({
690
+ actorUser: context.user,
691
+ resourceType,
692
+ resourceExternalId,
693
+ mode: context.mode,
694
+ });
695
+ bindings = result.directBindings;
696
+ effectiveBindings = result.effectiveBindings;
697
+ } else {
698
+ bindings = await aclService.listBindingsForResource({
699
+ actorUser: context.user,
700
+ resourceType,
701
+ resourceExternalId,
702
+ mode: context.mode,
703
+ });
704
+ effectiveBindings = bindings.map((binding) => ({
705
+ ...binding,
706
+ relation: "direct",
707
+ sourceResourceType: resourceType,
708
+ sourceResourceExternalId: resourceExternalId,
709
+ canRevoke: true,
710
+ }));
711
+ }
712
+
713
+ res.json({
714
+ resourceType,
715
+ resourceExternalId,
716
+ bindings,
717
+ effectiveBindings,
718
+ });
719
+ } catch (error) {
720
+ next(error);
721
+ }
722
+ });
723
+
724
+ app.post("/api/acl/grant", requireAuthentication, async (req, res, next) => {
725
+ try {
726
+ const context = ensureAuthOrReject(req, res);
727
+ if (!context) {
728
+ return;
729
+ }
730
+ if (!req.body || typeof req.body !== "object") {
731
+ res.status(400).json({ message: "Request body must be an object." });
732
+ return;
733
+ }
734
+
735
+ await ensureAclStateFromState(getHydratedState(), req);
736
+
737
+ const binding = await aclService.grantBinding({
738
+ actorUser: context.user,
739
+ mode: context.mode,
740
+ bindingInput: req.body,
741
+ });
742
+
743
+ broadcastPermissionsChanged({
744
+ resourceType: binding.resourceType,
745
+ resourceExternalId: binding.resourceExternalId,
746
+ operation: "grant",
747
+ });
748
+
749
+ res.json({ ok: true, binding });
750
+ } catch (error) {
751
+ next(error);
752
+ }
753
+ });
754
+
755
+ app.delete("/api/acl/grant/:bindingId", requireAuthentication, async (req, res, next) => {
756
+ try {
757
+ const context = ensureAuthOrReject(req, res);
758
+ if (!context) {
759
+ return;
760
+ }
761
+ const bindingId = String(req.params?.bindingId || "").trim();
762
+ if (!bindingId) {
763
+ res.status(400).json({ message: "bindingId is required." });
764
+ return;
765
+ }
766
+
767
+ await ensureAclStateFromState(getHydratedState(), req);
768
+
769
+ const deleted = await aclService.revokeBinding({
770
+ actorUser: context.user,
771
+ mode: context.mode,
772
+ bindingId,
773
+ });
774
+
775
+ if (deleted) {
776
+ broadcastPermissionsChanged({
777
+ operation: "revoke",
778
+ bindingId,
779
+ });
780
+ }
781
+
782
+ res.json({ ok: true, deleted });
783
+ } catch (error) {
784
+ next(error);
785
+ }
786
+ });
787
+
788
+ app.get("/api/events", requireAuthentication, async (req, res, next) => {
789
+ try {
790
+ const context = ensureAuthOrReject(req, res);
791
+ if (!context) {
792
+ return;
793
+ }
794
+
795
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
796
+ const scopedState = await toScopedStateForRequest(fullState, req);
797
+
798
+ res.status(200);
799
+ res.setHeader("Content-Type", "text/event-stream");
800
+ res.setHeader("Cache-Control", "no-store");
801
+ res.setHeader("Connection", "keep-alive");
802
+ res.setHeader("X-Accel-Buffering", "no");
803
+ res.flushHeaders?.();
804
+
805
+ const eventsClientId = normalizePresenceClientId(req.query?.clientId);
806
+ const clientEntry = {
807
+ response: res,
808
+ user: context.user,
809
+ mode: context.mode,
810
+ clientId: eventsClientId || "",
811
+ };
812
+
813
+ sseClients.add(clientEntry);
814
+
815
+ writeSseEvent(res, "connected", {
816
+ revision: Number(scopedState?._meta?.revision) || 0,
817
+ connectedAt: Date.now(),
818
+ authMode: context.mode,
819
+ user: context.user?.displayName || context.user?.id || "anonymous",
820
+ });
821
+
822
+ const keepalive = setInterval(() => {
823
+ try {
824
+ res.write(": keepalive\n\n");
825
+ } catch (_error) {
826
+ clearInterval(keepalive);
827
+ closeSseClient(clientEntry);
828
+ }
829
+ }, SSE_KEEPALIVE_MS);
830
+
831
+ res.on("close", () => {
832
+ clearInterval(keepalive);
833
+ sseClients.delete(clientEntry);
834
+ if (eventsClientId) {
835
+ removePresenceForClient(context.user, eventsClientId);
836
+ }
837
+ });
838
+ } catch (error) {
839
+ next(error);
840
+ }
841
+ });
842
+
843
+ app.post("/api/presence/heartbeat", requireAuthentication, async (req, res, next) => {
844
+ try {
845
+ const context = ensureAuthOrReject(req, res);
846
+ if (!context) {
847
+ return;
848
+ }
849
+ if (!req.body || typeof req.body !== "object") {
850
+ res.status(400).json({ message: "Request body must be an object." });
851
+ return;
852
+ }
853
+
854
+ const clientId = normalizePresenceClientId(req.body.clientId);
855
+ if (!clientId) {
856
+ res.status(400).json({ message: "clientId is required." });
857
+ return;
858
+ }
859
+
860
+ const mode = normalizePresenceMode(req.body.mode);
861
+ const noteId = String(req.body.noteId || "").trim() || null;
862
+ const anchor = normalizePresenceOffset(req.body.anchor);
863
+ const head = normalizePresenceOffset(req.body.head);
864
+
865
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
866
+ const previousKey = getPresenceClientKey(context.user, clientId);
867
+ const previous = presenceByClientKey.get(previousKey) || null;
868
+
869
+ if (!noteId) {
870
+ presenceByClientKey.delete(previousKey);
871
+ if (previous?.noteId) {
872
+ void broadcastPresenceForNote(previous.noteId);
873
+ }
874
+ res.json({ ok: true, noteId: null, participants: [] });
875
+ return;
876
+ }
877
+
878
+ const note = Array.isArray(fullState?.notes)
879
+ ? fullState.notes.find((item) => String(item.id) === noteId && !item.deletedAt)
880
+ : null;
881
+ if (!note) {
882
+ res.status(404).json({ message: "Note not found." });
883
+ return;
884
+ }
885
+
886
+ await aclService.assertAllowed(context.user, {
887
+ action: "read",
888
+ resourceType: "note",
889
+ resourceExternalId: noteId,
890
+ mode: context.mode,
891
+ });
892
+
893
+ presenceByClientKey.set(previousKey, {
894
+ clientId,
895
+ userKey: getPresenceUserKey(context.user),
896
+ displayName: context.user?.displayName || context.user?.email || context.user?.userId || context.user?.id || "anonymous",
897
+ noteId,
898
+ mode,
899
+ anchor,
900
+ head,
901
+ updatedAt: Date.now(),
902
+ });
903
+
904
+ if (previous?.noteId && previous.noteId !== noteId) {
905
+ void broadcastPresenceForNote(previous.noteId);
906
+ }
907
+ void broadcastPresenceForNote(noteId);
908
+
909
+ res.json({
910
+ ok: true,
911
+ noteId,
912
+ participants: listPresenceParticipantsForNote(noteId),
913
+ });
914
+ } catch (error) {
915
+ next(error);
916
+ }
308
917
  });
309
918
 
310
- function handleGetMediaFile(req, res) {
919
+ async function handleGetMediaFile(req, res) {
311
920
  const noteId = String(req.query?.noteId || "").trim();
312
921
  const mediaPath = String(req.query?.path || "").trim();
313
922
  const requestedKind = String(req.query?.kind || "").trim().toLowerCase();
@@ -316,12 +925,26 @@ function handleGetMediaFile(req, res) {
316
925
  return;
317
926
  }
318
927
 
319
- const note = resolveNoteById(noteId);
928
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
929
+ const note = Array.isArray(fullState?.notes)
930
+ ? fullState.notes.find((item) => String(item.id) === noteId)
931
+ : null;
320
932
  if (!note || note.deletedAt) {
321
933
  res.status(404).json({ message: "Note not found." });
322
934
  return;
323
935
  }
324
936
 
937
+ const context = ensureAuthOrReject(req, res);
938
+ if (!context) {
939
+ return;
940
+ }
941
+ await aclService.assertAllowed(context.user, {
942
+ action: "read",
943
+ resourceType: "note",
944
+ resourceExternalId: note.id,
945
+ mode: context.mode,
946
+ });
947
+
325
948
  const absolutePath = resolveRequestedMediaFilePath(note, mediaPath, requestedKind);
326
949
  if (!absolutePath || !fs.existsSync(absolutePath)) {
327
950
  res.status(404).json({ message: "Media file not found." });
@@ -342,16 +965,22 @@ function handleGetMediaFile(req, res) {
342
965
  res.sendFile(absolutePath);
343
966
  }
344
967
 
345
- app.get("/api/media/file", handleGetMediaFile);
346
- app.get("/api/media/file/:fileName", handleGetMediaFile);
968
+ app.get("/api/media/file", (req, res, next) => {
969
+ Promise.resolve(handleGetMediaFile(req, res)).catch(next);
970
+ });
971
+ app.get("/api/media/file/:fileName", (req, res, next) => {
972
+ Promise.resolve(handleGetMediaFile(req, res)).catch(next);
973
+ });
347
974
 
348
975
  app.post(
349
976
  "/api/media/upload",
977
+ requireAuthentication,
350
978
  express.raw({
351
979
  type: () => true,
352
980
  limit: MAX_MEDIA_UPLOAD_BYTES,
353
981
  }),
354
- (req, res) => {
982
+ async (req, res, next) => {
983
+ try {
355
984
  const noteId = String(req.query?.noteId || "").trim();
356
985
  const kindRaw = String(req.query?.kind || "").trim().toLowerCase();
357
986
  const originalFileName = String(req.query?.fileName || "").trim();
@@ -368,12 +997,26 @@ app.post(
368
997
  return;
369
998
  }
370
999
 
371
- const note = resolveNoteById(noteId);
1000
+ const fullState = await ensureAclStateFromState(getHydratedState(), req);
1001
+ const note = Array.isArray(fullState?.notes)
1002
+ ? fullState.notes.find((item) => String(item.id) === noteId)
1003
+ : null;
372
1004
  if (!note || note.deletedAt) {
373
1005
  res.status(404).json({ message: "Note not found." });
374
1006
  return;
375
1007
  }
376
1008
 
1009
+ const context = ensureAuthOrReject(req, res);
1010
+ if (!context) {
1011
+ return;
1012
+ }
1013
+ await aclService.assertAllowed(context.user, {
1014
+ action: "write",
1015
+ resourceType: "note",
1016
+ resourceExternalId: note.id,
1017
+ mode: context.mode,
1018
+ });
1019
+
377
1020
  const rawBody = Buffer.isBuffer(req.body)
378
1021
  ? req.body
379
1022
  : req.body instanceof Uint8Array
@@ -404,32 +1047,80 @@ app.post(
404
1047
  message: error?.message || "Failed to save uploaded media.",
405
1048
  });
406
1049
  }
407
- },
1050
+ } catch (error) {
1051
+ next(error);
1052
+ }
1053
+ }
408
1054
  );
409
1055
 
410
- function handleStateWrite(req, res) {
1056
+ async function handleStateWrite(req, res, next) {
411
1057
  if (!req.body || typeof req.body !== "object") {
412
1058
  res.status(400).json({ message: "Request body must be a state object." });
413
1059
  return;
414
1060
  }
415
1061
 
416
1062
  try {
417
- const nextState = replaceState(req.body);
418
- res.json(nextState);
1063
+ const context = ensureAuthOrReject(req, res);
1064
+ if (!context) {
1065
+ return;
1066
+ }
1067
+
1068
+ const currentFullState = await ensureAclStateFromState(getHydratedState(), req);
1069
+ const sourceClientId = resolveStateSourceClientId(req);
1070
+ debugSyncLog("state write:request", {
1071
+ user: context.user?.displayName || context.user?.id || "anonymous",
1072
+ baseRevision: Number(req.body?._meta?.baseRevision) || 0,
1073
+ currentRevision: Number(currentFullState?._meta?.revision) || 0,
1074
+ sourceClientId,
1075
+ });
1076
+
1077
+ const mergedState = await aclService.mergeScopedStateForWrite({
1078
+ currentFullState,
1079
+ incomingScopedState: req.body,
1080
+ user: context.user,
1081
+ mode: context.mode,
1082
+ });
1083
+
1084
+ const nextState = replaceState({
1085
+ ...mergedState,
1086
+ _meta: req.body?._meta,
1087
+ });
1088
+
1089
+ const ensuredNextState = await ensureAclStateFromState(nextState, req);
1090
+ broadcastStateRevision(ensuredNextState, { sourceClientId });
1091
+ debugSyncLog("state write:applied", {
1092
+ sourceClientId,
1093
+ nextRevision: Number(ensuredNextState?._meta?.revision) || 0,
1094
+ });
1095
+ const scopedState = await toScopedStateForRequest(ensuredNextState, req);
1096
+ res.json(scopedState);
419
1097
  } catch (error) {
1098
+ debugSyncLog("state write:error", {
1099
+ status: Number(error?.status) || 0,
1100
+ message: error?.message || "unknown",
1101
+ });
420
1102
  if (Number(error?.status) === 409) {
1103
+ let scopedConflictState = error?.payload?.state || null;
1104
+ if (scopedConflictState && typeof scopedConflictState === "object") {
1105
+ try {
1106
+ scopedConflictState = await toScopedStateForRequest(scopedConflictState, req);
1107
+ } catch (_scopeError) {
1108
+ scopedConflictState = null;
1109
+ }
1110
+ }
421
1111
  res.status(409).json({
422
1112
  message: error.message || "State conflict detected.",
423
1113
  ...(error?.payload && typeof error.payload === "object" ? error.payload : {}),
1114
+ ...(scopedConflictState ? { state: scopedConflictState } : {}),
424
1115
  });
425
1116
  return;
426
1117
  }
427
- throw error;
1118
+ next(error);
428
1119
  }
429
1120
  }
430
1121
 
431
- app.put("/api/state", handleStateWrite);
432
- app.post("/api/state", handleStateWrite);
1122
+ app.put("/api/state", requireAuthentication, handleStateWrite);
1123
+ app.post("/api/state", requireAuthentication, handleStateWrite);
433
1124
 
434
1125
  if (shouldServeDist && fs.existsSync(DIST_INDEX_FILE)) {
435
1126
  app.get("*", (req, res, next) => {
@@ -444,12 +1135,17 @@ if (shouldServeDist && fs.existsSync(DIST_INDEX_FILE)) {
444
1135
  app.use((error, _req, res, _next) => {
445
1136
  // eslint-disable-next-line no-console
446
1137
  console.error("[tui.notes.2026][api]", error);
447
- res.status(500).json({ message: "Unknown server error." });
1138
+ const status = Number(error?.status);
1139
+ const responseStatus = Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
1140
+ res.status(responseStatus).json({
1141
+ message: error?.message || "Unknown server error.",
1142
+ ...(error?.details && typeof error.details === "object" ? { details: error.details } : {}),
1143
+ });
448
1144
  });
449
1145
 
450
1146
  app.listen(PORT, HOST, () => {
451
1147
  // eslint-disable-next-line no-console
452
1148
  console.log(
453
- `[tui.notes.2026][api] listening on http://${HOST}:${PORT} (storage: ${getStorageRootDir()}, serveDist: ${String(shouldServeDist)})`,
1149
+ `[tui.notes.2026][api] listening on http://${HOST}:${PORT} (storage: ${getStorageRootDir()}, serveDist: ${String(shouldServeDist)}, authMode: ${getAuthMode()})`,
454
1150
  );
455
1151
  });