artifacty 0.8.0 → 0.10.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 +28 -0
- package/docs/central-team-deployment-design.md +212 -0
- package/docs/integrations.md +39 -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 +28 -6
- package/src/lib/background.js +3 -0
- package/src/lib/installer.js +31 -0
- package/src/lib/render.js +371 -1
- package/src/lib/security.js +4 -0
- package/src/lib/service.js +4 -0
- package/src/lib/storage.js +565 -1
- package/src/mcp-server.js +225 -81
- package/src/server.js +412 -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,315 @@ 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 db = openDatabase(store);
|
|
668
|
+
try {
|
|
669
|
+
return insertUser(db, input);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
if (/UNIQUE/i.test(error.message)) {
|
|
672
|
+
throw Object.assign(new Error(`User already exists: ${normalizeEmail(input.email)}`), { statusCode: 409, code: "USER_EXISTS" });
|
|
673
|
+
}
|
|
674
|
+
throw error;
|
|
675
|
+
} finally {
|
|
676
|
+
db.close();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export async function importUsersFromCsv(store = createStore(), csv, options = {}) {
|
|
681
|
+
const records = parseUserCsv(csv);
|
|
682
|
+
const result = {
|
|
683
|
+
created: [],
|
|
684
|
+
skipped: [],
|
|
685
|
+
failed: [],
|
|
686
|
+
totalRows: records.length
|
|
687
|
+
};
|
|
688
|
+
const db = openDatabase(store);
|
|
689
|
+
try {
|
|
690
|
+
for (const record of records) {
|
|
691
|
+
const email = normalizeEmail(record.email);
|
|
692
|
+
if (!email) {
|
|
693
|
+
result.failed.push({
|
|
694
|
+
row: record.row,
|
|
695
|
+
email: "",
|
|
696
|
+
error: "User email is required",
|
|
697
|
+
code: "USER_EMAIL_REQUIRED"
|
|
698
|
+
});
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const providedPassword = normalizeOptionalString(record.password || record.temporary_password);
|
|
703
|
+
const password = providedPassword || generateTemporaryPassword();
|
|
704
|
+
const resetFromCsv = firstDefined(
|
|
705
|
+
record.password_reset_required,
|
|
706
|
+
record.require_password_reset,
|
|
707
|
+
record.force_password_reset,
|
|
708
|
+
record.reset_required
|
|
709
|
+
);
|
|
710
|
+
const passwordResetRequired = !providedPassword || (resetFromCsv === undefined
|
|
711
|
+
? options.passwordResetRequired !== false
|
|
712
|
+
: parseBooleanOption(resetFromCsv));
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const user = insertUser(db, {
|
|
716
|
+
email,
|
|
717
|
+
name: record.name || email,
|
|
718
|
+
role: record.role || "user",
|
|
719
|
+
password,
|
|
720
|
+
passwordResetRequired
|
|
721
|
+
});
|
|
722
|
+
result.created.push({
|
|
723
|
+
user,
|
|
724
|
+
passwordGenerated: !providedPassword,
|
|
725
|
+
temporaryPassword: !providedPassword ? password : undefined
|
|
726
|
+
});
|
|
727
|
+
} catch (error) {
|
|
728
|
+
if (error.code === "USER_EXISTS" || /UNIQUE/i.test(error.message)) {
|
|
729
|
+
result.skipped.push({
|
|
730
|
+
row: record.row,
|
|
731
|
+
email,
|
|
732
|
+
reason: "User already exists"
|
|
733
|
+
});
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
result.failed.push({
|
|
737
|
+
row: record.row,
|
|
738
|
+
email,
|
|
739
|
+
error: error.message,
|
|
740
|
+
code: error.code || "USER_IMPORT_FAILED"
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return result;
|
|
745
|
+
} finally {
|
|
746
|
+
db.close();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
export async function listUsers(store = createStore()) {
|
|
751
|
+
const db = openDatabase(store);
|
|
752
|
+
try {
|
|
753
|
+
return db.prepare(`
|
|
754
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at
|
|
755
|
+
FROM users
|
|
756
|
+
ORDER BY created_at ASC
|
|
757
|
+
`).all().map(userFromRow);
|
|
758
|
+
} finally {
|
|
759
|
+
db.close();
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
export async function setUserActive(store = createStore(), id, active) {
|
|
764
|
+
const db = openDatabase(store);
|
|
765
|
+
try {
|
|
766
|
+
const now = new Date().toISOString();
|
|
767
|
+
const result = db.prepare("UPDATE users SET active = ?, updated_at = ? WHERE id = ?").run(active ? 1 : 0, now, id);
|
|
768
|
+
if (result.changes === 0) {
|
|
769
|
+
throw Object.assign(new Error(`User not found: ${id}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
770
|
+
}
|
|
771
|
+
return userFromRow(db.prepare(`
|
|
772
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at FROM users WHERE id = ?
|
|
773
|
+
`).get(id));
|
|
774
|
+
} finally {
|
|
775
|
+
db.close();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export async function changeUserPassword(store = createStore(), userId, input = {}) {
|
|
780
|
+
const password = String(input.password || input.newPassword || "");
|
|
781
|
+
if (password.length < 8) {
|
|
782
|
+
throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
|
|
783
|
+
}
|
|
784
|
+
const db = openDatabase(store);
|
|
785
|
+
try {
|
|
786
|
+
const existing = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
787
|
+
if (!existing) {
|
|
788
|
+
throw Object.assign(new Error(`User not found: ${userId}`), { statusCode: 404, code: "USER_NOT_FOUND" });
|
|
789
|
+
}
|
|
790
|
+
if (input.currentPassword !== undefined && !verifyPassword(input.currentPassword, existing.password_hash)) {
|
|
791
|
+
throw Object.assign(new Error("Current password is incorrect"), { statusCode: 400, code: "CURRENT_PASSWORD_INVALID" });
|
|
792
|
+
}
|
|
793
|
+
const now = new Date().toISOString();
|
|
794
|
+
db.prepare(`
|
|
795
|
+
UPDATE users
|
|
796
|
+
SET password_hash = ?, password_reset_required = ?, updated_at = ?
|
|
797
|
+
WHERE id = ?
|
|
798
|
+
`).run(hashPassword(password), input.passwordResetRequired ? 1 : 0, now, userId);
|
|
799
|
+
return userFromRow(db.prepare(`
|
|
800
|
+
SELECT id, email, name, role, active, password_reset_required, created_at, updated_at
|
|
801
|
+
FROM users
|
|
802
|
+
WHERE id = ?
|
|
803
|
+
`).get(userId));
|
|
804
|
+
} finally {
|
|
805
|
+
db.close();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function verifyUserPassword(store = createStore(), email, password) {
|
|
810
|
+
const db = openDatabase(store);
|
|
811
|
+
try {
|
|
812
|
+
const row = db.prepare(`
|
|
813
|
+
SELECT id, email, name, role, password_hash, active, password_reset_required, created_at, updated_at
|
|
814
|
+
FROM users
|
|
815
|
+
WHERE email = ?
|
|
816
|
+
`).get(normalizeEmail(email));
|
|
817
|
+
if (!row || !row.active || !verifyPassword(password, row.password_hash)) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
return userFromRow(row);
|
|
821
|
+
} finally {
|
|
822
|
+
db.close();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export async function createSession(store = createStore(), userId, options = {}) {
|
|
827
|
+
const token = generateOpaqueToken("arts");
|
|
828
|
+
const now = new Date();
|
|
829
|
+
const expiresAt = new Date(now.getTime() + Number(options.ttlMs || 7 * 24 * 60 * 60 * 1000)).toISOString();
|
|
830
|
+
const session = {
|
|
831
|
+
id: randomUUID(),
|
|
832
|
+
userId,
|
|
833
|
+
createdAt: now.toISOString(),
|
|
834
|
+
expiresAt
|
|
835
|
+
};
|
|
836
|
+
const db = openDatabase(store);
|
|
837
|
+
try {
|
|
838
|
+
db.prepare(`
|
|
839
|
+
INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at)
|
|
840
|
+
VALUES (?, ?, ?, ?, ?)
|
|
841
|
+
`).run(session.id, userId, hashToken(token), session.createdAt, expiresAt);
|
|
842
|
+
return { token, session };
|
|
843
|
+
} finally {
|
|
844
|
+
db.close();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export async function getSessionUser(store = createStore(), token) {
|
|
849
|
+
if (!token) {
|
|
850
|
+
return null;
|
|
851
|
+
}
|
|
852
|
+
const db = openDatabase(store);
|
|
853
|
+
try {
|
|
854
|
+
const row = db.prepare(`
|
|
855
|
+
SELECT s.id AS session_id, s.expires_at, u.id, u.email, u.name, u.role, u.active, u.password_reset_required, u.created_at, u.updated_at
|
|
856
|
+
FROM sessions s
|
|
857
|
+
JOIN users u ON u.id = s.user_id
|
|
858
|
+
WHERE s.token_hash = ? AND s.revoked_at IS NULL
|
|
859
|
+
`).get(hashToken(token));
|
|
860
|
+
if (!row || !row.active || Date.parse(row.expires_at) <= Date.now()) {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
return {
|
|
864
|
+
...userFromRow(row),
|
|
865
|
+
sessionId: row.session_id
|
|
866
|
+
};
|
|
867
|
+
} finally {
|
|
868
|
+
db.close();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export async function revokeSession(store = createStore(), token) {
|
|
873
|
+
if (!token) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
const db = openDatabase(store);
|
|
877
|
+
try {
|
|
878
|
+
const result = db.prepare("UPDATE sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL")
|
|
879
|
+
.run(new Date().toISOString(), hashToken(token));
|
|
880
|
+
return result.changes > 0;
|
|
881
|
+
} finally {
|
|
882
|
+
db.close();
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export async function createApiToken(store = createStore(), userId, input = {}) {
|
|
887
|
+
const token = generateOpaqueToken("arty");
|
|
888
|
+
const now = new Date().toISOString();
|
|
889
|
+
const record = {
|
|
890
|
+
id: randomUUID(),
|
|
891
|
+
userId,
|
|
892
|
+
name: normalizeOptionalString(input.name) || "Artifacty token",
|
|
893
|
+
createdAt: now,
|
|
894
|
+
lastUsedAt: null,
|
|
895
|
+
revokedAt: null
|
|
896
|
+
};
|
|
897
|
+
const db = openDatabase(store);
|
|
898
|
+
try {
|
|
899
|
+
db.prepare(`
|
|
900
|
+
INSERT INTO api_tokens (id, user_id, name, token_hash, created_at)
|
|
901
|
+
VALUES (?, ?, ?, ?, ?)
|
|
902
|
+
`).run(record.id, userId, record.name, hashToken(token), now);
|
|
903
|
+
return { token, record };
|
|
904
|
+
} finally {
|
|
905
|
+
db.close();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export async function listApiTokens(store = createStore(), userId) {
|
|
910
|
+
const db = openDatabase(store);
|
|
911
|
+
try {
|
|
912
|
+
return db.prepare(`
|
|
913
|
+
SELECT id, user_id, name, created_at, last_used_at, revoked_at
|
|
914
|
+
FROM api_tokens
|
|
915
|
+
WHERE user_id = ?
|
|
916
|
+
ORDER BY created_at DESC
|
|
917
|
+
`).all(userId).map(apiTokenFromRow);
|
|
918
|
+
} finally {
|
|
919
|
+
db.close();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export async function revokeApiToken(store = createStore(), tokenId, userId) {
|
|
924
|
+
const db = openDatabase(store);
|
|
925
|
+
try {
|
|
926
|
+
const result = db.prepare(`
|
|
927
|
+
UPDATE api_tokens
|
|
928
|
+
SET revoked_at = ?
|
|
929
|
+
WHERE id = ? AND user_id = ? AND revoked_at IS NULL
|
|
930
|
+
`).run(new Date().toISOString(), tokenId, userId);
|
|
931
|
+
return result.changes > 0;
|
|
932
|
+
} finally {
|
|
933
|
+
db.close();
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
export async function authenticateApiToken(store = createStore(), token) {
|
|
938
|
+
if (!token) {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
const db = openDatabase(store);
|
|
942
|
+
try {
|
|
943
|
+
const tokenHash = hashToken(token);
|
|
944
|
+
const row = db.prepare(`
|
|
945
|
+
SELECT t.id AS token_id, t.name AS token_name, u.id, u.email, u.name, u.role, u.active, u.password_reset_required, u.created_at, u.updated_at
|
|
946
|
+
FROM api_tokens t
|
|
947
|
+
JOIN users u ON u.id = t.user_id
|
|
948
|
+
WHERE t.token_hash = ? AND t.revoked_at IS NULL
|
|
949
|
+
`).get(tokenHash);
|
|
950
|
+
if (!row || !row.active) {
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
db.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(new Date().toISOString(), row.token_id);
|
|
954
|
+
return {
|
|
955
|
+
type: "api-token",
|
|
956
|
+
actor: row.email,
|
|
957
|
+
tokenId: row.token_id,
|
|
958
|
+
tokenName: row.token_name,
|
|
959
|
+
user: userFromRow(row)
|
|
960
|
+
};
|
|
961
|
+
} finally {
|
|
962
|
+
db.close();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
656
966
|
export async function readArtifactVersion(store, artifact, versionNumber) {
|
|
657
967
|
const version = artifact.versions.find((item) => item.version === versionNumber);
|
|
658
968
|
if (!version) {
|
|
@@ -798,14 +1108,51 @@ function initializeSchema(db) {
|
|
|
798
1108
|
metadata_json TEXT NOT NULL
|
|
799
1109
|
);
|
|
800
1110
|
|
|
1111
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
1112
|
+
id TEXT PRIMARY KEY,
|
|
1113
|
+
email TEXT NOT NULL UNIQUE,
|
|
1114
|
+
name TEXT NOT NULL,
|
|
1115
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
1116
|
+
password_hash TEXT NOT NULL,
|
|
1117
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
1118
|
+
password_reset_required INTEGER NOT NULL DEFAULT 0,
|
|
1119
|
+
created_at TEXT NOT NULL,
|
|
1120
|
+
updated_at TEXT NOT NULL
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
1124
|
+
id TEXT PRIMARY KEY,
|
|
1125
|
+
user_id TEXT NOT NULL,
|
|
1126
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
1127
|
+
created_at TEXT NOT NULL,
|
|
1128
|
+
expires_at TEXT NOT NULL,
|
|
1129
|
+
revoked_at TEXT,
|
|
1130
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
1131
|
+
);
|
|
1132
|
+
|
|
1133
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
1134
|
+
id TEXT PRIMARY KEY,
|
|
1135
|
+
user_id TEXT NOT NULL,
|
|
1136
|
+
name TEXT NOT NULL,
|
|
1137
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
1138
|
+
created_at TEXT NOT NULL,
|
|
1139
|
+
last_used_at TEXT,
|
|
1140
|
+
revoked_at TEXT,
|
|
1141
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
1142
|
+
);
|
|
1143
|
+
|
|
801
1144
|
CREATE INDEX IF NOT EXISTS idx_artifacts_updated_at ON artifacts(updated_at DESC);
|
|
802
1145
|
CREATE INDEX IF NOT EXISTS idx_artifacts_source_agent ON artifacts(source_agent);
|
|
803
1146
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at DESC);
|
|
804
1147
|
CREATE INDEX IF NOT EXISTS idx_audit_log_artifact_id ON audit_log(artifact_id);
|
|
1148
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
1149
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
|
1150
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
|
805
1151
|
`);
|
|
806
1152
|
ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
|
|
807
1153
|
ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
|
|
808
1154
|
ensureColumn(db, "artifacts", "archived_at", "TEXT");
|
|
1155
|
+
ensureColumn(db, "users", "password_reset_required", "INTEGER NOT NULL DEFAULT 0");
|
|
809
1156
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
|
|
810
1157
|
ensureSearchTable(db);
|
|
811
1158
|
}
|
|
@@ -1104,6 +1451,30 @@ function auditFromRow(row) {
|
|
|
1104
1451
|
};
|
|
1105
1452
|
}
|
|
1106
1453
|
|
|
1454
|
+
function userFromRow(row) {
|
|
1455
|
+
return {
|
|
1456
|
+
id: row.id,
|
|
1457
|
+
email: row.email,
|
|
1458
|
+
name: row.name,
|
|
1459
|
+
role: row.role,
|
|
1460
|
+
active: Boolean(row.active),
|
|
1461
|
+
passwordResetRequired: Boolean(row.password_reset_required),
|
|
1462
|
+
createdAt: row.created_at,
|
|
1463
|
+
updatedAt: row.updated_at
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function apiTokenFromRow(row) {
|
|
1468
|
+
return {
|
|
1469
|
+
id: row.id,
|
|
1470
|
+
userId: row.user_id,
|
|
1471
|
+
name: row.name,
|
|
1472
|
+
createdAt: row.created_at,
|
|
1473
|
+
lastUsedAt: row.last_used_at,
|
|
1474
|
+
revokedAt: row.revoked_at
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1107
1478
|
function versionFromRow(row) {
|
|
1108
1479
|
return {
|
|
1109
1480
|
version: row.version,
|
|
@@ -1280,6 +1651,171 @@ function normalizeMetadata(metadata) {
|
|
|
1280
1651
|
return metadata;
|
|
1281
1652
|
}
|
|
1282
1653
|
|
|
1654
|
+
function normalizeEmail(value) {
|
|
1655
|
+
return normalizeOptionalString(value).toLowerCase();
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function normalizeUserRole(value) {
|
|
1659
|
+
const role = normalizeOptionalString(value) || "user";
|
|
1660
|
+
if (!USER_ROLES.includes(role)) {
|
|
1661
|
+
throw Object.assign(new Error(`Unsupported user role: ${value}`), {
|
|
1662
|
+
statusCode: 400,
|
|
1663
|
+
code: "INVALID_USER_ROLE"
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
return role;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function insertUser(db, input = {}) {
|
|
1670
|
+
const email = normalizeEmail(input.email);
|
|
1671
|
+
const password = String(input.password || "");
|
|
1672
|
+
if (!email) {
|
|
1673
|
+
throw Object.assign(new Error("User email is required"), { statusCode: 400, code: "USER_EMAIL_REQUIRED" });
|
|
1674
|
+
}
|
|
1675
|
+
if (password.length < 8) {
|
|
1676
|
+
throw Object.assign(new Error("User password must be at least 8 characters"), { statusCode: 400, code: "USER_PASSWORD_WEAK" });
|
|
1677
|
+
}
|
|
1678
|
+
const role = normalizeUserRole(input.role || "user");
|
|
1679
|
+
const name = normalizeOptionalString(input.name) || email;
|
|
1680
|
+
const now = new Date().toISOString();
|
|
1681
|
+
const user = {
|
|
1682
|
+
id: randomUUID(),
|
|
1683
|
+
email,
|
|
1684
|
+
name,
|
|
1685
|
+
role,
|
|
1686
|
+
active: true,
|
|
1687
|
+
passwordResetRequired: Boolean(input.passwordResetRequired),
|
|
1688
|
+
createdAt: now,
|
|
1689
|
+
updatedAt: now
|
|
1690
|
+
};
|
|
1691
|
+
try {
|
|
1692
|
+
db.prepare(`
|
|
1693
|
+
INSERT INTO users (id, email, name, role, password_hash, active, password_reset_required, created_at, updated_at)
|
|
1694
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
1695
|
+
`).run(
|
|
1696
|
+
user.id,
|
|
1697
|
+
user.email,
|
|
1698
|
+
user.name,
|
|
1699
|
+
user.role,
|
|
1700
|
+
hashPassword(password),
|
|
1701
|
+
user.passwordResetRequired ? 1 : 0,
|
|
1702
|
+
now,
|
|
1703
|
+
now
|
|
1704
|
+
);
|
|
1705
|
+
return user;
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
if (/UNIQUE/i.test(error.message)) {
|
|
1708
|
+
throw Object.assign(new Error(`User already exists: ${email}`), { statusCode: 409, code: "USER_EXISTS" });
|
|
1709
|
+
}
|
|
1710
|
+
throw error;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function parseUserCsv(csv) {
|
|
1715
|
+
const rows = parseCsvRows(csv).filter((row) => row.some((cell) => normalizeOptionalString(cell)));
|
|
1716
|
+
if (rows.length === 0) {
|
|
1717
|
+
return [];
|
|
1718
|
+
}
|
|
1719
|
+
const headers = rows[0].map(normalizeCsvHeader);
|
|
1720
|
+
if (!headers.includes("email")) {
|
|
1721
|
+
throw Object.assign(new Error("User CSV requires an email header"), {
|
|
1722
|
+
statusCode: 400,
|
|
1723
|
+
code: "USER_CSV_EMAIL_REQUIRED"
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
return rows.slice(1).map((row, index) => {
|
|
1727
|
+
const record = { row: index + 2 };
|
|
1728
|
+
headers.forEach((header, columnIndex) => {
|
|
1729
|
+
if (header) {
|
|
1730
|
+
record[header] = normalizeOptionalString(row[columnIndex]);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
return record;
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function parseCsvRows(csv) {
|
|
1738
|
+
const text = String(csv || "").replace(/^\uFEFF/, "");
|
|
1739
|
+
const rows = [];
|
|
1740
|
+
let row = [];
|
|
1741
|
+
let cell = "";
|
|
1742
|
+
let quoted = false;
|
|
1743
|
+
|
|
1744
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1745
|
+
const char = text[index];
|
|
1746
|
+
if (quoted) {
|
|
1747
|
+
if (char === "\"" && text[index + 1] === "\"") {
|
|
1748
|
+
cell += "\"";
|
|
1749
|
+
index += 1;
|
|
1750
|
+
} else if (char === "\"") {
|
|
1751
|
+
quoted = false;
|
|
1752
|
+
} else {
|
|
1753
|
+
cell += char;
|
|
1754
|
+
}
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
if (char === "\"") {
|
|
1759
|
+
quoted = true;
|
|
1760
|
+
} else if (char === ",") {
|
|
1761
|
+
row.push(cell);
|
|
1762
|
+
cell = "";
|
|
1763
|
+
} else if (char === "\n") {
|
|
1764
|
+
row.push(cell.replace(/\r$/, ""));
|
|
1765
|
+
rows.push(row);
|
|
1766
|
+
row = [];
|
|
1767
|
+
cell = "";
|
|
1768
|
+
} else {
|
|
1769
|
+
cell += char;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (quoted) {
|
|
1774
|
+
throw Object.assign(new Error("User CSV has an unterminated quoted field"), {
|
|
1775
|
+
statusCode: 400,
|
|
1776
|
+
code: "USER_CSV_INVALID"
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
row.push(cell.replace(/\r$/, ""));
|
|
1780
|
+
rows.push(row);
|
|
1781
|
+
return rows;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
function normalizeCsvHeader(value) {
|
|
1785
|
+
const header = normalizeOptionalString(value).toLowerCase().replace(/[\s-]+/g, "_");
|
|
1786
|
+
if (header === "mail" || header === "email_address") {
|
|
1787
|
+
return "email";
|
|
1788
|
+
}
|
|
1789
|
+
if (header === "display_name" || header === "full_name") {
|
|
1790
|
+
return "name";
|
|
1791
|
+
}
|
|
1792
|
+
if (header === "temporary_password" || header === "temp_password") {
|
|
1793
|
+
return "temporary_password";
|
|
1794
|
+
}
|
|
1795
|
+
if (header === "force_reset" || header === "must_change_password") {
|
|
1796
|
+
return "password_reset_required";
|
|
1797
|
+
}
|
|
1798
|
+
return header;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function firstDefined(...values) {
|
|
1802
|
+
return values.find((value) => value !== undefined && value !== "");
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function parseBooleanOption(value) {
|
|
1806
|
+
const normalized = normalizeOptionalString(value).toLowerCase();
|
|
1807
|
+
if (["1", "true", "yes", "y", "on"].includes(normalized)) {
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
if (["0", "false", "no", "n", "off"].includes(normalized)) {
|
|
1811
|
+
return false;
|
|
1812
|
+
}
|
|
1813
|
+
throw Object.assign(new Error(`Invalid boolean value: ${value}`), {
|
|
1814
|
+
statusCode: 400,
|
|
1815
|
+
code: "INVALID_BOOLEAN"
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1283
1819
|
function normalizeOptionalString(value) {
|
|
1284
1820
|
if (value === undefined || value === null) {
|
|
1285
1821
|
return "";
|
|
@@ -1287,6 +1823,34 @@ function normalizeOptionalString(value) {
|
|
|
1287
1823
|
return String(value).trim();
|
|
1288
1824
|
}
|
|
1289
1825
|
|
|
1826
|
+
function generateTemporaryPassword() {
|
|
1827
|
+
return `tmp_${randomBytes(18).toString("base64url")}`;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function generateOpaqueToken(prefix) {
|
|
1831
|
+
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function hashToken(token) {
|
|
1835
|
+
return createHash("sha256").update(String(token || "")).digest("hex");
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function hashPassword(password) {
|
|
1839
|
+
const salt = randomBytes(16).toString("base64url");
|
|
1840
|
+
const hash = scryptSync(String(password), salt, 64).toString("base64url");
|
|
1841
|
+
return `scrypt:${salt}:${hash}`;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function verifyPassword(password, stored) {
|
|
1845
|
+
const [scheme, salt, expected] = String(stored || "").split(":");
|
|
1846
|
+
if (scheme !== "scrypt" || !salt || !expected) {
|
|
1847
|
+
return false;
|
|
1848
|
+
}
|
|
1849
|
+
const actualBuffer = scryptSync(String(password), salt, 64);
|
|
1850
|
+
const expectedBuffer = Buffer.from(expected, "base64url");
|
|
1851
|
+
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1290
1854
|
function inferFormat(contentType) {
|
|
1291
1855
|
const value = normalizeOptionalString(contentType).toLowerCase();
|
|
1292
1856
|
if (value.includes("vnd.ant.code") || value.includes("source-code")) {
|