artifacty 0.7.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 +21 -0
- package/docs/central-team-deployment-design.md +208 -0
- package/docs/integrations.md +32 -1
- package/docs/mcp-public-api.md +13 -4
- package/docs/network-sharing.md +13 -0
- package/docs/release-checklist.md +7 -0
- package/docs/threat-model.md +20 -6
- package/package.json +2 -1
- package/src/cli.js +37 -6
- package/src/lib/background.js +3 -0
- package/src/lib/doctor.js +214 -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/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")) {
|