@musnows/scriverse 0.3.9 → 0.3.11

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, proseReplacementPermissionModules } 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,8 +56,29 @@ 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", "settings-editor", "viewer"]) }).strict();
59
- const memberRoleSchema = z.object({ role: z.enum(["editor", "settings-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();
@@ -303,6 +325,55 @@ function noContent(response) {
303
325
  function parse(schema, value) {
304
326
  return schema.parse(value);
305
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
+ }
306
377
  export function createRuntime(options) {
307
378
  logger.info("runtime.initializing", {
308
379
  databasePath: options.databasePath,
@@ -319,6 +390,14 @@ export function createRuntime(options) {
319
390
  mkdirSync(attachmentStorage.temporaryDirectory, { recursive: true, mode: 0o700 });
320
391
  const auth = new UserAuthService(database);
321
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
+ };
322
401
  const captcha = new ImageCaptchaService({ revealAnswer: options.revealCaptchaAnswer === true });
323
402
  const ai = new AiManager(store, new CredentialVault(options.masterSecret), options.fetchImpl ?? fetch, options.security ? (url) => assertSafeAiEndpoint(url, options.security?.allowPrivateAiEndpoints) : undefined);
324
403
  const app = express();
@@ -536,19 +615,34 @@ export function createRuntime(options) {
536
615
  if (!request.authUser)
537
616
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
538
617
  const input = parse(memberSchema, request.body);
539
- const members = auth.addMember(request.params.workId, input.userId, input.role, request.authUser.userId);
540
- 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
+ });
541
626
  data(response, members, 201);
542
627
  });
543
628
  app.patch("/api/works/:workId/members/:userId", (request, response) => {
544
- const input = parse(memberRoleSchema, request.body);
545
- const members = auth.updateMemberRole(request.params.workId, request.params.userId, input.role);
546
- 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
+ });
547
638
  data(response, members);
548
639
  });
549
640
  app.delete("/api/works/:workId/members/:userId", (request, response) => {
550
- const members = auth.removeMember(request.params.workId, request.params.userId);
551
- 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
+ });
552
646
  data(response, members);
553
647
  });
554
648
  app.patch("/api/works/:workId", (request, response) => {
@@ -603,11 +697,14 @@ export function createRuntime(options) {
603
697
  if (extension !== ".txt" && extension !== ".docx") {
604
698
  throw new AppError(415, "UNSUPPORTED_FILE", "MVP 仅支持 TXT 和 DOCX 导入");
605
699
  }
700
+ const mode = parse(z.enum(["append", "overwrite"]), request.body.mode ?? "overwrite");
701
+ if (mode === "overwrite" && request.authUser) {
702
+ auth.assertWorkAccess(request.authUser, String(request.params.workId), { write: proseReplacementPermissionModules }, false, request.authMethod !== "api-key");
703
+ }
606
704
  const text = validateImportedText(extension === ".docx"
607
705
  ? await extractDocxText(request.file.buffer)
608
706
  : decodeUtf8ImportedText(request.file.buffer));
609
707
  const parsed = applyImportFileHints(parseNovelText(text), originalFileName);
610
- const mode = parse(z.enum(["append", "overwrite"]), request.body.mode ?? "overwrite");
611
708
  const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
612
709
  data(response, store.importNovel(String(request.params.workId), originalFileName, extension.slice(1), parsed, mode, expectedVersionNo), 201);
613
710
  });
@@ -731,23 +828,34 @@ export function createRuntime(options) {
731
828
  includeMerged: z.enum(["0", "1"]).default("0")
732
829
  }), request.query);
733
830
  const pagination = parsePagination(request.query);
734
- data(response, pagination
831
+ const permissions = requestPermissions(request, request.params.workId);
832
+ const characters = pagination
735
833
  ? store.listCharactersPage(request.params.workId, pagination, includeSections === "true", includeMerged === "1")
736
- : store.listCharacters(request.params.workId, includeSections === "true", includeMerged === "1"));
834
+ : store.listCharacters(request.params.workId, includeSections === "true", includeMerged === "1");
835
+ data(response, mapRecords(characters, (character) => redactCharacterLinks(character, permissions)));
836
+ });
837
+ app.post("/api/works/:workId/characters", (request, response) => {
838
+ const character = store.createCharacter(request.params.workId, parse(characterSchema, request.body));
839
+ data(response, redactCharacterLinks(character, requestPermissions(request, request.params.workId)), 201);
840
+ });
841
+ app.get("/api/characters/:characterId", (request, response) => {
842
+ data(response, redactCharacterLinks(store.getCharacter(request.params.characterId), requestPermissions(request)));
737
843
  });
738
- app.post("/api/works/:workId/characters", (request, response) => data(response, store.createCharacter(request.params.workId, parse(characterSchema, request.body)), 201));
739
- app.get("/api/characters/:characterId", (request, response) => data(response, store.getCharacter(request.params.characterId)));
740
844
  app.patch("/api/characters/:characterId", (request, response) => {
741
845
  const { changeNote, expectedVersionNo, ...input } = parse(characterUpdateSchema.extend({ expectedVersionNo: expectedVersionNoSchema }), request.body);
742
- data(response, store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo));
846
+ const character = store.updateCharacter(request.params.characterId, input, "manual", null, changeNote, expectedVersionNo);
847
+ data(response, redactCharacterLinks(character, requestPermissions(request)));
743
848
  });
744
849
  app.get("/api/characters/:characterId/versions", (request, response) => {
745
850
  const pagination = parsePagination(request.query);
746
- data(response, pagination ? store.listCharacterVersionsPage(request.params.characterId, pagination) : store.listCharacterVersions(request.params.characterId));
851
+ const versions = pagination ? store.listCharacterVersionsPage(request.params.characterId, pagination) : store.listCharacterVersions(request.params.characterId);
852
+ const permissions = requestPermissions(request);
853
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactCharacterLinks(snapshot, permissions)));
747
854
  });
748
855
  app.post("/api/characters/:characterId/restore", (request, response) => {
749
856
  const input = parse(z.object({ versionNo: z.number().int().positive(), expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
750
- data(response, store.restoreCharacter(request.params.characterId, input.versionNo, input.expectedVersionNo));
857
+ const character = store.restoreCharacter(request.params.characterId, input.versionNo, input.expectedVersionNo);
858
+ data(response, redactCharacterLinks(character, requestPermissions(request)));
751
859
  });
752
860
  app.delete("/api/characters/:characterId", (request, response) => {
753
861
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
@@ -760,11 +868,13 @@ export function createRuntime(options) {
760
868
  expectedTargetVersionNo: z.number().int().positive(),
761
869
  expectedSourceVersionNo: z.number().int().positive()
762
870
  }).strict(), request.body);
763
- data(response, store.mergeCharacters({
871
+ const result = store.mergeCharacters({
764
872
  reviewId: null,
765
873
  sourceCharacterId: request.params.characterId,
766
874
  ...input
767
- }));
875
+ });
876
+ const permissions = requestPermissions(request);
877
+ data(response, redactMergeRecords(result, (character) => redactCharacterLinks(character, permissions)));
768
878
  });
769
879
  app.get("/api/characters/:characterId/sections", (request, response) => {
770
880
  const pagination = parsePagination(request.query);
@@ -844,15 +954,21 @@ export function createRuntime(options) {
844
954
  });
845
955
  app.get("/api/works/:workId/races", (request, response) => {
846
956
  const pagination = parsePagination(request.query);
847
- data(response, pagination ? store.listRacesPage(request.params.workId, pagination) : store.listRaces(request.params.workId));
957
+ const races = pagination ? store.listRacesPage(request.params.workId, pagination) : store.listRaces(request.params.workId);
958
+ const permissions = requestPermissions(request, request.params.workId);
959
+ data(response, mapRecords(races, (race) => redactRaceMembers(race, permissions)));
848
960
  });
849
961
  app.post("/api/works/:workId/races", (request, response) => {
850
- data(response, store.createRace(request.params.workId, parse(raceSchema, request.body)), 201);
962
+ const race = store.createRace(request.params.workId, parse(raceSchema, request.body));
963
+ data(response, redactRaceMembers(race, requestPermissions(request, request.params.workId)), 201);
964
+ });
965
+ app.get("/api/races/:raceId", (request, response) => {
966
+ data(response, redactRaceMembers(store.getRace(request.params.raceId), requestPermissions(request)));
851
967
  });
852
- app.get("/api/races/:raceId", (request, response) => data(response, store.getRace(request.params.raceId)));
853
968
  app.patch("/api/races/:raceId", (request, response) => {
854
969
  const { changeNote, expectedVersionNo, ...input } = parse(raceSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
855
- data(response, store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo));
970
+ const race = store.updateRace(request.params.raceId, input, "manual", null, changeNote, expectedVersionNo);
971
+ data(response, redactRaceMembers(race, requestPermissions(request)));
856
972
  });
857
973
  app.delete("/api/races/:raceId", (request, response) => {
858
974
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
@@ -861,19 +977,27 @@ export function createRuntime(options) {
861
977
  });
862
978
  app.post("/api/races/:raceId/merge", (request, response) => {
863
979
  const input = parse(z.object({ targetRaceId: identifier }).strict(), request.body);
864
- data(response, store.mergeRaces(request.params.raceId, input.targetRaceId));
980
+ const result = store.mergeRaces(request.params.raceId, input.targetRaceId);
981
+ const permissions = requestPermissions(request);
982
+ data(response, redactMergeRecords(result, (race) => redactRaceMembers(race, permissions)));
865
983
  });
866
984
  app.get("/api/works/:workId/organizations", (request, response) => {
867
985
  const pagination = parsePagination(request.query);
868
- data(response, pagination ? store.listOrganizationsPage(request.params.workId, pagination) : store.listOrganizations(request.params.workId));
986
+ const organizations = pagination ? store.listOrganizationsPage(request.params.workId, pagination) : store.listOrganizations(request.params.workId);
987
+ const permissions = requestPermissions(request, request.params.workId);
988
+ data(response, mapRecords(organizations, (organization) => redactOrganizationMembers(organization, permissions)));
869
989
  });
870
990
  app.post("/api/works/:workId/organizations", (request, response) => {
871
- data(response, store.createOrganization(request.params.workId, parse(organizationSchema, request.body)), 201);
991
+ const organization = store.createOrganization(request.params.workId, parse(organizationSchema, request.body));
992
+ data(response, redactOrganizationMembers(organization, requestPermissions(request, request.params.workId)), 201);
993
+ });
994
+ app.get("/api/organizations/:organizationId", (request, response) => {
995
+ data(response, redactOrganizationMembers(store.getOrganization(request.params.organizationId), requestPermissions(request)));
872
996
  });
873
- app.get("/api/organizations/:organizationId", (request, response) => data(response, store.getOrganization(request.params.organizationId)));
874
997
  app.patch("/api/organizations/:organizationId", (request, response) => {
875
998
  const { changeNote, expectedVersionNo, ...input } = parse(organizationSchema.partial().extend({ changeNote: changeNoteSchema, expectedVersionNo: expectedVersionNoSchema }).strict(), request.body);
876
- data(response, store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo));
999
+ const organization = store.updateOrganization(request.params.organizationId, input, "manual", null, changeNote, expectedVersionNo);
1000
+ data(response, redactOrganizationMembers(organization, requestPermissions(request)));
877
1001
  });
878
1002
  app.delete("/api/organizations/:organizationId", (request, response) => {
879
1003
  const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
@@ -882,7 +1006,9 @@ export function createRuntime(options) {
882
1006
  });
883
1007
  app.post("/api/organizations/:organizationId/merge", (request, response) => {
884
1008
  const input = parse(z.object({ targetOrganizationId: identifier }).strict(), request.body);
885
- data(response, store.mergeOrganizations(request.params.organizationId, input.targetOrganizationId));
1009
+ const result = store.mergeOrganizations(request.params.organizationId, input.targetOrganizationId);
1010
+ const permissions = requestPermissions(request);
1011
+ data(response, redactMergeRecords(result, (organization) => redactOrganizationMembers(organization, permissions)));
886
1012
  });
887
1013
  app.get("/api/works/:workId/timeline-tracks", (request, response) => {
888
1014
  const pagination = parsePagination(request.query);
@@ -961,9 +1087,19 @@ export function createRuntime(options) {
961
1087
  app.get("/api/entity-versions/:entityType/:entityId", (request, response) => {
962
1088
  const input = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);
963
1089
  const pagination = parsePagination(request.query);
964
- data(response, pagination
1090
+ const versions = pagination
965
1091
  ? store.listEntityVersionsPage(input.entityType, input.entityId, pagination)
966
- : store.listEntityVersions(input.entityType, input.entityId));
1092
+ : store.listEntityVersions(input.entityType, input.entityId);
1093
+ const permissions = requestPermissions(request);
1094
+ if (input.entityType === "race") {
1095
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactRaceMembers(snapshot, permissions)));
1096
+ return;
1097
+ }
1098
+ if (input.entityType === "organization") {
1099
+ data(response, redactVersionSnapshots(versions, (snapshot) => redactOrganizationMembers(snapshot, permissions)));
1100
+ return;
1101
+ }
1102
+ data(response, versions);
967
1103
  });
968
1104
  app.post("/api/entity-versions/:entityType/:entityId/restore", (request, response) => {
969
1105
  const params = parse(z.object({ entityType: versionedEntityTypeSchema, entityId: identifier }), request.params);