artifacty 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/render.js CHANGED
@@ -2,7 +2,7 @@ import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorImportMapJson } from "./e
2
2
  import { createI18n, DEFAULT_LOCALE, editorMessages, localizedHref, switchLocaleHref } from "./i18n.js";
3
3
  import { ARTIFACT_FORMATS, ARTIFACT_TYPES } from "./storage.js";
4
4
 
5
- export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/" }) {
5
+ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination, locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
6
6
  const view = viewContext(locale, currentPath);
7
7
  const total = pagination?.total ?? artifacts.length;
8
8
  const start = artifacts.length ? (pagination?.offset ?? 0) + 1 : 0;
@@ -48,6 +48,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
48
48
  <a href="${view.href("/new")}">${view.text("nav.new")}</a>
49
49
  <a href="${view.href("/import")}">${view.text("nav.import")}</a>
50
50
  <a href="/api/artifacts">${view.text("nav.api")}</a>
51
+ ${authNav(user)}
51
52
  ${languageSwitcher(view)}
52
53
  </nav>
53
54
  </header>
@@ -249,6 +250,170 @@ export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEF
249
250
  });
250
251
  }
251
252
 
253
+ export function renderLoginPage({ baseUrl, setup = false, error = "", locale = DEFAULT_LOCALE, currentPath = "/login" }) {
254
+ const view = viewContext(locale, currentPath);
255
+ const title = setup ? "Create admin account" : "Sign in";
256
+ return pageShell({
257
+ title,
258
+ body: `
259
+ <header class="topbar">
260
+ <div>
261
+ <h1>${escapeHtml(title)}</h1>
262
+ <p>${escapeHtml(baseUrl)}</p>
263
+ </div>
264
+ <nav>
265
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
266
+ ${languageSwitcher(view)}
267
+ </nav>
268
+ </header>
269
+ <main class="artifact-editor auth-panel">
270
+ ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
271
+ ${setup ? `<p class="muted">No users exist yet. The first account becomes an administrator.</p>` : ""}
272
+ <form class="editor-form auth-form" method="post" action="/login">
273
+ <section class="editor-fields">
274
+ <label class="field">
275
+ <span>Email</span>
276
+ <input type="email" name="email" autocomplete="username" required>
277
+ </label>
278
+ ${setup ? `<label class="field">
279
+ <span>Name</span>
280
+ <input name="name" autocomplete="name">
281
+ </label>` : ""}
282
+ <label class="field">
283
+ <span>Password</span>
284
+ <input type="password" name="password" autocomplete="${setup ? "new-password" : "current-password"}" minlength="8" required>
285
+ </label>
286
+ </section>
287
+ <footer class="editor-actions">
288
+ <button type="submit">${setup ? "Create admin" : "Sign in"}</button>
289
+ </footer>
290
+ </form>
291
+ </main>
292
+ `,
293
+ locale: view.locale
294
+ });
295
+ }
296
+
297
+ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "", locale = DEFAULT_LOCALE, currentPath = "/account" }) {
298
+ const view = viewContext(locale, currentPath);
299
+ const rows = tokens.map((token) => `
300
+ <tr>
301
+ <td>${escapeHtml(token.name)}</td>
302
+ <td>${escapeHtml(token.createdAt)}</td>
303
+ <td>${token.lastUsedAt ? escapeHtml(token.lastUsedAt) : "Never"}</td>
304
+ <td>${token.revokedAt ? escapeHtml(token.revokedAt) : "Active"}</td>
305
+ <td>
306
+ ${token.revokedAt ? "" : `<form method="post" action="/account/tokens/${encodeURIComponent(token.id)}/revoke">
307
+ <button type="submit">Revoke</button>
308
+ </form>`}
309
+ </td>
310
+ </tr>
311
+ `).join("");
312
+
313
+ return pageShell({
314
+ title: "Account",
315
+ body: `
316
+ <header class="topbar">
317
+ <div>
318
+ <h1>Account</h1>
319
+ <p>${escapeHtml(user.email)} · ${escapeHtml(user.role)} · ${escapeHtml(baseUrl)}</p>
320
+ </div>
321
+ <nav>
322
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
323
+ ${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
324
+ <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
325
+ ${languageSwitcher(view)}
326
+ </nav>
327
+ </header>
328
+ <main class="artifact-view">
329
+ ${createdToken ? `<section class="token-once">
330
+ <h2>New API token</h2>
331
+ <p>Copy this token now. Artifacty stores only its hash and cannot show it again.</p>
332
+ <pre class="artifact-code"><code>${escapeHtml(createdToken)}</code></pre>
333
+ </section>` : ""}
334
+ <section class="meta-card">
335
+ <h2>Profile</h2>
336
+ <p><strong>${escapeHtml(user.name)}</strong></p>
337
+ <p>${escapeHtml(user.email)}</p>
338
+ <p>Role: ${escapeHtml(user.role)}</p>
339
+ </section>
340
+ <section class="meta-card">
341
+ <h2>Create API token</h2>
342
+ <form class="inline-action" method="post" action="/account/tokens">
343
+ <input name="name" placeholder="Token name" autocomplete="off" required>
344
+ <button type="submit">Create token</button>
345
+ </form>
346
+ </section>
347
+ <section class="meta-card">
348
+ <h2>API tokens</h2>
349
+ <table class="data-table">
350
+ <thead><tr><th>Name</th><th>Created</th><th>Last used</th><th>Status</th><th></th></tr></thead>
351
+ <tbody>${rows || `<tr><td colspan="5">No API tokens.</td></tr>`}</tbody>
352
+ </table>
353
+ </section>
354
+ </main>
355
+ `,
356
+ locale: view.locale
357
+ });
358
+ }
359
+
360
+ export function renderAdminUsersPage({ baseUrl, user, users = [], locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
361
+ const view = viewContext(locale, currentPath);
362
+ const rows = users.map((item) => `
363
+ <tr>
364
+ <td>${escapeHtml(item.email)}</td>
365
+ <td>${escapeHtml(item.name)}</td>
366
+ <td>${escapeHtml(item.role)}</td>
367
+ <td>${item.active ? "Active" : "Disabled"}</td>
368
+ <td>${escapeHtml(item.createdAt)}</td>
369
+ <td>
370
+ ${item.id === user.id ? "" : `<form method="post" action="/admin/users/${encodeURIComponent(item.id)}/${item.active ? "disable" : "enable"}">
371
+ <button type="submit">${item.active ? "Disable" : "Enable"}</button>
372
+ </form>`}
373
+ </td>
374
+ </tr>
375
+ `).join("");
376
+
377
+ return pageShell({
378
+ title: "Users",
379
+ body: `
380
+ <header class="topbar">
381
+ <div>
382
+ <h1>Users</h1>
383
+ <p>${escapeHtml(baseUrl)}</p>
384
+ </div>
385
+ <nav>
386
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
387
+ <a href="/account">Account</a>
388
+ ${languageSwitcher(view)}
389
+ </nav>
390
+ </header>
391
+ <main class="artifact-view">
392
+ <section class="meta-card">
393
+ <h2>Create user</h2>
394
+ <form class="editor-form auth-form" method="post" action="/admin/users">
395
+ <section class="editor-fields">
396
+ <label class="field"><span>Email</span><input type="email" name="email" required></label>
397
+ <label class="field"><span>Name</span><input name="name"></label>
398
+ <label class="field"><span>Role</span><select name="role"><option value="user">user</option><option value="admin">admin</option></select></label>
399
+ <label class="field"><span>Password</span><input type="password" name="password" minlength="8" required></label>
400
+ </section>
401
+ <footer class="editor-actions"><button type="submit">Create user</button></footer>
402
+ </form>
403
+ </section>
404
+ <section class="meta-card">
405
+ <h2>Existing users</h2>
406
+ <table class="data-table">
407
+ <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
408
+ <tbody>${rows}</tbody>
409
+ </table>
410
+ </section>
411
+ </main>
412
+ `,
413
+ locale: view.locale
414
+ });
415
+ }
416
+
252
417
  export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
253
418
  const view = viewContext(locale, currentPath);
254
419
  const versionLinks = artifact.versions
@@ -726,6 +891,25 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
726
891
  .inline-action {
727
892
  margin-bottom: 12px;
728
893
  }
894
+ .nav-form {
895
+ display: inline-flex;
896
+ margin: 0;
897
+ }
898
+ .nav-form button {
899
+ min-height: 0;
900
+ padding: 0;
901
+ border: 0;
902
+ background: transparent;
903
+ color: inherit;
904
+ font: inherit;
905
+ font-weight: inherit;
906
+ }
907
+ .nav-form button:hover {
908
+ background: transparent;
909
+ border: 0;
910
+ color: var(--text);
911
+ text-decoration: underline;
912
+ }
729
913
  .diff-form {
730
914
  grid-template-columns: 160px 160px auto;
731
915
  width: fit-content;
@@ -788,6 +972,64 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
788
972
  display: grid;
789
973
  gap: 16px;
790
974
  }
975
+ .auth-panel {
976
+ max-width: 720px;
977
+ margin: 0 auto;
978
+ }
979
+ .auth-form .editor-fields {
980
+ grid-template-columns: minmax(220px, 1fr);
981
+ }
982
+ .auth-error,
983
+ .token-once {
984
+ border: 1px solid var(--line);
985
+ border-radius: 8px;
986
+ padding: 12px 14px;
987
+ background: var(--panel);
988
+ }
989
+ .auth-error {
990
+ color: #991b1b;
991
+ background: #fef2f2;
992
+ border-color: #fecaca;
993
+ }
994
+ .muted {
995
+ color: var(--muted);
996
+ }
997
+ .meta-card {
998
+ display: grid;
999
+ gap: 10px;
1000
+ margin-bottom: 16px;
1001
+ border: 1px solid var(--line);
1002
+ border-radius: 8px;
1003
+ padding: 16px;
1004
+ background: var(--panel);
1005
+ }
1006
+ .meta-card h2,
1007
+ .token-once h2 {
1008
+ margin: 0;
1009
+ font-size: 18px;
1010
+ }
1011
+ .data-table {
1012
+ width: 100%;
1013
+ border-collapse: collapse;
1014
+ font-size: 13px;
1015
+ }
1016
+ .data-table th,
1017
+ .data-table td {
1018
+ padding: 9px 10px;
1019
+ border-bottom: 1px solid var(--line);
1020
+ text-align: left;
1021
+ vertical-align: middle;
1022
+ }
1023
+ .data-table th {
1024
+ color: var(--faint);
1025
+ font-family: var(--mono);
1026
+ font-size: 11.5px;
1027
+ letter-spacing: 0.06em;
1028
+ text-transform: uppercase;
1029
+ }
1030
+ .data-table form {
1031
+ margin: 0;
1032
+ }
791
1033
  .editor-fields {
792
1034
  display: grid;
793
1035
  grid-template-columns: minmax(220px, 1fr) 180px 180px minmax(160px, 240px);
@@ -1573,6 +1815,13 @@ function languageSwitcher(view) {
1573
1815
  return `<span class="language-switcher">${english}${korean}</span>`;
1574
1816
  }
1575
1817
 
1818
+ function authNav(user) {
1819
+ if (!user) {
1820
+ return `<a href="/login">Sign in</a>`;
1821
+ }
1822
+ return `${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}<a href="/account">Account</a>`;
1823
+ }
1824
+
1576
1825
  export function escapeHtml(value) {
1577
1826
  return String(value)
1578
1827
  .replaceAll("&", "&amp;")
@@ -57,6 +57,10 @@ export function requireToken({ request, url, body = {}, config = securityConfig(
57
57
  }
58
58
  }
59
59
 
60
+ export function requestToken({ request, url, body = {} }) {
61
+ return extractToken({ request, url, body });
62
+ }
63
+
60
64
  export function scanForSecrets(content) {
61
65
  const text = String(content ?? "");
62
66
  const findings = [];
@@ -230,6 +230,7 @@ function serviceConfig(options = {}) {
230
230
  apiToken: options.apiToken || "",
231
231
  shareMode: options.shareMode || "",
232
232
  allowSecrets: Boolean(options.allowSecrets),
233
+ mcpHttp: Boolean(options.mcpHttp),
233
234
  taskName: options.taskName || DEFAULT_TASK_NAME
234
235
  };
235
236
  }
@@ -251,6 +252,9 @@ function serverArgs(config, options = {}) {
251
252
  if (config.allowSecrets) {
252
253
  args.push("--allow-secrets");
253
254
  }
255
+ if (config.mcpHttp) {
256
+ args.push("--mcp-http");
257
+ }
254
258
  if (options.includeApiTokenArg && config.apiToken) {
255
259
  args.push("--api-token", config.apiToken);
256
260
  }
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
@@ -9,6 +9,7 @@ import { assertNoSecrets, securityConfig } from "./security.js";
9
9
  export const STORE_VERSION = 3;
10
10
  export const ARTIFACT_SCHEMA_VERSION = 1;
11
11
  export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
12
+ export const USER_ROLES = ["admin", "user"];
12
13
  export const ARTIFACT_FORMATS = [
13
14
  "html",
14
15
  "markdown",
@@ -653,6 +654,239 @@ export async function listAuditEvents(store = createStore(), filters = {}) {
653
654
  }
654
655
  }
655
656
 
657
+ export async function countUsers(store = createStore()) {
658
+ const db = openDatabase(store);
659
+ try {
660
+ return db.prepare("SELECT COUNT(*) AS count FROM users").get().count;
661
+ } finally {
662
+ db.close();
663
+ }
664
+ }
665
+
666
+ export async function createUser(store = createStore(), input = {}) {
667
+ const email = normalizeEmail(input.email);
668
+ const password = String(input.password || "");
669
+ if (!email) {
670
+ throw Object.assign(new Error("User email is required"), { statusCode: 400, code: "USER_EMAIL_REQUIRED" });
671
+ }
672
+ if (password.length < 8) {
673
+ throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
674
+ }
675
+ const role = normalizeUserRole(input.role || "user");
676
+ const name = normalizeOptionalString(input.name) || email;
677
+ const now = new Date().toISOString();
678
+ const user = {
679
+ id: randomUUID(),
680
+ email,
681
+ name,
682
+ role,
683
+ active: true,
684
+ createdAt: now,
685
+ updatedAt: now
686
+ };
687
+ const db = openDatabase(store);
688
+ try {
689
+ db.prepare(`
690
+ INSERT INTO users (id, email, name, role, password_hash, active, created_at, updated_at)
691
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?)
692
+ `).run(user.id, user.email, user.name, user.role, hashPassword(password), now, now);
693
+ return user;
694
+ } catch (error) {
695
+ if (/UNIQUE/i.test(error.message)) {
696
+ throw Object.assign(new Error(`User already exists: ${email}`), { statusCode: 409, code: "USER_EXISTS" });
697
+ }
698
+ throw error;
699
+ } finally {
700
+ db.close();
701
+ }
702
+ }
703
+
704
+ export async function listUsers(store = createStore()) {
705
+ const db = openDatabase(store);
706
+ try {
707
+ return db.prepare(`
708
+ SELECT id, email, name, role, active, created_at, updated_at
709
+ FROM users
710
+ ORDER BY created_at ASC
711
+ `).all().map(userFromRow);
712
+ } finally {
713
+ db.close();
714
+ }
715
+ }
716
+
717
+ export async function setUserActive(store = createStore(), id, active) {
718
+ const db = openDatabase(store);
719
+ try {
720
+ const now = new Date().toISOString();
721
+ const result = db.prepare("UPDATE users SET active = ?, updated_at = ? WHERE id = ?").run(active ? 1 : 0, now, id);
722
+ if (result.changes === 0) {
723
+ throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
724
+ }
725
+ return userFromRow(db.prepare(`
726
+ SELECT id, email, name, role, active, created_at, updated_at FROM users WHERE id = ?
727
+ `).get(id));
728
+ } finally {
729
+ db.close();
730
+ }
731
+ }
732
+
733
+ export async function verifyUserPassword(store = createStore(), email, password) {
734
+ const db = openDatabase(store);
735
+ try {
736
+ const row = db.prepare(`
737
+ SELECT id, email, name, role, password_hash, active, created_at, updated_at
738
+ FROM users
739
+ WHERE email = ?
740
+ `).get(normalizeEmail(email));
741
+ if (!row || !row.active || !verifyPassword(password, row.password_hash)) {
742
+ return null;
743
+ }
744
+ return userFromRow(row);
745
+ } finally {
746
+ db.close();
747
+ }
748
+ }
749
+
750
+ export async function createSession(store = createStore(), userId, options = {}) {
751
+ const token = generateOpaqueToken("arts");
752
+ const now = new Date();
753
+ const expiresAt = new Date(now.getTime() + Number(options.ttlMs || 7 * 24 * 60 * 60 * 1000)).toISOString();
754
+ const session = {
755
+ id: randomUUID(),
756
+ userId,
757
+ createdAt: now.toISOString(),
758
+ expiresAt
759
+ };
760
+ const db = openDatabase(store);
761
+ try {
762
+ db.prepare(`
763
+ INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at)
764
+ VALUES (?, ?, ?, ?, ?)
765
+ `).run(session.id, userId, hashToken(token), session.createdAt, expiresAt);
766
+ return { token, session };
767
+ } finally {
768
+ db.close();
769
+ }
770
+ }
771
+
772
+ export async function getSessionUser(store = createStore(), token) {
773
+ if (!token) {
774
+ return null;
775
+ }
776
+ const db = openDatabase(store);
777
+ try {
778
+ const row = db.prepare(`
779
+ SELECT s.id AS session_id, s.expires_at, u.id, u.email, u.name, u.role, u.active, u.created_at, u.updated_at
780
+ FROM sessions s
781
+ JOIN users u ON u.id = s.user_id
782
+ WHERE s.token_hash = ? AND s.revoked_at IS NULL
783
+ `).get(hashToken(token));
784
+ if (!row || !row.active || Date.parse(row.expires_at) <= Date.now()) {
785
+ return null;
786
+ }
787
+ return {
788
+ ...userFromRow(row),
789
+ sessionId: row.session_id
790
+ };
791
+ } finally {
792
+ db.close();
793
+ }
794
+ }
795
+
796
+ export async function revokeSession(store = createStore(), token) {
797
+ if (!token) {
798
+ return false;
799
+ }
800
+ const db = openDatabase(store);
801
+ try {
802
+ const result = db.prepare("UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
803
+ .run(new Date().toISOString(), hashToken(token));
804
+ return result.changes > 0;
805
+ } finally {
806
+ db.close();
807
+ }
808
+ }
809
+
810
+ export async function createApiToken(store = createStore(), userId, input = {}) {
811
+ const token = generateOpaqueToken("arty");
812
+ const now = new Date().toISOString();
813
+ const record = {
814
+ id: randomUUID(),
815
+ userId,
816
+ name: normalizeOptionalString(input.name) || "Artifacty token",
817
+ createdAt: now,
818
+ lastUsedAt: null,
819
+ revokedAt: null
820
+ };
821
+ const db = openDatabase(store);
822
+ try {
823
+ db.prepare(`
824
+ INSERT INTO api_tokens (id, user_id, name, token_hash, created_at)
825
+ VALUES (?, ?, ?, ?, ?)
826
+ `).run(record.id, userId, record.name, hashToken(token), now);
827
+ return { token, record };
828
+ } finally {
829
+ db.close();
830
+ }
831
+ }
832
+
833
+ export async function listApiTokens(store = createStore(), userId) {
834
+ const db = openDatabase(store);
835
+ try {
836
+ return db.prepare(`
837
+ SELECT id, user_id, name, created_at, last_used_at, revoked_at
838
+ FROM api_tokens
839
+ WHERE user_id = ?
840
+ ORDER BY created_at DESC
841
+ `).all(userId).map(apiTokenFromRow);
842
+ } finally {
843
+ db.close();
844
+ }
845
+ }
846
+
847
+ export async function revokeApiToken(store = createStore(), tokenId, userId) {
848
+ const db = openDatabase(store);
849
+ try {
850
+ const result = db.prepare(`
851
+ UPDATE api_tokens
852
+ SET revoked_at = ?
853
+ WHERE id = ? AND user_id = ? AND revoked_at IS NULL
854
+ `).run(new Date().toISOString(), tokenId, userId);
855
+ return result.changes > 0;
856
+ } finally {
857
+ db.close();
858
+ }
859
+ }
860
+
861
+ export async function authenticateApiToken(store = createStore(), token) {
862
+ if (!token) {
863
+ return null;
864
+ }
865
+ const db = openDatabase(store);
866
+ try {
867
+ const tokenHash = hashToken(token);
868
+ const row = db.prepare(`
869
+ SELECT t.id AS token_id, t.name AS token_name, u.id, u.email, u.name, u.role, u.active, u.created_at, u.updated_at
870
+ FROM api_tokens t
871
+ JOIN users u ON u.id = t.user_id
872
+ WHERE t.token_hash = ? AND t.revoked_at IS NULL
873
+ `).get(tokenHash);
874
+ if (!row || !row.active) {
875
+ return null;
876
+ }
877
+ db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(new Date().toISOString(), row.token_id);
878
+ return {
879
+ type: "api-token",
880
+ actor: row.email,
881
+ tokenId: row.token_id,
882
+ tokenName: row.token_name,
883
+ user: userFromRow(row)
884
+ };
885
+ } finally {
886
+ db.close();
887
+ }
888
+ }
889
+
656
890
  export async function readArtifactVersion(store, artifact, versionNumber) {
657
891
  const version = artifact.versions.find((item) => item.version === versionNumber);
658
892
  if (!version) {
@@ -798,10 +1032,45 @@ function initializeSchema(db) {
798
1032
  metadata_json TEXT NOT NULL
799
1033
  );
800
1034
 
1035
+ CREATE TABLE IF NOT EXISTS users (
1036
+ id TEXT PRIMARY KEY,
1037
+ email TEXT NOT NULL UNIQUE,
1038
+ name TEXT NOT NULL,
1039
+ role TEXT NOT NULL DEFAULT 'user',
1040
+ password_hash TEXT NOT NULL,
1041
+ active INTEGER NOT NULL DEFAULT 1,
1042
+ created_at TEXT NOT NULL,
1043
+ updated_at TEXT NOT NULL
1044
+ );
1045
+
1046
+ CREATE TABLE IF NOT EXISTS sessions (
1047
+ id TEXT PRIMARY KEY,
1048
+ user_id TEXT NOT NULL,
1049
+ token_hash TEXT NOT NULL UNIQUE,
1050
+ created_at TEXT NOT NULL,
1051
+ expires_at TEXT NOT NULL,
1052
+ revoked_at TEXT,
1053
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
1054
+ );
1055
+
1056
+ CREATE TABLE IF NOT EXISTS api_tokens (
1057
+ id TEXT PRIMARY KEY,
1058
+ user_id TEXT NOT NULL,
1059
+ name TEXT NOT NULL,
1060
+ token_hash TEXT NOT NULL UNIQUE,
1061
+ created_at TEXT NOT NULL,
1062
+ last_used_at TEXT,
1063
+ revoked_at TEXT,
1064
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
1065
+ );
1066
+
801
1067
  CREATE INDEX IF NOT EXISTS idx_artifacts_updated_at ON artifacts(updated_at DESC);
802
1068
  CREATE INDEX IF NOT EXISTS idx_artifacts_source_agent ON artifacts(source_agent);
803
1069
  CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
804
1070
  CREATE INDEX IF NOT EXISTS idx_audit_log_artifact_id ON audit_log(artifact_id);
1071
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
1072
+ CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
1073
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
805
1074
  `);
806
1075
  ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
807
1076
  ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
@@ -1104,6 +1373,29 @@ function auditFromRow(row) {
1104
1373
  };
1105
1374
  }
1106
1375
 
1376
+ function userFromRow(row) {
1377
+ return {
1378
+ id: row.id,
1379
+ email: row.email,
1380
+ name: row.name,
1381
+ role: row.role,
1382
+ active: Boolean(row.active),
1383
+ createdAt: row.created_at,
1384
+ updatedAt: row.updated_at
1385
+ };
1386
+ }
1387
+
1388
+ function apiTokenFromRow(row) {
1389
+ return {
1390
+ id: row.id,
1391
+ userId: row.user_id,
1392
+ name: row.name,
1393
+ createdAt: row.created_at,
1394
+ lastUsedAt: row.last_used_at,
1395
+ revokedAt: row.revoked_at
1396
+ };
1397
+ }
1398
+
1107
1399
  function versionFromRow(row) {
1108
1400
  return {
1109
1401
  version: row.version,
@@ -1280,6 +1572,21 @@ function normalizeMetadata(metadata) {
1280
1572
  return metadata;
1281
1573
  }
1282
1574
 
1575
+ function normalizeEmail(value) {
1576
+ return normalizeOptionalString(value).toLowerCase();
1577
+ }
1578
+
1579
+ function normalizeUserRole(value) {
1580
+ const role = normalizeOptionalString(value) || "user";
1581
+ if (!USER_ROLES.includes(role)) {
1582
+ throw Object.assign(new Error(`Unsupported user role: ${value}`), {
1583
+ statusCode: 400,
1584
+ code: "INVALID_USER_ROLE"
1585
+ });
1586
+ }
1587
+ return role;
1588
+ }
1589
+
1283
1590
  function normalizeOptionalString(value) {
1284
1591
  if (value === undefined || value === null) {
1285
1592
  return "";
@@ -1287,6 +1594,30 @@ function normalizeOptionalString(value) {
1287
1594
  return String(value).trim();
1288
1595
  }
1289
1596
 
1597
+ function generateOpaqueToken(prefix) {
1598
+ return `${prefix}_${randomBytes(32).toString("base64url")}`;
1599
+ }
1600
+
1601
+ function hashToken(token) {
1602
+ return createHash("sha256").update(String(token || "")).digest("hex");
1603
+ }
1604
+
1605
+ function hashPassword(password) {
1606
+ const salt = randomBytes(16).toString("base64url");
1607
+ const hash = scryptSync(String(password), salt, 64).toString("base64url");
1608
+ return `scrypt:${salt}:${hash}`;
1609
+ }
1610
+
1611
+ function verifyPassword(password, stored) {
1612
+ const [scheme, salt, expected] = String(stored || "").split(":");
1613
+ if (scheme !== "scrypt" || !salt || !expected) {
1614
+ return false;
1615
+ }
1616
+ const actualBuffer = scryptSync(String(password), salt, 64);
1617
+ const expectedBuffer = Buffer.from(expected, "base64url");
1618
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
1619
+ }
1620
+
1290
1621
  function inferFormat(contentType) {
1291
1622
  const value = normalizeOptionalString(contentType).toLowerCase();
1292
1623
  if (value.includes("vnd.ant.code") || value.includes("source-code")) {