mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-09 15:47:51 +00:00
fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals
Close remaining Codex/review gaps for PR8: keep empty sidecar shells from bypassing upload guards, skip startup hydrate when local settings are newer, seal/validate durable plugin credential refs, and cover schema 1→2 migration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,16 +6,17 @@
|
||||
*/
|
||||
|
||||
import type { PluginSyncSidecarBundle } from '../domain/pluginSyncSidecar';
|
||||
import { SYNC_STORAGE_KEYS } from '../domain/sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
/** Ordinary upload fallback when collect cannot reach the host. */
|
||||
const LAST_KNOWN_SIDECARS_KEY = 'netcatty_plugin_sidecars_last_known_v1';
|
||||
const LAST_KNOWN_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN;
|
||||
/**
|
||||
* Remote apply that could not reach the host DB. Distinct from last-known so
|
||||
* a later collect does not re-apply a stale post-collect snapshot over newer
|
||||
* local plugin settings.
|
||||
*/
|
||||
const PENDING_REMOTE_SIDECARS_KEY = 'netcatty_plugin_sidecars_pending_remote_v1';
|
||||
const PENDING_REMOTE_SIDECARS_KEY = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
|
||||
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
|
||||
|
||||
export class PluginSidecarHostUnavailableError extends Error {
|
||||
@@ -223,12 +224,21 @@ export async function applyPluginSyncSidecarsFromHost(
|
||||
writeLastKnownSidecars({ version: 1, entries: collected.entries });
|
||||
return;
|
||||
}
|
||||
// Collect returned a non-authoritative shape — leave prior last-known so
|
||||
// a later offline upload cannot drop preserved missing-plugin rows.
|
||||
// Apply already committed; collect shape was non-authoritative. Align
|
||||
// last-known with the applied remote so offline uploads cannot resurrect
|
||||
// pre-apply rows, then return successfully — throwing here would leave
|
||||
// the interrupted-vault-apply sentinel after vault/settings already landed.
|
||||
writeLastKnownSidecars(normalized);
|
||||
return;
|
||||
} catch {
|
||||
// Apply already committed on the host. Do not replace last-known with the
|
||||
// unmerged remote bundle (that would discard preserved local rows).
|
||||
// Apply already committed. Keep last-known aligned with the applied remote.
|
||||
// Trade-off: missing-plugin rows preserved in the host DB but omitted from
|
||||
// the remote snapshot are not reflected until a later successful collect.
|
||||
try {
|
||||
writeLastKnownSidecars(normalized);
|
||||
} catch {
|
||||
// last-known write failure is secondary; apply already succeeded.
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ const {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
} = await import("./syncPayload.ts");
|
||||
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
|
||||
const { SYNC_STORAGE_KEYS } = await import("../domain/sync.ts");
|
||||
const { localStorageAdapter } = await import("../infrastructure/persistence/localStorageAdapter.ts");
|
||||
|
||||
const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
id,
|
||||
@@ -839,6 +841,7 @@ test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful"
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Empty bundle alone must not bypass the empty-vault upload guard.
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
@@ -851,6 +854,63 @@ test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful"
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData treats empty sidecars as meaningful only after a prior non-empty last-known", () => {
|
||||
const previous = localStorageAdapter.read(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
try {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "k",
|
||||
value: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
if (previous == null) localStorageAdapter.remove(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN);
|
||||
else localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, previous);
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin sidecar storage keys stay aligned between sync domain and storageKeys registry", () => {
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE,
|
||||
);
|
||||
assert.equal(
|
||||
storageKeys.STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault recovery", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
@@ -122,17 +123,36 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
* Returns true when the payload carries plugin sidecar data worth syncing.
|
||||
* Non-empty entries always count. An explicit empty bundle is only meaningful
|
||||
* when last-known previously held entries (a real reset to push) — otherwise
|
||||
* ordinary `{entries:[]}` collects from an empty plugin host would bypass the
|
||||
* empty-vault upload guard.
|
||||
*/
|
||||
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
|
||||
return Boolean(
|
||||
payload.pluginSidecars
|
||||
&& Array.isArray(payload.pluginSidecars.entries)
|
||||
&& payload.pluginSidecars.entries.length > 0,
|
||||
);
|
||||
if (
|
||||
!payload.pluginSidecars
|
||||
|| !Array.isArray(payload.pluginSidecars.entries)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (payload.pluginSidecars.entries.length > 0) return true;
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) return false;
|
||||
try {
|
||||
const lastKnown = localStorageAdapter.read<{ entries?: unknown[] }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
return Array.isArray(lastKnown?.entries) && lastKnown.entries.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a payload contains entities, settings, or meaningful
|
||||
* plugin sidecars worth syncing / protecting with empty-vault guards.
|
||||
* Local-only trust records are intentionally ignored by the cloud variant.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
@@ -997,7 +1017,7 @@ export function buildLocalVaultPayload(
|
||||
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
|
||||
try {
|
||||
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
|
||||
'netcatty_plugin_sidecars_last_known_v1',
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
if (raw && Array.isArray(raw.entries)) {
|
||||
return withPluginSyncSidecars(base, {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeDurablePluginSyncCredentialRef } from './sync.ts';
|
||||
|
||||
test('normalizeDurablePluginSyncCredentialRef accepts durable refs only', () => {
|
||||
assert.deepEqual(
|
||||
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 's1' }),
|
||||
{ kind: 'secret', id: 's1' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeDurablePluginSyncCredentialRef({ kind: 'credential', id: 'c1', key: 'k' }),
|
||||
{ kind: 'credential', id: 'c1', key: 'k' },
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDurablePluginSyncCredentialRef({ kind: 'secret-lease', id: 'l1' }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDurablePluginSyncCredentialRef([{ kind: 'secret', id: 's1' }]),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: '' }), undefined);
|
||||
assert.equal(
|
||||
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x', key: 1 }),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeDurablePluginSyncCredentialRef({ kind: 'secret', id: 'x'.repeat(513) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -222,4 +222,37 @@ describe('pluginSyncSidecar', () => {
|
||||
assert.equal(merged.length, 1);
|
||||
assert.equal(merged[0].value, 'light');
|
||||
});
|
||||
|
||||
it('preferCloud keeps local-only settings and baseline orphans', () => {
|
||||
const localOnly: PluginSyncSidecarEntry = {
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'local-new',
|
||||
updatedAt: 50,
|
||||
};
|
||||
const baseline: PluginSyncSidecarEntry = {
|
||||
pluginId,
|
||||
kind: 'account_baseline',
|
||||
key: 'account',
|
||||
value: { id: 'local-acct' },
|
||||
updatedAt: 40,
|
||||
};
|
||||
const remoteBaseline: PluginSyncSidecarEntry = {
|
||||
pluginId,
|
||||
kind: 'crdt_baseline',
|
||||
key: 'crdt',
|
||||
value: { clock: 1 },
|
||||
updatedAt: 30,
|
||||
};
|
||||
const merged = mergePluginSyncSidecarsThreeWay({
|
||||
base: [],
|
||||
local: [localOnly, baseline],
|
||||
remote: [remoteBaseline],
|
||||
strategy: 'preferCloud',
|
||||
});
|
||||
assert.equal(merged.some((item) => item.key === localOnly.key), true);
|
||||
assert.equal(merged.some((item) => item.kind === 'account_baseline'), true);
|
||||
assert.equal(merged.some((item) => item.kind === 'crdt_baseline'), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,8 +195,8 @@ export function mergePluginSyncSidecarsThreeWay(params: {
|
||||
if (!kind) continue;
|
||||
|
||||
if (kind !== 'settings') {
|
||||
// Baselines: LWW between local and remote; preserve orphans.
|
||||
// preferCloud / preferLocal still apply when both sides present.
|
||||
// Baselines: LWW between local and remote; always preserve local orphans
|
||||
// (device-local CRDT/account merge bases must survive preferCloud).
|
||||
if (l && r) {
|
||||
if (strategy === 'preferCloud') out.push(r);
|
||||
else if (strategy === 'preferLocal') out.push(l);
|
||||
@@ -221,7 +221,8 @@ export function mergePluginSyncSidecarsThreeWay(params: {
|
||||
}
|
||||
// both deleted
|
||||
if (b && !l && !r) continue;
|
||||
// only local / only remote additions
|
||||
// only local / only remote additions — keep local-only so preferCloud does
|
||||
// not delete unsynced local settings on the subsequent local apply path.
|
||||
if (!b && l && !r) {
|
||||
out.push(l);
|
||||
continue;
|
||||
|
||||
+53
-2
@@ -162,12 +162,56 @@ export interface ProviderAccount {
|
||||
/**
|
||||
* Cloud provider connection state
|
||||
*/
|
||||
/**
|
||||
* Opaque host-owned sync credential reference (SecretRef / CredentialRef shape).
|
||||
* Never stores plaintext secrets — only the reference the plugin connect path needs.
|
||||
* Secret leases are one-shot and must not be persisted for reconnect.
|
||||
*/
|
||||
export interface PluginSyncCredentialRef {
|
||||
kind: 'secret' | 'credential';
|
||||
id: string;
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/** Reasonable upper bounds for durable opaque ref strings persisted at rest. */
|
||||
const MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS = 512;
|
||||
const MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS = 256;
|
||||
|
||||
/**
|
||||
* Normalize a value into a durable PluginSyncCredentialRef for reconnect
|
||||
* persistence. Rejects arrays, leases, and oversized / malformed shapes.
|
||||
*/
|
||||
export function normalizeDurablePluginSyncCredentialRef(
|
||||
value: unknown,
|
||||
): PluginSyncCredentialRef | undefined {
|
||||
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const kind = record.kind;
|
||||
const id = record.id;
|
||||
if (kind !== 'secret' && kind !== 'credential') return undefined;
|
||||
if (typeof id !== 'string' || id.length < 1 || id.length > MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(record, 'key') || record.key === undefined) {
|
||||
return { kind, id };
|
||||
}
|
||||
const key = record.key;
|
||||
if (typeof key !== 'string' || key.length < 1 || key.length > MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind, id, key };
|
||||
}
|
||||
|
||||
export interface ProviderConnection {
|
||||
provider: CloudProvider;
|
||||
status: ProviderConnectionStatus;
|
||||
account?: ProviderAccount;
|
||||
tokens?: OAuthTokens;
|
||||
config?: WebDAVConfig | S3Config;
|
||||
/** Plugin sync providers: persisted SyncConnectPayload.credential for reconnect. */
|
||||
credential?: PluginSyncCredentialRef;
|
||||
lastSync?: number; // Unix timestamp
|
||||
lastSyncVersion?: number;
|
||||
resourceId?: string; // gistId / fileId / itemId
|
||||
@@ -181,10 +225,11 @@ export interface ProviderConnection {
|
||||
* existence for `config`; do not use truthiness (`||` / `Boolean`).
|
||||
*/
|
||||
export const hasProviderConnectionData = (
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
|
||||
): boolean =>
|
||||
connection.tokens != null
|
||||
|| Object.prototype.hasOwnProperty.call(connection, 'config');
|
||||
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|
||||
|| connection.credential != null;
|
||||
|
||||
export const isProviderReadyForSync = (
|
||||
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
|
||||
@@ -654,6 +699,12 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
||||
/** Registry of connected namespaced plugin sync provider IDs. */
|
||||
PLUGIN_CLOUD_PROVIDERS: 'netcatty_plugin_cloud_providers_v1',
|
||||
/** Contribution-available plugin sync provider IDs (live catalog membership). */
|
||||
AVAILABLE_PLUGIN_SYNC_PROVIDERS: 'netcatty_available_plugin_sync_providers_v1',
|
||||
/** Last successful sidecar collect (upload fallback when host is offline). */
|
||||
PLUGIN_SIDECARS_LAST_KNOWN: 'netcatty_plugin_sidecars_last_known_v1',
|
||||
/** Remote sidecar apply queued while the plugin host was unavailable. */
|
||||
PLUGIN_SIDECARS_PENDING_REMOTE: 'netcatty_plugin_sidecars_pending_remote_v1',
|
||||
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
||||
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
|
||||
CONVERGENT_REPLICA: 'netcatty_convergent_sync_replica_v2',
|
||||
|
||||
@@ -5,7 +5,7 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
|
||||
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
|
||||
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
@@ -164,12 +164,12 @@ class PluginDatabase {
|
||||
);
|
||||
CREATE INDEX plugin_sync_sidecars_lookup
|
||||
ON plugin_sync_sidecars(plugin_id, kind, key);
|
||||
PRAGMA user_version = 1;
|
||||
PRAGMA user_version = 2;
|
||||
`);
|
||||
});
|
||||
} else if (version === 1) {
|
||||
// Pre-sidecar schema-1 databases only created the original tables.
|
||||
// Ensure the non-cascade sidecar table exists in place (AGENTS.md storage migrations).
|
||||
// Migrate in place to schema 2 with the non-cascade sidecar table.
|
||||
this.transaction(() => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
|
||||
@@ -182,6 +182,7 @@ class PluginDatabase {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
|
||||
ON plugin_sync_sidecars(plugin_id, kind, key);
|
||||
PRAGMA user_version = 2;
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,6 +59,121 @@ test("obsolete unpublished v1 layouts fail with an explicit reset instruction",
|
||||
);
|
||||
});
|
||||
|
||||
test("complete schema-1 databases migrate in place to schema 2 with sidecar table", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
const v1 = new DatabaseSync(file);
|
||||
// Full pre-sidecar schema-1 layout (all tables except plugin_sync_sidecars).
|
||||
v1.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
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 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 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 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 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
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
`);
|
||||
v1.prepare(
|
||||
"INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at) VALUES (?, 1, ?, 1, 1)",
|
||||
).run("com.example.v1", "1.0.0");
|
||||
v1.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
assert.equal(database.db.prepare("PRAGMA user_version").get().user_version, SCHEMA_VERSION);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
|
||||
["plugin_id", "kind", "key", "value_json", "updated_at"],
|
||||
);
|
||||
// Existing rows survive the in-place migration.
|
||||
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
|
||||
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
|
||||
assert.equal(
|
||||
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
|
||||
"dark",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("initial schema scopes runtime and crash state to immutable plugin versions", (context) => {
|
||||
const database = createDatabase(context);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -229,6 +229,20 @@ function createPluginHostService(options) {
|
||||
}
|
||||
return originalOnPluginEnabled(pluginId);
|
||||
};
|
||||
// Startup activation goes through contributionService.initialize() → #startPlugin
|
||||
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
|
||||
const originalInitialize = contributionService.initialize.bind(contributionService);
|
||||
contributionService.initialize = async () => {
|
||||
for (const plugin of database.listPlugins()) {
|
||||
if (!plugin?.enabled || typeof plugin.id !== "string") continue;
|
||||
try {
|
||||
await syncSidecarService.hydrateInstalledPluginSettings(plugin.id);
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
}
|
||||
return originalInitialize();
|
||||
};
|
||||
const terminalDataPipelineService = options.electron.MessageChannelMain
|
||||
? new PluginTerminalDataPipelineService({
|
||||
contributionService,
|
||||
|
||||
@@ -28,6 +28,31 @@ function parseSettingsSidecarKey(key) {
|
||||
return { settingId, scope, scopeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a settings sidecar key onto the currently declared scope coordinates.
|
||||
* Returns null when the declared scope cannot be remapped safely (e.g. host
|
||||
* scope without a fresh host id).
|
||||
*/
|
||||
function resolveSettingsSidecarTarget(field, parsed) {
|
||||
const targetScope = typeof field?.scope === "string" && field.scope.length > 0
|
||||
? field.scope
|
||||
: parsed.scope;
|
||||
let targetScopeId = parsed.scopeId;
|
||||
if (targetScope === "application") {
|
||||
targetScopeId = "application";
|
||||
} else if (targetScope === "device") {
|
||||
// parseSettingsSidecarKey guarantees a non-empty scopeId when parsed is set.
|
||||
targetScopeId = parsed.scope === "device" ? parsed.scopeId : "device";
|
||||
} else if (targetScope !== parsed.scope) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
nextKey: settingsSidecarKey(parsed.settingId, targetScope, targetScopeId),
|
||||
};
|
||||
}
|
||||
|
||||
function collectPluginSyncSidecars(input) {
|
||||
const now = input.now ?? Date.now();
|
||||
const entries = [];
|
||||
@@ -156,5 +181,6 @@ module.exports = {
|
||||
isPluginSyncSidecarKind,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
settingsSidecarKey,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
isCloudSyncablePluginSetting,
|
||||
} = require("./pluginSyncSidecarHelpers.cjs");
|
||||
|
||||
@@ -161,11 +162,9 @@ class PluginSyncSidecarService {
|
||||
// Older sidecars may encode an obsolete scope after a plugin update;
|
||||
// recreating that scope would leave duplicate rows that collection
|
||||
// republishes. Application scope always uses the fixed "application" id.
|
||||
const targetScope = typeof field.scope === "string" && field.scope.length > 0
|
||||
? field.scope
|
||||
: parsed.scope;
|
||||
const targetScopeId = targetScope === "application" ? "application" : parsed.scopeId;
|
||||
const nextKey = `${parsed.settingId}\0${targetScope}\0${targetScopeId}`;
|
||||
const target = resolveSettingsSidecarTarget(field, parsed);
|
||||
if (!target) continue;
|
||||
const { targetScope, targetScopeId, nextKey } = target;
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
@@ -274,11 +273,21 @@ class PluginSyncSidecarService {
|
||||
if (!parsed) continue;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const targetScope = typeof field.scope === "string" && field.scope.length > 0
|
||||
? field.scope
|
||||
: parsed.scope;
|
||||
const targetScopeId = targetScope === "application" ? "application" : parsed.scopeId;
|
||||
const nextKey = `${parsed.settingId}\0${targetScope}\0${targetScopeId}`;
|
||||
const target = resolveSettingsSidecarTarget(field, parsed);
|
||||
if (!target) continue;
|
||||
const { targetScope, targetScopeId, nextKey } = target;
|
||||
// Do not overwrite a newer local edit that has not been collected yet.
|
||||
if (typeof this.database.listSettings === "function") {
|
||||
const localRows = this.database.listSettings(pluginId);
|
||||
const local = localRows.find((row) => (
|
||||
row.settingId === parsed.settingId
|
||||
&& row.scope === targetScope
|
||||
&& row.scopeId === targetScopeId
|
||||
));
|
||||
if (local && Number(local.updatedAt) > Number(entry.updatedAt)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
|
||||
@@ -284,6 +284,50 @@ test("hydrateInstalledPluginSettings applies retained sidecars through contribut
|
||||
);
|
||||
});
|
||||
|
||||
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"local-newer",
|
||||
20,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud-stale",
|
||||
9,
|
||||
);
|
||||
const updates = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async updateSetting(pluginId, settingId, value, scopeId) {
|
||||
updates.push({ pluginId, settingId, value, scopeId });
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.hydrateInstalledPluginSettings("com.example.sync");
|
||||
assert.equal(updates.length, 0);
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"local-newer",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
||||
|
||||
@@ -257,6 +257,12 @@ export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
|
||||
// Default SSH Key Passphrases (for ~/.ssh keys not managed in the vault)
|
||||
export const STORAGE_KEY_DEFAULT_KEY_PASSPHRASES = 'netcatty_default_key_passphrases_v1';
|
||||
|
||||
// Plugin sync sidecars / availability. Literals MUST match domain/sync
|
||||
// SYNC_STORAGE_KEYS (PLUGIN_SIDECARS_* / AVAILABLE_PLUGIN_SYNC_PROVIDERS).
|
||||
export const STORAGE_KEY_PLUGIN_SIDECARS_LAST_KNOWN = 'netcatty_plugin_sidecars_last_known_v1';
|
||||
export const STORAGE_KEY_PLUGIN_SIDECARS_PENDING_REMOTE = 'netcatty_plugin_sidecars_pending_remote_v1';
|
||||
export const STORAGE_KEY_AVAILABLE_PLUGIN_SYNC_PROVIDERS = 'netcatty_available_plugin_sync_providers_v1';
|
||||
|
||||
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
|
||||
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
|
||||
export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';
|
||||
|
||||
@@ -158,11 +158,17 @@ export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyPro
|
||||
*/
|
||||
const PLUGIN_CONFIG_ENVELOPE_KEY = "__netcatty_plugin_config_v1" as const;
|
||||
const LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY = "__encryptedPluginConfig" as const;
|
||||
/** At-rest envelope for ProviderConnection.credential (opaque refs only). */
|
||||
const PLUGIN_CREDENTIAL_ENVELOPE_KEY = "__netcatty_plugin_credential_v1" as const;
|
||||
|
||||
type PluginConfigEnvelope = {
|
||||
[PLUGIN_CONFIG_ENVELOPE_KEY]: string;
|
||||
};
|
||||
|
||||
type PluginCredentialEnvelope = {
|
||||
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: string;
|
||||
};
|
||||
|
||||
function isPluginConfigEnvelope(value: unknown): value is PluginConfigEnvelope {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
@@ -182,6 +188,15 @@ function isLegacyPluginConfigEnvelope(value: unknown): value is { __encryptedPlu
|
||||
&& typeof record[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
|
||||
}
|
||||
|
||||
function isPluginCredentialEnvelope(value: unknown): value is PluginCredentialEnvelope {
|
||||
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
return keys.length === 1
|
||||
&& keys[0] === PLUGIN_CREDENTIAL_ENVELOPE_KEY
|
||||
&& typeof record[PLUGIN_CREDENTIAL_ENVELOPE_KEY] === "string";
|
||||
}
|
||||
|
||||
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
@@ -224,6 +239,34 @@ export async function encryptProviderSecrets(conn: ProviderConnection): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// Seal durable plugin credential refs as one opaque blob (same threat model
|
||||
// as plugin config: do not leave kind/id/key plaintext in localStorage).
|
||||
if (out.credential != null && typeof out.credential === "object") {
|
||||
if (isPluginCredentialEnvelope(out.credential)) {
|
||||
// already sealed
|
||||
} else {
|
||||
const kind = (out.credential as { kind?: unknown }).kind;
|
||||
const id = (out.credential as { id?: unknown }).id;
|
||||
const key = (out.credential as { key?: unknown }).key;
|
||||
if ((kind === "secret" || kind === "credential") && typeof id === "string" && id.length > 0) {
|
||||
const normalized = {
|
||||
kind,
|
||||
id,
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
};
|
||||
const sealed = await encryptField(JSON.stringify(normalized));
|
||||
if (sealed) {
|
||||
out.credential = {
|
||||
[PLUGIN_CREDENTIAL_ENVELOPE_KEY]: sealed,
|
||||
} as unknown as ProviderConnection["credential"];
|
||||
}
|
||||
} else {
|
||||
// Drop leases / malformed shapes — never persist them at rest.
|
||||
delete out.credential;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -271,6 +314,34 @@ export async function decryptProviderSecrets(conn: ProviderConnection): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (isPluginCredentialEnvelope(out.credential)) {
|
||||
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
|
||||
if (plain != null && plain !== "") {
|
||||
try {
|
||||
const parsed = JSON.parse(plain) as {
|
||||
kind?: unknown;
|
||||
id?: unknown;
|
||||
key?: unknown;
|
||||
};
|
||||
if (
|
||||
(parsed.kind === "secret" || parsed.kind === "credential")
|
||||
&& typeof parsed.id === "string"
|
||||
&& parsed.id.length > 0
|
||||
) {
|
||||
out.credential = {
|
||||
kind: parsed.kind,
|
||||
id: parsed.id,
|
||||
...(typeof parsed.key === "string" ? { key: parsed.key } : {}),
|
||||
};
|
||||
} else {
|
||||
delete out.credential;
|
||||
}
|
||||
} catch {
|
||||
// leave sealed if corrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,17 @@ import type {
|
||||
EncryptedObjectStorageCapabilities,
|
||||
EncryptedObjectWriteResult,
|
||||
} from '../../../domain/encryptedObjectStorage';
|
||||
import type { PluginSyncCredentialRef as DurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||||
|
||||
/** Host-owned secret/credential reference from SyncConnectPayload. */
|
||||
export type PluginSyncCredentialRef = {
|
||||
kind: 'secret' | 'credential' | 'secretLease';
|
||||
id: string;
|
||||
key?: string;
|
||||
};
|
||||
/**
|
||||
* SyncConnectPayload.credential — includes one-shot leases for live connect.
|
||||
* Durable reconnect persistence only keeps secret/credential refs (see domain).
|
||||
*/
|
||||
export type PluginSyncCredentialRef =
|
||||
| DurablePluginSyncCredentialRef
|
||||
| { kind: 'secret-lease'; id: string; key?: string; operationId?: string; expiresAt?: number };
|
||||
|
||||
export type { DurablePluginSyncCredentialRef };
|
||||
|
||||
export interface PluginSyncProviderHost {
|
||||
connectSync(
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SyncPayload,
|
||||
WebDAVConfig,
|
||||
} from '../../../domain/sync';
|
||||
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
|
||||
import type {
|
||||
ProviderSyncAnchor,
|
||||
@@ -416,6 +417,10 @@ export async function connectPluginProviderImpl(
|
||||
provider: providerId,
|
||||
status: 'connected',
|
||||
config: configuration as ProviderConnection['config'],
|
||||
...((() => {
|
||||
const ref = normalizeDurablePluginSyncCredentialRef(credential);
|
||||
return ref ? { credential: ref } : {};
|
||||
})()),
|
||||
account,
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
@@ -423,10 +428,13 @@ export async function connectPluginProviderImpl(
|
||||
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
|
||||
// Preserve merge base / anchors when reconnecting the same account+resource
|
||||
// and configuration; clear when backend identity or config changes.
|
||||
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
|
||||
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
|
||||
if (
|
||||
previousAccountId !== nextAccountId
|
||||
|| previousResource !== nextResource
|
||||
|| previousConfigFp !== nextConfigFp
|
||||
|| previousCredentialFp !== nextCredentialFp
|
||||
) {
|
||||
clearProviderMergeStateImpl.call(this, providerId);
|
||||
}
|
||||
|
||||
@@ -517,18 +517,45 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
|
||||
? { version: 1 as const, entries: mergedSidecars }
|
||||
: inputPayload.pluginSidecars;
|
||||
const sidecarFingerprint = (bundle: typeof pluginSidecars) =>
|
||||
JSON.stringify(
|
||||
(bundle?.entries ?? [])
|
||||
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value])
|
||||
.sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
|
||||
);
|
||||
const localSidecarFp = sidecarFingerprint(pluginSidecars);
|
||||
const remoteSidecarMismatch = usable.some((runtime) => {
|
||||
if (runtime.error) return false;
|
||||
const sidecarFingerprint = (
|
||||
bundle: typeof pluginSidecars,
|
||||
payload?: SyncPayload | null,
|
||||
) => {
|
||||
const present = payload
|
||||
? Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')
|
||||
: bundle != null && Array.isArray(bundle.entries);
|
||||
const entries = (bundle?.entries ?? [])
|
||||
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value] as const)
|
||||
.sort((a, b) => {
|
||||
if (a[0] !== b[0]) return String(a[0]).localeCompare(String(b[0]));
|
||||
if (a[1] !== b[1]) return String(a[1]).localeCompare(String(b[1]));
|
||||
return String(a[2]).localeCompare(String(b[2]));
|
||||
});
|
||||
// Empty present and empty absent both fingerprint as empty so devices
|
||||
// do not ping-pong uploading omitted vs explicit-empty markers.
|
||||
if (entries.length === 0) {
|
||||
return JSON.stringify({ present: false, version: null, entries: [] });
|
||||
}
|
||||
return JSON.stringify({
|
||||
present,
|
||||
version: present ? (bundle?.version ?? 1) : null,
|
||||
entries,
|
||||
});
|
||||
};
|
||||
const localSidecarFp = sidecarFingerprint(
|
||||
pluginSidecars,
|
||||
Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars') || mergedSidecars.length > 0
|
||||
? { ...inputPayload, pluginSidecars }
|
||||
: inputPayload,
|
||||
);
|
||||
const providerSidecarMismatch = (runtime: { provider: string }) => {
|
||||
const decoded = preflightVerified.get(runtime.provider);
|
||||
if (!decoded) return true; // no preflight match → need upload
|
||||
return sidecarFingerprint(decoded.payload?.pluginSidecars) !== localSidecarFp;
|
||||
return sidecarFingerprint(decoded.payload?.pluginSidecars, decoded.payload) !== localSidecarFp;
|
||||
};
|
||||
const remoteSidecarMismatch = usable.some((runtime) => {
|
||||
if (runtime.error) return false;
|
||||
return providerSidecarMismatch(runtime);
|
||||
});
|
||||
const needsUpload = usable.some((runtime) => (
|
||||
!runtime.error && !preflightVerified.has(runtime.provider)
|
||||
@@ -542,7 +569,7 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
await Promise.all(usable.map(async (runtime) => {
|
||||
// Upload when CRDT preflight missed OR sidecars diverge for this provider.
|
||||
if (runtime.error) return;
|
||||
if (preflightVerified.has(runtime.provider) && !remoteSidecarMismatch) return;
|
||||
if (preflightVerified.has(runtime.provider) && !providerSidecarMismatch(runtime)) return;
|
||||
if (!outgoingPayload) return;
|
||||
try {
|
||||
const remoteVersion = runtime.latestRemote?.meta.version ?? this.state.localVersion;
|
||||
|
||||
@@ -98,6 +98,9 @@ function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
|
||||
describe('plugin provider manager boundary', () => {
|
||||
it('loads registered plugin providers into initial state and keeps them across restart', () => {
|
||||
const storage = memoryStorage();
|
||||
// Seed device identity so loadInitialStateImpl never touches browser globals.
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_ID, 'test-device');
|
||||
storage.set(SYNC_STORAGE_KEYS.DEVICE_NAME, 'Test Device');
|
||||
const manager = createManagerHarness(storage);
|
||||
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
storage.set('netcatty_provider_plugin_v1:com.example.backup.sync', {
|
||||
|
||||
@@ -43,6 +43,16 @@ const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
/** Ensure per-provider sequence counters exist (dynamic plugins arrive late). */
|
||||
function ensureProviderSeqCounters(manager: any, provider: CloudProvider): void {
|
||||
// Lightweight test harnesses may omit the maps entirely; initialize before indexing.
|
||||
if (manager.providerDecryptSeq == null || typeof manager.providerDecryptSeq !== 'object') {
|
||||
manager.providerDecryptSeq = {};
|
||||
}
|
||||
if (manager.providerWriteSeq == null || typeof manager.providerWriteSeq !== 'object') {
|
||||
manager.providerWriteSeq = {};
|
||||
}
|
||||
if (manager.providerDecrypted == null || typeof manager.providerDecrypted !== 'object') {
|
||||
manager.providerDecrypted = {};
|
||||
}
|
||||
if (manager.providerDecryptSeq[provider] == null || Number.isNaN(manager.providerDecryptSeq[provider])) {
|
||||
manager.providerDecryptSeq[provider] = 0;
|
||||
}
|
||||
@@ -163,11 +173,9 @@ export function listRegisteredPluginProviderIdsImpl(this: any): string[] {
|
||||
.sort();
|
||||
}
|
||||
|
||||
const AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY = 'netcatty_available_plugin_sync_providers_v1';
|
||||
|
||||
export function listAvailablePluginSyncProviderIdsImpl(this: any): string[] {
|
||||
if (typeof this.loadFromStorage !== 'function') return [];
|
||||
const raw = this.loadFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY) as unknown;
|
||||
const raw = this.loadFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS) as unknown;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && isPluginCloudProviderId(id))
|
||||
@@ -178,16 +186,16 @@ export function markPluginSyncProviderAvailableImpl(this: any, provider: CloudPr
|
||||
if (!isPluginCloudProviderId(provider)) return;
|
||||
const next = new Set(listAvailablePluginSyncProviderIdsImpl.call(this));
|
||||
next.add(provider);
|
||||
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, [...next].sort());
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, [...next].sort());
|
||||
}
|
||||
|
||||
export function markPluginSyncProviderUnavailableImpl(this: any, provider: CloudProvider): void {
|
||||
if (!isPluginCloudProviderId(provider)) return;
|
||||
const next = listAvailablePluginSyncProviderIdsImpl.call(this).filter((id) => id !== provider);
|
||||
if (next.length === 0) {
|
||||
this.removeFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY);
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
|
||||
} else {
|
||||
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, next);
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +214,9 @@ export function setAvailablePluginSyncProviderIdsImpl(
|
||||
providerIds.filter((id): id is string => typeof id === 'string' && isPluginCloudProviderId(id)),
|
||||
)].sort();
|
||||
if (next.length === 0) {
|
||||
this.removeFromStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY);
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
|
||||
} else {
|
||||
this.saveToStorage(AVAILABLE_PLUGIN_SYNC_PROVIDERS_KEY, next);
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
|
||||
}
|
||||
if (!this.state?.providers) return;
|
||||
let changed = false;
|
||||
@@ -546,8 +554,10 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
// Config may be a valid falsy scalar — only null/undefined means absent.
|
||||
if (nextTokens == null && nextConfig == null) {
|
||||
// Config may be a valid falsy scalar including JSON null — presence is
|
||||
// property existence (matches hasProviderConnectionData).
|
||||
const hasConfigProperty = Object.prototype.hasOwnProperty.call(next, 'config');
|
||||
if (nextTokens == null && !hasConfigProperty && next.credential == null) {
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
@@ -567,12 +577,30 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config ?? null) !== JSON.stringify(nextConfig ?? null);
|
||||
|
||||
const prevCredential = prev.credential;
|
||||
const nextCredential = next.credential;
|
||||
const credentialChanged =
|
||||
(prevCredential?.kind ?? null) !== (nextCredential?.kind ?? null)
|
||||
|| (prevCredential?.id ?? null) !== (nextCredential?.id ?? null)
|
||||
|| (prevCredential?.key ?? null) !== (nextCredential?.key ?? null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
if (adapter && (tokenChanged || configChanged || credentialChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
// Credential identity changes invalidate merge anchors the same way
|
||||
// config/resource changes do on the connect path (authMethods).
|
||||
if (credentialChanged) {
|
||||
try {
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
} catch {
|
||||
// Best-effort; adapter eviction above already forces reconnect.
|
||||
}
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
}).catch(() => {
|
||||
@@ -619,7 +647,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
|
||||
// Config may be a valid scalar including JSON null — presence is property existence.
|
||||
const hasConfigProperty = connection != null
|
||||
&& Object.prototype.hasOwnProperty.call(connection, 'config');
|
||||
if (tokens == null && !hasConfigProperty) {
|
||||
if (tokens == null && !hasConfigProperty && connection?.credential == null) {
|
||||
throw new Error('Provider not connected');
|
||||
}
|
||||
|
||||
@@ -649,6 +677,7 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
|
||||
providerId,
|
||||
host,
|
||||
configuration,
|
||||
credential: connection?.credential,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user