fix(docsync): keep the Synology device token across a restart, tied to its row

The DSM trusted-device token lived in process memory only. After a
restart every token was gone and the stored one-time code long spent,
so every two-factor NAS binding stopped syncing until its owner typed a
fresh code, and nothing on screen said why.

The token is now kept in the connection's encrypted secrets, together
with a digest of the instance and account it was issued for. A saved
connection's ref carries a saveSecret callback that writes through
DocSyncConfigService.saveEarnedSecret, which rereads the row inside a
transaction so a sync run holding an old ref cannot undo a password
the owner changed meanwhile. The key belongs to no form field, so
maskSecrets never reports it and mergeSecrets never touches it: the GET
and PUT payloads do not carry it, an edit keeps it, and a client cannot
plant one. It survives a password edit and a spent or cleared code. A
connection repointed at another NAS or account drops it on its next
call, and a token DSM refuses with 403 or 404 is dropped in memory and
in storage. A probe of form values and an unsaved form may use what is
stored but never write. A refused token no longer shadows a fresh code:
the code in the form gets its turn in the same login call.

In memory the slot is keyed by the connection id together with the
row's created_at. A backup restored in place or the demo reset rolls
the id sequence back without a restart, and a new connection that
reused an id must not log in on the token of the row it replaced.

The Synology adapter receives its client through DI like Paperless and
WebDAV, instead of building a second instance beside the one the module
registers.

Two readings of a failed request are settled as well:
- INVALID_PURPOSE, INVALID_CA and PATH_LENGTH_EXCEEDED count as an
  untrusted certificate again, so the settings screen offers the
  self-signed switch. Papra used to find them through the message text,
  and the shared classifier reads codes only.
- A host that never completes the TCP handshake reads as unreachable on
  every client. WebDAV said timeout before the shared transport; the
  change is now documented and tested.
This commit is contained in:
Maurice
2026-09-19 13:36:16 +02:00
parent 71400b7e85
commit 25d4e34959
14 changed files with 689 additions and 128 deletions
@@ -9,7 +9,7 @@ import { docFailed } from './document-provider';
import { decryptSecrets, encryptSecrets, maskSecrets, mergeSecrets } from './doc-sync-secrets';
/**
* Connections and trip bindings — everything a human configures, as opposed to
* Connections and trip bindings: everything a human configures, as opposed to
* what the reconciler does with it.
*
* The connection carries a `trip_id`, and that is the one place this design
@@ -41,6 +41,7 @@ export interface ConnectionRow {
last_probe_at: string | null;
last_probe_state: string;
last_probe_error: string | null;
created_at: string;
}
export interface LinkRow {
@@ -69,7 +70,7 @@ export interface LinkRow {
interface ProviderFieldRow {
field_key: string;
/** i18n key suffix, never display text — the client resolves `docsync.<label>`. */
/** i18n key suffix, never display text: the client resolves `docsync.<label>`. */
label: string;
input_type: string;
placeholder: string | null;
@@ -127,7 +128,8 @@ export class DocSyncConfigService {
/**
* Turn a stored row into what an adapter needs. Secrets are decrypted here
* and nowhere else, so there is one place to audit and one place that could
* leak them into a log.
* leak them into a log. The ref can write back what the adapter earns, which
* is what makes it a saved connection's ref; see `saveEarnedSecret`.
*/
toRef(row: ConnectionRow): DocumentConnectionRef {
let settings: Record<string, string> = {};
@@ -143,14 +145,44 @@ export class DocSyncConfigService {
}
return {
connectionId: row.id,
createdAt: row.created_at,
ownerId: row.owner_user_id,
baseUrl: row.base_url,
secrets: decryptSecrets(row.secrets),
settings,
allowInsecureTls: row.allow_insecure_tls === 1,
saveSecret: (key, value) => this.saveEarnedSecret(row.id, key, value),
};
}
/**
* Keep a secret the provider earned itself rather than one typed into the
* form, or drop it with null. DSM's device token is the one there is: without
* it, a restart would leave every two-factor NAS waiting for its owner to
* type a fresh code.
*
* It lives in the same encrypted blob as the form's secrets, under a key no
* form field has, and that is what keeps it out of every response
* (`maskSecrets` reports form fields only) and out of the form's reach
* (`mergeSecrets` writes form fields only).
*
* The blob is read back inside the transaction rather than taken from the
* caller's ref: a sync run holds its ref for minutes, and writing that
* snapshot back would undo a password the owner changed in the meantime.
*/
saveEarnedSecret(connectionId: number, key: string, value: string | null): void {
this.db.transaction(() => {
const row = this.getConnection(connectionId);
if (!row) return;
const secrets = decryptSecrets(row.secrets);
if (value === null) delete secrets[key];
else secrets[key] = value;
this.db.connection
.prepare('UPDATE document_connections SET secrets = ? WHERE id = ?')
.run(encryptSecrets(secrets), connectionId);
});
}
/** What a client may see: no secret values, only whether each one is set. */
publicConnection(row: ConnectionRow): Record<string, unknown> {
const ref = this.toRef(row);
@@ -174,7 +206,7 @@ export class DocSyncConfigService {
* Validate a base URL before it is stored.
*
* A private address is stored WITH a warning rather than refused, because
* self-hosting is the normal case here — a Paperless on 192.168.x is the
* self-hosting is the normal case here: a Paperless on 192.168.x is the
* point of the feature, not an attack. That is the same call airtrail.service
* and dawarich.service already made. What is refused is an address that the
* SSRF guard rejects for a reason other than being private.
@@ -194,6 +226,10 @@ export class DocSyncConfigService {
*
* Secrets that arrive blank or masked keep their stored value, so a client
* that renders the form from a GET never has to hold the real credential.
* A secret the provider earned itself is kept too, whatever the form sends.
* DSM's device token is stored with the address and account it was issued
* for, so an edit that points the connection somewhere else leaves it
* unusable, and the adapter drops it on its next call.
*/
async upsertConnection(
tripId: number,
@@ -274,7 +310,7 @@ export class DocSyncConfigService {
/**
* Deleting a connection leaves every document in place. It unbinds, nothing
* more — the same promise Dawarich's disconnect makes, and the only version
* more: the same promise Dawarich's disconnect makes, and the only version
* of this action that is safe to offer without a confirmation dialog.
*/
deleteConnection(id: number): void {
@@ -376,7 +412,7 @@ export class DocSyncConfigService {
/**
* Unbinding keeps both copies and drops the pairing rows. It never deletes a
* document on either side — a user who wants that does it deliberately, in
* document on either side. A user who wants that does it deliberately, in
* the system that holds the file.
*/
deleteLink(id: number): void {
+12 -4
View File
@@ -7,7 +7,7 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
* All secret fields of a connection live in ONE encrypted JSON column rather
* than a column per provider. A sixth provider then needs no migration, and the
* key rotation in scripts/migrate-encryption.ts stays a single line instead of
* a field list somebody will extend and forget — a forgotten column does not
* a field list somebody will extend and forget. A forgotten column does not
* survive a rotation, and the failure only shows up months later as a
* connection that cannot authenticate any more.
*/
@@ -25,7 +25,7 @@ export function encryptSecrets(secrets: SecretMap): string | null {
* Decrypt for use. A value that cannot be decrypted returns an empty map rather
* than throwing: the caller then reports `unauthorized` and the user re-enters
* the credential, which is the recoverable outcome. That is the same choice
* synology.service.ts makes for a stale SID — a decryption failure after an
* synology.service.ts makes for a stale SID: a decryption failure after an
* ENCRYPTION_KEY rotation must not turn into a boot-time crash.
*/
export function decryptSecrets(stored: string | null | undefined): SecretMap {
@@ -50,9 +50,13 @@ export function decryptSecrets(stored: string | null | undefined): SecretMap {
*
* A secret field that arrives blank, absent, or as the mask keeps its stored
* value. That rule is what lets the client render a connection form without
* ever holding the secret it was given back — the same contract the photo
* ever holding the secret it was given back, the same contract the photo
* provider form and Dawarich already use, so the generic component needs no
* special case here.
*
* A stored key that is no form field is a secret the provider earned itself
* (DSM's device token). It is carried over untouched and cannot be set from
* here: the form never saw it, so an edit can neither wipe it nor plant one.
*/
export function mergeSecrets(stored: SecretMap, submitted: Record<string, string>, secretKeys: readonly string[]): SecretMap {
const next: SecretMap = { ...stored };
@@ -65,7 +69,11 @@ export function mergeSecrets(stored: SecretMap, submitted: Record<string, string
return next;
}
/** What a client is allowed to see: whether a secret is set, never its value. */
/**
* What a client is allowed to see: whether a secret is set, never its value.
* Form fields only, so a secret the provider earned itself is not even
* reported as present.
*/
export function maskSecrets(secrets: SecretMap, secretKeys: readonly string[]): Record<string, string> {
const out: Record<string, string> = {};
for (const key of secretKeys) {
@@ -190,6 +190,7 @@ export class DocSyncController {
deleteConnection(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
createdAt: existing?.created_at ?? '',
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
+27 -7
View File
@@ -4,8 +4,8 @@ import type { DocsyncErrorCode } from '@trek/shared';
/**
* The adapter seam for document providers.
*
* Shaped after `PhotoProvider` (memories/photo-provider.ts) — interface, symbol
* token, registry — with one deliberate difference: that interface only covers
* Shaped after `PhotoProvider` (memories/photo-provider.ts): interface, symbol
* token, registry. One difference is deliberate: that interface only covers
* handing out bytes, and everything else (settings, test, search, album list)
* is dispatched through hand-written `if (provider === 'immich')` branches in
* the service, the MCP tools and two separate controllers. With two providers
@@ -16,7 +16,7 @@ import type { DocsyncErrorCode } from '@trek/shared';
* Capability differences are answered by data (`DocumentProviderCapabilities`),
* never by type-switching: the core asks what a provider can do and plans
* accordingly. Synology FileStation has no change feed and no stable file id,
* so the core must be correct with nothing but a full enumeration — and because
* so the core must be correct with nothing but a full enumeration, and because
* the weakest provider sets the model, every provider is driven the same way.
* A webhook is only ever "look now", never a source of truth.
*/
@@ -58,17 +58,37 @@ export function docFailed<T>(result: DocResult<T>): result is DocFailure {
export interface DocumentConnectionRef {
connectionId: number;
/**
* The user whose credentials these are — the trip admin who set the binding
* When the connection's row was created, or '' for form values that were
* never saved. The id alone does not name one row for good: a backup
* restored in place rolls the id sequence back, and the id comes round
* again on the next connection saved, on any trip. The two together do.
*/
createdAt: string;
/**
* The user whose credentials these are: the trip admin who set the binding
* up, not whoever is asking. The core passes both around separately and never
* derives one from the other.
*/
ownerId: number;
baseUrl: string;
/** Decrypted `secret = 1` fields. Never logged, never returned to a client. */
/**
* Decrypted `secret = 1` fields, plus whatever the provider earned and kept
* through `saveSecret`. Never logged, never returned to a client.
*/
secrets: Readonly<Record<string, string>>;
/** Non-secret fields: username, organization_id, base_path, root_tag, … */
settings: Readonly<Record<string, string>>;
allowInsecureTls: boolean;
/**
* Keep a secret the provider earned itself (DSM's device token) with this
* connection, or drop it with null. `secrets` is the snapshot this ref was
* made from and does not change when this writes.
*
* Only a saved connection has one. A probe of form values may use what is
* stored but never changes it: the values on screen may be for another
* account, or never be saved at all.
*/
saveSecret?: (key: string, value: string | null) => void;
}
/** The container at the provider that one trip is bound to. */
@@ -96,7 +116,7 @@ export interface RemoteDocument {
mimeType: string | null;
/**
* Opaque version marker. Different means changed. That is all the core may
* conclude from it — it is not a content hash, not comparable across
* conclude from it. It is not a content hash, not comparable across
* instances, and not ordered.
*/
remoteVersion: string;
@@ -190,7 +210,7 @@ export interface DocumentProvider {
/**
* Verify form values before anything is stored, and report what the account
* can actually do. Always resolves — a failed probe is data, not an
* can actually do. Always resolves: a failed probe is data, not an
* exception, because the settings UI shows it inline (the photo providers
* pin that with a test marked CRITICAL in their e2e suite).
*/
@@ -59,20 +59,33 @@ export interface GuardedDownload {
*/
export const DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024;
/** undici's own timers, plus the abort `AbortSignal.timeout` fires. */
/**
* The abort `AbortSignal.timeout` fires, and undici's header and body timers:
* a host that took the request and then went quiet. undici's connect timer is
* left out on purpose. A host that never completed the handshake (switched
* off, or behind a firewall that drops the SYN) did not answer at all, so it
* reads as unreachable.
*/
const TIMEOUT_NAME = /^(TimeoutError|AbortError)$/;
const TIMEOUT_CODE = /ABORT_ERR|HEADERS_TIMEOUT|BODY_TIMEOUT/i;
/**
* Codes that are a verdict on the certificate rather than on the connection,
* which is what lets the settings screen offer the self-signed switch instead
* of a dead end. Most carry CERT or TLS in the name; these two do not.
* of a dead end. Most carry CERT or TLS in the name; the verify codes listed
* here do not, and the switch gets past each of them all the same.
*
* ERR_SSL_* is deliberately not among them: a handshake that fails on the
* protocol (https against a plain http port, say) is not something the switch
* can fix, and `tls_untrusted` tells the user it is.
*/
const CERTIFICATE_CODES = new Set(['UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'HOSTNAME_MISMATCH']);
const CERTIFICATE_CODES = new Set([
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'HOSTNAME_MISMATCH',
'INVALID_PURPOSE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
]);
const CERTIFICATE_CODE = /CERT|SELF_SIGNED|ERR_TLS/i;
/**
@@ -21,7 +21,7 @@ import {
* This is the ONLY place that talks to a user's NAS for document sync.
*
* Written against Synology's File Station Official API guide and verified
* against the FileStation stub in `trek-docsync-testlab/syno-stub` — DSM itself
* against the FileStation stub in `trek-docsync-testlab/syno-stub`. DSM itself
* cannot be containerised, so the parts marked below as unverified are the ones
* no reachable instance could confirm.
*
@@ -72,7 +72,7 @@ const SESSION_MAX_AGE_MS = 30 * 60 * 1000;
* five failures in five minutes, and a link that polls every five minutes would
* walk into it in under half an hour. A minute is long enough to keep any one
* sync run from spending a second attempt, and short enough that an admin who
* fixes the password does not wait — a corrected password changes the
* fixes the password does not wait: a corrected password changes the
* fingerprint and clears the lockout immediately.
*/
const CREDENTIAL_LOCKOUT_MS = 60 * 1000;
@@ -209,6 +209,8 @@ export interface SynologyDriveCreds {
* not been saved yet. It keys the device token; see `deviceTokens`.
*/
connectionId: number;
/** When that connection's row was created. With the id, it keys the device token. */
connectionCreatedAt: string;
/** Instance origin including the DSM port, e.g. `https://nas.example.com:5001`. */
baseUrl: string;
username: string;
@@ -216,9 +218,17 @@ export interface SynologyDriveCreds {
/**
* A TOTP code from the connection form. Single-use by construction, so it can
* only ever serve the first login; the device token that login returns is what
* keeps later logins working, and it lives in this process's memory only.
* keeps later logins working.
*/
otpCode?: string;
/** The connection's device token as stored, in the form `storedDeviceToken` writes. */
storedDeviceToken?: string;
/**
* Store a new token in that form, or drop it with null. Only a saved
* connection passes one; a probe of form values uses the stored token without
* being able to change it.
*/
saveDeviceToken?: (stored: string | null) => void;
allowInsecureTls: boolean;
}
@@ -296,6 +306,18 @@ interface CredentialLockout {
synoCode: number;
}
/** One connection's device token, and what its storage holds as far as this process knows. */
interface DeviceTokenSlot {
/** Digest of the instance and account the token was issued for. */
account: string;
token: string | null;
/**
* The stored form this process last read or wrote, or undefined until a
* caller that can write has shown what storage holds.
*/
stored?: string | null;
}
interface SynoEnvelope {
success?: unknown;
data?: unknown;
@@ -430,6 +452,22 @@ function appError(synoCode: number, isAuth: boolean): SynologyDriveError {
});
}
/**
* A device token the way a connection stores it: `<account digest>:<token>`.
* The account travels with it, so a connection repointed at another NAS or
* another account reads its old token as foreign instead of offering it there.
*/
function storedDeviceToken(account: string, token: string): string {
return `${account}:${token}`;
}
/** The token out of its stored form, or null when none is stored for this account. */
function deviceTokenFor(stored: string | undefined, account: string): string | null {
const prefix = `${account}:`;
if (!stored?.startsWith(prefix) || stored.length === prefix.length) return null;
return stored.slice(prefix.length);
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export class SynologyDriveClient {
@@ -441,7 +479,7 @@ export class SynologyDriveClient {
* displaces the first, so two TREK connections pointing at the same NAS with
* the same account share one session instead of evicting each other. The
* password is part of the key because a connection test with a corrected
* password has to reach the NAS — riding the session the old password opened
* password has to reach the NAS: riding the session the old password opened
* would report success for a credential that no longer works. It is also what
* makes an explicit "forget this session" unnecessary: an edited credential
* is a different key and cannot reach the old entry.
@@ -452,7 +490,7 @@ export class SynologyDriveClient {
private readonly lockouts = new Map<string, CredentialLockout>();
/**
* DSM's trusted-device tokens, one per TREK connection.
* DSM's trusted-device tokens, one slot per TREK connection.
*
* The token is DSM's record that somebody passed the second factor, and that
* somebody is whoever typed the code into this connection's form. Keyed by
@@ -460,17 +498,27 @@ export class SynologyDriveClient {
* never had the authenticator could point a connection at the same account
* and log in on the first trip's token.
*
* The connection is its id together with the time its row was created. The
* id alone comes round again: a backup restored in place, or the demo reset,
* rolls the id sequence back without restarting the process, and the next
* connection saved, on whatever trip, is handed an id this map may still hold
* a token under.
*
* Not keyed by the password or the code: the token has to survive a password
* edit and the single-use OTP going stale in the form, or the next session
* expiry needs a human with an authenticator app. An edit keeps the
* connection id, so it keeps the token. Instance and account stay in the key
* so a connection repointed at another NAS or account starts without one.
* expiry needs a human with an authenticator app. An edit updates the row in
* place, so it keeps the token. The slot remembers the instance and
* account the token was issued for, so a connection repointed at another NAS
* or account starts without one.
*
* A form that has not been saved has no id to file a token under (every
* unsaved form is connection 0), so what its probe earns rides on the session
* only, and the saved connection adopts it from there; see `session()`.
*
* This is the working copy. The one that survives a restart is in the
* connection's encrypted secrets; see `deviceSlot()`.
*/
private readonly deviceTokens = new Map<string, string>();
private readonly deviceTokens = new Map<string, DeviceTokenSlot>();
/** The credential this session and lockout belong to, without storing it. */
private sessionKey(creds: SynologyDriveCreds): string {
@@ -483,9 +531,56 @@ export class SynologyDriveClient {
return `${normalizeBaseUrl(creds.baseUrl)}\u0000${creds.username}`;
}
/** Where this connection's device token lives, or null for a form not saved yet. */
private deviceKey(creds: SynologyDriveCreds): string | null {
return creds.connectionId > 0 ? `${creds.connectionId}\u0000${this.accountKey(creds)}` : null;
/** What a device token is filed under: instance and account, never the password. */
private accountDigest(creds: SynologyDriveCreds): string {
return createHash('sha256').update(this.accountKey(creds)).digest('hex');
}
/**
* This connection's device token slot, with storage brought in line with it.
* Null for a form that has not been saved.
*
* Memory is what a login reads, because a sync run holds one snapshot of the
* stored secrets from its start, and a token earned or refused halfway
* through would otherwise be lost to the rest of the run. Storage is what
* survives a restart, and a process that has no slot for the connection yet
* starts from it.
*
* Whenever the two disagree and the caller can write, storage follows memory.
* That covers a token earned or refused here, one that a test of the saved
* form earned, and one left behind for an instance or account the connection
* no longer points at. The comparison is with what this process last read or
* wrote rather than with the caller's snapshot, which goes stale the moment
* anything is written, so a change is written once and not on every call of
* the run that made it.
*/
private deviceSlot(creds: SynologyDriveCreds): DeviceTokenSlot | null {
if (creds.connectionId <= 0) return null;
const key = `${creds.connectionId}\u0000${creds.connectionCreatedAt}`;
const account = this.accountDigest(creds);
let slot = this.deviceTokens.get(key);
if (!slot || slot.account !== account) {
slot = { account, token: deviceTokenFor(creds.storedDeviceToken, account) };
this.deviceTokens.set(key, slot);
}
this.persist(creds, slot);
return slot;
}
private persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): void {
if (!creds.saveDeviceToken) return;
if (slot.stored === undefined) slot.stored = creds.storedDeviceToken ?? null;
const wanted = slot.token === null ? null : storedDeviceToken(slot.account, slot.token);
if (wanted === slot.stored) return;
creds.saveDeviceToken(wanted);
slot.stored = wanted;
}
private setDeviceToken(creds: SynologyDriveCreds, token: string | null): void {
const slot = this.deviceSlot(creds);
if (!slot || slot.token === token) return;
slot.token = token;
this.persist(creds, slot);
}
private async request(
@@ -596,10 +691,8 @@ export class SynologyDriveClient {
private async session(creds: SynologyDriveCreds): Promise<SynoSession> {
const cached = this.sessions.get(this.sessionKey(creds));
if (cached && Date.now() - cached.createdAt < SESSION_MAX_AGE_MS) {
const deviceKey = this.deviceKey(creds);
if (deviceKey && cached.deviceId && !this.deviceTokens.has(deviceKey)) {
this.deviceTokens.set(deviceKey, cached.deviceId);
}
const slot = this.deviceSlot(creds);
if (slot && slot.token === null && cached.deviceId) this.setDeviceToken(creds, cached.deviceId);
return cached;
}
return await this.login(creds);
@@ -610,13 +703,11 @@ export class SynologyDriveClient {
*
* The connection's device token is reused when there is one: the form's OTP
* is a single TOTP code that expires in thirty seconds, so it can only ever
* serve the first login of a process. Without the device token, every session
* expiry would need a human to type a new code.
* serve the first login. Without the device token, every session expiry would
* need a human to type a new code.
*/
private async login(creds: SynologyDriveCreds): Promise<SynoSession> {
const key = this.sessionKey(creds);
const deviceKey = this.deviceKey(creds);
const deviceId = deviceKey ? this.deviceTokens.get(deviceKey) ?? null : null;
const lockout = this.lockouts.get(key);
if (lockout && lockout.until > Date.now()) {
throw new SynologyDriveError(lockout.code, 'The NAS rejected these credentials; not retrying yet', {
@@ -625,6 +716,66 @@ export class SynologyDriveClient {
});
}
let answer: { data: unknown; deviceId: string | null };
try {
answer = await this.authenticate(creds);
} catch (error: unknown) {
if (error instanceof SynologyDriveError && error.synoCode && error.synoCode >= 400) {
this.lockouts.set(key, {
until: Date.now() + (error.synoCode === 407 ? AUTOBLOCK_LOCKOUT_MS : CREDENTIAL_LOCKOUT_MS),
code: error.code,
synoCode: error.synoCode,
});
}
throw error;
}
const { data, deviceId } = answer;
const sid = isRecord(data) && typeof data.sid === 'string' ? data.sid : null;
if (!sid) {
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
}
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
if (issuedDeviceId) this.setDeviceToken(creds, issuedDeviceId);
const session: SynoSession = {
sid,
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
deviceId: issuedDeviceId,
createdAt: Date.now(),
};
this.lockouts.delete(key);
this.sessions.set(key, session);
return session;
}
/**
* The login request, on the connection's device token when it has one and on
* the form's code otherwise.
*
* A token DSM refuses (someone removed TREK from the account's trusted
* devices, or it expired) is dropped on the spot, and the code in the form
* gets its turn in the same call. That code is usually what the owner just
* typed to repair exactly this, and a dead token sent ahead of it would fail
* every attempt, now that a stored token outlives the process. It is a second
* credential rather than a second try of the first, and whichever fails last
* is what the lockout records.
*/
private async authenticate(creds: SynologyDriveCreds): Promise<{ data: unknown; deviceId: string | null }> {
const deviceId = this.deviceSlot(creds)?.token ?? null;
try {
return { data: await this.call(creds, this.loginParams(creds, deviceId), { cgi: AUTH_CGI }), deviceId };
} catch (error: unknown) {
if (!deviceId || !(error instanceof SynologyDriveError) || !DEVICE_TOKEN_REFUSED_CODES.has(error.synoCode ?? 0)) {
throw error;
}
// Unless another login replaced it while this one was out.
if (this.deviceSlot(creds)?.token === deviceId) this.setDeviceToken(creds, null);
if (!creds.otpCode) throw error;
return { data: await this.call(creds, this.loginParams(creds, null), { cgi: AUTH_CGI }), deviceId: null };
}
}
private loginParams(creds: SynologyDriveCreds, deviceId: string | null): Record<string, string> {
const params: Record<string, string> = {
api: 'SYNO.API.Auth',
version: '6',
@@ -645,49 +796,14 @@ export class SynologyDriveClient {
params.otp_code = creds.otpCode;
params.enable_device_token = 'yes';
}
let data: unknown;
try {
data = await this.call(creds, params, { cgi: AUTH_CGI });
} catch (error: unknown) {
if (error instanceof SynologyDriveError && error.synoCode && error.synoCode >= 400) {
// A token DSM no longer trusts would otherwise go out ahead of any fresh
// code typed into the form, on every attempt, until the process restarts.
if (deviceKey && deviceId && DEVICE_TOKEN_REFUSED_CODES.has(error.synoCode)
&& this.deviceTokens.get(deviceKey) === deviceId) {
this.deviceTokens.delete(deviceKey);
}
this.lockouts.set(key, {
until: Date.now() + (error.synoCode === 407 ? AUTOBLOCK_LOCKOUT_MS : CREDENTIAL_LOCKOUT_MS),
code: error.code,
synoCode: error.synoCode,
});
}
throw error;
}
const sid = isRecord(data) && typeof data.sid === 'string' ? data.sid : null;
if (!sid) {
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
}
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
if (deviceKey && issuedDeviceId) this.deviceTokens.set(deviceKey, issuedDeviceId);
const session: SynoSession = {
sid,
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
deviceId: issuedDeviceId,
createdAt: Date.now(),
};
this.lockouts.delete(key);
this.sessions.set(key, session);
return session;
return params;
}
/**
* Authenticate and report what this DSM offers.
*
* The API inventory is part of it because a DSM with File Station uninstalled
* logs in perfectly and then answers 102 to everything — a failure that looks
* logs in perfectly and then answers 102 to everything, a failure that looks
* like a TREK bug unless the connection test says so.
*/
async probe(creds: SynologyDriveCreds): Promise<SynoProbe> {
@@ -820,7 +936,7 @@ export class SynologyDriveClient {
// The session is resolved before the body is touched. A stream can only be
// read once, so the 106/119 re-login retry that every other call gets is not
// available here — better to spend the round trip up front than to fail an
// available here: better to spend the round trip up front than to fail an
// upload that already read half the file.
const session = await this.session(creds);
@@ -992,7 +1108,7 @@ export class SynologyDriveClient {
/**
* MD5 of a file already on the NAS.
*
* MD5, not sha256, because MD5 is the only digest FileStation computes — and
* MD5, not sha256, because MD5 is the only digest FileStation computes, and
* it is a task per file, so this is only ever a targeted question about one
* file, never part of a listing.
*/
@@ -33,8 +33,8 @@ import {
/**
* Synology Drive over the FileStation Web API.
*
* The scope is a folder and identity is its path — FileStation has no file id,
* no change feed and no webhook — so this adapter is the one the sync core's
* The scope is a folder and identity is its path (FileStation has no file id,
* no change feed and no webhook), so this adapter is the one the sync core's
* "full enumeration, nothing assumed" model was shaped around. Everything it
* cannot honestly promise it declares as absent in `capabilities()` rather than
* faking: no push, no stable id, no hash in the listing.
@@ -62,10 +62,10 @@ const SCOPE_PREFIX = 'path:';
const TRASH_FOLDER = '.trek-trash';
/**
* The adapter's own ceiling for one transfer. Not a policy — the install's limit
* The adapter's own ceiling for one transfer. Not a policy: the install's limit
* is the upload limit (`MAX_FILE_SIZE`), enforced by the core before and during
* every transfer — just a bound so a wrong path or a truncated header cannot
* start an unbounded read.
* every transfer. This is just a bound so a wrong path or a truncated header
* cannot start an unbounded read.
*/
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
@@ -82,14 +82,21 @@ const MD5_VERIFY_MAX_BYTES = 64 * 1024 * 1024;
/** How many scope options one picker call may answer with. */
const MAX_SCOPE_OPTIONS = 200;
/**
* Where the connection keeps DSM's device token among its secrets. No form
* field has this key, which is what keeps it out of every response and out of
* the form's reach; see `saveEarnedSecret` in the config service.
*/
const DEVICE_TOKEN_SECRET = 'device_token';
const CAPABILITIES: DocumentProviderCapabilities = {
// FileStation has no subscription API at all. DSM's own notifications are
// mail and mobile push, neither reachable from here.
push: 'none',
// The path IS the id: a rename upstream reads as delete + create.
stableId: false,
// Through the move into TRASH_FOLDER below, not through DSM's own recycle bin
// — the API's delete is permanent and offers no flag to route it elsewhere.
// Through the move into TRASH_FOLDER below, not through DSM's own recycle bin:
// the API's delete is permanent and offers no flag to route it elsewhere.
remoteTrash: true,
// An upload with overwrite=true replaces the bytes under the same path.
replaceInPlace: true,
@@ -145,7 +152,7 @@ function scopeOption(path: string, label?: string): DocumentScopeOption {
return {
scopeKey: `${SCOPE_PREFIX}${path}`,
label: label ?? baseName(path),
// There is no stable folder id on a Synology share — the path is all there
// There is no stable folder id on a Synology share: the path is all there
// is, and pretending otherwise would put a fiction in the binding.
remoteRootId: null,
remoteRootPath: path,
@@ -157,11 +164,11 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
readonly id = PROVIDER_ID;
/**
* Constructed here rather than injected: the client holds the SID cache, so
* there has to be exactly one of it per process, and it has no dependencies
* of its own to resolve.
* Injected, like the Paperless and WebDAV clients: it holds the SID cache,
* the lockouts and the device token slots, so there has to be exactly one of
* it per process, and the module's singleton is that one.
*/
private readonly client = new SynologyDriveClient();
constructor(private readonly client: SynologyDriveClient) {}
capabilities(): DocumentProviderCapabilities {
return CAPABILITIES;
@@ -188,7 +195,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
/**
* The shares this account can see, plus the folders under the configured base
* path — the two levels an operator actually picks from. Shares are offered
* path, the two levels an operator actually picks from. Shares are offered
* too because a small NAS often has a share per purpose and no sub-folders.
*/
async listScopes(conn: DocumentConnectionRef, query?: string): Promise<DocResult<DocumentScopeOption[]>> {
@@ -244,7 +251,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
return docOk(scopeOption(created, folder.name));
} catch (error: unknown) {
// With force_parent DSM creates missing folders, but never a missing
// share — that is a storage operation, not a file one. Saying "the base
// share: that is a storage operation, not a file one. Saying "the base
// path does not exist" is the actionable version of its 408.
if (error instanceof SynologyDriveError && error.code === 'not_found') {
return docFail('scope_missing', `The base path ${basePath} does not exist on the NAS`);
@@ -383,8 +390,8 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
*
* The confirmation is not ceremony: FileStation answers `success: true` as
* soon as it has written the part, and the version the core stores has to
* describe the file as the NAS now sees it — including the mtime DSM rounded
* to whole seconds — or the next listing reads TREK's own upload as a change
* describe the file as the NAS now sees it (including the mtime DSM rounded
* to whole seconds), or the next listing reads TREK's own upload as a change
* made upstream.
*/
async push(
@@ -539,12 +546,16 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
const password = conn.secrets.password ?? '';
if (!username || !password || !conn.baseUrl.trim()) return null;
const otpCode = (conn.secrets.otp_code ?? '').trim();
const save = conn.saveSecret;
return {
connectionId: conn.connectionId,
connectionCreatedAt: conn.createdAt,
baseUrl: conn.baseUrl,
username,
password,
otpCode: otpCode === '' ? undefined : otpCode,
storedDeviceToken: conn.secrets[DEVICE_TOKEN_SECRET],
saveDeviceToken: save ? (stored) => save(DEVICE_TOKEN_SECRET, stored) : undefined,
allowInsecureTls: conn.allowInsecureTls,
};
}
@@ -582,7 +593,7 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
* make. The trip boundary exists only here.
*
* Throws rather than returning a result, so a caller cannot forget to check
* it — every caller already runs inside the try that turns a
* it. Every caller already runs inside the try that turns a
* `SynologyDriveError` into a `DocResult`.
*/
private requireInScope(scope: DocumentScopeRef, remoteId: string): string {
@@ -5,7 +5,7 @@
* The database here is real in-memory SQLite with the full schema, and the
* at-rest crypto is real too. Both are deliberate. Almost everything this
* service owns is either a credential or a decision about one, and a spy on
* `db.run` would only prove that some strings were handed along — reading the
* `db.run` would only prove that some strings were handed along. Reading the
* row back is the assertion. A stubbed `maybe_encrypt_api_key` would let a
* regression that stores a Paperless token in plaintext pass every case below.
*
@@ -63,7 +63,7 @@ import {
type LinkRow,
} from '../../../../src/nest/doc-sync/doc-sync-config.service';
import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/document-provider.registry';
// `if (!result.success)` does not narrow in this workspace — strictNullChecks is
// `if (!result.success)` does not narrow in this workspace: strictNullChecks is
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
@@ -123,7 +123,7 @@ beforeAll(() => {
beforeEach(() => {
// The document_* tables are not in resetTestDb's list, and they reference
// trips and users — so they go first, before the rows they hang off vanish.
// trips and users, so they go first, before the rows they hang off vanish.
testDb.exec('DELETE FROM document_sync_items; DELETE FROM trip_document_links; DELETE FROM document_connections;');
resetTestDb(testDb);
vi.clearAllMocks();
@@ -226,6 +226,83 @@ describe('publicConnection', () => {
});
});
describe('a secret the provider earned itself', () => {
// The shape the Synology adapter stores: account digest, colon, DSM's token.
const EARNED = 'a1b2c3:DEVICE-7';
function nas(credentials: Record<string, string> = { username: 'anna', password: 'nas-pw', otp_code: '123456' }) {
return connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials });
}
function secretsOf(connectionId: number): Readonly<Record<string, string>> {
return svc.toRef(svc.getConnection(connectionId) as ConnectionRow).secrets;
}
it('is stored through the ref the adapter was handed, encrypted with the form\'s secrets', async () => {
const conn = await nas();
svc.toRef(conn).saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
expect((svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
});
it('never reaches a client, not even as a mask', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
expect(view.secrets).toEqual({ password: DOCSYNC_SECRET_MASK, otp_code: DOCSYNC_SECRET_MASK });
expect(JSON.stringify(view)).not.toContain('DEVICE-7');
expect(JSON.stringify(view)).not.toContain('device_token');
});
it('survives a form edit, whatever the form sends for the secrets it does show', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', password: 'rotated', otp_code: '' });
await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK });
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('cannot be planted or overwritten through the form', async () => {
const conn = await nas();
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBeUndefined();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBe(EARNED);
expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).settings).not.toHaveProperty('device_token');
});
it('is written against the row as it is now, so a run holding an old ref cannot undo a password edit', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
await nas({ username: 'anna', password: 'rotated' });
ref.saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('is dropped with null, and the form\'s secrets stay', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
svc.saveEarnedSecret(conn.id, 'device_token', null);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
it('brings back no connection that was deleted while a run still held its ref', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
svc.deleteConnection(conn.id);
ref.saveSecret!('device_token', EARNED);
expect(svc.getConnection(conn.id)).toBeUndefined();
});
});
describe('validateBaseUrl', () => {
it('stores a private address, because a self-hosted instance is the point', async () => {
checkSsrf.mockResolvedValue({ allowed: false, isPrivate: true, error: 'private network' });
@@ -338,6 +415,24 @@ describe('deleteConnection', () => {
});
});
describe('toRef', () => {
it('names the row and not only its id, which a backup restored in place hands out again', async () => {
const first = await connect();
testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id);
const before = svc.toRef(svc.getConnection(first.id) as ConnectionRow);
// What the restore of an older backup does to this table: the row is gone
// and the id sequence is back where it stood before the row was made.
testDb.prepare('DELETE FROM document_connections WHERE id = ?').run(first.id);
testDb.prepare("UPDATE sqlite_sequence SET seq = ? WHERE name = 'document_connections'").run(first.id - 1);
const after = svc.toRef(await connect());
expect(after.connectionId).toBe(before.connectionId);
expect(before.createdAt).toBe('2026-09-01 08:00:00');
expect(after.createdAt).not.toBe(before.createdAt);
});
});
describe('updateLink', () => {
it('changes only the fields the patch names', async () => {
const conn = await connect();
@@ -370,6 +370,52 @@ describe('probing form values', () => {
describe('reading the trip state', () => {
it('asks the reconciler with a number, because the path hands the handler a string', () => {
describe('a secret the adapter earned itself', () => {
const EARNED = 'a1b2c3:DEVICE-SECRET';
it('is in neither connection payload the form is rendered from', async () => {
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id = 'synologydrive'").run();
const nas = connBody({
providerId: 'synologydrive',
baseUrl: 'https://nas.example.com:5001',
credentials: { username: 'anna', password: 'nas-pw' },
});
const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number };
config.saveEarnedSecret(created.id, 'device_token', EARNED);
const listed = JSON.stringify(controller.listConnections(String(tripId)));
const edited = JSON.stringify(
await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }),
);
for (const payload of [listed, edited]) {
expect(payload).not.toContain('DEVICE-SECRET');
expect(payload).not.toContain('device_token');
}
expect(config.toRef(config.getConnection(created.id)!).secrets.device_token).toBe(EARNED);
});
it('reaches a probe of the saved form, which gets no way to write', async () => {
const conn = await storedPaperless();
config.saveEarnedSecret(conn.id, 'device_token', EARNED);
const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
// The row as well as its id: together they are where the saved connection keeps its device token.
expect(ref).toMatchObject({ connectionId: conn.id, createdAt: conn.created_at, secrets: { device_token: EARNED } });
expect(ref.createdAt).not.toBe('');
expect(ref.saveSecret).toBeUndefined();
expect(JSON.stringify(res)).not.toContain('DEVICE-SECRET');
});
it('gives a probe of an unsaved form no way to write either', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
expect(ref.connectionId).toBe(0);
expect(ref.createdAt).toBe('');
expect(ref.saveSecret).toBeUndefined();
});
});
controller.status(String(tripId));
expect(sync.status).toHaveBeenCalledWith(tripId);
});
@@ -1,5 +1,5 @@
/**
* The Paperless-ngx adapter — PAPERLESS-001..095.
* The Paperless-ngx adapter: PAPERLESS-001..095.
*
* Two things are pinned here. What the client puts on the wire: the pinned API
* version (an unpinned Accept header is how the `checksum` field silently moved
@@ -86,6 +86,7 @@ const BASE = 'https://papers.example.org';
const CONN: DocumentConnectionRef = {
connectionId: 4,
createdAt: '2026-09-01 08:00:00',
ownerId: 9,
baseUrl: BASE,
secrets: { api_token: TOKEN },
@@ -285,7 +286,7 @@ beforeEach(() => {
);
});
describe('PaperlessClient — the request it builds', () => {
describe('PaperlessClient: the request it builds', () => {
it('PAPERLESS-001: appends /api to whatever the operator pasted', () => {
expect(apiBase(BASE)).toBe(`${BASE}/api`);
expect(apiBase(`${BASE}/`)).toBe(`${BASE}/api`);
@@ -338,7 +339,7 @@ describe('PaperlessClient — the request it builds', () => {
});
});
describe('PaperlessProvider — probing a connection', () => {
describe('PaperlessProvider: probing a connection', () => {
it('PAPERLESS-010: reports the self-registering webhook when the token may create workflows', async () => {
on('GET /api/ui_settings/', reply({
user: { username: 'admin' },
@@ -416,7 +417,7 @@ describe('PaperlessProvider — probing a connection', () => {
});
});
describe('PaperlessProvider — scopes are tags', () => {
describe('PaperlessProvider: scopes are tags', () => {
it('PAPERLESS-020: a tag becomes a scope key, a label and the instance filter URL', async () => {
on('GET /api/tags/', reply(page([tagRow(3, 'japan-2026', 4), tagRow(9, 'privat')])));
const options = expectOk(await provider.listScopes(CONN));
@@ -468,7 +469,7 @@ describe('PaperlessProvider — scopes are tags', () => {
});
});
describe('PaperlessProvider — listing a scope', () => {
describe('PaperlessProvider: listing a scope', () => {
it('PAPERLESS-030: maps a document onto the core’s shape', async () => {
on('GET /api/documents/', reply(page([docRow()])));
const listed = expectOk(await provider.list(CONN, SCOPE));
@@ -544,7 +545,7 @@ describe('PaperlessProvider — listing a scope', () => {
const second = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(second.cursorUnchanged).toBe(true);
// A document leaving the tag moves no timestamp — only the count notices.
// A document leaving the tag moves no timestamp: only the count notices.
on('GET /api/documents/', reply(page([], null, 0)));
const third = expectOk(await provider.list(CONN, { ...SCOPE, cursor: first.cursor }));
expect(third.cursorUnchanged).toBe(false);
@@ -583,7 +584,7 @@ describe('PaperlessProvider — listing a scope', () => {
});
});
describe('PaperlessProvider — fetching bytes', () => {
describe('PaperlessProvider: fetching bytes', () => {
it('PAPERLESS-040: asks for the original file and hands back the stream with its version', async () => {
on('GET /api/documents/11/', reply(docRow()));
on('GET /api/documents/11/download/', streamReply('%PDF-1.4 hotel', {
@@ -635,7 +636,7 @@ describe('PaperlessProvider — fetching bytes', () => {
});
});
describe('PaperlessProvider — pushing bytes', () => {
describe('PaperlessProvider: pushing bytes', () => {
function stubSuccessfulCreate(): void {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
@@ -731,7 +732,7 @@ describe('PaperlessProvider — pushing bytes', () => {
expect(expectFail(await provider.push(CONN, SCOPE, pushRequest())).code).toBe('conflict');
});
it('PAPERLESS-055: the task filter is not trusted either — a foreign task is ignored', async () => {
it('PAPERLESS-055: the task filter is not trusted either, so a foreign task is ignored', async () => {
on('GET /api/custom_fields/', reply(page([{ id: 1, name: TRIP_UID_FIELD_NAME, data_type: 'string' }])));
on('POST /api/documents/post_document/', reply('"task-uuid-1"'));
on('GET /api/tasks/', reply(page([taskRow({ task_id: 'someone-elses-task', status: 'success' })])));
@@ -765,7 +766,7 @@ describe('PaperlessProvider — pushing bytes', () => {
it('PAPERLESS-057a: a document with the same bytes but another tag is not adopted', async () => {
// The tag goes on at upload, so only a tagged document can be the one this
// push made. Taking the untagged twin instead put a stranger's document in
// the trip — and delete-through would have trashed it on a later run.
// the trip, and delete-through would have trashed it on a later run.
vi.spyOn(client, 'awaitConsume').mockRejectedValue(
new PaperlessError('timeout', 'Paperless is still processing the uploaded file'),
);
@@ -842,7 +843,7 @@ describe('PaperlessProvider — pushing bytes', () => {
});
});
describe('PaperlessProvider — renaming and trashing', () => {
describe('PaperlessProvider: renaming and trashing', () => {
it('PAPERLESS-070: renaming sets the title without the extension it would add back', async () => {
on('PATCH /api/documents/11/', reply(docRow({ modified: '2026-09-18T19:00:00.000000+02:00' })));
const renamed = expectOk(await provider.rename(CONN, SCOPE, '11', ' Hotelrechnung Kyoto.pdf '));
@@ -866,7 +867,7 @@ describe('PaperlessProvider — renaming and trashing', () => {
});
});
describe('PaperlessProvider — the self-registered webhook', () => {
describe('PaperlessProvider: the self-registered webhook', () => {
it('PAPERLESS-090: subscribes both triggers, filtered on the tag, with the secret in a header', async () => {
on('POST /api/workflows/', reply({ id: 6 }, 201));
const registered = expectOk(
@@ -6,7 +6,7 @@
* nothing and restores a trashed twin instead still answers 200, a missing key
* scope is a 401 rather than a 403, and no timestamp moves when a document
* changes. So the cases pinned here are mostly the ones where believing the
* obvious reading would corrupt the sync — the dedup paths, the version
* obvious reading would corrupt the sync: the dedup paths, the version
* marker, and the scope filter.
*
* `safeFetch` is mocked because the SSRF guard does a real DNS lookup and
@@ -51,6 +51,7 @@ const TAG = 'tag_egz8zvs4gcesi99d00mma0f3';
const CONN: DocumentConnectionRef = {
connectionId: 7,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: 'https://papra.example.org',
secrets: { api_key: API_KEY },
@@ -1069,7 +1070,7 @@ describe('the version marker', () => {
/**
* The media type goes into a header, so it has to survive being one.
*
* It comes from `trip_files.mime_type` — influenced by an upload — and went in
* It comes from `trip_files.mime_type` (influenced by an upload) and went in
* untouched while the filename beside it was escaped. A CR or LF ends the
* header early and lets what follows be read as headers of its own.
*/
@@ -135,6 +135,10 @@ describe('classifyTransportFailure', () => {
'CERT_SIGNATURE_FAILURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
'HOSTNAME_MISMATCH',
// A leaf without serverAuth, signed by a CA this host does trust.
'INVALID_PURPOSE',
'INVALID_CA',
'PATH_LENGTH_EXCEEDED',
])('PROVIDER-HTTP-003: %s is an untrusted certificate', (code) => {
expect(classifyTransportFailure(wrapped(coded('certificate verdict', code))).code).toBe('tls_untrusted');
});
@@ -160,6 +164,15 @@ describe('classifyTransportFailure', () => {
expect(classifyTransportFailure(error).code).toBe('tls_untrusted');
expect(classifyTransportFailure(wrapped(error)).code).toBe('unreachable');
});
it('PROVIDER-HTTP-007: a host that never takes the connection is unreachable, not slow', () => {
// undici gives up on the handshake after ten seconds, before the request's
// own timeout does: a NAS that is switched off, or a firewall that drops the SYN.
const error = wrapped(
Object.assign(new Error('Connect Timeout Error'), { name: 'ConnectTimeoutError', code: 'UND_ERR_CONNECT_TIMEOUT' }),
);
expect(classifyTransportFailure(error).code).toBe('unreachable');
});
});
describe('providerFetch', () => {
@@ -5,7 +5,7 @@
* FileStation is the provider the sync core's weakest-common-denominator model
* was shaped around: no change feed, no stable file id, app-level errors inside
* HTTP 200, and one number meaning different things on two endpoints. So both
* halves of that contract are pinned here — the exact parameters and their JSON
* halves of that contract are pinned here: the exact parameters and their JSON
* encoding, and the docsync error code each way a NAS can disappoint maps to.
*
* Only `safeFetch` is mocked. `cappedFetch` runs for real against the fake
@@ -38,6 +38,7 @@ import {
normalizeBaseUrl,
normalizeSynoPath,
snapshotVersion,
SynologyDriveClient,
} from '../../../../src/nest/doc-sync/providers/synology-drive.client';
import type {
DocumentConnectionRef,
@@ -192,6 +193,7 @@ function callsTo(api: string, method?: string): Recorded[] {
function connection(overrides: Partial<DocumentConnectionRef> = {}): DocumentConnectionRef {
return {
connectionId: 3,
createdAt: '2026-09-01 08:00:00',
ownerId: 11,
baseUrl: BASE_URL,
secrets: { password: 'nas-secret' },
@@ -250,7 +252,7 @@ beforeEach(() => {
throw new Error('unstubbed safeFetch');
});
install();
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
});
// ── capabilities ─────────────────────────────────────────────────────────────
@@ -320,7 +322,7 @@ describe('SynologyDriveDocumentProvider: login', () => {
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized', detail: 'syno_code=400' } });
});
it('SYNO-PROVIDER-014: a rejected credential is not retried — that is what earns an auto-block', async () => {
it('SYNO-PROVIDER-014: a rejected credential is not retried, since that is what earns an auto-block', async () => {
route({ 'SYNO.API.Auth:login': fail(400) });
await provider.probe(connection());
const after = calls.length;
@@ -430,13 +432,20 @@ describe('SynologyDriveDocumentProvider: login', () => {
// ── device tokens ────────────────────────────────────────────────────────────
describe('SynologyDriveDocumentProvider: device tokens', () => {
/** DSM with two-factor on: a login needs a trusted device or a code, and a code earns a device. */
/** Codes the fake NAS has accepted. A TOTP code is good for one login, as on DSM. */
let spent = new Set<string>();
/** DSM with two-factor on: a login needs a trusted device or a fresh code, and a code earns a device. */
function twoFactorNas(refused: string[] = []): Handler {
return (call) => {
const { device_id: deviceId, otp_code: otpCode } = call.params;
if (deviceId && refused.includes(deviceId)) return fail(403);
if (deviceId) return ok({ sid: `SID-${deviceId}` });
if (otpCode) return ok({ sid: `SID-${otpCode}`, did: `DEVICE-${otpCode}` });
if (otpCode && spent.has(otpCode)) return fail(404);
if (otpCode) {
spent.add(otpCode);
return ok({ sid: `SID-${otpCode}`, did: `DEVICE-${otpCode}` });
}
return fail(403);
};
}
@@ -450,8 +459,42 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
calls = [];
}
/** Every write a saved connection made into its stored secrets. */
let writes: Array<[string, string | null]> = [];
/**
* A saved connection the way `DocSyncConfigService.toRef` hands one out: the
* secrets as they were stored when the ref was made, and a way to write back
* into `store`, which stands in for the connection's row.
*/
function saved(store: Record<string, string>, overrides: Partial<DocumentConnectionRef> = {}): DocumentConnectionRef {
return connection({
secrets: { ...store },
saveSecret: (key, value) => {
writes.push([key, value]);
if (value === null) delete store[key];
else store[key] = value;
},
...overrides,
});
}
/** A new process: nothing in memory, only what the connections stored. */
function restart(): void {
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
writes = [];
}
const picker = {
'SYNO.FileStation.List:list_share': ok({ shares: [] }),
'SYNO.FileStation.List:list': ok({ total: 0, files: [] }),
};
beforeEach(() => {
vi.useFakeTimers();
spent = new Set();
writes = [];
});
afterEach(() => {
@@ -499,13 +542,20 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
});
expireSessions();
expect(await provider.probe(first)).toMatchObject({ success: false, error: { code: 'unauthorized' } });
// The code in the form gets its turn in the same call; this one was spent
// long ago, so DSM turns it down as well.
expect(logins().map((call) => [call.params.device_id, call.params.otp_code])).toEqual([
['DEVICE-111111', undefined],
[undefined, '111111'],
]);
expect(await provider.probe(second)).toMatchObject({ success: true });
// Once the lockout has passed, the first connection offers the code in its
// form again instead of the token DSM refused.
// form again and never the token DSM refused.
vi.setSystemTime(Date.now() + 61 * 1000);
calls = [];
await provider.probe(first);
expect(logins()).toHaveLength(1);
expect(logins()[0].params).toMatchObject({ otp_code: '111111', enable_device_token: 'yes' });
expect(logins()[0].params.device_id).toBeUndefined();
});
@@ -549,6 +599,154 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(logins()[0].params.otp_code).toBeUndefined();
});
it('SYNO-PROVIDER-100: the token a code earns is stored with the connection once, tied to its NAS and account', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
await provider.listScopes(saved(store));
expect(store.device_token).toMatch(/^[0-9a-f]{64}:DEVICE-111111$/);
// Four FileStation calls, one change, one write.
expect(writes).toEqual([['device_token', store.device_token]]);
});
it('SYNO-PROVIDER-101: after a restart the stored token logs in, and nobody has to type a code', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
restart();
expect(await provider.listScopes(saved(store))).toMatchObject({ success: true });
expect(logins()).toHaveLength(1);
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(logins()[0].params.otp_code).toBeUndefined();
expect(writes).toEqual([]);
});
it('SYNO-PROVIDER-102: the stored token outlives a password edit and a spent or cleared code', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
store.password = 'rotated';
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params).toMatchObject({ device_id: 'DEVICE-111111', passwd: '"rotated"' });
expect(logins()[0].params.otp_code).toBeUndefined();
delete store.otp_code;
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
expect(store.device_token).toMatch(/:DEVICE-111111$/);
});
it('SYNO-PROVIDER-103: an unsaved form writes nothing, even when handed a place to write', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), 'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }) });
const saveSecret = vi.fn();
await provider.probe(connection({ connectionId: 0, ...withCode('111111'), saveSecret }));
expect(logins()[0].params.otp_code).toBe('111111');
expect(saveSecret).not.toHaveBeenCalled();
});
it('SYNO-PROVIDER-104: a token DSM refuses is dropped from storage too, and no error names it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
route({ 'SYNO.API.Auth:login': twoFactorNas(['DEVICE-111111']), ...picker });
restart();
const result = await provider.listScopes(saved(store));
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(store.device_token).toBeUndefined();
expect(JSON.stringify(result)).not.toContain('DEVICE-111111');
// The next process has nothing left to offer DSM but the code.
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBeUndefined();
});
it('SYNO-PROVIDER-105: a fresh code wins over a refused token in the same test, and the saved connection keeps it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), 'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
// TREK was removed from the account's trusted devices. After a restart the
// owner types a new code and tests the form, which merges in what is stored
// but has no way to write.
route({
'SYNO.API.Auth:login': twoFactorNas(['DEVICE-111111']),
'SYNO.FileStation.Info:get': ok({ hostname: 'nas' }),
...picker,
});
restart();
expect(await provider.probe(connection({ secrets: { ...store, otp_code: '222222' } }))).toMatchObject({ success: true });
expect(logins().map((call) => [call.params.device_id, call.params.otp_code])).toEqual([
['DEVICE-111111', undefined],
[undefined, '222222'],
]);
expect(store.device_token).toMatch(/:DEVICE-111111$/);
// Saved with that code: the connection's next call stores what the test earned.
store.otp_code = '222222';
await provider.listScopes(saved(store));
expect(store.device_token).toMatch(/:DEVICE-222222$/);
restart();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-222222');
});
it('SYNO-PROVIDER-106: a connection repointed at another account forgets its token, in memory and in storage', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
calls = [];
await provider.listScopes(saved(store, { settings: { username: 'someone-else', base_path: '/trek' } }));
expect(logins()[0].params.device_id).toBeUndefined();
expect(store.device_token).toBeUndefined();
// Pointed back, it has passed nobody's second factor there either.
expireSessions();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBeUndefined();
});
it('SYNO-PROVIDER-107: after a restart, a token stored for another NAS is dropped rather than offered there', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
restart();
await provider.listScopes(saved(store, { baseUrl: 'https://other-nas.example.org:5001' }));
expect(logins()[0].params.device_id).toBeUndefined();
expect(store.device_token).toBeUndefined();
});
it('SYNO-PROVIDER-108: a connection that a restore hands the same id is a stranger to the token under it', async () => {
route({ 'SYNO.API.Auth:login': twoFactorNas(), ...picker });
const store: Record<string, string> = { password: 'nas-secret', otp_code: '111111' };
await provider.listScopes(saved(store));
// A backup from before that connection is restored without a restart, and
// another trip's owner, who knows the password but has no authenticator,
// saves a connection to the same account. The id sequence went back with
// the database, so the new row gets the same id.
const reissued: Record<string, string> = { password: 'nas-secret' };
const stranger = await provider.listScopes(saved(reissued, { ownerId: 12, createdAt: '2026-09-02 09:30:00' }));
expect(stranger).toMatchObject({ success: false, error: { code: 'unauthorized' } });
expect(logins()[1].params.device_id).toBeUndefined();
expect(reissued.device_token).toBeUndefined();
// A later backup brings the first row back, and it logs in on its token as before.
expireSessions();
await provider.listScopes(saved(store));
expect(logins()[0].params.device_id).toBe('DEVICE-111111');
});
});
// ── scopes ───────────────────────────────────────────────────────────────────
@@ -672,7 +870,7 @@ describe('SynologyDriveDocumentProvider: list', () => {
size: 12,
mimeType: 'application/pdf',
// MD5 is the only digest FileStation computes, and it is not the sha256
// the core compares against — so there is nothing honest to put here.
// the core compares against, so there is nothing honest to put here.
contentHash: null,
remoteModifiedAt: new Date(1_700_000_500 * 1000).toISOString(),
isDeleted: false,
@@ -1031,7 +1229,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'unauthorized' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.FileStation.List:getinfo': fail(408) });
expect(await provider.resolveScope(connection(), scope())).toMatchObject({
@@ -1052,7 +1250,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
[117, 'forbidden'],
];
for (const [synoCode, code] of expected) {
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.FileStation.List:list': sequence(fail(synoCode), fail(synoCode)) });
const result = await provider.list(connection(), scope());
@@ -1107,7 +1305,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
it('SYNO-PROVIDER-088: the remaining HTTP statuses a NAS or its proxy answers', async () => {
for (const [status, code] of [[403, 'forbidden'], [404, 'not_found'], [413, 'too_large'], [429, 'rate_limited']] as const) {
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
calls = [];
route({ 'SYNO.API.Auth:login': reply({}, { status }) });
expect(await provider.probe(connection()), `http ${status}`).toMatchObject({
@@ -1133,7 +1331,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'quota_exceeded', status: 507 },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.Upload:upload': reply({}, { status: 500 }) });
expect(await provider.push(connection(), scope(), push())).toMatchObject({
success: false,
@@ -1154,21 +1352,21 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
error: { code: 'scope_missing' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.CreateFolder:create': ok({ folders: [] }) });
expect(await provider.createScope(connection(), 'kyoto')).toMatchObject({
success: false,
error: { code: 'provider_error' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({ 'SYNO.FileStation.Rename:rename': ok({ files: [] }) });
expect(await provider.rename(connection(), scope(), `${SCOPE}/a.pdf`, 'b.pdf')).toMatchObject({
success: false,
error: { code: 'provider_error' },
});
provider = new SynologyDriveDocumentProvider();
provider = new SynologyDriveDocumentProvider(new SynologyDriveClient());
route({
'SYNO.FileStation.Upload:upload': ok({}),
'SYNO.FileStation.List:getinfo': ok({ files: [entry(`${SCOPE}/itinerary.pdf`)] }),
@@ -1238,7 +1436,7 @@ describe('SynologyDriveDocumentProvider: how a NAS disappoints', () => {
// ── path handling ────────────────────────────────────────────────────────────
describe('synology-drive.client — paths and versions', () => {
describe('synology-drive.client: paths and versions', () => {
it('SYNO-CLIENT-090: normalises the spellings that mean the same folder', () => {
expect(normalizeSynoPath('/trek//japan-2026/')).toBe('/trek/japan-2026');
expect(normalizeSynoPath(' /trek/./japan-2026 ')).toBe('/trek/japan-2026');
@@ -69,6 +69,7 @@ const OC_ROOT = `/dav/spaces/${OC_DRIVE}`;
const ncConn: DocumentConnectionRef = {
connectionId: 7,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: NC_ORIGIN,
secrets: { app_password: 'app-pw' },
@@ -78,6 +79,7 @@ const ncConn: DocumentConnectionRef = {
const ocConn: DocumentConnectionRef = {
connectionId: 8,
createdAt: '2026-09-01 08:00:00',
ownerId: 3,
baseUrl: OC_ORIGIN,
secrets: { app_token: 'app-token' },