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/README.md +13 -0
- package/docs/central-team-deployment-design.md +208 -0
- package/docs/integrations.md +22 -1
- package/docs/mcp-public-api.md +13 -4
- package/docs/network-sharing.md +13 -0
- package/docs/threat-model.md +20 -6
- package/package.json +2 -1
- package/src/cli.js +14 -6
- package/src/lib/background.js +3 -0
- package/src/lib/installer.js +31 -0
- package/src/lib/render.js +250 -1
- package/src/lib/security.js +4 -0
- package/src/lib/service.js +4 -0
- package/src/lib/storage.js +332 -1
- package/src/mcp-server.js +225 -81
- package/src/server.js +317 -13
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("&", "&")
|
package/src/lib/security.js
CHANGED
|
@@ -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 = [];
|
package/src/lib/service.js
CHANGED
|
@@ -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
|
}
|
package/src/lib/storage.js
CHANGED
|
@@ -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")) {
|