@musnows/scriverse 0.8.7 → 0.9.0

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/user-auth.js CHANGED
@@ -25,8 +25,10 @@ export function relationshipAnalysisReadModules(scope) {
25
25
  }
26
26
  return [...modules];
27
27
  }
28
- const sessionCookieName = "scriverse_session";
29
- const sessionLifetimeMs = 30 * 24 * 60 * 60_000;
28
+ export const SESSION_COOKIE_NAME = "scriverse_session";
29
+ export const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
30
+ export const DESKTOP_SESSION_TOKEN_PREFIX = "scrvd_";
31
+ export const DESKTOP_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
30
32
  const apiKeyPrefix = "scrv_";
31
33
  function membershipAccessRole(row) {
32
34
  const role = String(row?.role ?? "");
@@ -72,30 +74,41 @@ function parseCookies(header) {
72
74
  }
73
75
  return result;
74
76
  }
77
+ function bearerCredential(request) {
78
+ const authorization = request.get("authorization");
79
+ if (!authorization?.startsWith("Bearer "))
80
+ return null;
81
+ const token = authorization.slice(7).trim();
82
+ return token.length > 0 && token.length <= 200 ? token : null;
83
+ }
75
84
  function apiKeyCredential(request) {
76
85
  const direct = request.get("x-scriverse-api-key")?.trim();
77
86
  if (direct)
78
87
  return direct;
79
- const authorization = request.get("authorization");
80
- if (!authorization?.startsWith("Bearer "))
81
- return null;
82
- return authorization.slice(7).trim();
88
+ const bearer = bearerCredential(request);
89
+ return bearer?.startsWith(apiKeyPrefix) ? bearer : null;
90
+ }
91
+ function desktopSessionCredential(request) {
92
+ const bearer = bearerCredential(request);
93
+ return bearer?.startsWith(DESKTOP_SESSION_TOKEN_PREFIX) ? bearer : null;
83
94
  }
84
95
  function mapUser(row) {
85
96
  const avatarSha256 = row.avatar_sha256 === null || row.avatar_sha256 === undefined
86
97
  ? null
87
98
  : String(row.avatar_sha256);
99
+ const isSystemAdmin = String(row.role) === "admin";
88
100
  return {
89
101
  userId: String(row.id),
90
102
  username: String(row.username),
91
103
  displayName: String(row.display_name),
92
- role: String(row.role) === "admin" ? "admin" : "user",
104
+ role: isSystemAdmin ? "admin" : "user",
93
105
  status: String(row.status) === "disabled" ? "disabled" : "active",
94
106
  createdAt: String(row.created_at),
95
107
  avatarUrl: avatarSha256
96
108
  ? `/api/user-avatars/${encodeURIComponent(String(row.id))}?v=${encodeURIComponent(avatarSha256)}`
97
109
  : null,
98
- onboardingCompleted: row.onboarding_completed_at !== null && row.onboarding_completed_at !== undefined
110
+ onboardingCompleted: row.onboarding_completed_at !== null && row.onboarding_completed_at !== undefined,
111
+ isSystemAdmin
99
112
  };
100
113
  }
101
114
  function passwordDigest(password, salt) {
@@ -116,6 +129,8 @@ function workIdFromPath(database, pathname) {
116
129
  return null;
117
130
  if (resource === "works" && decoded[3] && decoded[3].toLocaleLowerCase("en-US") !== "import")
118
131
  return decoded[3];
132
+ if (resource === "sync" && decoded[3]?.toLocaleLowerCase("en-US") === "works" && decoded[4])
133
+ return decoded[4];
119
134
  const tableByResource = {
120
135
  volumes: "volumes",
121
136
  chapters: "chapters",
@@ -173,10 +188,20 @@ function workIdFromPath(database, pathname) {
173
188
  }
174
189
  return null;
175
190
  }
191
+ function apiKeyCiphertext(row) {
192
+ const encrypted = String(row?.key_encrypted ?? "");
193
+ const iv = String(row?.key_iv ?? "");
194
+ const tag = String(row?.key_tag ?? "");
195
+ if (!encrypted || !iv || !tag)
196
+ return null;
197
+ return { encrypted, iv, tag };
198
+ }
176
199
  export class UserAuthService {
177
200
  database;
178
- constructor(database) {
201
+ vault;
202
+ constructor(database, vault) {
179
203
  this.database = database;
204
+ this.vault = vault;
180
205
  const revokedAt = new Date().toISOString();
181
206
  const result = this.database.run("UPDATE user_sessions SET revoked_at = ? WHERE revoked_at IS NULL", revokedAt);
182
207
  if (result.changes > 0) {
@@ -204,10 +229,33 @@ export class UserAuthService {
204
229
  const sessionId = randomUUID();
205
230
  const timestamp = new Date();
206
231
  this.database.run(`INSERT INTO user_sessions (id, user_id, token_hash, csrf_token, created_at, expires_at, last_seen_at)
207
- VALUES (?, ?, ?, ?, ?, ?, ?)`, sessionId, userId, sha256(token), csrfToken, timestamp.toISOString(), new Date(timestamp.getTime() + sessionLifetimeMs).toISOString(), timestamp.toISOString());
232
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, sessionId, userId, sha256(token), csrfToken, timestamp.toISOString(), new Date(timestamp.getTime() + SESSION_LIFETIME_MS).toISOString(), timestamp.toISOString());
208
233
  const user = this.getUser(userId);
209
234
  return { token, session: { id: sessionId, user, csrfToken } };
210
235
  }
236
+ createDesktopSession(userId, input) {
237
+ const token = `${DESKTOP_SESSION_TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
238
+ const sessionId = randomUUID();
239
+ const timestamp = new Date();
240
+ const createdAt = timestamp.toISOString();
241
+ const expiresAt = new Date(timestamp.getTime() + DESKTOP_SESSION_LIFETIME_MS).toISOString();
242
+ this.database.run(`UPDATE user_desktop_sessions SET revoked_at = ?
243
+ WHERE desktop_id = ? AND profile_id = ? AND revoked_at IS NULL`, createdAt, input.desktopId, input.profileId);
244
+ this.database.run(`INSERT INTO user_desktop_sessions (
245
+ id, user_id, token_hash, desktop_id, profile_id, client_version, created_at, expires_at, last_seen_at
246
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, sessionId, userId, sha256(token), input.desktopId, input.profileId, input.clientVersion, createdAt, expiresAt, createdAt);
247
+ return {
248
+ token,
249
+ session: {
250
+ id: sessionId,
251
+ user: this.getUser(userId),
252
+ desktopId: input.desktopId,
253
+ profileId: input.profileId,
254
+ clientVersion: input.clientVersion,
255
+ expiresAt
256
+ }
257
+ };
258
+ }
211
259
  register(input) {
212
260
  const normalizedUsername = normalizeUsername(input.username);
213
261
  const timestamp = new Date().toISOString();
@@ -229,9 +277,8 @@ export class UserAuthService {
229
277
  return this.createSession(userId);
230
278
  });
231
279
  }
232
- login(username, password) {
280
+ validateLoginCredentials(username, password) {
233
281
  const normalizedUsername = normalizeUsername(username);
234
- const timestamp = new Date();
235
282
  const row = this.database.get("SELECT * FROM users WHERE normalized_username = ?", normalizedUsername);
236
283
  const fallbackSalt = "invalid-login-salt";
237
284
  const calculated = passwordDigest(password, String(row?.password_salt ?? fallbackSalt));
@@ -245,15 +292,26 @@ export class UserAuthService {
245
292
  logger.warn("auth.login.failed", { reason: "account_disabled", actorRef: accountReference(user.userId) });
246
293
  throw new AppError(403, "ACCOUNT_DISABLED", "该账户已被停用");
247
294
  }
295
+ return { user, normalizedUsername, loginAt: new Date().toISOString() };
296
+ }
297
+ login(username, password) {
298
+ const validated = this.validateLoginCredentials(username, password);
248
299
  return this.database.transaction(() => {
249
- const loginAt = timestamp.toISOString();
250
- this.database.run("DELETE FROM login_attempts WHERE normalized_username = ?", normalizedUsername);
251
- this.database.run("UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?", loginAt, loginAt, user.userId);
252
- return this.createSession(user.userId);
300
+ this.database.run("DELETE FROM login_attempts WHERE normalized_username = ?", validated.normalizedUsername);
301
+ this.database.run("UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?", validated.loginAt, validated.loginAt, validated.user.userId);
302
+ return this.createSession(validated.user.userId);
303
+ });
304
+ }
305
+ loginDesktop(username, password, input) {
306
+ const validated = this.validateLoginCredentials(username, password);
307
+ return this.database.transaction(() => {
308
+ this.database.run("DELETE FROM login_attempts WHERE normalized_username = ?", validated.normalizedUsername);
309
+ this.database.run("UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?", validated.loginAt, validated.loginAt, validated.user.userId);
310
+ return this.createDesktopSession(validated.user.userId, input);
253
311
  });
254
312
  }
255
313
  authenticate(request) {
256
- const token = parseCookies(request.get("cookie")).get(sessionCookieName);
314
+ const token = parseCookies(request.get("cookie")).get(SESSION_COOKIE_NAME);
257
315
  if (!token)
258
316
  return null;
259
317
  const row = this.database.get(`SELECT session.id AS session_id, session.csrf_token, session.expires_at, session.revoked_at,
@@ -266,6 +324,30 @@ export class UserAuthService {
266
324
  this.database.run("UPDATE user_sessions SET last_seen_at = ? WHERE id = ?", new Date().toISOString(), String(row.session_id));
267
325
  return { id: String(row.session_id), csrfToken: String(row.csrf_token), user };
268
326
  }
327
+ authenticateDesktop(request) {
328
+ const token = desktopSessionCredential(request);
329
+ if (!token || token.length !== DESKTOP_SESSION_TOKEN_PREFIX.length + 43)
330
+ return null;
331
+ const row = this.database.get(`SELECT session.id AS session_id, session.desktop_id, session.profile_id, session.client_version,
332
+ session.expires_at, session.revoked_at, user.*
333
+ FROM user_desktop_sessions session
334
+ JOIN users user ON user.id = session.user_id
335
+ WHERE session.token_hash = ?`, sha256(token));
336
+ if (!row || row.revoked_at || String(row.expires_at) <= new Date().toISOString())
337
+ return null;
338
+ const user = mapUser(row);
339
+ if (user.status !== "active")
340
+ return null;
341
+ this.database.run("UPDATE user_desktop_sessions SET last_seen_at = ? WHERE id = ?", new Date().toISOString(), String(row.session_id));
342
+ return {
343
+ id: String(row.session_id),
344
+ user,
345
+ desktopId: String(row.desktop_id),
346
+ profileId: String(row.profile_id),
347
+ clientVersion: String(row.client_version),
348
+ expiresAt: String(row.expires_at)
349
+ };
350
+ }
269
351
  authenticateApiKey(request) {
270
352
  const key = apiKeyCredential(request);
271
353
  if (!key || !key.startsWith(apiKeyPrefix) || key.length > 200)
@@ -283,18 +365,22 @@ export class UserAuthService {
283
365
  hasApiKeyCredential(request) {
284
366
  return apiKeyCredential(request) !== null;
285
367
  }
368
+ hasDesktopSessionCredential(request) {
369
+ return desktopSessionCredential(request) !== null;
370
+ }
286
371
  getApiKeyStatus(userId) {
287
372
  this.getUser(userId);
288
373
  const row = this.database.get("SELECT * FROM user_api_keys WHERE user_id = ?", userId);
289
374
  if (!row) {
290
- return { configured: false, prefix: null, createdAt: null, rotatedAt: null, lastUsedAt: null };
375
+ return { configured: false, prefix: null, createdAt: null, rotatedAt: null, lastUsedAt: null, copyable: false };
291
376
  }
292
377
  return {
293
378
  configured: true,
294
379
  prefix: String(row.key_prefix),
295
380
  createdAt: String(row.created_at),
296
381
  rotatedAt: String(row.rotated_at),
297
- lastUsedAt: row.last_used_at === null ? null : String(row.last_used_at)
382
+ lastUsedAt: row.last_used_at === null ? null : String(row.last_used_at),
383
+ copyable: apiKeyCiphertext(row) !== null
298
384
  };
299
385
  }
300
386
  resetApiKey(userId) {
@@ -302,18 +388,40 @@ export class UserAuthService {
302
388
  const apiKey = `${apiKeyPrefix}${randomBytes(32).toString("base64url")}`;
303
389
  const prefix = apiKey.slice(0, 13);
304
390
  const timestamp = new Date().toISOString();
305
- this.database.run(`INSERT INTO user_api_keys (user_id, key_hash, key_prefix, created_at, rotated_at, last_used_at)
306
- VALUES (?, ?, ?, ?, ?, NULL)
391
+ const encrypted = this.vault.encrypt(apiKey);
392
+ this.database.run(`INSERT INTO user_api_keys (user_id, key_hash, key_prefix, created_at, rotated_at, last_used_at, key_encrypted, key_iv, key_tag)
393
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?)
307
394
  ON CONFLICT(user_id) DO UPDATE SET
308
395
  key_hash = excluded.key_hash,
309
396
  key_prefix = excluded.key_prefix,
310
397
  rotated_at = excluded.rotated_at,
311
- last_used_at = NULL`, userId, sha256(apiKey), prefix, timestamp, timestamp);
398
+ last_used_at = NULL,
399
+ key_encrypted = excluded.key_encrypted,
400
+ key_iv = excluded.key_iv,
401
+ key_tag = excluded.key_tag`, userId, sha256(apiKey), prefix, timestamp, timestamp, encrypted.encrypted, encrypted.iv, encrypted.tag);
312
402
  return { ...this.getApiKeyStatus(userId), apiKey };
313
403
  }
404
+ revealApiKey(userId) {
405
+ this.getUser(userId);
406
+ const row = this.database.get("SELECT * FROM user_api_keys WHERE user_id = ?", userId);
407
+ if (!row)
408
+ throw new AppError(404, "API_KEY_NOT_CONFIGURED", "尚未生成 API Key");
409
+ const ciphertext = apiKeyCiphertext(row);
410
+ if (!ciphertext)
411
+ throw new AppError(409, "API_KEY_NOT_RECOVERABLE", "当前 API Key 无法复制,请重置后再试");
412
+ try {
413
+ return { apiKey: this.vault.decrypt(ciphertext), prefix: String(row.key_prefix) };
414
+ }
415
+ catch {
416
+ throw new AppError(409, "API_KEY_NOT_RECOVERABLE", "当前 API Key 无法复制,请重置后再试");
417
+ }
418
+ }
314
419
  revoke(sessionId) {
315
420
  this.database.run("UPDATE user_sessions SET revoked_at = ? WHERE id = ?", new Date().toISOString(), sessionId);
316
421
  }
422
+ revokeDesktop(sessionId) {
423
+ this.database.run("UPDATE user_desktop_sessions SET revoked_at = ? WHERE id = ?", new Date().toISOString(), sessionId);
424
+ }
317
425
  getUser(userId) {
318
426
  const row = this.database.get("SELECT * FROM users WHERE id = ?", userId);
319
427
  if (!row)
@@ -365,7 +473,9 @@ export class UserAuthService {
365
473
  return this.database.transaction(() => {
366
474
  this.database.run("UPDATE users SET role = ?, status = ?, updated_at = ? WHERE id = ?", nextRole, nextStatus, new Date().toISOString(), userId);
367
475
  if (nextStatus === "disabled") {
368
- this.database.run("UPDATE user_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", new Date().toISOString(), userId);
476
+ const revokedAt = new Date().toISOString();
477
+ this.database.run("UPDATE user_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", revokedAt, userId);
478
+ this.database.run("UPDATE user_desktop_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", revokedAt, userId);
369
479
  }
370
480
  return this.getUser(userId);
371
481
  });
@@ -417,7 +527,7 @@ export class UserAuthService {
417
527
  });
418
528
  return this.getUser(userId);
419
529
  }
420
- changePassword(userId, sessionId, currentPassword, newPassword) {
530
+ changePassword(userId, sessionId, currentPassword, newPassword, sessionKind = "browser") {
421
531
  const row = this.database.get("SELECT * FROM users WHERE id = ?", userId);
422
532
  if (!row)
423
533
  throw notFound("用户");
@@ -428,7 +538,10 @@ export class UserAuthService {
428
538
  const timestamp = new Date().toISOString();
429
539
  this.database.transaction(() => {
430
540
  this.database.run("UPDATE users SET password_hash = ?, password_salt = ?, updated_at = ? WHERE id = ?", passwordDigest(newPassword, salt), salt, timestamp, userId);
431
- this.database.run("UPDATE user_sessions SET revoked_at = ? WHERE user_id = ? AND id <> ? AND revoked_at IS NULL", timestamp, userId, sessionId);
541
+ this.database.run(`UPDATE user_sessions SET revoked_at = ?
542
+ WHERE user_id = ? AND (? <> 'browser' OR id <> ?) AND revoked_at IS NULL`, timestamp, userId, sessionKind, sessionId);
543
+ this.database.run(`UPDATE user_desktop_sessions SET revoked_at = ?
544
+ WHERE user_id = ? AND (? <> 'desktop' OR id <> ?) AND revoked_at IS NULL`, timestamp, userId, sessionKind, sessionId);
432
545
  this.database.run("DELETE FROM login_attempts WHERE normalized_username = ?", String(row.normalized_username));
433
546
  });
434
547
  }
@@ -580,16 +693,16 @@ export class UserAuthService {
580
693
  }
581
694
  }
582
695
  export function setSessionCookie(response, token, secure) {
583
- response.cookie(sessionCookieName, token, {
696
+ response.cookie(SESSION_COOKIE_NAME, token, {
584
697
  httpOnly: true,
585
698
  sameSite: "lax",
586
699
  secure,
587
700
  path: "/",
588
- maxAge: sessionLifetimeMs
701
+ maxAge: SESSION_LIFETIME_MS
589
702
  });
590
703
  }
591
704
  export function clearSessionCookie(response, secure) {
592
- response.clearCookie(sessionCookieName, { httpOnly: true, sameSite: "lax", secure, path: "/" });
705
+ response.clearCookie(SESSION_COOKIE_NAME, { httpOnly: true, sameSite: "lax", secure, path: "/" });
593
706
  }
594
707
  export function createUserSessionMiddleware(auth, disabledOrOptions = false) {
595
708
  const options = typeof disabledOrOptions === "boolean"
@@ -605,18 +718,36 @@ export function createUserSessionMiddleware(auth, disabledOrOptions = false) {
605
718
  }
606
719
  return runWithRequestActor(null, next);
607
720
  }
608
- const apiKey = auth.authenticateApiKey(request);
609
- if (auth.hasApiKeyCredential(request) && !apiKey) {
721
+ const apiKeyProvided = auth.hasApiKeyCredential(request);
722
+ const desktopSessionProvided = auth.hasDesktopSessionCredential(request);
723
+ if (apiKeyProvided && desktopSessionProvided) {
724
+ logger.warn("auth.request.rejected", { reason: "conflicting_credentials", method: request.method, path: sanitizeRequestPath(request.path) });
725
+ response.status(400).json({ error: { code: "AUTH_CREDENTIAL_CONFLICT", message: "请求包含相互冲突的身份凭据" } });
726
+ return;
727
+ }
728
+ const apiKey = apiKeyProvided ? auth.authenticateApiKey(request) : null;
729
+ if (apiKeyProvided && !apiKey) {
610
730
  logger.warn("auth.request.rejected", { reason: "invalid_api_key", method: request.method, path: sanitizeRequestPath(request.path) });
611
731
  response.status(401).json({ error: { code: "API_KEY_INVALID", message: "API Key 无效或已失效" } });
612
732
  return;
613
733
  }
614
- const session = apiKey ? null : auth.authenticate(request);
734
+ const desktopSession = desktopSessionProvided ? auth.authenticateDesktop(request) : null;
735
+ if (desktopSessionProvided && !desktopSession) {
736
+ logger.warn("auth.request.rejected", { reason: "invalid_desktop_session", method: request.method, path: sanitizeRequestPath(request.path) });
737
+ response.status(401).json({ error: { code: "DESKTOP_SESSION_INVALID", message: "Desktop 登录已失效,请重新登录" } });
738
+ return;
739
+ }
740
+ const session = apiKey || desktopSession ? null : auth.authenticate(request);
615
741
  if (apiKey) {
616
742
  request.authApiKey = apiKey;
617
743
  request.authUser = apiKey.user;
618
744
  request.authMethod = "api-key";
619
745
  }
746
+ else if (desktopSession) {
747
+ request.authDesktopSession = desktopSession;
748
+ request.authUser = desktopSession.user;
749
+ request.authMethod = "desktop-session";
750
+ }
620
751
  else if (session) {
621
752
  request.authSession = session;
622
753
  request.authUser = session.user;
@@ -627,8 +758,9 @@ export function createUserSessionMiddleware(auth, disabledOrOptions = false) {
627
758
  || path === "/api/auth/session"
628
759
  || (path === "/api/auth/register" && request.method === "POST")
629
760
  || (path === "/api/auth/login" && request.method === "POST")
761
+ || (path === "/api/desktop/auth/login" && request.method === "POST")
630
762
  || !path.startsWith("/api/");
631
- if (!session && !apiKey && !isPublic) {
763
+ if (!session && !desktopSession && !apiKey && !isPublic) {
632
764
  logger.warn("auth.request.rejected", { reason: "authentication_required", method: request.method, path: sanitizeRequestPath(request.path) });
633
765
  response.status(401).json({ error: { code: "AUTH_REQUIRED", message: "请先登录" } });
634
766
  return;
@@ -641,10 +773,11 @@ export function createUserSessionMiddleware(auth, disabledOrOptions = false) {
641
773
  return;
642
774
  }
643
775
  }
644
- const user = apiKey?.user ?? session?.user ?? null;
645
- return runWithRequestActor(user ? { ...user, authentication: apiKey ? "api-key" : "session" } : null, () => {
776
+ const user = apiKey?.user ?? desktopSession?.user ?? session?.user ?? null;
777
+ const authMethod = apiKey ? "api-key" : desktopSession ? "desktop-session" : "session";
778
+ return runWithRequestActor(user ? { ...user, authentication: authMethod } : null, () => {
646
779
  if (user)
647
- logger.debug("auth.request.authenticated", { authMethod: apiKey ? "api-key" : "session" });
780
+ logger.debug("auth.request.authenticated", { authMethod });
648
781
  next();
649
782
  });
650
783
  };
@@ -762,11 +895,37 @@ function globalReplaceWriteModules(request) {
762
895
  return ["prose", "settings"];
763
896
  return [];
764
897
  }
898
+ function syncPushWriteModules(request) {
899
+ const mutations = requestBodyRecord(request).mutations;
900
+ if (!Array.isArray(mutations))
901
+ return [];
902
+ const modules = new Set();
903
+ for (const mutation of mutations) {
904
+ if (!mutation || typeof mutation !== "object" || Array.isArray(mutation))
905
+ continue;
906
+ const entityType = mutation.entityType;
907
+ if (entityType === "chapter")
908
+ modules.add("prose");
909
+ if (entityType === "setting")
910
+ modules.add("settings");
911
+ }
912
+ return [...modules];
913
+ }
765
914
  function workModuleRequirements(request, write, annotationAccess) {
766
915
  const pathname = normalizeApiPath(request.path);
767
916
  const direct = (module, extraWrite = []) => (write ? { write: [module, ...extraWrite] } : { read: [module] });
917
+ if (/^\/api\/sync\/works\/[^/]+\/snapshots$/u.test(pathname))
918
+ return { read: ["prose", "settings"] };
919
+ if (/^\/api\/sync\/works\/[^/]+\/changes$/u.test(pathname))
920
+ return { read: ["prose", "settings"] };
921
+ if (/^\/api\/sync\/works\/[^/]+\/push$/u.test(pathname))
922
+ return { write: syncPushWriteModules(request) };
923
+ if (/^\/api\/sync\/works\/[^/]+\/mutations\/[^/]+$/u.test(pathname))
924
+ return { read: ["prose", "settings"] };
768
925
  if (/^\/api\/works\/[^/]+$/u.test(pathname))
769
926
  return write ? { ownerOnly: true } : {};
927
+ if (/^\/api\/works\/[^/]+\/offline-access$/u.test(pathname))
928
+ return { ownerOnly: true };
770
929
  if (/^\/api\/works\/[^/]+\/cover$/u.test(pathname))
771
930
  return write ? { ownerOnly: true } : {};
772
931
  if (/^\/api\/works\/[^/]+\/members(?:\/[^/]+)?$/u.test(pathname))