@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 +167 -31
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +387 -125
- package/dist/public/index.html +31 -6
- package/dist/public/styles.css +40 -22
- package/dist/public/work-permissions.d.ts +14 -0
- package/dist/public/work-permissions.js +71 -0
- package/dist/store.js +89 -23
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +203 -52
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/work-permissions.js +129 -0
- package/dist/work-permissions.js.map +1 -0
- package/package.json +1 -1
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
|
|
59
|
-
const
|
|
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
|
|
540
|
-
|
|
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(
|
|
545
|
-
const
|
|
546
|
-
|
|
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 =
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|