Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1075 lines
41 KiB
JavaScript
1075 lines
41 KiB
JavaScript
"use strict";
|
|
|
|
const { createHash } = require("node:crypto");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { DatabaseSync } = require("node:sqlite");
|
|
|
|
const SCHEMA_VERSION = 3;
|
|
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
|
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
|
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
|
plugins: ["id", "enabled", "active_version", "installed_at", "updated_at"],
|
|
plugin_versions: ["plugin_id", "version", "manifest_json", "archive_sha256", "package_relative_path", "installed_at"],
|
|
plugin_runtime_state: ["plugin_id", "plugin_version", "status", "runtime_kind", "last_error", "quarantined_at", "updated_at"],
|
|
plugin_crashes: ["plugin_id", "plugin_version", "crashed_at"],
|
|
plugin_kv: ["plugin_id", "key", "value_json", "updated_at"],
|
|
plugin_settings: ["plugin_id", "setting_id", "scope", "scope_id", "value_json", "updated_at"],
|
|
plugin_view_state: ["plugin_id", "view_id", "scope_id", "state_json", "updated_at"],
|
|
plugin_permission_grants: ["plugin_id", "permission", "resource", "resource_kind", "declaration_hash", "granted_at"],
|
|
plugin_secrets: ["plugin_id", "key", "secret_ref", "ciphertext", "created_at", "updated_at"],
|
|
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
|
|
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
|
|
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
|
|
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
|
|
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
|
|
});
|
|
|
|
function parseJson(text, label) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`Corrupt plugin database ${label}`);
|
|
}
|
|
}
|
|
|
|
class PluginDatabase {
|
|
constructor(databasePath, options = {}) {
|
|
if (typeof databasePath !== "string" || !path.isAbsolute(databasePath)) {
|
|
throw new TypeError("Plugin database path must be absolute");
|
|
}
|
|
fs.mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
|
|
this.databasePath = databasePath;
|
|
this.clock = options.clock ?? (() => Date.now());
|
|
const ownsDatabase = !options.database;
|
|
this.db = options.database ?? new DatabaseSync(databasePath);
|
|
try {
|
|
this.#initializeSchema();
|
|
} catch (error) {
|
|
if (ownsDatabase) {
|
|
try { this.db.close(); } catch {}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
#initializeSchema() {
|
|
this.db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;");
|
|
const version = Number(this.db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
|
|
if (version > SCHEMA_VERSION) {
|
|
throw new Error(`Plugin database schema ${version} is newer than supported ${SCHEMA_VERSION}`);
|
|
}
|
|
if (version === 0) {
|
|
this.transaction(() => {
|
|
this.db.exec(`
|
|
CREATE TABLE plugins (
|
|
id TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
|
|
active_version TEXT,
|
|
installed_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE plugin_versions (
|
|
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
|
|
version TEXT NOT NULL,
|
|
manifest_json TEXT NOT NULL,
|
|
archive_sha256 TEXT NOT NULL,
|
|
package_relative_path TEXT NOT NULL,
|
|
installed_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, version)
|
|
);
|
|
CREATE TABLE plugin_runtime_state (
|
|
plugin_id TEXT NOT NULL,
|
|
plugin_version TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'stopped',
|
|
runtime_kind TEXT,
|
|
last_error TEXT,
|
|
quarantined_at INTEGER,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, plugin_version),
|
|
FOREIGN KEY (plugin_id, plugin_version)
|
|
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
|
|
);
|
|
CREATE TABLE plugin_crashes (
|
|
plugin_id TEXT NOT NULL,
|
|
plugin_version TEXT NOT NULL,
|
|
crashed_at INTEGER NOT NULL,
|
|
FOREIGN KEY (plugin_id, plugin_version)
|
|
REFERENCES plugin_versions(plugin_id, version) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX plugin_crashes_lookup
|
|
ON plugin_crashes(plugin_id, plugin_version, crashed_at);
|
|
CREATE TABLE plugin_kv (
|
|
plugin_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, key)
|
|
);
|
|
CREATE TABLE plugin_settings (
|
|
plugin_id TEXT NOT NULL,
|
|
setting_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL CHECK (scope IN ('application', 'workspace', 'host', 'session', 'device')),
|
|
scope_id TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, setting_id, scope, scope_id)
|
|
);
|
|
CREATE INDEX plugin_settings_lookup
|
|
ON plugin_settings(plugin_id, scope, scope_id, setting_id);
|
|
CREATE TABLE plugin_view_state (
|
|
plugin_id TEXT NOT NULL,
|
|
view_id TEXT NOT NULL,
|
|
scope_id TEXT NOT NULL,
|
|
state_json TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, view_id, scope_id)
|
|
);
|
|
CREATE INDEX plugin_view_state_lookup
|
|
ON plugin_view_state(plugin_id, scope_id, view_id);
|
|
CREATE TABLE plugin_permission_grants (
|
|
plugin_id TEXT NOT NULL,
|
|
permission TEXT NOT NULL,
|
|
resource TEXT NOT NULL,
|
|
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('exact', 'directory')),
|
|
declaration_hash TEXT NOT NULL,
|
|
granted_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, permission, resource)
|
|
);
|
|
CREATE TABLE plugin_secrets (
|
|
plugin_id TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
secret_ref TEXT NOT NULL UNIQUE,
|
|
ciphertext BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, key)
|
|
);
|
|
CREATE INDEX plugin_secrets_ref_lookup
|
|
ON plugin_secrets(plugin_id, secret_ref);
|
|
CREATE TABLE plugin_security_audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
event TEXT NOT NULL,
|
|
details_json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX plugin_security_audit_lookup
|
|
ON plugin_security_audit(plugin_id, created_at DESC);
|
|
CREATE TABLE plugin_sync_sidecars (
|
|
plugin_id TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
|
|
key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, kind, key)
|
|
);
|
|
CREATE INDEX plugin_sync_sidecars_lookup
|
|
ON plugin_sync_sidecars(plugin_id, kind, key);
|
|
CREATE TABLE plugin_sync_provider_bindings (
|
|
provider_id TEXT PRIMARY KEY,
|
|
plugin_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 3;
|
|
`);
|
|
});
|
|
} else if (version === 1) {
|
|
// Pre-sidecar schema-1 databases only created the original tables.
|
|
// Migrate in place to schema 3 with sidecar + provider binding tables.
|
|
this.transaction(() => {
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
|
|
plugin_id TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('settings', 'account_baseline', 'crdt_baseline')),
|
|
key TEXT NOT NULL,
|
|
value_json TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (plugin_id, kind, key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
|
|
ON plugin_sync_sidecars(plugin_id, kind, key);
|
|
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
|
|
provider_id TEXT PRIMARY KEY,
|
|
plugin_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 3;
|
|
`);
|
|
});
|
|
} else if (version === 2) {
|
|
this.transaction(() => {
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
|
|
provider_id TEXT PRIMARY KEY,
|
|
plugin_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
PRAGMA user_version = 3;
|
|
`);
|
|
});
|
|
}
|
|
this.#assertSchemaLayout();
|
|
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
|
// (hostService seeds after package initialize). Running here would promote
|
|
// sole map candidates before recovered manifests are visible, then skip
|
|
// re-evaluation because bindings already exist (Codex P2 on cded4c8a).
|
|
}
|
|
|
|
#assertSchemaLayout() {
|
|
for (const [table, columns] of Object.entries(REQUIRED_SCHEMA_COLUMNS)) {
|
|
const actual = this.db.prepare(`PRAGMA table_info(${table})`).all().map(({ name }) => name);
|
|
if (JSON.stringify(actual) !== JSON.stringify(columns)) {
|
|
throw new Error(
|
|
"Pre-release plugin database schema is obsolete; reset userData/plugins/plugins.sqlite",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
transaction(callback) {
|
|
this.db.exec("BEGIN IMMEDIATE");
|
|
try {
|
|
const result = callback();
|
|
if (result && typeof result.then === "function") {
|
|
throw new TypeError("Plugin database transactions must be synchronous");
|
|
}
|
|
this.db.exec("COMMIT");
|
|
return result;
|
|
} catch (error) {
|
|
try { this.db.exec("ROLLBACK"); } catch {}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
installVersion(record, options = {}) {
|
|
if (options.enable === true && options.forceDisabled === true) {
|
|
throw new TypeError("Plugin version cannot be enabled and force-disabled together");
|
|
}
|
|
const now = this.clock();
|
|
const manifestJson = JSON.stringify(record.manifest);
|
|
const requestedEnabled = options.enable === true ? 1 : 0;
|
|
const overwriteEnabled = options.enable === true || options.forceDisabled === true;
|
|
this.transaction(() => {
|
|
this.db.prepare(`
|
|
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
active_version = excluded.active_version,
|
|
enabled = CASE WHEN ? THEN excluded.enabled ELSE plugins.enabled END,
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
record.pluginId,
|
|
requestedEnabled,
|
|
record.version,
|
|
now,
|
|
now,
|
|
overwriteEnabled ? 1 : 0,
|
|
);
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_versions(
|
|
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, version) DO UPDATE SET
|
|
manifest_json = excluded.manifest_json,
|
|
archive_sha256 = excluded.archive_sha256,
|
|
package_relative_path = excluded.package_relative_path,
|
|
installed_at = excluded.installed_at
|
|
`).run(
|
|
record.pluginId,
|
|
record.version,
|
|
manifestJson,
|
|
record.archiveSha256,
|
|
record.packageRelativePath,
|
|
now,
|
|
);
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_runtime_state(plugin_id, plugin_version, status, updated_at)
|
|
VALUES (?, ?, 'stopped', ?)
|
|
ON CONFLICT(plugin_id, plugin_version) DO NOTHING
|
|
`).run(record.pluginId, record.version, now);
|
|
});
|
|
}
|
|
|
|
getVersion(pluginId, version) {
|
|
const row = this.db.prepare(`
|
|
SELECT plugin_id, version, manifest_json, archive_sha256,
|
|
package_relative_path, installed_at
|
|
FROM plugin_versions WHERE plugin_id = ? AND version = ?
|
|
`).get(pluginId, version);
|
|
return row ? this.#mapVersion(row) : null;
|
|
}
|
|
|
|
getActivePlugin(pluginId) {
|
|
const row = this.db.prepare(`
|
|
SELECT p.id, p.enabled, p.active_version, p.installed_at, p.updated_at,
|
|
v.manifest_json, v.archive_sha256, v.package_relative_path,
|
|
r.status, r.runtime_kind, r.last_error, r.quarantined_at
|
|
FROM plugins p
|
|
LEFT JOIN plugin_versions v
|
|
ON v.plugin_id = p.id AND v.version = p.active_version
|
|
LEFT JOIN plugin_runtime_state r ON r.plugin_id = p.id
|
|
AND r.plugin_version = p.active_version
|
|
WHERE p.id = ?
|
|
`).get(pluginId);
|
|
return row ? this.#mapPlugin(row) : null;
|
|
}
|
|
|
|
listPlugins() {
|
|
return this.db.prepare(`
|
|
SELECT p.id, p.enabled, p.active_version, p.installed_at, p.updated_at,
|
|
v.manifest_json, v.archive_sha256, v.package_relative_path,
|
|
r.status, r.runtime_kind, r.last_error, r.quarantined_at
|
|
FROM plugins p
|
|
LEFT JOIN plugin_versions v
|
|
ON v.plugin_id = p.id AND v.version = p.active_version
|
|
LEFT JOIN plugin_runtime_state r ON r.plugin_id = p.id
|
|
AND r.plugin_version = p.active_version
|
|
ORDER BY p.id COLLATE BINARY
|
|
`).all().map((row) => this.#mapPlugin(row));
|
|
}
|
|
|
|
/**
|
|
* Every installed package version (active and inactive), with parsed manifests.
|
|
* Used for ownership recovery when an older version still identifies a sync
|
|
* provider that the active manifest no longer contributes.
|
|
*/
|
|
listInstalledVersions() {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, version, manifest_json, archive_sha256,
|
|
package_relative_path, installed_at
|
|
FROM plugin_versions
|
|
ORDER BY plugin_id COLLATE BINARY, installed_at DESC, version COLLATE BINARY
|
|
`).all().map((row) => this.#mapVersion(row));
|
|
}
|
|
|
|
#mapVersion(row) {
|
|
return {
|
|
pluginId: row.plugin_id,
|
|
version: row.version,
|
|
manifest: parseJson(row.manifest_json, "manifest"),
|
|
archiveSha256: row.archive_sha256,
|
|
packageRelativePath: row.package_relative_path,
|
|
installedAt: Number(row.installed_at),
|
|
};
|
|
}
|
|
|
|
#mapPlugin(row) {
|
|
return {
|
|
id: row.id,
|
|
enabled: row.enabled === 1,
|
|
activeVersion: row.active_version ?? null,
|
|
installedAt: Number(row.installed_at),
|
|
updatedAt: Number(row.updated_at),
|
|
manifest: row.manifest_json ? parseJson(row.manifest_json, "manifest") : null,
|
|
archiveSha256: row.archive_sha256 ?? null,
|
|
packageRelativePath: row.package_relative_path ?? null,
|
|
runtime: {
|
|
status: row.status ?? "stopped",
|
|
kind: row.runtime_kind ?? null,
|
|
lastError: row.last_error ?? null,
|
|
quarantinedAt: row.quarantined_at == null ? null : Number(row.quarantined_at),
|
|
},
|
|
};
|
|
}
|
|
|
|
setEnabled(pluginId, enabled) {
|
|
const result = this.db.prepare("UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?")
|
|
.run(enabled ? 1 : 0, this.clock(), pluginId);
|
|
if (Number(result.changes) !== 1) throw new Error(`Plugin is not installed: ${pluginId}`);
|
|
}
|
|
|
|
setActiveVersion(pluginId, version, options = {}) {
|
|
this.transaction(() => {
|
|
if (!this.getVersion(pluginId, version)) {
|
|
throw new Error(`Plugin version is not installed: ${pluginId}@${version}`);
|
|
}
|
|
const enabled = options.enabled === true ? 1 : 0;
|
|
const expectedActiveVersion = options.expectedActiveVersion;
|
|
const result = expectedActiveVersion === undefined
|
|
? this.db.prepare(`
|
|
UPDATE plugins
|
|
SET active_version = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(version, enabled, this.clock(), pluginId)
|
|
: this.db.prepare(`
|
|
UPDATE plugins
|
|
SET active_version = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ? AND active_version = ?
|
|
`).run(version, enabled, this.clock(), pluginId, expectedActiveVersion);
|
|
if (Number(result.changes) !== 1) {
|
|
throw new Error(`Plugin active version changed before it could be restored: ${pluginId}`);
|
|
}
|
|
});
|
|
return this.getActivePlugin(pluginId);
|
|
}
|
|
|
|
setRuntimeState(pluginId, status, options = {}) {
|
|
const activeVersion = this.db.prepare(
|
|
"SELECT active_version FROM plugins WHERE id = ?",
|
|
).get(pluginId)?.active_version;
|
|
const pluginVersion = options.pluginVersion ?? activeVersion;
|
|
if (!pluginVersion) throw new Error(`Plugin is not installed: ${pluginId}`);
|
|
if (!this.getVersion(pluginId, pluginVersion)) {
|
|
throw new Error(`Plugin version is not installed: ${pluginId}@${pluginVersion}`);
|
|
}
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_runtime_state(
|
|
plugin_id, plugin_version, status, runtime_kind,
|
|
last_error, quarantined_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, plugin_version) DO UPDATE SET
|
|
status = excluded.status,
|
|
runtime_kind = excluded.runtime_kind,
|
|
last_error = excluded.last_error,
|
|
quarantined_at = COALESCE(excluded.quarantined_at, plugin_runtime_state.quarantined_at),
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
pluginId,
|
|
pluginVersion,
|
|
status,
|
|
options.kind ?? null,
|
|
options.error == null ? null : String(options.error).slice(0, 4_096),
|
|
options.quarantinedAt ?? null,
|
|
this.clock(),
|
|
);
|
|
}
|
|
|
|
recordCrash(pluginId, pluginVersion, windowMs, threshold) {
|
|
const now = this.clock();
|
|
return this.transaction(() => {
|
|
if (!this.getVersion(pluginId, pluginVersion)) {
|
|
throw new Error(`Plugin version is not installed: ${pluginId}@${pluginVersion}`);
|
|
}
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_crashes
|
|
WHERE plugin_id = ? AND plugin_version = ? AND crashed_at < ?
|
|
`).run(pluginId, pluginVersion, now - windowMs);
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_crashes(plugin_id, plugin_version, crashed_at)
|
|
VALUES (?, ?, ?)
|
|
`).run(pluginId, pluginVersion, now);
|
|
const count = Number(this.db.prepare(
|
|
"SELECT COUNT(*) AS count FROM plugin_crashes WHERE plugin_id = ? AND plugin_version = ?",
|
|
).get(pluginId, pluginVersion)?.count ?? 0);
|
|
if (count >= threshold) {
|
|
this.db.prepare(`
|
|
UPDATE plugin_runtime_state
|
|
SET status = 'quarantined', quarantined_at = ?, updated_at = ?
|
|
WHERE plugin_id = ? AND plugin_version = ?
|
|
`).run(now, now, pluginId, pluginVersion);
|
|
}
|
|
return { count, quarantined: count >= threshold, quarantinedAt: count >= threshold ? now : null };
|
|
});
|
|
}
|
|
|
|
clearQuarantine(pluginId, pluginVersion) {
|
|
const now = this.clock();
|
|
this.transaction(() => {
|
|
const activeVersion = this.db.prepare(
|
|
"SELECT active_version FROM plugins WHERE id = ?",
|
|
).get(pluginId)?.active_version;
|
|
const targetVersion = pluginVersion ?? activeVersion;
|
|
if (!targetVersion) throw new Error(`Plugin is not installed: ${pluginId}`);
|
|
if (!this.getVersion(pluginId, targetVersion)) {
|
|
throw new Error(`Plugin version is not installed: ${pluginId}@${targetVersion}`);
|
|
}
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_crashes WHERE plugin_id = ? AND plugin_version = ?
|
|
`).run(pluginId, targetVersion);
|
|
this.db.prepare(`
|
|
UPDATE plugin_runtime_state
|
|
SET status = 'stopped', last_error = NULL, quarantined_at = NULL, updated_at = ?
|
|
WHERE plugin_id = ? AND plugin_version = ?
|
|
`).run(now, pluginId, targetVersion);
|
|
});
|
|
}
|
|
|
|
removePlugin(pluginId) {
|
|
this.db.prepare("DELETE FROM plugins WHERE id = ?").run(pluginId);
|
|
}
|
|
|
|
getValue(pluginId, key) {
|
|
const row = this.db.prepare(
|
|
"SELECT value_json FROM plugin_kv WHERE plugin_id = ? AND key = ?",
|
|
).get(pluginId, key);
|
|
return row ? parseJson(row.value_json, "key/value entry") : undefined;
|
|
}
|
|
|
|
setValue(pluginId, key, value) {
|
|
const serialized = JSON.stringify(value);
|
|
if (serialized === undefined) throw new TypeError("Plugin storage value must be JSON serializable");
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_kv(plugin_id, key, value_json, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, key) DO UPDATE SET
|
|
value_json = excluded.value_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(pluginId, key, serialized, this.clock());
|
|
}
|
|
|
|
deleteValue(pluginId, key) {
|
|
this.db.prepare("DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?").run(pluginId, key);
|
|
}
|
|
|
|
listKeys(pluginId) {
|
|
return this.db.prepare(
|
|
"SELECT key FROM plugin_kv WHERE plugin_id = ? ORDER BY key COLLATE BINARY",
|
|
).all(pluginId).map((row) => row.key);
|
|
}
|
|
|
|
getSetting(pluginId, settingId, scope, scopeId) {
|
|
const row = this.db.prepare(`
|
|
SELECT value_json FROM plugin_settings
|
|
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
|
`).get(pluginId, settingId, scope, scopeId);
|
|
return row ? parseJson(row.value_json, "setting value") : undefined;
|
|
}
|
|
|
|
setSetting(pluginId, settingId, scope, scopeId, value, updatedAt = this.clock()) {
|
|
const serialized = JSON.stringify(value);
|
|
if (serialized === undefined) throw new TypeError("Plugin setting value must be JSON serializable");
|
|
const stamp = Number.isFinite(Number(updatedAt)) ? Number(updatedAt) : this.clock();
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_settings(plugin_id, setting_id, scope, scope_id, value_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, setting_id, scope, scope_id) DO UPDATE SET
|
|
value_json = excluded.value_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(pluginId, settingId, scope, scopeId, serialized, stamp);
|
|
}
|
|
|
|
deleteSetting(pluginId, settingId, scope, scopeId) {
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_settings
|
|
WHERE plugin_id = ? AND setting_id = ? AND scope = ? AND scope_id = ?
|
|
`).run(pluginId, settingId, scope, scopeId);
|
|
}
|
|
|
|
listSettings(pluginId) {
|
|
return this.db.prepare(`
|
|
SELECT setting_id, scope, scope_id, value_json, updated_at
|
|
FROM plugin_settings WHERE plugin_id = ?
|
|
ORDER BY setting_id COLLATE BINARY, scope COLLATE BINARY, scope_id COLLATE BINARY
|
|
`).all(pluginId).map((row) => ({
|
|
settingId: row.setting_id,
|
|
scope: row.scope,
|
|
scopeId: row.scope_id,
|
|
value: parseJson(row.value_json, "setting value"),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
listAllSettings() {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, setting_id, scope, scope_id, value_json, updated_at
|
|
FROM plugin_settings
|
|
ORDER BY plugin_id COLLATE BINARY, setting_id COLLATE BINARY, scope COLLATE BINARY, scope_id COLLATE BINARY
|
|
`).all().map((row) => ({
|
|
pluginId: row.plugin_id,
|
|
settingId: row.setting_id,
|
|
scope: row.scope,
|
|
scopeId: row.scope_id,
|
|
value: parseJson(row.value_json, "setting value"),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
getSyncSidecar(pluginId, kind, key) {
|
|
const row = this.db.prepare(`
|
|
SELECT value_json, updated_at FROM plugin_sync_sidecars
|
|
WHERE plugin_id = ? AND kind = ? AND key = ?
|
|
`).get(pluginId, kind, key);
|
|
if (!row) return undefined;
|
|
return {
|
|
pluginId,
|
|
kind,
|
|
key,
|
|
value: parseJson(row.value_json, "sync sidecar value"),
|
|
updatedAt: Number(row.updated_at),
|
|
};
|
|
}
|
|
|
|
setSyncSidecar(pluginId, kind, key, value, updatedAt = this.clock()) {
|
|
const serialized = JSON.stringify(value);
|
|
if (serialized === undefined) throw new TypeError("Plugin sync sidecar value must be JSON serializable");
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, kind, key) DO UPDATE SET
|
|
value_json = excluded.value_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(pluginId, kind, key, serialized, updatedAt);
|
|
}
|
|
|
|
deleteSyncSidecar(pluginId, kind, key) {
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_sync_sidecars
|
|
WHERE plugin_id = ? AND kind = ? AND key = ?
|
|
`).run(pluginId, kind, key);
|
|
}
|
|
|
|
listSyncSidecars(pluginId) {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, kind, key, value_json, updated_at
|
|
FROM plugin_sync_sidecars
|
|
WHERE plugin_id = ?
|
|
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
|
|
`).all(pluginId).map((row) => ({
|
|
pluginId: row.plugin_id,
|
|
kind: row.kind,
|
|
key: row.key,
|
|
value: parseJson(row.value_json, "sync sidecar value"),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
listAllSyncSidecars() {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, kind, key, value_json, updated_at
|
|
FROM plugin_sync_sidecars
|
|
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
|
|
`).all().map((row) => ({
|
|
pluginId: row.plugin_id,
|
|
kind: row.kind,
|
|
key: row.key,
|
|
value: parseJson(row.value_json, "sync sidecar value"),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
replaceAllSyncSidecars(entries) {
|
|
this.transaction(() => {
|
|
this.db.prepare("DELETE FROM plugin_sync_sidecars").run();
|
|
const insert = this.db.prepare(`
|
|
INSERT INTO plugin_sync_sidecars(plugin_id, kind, key, value_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`);
|
|
for (const entry of entries) {
|
|
const serialized = JSON.stringify(entry.value);
|
|
if (serialized === undefined) {
|
|
throw new TypeError("Plugin sync sidecar value must be JSON serializable");
|
|
}
|
|
insert.run(
|
|
entry.pluginId,
|
|
entry.kind,
|
|
entry.key,
|
|
serialized,
|
|
Number(entry.updatedAt) || this.clock(),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
getViewState(pluginId, viewId, scopeId) {
|
|
const row = this.db.prepare(`
|
|
SELECT state_json FROM plugin_view_state
|
|
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
|
`).get(pluginId, viewId, scopeId);
|
|
return row ? parseJson(row.state_json, "view state") : undefined;
|
|
}
|
|
|
|
setViewState(pluginId, viewId, scopeId, state) {
|
|
const serialized = JSON.stringify(state);
|
|
if (serialized === undefined) throw new TypeError("Plugin view state must be JSON serializable");
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_view_state(plugin_id, view_id, scope_id, state_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, view_id, scope_id) DO UPDATE SET
|
|
state_json = excluded.state_json,
|
|
updated_at = excluded.updated_at
|
|
`).run(pluginId, viewId, scopeId, serialized, this.clock());
|
|
}
|
|
|
|
deleteViewState(pluginId, viewId, scopeId) {
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_view_state
|
|
WHERE plugin_id = ? AND view_id = ? AND scope_id = ?
|
|
`).run(pluginId, viewId, scopeId);
|
|
}
|
|
|
|
listPermissionGrants(pluginId) {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, permission, resource, resource_kind, declaration_hash, granted_at
|
|
FROM plugin_permission_grants
|
|
WHERE plugin_id = ?
|
|
ORDER BY permission COLLATE BINARY, resource COLLATE BINARY
|
|
`).all(pluginId).map((row) => ({
|
|
pluginId: row.plugin_id,
|
|
permission: row.permission,
|
|
resource: row.resource,
|
|
resourceKind: row.resource_kind,
|
|
declarationHash: row.declaration_hash,
|
|
grantedAt: Number(row.granted_at),
|
|
}));
|
|
}
|
|
|
|
upsertPermissionGrant(record) {
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_permission_grants(
|
|
plugin_id, permission, resource, resource_kind, declaration_hash, granted_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, permission, resource) DO UPDATE SET
|
|
resource_kind = excluded.resource_kind,
|
|
declaration_hash = excluded.declaration_hash,
|
|
granted_at = excluded.granted_at
|
|
`).run(
|
|
record.pluginId,
|
|
record.permission,
|
|
record.resource,
|
|
record.resourceKind,
|
|
record.declarationHash,
|
|
this.clock(),
|
|
);
|
|
}
|
|
|
|
deletePermissionGrant(pluginId, permission, resource) {
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_permission_grants
|
|
WHERE plugin_id = ? AND permission = ? AND resource = ?
|
|
`).run(pluginId, permission, resource);
|
|
}
|
|
|
|
deleteAllPermissionGrants(pluginId) {
|
|
this.db.prepare("DELETE FROM plugin_permission_grants WHERE plugin_id = ?").run(pluginId);
|
|
}
|
|
|
|
getSecretByKey(pluginId, key) {
|
|
const row = this.db.prepare(`
|
|
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
|
FROM plugin_secrets WHERE plugin_id = ? AND key = ?
|
|
`).get(pluginId, key);
|
|
return row ? this.#mapSecret(row) : null;
|
|
}
|
|
|
|
getSecretByRef(pluginId, secretRef) {
|
|
const row = this.db.prepare(`
|
|
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
|
FROM plugin_secrets WHERE plugin_id = ? AND secret_ref = ?
|
|
`).get(pluginId, secretRef);
|
|
return row ? this.#mapSecret(row) : null;
|
|
}
|
|
|
|
#mapSecret(row) {
|
|
return {
|
|
pluginId: row.plugin_id,
|
|
key: row.key,
|
|
secretRef: row.secret_ref,
|
|
ciphertext: Buffer.from(row.ciphertext),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
};
|
|
}
|
|
|
|
upsertSecret(record) {
|
|
const now = this.clock();
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_secrets(
|
|
plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(plugin_id, key) DO UPDATE SET
|
|
secret_ref = excluded.secret_ref,
|
|
ciphertext = excluded.ciphertext,
|
|
updated_at = excluded.updated_at
|
|
`).run(
|
|
record.pluginId,
|
|
record.key,
|
|
record.secretRef,
|
|
record.ciphertext,
|
|
now,
|
|
now,
|
|
);
|
|
}
|
|
|
|
deleteSecret(pluginId, key) {
|
|
this.db.prepare("DELETE FROM plugin_secrets WHERE plugin_id = ? AND key = ?")
|
|
.run(pluginId, key);
|
|
}
|
|
|
|
/** Delete all secrets whose key equals prefix or starts with `${prefix}:`. */
|
|
deleteSecretsByKeyPrefix(pluginId, prefix) {
|
|
const result = this.db.prepare(`
|
|
DELETE FROM plugin_secrets
|
|
WHERE plugin_id = ?
|
|
AND (key = ? OR key LIKE ? ESCAPE '\\')
|
|
`).run(
|
|
pluginId,
|
|
prefix,
|
|
`${String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`)}:%`,
|
|
);
|
|
return Number(result?.changes) || 0;
|
|
}
|
|
|
|
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
|
|
findSecretsByKey(key) {
|
|
return this.db.prepare(`
|
|
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
|
|
FROM plugin_secrets WHERE key = ?
|
|
`).all(key).map((row) => this.#mapSecret(row));
|
|
}
|
|
|
|
upsertSyncProviderBinding(providerId, pluginId) {
|
|
const now = this.clock();
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?)
|
|
ON CONFLICT(provider_id) DO UPDATE SET
|
|
plugin_id = excluded.plugin_id,
|
|
updated_at = excluded.updated_at
|
|
`).run(providerId, pluginId, now, now);
|
|
}
|
|
|
|
getSyncProviderBinding(providerId) {
|
|
const row = this.db.prepare(`
|
|
SELECT provider_id, plugin_id, created_at, updated_at
|
|
FROM plugin_sync_provider_bindings WHERE provider_id = ?
|
|
`).get(providerId);
|
|
if (!row) return null;
|
|
return {
|
|
providerId: row.provider_id,
|
|
pluginId: row.plugin_id,
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
};
|
|
}
|
|
|
|
deleteSyncProviderBinding(providerId) {
|
|
const result = this.db.prepare(`
|
|
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
|
|
`).run(providerId);
|
|
return Number(result?.changes) || 0;
|
|
}
|
|
|
|
/** Distinct plugin ids that own secrets with key === prefix or key LIKE prefix:%. */
|
|
listPluginIdsWithSecretKeyPrefix(prefix) {
|
|
const escaped = String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
|
return this.db.prepare(`
|
|
SELECT DISTINCT plugin_id
|
|
FROM plugin_secrets
|
|
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
|
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
|
}
|
|
|
|
/**
|
|
* Promote leftover sync-provider-map:* secret rows into the host-owned binding
|
|
* table. Idempotent.
|
|
*
|
|
* Host map keys under this prefix are migration markers from an intermediate
|
|
* build (pluginId-namespaced provider ids only). After a successful promote,
|
|
* those map rows are deleted so constructor-time backfill cannot resurrect a
|
|
* binding after the user disconnects/unbinds. Multiple plugins may hold map
|
|
* rows for the same provider (parent + nested id): group by provider, never
|
|
* overwrite an existing host binding (including empty-plugin_id unbind
|
|
* tombstones). Among candidates, only plugins that still hold sync-credential*
|
|
* secrets are eligible. Auto-bind only when exactly one distinct
|
|
* credential-backed candidate exists; if several credential-backed plugins
|
|
* map the same provider (e.g. com.example and com.example.sync both hold
|
|
* credentials), skip entirely — longest pluginId is not a reliable owner
|
|
* signal (the shorter parent can be the legitimate owner). Map-only leftovers
|
|
* never invent ownership (validation only requires
|
|
* providerId.startsWith(pluginId + ".")).
|
|
*/
|
|
backfillSyncProviderBindingsFromLegacySecrets() {
|
|
const legacyPrefix = "sync-provider-map:";
|
|
const escaped = legacyPrefix.replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
|
const legacyRows = this.db.prepare(`
|
|
SELECT plugin_id, key
|
|
FROM plugin_secrets
|
|
WHERE key LIKE ? ESCAPE '\\'
|
|
`).all(`${escaped}%`);
|
|
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
|
const byProvider = new Map();
|
|
for (const row of legacyRows) {
|
|
const pluginId = row.plugin_id;
|
|
const key = row.key;
|
|
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
|
if (!key.startsWith(legacyPrefix)) continue;
|
|
const providerId = key.slice(legacyPrefix.length);
|
|
if (!providerId || providerId.includes("\0")) continue;
|
|
if (!providerId.startsWith(`${pluginId}.`)) {
|
|
continue;
|
|
}
|
|
const list = byProvider.get(providerId) || [];
|
|
list.push({ pluginId, key });
|
|
byProvider.set(providerId, list);
|
|
}
|
|
// Credential presence is the signal for real ownership. A longer nested map
|
|
// row without credentials must not steal bind from a parent that still owns
|
|
// sync-credential* secrets for the provider namespace.
|
|
const credentialOwners = new Set(
|
|
this.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
|
.filter((id) => typeof id === "string" && id.length > 0),
|
|
);
|
|
// Same single-provider guard as live backfill: one shared sync-credential*
|
|
// row must not seed bindings for every historical map provider under that
|
|
// plugin (disconnecting a stale provider would wipe the live credentials).
|
|
// Count both legacy map rows and installed-manifest sync providers so a
|
|
// stale map cannot promote when the live manifest also declares another
|
|
// provider under the same credential owner (Codex P2 on 8ae60205).
|
|
/** @type {Map<string, Set<string>>} */
|
|
const providersByCredentialOwner = new Map();
|
|
for (const [providerId, candidates] of byProvider) {
|
|
for (const c of candidates) {
|
|
if (!credentialOwners.has(c.pluginId)) continue;
|
|
const set = providersByCredentialOwner.get(c.pluginId) || new Set();
|
|
set.add(providerId);
|
|
providersByCredentialOwner.set(c.pluginId, set);
|
|
}
|
|
}
|
|
try {
|
|
const versions = typeof this.listInstalledVersions === "function"
|
|
? this.listInstalledVersions()
|
|
: [];
|
|
for (const version of versions) {
|
|
const pluginId = version?.pluginId;
|
|
if (typeof pluginId !== "string" || !credentialOwners.has(pluginId)) continue;
|
|
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
|
if (provider?.kind !== "sync") continue;
|
|
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
|
const set = providersByCredentialOwner.get(pluginId) || new Set();
|
|
set.add(provider.id);
|
|
providersByCredentialOwner.set(pluginId, set);
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore catalog probe failures */
|
|
}
|
|
let promoted = 0;
|
|
for (const [providerId, candidates] of byProvider) {
|
|
// Any host row means a prior decision: active bind or explicit unbind
|
|
// tombstone (empty plugin_id). Never resurrect from leftover map secrets.
|
|
if (this.getSyncProviderBinding(providerId)) {
|
|
continue;
|
|
}
|
|
// Only credential-backed map candidates may win; map-only leftovers stay
|
|
// unbound rather than inventing ownership from namespace length alone.
|
|
// Multiple distinct credential-backed candidates is ambiguous (parent vs
|
|
// nested can both hold credentials for the same provider namespace) — skip
|
|
// entirely rather than picking longest pluginId.
|
|
const uniqueCredentialOwners = [
|
|
...new Set(
|
|
candidates
|
|
.filter((c) => credentialOwners.has(c.pluginId))
|
|
.map((c) => c.pluginId),
|
|
),
|
|
];
|
|
if (uniqueCredentialOwners.length !== 1) {
|
|
continue;
|
|
}
|
|
const chosenOwner = uniqueCredentialOwners[0];
|
|
if ((providersByCredentialOwner.get(chosenOwner)?.size ?? 0) !== 1) {
|
|
continue;
|
|
}
|
|
// Constructor-time map promote runs before hostService's installed-manifest
|
|
// conflict check. Skip when another installed package would also claim this
|
|
// provider (live nested plugin without credentials yet) so we do not bind
|
|
// a legacy parent that later disconnect cannot clear (Codex P2 on 5bc1b60d).
|
|
let hasManifestConflict = false;
|
|
try {
|
|
const versions = typeof this.listInstalledVersions === "function"
|
|
? this.listInstalledVersions()
|
|
: [];
|
|
for (const version of versions) {
|
|
const pluginId = version?.pluginId;
|
|
if (typeof pluginId !== "string" || pluginId === chosenOwner) continue;
|
|
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
|
if (provider?.kind !== "sync") continue;
|
|
if (provider.id === providerId) {
|
|
hasManifestConflict = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasManifestConflict) break;
|
|
}
|
|
} catch {
|
|
/* ignore catalog probe failures */
|
|
}
|
|
if (hasManifestConflict) {
|
|
continue;
|
|
}
|
|
this.upsertSyncProviderBinding(providerId, chosenOwner);
|
|
// Consume legacy map markers for this provider so a later unbind + reopen
|
|
// cannot re-promote from leftover secrets. Only delete namespaced map keys
|
|
// we accepted as candidates (not arbitrary plugin secrets). Consume all
|
|
// candidate maps (including map-only losers) once a credential-backed
|
|
// owner is bound, so orphans cannot re-run later.
|
|
for (const candidate of candidates) {
|
|
this.deleteSecret(candidate.pluginId, candidate.key);
|
|
}
|
|
promoted += 1;
|
|
}
|
|
return promoted;
|
|
}
|
|
|
|
/**
|
|
* Credential-row prefix inference is intentionally not used.
|
|
*
|
|
* A shorter parent plugin id (e.g. com.example with sync-credential rows) can
|
|
* namespace-prefix a provider owned by a different/removed plugin
|
|
* (com.example.backup.sync). Falling back to that parent would let
|
|
* syncDeleteSecrets wipe unrelated credentials. Ownership must come from the
|
|
* host binding table (including rows promoted by
|
|
* backfillSyncProviderBindingsFromLegacySecrets) or a live contribution.
|
|
*/
|
|
inferPluginIdForSyncProvider(_providerId) {
|
|
return undefined;
|
|
}
|
|
|
|
recordSecurityAudit(pluginId, event, details) {
|
|
let detailsJson = JSON.stringify(details ?? {});
|
|
const originalBytes = Buffer.byteLength(detailsJson);
|
|
if (originalBytes > MAX_SECURITY_AUDIT_DETAILS_BYTES) {
|
|
detailsJson = JSON.stringify({
|
|
truncated: true,
|
|
originalBytes,
|
|
sha256: createHash("sha256").update(detailsJson).digest("hex"),
|
|
});
|
|
}
|
|
this.transaction(() => {
|
|
this.db.prepare(`
|
|
INSERT INTO plugin_security_audit(plugin_id, event, details_json, created_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(pluginId, event, detailsJson, this.clock());
|
|
this.db.prepare(`
|
|
DELETE FROM plugin_security_audit
|
|
WHERE plugin_id = ? AND id NOT IN (
|
|
SELECT id FROM plugin_security_audit
|
|
WHERE plugin_id = ? ORDER BY id DESC LIMIT ${MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN}
|
|
)
|
|
`).run(pluginId, pluginId);
|
|
});
|
|
}
|
|
|
|
listSecurityAudit(pluginId, limit = 100) {
|
|
const requestedLimit = Number(limit);
|
|
const normalizedLimit = Math.max(1, Math.min(
|
|
1_000,
|
|
Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : 100,
|
|
));
|
|
return this.db.prepare(`
|
|
SELECT event, details_json, created_at
|
|
FROM plugin_security_audit
|
|
WHERE plugin_id = ? ORDER BY id DESC LIMIT ?
|
|
`).all(pluginId, normalizedLimit).map((row) => ({
|
|
event: row.event,
|
|
details: parseJson(row.details_json, "security audit entry"),
|
|
createdAt: Number(row.created_at),
|
|
}));
|
|
}
|
|
|
|
close() {
|
|
this.db.close();
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_SECURITY_AUDIT_DETAILS_BYTES,
|
|
MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN,
|
|
PluginDatabase,
|
|
REQUIRED_SCHEMA_COLUMNS,
|
|
SCHEMA_VERSION,
|
|
};
|