553 lines
20 KiB
JavaScript
553 lines
20 KiB
JavaScript
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
createHash,
|
|
randomBytes,
|
|
randomUUID
|
|
} from "node:crypto";
|
|
import {mkdirSync, readFileSync} from "node:fs";
|
|
import {DatabaseSync} from "node:sqlite";
|
|
import {normalizeProvider} from "./providers.mjs";
|
|
|
|
function now() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function limitedString(value, field, maximum = 120) {
|
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
|
return value.trim().slice(0, maximum);
|
|
}
|
|
|
|
function secretBuffer(value) {
|
|
let normalized;
|
|
if (typeof value === "string") {
|
|
if (!value) throw new Error("secret is required");
|
|
normalized = {provider: {apiKey: value}};
|
|
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
normalized = value;
|
|
} else {
|
|
throw new Error("secret is required");
|
|
}
|
|
const buffer = Buffer.from(JSON.stringify(normalized));
|
|
if (buffer.length > 65536) throw new Error("secret is too large");
|
|
return buffer;
|
|
}
|
|
|
|
function parseSecret(buffer) {
|
|
const text = buffer.toString("utf8");
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
} catch {}
|
|
return {provider: {apiKey: text}};
|
|
}
|
|
|
|
export function loadKeyFile(path) {
|
|
const value = readFileSync(path);
|
|
const text = value.toString("utf8").trim();
|
|
const decoded = /^[A-Fa-f0-9]{64}$/.test(text) ? Buffer.from(text, "hex") : Buffer.from(text, "base64");
|
|
if (decoded.length !== 32) {
|
|
throw new Error(`Key file ${path} must contain exactly 32 random bytes encoded as base64 or hex`);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
function encrypt(key, plaintext, associatedData) {
|
|
const nonce = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
cipher.setAAD(Buffer.from(associatedData));
|
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
return {ciphertext, nonce, tag: cipher.getAuthTag()};
|
|
}
|
|
|
|
function decrypt(key, ciphertext, nonce, tag, associatedData) {
|
|
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
decipher.setAAD(Buffer.from(associatedData));
|
|
decipher.setAuthTag(tag);
|
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
}
|
|
|
|
function credentialAad(record) {
|
|
return `credential:v1:${record.id}:${record.owner_issuer}:${record.owner_sub}:${record.provider}`;
|
|
}
|
|
|
|
function wrappedDekAad(record) {
|
|
return `wrapped-dek:v1:${record.id}`;
|
|
}
|
|
|
|
function fingerprint(secret) {
|
|
return createHash("sha256").update(secret).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function publicCredential(row) {
|
|
return {
|
|
id: row.id,
|
|
owner: {
|
|
issuer: row.owner_issuer,
|
|
sub: row.owner_sub,
|
|
username: row.owner_username
|
|
},
|
|
providerId: row.provider,
|
|
name: row.name,
|
|
fingerprint: row.fingerprint,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
lastAccessedAt: row.last_used_at
|
|
};
|
|
}
|
|
|
|
export class Vault {
|
|
constructor({databasePath, masterKey, builtinProviders}) {
|
|
this.masterKey = masterKey;
|
|
this.builtinProviders = builtinProviders;
|
|
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
|
|
this.database = new DatabaseSync(databasePath);
|
|
this.database.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA busy_timeout = 5000;
|
|
|
|
CREATE TABLE IF NOT EXISTS vault_credential (
|
|
id TEXT PRIMARY KEY,
|
|
owner_issuer TEXT NOT NULL,
|
|
owner_sub TEXT NOT NULL,
|
|
owner_username TEXT NOT NULL,
|
|
owner_type TEXT NOT NULL DEFAULT 'user',
|
|
provider TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
ciphertext BLOB NOT NULL,
|
|
nonce BLOB NOT NULL,
|
|
auth_tag BLOB NOT NULL,
|
|
wrapped_dek BLOB NOT NULL,
|
|
wrap_nonce BLOB NOT NULL,
|
|
wrap_tag BLOB NOT NULL,
|
|
key_version INTEGER NOT NULL DEFAULT 1,
|
|
fingerprint TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
last_used_at TEXT,
|
|
revoked_at TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS vault_credential_owner
|
|
ON vault_credential(owner_issuer, owner_sub, provider, name);
|
|
|
|
CREATE TABLE IF NOT EXISTS custom_provider (
|
|
id TEXT PRIMARY KEY,
|
|
owner_issuer TEXT NOT NULL,
|
|
owner_sub TEXT NOT NULL,
|
|
owner_username TEXT NOT NULL,
|
|
provider_id TEXT NOT NULL,
|
|
definition_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(owner_issuer, owner_sub, provider_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS custom_provider_owner
|
|
ON custom_provider(owner_issuer, owner_sub, provider_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS vault_audit_event (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
actor_issuer TEXT NOT NULL,
|
|
actor_sub TEXT NOT NULL,
|
|
actor_username TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT,
|
|
owner_issuer TEXT,
|
|
owner_sub TEXT,
|
|
result TEXT NOT NULL,
|
|
detail TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS vault_audit_owner_time
|
|
ON vault_audit_event(owner_issuer, owner_sub, created_at DESC);
|
|
|
|
DROP TABLE IF EXISTS credential_grant;
|
|
UPDATE vault_credential SET status = 'active', revoked_at = NULL;
|
|
`);
|
|
}
|
|
|
|
close() {
|
|
this.database.close();
|
|
}
|
|
|
|
audit(actor, action, targetType, targetId, owner, result = "success", detail = null) {
|
|
this.database.prepare(`
|
|
INSERT INTO vault_audit_event (
|
|
actor_issuer, actor_sub, actor_username, action, target_type, target_id,
|
|
owner_issuer, owner_sub, result, detail, created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
actor.issuer,
|
|
actor.sub,
|
|
actor.username,
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
owner?.issuer || null,
|
|
owner?.sub || null,
|
|
result,
|
|
detail,
|
|
now()
|
|
);
|
|
}
|
|
|
|
ownerFromInput(actor, input) {
|
|
return input.owner && actor.admin
|
|
? {
|
|
issuer: limitedString(input.owner.issuer, "owner.issuer", 300),
|
|
sub: limitedString(input.owner.sub, "owner.sub", 200),
|
|
username: limitedString(input.owner.username, "owner.username", 80)
|
|
}
|
|
: {issuer: actor.issuer, sub: actor.sub, username: actor.username};
|
|
}
|
|
|
|
customProviderDefinition(value) {
|
|
return normalizeProvider(JSON.parse(value));
|
|
}
|
|
|
|
listCustomProviders(actor) {
|
|
return this.database.prepare(`
|
|
SELECT * FROM custom_provider
|
|
WHERE owner_issuer = ? AND owner_sub = ?
|
|
ORDER BY provider_id COLLATE NOCASE
|
|
`).all(actor.issuer, actor.sub).map((row) => ({
|
|
...this.customProviderDefinition(row.definition_json),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}));
|
|
}
|
|
|
|
getProviderForOwner(owner, providerId) {
|
|
const id = limitedString(providerId, "providerId", 80).toLowerCase();
|
|
const custom = this.database.prepare(`
|
|
SELECT definition_json FROM custom_provider
|
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
|
`).get(owner.issuer, owner.sub, id);
|
|
return custom ? this.customProviderDefinition(custom.definition_json) : this.builtinProviders.get(id) || null;
|
|
}
|
|
|
|
getProvider(actor, providerId) {
|
|
return this.getProviderForOwner(actor, providerId);
|
|
}
|
|
|
|
listProviders(actor) {
|
|
const effective = new Map([...this.builtinProviders].map(([id, provider]) => [id, {...provider}]));
|
|
for (const provider of this.listCustomProviders(actor)) effective.set(provider.id, provider);
|
|
const credentials = this.listCredentials(actor);
|
|
return [...effective.values()]
|
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
.map((provider) => ({
|
|
...provider,
|
|
credentials: provider.connection.type === "backend"
|
|
? credentials.filter((credential) => credential.providerId === provider.id)
|
|
: [],
|
|
credentialState: provider.connection.type === "frontend"
|
|
? "local"
|
|
: credentials.some((credential) => credential.providerId === provider.id) ? "configured" : "missing"
|
|
}));
|
|
}
|
|
|
|
saveCustomProvider(actor, provider) {
|
|
const timestamp = now();
|
|
const existing = this.database.prepare(`
|
|
SELECT id, created_at FROM custom_provider
|
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
|
`).get(actor.issuer, actor.sub, provider.id);
|
|
const id = existing?.id || randomUUID();
|
|
this.database.prepare(`
|
|
INSERT INTO custom_provider (
|
|
id, owner_issuer, owner_sub, owner_username, provider_id, definition_json, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(owner_issuer, owner_sub, provider_id) DO UPDATE SET
|
|
owner_username = excluded.owner_username,
|
|
definition_json = excluded.definition_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
id,
|
|
actor.issuer,
|
|
actor.sub,
|
|
actor.username,
|
|
provider.id,
|
|
JSON.stringify({...provider, builtin: false}),
|
|
existing?.created_at || timestamp,
|
|
timestamp
|
|
);
|
|
this.audit(actor, existing ? "provider.update" : "provider.create", "provider", provider.id, actor, "success", provider.connection.type);
|
|
return this.getProvider(actor, provider.id);
|
|
}
|
|
|
|
deleteCustomProvider(actor, providerId) {
|
|
const id = limitedString(providerId, "providerId", 80).toLowerCase();
|
|
const result = this.database.prepare(`
|
|
DELETE FROM custom_provider WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
|
`).run(actor.issuer, actor.sub, id);
|
|
if (!result.changes) {
|
|
const error = new Error("Custom provider not found");
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
this.audit(actor, "provider.delete", "provider", id, actor);
|
|
return {deleted: true, id};
|
|
}
|
|
|
|
listCredentials(actor, {all = false} = {}) {
|
|
const rows = all && actor.admin
|
|
? this.database.prepare(`SELECT * FROM vault_credential ORDER BY owner_username, provider, name`).all()
|
|
: this.database.prepare(`
|
|
SELECT * FROM vault_credential
|
|
WHERE owner_issuer = ? AND owner_sub = ?
|
|
ORDER BY provider, name
|
|
`).all(actor.issuer, actor.sub);
|
|
return rows.map(publicCredential);
|
|
}
|
|
|
|
getCredential(id) {
|
|
return this.database.prepare("SELECT * FROM vault_credential WHERE id = ?").get(id);
|
|
}
|
|
|
|
assertAccess(actor, row) {
|
|
if (!row || (!actor.admin && (row.owner_issuer !== actor.issuer || row.owner_sub !== actor.sub))) {
|
|
const error = new Error("Credential not found");
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
createCredential(actor, input) {
|
|
const owner = this.ownerFromInput(actor, input);
|
|
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
|
|
const provider = this.getProviderForOwner(owner, providerId);
|
|
if (!provider) {
|
|
const error = new Error("Provider not found");
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
if (provider.connection.type !== "backend") {
|
|
const error = new Error("Frontend credentials must remain in the browser");
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const name = limitedString(input.name || "default", "name", 120);
|
|
const duplicate = this.database.prepare(`
|
|
SELECT id FROM vault_credential WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
|
|
`).get(owner.issuer, owner.sub, providerId, name);
|
|
if (duplicate) {
|
|
const error = new Error("Credential already exists");
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const id = randomUUID();
|
|
const secret = secretBuffer(input.secret);
|
|
const record = {id, owner_issuer: owner.issuer, owner_sub: owner.sub, provider: providerId};
|
|
const dek = randomBytes(32);
|
|
const encryptedSecret = encrypt(dek, secret, credentialAad(record));
|
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(record));
|
|
const timestamp = now();
|
|
this.database.prepare(`
|
|
INSERT INTO vault_credential (
|
|
id, owner_issuer, owner_sub, owner_username, owner_type, provider, name,
|
|
ciphertext, nonce, auth_tag, wrapped_dek, wrap_nonce, wrap_tag,
|
|
fingerprint, status, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
|
`).run(
|
|
id, owner.issuer, owner.sub, owner.username, providerId, name,
|
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
|
|
fingerprint(secret), timestamp, timestamp
|
|
);
|
|
secret.fill(0);
|
|
dek.fill(0);
|
|
this.audit(actor, "credential.create", "credential", id, owner, "success", providerId);
|
|
return publicCredential(this.getCredential(id));
|
|
}
|
|
|
|
decryptCredential(row) {
|
|
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
|
|
try {
|
|
return decrypt(dek, row.ciphertext, row.nonce, row.auth_tag, credentialAad(row));
|
|
} finally {
|
|
dek.fill(0);
|
|
}
|
|
}
|
|
|
|
replaceCredential(actor, id, input) {
|
|
const row = this.getCredential(id);
|
|
this.assertAccess(actor, row);
|
|
const secret = secretBuffer(input.secret);
|
|
const dek = randomBytes(32);
|
|
const encryptedSecret = encrypt(dek, secret, credentialAad(row));
|
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(row));
|
|
const timestamp = now();
|
|
this.database.prepare(`
|
|
UPDATE vault_credential
|
|
SET ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?,
|
|
fingerprint = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
|
|
fingerprint(secret), timestamp, id
|
|
);
|
|
secret.fill(0);
|
|
dek.fill(0);
|
|
this.audit(actor, "credential.replace", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
|
|
return publicCredential(this.getCredential(id));
|
|
}
|
|
|
|
verifyCredential(actor, id) {
|
|
const row = this.getCredential(id);
|
|
this.assertAccess(actor, row);
|
|
const plaintext = this.decryptCredential(row);
|
|
const verified = fingerprint(plaintext) === row.fingerprint;
|
|
plaintext.fill(0);
|
|
this.audit(actor, "credential.verify", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}, verified ? "success" : "failure");
|
|
return {verified, fingerprint: row.fingerprint};
|
|
}
|
|
|
|
deleteCredential(actor, id) {
|
|
const row = this.getCredential(id);
|
|
this.assertAccess(actor, row);
|
|
this.database.prepare("DELETE FROM vault_credential WHERE id = ?").run(id);
|
|
this.audit(actor, "credential.delete", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
|
|
return {deleted: true, id};
|
|
}
|
|
|
|
reassignCredential(actor, id, ownerInput) {
|
|
if (!actor.admin) {
|
|
const error = new Error("Administrator required");
|
|
error.statusCode = 403;
|
|
throw error;
|
|
}
|
|
const row = this.getCredential(id);
|
|
this.assertAccess(actor, row);
|
|
const owner = {
|
|
issuer: limitedString(ownerInput.issuer, "owner.issuer", 300),
|
|
sub: limitedString(ownerInput.sub, "owner.sub", 200),
|
|
username: limitedString(ownerInput.username, "owner.username", 80)
|
|
};
|
|
const provider = this.getProviderForOwner(owner, row.provider);
|
|
if (!provider || provider.connection.type !== "backend") {
|
|
const error = new Error("Target owner has no matching Backend Provider");
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const plaintext = this.decryptCredential(row);
|
|
const next = {...row, owner_issuer: owner.issuer, owner_sub: owner.sub};
|
|
const dek = randomBytes(32);
|
|
const encryptedSecret = encrypt(dek, plaintext, credentialAad(next));
|
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(next));
|
|
this.database.prepare(`
|
|
UPDATE vault_credential
|
|
SET owner_issuer = ?, owner_sub = ?, owner_username = ?,
|
|
ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
owner.issuer, owner.sub, owner.username,
|
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, now(), id
|
|
);
|
|
plaintext.fill(0);
|
|
dek.fill(0);
|
|
this.audit(actor, "credential.reassign", "credential", id, owner, "success", `previous owner ${row.owner_username}`);
|
|
return publicCredential(this.getCredential(id));
|
|
}
|
|
|
|
resolve(actor, input) {
|
|
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
|
|
const provider = this.getProvider(actor, providerId);
|
|
if (!provider) {
|
|
const error = new Error("Provider not found");
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
if (provider.connection.type !== "backend") {
|
|
const error = new Error("Frontend credentials are stored in the browser");
|
|
error.statusCode = 409;
|
|
throw error;
|
|
}
|
|
const name = limitedString(input.credentialName || "default", "credentialName", 120);
|
|
const row = this.database.prepare(`
|
|
SELECT * FROM vault_credential
|
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
|
|
`).get(actor.issuer, actor.sub, providerId, name);
|
|
if (!row) {
|
|
const error = new Error("Credential not found");
|
|
error.statusCode = 404;
|
|
throw error;
|
|
}
|
|
const plaintext = this.decryptCredential(row);
|
|
const secret = parseSecret(plaintext);
|
|
plaintext.fill(0);
|
|
this.database.prepare("UPDATE vault_credential SET last_used_at = ? WHERE id = ?").run(now(), row.id);
|
|
this.audit(actor, "credential.resolve", "credential", row.id, actor, "success", providerId);
|
|
return {provider, credential: {id: row.id, name: row.name, secret}};
|
|
}
|
|
|
|
listAudit(actor, {all = false, limit = 100} = {}) {
|
|
const boundedLimit = Math.max(1, Math.min(500, Number.parseInt(limit, 10) || 100));
|
|
const rows = all && actor.admin
|
|
? this.database.prepare("SELECT * FROM vault_audit_event ORDER BY created_at DESC LIMIT ?").all(boundedLimit)
|
|
: this.database.prepare(`
|
|
SELECT * FROM vault_audit_event
|
|
WHERE owner_issuer = ? AND owner_sub = ?
|
|
ORDER BY created_at DESC LIMIT ?
|
|
`).all(actor.issuer, actor.sub, boundedLimit);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
actorUsername: row.actor_username,
|
|
action: row.action,
|
|
targetType: row.target_type,
|
|
targetId: row.target_id,
|
|
result: row.result,
|
|
detail: row.detail,
|
|
createdAt: row.created_at
|
|
}));
|
|
}
|
|
|
|
rotateMasterKey(actor, newMasterKey) {
|
|
if (!actor.admin) throw new Error("Administrator required");
|
|
const rows = this.database.prepare("SELECT * FROM vault_credential").all();
|
|
this.database.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const update = this.database.prepare(`
|
|
UPDATE vault_credential
|
|
SET wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, key_version = key_version + 1, updated_at = ?
|
|
WHERE id = ?
|
|
`);
|
|
for (const row of rows) {
|
|
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
|
|
const wrapped = encrypt(newMasterKey, dek, wrappedDekAad(row));
|
|
dek.fill(0);
|
|
update.run(wrapped.ciphertext, wrapped.nonce, wrapped.tag, now(), row.id);
|
|
}
|
|
this.database.exec("COMMIT");
|
|
} catch (error) {
|
|
this.database.exec("ROLLBACK");
|
|
throw error;
|
|
}
|
|
this.masterKey = newMasterKey;
|
|
this.audit(actor, "master-key.rotate", "vault", null, null, "success", `${rows.length} DEKs rewrapped`);
|
|
return {rewrapped: rows.length};
|
|
}
|
|
|
|
backup(destination) {
|
|
const escaped = destination.replaceAll("'", "''");
|
|
this.database.exec(`VACUUM INTO '${escaped}'`);
|
|
return {destination};
|
|
}
|
|
}
|
|
|
|
export function breakGlassActor() {
|
|
return {
|
|
issuer: "urn:xiteng:break-glass",
|
|
sub: "local-emergency-script",
|
|
username: "liooil",
|
|
admin: true
|
|
};
|
|
}
|