@musnows/scriverse 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -113,8 +113,8 @@ Run `scriverse --help` for all local server, default server, authentication, wor
113
113
  | `APP_AUTH_PASSWORD` | Empty | Optional deployment gateway password, at least 12 characters; must be transported over HTTPS |
114
114
  | `APP_TRUST_PROXY` | `false` | Set to the trusted proxy hop count (usually `1`) or `true` behind a trusted reverse proxy |
115
115
  | `APP_ALLOW_PRIVATE_AI_ENDPOINTS` | `true` in development, `false` in production | In production, AI provider URLs are validated and loopback, private-network, link-local, and reserved ranges are blocked by default. Setting `true`/`1` disables that validation for a trusted transparent-proxy or fake-IP deployment and writes a startup warning |
116
- | `APP_ALLOW_REGISTRATION` | `false` | Registration is enabled only when explicitly set to `true`; unset and all other values stay closed, including first-admin setup |
117
- | `APP_SETUP_TOKEN` | Empty | Required when registration is enabled and must contain at least 32 characters; only the first administrator must enter it |
116
+ | `APP_ALLOW_REGISTRATION` | `false` | Three mutually exclusive levels: unset, `false`, `0`, or any other value disables registration; `invite` requires a one-time invite code; `true` or `1` opens registration. Invite-only and open registration both require a setup token |
117
+ | `APP_SETUP_TOKEN` | Empty | Required when registration is open or invite-only and must contain at least 32 characters; only the first administrator must enter it |
118
118
 
119
119
  `APP_ALLOW_PRIVATE_AI_ENDPOINTS` disables AI provider URL SSRF validation and should be enabled only for a trusted deployment that must reach a provider through a transparent proxy or fake-IP mapping. Unset production deployments keep validating and blocking loopback, private-network, link-local, and reserved ranges; when enabled, those addresses no longer fail provider connection tests and startup writes a warning log. Remote MCP server URLs retain independent SSRF validation.
120
120
 
@@ -138,7 +138,7 @@ APP_AUTH_PASSWORD='replace-with-a-long-random-password' \
138
138
  npm start
139
139
  ```
140
140
 
141
- Production deployments must use HTTPS at a trusted reverse proxy. For first-time setup, set `APP_ALLOW_REGISTRATION=true` and configure `APP_SETUP_TOKEN` with at least 32 random characters. The first registered user must enter that token and becomes the system administrator. Afterwards, remove both variables or disable registration and restart the service. Later ordinary registrations do not require the setup token. Optional HTTP Basic Auth is only an additional deployment gateway, and its credentials are merely Base64 encoded. `/api/health` remains public for health checks, while business APIs require an in-app login.
141
+ Production deployments must use HTTPS at a trusted reverse proxy. For first-time setup, set `APP_ALLOW_REGISTRATION` to `true` or `invite` and configure `APP_SETUP_TOKEN` with at least 32 random characters. The first registered user must enter that token and becomes the system administrator. Afterwards, set the switch to `false` to close registration, or to `invite` so later users must present a one-time invite code generated by a system administrator. Fully open registration no longer requires the setup token. Optional HTTP Basic Auth is only an additional deployment gateway, and its credentials are merely Base64 encoded. `/api/health` remains public for health checks, while business APIs require an in-app login.
142
142
 
143
143
  ## AI Provider Setup
144
144
 
package/README.md CHANGED
@@ -122,8 +122,8 @@ CLI 会按服务器保存登录凭据。所有连接服务的数据命令都可
122
122
  | `APP_AUTH_PASSWORD` | 空 | 可选的部署网关密码,至少 12 个字符;必须通过 HTTPS 传输 |
123
123
  | `APP_TRUST_PROXY` | `false` | 位于可信反向代理后时设为代理跳数(通常为 `1`)或 `true` |
124
124
  | `APP_ALLOW_PRIVATE_AI_ENDPOINTS` | 开发环境 `true`,生产环境 `false` | 生产环境默认校验 AI 供应商地址并拦截本机、内网、链路本地和保留网段。显式设为 `true`/`1` 后关闭该校验,适用于受信任的透明代理或 fake-ip 部署;启动时会打印警告 |
125
- | `APP_ALLOW_REGISTRATION` | `false` | 仅明确设为 `true` 或 `1` 时开放注册;未设置或其他值均关闭,首次初始化创建管理员也必须显式开启 |
126
- | `APP_SETUP_TOKEN` | 空 | 开放注册时必填且至少 32 个字符;仅首位管理员注册需要在页面输入 |
125
+ | `APP_ALLOW_REGISTRATION` | `false` | 三个互斥等级:未设置、`false`、`0` 或其他值表示彻底禁止注册;`invite` 表示需要一次性邀请码才能注册;`true` 或 `1` 表示完全开放注册。`invite` 与开放注册时都必须配置初始化令牌 |
126
+ | `APP_SETUP_TOKEN` | 空 | 开放或邀请码注册时必填且至少 32 个字符;仅首位管理员注册需要在页面输入 |
127
127
  | `SCRIVERSE_AVATAR_IMAGE_MAX_BYTES` | `2097152` | 头像图片上传大小上限,单位为字节 |
128
128
  | `SCRIVERSE_COVER_IMAGE_MAX_BYTES` | `5242880` | 作品封面图片上传大小上限,单位为字节;封面不支持 GIF |
129
129
  | `SCRIVERSE_ATTACHMENT_IMAGE_MAX_BYTES` | `31457280` | 设定库等其他图片附件上传大小上限,单位为字节;包括 GIF |
@@ -155,7 +155,7 @@ APP_AUTH_PASSWORD='请替换为足够长的随机密码' \
155
155
  npm start
156
156
  ```
157
157
 
158
- 生产环境必须在可信反向代理后启用 HTTPS。首次初始化时,将 `APP_ALLOW_REGISTRATION` 设为 `true`,并为 `APP_SETUP_TOKEN` 配置至少 32 个字符的随机值;创建的第一个用户会自动成为系统管理员,且必须在页面输入该令牌。完成后应删除这两个环境变量或关闭注册并重启服务。后续添加普通用户只需临时开放注册,不再要求初始化令牌。可选的 HTTP Basic Auth 仅作为额外部署网关,其凭据只是 Base64 编码,未使用 HTTPS 时不能防止链路窃听。`/api/health` 保持免认证以供探活,业务 API 需要应用内登录。
158
+ 生产环境必须在可信反向代理后启用 HTTPS。首次初始化时,将 `APP_ALLOW_REGISTRATION` 设为 `true` 或 `invite`,并为 `APP_SETUP_TOKEN` 配置至少 32 个字符的随机值;创建的第一个用户会自动成为系统管理员,且必须在页面输入该令牌。完成后可将注册改为 `false` 彻底关闭,或改为 `invite` 仅允许持有一次性邀请码的用户注册。后续完全开放注册时不再要求初始化令牌;邀请码模式下普通注册必须填写系统管理员生成的一次性邀请码。可选的 HTTP Basic Auth 仅作为额外部署网关,其凭据只是 Base64 编码,未使用 HTTPS 时不能防止链路窃听。`/api/health` 保持免认证以供探活,业务 API 需要应用内登录。
159
159
 
160
160
  ## AI 供应商配置
161
161
 
package/dist/app.js CHANGED
@@ -39,7 +39,7 @@ import { composeRoleplayStoredUserContent } from "./roleplay-turn.js";
39
39
  import { ROLEPLAY_MEMORY_CATEGORIES, ROLEPLAY_MEMORY_CERTAINTY, ROLEPLAY_MEMORY_IMPORTANCE, ROLEPLAY_MEMORY_STATUSES } from "./roleplay-memory.js";
40
40
  import { paginated, parsePagination } from "./pagination.js";
41
41
  import { normalizeUploadFileName } from "./utils.js";
42
- import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, normalizeApiPath, resolveTrustProxySetting, verifySetupToken } from "./security.js";
42
+ import { assertSafeAiEndpoint, assertSafeS3Endpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createCaptchaRateLimitMiddleware, createExpensiveApiRateLimitMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, enforceCaseInsensitiveRouting, isRegistrationEnabled, normalizeApiPath, resolveRegistrationMode, resolveTrustProxySetting, verifySetupToken } from "./security.js";
43
43
  import { ImageCaptchaService } from "./image-captcha.js";
44
44
  import { ImService } from "./im.js";
45
45
  import { ImOrchestrator } from "./im-orchestrator.js";
@@ -146,6 +146,7 @@ const registrationSchema = z.object({
146
146
  password: passwordSchema,
147
147
  passwordConfirmation: passwordSchema,
148
148
  setupToken: z.string().max(500).optional(),
149
+ inviteCode: z.string().trim().max(32).optional(),
149
150
  ...captchaFields
150
151
  }).strict().refine((input) => input.password === input.passwordConfirmation, {
151
152
  path: ["passwordConfirmation"],
@@ -164,6 +165,20 @@ const desktopLoginSchema = z.object({
164
165
  clientVersion: z.string().trim().min(1).max(80),
165
166
  ...captchaFields
166
167
  }).strict();
168
+ const desktopRegistrationSchema = z.object({
169
+ username: usernameSchema,
170
+ password: passwordSchema,
171
+ passwordConfirmation: passwordSchema,
172
+ setupToken: z.string().max(500).optional(),
173
+ inviteCode: z.string().trim().max(32).optional(),
174
+ desktopId: z.string().uuid(),
175
+ profileId: z.string().uuid(),
176
+ clientVersion: z.string().trim().min(1).max(80),
177
+ ...captchaFields
178
+ }).strict().refine((input) => input.password === input.passwordConfirmation, {
179
+ path: ["passwordConfirmation"],
180
+ message: "两次输入的密码不一致"
181
+ });
167
182
  const userUpdateSchema = z.object({ role: z.enum(["admin", "user"]).optional(), status: z.enum(["active", "disabled"]).optional() }).strict();
168
183
  const memberRoleValueSchema = z.enum(["editor", "settings-editor", "viewer"]);
169
184
  const moduleAccessSchema = z.enum(["none", "read", "write"]);
@@ -1502,15 +1517,38 @@ export function createRuntime(options) {
1502
1517
  app.use(createSameOriginMiddleware());
1503
1518
  app.use("/api/sync/works", express.json({ limit: "3mb" }));
1504
1519
  app.use(express.json({ limit: "2mb" }));
1520
+ const registrationMode = resolveRegistrationMode(options.security);
1521
+ const registrationEnabled = isRegistrationEnabled(registrationMode);
1522
+ const registrationPolicy = (setupRequired) => ({
1523
+ registrationMode,
1524
+ registrationOpen: registrationEnabled,
1525
+ inviteRequired: registrationMode === "invite" && !setupRequired
1526
+ });
1527
+ const resolvedInviteCode = (inviteCode, setupRequired) => {
1528
+ if (registrationMode !== "invite" || setupRequired)
1529
+ return undefined;
1530
+ const trimmed = inviteCode?.trim() ?? "";
1531
+ if (!trimmed)
1532
+ throw new AppError(400, "INVITE_CODE_REQUIRED", "当前部署需要邀请码才能注册");
1533
+ return trimmed;
1534
+ };
1505
1535
  app.get("/api/auth/session", (request, response) => {
1506
1536
  const desktopSession = auth.authenticateDesktop(request);
1507
1537
  const session = desktopSession ? null : auth.authenticate(request);
1508
- const registrationOpen = options.security?.allowRegistration === true;
1509
1538
  const setupRequired = !auth.hasUsers();
1510
1539
  const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
1540
+ const policy = registrationPolicy(setupRequired);
1511
1541
  const developmentUser = getDevelopmentUser();
1512
1542
  if (!session && developmentUser) {
1513
- data(response, { authenticated: true, user: developmentUser, csrfToken: null, bootId, setupRequired: false, setupTokenRequired: false, registrationOpen });
1543
+ data(response, {
1544
+ authenticated: true,
1545
+ user: developmentUser,
1546
+ csrfToken: null,
1547
+ bootId,
1548
+ setupRequired: false,
1549
+ setupTokenRequired: false,
1550
+ ...registrationPolicy(false)
1551
+ });
1514
1552
  return;
1515
1553
  }
1516
1554
  const interactiveSession = desktopSession ?? session;
@@ -1522,25 +1560,33 @@ export function createRuntime(options) {
1522
1560
  bootId,
1523
1561
  setupRequired: false,
1524
1562
  setupTokenRequired: false,
1525
- registrationOpen
1563
+ ...registrationPolicy(false)
1526
1564
  }
1527
- : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, registrationOpen });
1565
+ : { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired, ...policy });
1528
1566
  });
1529
1567
  app.get("/api/auth/captcha", (_request, response) => {
1530
1568
  data(response, captcha.create());
1531
1569
  });
1532
1570
  app.post("/api/auth/register", (request, response) => {
1533
- if (options.security?.allowRegistration !== true) {
1571
+ if (!registrationEnabled) {
1534
1572
  throw new AppError(403, "REGISTRATION_DISABLED", "当前部署已关闭新用户注册");
1535
1573
  }
1536
1574
  const input = parse(registrationSchema, request.body);
1537
1575
  captcha.consume(input.captchaId, input.captchaAnswer);
1538
- if (!auth.hasUsers() && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
1576
+ const setupRequired = !auth.hasUsers();
1577
+ if (setupRequired && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
1539
1578
  throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
1540
1579
  }
1541
- const result = auth.register({ username: input.username, password: input.password });
1580
+ const result = auth.register({
1581
+ username: input.username,
1582
+ password: input.password,
1583
+ inviteCode: resolvedInviteCode(input.inviteCode, setupRequired)
1584
+ });
1542
1585
  setSessionCookie(response, result.token, request.secure);
1543
- runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
1586
+ runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, {
1587
+ role: result.session.user.role,
1588
+ ...(result.inviteId ? { inviteId: result.inviteId } : {})
1589
+ }));
1544
1590
  logger.info("auth.registration.succeeded", { actorRef: accountReference(result.session.user.userId) });
1545
1591
  data(response, { user: result.session.user, csrfToken: result.session.csrfToken }, 201);
1546
1592
  });
@@ -1567,6 +1613,34 @@ export function createRuntime(options) {
1567
1613
  logger.info("auth.desktop_login.succeeded", { actorRef: accountReference(result.session.user.userId) });
1568
1614
  data(response, { token: result.token, expiresAt: result.session.expiresAt, user: result.session.user });
1569
1615
  });
1616
+ app.post("/api/desktop/auth/register", (request, response) => {
1617
+ if (!registrationEnabled) {
1618
+ throw new AppError(403, "REGISTRATION_DISABLED", "当前部署已关闭新用户注册");
1619
+ }
1620
+ const input = parse(desktopRegistrationSchema, request.body);
1621
+ captcha.consume(input.captchaId, input.captchaAnswer);
1622
+ const setupRequired = !auth.hasUsers();
1623
+ if (setupRequired && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
1624
+ throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
1625
+ }
1626
+ const result = auth.registerDesktop({
1627
+ username: input.username,
1628
+ password: input.password,
1629
+ inviteCode: resolvedInviteCode(input.inviteCode, setupRequired),
1630
+ desktopId: input.desktopId,
1631
+ profileId: input.profileId,
1632
+ clientVersion: input.clientVersion
1633
+ });
1634
+ imOrchestrator.disconnectUser(result.session.user.userId);
1635
+ response.setHeader("Cache-Control", "no-store");
1636
+ runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, {
1637
+ role: result.session.user.role,
1638
+ source: "desktop",
1639
+ ...(result.inviteId ? { inviteId: result.inviteId } : {})
1640
+ }));
1641
+ logger.info("auth.desktop_registration.succeeded", { actorRef: accountReference(result.session.user.userId) });
1642
+ data(response, { token: result.token, expiresAt: result.session.expiresAt, user: result.session.user }, 201);
1643
+ });
1570
1644
  app.use(createUserSessionMiddleware(auth, {
1571
1645
  disabled: options.disableUserAuth === true,
1572
1646
  resolveBypassUser: getDevelopmentUser
@@ -1714,6 +1788,28 @@ export function createRuntime(options) {
1714
1788
  store.audit(null, "user.updated", "user", updated.userId, { role: updated.role, status: updated.status });
1715
1789
  data(response, updated);
1716
1790
  });
1791
+ app.get("/api/registration-invites", (request, response) => {
1792
+ if (!request.authUser)
1793
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1794
+ if (request.authUser.role !== "admin")
1795
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
1796
+ data(response, {
1797
+ registrationMode,
1798
+ registrationOpen: registrationEnabled,
1799
+ items: auth.listRegistrationInvites()
1800
+ });
1801
+ });
1802
+ app.post("/api/registration-invites", (request, response) => {
1803
+ if (!request.authUser)
1804
+ throw new AppError(401, "AUTH_REQUIRED", "请先登录");
1805
+ if (request.authUser.role !== "admin")
1806
+ throw new AppError(403, "ADMIN_REQUIRED", "该操作仅限系统管理员");
1807
+ parse(z.object({}).strict(), request.body ?? {});
1808
+ const created = auth.createRegistrationInvite(request.authUser);
1809
+ store.audit(null, "registration-invite.created", "registration-invite", created.id, {});
1810
+ logger.info("auth.registration_invite.created", { actorRef: accountReference(request.authUser.userId) });
1811
+ data(response, created, 201);
1812
+ });
1717
1813
  const requireImUser = (request) => {
1718
1814
  if (!request.authUser || !hasInteractiveSession(request)) {
1719
1815
  throw new AppError(401, "SESSION_REQUIRED", "请使用交互式会话访问 IM");