fix(sync): address package review feedback

This commit is contained in:
johannesjo 2026-05-14 12:11:48 +02:00
parent f0706ac779
commit 3bb07fc6d0
16 changed files with 79 additions and 25 deletions

View file

@ -134,7 +134,7 @@ packages/sync-providers/src/
├── file-based/dropbox/ # Dropbox provider implementation
├── file-based/webdav/ # WebDAV + Nextcloud providers
├── file-based/local-file/ # LocalFile provider classes
└── provider.types.ts # Provider-neutral contracts
└── provider-types.ts # Provider-neutral contracts
```
### App Sync Wiring

View file

@ -111,10 +111,11 @@ the package barrel deliberately and check that it is not app-owned.
The root `@sp/sync-providers` barrel has been removed; consumers MUST import
from focused subpath barrels: `@sp/sync-providers/dropbox`, `/webdav`,
`/super-sync`, `/local-file`, `/http`, `/errors`, `/file-based`, `/pkce`,
`/platform`, `/provider-types`, and `/credential-store`. Provider classes and
provider-owned string constants are exported there, but app enums such as
`SyncProviderId` are not. Internal helpers such as WebDAV API/adapter classes
stay unexported unless a second host needs them.
`/platform`, `/provider-types`, `/credential-store`, and `/log`. Provider
classes, provider-owned string constants, and shared privacy-boundary logging
helpers are exported there, but app enums such as `SyncProviderId` are not.
Internal helpers such as WebDAV API/adapter classes stay unexported unless a
second host needs them.
`@sp/sync-core` still exports deprecated full-state op compatibility defaults
and host-defined `OpType.SyncImport` / `BackupImport` / `Repair` strings for

View file

@ -33,7 +33,8 @@
"@sp/sync-providers/platform": ["packages/sync-providers/dist/platform.d.ts"],
"@sp/sync-providers/provider-types": [
"packages/sync-providers/dist/provider-types.d.ts"
]
],
"@sp/sync-providers/log": ["packages/sync-providers/dist/log.d.ts"]
}
},
"include": ["main.ts", "**/*.ts", "../src/app/core/window-ea.d.ts"],

5
package-lock.json generated
View file

@ -29976,14 +29976,17 @@
"name": "@sp/sync-providers",
"version": "1.0.0",
"dependencies": {
"@sp/sync-core": "*",
"hash-wasm": "^4.12.0"
},
"devDependencies": {
"@sp/sync-core": "*",
"@xmldom/xmldom": "^0.8.12",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
"@sp/sync-core": "*"
}
},
"packages/sync-providers/node_modules/@vitest/expect": {

View file

@ -32,7 +32,7 @@ All ciphertexts are base64-encoded for transport. The format is discriminated by
### Salt and IV semantics
- The **IV** (12 bytes) is freshly random per call. AES-GCM security under a fixed key reduces to IV uniqueness, which this guarantees.
- The **salt** (16 bytes) is derived once per `(process session, password)` pair and reused across every `encrypt`/`encryptWithDerivedKey`/`encryptBatch` call in that session. This is intentional — it lets the session cache amortize the ~500 ms2 s Argon2id derivation. Two encryptions of the same plaintext within a session therefore share the salt prefix and differ only in IV and ciphertext. Do not assert per-call salt uniqueness in tests.
- The **salt** (16 bytes) is derived once per `(process session, password)` pair and reused across every `encrypt`/`encryptBatch` call in that session. This is intentional — it lets the session cache amortize the ~500 ms2 s Argon2id derivation. Two encryptions of the same plaintext within a session therefore share the salt prefix and differ only in IV and ciphertext. Do not assert per-call salt uniqueness in tests.
### Session key caching
@ -42,10 +42,9 @@ Call `clearSessionKeyCache()` whenever the user changes their password or logs o
### Legacy-KDF migration
Old data was encrypted with PBKDF2 using the password as its own salt — cryptographically weak. Two complementary mechanisms surface legacy ciphertext:
Old data was encrypted with PBKDF2 using the password as its own salt — cryptographically weak. `decrypt()` and `decryptBatch()` still read legacy ciphertexts so existing sync data remains accessible.
1. **Structural**`decryptWithMigration(data, password)` returns a `DecryptResult` with `wasLegacyKdf` and `migratedCiphertext`. Persist `migratedCiphertext` to migrate the record off PBKDF2.
2. **Side-channel**`setLegacyKdfWarningHandler(fn)` registers a callback fired on every successful legacy decrypt, regardless of which entry point was used. The host throttles user-facing messages (e.g. show a deprecation banner once per session).
`setLegacyKdfWarningHandler(fn)` registers a callback fired on every successful legacy decrypt, regardless of which entry point was used. The host throttles user-facing messages (e.g. show a deprecation banner once per session).
### Argon2id parameters
@ -66,9 +65,9 @@ See `src/index.ts` for the full barrel and the JSDoc on individual symbols for u
## Tests
```bash
npm test # vitest run, Node WebCrypto + @noble fallback
npm test # typecheck specs + vitest run, Node WebCrypto + @noble fallback
npm run test:watch # watch mode
npm run build # tsup ESM + CJS + .d.ts
npm run build # tsup -> ESM + CJS + .d.ts
```
Browser-context smoke coverage lives in the consuming app at `src/app/op-log/encryption/encryption.browser.spec.ts` (Karma + real Chrome).

View file

@ -21,7 +21,8 @@
"scripts": {
"build": "tsup",
"build:tsc": "tsc",
"test": "vitest run",
"test": "npm run test:typecheck && vitest run",
"test:typecheck": "tsc --noEmit -p tsconfig.spec.json",
"test:watch": "vitest"
},
"dependencies": {

View file

@ -545,17 +545,20 @@ describe('partitionLwwResolutions', () => {
[createOp({ id: 'local' })],
[createOp({ id: 'remote', actionType: '[Test] Remote' })],
);
const processedRemoteOp = {
const processedRemoteOp: Operation = {
...conflict.remoteOps[0],
id: 'processed-remote',
entityId: 'processed-task',
actionType: '[Test] Processed Remote',
};
const processRemoteWinnerOps = vi.fn(() => [processedRemoteOp]);
const processRemoteWinnerOps = vi.fn((_conflict: EntityConflict): Operation[] => [
processedRemoteOp,
]);
const result = partitionLwwResolutions([{ conflict, winner: 'remote' }], {
processRemoteWinnerOps,
});
const result = partitionLwwResolutions<Operation, EntityConflict>(
[{ conflict, winner: 'remote' }],
{ processRemoteWinnerOps },
);
expect(processRemoteWinnerOps).toHaveBeenCalledWith(conflict);
expect(result.remoteWinsOps).toEqual([processedRemoteOp]);

View file

@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true,
"rootDir": ".",
"types": ["vitest"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -113,6 +113,16 @@
"types": "./dist/provider-types.d.ts",
"default": "./dist/provider-types.js"
}
},
"./log": {
"import": {
"types": "./dist/log.d.mts",
"default": "./dist/log.mjs"
},
"require": {
"types": "./dist/log.d.ts",
"default": "./dist/log.js"
}
}
},
"dependencies": {
@ -124,7 +134,8 @@
"scripts": {
"build": "tsup",
"build:tsc": "tsc",
"test": "vitest run",
"test": "npm run test:typecheck && vitest run",
"test:typecheck": "tsc --noEmit -p tsconfig.spec.json",
"test:watch": "vitest"
},
"devDependencies": {

View file

@ -0,0 +1 @@
export { errorMeta, urlPathOnly } from './log/error-meta';

View file

@ -11,7 +11,7 @@ import {
type DropboxPrivateCfg,
PROVIDER_ID_DROPBOX,
} from '../../../src/dropbox';
import type { NativeHttpExecutor } from '../../../src/http';
import type { NativeHttpExecutor, NativeHttpResponse } from '../../../src/http';
import type { SyncCredentialStorePort } from '../../../src/credential-store';
import { DropboxApi } from '../../../src/file-based/dropbox/dropbox-api';
import { createMockSyncLogger } from '../../helpers/sync-logger';
@ -758,7 +758,7 @@ describe('DropboxApi Native Platform Routing', () => {
describe('error handling on native platform', () => {
it('should handle 401 errors and retry on native platform', async () => {
let callCount = 0;
nativeExecutor.mockImplementation(async (options) => {
nativeExecutor.mockImplementation(async (options): Promise<NativeHttpResponse> => {
callCount++;
if (callCount === 1) {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { errorMeta, urlPathOnly } from '../../src/log/error-meta';
import { errorMeta, urlPathOnly } from '@sp/sync-providers/log';
describe('urlPathOnly', () => {
it('strips query string', () => {

View file

@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"composite": false,
"declaration": false,
"declarationMap": false,
"noEmit": true,
"paths": {
"@sp/sync-core": ["../sync-core/src/index.ts"],
"@sp/sync-providers/log": ["src/log.ts"]
},
"rootDir": "..",
"types": ["vitest"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -13,6 +13,7 @@ export default defineConfig({
'src/pkce.ts',
'src/platform.ts',
'src/provider-types.ts',
'src/log.ts',
],
format: ['esm', 'cjs'],
tsconfig: 'tsconfig.build.json',

View file

@ -22,7 +22,8 @@
"@sp/sync-providers/platform": ["packages/sync-providers/src/platform.ts"],
"@sp/sync-providers/provider-types": [
"packages/sync-providers/src/provider-types.ts"
]
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
}
},
"files": ["test.ts", "polyfills.ts"],

View file

@ -41,7 +41,8 @@
"@sp/sync-providers/platform": ["packages/sync-providers/src/platform.ts"],
"@sp/sync-providers/provider-types": [
"packages/sync-providers/src/provider-types.ts"
]
],
"@sp/sync-providers/log": ["packages/sync-providers/src/log.ts"]
},
"plugins": [
{