@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 +3 -3
- package/README.md +3 -3
- package/dist/app.js +105 -9
- package/dist/app.js.map +1 -1
- package/dist/database.js +29 -2
- package/dist/database.js.map +1 -1
- package/dist/public/ai-interactive.js +21 -1
- package/dist/public/app.js +143 -39
- package/dist/public/chapter-editor-scroll.d.ts +37 -0
- package/dist/public/chapter-editor-scroll.js +52 -0
- package/dist/public/index.html +22 -8
- package/dist/public/styles.css +47 -7
- package/dist/security.js +22 -3
- package/dist/security.js.map +1 -1
- package/dist/store.js +6 -14
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +125 -15
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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` |
|
|
117
|
-
| `APP_SETUP_TOKEN` | Empty | Required when registration is
|
|
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
|
|
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` |
|
|
126
|
-
| `APP_SETUP_TOKEN` | 空 |
|
|
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
|
|
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, {
|
|
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
|
-
|
|
1563
|
+
...registrationPolicy(false)
|
|
1526
1564
|
}
|
|
1527
|
-
: { authenticated: false, user: null, csrfToken: null, bootId, setupRequired, setupTokenRequired,
|
|
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 (
|
|
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
|
-
|
|
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({
|
|
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, {
|
|
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");
|