@musnows/scriverse 0.3.8 → 0.3.10

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/dist/app.js CHANGED
@@ -26,6 +26,7 @@ import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logg
26
26
  import { accountReference, logger, sanitizeError } from "./logger.js";
27
27
  import { runWithRequestActor } from "./request-context.js";
28
28
  import { APP_VERSION } from "./version.js";
29
+ import { fullWorkModulePermissions } from "./work-permissions.js";
29
30
  import { clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, setSessionCookie, UserAuthService } from "./user-auth.js";
30
31
  const nonEmpty = z.string().trim().min(1);
31
32
  const identifier = z.string().trim().min(1).max(200);
@@ -55,11 +56,33 @@ const loginSchema = z.object({
55
56
  ...captchaFields
56
57
  }).strict();
57
58
  const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
58
- const memberSchema = z.object({ userId: identifier, role: z.enum(["editor", "viewer"]) }).strict();
59
- const memberRoleSchema = z.object({ role: z.enum(["editor", "viewer"]) }).strict();
59
+ const memberRoleValueSchema = z.enum(["editor", "settings-editor", "viewer"]);
60
+ const moduleAccessSchema = z.enum(["none", "read", "write"]);
61
+ const modulePermissionsSchema = z.object({
62
+ prose: moduleAccessSchema,
63
+ settings: moduleAccessSchema,
64
+ characters: moduleAccessSchema,
65
+ races: moduleAccessSchema,
66
+ organizations: moduleAccessSchema,
67
+ timeline: moduleAccessSchema,
68
+ relationships: moduleAccessSchema,
69
+ outlines: moduleAccessSchema,
70
+ reviews: moduleAccessSchema,
71
+ ai: moduleAccessSchema,
72
+ "ai-settings": moduleAccessSchema
73
+ }).strict();
74
+ const memberSchema = z.union([
75
+ z.object({ userId: identifier, permissions: modulePermissionsSchema }).strict(),
76
+ z.object({ userId: identifier, role: memberRoleValueSchema }).strict()
77
+ ]);
78
+ const memberPermissionSchema = z.union([
79
+ z.object({ permissions: modulePermissionsSchema }).strict(),
80
+ z.object({ role: memberRoleValueSchema }).strict()
81
+ ]);
60
82
  const profileSchema = z.object({ displayName: z.string().trim().min(1).max(80) }).strict();
61
83
  const passwordChangeSchema = z.object({ currentPassword: z.string().max(200), newPassword: passwordSchema }).strict();
62
84
  const changeNoteSchema = z.string().trim().max(500).optional();
85
+ const expectedVersionNoSchema = z.coerce.number().int().positive().optional();
63
86
  function validateImportedText(text) {
64
87
  if (text.length > maximumImportedTextLength)
65
88
  throw new AppError(413, "IMPORT_TEXT_TOO_LARGE", "导入文件解压后的文本超过 2000 万字符限制");
@@ -174,7 +197,7 @@ const organizationSchema = z.object({
174
197
  description: z.string().max(100_000).optional(),
175
198
  settings: z.array(z.string().trim().min(1).max(20_000)).max(200).optional(),
176
199
  memberIds: z.array(identifier).max(1000).optional()
177
- });
200
+ }).strict();
178
201
  const raceSchema = z.object({
179
202
  name: nonEmpty.max(200),
180
203
  parentRaceId: identifier.nullable().optional(),
@@ -302,6 +325,55 @@ function noContent(response) {
302
325
  function parse(schema, value) {
303
326
  return schema.parse(value);
304
327
  }
328
+ function recordValue(value) {
329
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
330
+ }
331
+ function mapRecords(value, mapper) {
332
+ if (Array.isArray(value))
333
+ return value.map((item) => recordValue(item) ? mapper(item) : item);
334
+ const record = recordValue(value);
335
+ if (!record)
336
+ return value;
337
+ if (Array.isArray(record.items)) {
338
+ return { ...record, items: record.items.map((item) => recordValue(item) ? mapper(item) : item) };
339
+ }
340
+ return mapper(record);
341
+ }
342
+ function redactCharacterLinks(record, permissions) {
343
+ const result = { ...record };
344
+ if (permissions.races === "none") {
345
+ result.raceId = null;
346
+ result.race = null;
347
+ result.species = "";
348
+ }
349
+ if (permissions.organizations === "none") {
350
+ result.organizationIds = [];
351
+ result.organizations = [];
352
+ }
353
+ return result;
354
+ }
355
+ function redactRaceMembers(record, permissions) {
356
+ return permissions.characters === "none" ? { ...record, memberIds: [], members: [] } : record;
357
+ }
358
+ function redactOrganizationMembers(record, permissions) {
359
+ return permissions.characters === "none" ? { ...record, memberIds: [], members: [] } : record;
360
+ }
361
+ function redactMergeRecords(value, mapper) {
362
+ const record = recordValue(value);
363
+ if (!record)
364
+ return value;
365
+ return {
366
+ ...record,
367
+ ...(recordValue(record.target) ? { target: mapper(record.target) } : {}),
368
+ ...(recordValue(record.source) ? { source: mapper(record.source) } : {})
369
+ };
370
+ }
371
+ function redactVersionSnapshots(value, mapper) {
372
+ return mapRecords(value, (version) => {
373
+ const snapshot = recordValue(version.snapshot);
374
+ return snapshot ? { ...version, snapshot: mapper(snapshot) } : version;
375
+ });
376
+ }
305
377
  export function createRuntime(options) {
306
378
  logger.info("runtime.initializing", {
307
379
  databasePath: options.databasePath,
@@ -318,6 +390,14 @@ export function createRuntime(options) {
318
390
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
319
391
  const auth = new UserAuthService(database);
320
392
  const store = new Store(database);
393
+ const requestPermissions = (request, workId) => {
394
+ if (!request.authUser)
395
+ return fullWorkModulePermissions();
396
+ const resolvedWorkId = workId ?? auth.resolveWorkId(request.path) ?? undefined;
397
+ if (!resolvedWorkId)
398
+ return fullWorkModulePermissions();
399
+ return auth.workModulePermissions(request.authUser, resolvedWorkId, request.authMethod !== "api-key") ?? fullWorkModulePermissions();
400
+ };
321
401
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
322
402
  const ai = new AiManager(store, new CredentialVault(options.masterSecret), options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined);
323
403
  const app = express();
@@ -535,24 +615,43 @@ export function createRuntime(options) {
535
615
  if (!request.authUser)
536
616
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
537
617
  const input = parse(memberSchema, request.body);
538
- const members = auth.addMember(request.params.workId, input.userId, input.role, request.authUser.userId);
539
- store.audit(request.params.workId, "work.member-added", "user", input.userId, { role: input.role });
618
+ const permissionInput = "permissions" in input
619
+ ? { permissions: input.permissions }
620
+ : { role: input.role };
621
+ const members = database.transaction(() => {
622
+ const result = auth.addMember(request.params.workId, input.userId, permissionInput, request.authUser.userId);
623
+ store.audit(request.params.workId, "work.member-added", "user", input.userId, permissionInput);
624
+ return result;
625
+ });
540
626
  data(response, members, 201);
541
627
  });
542
628
  app.patch("/api/works/:workId/members/:userId", (request, response) => {
543
- const input = parse(memberRoleSchema, request.body);
544
- const members = auth.updateMemberRole(request.params.workId, request.params.userId, input.role);
545
- store.audit(request.params.workId, "work.member-role-updated", "user", request.params.userId, { role: input.role });
629
+ const input = parse(memberPermissionSchema, request.body);
630
+ const permissionInput = "permissions" in input
631
+ ? { permissions: input.permissions }
632
+ : { role: input.role };
633
+ const members = database.transaction(() => {
634
+ const result = auth.updateMemberPermissions(request.params.workId, request.params.userId, permissionInput);
635
+ store.audit(request.params.workId, "work.member-role-updated", "user", request.params.userId, permissionInput);
636
+ return result;
637
+ });
546
638
  data(response, members);
547
639
  });
548
640
  app.delete("/api/works/:workId/members/:userId", (request, response) => {
549
- const members = auth.removeMember(request.params.workId, request.params.userId);
550
- store.audit(request.params.workId, "work.member-removed", "user", request.params.userId);
641
+ const members = database.transaction(() => {
642
+ const result = auth.removeMember(request.params.workId, request.params.userId);
643
+ store.audit(request.params.workId, "work.member-removed", "user", request.params.userId);
644
+ return result;
645
+ });
551
646
  data(response, members);
552
647
  });
553
- app.patch("/api/works/:workId", (request, response) => data(response, store.updateWork(request.params.workId, parse(workSchema.partial(), request.body))));
648
+ app.patch("/api/works/:workId", (request, response) => {
649
+ const { expectedVersionNo, changeNote, ...input } = parse(workSchema.partial().extend({ expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
650
+ data(response, store.updateWork(request.params.workId, input, expectedVersionNo, "manual", null, changeNote));
651
+ });
554
652
  app.delete("/api/works/:workId", async (request, response) => {
555
- const removableStorageKeys = store.deleteWork(request.params.workId);
653
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
654
+ const removableStorageKeys = store.deleteWork(request.params.workId, input.expectedVersionNo);
556
655
  await Promise.all(removableStorageKeys.map((storageKey) => attachmentStorage.remove(storageKey)));
557
656
  noContent(response);
558
657
  });
@@ -574,10 +673,12 @@ export function createRuntime(options) {
574
673
  const mimeType = isPng ? "image/png" : isJpeg ? "image/jpeg" : isWebp ? "image/webp" : null;
575
674
  if (!mimeType)
576
675
  throw new AppError(415, "INVALID_COVER", "封面文件内容不是有效的 PNG、JPEG 或 WebP 图片");
577
- data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes));
676
+ const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
677
+ data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes, expectedVersionNo));
578
678
  });
579
679
  app.delete("/api/works/:workId/cover", (request, response) => {
580
- store.deleteWorkCover(request.params.workId);
680
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
681
+ store.deleteWorkCover(request.params.workId, input.expectedVersionNo);
581
682
  noContent(response);
582
683
  });
583
684
  app.get("/api/works/:workId/file-versions", (request, response) => {
@@ -585,7 +686,8 @@ export function createRuntime(options) {
585
686
  data(response, pagination ? store.listFileVersionsPage(request.params.workId, pagination) : store.listFileVersions(request.params.workId));
586
687
  });
587
688
  app.post("/api/works/:workId/file-versions/:fileVersionId/restore", (request, response) => {
588
- data(response, store.restoreFileVersion(request.params.workId, request.params.fileVersionId));
689
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
690
+ data(response, store.restoreFileVersion(request.params.workId, request.params.fileVersionId, input.expectedVersionNo));
589
691
  });
590
692
  app.post("/api/works/:workId/import", upload.single("file"), async (request, response) => {
591
693
  if (!request.file)
@@ -599,19 +701,23 @@ export function createRuntime(options) {
599
701
  ? await extractDocxText(request.file.buffer)
600
702
  : decodeUtf8ImportedText(request.file.buffer));
601
703
  const parsed = applyImportFileHints(parseNovelText(text), originalFileName);
602
- data(response, store.importNovel(String(request.params.workId), originalFileName, extension.slice(1), parsed), 201);
704
+ const mode = parse(z.enum(["append", "overwrite"]), request.body.mode ?? "overwrite");
705
+ const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
706
+ data(response, store.importNovel(String(request.params.workId), originalFileName, extension.slice(1), parsed, mode, expectedVersionNo), 201);
603
707
  });
604
708
  app.post("/api/works/:workId/volumes", (request, response) => {
605
709
  const input = parse(z.object({ title: nonEmpty.max(200), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional() }), request.body);
606
710
  data(response, store.createVolume(request.params.workId, input), 201);
607
711
  });
608
712
  app.patch("/api/volumes/:volumeId", (request, response) => {
609
- const input = parse(z.object({ title: nonEmpty.max(200).optional(), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), sortOrder: z.number().int().min(0).optional() }), request.body);
610
- data(response, store.updateVolume(request.params.volumeId, input));
713
+ const input = parse(z.object({ title: nonEmpty.max(200).optional(), kind: z.enum(["main", "prequel", "extra", "epilogue", "appendix"]).optional(), description: z.string().max(5_000).optional(), keywords: z.array(nonEmpty.max(100)).max(100).optional(), sortOrder: z.number().int().min(0).optional(), expectedVersionNo: expectedVersionNoSchema, changeNote: changeNoteSchema }).strict(), request.body);
714
+ const { expectedVersionNo, changeNote, ...volumeInput } = input;
715
+ data(response, store.updateVolume(request.params.volumeId, volumeInput, expectedVersionNo, "manual", null, changeNote));
611
716
  });
612
717
  app.get("/api/volumes/:volumeId", (request, response) => data(response, store.getVolume(request.params.volumeId)));
613
718
  app.delete("/api/volumes/:volumeId", (request, response) => {
614
- store.deleteVolume(request.params.volumeId);
719
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
720
+ store.deleteVolume(request.params.volumeId, input.expectedVersionNo);
615
721
  noContent(response);
616
722
  });
617
723
  app.post("/api/works/:workId/chapters", (request, response) => {
@@ -620,12 +726,13 @@ export function createRuntime(options) {
620
726
  });
621
727
  app.get("/api/chapters/:chapterId", (request, response) => data(response, store.getChapter(request.params.chapterId)));
622
728
  app.patch("/api/chapters/:chapterId", (request, response) => {
623
- const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema }).strict(), request.body);
624
- const { source, changeNote, ...chapterInput } = input;
625
- data(response, store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote));
729
+ const input = parse(z.object({ title: nonEmpty.max(300).optional(), content: z.string().max(2_000_000).optional(), excludedFromAnalysis: z.boolean().optional(), chapterType: chapterTypeSchema.optional(), source: z.enum(["manual", "auto"]).optional(), changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
730
+ const { source, changeNote, expectedVersionNo, ...chapterInput } = input;
731
+ data(response, store.saveChapter(request.params.chapterId, chapterInput, source ?? "manual", null, changeNote, expectedVersionNo));
626
732
  });
627
733
  app.delete("/api/chapters/:chapterId", (request, response) => {
628
- store.deleteChapter(request.params.chapterId);
734
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
735
+ store.deleteChapter(request.params.chapterId, input.expectedVersionNo);
629
736
  noContent(response);
630
737
  });
631
738
  app.get("/api/chapters/:chapterId/versions", (request, response) => {
@@ -637,12 +744,13 @@ export function createRuntime(options) {
637
744
  data(response, pagination ? store.listChapterInsightsPage(request.params.chapterId, pagination) : store.listChapterInsights(request.params.chapterId));
638
745
  });
639
746
  app.post("/api/chapters/:chapterId/restore", (request, response) => {
640
- const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
641
- data(response, store.restoreChapter(request.params.chapterId, input.versionNo));
747
+ const input = parse(z.object({ versionNo: z.number().int().positive(), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
748
+ data(response, store.restoreChapter(request.params.chapterId, input.versionNo, input.expectedVersionNo));
642
749
  });
643
750
  app.post("/api/chapters/:chapterId/move", (request, response) => {
644
- const input = parse(z.object({ volumeId: identifier, sortOrder: z.number().int().min(0) }), request.body);
645
- data(response, store.moveChapter(request.params.chapterId, input));
751
+ const input = parse(z.object({ volumeId: identifier, sortOrder: z.number().int().min(0), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
752
+ const { expectedVersionNo, ...moveInput } = input;
753
+ data(response, store.moveChapter(request.params.chapterId, moveInput, expectedVersionNo));
646
754
  });
647
755
  app.get("/api/works/:workId/outlines", (request, response) => {
648
756
  const pagination = parsePagination(request.query);
@@ -650,11 +758,12 @@ export function createRuntime(options) {
650
758
  });
651
759
  app.get("/api/chapters/:chapterId/outline", (request, response) => data(response, store.getChapterOutline(request.params.chapterId)));
652
760
  app.put("/api/chapters/:chapterId/outline", (request, response) => {
653
- const { changeNote, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema }), request.body);
654
- data(response, store.upsertChapterOutline(request.params.chapterId, input, "manual", null, changeNote));
761
+ const { changeNote, expectedVersionNo, ...input } = parse(chapterOutlineSchema.extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
762
+ data(response, store.upsertChapterOutline(request.params.chapterId, input, "manual", null, changeNote, expectedVersionNo));
655
763
  });
656
764
  app.delete("/api/chapters/:chapterId/outline", (request, response) => {
657
- store.deleteChapterOutline(request.params.chapterId);
765
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
766
+ store.deleteChapterOutline(request.params.chapterId, input.expectedVersionNo);
658
767
  noContent(response);
659
768
  });
660
769
  app.get("/api/works/:workId/foreshadows", (request, response) => {
@@ -672,21 +781,27 @@ export function createRuntime(options) {
672
781
  });
673
782
  app.get("/api/foreshadows/:foreshadowId", (request, response) => data(response, store.getForeshadow(request.params.foreshadowId)));
674
783
  app.patch("/api/foreshadows/:foreshadowId", (request, response) => {
675
- const { changeNote, ...input } = parse(foreshadowSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
676
- data(response, store.updateForeshadow(request.params.foreshadowId, input, "manual", null, changeNote));
784
+ const { changeNote, expectedVersionNo, ...input } = parse(foreshadowSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
785
+ data(response, store.updateForeshadow(request.params.foreshadowId, input, "manual", null, changeNote, expectedVersionNo));
677
786
  });
678
787
  app.delete("/api/foreshadows/:foreshadowId", (request, response) => {
679
- store.deleteForeshadow(request.params.foreshadowId);
788
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
789
+ store.deleteForeshadow(request.params.foreshadowId, input.expectedVersionNo);
680
790
  noContent(response);
681
791
  });
682
792
  app.post("/api/foreshadows/:foreshadowId/occurrences", (request, response) => {
683
- data(response, store.createForeshadowOccurrence(request.params.foreshadowId, parse(foreshadowOccurrenceSchema, request.body)), 201);
793
+ const input = parse(foreshadowOccurrenceSchema.extend({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
794
+ const { expectedVersionNo, ...occurrenceInput } = input;
795
+ data(response, store.createForeshadowOccurrence(request.params.foreshadowId, occurrenceInput, expectedVersionNo), 201);
684
796
  });
685
797
  app.patch("/api/foreshadow-occurrences/:occurrenceId", (request, response) => {
686
- data(response, store.updateForeshadowOccurrence(request.params.occurrenceId, parse(foreshadowOccurrenceSchema.partial(), request.body)));
798
+ const input = parse(foreshadowOccurrenceSchema.partial().extend({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
799
+ const { expectedVersionNo, ...occurrenceInput } = input;
800
+ data(response, store.updateForeshadowOccurrence(request.params.occurrenceId, occurrenceInput, expectedVersionNo));
687
801
  });
688
802
  app.delete("/api/foreshadow-occurrences/:occurrenceId", (request, response) => {
689
- store.deleteForeshadowOccurrence(request.params.occurrenceId);
803
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
804
+ store.deleteForeshadowOccurrence(request.params.occurrenceId, input.expectedVersionNo);
690
805
  noContent(response);
691
806
  });
692
807
  app.get("/api/works/:workId/settings", (request, response) => {
@@ -696,11 +811,12 @@ export function createRuntime(options) {
696
811
  app.post("/api/works/:workId/settings", (request, response) => data(response, store.createSetting(request.params.workId, parse(settingSchema, request.body)), 201));
697
812
  app.get("/api/settings/:settingId", (request, response) => data(response, store.getSetting(request.params.settingId)));
698
813
  app.patch("/api/settings/:settingId", (request, response) => {
699
- const { changeNote, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
700
- data(response, store.updateSetting(request.params.settingId, input, "manual", null, changeNote));
814
+ const { changeNote, expectedVersionNo, ...input } = parse(settingSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
815
+ data(response, store.updateSetting(request.params.settingId, input, "manual", null, changeNote, expectedVersionNo));
701
816
  });
702
817
  app.delete("/api/settings/:settingId", (request, response) => {
703
- store.deleteSetting(request.params.settingId);
818
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
819
+ store.deleteSetting(request.params.settingId, input.expectedVersionNo);
704
820
  noContent(response);
705
821
  });
706
822
  app.get("/api/works/:workId/characters", (request, response) => {
@@ -709,28 +825,54 @@ export function createRuntime(options) {
709
825
  includeMerged: z.enum(["0", "1"]).default("0")
710
826
  }), request.query);
711
827
  const pagination = parsePagination(request.query);
712
- data(response, pagination
828
+ const permissions = requestPermissions(request, request.params.workId);
829
+ const characters = pagination
713
830
  ? store.listCharactersPage(request.params.workId, pagination, includeSections === "true", includeMerged === "1")
714
- : store.listCharacters(request.params.workId, includeSections === "true", includeMerged === "1"));
831
+ : store.listCharacters(request.params.workId, includeSections === "true", includeMerged === "1");
832
+ data(response, mapRecords(characters, (character) => redactCharacterLinks(character, permissions)));
833
+ });
834
+ app.post("/api/works/:workId/characters", (request, response) => {
835
+ const character = store.createCharacter(request.params.workId, parse(characterSchema, request.body));
836
+ data(response, redactCharacterLinks(character, requestPermissions(request, request.params.workId)), 201);
837
+ });
838
+ app.get("/api/characters/:characterId", (request, response) => {
839
+ data(response, redactCharacterLinks(store.getCharacter(request.params.characterId), requestPermissions(request)));
715
840
  });
716
- app.post("/api/works/:workId/characters", (request, response) => data(response, store.createCharacter(request.params.workId, parse(characterSchema, request.body)), 201));
717
- app.get("/api/characters/:characterId", (request, response) => data(response, store.getCharacter(request.params.characterId)));
718
841
  app.patch("/api/characters/:characterId", (request, response) => {
719
- const { changeNote, ...input } = parse(characterUpdateSchema, request.body);
720
- data(response, store.updateCharacter(request.params.characterId, input, "manual", null, changeNote));
842
+ const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
843
+ const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
844
+ data(response, redactCharacterLinks(character, requestPermissions(request)));
721
845
  });
722
846
  app.get("/api/characters/:characterId/versions", (request, response) => {
723
847
  const pagination = parsePagination(request.query);
724
- data(response, pagination ? store.listCharacterVersionsPage(request.params.characterId, pagination) : store.listCharacterVersions(request.params.characterId));
848
+ const versions = pagination ? store.listCharacterVersionsPage(request.params.characterId, pagination) : store.listCharacterVersions(request.params.characterId);
849
+ const permissions = requestPermissions(request);
850
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactCharacterLinks(snapshot, permissions)));
725
851
  });
726
852
  app.post("/api/characters/:characterId/restore", (request, response) => {
727
- const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
728
- data(response, store.restoreCharacter(request.params.characterId, input.versionNo));
853
+ const input = parse(z.object({ versionNo: z.number().int().positive(), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
854
+ const character = store.restoreCharacter(request.params.characterId, input.versionNo, input.expectedVersionNo);
855
+ data(response, redactCharacterLinks(character, requestPermissions(request)));
729
856
  });
730
857
  app.delete("/api/characters/:characterId", (request, response) => {
731
- store.deleteCharacter(request.params.characterId);
858
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
859
+ store.deleteCharacter(request.params.characterId, input.expectedVersionNo);
732
860
  noContent(response);
733
861
  });
862
+ app.post("/api/characters/:characterId/merge", (request, response) => {
863
+ const input = parse(z.object({
864
+ targetCharacterId: identifier,
865
+ expectedTargetVersionNo: z.number().int().positive(),
866
+ expectedSourceVersionNo: z.number().int().positive()
867
+ }).strict(), request.body);
868
+ const result = store.mergeCharacters({
869
+ reviewId: null,
870
+ sourceCharacterId: request.params.characterId,
871
+ ...input
872
+ });
873
+ const permissions = requestPermissions(request);
874
+ data(response, redactMergeRecords(result, (character) => redactCharacterLinks(character, permissions)));
875
+ });
734
876
  app.get("/api/characters/:characterId/sections", (request, response) => {
735
877
  const pagination = parsePagination(request.query);
736
878
  data(response, pagination
@@ -744,11 +886,12 @@ export function createRuntime(options) {
744
886
  data(response, store.getCharacterProfileSection(request.params.sectionId));
745
887
  });
746
888
  app.patch("/api/character-sections/:sectionId", (request, response) => {
747
- const { changeNote, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema }).strict(), request.body);
748
- data(response, store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote));
889
+ const { changeNote, expectedVersionNo, ...input } = parse(characterProfileSectionSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
890
+ data(response, store.updateCharacterProfileSection(request.params.sectionId, input, "manual", null, changeNote, expectedVersionNo));
749
891
  });
750
892
  app.delete("/api/character-sections/:sectionId", (request, response) => {
751
- store.deleteCharacterProfileSection(request.params.sectionId);
893
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
894
+ store.deleteCharacterProfileSection(request.params.sectionId, input.expectedVersionNo);
752
895
  noContent(response);
753
896
  });
754
897
  app.get("/api/character-sections/:sectionId/versions", (request, response) => {
@@ -758,8 +901,8 @@ export function createRuntime(options) {
758
901
  : store.listCharacterProfileSectionVersions(request.params.sectionId));
759
902
  });
760
903
  app.post("/api/character-sections/:sectionId/restore", (request, response) => {
761
- const input = parse(z.object({ versionNo: z.number().int().positive() }).strict(), request.body);
762
- data(response, store.restoreCharacterProfileSection(request.params.sectionId, input.versionNo));
904
+ const input = parse(z.object({ versionNo: z.number().int().positive(), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
905
+ data(response, store.restoreCharacterProfileSection(request.params.sectionId, input.versionNo, input.expectedVersionNo));
763
906
  });
764
907
  app.get("/api/works/:workId/attachments", (request, response) => {
765
908
  const pagination = parsePagination(request.query);
@@ -808,36 +951,62 @@ export function createRuntime(options) {
808
951
  });
809
952
  app.get("/api/works/:workId/races", (request, response) => {
810
953
  const pagination = parsePagination(request.query);
811
- data(response, pagination ? store.listRacesPage(request.params.workId, pagination) : store.listRaces(request.params.workId));
954
+ const races = pagination ? store.listRacesPage(request.params.workId, pagination) : store.listRaces(request.params.workId);
955
+ const permissions = requestPermissions(request, request.params.workId);
956
+ data(response, mapRecords(races, (race) => redactRaceMembers(race, permissions)));
812
957
  });
813
958
  app.post("/api/works/:workId/races", (request, response) => {
814
- data(response, store.createRace(request.params.workId, parse(raceSchema, request.body)), 201);
959
+ const race = store.createRace(request.params.workId, parse(raceSchema, request.body));
960
+ data(response, redactRaceMembers(race, requestPermissions(request, request.params.workId)), 201);
961
+ });
962
+ app.get("/api/races/:raceId", (request, response) => {
963
+ data(response, redactRaceMembers(store.getRace(request.params.raceId), requestPermissions(request)));
815
964
  });
816
- app.get("/api/races/:raceId", (request, response) => data(response, store.getRace(request.params.raceId)));
817
965
  app.patch("/api/races/:raceId", (request, response) => {
818
- const { changeNote, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
819
- data(response, store.updateRace(request.params.raceId, input, "manual", null, changeNote));
966
+ const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
967
+ const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
968
+ data(response, redactRaceMembers(race, requestPermissions(request)));
820
969
  });
821
970
  app.delete("/api/races/:raceId", (request, response) => {
822
- store.deleteRace(request.params.raceId);
971
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
972
+ store.deleteRace(request.params.raceId, input.expectedVersionNo);
823
973
  noContent(response);
824
974
  });
975
+ app.post("/api/races/:raceId/merge", (request, response) => {
976
+ const input = parse(z.object({ targetRaceId: identifier }).strict(), request.body);
977
+ const result = store.mergeRaces(request.params.raceId, input.targetRaceId);
978
+ const permissions = requestPermissions(request);
979
+ data(response, redactMergeRecords(result, (race) => redactRaceMembers(race, permissions)));
980
+ });
825
981
  app.get("/api/works/:workId/organizations", (request, response) => {
826
982
  const pagination = parsePagination(request.query);
827
- data(response, pagination ? store.listOrganizationsPage(request.params.workId, pagination) : store.listOrganizations(request.params.workId));
983
+ const organizations = pagination ? store.listOrganizationsPage(request.params.workId, pagination) : store.listOrganizations(request.params.workId);
984
+ const permissions = requestPermissions(request, request.params.workId);
985
+ data(response, mapRecords(organizations, (organization) => redactOrganizationMembers(organization, permissions)));
828
986
  });
829
987
  app.post("/api/works/:workId/organizations", (request, response) => {
830
- data(response, store.createOrganization(request.params.workId, parse(organizationSchema, request.body)), 201);
988
+ const organization = store.createOrganization(request.params.workId, parse(organizationSchema, request.body));
989
+ data(response, redactOrganizationMembers(organization, requestPermissions(request, request.params.workId)), 201);
990
+ });
991
+ app.get("/api/organizations/:organizationId", (request, response) => {
992
+ data(response, redactOrganizationMembers(store.getOrganization(request.params.organizationId), requestPermissions(request)));
831
993
  });
832
- app.get("/api/organizations/:organizationId", (request, response) => data(response, store.getOrganization(request.params.organizationId)));
833
994
  app.patch("/api/organizations/:organizationId", (request, response) => {
834
- const { changeNote, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
835
- data(response, store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote));
995
+ const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
996
+ const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
997
+ data(response, redactOrganizationMembers(organization, requestPermissions(request)));
836
998
  });
837
999
  app.delete("/api/organizations/:organizationId", (request, response) => {
838
- store.deleteOrganization(request.params.organizationId);
1000
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1001
+ store.deleteOrganization(request.params.organizationId, input.expectedVersionNo);
839
1002
  noContent(response);
840
1003
  });
1004
+ app.post("/api/organizations/:organizationId/merge", (request, response) => {
1005
+ const input = parse(z.object({ targetOrganizationId: identifier }).strict(), request.body);
1006
+ const result = store.mergeOrganizations(request.params.organizationId, input.targetOrganizationId);
1007
+ const permissions = requestPermissions(request);
1008
+ data(response, redactMergeRecords(result, (organization) => redactOrganizationMembers(organization, permissions)));
1009
+ });
841
1010
  app.get("/api/works/:workId/timeline-tracks", (request, response) => {
842
1011
  const pagination = parsePagination(request.query);
843
1012
  data(response, pagination ? store.listTimelineTracksPage(request.params.workId, pagination) : store.listTimelineTracks(request.params.workId));
@@ -845,11 +1014,12 @@ export function createRuntime(options) {
845
1014
  app.post("/api/works/:workId/timeline-tracks", (request, response) => data(response, store.createTimelineTrack(request.params.workId, parse(timelineTrackSchema, request.body)), 201));
846
1015
  app.get("/api/timeline-tracks/:trackId", (request, response) => data(response, store.getTimelineTrack(request.params.trackId)));
847
1016
  app.patch("/api/timeline-tracks/:trackId", (request, response) => {
848
- const { changeNote, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
849
- data(response, store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote));
1017
+ const { changeNote, expectedVersionNo, ...input } = parse(timelineTrackSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1018
+ data(response, store.updateTimelineTrack(request.params.trackId, input, "manual", null, changeNote, expectedVersionNo));
850
1019
  });
851
1020
  app.delete("/api/timeline-tracks/:trackId", (request, response) => {
852
- store.deleteTimelineTrack(request.params.trackId);
1021
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1022
+ store.deleteTimelineTrack(request.params.trackId, input.expectedVersionNo);
853
1023
  noContent(response);
854
1024
  });
855
1025
  app.get("/api/works/:workId/timeline", (request, response) => {
@@ -863,14 +1033,16 @@ export function createRuntime(options) {
863
1033
  name: nonEmpty.max(300),
864
1034
  description: z.string().max(100_000).optional(),
865
1035
  timeLabel: z.string().max(300).optional(),
866
- timeSort: z.number().finite().nullable().optional()
867
- }), request.body);
868
- data(response, store.mergeTimelineEvents(request.params.workId, input.eventIds, input), 201);
1036
+ timeSort: z.number().finite().nullable().optional(),
1037
+ expectedVersionNos: z.record(identifier, z.number().int().positive()).optional()
1038
+ }).strict(), request.body);
1039
+ const { expectedVersionNos, ...mergeInput } = input;
1040
+ data(response, store.mergeTimelineEvents(request.params.workId, input.eventIds, mergeInput, expectedVersionNos), 201);
869
1041
  });
870
1042
  app.get("/api/timeline/:eventId", (request, response) => data(response, store.getTimelineEvent(request.params.eventId)));
871
1043
  app.patch("/api/timeline/:eventId", (request, response) => {
872
- const { changeNote, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
873
- data(response, store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote));
1044
+ const { changeNote, expectedVersionNo, ...input } = parse(timelineSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1045
+ data(response, store.updateTimelineEvent(request.params.eventId, input, "manual", null, changeNote, expectedVersionNo));
874
1046
  });
875
1047
  app.post("/api/timeline/:eventId/split", (request, response) => {
876
1048
  const input = parse(z.object({
@@ -879,12 +1051,14 @@ export function createRuntime(options) {
879
1051
  description: z.string().max(100_000).optional(),
880
1052
  timeLabel: z.string().max(300).optional(),
881
1053
  timeSort: z.number().finite().nullable().optional()
882
- })).min(2)
883
- }), request.body);
884
- data(response, store.splitTimelineEvent(request.params.eventId, input.parts), 201);
1054
+ })).min(2),
1055
+ expectedVersionNo: expectedVersionNoSchema
1056
+ }).strict(), request.body);
1057
+ data(response, store.splitTimelineEvent(request.params.eventId, input.parts, input.expectedVersionNo), 201);
885
1058
  });
886
1059
  app.delete("/api/timeline/:eventId", (request, response) => {
887
- store.deleteTimelineEvent(request.params.eventId);
1060
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1061
+ store.deleteTimelineEvent(request.params.eventId, input.expectedVersionNo);
888
1062
  noContent(response);
889
1063
  });
890
1064
  app.get("/api/works/:workId/relationships", (request, response) => {
@@ -899,24 +1073,35 @@ export function createRuntime(options) {
899
1073
  app.post("/api/works/:workId/relationships", (request, response) => data(response, store.createRelationship(request.params.workId, parse(relationshipSchema, request.body)), 201));
900
1074
  app.get("/api/relationships/:relationshipId", (request, response) => data(response, store.getRelationship(request.params.relationshipId)));
901
1075
  app.patch("/api/relationships/:relationshipId", (request, response) => {
902
- const { changeNote, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema }), request.body);
903
- data(response, store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote));
1076
+ const { changeNote, expectedVersionNo, ...input } = parse(relationshipSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1077
+ data(response, store.updateRelationship(request.params.relationshipId, input, "manual", null, changeNote, expectedVersionNo));
904
1078
  });
905
1079
  app.delete("/api/relationships/:relationshipId", (request, response) => {
906
- store.deleteRelationship(request.params.relationshipId);
1080
+ const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
1081
+ store.deleteRelationship(request.params.relationshipId, input.expectedVersionNo);
907
1082
  noContent(response);
908
1083
  });
909
1084
  app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
910
1085
  const input = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);
911
1086
  const pagination = parsePagination(request.query);
912
- data(response, pagination
1087
+ const versions = pagination
913
1088
  ? store.listEntityVersionsPage(input.entityType, input.entityId, pagination)
914
- : store.listEntityVersions(input.entityType, input.entityId));
1089
+ : store.listEntityVersions(input.entityType, input.entityId);
1090
+ const permissions = requestPermissions(request);
1091
+ if (input.entityType === "race") {
1092
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactRaceMembers(snapshot, permissions)));
1093
+ return;
1094
+ }
1095
+ if (input.entityType === "organization") {
1096
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactOrganizationMembers(snapshot, permissions)));
1097
+ return;
1098
+ }
1099
+ data(response, versions);
915
1100
  });
916
1101
  app.post("/api/entity-versions/:entityType/:entityId/restore", (request, response) => {
917
1102
  const params = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);
918
- const input = parse(z.object({ versionNo: z.number().int().positive() }), request.body);
919
- data(response, store.restoreEntityVersion(params.entityType, params.entityId, input.versionNo));
1103
+ const input = parse(z.object({ versionNo: z.number().int().positive(), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
1104
+ data(response, store.restoreEntityVersion(params.entityType, params.entityId, input.versionNo, input.expectedVersionNo));
920
1105
  });
921
1106
  app.get("/api/works/:workId/reviews", (request, response) => {
922
1107
  const status = typeof request.query.status === "string" ? request.query.status : undefined;