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.
@@ -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")) {