Compare commits

...
Author SHA1 Message Date
bincxzandCursor cb1b4f93ac fix(plugins): require sidecar grace before version backup
Always wait for contributions/grace tick before the upgrade snapshot, and only latch after a non-cancelled attempt so cleanup cannot suppress retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:16:03 +08:00
bincxzandCursor 551824c0e6 fix(plugins): wait for sidecar host before version backup latch
Defer the one-shot upgrade snapshot until the plugin sidecar host is ready (or a short grace), and catch getDataHash failures so pending/baseline paths do not reject unhandled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:11:40 +08:00
netcatty-bot 71b26c51a6 fix: address Codex review on PR #2713 2026-08-05 09:59:56 +00:00
bincxzandCursor 00b16ed421 fix(plugins): dedupe sync type imports for lint
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:27:35 +08:00
bincxzandCursor 13127bd492 fix(plugins): harden single-provider, secret schema, and conditional conflicts
Keep legacy restart from dual-connecting providers, extract writeOnly schema
secrets into OS storage, and surface conditional-write precondition failures
as conflict UI instead of a generic sync error.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:24:21 +08:00
bincxzandCursor b4eb61cb5e fix(plugins): close sync-provider credential, stream, and sidecar gaps
Make SecretRef usable end-to-end via lease-bound network.request, keep
renderer/main transfers abortable and bounded, and stop sidecar
last-known/pending from corrupting backups and conflict apply.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:31:26 +08:00
bincxzandCursor df4b05e259 fix(plugins): delete stale-scope settings at stored coordinates on reset
After resetSetting against the current declared scope, also remove the row at
the sidecar's stored scope so scope migrations cannot republish remote deletes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:37:11 +08:00
bincxzandCursor 1f79ea6d7c fix(plugins): keep merged sidecars in last-known after apply collect fails
Return applyFromSync entries from IPC and use them when follow-up collect fails,
so preserved missing-plugin rows are not dropped from the offline upload cache.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:30:05 +08:00
bincxzandCursor cafba2e5c7 fix(plugins): reseal marker-shaped plugin configs instead of skipping
Exact __netcatty_plugin_config_v1 collisions no longer bypass encryption; trusted
host envelopes are unwrapped then resealed, and false collisions are sealed whole.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:20:32 +08:00
bincxzandCursor 16cc5ee090 fix(plugins): commit applied sidecar cache and reset orphan settings
Prefer merged payload sidecars when updating last-known after sync, and clear
syncable plugin_settings that lack sidecar rows on authoritative remote deletes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:10:30 +08:00
bincxzandCursor 60a953a189 fix(plugins): avoid restricted localStorage global in sidecar test
Bind the mock to a local binding so eslint no-restricted-globals stays clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:55:53 +08:00
bincxzandCursor 83d056327c fix(plugins): defer empty sidecar last-known until sync succeeds
Keep prior non-empty last-known across an authoritative empty collect so the
empty-vault guard can still recognize a plugin-only reset, then commit the
empty cache after a successful upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:44:02 +08:00
bincxzandCursor 9ead42ccd6 fix(plugins): rebind plugin sync sessions and keep null configs
Address Codex P2 on 8ae52c54: re-issue connect after runtime replacement,
and treat stored config:null as present in the dashboard reconnect path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:27:49 +08:00
bincxzandCursor 8ae52c5458 fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals
Close remaining Codex/review gaps for PR8: keep empty sidecar shells from
bypassing upload guards, skip startup hydrate when local settings are newer,
seal/validate durable plugin credential refs, and cover schema 1→2 migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:17:35 +08:00
bincxz 0cbdcf75dd fix(plugins): keep inline read cutoff under control-plane budget
Restore the base64-safe inline cutoff so advertised-size reads cannot
overflow the 128 KiB provider JSON envelope; providers near the public
inlineObjectBytes limit must stream.

Related to #2269
2026-08-05 06:00:41 +08:00
bincxz 7b85c7af5a fix(plugins): close residual Codex P2 sync edge cases
Three-way merge sidecars during v2 convergent migration so local
resets are not resurrected, preserve explicit null plugin configs on
adapter rebuild, rebind EOS sessions after I/O failure for replaced
runtimes, and accept the public inlineObjectBytes limit on readObject.

Related to #2269
2026-08-05 05:47:13 +08:00
bincxz 0d41caff96 fix(plugins): use window.netcatty for sync IPC and allow null configs
Read plugin sync and sidecar APIs from the production preload global,
and treat an explicit config property (including JSON null) as present
for readiness and adapter lookup.

Related to #2269
2026-08-05 05:31:30 +08:00
bincxz c43f418e18 fix(plugins): strip top-bar plugin avatars and stop always-rebind
Hide plugin avatar URLs in the sync status popover, drop always-on
rebindSession for plugin adapters, and read convergent mode from the
local config module when deciding whether to auto-reactivate providers.

Related to #2269
2026-08-05 05:15:24 +08:00
bincxz 555e377e96 fix(plugins): strip plugin avatars and protect last-known sidecars
Do not load plugin-supplied avatar URLs in the main renderer, and avoid
overwriting last-known sidecars with an unmerged remote bundle when the
post-apply collect fails or is non-authoritative.

Related to #2269
2026-08-05 05:02:40 +08:00
bincxz e37bd54ffa fix(plugins): keep strategy on final sidecar merge and block dual legacy
Pass preferCloud/preferLocal through the final verified sidecar merge,
avoid auto-reactivating a retained plugin while another legacy provider
is already connected, cache plugin adapters only after initializeSync,
and preserve explicit null configuration values.

Related to #2269
2026-08-05 04:46:15 +08:00
bincxz 5917159ccb fix(plugins): honor conflict policy and re-key scoped sidecars
Pass preferCloud/preferLocal into three-way sidecar merge, use
per-provider baselines on convergent downgrade so local resets are not
resurrected, and re-key applied settings under the current declared
scope coordinates.

Related to #2269
2026-08-05 04:34:41 +08:00
bincxz 97fb0e39d2 fix(plugins): restore remoteKeys for installed sidecar resets
Reintroduce the remote key set used when dropping installed-plugin
settings the remote no longer carries so applyFromSync cannot throw
after replacing the sidecar table.

Related to #2269
2026-08-05 04:13:07 +08:00
bincxz 00e7dac9ff fix(plugins): avoid mid-sync adapter drops and sidecar resurrection
Keep plugin adapters across setting-only contribution refreshes and
rebind sessions on ensureConnected; stop preserving local-only missing
plugin sidecars when remote already mentions the plugin; compare configs
with nullish semantics across windows; swallow quick-connect failures
after toasting.

Related to #2269
2026-08-05 03:58:05 +08:00
bincxz f78f82d1e8 fix(plugins): seal config envelopes and stabilize provider counters
Use an unambiguous host-owned sealed-config envelope, initialize
dynamic-provider sequence counters before cross-window decrypt, keep
three-way sidecar deletions during convergent migration, and create the
sidecar table for existing schema-1 plugin databases.

Related to #2269
2026-08-05 03:45:01 +08:00
bincxz 7e61854843 fix(plugins): apply sidecars under current declared setting scopes
When a plugin update changes a syncable setting scope, write under the
live declaration and drop the obsolete scope row so collection does not
republish duplicates.

Related to #2269
2026-08-05 03:31:37 +08:00
bincxz 091cc82539 fix(plugins): rebind sync adapters on contribution refresh
Drop cached plugin sync adapters when live contributions still include
the provider so replaced runtimes get a fresh connect, and seal falsy
scalar plugin configs with nullish presence checks end-to-end.

Related to #2269
2026-08-05 03:30:58 +08:00
bincxz 14740674df fix(plugins): unblock schema-required sync connect and scalar configs
Add a JSON config dialog for plugin sync providers that declare a
required configurationSchema, reuse retained configs on reconnect, and
treat falsy scalar configs as present in readiness and adapter lookup.

Related to #2269
2026-08-05 03:18:31 +08:00
bincxz 31d1420b72 fix(plugins): thread sync credentials and harden availability checks
Pass SyncConnectPayload.credential through the object-storage and IPC host
path, treat only null/undefined config as missing, and avoid wiping the
plugin provider availability catalog on transient discovery failures.

Related to #2269
2026-08-05 02:57:53 +08:00
bincxz e47f5e367d fix(plugins): seal scalar configs, safe inline reads, stable config fingerprint
Encrypt any non-builtin plugin configuration JSON shape, enforce the
base64-safe inline read cutoff, and compare reconnect configurations with
order-independent fingerprints so merge bases are not cleared spuriously.

Related to #2269
2026-08-05 02:45:50 +08:00
bincxz 77fcf79e96 fix(plugins): seal plugin configs by provider id and hydrate before start
Encrypt plugin provider configuration only for non-builtin provider IDs
so field-name collisions cannot skip sealing, hydrate retained sidecars
before plugin enable starts the runtime, and stop tearing down live
adapters on no-op availability refreshes.

Related to #2269
2026-08-05 02:31:45 +08:00
bincxz 4afa963246 fix(plugins): seal plugin provider config and clear base on config change
Encrypt opaque plugin provider configuration before persistence, clear
merge state when reconnect configuration changes, and stream sync objects
below a base64-safe inline threshold so control-plane JSON stays in budget.

Related to #2269
2026-08-05 02:19:52 +08:00
bincxz 5a52d47bb6 fix(plugins): keep newer retained sidecars over older stored settings
When collection merges existing sidecars with plugin_settings rows, prefer
the newer updatedAt so a schema-rejected remote value retained only in
the sidecar table is not overwritten by a stale local setting.

Related to #2269
2026-08-05 02:09:01 +08:00
bincxz 15d19acd1c fix(plugins): preserve omitted sidecars and hydrate on plugin enable
Treat missing pluginSidecars as legacy/unsupported in three-way merge
(only explicit empty is a wipe), hydrate retained settings when a plugin
enables, and re-enable retained provider configs when contributions return.

Related to #2269
2026-08-05 01:54:32 +08:00
bincxz 5e703da494 fix(plugins): drop cached adapters on contribution refresh
Invalidate plugin sync adapters when live contributions change so a
restarted runtime gets a fresh connect, take offline any non-disconnected
missing providers, and latch version-change backups only after cancellation
checks pass.

Related to #2269
2026-08-05 01:40:23 +08:00
bincxz 5ceaddf41d fix(plugins): include sidecars in equality, preserve merge base on reconnect
Compare pluginSidecars in cloudSyncPayloadsEqual for migration freshness,
clear plugin merge state only when account/resource changes on reconnect,
cancel accepted sync read streams on failure, and stop re-connecting the
plugin EOS adapter on every I/O.

Related to #2269
2026-08-05 01:30:44 +08:00
bincxz a96c335fdb fix(plugins): abort backup collect failures and honor empty remote wipes
Only fall back to last-known sidecars when the host is unavailable during
protective backups, treat explicit empty remote sidecar bundles as
authoritative wipes, remove lint-blocking anys from harness tests, and
use a stable equal-timestamp sidecar tie-break.

Related to #2269
2026-08-05 01:14:49 +08:00
bincxz fd96527e0e fix(plugins): restore pluginBridge tests, fix import path, union downgrade sidecars
Correct SettingsSyncTab dynamic import path so production builds resolve,
add ipcMain.on to the plugin bridge test double, union all provider
sidecars on convergent downgrade, keep empty last-known resets, and skip
force-delete when contribution reset validation rejects.

Related to #2269
2026-08-05 00:56:12 +08:00
bincxz 69500b7a69 fix(plugins): abort conflict collect failures and merge migration sidecars
Rethrow operational sidecar collection errors during convergent conflict
resolution, LWW-union all local/provider sidecars during convergent
migration, re-collect last-known after apply, and prefer live host collect
for protective local vault backups.

Related to #2269
2026-08-05 00:46:22 +08:00
bincxz 109f88f852 fix(plugins): preserve sidecars in backups, pending queue, and migration
Attach last-known plugin sidecars to protective local vault backups, fail
operationally when pending/last-known storage writes are rejected, carry
sidecars through convergent migration publish, and reject trailing garbage
on encrypted object parse.

Related to #2269
2026-08-05 00:32:11 +08:00
bincxz 89a9b53012 fix(plugins): refresh sync provider availability from live contributions
Replace the contribution-available provider ID set when plugins change so
disabled or uninstalled sync providers leave the auto-sync ready set and
drop to disconnected with config retained.

Related to #2269
2026-08-05 00:24:34 +08:00
bincxz ab8507c060 fix(plugins): correct plugin provider discovery and sidecar reverify
Read nested contribution metadata for sync provider cards, gate empty-config
connects, enforce single-provider disconnect for plugins, and re-verify after
sidecar-only convergent uploads so baselines track the post-write payload.

Related to #2269
2026-08-05 00:17:00 +08:00
bincxz a16967c8da fix(plugins): per-provider sidecar baselines and safer collect/UI
Merge convergent sidecars against each provider's own baseline, keep
retained settings in sidecars without unvalidated plugin_settings writes,
guard isConnectDisabled for never-connected plugins, and fall back plugin
provider labels/icons in the status popup.

Related to #2269
2026-08-05 00:05:32 +08:00
bincxz 6c8adf7ea5 fix(plugins): surface plugin sync providers and preserve conflict sidecars
Collect live sidecars before convergent conflict apply/upload, include
dynamic providers in status and disconnect paths, and list plugin sync
providers in Cloud Sync settings for connect/disconnect.

Related to #2269
2026-08-04 23:53:33 +08:00
bincxz 38b4f5ee0b fix(plugins): hydrate retained sidecars and gate missing providers
Materialize installed-plugin settings from retained sidecars before
collect pruning, clear sidecars on setting reset, fail closed when
pending remote replay fails, and only restore plugin providers that are
contribution-available. Also initialize dynamic provider counters on
connect.

Related to #2269
2026-08-04 23:39:50 +08:00
bincxz 20ded2b5df fix(plugins): separate pending remote sidecars from last-known cache
Replay only host-offline remote applies into the DB, keep last-known as
upload fallback, use contribution reset for deleted settings, probe real
plugin host readiness, rebind storage events for plugin providers, clear
plugin bases on reset, and swallow disconnect rejections.

Related to #2269
2026-08-04 23:21:40 +08:00
bincxz ea3f60cc48 fix(plugins): close remaining Codex P1 sidecar and provider gaps
Replay offline-cached sidecars before collect, keep explicit empty
sidecar fields after three-way merge, thread sidecars through convergent
conflict/downgrade materialization, keep gated-off plugin providers from
joining sync as connected, and rebind plugin EOS sessions after runtime
replacement.

Related to #2269
2026-08-04 23:03:26 +08:00
bincxz 7f7df4c44d fix(plugins): close Codex P1 sidecar collect/apply/merge gaps
Treat null host responses as unavailable, persist collected settings into
the non-cascade table, retain decoded convergent payloads, use three-way
sidecar merge for deletions, and fail apply only on operational errors.
Also treat non-empty plugin sidecars as meaningful sync data and re-run
auto-sync when plugin contributions change.

Related to #2269
2026-08-04 22:43:54 +08:00
bincxz b65f9b8426 fix(plugins): close remaining Codex sidecar/sync path gaps
Include sidecars in auto-sync payload and hash, keep explicit empty bundles,
fail apply when host is offline, require getCapabilities, and carry merged
sidecars into convergent local apply/upload decisions.

Related to #2269
2026-08-04 22:24:28 +08:00
bincxz 5dc0f68fcd fix(plugins): harden sidecar collect/apply and write verification
Preserve last-known sidecars when the host is offline, apply remote
deletions for installed plugins, force convergent upload on sidecar
divergence, verify plugin object writes, and pick plugin providers in
auto-sync startup checks.

Related to #2269
2026-08-04 22:06:04 +08:00
bincxz 86faa4136a fix(plugins): close remaining Codex P1/P2 sync issues
Lazy stream cancel for inline sync reads, must-not-exist conditional writes,
three-way sidecar merge for local resets, validated apply path for settings,
and merge remote sidecars into convergent uploads.

Related to #2269
2026-08-04 21:48:53 +08:00
bincxz 684a397624 fix(plugins): address Codex PR 8 review findings
Carry pluginSidecars through merge/convergent uploads, lazy-connect before
object I/O with revision-aware writes, fail closed on sidecar collect errors,
expose connectPluginProvider, preserve remote timestamps, and drop deleted
installed-plugin settings from collection.

Related to #2269
2026-08-04 21:31:00 +08:00
bincxz 93924c720a fix(plugins): preserve WebDAV auth reuse and resourceId through EOS wrap
encryptedObjectStorageAsCloudAdapter now reports initiallyAuthenticated when
config exists (matching raw WebDAVAdapter) so getConnectedAdapter reuses the
cached instance, and preserves/refreshes resourceId from the backing adapter
instead of always forcing the default object key.

Related to #2269
2026-08-04 20:58:18 +08:00
bincxz 52ec3fff23 fix(plugins): wire sync providers and sidecars into production paths
Close PR 8 integration gaps: dynamic plugin provider registry and
getConnectedAdapter createPluginStorage, WebDAV through EncryptedObjectStorage,
cloud payload collect/apply for pluginSidecars, and IPC/preload bridges.

Related to #2269
2026-08-04 20:48:50 +08:00
bincxz 3fe349d3d5 feat(plugins): add sync providers and encrypted sidecar sync
Implement PR 8 of the plugin platform (#2269): namespaced sync Providers
with provider.sync, encrypted-object storage operations, WebDAV through the
shared storage surface, dynamic cloud provider IDs, and non-cascade sidecars
for sync:true plugin settings plus account/CRDT baselines.

Related to #2269
2026-08-04 20:37:24 +08:00
88 changed files with 10471 additions and 223 deletions

No files matched your search

+52 -12
View File
@@ -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
+7
View File
@@ -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',
+7
View File
@@ -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} мин назад',
+7
View File
@@ -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} 分钟前',
+7
View File
@@ -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} 分鐘前',
+2 -2
View File
@@ -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;
}
}
+209
View File
@@ -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');
});
+343
View File
@@ -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);
}
+1 -1
View File
@@ -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 }>) {
+85 -21
View File
@@ -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,
]);
+112 -5
View File
@@ -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,
+238
View File
@@ -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
View File
@@ -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(
+14 -4
View File
@@ -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
+25 -11
View File
@@ -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 -4
View File
@@ -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(),
);
+13 -1
View File
@@ -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.
+87
View File
@@ -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.
+2 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+32
View File
@@ -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);
});
});
+59
View File
@@ -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';
+3
View File
@@ -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));
}
+96 -3
View File
@@ -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(
+18 -1
View File
@@ -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 {
+71
View File
@@ -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';
+1
View File
@@ -18,6 +18,7 @@ const ALLOWED_KEYWORDS = new Set([
'properties',
'required',
'type',
'writeOnly',
]);
type Schema = Record<string, unknown>;
+65
View File
@@ -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: {} },
);
});
+40
View File
@@ -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');
}
+105
View File
@@ -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'));
});
});
+155
View File
@@ -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];
}
+31
View File
@@ -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,
);
});
+258
View File
@@ -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);
});
});
+344
View File
@@ -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);
});
}
+35
View File
@@ -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
View File
@@ -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
// ============================================================================
+108
View File
@@ -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",
+26
View File
@@ -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]);
+2
View File
@@ -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);
+8
View File
@@ -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", {
+150 -4
View File
@@ -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);
+120
View File
@@ -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();
});
+443 -2
View File
@@ -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": [
+31
View File
@@ -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,
+38 -1
View File
@@ -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) {
+464
View File
@@ -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");
+94
View File
@@ -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({
+68 -23
View File
@@ -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?.();
+9
View File
@@ -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);
+39 -3
View File
@@ -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");
});
+63
View File
@@ -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
View File
@@ -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>;
+6
View File
@@ -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';
+180 -12
View File
@@ -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
}
}
}
+49 -9
View File
@@ -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';
}
+77 -8
View File
@@ -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";
+4
View File
@@ -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,
+70 -2
View File
@@ -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" } }));
},
});
`);
+77 -1
View File
@@ -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;
}
+16
View File
@@ -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`;