artifacty 0.11.0 → 0.12.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.
@@ -17,7 +17,7 @@ import {
17
17
  vectorToBlob
18
18
  } from "./embeddings.js";
19
19
 
20
- export const STORE_VERSION = 8;
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
- // Shared SQL visibility predicate, mirroring canReadArtifactMeta: a falsy
212
- // `access` sees everything (empty clause); otherwise a private row is only
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: `(${prefix}visibility != 'private' OR (? != '' AND ${prefix}owner_user_id = ?) OR ? = 1)`,
225
- params: [userId, userId, isAdmin]
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
- assertArtifactWritable(db, artifact, input.access);
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
- assertArtifactWritable(db, artifact, input.access);
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
- assertArtifactWritable(db, artifact, options.access);
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 visibilityClauseSql = bypassVisibility ? "" : `AND (a.id IS NULL OR ${baseVisibility.sql})`;
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(auditFromRow);
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(baseVisibility.sql.startsWith("(") ? `(a.id IS NULL OR ${baseVisibility.sql})` : baseVisibility.sql);
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(auditFromRow);
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
- if (matchesFilter(event, filter) && eventVisibleTo(event, access)) {
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 != 'store_version'`).all().map(rowToCamelCase);
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 === "store_version") {
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 && (artifact.visibility || "team") === "private" && !isOwnerOrAdmin(options.access, artifact.ownerUserId)) {
3236
- throw Object.assign(new Error(`Artifact not found: ${artifact.id}`), {
3237
- code: "ARTIFACT_NOT_FOUND",
3238
- statusCode: 404
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 {
@@ -3412,6 +3714,38 @@ function initializeSchema(db) {
3412
3714
  updated_at TEXT NOT NULL
3413
3715
  );
3414
3716
 
3717
+ CREATE TABLE IF NOT EXISTS teams (
3718
+ id TEXT PRIMARY KEY,
3719
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE
3720
+ );
3721
+
3722
+ CREATE TABLE IF NOT EXISTS team_members (
3723
+ team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
3724
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3725
+ PRIMARY KEY (team_id, user_id)
3726
+ );
3727
+ CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
3728
+
3729
+ CREATE TABLE IF NOT EXISTS artifact_shares (
3730
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
3731
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
3732
+ team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
3733
+ permission TEXT NOT NULL DEFAULT 'read',
3734
+ CHECK ((user_id IS NOT NULL AND team_id IS NULL) OR (user_id IS NULL AND team_id IS NOT NULL)),
3735
+ UNIQUE (artifact_id, user_id),
3736
+ UNIQUE (artifact_id, team_id)
3737
+ );
3738
+
3739
+ CREATE TABLE IF NOT EXISTS notifications (
3740
+ id TEXT PRIMARY KEY,
3741
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3742
+ artifact_id TEXT NOT NULL REFERENCES artifacts(id) ON DELETE CASCADE,
3743
+ type TEXT NOT NULL,
3744
+ created_at TEXT NOT NULL,
3745
+ read_at TEXT
3746
+ );
3747
+ CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
3748
+
3415
3749
  CREATE TABLE IF NOT EXISTS sessions (
3416
3750
  id TEXT PRIMARY KEY,
3417
3751
  user_id TEXT NOT NULL,
@@ -3534,6 +3868,7 @@ function initializeSchema(db) {
3534
3868
  );
3535
3869
  }
3536
3870
  }
3871
+ ensureColumn(db, "artifact_shares", "permission", "TEXT NOT NULL DEFAULT 'read'");
3537
3872
  ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
3538
3873
  ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
3539
3874
  ensureColumn(db, "artifacts", "archived_at", "TEXT");
@@ -4287,6 +4622,92 @@ export function insertAuditRow(db, params) {
4287
4622
  insertAuditRecord(db, params);
4288
4623
  }
4289
4624
 
4625
+ function requireInboxUser(db, access) {
4626
+ if (!access?.userId || !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(access.userId)) {
4627
+ throw new ForbiddenError("A personal account is required for notifications");
4628
+ }
4629
+ return access.userId;
4630
+ }
4631
+
4632
+ export async function listNotifications(store = createStore(), { access, unreadOnly = false, limit = 50 } = {}) {
4633
+ const db = openDatabase(store);
4634
+ try {
4635
+ const userId = requireInboxUser(db, access);
4636
+ const visibility = visibilityClause("a.", access);
4637
+ const where = `n.user_id = ? AND ${visibility.sql}`;
4638
+ const params = [userId, ...visibility.params];
4639
+ const unreadCount = db.prepare(`SELECT COUNT(*) AS count FROM notifications n JOIN artifacts a ON a.id = n.artifact_id
4640
+ WHERE ${where} AND n.read_at IS NULL`).get(...params).count;
4641
+ const notifications = db.prepare(`SELECT n.id, n.type, n.artifact_id, a.title, n.created_at, n.read_at
4642
+ FROM notifications n JOIN artifacts a ON a.id = n.artifact_id WHERE ${where}
4643
+ ${unreadOnly ? "AND n.read_at IS NULL" : ""} ORDER BY n.created_at DESC, n.rowid DESC LIMIT ?`)
4644
+ .all(...params, clampInteger(limit, 1, 200, 50)).map((row) => ({
4645
+ id: row.id, type: row.type, artifactId: row.artifact_id, title: row.title, createdAt: row.created_at, readAt: row.read_at
4646
+ }));
4647
+ return { notifications, unreadCount };
4648
+ } finally { db.close(); }
4649
+ }
4650
+
4651
+ export async function markNotificationRead(store = createStore(), id, { access } = {}) {
4652
+ const db = openDatabase(store);
4653
+ try {
4654
+ const userId = requireInboxUser(db, access);
4655
+ const row = db.prepare("SELECT artifact_id FROM notifications WHERE id = ? AND user_id = ?").get(id, userId);
4656
+ if (!row) throw Object.assign(new Error("Notification not found"), { statusCode: 404 });
4657
+ assertArtifactReadable(db, findArtifactById(db, row.artifact_id), access);
4658
+ db.prepare("UPDATE notifications SET read_at = COALESCE(read_at, ?) WHERE id = ? AND user_id = ?").run(new Date().toISOString(), id, userId);
4659
+ return { id, readAt: db.prepare("SELECT read_at FROM notifications WHERE id = ?").get(id).read_at };
4660
+ } finally { db.close(); }
4661
+ }
4662
+
4663
+ export async function markAllNotificationsRead(store = createStore(), { access } = {}) {
4664
+ const db = openDatabase(store);
4665
+ try {
4666
+ const userId = requireInboxUser(db, access);
4667
+ const visibility = visibilityClause("a.", access);
4668
+ const result = db.prepare(`UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL
4669
+ AND artifact_id IN (SELECT a.id FROM artifacts a WHERE ${visibility.sql})`)
4670
+ .run(new Date().toISOString(), userId, ...visibility.params);
4671
+ return { updated: result.changes };
4672
+ } finally { db.close(); }
4673
+ }
4674
+
4675
+ function insertNotificationsForAudit(db, { action, artifactId, audit, metadata, createdAt }) {
4676
+ const actorId = audit.userId || audit.publisherUserId || null;
4677
+ const notify = (id, type, recipients) => {
4678
+ const row = db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(id);
4679
+ if (!row) return;
4680
+ const artifact = { id: row.id, visibility: row.visibility, ownerUserId: row.owner_user_id };
4681
+ const insert = db.prepare("INSERT INTO notifications (id, user_id, artifact_id, type, created_at) VALUES (?, ?, ?, ?, ?)");
4682
+ for (const userId of new Set(recipients)) {
4683
+ if (!userId || userId === actorId) continue;
4684
+ const user = db.prepare("SELECT role, email FROM users WHERE id = ? AND active = 1").get(userId);
4685
+ if (!user || user.email === audit.actor || userId === audit.actor || !canReadArtifactMeta(db, artifact, { userId, role: user.role })) continue;
4686
+ insert.run(randomUUID(), userId, id, type, createdAt);
4687
+ }
4688
+ };
4689
+ if (action === "team-members-change") {
4690
+ for (const row of db.prepare("SELECT artifact_id FROM artifact_shares WHERE team_id = ?").all(metadata.teamId)) {
4691
+ notify(row.artifact_id, "shared-with-me", metadata.addedUserIds || []);
4692
+ }
4693
+ return;
4694
+ }
4695
+ if (!artifactId) return;
4696
+ if (action === "artifact-sharing-change") {
4697
+ notify(artifactId, "shared-with-me", metadata.addedUserIds || []);
4698
+ return;
4699
+ }
4700
+ const type = action === "comment-add" ? "comment"
4701
+ : action === "review-status-change" && metadata.to === "pending" && metadata.from !== "pending" ? "review-requested"
4702
+ : action === "update" ? "new-version" : null;
4703
+ if (!type) return;
4704
+ const owner = db.prepare("SELECT owner_user_id FROM artifacts WHERE id = ?").get(artifactId)?.owner_user_id;
4705
+ 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);
4706
+ const recipients = [owner, ...sharedRecipientIds(db, artifactId), ...commenters];
4707
+ notify(artifactId, type, recipients);
4708
+ if (action === "update" && metadata.reviewStatusReset) notify(artifactId, "review-requested", recipients);
4709
+ }
4710
+
4290
4711
  function insertAuditRecord(db, { action, artifactId, version, sourceAgent, audit = {}, metadata = {}, tags, artifactType }) {
4291
4712
  const createdAt = new Date().toISOString();
4292
4713
  db.prepare(`
@@ -4306,6 +4727,8 @@ function insertAuditRecord(db, { action, artifactId, version, sourceAgent, audit
4306
4727
  JSON.stringify(metadata || {})
4307
4728
  );
4308
4729
 
4730
+ insertNotificationsForAudit(db, { action, artifactId, audit, metadata, createdAt });
4731
+
4309
4732
  let eventVisibility = null;
4310
4733
  let eventOwnerUserId = null;
4311
4734
  if (artifactId) {