@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.
- package/README.md +50 -0
- package/dist/assets/{arc-Dw2FFQpg.js → arc-PCH7RH34.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-CufEy62L.js → architectureDiagram-VXUJARFQ-CJCgNTQY.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-BnqXETC-.js → blockDiagram-VD42YOAC-Dr6BuZdb.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-CRRqnF8Q.js → c4Diagram-YG6GDRKO-Cujs6tue.js} +1 -1
- package/dist/assets/channel-CcerkviR.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BKdUMn0k.js → chunk-4BX2VUAB-BcD-NEr2.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B8U-4L0g.js → chunk-55IACEB6-CuADq_qH.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-DiSlffG3.js → chunk-B4BG7PRW-CQeb3pmx.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-DoFW5sIs.js → chunk-DI55MBZ5-CLozoy-P.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-G-X5BCqS.js → chunk-FMBD7UC4-DTHBR6EW.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-D_F0QBne.js → chunk-QN33PNHL-ChElDE3F.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-yGAa1Z7-.js → chunk-QZHKN3VN-BOuzHqeF.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-gDf9m9hb.js → chunk-TZMSLE5B-BWbpFvic.js} +1 -1
- package/dist/assets/classDiagram-2ON5EDUG-DuvJikuL.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-DuvJikuL.js +1 -0
- package/dist/assets/{clone-CtQPqq7L.js → clone-CZ5tMkEs.js} +1 -1
- package/dist/assets/{cose-bilkent-S5V4N54A-DWUmsMRp.js → cose-bilkent-S5V4N54A-BrtvxZJ8.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-B2BazWXF.js → dagre-6UL2VRFP-DLURHD-1.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-BpBMH0Ts.js → diagram-PSM6KHXK-D5gDu3Jy.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-DT4ajz1c.js → diagram-QEK2KX5R-DHhyRXC6.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-DFOpAZP9.js → diagram-S2PKOQOG-DB2mF9n1.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-C5AMhI0K.js → erDiagram-Q2GNP2WA-CuYjTYDE.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-CeGyvCMA.js → flowDiagram-NV44I4VS-BTmXRhk3.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-uVJ-OV6s.js → ganttDiagram-JELNMOA3-Dj8216Bt.js} +1 -1
- package/dist/assets/{gitGraphDiagram-NY62KEGX-BrhQjAjl.js → gitGraphDiagram-NY62KEGX-AfR_zz8Y.js} +1 -1
- package/dist/assets/{index-CNp6F8X7.css → index-8Yfq90k_.css} +1 -1
- package/dist/assets/{index-c2yhXVIu.js → index-CORObQTV.js} +594 -517
- package/dist/assets/{infoDiagram-WHAUD3N6-CRdDYgIS.js → infoDiagram-WHAUD3N6-B6ZVQAG8.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-Yqs1G7Ur.js → journeyDiagram-XKPGCS4Q-BoG7zgLk.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-GIkJLZdc.js → kanban-definition-3W4ZIXB7-CGkltrpk.js} +1 -1
- package/dist/assets/{linear-Bx_xGOD8.js → linear-ZFsx-qcY.js} +1 -1
- package/dist/assets/{mindmap-definition-VGOIOE7T-Bh9_I7_C.js → mindmap-definition-VGOIOE7T-DS3-vFjB.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-_IIkl2Vj.js → pieDiagram-ADFJNKIX-DS4SKK5L.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-BeN-mDm1.js → quadrantDiagram-AYHSOK5B-BqSmZSFq.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-DTuux7i8.js → requirementDiagram-UZGBJVZJ-MQtqXGHx.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-BnEwJfWi.js → sankeyDiagram-TZEHDZUN-DPxJ0i0v.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-CKDNxA0n.js → sequenceDiagram-WL72ISMW-0yXSvFmS.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-B5NsJSxj.js → stateDiagram-FKZM4ZOC-e_TqYmQe.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-B85_wtn8.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-90xY8Nw6.js → timeline-definition-IT6M3QCI-mfjyRSAU.js} +1 -1
- package/dist/assets/{treemap-KMMF4GRG-CT9Jf2NQ.js → treemap-KMMF4GRG-CxwWdtjF.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-CLAphPTr.js → xychartDiagram-PRI3JC2R-DzSzO4xZ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +10 -5
- package/server/acl-service.js +1249 -0
- package/server/acl-service.test.js +439 -0
- package/server/acl-store.js +421 -0
- package/server/auth.js +119 -0
- package/server/index.js +719 -23
- package/server/store.js +12 -0
- package/dist/assets/channel-DrL0fpY9.js +0 -1
- package/dist/assets/classDiagram-2ON5EDUG-BHSxN04i.js +0 -1
- package/dist/assets/classDiagram-v2-WZHVMYZB-BHSxN04i.js +0 -1
- 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/
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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",
|
|
346
|
-
|
|
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
|
|
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
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|