artifacty 0.15.0 → 0.16.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.
@@ -0,0 +1,206 @@
1
+ import net from "node:net";
2
+ import tls from "node:tls";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ const ERRORS = {
6
+ SMTP_CONFIG_INVALID: "SMTP configuration or message is invalid.",
7
+ SMTP_CONNECT_FAILED: "Could not connect to the SMTP server.",
8
+ SMTP_TLS_FAILED: "Could not establish a verified TLS connection to the SMTP server.",
9
+ SMTP_AUTH_FAILED: "SMTP authentication failed.",
10
+ SMTP_PROTOCOL_ERROR: "The SMTP server returned an invalid or unexpected response.",
11
+ SMTP_REJECTED: "The SMTP server rejected the message.",
12
+ SMTP_TIMEOUT: "The SMTP operation timed out.",
13
+ SMTP_DELIVERY_UNKNOWN: "The SMTP connection ended before message acceptance could be confirmed. Delivery may have occurred."
14
+ };
15
+ const MAX_REPLY_BYTES = 64 * 1024;
16
+
17
+ function smtpError(code) {
18
+ return Object.assign(new Error(ERRORS[code]), { code, ...(code === "SMTP_DELIVERY_UNKNOWN" ? { deliveryUnknown: true } : {}) });
19
+ }
20
+
21
+ function mailbox(value) {
22
+ if (typeof value !== "string" || value.length > 254 || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+$/.test(value)) return false;
23
+ const [local, domain] = value.split("@");
24
+ return local.length <= 64 && !local.startsWith(".") && !local.endsWith(".") && !local.includes("..") &&
25
+ domain.split(".").every((label) => label.length > 0 && label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
26
+ }
27
+
28
+ function validate(config, message) {
29
+ const bad = () => { throw smtpError("SMTP_CONFIG_INVALID"); };
30
+ if (!config || !message || typeof config.host !== "string" || !config.host || config.host.length > 253 ||
31
+ (!net.isIP(config.host) && !config.host.split(".").every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label))) ||
32
+ !Number.isInteger(config.port) || config.port < 1 || config.port > 65535 || !["tls", "starttls"].includes(config.tlsMode)) bad();
33
+ if (!mailbox(config.from) || !mailbox(message.to)) bad();
34
+ for (const value of [config.username ?? "", config.password ?? "", message.subject]) {
35
+ if (typeof value !== "string" || Buffer.byteLength(value) > 4096 || /[\x00-\x1f\x7f]/.test(value)) bad();
36
+ }
37
+ if (Boolean(config.username) !== Boolean(config.password) || typeof message.text !== "string" || Buffer.byteLength(message.text) > 1024 * 1024) bad();
38
+ }
39
+
40
+ function encodedSubject(subject) {
41
+ if (!subject) return "";
42
+ const chunks = [];
43
+ let current = "";
44
+ for (const character of subject) {
45
+ if (Buffer.byteLength(current + character) > 42) { chunks.push(current); current = ""; }
46
+ current += character;
47
+ }
48
+ chunks.push(current);
49
+ return chunks.map((chunk) => `=?UTF-8?B?${Buffer.from(chunk).toString("base64")}?=`).join("\r\n ");
50
+ }
51
+
52
+ function encodeMessage(config, { to, subject, text }) {
53
+ const encoded = Buffer.from(text.replace(/\r\n|\r|\n/g, "\r\n"), "utf8").toString("base64");
54
+ const body = encoded.match(/.{1,76}/g)?.join("\r\n") || "";
55
+ return [
56
+ `From: ${config.from}`, `To: ${to}`, `Subject: ${encodedSubject(subject)}`,
57
+ `Date: ${new Date().toUTCString()}`, `Message-ID: <${randomUUID()}@${config.from.split("@")[1]}>`,
58
+ "MIME-Version: 1.0", 'Content-Type: text/plain; charset="UTF-8"', "Content-Transfer-Encoding: base64", "", body
59
+ ].join("\r\n").replace(/^\./gm, "..") + "\r\n.\r\n";
60
+ }
61
+
62
+ // One bounded response parser per transport; STARTTLS discards the plaintext
63
+ // parser and its capabilities before sending a fresh EHLO over verified TLS.
64
+ function replies(socket) {
65
+ let buffer = "";
66
+ let current = null;
67
+ let failed = null;
68
+ let pending = null;
69
+ const queued = [];
70
+ function fail(error = smtpError("SMTP_CONNECT_FAILED")) {
71
+ if (failed) return;
72
+ failed = error;
73
+ if (pending) { pending.reject(error); pending = null; }
74
+ }
75
+ function onData(data) {
76
+ buffer += data.toString("latin1");
77
+ if (buffer.length + (current?.bytes || 0) > MAX_REPLY_BYTES) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
78
+ let end;
79
+ while ((end = buffer.indexOf("\r\n")) >= 0) {
80
+ const line = buffer.slice(0, end);
81
+ buffer = buffer.slice(end + 2);
82
+ const match = /^([2-5]\d{2})([ -])([^\r\n]*)$/.exec(line);
83
+ if (!match || (current && current.code !== Number(match[1]))) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
84
+ current ||= { code: Number(match[1]), lines: [], bytes: 0 };
85
+ current.lines.push(match[3]);
86
+ current.bytes += line.length + 2;
87
+ if (match[2] === " ") {
88
+ const reply = current;
89
+ current = null;
90
+ if (pending) { pending.resolve(reply); pending = null; }
91
+ else queued.push(reply);
92
+ if (queued.length > 8) { fail(smtpError("SMTP_PROTOCOL_ERROR")); socket.destroy(); return; }
93
+ }
94
+ }
95
+ }
96
+ const onError = () => fail();
97
+ const onClose = () => fail();
98
+ socket.on("data", onData);
99
+ socket.on("error", onError);
100
+ socket.on("close", onClose);
101
+ return {
102
+ next() {
103
+ if (failed) return Promise.reject(failed);
104
+ if (queued.length) return Promise.resolve(queued.shift());
105
+ return new Promise((resolve, reject) => { pending = { resolve, reject }; });
106
+ },
107
+ detach() {
108
+ if (buffer || current || queued.length || pending) throw smtpError("SMTP_PROTOCOL_ERROR");
109
+ socket.off("data", onData); socket.off("error", onError); socket.off("close", onClose);
110
+ },
111
+ fail
112
+ };
113
+ }
114
+
115
+ /** Send one message, without retries. options.ca is solely an explicit caller
116
+ * trust override for isolated tests; production settings must not expose it. */
117
+ export async function sendSmtpMail(config, message, options = {}) {
118
+ validate(config, message);
119
+ const timeoutMs = Math.min(15000, options.timeoutMs ?? 15000);
120
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) throw smtpError("SMTP_CONFIG_INVALID");
121
+ let socket;
122
+ let reader;
123
+ let timer;
124
+ let deliveryStarted = false;
125
+ let accepted = false;
126
+ let timeoutReject;
127
+ const timeout = new Promise((_, reject) => { timeoutReject = reject; });
128
+ timer = setTimeout(() => {
129
+ const error = smtpError(deliveryStarted ? "SMTP_DELIVERY_UNKNOWN" : "SMTP_TIMEOUT");
130
+ timeoutReject(error);
131
+ reader?.fail(error);
132
+ socket?.destroy();
133
+ }, timeoutMs);
134
+ const bounded = async (operation) => {
135
+ let commandTimer;
136
+ try {
137
+ return await Promise.race([operation, timeout, new Promise((_, reject) => {
138
+ commandTimer = setTimeout(() => reject(smtpError(deliveryStarted ? "SMTP_DELIVERY_UNKNOWN" : "SMTP_TIMEOUT")), Math.min(timeoutMs, 5000));
139
+ })]);
140
+ } finally { clearTimeout(commandTimer); }
141
+ };
142
+ const connect = async (secure, underlying) => {
143
+ socket = secure ? tls.connect({
144
+ ...(underlying ? { socket: underlying } : { port: config.port }),
145
+ host: config.host, servername: net.isIP(config.host) ? undefined : config.host,
146
+ rejectUnauthorized: true, minVersion: "TLSv1.2", ...(options.ca ? { ca: options.ca } : {}),
147
+ checkServerIdentity: (_hostname, certificate) => tls.checkServerIdentity(config.host, certificate)
148
+ }) : net.connect({ host: config.host, port: config.port });
149
+ reader = replies(socket);
150
+ await bounded(new Promise((resolve, reject) => {
151
+ const event = secure ? "secureConnect" : "connect";
152
+ const onError = () => { socket.off(event, onReady); reject(smtpError(secure ? "SMTP_TLS_FAILED" : "SMTP_CONNECT_FAILED")); };
153
+ const onReady = () => { socket.off("error", onError); resolve(); };
154
+ socket.once(event, onReady); socket.once("error", onError);
155
+ }));
156
+ };
157
+ const expect = async (codes, code = "SMTP_PROTOCOL_ERROR") => {
158
+ const response = await bounded(reader.next());
159
+ if (!codes.includes(response.code)) throw smtpError(code);
160
+ return response;
161
+ };
162
+ const command = async (text, codes, errorCode) => {
163
+ socket.write(`${text}\r\n`);
164
+ return expect(codes, errorCode);
165
+ };
166
+ try {
167
+ await connect(config.tlsMode === "tls");
168
+ await expect([220]);
169
+ let hello = await command("EHLO artifacty.local", [250]);
170
+ if (config.tlsMode === "starttls") {
171
+ if (!hello.lines.some((line) => /^STARTTLS(?:\s|$)/i.test(line))) throw smtpError("SMTP_TLS_FAILED");
172
+ await command("STARTTLS", [220], "SMTP_TLS_FAILED");
173
+ reader.detach();
174
+ await connect(true, socket);
175
+ hello = await command("EHLO artifacty.local", [250]);
176
+ }
177
+ if (config.username) {
178
+ const methods = hello.lines.flatMap((line) => /^AUTH(?:=|\s+)(.*)$/i.exec(line)?.[1].toUpperCase().split(/\s+/) || []);
179
+ if (methods.includes("PLAIN")) {
180
+ const credentials = Buffer.from(`\0${config.username}\0${config.password}`).toString("base64");
181
+ const response = await command(`AUTH PLAIN ${credentials}`, [235, 334], "SMTP_AUTH_FAILED");
182
+ if (response.code === 334) await command(credentials, [235], "SMTP_AUTH_FAILED");
183
+ } else if (methods.includes("LOGIN")) {
184
+ await command("AUTH LOGIN", [334], "SMTP_AUTH_FAILED");
185
+ await command(Buffer.from(config.username).toString("base64"), [334], "SMTP_AUTH_FAILED");
186
+ await command(Buffer.from(config.password).toString("base64"), [235], "SMTP_AUTH_FAILED");
187
+ } else throw smtpError("SMTP_AUTH_FAILED");
188
+ }
189
+ await command(`MAIL FROM:<${config.from}>`, [250], "SMTP_REJECTED");
190
+ await command(`RCPT TO:<${message.to}>`, [250, 251, 252], "SMTP_REJECTED");
191
+ await command("DATA", [354], "SMTP_REJECTED");
192
+ deliveryStarted = true;
193
+ socket.write(encodeMessage(config, message));
194
+ await expect([250], "SMTP_REJECTED");
195
+ accepted = true;
196
+ // Acceptance is final; failed QUIT must never turn success into a retry.
197
+ try { socket.end("QUIT\r\n"); } catch { /* DATA acceptance remains final. */ }
198
+ return { accepted: true };
199
+ } catch (error) {
200
+ if (deliveryStarted && !accepted && error.code !== "SMTP_REJECTED") throw smtpError("SMTP_DELIVERY_UNKNOWN");
201
+ throw smtpError(Object.hasOwn(ERRORS, error.code) ? error.code : "SMTP_PROTOCOL_ERROR");
202
+ } finally {
203
+ clearTimeout(timer);
204
+ socket?.destroy();
205
+ }
206
+ }
@@ -0,0 +1,109 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
2
+ import { mkdirSync, lstatSync, readFileSync, openSync, writeFileSync, closeSync, fsyncSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { openDatabase, transaction, insertAuditRow } from "./storage.js";
5
+
6
+ const AAD = Buffer.from("artifacty:smtp-settings:v1");
7
+ const defaults = () => ({ enabled: false, host: "", port: 587, tlsMode: "starttls", username: "", password: "", from: "" });
8
+ const fail = (code, statusCode = 500) => Object.assign(new Error(code === "SMTP_KEY_UNAVAILABLE"
9
+ ? "SMTP encryption key is unavailable. Restore the original secrets/smtp.key before changing settings."
10
+ : code === "SMTP_SETTINGS_INVALID" ? "Invalid SMTP settings. Check required fields and TLS configuration."
11
+ : "SMTP settings could not be accessed securely."), { code, statusCode });
12
+
13
+ function checkedPath(file, directory, mode) {
14
+ let info;
15
+ try { info = lstatSync(file); } catch (error) { if (error.code === "ENOENT") return false; throw fail("SMTP_KEY_UNAVAILABLE"); }
16
+ if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile()) ||
17
+ (process.platform !== "win32" && ((info.mode & 0o777) !== mode || (process.getuid && info.uid !== process.getuid())))) {
18
+ throw fail("SMTP_KEY_UNSAFE");
19
+ }
20
+ return true;
21
+ }
22
+
23
+ function keyFor(store, create) {
24
+ const directory = path.join(store.home, "secrets");
25
+ const file = path.join(directory, "smtp.key");
26
+ if (!checkedPath(directory, true, 0o700)) {
27
+ if (!create) throw fail("SMTP_KEY_UNAVAILABLE");
28
+ mkdirSync(directory, { mode: 0o700 });
29
+ checkedPath(directory, true, 0o700);
30
+ }
31
+ if (!checkedPath(file, false, 0o600)) {
32
+ if (!create) throw fail("SMTP_KEY_UNAVAILABLE");
33
+ let descriptor;
34
+ try {
35
+ descriptor = openSync(file, "wx", 0o600);
36
+ writeFileSync(descriptor, randomBytes(32));
37
+ fsyncSync(descriptor);
38
+ } catch (error) { if (error.code !== "EEXIST") throw fail("SMTP_KEY_UNAVAILABLE"); }
39
+ finally { if (descriptor !== undefined) closeSync(descriptor); }
40
+ }
41
+ checkedPath(file, false, 0o600);
42
+ let key;
43
+ try { key = readFileSync(file); } catch { throw fail("SMTP_KEY_UNAVAILABLE"); }
44
+ if (key.length !== 32) throw fail("SMTP_KEY_UNAVAILABLE");
45
+ return key;
46
+ }
47
+
48
+ function validate(input) {
49
+ if (!input || typeof input !== "object" || Array.isArray(input) || typeof input.enabled !== "boolean") throw fail("SMTP_SETTINGS_INVALID", 400);
50
+ for (const field of ["host", "username", "password", "from"]) {
51
+ if (typeof input[field] !== "string" || Buffer.byteLength(input[field], "utf8") > 4096 || /[\r\n\0]/.test(input[field])) throw fail("SMTP_SETTINGS_INVALID", 400);
52
+ }
53
+ if (Boolean(input.username) !== Boolean(input.password)) throw fail("SMTP_SETTINGS_INVALID", 400);
54
+ if (!input.host.trim() || /[\s/@?#]/.test(input.host) || !/^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(input.from) ||
55
+ !Number.isInteger(input.port) || input.port < 1 || input.port > 65535 || !["tls", "starttls"].includes(input.tlsMode)) throw fail("SMTP_SETTINGS_INVALID", 400);
56
+ return { enabled: input.enabled, host: input.host, port: input.port, tlsMode: input.tlsMode, username: input.username, password: input.password, from: input.from };
57
+ }
58
+
59
+ function readConfig(db, store) {
60
+ const row = db.prepare("SELECT value FROM meta WHERE key='smtp_settings'").get();
61
+ if (!row) return defaults();
62
+ const key = keyFor(store, false);
63
+ try {
64
+ const envelope = JSON.parse(row.value);
65
+ if (envelope.v !== 1 || !["iv", "tag", "ciphertext"].every((field) => typeof envelope[field] === "string")) throw new Error();
66
+ const iv = Buffer.from(envelope.iv, "base64");
67
+ const tag = Buffer.from(envelope.tag, "base64");
68
+ if (iv.length !== 12 || tag.length !== 16) throw new Error();
69
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
70
+ decipher.setAAD(AAD);
71
+ decipher.setAuthTag(tag);
72
+ return validate(JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext, "base64")), decipher.final()]).toString("utf8")));
73
+ } catch { throw fail("SMTP_SETTINGS_UNREADABLE"); }
74
+ }
75
+
76
+ function redact(config) {
77
+ const { password, ...settings } = config;
78
+ return { ...settings, hasPassword: Boolean(password) };
79
+ }
80
+
81
+ export async function getSmtpDeliveryConfig(store) {
82
+ const db = openDatabase(store);
83
+ try { return readConfig(db, store); } finally { db.close(); }
84
+ }
85
+
86
+ export async function getSmtpSettings(store) {
87
+ return redact(await getSmtpDeliveryConfig(store));
88
+ }
89
+
90
+ export async function saveSmtpSettings(store, input, { access, audit } = {}) {
91
+ const db = openDatabase(store);
92
+ try {
93
+ return transaction(db, () => {
94
+ const actor = db.prepare("SELECT id,email FROM users WHERE id=? AND active=1 AND role='admin'").get(access?.userId || "");
95
+ if (!actor) throw Object.assign(new Error("An active administrator is required"), { code: "ADMIN_REQUIRED", statusCode: 403 });
96
+ const previous = readConfig(db, store);
97
+ const config = validate({ ...input, password: input?.username === "" ? "" : input?.password === "" ? previous.password : input?.password });
98
+ const key = keyFor(store, true);
99
+ const iv = randomBytes(12);
100
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
101
+ cipher.setAAD(AAD);
102
+ const ciphertext = Buffer.concat([cipher.update(JSON.stringify(config), "utf8"), cipher.final()]);
103
+ const envelope = { v: 1, iv: iv.toString("base64"), tag: cipher.getAuthTag().toString("base64"), ciphertext: ciphertext.toString("base64") };
104
+ db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES ('smtp_settings',?)").run(JSON.stringify(envelope));
105
+ insertAuditRow(db, { action: "smtp-settings-change", artifactId: "", audit: { surface: audit?.surface, actor: actor.email, userId: actor.id }, metadata: { enabled: config.enabled, tlsMode: config.tlsMode } });
106
+ return redact(config);
107
+ });
108
+ } finally { db.close(); }
109
+ }
@@ -85,7 +85,7 @@ export const ARTIFACT_TYPES = [
85
85
 
86
86
  export const RELATION_TYPES = ["derived-from", "supersedes", "reviews", "references", "part-of"];
87
87
 
88
- export const VISIBILITY_VALUES = ["private", "team"];
88
+ export const VISIBILITY_VALUES = ["private", "team", "public"];
89
89
 
90
90
  // Allowlisted filter keys a saved view may capture. Keeps saved views forward
91
91
  // compatible with filters that are recognized but not yet acted on (e.g.
@@ -216,13 +216,13 @@ export function visibilityClause(prefix, access) {
216
216
  const isAdmin = access.role === "admin" ? 1 : 0;
217
217
  return {
218
218
  sql: `((COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') != 'private'
219
- AND ${prefix}visibility != 'private')
219
+ AND (${prefix}visibility = 'public' OR (${prefix}visibility = 'team' AND ? != '')))
220
220
  OR (? != '' AND ${prefix}owner_user_id = ?) OR ? = 1
221
221
  OR (COALESCE((SELECT value FROM meta WHERE key = 'access_mode'), 'public') = 'private'
222
222
  AND ? != '' AND EXISTS (SELECT 1 FROM artifact_shares s
223
223
  WHERE s.artifact_id = ${prefix}id AND
224
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]
225
+ params: [userId, userId, userId, isAdmin, userId, userId, userId]
226
226
  };
227
227
  }
228
228
 
@@ -451,9 +451,14 @@ export async function previewAccessMode(store = createStore(), mode, { access }
451
451
  if (!["public", "private"].includes(mode)) throw Object.assign(new Error("Access mode must be public or private"), { statusCode: 400 });
452
452
  const db = openDatabase(store);
453
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 };
454
+ // Simulate anonymous dashboard visibility under the proposed mode without
455
+ // consulting or changing the currently configured access mode.
456
+ if (mode === "private") return { mode, anonymousArtifactCount: 0, total: 0, artifacts: [] };
457
+ const eligibility = "archived_at IS NULL AND (visibility = 'public' OR (SELECT COUNT(*) FROM users) = 0)";
458
+ const count = db.prepare(`SELECT COUNT(*) AS count FROM artifacts WHERE ${eligibility}`).get().count;
459
+ const rows = db.prepare(`SELECT ${artifactColumns()} FROM artifacts WHERE ${eligibility}
460
+ ORDER BY updated_at DESC, created_at DESC, id ASC LIMIT 50`).all();
461
+ return { mode, anonymousArtifactCount: count, total: count, artifacts: rows.map((row) => toArtifactSummary(artifactFromRow(db, row))) };
457
462
  } finally { db.close(); }
458
463
  }
459
464
 
@@ -482,9 +487,8 @@ function canReadArtifactMeta(db, artifact, access) {
482
487
  AND (user_id = ? OR team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) LIMIT 1`)
483
488
  .get(artifact.id, access.userId, access.userId));
484
489
  }
485
- if (artifactVisibilityOf(artifact) !== "private") {
486
- return true;
487
- }
490
+ if (artifactVisibilityOf(artifact) === "public") return true;
491
+ if (artifactVisibilityOf(artifact) === "team" && access.userId) return true;
488
492
  return isOwnerOrAdmin(access, artifact.ownerUserId);
489
493
  }
490
494
 
@@ -492,12 +496,10 @@ function canWriteArtifactMeta(db, artifact, access) {
492
496
  if (!access || isSingleUserMode(db)) {
493
497
  return true;
494
498
  }
495
- if (accessModeInDb(db) === "private" || artifactVisibilityOf(artifact) === "private") {
499
+ if (accessModeInDb(db) === "private" || artifactVisibilityOf(artifact) !== "team") {
496
500
  return isOwnerOrAdmin(access, artifact.ownerUserId);
497
501
  }
498
- if (access.anonymous) {
499
- return !teamWriteRestrictedToOwner();
500
- }
502
+ if (access.anonymous || !access.userId) return access.role === "admin";
501
503
  if (teamWriteRestrictedToOwner()) {
502
504
  return isOwnerOrAdmin(access, artifact.ownerUserId);
503
505
  }
@@ -2526,10 +2528,14 @@ export async function canReadArtifactEvent(store = createStore(), event, access
2526
2528
  if (!access) return true;
2527
2529
  const db = openDatabase(store);
2528
2530
  try {
2529
- if (access.userId && !db.prepare("SELECT id FROM users WHERE id = ? AND active = 1").get(access.userId)) return false;
2531
+ if (access.userId) {
2532
+ const user = db.prepare("SELECT role FROM users WHERE id = ? AND active = 1").get(access.userId);
2533
+ if (!user) return false;
2534
+ access = { ...access, role: user.role };
2535
+ }
2530
2536
  const row = event.artifactId ? db.prepare("SELECT id, visibility, owner_user_id FROM artifacts WHERE id = ?").get(event.artifactId) : null;
2531
2537
  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);
2538
+ return isSingleUserMode(db) || (accessModeInDb(db) !== "private" && eventVisibleTo(event, access));
2533
2539
  } finally { db.close(); }
2534
2540
  }
2535
2541
 
@@ -2582,7 +2588,7 @@ export async function listEventsSince(store = createStore(), sinceSeq = 0, filte
2582
2588
  ? (row.live_visibility != null
2583
2589
  ? canReadArtifactMeta(db, { id: row.artifact_id, visibility: row.live_visibility, ownerUserId: row.live_owner_user_id }, access)
2584
2590
  : access.role === "admin")
2585
- : eventVisibleTo(event, access);
2591
+ : (isSingleUserMode(db) || eventVisibleTo(event, access));
2586
2592
  if (matchesFilter(event, filter) && visible) {
2587
2593
  matched.push(event);
2588
2594
  if (matched.length >= limit) {
@@ -3059,6 +3065,30 @@ export async function listUsers(store = createStore()) {
3059
3065
  }
3060
3066
  }
3061
3067
 
3068
+ export async function setUserRole(store = createStore(), id, role, { access, audit } = {}) {
3069
+ if (!["admin", "user"].includes(role)) {
3070
+ throw Object.assign(new Error("Role must be admin or user"), { statusCode: 400, code: "INVALID_USER_ROLE" });
3071
+ }
3072
+ const db = openDatabase(store);
3073
+ try {
3074
+ return transaction(db, () => {
3075
+ const actor = db.prepare("SELECT id, email, role, active FROM users WHERE id = ?").get(access?.userId || "");
3076
+ if (!actor?.active || actor.role !== "admin") {
3077
+ throw Object.assign(new Error("An active administrator is required"), { statusCode: 403, code: "ADMIN_REQUIRED" });
3078
+ }
3079
+ const target = db.prepare("SELECT * FROM users WHERE id = ?").get(id);
3080
+ if (!target) throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
3081
+ if (target.role === role) return userFromRow(target);
3082
+ if (target.active && target.role === "admin" && role === "user" && !db.prepare("SELECT id FROM users WHERE active = 1 AND role = 'admin' AND id != ? LIMIT 1").get(id)) {
3083
+ throw Object.assign(new Error("At least one active administrator must remain"), { statusCode: 409, code: "LAST_ADMIN_REQUIRED" });
3084
+ }
3085
+ db.prepare("UPDATE users SET role = ?, updated_at = ? WHERE id = ?").run(role, new Date().toISOString(), id);
3086
+ insertAuditRow(db, { action: "user-role-change", artifactId: "", audit: { ...audit, actor: actor.email, userId: actor.id }, metadata: { targetUserId: id, fromRole: target.role, toRole: role, actorUserId: actor.id } });
3087
+ return userFromRow(db.prepare("SELECT * FROM users WHERE id = ?").get(id));
3088
+ });
3089
+ } finally { db.close(); }
3090
+ }
3091
+
3062
3092
  export async function setUserActive(store = createStore(), id, active) {
3063
3093
  const db = openDatabase(store);
3064
3094
  try {
@@ -3075,6 +3105,28 @@ export async function setUserActive(store = createStore(), id, active) {
3075
3105
  }
3076
3106
  }
3077
3107
 
3108
+ export async function resetUserPassword(store = createStore(), id, { access, audit } = {}) {
3109
+ const db = openDatabase(store);
3110
+ try {
3111
+ return transaction(db, () => {
3112
+ const actor = db.prepare("SELECT id, email, role, active FROM users WHERE id = ?").get(access?.userId || "");
3113
+ if (!actor?.active || actor.role !== "admin") {
3114
+ throw Object.assign(new Error("An active administrator is required"), { statusCode: 403, code: "ADMIN_REQUIRED" });
3115
+ }
3116
+ if (!db.prepare("SELECT id FROM users WHERE id = ?").get(id)) {
3117
+ throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
3118
+ }
3119
+ const temporaryPassword = generateTemporaryPassword();
3120
+ const now = new Date().toISOString();
3121
+ db.prepare("UPDATE users SET password_hash = ?, password_reset_required = 1, updated_at = ? WHERE id = ?")
3122
+ .run(hashPassword(temporaryPassword), now, id);
3123
+ db.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL").run(now, id);
3124
+ insertAuditRow(db, { action: "user-password-reset", artifactId: "", audit: { ...audit, actor: actor.email, userId: actor.id }, metadata: { targetUserId: id } });
3125
+ return { user: userFromRow(db.prepare("SELECT * FROM users WHERE id = ?").get(id)), temporaryPassword };
3126
+ });
3127
+ } finally { db.close(); }
3128
+ }
3129
+
3078
3130
  export async function changeUserPassword(store = createStore(), userId, input = {}) {
3079
3131
  const password = String(input.password || input.newPassword || "");
3080
3132
  if (password.length < 8) {
@@ -3258,7 +3310,9 @@ export async function authenticateApiToken(store = createStore(), token) {
3258
3310
  actor: row.email,
3259
3311
  tokenId: row.token_id,
3260
3312
  tokenName: row.token_name,
3261
- scopes: row.token_scopes_json ? parseJson(row.token_scopes_json, ["read", "write"]) : ["read", "write"],
3313
+ // A stored admin grant must not outlive the owner's current admin role.
3314
+ scopes: (row.token_scopes_json ? parseJson(row.token_scopes_json, ["read", "write"]) : ["read", "write"])
3315
+ .filter((scope) => scope !== "admin" || row.role === "admin"),
3262
3316
  user: userFromRow(row)
3263
3317
  };
3264
3318
  } finally {
@@ -0,0 +1,32 @@
1
+ // Generated from @tabler/icons v3.49.0 (MIT), by npm run build:ui.
2
+ const paths = {
3
+ "layout-sidebar-left-collapse": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M9 4v16\" /> <path d=\"M15 10l-2 2l2 2\" />",
4
+ "layout-sidebar-left-expand": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M9 4v16\" /> <path d=\"M14 10l2 2l-2 2\" />",
5
+ "files": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M15 3v4a1 1 0 0 0 1 1h4\" /> <path d=\"M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2\" /> <path d=\"M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2\" />",
6
+ "inbox": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12\" /> <path d=\"M4 13h3l3 3h4l3 -3h3\" />",
7
+ "plus": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M12 5l0 14\" /> <path d=\"M5 12l14 0\" />",
8
+ "upload": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2\" /> <path d=\"M7 9l5 -5l5 5\" /> <path d=\"M12 4l0 12\" />",
9
+ "settings": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065\" /> <path d=\"M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0\" />",
10
+ "users": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M5 7a4 4 0 1 0 8 0a4 4 0 1 0 -8 0\" /> <path d=\"M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2\" /> <path d=\"M16 3.13a4 4 0 0 1 0 7.75\" /> <path d=\"M21 21v-2a4 4 0 0 0 -3 -3.85\" />",
11
+ "database-export": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4 6c0 1.657 3.582 3 8 3s8 -1.343 8 -3s-3.582 -3 -8 -3s-8 1.343 -8 3\" /> <path d=\"M4 6v6c0 1.657 3.582 3 8 3c1.118 0 2.183 -.086 3.15 -.241\" /> <path d=\"M20 12v-6\" /> <path d=\"M4 12v6c0 1.657 3.582 3 8 3c.157 0 .312 -.002 .466 -.005\" /> <path d=\"M16 19h6\" /> <path d=\"M19 16l3 3l-3 3\" />",
12
+ "archive": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 6a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2\" /> <path d=\"M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10\" /> <path d=\"M10 12l4 0\" />",
13
+ "webhook": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M4.876 13.61a4 4 0 1 0 6.124 3.39h6\" /> <path d=\"M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5\" /> <path d=\"M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5\" />",
14
+ "refresh": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4\" /> <path d=\"M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4\" />",
15
+ "language": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M9 6.371c0 4.418 -2.239 6.629 -5 6.629\" /> <path d=\"M4 6.371h7\" /> <path d=\"M5 9c0 2.144 2.252 3.908 6 4\" /> <path d=\"M12 20l4 -9l4 9\" /> <path d=\"M19.1 18h-6.2\" /> <path d=\"M6.694 3l.793 .582\" />",
16
+ "mail": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10\" /> <path d=\"M3 7l9 6l9 -6\" />",
17
+ "user-circle": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0\" /> <path d=\"M9 10a3 3 0 1 0 6 0a3 3 0 1 0 -6 0\" /> <path d=\"M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855\" />",
18
+ "key": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0\" /> <path d=\"M15 9h.01\" />",
19
+ "logout": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2\" /> <path d=\"M9 12h12l-3 -3\" /> <path d=\"M18 15l3 -3\" />",
20
+ "login": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2\" /> <path d=\"M21 12h-13l3 -3\" /> <path d=\"M11 15l-3 -3\" />",
21
+ "code": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M7 8l-4 4l4 4\" /> <path d=\"M17 8l4 4l-4 4\" /> <path d=\"M14 4l-4 16\" />",
22
+ "chevron-down": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M6 9l6 6l6 -6\" />",
23
+ "device-desktop": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10\" /> <path d=\"M7 20h10\" /> <path d=\"M9 16v4\" /> <path d=\"M15 16v4\" />",
24
+ "sun": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0\" /> <path d=\"M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7\" />",
25
+ "moon": "<path stroke=\"none\" d=\"M0 0h24v24H0z\" fill=\"none\" /> <path d=\"M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008\" />"
26
+ };
27
+
28
+ export function uiIcon(name, { size = 18 } = {}) {
29
+ if (!Object.hasOwn(paths, name)) throw new Error('Unknown UI icon');
30
+ const dimension = [16, 18, 20, 24].includes(size) ? size : 18;
31
+ return '<svg class="ui-icon" width="' + dimension + '" height="' + dimension + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + paths[name] + '</svg>';
32
+ }
package/src/mcp-server.js CHANGED
@@ -100,7 +100,7 @@ const nativeArtifactInputSchema = {
100
100
  visibility: {
101
101
  type: "string",
102
102
  enum: VISIBILITY_VALUES,
103
- description: "'team' (default) is visible to any authenticated user; 'private' is visible only to the owner and admins."
103
+ description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing."
104
104
  },
105
105
  relations: relationsInputSchema
106
106
  },
@@ -264,7 +264,7 @@ const tools = [
264
264
  visibility: {
265
265
  type: "string",
266
266
  enum: VISIBILITY_VALUES,
267
- description: "'team' (default) is visible to any authenticated user; 'private' is visible only to the owner and admins. Prefer artifacty_set_visibility to change visibility on its own."
267
+ description: "In public server mode, 'public' allows anonymous reading, 'team' (default) requires a signed-in user, and 'private' is owner/admin only. Private server mode always requires owner/admin or explicit sharing. Prefer artifacty_set_visibility to change visibility on its own."
268
268
  },
269
269
  relations: relationsInputSchema
270
270
  },
@@ -280,7 +280,7 @@ const tools = [
280
280
  {
281
281
  name: "artifacty_set_visibility",
282
282
  title: "Set Artifact Visibility",
283
- description: "Change an artifact's visibility between 'team' and 'private'. Requires the artifact's owner or an admin.",
283
+ description: "Change an artifact's visibility to 'public', 'team', or 'private'. Requires the artifact's owner or an admin.",
284
284
  inputSchema: {
285
285
  type: "object",
286
286
  properties: {