mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1b4f93ac | ||
|
|
551824c0e6 | ||
|
|
71b26c51a6 | ||
|
|
00b16ed421 | ||
|
|
13127bd492 | ||
|
|
b4eb61cb5e | ||
|
|
df4b05e259 | ||
|
|
1f79ea6d7c | ||
|
|
cafba2e5c7 | ||
|
|
16cc5ee090 | ||
|
|
60a953a189 | ||
|
|
83d056327c | ||
|
|
9ead42ccd6 | ||
|
|
8ae52c5458 | ||
|
|
0cbdcf75dd | ||
|
|
7b85c7af5a | ||
|
|
0d41caff96 | ||
|
|
c43f418e18 | ||
|
|
555e377e96 | ||
|
|
e37bd54ffa | ||
|
|
5917159ccb | ||
|
|
97fb0e39d2 | ||
|
|
00e7dac9ff | ||
|
|
f78f82d1e8 | ||
|
|
7e61854843 | ||
|
|
091cc82539 | ||
|
|
14740674df | ||
|
|
31d1420b72 | ||
|
|
e47f5e367d | ||
|
|
77fcf79e96 | ||
|
|
4afa963246 | ||
|
|
5a52d47bb6 | ||
|
|
15d19acd1c | ||
|
|
5e703da494 | ||
|
|
5ceaddf41d | ||
|
|
a96c335fdb | ||
|
|
fd96527e0e | ||
|
|
69500b7a69 | ||
|
|
109f88f852 | ||
|
|
89a9b53012 | ||
|
|
ab8507c060 | ||
|
|
a16967c8da | ||
|
|
6c8adf7ea5 | ||
|
|
38b4f5ee0b | ||
|
|
20ded2b5df | ||
|
|
ea3f60cc48 | ||
|
|
7f7df4c44d | ||
|
|
b65f9b8426 | ||
|
|
5dc0f68fcd | ||
|
|
86faa4136a | ||
|
|
684a397624 | ||
|
|
93924c720a | ||
|
|
52ec3fff23 | ||
|
|
3fe349d3d5 |
No files matched your search
@@ -50,7 +50,7 @@ import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useThemeRuntime, useTerminalAppearanceInjection } from './application/state/useThemeRuntime';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildLocalVaultPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
@@ -489,7 +489,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const buildCurrentSyncPayload = useCallback(() => {
|
||||
const buildCurrentSyncPayload = useCallback(async () => {
|
||||
let effectivePortForwardingRules = portForwardingRulesForSync;
|
||||
if (effectivePortForwardingRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
|
||||
@@ -505,7 +505,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
return buildLocalVaultPayloadAsync(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
@@ -548,6 +548,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [buildCurrentSyncPayload]);
|
||||
|
||||
const versionBackupAttemptedRef = useRef(false);
|
||||
const versionBackupInFlightRef = useRef(false);
|
||||
// Bumps when plugin contributions settle or the sidecar-host grace timer
|
||||
// fires so version-change backups can wait for live sidecar collect.
|
||||
const [pluginSidecarReadyTick, setPluginSidecarReadyTick] = useState(0);
|
||||
// Two-stage gate: once the vault has initialized we open the auto-sync
|
||||
// gate immediately — the hook's own hasMeaningfulSyncData guard and
|
||||
// the cross-window restore barrier prevent an empty-but-not-yet-
|
||||
@@ -559,6 +563,25 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [isVaultInitialized, startupSyncSafetyReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow) return;
|
||||
const bridge = netcattyBridge.get() as {
|
||||
onPluginContributionsChanged?: (callback: () => void) => () => void;
|
||||
} | null | undefined;
|
||||
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
|
||||
setPluginSidecarReadyTick((v) => v + 1);
|
||||
});
|
||||
// Plugins stay gated off for many installs; still allow a version
|
||||
// backup after a short grace so we do not wait forever for host ready.
|
||||
const graceTimer = window.setTimeout(() => {
|
||||
setPluginSidecarReadyTick((v) => v + 1);
|
||||
}, 5_000);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
window.clearTimeout(graceTimer);
|
||||
};
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
// Retry the version-change backup as hosts/keys/snippets become
|
||||
// available. ensureVersionChangeBackup refuses to advance the stored
|
||||
// version stamp when the observed payload is empty, so running this
|
||||
@@ -567,30 +590,47 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (isPeerSessionWindow || !isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
// Always wait for contributions or the grace tick — host-ready alone is
|
||||
// not enough because collect can still return an empty authoritative
|
||||
// bundle before plugin settings are declared.
|
||||
if (pluginSidecarReadyTick === 0) return;
|
||||
if (versionBackupInFlightRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
versionBackupInFlightRef.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await buildCurrentSyncPayloadRef.current();
|
||||
if (cancelled) return;
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
const info = await netcattyBridge.get()?.getAppInfo?.();
|
||||
if (cancelled) return;
|
||||
await ensureVersionChangeBackup(payload, info?.version ?? null);
|
||||
if (cancelled) return;
|
||||
// Latch only after a completed (non-cancelled) attempt so effect
|
||||
// cleanup cannot permanently suppress retries when the in-flight
|
||||
// call later throws.
|
||||
versionBackupAttemptedRef.current = true;
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
// Reset the latch so a later data change (or the next mount)
|
||||
// can retry. ensureVersionChangeBackup already leaves the
|
||||
// version stamp untouched on failure, so retrying is safe.
|
||||
versionBackupAttemptedRef.current = false;
|
||||
console.error('[App] Failed to create version-change backup:', error);
|
||||
}
|
||||
} finally {
|
||||
versionBackupInFlightRef.current = false;
|
||||
// If hydration re-ran the effect mid-flight, nudge another attempt
|
||||
// now that the in-flight guard is clear.
|
||||
if (cancelled && !versionBackupAttemptedRef.current) {
|
||||
queueMicrotask(() => {
|
||||
setPluginSidecarReadyTick((v) => v + 1);
|
||||
});
|
||||
}
|
||||
console.error('[App] Failed to create version-change backup:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts]);
|
||||
}, [isPeerSessionWindow, isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, notes, noteGroups, knownHosts, pluginSidecarReadyTick]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
|
||||
@@ -332,6 +332,13 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV connection failed',
|
||||
'cloudSync.connect.s3.success': 'S3 connected successfully',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 connection failed',
|
||||
'cloudSync.connect.plugin.success': 'Plugin sync provider connected successfully',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Plugin sync connection failed',
|
||||
'cloudSync.pluginConfig.title': 'Configure {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Enter the JSON configuration required by this plugin sync provider.',
|
||||
'cloudSync.pluginConfig.label': 'Provider configuration (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'Configuration must be valid JSON.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'Configuration does not match the provider schema.',
|
||||
'cloudSync.lastSync.never': 'Never',
|
||||
'cloudSync.lastSync.justNow': 'Just now',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} min ago',
|
||||
|
||||
@@ -348,6 +348,13 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.connect.webdav.failedTitle': 'Ошибка подключения WebDAV',
|
||||
'cloudSync.connect.s3.success': 'S3 успешно подключён',
|
||||
'cloudSync.connect.s3.failedTitle': 'Ошибка подключения S3',
|
||||
'cloudSync.connect.plugin.success': 'Провайдер синхронизации плагина подключен',
|
||||
'cloudSync.connect.plugin.failedTitle': 'Ошибка подключения синхронизации плагина',
|
||||
'cloudSync.pluginConfig.title': 'Настройка {name}',
|
||||
'cloudSync.pluginConfig.desc': 'Введите JSON-конфигурацию, требуемую этим провайдером синхронизации.',
|
||||
'cloudSync.pluginConfig.label': 'Конфигурация провайдера (JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': 'Конфигурация должна быть допустимым JSON.',
|
||||
'cloudSync.pluginConfig.schemaInvalid': 'Конфигурация не соответствует схеме провайдера.',
|
||||
'cloudSync.lastSync.never': 'Никогда',
|
||||
'cloudSync.lastSync.justNow': 'Только что',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} мин назад',
|
||||
|
||||
@@ -659,6 +659,13 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 连接失败',
|
||||
'cloudSync.connect.s3.success': 'S3 已连接',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 连接失败',
|
||||
'cloudSync.connect.plugin.success': '插件同步提供方已连接',
|
||||
'cloudSync.connect.plugin.failedTitle': '插件同步连接失败',
|
||||
'cloudSync.pluginConfig.title': '配置 {name}',
|
||||
'cloudSync.pluginConfig.desc': '请输入该插件同步提供方所需的 JSON 配置。',
|
||||
'cloudSync.pluginConfig.label': '提供方配置(JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': '配置必须是有效的 JSON。',
|
||||
'cloudSync.pluginConfig.schemaInvalid': '配置不符合提供方声明的结构。',
|
||||
'cloudSync.lastSync.never': '从未',
|
||||
'cloudSync.lastSync.justNow': '刚刚',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} 分钟前',
|
||||
|
||||
@@ -659,6 +659,13 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.connect.webdav.failedTitle': 'WebDAV 連線失敗',
|
||||
'cloudSync.connect.s3.success': 'S3 已連線',
|
||||
'cloudSync.connect.s3.failedTitle': 'S3 連線失敗',
|
||||
'cloudSync.connect.plugin.success': '外掛同步提供者已連線',
|
||||
'cloudSync.connect.plugin.failedTitle': '外掛同步連線失敗',
|
||||
'cloudSync.pluginConfig.title': '設定 {name}',
|
||||
'cloudSync.pluginConfig.desc': '請輸入此外掛同步提供者所需的 JSON 設定。',
|
||||
'cloudSync.pluginConfig.label': '提供者設定(JSON)',
|
||||
'cloudSync.pluginConfig.invalidJson': '設定必須是有效的 JSON。',
|
||||
'cloudSync.pluginConfig.schemaInvalid': '設定不符合提供者宣告的結構。',
|
||||
'cloudSync.lastSync.never': '從未',
|
||||
'cloudSync.lastSync.justNow': '剛剛',
|
||||
'cloudSync.lastSync.minutesAgo': '{minutes} 分鐘前',
|
||||
|
||||
@@ -400,7 +400,7 @@ function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void
|
||||
type ProtectedApplyCallback = () => void | Promise<void>;
|
||||
|
||||
export type ApplyProtectedSyncPayloadOptions = {
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
buildPreApplyPayload: () => SyncPayload | Promise<SyncPayload>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
} & (
|
||||
| { applyPayload: ProtectedApplyCallback; prepareApply?: never }
|
||||
@@ -415,7 +415,7 @@ export function applyProtectedSyncPayload(
|
||||
const applyPayload = options.prepareApply
|
||||
? await options.prepareApply()
|
||||
: options.applyPayload;
|
||||
const pre = buildPreApplyPayload();
|
||||
const pre = await buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { storePluginSyncSecretsThenConnect } from './pluginSyncConnectWithSecrets.ts';
|
||||
|
||||
describe('storePluginSyncSecretsThenConnect', () => {
|
||||
it('deletes just-created secret keys when connect rejects', async () => {
|
||||
const putCalls: Array<{ key: string; value: string }> = [];
|
||||
const deletedKeys: string[][] = [];
|
||||
let connectCalls = 0;
|
||||
|
||||
await assert.rejects(
|
||||
() => storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [
|
||||
{ secretKey: 'sync-credential', value: 'bad-password' },
|
||||
{ secretKey: 'sync-credential:token', value: 'bad-token' },
|
||||
],
|
||||
putSecret: async ({ key, value }) => {
|
||||
putCalls.push({ key, value });
|
||||
return { kind: 'secret', id: `ref-${key}`, key };
|
||||
},
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
connect: async () => {
|
||||
connectCalls += 1;
|
||||
throw new Error('auth failed');
|
||||
},
|
||||
}),
|
||||
/auth failed/,
|
||||
);
|
||||
|
||||
assert.equal(connectCalls, 1);
|
||||
assert.deepEqual(putCalls, [
|
||||
{ key: 'sync-credential', value: 'bad-password' },
|
||||
{ key: 'sync-credential:token', value: 'bad-token' },
|
||||
]);
|
||||
assert.deepEqual(deletedKeys, [['sync-credential', 'sync-credential:token']]);
|
||||
});
|
||||
|
||||
it('does not delete secrets when connect succeeds', async () => {
|
||||
const deletedKeys: string[][] = [];
|
||||
await storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [{ secretKey: 'sync-credential', value: 'ok' }],
|
||||
putSecret: async ({ key }) => ({ kind: 'secret', id: 'ref-1', key }),
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
connect: async () => {},
|
||||
});
|
||||
assert.deepEqual(deletedKeys, []);
|
||||
});
|
||||
|
||||
it('does not delete when reusing an existing credential without new secrets', async () => {
|
||||
const deletedKeys: string[][] = [];
|
||||
const existing = { kind: 'secret' as const, id: 'existing-ref', key: 'sync-credential' };
|
||||
await assert.rejects(
|
||||
() => storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [],
|
||||
existingCredential: existing,
|
||||
putSecret: async () => {
|
||||
throw new Error('put should not run');
|
||||
},
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
connect: async () => {
|
||||
throw new Error('network failed');
|
||||
},
|
||||
}),
|
||||
/network failed/,
|
||||
);
|
||||
assert.deepEqual(deletedKeys, []);
|
||||
});
|
||||
|
||||
it('deletes secrets that were put before a later put fails', async () => {
|
||||
const deletedKeys: string[][] = [];
|
||||
await assert.rejects(
|
||||
() => storePluginSyncSecretsThenConnect({
|
||||
providerId: 'plugin:sync.example',
|
||||
secrets: [
|
||||
{ secretKey: 'sync-credential', value: 'first' },
|
||||
{ secretKey: 'sync-credential:token', value: 'second' },
|
||||
],
|
||||
putSecret: async ({ key }) => {
|
||||
if (key === 'sync-credential:token') throw new Error('put failed');
|
||||
return { kind: 'secret', id: 'ref-1', key };
|
||||
},
|
||||
deleteSecrets: async ({ keys }) => {
|
||||
deletedKeys.push([...keys]);
|
||||
return { deleted: keys.length };
|
||||
},
|
||||
connect: async () => {
|
||||
throw new Error('connect should not run');
|
||||
},
|
||||
}),
|
||||
/put failed/,
|
||||
);
|
||||
assert.deepEqual(deletedKeys, [['sync-credential']]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Persist plugin sync secrets then connect; roll back just-created secrets if
|
||||
* connect (or a later put) fails so bad passwords are not left in OS storage.
|
||||
*/
|
||||
|
||||
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
|
||||
|
||||
export interface PluginSyncConnectSecretInput {
|
||||
secretKey: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface StorePluginSyncSecretsThenConnectParams {
|
||||
providerId: string;
|
||||
secrets: readonly PluginSyncConnectSecretInput[];
|
||||
/** Reused when `secrets` is empty (edit non-secret fields / reconnect). */
|
||||
existingCredential?: PluginSyncSecretRef;
|
||||
putSecret: (params: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => Promise<PluginSyncSecretRef>;
|
||||
deleteSecrets: (params: {
|
||||
providerId: string;
|
||||
keys: string[];
|
||||
}) => Promise<unknown>;
|
||||
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function storePluginSyncSecretsThenConnect(
|
||||
params: StorePluginSyncSecretsThenConnectParams,
|
||||
): Promise<void> {
|
||||
const createdKeys: string[] = [];
|
||||
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
|
||||
|
||||
try {
|
||||
if (params.secrets.length > 0) {
|
||||
credential = undefined;
|
||||
for (const secret of params.secrets) {
|
||||
const ref = await params.putSecret({
|
||||
providerId: params.providerId,
|
||||
key: secret.secretKey,
|
||||
value: secret.value,
|
||||
});
|
||||
createdKeys.push(secret.secretKey);
|
||||
// SyncConnectPayload.credential carries the primary (first) secret;
|
||||
// additional secrets remain addressable via secrets.get(key).
|
||||
if (!credential) credential = ref;
|
||||
}
|
||||
}
|
||||
await params.connect(credential);
|
||||
} catch (error) {
|
||||
if (createdKeys.length > 0) {
|
||||
try {
|
||||
await params.deleteSecrets({
|
||||
providerId: params.providerId,
|
||||
keys: [...createdKeys],
|
||||
});
|
||||
} catch {
|
||||
/* best-effort; surface the original connect/put error */
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() { store.clear(); },
|
||||
getItem(key) { return store.has(key) ? store.get(key)! : null; },
|
||||
setItem(key, value) { store.set(key, String(value)); },
|
||||
removeItem(key) { store.delete(key); },
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
|
||||
const { SYNC_STORAGE_KEYS } = await import('../domain/sync.ts');
|
||||
const { localStorageAdapter } = await import('../infrastructure/persistence/localStorageAdapter.ts');
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
commitPluginSidecarsLastKnown,
|
||||
isPluginSidecarHostReady,
|
||||
} = await import('./pluginSyncSidecarBridge.ts');
|
||||
const { hasMeaningfulCloudSyncData } = await import('./syncPayload.ts');
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
|
||||
test('isPluginSidecarHostReady follows pluginHostReady probe when present', () => {
|
||||
(globalThis as { window: unknown }).window = {
|
||||
netcatty: {
|
||||
pluginHostReady: () => true,
|
||||
async collectPluginSyncSidecars() {
|
||||
return { version: 1, entries: [] };
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(isPluginSidecarHostReady(), true);
|
||||
|
||||
(globalThis as { window: unknown }).window = {
|
||||
netcatty: {
|
||||
pluginHostReady: () => false,
|
||||
async collectPluginSyncSidecars() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(isPluginSidecarHostReady(), false);
|
||||
});
|
||||
|
||||
test('collect defers empty last-known until commit (empty-vault guard ignores last-known alone)', async () => {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.p',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
(globalThis as { window: unknown }).window = {
|
||||
netcatty: {
|
||||
async collectPluginSyncSidecars() {
|
||||
return { version: 1, entries: [] };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const collected = await collectPluginSyncSidecarsFromHost();
|
||||
assert.deepEqual(collected, { version: 1, entries: [] });
|
||||
|
||||
// Last-known still holds prior entries until commit, but that alone must not
|
||||
// bypass the empty-vault upload guard.
|
||||
const lastKnown = localStorageAdapter.read<{ entries: unknown[] }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.equal(lastKnown?.entries?.length, 1);
|
||||
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
commitPluginSidecarsLastKnown({ version: 1, entries: [] });
|
||||
const committed = localStorageAdapter.read<{ entries: unknown[] }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.deepEqual(committed?.entries, []);
|
||||
});
|
||||
|
||||
test('liveOnly collect returns null instead of last-known when host is gated off', async () => {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.p',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 1,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
(globalThis as { window: unknown }).window = {
|
||||
netcatty: {
|
||||
async collectPluginSyncSidecars() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
|
||||
const fallback = await collectPluginSyncSidecarsFromHost();
|
||||
assert.equal(fallback?.entries?.length, 1);
|
||||
});
|
||||
|
||||
test('liveOnly collect omits pending remote when replay cannot apply', async () => {
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.remote',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 'remote',
|
||||
updatedAt: 2,
|
||||
}],
|
||||
});
|
||||
(globalThis as { window: unknown }).window = {
|
||||
netcatty: {
|
||||
async collectPluginSyncSidecars() {
|
||||
return { version: 1, entries: [] };
|
||||
},
|
||||
// no applyPluginSyncSidecars → pending cannot replay
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(await collectPluginSyncSidecarsFromHost({ liveOnly: true }), null);
|
||||
const uploadPath = await collectPluginSyncSidecarsFromHost();
|
||||
assert.equal(uploadPath?.entries?.[0]?.value, 'remote');
|
||||
});
|
||||
|
||||
test('commitPluginSidecarsAfterSuccessfulSync prefers merged payload sidecars', async () => {
|
||||
const { commitPluginSidecarsAfterSuccessfulSync } = await import('./pluginSyncSidecarBridge.ts');
|
||||
localStorageAdapter.write(SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.old',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 'old',
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
commitPluginSidecarsAfterSuccessfulSync(
|
||||
{
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.local',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 'local',
|
||||
updatedAt: 2,
|
||||
}],
|
||||
},
|
||||
},
|
||||
[{
|
||||
success: true,
|
||||
mergedPayload: {
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: 'com.example.merged',
|
||||
kind: 'settings',
|
||||
key: 'k',
|
||||
value: 'merged',
|
||||
updatedAt: 3,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}],
|
||||
);
|
||||
|
||||
const committed = localStorageAdapter.read<{ entries: Array<{ value: string }> }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
assert.equal(committed?.entries?.[0]?.value, 'merged');
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Renderer bridge for plugin encrypted-sync sidecars.
|
||||
* Collects/applies sidecars through the main-process non-cascade store when
|
||||
* the plugin host is available. When the host is offline, last-known sidecars
|
||||
* are retained so cloud uploads cannot wipe previously synced plugin data.
|
||||
*/
|
||||
|
||||
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 = 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 = SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_PENDING_REMOTE;
|
||||
const HOST_UNAVAILABLE_MARKER = 'PLUGIN_SIDECAR_HOST_UNAVAILABLE';
|
||||
|
||||
export class PluginSidecarHostUnavailableError extends Error {
|
||||
readonly code = HOST_UNAVAILABLE_MARKER;
|
||||
|
||||
constructor(message = 'Plugin sidecar host is unavailable') {
|
||||
super(message);
|
||||
this.name = 'PluginSidecarHostUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginSidecarHostUnavailableError(error: unknown): boolean {
|
||||
if (error instanceof PluginSidecarHostUnavailableError) return true;
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const maybe = error as { code?: unknown; message?: unknown };
|
||||
if (maybe.code === HOST_UNAVAILABLE_MARKER) return true;
|
||||
return typeof maybe.message === 'string'
|
||||
&& maybe.message.includes('Plugin sidecar host is unavailable');
|
||||
}
|
||||
|
||||
type ElectronSidecarApi = {
|
||||
collectPluginSyncSidecars?: () => Promise<PluginSyncSidecarBundle | null | undefined>;
|
||||
applyPluginSyncSidecars?: (
|
||||
bundle: PluginSyncSidecarBundle | null | undefined,
|
||||
) => Promise<unknown>;
|
||||
/** True only when main process wired PluginSyncSidecarService. */
|
||||
pluginHostReady?: () => boolean;
|
||||
};
|
||||
|
||||
function getSidecarApi(): ElectronSidecarApi | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Preload exposes the production bridge as window.netcatty only.
|
||||
const bridge = (window as Window & {
|
||||
netcatty?: ElectronSidecarApi;
|
||||
electron?: ElectronSidecarApi;
|
||||
}).netcatty
|
||||
?? (window as Window & { electron?: ElectronSidecarApi }).electron;
|
||||
return bridge ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the main-process sidecar service is wired. Version-change backups
|
||||
* should wait for this (or a grace timeout) before latching, otherwise an
|
||||
* early empty collect permanently skips plugin settings in the upgrade snapshot.
|
||||
*/
|
||||
export function isPluginSidecarHostReady(): boolean {
|
||||
const api = getSidecarApi();
|
||||
if (!api) return false;
|
||||
if (typeof api.pluginHostReady === 'function') {
|
||||
try {
|
||||
return api.pluginHostReady() === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Older / test bridges without the probe: collect presence implies ready.
|
||||
return typeof api.collectPluginSyncSidecars === 'function';
|
||||
}
|
||||
|
||||
function readBundle(key: string): PluginSyncSidecarBundle | null {
|
||||
const raw = localStorageAdapter.read<PluginSyncSidecarBundle>(key);
|
||||
if (!raw || !Array.isArray(raw.entries)) return null;
|
||||
return { version: 1, entries: raw.entries };
|
||||
}
|
||||
|
||||
function writeBundle(key: string, bundle: PluginSyncSidecarBundle | null | undefined): void {
|
||||
if (!bundle || !Array.isArray(bundle.entries)) {
|
||||
localStorageAdapter.remove(key);
|
||||
return;
|
||||
}
|
||||
const ok = localStorageAdapter.write(key, {
|
||||
version: 1,
|
||||
entries: bundle.entries,
|
||||
});
|
||||
if (ok === false) {
|
||||
throw new Error(
|
||||
`Failed to persist plugin sidecars (${key}); local storage write was rejected`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readLastKnownSidecars(): PluginSyncSidecarBundle | null {
|
||||
return readBundle(LAST_KNOWN_SIDECARS_KEY);
|
||||
}
|
||||
|
||||
function writeLastKnownSidecars(bundle: PluginSyncSidecarBundle | null | undefined): void {
|
||||
writeBundle(LAST_KNOWN_SIDECARS_KEY, bundle);
|
||||
}
|
||||
|
||||
/** null means no pending remote apply. Empty entries is a valid pending reset. */
|
||||
function readPendingRemoteSidecars(): PluginSyncSidecarBundle | null {
|
||||
const raw = localStorageAdapter.read<PluginSyncSidecarBundle | null>(PENDING_REMOTE_SIDECARS_KEY);
|
||||
if (raw == null) return null;
|
||||
if (!Array.isArray(raw.entries)) return null;
|
||||
return { version: 1, entries: raw.entries };
|
||||
}
|
||||
|
||||
function writePendingRemoteSidecars(bundle: PluginSyncSidecarBundle): void {
|
||||
const ok = localStorageAdapter.write(PENDING_REMOTE_SIDECARS_KEY, {
|
||||
version: 1,
|
||||
entries: bundle.entries,
|
||||
});
|
||||
if (ok === false) {
|
||||
// Operational failure — must not look like host-unavailable (which apply
|
||||
// swallows). Sync must abort so the remote bundle is not lost.
|
||||
throw new Error(
|
||||
'Failed to queue pending remote plugin sidecars; local storage write was rejected',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingRemoteSidecars(): void {
|
||||
localStorageAdapter.remove(PENDING_REMOTE_SIDECARS_KEY);
|
||||
}
|
||||
|
||||
function isAuthoritativeBundle(
|
||||
value: unknown,
|
||||
): value is PluginSyncSidecarBundle {
|
||||
return Boolean(
|
||||
value
|
||||
&& typeof value === 'object'
|
||||
&& Array.isArray((value as PluginSyncSidecarBundle).entries),
|
||||
);
|
||||
}
|
||||
|
||||
function isSuccessfulApplyResult(result: unknown): boolean {
|
||||
if (result == null) return false;
|
||||
if (
|
||||
typeof result === 'object'
|
||||
&& result !== null
|
||||
&& 'applied' in result
|
||||
&& (result as { applied?: unknown }).applied === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* - a real bundle (possibly empty entries) when the host collected successfully
|
||||
* - last-known bundle when the host is unavailable (protect remote from wipe)
|
||||
* Throws on operational host failure (DB/runtime error) so sync aborts.
|
||||
*
|
||||
* Pending remote applies (host was offline during download) are replayed into
|
||||
* the DB before collection. Ordinary last-known collect snapshots are never
|
||||
* re-applied, so local settings edits made after the last collect are kept.
|
||||
*/
|
||||
export async function collectPluginSyncSidecarsFromHost(options?: {
|
||||
/**
|
||||
* When true, never fall back to last-known cache. Returns null if the host is
|
||||
* gated off / non-authoritative so callers can omit `pluginSidecars` instead
|
||||
* of applying a stale cache snapshot (e.g. convergent conflict materialize).
|
||||
*/
|
||||
liveOnly?: boolean;
|
||||
} = {}): Promise<PluginSyncSidecarBundle | null> {
|
||||
const liveOnly = options.liveOnly === true;
|
||||
const api = getSidecarApi();
|
||||
if (typeof api?.collectPluginSyncSidecars !== 'function') {
|
||||
return liveOnly ? null : readLastKnownSidecars();
|
||||
}
|
||||
|
||||
const pendingRemote = readPendingRemoteSidecars();
|
||||
if (pendingRemote && typeof api.applyPluginSyncSidecars === 'function') {
|
||||
try {
|
||||
const replayResult = await api.applyPluginSyncSidecars(pendingRemote);
|
||||
if (!isSuccessfulApplyResult(replayResult)) {
|
||||
// Upload path: keep returning pending so we do not push stale local
|
||||
// over an authoritative remote still waiting to apply.
|
||||
// liveOnly (conflict materialize): omit instead of attaching the queue.
|
||||
return liveOnly ? null : pendingRemote;
|
||||
}
|
||||
clearPendingRemoteSidecars();
|
||||
} catch (error) {
|
||||
// Operational failure: keep pending and surface so upload aborts.
|
||||
// Host-unavailable during replay: upload keeps pending; liveOnly omits.
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
return liveOnly ? null : pendingRemote;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (pendingRemote && typeof api.applyPluginSyncSidecars !== 'function') {
|
||||
return liveOnly ? null : pendingRemote;
|
||||
}
|
||||
|
||||
const bundle = await api.collectPluginSyncSidecars();
|
||||
// Passive/null means the plugin host is gated off or manager resolution failed.
|
||||
// Do not treat that as an authoritative empty bundle (would wipe last-known).
|
||||
if (bundle == null || !isAuthoritativeBundle(bundle)) {
|
||||
return liveOnly ? null : readLastKnownSidecars();
|
||||
}
|
||||
const normalized: PluginSyncSidecarBundle = {
|
||||
version: 1,
|
||||
entries: bundle.entries,
|
||||
};
|
||||
// Defer overwriting a non-empty last-known with an authoritative empty collect.
|
||||
// useAutoSync runs hasMeaningfulCloudSyncData() after buildPayload/collect; if
|
||||
// we erased last-known here, a deliberate plugin-only reset (entries → [])
|
||||
// would look like an empty vault and be blocked. Commit the empty cache after
|
||||
// a successful upload via commitPluginSidecarsLastKnown().
|
||||
const previous = readLastKnownSidecars();
|
||||
const previousHadEntries = Array.isArray(previous?.entries) && previous.entries.length > 0;
|
||||
if (!(previousHadEntries && normalized.entries.length === 0)) {
|
||||
writeLastKnownSidecars(normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the sidecar bundle that was just successfully synced (or applied).
|
||||
* Clears a deferred non-empty last-known after an empty reset upload.
|
||||
*/
|
||||
export function commitPluginSidecarsLastKnown(
|
||||
bundle: PluginSyncSidecarBundle | null | undefined,
|
||||
): void {
|
||||
if (!bundle || !Array.isArray(bundle.entries)) {
|
||||
writeLastKnownSidecars({ version: 1, entries: [] });
|
||||
return;
|
||||
}
|
||||
writeLastKnownSidecars({ version: 1, entries: bundle.entries });
|
||||
}
|
||||
|
||||
/**
|
||||
* After a successful cloud upload/download round-trip, finalize the deferred
|
||||
* last-known sidecar cache. Prefer merged/downloaded payload sidecars when
|
||||
* present so we do not overwrite a remote apply with the pre-sync local collect.
|
||||
*/
|
||||
export function commitPluginSidecarsAfterSuccessfulSync(
|
||||
payload: { pluginSidecars?: PluginSyncSidecarBundle | null },
|
||||
results: Iterable<{
|
||||
success?: boolean;
|
||||
mergedPayload?: { pluginSidecars?: PluginSyncSidecarBundle | null } | null;
|
||||
}>,
|
||||
): void {
|
||||
const resultList = Array.from(results);
|
||||
if (!resultList.some((result) => result.success === true)) return;
|
||||
|
||||
let commitSidecars: PluginSyncSidecarBundle | undefined;
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
commitSidecars = {
|
||||
version: 1,
|
||||
entries: Array.isArray(payload.pluginSidecars?.entries) ? payload.pluginSidecars.entries : [],
|
||||
};
|
||||
}
|
||||
for (const result of resultList) {
|
||||
if (
|
||||
result.mergedPayload
|
||||
&& Object.prototype.hasOwnProperty.call(result.mergedPayload, 'pluginSidecars')
|
||||
) {
|
||||
commitSidecars = {
|
||||
version: 1,
|
||||
entries: Array.isArray(result.mergedPayload.pluginSidecars?.entries)
|
||||
? result.mergedPayload.pluginSidecars.entries
|
||||
: [],
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (commitSidecars) commitPluginSidecarsLastKnown(commitSidecars);
|
||||
}
|
||||
|
||||
export async function applyPluginSyncSidecarsFromHost(
|
||||
bundle: PluginSyncSidecarBundle | null | undefined,
|
||||
): Promise<void> {
|
||||
// Empty bundle is authoritative (cloud deleted all sidecars) — still apply.
|
||||
const normalized: PluginSyncSidecarBundle = {
|
||||
version: 1,
|
||||
entries: Array.isArray(bundle?.entries) ? bundle.entries : [],
|
||||
};
|
||||
const api = getSidecarApi();
|
||||
if (typeof api?.applyPluginSyncSidecars !== 'function') {
|
||||
// Host offline: queue remote apply for later DB replay, and keep last-known
|
||||
// for upload protection so cloud is not wiped with empty collect.
|
||||
writePendingRemoteSidecars(normalized);
|
||||
writeLastKnownSidecars(normalized);
|
||||
throw new PluginSidecarHostUnavailableError(
|
||||
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
|
||||
);
|
||||
}
|
||||
const result = await api.applyPluginSyncSidecars(normalized);
|
||||
// Passive IPC returns null when the manager is unavailable, or
|
||||
// { applied: false } when the sidecar service was not wired.
|
||||
if (!isSuccessfulApplyResult(result)) {
|
||||
writePendingRemoteSidecars(normalized);
|
||||
writeLastKnownSidecars(normalized);
|
||||
throw new PluginSidecarHostUnavailableError(
|
||||
'Plugin sidecar host is unavailable; cannot apply downloaded sidecars',
|
||||
);
|
||||
}
|
||||
clearPendingRemoteSidecars();
|
||||
// Prefer merged entries returned by apply (includes missing-plugin rows the
|
||||
// remote omitted) when refreshing last-known without a successful collect.
|
||||
const appliedMerged: PluginSyncSidecarBundle | null = Array.isArray(
|
||||
(result as { entries?: unknown }).entries,
|
||||
)
|
||||
? { version: 1, entries: (result as { entries: PluginSyncSidecarBundle['entries'] }).entries }
|
||||
: null;
|
||||
const fallbackLastKnown = appliedMerged ?? normalized;
|
||||
// Prefer a fresh host collect so last-known reflects post-apply host state.
|
||||
if (typeof api.collectPluginSyncSidecars === 'function') {
|
||||
try {
|
||||
const collected = await api.collectPluginSyncSidecars();
|
||||
if (isAuthoritativeBundle(collected)) {
|
||||
writeLastKnownSidecars({ version: 1, entries: collected.entries });
|
||||
return;
|
||||
}
|
||||
// Apply already committed; collect shape was non-authoritative. Prefer the
|
||||
// merged apply result over the raw remote so preserved local rows survive.
|
||||
writeLastKnownSidecars(fallbackLastKnown);
|
||||
return;
|
||||
} catch {
|
||||
// Apply already committed. Do not fall back to the raw remote bundle —
|
||||
// that would drop missing-plugin rows applyFromSync preserved.
|
||||
try {
|
||||
writeLastKnownSidecars(fallbackLastKnown);
|
||||
} catch {
|
||||
// last-known write failure is secondary; apply already succeeded.
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No collect API: use merged apply result when available, else remote.
|
||||
writeLastKnownSidecars(fallbackLastKnown);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const publishCredentialCatalogIds = (ids: ReadonlyArray<string>) => {
|
||||
};
|
||||
|
||||
export const pluginExtensionBridge = Object.freeze({
|
||||
async listProviders(kind: "connection" | "authentication" | "importer") {
|
||||
async listProviders(kind: "connection" | "authentication" | "importer" | "sync") {
|
||||
return requireBridge().listPluginExtensionProviders?.({ kind }) ?? [];
|
||||
},
|
||||
async updateCredentialCatalog(entries: ReadonlyArray<{ id: string; ciphertext: string }>) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBridge';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
@@ -76,6 +77,21 @@ interface AutoSyncConfig {
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
/** Prefer built-in order, then any connected namespaced plugin provider. */
|
||||
function pickConnectedAutoSyncProvider(
|
||||
providers: Record<string, { status?: string; tokens?: unknown; config?: unknown }>,
|
||||
): CloudProvider | undefined {
|
||||
for (const id of AUTO_SYNC_PROVIDER_ORDER) {
|
||||
const conn = providers[id];
|
||||
if (conn && isProviderReadyForSync(conn as never)) return id;
|
||||
}
|
||||
for (const [id, conn] of Object.entries(providers)) {
|
||||
if (AUTO_SYNC_PROVIDER_ORDER.includes(id as CloudProvider)) continue;
|
||||
if (conn && isProviderReadyForSync(conn as never)) return id as CloudProvider;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
@@ -125,6 +141,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -188,6 +205,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
|
||||
}, []);
|
||||
|
||||
// Plugin settings live in the main-process DB; contribution change events
|
||||
// are the renderer signal that sidecar-backed values may have changed.
|
||||
const [pluginSidecarsVersion, setPluginSidecarsVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get() as {
|
||||
onPluginContributionsChanged?: (callback: () => void) => () => void;
|
||||
} | null | undefined;
|
||||
const unsubscribe = bridge?.onPluginContributionsChanged?.(() => {
|
||||
setPluginSidecarsVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const bumpIfSyncableSetting = (key: string | null | undefined) => {
|
||||
@@ -239,19 +271,24 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.groupConfigs,
|
||||
]);
|
||||
|
||||
// Build sync payload
|
||||
// Build sync payload (includes plugin sidecars when host is available)
|
||||
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
return {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const base: SyncPayload = {
|
||||
...getSyncSnapshot(),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
const sidecars = await collectPluginSyncSidecarsFromHost();
|
||||
return withPluginSyncSidecars(base, sidecars);
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
// Create a hash of current data for comparison (includes settings + sidecars)
|
||||
const getDataHash = useCallback(async () => {
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: await collectCloudSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
const payload = await buildPayload();
|
||||
return getSyncPayloadDataHash(payload);
|
||||
}, [buildPayload]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions): Promise<boolean> => {
|
||||
@@ -346,8 +383,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.vaultLocked'));
|
||||
}
|
||||
|
||||
const dataHash = await getDataHash();
|
||||
const payload = await buildPayload();
|
||||
const dataHash = getSyncPayloadDataHash(payload);
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (
|
||||
encryptedCredentialPaths.length > 0
|
||||
@@ -421,6 +458,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Commit sidecar last-known after a successful sync (also done inside
|
||||
// useCloudSync.syncNow; keep here as defense for this path's merge logic).
|
||||
commitPluginSidecarsAfterSuccessfulSync(payload, resultList);
|
||||
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
manager.setPendingLocalSync(false);
|
||||
|
||||
@@ -517,13 +558,19 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (!remoteCheckDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentHash = await getDataHashRef.current();
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
manager.setPendingLocalSync(hashDecision === 'sync');
|
||||
try {
|
||||
const currentHash = await getDataHashRef.current();
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
manager.setPendingLocalSync(hashDecision === 'sync');
|
||||
} catch (error) {
|
||||
// Sidecar collect can throw on host DB/runtime errors; leave the
|
||||
// pending indicator unchanged rather than surfacing an unhandled rejection.
|
||||
console.warn('[AutoSync] Failed to refresh pending local sync:', error);
|
||||
}
|
||||
}, [convergentSyncPaused, enabled, sync.hasAnyConnectedProvider, sync.isUnlocked]);
|
||||
|
||||
const refreshPendingLocalSyncRef = useRef(refreshPendingLocalSync);
|
||||
@@ -567,9 +614,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// "nothing to check" early return doesn't leak the lock and wedge
|
||||
// the retry timer. Any path that takes the lock MUST reach the
|
||||
// finally-release below.
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
const connectedProvider = pickConnectedAutoSyncProvider(state.providers) ?? null;
|
||||
|
||||
if (!connectedProvider) {
|
||||
// Nothing to check — mark as done so the auto-sync gate opens.
|
||||
@@ -715,6 +760,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
conflictActionOverride: 'upload-local',
|
||||
});
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(remotePayload, roundTripResultList);
|
||||
const wasShrinkBlocked = roundTripResultList.some((result) => result.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
@@ -734,6 +780,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (conflictAction === 'upload-local') {
|
||||
const pushResults = await manager.syncAllProviders(localPayload);
|
||||
const results = Array.from(pushResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(localPayload, results);
|
||||
const allProvidersSynced = results.length > 0
|
||||
&& results.every((result) => result.success);
|
||||
const wasShrinkBlocked = results.some((result) => result.shrinkBlocked === true);
|
||||
@@ -781,6 +828,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
try {
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResultList = Array.from(roundTripResults.values());
|
||||
commitPluginSidecarsAfterSuccessfulSync(mergeResult.payload, roundTripResultList);
|
||||
const wasShrinkBlocked = roundTripResultList.some((r) => r.shrinkBlocked === true);
|
||||
const roundTripFullySynced = roundTripResultList.length > 0
|
||||
&& roundTripResultList.every((result) => result.success);
|
||||
@@ -828,7 +876,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
if (markCurrentDataSynced && (!hadInitialBaseline || inspectedRemoteChange)) {
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
try {
|
||||
lastSyncedDataRef.current = await getDataHashRef.current();
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to capture post-inspect baseline hash:', error);
|
||||
}
|
||||
} else if (!markCurrentDataSynced) {
|
||||
lastSyncedDataRef.current = '';
|
||||
}
|
||||
@@ -873,9 +925,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const establishInitialBaseline = () => {
|
||||
isInitializedRef.current = true;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
try {
|
||||
const currentHash = await getDataHash();
|
||||
if (cancelled) return;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to establish sync baseline hash:', error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -916,7 +972,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
syncTimeoutRef.current = null;
|
||||
void (async () => {
|
||||
const currentHash = await getDataHash();
|
||||
let currentHash: string;
|
||||
try {
|
||||
currentHash = await getDataHash();
|
||||
} catch (error) {
|
||||
console.warn('[AutoSync] Failed to hash local vault before auto-sync:', error);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
// After a remote apply or merge, skip only that exact applied data.
|
||||
@@ -986,6 +1048,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
convergentSyncPaused,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
@@ -1002,6 +1065,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.settingsVersion,
|
||||
enabled,
|
||||
bookmarksVersion,
|
||||
pluginSidecarsVersion,
|
||||
syncableSettingsStorageVersion,
|
||||
]);
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ interface CloudSyncRunOptions {
|
||||
overrideShrink?: boolean;
|
||||
conflictActionOverride?: CloudSyncConflictAction;
|
||||
applyConvergentPayload?: ConvergentPayloadApplier;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CloudSyncHook {
|
||||
@@ -107,6 +108,12 @@ export interface CloudSyncHook {
|
||||
authAttemptId?: number
|
||||
) => Promise<void>;
|
||||
connectGoogle: () => Promise<string>;
|
||||
/** Connect a namespaced plugin sync Provider (encrypted-object storage only). */
|
||||
connectPluginProvider: (
|
||||
providerId: string,
|
||||
configuration?: unknown,
|
||||
credential?: unknown,
|
||||
) => Promise<void>;
|
||||
connectOneDrive: () => Promise<string>;
|
||||
connectWebDAV: (config: WebDAVConfig) => Promise<void>;
|
||||
connectS3: (config: S3Config) => Promise<void>;
|
||||
@@ -124,7 +131,7 @@ export interface CloudSyncHook {
|
||||
syncToProvider: (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<RemoteSyncPayload | null>;
|
||||
commitRemoteInspection: (provider: CloudProvider, remoteFile: SyncedFile, payload: SyncPayload, opts?: { recordDownload?: boolean }) => Promise<void>;
|
||||
@@ -303,6 +310,39 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
// A missing/corrupt replica is surfaced by the next explicit sync.
|
||||
});
|
||||
}, [state.securityState]);
|
||||
|
||||
// Keep plugin sync provider availability aligned with live contributions so
|
||||
// disabled/uninstalled providers leave the auto-sync ready set after restart.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshAvailable = async () => {
|
||||
try {
|
||||
const { pluginExtensionBridge } = await import('./pluginExtensionBridge');
|
||||
const listed = await pluginExtensionBridge.listProviders('sync');
|
||||
if (cancelled) return;
|
||||
const ids = (listed ?? []).map((entry) => {
|
||||
const nested = (entry as { provider?: { id?: string } }).provider;
|
||||
return typeof nested?.id === 'string' ? nested.id : '';
|
||||
}).filter((id) => id.length > 0);
|
||||
manager.setAvailablePluginSyncProviders(ids);
|
||||
} catch {
|
||||
// Transient IPC / host startup failures must not wipe the availability
|
||||
// catalog; leave the previous set until a successful discovery.
|
||||
}
|
||||
};
|
||||
void refreshAvailable();
|
||||
let unsubscribe = () => {};
|
||||
void import('./pluginExtensionBridge').then(({ pluginExtensionBridge }) => {
|
||||
if (cancelled) return;
|
||||
unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
|
||||
void refreshAvailable();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ========== Computed Values ==========
|
||||
|
||||
@@ -676,6 +716,18 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
const connectS3 = useCallback(async (config: S3Config): Promise<void> => {
|
||||
await manager.connectConfigProvider('s3', config);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
|
||||
* Configuration is opaque plugin-owned JSON; encryption stays host-owned.
|
||||
*/
|
||||
const connectPluginProvider = useCallback(async (
|
||||
providerId: string,
|
||||
configuration: unknown = {},
|
||||
credential?: unknown,
|
||||
): Promise<void> => {
|
||||
await manager.connectPluginProvider(providerId, configuration, credential);
|
||||
}, []);
|
||||
|
||||
const cancelOAuthConnect = useCallback(() => {
|
||||
const githubAbort = activeGitHubAuthAbortRef.current;
|
||||
@@ -734,16 +786,48 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: CloudSyncRunOptions) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
const controller = new AbortController();
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else {
|
||||
onAbort = () => controller.abort();
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const results = await manager.syncAllProviders(payload, { ...opts, signal: controller.signal });
|
||||
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
|
||||
commitPluginSidecarsAfterSuccessfulSync(payload, results.values());
|
||||
return results;
|
||||
} finally {
|
||||
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload'>,
|
||||
opts?: Pick<CloudSyncRunOptions, 'overrideShrink' | 'applyConvergentPayload' | 'signal'>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
const controller = new AbortController();
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else {
|
||||
onAbort = () => controller.abort();
|
||||
opts.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await manager.syncToProvider(provider, payload, { ...opts, signal: controller.signal });
|
||||
const { commitPluginSidecarsAfterSuccessfulSync } = await import('../pluginSyncSidecarBridge');
|
||||
commitPluginSidecarsAfterSuccessfulSync(payload, [result]);
|
||||
return result;
|
||||
} finally {
|
||||
if (opts?.signal && onAbort) opts.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -785,7 +869,29 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
await ensureUnlocked();
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload);
|
||||
// Collect live sidecars so conflict apply/upload does not revert plugin
|
||||
// settings that changed after the last provider baseline. Operational
|
||||
// collection failures must abort (not proceed as empty). Host gated off /
|
||||
// non-authoritative collect must omit the field (not last-known cache) so
|
||||
// apply preserves local DB sidecars.
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('../pluginSyncSidecarBridge');
|
||||
let pluginSidecars: SyncPayload['pluginSidecars'] | undefined;
|
||||
try {
|
||||
const collected = await collectPluginSyncSidecarsFromHost({ liveOnly: true });
|
||||
if (collected) pluginSidecars = collected;
|
||||
} catch (error) {
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
// Host gated off: omit field so apply preserves local sidecars.
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
|
||||
pluginSidecars,
|
||||
});
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const refreshConvergentSyncConfig = useCallback(() => {
|
||||
@@ -854,6 +960,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
connectOneDrive,
|
||||
connectWebDAV,
|
||||
connectS3,
|
||||
connectPluginProvider,
|
||||
completePKCEAuth,
|
||||
cancelOAuthConnect,
|
||||
disconnectProvider,
|
||||
|
||||
@@ -43,13 +43,17 @@ const {
|
||||
applySyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
buildCloudSyncPayload,
|
||||
withPluginSyncSidecars,
|
||||
buildSyncPayload,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
hasMeaningfulSyncData,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
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,
|
||||
@@ -791,6 +795,165 @@ test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayload includes last-known plugin sidecars for protective backups", () => {
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const payload = buildLocalVaultPayload(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
||||
assert.equal(hasMeaningfulSyncData(payload), true);
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
collectPluginSyncSidecars: async () => ({ version: 1, entries: [] }),
|
||||
pluginHostReady: () => true,
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"netcatty_plugin_sidecars_last_known_v1",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
|
||||
const payload = await buildLocalVaultPayloadAsync(vault([]));
|
||||
assert.equal(payload.pluginSidecars?.entries?.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
|
||||
} finally {
|
||||
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: previousWindow,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData treats non-empty plugin sidecars as meaningful", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings",
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Empty bundle alone must not bypass the empty-vault upload guard.
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData does not treat last-known-only empty sidecars as meaningful", () => {
|
||||
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,
|
||||
}],
|
||||
});
|
||||
// Empty vault + empty sidecar entries must stay blocked even when last-known
|
||||
// still remembers prior plugin settings (empty-vault upload guard).
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
} 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", () => {
|
||||
assert.equal(
|
||||
hasCloudSyncEntityData({
|
||||
@@ -1338,3 +1501,78 @@ test("applyLocalVaultPayload does not commit convergent writes when local import
|
||||
|
||||
assert.equal(committed, false);
|
||||
});
|
||||
|
||||
test("withPluginSyncSidecars attaches non-empty plugin sidecar bundles", () => {
|
||||
const base = buildSyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
});
|
||||
const withEmpty = withPluginSyncSidecars(base, { version: 1, entries: [] });
|
||||
assert.deepEqual(withEmpty.pluginSidecars, { version: 1, entries: [] });
|
||||
|
||||
const withData = withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
assert.equal(withData.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(withData.pluginSidecars?.entries[0].value, "dark");
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes plugin sidecars from the production collector hook", async () => {
|
||||
const payload = await buildCloudSyncPayload(vault([]), [], {
|
||||
collectPluginSidecars: async () => ({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct-1" },
|
||||
updatedAt: 9,
|
||||
}],
|
||||
}),
|
||||
});
|
||||
assert.equal(payload.pluginSidecars?.entries.length, 1);
|
||||
assert.equal(payload.pluginSidecars?.entries[0].kind, "account_baseline");
|
||||
assert.deepEqual(payload.pluginSidecars?.entries[0].value, { id: "acct-1" });
|
||||
});
|
||||
|
||||
test("applySyncPayload applies pluginSidecars through the production applier hook", async () => {
|
||||
let applied: unknown = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
kind: "crdt_baseline",
|
||||
key: "replica",
|
||||
value: { clock: 3 },
|
||||
updatedAt: 2,
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: () => {},
|
||||
}, {
|
||||
applyPluginSidecars: async (sidecars) => {
|
||||
applied = sidecars;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
+174
-12
@@ -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,15 +123,33 @@ export interface SyncableVaultData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
* Returns true when the payload carries non-empty plugin sidecar entries.
|
||||
*
|
||||
* An explicit empty `{entries:[]}` bundle is intentionally NOT treated as
|
||||
* meaningful by itself — last-known cache alone must not authorize pushing a
|
||||
* wiped hosts/keys vault to cloud (empty-vault upload guard). Sidecar-only
|
||||
* wipes still sync when the vault also has entities/settings, or via Force Push.
|
||||
*/
|
||||
function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
|
||||
return Array.isArray(payload.pluginSidecars?.entries)
|
||||
&& payload.pluginSidecars.entries.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,9 +159,14 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Plugin-only profiles (settings/baselines, no vault entities) must still sync.
|
||||
return hasMeaningfulPluginSidecars(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -877,11 +901,75 @@ export function buildSyncPayload(
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginSyncSidecarCollector = () =>
|
||||
| Promise<SyncPayload['pluginSidecars'] | null | undefined>
|
||||
| SyncPayload['pluginSidecars']
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type PluginSyncSidecarApplier = (
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
) => Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Attach a host-collected plugin sidecar bundle to a cloud payload.
|
||||
* Secrets must already be excluded by the collector; this only drops empty bundles.
|
||||
*/
|
||||
export function withPluginSyncSidecars(
|
||||
payload: SyncPayload,
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): SyncPayload {
|
||||
// null/undefined: collector unavailable — leave payload field unchanged
|
||||
// (callers should reattach last-known before upload).
|
||||
if (sidecars == null) {
|
||||
return payload;
|
||||
}
|
||||
// Explicit empty entries is a real reset and must remain on the wire so
|
||||
// apply paths can distinguish it from a legacy payload missing the field.
|
||||
return {
|
||||
...payload,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: Array.isArray(sidecars.entries) ? sidecars.entries : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultCollectPluginSyncSidecars(): Promise<SyncPayload['pluginSidecars'] | null | undefined> {
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('./pluginSyncSidecarBridge');
|
||||
// Let operational failures (DB/runtime) propagate so cloud upload does not
|
||||
// silently strip previously synced sidecars from the remote snapshot.
|
||||
return collectPluginSyncSidecarsFromHost();
|
||||
}
|
||||
|
||||
async function defaultApplyPluginSyncSidecars(
|
||||
sidecars: SyncPayload['pluginSidecars'] | null | undefined,
|
||||
): Promise<void> {
|
||||
const {
|
||||
applyPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
await applyPluginSyncSidecarsFromHost(sidecars);
|
||||
} catch (error) {
|
||||
// Only tolerate the host-gated-off / manager-unavailable path so a later
|
||||
// plugin-enabled session can still apply from last-known cache. DB/runtime
|
||||
// failures must fail the surrounding sync so the same remote is retried.
|
||||
if (isPluginSidecarHostUnavailableError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildCloudSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
options?: {
|
||||
collectPluginSidecars?: PluginSyncSidecarCollector;
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
return {
|
||||
const base: SyncPayload = {
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
@@ -896,6 +984,9 @@ export async function buildCloudSyncPayload(
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
const collect = options?.collectPluginSidecars ?? defaultCollectPluginSyncSidecars;
|
||||
const sidecars = await collect();
|
||||
return withPluginSyncSidecars(base, sidecars);
|
||||
}
|
||||
|
||||
/** Build a local backup/restore payload, including local-only trust records. */
|
||||
@@ -903,11 +994,66 @@ export function buildLocalVaultPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
const base: SyncPayload = {
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
settings: collectLocalBackupSettings(),
|
||||
knownHosts: vault.knownHosts,
|
||||
};
|
||||
// Protective backups must capture plugin sidecars so restore can recover
|
||||
// plugin settings/baselines. Prefer the last successful host collect cache
|
||||
// (sync). Callers that can await should use buildLocalVaultPayloadAsync.
|
||||
try {
|
||||
const raw = localStorageAdapter.read<{ version?: number; entries?: unknown }>(
|
||||
SYNC_STORAGE_KEYS.PLUGIN_SIDECARS_LAST_KNOWN,
|
||||
);
|
||||
if (raw && Array.isArray(raw.entries)) {
|
||||
return withPluginSyncSidecars(base, {
|
||||
version: 1,
|
||||
entries: raw.entries as NonNullable<SyncPayload['pluginSidecars']>['entries'],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore cache read failures; backup still carries vault entities
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async local backup payload that prefers a live host collect when available,
|
||||
* then falls back to last-known cache. Use for protective/version-change backups.
|
||||
*/
|
||||
export async function buildLocalVaultPayloadAsync(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): Promise<SyncPayload> {
|
||||
const base = buildLocalVaultPayload(vault, portForwardingRules);
|
||||
const {
|
||||
collectPluginSyncSidecarsFromHost,
|
||||
isPluginSidecarHostUnavailableError,
|
||||
} = await import('./pluginSyncSidecarBridge');
|
||||
try {
|
||||
const live = await collectPluginSyncSidecarsFromHost();
|
||||
if (live) {
|
||||
// Live collect may return authoritative-empty while still deferring the
|
||||
// last-known cache wipe until a successful sync commit. Protective
|
||||
// backups must keep the non-empty last-known already attached to `base`.
|
||||
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
|
||||
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
|
||||
&& base.pluginSidecars.entries.length > 0;
|
||||
if (liveEmpty && baseHasEntries) {
|
||||
return base;
|
||||
}
|
||||
return withPluginSyncSidecars(base, live);
|
||||
}
|
||||
} catch (error) {
|
||||
// Only fall back to last-known when the host is gated off. Operational
|
||||
// failures must abort protective backups so we never apply over data that
|
||||
// was not captured in the safety snapshot.
|
||||
if (!isPluginSidecarHostUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -919,7 +1065,10 @@ export function buildLocalVaultPayload(
|
||||
function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
options: {
|
||||
includeLocalOnlyData: boolean;
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
@@ -964,14 +1113,27 @@ function applyPayload(
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
|
||||
// Plugin encrypted sidecars: only apply when the field is present so
|
||||
// legacy payloads without pluginSidecars do not wipe installed settings.
|
||||
if (Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
const applySidecars = options.applyPluginSidecars ?? defaultApplyPluginSyncSidecars;
|
||||
await applySidecars(payload.pluginSidecars ?? { version: 1, entries: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options?: {
|
||||
applyPluginSidecars?: PluginSyncSidecarApplier;
|
||||
},
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
return applyPayload(payload, importers, {
|
||||
includeLocalOnlyData: false,
|
||||
applyPluginSidecars: options?.applyPluginSidecars,
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareLocalVaultPayloadApply(
|
||||
|
||||
@@ -136,10 +136,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
sync.cancelOAuthConnect();
|
||||
if (sync.convergentSyncConfig.initialized) return;
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const providers = new Set<CloudProvider>(['github', 'google', 'onedrive', 'webdav', 's3']);
|
||||
for (const id of Object.keys(sync.providers)) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
for (const provider of providers) {
|
||||
if (provider === current) continue;
|
||||
if (isProviderReadyForSync(sync.providers[provider])) {
|
||||
const conn = sync.providers[provider];
|
||||
if (conn && isProviderReadyForSync(conn)) {
|
||||
await sync.disconnectProvider(provider);
|
||||
}
|
||||
}
|
||||
@@ -176,18 +180,23 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
);
|
||||
|
||||
const isConnectDisabled = (provider: CloudProvider): boolean => {
|
||||
const connection = sync.providers[provider];
|
||||
if (pendingConnectProvider && pendingConnectProvider !== provider) {
|
||||
return true;
|
||||
}
|
||||
if (pendingConnectProvider === provider) {
|
||||
return true;
|
||||
}
|
||||
if (hasConnectingProvider && sync.providers[provider].status !== 'connecting') {
|
||||
if (hasConnectingProvider && connection?.status !== 'connecting') {
|
||||
return true;
|
||||
}
|
||||
// Never-connected plugin providers still honor the single-provider gate.
|
||||
if (!connection) {
|
||||
return !sync.convergentSyncConfig.initialized && sync.hasAnyConnectedProvider;
|
||||
}
|
||||
return !sync.convergentSyncConfig.initialized
|
||||
&& sync.hasAnyConnectedProvider
|
||||
&& !isProviderReadyForSync(sync.providers[provider]);
|
||||
&& !isProviderReadyForSync(connection);
|
||||
};
|
||||
|
||||
const beginPendingConnect = (provider: CloudProvider): boolean => {
|
||||
@@ -910,6 +919,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}}
|
||||
onResolveConvergentConflict={handleResolveConvergentConflict}
|
||||
onDowngradeConvergent={handleDowngradeConvergent}
|
||||
disconnectOtherProviders={disconnectOtherProviders}
|
||||
/>
|
||||
|
||||
<CloudSyncDialogs
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CloudOff,
|
||||
Github,
|
||||
Loader2,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
X,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { isProviderReadyForSync, type CloudProvider, formatSyncDateTime } from '../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../domain/cloudProviderIds';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -53,7 +55,7 @@ const OneDriveIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const providerIcons: Record<CloudProvider, React.ReactNode> = {
|
||||
const providerIcons: Partial<Record<CloudProvider, React.ReactNode>> = {
|
||||
github: <Github size={16} />,
|
||||
google: <GoogleDriveIcon className="w-4 h-4" />,
|
||||
onedrive: <OneDriveIcon className="w-4 h-4" />,
|
||||
@@ -61,7 +63,7 @@ const providerIcons: Record<CloudProvider, React.ReactNode> = {
|
||||
s3: <Database size={16} />,
|
||||
};
|
||||
|
||||
const providerNames: Record<CloudProvider, string> = {
|
||||
const providerNames: Partial<Record<CloudProvider, string>> = {
|
||||
github: 'GitHub Gist',
|
||||
google: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
@@ -69,6 +71,12 @@ const providerNames: Record<CloudProvider, string> = {
|
||||
s3: 'S3 Compatible',
|
||||
};
|
||||
|
||||
const resolveProviderIcon = (provider: CloudProvider): React.ReactNode =>
|
||||
providerIcons[provider] ?? <Plug size={16} />;
|
||||
|
||||
const resolveProviderName = (provider: CloudProvider): string =>
|
||||
providerNames[provider] ?? provider;
|
||||
|
||||
// ============================================================================
|
||||
// Status Dot Component
|
||||
// ============================================================================
|
||||
@@ -123,13 +131,17 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
// State is now automatically synced via useSyncExternalStore - no manual refresh needed
|
||||
|
||||
// Get connected provider (include syncing status as it's still connected)
|
||||
// Get connected provider (built-ins first, then any namespaced plugin provider).
|
||||
const getConnectedProvider = (): CloudProvider | null => {
|
||||
if (isProviderReadyForSync(sync.providers.github)) return 'github';
|
||||
if (isProviderReadyForSync(sync.providers.google)) return 'google';
|
||||
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
|
||||
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
|
||||
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
|
||||
const preferred: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const id of preferred) {
|
||||
const conn = sync.providers[id];
|
||||
if (conn && isProviderReadyForSync(conn)) return id;
|
||||
}
|
||||
for (const [id, conn] of Object.entries(sync.providers)) {
|
||||
if (preferred.includes(id as CloudProvider)) continue;
|
||||
if (conn && isProviderReadyForSync(conn)) return id as CloudProvider;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -286,15 +298,17 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
{connectedProvider && providerConnection && (
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg bg-muted/30">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
|
||||
{providerIcons[connectedProvider]}
|
||||
{resolveProviderIcon(connectedProvider)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{providerNames[connectedProvider]}</span>
|
||||
<span className="text-sm font-medium">{resolveProviderName(connectedProvider)}</span>
|
||||
<StatusIndicator status={overallStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{providerConnection.account?.avatarUrl && (
|
||||
{providerConnection.account?.avatarUrl
|
||||
&& connectedProvider
|
||||
&& !isPluginCloudProviderId(connectedProvider) && (
|
||||
<img
|
||||
src={providerConnection.account.avatarUrl}
|
||||
alt=""
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import React, { type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { Database, Github, History, Server, Trash2 } from 'lucide-react';
|
||||
import React, { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { Database, Github, History, Plug, Server, Trash2 } from 'lucide-react';
|
||||
import type {
|
||||
CloudProvider,
|
||||
ConvergentFieldConflict,
|
||||
ConvergentMigrationPreview,
|
||||
SyncPayload,
|
||||
} from '../../domain/sync';
|
||||
import { isBuiltinCloudProvider } from '../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../domain/cloudProviderIds';
|
||||
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from '../../domain/pluginSyncConnect';
|
||||
import { planPluginSyncCredential, syncConfigurationSchemaWithoutSecretRequirements } from '../../domain/pluginSyncCredential';
|
||||
import { pluginConfigurationMatchesSchema } from '../../domain/pluginConfigurationSchema';
|
||||
import type { useCloudSync } from '../../application/state/useCloudSync';
|
||||
import { storePluginSyncSecretsThenConnect } from '../../application/pluginSyncConnectWithSecrets';
|
||||
import { cleanOneDriveErrorMessage, isProviderReadyForSync } from '../../domain/sync';
|
||||
import { pluginExtensionBridge } from '../../application/state/pluginExtensionBridge';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '../ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { toast } from '../ui/toast';
|
||||
import { GoogleDriveIcon, OneDriveIcon, ProviderCard, Toggle } from './CloudSyncControls';
|
||||
import { LocalBackupsPanel } from './CloudSyncLocalBackupsPanel';
|
||||
import { ConvergentSyncPanel } from './ConvergentSyncPanel';
|
||||
@@ -48,6 +57,8 @@ interface CloudSyncDashboardTabsProps {
|
||||
onCancelConvergentMigration: () => void;
|
||||
onResolveConvergentConflict: (addressKey: string, candidateDot: string) => void | Promise<void>;
|
||||
onDowngradeConvergent: () => void | Promise<void>;
|
||||
/** Disconnect other legacy providers before connecting a plugin backend. */
|
||||
disconnectOtherProviders?: (current: CloudProvider) => Promise<void>;
|
||||
}
|
||||
|
||||
export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
@@ -78,7 +89,199 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
onCancelConvergentMigration,
|
||||
onResolveConvergentConflict,
|
||||
onDowngradeConvergent,
|
||||
}) => (
|
||||
disconnectOtherProviders,
|
||||
}) => {
|
||||
const [pluginSyncProviders, setPluginSyncProviders] = useState<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
}>>([]);
|
||||
const [pluginConnectBusy, setPluginConnectBusy] = useState<string | null>(null);
|
||||
const [pluginConfigDialog, setPluginConfigDialog] = useState<{
|
||||
providerId: string;
|
||||
title: string;
|
||||
configurationSchema?: unknown;
|
||||
} | null>(null);
|
||||
const [pluginConfigText, setPluginConfigText] = useState('{}');
|
||||
const [pluginConfigError, setPluginConfigError] = useState<string | null>(null);
|
||||
const [pluginConfigSaving, setPluginConfigSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const listed = await pluginExtensionBridge.listProviders('sync');
|
||||
if (cancelled) return;
|
||||
setPluginSyncProviders(
|
||||
(listed ?? []).map((entry) => {
|
||||
const nested = (entry as {
|
||||
provider?: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
configurationSchema?: unknown;
|
||||
};
|
||||
pluginDisplayName?: string;
|
||||
}).provider;
|
||||
const id = String(nested?.id ?? '');
|
||||
return {
|
||||
id,
|
||||
title: String(
|
||||
nested?.label
|
||||
?? (entry as { pluginDisplayName?: string }).pluginDisplayName
|
||||
?? id
|
||||
?? 'Plugin sync',
|
||||
),
|
||||
configurationSchema: nested?.configurationSchema,
|
||||
};
|
||||
}).filter((entry) => entry.id.length > 0 && isPluginCloudProviderId(entry.id)),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setPluginSyncProviders([]);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const unsubscribe = pluginExtensionBridge.onContributionsChanged(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPluginConfigDialog = (
|
||||
providerId: string,
|
||||
title: string,
|
||||
configurationSchema: unknown | undefined,
|
||||
) => {
|
||||
const connection = sync.providers[providerId];
|
||||
// Include falsy scalars and JSON null so Edit can re-save them.
|
||||
const seed = hasPluginProviderStoredConfig(connection) ? connection!.config : {};
|
||||
setPluginConfigText(JSON.stringify(seed, null, 2));
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigDialog({ providerId, title, configurationSchema });
|
||||
};
|
||||
|
||||
const runPluginConnect = async (providerId: string, configuration: unknown): Promise<boolean> => {
|
||||
setPluginConnectBusy(providerId);
|
||||
try {
|
||||
if (disconnectOtherProviders) {
|
||||
await disconnectOtherProviders(providerId as CloudProvider);
|
||||
}
|
||||
const credentialPlan = planPluginSyncCredential(configuration, {
|
||||
configurationSchema: pluginSyncProviders.find((entry) => entry.id === providerId)
|
||||
?.configurationSchema,
|
||||
});
|
||||
const stored = sync.providers[providerId]?.credential;
|
||||
const existingCredential =
|
||||
stored
|
||||
&& typeof stored === 'object'
|
||||
&& stored.kind === 'secret'
|
||||
&& typeof stored.id === 'string'
|
||||
? stored as { kind: 'secret'; id: string; key: string }
|
||||
: undefined;
|
||||
if (credentialPlan.secrets.length > 0) {
|
||||
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
|
||||
'../../infrastructure/services/adapters/pluginSyncIpcHost'
|
||||
);
|
||||
// Put secrets before connect; roll back just-created keys if connect fails
|
||||
// so a rejected password/token is not left readable in plugin_secrets.
|
||||
await storePluginSyncSecretsThenConnect({
|
||||
providerId,
|
||||
secrets: credentialPlan.secrets.map((secret) => ({
|
||||
secretKey: secret.secretKey,
|
||||
value: secret.value,
|
||||
})),
|
||||
putSecret: putPluginSyncSecret,
|
||||
deleteSecrets: deletePluginSyncSecrets,
|
||||
connect: async (credential) => {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
credential,
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await sync.connectPluginProvider(
|
||||
providerId,
|
||||
credentialPlan.configuration,
|
||||
existingCredential,
|
||||
);
|
||||
}
|
||||
toast.success(t('cloudSync.connect.plugin.success'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
toast.error(message, t('cloudSync.connect.plugin.failedTitle'));
|
||||
return false;
|
||||
} finally {
|
||||
setPluginConnectBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePluginConnect = async (providerId: string) => {
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const connection = sync.providers[providerId];
|
||||
// Config may be a valid falsy scalar or JSON null — property presence, not truthiness.
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const plan = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
});
|
||||
if (plan.action === 'prompt') {
|
||||
openPluginConfigDialog(
|
||||
providerId,
|
||||
listed?.title ?? providerId,
|
||||
listed?.configurationSchema,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runPluginConnect(providerId, plan.configuration);
|
||||
};
|
||||
|
||||
const handleSavePluginConfig = async () => {
|
||||
if (!pluginConfigDialog) return;
|
||||
let configuration: unknown;
|
||||
try {
|
||||
configuration = JSON.parse(pluginConfigText) as unknown;
|
||||
} catch {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.invalidJson'));
|
||||
return;
|
||||
}
|
||||
if (pluginConfigDialog.configurationSchema !== undefined) {
|
||||
const connection = sync.providers[pluginConfigDialog.providerId];
|
||||
const hasStoredSecret = connection?.credential != null
|
||||
&& typeof connection.credential === 'object'
|
||||
&& (connection.credential as { kind?: string }).kind === 'secret';
|
||||
// Edit seeds stripped config; required secret fields stay satisfied by the
|
||||
// durable SecretRef until the user re-enters plaintext secrets.
|
||||
const schema = hasStoredSecret
|
||||
? syncConfigurationSchemaWithoutSecretRequirements(pluginConfigDialog.configurationSchema)
|
||||
: pluginConfigDialog.configurationSchema;
|
||||
if (!pluginConfigurationMatchesSchema(schema, configuration)) {
|
||||
setPluginConfigError(t('cloudSync.pluginConfig.schemaInvalid'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPluginConfigError(null);
|
||||
setPluginConfigSaving(true);
|
||||
try {
|
||||
const ok = await runPluginConnect(pluginConfigDialog.providerId, configuration);
|
||||
if (ok) setPluginConfigDialog(null);
|
||||
// On failure toast already shown; keep dialog open for correction.
|
||||
} finally {
|
||||
setPluginConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pluginProviderIds = new Set<string>([
|
||||
...pluginSyncProviders.map((entry) => entry.id),
|
||||
...Object.keys(sync.providers).filter((id) => !isBuiltinCloudProvider(id)),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
@@ -187,6 +390,112 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
onDisconnect={() => sync.disconnectProvider('s3')}
|
||||
onSync={() => handleSync('s3')}
|
||||
/>
|
||||
|
||||
{[...pluginProviderIds].sort().map((providerId) => {
|
||||
const connection = sync.providers[providerId];
|
||||
const listed = pluginSyncProviders.find((entry) => entry.id === providerId);
|
||||
const title = listed?.title ?? providerId;
|
||||
const connected = connection ? isProviderReadyForSync(connection) : false;
|
||||
const hasStoredConfig = hasPluginProviderStoredConfig(connection);
|
||||
const needsConfig = planPluginSyncConnect({
|
||||
configurationSchema: listed?.configurationSchema,
|
||||
storedConfig: connection?.config,
|
||||
hasStoredConfig,
|
||||
}).action === 'prompt';
|
||||
return (
|
||||
<ProviderCard
|
||||
key={providerId}
|
||||
provider={providerId as CloudProvider}
|
||||
name={title}
|
||||
icon={<Plug size={24} />}
|
||||
isConnected={connected}
|
||||
isSyncing={connection?.status === 'syncing'}
|
||||
isConnecting={
|
||||
connection?.status === 'connecting'
|
||||
|| pluginConnectBusy === providerId
|
||||
}
|
||||
account={connection?.account
|
||||
? {
|
||||
// Never load plugin-supplied avatar URLs in the main
|
||||
// renderer (offline plugin network boundary).
|
||||
id: connection.account.id,
|
||||
name: connection.account.name,
|
||||
email: connection.account.email,
|
||||
}
|
||||
: undefined}
|
||||
lastSync={connection?.lastSync}
|
||||
error={connection?.error}
|
||||
disabled={isConnectDisabled(providerId as CloudProvider)}
|
||||
onEdit={
|
||||
connected || needsConfig || hasStoredConfig
|
||||
? () => openPluginConfigDialog(
|
||||
providerId,
|
||||
title,
|
||||
listed?.configurationSchema,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onConnect={() => {
|
||||
void handlePluginConnect(providerId);
|
||||
}}
|
||||
onDisconnect={() => sync.disconnectProvider(providerId as CloudProvider)}
|
||||
onSync={() => handleSync(providerId as CloudProvider)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog
|
||||
open={pluginConfigDialog != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pluginConfigSaving) setPluginConfigDialog(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('cloudSync.pluginConfig.title', {
|
||||
name: pluginConfigDialog?.title ?? '',
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.pluginConfig.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
value={pluginConfigText}
|
||||
onChange={(event) => {
|
||||
setPluginConfigText(event.target.value);
|
||||
if (pluginConfigError) setPluginConfigError(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={pluginConfigSaving}
|
||||
className="min-h-40 w-full resize-y rounded-md border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('cloudSync.pluginConfig.label')}
|
||||
/>
|
||||
{pluginConfigError ? (
|
||||
<p role="alert" className="text-xs text-destructive">{pluginConfigError}</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => setPluginConfigDialog(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pluginConfigSaving}
|
||||
onClick={() => { void handleSavePluginConfig(); }}
|
||||
>
|
||||
{pluginConfigSaving
|
||||
? t('cloudSync.provider.connecting')
|
||||
: t('cloudSync.provider.connect')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="status" className="space-y-4">
|
||||
@@ -371,4 +680,5 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import {
|
||||
buildCloudSyncPayload,
|
||||
buildLocalVaultPayload,
|
||||
applySyncPayload,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
prepareLocalVaultPayloadApply,
|
||||
@@ -41,10 +40,10 @@ export default function SettingsSyncTab(props: {
|
||||
return buildCloudSyncPayload(vault, getEffectivePortForwardingRules());
|
||||
}, [vault, getEffectivePortForwardingRules]);
|
||||
|
||||
const onBuildLocalPayload = useCallback((): SyncPayload => {
|
||||
const onBuildLocalPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
|
||||
|
||||
return buildLocalVaultPayload(
|
||||
const { buildLocalVaultPayloadAsync } = await import('../../../application/syncPayload');
|
||||
return buildLocalVaultPayloadAsync(
|
||||
{ ...vault, knownHosts: effectiveKnownHosts ?? [] },
|
||||
getEffectivePortForwardingRules(),
|
||||
);
|
||||
|
||||
@@ -413,6 +413,18 @@ connection draft may reference an identity or key draft from the same import by
|
||||
its source ID; Netcatty maps that reference to the new host-owned credential ID
|
||||
before encrypted persistence and rejects unresolved or ambiguous references.
|
||||
|
||||
PR 8 adds sync Providers as another operation-keyed map under permission
|
||||
`provider.sync`. Plugins implement only encrypted object storage: `connect`,
|
||||
`disconnect`, `getAccount`, `getCapabilities`, `readObject`, `writeObject`, and
|
||||
`deleteObject`. Capability reporting covers revisions, conditional writes,
|
||||
atomic replacement, and size limits (`SyncLimits`). Large objects leave the
|
||||
JSON control plane on the existing stream seam; plugins never receive the cloud
|
||||
master key or plaintext vault. Netcatty continues to own encryption, CRDT merge,
|
||||
migrations, protection snapshots, conflict handling, and read-merge-write-verify.
|
||||
WebDAV is adapted through the shared encrypted-object storage interface so the
|
||||
same path can exercise configuration, secret handles, upload/download,
|
||||
verification, and recovery.
|
||||
|
||||
`PluginSecretStore.get()` never returns plaintext. It returns a host-issued
|
||||
`SecretRef`. Its random ID stays opaque; its non-secret `key` binds later lease
|
||||
authorization to the same manifest resource used by `get()`/`set()`. `set()`
|
||||
@@ -575,7 +587,7 @@ The cross-phase contract was checked against every planned consumer:
|
||||
| PR 5 terminal providers | namespaced provider IDs, provider request/result envelopes and bounded streams |
|
||||
| PR 6 data pipeline | direct MessagePort transfer envelopes, sequence, per-chunk credit, and bounded receive-window fields |
|
||||
| PR 7 connection/auth/import | provider kinds and configuration schemas, platform-specific companion variants, framing, stable failures and progress |
|
||||
| PR 8 sync | namespaced sync providers and JSON/binary bounded transport |
|
||||
| PR 8 sync | implemented: namespaced `sync` Providers, `SyncLimits`, operation-keyed connect/read/write/delete/capabilities, inline or streamed encrypted objects |
|
||||
| PR 9 rollout | schema/API selection, compatibility reporting and the final API 1.0 freeze |
|
||||
|
||||
The contract tests construct representative manifests for the contribution UI,
|
||||
|
||||
@@ -225,7 +225,7 @@ new protocol route. Plugin packages still cannot add mappings themselves.
|
||||
| PR 5 terminal Providers | validated host requests, cancellation, lifecycle events | Provider ranking, deadlines, snapshots, built-in highlighter/autocomplete adapters |
|
||||
| PR 6 terminal pipeline | runtime identity and placement policy | direct MessagePort fast path, sensitive-input bypass, circuit breaker |
|
||||
| PR 7 connection/auth/import | activation-owned Provider requests, exact result validators, bounded streams, diagnostics, secret leases, credential refs | implemented: connection sessions, authentication challenges, importer preview/commit |
|
||||
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | dynamic providers, encrypted sidecar, CRDT state and account baselines |
|
||||
| PR 8 sync | streams, lifecycle identity, namespaced storage boundary | implemented: `kind: "sync"` Providers with `provider.sync`, encrypted-object connect/read/write/delete/capabilities, WebDAV through shared storage surface, non-cascade sidecars for `sync: true` settings and account/CRDT baselines |
|
||||
| PR 9 distribution | retained immutable versions, compare-and-set restore, placement resolver, module resources | signatures, trust, health checks, audited update and user rollback policy, API 1.0, and the reproducible terminal benchmark harness/environment/release gate for the 1% throughput and 4 ms p95 / 8 ms p99 input-latency targets |
|
||||
|
||||
## Data-model decisions that must remain explicit
|
||||
@@ -249,10 +249,11 @@ writing the active-version pointer directly.
|
||||
|
||||
`plugin_kv` is runtime-owned local data and is removed by explicit uninstall.
|
||||
Phase-3 encrypted secrets, persistent grants and security audit, plus phase-4
|
||||
settings and view state, use separate non-cascade tables. Future connection
|
||||
profiles and CRDT sidecar baselines are the same user-owned class and must not
|
||||
be added to the `plugin_kv` cascade when missing plugin code must preserve user
|
||||
or synchronized data.
|
||||
settings and view state, use separate non-cascade tables. PR 8 stores encrypted
|
||||
sync sidecars (`plugin_sync_sidecars`) in the same non-cascade class for
|
||||
`sync: true` settings plus plugin sync account/CRDT baselines. Missing or
|
||||
uninstalled plugin code must not cascade-delete those rows or coerce related
|
||||
sync configuration away.
|
||||
|
||||
Activation events are declared by the public manifest. Phase 4 now starts only
|
||||
`onStartupFinished` plugins during contribution initialization; commands, views,
|
||||
|
||||
@@ -219,8 +219,10 @@ after durable user data can exist.
|
||||
a post-permission lookup. Importers use bounded streams, not directory walks,
|
||||
and their draft schemas reject executable startup commands and hidden
|
||||
plaintext built-in credential fields before Vault persistence.
|
||||
- PR 8 stores encrypted sync sidecars separately from package cascade storage
|
||||
and transports larger encrypted objects over the existing stream seam.
|
||||
- PR 8 stores encrypted sync sidecars in `plugin_sync_sidecars` (no package
|
||||
cascade) for non-secret `sync: true` settings plus account/CRDT baselines, and
|
||||
transports larger encrypted objects over the existing stream seam. Secret
|
||||
settings never enter cloud sidecars.
|
||||
- PR 9 supplies signed publisher principals and trust policy through placement;
|
||||
signed identity changes force a fresh grant instead of widening an unsigned
|
||||
grant silently.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Sync providers
|
||||
|
||||
Netcatty cloud sync providers are dynamic and namespaced. Built-in providers
|
||||
(`github`, `google`, `onedrive`, `webdav`, `s3`) stay compatible; plugins
|
||||
register additional IDs under their plugin namespace with `kind: "sync"` and
|
||||
permission `provider.sync`.
|
||||
|
||||
## Boundary
|
||||
|
||||
Plugins implement **encrypted object storage only**:
|
||||
|
||||
- `connect` / `disconnect` / `getAccount`
|
||||
- `getCapabilities` (`revisions`, `conditionalWrites`, `atomicReplacement`, size limits)
|
||||
- `readObject` / `writeObject` / `deleteObject`
|
||||
|
||||
Netcatty owns encryption, the master key, CRDT merge, migrations, protection
|
||||
snapshots, conflict handling, and read-merge-write-verify. Plugin providers
|
||||
never receive the vault master key or plaintext sync payloads.
|
||||
|
||||
## Secrets
|
||||
|
||||
Only non-secret configuration marked for sync enters cloud payloads. Plugin
|
||||
connect secrets (`password`, `token`, `secret`, `apiKey`, `accessToken`) are
|
||||
stripped from configuration, stored in the OS-backed plugin secret store, and
|
||||
passed to `SyncConnectPayload.credential` as opaque `{ kind: "secret", id, key }`
|
||||
references. Additional extracted secrets are stored under `sync-credential:<field>`
|
||||
keys so plugins can `secrets.get` / `credentials.createLease` them.
|
||||
|
||||
Durable reconnects persist an opaque SecretRef (`{ kind, id, key }`), not
|
||||
plaintext. The host injects Authorization only after consuming an
|
||||
operation-bound lease whose `operationId` matches `network:<origin>`.
|
||||
|
||||
**Using a SecretRef from a sandbox plugin:** create an operation-bound lease via
|
||||
`credentials.createLease` with `operationId` set to `network:<origin>` (same
|
||||
origin the request will call), then call `network.request` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/…",
|
||||
"credentialLease": { "kind": "secret-lease", "id": "…", "operationId": "network:https://example.com", "expiresAt": 0 },
|
||||
"authorization": { "scheme": "Bearer" }
|
||||
}
|
||||
```
|
||||
|
||||
The host consumes the lease (bound to the request origin, not a plugin-echoed
|
||||
id) and injects `Authorization` (Bearer or Basic). Plaintext never returns to
|
||||
the plugin. Companion `credentialLeases` remains available for node-only
|
||||
companions.
|
||||
|
||||
WebDAV continues to exercise the shared EncryptedObjectStorage path for
|
||||
configuration, proxy behavior, upload/download, and recovery. Write verification
|
||||
on WebDAV is performed by the adapter's pad+verify upload (not a second host
|
||||
byte re-read). Credentials remain field-encrypted at rest via the secure field
|
||||
adapter.
|
||||
|
||||
## Streams and SyncLimits
|
||||
|
||||
Public `SyncLimits` (see plugin contract) define:
|
||||
|
||||
- `maxObjectBytes` — hard ciphertext cap
|
||||
- `inlineObjectBytes` — maximum size that may travel inline on the control plane
|
||||
- key / revision length bounds
|
||||
|
||||
Above `inlineObjectBytes`, main↔plugin uses credit-window streams
|
||||
(`STREAM_WINDOW_BYTES` = 256 KiB). Renderer↔main uses structured-clone
|
||||
`Uint8Array` for inline payloads and pull/chunked IPC (`sync-write-begin` /
|
||||
`sync-write-chunk` / `sync-write-commit`, `sync-read-chunk`) for larger objects.
|
||||
Transfers are per-sender, TTL-bounded, capped, and cancelled via
|
||||
`cancelPluginExtensionRequest(requestId)` / `AbortSignal`.
|
||||
|
||||
## Sidecars (non-cascade)
|
||||
|
||||
Missing or disabled plugins must not delete synced settings or connection
|
||||
baselines. Host-owned `plugin_sync_sidecars` carry `sync:true` non-secret
|
||||
settings and account/CRDT baselines through collect/apply with last-known and
|
||||
prefer-cloud merge semantics. Device-local baselines survive remote settings
|
||||
wipes; empty-vault upload guards ignore last-known-only evidence.
|
||||
|
||||
## WebDAV
|
||||
|
||||
The production WebDAV adapter is wrapped as EncryptedObjectStorage so it shares
|
||||
the same encrypt→write / read→decrypt surface as plugin providers. WebDAV's
|
||||
native pad+verify upload already satisfies write verification and may leave
|
||||
trailing padding on the remote object; the shared bridge therefore skips a
|
||||
full byte re-read on that path (`assumeVerifiedWrites`) — without that flag the
|
||||
host compare would false-fail on padded bodies. Plugin providers keep
|
||||
host-owned byte compare after write.
|
||||
@@ -283,5 +283,6 @@ runtime identity, cancellation, progress, permission names, and stream
|
||||
protocol. PR 6 added its direct interceptor transport without changing the
|
||||
ordinary registry. PR 7 reused that registration and runtime lifecycle for
|
||||
connection, authentication, and importer Providers, with operation-specific
|
||||
result validators and bounded stream consumers. PR 8 sync Providers and PR 9
|
||||
result validators and bounded stream consumers. PR 8 sync Providers (implemented)
|
||||
and PR 9
|
||||
rollout can reuse the same boundaries.
|
||||
@@ -64,8 +64,8 @@ platform is unreleased, so both tables are part of the complete schema at
|
||||
Secret settings never enter the settings table or a renderer snapshot. The
|
||||
host stores plaintext only through the phase-3 safeStorage-backed secret store,
|
||||
shows a configured indicator, and exposes an opaque `SecretRef` to the owning
|
||||
runtime. Only non-secret fields explicitly declaring `sync: true` may be used by
|
||||
the future encrypted sidecar sync phase.
|
||||
runtime. Only non-secret fields explicitly declaring `sync: true` enter the
|
||||
encrypted sidecar sync path (PR 8).
|
||||
|
||||
## Commands, menus, and Context Keys
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
BUILTIN_CLOUD_PROVIDERS,
|
||||
assertCloudProviderId,
|
||||
isBuiltinCloudProvider,
|
||||
isPluginCloudProviderId,
|
||||
providerConnectionStorageKey,
|
||||
} from './cloudProviderIds';
|
||||
|
||||
describe('cloudProviderIds', () => {
|
||||
it('recognizes built-in providers', () => {
|
||||
for (const id of BUILTIN_CLOUD_PROVIDERS) {
|
||||
assert.equal(isBuiltinCloudProvider(id), true);
|
||||
assert.equal(isPluginCloudProviderId(id), false);
|
||||
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_${id}_v1`);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts namespaced plugin provider IDs without coercing them to built-ins', () => {
|
||||
const id = 'com.example.backup.sync';
|
||||
assert.equal(isPluginCloudProviderId(id), true);
|
||||
assert.equal(isBuiltinCloudProvider(id), false);
|
||||
assert.equal(providerConnectionStorageKey(id), `netcatty_provider_plugin_v1:${id}`);
|
||||
assert.equal(assertCloudProviderId(id), id);
|
||||
});
|
||||
|
||||
it('rejects empty or NUL-containing provider IDs', () => {
|
||||
assert.throws(() => assertCloudProviderId(''), /invalid/i);
|
||||
assert.throws(() => assertCloudProviderId('bad\0id'), /invalid/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Cloud provider identity helpers.
|
||||
*
|
||||
* Built-in providers keep their stable short IDs. Plugin sync Providers use
|
||||
* namespaced contribution IDs (e.g. com.example.backup.sync). The manager
|
||||
* boundary accepts both without coercing missing plugins away.
|
||||
*/
|
||||
|
||||
export const BUILTIN_CLOUD_PROVIDERS = [
|
||||
'github',
|
||||
'google',
|
||||
'onedrive',
|
||||
'webdav',
|
||||
's3',
|
||||
] as const;
|
||||
|
||||
export type BuiltinCloudProvider = (typeof BUILTIN_CLOUD_PROVIDERS)[number];
|
||||
|
||||
/** Built-in short IDs or namespaced plugin contribution IDs. */
|
||||
export type CloudProviderId = BuiltinCloudProvider | (string & {});
|
||||
|
||||
const BUILTIN_SET = new Set<string>(BUILTIN_CLOUD_PROVIDERS);
|
||||
|
||||
export function isBuiltinCloudProvider(provider: string): provider is BuiltinCloudProvider {
|
||||
return BUILTIN_SET.has(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the provider ID looks like a namespaced plugin contribution.
|
||||
* Contribution IDs are reverse-DNS style and always contain a dot.
|
||||
*/
|
||||
export function isPluginCloudProviderId(provider: string): boolean {
|
||||
if (isBuiltinCloudProvider(provider)) return false;
|
||||
if (typeof provider !== 'string' || provider.length < 3 || provider.length > 256) return false;
|
||||
if (provider.includes('\0') || provider.includes('/') || provider.includes('\\')) return false;
|
||||
return provider.includes('.');
|
||||
}
|
||||
|
||||
export function assertCloudProviderId(provider: string): CloudProviderId {
|
||||
if (typeof provider !== 'string' || provider.length < 1 || provider.length > 256) {
|
||||
throw new TypeError('Cloud provider ID is invalid');
|
||||
}
|
||||
if (provider.includes('\0')) {
|
||||
throw new TypeError('Cloud provider ID is invalid');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** localStorage / encrypted local key for a provider connection record. */
|
||||
export function providerConnectionStorageKey(provider: CloudProviderId): string {
|
||||
if (isBuiltinCloudProvider(provider)) {
|
||||
return `netcatty_provider_${provider}_v1`;
|
||||
}
|
||||
// Plugin IDs may contain characters unsafe for ad-hoc key templates.
|
||||
return `netcatty_provider_plugin_v1:${provider}`;
|
||||
}
|
||||
|
||||
/** Registry of dynamic plugin provider IDs that have been connected on this device. */
|
||||
export const PLUGIN_CLOUD_PROVIDER_REGISTRY_KEY = 'netcatty_plugin_cloud_providers_v1';
|
||||
@@ -125,6 +125,9 @@ export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): b
|
||||
.map((key) => [key, (payload as unknown as Record<string, unknown>)[key] ?? []]),
|
||||
),
|
||||
settings: payload.settings ?? {},
|
||||
// Plugin sidecars are host-owned opaque data on the encrypted blob and
|
||||
// must participate in migration freshness / equality checks.
|
||||
pluginSidecars: payload.pluginSidecars ?? { version: 1, entries: [] },
|
||||
});
|
||||
return fingerprint(project(left)) === fingerprint(project(right));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,58 @@ import {
|
||||
materializeSyncPayloadFromConvergentState,
|
||||
withConvergentSyncEnvelope,
|
||||
} from './payload';
|
||||
import {
|
||||
mergePluginSyncSidecars,
|
||||
mergePluginSyncSidecarsThreeWay,
|
||||
} from '../pluginSyncSidecar';
|
||||
|
||||
/** LWW-union plugin sidecars from every migration input that carries them. */
|
||||
function mergeMigrationSidecars(
|
||||
...bundles: Array<SyncPayload['pluginSidecars'] | null | undefined>
|
||||
): SyncPayload['pluginSidecars'] | undefined {
|
||||
let entries: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
|
||||
let sawAny = false;
|
||||
for (const bundle of bundles) {
|
||||
if (!bundle || !Array.isArray(bundle.entries)) continue;
|
||||
sawAny = true;
|
||||
entries = mergePluginSyncSidecars({ local: entries, remote: bundle });
|
||||
}
|
||||
return sawAny ? { version: 1, entries } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-way merge local sidecars against each remote source using that
|
||||
* source's trusted baseline (falls back to local baseline). Preserves
|
||||
* explicit local deletions instead of resurrecting them via LWW union.
|
||||
*/
|
||||
function mergeMigrationSidecarsWithBaselines(options: {
|
||||
local: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
localBaseline: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
sources: Array<{
|
||||
remote: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
baseline: SyncPayload['pluginSidecars'] | null | undefined;
|
||||
}>;
|
||||
}): SyncPayload['pluginSidecars'] | undefined {
|
||||
let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : [];
|
||||
const localBase = Array.isArray(options.localBaseline?.entries)
|
||||
? options.localBaseline.entries
|
||||
: [];
|
||||
let sawAny = Array.isArray(options.local?.entries);
|
||||
for (const source of options.sources) {
|
||||
if (!source.remote || !Array.isArray(source.remote.entries)) continue;
|
||||
sawAny = true;
|
||||
const base = Array.isArray(source.baseline?.entries)
|
||||
? source.baseline.entries
|
||||
: localBase;
|
||||
entries = mergePluginSyncSidecarsThreeWay({
|
||||
base,
|
||||
local: entries,
|
||||
remote: source.remote.entries,
|
||||
});
|
||||
}
|
||||
if (!sawAny) return undefined;
|
||||
return { version: 1, entries };
|
||||
}
|
||||
|
||||
/*
|
||||
* A local snapshot with no cloud entities and no trusted base is a fresh
|
||||
@@ -203,14 +255,43 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
if (blockedReasons.length === 0) {
|
||||
state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now);
|
||||
// Prefer the three-way merge result already on `merged` (preserves
|
||||
// explicit sidecar deletions). Do not re-LWW raw provider bundles —
|
||||
// that would resurrect entries three-way merge correctly removed.
|
||||
const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars')
|
||||
? merged.pluginSidecars
|
||||
: mergeMigrationSidecars(
|
||||
options.localPayload.pluginSidecars,
|
||||
...v1Inputs.map((input) => input.payload.pluginSidecars),
|
||||
);
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
syncMeta: merged.syncMeta,
|
||||
...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}),
|
||||
});
|
||||
}
|
||||
} else if (blockedReasons.length === 0) {
|
||||
state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates);
|
||||
const joinedPayload = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
|
||||
// Three-way per provider so local resets are not resurrected from a
|
||||
// still-stale remote entry during convergent enablement.
|
||||
const joinedSidecars = mergeMigrationSidecarsWithBaselines({
|
||||
local: options.localPayload.pluginSidecars,
|
||||
localBaseline: options.localTrustedBaseline?.pluginSidecars,
|
||||
sources: [
|
||||
...v2Inputs.map((input) => ({
|
||||
remote: input.payload.pluginSidecars,
|
||||
baseline: input.trustedBaseline?.pluginSidecars,
|
||||
})),
|
||||
...v1Inputs.map((input) => ({
|
||||
remote: input.payload.pluginSidecars,
|
||||
baseline: input.trustedBaseline?.pluginSidecars,
|
||||
})),
|
||||
],
|
||||
});
|
||||
const joinedPayload = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
|
||||
});
|
||||
const legacySources: Array<{
|
||||
id: string;
|
||||
payload: SyncPayload;
|
||||
@@ -257,7 +338,13 @@ export function planConvergentSyncMigration(options: {
|
||||
}
|
||||
if (blockedReasons.length === 0) {
|
||||
state = branches.reduce(mergeConvergentSyncStates, state);
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now });
|
||||
// joinedSidecars already unions local + all provider inputs. Re-LWW-ing
|
||||
// raw sources again cannot add unique entries and can confuse future
|
||||
// three-way paths that expect the joined set to be final.
|
||||
materialized = materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: options.now,
|
||||
...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +352,13 @@ export function planConvergentSyncMigration(options: {
|
||||
if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts');
|
||||
const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null;
|
||||
const payload = canInitialize && state
|
||||
? withConvergentSyncEnvelope(state, { syncedAt: options.now, syncMeta: materialized?.syncMeta })
|
||||
? withConvergentSyncEnvelope(state, {
|
||||
syncedAt: options.now,
|
||||
syncMeta: materialized?.syncMeta,
|
||||
...(materialized?.pluginSidecars
|
||||
? { pluginSidecars: materialized.pluginSidecars }
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
const previewPayload = materialized ?? options.localPayload;
|
||||
const entityCounts = Object.fromEntries(
|
||||
|
||||
@@ -202,6 +202,8 @@ export function materializeSyncPayloadFromConvergentState(
|
||||
options: {
|
||||
syncedAt: number;
|
||||
syncMeta?: SyncReliabilityMeta;
|
||||
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): SyncPayload {
|
||||
requireKnownCollections(state);
|
||||
@@ -228,6 +230,17 @@ export function materializeSyncPayloadFromConvergentState(
|
||||
settings,
|
||||
syncedAt: options.syncedAt,
|
||||
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
|
||||
// Preserve explicit empty bundles so lifecycle materializations (conflict
|
||||
// resolve / downgrade) can clear or re-upload sidecars rather than omit
|
||||
// the field and look like a legacy payload.
|
||||
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
|
||||
? {
|
||||
pluginSidecars: {
|
||||
version: 1 as const,
|
||||
entries: options.pluginSidecars.entries,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -513,7 +526,11 @@ export function hydrateConvergentSyncEnvelope(
|
||||
|
||||
export function withConvergentSyncEnvelope(
|
||||
state: ConvergentSyncStateV2,
|
||||
options: { syncedAt: number; syncMeta?: SyncReliabilityMeta },
|
||||
options: {
|
||||
syncedAt: number;
|
||||
syncMeta?: SyncReliabilityMeta;
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): SyncPayload {
|
||||
const payload = materializeSyncPayloadFromConvergentState(state, options);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Shared encrypted-object storage surface for built-in cloud adapters and
|
||||
* plugin sync Providers. Netcatty always encrypts before write and decrypts
|
||||
* after read; implementations only handle already-encrypted bytes.
|
||||
*/
|
||||
|
||||
export interface EncryptedObjectAccount {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectStorageCapabilities {
|
||||
revisions: boolean;
|
||||
conditionalWrites: boolean;
|
||||
atomicReplacement: boolean;
|
||||
maxObjectBytes?: number;
|
||||
maxObjects?: number;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectReadResult {
|
||||
found: boolean;
|
||||
key: string;
|
||||
bytes: Uint8Array | null;
|
||||
revision?: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectWriteResult {
|
||||
created: boolean;
|
||||
revision?: string;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectDeleteResult {
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectWriteOptions {
|
||||
/** When set, the write is conditional on the current remote revision. `null` means the object must not exist. */
|
||||
expectedRevision?: string | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface EncryptedObjectDeleteOptions {
|
||||
expectedRevision?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-agnostic encrypted object store. Plugins and WebDAV both implement
|
||||
* this shape so CloudSyncManager can encrypt→write / read→decrypt without
|
||||
* special-casing storage backends.
|
||||
*/
|
||||
export interface EncryptedObjectStorage {
|
||||
readonly providerId: string;
|
||||
connect(configuration?: unknown, options?: { signal?: AbortSignal }): Promise<{ account: EncryptedObjectAccount }>;
|
||||
disconnect(options?: { signal?: AbortSignal }): Promise<void>;
|
||||
getAccount(options?: { signal?: AbortSignal }): Promise<EncryptedObjectAccount | null>;
|
||||
getCapabilities(options?: { signal?: AbortSignal }): Promise<EncryptedObjectStorageCapabilities>;
|
||||
readObject(key: string, options?: { signal?: AbortSignal; preferStream?: boolean }): Promise<EncryptedObjectReadResult>;
|
||||
writeObject(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
options?: EncryptedObjectWriteOptions & { preferStream?: boolean },
|
||||
): Promise<EncryptedObjectWriteResult>;
|
||||
deleteObject(key: string, options?: EncryptedObjectDeleteOptions): Promise<EncryptedObjectDeleteResult>;
|
||||
}
|
||||
|
||||
/** Default object key used when adapting the legacy single-file CloudAdapter path. */
|
||||
export const DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY = 'netcatty-vault.json';
|
||||
@@ -18,6 +18,7 @@ const ALLOWED_KEYWORDS = new Set([
|
||||
'properties',
|
||||
'required',
|
||||
'type',
|
||||
'writeOnly',
|
||||
]);
|
||||
|
||||
type Schema = Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { planPluginSyncConnect, hasPluginProviderStoredConfig } from './pluginSyncConnect';
|
||||
|
||||
const requiredSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
endpoint: { type: 'string', minLength: 1 },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
required: ['endpoint'],
|
||||
};
|
||||
|
||||
const optionalSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
region: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
test('planPluginSyncConnect reuses stored config including falsy scalars', () => {
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: false }),
|
||||
{ action: 'connect', configuration: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: 0, configurationSchema: requiredSchema }),
|
||||
{ action: 'connect', configuration: 0 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: '', configurationSchema: requiredSchema }),
|
||||
{ action: 'connect', configuration: '' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: true, storedConfig: null, configurationSchema: { type: 'null' } }),
|
||||
{ action: 'connect', configuration: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('hasPluginProviderStoredConfig uses property presence including null', () => {
|
||||
assert.equal(hasPluginProviderStoredConfig({ config: null }), true);
|
||||
assert.equal(hasPluginProviderStoredConfig({ config: false }), true);
|
||||
assert.equal(hasPluginProviderStoredConfig({}), false);
|
||||
assert.equal(hasPluginProviderStoredConfig(undefined), false);
|
||||
});
|
||||
|
||||
test('planPluginSyncConnect prompts when schema rejects empty config', () => {
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined, configurationSchema: requiredSchema }),
|
||||
{ action: 'prompt' },
|
||||
);
|
||||
});
|
||||
|
||||
test('planPluginSyncConnect uses empty object when schema allows it or is absent', () => {
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined }),
|
||||
{ action: 'connect', configuration: {} },
|
||||
);
|
||||
assert.deepEqual(
|
||||
planPluginSyncConnect({ hasStoredConfig: false, storedConfig: undefined, configurationSchema: optionalSchema }),
|
||||
{ action: 'connect', configuration: {} },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { pluginConfigurationMatchesSchema } from './pluginConfigurationSchema';
|
||||
|
||||
export type PluginSyncConnectPlan =
|
||||
| { action: 'connect'; configuration: unknown }
|
||||
| { action: 'prompt' };
|
||||
|
||||
/**
|
||||
* Decide how to connect a plugin sync provider.
|
||||
* - Reuse retained config when present (including falsy scalars and JSON null).
|
||||
* - Connect with `{}` when no schema or when empty config is schema-valid.
|
||||
* - Otherwise prompt for configuration before connect.
|
||||
*
|
||||
* Callers must set `hasStoredConfig` from property presence (`'config' in`
|
||||
* / `hasOwnProperty`), not truthiness — `config: null` is a stored value.
|
||||
*/
|
||||
export function planPluginSyncConnect(options: {
|
||||
configurationSchema?: unknown;
|
||||
storedConfig: unknown;
|
||||
hasStoredConfig: boolean;
|
||||
}): PluginSyncConnectPlan {
|
||||
if (options.hasStoredConfig) {
|
||||
return { action: 'connect', configuration: options.storedConfig };
|
||||
}
|
||||
const schema = options.configurationSchema;
|
||||
if (schema === undefined) {
|
||||
return { action: 'connect', configuration: {} };
|
||||
}
|
||||
if (pluginConfigurationMatchesSchema(schema, {})) {
|
||||
return { action: 'connect', configuration: {} };
|
||||
}
|
||||
return { action: 'prompt' };
|
||||
}
|
||||
|
||||
/** True when a provider connection retains a config property (including null). */
|
||||
export function hasPluginProviderStoredConfig(
|
||||
connection: { config?: unknown } | null | undefined,
|
||||
): boolean {
|
||||
return connection != null
|
||||
&& Object.prototype.hasOwnProperty.call(connection, 'config');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
planPluginSyncCredential,
|
||||
pluginSyncSecretStoreKeys,
|
||||
syncConfigurationSchemaWithoutSecretRequirements,
|
||||
} from './pluginSyncCredential.ts';
|
||||
|
||||
describe('planPluginSyncCredential', () => {
|
||||
it('extracts all secret keys and strips them from configuration', () => {
|
||||
const plan = planPluginSyncCredential({
|
||||
endpoint: 'https://dav.example',
|
||||
username: 'alice',
|
||||
password: 's3cret',
|
||||
token: 'also-secret',
|
||||
});
|
||||
assert.equal(plan.plaintextSecret, 's3cret');
|
||||
assert.equal(plan.extractedFrom, 'password');
|
||||
assert.equal(plan.secrets.length, 2);
|
||||
assert.deepEqual(
|
||||
plan.secrets.map((entry) => ({ key: entry.key, value: entry.value })),
|
||||
[
|
||||
{ key: 'password', value: 's3cret' },
|
||||
{ key: 'token', value: 'also-secret' },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(plan.configuration, {
|
||||
endpoint: 'https://dav.example',
|
||||
username: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts writeOnly schema fields that are not well-known names', () => {
|
||||
const plan = planPluginSyncCredential(
|
||||
{
|
||||
endpoint: 'https://dav.example',
|
||||
appPassword: 'hidden',
|
||||
},
|
||||
{
|
||||
configurationSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
endpoint: { type: 'string' },
|
||||
appPassword: { type: 'string', writeOnly: true },
|
||||
},
|
||||
required: ['endpoint', 'appPassword'],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(plan.secrets.length, 1);
|
||||
assert.equal(plan.secrets[0]?.key, 'appPassword');
|
||||
assert.equal(plan.secrets[0]?.secretKey, 'sync-credential:appPassword');
|
||||
assert.deepEqual(plan.configuration, { endpoint: 'https://dav.example' });
|
||||
});
|
||||
|
||||
it('passes through non-object configs unchanged', () => {
|
||||
assert.deepEqual(planPluginSyncCredential(null), {
|
||||
configuration: null,
|
||||
secrets: [],
|
||||
secretKey: 'sync-credential',
|
||||
});
|
||||
assert.deepEqual(planPluginSyncCredential('x'), {
|
||||
configuration: 'x',
|
||||
secrets: [],
|
||||
secretKey: 'sync-credential',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves configs without secrets alone', () => {
|
||||
const configuration = { endpoint: 'https://dav.example', username: 'alice' };
|
||||
assert.deepEqual(planPluginSyncCredential(configuration), {
|
||||
configuration,
|
||||
secrets: [],
|
||||
secretKey: 'sync-credential',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncConfigurationSchemaWithoutSecretRequirements', () => {
|
||||
it('drops known secret keys and writeOnly fields from required', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
endpoint: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
appPassword: { type: 'string', writeOnly: true },
|
||||
},
|
||||
required: ['endpoint', 'password', 'appPassword'],
|
||||
};
|
||||
assert.deepEqual(syncConfigurationSchemaWithoutSecretRequirements(schema), {
|
||||
...schema,
|
||||
required: ['endpoint'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pluginSyncSecretStoreKeys', () => {
|
||||
it('includes primary and secondary credential keys', () => {
|
||||
assert.ok(pluginSyncSecretStoreKeys().includes('sync-credential'));
|
||||
assert.ok(pluginSyncSecretStoreKeys().includes('sync-credential:token'));
|
||||
assert.ok(pluginSyncSecretStoreKeys(['appPassword']).includes('sync-credential:appPassword'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Split opaque sync secrets out of plugin configuration so only non-secret
|
||||
* JSON reaches cloud persistence / SyncConnectPayload.configuration.
|
||||
*/
|
||||
|
||||
export const PLUGIN_SYNC_SECRET_CONFIG_KEYS = [
|
||||
'password',
|
||||
'token',
|
||||
'secret',
|
||||
'apiKey',
|
||||
'accessToken',
|
||||
] as const;
|
||||
|
||||
export type PluginSyncSecretConfigKey = (typeof PLUGIN_SYNC_SECRET_CONFIG_KEYS)[number];
|
||||
|
||||
export interface PluginSyncExtractedSecret {
|
||||
/** Configuration property name that held the secret. */
|
||||
key: string;
|
||||
value: string;
|
||||
/** SecretStore key used for the opaque ref (stable per config field). */
|
||||
secretKey: string;
|
||||
}
|
||||
|
||||
export interface PluginSyncCredentialPlan {
|
||||
/** Configuration with secret fields removed (safe to persist as plugin config). */
|
||||
configuration: unknown;
|
||||
/** All extracted top-level secrets (may be empty). */
|
||||
secrets: PluginSyncExtractedSecret[];
|
||||
/**
|
||||
* Primary secret for SyncConnectPayload.credential (first extracted).
|
||||
* Additional secrets remain in OS storage under their secretKey for plugins
|
||||
* that call secrets.get / createLease by key.
|
||||
*/
|
||||
plaintextSecret?: string;
|
||||
secretKey: string;
|
||||
extractedFrom?: string;
|
||||
}
|
||||
|
||||
function isWellKnownSecretConfigKey(key: string): boolean {
|
||||
return (PLUGIN_SYNC_SECRET_CONFIG_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level property names that configurationSchema marks as writeOnly string
|
||||
* fields (JSON Schema writeOnly) — treated as secrets even when not well-known names.
|
||||
*/
|
||||
export function listWriteOnlySecretPropertyNames(schema: unknown): string[] {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return [];
|
||||
const properties = (schema as { properties?: unknown }).properties;
|
||||
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return [];
|
||||
const names: string[] = [];
|
||||
for (const [name, child] of Object.entries(properties as Record<string, unknown>)) {
|
||||
if (!child || typeof child !== 'object' || Array.isArray(child)) continue;
|
||||
const node = child as { type?: unknown; writeOnly?: unknown };
|
||||
if (node.writeOnly === true && (node.type === undefined || node.type === 'string')) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function secretStoreKeyForConfigField(field: string): string {
|
||||
return field === 'password' ? 'sync-credential' : `sync-credential:${field}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract top-level secret strings from plugin configuration JSON.
|
||||
* Every matching key is removed from configuration and returned in `secrets`.
|
||||
* Non-object configs pass through unchanged.
|
||||
*
|
||||
* Secret keys are the well-known name whitelist plus any top-level properties
|
||||
* marked `writeOnly: true` in the provider configurationSchema.
|
||||
*/
|
||||
export function planPluginSyncCredential(
|
||||
configuration: unknown,
|
||||
options?: { configurationSchema?: unknown },
|
||||
): PluginSyncCredentialPlan {
|
||||
if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) {
|
||||
return { configuration, secrets: [], secretKey: 'sync-credential' };
|
||||
}
|
||||
const source = configuration as Record<string, unknown>;
|
||||
const schemaSecretNames = new Set(listWriteOnlySecretPropertyNames(options?.configurationSchema));
|
||||
const candidateKeys = new Set<string>([
|
||||
...PLUGIN_SYNC_SECRET_CONFIG_KEYS,
|
||||
...schemaSecretNames,
|
||||
]);
|
||||
const secrets: PluginSyncExtractedSecret[] = [];
|
||||
// Stable order: well-known list first, then schema writeOnly names alphabetically.
|
||||
const orderedKeys = [
|
||||
...PLUGIN_SYNC_SECRET_CONFIG_KEYS.filter((key) => candidateKeys.has(key)),
|
||||
...[...schemaSecretNames].filter((key) => !isWellKnownSecretConfigKey(key)).sort(),
|
||||
];
|
||||
for (const key of orderedKeys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
secrets.push({
|
||||
key,
|
||||
value,
|
||||
secretKey: secretStoreKeyForConfigField(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (secrets.length === 0) {
|
||||
return { configuration, secrets: [], secretKey: 'sync-credential' };
|
||||
}
|
||||
const stripped: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (candidateKeys.has(key)) continue;
|
||||
stripped[key] = value;
|
||||
}
|
||||
const primary = secrets[0]!;
|
||||
return {
|
||||
configuration: stripped,
|
||||
secrets,
|
||||
plaintextSecret: primary.value,
|
||||
secretKey: primary.secretKey,
|
||||
extractedFrom: primary.key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync connect strips secret fields from configuration before invoke. Host
|
||||
* schema validation must therefore treat those keys as optional even when the
|
||||
* contribution marks them required (secrets arrive via SyncConnectPayload.credential).
|
||||
*/
|
||||
export function syncConfigurationSchemaWithoutSecretRequirements(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema;
|
||||
const source = schema as Record<string, unknown>;
|
||||
if (!Array.isArray(source.required)) return schema;
|
||||
const secretNames = new Set([
|
||||
...PLUGIN_SYNC_SECRET_CONFIG_KEYS,
|
||||
...listWriteOnlySecretPropertyNames(schema),
|
||||
]);
|
||||
const required = source.required.filter(
|
||||
(name) => typeof name === 'string' && !secretNames.has(name),
|
||||
);
|
||||
if (required.length === source.required.length) return schema;
|
||||
return { ...source, required };
|
||||
}
|
||||
|
||||
/** Stable SecretStore keys used for plugin sync credentials (for delete-on-disconnect). */
|
||||
export function pluginSyncSecretStoreKeys(extraFieldNames: readonly string[] = []): readonly string[] {
|
||||
const keys = new Set<string>([
|
||||
'sync-credential',
|
||||
...PLUGIN_SYNC_SECRET_CONFIG_KEYS
|
||||
.filter((key) => key !== 'password')
|
||||
.map((key) => `sync-credential:${key}`),
|
||||
]);
|
||||
for (const name of extraFieldNames) {
|
||||
if (typeof name === 'string' && name.length > 0) {
|
||||
keys.add(secretStoreKeyForConfigField(name));
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
collectPluginSyncSidecars,
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
isCloudSyncablePluginSetting,
|
||||
mergePluginSyncSidecars,
|
||||
mergePluginSyncSidecarsThreeWay,
|
||||
parseSettingsSidecarKey,
|
||||
type PluginSettingFieldDescriptor,
|
||||
type PluginSyncSidecarEntry,
|
||||
} from './pluginSyncSidecar';
|
||||
|
||||
const pluginId = 'com.example.sidecar';
|
||||
|
||||
const declared = new Map<string, PluginSettingFieldDescriptor[]>([
|
||||
[pluginId, [
|
||||
{ id: `${pluginId}.theme`, sync: true, secret: false },
|
||||
{ id: `${pluginId}.token`, sync: true, secret: true },
|
||||
{ id: `${pluginId}.localOnly`, sync: false, secret: false },
|
||||
]],
|
||||
]);
|
||||
|
||||
describe('pluginSyncSidecar', () => {
|
||||
it('rejects secret fields from cloud sync even when sync is true', () => {
|
||||
assert.equal(isCloudSyncablePluginSetting({ sync: true, secret: true }), false);
|
||||
assert.equal(isCloudSyncablePluginSetting({ sync: true, secret: false }), true);
|
||||
assert.equal(isCloudSyncablePluginSetting({ sync: false, secret: false }), false);
|
||||
});
|
||||
|
||||
it('collects only non-secret sync:true settings into the sidecar bundle', () => {
|
||||
const bundle = collectPluginSyncSidecars({
|
||||
declaredSettingsByPlugin: declared,
|
||||
storedSettings: [
|
||||
{
|
||||
pluginId,
|
||||
settingId: `${pluginId}.theme`,
|
||||
scope: 'application',
|
||||
scopeId: 'application',
|
||||
value: 'dark',
|
||||
updatedAt: 10,
|
||||
},
|
||||
{
|
||||
pluginId,
|
||||
settingId: `${pluginId}.token`,
|
||||
scope: 'application',
|
||||
scopeId: 'application',
|
||||
value: 'should-not-sync',
|
||||
updatedAt: 10,
|
||||
},
|
||||
{
|
||||
pluginId,
|
||||
settingId: `${pluginId}.localOnly`,
|
||||
scope: 'application',
|
||||
scopeId: 'application',
|
||||
value: true,
|
||||
updatedAt: 10,
|
||||
},
|
||||
],
|
||||
now: 20,
|
||||
});
|
||||
|
||||
assert.equal(bundle.version, 1);
|
||||
assert.equal(bundle.entries.length, 1);
|
||||
assert.equal(bundle.entries[0].kind, 'settings');
|
||||
assert.equal(bundle.entries[0].value, 'dark');
|
||||
assert.deepEqual(parseSettingsSidecarKey(bundle.entries[0].key), {
|
||||
settingId: `${pluginId}.theme`,
|
||||
scope: 'application',
|
||||
scopeId: 'application',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves account and CRDT baselines when the plugin is missing', () => {
|
||||
const existing: PluginSyncSidecarEntry[] = [
|
||||
{
|
||||
pluginId: 'com.missing.plugin',
|
||||
kind: 'account_baseline',
|
||||
key: 'account',
|
||||
value: { accountId: 'acct-1' },
|
||||
updatedAt: 5,
|
||||
},
|
||||
{
|
||||
pluginId: 'com.missing.plugin',
|
||||
kind: 'crdt_baseline',
|
||||
key: 'replica',
|
||||
value: { clock: 3 },
|
||||
updatedAt: 5,
|
||||
},
|
||||
{
|
||||
pluginId: 'com.missing.plugin',
|
||||
kind: 'settings',
|
||||
key: 'com.missing.plugin.theme\0application\0application',
|
||||
value: 'light',
|
||||
updatedAt: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const bundle = collectPluginSyncSidecars({
|
||||
declaredSettingsByPlugin: new Map(), // plugin not installed
|
||||
storedSettings: [],
|
||||
existingSidecars: existing,
|
||||
now: 100,
|
||||
});
|
||||
|
||||
assert.equal(bundle.entries.length, 3);
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === 'account_baseline'));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === 'crdt_baseline'));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === 'settings' && entry.value === 'light'));
|
||||
});
|
||||
|
||||
it('merges remote sidecars without dropping local baselines for missing remote plugins', () => {
|
||||
const local: PluginSyncSidecarEntry[] = [
|
||||
{
|
||||
pluginId: 'com.local.only',
|
||||
kind: 'account_baseline',
|
||||
key: 'account',
|
||||
value: { id: 'local' },
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const remote = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{
|
||||
pluginId,
|
||||
kind: 'settings' as const,
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'dark',
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const merged = mergePluginSyncSidecars({ local, remote, declaredSettingsByPlugin: declared });
|
||||
assert.equal(merged.length, 2);
|
||||
assert.ok(merged.some((entry) => entry.pluginId === 'com.local.only'));
|
||||
assert.ok(merged.some((entry) => entry.value === 'dark'));
|
||||
});
|
||||
|
||||
it('excludes secret settings when stripping sidecars with known declarations', () => {
|
||||
const entries: PluginSyncSidecarEntry[] = [
|
||||
{
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.token\0application\0application`,
|
||||
value: 'secret',
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'dark',
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const filtered = excludeSecretPluginSettingsFromSidecars(entries, declared);
|
||||
assert.equal(filtered.length, 1);
|
||||
assert.equal(filtered[0].value, 'dark');
|
||||
});
|
||||
|
||||
it('collect prefers newer retained sidecars over older stored settings', () => {
|
||||
const bundle = collectPluginSyncSidecars({
|
||||
declaredSettingsByPlugin: declared,
|
||||
storedSettings: [{
|
||||
pluginId,
|
||||
settingId: `${pluginId}.theme`,
|
||||
scope: 'application',
|
||||
scopeId: 'application',
|
||||
value: 'local-old',
|
||||
updatedAt: 100,
|
||||
}],
|
||||
existingSidecars: [{
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'remote-new',
|
||||
updatedAt: 200,
|
||||
}],
|
||||
now: 300,
|
||||
});
|
||||
assert.equal(bundle.entries.length, 1);
|
||||
assert.equal(bundle.entries[0].value, 'remote-new');
|
||||
assert.equal(bundle.entries[0].updatedAt, 200);
|
||||
});
|
||||
|
||||
it('three-way merge propagates local settings deletions against an unchanged remote', () => {
|
||||
const entry: PluginSyncSidecarEntry = {
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'dark',
|
||||
updatedAt: 10,
|
||||
};
|
||||
const merged = mergePluginSyncSidecarsThreeWay({
|
||||
base: [entry],
|
||||
local: [],
|
||||
remote: [entry],
|
||||
});
|
||||
assert.equal(merged.some((item) => item.key === entry.key), false);
|
||||
});
|
||||
|
||||
it('three-way merge keeps a newer remote edit over a local deletion', () => {
|
||||
const base: PluginSyncSidecarEntry = {
|
||||
pluginId,
|
||||
kind: 'settings',
|
||||
key: `${pluginId}.theme\0application\0application`,
|
||||
value: 'dark',
|
||||
updatedAt: 10,
|
||||
};
|
||||
const remote: PluginSyncSidecarEntry = {
|
||||
...base,
|
||||
value: 'light',
|
||||
updatedAt: 20,
|
||||
};
|
||||
const merged = mergePluginSyncSidecarsThreeWay({
|
||||
base: [base],
|
||||
local: [],
|
||||
remote: [remote],
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Pure helpers for plugin cloud-sync sidecars.
|
||||
*
|
||||
* Sidecars hold user-owned encrypted-sync data that must survive missing or
|
||||
* uninstalled plugins: non-secret settings marked `sync: true`, plus optional
|
||||
* plugin sync account / CRDT baselines. Secret settings never enter sidecars.
|
||||
*/
|
||||
|
||||
export type PluginSyncSidecarKind = 'settings' | 'account_baseline' | 'crdt_baseline';
|
||||
|
||||
export interface PluginSettingFieldDescriptor {
|
||||
id: string;
|
||||
secret?: boolean;
|
||||
sync?: boolean;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface PluginSettingValueRecord {
|
||||
pluginId: string;
|
||||
settingId: string;
|
||||
scope: string;
|
||||
scopeId: string;
|
||||
value: unknown;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface PluginSyncSidecarEntry {
|
||||
pluginId: string;
|
||||
kind: PluginSyncSidecarKind;
|
||||
key: string;
|
||||
value: unknown;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PluginSyncSidecarBundle {
|
||||
/** Schema version for the sidecar envelope inside the encrypted payload. */
|
||||
version: 1;
|
||||
entries: PluginSyncSidecarEntry[];
|
||||
}
|
||||
|
||||
export interface CollectPluginSyncSidecarsInput {
|
||||
/** Declared setting fields keyed by plugin ID (from installed manifests when available). */
|
||||
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
|
||||
/** Stored non-secret setting values (from non-cascade plugin_settings rows). */
|
||||
storedSettings: readonly PluginSettingValueRecord[];
|
||||
/** Existing sidecar rows (account/CRDT baselines and previously synced settings). */
|
||||
existingSidecars?: readonly PluginSyncSidecarEntry[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
const SIDECAR_KINDS = new Set<PluginSyncSidecarKind>([
|
||||
'settings',
|
||||
'account_baseline',
|
||||
'crdt_baseline',
|
||||
]);
|
||||
|
||||
export function isPluginSyncSidecarKind(value: unknown): value is PluginSyncSidecarKind {
|
||||
return typeof value === 'string' && SIDECAR_KINDS.has(value as PluginSyncSidecarKind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a setting field may enter cloud sync sidecars.
|
||||
* Secrets always fail closed even if a descriptor incorrectly sets sync.
|
||||
*/
|
||||
export function isCloudSyncablePluginSetting(
|
||||
field: Pick<PluginSettingFieldDescriptor, 'secret' | 'sync'>,
|
||||
): boolean {
|
||||
if (field.secret === true) return false;
|
||||
return field.sync === true;
|
||||
}
|
||||
|
||||
function settingsSidecarKey(settingId: string, scope: string, scopeId: string): string {
|
||||
return `${settingId}\0${scope}\0${scopeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sidecar bundle for the encrypted cloud payload.
|
||||
* Missing plugins keep existing sidecar rows; only non-secret sync:true
|
||||
* settings from the current store are (re)published under kind "settings".
|
||||
*/
|
||||
export function collectPluginSyncSidecars(
|
||||
input: CollectPluginSyncSidecarsInput,
|
||||
): PluginSyncSidecarBundle {
|
||||
const now = input.now ?? Date.now();
|
||||
const entries: PluginSyncSidecarEntry[] = [];
|
||||
const settingsIndex = new Map<string, PluginSyncSidecarEntry>();
|
||||
|
||||
// Preserve non-settings sidecars and previous settings for unknown plugins.
|
||||
for (const existing of input.existingSidecars ?? []) {
|
||||
if (!existing || typeof existing.pluginId !== 'string' || !isPluginSyncSidecarKind(existing.kind)) {
|
||||
continue;
|
||||
}
|
||||
if (existing.kind !== 'settings') {
|
||||
entries.push({
|
||||
pluginId: existing.pluginId,
|
||||
kind: existing.kind,
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const copy: PluginSyncSidecarEntry = {
|
||||
pluginId: existing.pluginId,
|
||||
kind: 'settings',
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
};
|
||||
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
|
||||
}
|
||||
|
||||
for (const stored of input.storedSettings) {
|
||||
if (!stored || typeof stored.pluginId !== 'string' || typeof stored.settingId !== 'string') continue;
|
||||
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
|
||||
// Missing plugin: keep any prior settings sidecar rows, do not invent new ones
|
||||
// from undeclared storage (could include secret-adjacent values).
|
||||
if (!declared) continue;
|
||||
const field = declared.find((entry) => entry.id === stored.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
|
||||
const entry: PluginSyncSidecarEntry = {
|
||||
pluginId: stored.pluginId,
|
||||
kind: 'settings',
|
||||
key,
|
||||
value: stored.value,
|
||||
updatedAt: Number(stored.updatedAt) || now,
|
||||
};
|
||||
const mapKey = `${entry.pluginId}\0${entry.key}`;
|
||||
const previous = settingsIndex.get(mapKey);
|
||||
// Keep a retained sidecar when it is strictly newer than the stored row
|
||||
// (e.g. schema-rejected remote apply left sidecar only).
|
||||
if (!previous || entry.updatedAt >= previous.updatedAt) {
|
||||
settingsIndex.set(mapKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(...settingsIndex.values());
|
||||
entries.sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
|
||||
return { version: 1, entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a remote sidecar bundle into local non-cascade storage without deleting
|
||||
* local rows for plugins that are not present on the remote (partial sync safety).
|
||||
* Secret settings are never applied even if a malicious remote marks them as settings.
|
||||
*/
|
||||
/**
|
||||
* Three-way merge for settings sidecars so local resets (absent from local,
|
||||
* present in base) propagate instead of being resurrected from base/remote.
|
||||
* Non-settings kinds (baselines) still use last-writer-wins union.
|
||||
*/
|
||||
export function mergePluginSyncSidecarsThreeWay(params: {
|
||||
base: readonly PluginSyncSidecarEntry[];
|
||||
local: readonly PluginSyncSidecarEntry[];
|
||||
remote: readonly PluginSyncSidecarEntry[];
|
||||
/**
|
||||
* When set, honor cloud/local conflict policy for settings instead of pure
|
||||
* timestamp LWW (preferCloud / preferLocal). Default is smart three-way.
|
||||
*/
|
||||
strategy?: 'smart' | 'preferCloud' | 'preferLocal';
|
||||
}): PluginSyncSidecarEntry[] {
|
||||
const strategy = params.strategy ?? 'smart';
|
||||
const keyOf = (entry: PluginSyncSidecarEntry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
|
||||
const toMap = (entries: readonly PluginSyncSidecarEntry[]) => {
|
||||
const map = new Map<string, PluginSyncSidecarEntry>();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
|
||||
map.set(keyOf(entry), {
|
||||
pluginId: entry.pluginId,
|
||||
kind: entry.kind,
|
||||
key: String(entry.key),
|
||||
value: entry.value,
|
||||
updatedAt: Number(entry.updatedAt) || 0,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const baseMap = toMap(params.base);
|
||||
const localMap = toMap(params.local);
|
||||
const remoteMap = toMap(params.remote);
|
||||
const allKeys = new Set([...baseMap.keys(), ...localMap.keys(), ...remoteMap.keys()]);
|
||||
const out: PluginSyncSidecarEntry[] = [];
|
||||
|
||||
for (const mapKey of allKeys) {
|
||||
const b = baseMap.get(mapKey);
|
||||
const l = localMap.get(mapKey);
|
||||
const r = remoteMap.get(mapKey);
|
||||
const kind = (l?.kind ?? r?.kind ?? b?.kind) as PluginSyncSidecarKind | undefined;
|
||||
if (!kind) continue;
|
||||
|
||||
if (kind !== 'settings') {
|
||||
// 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);
|
||||
else out.push(l.updatedAt >= r.updatedAt ? l : r);
|
||||
} else if (l) out.push(l);
|
||||
else if (r) out.push(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Settings three-way with optional conflict policy:
|
||||
// base+remote, not local → local deleted
|
||||
if (b && !l && r) {
|
||||
if (strategy === 'preferLocal') continue; // honor local deletion
|
||||
if (strategy === 'preferCloud' || r.updatedAt > b.updatedAt) out.push(r);
|
||||
continue;
|
||||
}
|
||||
// base+local, not remote → remote deleted
|
||||
if (b && l && !r) {
|
||||
if (strategy === 'preferCloud') continue; // honor remote deletion
|
||||
if (strategy === 'preferLocal' || l.updatedAt > b.updatedAt) out.push(l);
|
||||
continue;
|
||||
}
|
||||
// both deleted
|
||||
if (b && !l && !r) continue;
|
||||
// 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;
|
||||
}
|
||||
if (!b && !l && r) {
|
||||
out.push(r);
|
||||
continue;
|
||||
}
|
||||
// both sides present (with or without base)
|
||||
if (l && r) {
|
||||
if (strategy === 'preferCloud') {
|
||||
out.push(r);
|
||||
} else if (strategy === 'preferLocal') {
|
||||
out.push(l);
|
||||
} else if (l.updatedAt !== r.updatedAt) {
|
||||
out.push(l.updatedAt > r.updatedAt ? l : r);
|
||||
} else {
|
||||
// Equal timestamps: stable secondary key so devices converge.
|
||||
const lTie = JSON.stringify(l.value);
|
||||
const rTie = JSON.stringify(r.value);
|
||||
out.push(lTie >= rTie ? l : r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (l) out.push(l);
|
||||
else if (r) out.push(r);
|
||||
}
|
||||
|
||||
return out.sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePluginSyncSidecars(params: {
|
||||
local: readonly PluginSyncSidecarEntry[];
|
||||
remote: PluginSyncSidecarBundle | null | undefined;
|
||||
declaredSettingsByPlugin?: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>;
|
||||
}): PluginSyncSidecarEntry[] {
|
||||
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
|
||||
const merged = new Map<string, PluginSyncSidecarEntry>();
|
||||
|
||||
for (const entry of params.local) {
|
||||
if (!entry || typeof entry.pluginId !== 'string' || !isPluginSyncSidecarKind(entry.kind)) continue;
|
||||
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
|
||||
pluginId: entry.pluginId,
|
||||
kind: entry.kind,
|
||||
key: String(entry.key),
|
||||
value: entry.value,
|
||||
updatedAt: Number(entry.updatedAt) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const raw of remoteEntries) {
|
||||
if (!raw || typeof raw.pluginId !== 'string' || !isPluginSyncSidecarKind(raw.kind)) continue;
|
||||
if (raw.kind === 'settings') {
|
||||
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
|
||||
if (declared) {
|
||||
// key format: settingId\0scope\0scopeId
|
||||
const settingId = String(raw.key).split('\0')[0] ?? '';
|
||||
const field = declared.find((entry) => entry.id === settingId);
|
||||
if (field && !isCloudSyncablePluginSetting(field)) continue;
|
||||
}
|
||||
}
|
||||
const next: PluginSyncSidecarEntry = {
|
||||
pluginId: raw.pluginId,
|
||||
kind: raw.kind,
|
||||
key: String(raw.key),
|
||||
value: raw.value,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
};
|
||||
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
|
||||
const previous = merged.get(mapKey);
|
||||
if (!previous || next.updatedAt >= previous.updatedAt) {
|
||||
merged.set(mapKey, next);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a settings sidecar key into setting coordinates.
|
||||
*/
|
||||
export function parseSettingsSidecarKey(key: string): {
|
||||
settingId: string;
|
||||
scope: string;
|
||||
scopeId: string;
|
||||
} | null {
|
||||
const parts = String(key).split('\0');
|
||||
if (parts.length !== 3) return null;
|
||||
const [settingId, scope, scopeId] = parts;
|
||||
if (!settingId || !scope || !scopeId) return null;
|
||||
return { settingId, scope, scopeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip secret settings from an arbitrary sidecar entry list (defense in depth).
|
||||
*/
|
||||
export function excludeSecretPluginSettingsFromSidecars(
|
||||
entries: readonly PluginSyncSidecarEntry[],
|
||||
declaredSettingsByPlugin: ReadonlyMap<string, readonly PluginSettingFieldDescriptor[]>,
|
||||
): PluginSyncSidecarEntry[] {
|
||||
return entries.filter((entry) => {
|
||||
if (entry.kind !== 'settings') return true;
|
||||
const declared = declaredSettingsByPlugin.get(entry.pluginId);
|
||||
if (!declared) return true; // preserve orphaned rows for missing plugins
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) return false;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field) return true;
|
||||
return isCloudSyncablePluginSetting(field);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { hasProviderConnectionData, isProviderReadyForSync } from './sync';
|
||||
|
||||
test('hasProviderConnectionData treats falsy scalar configs as present', () => {
|
||||
assert.equal(hasProviderConnectionData({ config: false }), true);
|
||||
assert.equal(hasProviderConnectionData({ config: 0 }), true);
|
||||
assert.equal(hasProviderConnectionData({ config: '' }), true);
|
||||
assert.equal(hasProviderConnectionData({ config: null as never }), true);
|
||||
assert.equal(hasProviderConnectionData({}), false);
|
||||
assert.equal(hasProviderConnectionData({ tokens: undefined }), false);
|
||||
});
|
||||
|
||||
test('isProviderReadyForSync keeps error status ready when only scalar config remains', () => {
|
||||
assert.equal(
|
||||
isProviderReadyForSync({ status: 'error', config: false }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isProviderReadyForSync({ status: 'error', config: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isProviderReadyForSync({ status: 'error', config: '' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isProviderReadyForSync({ status: 'error' }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isProviderReadyForSync({ status: 'disconnected', config: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
+101
-5
@@ -55,9 +55,23 @@ export type ConflictResolution =
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Supported cloud storage providers
|
||||
* Supported cloud storage providers.
|
||||
* Built-in short IDs remain stable; plugin sync Providers use namespaced
|
||||
* contribution IDs (e.g. com.example.backup.sync).
|
||||
*/
|
||||
export type CloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
|
||||
export type BuiltinCloudProvider = 'github' | 'google' | 'onedrive' | 'webdav' | 's3';
|
||||
export type CloudProvider = BuiltinCloudProvider | (string & {});
|
||||
|
||||
export const BUILTIN_CLOUD_PROVIDERS: readonly BuiltinCloudProvider[] = [
|
||||
'github',
|
||||
'google',
|
||||
'onedrive',
|
||||
'webdav',
|
||||
's3',
|
||||
] as const;
|
||||
|
||||
export const isBuiltinCloudProvider = (provider: string): provider is BuiltinCloudProvider =>
|
||||
(BUILTIN_CLOUD_PROVIDERS as readonly string[]).includes(provider);
|
||||
|
||||
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
|
||||
|
||||
@@ -148,21 +162,74 @@ 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
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const hasProviderConnectionData = (
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
|
||||
): boolean => Boolean(connection.tokens || connection.config);
|
||||
/**
|
||||
* Whether a connection still has usable credentials/config to retry.
|
||||
* Plugin configs may be valid falsy JSON scalars (`false`, `0`, `""`) or even
|
||||
* JSON `null` when a schema uses `type: "null"`. Presence is property
|
||||
* existence for `config`; do not use truthiness (`||` / `Boolean`).
|
||||
*/
|
||||
export const hasProviderConnectionData = (
|
||||
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
|
||||
): boolean =>
|
||||
connection.tokens != null
|
||||
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|
||||
|| connection.credential != null;
|
||||
|
||||
export const isProviderReadyForSync = (
|
||||
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
|
||||
@@ -299,6 +366,12 @@ export interface SyncPayload {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Encrypted-sidecar envelope for plugin user data that must survive missing
|
||||
* plugins (sync:true settings, account/CRDT baselines). Secrets never appear here.
|
||||
*/
|
||||
pluginSidecars?: import('./pluginSyncSidecar').PluginSyncSidecarBundle;
|
||||
|
||||
// Sync metadata
|
||||
syncedAt: number; // When this payload was created
|
||||
|
||||
@@ -624,12 +697,35 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
|
||||
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
||||
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',
|
||||
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
|
||||
} as const;
|
||||
|
||||
/** Resolve the localStorage key used for a provider connection (built-in or plugin). */
|
||||
export function providerConnectionStorageKey(provider: CloudProvider): string {
|
||||
if (isBuiltinCloudProvider(provider)) {
|
||||
const map: Record<BuiltinCloudProvider, string> = {
|
||||
github: SYNC_STORAGE_KEYS.PROVIDER_GITHUB,
|
||||
google: SYNC_STORAGE_KEYS.PROVIDER_GOOGLE,
|
||||
onedrive: SYNC_STORAGE_KEYS.PROVIDER_ONEDRIVE,
|
||||
webdav: SYNC_STORAGE_KEYS.PROVIDER_WEBDAV,
|
||||
s3: SYNC_STORAGE_KEYS.PROVIDER_S3,
|
||||
};
|
||||
return map[provider];
|
||||
}
|
||||
return `netcatty_provider_plugin_v1:${provider}`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
@@ -418,6 +418,114 @@ test("mergeSyncPayloads keeps local settings conflict policy when a base exists"
|
||||
});
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads carries plugin sidecars through without dropping remote baselines", () => {
|
||||
const local: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.local.plugin",
|
||||
kind: "settings",
|
||||
key: "com.local.plugin.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 5,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const remote: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 2,
|
||||
pluginSidecars: {
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.remote.plugin",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct-r" },
|
||||
updatedAt: 9,
|
||||
}],
|
||||
},
|
||||
};
|
||||
const result = mergeSyncPayloads(null, local, remote);
|
||||
assert.ok(result.payload.pluginSidecars);
|
||||
assert.equal(result.payload.pluginSidecars!.entries.length, 2);
|
||||
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.pluginId === "com.local.plugin"));
|
||||
assert.ok(result.payload.pluginSidecars!.entries.some((e) => e.kind === "account_baseline"));
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads propagates local sidecar setting resets via three-way merge", () => {
|
||||
const entry = {
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings" as const,
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const base: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [entry] },
|
||||
};
|
||||
const local: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 2,
|
||||
// user reset: explicit empty sidecar bundle (omitted field = legacy/unsupported)
|
||||
pluginSidecars: { version: 1, entries: [] },
|
||||
};
|
||||
const remote: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 2,
|
||||
pluginSidecars: { version: 1, entries: [entry] },
|
||||
};
|
||||
const result = mergeSyncPayloads(base, local, remote);
|
||||
assert.equal(
|
||||
result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key) ?? false,
|
||||
false,
|
||||
"local reset must not be resurrected from base/remote",
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(result.payload, "pluginSidecars"),
|
||||
"explicit empty sidecar field must remain so apply clears remote/local DB",
|
||||
);
|
||||
assert.deepEqual(result.payload.pluginSidecars, { version: 1, entries: [] });
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads preserves sidecars when remote legacy payload omits the field", () => {
|
||||
const entry = {
|
||||
pluginId: "com.example.p",
|
||||
kind: "settings" as const,
|
||||
key: "com.example.p.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const base: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 1,
|
||||
pluginSidecars: { version: 1, entries: [entry] },
|
||||
};
|
||||
const local: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 2,
|
||||
pluginSidecars: { version: 1, entries: [entry] },
|
||||
};
|
||||
const remote: SyncPayload = {
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
syncedAt: 2,
|
||||
// legacy remote omits pluginSidecars entirely
|
||||
};
|
||||
const result = mergeSyncPayloads(base, local, remote);
|
||||
assert.ok(result.payload.pluginSidecars?.entries?.some((e) => e.key === entry.key));
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads treats missing optional arrays as legacy payloads, not deletions", () => {
|
||||
const identity = {
|
||||
id: "identity-1",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
|
||||
import type { CloudSyncPayloadEntityKey, SyncPayload } from './sync';
|
||||
import { mergePluginSyncSidecarsThreeWay } from './pluginSyncSidecar';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
@@ -552,6 +553,28 @@ export function mergeSyncPayloads(
|
||||
|
||||
const groupConfigs = unwrapGC(groupConfigsResult.merged);
|
||||
|
||||
// Plugin sidecars: three-way merge so local resets propagate and baselines
|
||||
// from missing plugins are preserved. An *omitted* pluginSidecars field means
|
||||
// legacy/unsupported (treat as base for that side). Explicit { entries: [] }
|
||||
// is an authoritative wipe.
|
||||
const baseHasSidecars = Object.prototype.hasOwnProperty.call(b, 'pluginSidecars');
|
||||
const localHasSidecars = Object.prototype.hasOwnProperty.call(local, 'pluginSidecars');
|
||||
const remoteHasSidecars = Object.prototype.hasOwnProperty.call(remote, 'pluginSidecars');
|
||||
const baseSidecars = Array.isArray(b.pluginSidecars?.entries) ? b.pluginSidecars.entries : [];
|
||||
const localSidecars = localHasSidecars
|
||||
? (Array.isArray(local.pluginSidecars?.entries) ? local.pluginSidecars.entries : [])
|
||||
: baseSidecars;
|
||||
const remoteSidecars = remoteHasSidecars
|
||||
? (Array.isArray(remote.pluginSidecars?.entries) ? remote.pluginSidecars.entries : [])
|
||||
: baseSidecars;
|
||||
const mergedSidecarEntries = mergePluginSyncSidecarsThreeWay({
|
||||
base: baseSidecars,
|
||||
local: localSidecars,
|
||||
remote: remoteSidecars,
|
||||
});
|
||||
// Emit the field when any side explicitly carried it (including empty reset).
|
||||
const anySideHadPluginSidecars = baseHasSidecars || localHasSidecars || remoteHasSidecars;
|
||||
|
||||
const payload: SyncPayload = carryForwardSyncDeletions({
|
||||
hosts: hosts.merged,
|
||||
keys: keys.merged,
|
||||
@@ -565,6 +588,9 @@ export function mergeSyncPayloads(
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
groupConfigs,
|
||||
settings,
|
||||
...(mergedSidecarEntries.length > 0 || anySideHadPluginSidecars
|
||||
? { pluginSidecars: { version: 1 as const, entries: mergedSidecarEntries } }
|
||||
: {}),
|
||||
syncedAt: Date.now(),
|
||||
}, [local, remote]);
|
||||
|
||||
|
||||
@@ -188,7 +188,9 @@ function createBridgeRegistrar(context) {
|
||||
terminalProviderService: pluginHostService?.terminalProviderService,
|
||||
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
|
||||
extensionProviderService: pluginHostService?.extensionProviderService,
|
||||
syncSidecarService: pluginHostService?.syncSidecarService,
|
||||
credentialResolver: pluginHostService?.credentialResolver,
|
||||
secretStore: pluginHostService?.secretStore,
|
||||
getTerminalWorkerManager: () => terminalWorkerManager,
|
||||
selectImporterFile: async (event) => {
|
||||
const owner = electronModule.BrowserWindow.fromWebContents(event.sender);
|
||||
|
||||
@@ -452,6 +452,14 @@ class PluginContributionService {
|
||||
const normalizedScopeId = normalizeScopeId(setting.scope, scopeId);
|
||||
if (setting.secret) this.secretStore.delete(pluginId, secretSettingKey(setting.id, setting.scope, normalizedScopeId));
|
||||
else this.database.deleteSetting(pluginId, setting.id, setting.scope, normalizedScopeId);
|
||||
// Clear the matching cloud-sync sidecar so collect does not rehydrate the
|
||||
// retained remote value after an intentional local reset.
|
||||
if (typeof this.database.deleteSyncSidecar === "function" && !setting.secret) {
|
||||
const sidecarKey = `${setting.id}\0${setting.scope}\0${normalizedScopeId}`;
|
||||
try {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", sidecarKey);
|
||||
} catch {}
|
||||
}
|
||||
this.#emitChange("setting-reset", pluginId);
|
||||
try {
|
||||
await this.runtimeSupervisor.notify(pluginId, "plugin.settings.changed", {
|
||||
|
||||
@@ -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({
|
||||
@@ -19,6 +19,8 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
|
||||
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"],
|
||||
});
|
||||
|
||||
function parseJson(text, label) {
|
||||
@@ -152,7 +154,35 @@ class PluginDatabase {
|
||||
);
|
||||
CREATE INDEX plugin_security_audit_lookup
|
||||
ON plugin_security_audit(plugin_id, created_at DESC);
|
||||
PRAGMA user_version = 1;
|
||||
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);
|
||||
PRAGMA user_version = 2;
|
||||
`);
|
||||
});
|
||||
} else if (version === 1) {
|
||||
// Pre-sidecar schema-1 databases only created the original tables.
|
||||
// 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 (
|
||||
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);
|
||||
PRAGMA user_version = 2;
|
||||
`);
|
||||
});
|
||||
}
|
||||
@@ -455,16 +485,17 @@ class PluginDatabase {
|
||||
return row ? parseJson(row.value_json, "setting value") : undefined;
|
||||
}
|
||||
|
||||
setSetting(pluginId, settingId, scope, scopeId, value) {
|
||||
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, this.clock());
|
||||
`).run(pluginId, settingId, scope, scopeId, serialized, stamp);
|
||||
}
|
||||
|
||||
deleteSetting(pluginId, settingId, scope, scopeId) {
|
||||
@@ -488,6 +519,107 @@ class PluginDatabase {
|
||||
}));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -613,6 +745,20 @@ class PluginDatabase {
|
||||
.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;
|
||||
}
|
||||
|
||||
recordSecurityAudit(pluginId, event, details) {
|
||||
let detailsJson = JSON.stringify(details ?? {});
|
||||
const originalBytes = Buffer.byteLength(detailsJson);
|
||||
|
||||
@@ -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(
|
||||
@@ -93,8 +208,13 @@ test("initial schema scopes runtime and crash state to immutable plugin versions
|
||||
database.db.prepare("PRAGMA table_info(plugin_view_state)").all().map(({ name }) => name),
|
||||
["plugin_id", "view_id", "scope_id", "state_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
|
||||
["plugin_id", "kind", "key", "value_json", "updated_at"],
|
||||
);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_settings)").all(), []);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_view_state)").all(), []);
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,16 +14,18 @@ const { PluginRpcError, RPC_ERRORS, raceWithAbort } = require("./rpcRouter.cjs")
|
||||
const { compileRestrictedJsonSchema } = require("./restrictedJsonSchema.cjs");
|
||||
const pluginContractSchema = require("./generated/plugin-contract.schema.json");
|
||||
|
||||
const EXTENSION_PROVIDER_KINDS = Object.freeze(["connection", "authentication", "importer"]);
|
||||
const EXTENSION_PROVIDER_KINDS = Object.freeze(["connection", "authentication", "importer", "sync"]);
|
||||
const PROVIDER_PERMISSIONS = Object.freeze({
|
||||
connection: "provider.connection",
|
||||
authentication: "provider.authentication",
|
||||
importer: "provider.importer",
|
||||
sync: "provider.sync",
|
||||
});
|
||||
const OPERATIONS = Object.freeze({
|
||||
connection: new Set(["validateConfiguration", "probe", "open", "resize", "signal", "reconnect", "close", "getStatus"]),
|
||||
authentication: new Set(["begin", "respond", "cancel"]),
|
||||
importer: new Set(["detect", "parse"]),
|
||||
sync: new Set(["connect", "disconnect", "getAccount", "getCapabilities", "readObject", "writeObject", "deleteObject"]),
|
||||
});
|
||||
const MAX_PROVIDER_JSON_BYTES = 128 * 1024;
|
||||
const DEFAULT_DEADLINE_MS = 30_000;
|
||||
@@ -36,6 +38,36 @@ const MAX_IMPORT_BYTES = importerLimits.maxInputBytes;
|
||||
const MAX_IMPORT_OUTPUT_BYTES = importerLimits.maxOutputBytes;
|
||||
const MAX_IMPORT_RECORDS = importerLimits.maxRecords;
|
||||
const MAX_IMPORT_RECORD_BYTES = importerLimits.maxRecordBytes;
|
||||
const SYNC_SECRET_CONFIG_KEYS = new Set(["password", "token", "secret", "apiKey", "accessToken"]);
|
||||
|
||||
/** Drop known secret / writeOnly field names from required[] so stripped sync configs still validate. */
|
||||
function schemaWithoutSyncSecretRequirements(schema) {
|
||||
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
||||
if (!Array.isArray(schema.required)) return schema;
|
||||
const writeOnlyNames = new Set();
|
||||
if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
|
||||
for (const [name, child] of Object.entries(schema.properties)) {
|
||||
if (child && typeof child === "object" && !Array.isArray(child) && child.writeOnly === true) {
|
||||
writeOnlyNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const required = schema.required.filter(
|
||||
(name) => !SYNC_SECRET_CONFIG_KEYS.has(name) && !writeOnlyNames.has(name),
|
||||
);
|
||||
if (required.length === schema.required.length) return schema;
|
||||
return { ...schema, required };
|
||||
}
|
||||
const syncLimits = pluginContractSchema.$defs.SyncLimits.const;
|
||||
const MAX_SYNC_OBJECT_BYTES = syncLimits.maxObjectBytes;
|
||||
const MAX_SYNC_OBJECT_KEY_LENGTH = syncLimits.maxObjectKeyLength;
|
||||
const MAX_SYNC_REVISION_LENGTH = syncLimits.maxRevisionLength;
|
||||
const INLINE_SYNC_OBJECT_BYTES = syncLimits.inlineObjectBytes;
|
||||
// inlineObjectBytes is sized to fit under the 128 KiB provider JSON budget after
|
||||
// base64 expansion (~4/3) with envelope headroom. Keep the runtime cutoff equal
|
||||
// to the public SyncLimits constant so plugins that honor the schema are not
|
||||
// rejected with dataLoss in the 90–96 KiB window.
|
||||
const INLINE_SYNC_OBJECT_SAFE_BYTES = INLINE_SYNC_OBJECT_BYTES;
|
||||
const definitionValidators = Object.freeze({
|
||||
AuthenticationResult: createDefinitionValidator("AuthenticationResult"),
|
||||
ConnectionOpenResult: createDefinitionValidator("ConnectionOpenResult"),
|
||||
@@ -46,6 +78,13 @@ const definitionValidators = Object.freeze({
|
||||
ImporterDetectResult: createDefinitionValidator("ImporterDetectResult"),
|
||||
ImporterParseResult: createDefinitionValidator("ImporterParseResult"),
|
||||
ImporterRecord: createDefinitionValidator("ImporterRecord"),
|
||||
SyncConnectResult: createDefinitionValidator("SyncConnectResult"),
|
||||
SyncDisconnectResult: createDefinitionValidator("SyncDisconnectResult"),
|
||||
SyncGetAccountResult: createDefinitionValidator("SyncGetAccountResult"),
|
||||
SyncCapabilitiesResult: createDefinitionValidator("SyncCapabilitiesResult"),
|
||||
SyncReadObjectResult: createDefinitionValidator("SyncReadObjectResult"),
|
||||
SyncWriteObjectResult: createDefinitionValidator("SyncWriteObjectResult"),
|
||||
SyncDeleteObjectResult: createDefinitionValidator("SyncDeleteObjectResult"),
|
||||
});
|
||||
|
||||
function invalidArgument(message) {
|
||||
@@ -312,6 +351,96 @@ function normalizeImportRecord(value) {
|
||||
return freezeJson(value);
|
||||
}
|
||||
|
||||
function assertSyncObjectKey(value) {
|
||||
return assertString(value, "Sync object key", MAX_SYNC_OBJECT_KEY_LENGTH);
|
||||
}
|
||||
|
||||
function assertSyncRevision(value, label = "Sync object revision") {
|
||||
if (value == null) return value;
|
||||
return assertString(value, label, MAX_SYNC_REVISION_LENGTH);
|
||||
}
|
||||
|
||||
function assertSyncResult(operation, value) {
|
||||
if (operation === "disconnect") {
|
||||
assertDefinition("SyncDisconnectResult", value, "Sync disconnect result");
|
||||
if (value !== null) throw new TypeError("Sync disconnect result must be null");
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`Sync ${operation} result must be an object`);
|
||||
}
|
||||
if (operation === "connect") {
|
||||
assertDefinition("SyncConnectResult", value, "Sync connect result");
|
||||
assertString(value.account?.id, "Sync account ID", 512);
|
||||
} else if (operation === "getAccount") {
|
||||
assertDefinition("SyncGetAccountResult", value, "Sync getAccount result");
|
||||
if (value.account != null) assertString(value.account.id, "Sync account ID", 512);
|
||||
} else if (operation === "getCapabilities") {
|
||||
assertDefinition("SyncCapabilitiesResult", value, "Sync capabilities result");
|
||||
if (typeof value.revisions !== "boolean"
|
||||
|| typeof value.conditionalWrites !== "boolean"
|
||||
|| typeof value.atomicReplacement !== "boolean") {
|
||||
throw new TypeError("Sync capabilities result is invalid");
|
||||
}
|
||||
if (value.maxObjectBytes != null
|
||||
&& (!Number.isSafeInteger(value.maxObjectBytes)
|
||||
|| value.maxObjectBytes < 1
|
||||
|| value.maxObjectBytes > MAX_SYNC_OBJECT_BYTES)) {
|
||||
throw new TypeError("Sync maxObjectBytes is invalid");
|
||||
}
|
||||
} else if (operation === "readObject") {
|
||||
assertDefinition("SyncReadObjectResult", value, "Sync readObject result");
|
||||
if (value.found === true) {
|
||||
if (!Number.isSafeInteger(value.byteLength) || value.byteLength < 0 || value.byteLength > MAX_SYNC_OBJECT_BYTES) {
|
||||
throw new TypeError("Sync readObject byteLength is invalid");
|
||||
}
|
||||
if (value.streamed === true) {
|
||||
// Host streams the ciphertext separately.
|
||||
} else if (value.encoding === "base64") {
|
||||
if (typeof value.data !== "string") throw new TypeError("Sync readObject data is invalid");
|
||||
const decoded = Buffer.byteLength(value.data, "base64");
|
||||
// Use the safe inline cutoff (base64 + envelope must fit control-plane JSON).
|
||||
if (value.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES) {
|
||||
throw new TypeError("Sync readObject inline payload exceeds the inline limit");
|
||||
}
|
||||
if (decoded < value.byteLength) {
|
||||
throw new TypeError("Sync readObject data is shorter than byteLength");
|
||||
}
|
||||
} else {
|
||||
throw new TypeError("Sync readObject result encoding is invalid");
|
||||
}
|
||||
if (value.revision !== undefined) assertSyncRevision(value.revision);
|
||||
} else if (value.found !== false) {
|
||||
throw new TypeError("Sync readObject found flag is invalid");
|
||||
}
|
||||
} else if (operation === "writeObject") {
|
||||
assertDefinition("SyncWriteObjectResult", value, "Sync writeObject result");
|
||||
if (typeof value.created !== "boolean") throw new TypeError("Sync writeObject created flag is invalid");
|
||||
if (value.revision !== undefined) assertSyncRevision(value.revision);
|
||||
} else if (operation === "deleteObject") {
|
||||
assertDefinition("SyncDeleteObjectResult", value, "Sync deleteObject result");
|
||||
if (typeof value.deleted !== "boolean") throw new TypeError("Sync deleteObject deleted flag is invalid");
|
||||
} else {
|
||||
throw new TypeError(`Unsupported sync operation result: ${operation}`);
|
||||
}
|
||||
assertBoundedJson(value, `Sync ${operation} result`);
|
||||
return freezeJson(value);
|
||||
}
|
||||
|
||||
function decodeBase64Bytes(data, expectedLength, label) {
|
||||
if (typeof data !== "string") throw invalidArgument(`${label} data is invalid`);
|
||||
let bytes;
|
||||
try {
|
||||
bytes = Buffer.from(data, "base64");
|
||||
} catch {
|
||||
throw invalidArgument(`${label} data is not valid base64`);
|
||||
}
|
||||
if (bytes.byteLength !== expectedLength) {
|
||||
throw invalidArgument(`${label} byteLength does not match decoded data`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
class PluginExtensionProviderService {
|
||||
constructor(options) {
|
||||
if (!options?.contributionService || !options?.permissionEngine || !options?.runtimeSupervisor
|
||||
@@ -382,7 +511,10 @@ class PluginExtensionProviderService {
|
||||
const configuration = request.payload?.configuration;
|
||||
if (activation.provider.configurationSchema !== undefined && configuration !== undefined) {
|
||||
try {
|
||||
compileRestrictedJsonSchema(activation.provider.configurationSchema)(configuration);
|
||||
const schema = kind === "sync"
|
||||
? schemaWithoutSyncSecretRequirements(activation.provider.configurationSchema)
|
||||
: activation.provider.configurationSchema;
|
||||
compileRestrictedJsonSchema(schema)(configuration);
|
||||
} catch (error) {
|
||||
throw invalidArgument(`Provider configuration failed host schema validation: ${error?.message ?? error}`);
|
||||
}
|
||||
@@ -400,6 +532,7 @@ class PluginExtensionProviderService {
|
||||
if (kind === "connection") return assertConnectionResult(operation, result);
|
||||
if (kind === "authentication") return assertAuthenticationResult(result);
|
||||
if (kind === "importer" && operation === "detect") return assertImporterDetectResult(result);
|
||||
if (kind === "sync") return assertSyncResult(operation, result);
|
||||
return freezeJson(assertBoundedJson(result, "Extension Provider result"));
|
||||
} catch (error) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, `Extension Provider result failed validation: ${error?.message ?? error}`);
|
||||
@@ -904,6 +1037,310 @@ class PluginExtensionProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
async connectSync(params, options = {}) {
|
||||
const providerId = assertString(params?.providerId, "Sync Provider ID");
|
||||
const operationId = `sync:connect:${randomUUID()}`;
|
||||
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
|
||||
try {
|
||||
return await this.invoke({
|
||||
providerId,
|
||||
kind: "sync",
|
||||
operation: "connect",
|
||||
payload: {
|
||||
operationId,
|
||||
configuration: freezeJson(assertBoundedJson(
|
||||
params?.configuration === undefined ? {} : params.configuration,
|
||||
"Sync configuration",
|
||||
)),
|
||||
...(params.credential === undefined ? {} : { credential: params.credential }),
|
||||
},
|
||||
deadlineMs: params?.deadlineMs,
|
||||
}, { ...options, activation });
|
||||
} finally {
|
||||
this.leaseStore.revokeOperation(activation.identity.pluginId, operationId);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectSync(params, options = {}) {
|
||||
return this.invoke({
|
||||
providerId: assertString(params?.providerId, "Sync Provider ID"),
|
||||
kind: "sync",
|
||||
operation: "disconnect",
|
||||
payload: {
|
||||
operationId: `sync:disconnect:${randomUUID()}`,
|
||||
},
|
||||
deadlineMs: params?.deadlineMs,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async getSyncAccount(params, options = {}) {
|
||||
return this.invoke({
|
||||
providerId: assertString(params?.providerId, "Sync Provider ID"),
|
||||
kind: "sync",
|
||||
operation: "getAccount",
|
||||
payload: {
|
||||
operationId: `sync:getAccount:${randomUUID()}`,
|
||||
},
|
||||
deadlineMs: params?.deadlineMs,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async getSyncCapabilities(params, options = {}) {
|
||||
return this.invoke({
|
||||
providerId: assertString(params?.providerId, "Sync Provider ID"),
|
||||
kind: "sync",
|
||||
operation: "getCapabilities",
|
||||
payload: {
|
||||
operationId: `sync:getCapabilities:${randomUUID()}`,
|
||||
},
|
||||
deadlineMs: params?.deadlineMs,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async readSyncObject(params, options = {}) {
|
||||
const providerId = assertString(params?.providerId, "Sync Provider ID");
|
||||
const key = assertSyncObjectKey(params?.key);
|
||||
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
|
||||
const deadlineAt = performance.now() + deadlineMs;
|
||||
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
|
||||
const operationId = `sync:read:${randomUUID()}`;
|
||||
// Always offer an output stream so large encrypted objects can leave the
|
||||
// JSON control plane; small objects may still return inline base64.
|
||||
const outputStreamId = `${operationId}:output`;
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
let resolveOutputDone;
|
||||
let rejectOutputDone;
|
||||
let outputSettled = false;
|
||||
const outputDone = new Promise((resolve, reject) => {
|
||||
resolveOutputDone = (value) => {
|
||||
if (outputSettled) return;
|
||||
outputSettled = true;
|
||||
resolve(value);
|
||||
};
|
||||
rejectOutputDone = (error) => {
|
||||
if (outputSettled) return;
|
||||
outputSettled = true;
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
void outputDone.catch(() => {});
|
||||
|
||||
let acceptedStream = null;
|
||||
const expected = this.expectIncoming(activation.identity, outputStreamId, async (stream) => {
|
||||
acceptedStream = stream;
|
||||
stream.bind({
|
||||
onChunk: (chunk, release) => {
|
||||
try {
|
||||
if (chunk.encoding !== "binary") throw new Error("Sync object stream must be binary");
|
||||
totalBytes += chunk.bytes.byteLength;
|
||||
if (totalBytes > MAX_SYNC_OBJECT_BYTES) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Sync object exceeds its size limit");
|
||||
}
|
||||
chunks.push(Buffer.from(chunk.bytes));
|
||||
release();
|
||||
} catch (error) {
|
||||
rejectOutputDone(error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onClose: (reason) => {
|
||||
try {
|
||||
if (reason !== "end") {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Sync object stream did not end normally");
|
||||
}
|
||||
resolveOutputDone(Buffer.concat(chunks));
|
||||
} catch (error) {
|
||||
rejectOutputDone(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}, options.signal, deadlineMs);
|
||||
|
||||
const request = this.invoke({
|
||||
providerId,
|
||||
kind: "sync",
|
||||
operation: "readObject",
|
||||
payload: {
|
||||
key,
|
||||
operationId,
|
||||
outputStreamId,
|
||||
windowBytes: STREAM_WINDOW_BYTES,
|
||||
},
|
||||
deadlineMs,
|
||||
}, { ...options, activation });
|
||||
|
||||
const cancelAcceptedStream = (error) => {
|
||||
try {
|
||||
if (acceptedStream && typeof acceptedStream.cancel === "function") {
|
||||
acceptedStream.cancel(error);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort containment after timeout/failure.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await waitUntilDeadline(
|
||||
request,
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync readObject exceeded its deadline",
|
||||
);
|
||||
if (result.found !== true) {
|
||||
// Swallow the cancelled expectation; inline/not-found paths intentionally
|
||||
// do not consume the pre-opened output stream.
|
||||
void expected.promise.catch(() => {});
|
||||
expected.cancel(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object not found"));
|
||||
cancelAcceptedStream(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object not found"));
|
||||
return Object.freeze({ found: false, key, bytes: null, revision: undefined });
|
||||
}
|
||||
if (result.streamed === true) {
|
||||
await waitUntilDeadline(
|
||||
waitForStreamOrRequestFailure(expected.promise, request),
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync readObject stream exceeded its deadline",
|
||||
);
|
||||
const bytes = await waitUntilDeadline(
|
||||
outputDone,
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync readObject stream exceeded its deadline",
|
||||
);
|
||||
if (bytes.byteLength !== result.byteLength) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Sync readObject stream size does not match byteLength");
|
||||
}
|
||||
// Bytes are mutable buffers; freeze only the metadata envelope.
|
||||
return Object.freeze({
|
||||
found: true,
|
||||
key,
|
||||
bytes: new Uint8Array(bytes),
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
});
|
||||
}
|
||||
// Inline base64 path — cancel unused stream expectation.
|
||||
void expected.promise.catch(() => {});
|
||||
expected.cancel(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object returned inline"));
|
||||
cancelAcceptedStream(new PluginRpcError(RPC_ERRORS.cancelled, "Sync object returned inline"));
|
||||
const bytes = decodeBase64Bytes(result.data, result.byteLength, "Sync readObject");
|
||||
return Object.freeze({
|
||||
found: true,
|
||||
key,
|
||||
bytes: new Uint8Array(bytes),
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
});
|
||||
} catch (error) {
|
||||
void expected?.promise?.catch?.(() => {});
|
||||
expected?.cancel?.(error);
|
||||
cancelAcceptedStream(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async writeSyncObject(params, options = {}) {
|
||||
const providerId = assertString(params?.providerId, "Sync Provider ID");
|
||||
const key = assertSyncObjectKey(params?.key);
|
||||
const rawBytes = params?.bytes instanceof Uint8Array
|
||||
? params.bytes
|
||||
: params?.bytes == null
|
||||
? null
|
||||
: new Uint8Array(params.bytes);
|
||||
if (!rawBytes) throw invalidArgument("Sync writeObject bytes are required");
|
||||
if (rawBytes.byteLength > MAX_SYNC_OBJECT_BYTES) {
|
||||
throw invalidArgument("Sync writeObject payload exceeds the size limit");
|
||||
}
|
||||
const deadlineMs = normalizeDeadlineMs(params?.deadlineMs);
|
||||
const deadlineAt = performance.now() + deadlineMs;
|
||||
const activation = options.activation ?? await this.activate(providerId, "sync", options.signal);
|
||||
const operationId = `sync:write:${randomUUID()}`;
|
||||
const useStream = rawBytes.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES || params?.preferStream === true;
|
||||
const inputStreamId = useStream ? `${operationId}:input` : undefined;
|
||||
const expectedRevision = params?.expectedRevision === undefined
|
||||
? undefined
|
||||
: params.expectedRevision === null
|
||||
? null
|
||||
: assertSyncRevision(params.expectedRevision, "Sync expectedRevision");
|
||||
|
||||
const request = this.invoke({
|
||||
providerId,
|
||||
kind: "sync",
|
||||
operation: "writeObject",
|
||||
payload: {
|
||||
key,
|
||||
operationId,
|
||||
byteLength: rawBytes.byteLength,
|
||||
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
||||
...(useStream
|
||||
? { inputStreamId, windowBytes: STREAM_WINDOW_BYTES }
|
||||
: {
|
||||
encoding: "base64",
|
||||
data: Buffer.from(rawBytes).toString("base64"),
|
||||
}),
|
||||
},
|
||||
deadlineMs,
|
||||
}, { ...options, activation });
|
||||
|
||||
let input;
|
||||
try {
|
||||
if (useStream) {
|
||||
input = await waitUntilDeadline(
|
||||
waitForStreamOrRequestFailure(
|
||||
this.runtimeSupervisor.openStream(activation.activation.plugin.id, inputStreamId, STREAM_WINDOW_BYTES, {
|
||||
expectedIdentity: activation.identity,
|
||||
}),
|
||||
request,
|
||||
),
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync writeObject input stream exceeded its deadline",
|
||||
);
|
||||
for (let offset = 0; offset < rawBytes.byteLength; offset += STREAM_WINDOW_BYTES) {
|
||||
await waitUntilDeadline(
|
||||
input.write(rawBytes.subarray(offset, Math.min(rawBytes.byteLength, offset + STREAM_WINDOW_BYTES))),
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync writeObject input stream exceeded its deadline",
|
||||
);
|
||||
}
|
||||
await waitUntilDeadline(
|
||||
input.end(),
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync writeObject input stream exceeded its deadline",
|
||||
);
|
||||
}
|
||||
return await waitUntilDeadline(
|
||||
request,
|
||||
deadlineAt,
|
||||
options.signal,
|
||||
"Sync writeObject exceeded its deadline",
|
||||
);
|
||||
} catch (error) {
|
||||
try { input?.cancel?.(); } catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSyncObject(params, options = {}) {
|
||||
return this.invoke({
|
||||
providerId: assertString(params?.providerId, "Sync Provider ID"),
|
||||
kind: "sync",
|
||||
operation: "deleteObject",
|
||||
payload: {
|
||||
key: assertSyncObjectKey(params?.key),
|
||||
operationId: `sync:delete:${randomUUID()}`,
|
||||
...(params?.expectedRevision === undefined
|
||||
? {}
|
||||
: { expectedRevision: assertSyncRevision(params.expectedRevision, "Sync expectedRevision") }),
|
||||
},
|
||||
deadlineMs: params?.deadlineMs,
|
||||
}, options);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.streamRegistration?.dispose();
|
||||
this.runtimeRegistration?.dispose();
|
||||
@@ -917,9 +1354,13 @@ class PluginExtensionProviderService {
|
||||
module.exports = {
|
||||
DEFAULT_DEADLINE_MS,
|
||||
EXTENSION_PROVIDER_KINDS,
|
||||
INLINE_SYNC_OBJECT_BYTES,
|
||||
INLINE_SYNC_OBJECT_SAFE_BYTES,
|
||||
MAX_IMPORT_BYTES,
|
||||
MAX_IMPORT_RECORDS,
|
||||
MAX_SYNC_OBJECT_BYTES,
|
||||
PluginExtensionProviderService,
|
||||
STREAM_WINDOW_BYTES,
|
||||
assertSyncResult,
|
||||
normalizeImportRecord,
|
||||
};
|
||||
@@ -26,6 +26,13 @@ function fixture(options = {}) {
|
||||
},
|
||||
{ id: "com.example.transport.auth", kind: "authentication" },
|
||||
{ id: "com.example.transport.importer", kind: "importer" },
|
||||
{
|
||||
id: "com.example.transport.sync",
|
||||
kind: "sync",
|
||||
...(options.syncConfigurationSchema === undefined
|
||||
? {}
|
||||
: { configurationSchema: options.syncConfigurationSchema }),
|
||||
},
|
||||
];
|
||||
const contributionService = {
|
||||
listProviders({ kind }) {
|
||||
@@ -53,7 +60,13 @@ function fixture(options = {}) {
|
||||
};
|
||||
const permissions = [];
|
||||
const permissionEngine = {
|
||||
async authorize(context, descriptor) { permissions.push({ context, descriptor }); return { scope: "application" }; },
|
||||
async authorize(context, descriptor) {
|
||||
if (typeof options.authorize === "function") {
|
||||
return options.authorize(context, descriptor, permissions);
|
||||
}
|
||||
permissions.push({ context, descriptor });
|
||||
return { scope: "application" };
|
||||
},
|
||||
};
|
||||
const streamHandlers = [];
|
||||
const rpcRegistry = {
|
||||
@@ -920,3 +933,152 @@ test("import parsing enforces its deadline while the selected input source stall
|
||||
assert.equal(sourceReturned, true);
|
||||
assert.equal(h.revokedOperations.length, 1);
|
||||
});
|
||||
|
||||
test("sync providers require provider.sync and only exchange encrypted object bytes", async () => {
|
||||
const cipher = Buffer.from([0x9b, 0x01, 0x02, 0x03]);
|
||||
let writePayload;
|
||||
const h = fixture({
|
||||
async request({ params }) {
|
||||
assert.equal(params.kind, "sync");
|
||||
if (params.operation === "connect") {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: { account: { id: "acct-1", name: "Plugin Sync" } },
|
||||
};
|
||||
}
|
||||
if (params.operation === "getCapabilities") {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
maxObjectBytes: 1024,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.operation === "readObject") {
|
||||
assert.equal(params.payload.key, "netcatty-vault.json");
|
||||
assert.equal(typeof params.payload.outputStreamId, "string");
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: {
|
||||
found: true,
|
||||
byteLength: cipher.byteLength,
|
||||
encoding: "base64",
|
||||
data: cipher.toString("base64"),
|
||||
revision: "rev-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (params.operation === "writeObject") {
|
||||
writePayload = params.payload;
|
||||
assert.equal(params.payload.key, "netcatty-vault.json");
|
||||
assert.equal(params.payload.encoding, "base64");
|
||||
assert.equal(Buffer.from(params.payload.data, "base64").equals(cipher), true);
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: { created: true, revision: "rev-2" },
|
||||
};
|
||||
}
|
||||
if (params.operation === "deleteObject") {
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: { deleted: true },
|
||||
};
|
||||
}
|
||||
if (params.operation === "disconnect") {
|
||||
return { requestId: params.requestId, status: "ok", result: null };
|
||||
}
|
||||
throw new Error(`unexpected operation ${params.operation}`);
|
||||
},
|
||||
});
|
||||
|
||||
const connected = await h.service.connectSync({
|
||||
providerId: "com.example.transport.sync",
|
||||
configuration: { endpoint: "https://example.test" },
|
||||
});
|
||||
assert.equal(connected.account.id, "acct-1");
|
||||
assert.equal(h.permissions[0].descriptor.permission, "provider.sync");
|
||||
|
||||
const caps = await h.service.getSyncCapabilities({
|
||||
providerId: "com.example.transport.sync",
|
||||
});
|
||||
assert.equal(caps.conditionalWrites, true);
|
||||
|
||||
const read = await h.service.readSyncObject({
|
||||
providerId: "com.example.transport.sync",
|
||||
key: "netcatty-vault.json",
|
||||
});
|
||||
assert.equal(read.found, true);
|
||||
assert.equal(Buffer.from(read.bytes).equals(cipher), true);
|
||||
assert.equal(read.revision, "rev-1");
|
||||
|
||||
const written = await h.service.writeSyncObject({
|
||||
providerId: "com.example.transport.sync",
|
||||
key: "netcatty-vault.json",
|
||||
bytes: cipher,
|
||||
});
|
||||
assert.equal(written.created, true);
|
||||
assert.equal(written.revision, "rev-2");
|
||||
assert.equal(writePayload.byteLength, cipher.byteLength);
|
||||
|
||||
const deleted = await h.service.deleteSyncObject({
|
||||
providerId: "com.example.transport.sync",
|
||||
key: "netcatty-vault.json",
|
||||
});
|
||||
assert.equal(deleted.deleted, true);
|
||||
|
||||
const disconnected = await h.service.disconnectSync({
|
||||
providerId: "com.example.transport.sync",
|
||||
});
|
||||
assert.equal(disconnected, null);
|
||||
});
|
||||
|
||||
test("sync providers fail closed without provider.sync permission", async () => {
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const h = fixture({
|
||||
async request() {
|
||||
throw new Error("plugin should not be invoked");
|
||||
},
|
||||
async authorize() {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "missing provider.sync");
|
||||
},
|
||||
});
|
||||
await assert.rejects(h.service.connectSync({
|
||||
providerId: "com.example.transport.sync",
|
||||
configuration: {},
|
||||
}), /provider\.sync|permission/i);
|
||||
});
|
||||
|
||||
test("sync writeObject streams large encrypted objects outside the JSON budget", async () => {
|
||||
const { INLINE_SYNC_OBJECT_BYTES } = require("./extensionProviderService.cjs");
|
||||
const large = Buffer.alloc(INLINE_SYNC_OBJECT_BYTES + 16, 0xab);
|
||||
let sawStream = false;
|
||||
const h = fixture({
|
||||
async request({ params }) {
|
||||
assert.equal(params.operation, "writeObject");
|
||||
assert.equal(typeof params.payload.inputStreamId, "string");
|
||||
assert.equal(params.payload.data, undefined);
|
||||
sawStream = true;
|
||||
return {
|
||||
requestId: params.requestId,
|
||||
status: "ok",
|
||||
result: { created: false, revision: "stream-rev" },
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await h.service.writeSyncObject({
|
||||
providerId: "com.example.transport.sync",
|
||||
key: "netcatty-vault.json",
|
||||
bytes: large,
|
||||
});
|
||||
assert.equal(sawStream, true);
|
||||
assert.equal(result.revision, "stream-rev");
|
||||
assert.ok(h.writes.some((entry) => entry[1] === "end"));
|
||||
});
|
||||
@@ -96,6 +96,15 @@
|
||||
"maxRecords": 10000
|
||||
}
|
||||
},
|
||||
"SyncLimits": {
|
||||
"description": "Host-enforced byte and identifier limits for one encrypted sync-object operation. inlineObjectBytes is the maximum ciphertext size that may travel inline on the control plane (below the 128 KiB provider JSON budget after base64 expansion).",
|
||||
"const": {
|
||||
"maxObjectBytes": 67108864,
|
||||
"maxObjectKeyLength": 1024,
|
||||
"maxRevisionLength": 256,
|
||||
"inlineObjectBytes": 92160
|
||||
}
|
||||
},
|
||||
"StreamId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
@@ -2380,6 +2389,376 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SyncObjectKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"SyncObjectRevision": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"SyncAccount": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 512
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncConnectPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"configuration",
|
||||
"operationId"
|
||||
],
|
||||
"properties": {
|
||||
"configuration": {
|
||||
"$ref": "#/$defs/JsonValue"
|
||||
},
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"credential": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/SecretRef"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/CredentialRef"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/SecretLeaseRef"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncConnectResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"account"
|
||||
],
|
||||
"properties": {
|
||||
"account": {
|
||||
"$ref": "#/$defs/SyncAccount"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDisconnectPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDisconnectResult": {
|
||||
"type": "null"
|
||||
},
|
||||
"SyncGetAccountPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncGetAccountResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"account"
|
||||
],
|
||||
"properties": {
|
||||
"account": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/SyncAccount"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncGetCapabilitiesPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncCapabilitiesResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"revisions",
|
||||
"conditionalWrites",
|
||||
"atomicReplacement"
|
||||
],
|
||||
"properties": {
|
||||
"revisions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"conditionalWrites": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"atomicReplacement": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxObjectBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 67108864
|
||||
},
|
||||
"maxObjects": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncReadObjectPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"operationId"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/$defs/SyncObjectKey"
|
||||
},
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"outputStreamId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"windowBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1024,
|
||||
"maximum": 16777216
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncReadObjectResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found"
|
||||
],
|
||||
"properties": {
|
||||
"found": {
|
||||
"const": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found",
|
||||
"byteLength",
|
||||
"encoding",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"found": {
|
||||
"const": true
|
||||
},
|
||||
"byteLength": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 67108864
|
||||
},
|
||||
"encoding": {
|
||||
"const": "base64"
|
||||
},
|
||||
"data": {
|
||||
"type": "string",
|
||||
"maxLength": 9007199254740991
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/SyncObjectRevision"
|
||||
},
|
||||
"contentType": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"found",
|
||||
"byteLength",
|
||||
"streamed"
|
||||
],
|
||||
"properties": {
|
||||
"found": {
|
||||
"const": true
|
||||
},
|
||||
"byteLength": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 67108864
|
||||
},
|
||||
"streamed": {
|
||||
"const": true
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/SyncObjectRevision"
|
||||
},
|
||||
"contentType": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"SyncWriteObjectPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"operationId",
|
||||
"byteLength"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/$defs/SyncObjectKey"
|
||||
},
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"byteLength": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 67108864
|
||||
},
|
||||
"expectedRevision": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/SyncObjectRevision"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"encoding": {
|
||||
"const": "base64"
|
||||
},
|
||||
"data": {
|
||||
"type": "string",
|
||||
"maxLength": 9007199254740991
|
||||
},
|
||||
"inputStreamId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"windowBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1024,
|
||||
"maximum": 16777216
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncWriteObjectResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"created"
|
||||
],
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"revision": {
|
||||
"$ref": "#/$defs/SyncObjectRevision"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDeleteObjectPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"operationId"
|
||||
],
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/$defs/SyncObjectKey"
|
||||
},
|
||||
"operationId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"expectedRevision": {
|
||||
"$ref": "#/$defs/SyncObjectRevision"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDeleteObjectResult": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"deleted"
|
||||
],
|
||||
"properties": {
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CompanionPlatform": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -35,6 +35,7 @@ const { SecretLeaseStore } = require("./secretLease.cjs");
|
||||
const { PluginTerminalProviderService } = require("./terminalProviderService.cjs");
|
||||
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
|
||||
const { PluginExtensionProviderService } = require("./extensionProviderService.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
|
||||
function getElectronProcessMetrics(app, pid) {
|
||||
const metric = app.getAppMetrics?.().find((candidate) => candidate.pid === pid);
|
||||
@@ -213,6 +214,35 @@ function createPluginHostService(options) {
|
||||
rpcRegistry,
|
||||
runtimeSupervisor,
|
||||
});
|
||||
const syncSidecarService = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService,
|
||||
});
|
||||
// Replay retained cloud sidecars into plugin_settings before the plugin
|
||||
// runtime starts so onStartupFinished sees hydrated values.
|
||||
const originalOnPluginEnabled = contributionService.onPluginEnabled.bind(contributionService);
|
||||
contributionService.onPluginEnabled = async (pluginId) => {
|
||||
try {
|
||||
await syncSidecarService.hydrateInstalledPluginSettings(pluginId);
|
||||
} catch {
|
||||
// Hydration is best-effort; enable must still succeed.
|
||||
}
|
||||
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,
|
||||
@@ -260,6 +290,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
filesystemBroker,
|
||||
extensionProviderService,
|
||||
syncSidecarService,
|
||||
leaseStore,
|
||||
manager,
|
||||
moduleResources,
|
||||
|
||||
@@ -166,7 +166,7 @@ class PluginNetworkBroker {
|
||||
|
||||
validate(params) {
|
||||
const request = assertNetworkRequest(params);
|
||||
return {
|
||||
const validated = {
|
||||
url: request.url.href,
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
@@ -175,6 +175,43 @@ class PluginNetworkBroker {
|
||||
}),
|
||||
timeoutMs: request.timeoutMs,
|
||||
};
|
||||
// Preserve host-consumed credential fields. assertNetworkRequest ignores
|
||||
// unknown keys; without this, registry validateParams would strip them and
|
||||
// network.request lease injection would become dead code.
|
||||
if (params?.credentialLease !== undefined) {
|
||||
const lease = params.credentialLease;
|
||||
if (
|
||||
!lease
|
||||
|| typeof lease !== "object"
|
||||
|| Array.isArray(lease)
|
||||
|| lease.kind !== "secret-lease"
|
||||
|| typeof lease.id !== "string"
|
||||
|| typeof lease.operationId !== "string"
|
||||
|| !Number.isFinite(lease.expiresAt)
|
||||
) {
|
||||
throw invalidArgument("Plugin network credential lease is invalid");
|
||||
}
|
||||
validated.credentialLease = Object.freeze({
|
||||
kind: "secret-lease",
|
||||
id: lease.id,
|
||||
operationId: lease.operationId,
|
||||
expiresAt: lease.expiresAt,
|
||||
});
|
||||
}
|
||||
if (params?.authorization !== undefined) {
|
||||
const authorization = params.authorization;
|
||||
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
|
||||
throw invalidArgument("Plugin network authorization is invalid");
|
||||
}
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
validated.authorization = Object.freeze({
|
||||
scheme,
|
||||
...(typeof authorization.username === "string"
|
||||
? { username: authorization.username }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
describeAuthorization(params) {
|
||||
|
||||
@@ -5,6 +5,12 @@ const { randomUUID } = require("node:crypto");
|
||||
|
||||
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
|
||||
const { raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
INLINE_SYNC_OBJECT_SAFE_BYTES,
|
||||
MAX_SYNC_OBJECT_BYTES,
|
||||
STREAM_WINDOW_BYTES,
|
||||
} = require("./extensionProviderService.cjs");
|
||||
const { assertSecretKey } = require("./secretStore.cjs");
|
||||
|
||||
const CHANNELS = Object.freeze({
|
||||
status: "netcatty:plugins:status",
|
||||
@@ -27,6 +33,22 @@ const CHANNELS = Object.freeze({
|
||||
extensionProviders: "netcatty:plugins:extension-providers",
|
||||
extensionInvoke: "netcatty:plugins:extension-invoke",
|
||||
extensionCancel: "netcatty:plugins:extension-cancel",
|
||||
syncConnect: "netcatty:plugins:sync-connect",
|
||||
syncDisconnect: "netcatty:plugins:sync-disconnect",
|
||||
syncGetAccount: "netcatty:plugins:sync-get-account",
|
||||
syncGetCapabilities: "netcatty:plugins:sync-get-capabilities",
|
||||
syncReadObject: "netcatty:plugins:sync-read-object",
|
||||
syncReadChunk: "netcatty:plugins:sync-read-chunk",
|
||||
syncWriteObject: "netcatty:plugins:sync-write-object",
|
||||
syncWriteBegin: "netcatty:plugins:sync-write-begin",
|
||||
syncWriteChunk: "netcatty:plugins:sync-write-chunk",
|
||||
syncWriteCommit: "netcatty:plugins:sync-write-commit",
|
||||
syncDeleteObject: "netcatty:plugins:sync-delete-object",
|
||||
syncPutSecret: "netcatty:plugins:sync-put-secret",
|
||||
syncDeleteSecrets: "netcatty:plugins:sync-delete-secrets",
|
||||
syncSidecarsCollect: "netcatty:plugins:sync-sidecars-collect",
|
||||
syncSidecarsApply: "netcatty:plugins:sync-sidecars-apply",
|
||||
hostAvailableSync: "netcatty:plugins:host-available-sync",
|
||||
connectionStart: "netcatty:plugins:connection-start",
|
||||
connectionWrite: "netcatty:plugins:connection-write",
|
||||
connectionControl: "netcatty:plugins:connection-control",
|
||||
@@ -54,6 +76,8 @@ const CHANNELS = Object.freeze({
|
||||
|
||||
const SCOPE_KINDS = Object.freeze(["workspace", "host", "session", "device"]);
|
||||
const MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER = 64;
|
||||
const MAX_SYNC_TRANSFERS_PER_SENDER = 8;
|
||||
const SYNC_TRANSFER_TTL_MS = 5 * 60_000;
|
||||
const MAX_IMPORT_FILE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_IMPORT_SELECTIONS_PER_SENDER = 8;
|
||||
const IMPORT_SELECTION_TTL_MS = 5 * 60_000;
|
||||
@@ -173,7 +197,9 @@ function registerPluginBridge(ipcMain, options) {
|
||||
const terminalProviderService = options.terminalProviderService;
|
||||
const terminalDataPipelineService = options.terminalDataPipelineService;
|
||||
const extensionProviderService = options.extensionProviderService;
|
||||
const syncSidecarService = options.syncSidecarService;
|
||||
const credentialResolver = options.credentialResolver;
|
||||
const secretStore = options.secretStore;
|
||||
const connectionStatusPollMs = Number.isSafeInteger(options.connectionStatusPollMs)
|
||||
&& options.connectionStatusPollMs >= 0
|
||||
&& options.connectionStatusPollMs <= 60_000
|
||||
@@ -199,6 +225,8 @@ function registerPluginBridge(ipcMain, options) {
|
||||
const observedAuthenticationSenders = new WeakSet();
|
||||
const importerSelectionsBySender = new WeakMap();
|
||||
const observedImporterSelectionSenders = new WeakSet();
|
||||
const syncTransfersBySender = new WeakMap();
|
||||
const observedSyncTransferSenders = new WeakSet();
|
||||
const scopeCatalogSenderKey = (event) => {
|
||||
const id = event?.sender?.id;
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : "default";
|
||||
@@ -531,6 +559,80 @@ function registerPluginBridge(ipcMain, options) {
|
||||
}
|
||||
return selections;
|
||||
};
|
||||
const syncTransferMap = (sender) => {
|
||||
if (!sender || typeof sender !== "object") throw new Error("Plugin sync transfer sender is unavailable");
|
||||
let transfers = syncTransfersBySender.get(sender);
|
||||
if (!transfers) {
|
||||
transfers = new Map();
|
||||
syncTransfersBySender.set(sender, transfers);
|
||||
}
|
||||
if (!observedSyncTransferSenders.has(sender)) {
|
||||
observedSyncTransferSenders.add(sender);
|
||||
sender.once?.("destroyed", () => {
|
||||
for (const [transferId, transfer] of [...transfers.entries()]) {
|
||||
try {
|
||||
transfer.controller?.abort();
|
||||
if (transfer.requestId) {
|
||||
extensionRequestMap(sender).delete(transfer.requestId);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
transfers.delete(transferId);
|
||||
}
|
||||
});
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [transferId, transfer] of [...transfers.entries()]) {
|
||||
if (typeof transfer.expiresAt === "number" && transfer.expiresAt <= now) {
|
||||
try {
|
||||
transfer.controller?.abort();
|
||||
if (transfer.requestId) extensionRequestMap(sender).delete(transfer.requestId);
|
||||
} catch { /* ignore */ }
|
||||
transfers.delete(transferId);
|
||||
}
|
||||
}
|
||||
return transfers;
|
||||
};
|
||||
const releaseSyncTransfer = (sender, transferId, transfer) => {
|
||||
const transfers = syncTransfersBySender.get(sender);
|
||||
if (transfers && transferId && transfers.get(transferId) === transfer) {
|
||||
transfers.delete(transferId);
|
||||
}
|
||||
if (transfer?.requestId) {
|
||||
const requests = extensionRequestMap(sender);
|
||||
if (requests.get(transfer.requestId) === transfer.controller || transfer.controller == null) {
|
||||
requests.delete(transfer.requestId);
|
||||
}
|
||||
}
|
||||
};
|
||||
const assertSyncTransferBudget = (transfers) => {
|
||||
if (transfers.size >= MAX_SYNC_TRANSFERS_PER_SENDER) {
|
||||
throw new Error("Too many active Plugin sync transfers");
|
||||
}
|
||||
};
|
||||
const coerceIpcBytes = (value, label) => {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
if (value && typeof value === "object" && value.type === "Buffer" && Array.isArray(value.data)) {
|
||||
return Buffer.from(value.data);
|
||||
}
|
||||
throw new TypeError(`${label} must be binary bytes`);
|
||||
};
|
||||
const registerStreamingWriteRequest = (event, requestId) => {
|
||||
if (typeof requestId !== "string" || requestId.length < 1 || requestId.length > 128 || requestId.includes("\0")) {
|
||||
throw new TypeError("Plugin extension request ID is invalid");
|
||||
}
|
||||
const requests = extensionRequestMap(event.sender);
|
||||
if (requests.has(requestId)) throw new Error("Plugin extension request ID is already active");
|
||||
if (requests.size >= MAX_ACTIVE_TERMINAL_REQUESTS_PER_SENDER) {
|
||||
throw new Error("Too many active Plugin extension requests");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
requests.set(requestId, controller);
|
||||
return controller;
|
||||
};
|
||||
ipcMain.handle(CHANNELS.status, async (event) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
let available = false;
|
||||
@@ -641,6 +743,368 @@ function registerPluginBridge(ipcMain, options) {
|
||||
if (!extensionProviderService) throw new Error("Plugin extension Providers are unavailable");
|
||||
return extensionProviderService.listProviders(payload ?? {});
|
||||
});
|
||||
|
||||
// Synchronous readiness probe for renderer restore paths (no await).
|
||||
ipcMain.on(CHANNELS.hostAvailableSync, (event) => {
|
||||
event.returnValue = Boolean(syncSidecarService);
|
||||
});
|
||||
|
||||
// Fallback null = host gated off / manager unavailable. Renderer must treat
|
||||
// null as "unavailable" (last-known) rather than an authoritative empty set.
|
||||
handlePassive(CHANNELS.syncSidecarsCollect, null, async () => {
|
||||
if (!syncSidecarService) return null;
|
||||
return syncSidecarService.collectForSync();
|
||||
});
|
||||
|
||||
handlePassive(CHANNELS.syncSidecarsApply, null, async (_activeManager, payload) => {
|
||||
if (!syncSidecarService) return { applied: false };
|
||||
const entries = await syncSidecarService.applyFromSync(payload ?? { version: 1, entries: [] });
|
||||
// Include merged entries so the renderer can refresh last-known without a
|
||||
// follow-up collect (preserves missing-plugin rows remote omitted).
|
||||
return { applied: true, count: entries.length, entries };
|
||||
});
|
||||
|
||||
const runSyncProvider = (channel, methodName) => {
|
||||
ipcMain.handle(channel, async (event, payload) => runExtensionRequest(event, payload, async (signal) => {
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const method = extensionProviderService[methodName];
|
||||
if (typeof method !== "function") throw new Error(`Plugin sync method missing: ${methodName}`);
|
||||
return method.call(extensionProviderService, payload ?? {}, { signal });
|
||||
}));
|
||||
};
|
||||
|
||||
runSyncProvider(CHANNELS.syncConnect, "connectSync");
|
||||
runSyncProvider(CHANNELS.syncDisconnect, "disconnectSync");
|
||||
runSyncProvider(CHANNELS.syncGetAccount, "getSyncAccount");
|
||||
runSyncProvider(CHANNELS.syncGetCapabilities, "getSyncCapabilities");
|
||||
runSyncProvider(CHANNELS.syncDeleteObject, "deleteSyncObject");
|
||||
|
||||
ipcMain.handle(CHANNELS.syncReadObject, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const requestId = payload?.requestId;
|
||||
const controller = registerStreamingWriteRequest(event, requestId);
|
||||
try {
|
||||
await raceWithAbort(resolveManager(), controller.signal);
|
||||
const result = await extensionProviderService.readSyncObject(payload ?? {}, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!result?.found || !result.bytes) {
|
||||
extensionRequestMap(event.sender).delete(requestId);
|
||||
return { found: false, key: payload?.key, data: null };
|
||||
}
|
||||
const bytes = result.bytes instanceof Uint8Array
|
||||
? result.bytes
|
||||
: new Uint8Array(result.bytes);
|
||||
const preferStream = payload?.preferStream === true;
|
||||
if (!preferStream && bytes.byteLength <= INLINE_SYNC_OBJECT_SAFE_BYTES) {
|
||||
extensionRequestMap(event.sender).delete(requestId);
|
||||
return {
|
||||
found: true,
|
||||
key: result.key,
|
||||
data: bytes,
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
}
|
||||
const transfers = syncTransferMap(event.sender);
|
||||
assertSyncTransferBudget(transfers);
|
||||
const transferId = randomUUID();
|
||||
const transfer = {
|
||||
kind: "read",
|
||||
sender: event.sender,
|
||||
requestId,
|
||||
controller,
|
||||
bytes,
|
||||
offset: 0,
|
||||
expiresAt: Date.now() + SYNC_TRANSFER_TTL_MS,
|
||||
};
|
||||
transfers.set(transferId, transfer);
|
||||
const onAbort = () => releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
transfer.onAbort = onAbort;
|
||||
return {
|
||||
found: true,
|
||||
key: result.key,
|
||||
streamed: true,
|
||||
transferId,
|
||||
byteLength: bytes.byteLength,
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
} catch (error) {
|
||||
extensionRequestMap(event.sender).delete(requestId);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncReadChunk, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
const transferId = payload?.transferId;
|
||||
if (typeof transferId !== "string" || transferId.length < 1) {
|
||||
throw new TypeError("Sync read transfer ID is invalid");
|
||||
}
|
||||
const transfers = syncTransferMap(event.sender);
|
||||
const transfer = transfers.get(transferId);
|
||||
if (!transfer || transfer.kind !== "read") {
|
||||
throw new Error("Sync read transfer is not owned by this window");
|
||||
}
|
||||
if (payload?.requestId !== transfer.requestId) {
|
||||
throw new Error("Sync read transfer request ID mismatch");
|
||||
}
|
||||
if (transfer.controller?.signal?.aborted) {
|
||||
releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
transfer.expiresAt = Date.now() + SYNC_TRANSFER_TTL_MS;
|
||||
const maxBytes = Number.isSafeInteger(payload?.maxBytes) && payload.maxBytes > 0
|
||||
? Math.min(payload.maxBytes, STREAM_WINDOW_BYTES)
|
||||
: STREAM_WINDOW_BYTES;
|
||||
const start = transfer.offset;
|
||||
const end = Math.min(transfer.bytes.byteLength, start + maxBytes);
|
||||
const chunk = transfer.bytes.subarray(start, end);
|
||||
transfer.offset = end;
|
||||
const done = end >= transfer.bytes.byteLength;
|
||||
if (done) releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
return { chunk, done };
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncWriteObject, async (event, payload) => runExtensionRequest(event, payload, async (signal) => {
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const data = coerceIpcBytes(payload?.data ?? payload?.bytes, "Sync writeObject data");
|
||||
if (data.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES) {
|
||||
throw new Error("Sync writeObject payload requires the streamed write path");
|
||||
}
|
||||
if (data.byteLength > MAX_SYNC_OBJECT_BYTES) {
|
||||
throw new Error("Sync writeObject payload exceeds the size limit");
|
||||
}
|
||||
return extensionProviderService.writeSyncObject({
|
||||
...payload,
|
||||
bytes: data instanceof Uint8Array ? data : new Uint8Array(data),
|
||||
preferStream: false,
|
||||
}, { signal });
|
||||
}));
|
||||
|
||||
ipcMain.handle(CHANNELS.syncWriteBegin, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const requestId = payload?.requestId;
|
||||
const controller = registerStreamingWriteRequest(event, requestId);
|
||||
const failBegin = (error) => {
|
||||
controller.abort();
|
||||
extensionRequestMap(event.sender).delete(requestId);
|
||||
throw error;
|
||||
};
|
||||
const byteLength = payload?.byteLength;
|
||||
if (!Number.isSafeInteger(byteLength) || byteLength < 1 || byteLength > MAX_SYNC_OBJECT_BYTES) {
|
||||
failBegin(new TypeError("Sync writeObject byteLength is invalid"));
|
||||
}
|
||||
const providerId = payload?.providerId;
|
||||
const key = payload?.key;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
failBegin(new TypeError("Sync writeObject provider ID is invalid"));
|
||||
}
|
||||
if (typeof key !== "string" || key.length < 1) {
|
||||
failBegin(new TypeError("Sync writeObject key is invalid"));
|
||||
}
|
||||
const transfers = syncTransferMap(event.sender);
|
||||
try {
|
||||
assertSyncTransferBudget(transfers);
|
||||
} catch (error) {
|
||||
failBegin(error);
|
||||
}
|
||||
const transferId = randomUUID();
|
||||
const transfer = {
|
||||
kind: "write",
|
||||
sender: event.sender,
|
||||
requestId,
|
||||
controller,
|
||||
providerId,
|
||||
key,
|
||||
expectedRevision: payload?.expectedRevision,
|
||||
deadlineMs: payload?.deadlineMs,
|
||||
byteLength,
|
||||
received: 0,
|
||||
sequence: -1,
|
||||
chunks: [],
|
||||
signal: controller.signal,
|
||||
expiresAt: Date.now() + SYNC_TRANSFER_TTL_MS,
|
||||
};
|
||||
transfers.set(transferId, transfer);
|
||||
const onAbort = () => releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
transfer.onAbort = onAbort;
|
||||
return { transferId, windowBytes: STREAM_WINDOW_BYTES };
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncWriteChunk, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
const transferId = payload?.transferId;
|
||||
if (typeof transferId !== "string" || transferId.length < 1) {
|
||||
throw new TypeError("Sync write transfer ID is invalid");
|
||||
}
|
||||
const transfers = syncTransferMap(event.sender);
|
||||
const transfer = transfers.get(transferId);
|
||||
if (!transfer || transfer.kind !== "write") {
|
||||
throw new Error("Sync write transfer is not owned by this window");
|
||||
}
|
||||
const failChunk = (error) => {
|
||||
try { transfer.controller?.abort(); } catch { /* ignore */ }
|
||||
releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
throw error;
|
||||
};
|
||||
if (payload?.requestId !== transfer.requestId) {
|
||||
failChunk(new Error("Sync write transfer request ID mismatch"));
|
||||
}
|
||||
if (transfer.signal.aborted) {
|
||||
failChunk(new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
const sequence = payload?.sequence;
|
||||
if (!Number.isSafeInteger(sequence) || sequence !== transfer.sequence + 1) {
|
||||
failChunk(new TypeError("Sync write chunk sequence is invalid"));
|
||||
}
|
||||
let chunk;
|
||||
try {
|
||||
chunk = coerceIpcBytes(payload?.chunk, "Sync write chunk");
|
||||
} catch (error) {
|
||||
failChunk(error);
|
||||
}
|
||||
if (chunk.byteLength < 1 || chunk.byteLength > STREAM_WINDOW_BYTES) {
|
||||
failChunk(new TypeError("Sync write chunk size is invalid"));
|
||||
}
|
||||
if (transfer.received + chunk.byteLength > transfer.byteLength) {
|
||||
failChunk(new Error("Sync write exceeded declared byteLength"));
|
||||
}
|
||||
transfer.chunks.push(Buffer.from(chunk));
|
||||
transfer.received += chunk.byteLength;
|
||||
transfer.sequence = sequence;
|
||||
transfer.expiresAt = Date.now() + SYNC_TRANSFER_TTL_MS;
|
||||
return { accepted: chunk.byteLength };
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncWriteCommit, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const transferId = payload?.transferId;
|
||||
if (typeof transferId !== "string" || transferId.length < 1) {
|
||||
throw new TypeError("Sync write transfer ID is invalid");
|
||||
}
|
||||
const transfers = syncTransferMap(event.sender);
|
||||
const transfer = transfers.get(transferId);
|
||||
if (!transfer || transfer.kind !== "write") {
|
||||
throw new Error("Sync write transfer is not owned by this window");
|
||||
}
|
||||
const failCommit = (error) => {
|
||||
try { transfer.controller?.abort(); } catch { /* ignore */ }
|
||||
releaseSyncTransfer(event.sender, transferId, transfer);
|
||||
throw error;
|
||||
};
|
||||
if (payload?.requestId !== transfer.requestId) {
|
||||
failCommit(new Error("Sync write transfer request ID mismatch"));
|
||||
}
|
||||
if (transfer.received !== transfer.byteLength) {
|
||||
failCommit(new Error("Sync write commit size does not match byteLength"));
|
||||
}
|
||||
const bytes = Buffer.concat(transfer.chunks);
|
||||
transfers.delete(transferId);
|
||||
try {
|
||||
if (transfer.signal.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
await raceWithAbort(resolveManager(), transfer.signal);
|
||||
return await extensionProviderService.writeSyncObject({
|
||||
providerId: transfer.providerId,
|
||||
key: transfer.key,
|
||||
bytes: new Uint8Array(bytes),
|
||||
expectedRevision: transfer.expectedRevision,
|
||||
preferStream: true,
|
||||
deadlineMs: transfer.deadlineMs,
|
||||
}, { signal: transfer.signal });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
if (extensionRequestMap(event.sender).get(transfer.requestId) === transfer.controller
|
||||
|| extensionRequestMap(event.sender).has(transfer.requestId)) {
|
||||
extensionRequestMap(event.sender).delete(transfer.requestId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncPutSecret, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
if (!secretStore || typeof secretStore.set !== "function") {
|
||||
throw new Error("Plugin sync secret storage is unavailable");
|
||||
}
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const providerId = payload?.providerId;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
throw new TypeError("Sync provider ID is invalid");
|
||||
}
|
||||
const key = assertSecretKey(typeof payload?.key === "string" && payload.key
|
||||
? payload.key
|
||||
: "sync-credential");
|
||||
const value = payload?.value;
|
||||
if (typeof value !== "string" || value.length < 1) {
|
||||
throw new TypeError("Sync secret value is invalid");
|
||||
}
|
||||
const providers = extensionProviderService.listProviders({ kind: "sync" });
|
||||
const match = Array.isArray(providers)
|
||||
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
|
||||
: null;
|
||||
if (!match || typeof match.pluginId !== "string") {
|
||||
throw new Error(`Plugin sync provider is unavailable: ${providerId}`);
|
||||
}
|
||||
return secretStore.set(match.pluginId, key, value);
|
||||
});
|
||||
|
||||
ipcMain.handle(CHANNELS.syncDeleteSecrets, async (event, payload) => {
|
||||
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
|
||||
if (!secretStore || typeof secretStore.delete !== "function") {
|
||||
throw new Error("Plugin sync secret storage is unavailable");
|
||||
}
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
const providerId = payload?.providerId;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
throw new TypeError("Sync provider ID is invalid");
|
||||
}
|
||||
const providers = extensionProviderService.listProviders({ kind: "sync" });
|
||||
const match = Array.isArray(providers)
|
||||
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
|
||||
: null;
|
||||
if (!match || typeof match.pluginId !== "string") {
|
||||
// Provider may already be uninstalled; treat as no-op cleanup.
|
||||
return { deleted: 0 };
|
||||
}
|
||||
const keys = Array.isArray(payload?.keys) && payload.keys.length > 0
|
||||
? payload.keys
|
||||
: null;
|
||||
if (!keys) {
|
||||
// Wipe every sync-credential* key (well-known + schema writeOnly fields).
|
||||
if (typeof secretStore.deleteByKeyPrefix === "function") {
|
||||
const deleted = secretStore.deleteByKeyPrefix(match.pluginId, "sync-credential");
|
||||
return { deleted: Number(deleted) || 0 };
|
||||
}
|
||||
}
|
||||
let deleted = 0;
|
||||
for (const rawKey of keys ?? [
|
||||
"sync-credential",
|
||||
"sync-credential:token",
|
||||
"sync-credential:secret",
|
||||
"sync-credential:apiKey",
|
||||
"sync-credential:accessToken",
|
||||
]) {
|
||||
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
|
||||
const key = assertSecretKey(rawKey);
|
||||
try {
|
||||
secretStore.delete(match.pluginId, key);
|
||||
deleted += 1;
|
||||
} catch {
|
||||
/* ignore missing / unavailable keys */
|
||||
}
|
||||
}
|
||||
return { deleted };
|
||||
});
|
||||
|
||||
handlePassive(CHANNELS.credentialCatalogUpdate, 0, async (_activeManager, payload) => {
|
||||
if (!credentialResolver || typeof credentialResolver.update !== "function") {
|
||||
throw new Error("Plugin Vault credential catalog is unavailable");
|
||||
|
||||
@@ -15,9 +15,16 @@ const {
|
||||
|
||||
function createIpcMain() {
|
||||
const handlers = new Map();
|
||||
const listeners = new Map();
|
||||
return {
|
||||
handlers,
|
||||
listeners,
|
||||
handle(channel, handler) { handlers.set(channel, handler); },
|
||||
on(channel, listener) {
|
||||
const list = listeners.get(channel) ?? [];
|
||||
list.push(listener);
|
||||
listeners.set(channel, list);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1456,3 +1463,90 @@ test("closing the terminal while a plugin connection opens cancels the provider
|
||||
await assert.rejects(pending, (error) => error?.name === "AbortError");
|
||||
assert.deepEqual(closed, ["session-close-during-start"]);
|
||||
});
|
||||
|
||||
test("sync read/write shuttle Uint8Array inline and stream large writes", async () => {
|
||||
const written = [];
|
||||
const ipcMain = createIpcMain();
|
||||
registerPluginBridge(ipcMain, {
|
||||
manager: { async initialize() {} },
|
||||
extensionProviderService: {
|
||||
listProviders() {
|
||||
return [{
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
provider: { id: "com.example.sync", kind: "sync", label: "Example Sync" },
|
||||
}];
|
||||
},
|
||||
async readSyncObject(params) {
|
||||
return {
|
||||
found: true,
|
||||
key: params.key,
|
||||
bytes: Buffer.from("cipher-inline"),
|
||||
revision: "r1",
|
||||
};
|
||||
},
|
||||
async writeSyncObject(params) {
|
||||
written.push({
|
||||
key: params.key,
|
||||
byteLength: params.bytes.byteLength,
|
||||
preferStream: params.preferStream === true,
|
||||
});
|
||||
return { created: true, revision: "r2" };
|
||||
},
|
||||
},
|
||||
secretStore: {
|
||||
set(pluginId, key, value) {
|
||||
return Object.freeze({ kind: "secret", id: "sec-1", key, pluginId, value });
|
||||
},
|
||||
},
|
||||
env: { NETCATTY_PLUGIN_DEV: "1" },
|
||||
isTrustedSender: () => true,
|
||||
});
|
||||
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
|
||||
|
||||
const inlineRead = await ipcMain.handlers.get(CHANNELS.syncReadObject)(event, {
|
||||
requestId: "sync-read-1",
|
||||
providerId: "com.example.sync",
|
||||
key: "vault",
|
||||
});
|
||||
assert.equal(inlineRead.found, true);
|
||||
assert.equal(inlineRead.streamed, undefined);
|
||||
assert.ok(inlineRead.data instanceof Uint8Array || Buffer.isBuffer(inlineRead.data));
|
||||
assert.equal(Buffer.from(inlineRead.data).toString("utf8"), "cipher-inline");
|
||||
|
||||
const inlineWrite = await ipcMain.handlers.get(CHANNELS.syncWriteObject)(event, {
|
||||
requestId: "sync-write-1",
|
||||
providerId: "com.example.sync",
|
||||
key: "vault",
|
||||
data: Buffer.from("small"),
|
||||
});
|
||||
assert.deepEqual(inlineWrite, { created: true, revision: "r2" });
|
||||
|
||||
const begin = await ipcMain.handlers.get(CHANNELS.syncWriteBegin)(event, {
|
||||
requestId: "sync-write-stream",
|
||||
providerId: "com.example.sync",
|
||||
key: "vault",
|
||||
byteLength: 5,
|
||||
});
|
||||
assert.equal(typeof begin.transferId, "string");
|
||||
await ipcMain.handlers.get(CHANNELS.syncWriteChunk)(event, {
|
||||
requestId: "sync-write-stream",
|
||||
transferId: begin.transferId,
|
||||
sequence: 0,
|
||||
chunk: Buffer.from("large"),
|
||||
});
|
||||
const committed = await ipcMain.handlers.get(CHANNELS.syncWriteCommit)(event, {
|
||||
requestId: "sync-write-stream",
|
||||
transferId: begin.transferId,
|
||||
});
|
||||
assert.deepEqual(committed, { created: true, revision: "r2" });
|
||||
assert.equal(written.at(-1)?.preferStream, true);
|
||||
assert.equal(written.at(-1)?.byteLength, 5);
|
||||
|
||||
const secret = await ipcMain.handlers.get(CHANNELS.syncPutSecret)(event, {
|
||||
providerId: "com.example.sync",
|
||||
key: "sync-credential",
|
||||
value: "pw",
|
||||
});
|
||||
assert.deepEqual(secret, { kind: "secret", id: "sec-1", key: "sync-credential", pluginId: "com.example", value: "pw" });
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Pure sidecar collect/merge helpers for the plugin host.
|
||||
* Mirrors domain/pluginSyncSidecar.ts so main-process CJS can load without TS.
|
||||
*/
|
||||
|
||||
const SIDECAR_KINDS = new Set(["settings", "account_baseline", "crdt_baseline"]);
|
||||
|
||||
function isPluginSyncSidecarKind(value) {
|
||||
return typeof value === "string" && SIDECAR_KINDS.has(value);
|
||||
}
|
||||
|
||||
function isCloudSyncablePluginSetting(field) {
|
||||
if (!field || field.secret === true) return false;
|
||||
return field.sync === true;
|
||||
}
|
||||
|
||||
function settingsSidecarKey(settingId, scope, scopeId) {
|
||||
return `${settingId}\0${scope}\0${scopeId}`;
|
||||
}
|
||||
|
||||
function parseSettingsSidecarKey(key) {
|
||||
const parts = String(key).split("\0");
|
||||
if (parts.length !== 3) return null;
|
||||
const [settingId, scope, scopeId] = parts;
|
||||
if (!settingId || !scope || !scopeId) return null;
|
||||
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 = [];
|
||||
const settingsIndex = new Map();
|
||||
|
||||
for (const existing of input.existingSidecars ?? []) {
|
||||
if (!existing || typeof existing.pluginId !== "string" || !isPluginSyncSidecarKind(existing.kind)) {
|
||||
continue;
|
||||
}
|
||||
if (existing.kind !== "settings") {
|
||||
entries.push({
|
||||
pluginId: existing.pluginId,
|
||||
kind: existing.kind,
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const copy = {
|
||||
pluginId: existing.pluginId,
|
||||
kind: "settings",
|
||||
key: String(existing.key),
|
||||
value: existing.value,
|
||||
updatedAt: Number(existing.updatedAt) || now,
|
||||
};
|
||||
settingsIndex.set(`${copy.pluginId}\0${copy.key}`, copy);
|
||||
}
|
||||
|
||||
for (const stored of input.storedSettings ?? []) {
|
||||
if (!stored || typeof stored.pluginId !== "string" || typeof stored.settingId !== "string") continue;
|
||||
const declared = input.declaredSettingsByPlugin.get(stored.pluginId);
|
||||
if (!declared) continue;
|
||||
const field = declared.find((entry) => entry.id === stored.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const key = settingsSidecarKey(stored.settingId, stored.scope, stored.scopeId);
|
||||
const entry = {
|
||||
pluginId: stored.pluginId,
|
||||
kind: "settings",
|
||||
key,
|
||||
value: stored.value,
|
||||
updatedAt: Number(stored.updatedAt) || now,
|
||||
};
|
||||
const mapKey = `${entry.pluginId}\0${entry.key}`;
|
||||
const previous = settingsIndex.get(mapKey);
|
||||
// Keep a retained sidecar when it is strictly newer than the stored row
|
||||
// (e.g. schema-rejected remote apply left sidecar only).
|
||||
if (!previous || entry.updatedAt >= previous.updatedAt) {
|
||||
settingsIndex.set(mapKey, entry);
|
||||
}
|
||||
}
|
||||
|
||||
entries.push(...settingsIndex.values());
|
||||
entries.sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
|
||||
return { version: 1, entries };
|
||||
}
|
||||
|
||||
function mergePluginSyncSidecars(params) {
|
||||
const remoteEntries = Array.isArray(params.remote?.entries) ? params.remote.entries : [];
|
||||
const merged = new Map();
|
||||
|
||||
for (const entry of params.local ?? []) {
|
||||
if (!entry || typeof entry.pluginId !== "string" || !isPluginSyncSidecarKind(entry.kind)) continue;
|
||||
merged.set(`${entry.pluginId}\0${entry.kind}\0${entry.key}`, {
|
||||
pluginId: entry.pluginId,
|
||||
kind: entry.kind,
|
||||
key: String(entry.key),
|
||||
value: entry.value,
|
||||
updatedAt: Number(entry.updatedAt) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const raw of remoteEntries) {
|
||||
if (!raw || typeof raw.pluginId !== "string" || !isPluginSyncSidecarKind(raw.kind)) continue;
|
||||
if (raw.kind === "settings") {
|
||||
const declared = params.declaredSettingsByPlugin?.get(raw.pluginId);
|
||||
if (declared) {
|
||||
const settingId = String(raw.key).split("\0")[0] ?? "";
|
||||
const field = declared.find((entry) => entry.id === settingId);
|
||||
if (field && !isCloudSyncablePluginSetting(field)) continue;
|
||||
}
|
||||
}
|
||||
const next = {
|
||||
pluginId: raw.pluginId,
|
||||
kind: raw.kind,
|
||||
key: String(raw.key),
|
||||
value: raw.value,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
};
|
||||
const mapKey = `${next.pluginId}\0${next.kind}\0${next.key}`;
|
||||
const previous = merged.get(mapKey);
|
||||
if (!previous || next.updatedAt >= previous.updatedAt) {
|
||||
merged.set(mapKey, next);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((a, b) => {
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId < b.pluginId ? -1 : 1;
|
||||
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
||||
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function excludeSecretPluginSettingsFromSidecars(entries, declaredSettingsByPlugin) {
|
||||
return entries.filter((entry) => {
|
||||
if (entry.kind !== "settings") return true;
|
||||
const declared = declaredSettingsByPlugin.get(entry.pluginId);
|
||||
if (!declared) return true;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) return false;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field) return true;
|
||||
return isCloudSyncablePluginSetting(field);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectPluginSyncSidecars,
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
isCloudSyncablePluginSetting,
|
||||
isPluginSyncSidecarKind,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
settingsSidecarKey,
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Host-side collection and apply path for plugin encrypted sync sidecars.
|
||||
* Uses non-cascade tables so uninstall/missing plugins do not destroy data.
|
||||
*/
|
||||
|
||||
const {
|
||||
collectPluginSyncSidecars,
|
||||
excludeSecretPluginSettingsFromSidecars,
|
||||
mergePluginSyncSidecars,
|
||||
parseSettingsSidecarKey,
|
||||
resolveSettingsSidecarTarget,
|
||||
settingsSidecarKey,
|
||||
isCloudSyncablePluginSetting,
|
||||
} = require("./pluginSyncSidecarHelpers.cjs");
|
||||
|
||||
class PluginSyncSidecarService {
|
||||
constructor(options) {
|
||||
if (!options?.database) {
|
||||
throw new TypeError("Plugin sync sidecar service requires a database");
|
||||
}
|
||||
this.database = options.database;
|
||||
this.contributionService = options.contributionService ?? null;
|
||||
}
|
||||
|
||||
#declaredSettingsByPlugin() {
|
||||
const map = new Map();
|
||||
const snapshot = this.contributionService?.snapshot?.();
|
||||
const plugins = snapshot?.plugins ?? [];
|
||||
for (const plugin of plugins) {
|
||||
const fields = (plugin.settings ?? []).map((setting) => ({
|
||||
id: setting.id,
|
||||
secret: setting.secret === true,
|
||||
sync: setting.sync === true,
|
||||
scope: setting.scope,
|
||||
}));
|
||||
map.set(plugin.id, fields);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sidecar bundle for inclusion in the encrypted cloud payload.
|
||||
*/
|
||||
collectForSync() {
|
||||
const declared = this.#declaredSettingsByPlugin();
|
||||
const storedSettings = typeof this.database.listAllSettings === "function"
|
||||
? this.database.listAllSettings()
|
||||
: [];
|
||||
const existingSidecars = typeof this.database.listAllSyncSidecars === "function"
|
||||
? this.database.listAllSyncSidecars()
|
||||
: [];
|
||||
// Keep retained settings sidecars for installed plugins even when
|
||||
// plugin_settings has no row yet (downloaded while plugin was missing).
|
||||
// Do not write them into plugin_settings here — that would bypass the
|
||||
// contribution service's schema validation. applyFromSync / enable paths
|
||||
// rehydrate through updateSetting. User resets clear the sidecar row via
|
||||
// contributionService.resetSetting so deliberate deletions stay deleted.
|
||||
const filteredExisting = existingSidecars.filter((entry) => {
|
||||
if (entry.kind !== "settings") return true;
|
||||
if (!declared.has(entry.pluginId)) return true; // missing plugin — preserve
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) return false;
|
||||
const fields = declared.get(entry.pluginId) ?? [];
|
||||
const field = fields.find((item) => item.id === parsed.settingId);
|
||||
// Drop secret / non-sync declarations; unknown fields stay for schema lag.
|
||||
if (field && !isCloudSyncablePluginSetting(field)) return false;
|
||||
return true;
|
||||
});
|
||||
const bundle = collectPluginSyncSidecars({
|
||||
declaredSettingsByPlugin: declared,
|
||||
storedSettings,
|
||||
existingSidecars: filteredExisting,
|
||||
});
|
||||
bundle.entries = excludeSecretPluginSettingsFromSidecars(bundle.entries, declared);
|
||||
// Persist collected settings into the non-cascade table so a later uninstall
|
||||
// still has rows to re-emit (plugin_settings alone is not enough once the
|
||||
// declaration disappears and collection skips undeclared stored values).
|
||||
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
||||
this.database.replaceAllSyncSidecars(bundle.entries);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a remote sidecar bundle into local non-cascade storage.
|
||||
* Does not delete local rows for plugins absent from the remote bundle.
|
||||
* Installed-plugin settings go through the contribution service when available
|
||||
* so values are validated and runtime change events fire.
|
||||
*/
|
||||
async applyFromSync(remoteBundle) {
|
||||
const declared = this.#declaredSettingsByPlugin();
|
||||
const local = typeof this.database.listAllSyncSidecars === "function"
|
||||
? this.database.listAllSyncSidecars()
|
||||
: [];
|
||||
const remoteEntries = Array.isArray(remoteBundle?.entries) ? remoteBundle.entries : [];
|
||||
// Explicit empty remote bundle is an authoritative wipe (including rows for
|
||||
// missing plugins). Legacy payloads without pluginSidecars never reach here
|
||||
// (apply path only runs when the field is present).
|
||||
const remoteIsAuthoritativeEmpty = Array.isArray(remoteBundle?.entries)
|
||||
&& remoteBundle.entries.length === 0;
|
||||
// Remote is authoritative for installed plugins. For missing plugins, if the
|
||||
// remote bundle mentions the plugin at all, remote is authoritative for that
|
||||
// plugin's entire sidecar set (do not resurrect local-only keys another
|
||||
// device deleted). Only plugins completely absent from remote keep local
|
||||
// retention so first-time missing-plugin baselines are not wiped.
|
||||
const remotePluginIds = new Set(
|
||||
remoteEntries.map((entry) => entry.pluginId).filter((id) => typeof id === "string"),
|
||||
);
|
||||
const remoteKeys = new Set(
|
||||
remoteEntries.map((entry) => `${entry.pluginId}\0${entry.kind}\0${entry.key}`),
|
||||
);
|
||||
// Settings: remote is authoritative for installed plugins and for missing
|
||||
// plugins mentioned in the remote bundle. Baselines (account/crdt) always
|
||||
// enter merge so domain LWW can compare local vs remote; orphans survive.
|
||||
const preservedLocal = local.filter((entry) => {
|
||||
if (entry.kind !== "settings") {
|
||||
return true;
|
||||
}
|
||||
if (remoteIsAuthoritativeEmpty) return false;
|
||||
if (declared.has(entry.pluginId)) return false; // installed: remote wins
|
||||
if (remotePluginIds.has(entry.pluginId)) return false;
|
||||
return true;
|
||||
});
|
||||
const merged = mergePluginSyncSidecars({
|
||||
local: preservedLocal,
|
||||
remote: { version: 1, entries: remoteEntries },
|
||||
declaredSettingsByPlugin: declared,
|
||||
});
|
||||
const safe = excludeSecretPluginSettingsFromSidecars(merged, declared);
|
||||
if (typeof this.database.replaceAllSyncSidecars === "function") {
|
||||
this.database.replaceAllSyncSidecars(safe);
|
||||
}
|
||||
// Drop installed-plugin settings that remote no longer carries.
|
||||
// Prefer contributionService.resetSetting so plugins and the renderer
|
||||
// receive change events; fall back to direct DB delete when unavailable.
|
||||
// Include live plugin_settings rows that were never mirrored into
|
||||
// plugin_sync_sidecars (e.g. pre-sidecar schema migrations) so remote
|
||||
// deletions still clear them instead of being republished on next collect.
|
||||
const resetCandidates = [];
|
||||
const seenResetKeys = new Set();
|
||||
const pushResetCandidate = (entry) => {
|
||||
const mapKey = `${entry.pluginId}\0${entry.kind}\0${entry.key}`;
|
||||
if (seenResetKeys.has(mapKey)) return;
|
||||
seenResetKeys.add(mapKey);
|
||||
resetCandidates.push(entry);
|
||||
};
|
||||
for (const entry of local) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
if (!declared.has(entry.pluginId)) continue;
|
||||
if (remoteKeys.has(`${entry.pluginId}\0${entry.kind}\0${entry.key}`)) continue;
|
||||
pushResetCandidate(entry);
|
||||
}
|
||||
if (typeof this.database.listAllSettings === "function") {
|
||||
for (const row of this.database.listAllSettings()) {
|
||||
if (!declared.has(row.pluginId)) continue;
|
||||
const fields = declared.get(row.pluginId) ?? [];
|
||||
const field = fields.find((item) => item.id === row.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
const key = settingsSidecarKey(row.settingId, row.scope, row.scopeId);
|
||||
if (remoteKeys.has(`${row.pluginId}\0settings\0${key}`)) continue;
|
||||
pushResetCandidate({
|
||||
pluginId: row.pluginId,
|
||||
kind: "settings",
|
||||
key,
|
||||
value: row.value,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const entry of resetCandidates) {
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
if (typeof this.contributionService?.resetSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.resetSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Validation rejected the reset (e.g. required setting) — still try
|
||||
// the stored-coordinate delete below for stale-scope rows.
|
||||
}
|
||||
// resetSetting normalizes against the *current* declared scope. After a
|
||||
// scope migration (application → device), that may miss the stale row
|
||||
// still stored under the old coordinates; always delete those too.
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the stored-coordinate row.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
this.database.deleteSetting(entry.pluginId, parsed.settingId, parsed.scope, parsed.scopeId);
|
||||
}
|
||||
for (const entry of safe) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
const fields = declared.get(entry.pluginId);
|
||||
if (!fields) continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
const field = fields.find((item) => item.id === parsed.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
// Always write under the currently declared scope/key coordinates.
|
||||
// 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 target = resolveSettingsSidecarTarget(field, parsed);
|
||||
if (!target) continue;
|
||||
const { targetScope, targetScopeId, nextKey } = target;
|
||||
if (typeof this.contributionService?.updateSetting === "function") {
|
||||
try {
|
||||
await this.contributionService.updateSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
entry.value,
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
// Preserve remote LWW timestamp after validated write (updateSetting uses clock).
|
||||
this.database.setSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
entry.pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid against current schema — keep sidecar row only.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
this.database.setSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
entry.pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
entry.pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(entry.pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a plugin is installed/enabled, validate and apply retained settings
|
||||
* sidecars into plugin_settings so the running plugin sees cloud values.
|
||||
*/
|
||||
async hydrateInstalledPluginSettings(pluginId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return;
|
||||
const declared = this.#declaredSettingsByPlugin().get(pluginId);
|
||||
if (!declared) return;
|
||||
const rows = typeof this.database.listSyncSidecars === "function"
|
||||
? this.database.listSyncSidecars(pluginId)
|
||||
: [];
|
||||
for (const entry of rows) {
|
||||
if (entry.kind !== "settings") continue;
|
||||
const parsed = parseSettingsSidecarKey(entry.key);
|
||||
if (!parsed) continue;
|
||||
const field = declared.find((item) => item.id === parsed.settingId);
|
||||
if (!field || !isCloudSyncablePluginSetting(field)) continue;
|
||||
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(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
entry.value,
|
||||
targetScopeId,
|
||||
{ source: "host" },
|
||||
);
|
||||
this.database.setSetting(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
targetScope,
|
||||
targetScopeId,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (targetScope !== parsed.scope || targetScopeId !== parsed.scopeId) {
|
||||
try {
|
||||
this.database.deleteSetting(
|
||||
pluginId,
|
||||
parsed.settingId,
|
||||
parsed.scope,
|
||||
parsed.scopeId,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort cleanup of the obsolete scope row.
|
||||
}
|
||||
if (typeof this.database.setSyncSidecar === "function") {
|
||||
try {
|
||||
this.database.setSyncSidecar(
|
||||
pluginId,
|
||||
"settings",
|
||||
nextKey,
|
||||
entry.value,
|
||||
entry.updatedAt,
|
||||
);
|
||||
if (nextKey !== entry.key && typeof this.database.deleteSyncSidecar === "function") {
|
||||
this.database.deleteSyncSidecar(pluginId, "settings", entry.key);
|
||||
}
|
||||
} catch {
|
||||
// Sidecar re-key is best-effort after settings write.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid against current schema — keep sidecar only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an account or CRDT baseline without cascading on uninstall.
|
||||
*/
|
||||
setBaseline(pluginId, kind, key, value) {
|
||||
if (kind !== "account_baseline" && kind !== "crdt_baseline") {
|
||||
throw new TypeError("Baseline kind must be account_baseline or crdt_baseline");
|
||||
}
|
||||
this.database.setSyncSidecar(pluginId, kind, key, value);
|
||||
}
|
||||
|
||||
getBaseline(pluginId, kind, key) {
|
||||
return this.database.getSyncSidecar(pluginId, kind, key);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PluginSyncSidecarService,
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
|
||||
function tempDb(context) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sidecar-"));
|
||||
context.after(() => {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
});
|
||||
return new PluginDatabase(path.join(dir, "plugins.sqlite"));
|
||||
}
|
||||
|
||||
test("sidecar table has no package cascade and survives removePlugin", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.installVersion({
|
||||
pluginId: "com.example.sync",
|
||||
version: "1.0.0",
|
||||
manifest: { id: "com.example.sync", version: "1.0.0" },
|
||||
archiveSha256: "a".repeat(64),
|
||||
packageRelativePath: "packages/com.example.sync/1.0.0",
|
||||
}, { enable: true });
|
||||
database.setSyncSidecar("com.example.sync", "account_baseline", "account", { id: "acct" }, 10);
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
|
||||
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
|
||||
database.removePlugin("com.example.sync");
|
||||
|
||||
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
|
||||
pluginId: "com.example.sync",
|
||||
kind: "account_baseline",
|
||||
key: "account",
|
||||
value: { id: "acct" },
|
||||
updatedAt: 10,
|
||||
});
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"dark",
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync excludes secrets and preserves missing-plugin baselines", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "replica", { clock: 2 }, 5);
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
database.setSetting("com.example.sync", "com.example.sync.token", "application", "application", "secret");
|
||||
|
||||
const contributionService = {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
{ id: "com.example.sync.token", secret: true, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const service = new PluginSyncSidecarService({ database, contributionService });
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.version, 1);
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
|
||||
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
||||
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
|
||||
// Collected settings must land in the non-cascade table for post-uninstall collect.
|
||||
const persisted = database.listAllSyncSidecars();
|
||||
assert.ok(persisted.some((entry) => entry.kind === "settings" && entry.value === "dark"));
|
||||
assert.ok(persisted.some((entry) => entry.kind === "crdt_baseline"));
|
||||
});
|
||||
|
||||
test("collectForSync re-emits retained sidecars after plugin reinstall without writing settings", (context) => {
|
||||
const database = tempDb(context);
|
||||
// Sidecar applied while plugin was missing (no plugin_settings row).
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud",
|
||||
9,
|
||||
);
|
||||
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" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = service.collectForSync();
|
||||
assert.ok(bundle.entries.some((e) => e.value === "from-cloud"));
|
||||
// Collect must not bypass schema validation by writing plugin_settings.
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync re-emits settings after plugin uninstall via non-cascade table", (context) => {
|
||||
const database = tempDb(context);
|
||||
database.installVersion({
|
||||
pluginId: "com.example.sync",
|
||||
version: "1.0.0",
|
||||
manifest: { id: "com.example.sync", version: "1.0.0" },
|
||||
archiveSha256: "b".repeat(64),
|
||||
packageRelativePath: "packages/com.example.sync/1.0.0",
|
||||
}, { enable: true });
|
||||
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
|
||||
|
||||
const contributionService = {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
const service = new PluginSyncSidecarService({ database, contributionService });
|
||||
service.collectForSync();
|
||||
|
||||
database.removePlugin("com.example.sync");
|
||||
const missingService = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
const afterUninstall = missingService.collectForSync();
|
||||
assert.ok(
|
||||
afterUninstall.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"),
|
||||
"settings sidecar must survive uninstall via non-cascade persistence",
|
||||
);
|
||||
});
|
||||
|
||||
test("collectForSync omits deleted settings for installed plugins but keeps missing-plugin rows", (context) => {
|
||||
const database = tempDb(context);
|
||||
// User reset clears both plugin_settings and the matching sidecar row.
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"stale",
|
||||
5,
|
||||
);
|
||||
database.deleteSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "keep", 5);
|
||||
// Only store a different setting for the installed plugin.
|
||||
database.setSetting("com.example.sync", "com.example.sync.other", "application", "application", "ok");
|
||||
|
||||
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" },
|
||||
{ id: "com.example.sync.other", secret: false, sync: true, scope: "application" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const bundle = service.collectForSync();
|
||||
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
|
||||
assert.ok(bundle.entries.some((e) => e.value === "ok"));
|
||||
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
|
||||
});
|
||||
|
||||
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
|
||||
const database = tempDb(context);
|
||||
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" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.sync",
|
||||
kind: "settings",
|
||||
key: "com.example.sync.theme\0application\0application",
|
||||
value: "dark",
|
||||
updatedAt: 42,
|
||||
}],
|
||||
});
|
||||
const rows = database.listAllSettings();
|
||||
const theme = rows.find((r) => r.settingId === "com.example.sync.theme");
|
||||
assert.equal(theme?.value, "dark");
|
||||
assert.equal(theme?.updatedAt, 42);
|
||||
});
|
||||
|
||||
test("applyFromSync does not drop local baselines for plugins absent remotely", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.local.only", "account_baseline", "account", { id: "local" }, 1);
|
||||
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.remote.plugin",
|
||||
kind: "settings",
|
||||
key: "com.remote.plugin.theme\0application\0application",
|
||||
value: "light",
|
||||
updatedAt: 2,
|
||||
}],
|
||||
});
|
||||
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
|
||||
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
|
||||
});
|
||||
|
||||
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"from-cloud",
|
||||
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, 1);
|
||||
assert.equal(updates[0].value, "from-cloud");
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
"from-cloud",
|
||||
);
|
||||
});
|
||||
|
||||
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 clears installed settings without sidecar rows", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Pre-sidecar migration residue: setting exists, sidecar table empty.
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"stale-local",
|
||||
3,
|
||||
);
|
||||
const resets = [];
|
||||
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 resetSetting(pluginId, settingId, scopeId) {
|
||||
resets.push({ pluginId, settingId, scopeId });
|
||||
database.deleteSetting(pluginId, settingId, "application", scopeId);
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(resets.length, 1);
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("applyFromSync deletes stale-scope settings under stored coordinates", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Sidecar/key still encodes application scope, but declaration moved to device.
|
||||
database.setSetting(
|
||||
"com.example.sync",
|
||||
"com.example.sync.theme",
|
||||
"application",
|
||||
"application",
|
||||
"stale-scope",
|
||||
3,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.example.sync",
|
||||
"settings",
|
||||
"com.example.sync.theme\0application\0application",
|
||||
"stale-scope",
|
||||
3,
|
||||
);
|
||||
const resets = [];
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot() {
|
||||
return {
|
||||
plugins: [{
|
||||
id: "com.example.sync",
|
||||
settings: [
|
||||
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "device" },
|
||||
],
|
||||
}],
|
||||
};
|
||||
},
|
||||
async resetSetting(pluginId, settingId, scopeId) {
|
||||
// Simulates normalizeScopeId against the *current* device scope.
|
||||
resets.push({ pluginId, settingId, scopeId });
|
||||
database.deleteSetting(pluginId, settingId, "device", scopeId === "application" ? "device" : scopeId);
|
||||
},
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(
|
||||
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
|
||||
undefined,
|
||||
"stale application-scope row must be deleted by stored-coordinate fallback",
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
assert.equal(database.listAllSyncSidecars().length, 0);
|
||||
});
|
||||
|
||||
test("applyFromSync does not resurrect missing-plugin keys deleted on another device", async (context) => {
|
||||
const database = tempDb(context);
|
||||
// Local still has a setting that remote deleted, plus we keep another remote key.
|
||||
database.setSyncSidecar(
|
||||
"com.missing.plugin",
|
||||
"settings",
|
||||
"com.missing.plugin.deleted\0application\0application",
|
||||
"stale",
|
||||
1,
|
||||
);
|
||||
database.setSyncSidecar(
|
||||
"com.missing.plugin",
|
||||
"settings",
|
||||
"com.missing.plugin.kept\0application\0application",
|
||||
"local-old",
|
||||
1,
|
||||
);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.missing.plugin",
|
||||
kind: "settings",
|
||||
key: "com.missing.plugin.kept\0application\0application",
|
||||
value: "remote-new",
|
||||
updatedAt: 5,
|
||||
}],
|
||||
});
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.equal(
|
||||
all.some((entry) => String(entry.key).includes("deleted")),
|
||||
false,
|
||||
"must not resurrect remote-deleted missing-plugin setting",
|
||||
);
|
||||
assert.ok(all.some((entry) => entry.value === "remote-new"));
|
||||
});
|
||||
|
||||
test("applyFromSync empty remote bundle preserves local baselines", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
|
||||
database.setSyncSidecar("com.missing.plugin", "account_baseline", "account", { id: "a1" }, 2);
|
||||
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "crdt", { clock: 9 }, 3);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: { snapshot: () => ({ plugins: [] }) },
|
||||
});
|
||||
await service.applyFromSync({ version: 1, entries: [] });
|
||||
const all = database.listAllSyncSidecars();
|
||||
assert.equal(all.some((entry) => entry.kind === "settings"), false);
|
||||
assert.equal(all.some((entry) => entry.kind === "account_baseline"), true);
|
||||
assert.equal(all.some((entry) => entry.kind === "crdt_baseline"), true);
|
||||
});
|
||||
|
||||
test("applyFromSync keeps newer local baseline over older remote (LWW)", async (context) => {
|
||||
const database = tempDb(context);
|
||||
database.setSyncSidecar("com.example.plugin", "crdt_baseline", "crdt", { clock: 100 }, 100);
|
||||
const service = new PluginSyncSidecarService({
|
||||
database,
|
||||
contributionService: {
|
||||
snapshot: () => ({
|
||||
plugins: [{
|
||||
id: "com.example.plugin",
|
||||
settings: [],
|
||||
}],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await service.applyFromSync({
|
||||
version: 1,
|
||||
entries: [{
|
||||
pluginId: "com.example.plugin",
|
||||
kind: "crdt_baseline",
|
||||
key: "crdt",
|
||||
value: { clock: 50 },
|
||||
updatedAt: 50,
|
||||
}],
|
||||
});
|
||||
const row = database.listAllSyncSidecars().find((entry) => entry.kind === "crdt_baseline");
|
||||
assert.ok(row);
|
||||
assert.equal(row.updatedAt, 100);
|
||||
assert.deepEqual(row.value, { clock: 100 });
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const ALLOWED_KEYWORDS = new Set([
|
||||
"properties",
|
||||
"required",
|
||||
"type",
|
||||
"writeOnly",
|
||||
]);
|
||||
|
||||
const ajv = new Ajv2020({
|
||||
|
||||
@@ -57,6 +57,16 @@ const CONNECTION_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"getStatus",
|
||||
]);
|
||||
const IMPORTER_PROVIDER_OPERATIONS = Object.freeze(["detect", "parse"]);
|
||||
const SYNC_PROVIDER_OPERATIONS = Object.freeze([
|
||||
"connect",
|
||||
"disconnect",
|
||||
"getAccount",
|
||||
"getCapabilities",
|
||||
"readObject",
|
||||
"writeObject",
|
||||
"deleteObject",
|
||||
]);
|
||||
const OPERATION_MAP_PROVIDER_KINDS = Object.freeze(new Set(["connection", "importer", "sync"]));
|
||||
|
||||
function pluginErrorNameFromRpcError(error) {
|
||||
if (typeof error?.data?.pluginCode === "string") return error.data.pluginCode;
|
||||
@@ -243,21 +253,33 @@ function assertProviderKind(kind) {
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationMapLabel(kind) {
|
||||
if (kind === "connection") return "Connection";
|
||||
if (kind === "importer") return "Importer";
|
||||
if (kind === "sync") return "Sync";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function operationsForProviderKind(kind) {
|
||||
if (kind === "connection") return CONNECTION_PROVIDER_OPERATIONS;
|
||||
if (kind === "importer") return IMPORTER_PROVIDER_OPERATIONS;
|
||||
if (kind === "sync") return SYNC_PROVIDER_OPERATIONS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProviderHandler(kind, handler) {
|
||||
if (kind !== "connection" && kind !== "importer") {
|
||||
if (!OPERATION_MAP_PROVIDER_KINDS.has(kind)) {
|
||||
if (typeof handler !== "function") {
|
||||
throw new PluginError("invalid_argument", "Plugin Provider handler must be a function");
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
const label = kind === "connection" ? "Connection" : "Importer";
|
||||
const label = operationMapLabel(kind);
|
||||
if (!handler || typeof handler !== "object" || Array.isArray(handler)) {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler must be an operation map`);
|
||||
}
|
||||
const normalized = {};
|
||||
const operations = kind === "connection"
|
||||
? CONNECTION_PROVIDER_OPERATIONS
|
||||
: IMPORTER_PROVIDER_OPERATIONS;
|
||||
const operations = operationsForProviderKind(kind);
|
||||
for (const operation of operations) {
|
||||
if (typeof handler[operation] !== "function") {
|
||||
throw new PluginError("invalid_argument", `${label} Provider handler is missing operation: ${operation}`);
|
||||
@@ -639,8 +661,11 @@ export async function startPluginRuntime({
|
||||
const payload = message.params?.payload === undefined
|
||||
? undefined
|
||||
: freezeRuntimeJson(message.params.payload);
|
||||
const streamed = (kind === "connection" && operation === "open")
|
||||
|| (kind === "importer" && operation === "parse");
|
||||
const connectionOpen = kind === "connection" && operation === "open";
|
||||
const importerParse = kind === "importer" && operation === "parse";
|
||||
const syncReadStream = kind === "sync" && operation === "readObject" && typeof payload?.outputStreamId === "string";
|
||||
const syncWriteStream = kind === "sync" && operation === "writeObject" && typeof payload?.inputStreamId === "string";
|
||||
const streamed = connectionOpen || importerParse || syncReadStream || syncWriteStream;
|
||||
let input;
|
||||
let output;
|
||||
let cancelStreams;
|
||||
@@ -648,24 +673,38 @@ export async function startPluginRuntime({
|
||||
const inputStreamId = payload?.inputStreamId;
|
||||
const outputStreamId = payload?.outputStreamId;
|
||||
const windowBytes = payload?.windowBytes;
|
||||
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
|
||||
if (connectionOpen || importerParse) {
|
||||
if (typeof inputStreamId !== "string" || typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Streamed Provider invocation requires input and output stream IDs");
|
||||
}
|
||||
} else if (syncReadStream && typeof outputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync readObject stream requires an output stream ID");
|
||||
} else if (syncWriteStream && typeof inputStreamId !== "string") {
|
||||
throw new PluginError("invalid_argument", "Sync writeObject stream requires an input stream ID");
|
||||
}
|
||||
input = runtimeApi.streams.acceptReadable(inputStreamId);
|
||||
// Prevent a cancelled request from creating an unhandled rejected
|
||||
// promise when the Provider never awaited its input stream.
|
||||
void input.catch(() => {});
|
||||
try {
|
||||
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
|
||||
} catch (error) {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
throw error;
|
||||
if (typeof inputStreamId === "string") {
|
||||
input = runtimeApi.streams.acceptReadable(inputStreamId);
|
||||
// Prevent a cancelled request from creating an unhandled rejected
|
||||
// promise when the Provider never awaited its input stream.
|
||||
void input.catch(() => {});
|
||||
}
|
||||
if (typeof outputStreamId === "string") {
|
||||
try {
|
||||
output = await runtimeApi.streams.openWritable(outputStreamId, windowBytes);
|
||||
} catch (error) {
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
cancelStreams = () => {
|
||||
const error = new PluginError("cancelled", "Streamed Provider invocation was cancelled");
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
output.cancel();
|
||||
void input.then((stream) => stream.cancel(), () => {});
|
||||
if (typeof inputStreamId === "string") {
|
||||
runtimeApi.streams.rejectReadable(inputStreamId, error);
|
||||
void input?.then((stream) => stream.cancel(), () => {});
|
||||
}
|
||||
output?.cancel?.();
|
||||
};
|
||||
}
|
||||
const cancellationDisposable = cancelStreams
|
||||
@@ -679,10 +718,11 @@ export async function startPluginRuntime({
|
||||
payload,
|
||||
deadlineMs,
|
||||
cancellationToken,
|
||||
...(streamed ? { input, output } : {}),
|
||||
...(input !== undefined ? { input } : {}),
|
||||
...(output !== undefined ? { output } : {}),
|
||||
});
|
||||
try {
|
||||
const handler = registration.kind === "connection" || registration.kind === "importer"
|
||||
const handler = OPERATION_MAP_PROVIDER_KINDS.has(registration.kind)
|
||||
? registration.handler[operation]
|
||||
: registration.handler;
|
||||
if (typeof handler !== "function") {
|
||||
@@ -692,6 +732,11 @@ export async function startPluginRuntime({
|
||||
if (cancellationToken.isCancellationRequested) {
|
||||
return { requestId, status: "cancelled" };
|
||||
}
|
||||
// Sync readObject may return inline base64 or found:false while the host
|
||||
// still opened an output stream for large-object fallback — release it.
|
||||
if (syncReadStream && output && !(result && result.streamed === true)) {
|
||||
try { output.cancel?.(); } catch { /* best-effort */ }
|
||||
}
|
||||
return { requestId, status: "ok", result: result === undefined ? null : result };
|
||||
} catch (error) {
|
||||
cancelStreams?.();
|
||||
|
||||
@@ -87,6 +87,15 @@ class PluginSecretStore {
|
||||
this.database.deleteSecret(pluginId, key);
|
||||
}
|
||||
|
||||
deleteByKeyPrefix(pluginId, prefix) {
|
||||
assertSecretKey(prefix);
|
||||
if (typeof this.database.deleteSecretsByKeyPrefix !== "function") {
|
||||
this.delete(pluginId, prefix);
|
||||
return 1;
|
||||
}
|
||||
return this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
|
||||
}
|
||||
|
||||
resolve(pluginId, secret) {
|
||||
this.#assertAvailable();
|
||||
const record = this.getRecordByReference(pluginId, secret);
|
||||
|
||||
@@ -65,9 +65,45 @@ function registerSecurePluginCapabilities(registry, options) {
|
||||
authorization: (params) => options.credentialBroker.describeAuthorization(params),
|
||||
});
|
||||
|
||||
registry.registerRequest("network.request", (params, context) => (
|
||||
options.networkBroker.request(params, context)
|
||||
), {
|
||||
registry.registerRequest("network.request", async (params, context) => {
|
||||
// params are already registry-validated (preserve credentialLease/authorization).
|
||||
const validated = params;
|
||||
const lease = validated?.credentialLease;
|
||||
if (lease === undefined) {
|
||||
return options.networkBroker.request(validated, context);
|
||||
}
|
||||
if (!options.credentialBroker || typeof options.credentialBroker.consumeLease !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin credential leases are unavailable");
|
||||
}
|
||||
// Bind consumption to the host-owned network operation id (network:<origin>),
|
||||
// not the plugin-echoed lease.operationId, so leases cannot be replayed across ops.
|
||||
const hostOperationId = options.networkBroker.describeAuthorization(validated).operationId;
|
||||
const plaintext = await options.credentialBroker.consumeLease(
|
||||
context,
|
||||
lease,
|
||||
hostOperationId,
|
||||
);
|
||||
if (typeof plaintext !== "string" || plaintext.length < 1) {
|
||||
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin credential lease resolved to an empty secret");
|
||||
}
|
||||
const authorization = validated?.authorization && typeof validated.authorization === "object"
|
||||
&& !Array.isArray(validated.authorization)
|
||||
? validated.authorization
|
||||
: { scheme: "Bearer" };
|
||||
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
|
||||
const headers = { ...(validated.headers ?? {}) };
|
||||
if (scheme === "Basic") {
|
||||
const username = typeof authorization.username === "string" ? authorization.username : "";
|
||||
headers.authorization = `Basic ${Buffer.from(`${username}:${plaintext}`, "utf8").toString("base64")}`;
|
||||
} else {
|
||||
headers.authorization = `Bearer ${plaintext}`;
|
||||
}
|
||||
const { credentialLease: _lease, authorization: _auth, ...requestParams } = validated;
|
||||
return options.networkBroker.request({
|
||||
...requestParams,
|
||||
headers,
|
||||
}, context);
|
||||
}, {
|
||||
metadata: { capability: "network", mutating: true, permission: "network" },
|
||||
validateParams: (params) => options.networkBroker.validate(params),
|
||||
authorization: (params) => options.networkBroker.describeAuthorization(params),
|
||||
|
||||
@@ -241,3 +241,96 @@ test("companion requests consume operation-bound credential leases exactly once"
|
||||
});
|
||||
assert.equal(forwarded[0].context, context);
|
||||
});
|
||||
|
||||
test("network.request injects Authorization from a consumed credential lease", async () => {
|
||||
const registrations = new Map();
|
||||
const registry = {
|
||||
use() {},
|
||||
registerRequest(method, handler, options) { registrations.set(method, { handler, options }); },
|
||||
};
|
||||
const middlewareOwner = { createMiddleware: () => async (_context, next) => next() };
|
||||
const forwarded = [];
|
||||
const consumed = [];
|
||||
const credentialBroker = {
|
||||
async consumeLease(context, lease, operationId) {
|
||||
consumed.push({ lease, operationId, pluginId: context.pluginId });
|
||||
return "lease-token-value";
|
||||
},
|
||||
};
|
||||
const networkBroker = {
|
||||
// Mirror production validate: preserve credentialLease / authorization.
|
||||
validate: (params) => ({
|
||||
url: params.url,
|
||||
method: params.method ?? "GET",
|
||||
headers: params.headers ?? {},
|
||||
timeoutMs: params.timeoutMs ?? 30_000,
|
||||
...(params.credentialLease === undefined ? {} : { credentialLease: params.credentialLease }),
|
||||
...(params.authorization === undefined ? {} : { authorization: params.authorization }),
|
||||
}),
|
||||
describeAuthorization: () => ({
|
||||
permission: "network",
|
||||
resources: ["https://example.com"],
|
||||
reason: "test",
|
||||
operationId: "network:https://example.com",
|
||||
}),
|
||||
async request(params, context) {
|
||||
forwarded.push({ params, context });
|
||||
return { status: 204, headers: {}, body: { encoding: "base64", data: "" } };
|
||||
},
|
||||
};
|
||||
const broker = new Proxy({}, { get: () => () => ({}) });
|
||||
registerSecurePluginCapabilities(registry, {
|
||||
quotaManager: middlewareOwner,
|
||||
permissionEngine: middlewareOwner,
|
||||
secretStore: {},
|
||||
credentialBroker,
|
||||
networkBroker,
|
||||
filesystemBroker: broker,
|
||||
companionSupervisor: broker,
|
||||
assertLeaseParams: (params) => params,
|
||||
});
|
||||
|
||||
const context = {
|
||||
pluginId: "com.example.secure",
|
||||
async assertActive() {},
|
||||
};
|
||||
const registration = registrations.get("network.request");
|
||||
// Simulate hostRpcRegistry: validateParams first, then handler(validatedParams).
|
||||
const validated = registration.options.validateParams({
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
authorization: { scheme: "Bearer" },
|
||||
});
|
||||
await registration.handler(validated, context);
|
||||
|
||||
assert.equal(consumed.length, 1);
|
||||
assert.equal(consumed[0].operationId, "network:https://example.com");
|
||||
assert.equal(forwarded[0].params.headers.authorization, "Bearer lease-token-value");
|
||||
assert.equal(forwarded[0].params.credentialLease, undefined);
|
||||
});
|
||||
|
||||
test("networkBroker.validate preserves credentialLease for registry dispatch", () => {
|
||||
const { PluginNetworkBroker } = require("./networkBroker.cjs");
|
||||
const broker = new PluginNetworkBroker({
|
||||
fetch: async () => ({ status: 204, headers: new Headers(), body: null, url: "https://example.com/" }),
|
||||
permissionEngine: { check: () => {} },
|
||||
});
|
||||
const validated = broker.validate({
|
||||
url: "https://example.com/vault",
|
||||
credentialLease: {
|
||||
kind: "secret-lease",
|
||||
id: "lease-1",
|
||||
operationId: "network:https://example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
},
|
||||
authorization: { scheme: "Basic", username: "alice" },
|
||||
});
|
||||
assert.equal(validated.credentialLease.kind, "secret-lease");
|
||||
assert.equal(validated.authorization.scheme, "Basic");
|
||||
assert.equal(validated.authorization.username, "alice");
|
||||
});
|
||||
@@ -130,6 +130,69 @@ function createPreloadApi(ctx) {
|
||||
...request,
|
||||
}),
|
||||
cancelPluginExtensionRequest: (requestId) => ipcRenderer.invoke("netcatty:plugins:extension-cancel", { requestId }),
|
||||
pluginSyncConnect: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-connect", { ...request, requestId });
|
||||
},
|
||||
pluginSyncDisconnect: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-disconnect", { ...request, requestId });
|
||||
},
|
||||
pluginSyncGetAccount: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-get-account", { ...request, requestId });
|
||||
},
|
||||
pluginSyncGetCapabilities: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-get-capabilities", { ...request, requestId });
|
||||
},
|
||||
pluginSyncReadObject: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-read-object", { ...request, requestId });
|
||||
},
|
||||
pluginSyncReadChunk: (request) => ipcRenderer.invoke("netcatty:plugins:sync-read-chunk", request),
|
||||
pluginSyncWriteObject: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-write-object", { ...request, requestId });
|
||||
},
|
||||
pluginSyncWriteBegin: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-write-begin", { ...request, requestId });
|
||||
},
|
||||
pluginSyncWriteChunk: (request) => ipcRenderer.invoke("netcatty:plugins:sync-write-chunk", request),
|
||||
pluginSyncWriteCommit: (request) => ipcRenderer.invoke("netcatty:plugins:sync-write-commit", request),
|
||||
pluginSyncDeleteObject: (request) => {
|
||||
const requestId = typeof request?.requestId === "string" && request.requestId
|
||||
? request.requestId
|
||||
: randomUUID();
|
||||
return ipcRenderer.invoke("netcatty:plugins:sync-delete-object", { ...request, requestId });
|
||||
},
|
||||
pluginSyncPutSecret: (request) => ipcRenderer.invoke("netcatty:plugins:sync-put-secret", request ?? {}),
|
||||
pluginSyncDeleteSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-delete-secrets", request ?? {}),
|
||||
collectPluginSyncSidecars: () => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-collect", {}),
|
||||
applyPluginSyncSidecars: (bundle) => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-apply", bundle ?? { version: 1, entries: [] }),
|
||||
/** True only when the main-process plugin host wired a sidecar service. */
|
||||
pluginHostReady: () => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("netcatty:plugins:host-available-sync") === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
startPluginConnection: async (request) => {
|
||||
markRequestedTerminalDataSessionOpen(request);
|
||||
const result = await ipcRenderer.invoke("netcatty:plugins:connection-start", {
|
||||
|
||||
Vendored
+86
-1
@@ -353,10 +353,95 @@ declare global {
|
||||
providePluginTerminal?(request: NetcattyTerminalProviderRequest): Promise<ReadonlyArray<NetcattyTerminalProviderResult>>;
|
||||
cancelPluginTerminalRequest?(requestId: string): Promise<boolean>;
|
||||
publishPluginTerminalSessionEvent?(event: NetcattyTerminalSessionEvent): Promise<ReadonlyArray<{ pluginId: string; delivered: boolean }>>;
|
||||
listPluginExtensionProviders?(options: { kind: 'connection' | 'authentication' | 'importer'; locale?: string }): Promise<ReadonlyArray<NetcattyExtensionProviderContribution>>;
|
||||
listPluginExtensionProviders?(options: { kind: 'connection' | 'authentication' | 'importer' | 'sync'; locale?: string }): Promise<ReadonlyArray<NetcattyExtensionProviderContribution>>;
|
||||
updatePluginCredentialCatalog?(entries: ReadonlyArray<{ id: string; ciphertext: string }>): Promise<number>;
|
||||
invokePluginExtensionProvider?(request: NetcattyExtensionProviderRequest): Promise<import("@netcatty/plugin-contract").JsonValue>;
|
||||
cancelPluginExtensionRequest?(requestId: string): Promise<boolean>;
|
||||
pluginSyncConnect?(request: {
|
||||
requestId?: string;
|
||||
providerId: string;
|
||||
configuration?: unknown;
|
||||
credential?: unknown;
|
||||
deadlineMs?: number;
|
||||
}): Promise<{ account: { id: string; email?: string; name?: string; avatarUrl?: string } }>;
|
||||
pluginSyncDisconnect?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<null>;
|
||||
pluginSyncGetAccount?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<{
|
||||
account: { id: string; email?: string; name?: string; avatarUrl?: string } | null;
|
||||
}>;
|
||||
pluginSyncGetCapabilities?(request: { requestId?: string; providerId: string; deadlineMs?: number }): Promise<{
|
||||
revisions: boolean;
|
||||
conditionalWrites: boolean;
|
||||
atomicReplacement: boolean;
|
||||
maxObjectBytes?: number;
|
||||
maxObjects?: number;
|
||||
}>;
|
||||
pluginSyncReadObject?(request: {
|
||||
requestId?: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
preferStream?: boolean;
|
||||
deadlineMs?: number;
|
||||
}): Promise<{
|
||||
found: boolean;
|
||||
key: string;
|
||||
data?: Uint8Array | null;
|
||||
streamed?: boolean;
|
||||
transferId?: string;
|
||||
byteLength?: number;
|
||||
revision?: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
pluginSyncReadChunk?(request: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<{ chunk: Uint8Array; done: boolean }>;
|
||||
pluginSyncWriteObject?(request: {
|
||||
requestId?: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
data: Uint8Array;
|
||||
expectedRevision?: string | null;
|
||||
preferStream?: boolean;
|
||||
deadlineMs?: number;
|
||||
}): Promise<{ created: boolean; revision?: string }>;
|
||||
pluginSyncWriteBegin?(request: {
|
||||
requestId?: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
byteLength: number;
|
||||
expectedRevision?: string | null;
|
||||
deadlineMs?: number;
|
||||
}): Promise<{ transferId: string; windowBytes: number }>;
|
||||
pluginSyncWriteChunk?(request: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
sequence: number;
|
||||
chunk: Uint8Array;
|
||||
}): Promise<{ accepted: number }>;
|
||||
pluginSyncWriteCommit?(request: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
}): Promise<{ created: boolean; revision?: string }>;
|
||||
pluginSyncDeleteObject?(request: {
|
||||
requestId?: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
expectedRevision?: string;
|
||||
deadlineMs?: number;
|
||||
}): Promise<{ deleted: boolean }>;
|
||||
pluginSyncPutSecret?(request: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<{ kind: 'secret'; id: string; key: string }>;
|
||||
pluginSyncDeleteSecrets?(request: {
|
||||
providerId: string;
|
||||
keys?: string[];
|
||||
}): Promise<{ deleted: number }>;
|
||||
collectPluginSyncSidecars?(): Promise<unknown>;
|
||||
applyPluginSyncSidecars?(bundle: unknown): Promise<{ applied: boolean; count?: number; entries?: unknown }>;
|
||||
pluginHostReady?(): boolean;
|
||||
startPluginConnection?(request: NetcattyPluginConnectionStartRequest): Promise<{ sessionId: string; providerId: string; status: 'connecting' | 'connected'; diagnostics: ReadonlyArray<import("@netcatty/plugin-contract").ProviderValidationIssue> }>;
|
||||
writePluginConnection?(sessionId: string, data: Uint8Array): Promise<void>;
|
||||
controlPluginConnection?(sessionId: string, operation: 'resize' | 'signal' | 'reconnect' | 'close' | 'getStatus', payload?: Record<string, unknown>): Promise<unknown>;
|
||||
|
||||
@@ -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';
|
||||
@@ -151,6 +151,52 @@ export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyPro
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Host-owned sealed-config envelope. Must be unambiguous against plugin-owned
|
||||
* JSON: exactly one reserved key, no extra properties. Never treat a plugin
|
||||
* object that merely contains a similar key as already sealed.
|
||||
*/
|
||||
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>;
|
||||
const keys = Object.keys(record);
|
||||
return keys.length === 1
|
||||
&& keys[0] === PLUGIN_CONFIG_ENVELOPE_KEY
|
||||
&& typeof record[PLUGIN_CONFIG_ENVELOPE_KEY] === "string";
|
||||
}
|
||||
|
||||
/** Legacy envelope shape (still accepted on decrypt for one migration hop). */
|
||||
function isLegacyPluginConfigEnvelope(value: unknown): value is { __encryptedPluginConfig: string } {
|
||||
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] === LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY
|
||||
&& 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 };
|
||||
|
||||
@@ -161,22 +207,97 @@ export async function encryptProviderSecrets(conn: ProviderConnection): Promise<
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
|
||||
// so that token-auth configs (which may lack a password key after JSON round-trip)
|
||||
// still get their token field encrypted.
|
||||
if ("authType" in out.config) {
|
||||
// Config may be a valid falsy scalar (false, 0, "") — only null/undefined means absent.
|
||||
if (out.config != null) {
|
||||
const providerId = String(out.provider ?? "");
|
||||
const isBuiltin = providerId === "webdav"
|
||||
|| providerId === "s3"
|
||||
|| providerId === "github"
|
||||
|| providerId === "google"
|
||||
|| providerId === "onedrive";
|
||||
// Built-in providers use field-level encryption; plugin IDs always seal
|
||||
// the whole opaque config so field-name collisions cannot leak secrets.
|
||||
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await encryptField(c.password);
|
||||
c.token = await encryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
// S3
|
||||
if ("secretAccessKey" in out.config) {
|
||||
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await encryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
} else if (!isBuiltin) {
|
||||
// Always (re)seal opaque plugin config. An exact marker-shaped object may
|
||||
// be either a trusted host envelope or plugin-owned JSON that collides
|
||||
// with our key — try unwrap; on failure seal the whole value as opaque.
|
||||
let toSeal: unknown = out.config;
|
||||
if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
|
||||
const sealedValue = isPluginConfigEnvelope(out.config)
|
||||
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
|
||||
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
|
||||
const plain = await decryptField(sealedValue);
|
||||
if (plain != null && plain !== "") {
|
||||
try {
|
||||
toSeal = JSON.parse(plain);
|
||||
} catch {
|
||||
toSeal = out.config;
|
||||
}
|
||||
} else {
|
||||
toSeal = out.config;
|
||||
}
|
||||
}
|
||||
const sealed = await encryptField(JSON.stringify(toSeal));
|
||||
if (sealed) {
|
||||
out.config = {
|
||||
[PLUGIN_CONFIG_ENVELOPE_KEY]: sealed,
|
||||
} as ProviderConnection["config"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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") {
|
||||
let toSeal: unknown = out.credential;
|
||||
if (isPluginCredentialEnvelope(out.credential)) {
|
||||
const plain = await decryptField(out.credential[PLUGIN_CREDENTIAL_ENVELOPE_KEY]);
|
||||
if (plain != null && plain !== "") {
|
||||
try {
|
||||
toSeal = JSON.parse(plain);
|
||||
} catch {
|
||||
toSeal = out.credential;
|
||||
}
|
||||
} else {
|
||||
toSeal = out.credential;
|
||||
}
|
||||
}
|
||||
const kind = (toSeal as { kind?: unknown }).kind;
|
||||
const id = (toSeal as { id?: unknown }).id;
|
||||
const key = (toSeal 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 if (isPluginCredentialEnvelope(toSeal)) {
|
||||
// Marker-collision object that is not a durable ref — seal as opaque JSON.
|
||||
const sealed = await encryptField(JSON.stringify(toSeal));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,18 +314,65 @@ export async function decryptProviderSecrets(conn: ProviderConnection): Promise<
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
if ("authType" in out.config) {
|
||||
// Config may be a valid falsy scalar — only null/undefined means absent.
|
||||
if (out.config != null) {
|
||||
const providerId = String(out.provider ?? "");
|
||||
const isBuiltin = providerId === "webdav"
|
||||
|| providerId === "s3"
|
||||
|| providerId === "github"
|
||||
|| providerId === "google"
|
||||
|| providerId === "onedrive";
|
||||
if (isBuiltin && typeof out.config === "object" && "authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await decryptField(c.password);
|
||||
c.token = await decryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
if ("secretAccessKey" in out.config) {
|
||||
} else if (isBuiltin && typeof out.config === "object" && "secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await decryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
} else if (isPluginConfigEnvelope(out.config) || isLegacyPluginConfigEnvelope(out.config)) {
|
||||
const sealed = isPluginConfigEnvelope(out.config)
|
||||
? out.config[PLUGIN_CONFIG_ENVELOPE_KEY]
|
||||
: out.config[LEGACY_PLUGIN_CONFIG_ENVELOPE_KEY];
|
||||
const plain = await decryptField(sealed);
|
||||
// plain may be JSON "false"/"0"/'""' — treat empty decrypt as failure only.
|
||||
if (plain != null && plain !== "") {
|
||||
try {
|
||||
out.config = JSON.parse(plain) as ProviderConnection["config"];
|
||||
} catch {
|
||||
// leave sealed if corrupt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
completeGitHubAuthImpl,
|
||||
completePKCEAuthImpl,
|
||||
connectConfigProviderImpl,
|
||||
connectPluginProviderImpl,
|
||||
resetProviderStatusImpl,
|
||||
setProviderErrorImpl,
|
||||
clearConnectingStatusImpl,
|
||||
@@ -118,6 +119,7 @@ import {
|
||||
changeMasterKeyImpl,
|
||||
verifyPasswordImpl,
|
||||
handleProviderReauthRequiredImpl,
|
||||
setAvailablePluginSyncProviderIdsImpl,
|
||||
} from './cloudSync/stateAndSecurityMethods';
|
||||
import {
|
||||
clearConvergentSyncStorageImpl,
|
||||
@@ -232,6 +234,8 @@ export class CloudSyncManager {
|
||||
private providerWriteSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
/** Optional abort signal for the in-flight syncNow / convergent upload path. */
|
||||
activeSyncAbortSignal: AbortSignal | undefined;
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadInitialState();
|
||||
@@ -343,15 +347,13 @@ export class CloudSyncManager {
|
||||
*/
|
||||
private notifyStateChange(): void {
|
||||
// Deep clone the state to ensure all nested objects are new references
|
||||
const providers: Record<CloudProvider, ProviderConnection> = {};
|
||||
for (const [providerId, connection] of Object.entries(this.state.providers)) {
|
||||
providers[providerId] = { ...connection };
|
||||
}
|
||||
this.stateSnapshot = {
|
||||
...this.state,
|
||||
providers: {
|
||||
github: { ...this.state.providers.github },
|
||||
google: { ...this.state.providers.google },
|
||||
onedrive: { ...this.state.providers.onedrive },
|
||||
webdav: { ...this.state.providers.webdav },
|
||||
s3: { ...this.state.providers.s3 },
|
||||
},
|
||||
providers,
|
||||
syncHistory: [...this.state.syncHistory],
|
||||
convergentConflicts: [...this.state.convergentConflicts],
|
||||
currentConflict: this.state.currentConflict ? { ...this.state.currentConflict } : null,
|
||||
@@ -508,6 +510,26 @@ export class CloudSyncManager {
|
||||
return connectConfigProviderImpl.call(this, provider, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
|
||||
*/
|
||||
async connectPluginProvider(
|
||||
providerId: string,
|
||||
configuration: unknown = {},
|
||||
credential?: unknown,
|
||||
): Promise<void> {
|
||||
return connectPluginProviderImpl.call(this, providerId, configuration, credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the live set of contribution-available plugin sync provider IDs.
|
||||
* Call when plugin contributions change so disabled/uninstalled providers
|
||||
* leave the auto-sync ready set.
|
||||
*/
|
||||
setAvailablePluginSyncProviders(providerIds: readonly string[]): void {
|
||||
setAvailablePluginSyncProviderIdsImpl.call(this, providerIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset provider status to disconnected without tearing down existing connections.
|
||||
* Used when an auth attempt is cancelled/fails — avoids destroying a previously
|
||||
@@ -695,9 +717,16 @@ export class CloudSyncManager {
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<SyncResult> {
|
||||
return syncToProviderImpl.call(this, provider, payload, opts);
|
||||
const previous = this.activeSyncAbortSignal;
|
||||
this.activeSyncAbortSignal = opts.signal;
|
||||
try {
|
||||
return await syncToProviderImpl.call(this, provider, payload, opts);
|
||||
} finally {
|
||||
this.activeSyncAbortSignal = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -793,9 +822,16 @@ export class CloudSyncManager {
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<Map<CloudProvider, SyncResult>> {
|
||||
return syncAllProvidersImpl.call(this, inputPayload, opts);
|
||||
const previous = this.activeSyncAbortSignal;
|
||||
this.activeSyncAbortSignal = opts.signal;
|
||||
try {
|
||||
return await syncAllProvidersImpl.call(this, inputPayload, opts);
|
||||
} finally {
|
||||
this.activeSyncAbortSignal = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -911,12 +947,16 @@ export class CloudSyncManager {
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
options?: {
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
|
||||
return resolveConvergentConflictAndSyncImpl.call(
|
||||
this,
|
||||
addressKey,
|
||||
candidateDot,
|
||||
applyPayload,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { SyncedFile } from '../../../domain/sync';
|
||||
import type { CloudAdapter } from './index';
|
||||
import {
|
||||
cloudAdapterAsEncryptedObjectStorage,
|
||||
encryptedObjectStorageAsCloudAdapter,
|
||||
webdavEncryptedObjectCapabilities,
|
||||
} from './encryptedObjectStorageBridge';
|
||||
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
|
||||
import { createPluginSyncObjectStorage } from './pluginSyncObjectStorage';
|
||||
|
||||
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
|
||||
return {
|
||||
meta: {
|
||||
version,
|
||||
updatedAt: 1,
|
||||
deviceId: 'device',
|
||||
appVersion: '0.0.0',
|
||||
iv: 'iv',
|
||||
salt: 'salt',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryCloudAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
|
||||
const adapter = {
|
||||
store: initial,
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'webdav-host', name: 'user@host' },
|
||||
resourceId: null as string | null,
|
||||
signOut() {
|
||||
adapter.isAuthenticated = false;
|
||||
adapter.accountInfo = null;
|
||||
adapter.store = null;
|
||||
},
|
||||
async initializeSync() {
|
||||
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
return adapter.resourceId;
|
||||
},
|
||||
async upload(file: SyncedFile) {
|
||||
adapter.store = file;
|
||||
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
return adapter.resourceId;
|
||||
},
|
||||
async download() {
|
||||
return adapter.store;
|
||||
},
|
||||
async deleteSync() {
|
||||
adapter.store = null;
|
||||
},
|
||||
getTokens() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe('encryptedObjectStorageBridge', () => {
|
||||
it('adapts WebDAV-style CloudAdapter through the encrypted-object interface', async () => {
|
||||
const adapter = memoryCloudAdapter(makeSyncedFile(3, 'remote-cipher'));
|
||||
const storage = cloudAdapterAsEncryptedObjectStorage(adapter, 'webdav', {
|
||||
capabilities: webdavEncryptedObjectCapabilities(),
|
||||
});
|
||||
|
||||
const caps = await storage.getCapabilities();
|
||||
assert.equal(caps.atomicReplacement, true);
|
||||
assert.equal(caps.revisions, false);
|
||||
|
||||
const connected = await storage.connect();
|
||||
assert.equal(connected.account.id, 'webdav-host');
|
||||
|
||||
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
|
||||
assert.equal(read.found, true);
|
||||
assert.ok(read.bytes);
|
||||
assert.equal(read.revision, '3');
|
||||
|
||||
const next = makeSyncedFile(4, 'next-cipher');
|
||||
const encoded = new TextEncoder().encode(JSON.stringify(next));
|
||||
const written = await storage.writeObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY, encoded);
|
||||
assert.equal(written.created, false);
|
||||
assert.equal(written.revision, '4');
|
||||
|
||||
const reloaded = await adapter.download();
|
||||
assert.equal(reloaded?.payload, 'next-cipher');
|
||||
|
||||
const deleted = await storage.deleteObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
|
||||
assert.equal(deleted.deleted, true);
|
||||
assert.equal(await adapter.download(), null);
|
||||
});
|
||||
|
||||
it('adapts EncryptedObjectStorage back to CloudAdapter for manager upload/download', async () => {
|
||||
const memory = new Map<string, Uint8Array>();
|
||||
let backingResourceId: string | null = '/persisted/path.json';
|
||||
const storage = {
|
||||
providerId: 'com.example.sync',
|
||||
async connect() {
|
||||
backingResourceId = '/after-connect/path.json';
|
||||
return { account: { id: 'plugin-acct' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { id: 'plugin-acct' };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
maxObjectBytes: 1024,
|
||||
};
|
||||
},
|
||||
async readObject(key: string) {
|
||||
const bytes = memory.get(key) ?? null;
|
||||
return bytes
|
||||
? { found: true as const, key, bytes, revision: 'r1' }
|
||||
: { found: false as const, key, bytes: null };
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array) {
|
||||
const created = !memory.has(key);
|
||||
memory.set(key, bytes);
|
||||
return { created, revision: 'r2' };
|
||||
},
|
||||
async deleteObject(key: string) {
|
||||
const deleted = memory.delete(key);
|
||||
return { deleted };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
resourceId: '/persisted/path.json',
|
||||
resolveResourceId: () => backingResourceId,
|
||||
});
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
assert.equal(adapter.resourceId, '/persisted/path.json');
|
||||
|
||||
await adapter.initializeSync();
|
||||
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
|
||||
assert.equal(adapter.resourceId, '/after-connect/path.json');
|
||||
|
||||
const file = makeSyncedFile(9, 'plugin-cipher');
|
||||
await adapter.upload(file);
|
||||
const downloaded = await adapter.download();
|
||||
assert.equal(downloaded?.payload, 'plugin-cipher');
|
||||
assert.equal(downloaded?.meta.version, 9);
|
||||
});
|
||||
|
||||
// note: upload() re-reads for verification; memory map above is sufficient
|
||||
|
||||
it('rebindSession re-issues connect after a prior session for plugin runtime replacement', async () => {
|
||||
const ops: string[] = [];
|
||||
const storage = {
|
||||
providerId: 'com.example.sync',
|
||||
async connect() {
|
||||
ops.push('connect');
|
||||
return { account: { id: 'a1' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { id: 'a1' };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
|
||||
},
|
||||
async readObject(key: string) {
|
||||
return { found: false as const, key, bytes: null };
|
||||
},
|
||||
async writeObject() {
|
||||
return { created: true as const };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: false as const };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
rebindSession: true,
|
||||
});
|
||||
await adapter.initializeSync();
|
||||
await adapter.download();
|
||||
await adapter.download();
|
||||
assert.ok(ops.filter((op) => op === 'connect').length >= 3,
|
||||
'rebindSession must call connect on each ensureConnected path');
|
||||
});
|
||||
|
||||
it('re-issues connect after an I/O failure so replaced runtimes can recover', async () => {
|
||||
const ops: string[] = [];
|
||||
let failNextRead = true;
|
||||
const storage = {
|
||||
providerId: 'com.example.sync',
|
||||
async connect() {
|
||||
ops.push('connect');
|
||||
return { account: { id: 'a1' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { id: 'a1' };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return { revisions: false, conditionalWrites: false, atomicReplacement: true };
|
||||
},
|
||||
async readObject(key: string) {
|
||||
if (failNextRead) {
|
||||
failNextRead = false;
|
||||
throw new Error('runtime gone');
|
||||
}
|
||||
return { found: false as const, key, bytes: null };
|
||||
},
|
||||
async writeObject() {
|
||||
return { created: true as const };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: false as const };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
});
|
||||
await adapter.initializeSync();
|
||||
assert.equal(ops.filter((op) => op === 'connect').length, 1);
|
||||
await assert.rejects(() => adapter.download(), /runtime gone/);
|
||||
await adapter.download();
|
||||
assert.equal(
|
||||
ops.filter((op) => op === 'connect').length,
|
||||
2,
|
||||
'failed I/O must stale the session so the next ensureConnected rebinds',
|
||||
);
|
||||
});
|
||||
|
||||
it('lazy-connects before first I/O and passes revisions for conditional writes', async () => {
|
||||
const ops: string[] = [];
|
||||
let revision: string | undefined = 'rev-1';
|
||||
let stored: Uint8Array | null = new TextEncoder().encode(JSON.stringify(makeSyncedFile(1, 'c')));
|
||||
const storage = {
|
||||
providerId: 'com.example.sync',
|
||||
async connect() {
|
||||
ops.push('connect');
|
||||
return { account: { id: 'a1' } };
|
||||
},
|
||||
async disconnect() {
|
||||
ops.push('disconnect');
|
||||
},
|
||||
async getAccount() {
|
||||
return { id: 'a1' };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return { revisions: true, conditionalWrites: true, atomicReplacement: true };
|
||||
},
|
||||
async readObject(key: string) {
|
||||
ops.push(`read:${key}:${revision ?? ''}`);
|
||||
if (!stored) return { found: false as const, key, bytes: null };
|
||||
return {
|
||||
found: true as const,
|
||||
key,
|
||||
bytes: stored,
|
||||
revision,
|
||||
};
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array, options?: { expectedRevision?: string | null }) {
|
||||
ops.push(`write:${key}:${options?.expectedRevision === null ? 'null' : (options?.expectedRevision ?? '')}`);
|
||||
stored = bytes;
|
||||
revision = revision ? 'rev-2' : 'rev-new';
|
||||
return { created: !revision || revision === 'rev-new', revision };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: true };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
});
|
||||
// Restored session: authenticated for cache reuse, but not yet connected.
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
const downloaded = await adapter.download();
|
||||
assert.equal(downloaded?.payload, 'c');
|
||||
assert.deepEqual(ops[0], 'connect');
|
||||
assert.ok(ops.some((op) => op.startsWith('read:')));
|
||||
|
||||
await adapter.upload(makeSyncedFile(2, 'next'));
|
||||
assert.ok(
|
||||
ops.some((op) => op === 'write:netcatty-vault.json:rev-1'),
|
||||
`expected conditional write with rev-1, got ${JSON.stringify(ops)}`,
|
||||
);
|
||||
|
||||
// Confirmed absence → must-not-exist (expectedRevision null)
|
||||
stored = null;
|
||||
revision = undefined;
|
||||
const adapter2 = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
});
|
||||
assert.equal(await adapter2.download(), null);
|
||||
await adapter2.upload(makeSyncedFile(3, 'fresh'));
|
||||
assert.ok(
|
||||
ops.some((op) => op === 'write:netcatty-vault.json:null'),
|
||||
`expected must-not-exist write, got ${JSON.stringify(ops)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('plugin sync object storage only forwards encrypted bytes to the host', async () => {
|
||||
const calls: string[] = [];
|
||||
const host = {
|
||||
async connectSync(params: { providerId: string; configuration?: unknown }) {
|
||||
calls.push(`connect:${params.providerId}`);
|
||||
assert.deepEqual(params.configuration, { endpoint: 'https://example.test' });
|
||||
return { account: { id: 'a1', name: 'Plugin' } };
|
||||
},
|
||||
async disconnectSync() {
|
||||
calls.push('disconnect');
|
||||
return null;
|
||||
},
|
||||
async getSyncAccount() {
|
||||
return { account: { id: 'a1' } };
|
||||
},
|
||||
async getSyncCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: false,
|
||||
maxObjectBytes: 64,
|
||||
};
|
||||
},
|
||||
async readSyncObject(params: { key: string }) {
|
||||
calls.push(`read:${params.key}`);
|
||||
return {
|
||||
found: true,
|
||||
key: params.key,
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
revision: 'rev-1',
|
||||
};
|
||||
},
|
||||
async writeSyncObject(params: { key: string; bytes: Uint8Array; expectedRevision?: string | null }) {
|
||||
calls.push(`write:${params.key}:${params.bytes.byteLength}:${params.expectedRevision ?? ''}`);
|
||||
// Ensure bytes look like opaque ciphertext, not a vault JSON root.
|
||||
assert.equal(params.bytes[0], 0x9b);
|
||||
return { created: true, revision: 'rev-2' };
|
||||
},
|
||||
async deleteSyncObject(params: { key: string }) {
|
||||
calls.push(`delete:${params.key}`);
|
||||
return { deleted: true };
|
||||
},
|
||||
};
|
||||
|
||||
const storage = createPluginSyncObjectStorage({
|
||||
providerId: 'com.example.sync',
|
||||
host,
|
||||
configuration: { endpoint: 'https://example.test' },
|
||||
});
|
||||
|
||||
await storage.connect();
|
||||
const caps = await storage.getCapabilities();
|
||||
assert.equal(caps.conditionalWrites, true);
|
||||
const read = await storage.readObject('vault');
|
||||
assert.deepEqual([...read.bytes!], [1, 2, 3]);
|
||||
await storage.writeObject('vault', new Uint8Array([0x9b, 0x01]), { expectedRevision: null });
|
||||
await storage.deleteObject('vault');
|
||||
await storage.disconnect();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'connect:com.example.sync',
|
||||
'read:vault',
|
||||
'write:vault:2:',
|
||||
'delete:vault',
|
||||
'disconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips host re-read when assumeVerifiedWrites is set (WebDAV path)', async () => {
|
||||
const ops: string[] = [];
|
||||
let stored: Uint8Array | null = null;
|
||||
const storage = {
|
||||
providerId: 'webdav',
|
||||
async connect() {
|
||||
ops.push('connect');
|
||||
return { account: { id: 'w1' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { id: 'w1' };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return webdavEncryptedObjectCapabilities();
|
||||
},
|
||||
async readObject(key: string) {
|
||||
ops.push(`read:${key}`);
|
||||
if (!stored) return { found: false as const, key, bytes: null };
|
||||
return { found: true as const, key, bytes: stored, revision: '1' };
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array) {
|
||||
ops.push(`write:${key}`);
|
||||
stored = bytes;
|
||||
return { created: true as const, revision: '1' };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: true as const };
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
assumeVerifiedWrites: true,
|
||||
});
|
||||
await adapter.upload(makeSyncedFile(1, 'body'));
|
||||
assert.deepEqual(
|
||||
ops.filter((op) => op.startsWith('read:')),
|
||||
[],
|
||||
'assumeVerifiedWrites must not re-read after write',
|
||||
);
|
||||
assert.ok(ops.includes('write:netcatty-vault.json'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Bridges between the legacy single-file CloudAdapter interface and the
|
||||
* shared EncryptedObjectStorage surface used by plugin sync Providers.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EncryptedObjectAccount,
|
||||
EncryptedObjectDeleteResult,
|
||||
EncryptedObjectReadResult,
|
||||
EncryptedObjectStorage,
|
||||
EncryptedObjectStorageCapabilities,
|
||||
EncryptedObjectWriteResult,
|
||||
} from '../../../domain/encryptedObjectStorage';
|
||||
// EncryptedObjectStorageCapabilities used for session capability gating.
|
||||
import {
|
||||
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
|
||||
} from '../../../domain/encryptedObjectStorage';
|
||||
import type {
|
||||
CloudProvider,
|
||||
OAuthTokens,
|
||||
ProviderAccount,
|
||||
SyncedFile,
|
||||
} from '../../../domain/sync';
|
||||
import type { CloudAdapter } from './index';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function syncedFileToBytes(syncedFile: SyncedFile): Uint8Array {
|
||||
return textEncoder.encode(JSON.stringify(syncedFile));
|
||||
}
|
||||
|
||||
function bytesToSyncedFile(bytes: Uint8Array): SyncedFile {
|
||||
const raw = textDecoder.decode(bytes);
|
||||
// Require a complete JSON object. Do not accept a valid prefix with trailing
|
||||
// garbage — that hides provider corruption from read-and-verify recovery.
|
||||
return JSON.parse(raw) as SyncedFile;
|
||||
}
|
||||
|
||||
/** Thrown when a conditional write is rejected (revision / precondition). */
|
||||
export class ConditionalWriteConflictError extends Error {
|
||||
readonly code = 'conditional_write_conflict';
|
||||
|
||||
constructor(message = 'Encrypted object conditional write was rejected', options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = 'ConditionalWriteConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isConditionalWriteConflictError(error: unknown): boolean {
|
||||
if (error instanceof ConditionalWriteConflictError) return true;
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const maybe = error as { name?: unknown; code?: unknown; message?: unknown; data?: { pluginCode?: unknown } };
|
||||
if (maybe.name === 'ConditionalWriteConflictError') return true;
|
||||
if (maybe.code === 'conditional_write_conflict' || maybe.code === -32009 || maybe.code === 'failed_precondition') {
|
||||
return true;
|
||||
}
|
||||
if (maybe.data?.pluginCode === 'failed_precondition') return true;
|
||||
return typeof maybe.message === 'string'
|
||||
&& /failed[_ ]precondition|expectedRevision|revision mismatch|conditional write/i.test(maybe.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a legacy CloudAdapter into EncryptedObjectStorage.
|
||||
* Uses a single default object key matching the historical vault file name.
|
||||
*/
|
||||
export function cloudAdapterAsEncryptedObjectStorage(
|
||||
adapter: CloudAdapter,
|
||||
providerId: string,
|
||||
options: {
|
||||
objectKey?: string;
|
||||
capabilities?: EncryptedObjectStorageCapabilities;
|
||||
} = {},
|
||||
): EncryptedObjectStorage {
|
||||
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
const capabilities: EncryptedObjectStorageCapabilities = options.capabilities ?? {
|
||||
revisions: false,
|
||||
conditionalWrites: false,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
|
||||
return {
|
||||
providerId,
|
||||
async connect(): Promise<{ account: EncryptedObjectAccount }> {
|
||||
if (!adapter.isAuthenticated) {
|
||||
throw new Error(`Cloud provider ${providerId} is not authenticated`);
|
||||
}
|
||||
await adapter.initializeSync();
|
||||
const account = adapter.accountInfo;
|
||||
if (!account) {
|
||||
return { account: { id: providerId } };
|
||||
}
|
||||
return { account: { ...account } };
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
adapter.signOut();
|
||||
},
|
||||
async getAccount(): Promise<EncryptedObjectAccount | null> {
|
||||
return adapter.accountInfo ? { ...adapter.accountInfo } : null;
|
||||
},
|
||||
async getCapabilities(): Promise<EncryptedObjectStorageCapabilities> {
|
||||
return { ...capabilities };
|
||||
},
|
||||
async readObject(key: string): Promise<EncryptedObjectReadResult> {
|
||||
if (key !== objectKey) {
|
||||
return { found: false, key, bytes: null };
|
||||
}
|
||||
const file = await adapter.download();
|
||||
if (!file) return { found: false, key, bytes: null };
|
||||
const bytes = syncedFileToBytes(file);
|
||||
return {
|
||||
found: true,
|
||||
key,
|
||||
bytes,
|
||||
revision: file.meta?.version != null ? String(file.meta.version) : undefined,
|
||||
contentType: 'application/json',
|
||||
};
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array): Promise<EncryptedObjectWriteResult> {
|
||||
if (key !== objectKey) {
|
||||
throw new Error(`Cloud adapter ${providerId} only supports object key ${objectKey}`);
|
||||
}
|
||||
const syncedFile = bytesToSyncedFile(bytes);
|
||||
// Skip a pre-upload GET for `created`. The single-object vault path does not
|
||||
// consume that flag, and WebDAV already pays for pad+verify inside upload.
|
||||
await adapter.upload(syncedFile);
|
||||
return {
|
||||
created: false,
|
||||
revision: syncedFile.meta?.version != null ? String(syncedFile.meta.version) : undefined,
|
||||
};
|
||||
},
|
||||
async deleteObject(key: string): Promise<EncryptedObjectDeleteResult> {
|
||||
if (key !== objectKey) {
|
||||
return { deleted: false };
|
||||
}
|
||||
const existing = await adapter.download();
|
||||
if (!existing) return { deleted: false };
|
||||
await adapter.deleteSync();
|
||||
return { deleted: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt EncryptedObjectStorage into the legacy CloudAdapter surface so the
|
||||
* existing encrypt→upload / download→decrypt manager path can drive WebDAV and
|
||||
* plugin providers through one code path.
|
||||
*
|
||||
* Authentication and resourceId must match pre-bridge CloudAdapter semantics:
|
||||
* config-backed providers (WebDAV) report authenticated as soon as credentials
|
||||
* exist so getConnectedAdapter can reuse the cached instance; resourceId must
|
||||
* preserve the persisted path (or the backing adapter's authoritative id) rather
|
||||
* than always forcing the default object key.
|
||||
*/
|
||||
export function encryptedObjectStorageAsCloudAdapter(
|
||||
storage: EncryptedObjectStorage,
|
||||
options: {
|
||||
objectKey?: string;
|
||||
account?: ProviderAccount | null;
|
||||
/** When true, getConnectedAdapter reuses this instance without recreating. */
|
||||
initiallyAuthenticated?: boolean;
|
||||
/** Seeded resource id (e.g. path restored from provider connection storage). */
|
||||
resourceId?: string | null;
|
||||
/**
|
||||
* Prefer the backing adapter's resource id after connect/upload (WebDAV sets
|
||||
* `/netcatty-vault.json` via initializeSync; plugins may use object keys).
|
||||
*/
|
||||
resolveResourceId?: () => string | null | undefined;
|
||||
/**
|
||||
* When true, re-issue connect() even if a prior session was established.
|
||||
* Required for plugin providers whose in-process clients die on runtime
|
||||
* restart/replace while the CloudAdapter cache stays alive.
|
||||
*/
|
||||
rebindSession?: boolean;
|
||||
/**
|
||||
* When true, skip the host full-byte re-read after writeObject. Use only when
|
||||
* the backing storage already performs Netcatty-grade write verification
|
||||
* (WebDAV pad+verify). Plugin providers keep host-owned verify.
|
||||
*/
|
||||
assumeVerifiedWrites?: boolean;
|
||||
} = {},
|
||||
): CloudAdapter {
|
||||
const objectKey = options.objectKey ?? DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
let account: ProviderAccount | null = options.account ?? null;
|
||||
let resourceId: string | null = options.resourceId ?? null;
|
||||
let authenticated = options.initiallyAuthenticated === true;
|
||||
/** Distinct from credential presence: plugin providers need an explicit connect. */
|
||||
let sessionConnected = false;
|
||||
/**
|
||||
* Last observed remote revision for conditional writes.
|
||||
* - string: known revision
|
||||
* - null: confirmed absent (must-not-exist write)
|
||||
* - undefined: unknown / unconditional
|
||||
*/
|
||||
let lastRevision: string | null | undefined;
|
||||
|
||||
const refreshResourceId = (fallback?: string | null): string | null => {
|
||||
const resolved = options.resolveResourceId?.();
|
||||
if (typeof resolved === 'string' && resolved.length > 0) {
|
||||
resourceId = resolved;
|
||||
return resourceId;
|
||||
}
|
||||
if (typeof fallback === 'string' && fallback.length > 0) {
|
||||
resourceId = fallback;
|
||||
return resourceId;
|
||||
}
|
||||
return resourceId;
|
||||
};
|
||||
|
||||
let capabilities: EncryptedObjectStorageCapabilities | null = null;
|
||||
|
||||
const ensureConnected = async (): Promise<void> => {
|
||||
if (sessionConnected && !options.rebindSession) return;
|
||||
// rebindSession: always re-run connect so a replaced plugin runtime gets a
|
||||
// fresh client. Connect is expected to be idempotent when still healthy.
|
||||
const result = await storage.connect();
|
||||
account = result.account;
|
||||
capabilities = await storage.getCapabilities();
|
||||
authenticated = true;
|
||||
sessionConnected = true;
|
||||
refreshResourceId(objectKey);
|
||||
};
|
||||
|
||||
/** After I/O failure, force the next ensureConnected to re-issue connect(). */
|
||||
const markSessionStale = (): void => {
|
||||
sessionConnected = false;
|
||||
};
|
||||
|
||||
return {
|
||||
get isAuthenticated() {
|
||||
return authenticated;
|
||||
},
|
||||
get accountInfo() {
|
||||
return account;
|
||||
},
|
||||
get resourceId() {
|
||||
return resourceId;
|
||||
},
|
||||
signOut() {
|
||||
authenticated = false;
|
||||
sessionConnected = false;
|
||||
lastRevision = undefined;
|
||||
capabilities = null;
|
||||
account = null;
|
||||
resourceId = null;
|
||||
// Fire-and-forget is intentional for CloudAdapter.signOut sync API;
|
||||
// disconnectProvider should call initializeSync/connect after a full await
|
||||
// path when a future async signOut is added to CloudAdapter.
|
||||
void Promise.resolve(storage.disconnect()).catch(() => {
|
||||
// Plugin runtime may already be gone; local sign-out still succeeds.
|
||||
});
|
||||
},
|
||||
async initializeSync(): Promise<string | null> {
|
||||
try {
|
||||
await ensureConnected();
|
||||
return refreshResourceId(objectKey);
|
||||
} catch (error) {
|
||||
markSessionStale();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async upload(syncedFile: SyncedFile, uploadOptions?: { signal?: AbortSignal }): Promise<string> {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const bytes = syncedFileToBytes(syncedFile);
|
||||
if (
|
||||
capabilities?.maxObjectBytes != null
|
||||
&& bytes.byteLength > capabilities.maxObjectBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`Encrypted object exceeds provider maxObjectBytes (${capabilities.maxObjectBytes})`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
capabilities?.maxObjects != null
|
||||
&& capabilities.maxObjects < 1
|
||||
) {
|
||||
throw new Error(
|
||||
`Encrypted object provider reports maxObjects < 1 (${capabilities.maxObjects})`,
|
||||
);
|
||||
}
|
||||
const conditional = capabilities?.conditionalWrites === true;
|
||||
let writeResult: EncryptedObjectWriteResult;
|
||||
try {
|
||||
writeResult = await storage.writeObject(objectKey, bytes, {
|
||||
...(conditional && lastRevision !== undefined
|
||||
? { expectedRevision: lastRevision }
|
||||
: {}),
|
||||
signal: uploadOptions?.signal,
|
||||
});
|
||||
} catch (writeError) {
|
||||
if (conditional && isConditionalWriteConflictError(writeError)) {
|
||||
throw new ConditionalWriteConflictError(
|
||||
writeError instanceof Error ? writeError.message : String(writeError),
|
||||
{ cause: writeError },
|
||||
);
|
||||
}
|
||||
throw writeError;
|
||||
}
|
||||
if (options.assumeVerifiedWrites === true) {
|
||||
// Backing adapter already verified (e.g. WebDAV pad+verify). Still honor
|
||||
// maxObjectBytes above and refresh revision from the write result.
|
||||
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
|
||||
lastRevision = writeResult.revision;
|
||||
}
|
||||
} else {
|
||||
// Host-owned write verification: re-read and compare ciphertext bytes.
|
||||
const verified = await storage.readObject(objectKey, { signal: uploadOptions?.signal });
|
||||
if (!verified.found || !verified.bytes) {
|
||||
throw new Error('Encrypted object write verification failed: object missing after write');
|
||||
}
|
||||
if (verified.bytes.byteLength !== bytes.byteLength) {
|
||||
throw new Error('Encrypted object write verification failed: size mismatch');
|
||||
}
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
if (verified.bytes[i] !== bytes[i]) {
|
||||
throw new Error('Encrypted object write verification failed: content mismatch');
|
||||
}
|
||||
}
|
||||
if (typeof writeResult.revision === 'string' && writeResult.revision.length > 0) {
|
||||
lastRevision = writeResult.revision;
|
||||
} else if (typeof verified.revision === 'string' && verified.revision.length > 0) {
|
||||
lastRevision = verified.revision;
|
||||
} else {
|
||||
lastRevision = undefined;
|
||||
}
|
||||
}
|
||||
authenticated = true;
|
||||
return refreshResourceId(objectKey) ?? objectKey;
|
||||
} catch (error) {
|
||||
markSessionStale();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async download(downloadOptions?: { signal?: AbortSignal }): Promise<SyncedFile | null> {
|
||||
try {
|
||||
await ensureConnected();
|
||||
const result = await storage.readObject(objectKey, { signal: downloadOptions?.signal });
|
||||
if (!result.found || !result.bytes) {
|
||||
// Confirmed absence: next conditional write must use expectedRevision null.
|
||||
lastRevision = null;
|
||||
return null;
|
||||
}
|
||||
if (typeof result.revision === 'string' && result.revision.length > 0) {
|
||||
lastRevision = result.revision;
|
||||
} else {
|
||||
lastRevision = undefined;
|
||||
}
|
||||
return bytesToSyncedFile(result.bytes);
|
||||
} catch (error) {
|
||||
markSessionStale();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async deleteSync(deleteOptions?: { signal?: AbortSignal }): Promise<void> {
|
||||
try {
|
||||
await ensureConnected();
|
||||
await storage.deleteObject(objectKey, {
|
||||
...(typeof lastRevision === 'string' ? { expectedRevision: lastRevision } : {}),
|
||||
signal: deleteOptions?.signal,
|
||||
});
|
||||
lastRevision = null;
|
||||
} catch (error) {
|
||||
markSessionStale();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getTokens(): OAuthTokens | null {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* WebDAV-specific capabilities: atomic replacement via temp+MOVE, no native revisions.
|
||||
*/
|
||||
export function webdavEncryptedObjectCapabilities(): EncryptedObjectStorageCapabilities {
|
||||
return {
|
||||
revisions: false,
|
||||
conditionalWrites: false,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWebdavProvider(provider: CloudProvider): boolean {
|
||||
return provider === 'webdav';
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import type {
|
||||
WebDAVConfig,
|
||||
S3Config,
|
||||
} from '../../../domain/sync';
|
||||
import { isBuiltinCloudProvider } from '../../../domain/sync';
|
||||
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
|
||||
import {
|
||||
cloudAdapterAsEncryptedObjectStorage,
|
||||
encryptedObjectStorageAsCloudAdapter,
|
||||
webdavEncryptedObjectCapabilities,
|
||||
} from './encryptedObjectStorageBridge';
|
||||
|
||||
/**
|
||||
* Unified adapter interface
|
||||
@@ -21,20 +28,28 @@ export interface CloudAdapter {
|
||||
|
||||
signOut(): void;
|
||||
initializeSync(): Promise<string | null>;
|
||||
upload(syncedFile: SyncedFile): Promise<string>;
|
||||
download(): Promise<SyncedFile | null>;
|
||||
deleteSync(): Promise<void>;
|
||||
upload(syncedFile: SyncedFile, options?: { signal?: AbortSignal }): Promise<string>;
|
||||
download(options?: { signal?: AbortSignal }): Promise<SyncedFile | null>;
|
||||
deleteSync(options?: { signal?: AbortSignal }): Promise<void>;
|
||||
getTokens(): OAuthTokens | null;
|
||||
}
|
||||
|
||||
export type { EncryptedObjectStorage };
|
||||
|
||||
/**
|
||||
* Create adapter for a specific provider
|
||||
* Create adapter for a specific provider.
|
||||
* Built-in providers keep their dedicated adapters. Namespaced plugin provider
|
||||
* IDs require an EncryptedObjectStorage factory (plugin host bridge).
|
||||
*/
|
||||
export const createAdapter = async (
|
||||
provider: CloudProvider,
|
||||
tokens?: OAuthTokens,
|
||||
resourceId?: string,
|
||||
config?: WebDAVConfig | S3Config
|
||||
config?: WebDAVConfig | S3Config,
|
||||
options?: {
|
||||
/** Plugin sync storage factory for namespaced provider IDs. */
|
||||
createPluginStorage?: (providerId: string) => EncryptedObjectStorage | Promise<EncryptedObjectStorage>;
|
||||
},
|
||||
): Promise<CloudAdapter> => {
|
||||
switch (provider) {
|
||||
case 'github': {
|
||||
@@ -51,13 +66,67 @@ export const createAdapter = async (
|
||||
}
|
||||
case 'webdav': {
|
||||
const { WebDAVAdapter } = await import('./WebDAVAdapter');
|
||||
return new WebDAVAdapter(config as WebDAVConfig | undefined, resourceId);
|
||||
// Production WebDAV path runs through EncryptedObjectStorage so plugin
|
||||
// providers and WebDAV share one encrypt→write / read→decrypt surface.
|
||||
const raw = new WebDAVAdapter(config as WebDAVConfig | undefined, resourceId);
|
||||
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
|
||||
capabilities: webdavEncryptedObjectCapabilities(),
|
||||
});
|
||||
return encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
account: raw.accountInfo,
|
||||
// Match raw WebDAVAdapter: config present ⇒ authenticated for cache reuse.
|
||||
initiallyAuthenticated: raw.isAuthenticated,
|
||||
// Preserve constructor/persisted resourceId; refresh from raw after connect.
|
||||
resourceId: raw.resourceId ?? resourceId ?? null,
|
||||
resolveResourceId: () => raw.resourceId,
|
||||
// WebDAVAdapter.upload already performs pad + strict body verify; skip the
|
||||
// shared bridge's second full re-read to avoid write amplification.
|
||||
assumeVerifiedWrites: true,
|
||||
});
|
||||
}
|
||||
case 's3': {
|
||||
const { S3Adapter } = await import('./S3Adapter');
|
||||
return new S3Adapter(config as S3Config | undefined, resourceId);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
default: {
|
||||
if (isBuiltinCloudProvider(provider)) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
if (!options?.createPluginStorage) {
|
||||
throw new Error(
|
||||
`Plugin sync provider ${provider} is unavailable (missing or not registered)`,
|
||||
);
|
||||
}
|
||||
const storage = await options.createPluginStorage(provider);
|
||||
// Credentials/config already validated by getConnectedAdapter; report
|
||||
// authenticated so the manager reuses this instance across sync calls.
|
||||
// rebindSession: plugin runtimes can restart while availability membership
|
||||
// stays unchanged, so ensureConnected must re-issue connect() with the
|
||||
// saved configuration/credential instead of skipping after the first call.
|
||||
return encryptedObjectStorageAsCloudAdapter(storage, {
|
||||
initiallyAuthenticated: true,
|
||||
resourceId: resourceId ?? null,
|
||||
rebindSession: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* View a CloudAdapter as EncryptedObjectStorage. WebDAV is the first built-in
|
||||
* adapted through this shared surface for configuration, secrets, upload,
|
||||
* download, verification, and recovery exercises.
|
||||
*/
|
||||
export const asEncryptedObjectStorage = (
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
): EncryptedObjectStorage =>
|
||||
cloudAdapterAsEncryptedObjectStorage(adapter, provider, {
|
||||
capabilities: provider === 'webdav'
|
||||
? webdavEncryptedObjectCapabilities()
|
||||
: {
|
||||
revisions: provider === 'github',
|
||||
conditionalWrites: false,
|
||||
atomicReplacement: provider === 'webdav' || provider === 's3',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Renderer-side host that drives plugin sync Providers over the preload IPC
|
||||
* surface. Main process extensionProviderService owns activation, permission
|
||||
* checks, and stream handling; this client shuttles already-encrypted object
|
||||
* bytes with inline Uint8Array for small objects and pull/chunked transfers
|
||||
* above the SyncLimits inline cutoff.
|
||||
*/
|
||||
|
||||
import {
|
||||
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
|
||||
} from '@netcatty/plugin-contract';
|
||||
import type {
|
||||
EncryptedObjectAccount,
|
||||
EncryptedObjectStorageCapabilities,
|
||||
} from '../../../domain/encryptedObjectStorage';
|
||||
import type { PluginSyncProviderHost } from './pluginSyncObjectStorage';
|
||||
|
||||
/** Match host STREAM_WINDOW_BYTES so each IPC chunk maps 1:1 to a plugin stream write. */
|
||||
const STREAM_WINDOW_BYTES = 256 * 1024;
|
||||
/** Same as main INLINE_SYNC_OBJECT_SAFE_BYTES (aligned SyncLimits.inlineObjectBytes). */
|
||||
const INLINE_SYNC_OBJECT_SAFE_BYTES = PLUGIN_SYNC_INLINE_OBJECT_BYTES;
|
||||
|
||||
type ElectronPluginSyncApi = {
|
||||
cancelPluginExtensionRequest?: (requestId: string) => Promise<boolean>;
|
||||
pluginSyncConnect?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
configuration?: unknown;
|
||||
credential?: unknown;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{ account: EncryptedObjectAccount }>;
|
||||
pluginSyncDisconnect?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<null>;
|
||||
pluginSyncGetAccount?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{ account: EncryptedObjectAccount | null }>;
|
||||
pluginSyncGetCapabilities?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<EncryptedObjectStorageCapabilities>;
|
||||
pluginSyncReadObject?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
preferStream?: boolean;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{
|
||||
found: boolean;
|
||||
key: string;
|
||||
data?: Uint8Array | null;
|
||||
streamed?: boolean;
|
||||
transferId?: string;
|
||||
byteLength?: number;
|
||||
revision?: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
pluginSyncReadChunk?: (params: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
maxBytes?: number;
|
||||
}) => Promise<{ chunk: Uint8Array; done: boolean }>;
|
||||
pluginSyncWriteObject?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
data: Uint8Array;
|
||||
expectedRevision?: string | null;
|
||||
preferStream?: boolean;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{ created: boolean; revision?: string }>;
|
||||
pluginSyncWriteBegin?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
byteLength: number;
|
||||
expectedRevision?: string | null;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{ transferId: string; windowBytes: number }>;
|
||||
pluginSyncWriteChunk?: (params: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
sequence: number;
|
||||
chunk: Uint8Array;
|
||||
}) => Promise<{ accepted: number }>;
|
||||
pluginSyncWriteCommit?: (params: {
|
||||
requestId: string;
|
||||
transferId: string;
|
||||
}) => Promise<{ created: boolean; revision?: string }>;
|
||||
pluginSyncDeleteObject?: (params: {
|
||||
requestId: string;
|
||||
providerId: string;
|
||||
key: string;
|
||||
expectedRevision?: string;
|
||||
deadlineMs?: number;
|
||||
}) => Promise<{ deleted: boolean }>;
|
||||
pluginSyncPutSecret?: (params: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) => Promise<{ kind: 'secret'; id: string; key: string }>;
|
||||
pluginSyncDeleteSecrets?: (params: {
|
||||
providerId: string;
|
||||
keys?: string[];
|
||||
}) => Promise<{ deleted: number }>;
|
||||
};
|
||||
|
||||
function getPluginSyncApi(): ElectronPluginSyncApi | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Preload exposes the production bridge as window.netcatty only.
|
||||
const bridge = (window as Window & {
|
||||
netcatty?: ElectronPluginSyncApi;
|
||||
electron?: ElectronPluginSyncApi;
|
||||
}).netcatty
|
||||
?? (window as Window & { electron?: ElectronPluginSyncApi }).electron;
|
||||
return bridge ?? null;
|
||||
}
|
||||
|
||||
function mintRequestId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `sync-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function coerceBytes(value: unknown): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value;
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
const view = value as ArrayBufferView;
|
||||
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
if (value && typeof value === 'object' && (value as { type?: string }).type === 'Buffer'
|
||||
&& Array.isArray((value as { data?: unknown }).data)) {
|
||||
return Uint8Array.from((value as { data: number[] }).data);
|
||||
}
|
||||
throw new Error('Plugin sync IPC expected binary bytes');
|
||||
}
|
||||
|
||||
async function withAbortSignal<T>(
|
||||
api: ElectronPluginSyncApi,
|
||||
signal: AbortSignal | undefined,
|
||||
run: (requestId: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const requestId = mintRequestId();
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
let onAbort: (() => void) | undefined;
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
void api.cancelPluginExtensionRequest?.(requestId).catch(() => false);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
try {
|
||||
return await run(requestId);
|
||||
} catch (error) {
|
||||
// Release main-process transfer / abort controller on stream or RPC failure
|
||||
// even when the caller did not abort (TTL alone is not enough).
|
||||
try {
|
||||
await api.cancelPluginExtensionRequest?.(requestId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPluginSyncIpcAvailable(): boolean {
|
||||
const api = getPluginSyncApi();
|
||||
if (
|
||||
typeof api?.pluginSyncConnect !== 'function'
|
||||
|| typeof api?.pluginSyncReadObject !== 'function'
|
||||
|| typeof api?.pluginSyncWriteObject !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Preload always exposes the IPC methods; the host is only ready when the
|
||||
// main process has a live plugin host (sync sidecar service wired).
|
||||
const ready = (api as { pluginHostReady?: () => boolean }).pluginHostReady;
|
||||
if (typeof ready === 'function') {
|
||||
try {
|
||||
return ready() === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PluginSyncProviderHost bound to the renderer preload API.
|
||||
* Throws when the plugin development gate / host is unavailable.
|
||||
*/
|
||||
export function createPluginSyncIpcHost(): PluginSyncProviderHost {
|
||||
return {
|
||||
async connectSync(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncConnect !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncConnect!({
|
||||
...params,
|
||||
requestId,
|
||||
}));
|
||||
},
|
||||
async disconnectSync(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncDisconnect !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDisconnect!({
|
||||
...params,
|
||||
requestId,
|
||||
}));
|
||||
},
|
||||
async getSyncAccount(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncGetAccount !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetAccount!({
|
||||
...params,
|
||||
requestId,
|
||||
}));
|
||||
},
|
||||
async getSyncCapabilities(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncGetCapabilities !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncGetCapabilities!({
|
||||
...params,
|
||||
requestId,
|
||||
}));
|
||||
},
|
||||
async readSyncObject(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncReadObject !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, async (requestId) => {
|
||||
const preferStream = params.preferStream === true;
|
||||
const result = await api.pluginSyncReadObject!({
|
||||
...params,
|
||||
preferStream,
|
||||
requestId,
|
||||
});
|
||||
if (!result.found) {
|
||||
return { found: false, key: params.key, bytes: null };
|
||||
}
|
||||
if (result.streamed === true && typeof result.transferId === 'string') {
|
||||
if (typeof api.pluginSyncReadChunk !== 'function') {
|
||||
throw new Error('Plugin sync streamed read is unavailable');
|
||||
}
|
||||
const total = result.byteLength;
|
||||
if (!Number.isSafeInteger(total) || total < 1) {
|
||||
throw new Error('Plugin sync streamed read missing byteLength');
|
||||
}
|
||||
const parts: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
for (;;) {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
const { chunk, done } = await api.pluginSyncReadChunk({
|
||||
requestId,
|
||||
transferId: result.transferId,
|
||||
maxBytes: STREAM_WINDOW_BYTES,
|
||||
});
|
||||
const bytes = coerceBytes(chunk);
|
||||
parts.push(bytes);
|
||||
received += bytes.byteLength;
|
||||
if (received > total) {
|
||||
throw new Error('Plugin sync read exceeded declared byteLength');
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
if (received !== total) {
|
||||
throw new Error('Plugin sync read size does not match byteLength');
|
||||
}
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
merged.set(part, offset);
|
||||
offset += part.byteLength;
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
key: result.key,
|
||||
bytes: merged,
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
}
|
||||
if (result.data == null) {
|
||||
return { found: false, key: params.key, bytes: null };
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
key: result.key,
|
||||
bytes: coerceBytes(result.data),
|
||||
revision: result.revision,
|
||||
contentType: result.contentType,
|
||||
};
|
||||
});
|
||||
},
|
||||
async writeSyncObject(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncWriteObject !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, async (requestId) => {
|
||||
const bytes = params.bytes instanceof Uint8Array
|
||||
? params.bytes
|
||||
: new Uint8Array(params.bytes);
|
||||
const useStream = bytes.byteLength > INLINE_SYNC_OBJECT_SAFE_BYTES
|
||||
|| params.preferStream === true;
|
||||
if (!useStream) {
|
||||
return api.pluginSyncWriteObject!({
|
||||
requestId,
|
||||
providerId: params.providerId,
|
||||
key: params.key,
|
||||
data: bytes,
|
||||
expectedRevision: params.expectedRevision,
|
||||
preferStream: false,
|
||||
deadlineMs: params.deadlineMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof api.pluginSyncWriteBegin !== 'function'
|
||||
|| typeof api.pluginSyncWriteChunk !== 'function'
|
||||
|| typeof api.pluginSyncWriteCommit !== 'function'
|
||||
) {
|
||||
throw new Error('Plugin sync streamed write is unavailable');
|
||||
}
|
||||
const { transferId, windowBytes } = await api.pluginSyncWriteBegin({
|
||||
requestId,
|
||||
providerId: params.providerId,
|
||||
key: params.key,
|
||||
byteLength: bytes.byteLength,
|
||||
expectedRevision: params.expectedRevision,
|
||||
deadlineMs: params.deadlineMs,
|
||||
});
|
||||
const chunkSize = Number.isSafeInteger(windowBytes) && windowBytes > 0
|
||||
? Math.min(windowBytes, STREAM_WINDOW_BYTES)
|
||||
: STREAM_WINDOW_BYTES;
|
||||
let sequence = 0;
|
||||
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
|
||||
if (options?.signal?.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
const end = Math.min(bytes.byteLength, offset + chunkSize);
|
||||
await api.pluginSyncWriteChunk({
|
||||
requestId,
|
||||
transferId,
|
||||
sequence,
|
||||
chunk: bytes.subarray(offset, end),
|
||||
});
|
||||
sequence += 1;
|
||||
}
|
||||
return api.pluginSyncWriteCommit({ requestId, transferId });
|
||||
});
|
||||
},
|
||||
async deleteSyncObject(params, options) {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncDeleteObject !== 'function') {
|
||||
throw new Error('Plugin sync host is unavailable');
|
||||
}
|
||||
return withAbortSignal(api, options?.signal, (requestId) => api.pluginSyncDeleteObject!({
|
||||
...params,
|
||||
requestId,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Store a sync credential in the OS-backed plugin secret store; returns an opaque SecretRef. */
|
||||
export async function putPluginSyncSecret(params: {
|
||||
providerId: string;
|
||||
key?: string;
|
||||
value: string;
|
||||
}): Promise<{ kind: 'secret'; id: string; key: string }> {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncPutSecret !== 'function') {
|
||||
throw new Error('Plugin sync secret storage is unavailable');
|
||||
}
|
||||
return api.pluginSyncPutSecret({
|
||||
providerId: params.providerId,
|
||||
key: params.key ?? 'sync-credential',
|
||||
value: params.value,
|
||||
});
|
||||
}
|
||||
|
||||
/** Best-effort delete of OS-backed sync secrets for a plugin provider (disconnect cleanup). */
|
||||
export async function deletePluginSyncSecrets(params: {
|
||||
providerId: string;
|
||||
keys?: string[];
|
||||
}): Promise<{ deleted: number }> {
|
||||
const api = getPluginSyncApi();
|
||||
if (typeof api?.pluginSyncDeleteSecrets !== 'function') {
|
||||
return { deleted: 0 };
|
||||
}
|
||||
return api.pluginSyncDeleteSecrets({
|
||||
providerId: params.providerId,
|
||||
...(params.keys ? { keys: [...params.keys] } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* EncryptedObjectStorage implementation that drives a plugin sync Provider
|
||||
* through the host extension Provider service. Plugins only ever see already
|
||||
* encrypted object bytes — never the master key or plaintext vault.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EncryptedObjectAccount,
|
||||
EncryptedObjectDeleteResult,
|
||||
EncryptedObjectReadResult,
|
||||
EncryptedObjectStorage,
|
||||
EncryptedObjectStorageCapabilities,
|
||||
EncryptedObjectWriteResult,
|
||||
} from '../../../domain/encryptedObjectStorage';
|
||||
import type { PluginSyncCredentialRef as DurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||||
|
||||
/**
|
||||
* 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(
|
||||
params: {
|
||||
providerId: string;
|
||||
configuration?: unknown;
|
||||
credential?: PluginSyncCredentialRef;
|
||||
deadlineMs?: number;
|
||||
},
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ account: EncryptedObjectAccount }>;
|
||||
disconnectSync(
|
||||
params: { providerId: string; deadlineMs?: number },
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<null>;
|
||||
getSyncAccount(
|
||||
params: { providerId: string; deadlineMs?: number },
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ account: EncryptedObjectAccount | null }>;
|
||||
getSyncCapabilities(
|
||||
params: { providerId: string; deadlineMs?: number },
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<EncryptedObjectStorageCapabilities>;
|
||||
readSyncObject(
|
||||
params: { providerId: string; key: string; preferStream?: boolean; deadlineMs?: number },
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{
|
||||
found: boolean;
|
||||
key: string;
|
||||
bytes: Uint8Array | null;
|
||||
revision?: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
writeSyncObject(
|
||||
params: {
|
||||
providerId: string;
|
||||
key: string;
|
||||
bytes: Uint8Array;
|
||||
expectedRevision?: string | null;
|
||||
preferStream?: boolean;
|
||||
deadlineMs?: number;
|
||||
},
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ created: boolean; revision?: string }>;
|
||||
deleteSyncObject(
|
||||
params: { providerId: string; key: string; expectedRevision?: string; deadlineMs?: number },
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ deleted: boolean }>;
|
||||
}
|
||||
|
||||
export function createPluginSyncObjectStorage(options: {
|
||||
providerId: string;
|
||||
host: PluginSyncProviderHost;
|
||||
configuration?: unknown;
|
||||
/** Canonical SyncConnectPayload.credential for host-owned secret refs. */
|
||||
credential?: PluginSyncCredentialRef;
|
||||
deadlineMs?: number;
|
||||
}): EncryptedObjectStorage {
|
||||
const { providerId, host, configuration, credential, deadlineMs } = options;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
async connect(connectConfiguration, connectOptions): Promise<{ account: EncryptedObjectAccount }> {
|
||||
// Only treat undefined as "use stored / default". Explicit null is a
|
||||
// valid JSON configuration for schemas with type: "null".
|
||||
const resolvedConfiguration = connectConfiguration !== undefined
|
||||
? connectConfiguration
|
||||
: (configuration !== undefined ? configuration : {});
|
||||
return host.connectSync({
|
||||
providerId,
|
||||
configuration: resolvedConfiguration,
|
||||
...(credential ? { credential } : {}),
|
||||
deadlineMs,
|
||||
}, { signal: connectOptions?.signal });
|
||||
},
|
||||
async disconnect(disconnectOptions): Promise<void> {
|
||||
await host.disconnectSync({ providerId, deadlineMs }, { signal: disconnectOptions?.signal });
|
||||
},
|
||||
async getAccount(accountOptions): Promise<EncryptedObjectAccount | null> {
|
||||
const result = await host.getSyncAccount({ providerId, deadlineMs }, { signal: accountOptions?.signal });
|
||||
return result.account ?? null;
|
||||
},
|
||||
async getCapabilities(capOptions): Promise<EncryptedObjectStorageCapabilities> {
|
||||
return host.getSyncCapabilities({ providerId, deadlineMs }, { signal: capOptions?.signal });
|
||||
},
|
||||
async readObject(key, readOptions): Promise<EncryptedObjectReadResult> {
|
||||
return host.readSyncObject({
|
||||
providerId,
|
||||
key,
|
||||
preferStream: readOptions?.preferStream,
|
||||
deadlineMs,
|
||||
}, { signal: readOptions?.signal });
|
||||
},
|
||||
async writeObject(key, bytes, writeOptions): Promise<EncryptedObjectWriteResult> {
|
||||
return host.writeSyncObject({
|
||||
providerId,
|
||||
key,
|
||||
bytes,
|
||||
expectedRevision: writeOptions?.expectedRevision,
|
||||
preferStream: writeOptions?.preferStream,
|
||||
deadlineMs,
|
||||
}, { signal: writeOptions?.signal });
|
||||
},
|
||||
async deleteObject(key, deleteOptions): Promise<EncryptedObjectDeleteResult> {
|
||||
return host.deleteSyncObject({
|
||||
providerId,
|
||||
key,
|
||||
expectedRevision: deleteOptions?.expectedRevision,
|
||||
deadlineMs,
|
||||
}, { signal: deleteOptions?.signal });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { SyncedFile } from '../../../domain/sync';
|
||||
import { createAdapter } from './index';
|
||||
import {
|
||||
cloudAdapterAsEncryptedObjectStorage,
|
||||
webdavEncryptedObjectCapabilities,
|
||||
} from './encryptedObjectStorageBridge';
|
||||
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
|
||||
import type { CloudAdapter } from './index';
|
||||
|
||||
/**
|
||||
* Prove the production WebDAV factory returns a CloudAdapter that is wired
|
||||
* through EncryptedObjectStorage (same surface plugins use), by exercising
|
||||
* the shared bridge with a real in-memory CloudAdapter that mirrors WebDAV's
|
||||
* single-file semantics and comparing with createAdapter's webdav wrap shape.
|
||||
*/
|
||||
function memoryWebdavAdapter(initial: SyncedFile | null = null): CloudAdapter & { store: SyncedFile | null } {
|
||||
const adapter = {
|
||||
store: initial,
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'webdav.example', name: 'user@webdav.example' },
|
||||
resourceId: null as string | null,
|
||||
signOut() {
|
||||
adapter.isAuthenticated = false;
|
||||
adapter.accountInfo = null;
|
||||
adapter.store = null;
|
||||
},
|
||||
async initializeSync() {
|
||||
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
return adapter.resourceId;
|
||||
},
|
||||
async upload(file: SyncedFile) {
|
||||
adapter.store = file;
|
||||
adapter.resourceId = DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY;
|
||||
return adapter.resourceId;
|
||||
},
|
||||
async download() {
|
||||
return adapter.store;
|
||||
},
|
||||
async deleteSync() {
|
||||
adapter.store = null;
|
||||
},
|
||||
getTokens() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
|
||||
describe('WebDAV EncryptedObjectStorage production path', () => {
|
||||
it('createAdapter(webdav) wraps through EncryptedObjectStorage (not a raw WebDAVAdapter)', async () => {
|
||||
const { default: WebDAVAdapter } = await import('./WebDAVAdapter');
|
||||
const adapter = await createAdapter('webdav', undefined, undefined, {
|
||||
endpoint: 'https://webdav.example.test',
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
assert.equal(adapter instanceof WebDAVAdapter, false, 'must not return raw WebDAVAdapter');
|
||||
assert.equal(typeof adapter.upload, 'function');
|
||||
assert.equal(typeof adapter.download, 'function');
|
||||
assert.equal(typeof adapter.initializeSync, 'function');
|
||||
assert.equal(typeof adapter.deleteSync, 'function');
|
||||
assert.equal(adapter.getTokens(), null);
|
||||
});
|
||||
|
||||
it('createAdapter(webdav) is authenticated when config exists so getConnectedAdapter can reuse', async () => {
|
||||
const adapter = await createAdapter('webdav', undefined, undefined, {
|
||||
endpoint: 'https://webdav.example.test',
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
});
|
||||
// Pre-wrap WebDAVAdapter reported isAuthenticated whenever config existed.
|
||||
// The bridge must match so manager cache reuse (existing?.isAuthenticated) works.
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
});
|
||||
|
||||
it('createAdapter(webdav) preserves constructor resourceId and refreshes from backing adapter after initializeSync', async () => {
|
||||
const persistedPath = '/netcatty-vault.json';
|
||||
const adapter = await createAdapter(
|
||||
'webdav',
|
||||
undefined,
|
||||
persistedPath,
|
||||
{
|
||||
endpoint: 'https://webdav.example.test',
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
adapter.resourceId,
|
||||
persistedPath,
|
||||
'must not discard resourceId passed into createAdapter',
|
||||
);
|
||||
|
||||
// Without network, initializeSync fails; still prove resourceId is not
|
||||
// overwritten to the bare DEFAULT key before connect runs.
|
||||
assert.notEqual(adapter.resourceId, 'netcatty-vault.json');
|
||||
});
|
||||
|
||||
it('WebDAV-style adapters round-trip encrypted SyncedFile bytes through EncryptedObjectStorage', async () => {
|
||||
const raw = memoryWebdavAdapter({
|
||||
meta: {
|
||||
version: 2,
|
||||
updatedAt: 1,
|
||||
deviceId: 'd',
|
||||
appVersion: '0.0.0',
|
||||
iv: 'iv',
|
||||
salt: 'salt',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
},
|
||||
payload: 'remote-cipher',
|
||||
});
|
||||
const storage = cloudAdapterAsEncryptedObjectStorage(raw, 'webdav', {
|
||||
capabilities: webdavEncryptedObjectCapabilities(),
|
||||
});
|
||||
const caps = await storage.getCapabilities();
|
||||
assert.equal(caps.atomicReplacement, true);
|
||||
assert.equal(caps.revisions, false);
|
||||
|
||||
await storage.connect();
|
||||
const read = await storage.readObject(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY);
|
||||
assert.equal(read.found, true);
|
||||
assert.ok(read.bytes);
|
||||
const text = new TextDecoder().decode(read.bytes!);
|
||||
assert.match(text, /remote-cipher/);
|
||||
|
||||
const next: SyncedFile = {
|
||||
meta: {
|
||||
version: 3,
|
||||
updatedAt: 2,
|
||||
deviceId: 'd',
|
||||
appVersion: '0.0.0',
|
||||
iv: 'iv',
|
||||
salt: 'salt',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
},
|
||||
payload: 'next-cipher',
|
||||
};
|
||||
await storage.writeObject(
|
||||
DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY,
|
||||
new TextEncoder().encode(JSON.stringify(next)),
|
||||
);
|
||||
assert.equal(raw.store?.payload, 'next-cipher');
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,16 @@ import type {
|
||||
SyncPayload,
|
||||
WebDAVConfig,
|
||||
} from '../../../domain/sync';
|
||||
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
|
||||
import type {
|
||||
ProviderSyncAnchor,
|
||||
StartProviderAuthResult,
|
||||
} from '../CloudSyncManager';
|
||||
import {
|
||||
registerPluginProviderIdImpl,
|
||||
unregisterPluginProviderIdImpl,
|
||||
} from './stateAndSecurityMethods';
|
||||
|
||||
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
|
||||
|
||||
@@ -309,6 +315,141 @@ export async function connectConfigProviderImpl(this: any,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
|
||||
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
|
||||
*/
|
||||
export async function connectPluginProviderImpl(
|
||||
this: any,
|
||||
providerId: string,
|
||||
configuration: unknown = {},
|
||||
credential?: unknown,
|
||||
): Promise<void> {
|
||||
if (!isPluginCloudProviderId(providerId)) {
|
||||
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
|
||||
}
|
||||
// Initialize sequence counters before the first status write so cross-window
|
||||
// handlers never see NaN and discard every later update.
|
||||
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
|
||||
this.providerWriteSeq[providerId] = 0;
|
||||
}
|
||||
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
|
||||
this.providerDecryptSeq[providerId] = 0;
|
||||
}
|
||||
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||||
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
|
||||
this.providerAuthAttemptSeq[providerId] = 0;
|
||||
}
|
||||
this.updateProviderStatus(providerId, 'connecting');
|
||||
try {
|
||||
const createPluginStorage = async (id: string) => {
|
||||
if (typeof this.createPluginStorage === 'function') {
|
||||
return this.createPluginStorage(id, {
|
||||
provider: id,
|
||||
status: 'connecting',
|
||||
config: configuration as ProviderConnection['config'],
|
||||
});
|
||||
}
|
||||
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
|
||||
'../adapters/pluginSyncIpcHost'
|
||||
);
|
||||
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
|
||||
if (!isPluginSyncIpcAvailable()) {
|
||||
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
|
||||
}
|
||||
return createPluginSyncObjectStorage({
|
||||
providerId: id,
|
||||
host: createPluginSyncIpcHost(),
|
||||
configuration,
|
||||
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
|
||||
});
|
||||
};
|
||||
const adapter = await createAdapter(
|
||||
providerId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ createPluginStorage },
|
||||
);
|
||||
// Only cache after initializeSync succeeds so a rejected reconnect cannot
|
||||
// leave a half-authenticated adapter pinned for later auto-sync.
|
||||
let resourceId: string | null;
|
||||
try {
|
||||
resourceId = await adapter.initializeSync();
|
||||
} catch (error) {
|
||||
try { adapter.signOut(); } catch { /* ignore */ }
|
||||
this.adapters.delete(providerId);
|
||||
throw error;
|
||||
}
|
||||
this.adapters.set(providerId, adapter);
|
||||
const account = adapter.accountInfo ?? { id: providerId };
|
||||
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
|
||||
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
|
||||
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
|
||||
++this.providerDecryptSeq[providerId];
|
||||
// Capture pre-connect identity before overwriting state.
|
||||
const previous = this.state.providers[providerId];
|
||||
const previousAccountId = previous?.account?.id ?? null;
|
||||
const previousResource = previous?.resourceId ?? null;
|
||||
const configFingerprint = (value: unknown): string => {
|
||||
const normalize = (input: unknown): unknown => {
|
||||
if (Array.isArray(input)) return input.map(normalize);
|
||||
if (input && typeof input === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(input as Record<string, unknown>)
|
||||
.sort()
|
||||
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
|
||||
);
|
||||
}
|
||||
return input;
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(normalize(value ?? null));
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
const previousConfigFp = configFingerprint(previous?.config ?? null);
|
||||
const nextAccountId = account?.id ?? null;
|
||||
const nextResource = resourceId || null;
|
||||
const nextConfigFp = configFingerprint(configuration ?? null);
|
||||
this.state.providers[providerId] = {
|
||||
provider: providerId,
|
||||
status: 'connected',
|
||||
config: configuration as ProviderConnection['config'],
|
||||
...((() => {
|
||||
const ref = normalizeDurablePluginSyncCredentialRef(credential);
|
||||
return ref ? { credential: ref } : {};
|
||||
})()),
|
||||
account,
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
registerPluginProviderIdImpl.call(this, providerId);
|
||||
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);
|
||||
}
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: providerId,
|
||||
account,
|
||||
});
|
||||
this.notifyStateChange();
|
||||
} catch (error) {
|
||||
this.updateProviderStatus(providerId, 'error', String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
|
||||
const restoreState = this.providerAuthRestoreState[provider];
|
||||
if (
|
||||
@@ -390,10 +531,22 @@ export async function disconnectProviderImpl(this: any,provider: CloudProvider):
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Explicit disconnect removes the dynamic provider from the restart registry.
|
||||
// Missing plugins with preserved config remain registered and are not coerced away.
|
||||
unregisterPluginProviderIdImpl.call(this, provider);
|
||||
// Clear all trusted merge state so a later account/resource cannot reuse
|
||||
// an unrelated snapshot or convergent baseline.
|
||||
clearProviderMergeStateImpl.call(this, provider);
|
||||
this.removeFromStorage(this.providerAccountIdKey(provider));
|
||||
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
|
||||
if (isPluginCloudProviderId(provider)) {
|
||||
try {
|
||||
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
|
||||
await deletePluginSyncSecrets({ providerId: provider });
|
||||
} catch {
|
||||
/* best-effort; disconnect still succeeds */
|
||||
}
|
||||
}
|
||||
// Reset BLOCKED state if it was present — disconnect implicitly resolves
|
||||
// any pending shrink-block warning since there's no provider to push to.
|
||||
this.exitBlockedState();
|
||||
@@ -466,7 +619,18 @@ export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
|
||||
this.removeFromStorage(this.syncAnchorKey(provider));
|
||||
return;
|
||||
}
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
const providers = new Set<CloudProvider>([
|
||||
'github', 'google', 'onedrive', 'webdav', 's3',
|
||||
]);
|
||||
for (const id of Object.keys(this.state?.providers ?? {})) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
if (typeof this.listRegisteredPluginProviderIds === 'function') {
|
||||
for (const id of this.listRegisteredPluginProviderIds()) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
}
|
||||
for (const p of providers) {
|
||||
this.removeFromStorage(this.syncAnchorKey(p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,8 +39,12 @@ interface ProviderRuntime {
|
||||
provider: CloudProvider;
|
||||
adapter: CloudAdapter;
|
||||
latestRemote: SyncedFile | null;
|
||||
/** Decrypted payload from the most recent successful download for this cycle. */
|
||||
latestRemotePayload?: SyncPayload;
|
||||
verifiedState?: ConvergentSyncStateV2;
|
||||
verifiedFile?: SyncedFile;
|
||||
/** Decrypted payload retained when the provider is marked verified. */
|
||||
verifiedPayload?: SyncPayload;
|
||||
resourceId?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -136,8 +140,31 @@ function mergeStates(
|
||||
return merged;
|
||||
}
|
||||
|
||||
function materializedPayload(state: ConvergentSyncStateV2, now: number): SyncPayload {
|
||||
return materializeSyncPayloadFromConvergentState(state, { syncedAt: now });
|
||||
function materializedPayload(
|
||||
state: ConvergentSyncStateV2,
|
||||
now: number,
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'],
|
||||
): SyncPayload {
|
||||
return materializeSyncPayloadFromConvergentState(state, {
|
||||
syncedAt: now,
|
||||
...(pluginSidecars && Array.isArray(pluginSidecars.entries)
|
||||
? { pluginSidecars }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function sidecarBundleFromPayload(
|
||||
payload: SyncPayload | null | undefined,
|
||||
): SyncPayload['pluginSidecars'] | undefined {
|
||||
if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
entries: Array.isArray(payload.pluginSidecars?.entries)
|
||||
? payload.pluginSidecars.entries
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function currentConflicts(state: ConvergentSyncStateV2): ConvergentFieldConflict[] {
|
||||
@@ -166,12 +193,18 @@ async function downloadProviderState(
|
||||
runtime: ProviderRuntime,
|
||||
syncSecurityGeneration?: number,
|
||||
): Promise<DecodedRemote | null> {
|
||||
const file = await runtime.adapter.download();
|
||||
const file = await runtime.adapter.download({
|
||||
signal: manager.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
|
||||
runtime.latestRemote = file;
|
||||
return file
|
||||
? decodeRemote(manager, runtime.provider, file, syncSecurityGeneration)
|
||||
: null;
|
||||
if (!file) {
|
||||
runtime.latestRemotePayload = undefined;
|
||||
return null;
|
||||
}
|
||||
const decoded = await decodeRemote(manager, runtime.provider, file, syncSecurityGeneration);
|
||||
runtime.latestRemotePayload = decoded.payload;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
async function saveVerifiedBaseline(
|
||||
@@ -197,6 +230,8 @@ async function markProviderVerified(
|
||||
): Promise<void> {
|
||||
runtime.verifiedState = decoded.state;
|
||||
runtime.verifiedFile = decoded.file;
|
||||
runtime.verifiedPayload = decoded.payload;
|
||||
runtime.latestRemotePayload = decoded.payload;
|
||||
runtime.error = undefined;
|
||||
await saveVerifiedBaseline(manager, runtime, decoded);
|
||||
|
||||
@@ -236,7 +271,10 @@ async function runInitialDownloads(
|
||||
const runtime: ProviderRuntime = { provider, adapter, latestRemote: null };
|
||||
try {
|
||||
const decoded = await downloadProviderState(manager, runtime, syncSecurityGeneration);
|
||||
if (decoded) runtime.verifiedState = decoded.state;
|
||||
if (decoded) {
|
||||
runtime.verifiedState = decoded.state;
|
||||
runtime.verifiedPayload = decoded.payload;
|
||||
}
|
||||
} catch (error) {
|
||||
runtime.error = errorMessage(error);
|
||||
}
|
||||
@@ -397,6 +435,25 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
await persistReplica(this, durableBeforeVerification, now());
|
||||
updateConflictState(this, durableBeforeVerification);
|
||||
|
||||
// Capture pre-cycle sidecar baselines per provider (never union across
|
||||
// providers). Cross-provider union invents a shared base that can treat one
|
||||
// provider's empty remote as deleting another provider's retained settings.
|
||||
const cycleBaseByProvider = new Map<CloudProvider, NonNullable<SyncPayload['pluginSidecars']>['entries']>();
|
||||
if (typeof this.loadConvergentProviderBaseline === 'function') {
|
||||
for (const runtime of usable) {
|
||||
try {
|
||||
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
|
||||
materializedPayload?: SyncPayload;
|
||||
} | null;
|
||||
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
|
||||
if (!baseBundle || !Array.isArray(baseBundle.entries)) continue;
|
||||
cycleBaseByProvider.set(runtime.provider, baseBundle.entries);
|
||||
} catch {
|
||||
// First cycle or missing baseline is fine.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let round = 0; round < maxRounds && usable.length > 0; round += 1) {
|
||||
if (round > 0) await jitter(round);
|
||||
|
||||
@@ -432,14 +489,90 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
runtime.error = errorMessage(error);
|
||||
}
|
||||
}));
|
||||
// Merge plugin sidecars per successful provider with that provider's own
|
||||
// pre-cycle baseline. Failed providers are skipped so their baselines
|
||||
// cannot invent deletions against another provider's empty remote.
|
||||
const {
|
||||
mergePluginSyncSidecarsThreeWay,
|
||||
} = await import('../../../domain/pluginSyncSidecar');
|
||||
const localSidecarEntries = Array.isArray(inputPayload.pluginSidecars?.entries)
|
||||
? inputPayload.pluginSidecars.entries
|
||||
: [];
|
||||
let mergedSidecars = [...localSidecarEntries];
|
||||
for (const runtime of usable) {
|
||||
if (runtime.error) continue;
|
||||
const fromPreflight = preflightVerified.get(runtime.provider)?.payload?.pluginSidecars;
|
||||
const fromLatest = runtime.latestRemotePayload?.pluginSidecars;
|
||||
const remoteBundle = fromPreflight ?? fromLatest;
|
||||
if (!remoteBundle) continue;
|
||||
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
|
||||
const sidecarStrategy =
|
||||
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
|
||||
mergedSidecars = mergePluginSyncSidecarsThreeWay({
|
||||
base: baseForProvider,
|
||||
local: mergedSidecars,
|
||||
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||||
strategy: sidecarStrategy,
|
||||
});
|
||||
}
|
||||
const pluginSidecars = mergedSidecars.length > 0
|
||||
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
|
||||
? { version: 1 as const, entries: mergedSidecars }
|
||||
: inputPayload.pluginSidecars;
|
||||
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, 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)
|
||||
));
|
||||
)) || remoteSidecarMismatch;
|
||||
const outgoingPayload = needsUpload
|
||||
? withConvergentSyncEnvelope(expected, { syncedAt: now() })
|
||||
? withConvergentSyncEnvelope(expected, {
|
||||
syncedAt: now(),
|
||||
pluginSidecars,
|
||||
})
|
||||
: null;
|
||||
await Promise.all(usable.map(async (runtime) => {
|
||||
if (runtime.error || preflightVerified.has(runtime.provider)) return;
|
||||
// Upload when CRDT preflight missed OR sidecars diverge for this provider.
|
||||
if (runtime.error) return;
|
||||
if (preflightVerified.has(runtime.provider) && !providerSidecarMismatch(runtime)) return;
|
||||
if (!outgoingPayload) return;
|
||||
try {
|
||||
const remoteVersion = runtime.latestRemote?.meta.version ?? this.state.localVersion;
|
||||
const file = await EncryptionService.encryptPayload(
|
||||
@@ -451,9 +584,17 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
remoteVersion,
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
runtime.resourceId = await runtime.adapter.upload(file);
|
||||
runtime.resourceId = await runtime.adapter.upload(file, {
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
runtime.latestRemote = file;
|
||||
// Sidecar-only (or any) upload invalidates preflight verification so the
|
||||
// post-write payload is re-downloaded and baselines stay current.
|
||||
preflightVerified.delete(runtime.provider);
|
||||
runtime.verifiedState = undefined;
|
||||
runtime.verifiedPayload = undefined;
|
||||
runtime.verifiedFile = undefined;
|
||||
} catch (error) {
|
||||
runtime.error = errorMessage(error);
|
||||
}
|
||||
@@ -496,8 +637,39 @@ export async function syncConvergentProvidersUnlockedImpl(
|
||||
&& versionVectorDominates(runtime.verifiedState.vector, canonical.vector)
|
||||
));
|
||||
const hasSuccess = successfulRuntimes.length > 0;
|
||||
const mergedPayload = materializedPayload(canonical, now());
|
||||
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload);
|
||||
const {
|
||||
mergePluginSyncSidecarsThreeWay: mergeSidecarsThreeWayFinal,
|
||||
} = await import('../../../domain/pluginSyncSidecar');
|
||||
const finalLocalSidecars = Array.isArray(inputPayload.pluginSidecars?.entries)
|
||||
? [...inputPayload.pluginSidecars.entries]
|
||||
: [];
|
||||
let finalSidecarEntries = [...finalLocalSidecars];
|
||||
for (const runtime of successfulRuntimes) {
|
||||
const remoteBundle = runtime.verifiedPayload?.pluginSidecars
|
||||
?? runtime.latestRemotePayload?.pluginSidecars;
|
||||
if (!remoteBundle) continue;
|
||||
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
|
||||
const finalSidecarStrategy =
|
||||
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
|
||||
finalSidecarEntries = mergeSidecarsThreeWayFinal({
|
||||
base: baseForProvider,
|
||||
local: finalSidecarEntries,
|
||||
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||||
strategy: finalSidecarStrategy,
|
||||
});
|
||||
}
|
||||
const finalPluginSidecars = finalSidecarEntries.length > 0
|
||||
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
|
||||
? { version: 1 as const, entries: finalSidecarEntries }
|
||||
: undefined;
|
||||
const mergedPayloadBase = materializedPayload(canonical, now());
|
||||
const mergedPayload: SyncPayload = {
|
||||
...mergedPayloadBase,
|
||||
...(finalPluginSidecars ? { pluginSidecars: finalPluginSidecars } : {}),
|
||||
};
|
||||
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload)
|
||||
|| JSON.stringify(inputPayload.pluginSidecars ?? null)
|
||||
!== JSON.stringify(mergedPayload.pluginSidecars ?? null);
|
||||
let mergedPayloadApplied = false;
|
||||
if (hasSuccess && localPayloadChanged) {
|
||||
try {
|
||||
@@ -687,6 +859,7 @@ async function prepareConvergentConflictResolutionImpl(
|
||||
addressKey: string,
|
||||
candidateDot: string,
|
||||
now = Date.now(),
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'],
|
||||
): Promise<{ state: ConvergentSyncStateV2; payload: SyncPayload; now: number }> {
|
||||
const replica = await this.loadConvergentReplica();
|
||||
if (!replica) throw new Error('Convergent sync replica is unavailable');
|
||||
@@ -701,7 +874,15 @@ async function prepareConvergentConflictResolutionImpl(
|
||||
this.state.deviceId,
|
||||
now,
|
||||
);
|
||||
return { state: resolved, payload: materializedPayload(resolved, now), now };
|
||||
// Prefer caller-provided current sidecars. Never fall back to a stale
|
||||
// provider baseline — that would revert plugin settings changed after the
|
||||
// last verified sync. When omitted, materialize without the field so apply
|
||||
// preserves local sidecars (legacy missing-field semantics).
|
||||
return {
|
||||
state: resolved,
|
||||
payload: materializedPayload(resolved, now, pluginSidecars),
|
||||
now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveConvergentConflictAndSyncImpl(
|
||||
@@ -712,12 +893,17 @@ export async function resolveConvergentConflictAndSyncImpl(
|
||||
payload: SyncPayload,
|
||||
commitReplica: () => Promise<void>,
|
||||
) => Promise<void>,
|
||||
options?: {
|
||||
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||||
},
|
||||
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
|
||||
return withConvergentSyncWebLock(async () => {
|
||||
const prepared = await prepareConvergentConflictResolutionImpl.call(
|
||||
this,
|
||||
addressKey,
|
||||
candidateDot,
|
||||
Date.now(),
|
||||
options?.pluginSidecars,
|
||||
);
|
||||
let committed = false;
|
||||
await applyPayload(prepared.payload, async () => {
|
||||
@@ -740,7 +926,11 @@ export async function resolveConvergentConflictAndSyncImpl(
|
||||
if (!finalReplica) {
|
||||
throw new Error('Convergent sync replica disappeared after conflict propagation');
|
||||
}
|
||||
const finalPayload = materializedPayload(finalReplica.state, Date.now());
|
||||
const finalPayload = materializedPayload(
|
||||
finalReplica.state,
|
||||
Date.now(),
|
||||
sidecarBundleFromPayload(prepared.payload),
|
||||
);
|
||||
return { payload: finalPayload, results };
|
||||
});
|
||||
}
|
||||
@@ -762,6 +952,7 @@ export async function downgradeConvergentSyncImpl(
|
||||
const replica = await this.loadConvergentReplica();
|
||||
if (!replica) throw new Error('Convergent sync replica is unavailable');
|
||||
const localPayload = await buildLocalPayload();
|
||||
const localSidecars = sidecarBundleFromPayload(localPayload);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
this.state.syncState = 'SYNCING';
|
||||
@@ -790,9 +981,47 @@ export async function downgradeConvergentSyncImpl(
|
||||
// vault change made since the last persisted replica into causal local
|
||||
// writes before joining provider states; otherwise downgrade would
|
||||
// materialize the stale replica and overwrite those paused edits.
|
||||
// Prefer local sidecars, then three-way-merge each remote against that
|
||||
// provider's trusted baseline so explicit local resets are not resurrected
|
||||
// by a stale remote entry during downgrade.
|
||||
let downgradeSidecars = localSidecars;
|
||||
{
|
||||
const { mergePluginSyncSidecarsThreeWay } = await import('../../../domain/pluginSyncSidecar');
|
||||
let entries = Array.isArray(downgradeSidecars?.entries) ? [...downgradeSidecars.entries] : [];
|
||||
let sawRemote = false;
|
||||
for (const runtime of runtimes) {
|
||||
const remoteBundle = sidecarBundleFromPayload(
|
||||
runtime.verifiedPayload ?? runtime.latestRemotePayload,
|
||||
);
|
||||
if (!remoteBundle) continue;
|
||||
sawRemote = true;
|
||||
let baseForProvider: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
|
||||
if (typeof this.loadConvergentProviderBaseline === 'function') {
|
||||
try {
|
||||
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
|
||||
materializedPayload?: SyncPayload;
|
||||
} | null;
|
||||
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
|
||||
if (baseBundle && Array.isArray(baseBundle.entries)) {
|
||||
baseForProvider = baseBundle.entries;
|
||||
}
|
||||
} catch {
|
||||
// First cycle or missing baseline is fine.
|
||||
}
|
||||
}
|
||||
entries = mergePluginSyncSidecarsThreeWay({
|
||||
base: baseForProvider,
|
||||
local: entries,
|
||||
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||||
});
|
||||
}
|
||||
if (sawRemote || entries.length > 0) {
|
||||
downgradeSidecars = { version: 1, entries };
|
||||
}
|
||||
}
|
||||
const withLocalWrites = applyLegacySyncPayload(
|
||||
replica.state,
|
||||
materializedPayload(replica.state, now),
|
||||
materializedPayload(replica.state, now, downgradeSidecars),
|
||||
stripConvergentSyncEnvelope(localPayload),
|
||||
this.state.deviceId,
|
||||
now,
|
||||
@@ -817,7 +1046,7 @@ export async function downgradeConvergentSyncImpl(
|
||||
return results;
|
||||
}
|
||||
|
||||
const outgoing = materializedPayload(canonical, now);
|
||||
const outgoing = materializedPayload(canonical, now, downgradeSidecars);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
let committed = false;
|
||||
try {
|
||||
@@ -855,9 +1084,13 @@ export async function downgradeConvergentSyncImpl(
|
||||
before?.meta.version ?? this.state.localVersion,
|
||||
);
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
await adapter.upload(file);
|
||||
await adapter.upload(file, {
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const verifiedFile = await adapter.download();
|
||||
const verifiedFile = await adapter.download({
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
if (!verifiedFile) throw new Error('Provider returned no file after downgrade');
|
||||
const verifiedPayload = await EncryptionService.decryptPayload(verifiedFile, this.masterPassword);
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
encryptLocalStorageValue,
|
||||
} from './encryptedLocalStorage';
|
||||
|
||||
const PROVIDERS: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
/** Built-ins plus any dynamic plugin providers currently in manager state. */
|
||||
function listedProviders(manager: any): CloudProvider[] {
|
||||
const fromState = Object.keys(manager?.state?.providers ?? {}) as CloudProvider[];
|
||||
if (fromState.length > 0) return fromState.sort();
|
||||
return ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
}
|
||||
|
||||
export function convergentProviderBaselineKeyImpl(this: any, provider: CloudProvider): string {
|
||||
return `${SYNC_STORAGE_KEYS.CONVERGENT_PROVIDER_BASELINE}_${provider}`;
|
||||
@@ -107,7 +112,7 @@ export async function loadConvergentProviderBaselineImpl(
|
||||
export function clearConvergentSyncStorageImpl(this: any, confirmed: boolean): void {
|
||||
if (!confirmed) throw new Error('Explicit confirmation is required to remove convergent sync state');
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.CONVERGENT_REPLICA);
|
||||
for (const provider of PROVIDERS) {
|
||||
for (const provider of listedProviders(this)) {
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
|
||||
}
|
||||
}
|
||||
@@ -118,7 +123,7 @@ function encryptedSyncStorageKeys(manager: any): string[] {
|
||||
manager.syncSnapshotsKey(),
|
||||
SYNC_STORAGE_KEYS.CONVERGENT_REPLICA,
|
||||
]);
|
||||
for (const provider of PROVIDERS) {
|
||||
for (const provider of listedProviders(manager)) {
|
||||
keys.add(manager.syncBaseKey(provider));
|
||||
keys.add(manager.syncSnapshotsKey(provider));
|
||||
keys.add(manager.convergentProviderBaselineKey(provider));
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { CloudProvider, ProviderConnection, SyncedFile } from '../../../domain/sync';
|
||||
import { SYNC_STORAGE_KEYS } from '../../../domain/sync';
|
||||
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
|
||||
import { DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY } from '../../../domain/encryptedObjectStorage';
|
||||
import {
|
||||
enforceLegacySingleProviderConnected,
|
||||
getConnectedAdapterImpl,
|
||||
listAvailablePluginSyncProviderIdsImpl,
|
||||
listRegisteredPluginProviderIdsImpl,
|
||||
loadInitialStateImpl,
|
||||
loadProviderConnectionImpl,
|
||||
registerPluginProviderIdImpl,
|
||||
saveProviderConnectionImpl,
|
||||
setAvailablePluginSyncProviderIdsImpl,
|
||||
unregisterPluginProviderIdImpl,
|
||||
} from './stateAndSecurityMethods';
|
||||
|
||||
function makeSyncedFile(version: number, payload = 'cipher'): SyncedFile {
|
||||
return {
|
||||
meta: {
|
||||
version,
|
||||
updatedAt: 1,
|
||||
deviceId: 'device',
|
||||
appVersion: '0.0.0',
|
||||
iv: 'iv',
|
||||
salt: 'salt',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function memoryStorage(): Map<string, unknown> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
type ManagerHarness = {
|
||||
adapters: Map<string, unknown>;
|
||||
providerWriteSeq: Record<string, number>;
|
||||
providerDecryptSeq: Record<string, number>;
|
||||
providerDecrypted: Record<string, boolean>;
|
||||
providerAuthAttemptSeq: Record<string, number>;
|
||||
providerAuthRestoreState: Record<string, unknown>;
|
||||
decryptionReady: Promise<void>;
|
||||
state: { providers: Record<string, ProviderConnection> } | null;
|
||||
createPluginStorage?: (id: string) => Promise<EncryptedObjectStorage>;
|
||||
loadFromStorage: <T>(key: string) => T | null;
|
||||
saveToStorage: (key: string, value: unknown) => boolean;
|
||||
removeFromStorage: (key: string) => void;
|
||||
loadProviderConnection: (provider: CloudProvider) => ProviderConnection;
|
||||
notifyStateChange: () => void;
|
||||
isActiveAuthAttempt: () => boolean;
|
||||
};
|
||||
|
||||
function createManagerHarness(storage: Map<string, unknown>): ManagerHarness {
|
||||
const manager: ManagerHarness = {
|
||||
adapters: new Map(),
|
||||
providerWriteSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerDecryptSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerDecrypted: {
|
||||
github: true, google: true, onedrive: true, webdav: true, s3: true,
|
||||
},
|
||||
providerAuthAttemptSeq: {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
},
|
||||
providerAuthRestoreState: {
|
||||
github: null, google: null, onedrive: null, webdav: null, s3: null,
|
||||
},
|
||||
decryptionReady: Promise.resolve(),
|
||||
state: null,
|
||||
loadFromStorage<T>(key: string): T | null {
|
||||
return (storage.has(key) ? storage.get(key) : null) as T | null;
|
||||
},
|
||||
saveToStorage(key: string, value: unknown) {
|
||||
storage.set(key, value);
|
||||
return true;
|
||||
},
|
||||
removeFromStorage(key: string) {
|
||||
storage.delete(key);
|
||||
},
|
||||
loadProviderConnection(provider: CloudProvider) {
|
||||
return loadProviderConnectionImpl.call(manager, provider);
|
||||
},
|
||||
notifyStateChange() {},
|
||||
isActiveAuthAttempt() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return manager;
|
||||
}
|
||||
|
||||
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', {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
});
|
||||
|
||||
const state = loadInitialStateImpl.call(manager);
|
||||
assert.ok(state.providers['com.example.backup.sync']);
|
||||
// Without the plugin host IPC surface, dynamic providers keep config but
|
||||
// must not join sync cycles as "connected".
|
||||
assert.equal(state.providers['com.example.backup.sync'].status, 'disconnected');
|
||||
assert.deepEqual(
|
||||
(state.providers['com.example.backup.sync'].config as { endpoint?: string })?.endpoint,
|
||||
'https://example.test',
|
||||
);
|
||||
assert.deepEqual(listRegisteredPluginProviderIdsImpl.call(manager), [
|
||||
'com.example.backup.sync',
|
||||
]);
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds drops missing providers from the ready set', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = {
|
||||
providers: {
|
||||
'com.example.backup.sync': {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map();
|
||||
registerPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), [
|
||||
'com.example.backup.sync',
|
||||
]);
|
||||
// Plugin uninstalled / contribution gone.
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
|
||||
assert.deepEqual(listAvailablePluginSyncProviderIdsImpl.call(manager), []);
|
||||
assert.equal(manager.state.providers['com.example.backup.sync'].status, 'disconnected');
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds keeps adapters when availability membership is unchanged', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
let signedOut = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map([
|
||||
[pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
}],
|
||||
]);
|
||||
registerPluginProviderIdImpl.call(manager, pluginId);
|
||||
// Same contribution still present (setting-updated noise).
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
|
||||
assert.equal(manager.adapters.has(pluginId), true, 'must keep adapter on no-op membership refresh');
|
||||
assert.equal(signedOut, 0);
|
||||
assert.equal(manager.state.providers[pluginId].status, 'connected');
|
||||
});
|
||||
|
||||
it('setAvailablePluginSyncProviderIds drops adapters when a provider re-enters the set', () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
let signedOut = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'disconnected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
error: 'Plugin sync provider is no longer installed or enabled',
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.adapters = new Map([
|
||||
[pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
}],
|
||||
]);
|
||||
// Not in available set yet.
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, []);
|
||||
assert.equal(manager.adapters.has(pluginId), false);
|
||||
// Stale adapter reattached (should not happen in prod) then re-enter.
|
||||
manager.adapters.set(pluginId, {
|
||||
isAuthenticated: true,
|
||||
accountInfo: { id: 'acct' },
|
||||
resourceId: null,
|
||||
signOut() { signedOut += 1; },
|
||||
async initializeSync() { return null; },
|
||||
async upload() { return 'ok'; },
|
||||
async download() { return null; },
|
||||
async deleteSync() {},
|
||||
getTokens() { return null; },
|
||||
});
|
||||
setAvailablePluginSyncProviderIdsImpl.call(manager, [pluginId]);
|
||||
assert.equal(manager.adapters.has(pluginId), false, 'must drop adapter when provider re-enters');
|
||||
assert.equal(signedOut >= 1, true);
|
||||
assert.equal(manager.state.providers[pluginId].status, 'connected');
|
||||
});
|
||||
|
||||
it('saveProviderConnection registers plugin IDs and disconnect unregisters them', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = { providers: {} };
|
||||
|
||||
const connection: ProviderConnection = {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
// Non-secret plugin config: encryptProviderSecrets is a no-op without bridge.
|
||||
config: { endpoint: 'https://example.test' } as never,
|
||||
};
|
||||
await saveProviderConnectionImpl.call(manager, 'com.example.backup.sync', connection);
|
||||
assert.deepEqual(
|
||||
storage.get(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS),
|
||||
['com.example.backup.sync'],
|
||||
);
|
||||
assert.ok(storage.get('netcatty_provider_plugin_v1:com.example.backup.sync'));
|
||||
|
||||
unregisterPluginProviderIdImpl.call(manager, 'com.example.backup.sync');
|
||||
assert.equal(storage.has(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS), false);
|
||||
});
|
||||
|
||||
it('getConnectedAdapter uses createPluginStorage for namespaced provider IDs', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.backup.sync';
|
||||
const objectStore = new Map<string, Uint8Array>();
|
||||
let factoryCalls = 0;
|
||||
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted[pluginId] = true;
|
||||
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
|
||||
factoryCalls += 1;
|
||||
assert.equal(id, pluginId);
|
||||
return {
|
||||
providerId: id,
|
||||
async connect() {
|
||||
return { account: { id: 'plugin-acct' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { account: { id: 'plugin-acct' } };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
},
|
||||
async readObject(key: string) {
|
||||
const bytes = objectStore.get(key) ?? null;
|
||||
return bytes
|
||||
? { found: true, key, bytes, revision: '1' }
|
||||
: { found: false, key, bytes: null };
|
||||
},
|
||||
async writeObject(key: string, bytes: Uint8Array) {
|
||||
const created = !objectStore.has(key);
|
||||
objectStore.set(key, bytes);
|
||||
return { created, revision: '2' };
|
||||
},
|
||||
async deleteObject(key: string) {
|
||||
return { deleted: objectStore.delete(key) };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(factoryCalls, 1);
|
||||
// Config-backed plugin connections report authenticated for cache reuse.
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
const reused = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(reused, adapter);
|
||||
assert.equal(factoryCalls, 1, 'must not recreate when cached adapter is authenticated');
|
||||
|
||||
await adapter.initializeSync();
|
||||
assert.equal(adapter.accountInfo?.id, 'plugin-acct');
|
||||
|
||||
const file = makeSyncedFile(7, 'plugin-cipher');
|
||||
await adapter.upload(file);
|
||||
const downloaded = await adapter.download();
|
||||
assert.equal(downloaded?.payload, 'plugin-cipher');
|
||||
assert.equal(downloaded?.meta.version, 7);
|
||||
assert.ok(objectStore.has(DEFAULT_ENCRYPTED_SYNC_OBJECT_KEY));
|
||||
});
|
||||
|
||||
it('getConnectedAdapter accepts falsy scalar plugin configs as present', async () => {
|
||||
for (const scalar of [false, 0, ''] as const) {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
const pluginId = 'com.example.scalar.sync';
|
||||
let factoryCalls = 0;
|
||||
manager.state = {
|
||||
providers: {
|
||||
[pluginId]: {
|
||||
provider: pluginId,
|
||||
status: 'connected',
|
||||
config: scalar as never,
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted[pluginId] = true;
|
||||
manager.createPluginStorage = async (id: string): Promise<EncryptedObjectStorage> => {
|
||||
factoryCalls += 1;
|
||||
assert.equal(id, pluginId);
|
||||
return {
|
||||
providerId: id,
|
||||
async connect() {
|
||||
return { account: { id: 'scalar-acct' } };
|
||||
},
|
||||
async disconnect() {},
|
||||
async getAccount() {
|
||||
return { account: { id: 'scalar-acct' } };
|
||||
},
|
||||
async getCapabilities() {
|
||||
return {
|
||||
revisions: false,
|
||||
conditionalWrites: false,
|
||||
atomicReplacement: true,
|
||||
};
|
||||
},
|
||||
async readObject() {
|
||||
return { found: false, key: 'x', bytes: null };
|
||||
},
|
||||
async writeObject() {
|
||||
return { created: true, revision: '1' };
|
||||
},
|
||||
async deleteObject() {
|
||||
return { deleted: false };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const adapter = await getConnectedAdapterImpl.call(manager, pluginId);
|
||||
assert.equal(factoryCalls, 1);
|
||||
assert.equal(adapter.isAuthenticated, true);
|
||||
}
|
||||
|
||||
const empty = createManagerHarness(memoryStorage());
|
||||
empty.state = {
|
||||
providers: {
|
||||
'com.example.empty.sync': {
|
||||
provider: 'com.example.empty.sync',
|
||||
status: 'connected',
|
||||
},
|
||||
},
|
||||
};
|
||||
empty.providerDecrypted['com.example.empty.sync'] = true;
|
||||
await assert.rejects(
|
||||
() => getConnectedAdapterImpl.call(empty, 'com.example.empty.sync'),
|
||||
/Provider not connected/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAdapter WebDAV production path', () => {
|
||||
it('returns an authenticated adapter and reuses it via getConnectedAdapter cache semantics', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = createManagerHarness(storage);
|
||||
manager.state = {
|
||||
providers: {
|
||||
webdav: {
|
||||
provider: 'webdav',
|
||||
status: 'connected',
|
||||
config: {
|
||||
endpoint: 'https://webdav.example.test',
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
},
|
||||
resourceId: '/netcatty-vault.json',
|
||||
},
|
||||
},
|
||||
};
|
||||
manager.providerDecrypted.webdav = true;
|
||||
|
||||
const a1 = await getConnectedAdapterImpl.call(manager, 'webdav');
|
||||
assert.equal(a1.isAuthenticated, true);
|
||||
assert.equal(a1.resourceId, '/netcatty-vault.json');
|
||||
const a2 = await getConnectedAdapterImpl.call(manager, 'webdav');
|
||||
assert.equal(a1, a2, 'must reuse cached adapter when isAuthenticated is true');
|
||||
});
|
||||
|
||||
it('enforceLegacySingleProviderConnected keeps builtin and disconnects plugin', () => {
|
||||
const providers: Record<string, ProviderConnection> = {
|
||||
github: { provider: 'github', status: 'connected' },
|
||||
'com.example.backup.sync': {
|
||||
provider: 'com.example.backup.sync',
|
||||
status: 'connected',
|
||||
config: { endpoint: 'https://example.test' },
|
||||
},
|
||||
};
|
||||
enforceLegacySingleProviderConnected(providers);
|
||||
assert.equal(providers.github?.status, 'connected');
|
||||
assert.equal(providers['com.example.backup.sync']?.status, 'disconnected');
|
||||
assert.deepEqual(
|
||||
providers['com.example.backup.sync']?.config,
|
||||
{ endpoint: 'https://example.test' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from '../../../domain/sync';
|
||||
import { isConditionalWriteConflictError } from '../adapters/encryptedObjectStorageBridge';
|
||||
|
||||
function getSyncSecurityGeneration(manager: any): number | undefined {
|
||||
return typeof manager.getSyncSecurityGeneration === 'function'
|
||||
@@ -117,6 +118,8 @@ async function uploadLocalPayloadImpl(this: any,
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
} else if (result.conflictDetected) {
|
||||
// Conflict UI / CONFLICT state already set by uploadToProvider.
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
if (result.error) {
|
||||
@@ -169,7 +172,9 @@ export async function uploadToProviderImpl(this: any,
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
const resourceId = await adapter.upload(syncedFile);
|
||||
const resourceId = await adapter.upload(syncedFile, {
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
this.state.lastError = null;
|
||||
|
||||
@@ -222,6 +227,53 @@ export async function uploadToProviderImpl(this: any,
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Conditional write rejected: surface as conflict so the user can pick
|
||||
// local vs remote instead of a generic sync failure.
|
||||
if (isConditionalWriteConflictError(error)) {
|
||||
try {
|
||||
const remoteFile = await adapter.download({
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
if (remoteFile) {
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
// Leave the provider ready (not stuck on "syncing") while the user resolves.
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generic ERROR if we cannot load remote for the UI.
|
||||
}
|
||||
}
|
||||
|
||||
this.state.lastError = String(error);
|
||||
if (!handleProviderReauthRequired(this, provider, error)) {
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
@@ -491,9 +543,12 @@ export async function syncToProviderImpl(this: any,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload after merge failed — set ERROR so sync isn't stuck in SYNCING
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = uploadResult.error || 'Upload failed after merge';
|
||||
// Upload after merge failed — preserve CONFLICT when conditional write
|
||||
// already surfaced a conflict; otherwise mark ERROR so we leave SYNCING.
|
||||
if (!uploadResult.conflictDetected) {
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = uploadResult.error || 'Upload failed after merge';
|
||||
}
|
||||
return uploadResult;
|
||||
} catch (mergeError) {
|
||||
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||||
@@ -581,7 +636,9 @@ export async function downloadFromProviderImpl(this: any,provider: CloudProvider
|
||||
try {
|
||||
let remoteFile: SyncedFile | null;
|
||||
try {
|
||||
remoteFile = await adapter.download();
|
||||
remoteFile = await adapter.download({
|
||||
signal: this.activeSyncAbortSignal,
|
||||
});
|
||||
} catch (downloadError) {
|
||||
throw new Error(`Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BUILTIN_CLOUD_PROVIDERS,
|
||||
SYNC_CONSTANTS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
cleanOneDriveErrorMessage,
|
||||
generateDeviceId,
|
||||
getDefaultDeviceName,
|
||||
isBuiltinCloudProvider,
|
||||
isOneDriveReauthRequiredMessage,
|
||||
providerConnectionStorageKey,
|
||||
type CloudProvider,
|
||||
type MasterKeyConfig,
|
||||
type ProviderConnection,
|
||||
type SecurityState,
|
||||
type SyncHistoryEntry,
|
||||
} from '../../../domain/sync';
|
||||
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
|
||||
import { createPluginSyncObjectStorage } from '../adapters/pluginSyncObjectStorage';
|
||||
import {
|
||||
createPluginSyncIpcHost,
|
||||
isPluginSyncIpcAvailable,
|
||||
} from '../adapters/pluginSyncIpcHost';
|
||||
import type { EncryptedObjectStorage } from '../../../domain/encryptedObjectStorage';
|
||||
import {
|
||||
DEFAULT_CLOUD_SYNC_STRATEGY,
|
||||
normalizeCloudSyncStrategy,
|
||||
@@ -19,17 +34,34 @@ import {
|
||||
encryptProviderSecrets,
|
||||
} from '../../persistence/secureFieldAdapter';
|
||||
import type { CloudAdapter } from '../adapters';
|
||||
import type {
|
||||
CloudProvider,
|
||||
MasterKeyConfig,
|
||||
ProviderConnection,
|
||||
SecurityState,
|
||||
SyncHistoryEntry,
|
||||
} from '../../../domain/sync';
|
||||
import type { SyncManagerState } from '../CloudSyncManager';
|
||||
import { getConvergentSyncLocalConfig } from '../convergentSyncConfig';
|
||||
|
||||
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;
|
||||
}
|
||||
if (manager.providerWriteSeq[provider] == null || Number.isNaN(manager.providerWriteSeq[provider])) {
|
||||
manager.providerWriteSeq[provider] = 0;
|
||||
}
|
||||
if (manager.providerDecrypted[provider] == null) {
|
||||
manager.providerDecrypted[provider] = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadInitialStateImpl(this: any): SyncManagerState {
|
||||
// Load persisted configuration
|
||||
const masterKeyConfig = this.loadFromStorage<MasterKeyConfig>(
|
||||
@@ -58,7 +90,7 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
|
||||
// Determine initial security state
|
||||
const securityState: SecurityState = masterKeyConfig ? 'LOCKED' : 'NO_KEY';
|
||||
|
||||
// Load provider connections
|
||||
// Load provider connections (built-ins + registered plugin provider IDs)
|
||||
const providers: Record<CloudProvider, ProviderConnection> = {
|
||||
github: this.loadProviderConnection('github'),
|
||||
google: this.loadProviderConnection('google'),
|
||||
@@ -66,6 +98,17 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
|
||||
webdav: this.loadProviderConnection('webdav'),
|
||||
s3: this.loadProviderConnection('s3'),
|
||||
};
|
||||
for (const pluginProviderId of listRegisteredPluginProviderIdsImpl.call(this)) {
|
||||
providers[pluginProviderId] = this.loadProviderConnection(pluginProviderId);
|
||||
if (this.providerWriteSeq[pluginProviderId] == null) this.providerWriteSeq[pluginProviderId] = 0;
|
||||
if (this.providerDecryptSeq[pluginProviderId] == null) this.providerDecryptSeq[pluginProviderId] = 0;
|
||||
if (this.providerDecrypted[pluginProviderId] == null) this.providerDecrypted[pluginProviderId] = false;
|
||||
if (this.providerAuthAttemptSeq[pluginProviderId] == null) this.providerAuthAttemptSeq[pluginProviderId] = 0;
|
||||
if (this.providerAuthRestoreState[pluginProviderId] === undefined) {
|
||||
this.providerAuthRestoreState[pluginProviderId] = null;
|
||||
}
|
||||
}
|
||||
enforceLegacySingleProviderConnected(providers);
|
||||
|
||||
// Save device ID if new
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||
@@ -95,14 +138,24 @@ export function loadInitialStateImpl(this: any): SyncManagerState {
|
||||
}
|
||||
|
||||
export function loadProviderConnectionImpl(this: any,provider: CloudProvider): ProviderConnection {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
const key = providerConnectionStorageKey(provider);
|
||||
const stored = this.loadFromStorage<Partial<ProviderConnection>>(key);
|
||||
|
||||
// Config may be a valid scalar including JSON null (schema type: "null").
|
||||
// Presence is property existence; only a missing property means absent.
|
||||
const hasCreds = stored?.tokens != null
|
||||
|| (stored != null && Object.prototype.hasOwnProperty.call(stored, 'config'));
|
||||
// Determine the correct status: if tokens or config exist, should be 'connected'
|
||||
// Never restore 'syncing' or 'error' status - those are transient
|
||||
const status: ProviderConnection['status'] = (stored?.tokens || stored?.config)
|
||||
? 'connected'
|
||||
: 'disconnected';
|
||||
// Never restore 'syncing' or 'error' status - those are transient.
|
||||
// Dynamic plugin providers only rejoin sync when the host is ready AND the
|
||||
// provider was recorded as contribution-available (survives restart).
|
||||
// Host-off or uninstalled providers stay disconnected with config retained.
|
||||
let status: ProviderConnection['status'] = hasCreds ? 'connected' : 'disconnected';
|
||||
if (isPluginCloudProviderId(provider) && hasCreds) {
|
||||
const hostReady = isPluginSyncIpcAvailable();
|
||||
const available = listAvailablePluginSyncProviderIdsImpl.call(this);
|
||||
status = hostReady && available.includes(provider) ? 'connected' : 'disconnected';
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -111,12 +164,192 @@ export function loadProviderConnectionImpl(this: any,provider: CloudProvider): P
|
||||
} as ProviderConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy (non-convergent) mode allows only one ready provider. After restart,
|
||||
* retained plugin configs can race with a builtin the user switched to — keep
|
||||
* config but force extras offline (same policy as setAvailablePluginSyncProviderIds).
|
||||
*/
|
||||
export function enforceLegacySingleProviderConnected(
|
||||
providers: Record<string, ProviderConnection>,
|
||||
): void {
|
||||
let convergentActive = false;
|
||||
try {
|
||||
convergentActive = getConvergentSyncLocalConfig().initialized === true;
|
||||
} catch {
|
||||
convergentActive = false;
|
||||
}
|
||||
if (convergentActive) return;
|
||||
|
||||
const readyIds = Object.entries(providers)
|
||||
.filter(([, conn]) => conn != null && (conn.status === 'connected' || conn.status === 'syncing'))
|
||||
.map(([id]) => id);
|
||||
if (readyIds.length <= 1) return;
|
||||
|
||||
// Prefer a builtin (stable UI order), else first ready id.
|
||||
const preferred = BUILTIN_CLOUD_PROVIDERS.find((id) => readyIds.includes(id))
|
||||
?? readyIds[0]!;
|
||||
for (const id of readyIds) {
|
||||
if (id === preferred) continue;
|
||||
const conn = providers[id];
|
||||
if (!conn) continue;
|
||||
providers[id] = {
|
||||
...conn,
|
||||
status: 'disconnected',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function listRegisteredPluginProviderIdsImpl(this: any): string[] {
|
||||
const raw = this.loadFromStorage<unknown>(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((id: unknown): id is string => typeof id === 'string' && isPluginCloudProviderId(id))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function listAvailablePluginSyncProviderIdsImpl(this: any): string[] {
|
||||
if (typeof this.loadFromStorage !== 'function') return [];
|
||||
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))
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function markPluginSyncProviderAvailableImpl(this: any, provider: CloudProvider): void {
|
||||
if (!isPluginCloudProviderId(provider)) return;
|
||||
const next = new Set(listAvailablePluginSyncProviderIdsImpl.call(this));
|
||||
next.add(provider);
|
||||
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(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
|
||||
} else {
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the live contribution-available set. Called when the renderer
|
||||
* refreshes plugin sync provider contributions so disabled/uninstalled
|
||||
* providers stop rejoining sync cycles after restart.
|
||||
*/
|
||||
export function setAvailablePluginSyncProviderIdsImpl(
|
||||
this: any,
|
||||
providerIds: readonly string[],
|
||||
): void {
|
||||
const previous = listAvailablePluginSyncProviderIdsImpl.call(this);
|
||||
const previousSet = new Set(previous);
|
||||
const next = [...new Set(
|
||||
providerIds.filter((id): id is string => typeof id === 'string' && isPluginCloudProviderId(id)),
|
||||
)].sort();
|
||||
if (next.length === 0) {
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS);
|
||||
} else {
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.AVAILABLE_PLUGIN_SYNC_PROVIDERS, next);
|
||||
}
|
||||
if (!this.state?.providers) return;
|
||||
let changed = false;
|
||||
for (const id of Object.keys(this.state.providers)) {
|
||||
if (!isPluginCloudProviderId(id)) continue;
|
||||
const conn = this.state.providers[id];
|
||||
if (!conn) continue;
|
||||
if (!next.includes(id)) {
|
||||
// Disabled/uninstalled: leave config, force offline ready-state.
|
||||
if (conn.status !== 'disconnected') {
|
||||
this.state.providers[id] = {
|
||||
...conn,
|
||||
status: 'disconnected',
|
||||
error: 'Plugin sync provider is no longer installed or enabled',
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
const adapter = this.adapters?.get?.(id);
|
||||
if (adapter) {
|
||||
try { adapter.signOut(); } catch { /* ignore */ }
|
||||
this.adapters.delete(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Provider is still contributed. Only drop the cached adapter when the
|
||||
// provider newly re-entered the available set (install/enable/restart of
|
||||
// the package). Setting-only contribution noise keeps membership stable
|
||||
// and must not signOut mid-sync — plugin adapters rebind via rebindSession.
|
||||
if (!previousSet.has(id) && this.adapters?.has?.(id)) {
|
||||
const adapter = this.adapters.get(id);
|
||||
try { adapter?.signOut?.(); } catch { /* ignore */ }
|
||||
this.adapters.delete(id);
|
||||
}
|
||||
// Re-enable retained configs whenever the contribution reappears, but only
|
||||
// when doing so cannot create a second active provider in legacy
|
||||
// single-provider mode (convergent multi-provider allows it).
|
||||
// Config may be a valid falsy scalar (false, 0, "") — only null/undefined means absent.
|
||||
const hasRetainedConfig = conn.tokens != null
|
||||
|| Object.prototype.hasOwnProperty.call(conn, 'config');
|
||||
if (hasRetainedConfig && conn.status === 'disconnected') {
|
||||
let convergentActive = false;
|
||||
try {
|
||||
convergentActive = getConvergentSyncLocalConfig().initialized === true;
|
||||
} catch {
|
||||
convergentActive = false;
|
||||
}
|
||||
const anotherReady = Object.entries(this.state.providers as Record<string, ProviderConnection>)
|
||||
.some(([otherId, other]) => (
|
||||
otherId !== id
|
||||
&& other != null
|
||||
&& (other.status === 'connected' || other.status === 'syncing')
|
||||
));
|
||||
if (!convergentActive && anotherReady) {
|
||||
// Keep disconnected with retained config until the user reconnects
|
||||
// explicitly; auto-reactivation would mirror two legacy providers.
|
||||
continue;
|
||||
}
|
||||
this.state.providers[id] = {
|
||||
...conn,
|
||||
status: 'connected',
|
||||
error: undefined,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed && typeof this.notifyStateChange === 'function') {
|
||||
this.notifyStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPluginProviderIdImpl(this: any, provider: CloudProvider): void {
|
||||
if (!isPluginCloudProviderId(provider)) return;
|
||||
const next = new Set(listRegisteredPluginProviderIdsImpl.call(this));
|
||||
next.add(provider);
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS, [...next].sort());
|
||||
markPluginSyncProviderAvailableImpl.call(this, provider);
|
||||
}
|
||||
|
||||
export function unregisterPluginProviderIdImpl(this: any, provider: CloudProvider): void {
|
||||
if (!isPluginCloudProviderId(provider)) return;
|
||||
const next = listRegisteredPluginProviderIdsImpl.call(this).filter((id) => id !== provider);
|
||||
if (next.length === 0) {
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS);
|
||||
} else {
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS, next);
|
||||
}
|
||||
markPluginSyncProviderUnavailableImpl.call(this, provider);
|
||||
}
|
||||
|
||||
export async function initProviderDecryptionImpl(this: any): Promise<void> {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
const providers: CloudProvider[] = [
|
||||
...BUILTIN_CLOUD_PROVIDERS,
|
||||
...listRegisteredPluginProviderIdsImpl.call(this),
|
||||
];
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const conn = this.state.providers[p];
|
||||
if (conn.tokens || conn.config) {
|
||||
// Config may be a valid scalar including JSON null — presence is property existence.
|
||||
if (conn.tokens != null || Object.prototype.hasOwnProperty.call(conn, 'config')) {
|
||||
const seq = ++this.providerDecryptSeq[p];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
@@ -141,9 +374,10 @@ export async function saveProviderConnectionImpl(this: any,
|
||||
connection: ProviderConnection,
|
||||
authAttemptId?: number
|
||||
): Promise<void> {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
const key = providerConnectionStorageKey(provider);
|
||||
// Use write-specific counter so status-only updates cannot discard
|
||||
// an in-flight encrypted write that must be persisted.
|
||||
ensureProviderSeqCounters(this, provider);
|
||||
const seq = ++this.providerWriteSeq[provider];
|
||||
const encrypted = await encryptProviderSecrets(connection);
|
||||
// Only persist if no newer save has started during the async gap
|
||||
@@ -152,6 +386,16 @@ export async function saveProviderConnectionImpl(this: any,
|
||||
(authAttemptId == null || this.isActiveAuthAttempt(provider, authAttemptId))
|
||||
) {
|
||||
this.saveToStorage(key, encrypted);
|
||||
// Keep dynamic plugin providers in the restart registry while connected
|
||||
// (or while credentials/config remain so a missing plugin cannot drop them).
|
||||
if (isPluginCloudProviderId(provider)) {
|
||||
// Config may be a valid scalar including JSON null — presence is property existence.
|
||||
const hasData = encrypted.tokens != null
|
||||
|| Object.prototype.hasOwnProperty.call(encrypted, 'config');
|
||||
if (hasData || encrypted.status === 'connected' || encrypted.status === 'syncing') {
|
||||
registerPluginProviderIdImpl.call(this, provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +514,35 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Plugin provider registry changes (connect/disconnect of dynamic IDs).
|
||||
if (key === SYNC_STORAGE_KEYS.PLUGIN_CLOUD_PROVIDERS) {
|
||||
const registered = listRegisteredPluginProviderIdsImpl.call(this);
|
||||
const nextProviders = { ...this.state.providers } as Record<CloudProvider, ProviderConnection>;
|
||||
for (const id of registered) {
|
||||
if (!nextProviders[id]) {
|
||||
nextProviders[id] = this.loadProviderConnection(id);
|
||||
if (this.providerWriteSeq[id] == null) this.providerWriteSeq[id] = 0;
|
||||
if (this.providerDecryptSeq[id] == null) this.providerDecryptSeq[id] = 0;
|
||||
if (this.providerDecrypted[id] == null) this.providerDecrypted[id] = false;
|
||||
if (this.providerAuthAttemptSeq[id] == null) this.providerAuthAttemptSeq[id] = 0;
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(nextProviders)) {
|
||||
if (isBuiltinCloudProvider(id)) continue;
|
||||
if (!registered.includes(id)) {
|
||||
const adapter = this.adapters.get(id);
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(id);
|
||||
}
|
||||
delete nextProviders[id];
|
||||
}
|
||||
}
|
||||
this.state.providers = nextProviders;
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync provider connections (connect/disconnect, account, tokens, last sync)
|
||||
const providerByKey: Partial<Record<string, CloudProvider>> = {
|
||||
[SYNC_STORAGE_KEYS.PROVIDER_GITHUB]: 'github',
|
||||
@@ -278,8 +551,15 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
[SYNC_STORAGE_KEYS.PROVIDER_WEBDAV]: 'webdav',
|
||||
[SYNC_STORAGE_KEYS.PROVIDER_S3]: 's3',
|
||||
};
|
||||
const provider = providerByKey[key];
|
||||
let provider = providerByKey[key] as CloudProvider | undefined;
|
||||
if (!provider && key.startsWith('netcatty_provider_plugin_v1:')) {
|
||||
provider = key.slice('netcatty_provider_plugin_v1:'.length) as CloudProvider;
|
||||
}
|
||||
if (provider) {
|
||||
// Dynamic plugin providers may receive their first storage event before
|
||||
// the registry handler initializes counters; undefined++ becomes NaN and
|
||||
// every subsequent decrypt result is discarded (NaN !== NaN).
|
||||
ensureProviderSeqCounters(this, provider);
|
||||
const rawNext = this.loadProviderConnection(provider);
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
// Also bump write seq so any in-flight save from this window for the
|
||||
@@ -292,7 +572,10 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
decryptProviderSecrets(rawNext).then((next) => {
|
||||
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
|
||||
|
||||
const prev = this.state.providers[provider];
|
||||
const prev = this.state.providers[provider] ?? {
|
||||
provider,
|
||||
status: 'disconnected' as const,
|
||||
};
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
|
||||
@@ -305,7 +588,10 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
// 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);
|
||||
@@ -315,21 +601,40 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
(prev.tokens?.accessToken ?? null) !== (nextTokens?.accessToken ?? null) ||
|
||||
(prev.tokens?.refreshToken ?? null) !== (nextTokens?.refreshToken ?? null) ||
|
||||
(prev.tokens?.expiresAt ?? null) !== (nextTokens?.expiresAt ?? null) ||
|
||||
(prev.tokens?.tokenType ?? null) !== (nextTokens?.tokenType ?? null) ||
|
||||
(prev.tokens?.scope ?? null) !== (nextTokens?.scope ?? null);
|
||||
|
||||
// Config may be a valid falsy scalar (false, 0, "") — use nullish, not ||.
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
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(() => {
|
||||
@@ -341,12 +646,14 @@ export function handleStorageEventImpl(this: any, event: StorageEvent): void {
|
||||
export async function getConnectedAdapterImpl(this: any,provider: CloudProvider): Promise<CloudAdapter> {
|
||||
// Ensure startup decryption has finished before reading tokens
|
||||
await this.decryptionReady;
|
||||
ensureProviderSeqCounters(this, provider);
|
||||
|
||||
// If this provider's secrets were not successfully decrypted at
|
||||
// startup (IPC handler not registered yet), retry now.
|
||||
if (!this.providerDecrypted[provider]) {
|
||||
const conn = this.state.providers[provider];
|
||||
if (conn.tokens || conn.config) {
|
||||
// Config may be a valid scalar including JSON null — presence is property existence.
|
||||
if (conn.tokens != null || Object.prototype.hasOwnProperty.call(conn, 'config')) {
|
||||
try {
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
@@ -371,7 +678,10 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
|
||||
const connection = this.state.providers[provider];
|
||||
const tokens = connection?.tokens;
|
||||
const config = connection?.config;
|
||||
if (!tokens && !config) {
|
||||
// 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 && connection?.credential == null) {
|
||||
throw new Error('Provider not connected');
|
||||
}
|
||||
|
||||
@@ -381,7 +691,37 @@ export async function getConnectedAdapterImpl(this: any,provider: CloudProvider)
|
||||
return existing;
|
||||
}
|
||||
|
||||
const adapter = await createAdapter(provider, tokens, connection.resourceId, config);
|
||||
const createPluginStorage = async (providerId: string): Promise<EncryptedObjectStorage> => {
|
||||
if (typeof this.createPluginStorage === 'function') {
|
||||
return this.createPluginStorage(providerId, connection);
|
||||
}
|
||||
if (!isPluginSyncIpcAvailable()) {
|
||||
throw new Error(
|
||||
`Plugin sync provider ${providerId} is unavailable (plugin host not enabled)`,
|
||||
);
|
||||
}
|
||||
const host = createPluginSyncIpcHost();
|
||||
// Preserve explicit null configuration (schema type: "null"); only
|
||||
// default to {} when the config property is truly absent.
|
||||
const configuration = connection != null
|
||||
&& Object.prototype.hasOwnProperty.call(connection, 'config')
|
||||
? connection.config
|
||||
: {};
|
||||
return createPluginSyncObjectStorage({
|
||||
providerId,
|
||||
host,
|
||||
configuration,
|
||||
credential: connection?.credential,
|
||||
});
|
||||
};
|
||||
|
||||
const adapter = await createAdapter(
|
||||
provider,
|
||||
tokens,
|
||||
connection.resourceId,
|
||||
config,
|
||||
isBuiltinCloudProvider(provider) ? undefined : { createPluginStorage },
|
||||
);
|
||||
attachTokenRefreshPersistence.call(this, provider, adapter);
|
||||
this.adapters.set(provider, adapter);
|
||||
return adapter;
|
||||
@@ -429,6 +769,7 @@ export function persistRefreshedProviderTokensImpl(
|
||||
|
||||
// Invalidate any in-flight decrypt (startup / cross-window) so it cannot
|
||||
// overwrite the rotated tokens we are about to commit.
|
||||
ensureProviderSeqCounters(this, provider);
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...existing,
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from '../../../domain/sync';
|
||||
// CloudProvider used when clearing dynamic plugin-provider bases/anchors.
|
||||
import {
|
||||
decryptLocalStorageValue,
|
||||
encryptLocalStorageValue,
|
||||
@@ -596,8 +597,21 @@ export async function syncAllProvidersImpl(this: any,
|
||||
await Promise.all(uploadTasks);
|
||||
|
||||
// 5. Final State Update
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
const resultList = Array.from(results.values());
|
||||
const hasSuccess = resultList.some((r) => r.success);
|
||||
const hasConflict = resultList.some((r) => r.conflictDetected);
|
||||
if (hasConflict) {
|
||||
// Prefer CONFLICT over IDLE even when another provider succeeded, so the
|
||||
// conflict UI from uploadToProvider is not wiped by a mixed multi-provider run.
|
||||
this.state.syncState = 'CONFLICT';
|
||||
if (wasMerged && payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) {
|
||||
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (hasSuccess) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
@@ -780,7 +794,18 @@ export function clearSyncBaseImpl(this: any): void {
|
||||
if (typeof this.syncSnapshotsKey === 'function') {
|
||||
this.removeFromStorage(this.syncSnapshotsKey());
|
||||
}
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
const providers = new Set<CloudProvider>([
|
||||
'github', 'google', 'onedrive', 'webdav', 's3',
|
||||
]);
|
||||
for (const id of Object.keys(this.state?.providers ?? {})) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
if (typeof this.listRegisteredPluginProviderIds === 'function') {
|
||||
for (const id of this.listRegisteredPluginProviderIds()) {
|
||||
providers.add(id as CloudProvider);
|
||||
}
|
||||
}
|
||||
for (const p of providers) {
|
||||
this.removeFromStorage(this.syncBaseKey(p));
|
||||
this.removeFromStorage(this.convergentProviderBaselineKey(p));
|
||||
if (typeof this.syncSnapshotsKey === 'function') {
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
"maxRecords": 10000
|
||||
}
|
||||
},
|
||||
"SyncLimits": {
|
||||
"description": "Host-enforced byte and identifier limits for one encrypted sync-object operation. inlineObjectBytes is the maximum ciphertext size that may travel inline on the control plane (below the 128 KiB provider JSON budget after base64 expansion).",
|
||||
"const": {
|
||||
"maxObjectBytes": 67108864,
|
||||
"maxObjectKeyLength": 1024,
|
||||
"maxRevisionLength": 256,
|
||||
"inlineObjectBytes": 92160
|
||||
}
|
||||
},
|
||||
"StreamId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
@@ -1195,6 +1204,196 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"SyncObjectKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1024
|
||||
},
|
||||
"SyncObjectRevision": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"SyncAccount": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 512 },
|
||||
"email": { "type": "string", "maxLength": 512 },
|
||||
"name": { "type": "string", "maxLength": 512 },
|
||||
"avatarUrl": { "type": "string", "maxLength": 2048 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncConnectPayload": {
|
||||
"type": "object",
|
||||
"required": ["configuration", "operationId"],
|
||||
"properties": {
|
||||
"configuration": { "$ref": "#/$defs/JsonValue" },
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"credential": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/SecretRef" },
|
||||
{ "$ref": "#/$defs/CredentialRef" },
|
||||
{ "$ref": "#/$defs/SecretLeaseRef" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncConnectResult": {
|
||||
"type": "object",
|
||||
"required": ["account"],
|
||||
"properties": {
|
||||
"account": { "$ref": "#/$defs/SyncAccount" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDisconnectPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDisconnectResult": {
|
||||
"type": "null"
|
||||
},
|
||||
"SyncGetAccountPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncGetAccountResult": {
|
||||
"type": "object",
|
||||
"required": ["account"],
|
||||
"properties": {
|
||||
"account": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/SyncAccount" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncGetCapabilitiesPayload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncCapabilitiesResult": {
|
||||
"type": "object",
|
||||
"required": ["revisions", "conditionalWrites", "atomicReplacement"],
|
||||
"properties": {
|
||||
"revisions": { "type": "boolean" },
|
||||
"conditionalWrites": { "type": "boolean" },
|
||||
"atomicReplacement": { "type": "boolean" },
|
||||
"maxObjectBytes": { "type": "integer", "minimum": 1, "maximum": 67108864 },
|
||||
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncReadObjectPayload": {
|
||||
"type": "object",
|
||||
"required": ["key", "operationId"],
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/SyncObjectKey" },
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"outputStreamId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"windowBytes": { "type": "integer", "minimum": 1024, "maximum": 16777216 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncReadObjectResult": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["found"],
|
||||
"properties": {
|
||||
"found": { "const": false }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["found", "byteLength", "encoding", "data"],
|
||||
"properties": {
|
||||
"found": { "const": true },
|
||||
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
|
||||
"encoding": { "const": "base64" },
|
||||
"data": { "type": "string", "maxLength": 9007199254740991 },
|
||||
"revision": { "$ref": "#/$defs/SyncObjectRevision" },
|
||||
"contentType": { "type": "string", "maxLength": 256 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["found", "byteLength", "streamed"],
|
||||
"properties": {
|
||||
"found": { "const": true },
|
||||
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
|
||||
"streamed": { "const": true },
|
||||
"revision": { "$ref": "#/$defs/SyncObjectRevision" },
|
||||
"contentType": { "type": "string", "maxLength": 256 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"SyncWriteObjectPayload": {
|
||||
"type": "object",
|
||||
"required": ["key", "operationId", "byteLength"],
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/SyncObjectKey" },
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"byteLength": { "type": "integer", "minimum": 0, "maximum": 67108864 },
|
||||
"expectedRevision": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/SyncObjectRevision" },
|
||||
{ "type": "null" }
|
||||
]
|
||||
},
|
||||
"encoding": { "const": "base64" },
|
||||
"data": { "type": "string", "maxLength": 9007199254740991 },
|
||||
"inputStreamId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"windowBytes": { "type": "integer", "minimum": 1024, "maximum": 16777216 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncWriteObjectResult": {
|
||||
"type": "object",
|
||||
"required": ["created"],
|
||||
"properties": {
|
||||
"created": { "type": "boolean" },
|
||||
"revision": { "$ref": "#/$defs/SyncObjectRevision" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDeleteObjectPayload": {
|
||||
"type": "object",
|
||||
"required": ["key", "operationId"],
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/SyncObjectKey" },
|
||||
"operationId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
||||
"expectedRevision": { "$ref": "#/$defs/SyncObjectRevision" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncDeleteObjectResult": {
|
||||
"type": "object",
|
||||
"required": ["deleted"],
|
||||
"properties": {
|
||||
"deleted": { "type": "boolean" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CompanionPlatform": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -59,6 +59,69 @@ test("plugin manifest schema accepts the internal contract", () => {
|
||||
assert.equal(validate({ ...validManifest, version: "1.0.0-01" }), false);
|
||||
});
|
||||
|
||||
test("sync provider encrypted-object payloads and results are canonical and bounded", () => {
|
||||
const connect = validator("SyncConnectPayload");
|
||||
const capabilities = validator("SyncCapabilitiesResult");
|
||||
const readResult = validator("SyncReadObjectResult");
|
||||
const writePayload = validator("SyncWriteObjectPayload");
|
||||
const writeResult = validator("SyncWriteObjectResult");
|
||||
const deleteResult = validator("SyncDeleteObjectResult");
|
||||
const disconnectResult = validator("SyncDisconnectResult");
|
||||
|
||||
assert.equal(connect({
|
||||
configuration: { endpoint: "https://example.test" },
|
||||
operationId: "sync:1",
|
||||
}), true, JSON.stringify(connect.errors));
|
||||
assert.equal(connect({ configuration: {}, operationId: "" }), false);
|
||||
|
||||
assert.equal(capabilities({
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: false,
|
||||
maxObjectBytes: 1024,
|
||||
}), true, JSON.stringify(capabilities.errors));
|
||||
assert.equal(capabilities({
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: false,
|
||||
maxObjectBytes: 0,
|
||||
}), false);
|
||||
|
||||
assert.equal(readResult({ found: false }), true, JSON.stringify(readResult.errors));
|
||||
assert.equal(readResult({
|
||||
found: true,
|
||||
byteLength: 4,
|
||||
encoding: "base64",
|
||||
data: "qQECAw==",
|
||||
revision: "rev-1",
|
||||
}), true, JSON.stringify(readResult.errors));
|
||||
assert.equal(readResult({
|
||||
found: true,
|
||||
byteLength: 4,
|
||||
streamed: true,
|
||||
revision: "rev-1",
|
||||
}), true, JSON.stringify(readResult.errors));
|
||||
assert.equal(readResult({ found: true, byteLength: 4 }), false);
|
||||
|
||||
assert.equal(writePayload({
|
||||
key: "netcatty-vault.json",
|
||||
operationId: "sync:write:1",
|
||||
byteLength: 4,
|
||||
encoding: "base64",
|
||||
data: "qQECAw==",
|
||||
expectedRevision: null,
|
||||
}), true, JSON.stringify(writePayload.errors));
|
||||
assert.equal(writePayload({
|
||||
key: "netcatty-vault.json",
|
||||
operationId: "sync:write:1",
|
||||
byteLength: -1,
|
||||
}), false);
|
||||
assert.equal(writeResult({ created: true, revision: "rev-2" }), true, JSON.stringify(writeResult.errors));
|
||||
assert.equal(deleteResult({ deleted: true }), true, JSON.stringify(deleteResult.errors));
|
||||
assert.equal(disconnectResult(null), true, JSON.stringify(disconnectResult.errors));
|
||||
assert.equal(disconnectResult({}), false);
|
||||
});
|
||||
|
||||
test("connection authentication and importer provider payloads are canonical and bounded", () => {
|
||||
const connectionOpen = validator("ConnectionOpenPayload");
|
||||
const authenticationChallenge = validator("AuthenticationChallenge");
|
||||
|
||||
@@ -19,3 +19,7 @@ export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_RECORDS = 10000 as const;
|
||||
export const PLUGIN_SYNC_MAX_OBJECT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = 1024 as const;
|
||||
export const PLUGIN_SYNC_MAX_REVISION_LENGTH = 256 as const;
|
||||
export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = 92160 as const;
|
||||
@@ -747,6 +747,105 @@ export type StreamLimits = {"maxStreamIdLength":128,"maxChunkBytes":16777216,"ma
|
||||
|
||||
export type StreamWindowBytes = number;
|
||||
|
||||
export type SyncAccount = {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type SyncCapabilitiesResult = {
|
||||
revisions: boolean;
|
||||
conditionalWrites: boolean;
|
||||
atomicReplacement: boolean;
|
||||
maxObjectBytes?: number;
|
||||
maxObjects?: number;
|
||||
};
|
||||
|
||||
export type SyncConnectPayload = {
|
||||
configuration: JsonValue;
|
||||
operationId: string;
|
||||
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
|
||||
};
|
||||
|
||||
export type SyncConnectResult = {
|
||||
account: SyncAccount;
|
||||
};
|
||||
|
||||
export type SyncDeleteObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
expectedRevision?: SyncObjectRevision;
|
||||
};
|
||||
|
||||
export type SyncDeleteObjectResult = {
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type SyncDisconnectPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncDisconnectResult = null;
|
||||
|
||||
export type SyncGetAccountPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncGetAccountResult = {
|
||||
account: (SyncAccount) | (null);
|
||||
};
|
||||
|
||||
export type SyncGetCapabilitiesPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncLimits = {"maxObjectBytes":67108864,"maxObjectKeyLength":1024,"maxRevisionLength":256,"inlineObjectBytes":92160};
|
||||
|
||||
export type SyncObjectKey = string;
|
||||
|
||||
export type SyncObjectRevision = string;
|
||||
|
||||
export type SyncReadObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
outputStreamId?: string;
|
||||
windowBytes?: number;
|
||||
};
|
||||
|
||||
export type SyncReadObjectResult = ({
|
||||
found: false;
|
||||
}) | ({
|
||||
found: true;
|
||||
byteLength: number;
|
||||
encoding: "base64";
|
||||
data: string;
|
||||
revision?: SyncObjectRevision;
|
||||
contentType?: string;
|
||||
}) | ({
|
||||
found: true;
|
||||
byteLength: number;
|
||||
streamed: true;
|
||||
revision?: SyncObjectRevision;
|
||||
contentType?: string;
|
||||
});
|
||||
|
||||
export type SyncWriteObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
byteLength: number;
|
||||
expectedRevision?: (SyncObjectRevision) | (null);
|
||||
encoding?: "base64";
|
||||
data?: string;
|
||||
inputStreamId?: string;
|
||||
windowBytes?: number;
|
||||
};
|
||||
|
||||
export type SyncWriteObjectResult = {
|
||||
created: boolean;
|
||||
revision?: SyncObjectRevision;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentDescriptor = {
|
||||
providerId: ContributionId;
|
||||
direction: "input" | "output";
|
||||
|
||||
@@ -16,6 +16,10 @@ export {
|
||||
PLUGIN_IMPORTER_MAX_RECORDS,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
PLUGIN_RPC_MAX_JSON_BYTES,
|
||||
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
|
||||
PLUGIN_SYNC_MAX_OBJECT_BYTES,
|
||||
PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH,
|
||||
PLUGIN_SYNC_MAX_REVISION_LENGTH,
|
||||
PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
PLUGIN_WIRE_MAX_SAFE_INTEGER,
|
||||
|
||||
@@ -122,7 +122,7 @@ test("terminal interceptor typing stays specialized while broad ProviderKind hel
|
||||
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/kind: Exclude<\s*ProviderKind,\s*TerminalInterceptorKind \| OrdinaryTerminalProviderKind \| "connection" \| "authentication" \| "importer"\s*>,\s*handler: PluginProviderHandler/u,
|
||||
/kind: Exclude<\s*ProviderKind,\s*TerminalInterceptorKind \| OrdinaryTerminalProviderKind \| "connection" \| "authentication" \| "importer" \| "sync"\s*>,\s*handler: PluginProviderHandler/u,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
@@ -134,7 +134,7 @@ test("terminal interceptor typing stays specialized while broad ProviderKind hel
|
||||
);
|
||||
});
|
||||
|
||||
test("provider registrations infer typed connection and importer stream invocations", () => {
|
||||
test("provider registrations infer typed connection importer and sync stream invocations", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import { definePlugin } from "./index.ts";
|
||||
import type {
|
||||
@@ -143,6 +143,8 @@ test("provider registrations infer typed connection and importer stream invocati
|
||||
AuthenticationResult,
|
||||
ImporterKeyDraft,
|
||||
ImporterProviderHandler,
|
||||
SyncProviderHandler,
|
||||
SyncProviderResultByOperation,
|
||||
} from "./index.ts";
|
||||
|
||||
const resizeAck: ConnectionProviderResultByOperation["resize"] = null;
|
||||
@@ -192,6 +194,24 @@ test("provider registrations infer typed connection and importer stream invocati
|
||||
};
|
||||
void invalidImporterProvider;
|
||||
|
||||
const disconnectAck: SyncProviderResultByOperation["disconnect"] = null;
|
||||
void disconnectAck;
|
||||
// @ts-expect-error disconnect acknowledges with JSON null.
|
||||
const invalidDisconnectAck: SyncProviderResultByOperation["disconnect"] = { ok: true };
|
||||
void invalidDisconnectAck;
|
||||
|
||||
const invalidSyncProvider: SyncProviderHandler = {
|
||||
connect: () => ({ account: { id: "a" } }),
|
||||
disconnect: () => null,
|
||||
getAccount: () => ({ account: null }),
|
||||
getCapabilities: () => ({ revisions: true, conditionalWrites: true, atomicReplacement: true }),
|
||||
// @ts-expect-error readObject must return a SyncReadObjectResult, not write result.
|
||||
readObject: () => ({ created: true }),
|
||||
writeObject: () => ({ created: true }),
|
||||
deleteObject: () => ({ deleted: true }),
|
||||
};
|
||||
void invalidSyncProvider;
|
||||
|
||||
// @ts-expect-error challenge results must include the exact challenge payload.
|
||||
const incompleteAuthenticationResult: AuthenticationResult = { status: "challenge" };
|
||||
void incompleteAuthenticationResult;
|
||||
@@ -252,6 +272,54 @@ test("provider registrations infer typed connection and importer stream invocati
|
||||
return { confidence: 1 };
|
||||
},
|
||||
});
|
||||
|
||||
context.providers.register("com.example.sync", "sync", {
|
||||
connect(invocation) {
|
||||
void invocation.payload.configuration;
|
||||
return { account: { id: "acct" } };
|
||||
},
|
||||
disconnect() {
|
||||
return null;
|
||||
},
|
||||
getAccount() {
|
||||
return { account: { id: "acct" } };
|
||||
},
|
||||
getCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
maxObjectBytes: 1024,
|
||||
};
|
||||
},
|
||||
async readObject(invocation) {
|
||||
if (invocation.output) {
|
||||
await invocation.output.write(new Uint8Array([1, 2, 3]));
|
||||
await invocation.output.end();
|
||||
return { found: true, byteLength: 3, streamed: true, revision: "r1" };
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
byteLength: 3,
|
||||
encoding: "base64",
|
||||
data: "AQID",
|
||||
revision: "r1",
|
||||
};
|
||||
},
|
||||
async writeObject(invocation) {
|
||||
if (invocation.input) {
|
||||
const stream = await invocation.input;
|
||||
await stream.read();
|
||||
}
|
||||
return { created: true, revision: "r2" };
|
||||
},
|
||||
deleteObject() {
|
||||
return { deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error sync Providers use operation-keyed handlers.
|
||||
context.providers.register("com.example.sync.invalid", "sync", async () => ({ account: { id: "x" } }));
|
||||
},
|
||||
});
|
||||
`);
|
||||
|
||||
@@ -28,6 +28,20 @@ import type {
|
||||
SecretLeaseRef,
|
||||
SecretRef,
|
||||
SemanticVersion,
|
||||
SyncCapabilitiesResult,
|
||||
SyncConnectPayload,
|
||||
SyncConnectResult,
|
||||
SyncDeleteObjectPayload,
|
||||
SyncDeleteObjectResult,
|
||||
SyncDisconnectPayload,
|
||||
SyncDisconnectResult,
|
||||
SyncGetAccountPayload,
|
||||
SyncGetAccountResult,
|
||||
SyncGetCapabilitiesPayload,
|
||||
SyncReadObjectPayload,
|
||||
SyncReadObjectResult,
|
||||
SyncWriteObjectPayload,
|
||||
SyncWriteObjectResult,
|
||||
TerminalSessionSnapshot,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
@@ -137,6 +151,8 @@ type ProviderHandlerForKind<
|
||||
? AuthenticationProviderHandler
|
||||
: K extends "importer"
|
||||
? ImporterProviderHandler
|
||||
: K extends "sync"
|
||||
? SyncProviderHandler
|
||||
: PluginProviderHandler<TPayload, TResult>;
|
||||
|
||||
export interface PluginProviders {
|
||||
@@ -165,11 +181,16 @@ export interface PluginProviders {
|
||||
kind: "importer",
|
||||
handler: ImporterProviderHandler,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: "sync",
|
||||
handler: SyncProviderHandler,
|
||||
): Disposable;
|
||||
register<TPayload extends JsonValue = JsonValue, TResult extends JsonValue = JsonValue>(
|
||||
providerId: string,
|
||||
kind: Exclude<
|
||||
ProviderKind,
|
||||
TerminalInterceptorKind | OrdinaryTerminalProviderKind | "connection" | "authentication" | "importer"
|
||||
TerminalInterceptorKind | OrdinaryTerminalProviderKind | "connection" | "authentication" | "importer" | "sync"
|
||||
>,
|
||||
handler: PluginProviderHandler<TPayload, TResult>,
|
||||
): Disposable;
|
||||
@@ -534,6 +555,61 @@ export type ImporterProviderHandler = Readonly<{
|
||||
[TOperation in ImporterProviderOperation]: ImporterProviderOperationHandler<TOperation>;
|
||||
}>;
|
||||
|
||||
export interface SyncProviderInvocationByOperation {
|
||||
readonly connect: TypedPluginProviderInvocation<SyncConnectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "connect";
|
||||
};
|
||||
readonly disconnect: TypedPluginProviderInvocation<SyncDisconnectPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "disconnect";
|
||||
};
|
||||
readonly getAccount: TypedPluginProviderInvocation<SyncGetAccountPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "getAccount";
|
||||
};
|
||||
readonly getCapabilities: TypedPluginProviderInvocation<SyncGetCapabilitiesPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "getCapabilities";
|
||||
};
|
||||
readonly readObject: TypedPluginProviderInvocation<SyncReadObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "readObject";
|
||||
readonly output?: PluginWritableByteStream;
|
||||
};
|
||||
readonly writeObject: TypedPluginProviderInvocation<SyncWriteObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "writeObject";
|
||||
readonly input?: Promise<PluginReadableByteStream>;
|
||||
};
|
||||
readonly deleteObject: TypedPluginProviderInvocation<SyncDeleteObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "deleteObject";
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncProviderResultByOperation {
|
||||
readonly connect: SyncConnectResult;
|
||||
readonly disconnect: SyncDisconnectResult;
|
||||
readonly getAccount: SyncGetAccountResult;
|
||||
readonly getCapabilities: SyncCapabilitiesResult;
|
||||
readonly readObject: SyncReadObjectResult;
|
||||
readonly writeObject: SyncWriteObjectResult;
|
||||
readonly deleteObject: SyncDeleteObjectResult;
|
||||
}
|
||||
|
||||
export type SyncProviderOperation = keyof SyncProviderInvocationByOperation;
|
||||
export type SyncProviderInvocation =
|
||||
SyncProviderInvocationByOperation[SyncProviderOperation];
|
||||
export type SyncProviderResult =
|
||||
SyncProviderResultByOperation[SyncProviderOperation];
|
||||
export type SyncProviderOperationHandler<TOperation extends SyncProviderOperation> = (
|
||||
invocation: SyncProviderInvocationByOperation[TOperation],
|
||||
) => SyncProviderResultByOperation[TOperation] | Promise<SyncProviderResultByOperation[TOperation]>;
|
||||
export type SyncProviderHandler = Readonly<{
|
||||
[TOperation in SyncProviderOperation]: SyncProviderOperationHandler<TOperation>;
|
||||
}>;
|
||||
|
||||
export interface PluginTerminalSessions {
|
||||
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,18 @@ if (!Number.isSafeInteger(importerLimits?.maxInputBytes)
|
||||
|| importerLimits.maxRecords < 1) {
|
||||
throw new Error("ImporterLimits must define positive bounded input, output, record, and count limits");
|
||||
}
|
||||
const syncLimits = schema.$defs.SyncLimits?.const;
|
||||
if (!Number.isSafeInteger(syncLimits?.maxObjectBytes)
|
||||
|| !Number.isSafeInteger(syncLimits?.maxObjectKeyLength)
|
||||
|| !Number.isSafeInteger(syncLimits?.maxRevisionLength)
|
||||
|| !Number.isSafeInteger(syncLimits?.inlineObjectBytes)
|
||||
|| syncLimits.maxObjectBytes < 1
|
||||
|| syncLimits.maxObjectKeyLength < 1
|
||||
|| syncLimits.maxRevisionLength < 1
|
||||
|| syncLimits.inlineObjectBytes < 1
|
||||
|| syncLimits.inlineObjectBytes > syncLimits.maxObjectBytes) {
|
||||
throw new Error("SyncLimits must define positive bounded object, key, revision, and inline size limits");
|
||||
}
|
||||
for (const [name, minimum, maximum] of [
|
||||
["TerminalInterceptorChunkByteLength", 0, terminalInterceptorLimits.maxChunkBytes],
|
||||
["TerminalInterceptorWindowBytes", 1, terminalInterceptorLimits.maxWindowBytes],
|
||||
@@ -328,6 +340,10 @@ const generatedLimits = [
|
||||
`export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = ${importerLimits.maxOutputBytes} as const;`,
|
||||
`export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = ${importerLimits.maxRecordBytes} as const;`,
|
||||
`export const PLUGIN_IMPORTER_MAX_RECORDS = ${importerLimits.maxRecords} as const;`,
|
||||
`export const PLUGIN_SYNC_MAX_OBJECT_BYTES = ${syncLimits.maxObjectBytes} as const;`,
|
||||
`export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = ${syncLimits.maxObjectKeyLength} as const;`,
|
||||
`export const PLUGIN_SYNC_MAX_REVISION_LENGTH = ${syncLimits.maxRevisionLength} as const;`,
|
||||
`export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = ${syncLimits.inlineObjectBytes} as const;`,
|
||||
"",
|
||||
].join("\n");
|
||||
const normalizedSchema = `${JSON.stringify(schema, null, 2)}\n`;
|
||||
|
||||
Reference in new issue
Block a user