[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { downloadGistRevision, downloadSyncGist } from './GitHubAdapter.ts';
import { SYNC_CONSTANTS } from '../../../domain/sync.ts';
type FetchCall = {
url: string;
headers?: HeadersInit;
};
function installFetchMock(
handler: (url: string, init?: RequestInit) => Promise<Response> | Response,
): { calls: FetchCall[]; restore: () => void } {
const calls: FetchCall[] = [];
const original = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
calls.push({ url, headers: init?.headers });
return handler(url, init);
}) as typeof fetch;
return {
calls,
restore: () => {
globalThis.fetch = original;
},
};
}
const FULL_SYNCED_FILE = {
meta: {
version: 3,
updatedAt: 1_700_000_000_000,
deviceId: 'device-1',
deviceName: 'Test',
},
payload: `ENC:${'A'.repeat(50_000)}`,
};
const FULL_CONTENT = JSON.stringify(FULL_SYNCED_FILE, null, 2);
// Reproduce #2643: Gist API embeds a truncated mid-string payload (~900 KiB cut).
const TRUNCATED_CONTENT = FULL_CONTENT.slice(0, 900);
const utf8ByteLength = (value: string): number =>
new TextEncoder().encode(value).byteLength;
test('downloadSyncGist fetches raw_url when Gist API marks the file truncated', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
const { calls, restore } = installFetchMock((url) => {
if (url.includes('/gists/') && !url.includes('raw')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: TRUNCATED_CONTENT,
truncated: true,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, FULL_SYNCED_FILE);
const rawCall = calls.find((c) => c.url === rawUrl);
assert.ok(rawCall, 'must fetch raw_url for truncated gist');
assert.deepEqual(rawCall.headers, {
Authorization: 'Bearer token-xyz',
Accept: 'application/vnd.github.raw',
});
} finally {
restore();
}
});
test('downloadSyncGist falls back to raw_url when embedded content is incomplete JSON', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/netcatty-vault.json';
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/') && !url.includes('raw')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
// truncated flag missing (edge case) but content is cut mid-string
content: TRUNCATED_CONTENT,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, FULL_SYNCED_FILE);
} finally {
restore();
}
});
test('downloadSyncGist uses embedded content when file is not truncated', async () => {
const small = { meta: { version: 1 }, payload: 'small' };
const body = JSON.stringify(small);
let rawFetches = 0;
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: body,
truncated: false,
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
size: utf8ByteLength(body),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
rawFetches += 1;
return new Response('should not fetch', { status: 500 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, small);
assert.equal(rawFetches, 0);
} finally {
restore();
}
});
test('downloadSyncGist keeps embedded multibyte content without raw fetch', async () => {
const small = {
meta: {
version: 1,
deviceName: '我的电脑',
},
payload: 'small',
};
const body = JSON.stringify(small);
// GitHub reports UTF-8 bytes; string.length is smaller for CJK, which must not
// be treated as truncation.
assert.ok(utf8ByteLength(body) > body.length);
let rawFetches = 0;
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: body,
truncated: false,
raw_url: 'https://gist.githubusercontent.com/u/abc/raw/unused',
size: utf8ByteLength(body),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
rawFetches += 1;
return new Response('should not fetch', { status: 500 });
});
try {
const result = await downloadSyncGist('token-xyz', 'gist-1');
assert.deepEqual(result, small);
assert.equal(rawFetches, 0);
} finally {
restore();
}
});
test('downloadGistRevision also recovers truncated content via raw_url', async () => {
const rawUrl = 'https://gist.githubusercontent.com/u/abc/raw/rev/netcatty-vault.json';
const { restore } = installFetchMock((url) => {
if (url.includes('/gists/gist-1/deadbeef')) {
return new Response(
JSON.stringify({
id: 'gist-1',
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
filename: SYNC_CONSTANTS.SYNC_FILE_NAME,
content: TRUNCATED_CONTENT,
truncated: true,
raw_url: rawUrl,
size: utf8ByteLength(FULL_CONTENT),
},
},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}),
{ status: 200 },
);
}
if (url === rawUrl) {
return new Response(FULL_CONTENT, { status: 200 });
}
return new Response('not found', { status: 404 });
});
try {
const result = await downloadGistRevision('token-xyz', 'gist-1', 'deadbeef');
assert.deepEqual(result, FULL_SYNCED_FILE);
} finally {
restore();
}
});

View File

@@ -0,0 +1,773 @@
/**
* GitHub OAuth Adapter - Device Flow Implementation
*
* Uses Device Authorization Grant (RFC 8628) which doesn't require a client secret.
* Perfect for desktop apps where the secret cannot be securely stored.
*
* Flow:
* 1. Request device code from GitHub
* 2. User opens browser and enters the code
* 3. Poll for access token until user completes auth
* 4. Use Gist API for sync file storage
*/
import {
SYNC_CONSTANTS,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type GitHubDeviceCodeResponse,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
// ============================================================================
// Types
// ============================================================================
export interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
}
export interface GitHubGistFile {
filename: string;
/** Embedded body from the Gist API. Truncated at ~1 MB when `truncated` is true. */
content?: string;
truncated?: boolean;
raw_url?: string;
/** File size in UTF-8 bytes (GitHub Gist API). */
size?: number;
}
export interface GitHubGist {
id: string;
description: string;
files: Record<string, GitHubGistFile>;
created_at: string;
updated_at: string;
history?: Array<{
version: string;
committed_at: string;
}>;
}
export interface DeviceFlowState {
deviceCode: string;
userCode: string;
verificationUri: string;
expiresAt: number;
interval: number;
authAttemptId?: number;
}
const createGitHubPollId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `github-poll-${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const createGitHubCancelError = (): Error => {
const error = new Error('GitHub auth cancelled');
error.name = 'AbortError';
return error;
};
const throwIfAborted = (signal?: AbortSignal): void => {
if (signal?.aborted) {
throw createGitHubCancelError();
}
};
const delayWithSignal = (ms: number, signal?: AbortSignal): Promise<void> => {
if (!signal) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(createGitHubCancelError());
return;
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(createGitHubCancelError());
};
signal.addEventListener('abort', onAbort, { once: true });
});
};
// ============================================================================
// Device Flow Authentication
// ============================================================================
/**
* Start GitHub Device Flow authentication
* Returns codes for user to enter in browser
*/
export const startDeviceFlow = async (): Promise<DeviceFlowState> => {
console.log('[GitHub] Starting device flow...');
console.log('[GitHub] Client ID:', SYNC_CONSTANTS.GITHUB_CLIENT_ID);
const bridge = netcattyBridge.get();
if (bridge?.githubStartDeviceFlow) {
return bridge.githubStartDeviceFlow({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
scope: 'gist read:user',
});
}
let response: Response;
try {
response = await fetch(SYNC_CONSTANTS.GITHUB_DEVICE_CODE_URL, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
scope: 'gist read:user',
}).toString(),
});
} catch (fetchError) {
console.error('[GitHub] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
console.log('[GitHub] Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('[GitHub] Error response:', errorText);
throw new Error(`GitHub device flow failed: ${response.status} - ${errorText}`);
}
const data: GitHubDeviceCodeResponse = await response.json();
console.log('[GitHub] Device flow started, user code:', data.user_code);
return {
deviceCode: data.device_code,
userCode: data.user_code,
verificationUri: data.verification_uri,
expiresAt: Date.now() + data.expires_in * 1000,
interval: data.interval,
};
};
/**
* Poll for access token after user authorizes
*/
export const pollForToken = async (
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens | null> => {
const pollInterval = Math.max(interval, 5) * 1000; // Minimum 5 seconds
const bridge = netcattyBridge.get();
while (Date.now() < expiresAt) {
await delayWithSignal(pollInterval, signal);
throwIfAborted(signal);
const pollId = createGitHubPollId();
const cancelPoll = () => {
void bridge?.githubCancelDeviceFlowPoll?.(pollId);
};
if (signal) {
signal.addEventListener('abort', cancelPoll, { once: true });
}
try {
let data;
try {
data = bridge?.githubPollDeviceFlowToken
? await bridge.githubPollDeviceFlowToken({
clientId: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
deviceCode,
pollId,
})
: await (async () => {
const response = await fetch(SYNC_CONSTANTS.GITHUB_ACCESS_TOKEN_URL, {
method: 'POST',
signal,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
}).toString(),
});
return response.json();
})();
} catch (error) {
if (
signal?.aborted ||
(error instanceof Error &&
(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')))
) {
throw createGitHubCancelError();
}
throw error;
}
throwIfAborted(signal);
if (data.access_token) {
return {
accessToken: data.access_token,
tokenType: data.token_type || 'bearer',
scope: data.scope,
};
}
if (data.error === 'authorization_pending') {
onPending?.();
continue;
}
if (data.error === 'slow_down') {
// Increase interval as requested
await delayWithSignal(5000, signal);
continue;
}
if (data.error === 'expired_token') {
throw new Error('Device code expired. Please try again.');
}
if (data.error === 'access_denied') {
throw new Error('User denied authorization.');
}
if (data.error) {
throw new Error(`GitHub auth error: ${data.error_description || data.error}`);
}
} finally {
if (signal) {
signal.removeEventListener('abort', cancelPoll);
}
}
}
throw new Error('Device code expired. Please try again.');
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (
accessToken: string,
signal?: AbortSignal
): Promise<ProviderAccount> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to get user info: ${response.statusText}`);
}
const user: GitHubUser = await response.json();
return {
id: String(user.id),
email: user.email || undefined,
name: user.name || user.login,
avatarUrl: user.avatar_url,
};
};
/**
* Validate access token is still valid
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
try {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/user`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// Gist Operations
// ============================================================================
/**
* Find existing Netcatty sync gist
*/
export const findSyncGist = async (
accessToken: string,
signal?: AbortSignal
): Promise<string | null> => {
// List user's gists and find ours
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists?per_page=100`, {
signal,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to list gists: ${response.statusText}`);
}
const gists: GitHubGist[] = await response.json();
const syncGist = gists.find(g =>
g.description === SYNC_CONSTANTS.GIST_DESCRIPTION &&
g.files[SYNC_CONSTANTS.SYNC_FILE_NAME]
);
return syncGist?.id || null;
};
/**
* Create a new sync gist
*/
export const createSyncGist = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
description: SYNC_CONSTANTS.GIST_DESCRIPTION,
public: false,
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
content: JSON.stringify(syncedFile, null, 2),
},
},
}),
});
if (!response.ok) {
throw new Error(`Failed to create gist: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return gist.id;
};
/**
* Update existing sync gist
*/
export const updateSyncGist = async (
accessToken: string,
gistId: string,
syncedFile: SyncedFile
): Promise<void> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: {
[SYNC_CONSTANTS.SYNC_FILE_NAME]: {
content: JSON.stringify(syncedFile, null, 2),
},
},
}),
});
if (!response.ok) {
throw new Error(`Failed to update gist: ${response.statusText}`);
}
};
/**
* GitHub's Gist REST API truncates each file's embedded `content` around 1 MB
* and sets `truncated: true`. Full bodies must be fetched from `raw_url`
* (issue #2643: JSON.parse on truncated content → "Unterminated string").
*/
const fetchGistRawContent = async (
accessToken: string,
rawUrl: string,
): Promise<string> => {
const bridge = netcattyBridge.get();
if (bridge?.githubDownloadGistRawContent) {
return bridge.githubDownloadGistRawContent({ accessToken, rawUrl });
}
const response = await fetch(rawUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.raw',
},
});
if (!response.ok) {
throw new Error(`Failed to download full gist content: ${response.statusText}`);
}
return response.text();
};
const utf8ByteLength = (value: string): number =>
new TextEncoder().encode(value).byteLength;
const gistFileNeedsRawFetch = (file: GitHubGistFile): boolean => {
if (file.truncated) return true;
// GitHub `size` is UTF-8 bytes; JS string `.length` is UTF-16 code units.
if (
typeof file.size === 'number' &&
typeof file.content === 'string' &&
file.size > utf8ByteLength(file.content)
) {
return true;
}
return false;
};
const parseSyncedFileFromGistFile = async (
accessToken: string,
file: GitHubGistFile | undefined,
): Promise<SyncedFile | null> => {
if (!file) return null;
const tryParse = (raw: string): SyncedFile => JSON.parse(raw) as SyncedFile;
if (gistFileNeedsRawFetch(file)) {
if (!file.raw_url) {
throw new Error(
'GitHub Gist sync file is truncated and no raw URL is available. ' +
'The vault may exceed GitHub Gist size limits.',
);
}
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
}
if (!file.content) return null;
try {
return tryParse(file.content);
} catch (error) {
// Defensive path for incomplete embedded JSON without truncated=true (#2643).
if (error instanceof SyntaxError && file.raw_url) {
return tryParse(await fetchGistRawContent(accessToken, file.raw_url));
}
throw error;
}
};
/**
* Download sync file from gist
*/
export const downloadSyncGist = async (
accessToken: string,
gistId: string
): Promise<SyncedFile | null> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`Failed to download gist: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
};
/**
* Delete sync gist
*/
export const deleteSyncGist = async (
accessToken: string,
gistId: string
): Promise<void> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok && response.status !== 404) {
throw new Error(`Failed to delete gist: ${response.statusText}`);
}
};
/**
* Get gist revision history
*/
export const getGistHistory = async (
accessToken: string,
gistId: string
): Promise<Array<{ version: string; date: Date }>> => {
const response = await fetch(`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
});
if (!response.ok) {
throw new Error(`Failed to get gist history: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return (gist.history || []).map(h => ({
version: h.version,
date: new Date(h.committed_at),
}));
};
/**
* Download a specific historical revision of the sync gist.
* Uses `GET /gists/{gist_id}/{sha}` which returns the gist at that point
* in time. Returns the raw SyncedFile (still encrypted) or null if the
* revision does not contain the sync file.
*/
export const downloadGistRevision = async (
accessToken: string,
gistId: string,
sha: string,
): Promise<SyncedFile | null> => {
const response = await fetch(
`${SYNC_CONSTANTS.GITHUB_API_BASE}/gists/${gistId}/${sha}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/vnd.github.v3+json',
},
},
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Failed to download gist revision: ${response.statusText}`);
}
const gist: GitHubGist = await response.json();
return parseSyncedFileFromGistFile(accessToken, gist.files[SYNC_CONSTANTS.SYNC_FILE_NAME]);
};
// ============================================================================
// GitHub Adapter Class
// ============================================================================
export class GitHubAdapter {
private accessToken: string | null = null;
private gistId: string | null = null;
private account: ProviderAccount | null = null;
constructor(tokens?: OAuthTokens, gistId?: string) {
if (tokens) {
this.accessToken = tokens.accessToken;
}
this.gistId = gistId || null;
}
get isAuthenticated(): boolean {
return !!this.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.gistId;
}
/**
* Start Device Flow authentication
*/
async startAuth(): Promise<DeviceFlowState> {
return startDeviceFlow();
}
/**
* Complete authentication by polling for token
*/
async completeAuth(
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal
): Promise<OAuthTokens> {
const tokens = await pollForToken(deviceCode, interval, expiresAt, onPending, signal);
if (!tokens) {
throw new Error('Failed to obtain access token');
}
throwIfAborted(signal);
this.accessToken = tokens.accessToken;
this.account = await getUserInfo(tokens.accessToken, signal);
throwIfAborted(signal);
return tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.accessToken = tokens.accessToken;
if (await validateToken(tokens.accessToken)) {
this.account = await getUserInfo(tokens.accessToken);
} else {
throw new Error('Token is invalid or expired');
}
}
/**
* Sign out
*/
signOut(): void {
this.accessToken = null;
this.gistId = null;
this.account = null;
}
/**
* Initialize or find sync gist
*/
async initializeSync(signal?: AbortSignal): Promise<string | null> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
this.gistId = await findSyncGist(this.accessToken, signal);
return this.gistId;
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
if (this.gistId) {
await updateSyncGist(this.accessToken, this.gistId, syncedFile);
return this.gistId;
} else {
this.gistId = await createSyncGist(this.accessToken, syncedFile);
return this.gistId;
}
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) {
return null;
}
return downloadSyncGist(this.accessToken, this.gistId);
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.accessToken || !this.gistId) {
return;
}
await deleteSyncGist(this.accessToken, this.gistId);
this.gistId = null;
}
/**
* Get revision history for the sync gist. Lazily discovers the gist
* ID if it hasn't been resolved yet (same pattern as `download()`).
*/
async getHistory(): Promise<Array<{ version: string; date: Date }>> {
if (!this.accessToken) return [];
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) return [];
return getGistHistory(this.accessToken, this.gistId);
}
/**
* Download a specific historical revision of the sync gist (still
* encrypted — the caller must decrypt it). Lazily discovers the
* gist ID if needed.
*/
async downloadRevision(sha: string): Promise<SyncedFile | null> {
if (!this.accessToken) return null;
if (!this.gistId) {
this.gistId = await findSyncGist(this.accessToken);
}
if (!this.gistId) return null;
return downloadGistRevision(this.accessToken, this.gistId, sha);
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
if (!this.accessToken) return null;
return {
accessToken: this.accessToken,
tokenType: 'bearer',
};
}
}
export default GitHubAdapter;

View File

@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { GoogleDriveAdapter } from './GoogleDriveAdapter.ts';
import type { OAuthTokens } from '../../../domain/sync.ts';
type WindowGlobal = typeof globalThis & { window?: unknown };
function setBridge(bridge: Record<string, unknown>): () => void {
const g = globalThis as WindowGlobal;
const original = g.window;
// Loosely typed: the real window.netcatty is a large NetcattyBridge; tests
// only stub the handful of Google methods the adapter actually calls.
g.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
return () => {
g.window = original;
};
}
const expiredTokens = (): OAuthTokens => ({
accessToken: 'old-access',
refreshToken: 'old-refresh',
// Already expired so any operation forces a refresh.
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
});
test('refreshing tokens during an operation fires the persistence callback with refreshed tokens', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
let refreshCalledWith: string | undefined;
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async ({ refreshToken }: { refreshToken: string }) => {
refreshCalledWith = refreshToken;
return refreshed;
},
googleDriveDownloadSyncFile: async ({ accessToken }: { accessToken: string }) => {
// Operation must run with the refreshed access token.
assert.equal(accessToken, 'fresh-access');
return { syncedFile: remoteSyncedFile };
},
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.equal(refreshCalledWith, 'old-refresh');
assert.equal(persisted.length, 1);
assert.deepEqual(persisted[0], refreshed);
// Adapter also exposes the refreshed tokens for the caller to persist.
assert.deepEqual(adapter.getTokens(), refreshed);
} finally {
restore();
}
});
test('refresh preserves the prior refresh token when Google omits a new one', async () => {
// Google's refresh response frequently has no refresh_token. The persisted
// tokens must keep the previous refresh token, or the connection becomes
// unrefreshable on the next launch.
const refreshedWithoutRefreshToken: OAuthTokens = {
accessToken: 'fresh-access',
// No refreshToken — mirrors a real Google refresh_token response.
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async () => refreshedWithoutRefreshToken,
googleDriveDownloadSyncFile: async () => ({ syncedFile: remoteSyncedFile }),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.download();
assert.equal(persisted.length, 1);
// The persisted (and in-memory) tokens carry the original refresh token.
assert.equal(persisted[0].refreshToken, 'old-refresh');
assert.equal(persisted[0].accessToken, 'fresh-access');
assert.equal(adapter.getTokens()?.refreshToken, 'old-refresh');
} finally {
restore();
}
});
test('setTokens refreshes an expired token and persists the refreshed tokens', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleGetUserInfo: async () => ({
id: 'u1',
email: 'u@example.com',
name: 'User',
picture: '',
}),
});
try {
const adapter = new GoogleDriveAdapter();
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.setTokens(expiredTokens());
assert.deepEqual(persisted, [refreshed]);
assert.deepEqual(adapter.getTokens(), refreshed);
assert.equal(adapter.accountInfo?.id, 'u1');
} finally {
restore();
}
});
test('a persistence callback that throws does not abort the operation', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleDriveDownloadSyncFile: async () => ({ syncedFile: remoteSyncedFile }),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
adapter.setOnTokensRefreshed(() => {
throw new Error('persist boom');
});
// Refresh succeeds, the throwing callback is swallowed, the op completes.
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.deepEqual(adapter.getTokens(), refreshed);
} finally {
restore();
}
});
test('signOut clears the refresh persistence callback', async () => {
const refreshed: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
googleRefreshAccessToken: async () => refreshed,
googleGetUserInfo: async () => ({
id: 'u1',
email: 'u@example.com',
name: 'User',
picture: '',
}),
});
try {
const adapter = new GoogleDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
adapter.signOut();
// Re-arm tokens and refresh; the callback must not fire after signOut.
await adapter.setTokens(expiredTokens());
assert.equal(callbackFired, false);
} finally {
restore();
}
});

View File

@@ -0,0 +1,701 @@
/**
* Google Drive OAuth Adapter - PKCE Loopback Flow
*
* Uses Authorization Code Grant with PKCE (RFC 7636) and loopback redirect.
* Data is stored in appDataFolder (hidden, app-specific folder).
*
* Flow:
* 1. Generate PKCE challenge
* 2. Open browser with auth URL
* 3. User authorizes, redirected to loopback
* 4. Exchange code for tokens
* 5. Use Drive API to manage sync file
*/
import {
SYNC_CONSTANTS,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type PKCEChallenge,
} from '../../../domain/sync';
import { arrayBufferToBase64, generateRandomBytes } from '../EncryptionService';
import { netcattyBridge } from '../netcattyBridge';
// ============================================================================
// Types
// ============================================================================
export interface GoogleUserInfo {
id: string;
email: string;
name: string;
picture: string;
}
export interface DriveFile {
id: string;
name: string;
modifiedTime: string;
size?: string;
}
// ============================================================================
// PKCE Utilities
// ============================================================================
/**
* Generate a cryptographically random code verifier
*/
const generateCodeVerifier = (): string => {
const bytes = generateRandomBytes(32);
return base64UrlEncode(bytes);
};
/**
* Generate code challenge from verifier (S256)
*/
const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
};
/**
* Base64 URL encoding (no padding, URL-safe chars)
*/
const base64UrlEncode = (bytes: Uint8Array): string => {
const base64 = arrayBufferToBase64(bytes);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
/**
* Generate PKCE challenge
*/
export const generatePKCEChallenge = async (): Promise<PKCEChallenge> => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = base64UrlEncode(generateRandomBytes(16));
return {
codeVerifier,
codeChallenge,
state,
};
};
// ============================================================================
// OAuth Flow
// ============================================================================
/**
* Build authorization URL for Google OAuth
*/
export const buildAuthUrl = async (
redirectUri: string
): Promise<{ url: string; pkce: PKCEChallenge }> => {
const pkce = await generatePKCEChallenge();
const params = new URLSearchParams({
client_id: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state: pkce.state,
access_type: 'offline',
prompt: 'consent',
});
return {
url: `${SYNC_CONSTANTS.GOOGLE_AUTH_URL}?${params.toString()}`,
pkce,
};
};
/**
* Exchange authorization code for tokens
*/
export const exchangeCodeForTokens = async (
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
const exchangeViaMain = bridge?.googleExchangeCodeForTokens;
if (!exchangeViaMain) {
throw new Error(
'Google OAuth bridge unavailable (token exchange is blocked by CORS in renderer). Please restart Netcatty.'
);
}
return await exchangeViaMain({
clientId: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
clientSecret: SYNC_CONSTANTS.GOOGLE_CLIENT_SECRET,
code,
codeVerifier,
redirectUri,
});
};
/**
* Refresh access token
*/
export const refreshAccessToken = async (refreshToken: string): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
const refreshViaMain = bridge?.googleRefreshAccessToken;
if (!refreshViaMain) {
throw new Error(
'Google OAuth bridge unavailable (token refresh is blocked by CORS in renderer). Please restart Netcatty.'
);
}
return await refreshViaMain({
clientId: SYNC_CONSTANTS.GOOGLE_CLIENT_ID,
clientSecret: SYNC_CONSTANTS.GOOGLE_CLIENT_SECRET,
refreshToken,
});
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
const bridge = netcattyBridge.get();
const userInfoViaMain = bridge?.googleGetUserInfo;
if (userInfoViaMain) {
const user = await userInfoViaMain({ accessToken });
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.picture,
};
}
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
const user: GoogleUserInfo = await response.json();
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.picture,
};
};
/**
* Validate access token
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
try {
const bridge = netcattyBridge.get();
const userInfoViaMain = bridge?.googleGetUserInfo;
if (userInfoViaMain) {
await userInfoViaMain({ accessToken });
return true;
}
const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// Drive Operations (appDataFolder)
// ============================================================================
/**
* Find sync file in appDataFolder
*/
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
const findViaMain = bridge?.googleDriveFindSyncFile;
if (findViaMain) {
const { fileId } = await findViaMain({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return fileId || null;
}
const params = new URLSearchParams({
spaces: 'appDataFolder',
q: `name = '${SYNC_CONSTANTS.SYNC_FILE_NAME}'`,
fields: 'files(id, name, modifiedTime)',
});
const url = `${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files?${params.toString()}`;
console.log('[GoogleDrive] Searching for sync file:', url);
let response: Response;
try {
response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[GoogleDrive] API error:', response.status, errorData);
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Drive API error: ${errorData.error?.message || response.status}`);
}
const data = await response.json();
console.log('[GoogleDrive] Found files:', data.files?.length || 0);
return data.files?.[0]?.id || null;
};
/**
* Create sync file in appDataFolder
*/
export const createSyncFile = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const bridge = netcattyBridge.get();
const createViaMain = bridge?.googleDriveCreateSyncFile;
if (createViaMain) {
const { fileId } = await createViaMain({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
syncedFile,
});
return fileId;
}
const metadata = {
name: SYNC_CONSTANTS.SYNC_FILE_NAME,
parents: ['appDataFolder'],
};
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);
form.append(
'file',
new Blob([JSON.stringify(syncedFile, null, 2)], { type: 'application/json' })
);
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API.replace('/v3', '/upload/v3')}/files?uploadType=multipart`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
body: form,
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to create file: ${errorData.error?.message || response.status}`);
}
const data = await response.json();
return data.id;
};
/**
* Update sync file
*/
export const updateSyncFile = async (
accessToken: string,
fileId: string,
syncedFile: SyncedFile
): Promise<void> => {
const bridge = netcattyBridge.get();
const updateViaMain = bridge?.googleDriveUpdateSyncFile;
if (updateViaMain) {
await updateViaMain({ accessToken, fileId, syncedFile });
return;
}
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API.replace('/v3', '/upload/v3')}/files/${fileId}?uploadType=media`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(syncedFile, null, 2),
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to update file: ${errorData.error?.message || response.status}`);
}
};
/**
* Download sync file
*/
export const downloadSyncFile = async (
accessToken: string,
fileId: string
): Promise<SyncedFile | null> => {
const bridge = netcattyBridge.get();
const downloadViaMain = bridge?.googleDriveDownloadSyncFile;
if (downloadViaMain) {
const { syncedFile } = await downloadViaMain({ accessToken, fileId });
return (syncedFile as SyncedFile | null) || null;
}
let response: Response;
try {
response = await fetch(
`${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files/${fileId}?alt=media`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok) {
if (response.status === 404) {
return null;
}
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to download file: ${errorData.error?.message || response.status}`);
}
return response.json();
};
/**
* Delete sync file
*/
export const deleteSyncFile = async (
accessToken: string,
fileId: string
): Promise<void> => {
const bridge = netcattyBridge.get();
const deleteViaMain = bridge?.googleDriveDeleteSyncFile;
if (deleteViaMain) {
await deleteViaMain({ accessToken, fileId });
return;
}
let response: Response;
try {
response = await fetch(`${SYNC_CONSTANTS.GOOGLE_DRIVE_API}/files/${fileId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
} catch (fetchError) {
console.error('[GoogleDrive] Network error:', fetchError);
throw new Error(`Network error: ${fetchError instanceof Error ? fetchError.message : 'Failed to fetch'}`);
}
if (!response.ok && response.status !== 404) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403) {
throw new Error('Google Drive API not enabled. Please enable it in Google Cloud Console.');
}
if (response.status === 401) {
throw new Error('Token expired or invalid. Please reconnect.');
}
throw new Error(`Failed to delete file: ${errorData.error?.message || response.status}`);
}
};
// ============================================================================
// Google Drive Adapter Class
// ============================================================================
export class GoogleDriveAdapter {
private tokens: OAuthTokens | null = null;
private fileId: string | null = null;
private account: ProviderAccount | null = null;
private pkceChallenge: PKCEChallenge | null = null;
/**
* Invoked whenever the access token is silently refreshed. Lets the owner
* (CloudSyncManager) persist the rotated tokens so the next launch doesn't
* load a stale access token and force a reconnect. Mirrors the OneDrive fix
* (#1189 / #1208); Google differs in that its refresh response usually omits a
* new refresh token, so refreshTokens() carries the previous one forward.
*/
private onTokensRefreshed: ((tokens: OAuthTokens) => void) | null = null;
constructor(tokens?: OAuthTokens, fileId?: string) {
if (tokens) {
this.tokens = tokens;
}
this.fileId = fileId || null;
}
/**
* Register a callback that receives refreshed tokens so the caller can
* persist them. Passing null removes the callback.
*/
setOnTokensRefreshed(callback: ((tokens: OAuthTokens) => void) | null): void {
this.onTokensRefreshed = callback;
}
/**
* Refresh the access token using the supplied refresh token, store the
* rotated tokens in-memory, and notify the persistence callback. Google
* typically returns the same (or no) refresh token on refresh, so the prior
* refresh token is preserved when the response omits one — otherwise the
* persisted credentials would lose the ability to refresh again and force a
* reconnect on the next launch.
*/
private async refreshTokens(refreshToken: string): Promise<OAuthTokens> {
const refreshed = await refreshAccessToken(refreshToken);
// Google's refresh response frequently omits refresh_token (it does not
// rotate on every refresh). Never let a missing value clobber the working
// refresh token, or the persisted connection becomes unrefreshable.
const merged: OAuthTokens = {
...refreshed,
refreshToken: refreshed.refreshToken || refreshToken,
};
this.tokens = merged;
try {
this.onTokensRefreshed?.(merged);
} catch {
// Persistence is best-effort; a failed save must not abort the sync that
// triggered the refresh — the fresh tokens still work for this session.
}
return merged;
}
get isAuthenticated(): boolean {
return !!this.tokens?.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.fileId;
}
/**
* Start OAuth flow - returns URL to open in browser
*/
async startAuth(redirectUri: string): Promise<string> {
const { url, pkce } = await buildAuthUrl(redirectUri);
this.pkceChallenge = pkce;
return url;
}
/**
* Get PKCE state for verification
*/
getPKCEState(): string | null {
return this.pkceChallenge?.state || null;
}
/**
* Complete authentication with authorization code
*/
async completeAuth(code: string, redirectUri: string): Promise<OAuthTokens> {
if (!this.pkceChallenge) {
throw new Error('No PKCE challenge - start auth first');
}
this.tokens = await exchangeCodeForTokens(
code,
this.pkceChallenge.codeVerifier,
redirectUri
);
this.pkceChallenge = null;
this.account = await getUserInfo(this.tokens.accessToken);
return this.tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.tokens = tokens;
// Refresh if expired
if (tokens.expiresAt && Date.now() > tokens.expiresAt - 60000) {
if (tokens.refreshToken) {
this.tokens = await this.refreshTokens(tokens.refreshToken);
} else {
throw new Error('Token expired and no refresh token');
}
}
if (await validateToken(this.tokens.accessToken)) {
this.account = await getUserInfo(this.tokens.accessToken);
} else {
throw new Error('Token is invalid');
}
}
/**
* Ensure token is fresh
*/
private async ensureValidToken(): Promise<string> {
if (!this.tokens) {
throw new Error('Not authenticated');
}
if (this.tokens.expiresAt && Date.now() > this.tokens.expiresAt - 60000) {
if (this.tokens.refreshToken) {
this.tokens = await this.refreshTokens(this.tokens.refreshToken);
} else {
throw new Error('Token expired');
}
}
return this.tokens.accessToken;
}
/**
* Sign out
*/
signOut(): void {
this.tokens = null;
this.fileId = null;
this.account = null;
this.pkceChallenge = null;
this.onTokensRefreshed = null;
}
/**
* Initialize or find sync file
*/
async initializeSync(): Promise<string | null> {
const accessToken = await this.ensureValidToken();
this.fileId = await findSyncFile(accessToken);
return this.fileId;
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
const accessToken = await this.ensureValidToken();
if (this.fileId) {
await updateSyncFile(accessToken, this.fileId, syncedFile);
return this.fileId;
} else {
this.fileId = await createSyncFile(accessToken, syncedFile);
return this.fileId;
}
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
const accessToken = await this.ensureValidToken();
if (!this.fileId) {
this.fileId = await findSyncFile(accessToken);
}
if (!this.fileId) {
return null;
}
return downloadSyncFile(accessToken, this.fileId);
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.tokens || !this.fileId) {
return;
}
const accessToken = await this.ensureValidToken();
await deleteSyncFile(accessToken, this.fileId);
this.fileId = null;
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
return this.tokens;
}
}
export default GoogleDriveAdapter;

View File

@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
OneDriveAdapter,
OneDriveReauthRequiredError,
isOneDriveReauthRequiredError,
} from './OneDriveAdapter.ts';
import {
ONEDRIVE_REAUTH_REQUIRED_MARKER,
cleanOneDriveErrorMessage,
type OAuthTokens,
} from '../../../domain/sync.ts';
type WindowGlobal = typeof globalThis & { window?: unknown };
function setBridge(bridge: Record<string, unknown>): () => void {
const g = globalThis as WindowGlobal;
const original = g.window;
// Loosely typed: the real window.netcatty is a large NetcattyBridge; tests
// only stub the handful of OneDrive methods the adapter actually calls.
g.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
return () => {
g.window = original;
};
}
const expiredTokens = (): OAuthTokens => ({
accessToken: 'old-access',
refreshToken: 'old-refresh',
// Already expired so any operation forces a refresh.
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
});
test('isOneDriveReauthRequiredError detects the marker and the error class', () => {
assert.equal(isOneDriveReauthRequiredError(new OneDriveReauthRequiredError()), true);
assert.equal(
isOneDriveReauthRequiredError(new Error(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: x`)),
true,
);
// Survives re-wrapping the way the sync pipeline re-wraps errors.
const wrapped = new Error(String(new OneDriveReauthRequiredError('boom')));
assert.equal(isOneDriveReauthRequiredError(wrapped), true);
assert.equal(isOneDriveReauthRequiredError(new Error('network down')), false);
});
test('cleanOneDriveErrorMessage strips the marker and wrapping prefixes', () => {
const wrapped = `Error: OneDriveReauthRequiredError: ${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`;
assert.equal(
cleanOneDriveErrorMessage(wrapped),
'OneDrive session expired, please reconnect. (AADSTS70000)',
);
// No marker -> returned unchanged.
assert.equal(cleanOneDriveErrorMessage('plain network error'), 'plain network error');
});
test('OneDriveReauthRequiredError always carries the marker in its message', () => {
assert.ok(new OneDriveReauthRequiredError('hi').message.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER));
// Does not double-prefix when the marker is already present.
const once = new OneDriveReauthRequiredError(`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: hi`).message;
assert.equal(once.indexOf(ONEDRIVE_REAUTH_REQUIRED_MARKER), once.lastIndexOf(ONEDRIVE_REAUTH_REQUIRED_MARKER));
});
test('refreshing tokens during an operation fires the persistence callback with rotated tokens', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
let refreshCalledWith: string | undefined;
// Returning a non-null synced file avoids the eventual-consistency retry loop
// (retryOnNotFound) that backs off on a null result and would slow the test.
const remoteSyncedFile = { meta: { version: 1 }, payload: 'x' };
const restore = setBridge({
onedriveRefreshAccessToken: async ({ refreshToken }: { refreshToken: string }) => {
refreshCalledWith = refreshToken;
return rotated;
},
onedriveDownloadSyncFile: async ({ accessToken }: { accessToken: string }) => {
// Operation must run with the refreshed access token.
assert.equal(accessToken, 'fresh-access');
return { syncedFile: remoteSyncedFile };
},
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
const result = await adapter.download();
assert.deepEqual(result, remoteSyncedFile);
assert.equal(refreshCalledWith, 'old-refresh');
assert.equal(persisted.length, 1);
assert.deepEqual(persisted[0], rotated);
// Adapter also exposes the rotated tokens for the caller to persist.
assert.deepEqual(adapter.getTokens(), rotated);
} finally {
restore();
}
});
test('a dead refresh token surfaces OneDriveReauthRequiredError and does not fire the callback', async () => {
const restore = setBridge({
onedriveRefreshAccessToken: async () => {
throw new Error(
`${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (AADSTS70000)`,
);
},
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
await assert.rejects(
() => adapter.download(),
(err) => {
assert.equal(isOneDriveReauthRequiredError(err), true);
assert.ok(err instanceof OneDriveReauthRequiredError);
return true;
},
);
assert.equal(callbackFired, false);
} finally {
restore();
}
});
test('setTokens refreshes an expired token and persists the rotated tokens', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
onedriveRefreshAccessToken: async () => rotated,
onedriveGetUserInfo: async () => ({ id: 'u1', email: 'u@example.com', name: 'User' }),
});
try {
const adapter = new OneDriveAdapter();
const persisted: OAuthTokens[] = [];
adapter.setOnTokensRefreshed((tokens) => persisted.push(tokens));
await adapter.setTokens(expiredTokens());
assert.deepEqual(persisted, [rotated]);
assert.deepEqual(adapter.getTokens(), rotated);
assert.equal(adapter.accountInfo?.id, 'u1');
} finally {
restore();
}
});
test('setTokens with an expired token and no refresh token requires reconnect', async () => {
const restore = setBridge({});
try {
const adapter = new OneDriveAdapter();
await assert.rejects(
() =>
adapter.setTokens({
accessToken: 'old-access',
expiresAt: Date.now() - 60_000,
tokenType: 'Bearer',
}),
(err) => {
assert.equal(isOneDriveReauthRequiredError(err), true);
return true;
},
);
} finally {
restore();
}
});
test('signOut clears the refresh persistence callback', async () => {
const rotated: OAuthTokens = {
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: Date.now() + 3_600_000,
tokenType: 'Bearer',
};
const restore = setBridge({
onedriveRefreshAccessToken: async () => rotated,
onedriveGetUserInfo: async () => ({ id: 'u1', email: 'u@example.com', name: 'User' }),
});
try {
const adapter = new OneDriveAdapter(expiredTokens(), 'file-1');
let callbackFired = false;
adapter.setOnTokensRefreshed(() => {
callbackFired = true;
});
adapter.signOut();
// Re-arm tokens and refresh; the callback must not fire after signOut.
await adapter.setTokens(expiredTokens());
assert.equal(callbackFired, false);
} finally {
restore();
}
});

View File

@@ -0,0 +1,759 @@
/**
* OneDrive OAuth Adapter - PKCE Loopback Flow with MSAL
*
* Uses MSAL-style Authorization Code Grant with PKCE.
* Data is stored in the app's special folder.
*
* Flow:
* 1. Generate PKCE challenge
* 2. Open browser with auth URL
* 3. User authorizes, redirected to loopback
* 4. Exchange code for tokens
* 5. Use Graph API to manage sync file
*/
import {
SYNC_CONSTANTS,
ONEDRIVE_REAUTH_REQUIRED_MARKER,
isOneDriveReauthRequiredMessage,
type OAuthTokens,
type ProviderAccount,
type SyncedFile,
type PKCEChallenge,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
import { arrayBufferToBase64, generateRandomBytes } from '../EncryptionService';
// ============================================================================
// Types
// ============================================================================
export interface OneDriveUserInfo {
id: string;
displayName: string;
mail?: string;
userPrincipalName: string;
}
export interface DriveItem {
id: string;
name: string;
lastModifiedDateTime: string;
size?: number;
'@microsoft.graph.downloadUrl'?: string;
}
const ONEDRIVE_SCOPES = [
'https://graph.microsoft.com/Files.ReadWrite.AppFolder',
'https://graph.microsoft.com/User.Read',
'offline_access',
];
const ONEDRIVE_SCOPE = ONEDRIVE_SCOPES.join(' ');
/**
* Raised when the OneDrive refresh token can no longer be exchanged for a new
* access token (expired / revoked / consent withdrawn). The user must
* re-authorize; silent refresh cannot recover. CloudSyncManager detects this to
* surface a clear "reconnect" state instead of a raw error.
*
* The message always carries ONEDRIVE_REAUTH_REQUIRED_MARKER so the condition is
* still detectable after the error is re-wrapped (e.g. `new Error(String(err))`)
* as it bubbles through the provider-agnostic sync pipeline.
*/
export class OneDriveReauthRequiredError extends Error {
constructor(message = 'OneDrive session expired, please reconnect.') {
super(
isOneDriveReauthRequiredMessage(message)
? message
: `${ONEDRIVE_REAUTH_REQUIRED_MARKER}: ${message}`
);
this.name = 'OneDriveReauthRequiredError';
}
}
export const isOneDriveReauthRequiredError = (error: unknown): boolean => {
if (error instanceof OneDriveReauthRequiredError) {
return true;
}
const message = error instanceof Error ? error.message : String(error);
return isOneDriveReauthRequiredMessage(message);
};
const isUnauthorizedError = (error: unknown): boolean => {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return message.includes(' 401') ||
message.includes('401 -') ||
lower.includes('unauthenticated') ||
lower.includes('invalidauthenticationtoken');
};
// ============================================================================
// PKCE Utilities
// ============================================================================
/**
* Base64 URL encoding (no padding, URL-safe chars)
*/
const base64UrlEncode = (bytes: Uint8Array): string => {
const base64 = arrayBufferToBase64(bytes);
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
};
/**
* Generate a cryptographically random code verifier
*/
const generateCodeVerifier = (): string => {
const bytes = generateRandomBytes(32);
return base64UrlEncode(bytes);
};
/**
* Generate code challenge from verifier (S256)
*/
const generateCodeChallenge = async (verifier: string): Promise<string> => {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
};
/**
* Generate PKCE challenge
*/
export const generatePKCEChallenge = async (): Promise<PKCEChallenge> => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = base64UrlEncode(generateRandomBytes(16));
return {
codeVerifier,
codeChallenge,
state,
};
};
// ============================================================================
// OAuth Flow
// ============================================================================
/**
* Build authorization URL for OneDrive OAuth
*/
export const buildAuthUrl = async (
redirectUri: string
): Promise<{ url: string; pkce: PKCEChallenge }> => {
const pkce = await generatePKCEChallenge();
const params = new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
scope: ONEDRIVE_SCOPE,
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256',
state: pkce.state,
response_mode: 'query',
prompt: 'consent',
});
return {
url: `${SYNC_CONSTANTS.ONEDRIVE_AUTH_URL}?${params.toString()}`,
pkce,
};
};
/**
* Exchange authorization code for tokens
*/
export const exchangeCodeForTokens = async (
code: string,
codeVerifier: string,
redirectUri: string
): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveExchangeCodeForTokens) {
return bridge.onedriveExchangeCodeForTokens({
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
code,
codeVerifier,
redirectUri,
scope: ONEDRIVE_SCOPE,
});
}
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
scope: ONEDRIVE_SCOPE,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error_description || error.error}`);
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + data.expires_in * 1000,
tokenType: data.token_type,
scope: data.scope,
};
};
/**
* Refresh access token
*/
export const refreshAccessToken = async (refreshToken: string): Promise<OAuthTokens> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveRefreshAccessToken) {
return bridge.onedriveRefreshAccessToken({
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
refreshToken,
scope: ONEDRIVE_SCOPE,
});
}
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: ONEDRIVE_SCOPE,
}),
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresAt: Date.now() + data.expires_in * 1000,
tokenType: data.token_type,
scope: data.scope,
};
};
// ============================================================================
// User Info
// ============================================================================
/**
* Get authenticated user info
*/
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveGetUserInfo) {
const user = await bridge.onedriveGetUserInfo({ accessToken });
return {
id: user.id,
email: user.email,
name: user.name,
avatarUrl: user.avatarDataUrl,
};
}
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to get user info');
}
const user: OneDriveUserInfo = await response.json();
// Try to get profile photo
let avatarUrl: string | undefined;
try {
const photoResponse = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/photo/$value`,
{
headers: { 'Authorization': `Bearer ${accessToken}` },
}
);
if (photoResponse.ok) {
const blob = await photoResponse.blob();
avatarUrl = URL.createObjectURL(blob);
}
} catch {
// Photo not available
}
return {
id: user.id,
email: user.mail || user.userPrincipalName,
name: user.displayName,
avatarUrl,
};
};
/**
* Validate access token
*/
export const validateToken = async (accessToken: string): Promise<boolean> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveGetUserInfo) {
try {
await bridge.onedriveGetUserInfo({ accessToken });
return true;
} catch {
return false;
}
}
try {
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
return response.ok;
} catch {
return false;
}
};
// ============================================================================
// OneDrive App Folder Operations
// ============================================================================
const APP_FOLDER_PATH = '/drive/special/approot';
// Eventual-consistency retry for OneDrive "not found" lookups. The Graph API
// can briefly 404 a file that was uploaded seconds ago from another device
// (most commonly when the other device is syncing through the OneDrive
// desktop client and the change has not yet reached Graph). Treating every
// 404 as authoritative "cloud is empty" lets a second device proceed to an
// empty-cloud upload path and overwrite real data (#779). We retry a small
// bounded number of times with short backoff to flush through that window.
const NOT_FOUND_RETRIES = 2;
const NOT_FOUND_BACKOFF_MS = 1500;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
async function retryOnNotFound<T>(
fetchOnce: () => Promise<T | null>,
): Promise<T | null> {
let result = await fetchOnce();
for (let attempt = 1; attempt <= NOT_FOUND_RETRIES && result === null; attempt++) {
await sleep(NOT_FOUND_BACKOFF_MS * attempt);
result = await fetchOnce();
}
return result;
}
/**
* Ensure app folder exists and find sync file
*/
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
const fetchOnce = async (): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveFindSyncFile) {
const result = await bridge.onedriveFindSyncFile({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return result.fileId || null;
}
try {
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to find sync file');
}
const item: DriveItem = await response.json();
return item.id;
} catch {
return null;
}
};
return retryOnNotFound(fetchOnce);
};
/**
* Create or update sync file in app folder
*/
export const uploadSyncFile = async (
accessToken: string,
syncedFile: SyncedFile
): Promise<string> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveUploadSyncFile) {
const result = await bridge.onedriveUploadSyncFile({
accessToken,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
syncedFile,
});
if (!result.fileId) {
throw new Error('Failed to upload sync file');
}
return result.fileId;
}
const content = JSON.stringify(syncedFile, null, 2);
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: content,
}
);
if (!response.ok) {
throw new Error('Failed to upload sync file');
}
const item: DriveItem = await response.json();
return item.id;
};
/**
* Download sync file
*/
export const downloadSyncFile = async (
accessToken: string,
fileId?: string
): Promise<SyncedFile | null> => {
const fetchOnce = async (): Promise<SyncedFile | null> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveDownloadSyncFile) {
const result = await bridge.onedriveDownloadSyncFile({
accessToken,
fileId,
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
});
return (result.syncedFile as SyncedFile | null) || null;
}
try {
// Can use either file ID or path
const url = fileId
? `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}/content`
: `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to download sync file');
}
return response.json();
} catch {
return null;
}
};
return retryOnNotFound(fetchOnce);
};
/**
* Delete sync file
*/
export const deleteSyncFile = async (
accessToken: string,
fileId: string
): Promise<void> => {
const bridge = netcattyBridge.get();
if (bridge?.onedriveDeleteSyncFile) {
await bridge.onedriveDeleteSyncFile({ accessToken, fileId });
return;
}
const response = await fetch(
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
}
);
if (!response.ok && response.status !== 404) {
throw new Error('Failed to delete sync file');
}
};
// ============================================================================
// OneDrive Adapter Class
// ============================================================================
export class OneDriveAdapter {
private tokens: OAuthTokens | null = null;
private fileId: string | null = null;
private account: ProviderAccount | null = null;
private pkceChallenge: PKCEChallenge | null = null;
/**
* Invoked whenever the access token is silently refreshed. Lets the owner
* (CloudSyncManager) persist the rotated tokens — Microsoft consumer refresh
* tokens rotate on every refresh and invalidate the previous one, so without
* persisting the new refresh token the stored one eventually goes stale and
* forces the user to reconnect (#1189).
*/
private onTokensRefreshed: ((tokens: OAuthTokens) => void) | null = null;
constructor(tokens?: OAuthTokens, fileId?: string) {
if (tokens) {
this.tokens = tokens;
}
this.fileId = fileId || null;
}
/**
* Register a callback that receives refreshed tokens so the caller can
* persist them. Passing null removes the callback.
*/
setOnTokensRefreshed(callback: ((tokens: OAuthTokens) => void) | null): void {
this.onTokensRefreshed = callback;
}
/**
* Refresh the access token using the supplied refresh token, store the
* rotated tokens in-memory, and notify the persistence callback. Refresh
* failures caused by a dead refresh token are normalized to
* OneDriveReauthRequiredError so callers can prompt for reconnect.
*/
private async refreshTokens(refreshToken: string): Promise<OAuthTokens> {
let refreshed: OAuthTokens;
try {
refreshed = await refreshAccessToken(refreshToken);
} catch (error) {
if (isOneDriveReauthRequiredError(error)) {
// Preserve the message (it carries the marker the bridge added) so
// downstream layers can still detect this after any re-wrapping.
throw new OneDriveReauthRequiredError(
error instanceof Error ? error.message : String(error)
);
}
throw error;
}
this.tokens = refreshed;
try {
this.onTokensRefreshed?.(refreshed);
} catch {
// Persistence is best-effort; a failed save must not abort the sync that
// triggered the refresh — the fresh tokens still work for this session.
}
return refreshed;
}
get isAuthenticated(): boolean {
return !!this.tokens?.accessToken;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.fileId;
}
/**
* Start OAuth flow - returns URL to open in browser
*/
async startAuth(redirectUri: string): Promise<string> {
const { url, pkce } = await buildAuthUrl(redirectUri);
this.pkceChallenge = pkce;
return url;
}
/**
* Get PKCE state for verification
*/
getPKCEState(): string | null {
return this.pkceChallenge?.state || null;
}
/**
* Complete authentication with authorization code
*/
async completeAuth(code: string, redirectUri: string): Promise<OAuthTokens> {
if (!this.pkceChallenge) {
throw new Error('No PKCE challenge - start auth first');
}
this.tokens = await exchangeCodeForTokens(
code,
this.pkceChallenge.codeVerifier,
redirectUri
);
this.pkceChallenge = null;
this.account = await getUserInfo(this.tokens.accessToken);
return this.tokens;
}
/**
* Set tokens from storage
*/
async setTokens(tokens: OAuthTokens): Promise<void> {
this.tokens = tokens;
// Refresh if expired
if (tokens.expiresAt && Date.now() > tokens.expiresAt - 60000) {
if (tokens.refreshToken) {
this.tokens = await this.refreshTokens(tokens.refreshToken);
} else {
throw new OneDriveReauthRequiredError(
'OneDrive session expired and no refresh token is available, please reconnect.'
);
}
}
if (await validateToken(this.tokens.accessToken)) {
this.account = await getUserInfo(this.tokens.accessToken);
} else {
throw new Error('Token is invalid');
}
}
/**
* Ensure token is fresh
*/
private async ensureValidToken(): Promise<string> {
if (!this.tokens) {
throw new Error('Not authenticated');
}
if (this.tokens.expiresAt && Date.now() > this.tokens.expiresAt - 60000) {
if (this.tokens.refreshToken) {
this.tokens = await this.refreshTokens(this.tokens.refreshToken);
} else {
throw new OneDriveReauthRequiredError(
'OneDrive session expired and no refresh token is available, please reconnect.'
);
}
}
return this.tokens.accessToken;
}
private async runWithAuthRetry<T>(
operation: (accessToken: string) => Promise<T>
): Promise<T> {
const accessToken = await this.ensureValidToken();
try {
return await operation(accessToken);
} catch (error) {
if (isUnauthorizedError(error) && this.tokens?.refreshToken) {
const refreshed = await this.refreshTokens(this.tokens.refreshToken);
return await operation(refreshed.accessToken);
}
throw error;
}
}
/**
* Sign out
*/
signOut(): void {
this.tokens = null;
this.fileId = null;
this.account = null;
this.pkceChallenge = null;
this.onTokensRefreshed = null;
}
/**
* Initialize or find sync file
*/
async initializeSync(): Promise<string | null> {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await findSyncFile(accessToken);
return this.fileId;
});
}
/**
* Upload sync file
*/
async upload(syncedFile: SyncedFile): Promise<string> {
return this.runWithAuthRetry(async (accessToken) => {
this.fileId = await uploadSyncFile(accessToken, syncedFile);
return this.fileId;
});
}
/**
* Download sync file
*/
async download(): Promise<SyncedFile | null> {
return this.runWithAuthRetry(async (accessToken) => {
if (!this.fileId) {
this.fileId = await findSyncFile(accessToken);
}
return downloadSyncFile(accessToken, this.fileId || undefined);
});
}
/**
* Delete sync data
*/
async deleteSync(): Promise<void> {
if (!this.tokens || !this.fileId) {
return;
}
await this.runWithAuthRetry(async (accessToken) => {
await deleteSyncFile(accessToken, this.fileId as string);
this.fileId = null;
});
}
/**
* Get tokens for storage
*/
getTokens(): OAuthTokens | null {
return this.tokens;
}
}
export default OneDriveAdapter;

View File

@@ -0,0 +1,253 @@
/**
* S3 Compatible Adapter - AWS SDK v3
*/
import {
S3Client,
HeadObjectCommand,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import {
SYNC_CONSTANTS,
type S3Config,
type SyncedFile,
type ProviderAccount,
type OAuthTokens,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
const normalizeEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const toBodyString = async (body: unknown): Promise<string> => {
if (!body) return '';
if (typeof body === 'string') return body;
if (body instanceof Uint8Array) {
return new TextDecoder().decode(body);
}
if (body instanceof Blob) {
return await body.text();
}
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) {
return await new Response(body).text();
}
if (typeof (body as { transformToString?: () => Promise<string> }).transformToString === 'function') {
return await (body as { transformToString: () => Promise<string> }).transformToString();
}
throw new Error('Unsupported S3 response body');
};
export class S3Adapter {
private config: S3Config | null;
private resource: string | null;
private account: ProviderAccount | null;
private client: S3Client | null;
constructor(config?: S3Config, resourceId?: string) {
this.config = config
? { ...config, endpoint: normalizeEndpoint(config.endpoint) }
: null;
this.resource = resourceId || null;
this.account = this.buildAccountInfo(this.config);
this.client = this.config ? this.createClient(this.config) : null;
}
get isAuthenticated(): boolean {
return !!this.config;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.resource;
}
signOut(): void {
this.config = null;
this.resource = null;
this.account = null;
this.client = null;
}
async initializeSync(): Promise<string | null> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Initialize) {
const result = await bridge.cloudSyncS3Initialize(this.config);
this.resource = result?.resourceId || this.getObjectKey();
return this.resource;
}
const client = this.getClient();
try {
await client.send(new HeadObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
} catch (error) {
if (this.isNotFound(error)) {
// File doesn't exist yet.
} else if (this.isAccessDenied(error)) {
throw new Error('S3 access denied');
} else {
throw error;
}
}
this.resource = this.getObjectKey();
return this.resource;
}
async upload(syncedFile: SyncedFile): Promise<string> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Upload) {
const result = await bridge.cloudSyncS3Upload(this.config, syncedFile);
this.resource = result?.resourceId || this.getObjectKey();
return this.resource;
}
const body = JSON.stringify(syncedFile);
const client = this.getClient();
await client.send(new PutObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
Body: body,
ContentType: 'application/json',
}));
this.resource = this.getObjectKey();
return this.resource;
}
async download(): Promise<SyncedFile | null> {
if (!this.config) {
throw new Error('Missing S3 config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Download) {
const result = await bridge.cloudSyncS3Download(this.config);
return (result?.syncedFile ?? null) as SyncedFile | null;
}
const client = this.getClient();
try {
const response = await client.send(new GetObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
const text = await toBodyString(response.Body);
if (!text) return null;
return JSON.parse(text) as SyncedFile;
} catch (error) {
if (this.isNotFound(error)) {
return null;
}
throw error;
}
}
async deleteSync(): Promise<void> {
if (!this.config) {
return;
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncS3Delete) {
await bridge.cloudSyncS3Delete(this.config);
return;
}
const client = this.getClient();
try {
await client.send(new DeleteObjectCommand({
Bucket: this.config.bucket,
Key: this.getObjectKey(),
}));
} catch (error) {
if (this.isNotFound(error)) {
return;
}
throw error;
}
}
getTokens(): OAuthTokens | null {
return null;
}
private getClient(): S3Client {
if (!this.config || !this.client) {
if (this.config?.allowInsecure) {
throw new Error('S3 insecure connections require the Netcatty desktop sync bridge');
}
throw new Error('Missing S3 config');
}
return this.client;
}
private createClient(config: S3Config): S3Client | null {
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle ?? true,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
sessionToken: config.sessionToken,
},
};
if (
config.allowInsecure
&& typeof globalThis.process !== 'undefined'
&& typeof require === 'function'
) {
const https = require('https');
const { NodeHttpHandler } = require('@smithy/node-http-handler');
clientConfig.requestHandler = new NodeHttpHandler({
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
});
} else if (config.allowInsecure) {
return null;
}
return new S3Client(clientConfig);
}
private isNotFound(error: unknown): boolean {
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 404);
}
private isAccessDenied(error: unknown): boolean {
return Boolean((error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode === 403);
}
private getObjectKey(): string {
if (!this.config) {
throw new Error('Missing S3 config');
}
const prefix = (this.config.prefix || '').trim().replace(/^\/+|\/+$/g, '');
if (!prefix) {
return SYNC_CONSTANTS.SYNC_FILE_NAME;
}
return `${prefix}/${SYNC_CONSTANTS.SYNC_FILE_NAME}`;
}
private buildAccountInfo(config: S3Config | null): ProviderAccount | null {
if (!config) return null;
const name = `${config.bucket} (${config.region})`;
const id = `${config.bucket}@${config.endpoint}`;
return { id, name };
}
}
export default S3Adapter;

View File

@@ -0,0 +1,377 @@
/**
* WebDAV Adapter - webdav client library
*/
import { AuthType, createClient } from 'webdav';
import {
SYNC_CONSTANTS,
type WebDAVConfig,
type SyncedFile,
type ProviderAccount,
type OAuthTokens,
} from '../../../domain/sync';
import { netcattyBridge } from '../netcattyBridge';
type WebDAVClient = ReturnType<typeof createClient>;
const normalizeEndpoint = (endpoint: string): string => {
const trimmed = endpoint.trim();
if (!/^https?:\/\//i.test(trimmed)) {
return `https://${trimmed}`;
}
return trimmed;
};
const ensureLeadingSlash = (value: string): string =>
value.startsWith('/') ? value : `/${value}`;
/**
* Recover from trailing garbage left by non-truncating WebDAV PUT overwrites
* (#2223). Node/V8: "Unexpected non-whitespace character after JSON at position N".
*/
const parseSyncedFileJson = (raw: string): SyncedFile => {
try {
return JSON.parse(raw) as SyncedFile;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const match = /Unexpected non-whitespace character after JSON at position (\d+)/i.exec(
message,
);
if (match) {
const pos = Number(match[1]);
if (Number.isFinite(pos) && pos > 0 && pos <= raw.length) {
return JSON.parse(raw.slice(0, pos)) as SyncedFile;
}
}
throw error;
}
};
const utf8ByteLength = (value: string): number =>
typeof Buffer !== 'undefined'
? Buffer.byteLength(value, 'utf8')
: new TextEncoder().encode(value).length;
/** Strict: intended body plus optional trailing whitespace only. */
const remoteMatchesUploadedBody = (remoteText: string, body: string): boolean => {
if (!remoteText.startsWith(body)) return false;
return /^\s*$/.test(remoteText.slice(body.length));
};
/**
* Prefer fixed-name temp PUT + MOVE (with pad + verify); else padded in-place
* PUT with strict body match so non-truncating servers cannot leave garbage.
*/
const putWebdavFileReplacing = async (
client: WebDAVClient,
path: string,
body: string,
): Promise<void> => {
const tmpPath = `${path}.tmp`;
const bodyLen = utf8ByteLength(body);
const cleanupTemp = async () => {
try {
if (await client.exists(tmpPath)) {
await client.deleteFile(tmpPath);
}
} catch {
// best-effort
}
};
const readLen = async (target: string): Promise<number> => {
let exists = false;
try {
exists = await client.exists(target);
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not check existing file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (!exists) return 0;
try {
const existing = await client.getFileContents(target, { format: 'text' });
if (existing == null) return 0;
return utf8ByteLength(String(existing));
} catch (error) {
throw new Error(
`WebDAV replace aborted: could not read existing file length (${
error instanceof Error ? error.message : String(error)
})`,
);
}
};
try {
let tmpMin = 0;
try {
tmpMin = await readLen(tmpPath);
} catch {
tmpMin = 0;
}
const tmpBody = tmpMin > bodyLen ? body + ' '.repeat(tmpMin - bodyLen) : body;
await client.putFileContents(tmpPath, tmpBody, { overwrite: true });
await client.moveFile(tmpPath, path, { overwrite: true });
const moved = String((await client.getFileContents(path, { format: 'text' })) ?? '');
if (remoteMatchesUploadedBody(moved, body)) {
return;
}
} catch {
// fall through
}
await cleanupTemp();
let minLen = await readLen(path);
minLen = Math.max(minLen, bodyLen);
for (let attempt = 0; attempt < 3; attempt++) {
const payload = minLen > bodyLen ? body + ' '.repeat(minLen - bodyLen) : body;
await client.putFileContents(path, payload, { overwrite: true });
let remoteText = '';
try {
remoteText = String((await client.getFileContents(path, { format: 'text' })) ?? '');
} catch (error) {
throw new Error(
`WebDAV upload verification failed: could not re-read file (${
error instanceof Error ? error.message : String(error)
})`,
);
}
if (remoteMatchesUploadedBody(remoteText, body)) {
return;
}
minLen = Math.max(minLen, utf8ByteLength(remoteText), bodyLen);
}
throw new Error(
'WebDAV upload verification failed: remote file still does not match uploaded body after padded PUT',
);
};
export class WebDAVAdapter {
private config: WebDAVConfig | null;
private resource: string | null;
private account: ProviderAccount | null;
private client: WebDAVClient | null;
constructor(config?: WebDAVConfig, resourceId?: string) {
this.config = config
? { ...config, endpoint: normalizeEndpoint(config.endpoint) }
: null;
this.resource = resourceId || null;
this.account = this.buildAccountInfo(this.config);
this.client = this.config ? this.createClient(this.config) : null;
}
get isAuthenticated(): boolean {
return !!this.config;
}
get accountInfo(): ProviderAccount | null {
return this.account;
}
get resourceId(): string | null {
return this.resource;
}
signOut(): void {
this.config = null;
this.resource = null;
this.account = null;
this.client = null;
}
async initializeSync(): Promise<string | null> {
return this.withWebdavErrorContext('initialize', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavInitialize) {
const result = await bridge.cloudSyncWebdavInitialize(this.config);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await client.exists(path);
this.resource = path;
return this.resource;
});
}
async upload(syncedFile: SyncedFile): Promise<string> {
return this.withWebdavErrorContext('upload', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavUpload) {
const result = await bridge.cloudSyncWebdavUpload(this.config, syncedFile);
this.resource = result?.resourceId || this.getSyncPath();
return this.resource;
}
const client = this.getClient();
const path = this.getSyncPath();
await putWebdavFileReplacing(client, path, JSON.stringify(syncedFile));
this.resource = path;
return path;
});
}
async download(): Promise<SyncedFile | null> {
return this.withWebdavErrorContext('download', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDownload) {
const result = await bridge.cloudSyncWebdavDownload(this.config);
return (result?.syncedFile ?? null) as SyncedFile | null;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return null;
const data = await client.getFileContents(path, { format: 'text' });
if (!data) return null;
return parseSyncedFileJson(data as string);
});
}
async deleteSync(): Promise<void> {
return this.withWebdavErrorContext('delete', async () => {
if (!this.config) {
throw new Error('Missing WebDAV config');
}
const bridge = netcattyBridge.get();
if (bridge?.cloudSyncWebdavDelete) {
await bridge.cloudSyncWebdavDelete(this.config);
return;
}
const client = this.getClient();
const path = this.getSyncPath();
const exists = await client.exists(path);
if (!exists) return;
await client.deleteFile(path);
});
}
getTokens(): OAuthTokens | null {
return null;
}
private getClient(): WebDAVClient {
if (!this.config || !this.client) {
throw new Error('Missing WebDAV config');
}
return this.client;
}
private createClient(config: WebDAVConfig): WebDAVClient {
const extraOpts: Record<string, unknown> = {};
if (config.allowInsecure && typeof globalThis.process !== 'undefined') {
const https = require('https');
extraOpts.httpsAgent = new https.Agent({ rejectUnauthorized: false });
}
if (config.authType === 'token') {
return createClient(config.endpoint, {
authType: AuthType.Token,
token: {
access_token: config.token || '',
token_type: 'Bearer',
},
...extraOpts,
});
}
if (config.authType === 'digest') {
return createClient(config.endpoint, {
authType: AuthType.Digest,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
return createClient(config.endpoint, {
authType: AuthType.Password,
username: config.username || '',
password: config.password || '',
...extraOpts,
});
}
private async withWebdavErrorContext<T>(
operation: string,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (error) {
throw this.buildWebdavError(operation, error);
}
}
private buildWebdavError(operation: string, error: unknown): Error {
const baseMessage = error instanceof Error ? error.message : String(error);
const details: Record<string, string | number | boolean | null | undefined> = {
operation,
};
const raw = error as {
status?: number;
statusText?: string;
url?: string;
method?: string;
code?: string;
response?: {
status?: number;
statusText?: string;
url?: string;
};
cause?: unknown;
};
if (raw?.status) details.status = raw.status;
if (raw?.statusText) details.statusText = raw.statusText;
if (raw?.url) details.url = raw.url;
if (raw?.method) details.method = raw.method;
if (raw?.code) details.code = raw.code;
if (raw?.response?.status) details.status = raw.response.status;
if (raw?.response?.statusText) details.statusText = raw.response.statusText;
if (raw?.response?.url) details.url = raw.response.url;
if (raw?.cause && typeof raw.cause === 'object') {
Object.assign(details, raw.cause as Record<string, unknown>);
details.operation = operation;
const cause = raw.cause as { code?: string };
if (cause?.code) details.causeCode = cause.code;
} else if (raw?.cause) {
details.cause = String(raw.cause);
}
const err = new Error(`WebDAV ${operation} failed: ${baseMessage}`);
(err as Error & { cause?: unknown }).cause = details;
return err;
}
private getSyncPath(): string {
return ensureLeadingSlash(SYNC_CONSTANTS.SYNC_FILE_NAME);
}
private buildAccountInfo(config: WebDAVConfig | null): ProviderAccount | null {
if (!config) return null;
try {
const url = new URL(config.endpoint);
const host = url.host;
const name = config.username ? `${config.username}@${host}` : host;
return { id: host, name };
} catch {
return { id: config.endpoint, name: config.endpoint };
}
}
}
export default WebDAVAdapter;

View File

@@ -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'));
});
});

View File

@@ -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';
}

View File

@@ -0,0 +1,132 @@
/**
* Cloud Sync Adapters - Unified Export
*/
import type {
CloudProvider,
SyncedFile,
OAuthTokens,
ProviderAccount,
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
*/
export interface CloudAdapter {
readonly isAuthenticated: boolean;
readonly accountInfo: ProviderAccount | null;
readonly resourceId: string | null;
signOut(): void;
initializeSync(): Promise<string | null>;
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.
* 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,
options?: {
/** Plugin sync storage factory for namespaced provider IDs. */
createPluginStorage?: (providerId: string) => EncryptedObjectStorage | Promise<EncryptedObjectStorage>;
},
): Promise<CloudAdapter> => {
switch (provider) {
case 'github': {
const { GitHubAdapter } = await import('./GitHubAdapter');
return new GitHubAdapter(tokens, resourceId);
}
case 'google': {
const { GoogleDriveAdapter } = await import('./GoogleDriveAdapter');
return new GoogleDriveAdapter(tokens, resourceId);
}
case 'onedrive': {
const { OneDriveAdapter } = await import('./OneDriveAdapter');
return new OneDriveAdapter(tokens, resourceId);
}
case 'webdav': {
const { WebDAVAdapter } = await import('./WebDAVAdapter');
// 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: {
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',
},
});

View File

@@ -0,0 +1,437 @@
/**
* 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; created?: boolean }>;
pluginSyncDeleteSecrets?: (params: {
providerId: string;
keys?: string[];
}) => Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<{ restored: number; discarded?: 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; created?: boolean }> {
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] } : {}),
});
}
/** Restore host-stashed plaintext after a rejected overwrite during reconnect. */
export async function restorePluginSyncSecrets(params: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncRestoreSecrets !== 'function') {
return { restored: 0, discarded: 0 };
}
return api.pluginSyncRestoreSecrets({
providerId: params.providerId,
keys: [...params.keys],
...(params.discard === true ? { discard: true } : {}),
});
}

View File

@@ -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 });
},
};
}

View File

@@ -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');
});
});