[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
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:
246
infrastructure/services/adapters/GitHubAdapter.test.ts
Normal file
246
infrastructure/services/adapters/GitHubAdapter.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
773
infrastructure/services/adapters/GitHubAdapter.ts
Normal file
773
infrastructure/services/adapters/GitHubAdapter.ts
Normal 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;
|
||||
190
infrastructure/services/adapters/GoogleDriveAdapter.test.ts
Normal file
190
infrastructure/services/adapters/GoogleDriveAdapter.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
701
infrastructure/services/adapters/GoogleDriveAdapter.ts
Normal file
701
infrastructure/services/adapters/GoogleDriveAdapter.ts
Normal 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;
|
||||
209
infrastructure/services/adapters/OneDriveAdapter.test.ts
Normal file
209
infrastructure/services/adapters/OneDriveAdapter.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
759
infrastructure/services/adapters/OneDriveAdapter.ts
Normal file
759
infrastructure/services/adapters/OneDriveAdapter.ts
Normal 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;
|
||||
253
infrastructure/services/adapters/S3Adapter.ts
Normal file
253
infrastructure/services/adapters/S3Adapter.ts
Normal 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;
|
||||
377
infrastructure/services/adapters/WebDAVAdapter.ts
Normal file
377
infrastructure/services/adapters/WebDAVAdapter.ts
Normal 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;
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
387
infrastructure/services/adapters/encryptedObjectStorageBridge.ts
Normal file
387
infrastructure/services/adapters/encryptedObjectStorageBridge.ts
Normal 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';
|
||||
}
|
||||
132
infrastructure/services/adapters/index.ts
Normal file
132
infrastructure/services/adapters/index.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
437
infrastructure/services/adapters/pluginSyncIpcHost.ts
Normal file
437
infrastructure/services/adapters/pluginSyncIpcHost.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
138
infrastructure/services/adapters/pluginSyncObjectStorage.ts
Normal file
138
infrastructure/services/adapters/pluginSyncObjectStorage.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user