@musnows/scriverse 0.6.2 → 0.6.3

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
@@ -21,7 +21,7 @@ import { applyImportFileHints, parseNovelText } from "./parser.js";
21
21
  import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
22
22
  import { paginated, parsePagination } from "./pagination.js";
23
23
  import { normalizeUploadFileName } from "./utils.js";
24
- import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, verifySetupToken } from "./security.js";
24
+ import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
25
25
  import { ImageCaptchaService } from "./image-captcha.js";
26
26
  import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
27
27
  import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
@@ -379,6 +379,7 @@ const aiProcessStepSchema = z.discriminatedUnion("type", [
379
379
  ]);
380
380
  const workAiSettingsSchema = z.object({
381
381
  systemPrompt: z.string().max(100_000).optional(),
382
+ dailyTokenQuota: z.number().int().min(10_000).max(2_000_000_000).nullable().optional(),
382
383
  autoRunEnabled: z.boolean().optional(),
383
384
  autoRunConcurrency: z.number().int().min(1).max(8).optional(),
384
385
  autoRunBatchLimit: z.number().int().min(1).max(200).optional(),
@@ -386,6 +387,8 @@ const workAiSettingsSchema = z.object({
386
387
  autoRunFailureThreshold: z.number().int().min(1).max(10).optional(),
387
388
  bookSummaryContextPercent: z.number().int().min(1).max(90).optional(),
388
389
  contextCompactThreshold: z.number().int().min(50).max(90).optional(),
390
+ agentToolCallLimit: z.number().int().min(5).max(48).optional(),
391
+ agentToolCallGlobalMultiplier: z.number().int().min(1).max(6).optional(),
389
392
  agentTools: z.array(z.enum(["story_index", "read_chapters", "grep", "search_story_entities", "read_character_sections", "search_drafts"])).max(6).optional(),
390
393
  titleGenerationModelId: z.string().trim().max(200).optional()
391
394
  }).strict();
@@ -626,6 +629,88 @@ function redactAiCallContext(record, permissions) {
626
629
  result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
627
630
  return result;
628
631
  }
632
+ const proseRestrictedPlaceholder = "(正文读取权限受限)";
633
+ function redactContinuationGuard(record, permissions) {
634
+ if (permissions.prose !== "none")
635
+ return record;
636
+ return {
637
+ ...record,
638
+ issues: [],
639
+ contextRefs: {},
640
+ failure: null,
641
+ restricted: true
642
+ };
643
+ }
644
+ /** 无正文读取权限时移除建议中的原文、指令和检查证据,避免通过 AI 接口绕过 prose=none。 */
645
+ function redactSuggestion(record, permissions) {
646
+ if (permissions.prose !== "none")
647
+ return record;
648
+ const guard = recordValue(record.guard);
649
+ return {
650
+ ...record,
651
+ instruction: proseRestrictedPlaceholder,
652
+ sourceText: "",
653
+ ...(guard ? { guard: redactContinuationGuard(guard, permissions) } : {}),
654
+ restricted: true
655
+ };
656
+ }
657
+ function redactAiConversationMessage(item, permissions) {
658
+ if (permissions.prose !== "none")
659
+ return item;
660
+ const message = recordValue(item);
661
+ if (!message)
662
+ return item;
663
+ return {
664
+ ...message,
665
+ content: proseRestrictedPlaceholder,
666
+ citations: [],
667
+ metadata: { restricted: true },
668
+ restricted: true
669
+ };
670
+ }
671
+ /** 无正文读取权限时隐藏对话预览与消息正文,避免历史对话泄露章节原文。 */
672
+ function redactAiConversation(record, permissions) {
673
+ if (permissions.prose !== "none")
674
+ return record;
675
+ const result = {
676
+ ...record,
677
+ title: proseRestrictedPlaceholder
678
+ };
679
+ if (typeof result.preview === "string" && result.preview.length > 0) {
680
+ result.preview = proseRestrictedPlaceholder;
681
+ }
682
+ if (Array.isArray(result.messages)) {
683
+ result.messages = result.messages.map((item) => redactAiConversationMessage(item, permissions));
684
+ }
685
+ const messagesPage = recordValue(result.messagesPage);
686
+ if (messagesPage && Array.isArray(messagesPage.items)) {
687
+ result.messagesPage = {
688
+ ...messagesPage,
689
+ items: messagesPage.items.map((item) => redactAiConversationMessage(item, permissions))
690
+ };
691
+ }
692
+ return { ...result, restricted: true };
693
+ }
694
+ /** SSE 错误事件只暴露 AppError 的公开信息,避免透传内部异常 message。 */
695
+ export function publicAiStreamError(error) {
696
+ if (error instanceof AppError) {
697
+ const details = error.details && typeof error.details === "object" && !Array.isArray(error.details)
698
+ ? error.details
699
+ : null;
700
+ return {
701
+ code: error.code,
702
+ message: error.message,
703
+ status: error.status,
704
+ ...(error.status < 500 && typeof details?.failure === "string" ? { failure: details.failure } : {}),
705
+ ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
706
+ ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
707
+ ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
708
+ ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
709
+ ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
710
+ };
711
+ }
712
+ return { code: "AI_STREAM_FAILED", message: "AI 流式调用失败" };
713
+ }
629
714
  function redactMergeRecords(value, mapper) {
630
715
  const record = recordValue(value);
631
716
  if (!record)
@@ -652,6 +737,7 @@ export function createRuntime(options) {
652
737
  sameOriginEnforced: options.security?.enforceSameOrigin ?? true
653
738
  });
654
739
  const database = new Database(options.databasePath);
740
+ const bootId = randomUUID();
655
741
  const temporaryAttachmentRoot = options.databasePath === ":memory:" && !options.attachmentDirectory
656
742
  ? mkdtempSync(join(tmpdir(), "scriverse-attachments-"))
657
743
  : null;
@@ -722,6 +808,7 @@ export function createRuntime(options) {
722
808
  }, false, actor?.allowAdminAccess ?? false);
723
809
  });
724
810
  const app = express();
811
+ enforceCaseInsensitiveRouting(app);
725
812
  const upload = multer({
726
813
  storage: multer.memoryStorage(),
727
814
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 10, fieldSize: 64 * 1024, parts: 11, headerPairs: 100 }
@@ -742,13 +829,18 @@ export function createRuntime(options) {
742
829
  limits: { fileSize: 30 * 1024 * 1024, files: 1, fields: 4, fieldSize: 16 * 1024, parts: 5, headerPairs: 100 }
743
830
  });
744
831
  app.disable("x-powered-by");
745
- if (options.security?.trustProxy !== undefined)
746
- app.set("trust proxy", options.security.trustProxy);
832
+ const trustProxy = resolveTrustProxySetting(options.security?.trustProxy);
833
+ if (options.security?.trustProxy === true) {
834
+ logger.warn("security.trust_proxy.coerced", { from: true, to: 1 });
835
+ }
836
+ if (trustProxy !== undefined)
837
+ app.set("trust proxy", trustProxy);
747
838
  app.use(createRequestLoggingMiddleware());
748
839
  app.use(createSecurityHeadersMiddleware());
749
840
  app.get("/api/health", (_request, response) => {
750
841
  data(response, {
751
842
  status: "ok",
843
+ bootId,
752
844
  version: APP_VERSION,
753
845
  protocol: "openai-chat-completions",
754
846
  protocols: ["openai-chat-completions", "anthropic-messages"],
@@ -758,6 +850,7 @@ export function createRuntime(options) {
758
850
  if (options.security?.auth)
759
851
  app.use(createBasicAuthMiddleware(options.security.auth));
760
852
  app.use(createAuthenticationRateLimitMiddleware());
853
+ app.use(createCaptchaRateLimitMiddleware());
761
854
  app.use(createApiRateLimitMiddleware(options.security?.apiRateLimit, options.security?.apiRateWindowMs));
762
855
  if (options.security?.enforceSameOrigin ?? true)
763
856
  app.use(createSameOriginMiddleware());
@@ -769,12 +862,12 @@ export function createRuntime(options) {
769
862
  const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
770
863
  const developmentUser = getDevelopmentUser();
771
864
  if (!session && developmentUser) {
772
- data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, setupTokenRequired: false, registrationOpen });
865
+ data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
773
866
  return;
774
867
  }
775
868
  data(response, session
776
- ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, setupTokenRequired: false, registrationOpen }
777
- : { authenticated: false, user: null, csrfToken: null, setupRequired, setupTokenRequired, registrationOpen });
869
+ ? { authenticated: true, user: session.user, csrfToken: session.csrfToken, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen }
870
+ : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
778
871
  });
779
872
  app.get("/api/auth/captcha", (_request, response) => {
780
873
  data(response, captcha.create());
@@ -808,6 +901,7 @@ export function createRuntime(options) {
808
901
  resolveBypassUser: getDevelopmentUser
809
902
  }));
810
903
  app.use(createUploadRateLimitMiddleware());
904
+ app.use(createExpensiveApiRateLimitMiddleware());
811
905
  app.use(createCliApiScopeMiddleware(options.disableUserAuth));
812
906
  app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
813
907
  app.get("/api/cli/session", (request, response) => {
@@ -916,6 +1010,8 @@ export function createRuntime(options) {
916
1010
  app.patch("/api/users/:userId", (request, response) => {
917
1011
  if (!request.authUser)
918
1012
  throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1013
+ if (request.authUser.role !== "admin")
1014
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
919
1015
  const updated = auth.updateUser(request.authUser, request.params.userId, parse(userUpdateSchema, request.body));
920
1016
  store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
921
1017
  data(response, updated);
@@ -1780,7 +1876,8 @@ export function createRuntime(options) {
1780
1876
  page: request.query.page ?? "1",
1781
1877
  limit: request.query.limit ?? "20"
1782
1878
  }) ?? { page: 1, limit: 20, offset: 0 };
1783
- data(response, store.listAiConversationsPage(request.params.workId, pagination));
1879
+ const permissions = requestPermissions(request, request.params.workId);
1880
+ data(response, mapRecords(store.listAiConversationsPage(request.params.workId, pagination), (conversation) => (redactAiConversation(conversation, permissions))));
1784
1881
  });
1785
1882
  app.post("/api/works/:workId/ai-conversations", (request, response) => {
1786
1883
  const input = parse(z.object({ title: z.string().max(200).optional() }), request.body ?? {});
@@ -1788,11 +1885,17 @@ export function createRuntime(options) {
1788
1885
  });
1789
1886
  app.get("/api/ai-conversations/:conversationId", (request, response) => {
1790
1887
  const pagination = parsePagination(request.query);
1791
- data(response, pagination ? store.getAiConversationPage(request.params.conversationId, pagination) : store.getAiConversation(request.params.conversationId));
1888
+ const conversation = pagination
1889
+ ? store.getAiConversationPage(request.params.conversationId, pagination)
1890
+ : store.getAiConversation(request.params.conversationId);
1891
+ const permissions = requestPermissions(request, String(conversation.workId));
1892
+ data(response, redactAiConversation(conversation, permissions));
1792
1893
  });
1793
1894
  app.post("/api/ai-conversations/:conversationId/fork", (request, response) => {
1794
1895
  const input = parse(z.object({ messageId: identifier, title: z.string().max(200).optional() }), request.body);
1795
- data(response, store.forkAiConversation(request.params.conversationId, input.messageId, input.title), 201);
1896
+ const forked = store.forkAiConversation(request.params.conversationId, input.messageId, input.title);
1897
+ const permissions = requestPermissions(request, String(forked.workId));
1898
+ data(response, redactAiConversation(forked, permissions), 201);
1796
1899
  });
1797
1900
  app.post("/api/ai-conversations/:conversationId/messages", (request, response) => {
1798
1901
  const input = parse(z.object({
@@ -1809,7 +1912,10 @@ export function createRuntime(options) {
1809
1912
  processSteps: z.array(aiProcessStepSchema).max(50).optional()
1810
1913
  }).optional()
1811
1914
  }), request.body);
1812
- data(response, store.addAiConversationMessage(request.params.conversationId, input), 201);
1915
+ const message = store.addAiConversationMessage(request.params.conversationId, input);
1916
+ const conversation = store.getAiConversationSummary(request.params.conversationId);
1917
+ const permissions = requestPermissions(request, String(conversation.workId));
1918
+ data(response, redactAiConversationMessage(message, permissions), 201);
1813
1919
  });
1814
1920
  app.post("/api/ai-conversations/:conversationId/context/prepare", async (request, response) => {
1815
1921
  const input = parse(z.object({
@@ -1891,7 +1997,10 @@ export function createRuntime(options) {
1891
1997
  app.get("/api/works/:workId/suggestions", (request, response) => {
1892
1998
  const status = typeof request.query.status === "string" ? request.query.status : undefined;
1893
1999
  const pagination = parsePagination(request.query);
1894
- data(response, pagination ? ai.listSuggestionsPage(request.params.workId, pagination, status) : ai.listSuggestions(request.params.workId, status));
2000
+ const permissions = requestPermissions(request, request.params.workId);
2001
+ data(response, pagination
2002
+ ? mapRecords(ai.listSuggestionsPage(request.params.workId, pagination, status), (suggestion) => redactSuggestion(suggestion, permissions))
2003
+ : ai.listSuggestions(request.params.workId, status).map((suggestion) => redactSuggestion(suggestion, permissions)));
1895
2004
  });
1896
2005
  app.post("/api/works/:workId/suggestions", async (request, response) => {
1897
2006
  const input = parse(z.object({
@@ -1907,14 +2016,14 @@ export function createRuntime(options) {
1907
2016
  if (store.getChapter(citation.chapterId).workId !== request.params.workId)
1908
2017
  throw new AppError(400, "CITATION_WORK_MISMATCH", "引用章节不属于当前作品");
1909
2018
  }
1910
- data(response, await ai.createSuggestion({
2019
+ data(response, redactSuggestion(await ai.createSuggestion({
1911
2020
  workId: request.params.workId,
1912
2021
  taskType: input.taskType,
1913
2022
  instruction: instructionWithCitations(input.instruction, citations),
1914
2023
  scope: input.scope,
1915
2024
  ...(input.modelId ? { modelId: input.modelId } : {}),
1916
2025
  ...(input.parameters ? { parameters: input.parameters } : {})
1917
- }), 201);
2026
+ }), requestPermissions(request, request.params.workId)), 201);
1918
2027
  });
1919
2028
  app.post("/api/works/:workId/chat/stream", async (request, response) => {
1920
2029
  const input = parse(z.object({
@@ -1955,6 +2064,7 @@ export function createRuntime(options) {
1955
2064
  throw new AppError(400, "CONVERSATION_WORK_MISMATCH", "AI 对话不属于当前作品");
1956
2065
  }
1957
2066
  const conversationId = String(conversation.id);
2067
+ const permissions = requestPermissions(request, request.params.workId);
1958
2068
  const prepared = await ai.prepareConversationContext({
1959
2069
  conversationId,
1960
2070
  workId: request.params.workId,
@@ -1965,10 +2075,10 @@ export function createRuntime(options) {
1965
2075
  });
1966
2076
  sendEvent("context", {
1967
2077
  ...prepared,
1968
- conversation: {
2078
+ conversation: redactAiConversation({
1969
2079
  ...store.getAiConversationSummary(conversationId),
1970
2080
  contextWarningPending: prepared.action === "warn"
1971
- }
2081
+ }, permissions)
1972
2082
  });
1973
2083
  if (prepared.action === "warn")
1974
2084
  return;
@@ -1981,7 +2091,7 @@ export function createRuntime(options) {
1981
2091
  });
1982
2092
  const currentMessageId = input.currentMessageId ?? String(userMessage?.id ?? "");
1983
2093
  if (userMessage)
1984
- sendEvent("user_message", { message: userMessage });
2094
+ sendEvent("user_message", { message: redactAiConversationMessage(userMessage, permissions) });
1985
2095
  const suggestion = await ai.createStreamingChat({
1986
2096
  workId: request.params.workId,
1987
2097
  instruction: instructionWithCitations(input.instruction, citations),
@@ -2019,20 +2129,11 @@ export function createRuntime(options) {
2019
2129
  }
2020
2130
  catch (error) {
2021
2131
  if (!controller.signal.aborted) {
2022
- const details = error instanceof AppError && error.details && typeof error.details === "object" && !Array.isArray(error.details)
2023
- ? error.details
2024
- : null;
2025
- sendEvent("error", {
2026
- code: error instanceof AppError ? error.code : "AI_STREAM_FAILED",
2027
- message: error instanceof Error ? error.message : "AI 流式调用失败",
2028
- ...(error instanceof AppError ? { status: error.status } : {}),
2029
- ...(typeof details?.failure === "string" ? { failure: details.failure } : {}),
2030
- ...(typeof details?.callId === "string" ? { callId: details.callId } : {}),
2031
- ...(typeof details?.providerName === "string" ? { providerName: details.providerName } : {}),
2032
- ...(typeof details?.providerId === "string" ? { providerId: details.providerId } : {}),
2033
- ...(typeof details?.modelId === "string" ? { modelId: details.modelId } : {}),
2034
- ...(typeof details?.modelRecordId === "string" ? { modelRecordId: details.modelRecordId } : {})
2132
+ logger.error("ai.stream.failed", {
2133
+ workId: request.params.workId,
2134
+ error: sanitizeError(error)
2035
2135
  });
2136
+ sendEvent("error", publicAiStreamError(error));
2036
2137
  }
2037
2138
  }
2038
2139
  finally {
@@ -2040,22 +2141,34 @@ export function createRuntime(options) {
2040
2141
  response.end();
2041
2142
  }
2042
2143
  });
2043
- app.get("/api/suggestions/:suggestionId", (request, response) => data(response, ai.getSuggestion(request.params.suggestionId)));
2144
+ app.get("/api/suggestions/:suggestionId", (request, response) => {
2145
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2146
+ const permissions = requestPermissions(request, String(suggestion.workId));
2147
+ data(response, redactSuggestion(suggestion, permissions));
2148
+ });
2044
2149
  app.get("/api/suggestions/:suggestionId/guards", (request, response) => {
2045
2150
  const pagination = parsePagination(request.query);
2046
- data(response, pagination
2151
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2152
+ const permissions = requestPermissions(request, String(suggestion.workId));
2153
+ data(response, mapRecords(pagination
2047
2154
  ? store.listContinuationGuardsPage(request.params.suggestionId, pagination)
2048
- : store.listContinuationGuards(request.params.suggestionId));
2155
+ : store.listContinuationGuards(request.params.suggestionId), (guard) => redactContinuationGuard(guard, permissions)));
2049
2156
  });
2050
2157
  app.post("/api/suggestions/:suggestionId/guard", async (request, response) => {
2051
2158
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
2052
- data(response, await ai.runSuggestionGuard(request.params.suggestionId, input.content), 201);
2159
+ const suggestion = ai.getSuggestion(request.params.suggestionId);
2160
+ const permissions = requestPermissions(request, String(suggestion.workId));
2161
+ data(response, redactContinuationGuard(await ai.runSuggestionGuard(request.params.suggestionId, input.content), permissions), 201);
2053
2162
  });
2054
2163
  app.post("/api/suggestions/:suggestionId/accept", (request, response) => {
2055
2164
  const input = parse(z.object({ content: z.string().max(2_000_000).optional() }), request.body ?? {});
2056
2165
  data(response, ai.acceptSuggestion(request.params.suggestionId, input.content));
2057
2166
  });
2058
- app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
2167
+ app.post("/api/suggestions/:suggestionId/reject", (request, response) => {
2168
+ const suggestion = ai.rejectSuggestion(request.params.suggestionId);
2169
+ const permissions = requestPermissions(request, String(suggestion.workId));
2170
+ data(response, redactSuggestion(suggestion, permissions));
2171
+ });
2059
2172
  app.get("/api/works/:workId/ai-calls", (request, response) => {
2060
2173
  const pagination = parsePagination(request.query);
2061
2174
  const permissions = requestPermissions(request, request.params.workId);
@@ -2072,7 +2185,7 @@ export function createRuntime(options) {
2072
2185
  data(response, await ai.searchWork(request.params.workId, query.q, { type: query.type, limit: query.limit }));
2073
2186
  });
2074
2187
  app.get("/api/works/:workId/export", async (request, response) => {
2075
- const format = parse(z.enum(["json", "txt", "markdown"]), request.query.format ?? "json");
2188
+ const format = parse(z.enum(["json", "txt", "markdown", "docx"]), request.query.format ?? "json");
2076
2189
  if (format === "json") {
2077
2190
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.json`);
2078
2191
  data(response, store.exportWork(request.params.workId));
@@ -2092,6 +2205,12 @@ export function createRuntime(options) {
2092
2205
  }), response);
2093
2206
  return;
2094
2207
  }
2208
+ if (format === "docx") {
2209
+ response.type("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
2210
+ response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.docx`);
2211
+ response.send(await store.exportDocx(request.params.workId));
2212
+ return;
2213
+ }
2095
2214
  response.type("text/plain");
2096
2215
  response.setHeader("Content-Disposition", `attachment; filename=novel-${request.params.workId}.txt`);
2097
2216
  response.send(store.exportText(request.params.workId, format));
@@ -2151,7 +2270,7 @@ export function createRuntime(options) {
2151
2270
  setHeaders: setStaticCacheControl
2152
2271
  }));
2153
2272
  app.get("/{*path}", (request, response, next) => {
2154
- if (request.path.startsWith("/api/"))
2273
+ if (normalizeApiPath(request.path).startsWith("/api/"))
2155
2274
  return next();
2156
2275
  sendIndexHtml(request, response);
2157
2276
  });