artifacty 0.11.1 → 0.13.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/CLAUDE.md +10 -4
- package/README.md +164 -24
- package/SECURITY.md +25 -16
- package/docs/artifact-schema-v1.md +73 -50
- package/docs/central-team-deployment-design.md +56 -52
- package/docs/integrations.md +29 -25
- package/docs/mcp-public-api.md +75 -57
- package/docs/network-sharing.md +8 -8
- package/docs/release-checklist.md +29 -13
- package/docs/roadmap-design.md +145 -133
- package/docs/sarif-csv-artifact-plan.md +25 -26
- package/docs/threat-model.md +121 -100
- package/docs/upgrade-backups.md +89 -0
- package/docs/writing-style.md +56 -0
- package/package.json +5 -3
- package/src/cli.js +78 -3
- package/src/lib/backup.js +19 -6
- package/src/lib/doctor.js +1 -1
- package/src/lib/i18n.js +142 -0
- package/src/lib/network-policy.js +234 -0
- package/src/lib/render.js +142 -28
- package/src/lib/sample-content.js +191 -0
- package/src/lib/samples.js +74 -0
- package/src/lib/share-links.js +136 -0
- package/src/lib/storage.js +458 -45
- package/src/lib/upgrade-backup.js +417 -0
- package/src/mcp-server.js +18 -6
- package/src/server.js +198 -7
package/src/lib/storage.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, s
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { openProtectedDatabase } from "./upgrade-backup.js";
|
|
7
7
|
import { isUnknownSourceAgent, normalizeSourceAgent } from "./agents.js";
|
|
8
8
|
import { assertNoSecrets, normalizeTokenScopes, securityConfig } from "./security.js";
|
|
9
9
|
import { EVENT_TYPES, eventFromAudit, eventHistoryLimit, eventVisibleTo, matchesFilter, publish as publishEvent } from "./events.js";
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
vectorToBlob
|
|
18
18
|
} from "./embeddings.js";
|
|
19
19
|
|
|
20
|
-
export const STORE_VERSION =
|
|
20
|
+
export const STORE_VERSION = 10;
|
|
21
21
|
// Not exported: no caller outside this file reads the constant itself, only
|
|
22
22
|
// the numeric schemaVersion field it seeds on artifacts.
|
|
23
23
|
const ARTIFACT_SCHEMA_VERSION = 1;
|
|
@@ -205,27 +205,258 @@ function userCountSync(db) {
|
|
|
205
205
|
// scope visibility against. Every access check below short-circuits in that
|
|
206
206
|
// case, matching pre-Section-10 behavior.
|
|
207
207
|
function isSingleUserMode(db) {
|
|
208
|
-
return userCountSync(db) === 0;
|
|
208
|
+
return userCountSync(db) === 0 && accessModeInDb(db) !== "private";
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// visible to its own owner or an admin. `prefix` is a table alias including
|
|
214
|
-
// the trailing dot (e.g. "a."), or "" for an unaliased query. The `? != ''`
|
|
215
|
-
// guard keeps an empty/anonymous access.userId from matching rows whose
|
|
216
|
-
// owner_user_id happens to be stored as an empty string.
|
|
211
|
+
// The mode is read from SQLite for every query so toggles and membership
|
|
212
|
+
// revocations also affect existing sessions and long-running clients.
|
|
217
213
|
export function visibilityClause(prefix, access) {
|
|
218
|
-
if (!access) {
|
|
219
|
-
return { sql: "", params: [] };
|
|
220
|
-
}
|
|
214
|
+
if (!access) return { sql: "", params: [] };
|
|
221
215
|
const userId = access.userId || "";
|
|
222
216
|
const isAdmin = access.role === "admin" ? 1 : 0;
|
|
223
217
|
return {
|
|
224
|
-
sql: `(
|
|
225
|
-
|
|
218
|
+
sql: `((COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') != 'private'
|
|
219
|
+
AND ${prefix}visibility != 'private')
|
|
220
|
+
OR (? != '' AND ${prefix}owner_user_id = ?) OR ? = 1
|
|
221
|
+
OR (COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') = 'private'
|
|
222
|
+
AND ? != '' AND EXISTS (SELECT 1 FROM artifact_shares s
|
|
223
|
+
WHERE s.artifact_id = ${prefix}id AND
|
|
224
|
+
(s.user_id = ? OR s.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)))))`,
|
|
225
|
+
params: [userId, userId, isAdmin, userId, userId, userId]
|
|
226
226
|
};
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
function accessModeInDb(db) {
|
|
230
|
+
return db.prepare("SELECT value FROM meta WHERE key = 'access_mode'").get()?.value === "private" ? "private" : "public";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function getAccessMode(store = createStore()) {
|
|
234
|
+
const db = openDatabase(store);
|
|
235
|
+
try { return accessModeInDb(db); } finally { db.close(); }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function assertAccessAdmin(access) {
|
|
239
|
+
if (access && access.role !== "admin") throw new ForbiddenError("Administrator access required");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function setAccessMode(store = createStore(), mode, { access, audit } = {}) {
|
|
243
|
+
assertAccessAdmin(access);
|
|
244
|
+
if (!["public", "private"].includes(mode)) {
|
|
245
|
+
throw Object.assign(new Error("Access mode must be public or private"), { statusCode: 400 });
|
|
246
|
+
}
|
|
247
|
+
const db = openDatabase(store);
|
|
248
|
+
try {
|
|
249
|
+
transaction(db, () => {
|
|
250
|
+
if (mode === "private" && !db.prepare("SELECT id FROM users WHERE active = 1 AND role = 'admin' LIMIT 1").get()) {
|
|
251
|
+
throw Object.assign(new Error("Create an active administrator before enabling private mode"), { statusCode: 400 });
|
|
252
|
+
}
|
|
253
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('access_mode', ?)").run(mode);
|
|
254
|
+
insertAuditRecord(db, { action: "access-mode-change", artifactId: "", audit, metadata: { mode } });
|
|
255
|
+
});
|
|
256
|
+
return mode;
|
|
257
|
+
} finally { db.close(); }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeShareIds(value, label) {
|
|
261
|
+
if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || !id)) {
|
|
262
|
+
throw Object.assign(new Error(`${label} must be an array of ids`), { statusCode: 400 });
|
|
263
|
+
}
|
|
264
|
+
return [...new Set(value)];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function assertActiveUsers(db, userIds) {
|
|
268
|
+
for (const id of userIds) {
|
|
269
|
+
if (!db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(id)) {
|
|
270
|
+
throw Object.assign(new Error(`Unknown or inactive user: ${id}`), { statusCode: 400 });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function teamsInDb(db) {
|
|
276
|
+
return db.prepare("SELECT id, name FROM teams ORDER BY name, id").all().map((team) => ({
|
|
277
|
+
...team, memberIds: db.prepare("SELECT user_id FROM team_members WHERE team_id = ? ORDER BY user_id").all(team.id).map((row) => row.user_id)
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function listTeams(store = createStore()) {
|
|
282
|
+
const db = openDatabase(store);
|
|
283
|
+
try { return teamsInDb(db); } finally { db.close(); }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function createTeam(store = createStore(), { name } = {}, { access, audit } = {}) {
|
|
287
|
+
assertAccessAdmin(access);
|
|
288
|
+
name = typeof name === "string" ? name.trim() : "";
|
|
289
|
+
if (!name || name.length > 200) throw Object.assign(new Error("Team name must contain 1 to 200 characters"), { statusCode: 400 });
|
|
290
|
+
const db = openDatabase(store);
|
|
291
|
+
try {
|
|
292
|
+
const id = randomUUID();
|
|
293
|
+
transaction(db, () => {
|
|
294
|
+
if (db.prepare("SELECT id FROM teams WHERE name = ? COLLATE NOCASE").get(name)) {
|
|
295
|
+
throw Object.assign(new Error("Team name already exists"), { statusCode: 409 });
|
|
296
|
+
}
|
|
297
|
+
db.prepare("INSERT INTO teams (id, name) VALUES (?, ?)").run(id, name);
|
|
298
|
+
insertAuditRecord(db, { action: "team-create", artifactId: "", audit, metadata: { teamId: id, name } });
|
|
299
|
+
});
|
|
300
|
+
return { id, name, memberIds: [] };
|
|
301
|
+
} finally { db.close(); }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function setTeamMembers(store = createStore(), teamId, userIds, { access, audit } = {}) {
|
|
305
|
+
assertAccessAdmin(access);
|
|
306
|
+
audit = { ...audit, userId: access?.userId || audit?.userId };
|
|
307
|
+
userIds = normalizeShareIds(userIds, "userIds");
|
|
308
|
+
const db = openDatabase(store);
|
|
309
|
+
try {
|
|
310
|
+
transaction(db, () => {
|
|
311
|
+
if (!db.prepare("SELECT id FROM teams WHERE id = ?").get(teamId)) throw Object.assign(new Error("Team not found"), { statusCode: 404 });
|
|
312
|
+
assertActiveUsers(db, userIds);
|
|
313
|
+
const previousMembers = new Set(db.prepare("SELECT user_id FROM team_members WHERE team_id = ?").all(teamId).map((row) => row.user_id));
|
|
314
|
+
db.prepare("DELETE FROM team_members WHERE team_id = ?").run(teamId);
|
|
315
|
+
const insert = db.prepare("INSERT INTO team_members (team_id, user_id) VALUES (?, ?)");
|
|
316
|
+
for (const userId of userIds) insert.run(teamId, userId);
|
|
317
|
+
insertAuditRecord(db, { action: "team-members-change", artifactId: "", audit, metadata: { teamId, userIds, addedUserIds: userIds.filter((id) => !previousMembers.has(id)) } });
|
|
318
|
+
});
|
|
319
|
+
return teamsInDb(db).find((team) => team.id === teamId);
|
|
320
|
+
} finally { db.close(); }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function sharingInDb(db, id, includePermissions = false) {
|
|
324
|
+
const rows = db.prepare("SELECT user_id, team_id, permission FROM artifact_shares WHERE artifact_id = ? ORDER BY user_id, team_id").all(id);
|
|
325
|
+
const sharing = { userIds: rows.filter((row) => row.user_id).map((row) => row.user_id), teamIds: rows.filter((row) => row.team_id).map((row) => row.team_id) };
|
|
326
|
+
if (includePermissions) {
|
|
327
|
+
sharing.userPermissions = Object.fromEntries(rows.filter((row) => row.user_id).map((row) => [row.user_id, row.permission]));
|
|
328
|
+
sharing.teamPermissions = Object.fromEntries(rows.filter((row) => row.team_id).map((row) => [row.team_id, row.permission]));
|
|
329
|
+
}
|
|
330
|
+
return sharing;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function getArtifactSharing(store = createStore(), id, { access, includePermissions = false } = {}) {
|
|
334
|
+
const db = openDatabase(store);
|
|
335
|
+
try {
|
|
336
|
+
const artifact = findArtifactById(db, id);
|
|
337
|
+
assertArtifactReadable(db, artifact, access);
|
|
338
|
+
assertArtifactManageable(db, artifact, access);
|
|
339
|
+
return sharingInDb(db, id, includePermissions);
|
|
340
|
+
} finally { db.close(); }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function setArtifactSharing(store = createStore(), id, { userIds = [], teamIds = [], userPermissions = {}, teamPermissions = {} } = {}, { access, audit } = {}) {
|
|
344
|
+
userIds = normalizeShareIds(userIds, "userIds");
|
|
345
|
+
teamIds = normalizeShareIds(teamIds, "teamIds");
|
|
346
|
+
validatePermissionMap(userPermissions, userIds);
|
|
347
|
+
validatePermissionMap(teamPermissions, teamIds);
|
|
348
|
+
audit = { ...audit, userId: access?.userId || audit?.userId };
|
|
349
|
+
const db = openDatabase(store);
|
|
350
|
+
try {
|
|
351
|
+
transaction(db, () => {
|
|
352
|
+
const artifact = findArtifactById(db, id);
|
|
353
|
+
assertArtifactReadable(db, artifact, access);
|
|
354
|
+
assertArtifactManageable(db, artifact, access);
|
|
355
|
+
assertActiveUsers(db, userIds);
|
|
356
|
+
for (const teamId of teamIds) {
|
|
357
|
+
if (!db.prepare("SELECT id FROM teams WHERE id = ?").get(teamId)) throw Object.assign(new Error(`Unknown team: ${teamId}`), { statusCode: 400 });
|
|
358
|
+
}
|
|
359
|
+
const previousRecipients = new Set(sharedRecipientIds(db, id));
|
|
360
|
+
db.prepare("DELETE FROM artifact_shares WHERE artifact_id = ?").run(id);
|
|
361
|
+
const insert = db.prepare("INSERT INTO artifact_shares (artifact_id, user_id, team_id, permission) VALUES (?, ?, ?, ?)");
|
|
362
|
+
for (const userId of userIds) insert.run(id, userId, null, userPermissions[userId] || "read");
|
|
363
|
+
for (const teamId of teamIds) insert.run(id, null, teamId, teamPermissions[teamId] || "read");
|
|
364
|
+
const addedUserIds = sharedRecipientIds(db, id).filter((userId) => !previousRecipients.has(userId));
|
|
365
|
+
insertAuditRecord(db, { action: "artifact-sharing-change", artifactId: id, audit, metadata: { userIds, teamIds, userPermissions, teamPermissions, addedUserIds } });
|
|
366
|
+
});
|
|
367
|
+
return sharingInDb(db, id);
|
|
368
|
+
} finally { db.close(); }
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const SHARE_PERMISSIONS = ["read", "comment", "edit"];
|
|
372
|
+
|
|
373
|
+
function validatePermissionMap(permissions, selectedIds) {
|
|
374
|
+
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)
|
|
375
|
+
|| Object.entries(permissions).some(([id, level]) => !selectedIds.includes(id) || !SHARE_PERMISSIONS.includes(level))) {
|
|
376
|
+
throw Object.assign(new Error("Permissions must map selected user or team ids to read, comment, or edit"), { statusCode: 400 });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function sharedRecipientIds(db, artifactId) {
|
|
381
|
+
return db.prepare(`SELECT user_id FROM artifact_shares WHERE artifact_id = ? AND user_id IS NOT NULL
|
|
382
|
+
UNION SELECT m.user_id FROM team_members m JOIN artifact_shares s ON s.team_id = m.team_id WHERE s.artifact_id = ?`)
|
|
383
|
+
.all(artifactId, artifactId).map((row) => row.user_id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function artifactPermissionInDb(db, artifact, access) {
|
|
387
|
+
if (!access) return "admin";
|
|
388
|
+
if (isSingleUserMode(db)) return "owner";
|
|
389
|
+
if (access.userId && !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(access.userId)) return "none";
|
|
390
|
+
if (access.role === "admin") return "admin";
|
|
391
|
+
if (access.userId && access.userId === artifact.ownerUserId) return "owner";
|
|
392
|
+
if (accessModeInDb(db) === "private") {
|
|
393
|
+
if (!access.userId) return "none";
|
|
394
|
+
const rows = db.prepare(`SELECT permission FROM artifact_shares WHERE artifact_id = ?
|
|
395
|
+
AND (user_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))`)
|
|
396
|
+
.all(artifact.id, access.userId, access.userId);
|
|
397
|
+
return rows.reduce((level, row) => SHARE_PERMISSIONS.indexOf(row.permission) > SHARE_PERMISSIONS.indexOf(level) ? row.permission : level, "none");
|
|
398
|
+
}
|
|
399
|
+
if (!canReadArtifactMeta(db, artifact, access)) return "none";
|
|
400
|
+
return canWriteArtifactMeta(db, artifact, access) ? "edit" : "read";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function getArtifactPermission(store = createStore(), id, { access } = {}) {
|
|
404
|
+
const db = openDatabase(store);
|
|
405
|
+
try { return artifactPermissionInDb(db, findArtifactById(db, id), access); }
|
|
406
|
+
finally { db.close(); }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function assertArtifactEditable(db, artifact, access) {
|
|
410
|
+
if (!["edit", "owner", "admin"].includes(artifactPermissionInDb(db, artifact, access))) {
|
|
411
|
+
throw new ForbiddenError(`Not permitted to modify artifact: ${artifact.id}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function assertArtifactCommentable(db, artifact, access) {
|
|
416
|
+
if (!["comment", "edit", "owner", "admin"].includes(artifactPermissionInDb(db, artifact, access))) {
|
|
417
|
+
throw new ForbiddenError(`Not permitted to comment on artifact: ${artifact.id}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function getArtifactAccessReport(store = createStore(), id, { access } = {}) {
|
|
422
|
+
const db = openDatabase(store);
|
|
423
|
+
try {
|
|
424
|
+
const artifact = findArtifactById(db, id);
|
|
425
|
+
assertArtifactReadable(db, artifact, access);
|
|
426
|
+
assertArtifactManageable(db, artifact, access);
|
|
427
|
+
const mode = accessModeInDb(db);
|
|
428
|
+
const users = db.prepare("SELECT id, name, email, active, role FROM users ORDER BY name, email").all().map((user) => {
|
|
429
|
+
const context = { userId: user.id, role: user.role };
|
|
430
|
+
const permission = artifactPermissionInDb(db, artifact, context);
|
|
431
|
+
const reasons = [];
|
|
432
|
+
if (!user.active) reasons.push("inactive");
|
|
433
|
+
else if (user.role === "admin") reasons.push("admin");
|
|
434
|
+
else if (user.id === artifact.ownerUserId) reasons.push("owner");
|
|
435
|
+
else if (mode === "public") reasons.push(permission === "none" ? "private-artifact" : "public-default");
|
|
436
|
+
else {
|
|
437
|
+
for (const row of db.prepare(`SELECT s.user_id, s.permission, t.name FROM artifact_shares s
|
|
438
|
+
LEFT JOIN teams t ON t.id = s.team_id WHERE s.artifact_id = ? AND
|
|
439
|
+
(s.user_id = ? OR s.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))`).all(id, user.id, user.id)) {
|
|
440
|
+
reasons.push(row.user_id ? `direct:${row.permission}` : `team:${row.name}:${row.permission}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { id: user.id, name: user.name, email: user.email, active: Boolean(user.active), permission, reasons };
|
|
444
|
+
});
|
|
445
|
+
return { mode, anonymousRead: canReadArtifactMeta(db, artifact, { anonymous: true }), users };
|
|
446
|
+
} finally { db.close(); }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function previewAccessMode(store = createStore(), mode, { access } = {}) {
|
|
450
|
+
assertAccessAdmin(access);
|
|
451
|
+
if (!["public", "private"].includes(mode)) throw Object.assign(new Error("Access mode must be public or private"), { statusCode: 400 });
|
|
452
|
+
const db = openDatabase(store);
|
|
453
|
+
try {
|
|
454
|
+
const count = mode === "private" ? 0 : db.prepare(`SELECT COUNT(*) AS count FROM artifacts
|
|
455
|
+
WHERE visibility != 'private' OR (SELECT COUNT(*) FROM users) = 0`).get().count;
|
|
456
|
+
return { mode, anonymousArtifactCount: count };
|
|
457
|
+
} finally { db.close(); }
|
|
458
|
+
}
|
|
459
|
+
|
|
229
460
|
function isOwnerOrAdmin(access, ownerUserId) {
|
|
230
461
|
if (!access) {
|
|
231
462
|
return false;
|
|
@@ -244,6 +475,13 @@ function canReadArtifactMeta(db, artifact, access) {
|
|
|
244
475
|
if (!access || isSingleUserMode(db)) {
|
|
245
476
|
return true;
|
|
246
477
|
}
|
|
478
|
+
if (accessModeInDb(db) === "private") {
|
|
479
|
+
if (isOwnerOrAdmin(access, artifact.ownerUserId)) return true;
|
|
480
|
+
if (!access.userId) return false;
|
|
481
|
+
return Boolean(db.prepare(`SELECT 1 FROM artifact_shares WHERE artifact_id = ?
|
|
482
|
+
AND (user_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) LIMIT 1`)
|
|
483
|
+
.get(artifact.id, access.userId, access.userId));
|
|
484
|
+
}
|
|
247
485
|
if (artifactVisibilityOf(artifact) !== "private") {
|
|
248
486
|
return true;
|
|
249
487
|
}
|
|
@@ -254,7 +492,7 @@ function canWriteArtifactMeta(db, artifact, access) {
|
|
|
254
492
|
if (!access || isSingleUserMode(db)) {
|
|
255
493
|
return true;
|
|
256
494
|
}
|
|
257
|
-
if (artifactVisibilityOf(artifact) === "private") {
|
|
495
|
+
if (accessModeInDb(db) === "private" || artifactVisibilityOf(artifact) === "private") {
|
|
258
496
|
return isOwnerOrAdmin(access, artifact.ownerUserId);
|
|
259
497
|
}
|
|
260
498
|
if (access.anonymous) {
|
|
@@ -366,6 +604,14 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
366
604
|
|
|
367
605
|
let writtenVersionPath;
|
|
368
606
|
try {
|
|
607
|
+
if (input.access && accessModeInDb(db) === "private") {
|
|
608
|
+
if (!input.access.userId || !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(input.access.userId)) {
|
|
609
|
+
throw new ForbiddenError("Private mode requires an active personal account");
|
|
610
|
+
}
|
|
611
|
+
if (input.ownerUserId && input.ownerUserId !== input.access.userId && input.access.role !== "admin") {
|
|
612
|
+
throw new ForbiddenError("Only administrators may create artifacts for another owner");
|
|
613
|
+
}
|
|
614
|
+
}
|
|
369
615
|
let artifact;
|
|
370
616
|
try {
|
|
371
617
|
transaction(db, () => {
|
|
@@ -375,7 +621,7 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
375
621
|
writtenVersionPath = version.path;
|
|
376
622
|
const publisher = publisherFromAudit(input.audit);
|
|
377
623
|
const visibility = normalizeVisibility(input.visibility);
|
|
378
|
-
const ownerUserId = normalizeOptionalString(input.ownerUserId) || publisher.publisherUserId || null;
|
|
624
|
+
const ownerUserId = normalizeOptionalString(input.ownerUserId) || input.access?.userId || publisher.publisherUserId || null;
|
|
379
625
|
|
|
380
626
|
artifact = {
|
|
381
627
|
id,
|
|
@@ -431,6 +677,7 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
431
677
|
}
|
|
432
678
|
|
|
433
679
|
export async function updateArtifact(store = createStore(), id, input = {}) {
|
|
680
|
+
input = { ...input, audit: { ...input.audit, userId: input.access?.userId || input.audit?.userId } };
|
|
434
681
|
const secretScan = assertNoSecrets(input, securityConfig());
|
|
435
682
|
input = withSecretScan(input, secretScan);
|
|
436
683
|
const normalized = normalizeArtifactInput(input, {
|
|
@@ -450,7 +697,8 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
|
|
|
450
697
|
transaction(db, () => {
|
|
451
698
|
artifact = findArtifactById(db, id);
|
|
452
699
|
assertArtifactReadable(db, artifact, input.access);
|
|
453
|
-
|
|
700
|
+
assertArtifactEditable(db, artifact, input.access);
|
|
701
|
+
if (input.relations?.length) assertArtifactWritable(db, artifact, input.access);
|
|
454
702
|
if (expectedVersion !== null && artifact.latestVersion !== expectedVersion) {
|
|
455
703
|
throw new VersionConflictError(artifact.id, artifact.latestVersion);
|
|
456
704
|
}
|
|
@@ -881,6 +1129,9 @@ export async function setArtifactOwner(store = createStore(), id, ownerUserId, o
|
|
|
881
1129
|
const now = new Date().toISOString();
|
|
882
1130
|
const previousOwnerUserId = artifact.ownerUserId;
|
|
883
1131
|
db.prepare("UPDATE artifacts SET owner_user_id = ?, updated_at = ? WHERE id = ?").run(nextOwnerUserId, now, id);
|
|
1132
|
+
if (previousOwnerUserId !== nextOwnerUserId && tableExistsInDb(db, "artifact_share_links")) {
|
|
1133
|
+
db.prepare("UPDATE artifact_share_links SET revoked_at = ? WHERE artifact_id = ? AND revoked_at IS NULL").run(now, id);
|
|
1134
|
+
}
|
|
884
1135
|
artifact.ownerUserId = nextOwnerUserId;
|
|
885
1136
|
artifact.updatedAt = now;
|
|
886
1137
|
insertAuditRecord(db, {
|
|
@@ -1878,6 +2129,7 @@ function maxSavedViewsPerUser() {
|
|
|
1878
2129
|
}
|
|
1879
2130
|
|
|
1880
2131
|
export async function addComment(store = createStore(), id, input = {}) {
|
|
2132
|
+
input = { ...input, audit: { ...input.audit, userId: input.access?.userId || input.audit?.userId } };
|
|
1881
2133
|
const body = normalizeCommentBody(input.body);
|
|
1882
2134
|
const anchor = normalizeCommentAnchor(input.anchor);
|
|
1883
2135
|
const db = openDatabase(store);
|
|
@@ -1886,7 +2138,7 @@ export async function addComment(store = createStore(), id, input = {}) {
|
|
|
1886
2138
|
transaction(db, () => {
|
|
1887
2139
|
const artifact = findArtifactById(db, id);
|
|
1888
2140
|
assertArtifactReadable(db, artifact, input.access);
|
|
1889
|
-
|
|
2141
|
+
assertArtifactCommentable(db, artifact, input.access);
|
|
1890
2142
|
|
|
1891
2143
|
const commentCap = maxCommentsPerArtifact();
|
|
1892
2144
|
const { count: existingComments } = db
|
|
@@ -1941,7 +2193,7 @@ export async function addComment(store = createStore(), id, input = {}) {
|
|
|
1941
2193
|
artifactId: id,
|
|
1942
2194
|
version: versionNumber,
|
|
1943
2195
|
parentId,
|
|
1944
|
-
authorUserId: publisher.publisherUserId || null,
|
|
2196
|
+
authorUserId: input.access?.userId || publisher.publisherUserId || null,
|
|
1945
2197
|
authorLabel,
|
|
1946
2198
|
sourceAgent,
|
|
1947
2199
|
body,
|
|
@@ -2019,7 +2271,7 @@ export async function resolveComment(store = createStore(), id, commentId, optio
|
|
|
2019
2271
|
transaction(db, () => {
|
|
2020
2272
|
const artifact = findArtifactById(db, id);
|
|
2021
2273
|
assertArtifactReadable(db, artifact, options.access);
|
|
2022
|
-
|
|
2274
|
+
assertArtifactCommentable(db, artifact, options.access);
|
|
2023
2275
|
|
|
2024
2276
|
const row = db.prepare(`SELECT * FROM artifact_comments WHERE id = ? AND artifact_id = ?`).get(commentId, id);
|
|
2025
2277
|
if (!row || row.deleted_at) {
|
|
@@ -2029,6 +2281,9 @@ export async function resolveComment(store = createStore(), id, commentId, optio
|
|
|
2029
2281
|
});
|
|
2030
2282
|
}
|
|
2031
2283
|
|
|
2284
|
+
if (artifactPermissionInDb(db, artifact, options.access) === "comment" && row.author_user_id !== options.access?.userId) {
|
|
2285
|
+
throw new ForbiddenError("Comment permission may resolve only your own comments");
|
|
2286
|
+
}
|
|
2032
2287
|
const now = new Date().toISOString();
|
|
2033
2288
|
const publisher = publisherFromAudit(options.audit);
|
|
2034
2289
|
const resolvedBy = publisher.publisherName || publisher.publisherId || null;
|
|
@@ -2089,6 +2344,7 @@ export async function deleteComment(store = createStore(), id, commentId, option
|
|
|
2089
2344
|
}
|
|
2090
2345
|
|
|
2091
2346
|
export async function setReviewStatus(store = createStore(), id, status, options = {}) {
|
|
2347
|
+
options = { ...options, audit: { ...options.audit, userId: options.access?.userId || options.audit?.userId } };
|
|
2092
2348
|
if (!REVIEW_STATUSES.includes(status)) {
|
|
2093
2349
|
throw Object.assign(new Error(`Unsupported review status: ${status}`), {
|
|
2094
2350
|
code: "INVALID_REVIEW_STATUS",
|
|
@@ -2203,9 +2459,26 @@ export async function listAuditEvents(store = createStore(), filters = {}) {
|
|
|
2203
2459
|
const db = openDatabase(store);
|
|
2204
2460
|
try {
|
|
2205
2461
|
const limit = clampInteger(filters.limit, 1, 500, 100);
|
|
2462
|
+
const visibleAudit = (row) => {
|
|
2463
|
+
const event = auditFromRow(row);
|
|
2464
|
+
if (!filters.access || filters.access.role === "admin") return event;
|
|
2465
|
+
if ((event.action === "relation-add" || event.action === "relation-remove") && event.metadata.toId) {
|
|
2466
|
+
const target = db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(event.metadata.toId);
|
|
2467
|
+
if (!target || !canReadArtifactMeta(db, { id: target.id, visibility: target.visibility, ownerUserId: target.owner_user_id }, filters.access)) {
|
|
2468
|
+
event.metadata = { relation: event.metadata.relation, restricted: true };
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
if (event.action === "artifact-sharing-change") {
|
|
2472
|
+
const artifact = db.prepare("SELECT owner_user_id FROM artifacts WHERE id = ?").get(event.artifactId);
|
|
2473
|
+
if (!isOwnerOrAdmin(filters.access, artifact?.owner_user_id)) event.metadata = { restricted: true };
|
|
2474
|
+
}
|
|
2475
|
+
return event;
|
|
2476
|
+
};
|
|
2206
2477
|
const bypassVisibility = !filters.access || isSingleUserMode(db);
|
|
2207
2478
|
const baseVisibility = bypassVisibility ? { sql: "", params: [] } : visibilityClause("a.", filters.access);
|
|
2208
|
-
const
|
|
2479
|
+
const allowMissing = accessModeInDb(db) !== "private" || filters.access?.role === "admin";
|
|
2480
|
+
const auditVisibility = allowMissing ? `(a.id IS NULL OR ${baseVisibility.sql})` : baseVisibility.sql;
|
|
2481
|
+
const visibilityClauseSql = bypassVisibility ? "" : `AND ${auditVisibility}`;
|
|
2209
2482
|
const visibilityParams = bypassVisibility ? [] : baseVisibility.params;
|
|
2210
2483
|
|
|
2211
2484
|
const actionClauses = [];
|
|
@@ -2227,12 +2500,12 @@ export async function listAuditEvents(store = createStore(), filters = {}) {
|
|
|
2227
2500
|
${visibilityClauseSql}
|
|
2228
2501
|
ORDER BY audit_log.created_at DESC
|
|
2229
2502
|
LIMIT ?
|
|
2230
|
-
`).all(filters.artifactId, ...actionParams, ...visibilityParams, limit).map(
|
|
2503
|
+
`).all(filters.artifactId, ...actionParams, ...visibilityParams, limit).map(visibleAudit);
|
|
2231
2504
|
}
|
|
2232
2505
|
|
|
2233
2506
|
const whereParts = [...actionClauses];
|
|
2234
2507
|
if (!bypassVisibility) {
|
|
2235
|
-
whereParts.push(
|
|
2508
|
+
whereParts.push(auditVisibility);
|
|
2236
2509
|
}
|
|
2237
2510
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(" AND ")}` : "";
|
|
2238
2511
|
return db.prepare(`
|
|
@@ -2243,12 +2516,23 @@ export async function listAuditEvents(store = createStore(), filters = {}) {
|
|
|
2243
2516
|
${whereSql}
|
|
2244
2517
|
ORDER BY audit_log.created_at DESC
|
|
2245
2518
|
LIMIT ?
|
|
2246
|
-
`).all(...actionParams, ...visibilityParams, limit).map(
|
|
2519
|
+
`).all(...actionParams, ...visibilityParams, limit).map(visibleAudit);
|
|
2247
2520
|
} finally {
|
|
2248
2521
|
db.close();
|
|
2249
2522
|
}
|
|
2250
2523
|
}
|
|
2251
2524
|
|
|
2525
|
+
export async function canReadArtifactEvent(store = createStore(), event, access = null) {
|
|
2526
|
+
if (!access) return true;
|
|
2527
|
+
const db = openDatabase(store);
|
|
2528
|
+
try {
|
|
2529
|
+
if (access.userId && !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(access.userId)) return false;
|
|
2530
|
+
const row = event.artifactId ? db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(event.artifactId) : null;
|
|
2531
|
+
if (row) return canReadArtifactMeta(db, { id: row.id, visibility: row.visibility, ownerUserId: row.owner_user_id }, access);
|
|
2532
|
+
return accessModeInDb(db) !== "private" && eventVisibleTo(event, access);
|
|
2533
|
+
} finally { db.close(); }
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2252
2536
|
/**
|
|
2253
2537
|
* Replay events with seq > sinceSeq, oldest first, applying `filter`
|
|
2254
2538
|
* ({ type, tag, artifactId, sourceAgent }) and `access` (private-artifact
|
|
@@ -2294,7 +2578,12 @@ export async function listEventsSince(store = createStore(), sinceSeq = 0, filte
|
|
|
2294
2578
|
event.visibility = row.live_visibility;
|
|
2295
2579
|
event.ownerUserId = row.live_owner_user_id || null;
|
|
2296
2580
|
}
|
|
2297
|
-
|
|
2581
|
+
const visible = access && accessModeInDb(db) === "private"
|
|
2582
|
+
? (row.live_visibility != null
|
|
2583
|
+
? canReadArtifactMeta(db, { id: row.artifact_id, visibility: row.live_visibility, ownerUserId: row.live_owner_user_id }, access)
|
|
2584
|
+
: access.role === "admin")
|
|
2585
|
+
: eventVisibleTo(event, access);
|
|
2586
|
+
if (matchesFilter(event, filter) && visible) {
|
|
2298
2587
|
matched.push(event);
|
|
2299
2588
|
if (matched.length >= limit) {
|
|
2300
2589
|
break;
|
|
@@ -2988,7 +3277,11 @@ const FULL_BACKUP_CORE_TABLES = {
|
|
|
2988
3277
|
auditLog: "audit_log",
|
|
2989
3278
|
relations: "artifact_relations",
|
|
2990
3279
|
webhooks: "webhooks",
|
|
2991
|
-
comments: "artifact_comments"
|
|
3280
|
+
comments: "artifact_comments",
|
|
3281
|
+
teams: "teams",
|
|
3282
|
+
teamMembers: "team_members",
|
|
3283
|
+
artifactShares: "artifact_shares",
|
|
3284
|
+
notifications: "notifications"
|
|
2992
3285
|
};
|
|
2993
3286
|
|
|
2994
3287
|
export async function exportFullStoreTables(store = createStore()) {
|
|
@@ -3021,7 +3314,7 @@ export async function exportFullStoreTables(store = createStore()) {
|
|
|
3021
3314
|
createdAt: row.created_at
|
|
3022
3315
|
}));
|
|
3023
3316
|
}
|
|
3024
|
-
tables.meta = db.prepare(`SELECT * FROM meta WHERE key
|
|
3317
|
+
tables.meta = db.prepare(`SELECT * FROM meta WHERE key NOT IN ('store_version', 'network_policy_pending')`).all().map(rowToCamelCase);
|
|
3025
3318
|
return tables;
|
|
3026
3319
|
} finally {
|
|
3027
3320
|
db.close();
|
|
@@ -3076,6 +3369,9 @@ export async function importFullStoreTables(store = createStore(), tables = {},
|
|
|
3076
3369
|
try {
|
|
3077
3370
|
let counts;
|
|
3078
3371
|
transaction(db, () => {
|
|
3372
|
+
const previousAccessMode = accessModeInDb(db);
|
|
3373
|
+
const previousNetworkPolicy = db.prepare("SELECT value FROM meta WHERE key = 'network_policy'").get();
|
|
3374
|
+
const previousShareLinkPolicy = db.prepare("SELECT value FROM meta WHERE key = 'share_link_policy'").get();
|
|
3079
3375
|
const existingUsers = db.prepare("SELECT COUNT(*) AS count FROM users").get().count;
|
|
3080
3376
|
if (existingUsers > 0 && !forceUsers) {
|
|
3081
3377
|
throw Object.assign(new Error("Target store already has users; pass forceUsers to overwrite them"), {
|
|
@@ -3103,7 +3399,13 @@ export async function importFullStoreTables(store = createStore(), tables = {},
|
|
|
3103
3399
|
if (Array.isArray(tables.embeddings)) {
|
|
3104
3400
|
counts.embeddings = replaceEmbeddingRows(db, tables.embeddings);
|
|
3105
3401
|
}
|
|
3402
|
+
for (const [key, table] of [["teams", "teams"], ["teamMembers", "team_members"], ["artifactShares", "artifact_shares"], ["notifications", "notifications"]]) {
|
|
3403
|
+
if (Array.isArray(tables[key])) counts[key] = replaceTableRows(db, table, tables[key]);
|
|
3404
|
+
}
|
|
3106
3405
|
counts.meta = replaceMetaRows(db, tables.meta);
|
|
3406
|
+
if (previousAccessMode === "private") db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('access_mode', 'private')").run();
|
|
3407
|
+
if (previousNetworkPolicy) db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('network_policy', ?)").run(previousNetworkPolicy.value);
|
|
3408
|
+
if (previousShareLinkPolicy) db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('share_link_policy', ?)").run(previousShareLinkPolicy.value);
|
|
3107
3409
|
|
|
3108
3410
|
insertAuditRecord(db, {
|
|
3109
3411
|
action: "backup-import",
|
|
@@ -3162,6 +3464,7 @@ function insertRowsIntoTable(db, table, rows) {
|
|
|
3162
3464
|
const values = columns.map((column) => {
|
|
3163
3465
|
const camelKey = snakeToCamel(column);
|
|
3164
3466
|
const value = row[camelKey] !== undefined ? row[camelKey] : row[column];
|
|
3467
|
+
if (table === "artifact_shares" && column === "permission" && value == null) return "read";
|
|
3165
3468
|
if (typeof value === "boolean") {
|
|
3166
3469
|
return value ? 1 : 0;
|
|
3167
3470
|
}
|
|
@@ -3209,7 +3512,7 @@ function replaceMetaRows(db, rows) {
|
|
|
3209
3512
|
const upsert = db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)");
|
|
3210
3513
|
let count = 0;
|
|
3211
3514
|
for (const row of rows) {
|
|
3212
|
-
if (!row || !row.key || row.key
|
|
3515
|
+
if (!row || !row.key || ["store_version", "network_policy_pending"].includes(row.key)) {
|
|
3213
3516
|
continue;
|
|
3214
3517
|
}
|
|
3215
3518
|
upsert.run(row.key, row.value);
|
|
@@ -3232,11 +3535,10 @@ async function readArtifactVersion(store, artifact, versionNumber, options = {})
|
|
|
3232
3535
|
// Defense in depth only: callers are expected to have already resolved the
|
|
3233
3536
|
// artifact through getArtifact()/access-checked helpers, which know
|
|
3234
3537
|
// whether the store is running in single-user mode.
|
|
3235
|
-
if (options.access
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
});
|
|
3538
|
+
if (options.access) {
|
|
3539
|
+
const db = openDatabase(store);
|
|
3540
|
+
try { assertArtifactReadable(db, findArtifactById(db, artifact.id), options.access); }
|
|
3541
|
+
finally { db.close(); }
|
|
3240
3542
|
}
|
|
3241
3543
|
|
|
3242
3544
|
return {
|
|
@@ -3328,11 +3630,7 @@ export function normalizeArtifactType(value = "document") {
|
|
|
3328
3630
|
// same connection setup (PRAGMAs, schema init/migration) instead of opening
|
|
3329
3631
|
// a second, differently-configured DatabaseSync against the same file.
|
|
3330
3632
|
export function openDatabase(store) {
|
|
3331
|
-
|
|
3332
|
-
mkdirSync(store.artifactsDir, { recursive: true });
|
|
3333
|
-
|
|
3334
|
-
const db = new DatabaseSync(store.dbPath);
|
|
3335
|
-
try {
|
|
3633
|
+
return openProtectedDatabase(store, STORE_VERSION, (db) => {
|
|
3336
3634
|
db.exec(`
|
|
3337
3635
|
PRAGMA journal_mode = WAL;
|
|
3338
3636
|
PRAGMA foreign_keys = ON;
|
|
@@ -3342,13 +3640,7 @@ export function openDatabase(store) {
|
|
|
3342
3640
|
migrateJsonIndex(db, store);
|
|
3343
3641
|
normalizeStoredSourceAgents(db, store);
|
|
3344
3642
|
syncSearchIndexIfEmpty(db, store);
|
|
3345
|
-
}
|
|
3346
|
-
// Never leak an open handle when initialization fails (for example the
|
|
3347
|
-
// store_version guard); on Windows an open handle blocks file removal.
|
|
3348
|
-
db.close();
|
|
3349
|
-
throw error;
|
|
3350
|
-
}
|
|
3351
|
-
return db;
|
|
3643
|
+
});
|
|
3352
3644
|
}
|
|
3353
3645
|
|
|
3354
3646
|
function initializeSchema(db) {
|
|
@@ -3412,6 +3704,38 @@ function initializeSchema(db) {
|
|
|
3412
3704
|
updated_at TEXT NOT NULL
|
|
3413
3705
|
);
|
|
3414
3706
|
|
|
3707
|
+
CREATE TABLE IF NOT EXISTS teams (
|
|
3708
|
+
id TEXT PRIMARY KEY,
|
|
3709
|
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
|
3710
|
+
);
|
|
3711
|
+
|
|
3712
|
+
CREATE TABLE IF NOT EXISTS team_members (
|
|
3713
|
+
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
3714
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
3715
|
+
PRIMARY KEY (team_id, user_id)
|
|
3716
|
+
);
|
|
3717
|
+
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
|
|
3718
|
+
|
|
3719
|
+
CREATE TABLE IF NOT EXISTS artifact_shares (
|
|
3720
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
3721
|
+
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
|
3722
|
+
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
|
3723
|
+
permission TEXT NOT NULL DEFAULT 'read',
|
|
3724
|
+
CHECK ((user_id IS NOT NULL AND team_id IS NULL) OR (user_id IS NULL AND team_id IS NOT NULL)),
|
|
3725
|
+
UNIQUE (artifact_id, user_id),
|
|
3726
|
+
UNIQUE (artifact_id, team_id)
|
|
3727
|
+
);
|
|
3728
|
+
|
|
3729
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
3730
|
+
id TEXT PRIMARY KEY,
|
|
3731
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
3732
|
+
artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
|
|
3733
|
+
type TEXT NOT NULL,
|
|
3734
|
+
created_at TEXT NOT NULL,
|
|
3735
|
+
read_at TEXT
|
|
3736
|
+
);
|
|
3737
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
|
|
3738
|
+
|
|
3415
3739
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
3416
3740
|
id TEXT PRIMARY KEY,
|
|
3417
3741
|
user_id TEXT NOT NULL,
|
|
@@ -3534,6 +3858,7 @@ function initializeSchema(db) {
|
|
|
3534
3858
|
);
|
|
3535
3859
|
}
|
|
3536
3860
|
}
|
|
3861
|
+
ensureColumn(db, "artifact_shares", "permission", "TEXT NOT NULL DEFAULT 'read'");
|
|
3537
3862
|
ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
|
|
3538
3863
|
ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
|
|
3539
3864
|
ensureColumn(db, "artifacts", "archived_at", "TEXT");
|
|
@@ -4287,6 +4612,92 @@ export function insertAuditRow(db, params) {
|
|
|
4287
4612
|
insertAuditRecord(db, params);
|
|
4288
4613
|
}
|
|
4289
4614
|
|
|
4615
|
+
function requireInboxUser(db, access) {
|
|
4616
|
+
if (!access?.userId || !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(access.userId)) {
|
|
4617
|
+
throw new ForbiddenError("A personal account is required for notifications");
|
|
4618
|
+
}
|
|
4619
|
+
return access.userId;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
export async function listNotifications(store = createStore(), { access, unreadOnly = false, limit = 50 } = {}) {
|
|
4623
|
+
const db = openDatabase(store);
|
|
4624
|
+
try {
|
|
4625
|
+
const userId = requireInboxUser(db, access);
|
|
4626
|
+
const visibility = visibilityClause("a.", access);
|
|
4627
|
+
const where = `n.user_id = ? AND ${visibility.sql}`;
|
|
4628
|
+
const params = [userId, ...visibility.params];
|
|
4629
|
+
const unreadCount = db.prepare(`SELECT COUNT(*) AS count FROM notifications n JOIN artifacts a ON a.id = n.artifact_id
|
|
4630
|
+
WHERE ${where} AND n.read_at IS NULL`).get(...params).count;
|
|
4631
|
+
const notifications = db.prepare(`SELECT n.id, n.type, n.artifact_id, a.title, n.created_at, n.read_at
|
|
4632
|
+
FROM notifications n JOIN artifacts a ON a.id = n.artifact_id WHERE ${where}
|
|
4633
|
+
${unreadOnly ? "AND n.read_at IS NULL" : ""} ORDER BY n.created_at DESC, n.rowid DESC LIMIT ?`)
|
|
4634
|
+
.all(...params, clampInteger(limit, 1, 200, 50)).map((row) => ({
|
|
4635
|
+
id: row.id, type: row.type, artifactId: row.artifact_id, title: row.title, createdAt: row.created_at, readAt: row.read_at
|
|
4636
|
+
}));
|
|
4637
|
+
return { notifications, unreadCount };
|
|
4638
|
+
} finally { db.close(); }
|
|
4639
|
+
}
|
|
4640
|
+
|
|
4641
|
+
export async function markNotificationRead(store = createStore(), id, { access } = {}) {
|
|
4642
|
+
const db = openDatabase(store);
|
|
4643
|
+
try {
|
|
4644
|
+
const userId = requireInboxUser(db, access);
|
|
4645
|
+
const row = db.prepare("SELECT artifact_id FROM notifications WHERE id = ? AND user_id = ?").get(id, userId);
|
|
4646
|
+
if (!row) throw Object.assign(new Error("Notification not found"), { statusCode: 404 });
|
|
4647
|
+
assertArtifactReadable(db, findArtifactById(db, row.artifact_id), access);
|
|
4648
|
+
db.prepare("UPDATE notifications SET read_at = COALESCE(read_at, ?) WHERE id = ? AND user_id = ?").run(new Date().toISOString(), id, userId);
|
|
4649
|
+
return { id, readAt: db.prepare("SELECT read_at FROM notifications WHERE id = ?").get(id).read_at };
|
|
4650
|
+
} finally { db.close(); }
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
export async function markAllNotificationsRead(store = createStore(), { access } = {}) {
|
|
4654
|
+
const db = openDatabase(store);
|
|
4655
|
+
try {
|
|
4656
|
+
const userId = requireInboxUser(db, access);
|
|
4657
|
+
const visibility = visibilityClause("a.", access);
|
|
4658
|
+
const result = db.prepare(`UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL
|
|
4659
|
+
AND artifact_id IN (SELECT a.id FROM artifacts a WHERE ${visibility.sql})`)
|
|
4660
|
+
.run(new Date().toISOString(), userId, ...visibility.params);
|
|
4661
|
+
return { updated: result.changes };
|
|
4662
|
+
} finally { db.close(); }
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
function insertNotificationsForAudit(db, { action, artifactId, audit, metadata, createdAt }) {
|
|
4666
|
+
const actorId = audit.userId || audit.publisherUserId || null;
|
|
4667
|
+
const notify = (id, type, recipients) => {
|
|
4668
|
+
const row = db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(id);
|
|
4669
|
+
if (!row) return;
|
|
4670
|
+
const artifact = { id: row.id, visibility: row.visibility, ownerUserId: row.owner_user_id };
|
|
4671
|
+
const insert = db.prepare("INSERT INTO notifications (id, user_id, artifact_id, type, created_at) VALUES (?, ?, ?, ?, ?)");
|
|
4672
|
+
for (const userId of new Set(recipients)) {
|
|
4673
|
+
if (!userId || userId === actorId) continue;
|
|
4674
|
+
const user = db.prepare("SELECT role, email FROM users WHERE id = ? AND active = 1").get(userId);
|
|
4675
|
+
if (!user || user.email === audit.actor || userId === audit.actor || !canReadArtifactMeta(db, artifact, { userId, role: user.role })) continue;
|
|
4676
|
+
insert.run(randomUUID(), userId, id, type, createdAt);
|
|
4677
|
+
}
|
|
4678
|
+
};
|
|
4679
|
+
if (action === "team-members-change") {
|
|
4680
|
+
for (const row of db.prepare("SELECT artifact_id FROM artifact_shares WHERE team_id = ?").all(metadata.teamId)) {
|
|
4681
|
+
notify(row.artifact_id, "shared-with-me", metadata.addedUserIds || []);
|
|
4682
|
+
}
|
|
4683
|
+
return;
|
|
4684
|
+
}
|
|
4685
|
+
if (!artifactId) return;
|
|
4686
|
+
if (action === "artifact-sharing-change") {
|
|
4687
|
+
notify(artifactId, "shared-with-me", metadata.addedUserIds || []);
|
|
4688
|
+
return;
|
|
4689
|
+
}
|
|
4690
|
+
const type = action === "comment-add" ? "comment"
|
|
4691
|
+
: action === "review-status-change" && metadata.to === "pending" && metadata.from !== "pending" ? "review-requested"
|
|
4692
|
+
: action === "update" ? "new-version" : null;
|
|
4693
|
+
if (!type) return;
|
|
4694
|
+
const owner = db.prepare("SELECT owner_user_id FROM artifacts WHERE id = ?").get(artifactId)?.owner_user_id;
|
|
4695
|
+
const commenters = db.prepare("SELECT DISTINCT author_user_id FROM artifact_comments WHERE artifact_id = ? AND deleted_at IS NULL").all(artifactId).map((row) => row.author_user_id);
|
|
4696
|
+
const recipients = [owner, ...sharedRecipientIds(db, artifactId), ...commenters];
|
|
4697
|
+
notify(artifactId, type, recipients);
|
|
4698
|
+
if (action === "update" && metadata.reviewStatusReset) notify(artifactId, "review-requested", recipients);
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4290
4701
|
function insertAuditRecord(db, { action, artifactId, version, sourceAgent, audit = {}, metadata = {}, tags, artifactType }) {
|
|
4291
4702
|
const createdAt = new Date().toISOString();
|
|
4292
4703
|
db.prepare(`
|
|
@@ -4306,6 +4717,8 @@ function insertAuditRecord(db, { action, artifactId, version, sourceAgent, audit
|
|
|
4306
4717
|
JSON.stringify(metadata || {})
|
|
4307
4718
|
);
|
|
4308
4719
|
|
|
4720
|
+
insertNotificationsForAudit(db, { action, artifactId, audit, metadata, createdAt });
|
|
4721
|
+
|
|
4309
4722
|
let eventVisibility = null;
|
|
4310
4723
|
let eventOwnerUserId = null;
|
|
4311
4724
|
if (artifactId) {
|