Files

1075 lines
41 KiB
JavaScript
Raw Permalink Normal View History

"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,
};