artifacty 0.14.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.
@@ -30,7 +30,7 @@ Open http://127.0.0.1:8787. The server binds to localhost by default. To start a
30
30
  5. Inspect the version history.
31
31
  6. Compare the versions with a diff.
32
32
  7. Add tags.
33
- 8. Use search, filters, or saved views to find the artifact again.
33
+ 8. Use search to find the artifact again. Open **Advanced filters** or **Saved views** in the main dashboard when needed. These sections start collapsed; closing them preserves your filters.
34
34
  9. Add a comment.
35
35
  10. Request a review.
36
36
  11. As the owner or administrator, record an approved or changes-requested status.
@@ -42,7 +42,7 @@ Open http://127.0.0.1:8787. The server binds to localhost by default. To start a
42
42
 
43
43
  ## Choose your access model
44
44
 
45
- Before accounts exist, Artifacty operates as a local single-user store. Once accounts exist, use personal accounts and tokens. Create the initial administrator at **/login**. Administrators manage accounts at **/admin/users**. The Access page links to that page. Administrators manage teams, access mode, and network rules at **/admin/access**. In private mode, use personal authentication for ordinary artifact reads and creation. Server-wide tokens do not authorize those operations. Local CLI operations remain trusted store administration.
45
+ Before accounts exist, Artifacty operates as a local single-user store. Once accounts exist, use personal accounts and tokens. Create the initial administrator at **/login**. In the desktop navigation rail, open **Administration**. On narrower screens, open **Menu** first. Use **Users** (**/admin/users**) for accounts. Use **Access** (**/admin/access**) for teams, access mode, and network rules. Administration also contains Backup, Retention, Webhooks, and Server update. In private mode, use personal authentication for ordinary artifact reads and creation. Server-wide tokens do not authorize those operations. Local CLI operations remain trusted store administration.
46
46
 
47
47
  A share link is a separate read-only capability with an expiry time. It does not grant account membership. Default expiry is seven days. Owners can share the latest version or pin one version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, an owner can link a private-visibility artifact without that site toggle. When you no longer need a link, revoke it. An owner change invalidates existing links.
48
48
 
@@ -70,10 +70,10 @@ If the current store is corrupt, preserve it and copy the selected snapshot to a
70
70
  <header><p>ARTIFACTY / REFERENCE</p><h1>From an agent output to a shared record</h1><p>Use this inventory to choose the right publishing, review, access, and maintenance workflow. Use Node.js 22.12.0 or newer. The current store schema is 10. Mermaid 12.1.0 keeps the classic look, dagre layout, and default theme. Use an ES2024-capable browser, such as Safari 17.4 or newer. The optional Babel 8 React renderer requires Node ^22.18.0 or &gt;=24.11.0.</p></header>
71
71
  <nav aria-label="Guide sections"><a href="#formats">Formats</a> · <a href="#history">History and discovery</a> · <a href="#collaboration">Collaboration</a> · <a href="#access">Access</a> · <a href="#operations">Operations</a></nav>
72
72
  <section id="formats"><h2>Formats and imports</h2><p>The 13 formats are HTML, Markdown, text, JSON, code, SVG, Mermaid, React, SARIF, CSV, image, video, and notebook. Choose an artifact type separately: a document, handoff, code review, test report, dashboard, diagram, component, table, or another supported type.</p><p>Bundles group multiple files under one artifact. Supported binary bundle assets include PDF, DOCX, XLSX, and ZIP. Importers normalize outputs from Claude, Codex, Gemini, GitHub Copilot, Cursor, and generic inputs. Use the browser import screen or <code>artifacty import --agent codex --file handoff.md</code>.</p><p>Artifact content is untrusted. Browser previews use constrained rendering. A public HTML share preview is static. It blocks scripts and external resource loading. Share links show other active formats as source. Do not expect those previews to preserve interactive application behavior.</p></section>
73
- <section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
73
+ <section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view. Search stays visible in the main dashboard. Open Advanced filters or Saved views when needed. Both sections start collapsed; closing them preserves selected filters.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
74
74
  <section id="collaboration"><h2>Relationships, comments, review, and notifications</h2><p>Connect artifacts using derived-from, supersedes, reviews, references, or part-of relations. Comments support threads and anchors. After you address a thread, resolve it. Review status progresses among none, pending, changes-requested, and approved.</p><p>The inbox at <code>/inbox</code> collects relevant shares, comments, review activity, and new versions. Notifications depend on the user and current artifact access. You can mark items read individually or together. A notification does not grant access.</p><p>Audit records support investigation. Event polling and server-sent events expose authorized activity streams. Webhooks deliver configured events to validated destinations. Use event filters, replay cursors, and webhook administration for integrations.</p></section>
75
- <section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>. Administrators manage accounts at <code>/admin/users</code>. The Access page links to that page. Administrators manage teams, access mode, and network rules at <code>/admin/access</code>. The artifact <strong>Sharing</strong> panel shows effective access and its sources. Direct-user and team grants support read, comment, and edit. Where grants overlap, the strongest permission wins. Ownership and administrator management are separate from grants. Token scopes limit token actions. They do not grant artifact access.</p><p>Private mode requires an active personal account. Server-wide tokens do not authorize ordinary artifact reads or creation in private mode. Administrator APIs have their own authorization rules. Personal tokens remain constrained by both their scopes and the user's artifact permissions. Trusted local CLI operations can administer the local store directly.</p><p>Read-only share links default to seven days and can target the latest version or a pinned version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, owners may link private-visibility artifacts without that site toggle. Links can be revoked, and owner changes invalidate them. A valid capability permits only its read-only shared surface.</p><p>Network allow rules accept IP addresses and CIDRs. First, stage a change. Next, verify access from the intended peer. Then confirm the change within 120 seconds or roll it back. Pending state survives a restart. Before the next access, the server checks for expiry. If the change has expired, the server restores the previous rules. Rules use the socket peer address, not forwarded proxy headers.</p></section>
76
- <section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import JSON backups to move a store. Upgrade snapshots separately preserve raw SQLite, referenced versions, and any legacy index. Schema migrations always require a verified snapshot. Set <code>ARTIFACTY_BACKUP_ON_UPDATE=true</code> for application-version snapshots, including an unknown-version baseline. <code>ARTIFACTY_UPGRADE_BACKUP_KEEP</code> defaults to 3 and accepts 1–1000.</li><li>Use <code>artifacty backups list</code>, <code>artifacty backups verify &lt;id&gt;</code>, and <code>artifacty backups restore &lt;id&gt; --confirm restore-store</code>. Stop every server and MCP process first. Restore saves a recovery snapshot and rolls back credentials and security state. Revoked sessions, tokens, or links can become valid again. For a corrupt store, copy the snapshot to a new empty home and restore there. Preserve interrupted <code>.restore-*</code> directories and use their <code>restore.json</code> recoveryBackupId. See <code>docs/upgrade-backups.md</code>.</li><li>Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
75
+ <section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>. In the navigation, administrators open <strong>Administration</strong>. Use Users at <code>/admin/users</code> for accounts. Use Access at <code>/admin/access</code> for teams, access mode, and network rules. Backup, Retention, Webhooks, and Server update are in the same group. The artifact <strong>Sharing</strong> panel shows effective access and its sources. Direct-user and team grants support read, comment, and edit. Where grants overlap, the strongest permission wins. Ownership and administrator management are separate from grants. Token scopes limit token actions. They do not grant artifact access.</p><p>Private mode requires an active personal account. Server-wide tokens do not authorize ordinary artifact reads or creation in private mode. Administrator APIs have their own authorization rules. Personal tokens remain constrained by both their scopes and the user's artifact permissions. Trusted local CLI operations can administer the local store directly.</p><p>Read-only share links default to seven days and can target the latest version or a pinned version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, owners may link private-visibility artifacts without that site toggle. Links can be revoked, and owner changes invalidate them. A valid capability permits only its read-only shared surface.</p><p>Network allow rules accept IP addresses and CIDRs. First, stage a change. Next, verify access from the intended peer. Then confirm the change within 120 seconds or roll it back. Pending state survives a restart. Before the next access, the server checks for expiry. If the change has expired, the server restores the previous rules. Rules use the socket peer address, not forwarded proxy headers.</p></section>
76
+ <section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration. On desktop, use the navigation rail. On narrower screens, open Menu. Artifacts and Inbox lead to your work; New and Import add content. Account contains Account &amp; API tokens, password changes, and sign-out. API docs and language selection are also in the navigation. Administrators can open Server update at /admin/update. Checking the latest release does not install it. For a supported non-root Linux systemd user service, confirm the displayed version and backup/restart before starting. Keep the page open to reconnect and read the result.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import JSON backups to move a store. Upgrade snapshots separately preserve raw SQLite, referenced versions, and any legacy index. Schema migrations always require a verified snapshot. Set <code>ARTIFACTY_BACKUP_ON_UPDATE=true</code> for application-version snapshots, including an unknown-version baseline. <code>ARTIFACTY_UPGRADE_BACKUP_KEEP</code> defaults to 3 and accepts 1–1000.</li><li>Use <code>artifacty backups list</code>, <code>artifacty backups verify &lt;id&gt;</code>, and <code>artifacty backups restore &lt;id&gt; --confirm restore-store</code>. Stop every server and MCP process first. Restore saves a recovery snapshot and rolls back credentials and security state. Revoked sessions, tokens, or links can become valid again. For a corrupt store, copy the snapshot to a new empty home and restore there. Preserve interrupted <code>.restore-*</code> directories and use their <code>restore.json</code> recoveryBackupId. See <code>docs/upgrade-backups.md</code>.</li><li>Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
77
77
  <footer><p>This guide is a managed sample artifact. Editing it creates your own version. Reinstalling samples preserves that work.</p></footer>
78
78
  </main></body></html>`
79
79
  },
@@ -171,7 +171,7 @@ The four sample manuals are installed. You can open the browser dashboard. To pr
171
171
 
172
172
  ## Administration practice
173
173
 
174
- As an administrator, open **/admin/access** to inspect access mode and network rules. You can switch access mode or stage a network change.
174
+ As an administrator, open **Administration → Access** (**/admin/access**) to inspect access mode and network rules. On narrower screens, open **Menu** first. You can switch access mode or stage a network change.
175
175
 
176
176
  1. Before you stage a network change, keep your actual socket peer address in the allowed rules.
177
177
  2. Stage the change.
@@ -197,6 +197,18 @@ async function waitHealthy(run, home, health, sleep, identity, args) {
197
197
  throw failure("The updated service did not pass its PID and HTTP health checks", "UPDATE_HEALTH_FAILED");
198
198
  }
199
199
 
200
+ /** Verify that a web administrator is operating the managed service itself. */
201
+ export async function inspectWebUpdateService({ home, pid = process.pid }, injected = {}) {
202
+ if ((injected.platform || process.platform) !== "linux" || (injected.uid ?? process.geteuid?.()) === 0) {
203
+ throw failure("Web updates require a non-root Linux systemd user service", "UNSUPPORTED_SERVICE");
204
+ }
205
+ const run = injected.run || ((command, args) => execute(command, args, { timeout: 10000, maxBuffer: 1024 * 1024 }));
206
+ const service = await discoverService(run, path.resolve(home));
207
+ if (!service.active || service.pid !== pid) throw failure("This server is not the managed Artifacty service", "SERVICE_IDENTITY_MISMATCH");
208
+ await (injected.verifyServiceIdentity || verifyServiceIdentity)(pid, service.args);
209
+ return { currentVersion: service.version };
210
+ }
211
+
200
212
  /** Update the managed Linux user service. Dependencies are injectable for offline tests. */
201
213
  export async function updateApplication(options = {}, injected = {}) {
202
214
  const env = injected.env || process.env;
@@ -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
+ }