mirror of
https://github.com/ckelsoe/obsidian-plaud-importer.git
synced 2026-07-22 07:49:02 +00:00
The long-lived user token (0.31.0) made the 24h silent-refresh machinery dead weight: it could never fire against the stored token, and running it would only mint a 24h WT and reinstate daily expiry. Delete it end to end: plaud-refresh.ts, plaud-refresh-net.ts, their tests, the proactive timer, retry backoff, reactive refresh, the 'Refresh session now' command, and the keepSessionAlive setting (a stale key in existing data.json is scrubbed at load). Pause + Reconnect and both sign-in flows stay; they are the roughly yearly re-auth path. The rebuild spec lives in dev-docs/plaud-importer/2026-07-17-auth-mechanisms-reference.md (workspace). Reconnect routing (SSO vs email) used to infer the account type from the stored WRT that this release deletes. Capture now records how the session was obtained (settings.signInMethod: the email window sets 'window', storeAccessToken sets 'browser' for paste and deep-link) and Reconnect routes off that; pre-0.32.0 sessions with no recorded method fall back to peeking at the legacy WRT secret, which only email-window sign-ins ever stored. Manually linking a different secret in settings clears the recorded method so it cannot misdescribe an unknown credential. The decision is a pure function (reconnect-routing.ts) with unit tests, including the legacy fallback and reader-failure cases. Verified by a four-agent adversarial pass (orphans, spec compliance, behavior walks, prose drift) plus Codex review; all findings addressed. 998 tests green; strict unused-symbol tsc sweep clean.
This commit is contained in:
parent
d5876e0e12
commit
cad39f4a41
15 changed files with 169 additions and 1671 deletions
|
|
@ -4,6 +4,14 @@ All notable changes to Plaud Importer will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Removed
|
||||
|
||||
- **The daily background session renewal machinery is gone.** Since 0.31.0 your sign-in captures Plaud's long-lived account token, which stays valid for months and needs no renewal, so the old 24-hour refresh subsystem (the background timers, the retry backoff, the "Refresh session now" command, and the "Keep the session alive automatically" setting) had nothing left to do and has been removed. When your session eventually lapses, the same one-click Reconnect prompt as before signs you back in. The "Test connection" button in settings remains the way to check session health.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reconnect now remembers how you signed in.** The prompt reopens the email sign-in window or the browser flow based on which one captured your current session, instead of inferring it from leftover renewal data. Sessions signed in before this version keep routing correctly via the old signal.
|
||||
|
||||
## [0.31.0] - 2026-07-18
|
||||
|
||||
### Changed
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
import { readJwtPayloadClaim } from '../plaud-refresh';
|
||||
import {
|
||||
extractWorkspaceId,
|
||||
normalizeTrustedOrigin,
|
||||
parseWorkspaceTokenResponse,
|
||||
performNetRefresh,
|
||||
readRegionRedirect,
|
||||
type SessionPost,
|
||||
} from '../plaud-refresh-net';
|
||||
|
||||
// Build a minimal unsigned JWT with the given payload claims. The helpers only
|
||||
// read unverified payload claims, so an unsigned token with a dummy signature is
|
||||
// a faithful fixture.
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const b64url = (obj: unknown): string =>
|
||||
Buffer.from(JSON.stringify(obj))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
return `${b64url({ alg: 'HS256', typ: 'WT' })}.${b64url(payload)}.sig`;
|
||||
}
|
||||
|
||||
const WID = 'ws_clF1vOqcHS';
|
||||
const STORED_WT = makeJwt({ wid: WID, client_id: 'web', sub: 'x' });
|
||||
const FRESH_WT = makeJwt({ wid: WID, sub: 'x', exp: 9_999_999_999 });
|
||||
|
||||
describe('readJwtPayloadClaim', () => {
|
||||
it('reads a string payload claim', () => {
|
||||
expect(readJwtPayloadClaim(STORED_WT, 'wid')).toBe(WID);
|
||||
expect(readJwtPayloadClaim(STORED_WT, 'client_id')).toBe('web');
|
||||
expect(readJwtPayloadClaim(`bearer ${STORED_WT}`, 'wid')).toBe(WID);
|
||||
});
|
||||
it('returns null for an absent claim, a non-string claim, or a non-JWT', () => {
|
||||
expect(readJwtPayloadClaim(STORED_WT, 'nope')).toBeNull();
|
||||
expect(readJwtPayloadClaim(makeJwt({ wid: 5 }), 'wid')).toBeNull();
|
||||
expect(readJwtPayloadClaim('not-a-token', 'wid')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractWorkspaceId', () => {
|
||||
it('returns the wid claim when it looks like a workspace id', () => {
|
||||
expect(extractWorkspaceId(STORED_WT)).toBe(WID);
|
||||
});
|
||||
it('returns null when wid is missing or not a ws_ id', () => {
|
||||
expect(extractWorkspaceId(makeJwt({ sub: 'x' }))).toBeNull();
|
||||
expect(extractWorkspaceId(makeJwt({ wid: 'mem_x' }))).toBeNull();
|
||||
expect(extractWorkspaceId('not-a-token')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTrustedOrigin', () => {
|
||||
it('returns the origin (no path) for a trusted plaud https host', () => {
|
||||
expect(normalizeTrustedOrigin('https://api.plaud.ai/x?y=1')).toBe(
|
||||
'https://api.plaud.ai',
|
||||
);
|
||||
expect(normalizeTrustedOrigin('https://api-euc1.plaud.ai')).toBe(
|
||||
'https://api-euc1.plaud.ai',
|
||||
);
|
||||
});
|
||||
it('rejects non-https, non-plaud, or malformed urls', () => {
|
||||
expect(normalizeTrustedOrigin('http://api.plaud.ai')).toBeNull();
|
||||
expect(normalizeTrustedOrigin('https://evil.com')).toBeNull();
|
||||
expect(normalizeTrustedOrigin('https://plaud.ai.evil.com')).toBeNull();
|
||||
expect(normalizeTrustedOrigin('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRegionRedirect', () => {
|
||||
it('returns the origin for a -302 pointing at a trusted plaud host', () => {
|
||||
expect(
|
||||
readRegionRedirect({
|
||||
status: -302,
|
||||
data: { domains: { api: 'https://api-euc1.plaud.ai/x?y=1' } },
|
||||
}),
|
||||
).toBe('https://api-euc1.plaud.ai');
|
||||
});
|
||||
it('returns null for non-redirect status, non-plaud host, or non-https', () => {
|
||||
expect(readRegionRedirect({ status: 0 })).toBeNull();
|
||||
expect(
|
||||
readRegionRedirect({ status: -302, data: { domains: { api: 'https://evil.com' } } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readRegionRedirect({ status: -302, data: { domains: { api: 'http://api.plaud.ai' } } }),
|
||||
).toBeNull();
|
||||
expect(readRegionRedirect({ status: -302, data: { domains: {} } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWorkspaceTokenResponse', () => {
|
||||
it('extracts the workspace token and rotated refresh token', () => {
|
||||
expect(
|
||||
parseWorkspaceTokenResponse({
|
||||
status: 0,
|
||||
data: { workspace_token: 'eyJ.wt.sig', refresh_token: 'urt-value' },
|
||||
}),
|
||||
).toEqual({ token: 'eyJ.wt.sig', refreshToken: 'urt-value' });
|
||||
});
|
||||
it('treats an absent refresh token as null', () => {
|
||||
expect(
|
||||
parseWorkspaceTokenResponse({ status: 0, data: { workspace_token: 'eyJ.wt.sig' } }),
|
||||
).toEqual({ token: 'eyJ.wt.sig', refreshToken: null });
|
||||
});
|
||||
it('returns null on non-success status or a missing token', () => {
|
||||
expect(
|
||||
parseWorkspaceTokenResponse({ status: -1, data: { workspace_token: 'eyJ.wt.sig' } }),
|
||||
).toBeNull();
|
||||
expect(parseWorkspaceTokenResponse({ status: 0, data: {} })).toBeNull();
|
||||
expect(parseWorkspaceTokenResponse({ status: 0, data: { workspace_token: '' } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('performNetRefresh', () => {
|
||||
const okRefresh = JSON.stringify({ status: 0, data: {} });
|
||||
const okMint = JSON.stringify({
|
||||
status: 0,
|
||||
data: { workspace_token: FRESH_WT, refresh_token: 'new-urt' },
|
||||
});
|
||||
|
||||
function recordingPost(
|
||||
responses: Array<{ status: number; text: string }>,
|
||||
): { post: SessionPost; calls: Array<{ url: string; body: string }> } {
|
||||
const calls: Array<{ url: string; body: string }> = [];
|
||||
let i = 0;
|
||||
const post: SessionPost = (url, body) => {
|
||||
calls.push({ url, body });
|
||||
return Promise.resolve(responses[i++]);
|
||||
};
|
||||
return { post, calls };
|
||||
}
|
||||
|
||||
it('runs refresh then mint and returns the fresh WT', async () => {
|
||||
const { post, calls } = recordingPost([
|
||||
{ status: 200, text: okRefresh },
|
||||
{ status: 200, text: okMint },
|
||||
]);
|
||||
const result = await performNetRefresh({
|
||||
currentToken: STORED_WT,
|
||||
baseUrl: 'https://api.plaud.ai',
|
||||
post,
|
||||
});
|
||||
expect(result).toEqual({ token: FRESH_WT, refreshToken: 'new-urt', apiBaseUrl: null });
|
||||
expect(calls[0]).toEqual({
|
||||
url: 'https://api.plaud.ai/auth/refresh-user-token',
|
||||
body: '{}',
|
||||
});
|
||||
expect(calls[1]).toEqual({
|
||||
url: `https://api.plaud.ai/user-app/auth/workspace/token/${WID}`,
|
||||
body: '{}',
|
||||
});
|
||||
});
|
||||
|
||||
it('follows a region redirect once and mints against the new host', async () => {
|
||||
const redirect = JSON.stringify({
|
||||
status: -302,
|
||||
data: { domains: { api: 'https://api-euc1.plaud.ai' } },
|
||||
});
|
||||
const { post, calls } = recordingPost([
|
||||
{ status: 200, text: redirect },
|
||||
{ status: 200, text: okRefresh },
|
||||
{ status: 200, text: okMint },
|
||||
]);
|
||||
const result = await performNetRefresh({
|
||||
currentToken: STORED_WT,
|
||||
baseUrl: 'https://api.plaud.ai',
|
||||
post,
|
||||
});
|
||||
expect(result?.apiBaseUrl).toBe('https://api-euc1.plaud.ai');
|
||||
expect(calls[2].url).toBe(
|
||||
`https://api-euc1.plaud.ai/user-app/auth/workspace/token/${WID}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null (no call) when the stored token has no wid', async () => {
|
||||
const { post, calls } = recordingPost([]);
|
||||
const result = await performNetRefresh({
|
||||
currentToken: makeJwt({ sub: 'x' }),
|
||||
baseUrl: 'https://api.plaud.ai',
|
||||
post,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns null (no call) when the stored base host is not a trusted plaud host', async () => {
|
||||
const { post, calls } = recordingPost([]);
|
||||
const result = await performNetRefresh({
|
||||
currentToken: STORED_WT,
|
||||
baseUrl: 'https://evil.com',
|
||||
post,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns null when refresh is not JSON, fails, or the mint fails', async () => {
|
||||
const bad = { currentToken: STORED_WT, baseUrl: 'https://api.plaud.ai' };
|
||||
await expect(
|
||||
performNetRefresh({ ...bad, post: recordingPost([{ status: 200, text: 'nope' }]).post }),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
performNetRefresh({
|
||||
...bad,
|
||||
post: recordingPost([{ status: 200, text: JSON.stringify({ status: -1 }) }]).post,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
performNetRefresh({
|
||||
...bad,
|
||||
post: recordingPost([
|
||||
{ status: 200, text: okRefresh },
|
||||
{ status: 200, text: JSON.stringify({ status: -1 }) },
|
||||
]).post,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null (never throws) when the transport throws', async () => {
|
||||
const post: SessionPost = () => Promise.reject(new Error('network down'));
|
||||
await expect(
|
||||
performNetRefresh({ currentToken: STORED_WT, baseUrl: 'https://api.plaud.ai', post }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('redacts a JWT-shaped string from a logged error body', async () => {
|
||||
const jwt = 'aaaaaaaa.bbbbbbbb.cccccccc';
|
||||
const { post } = recordingPost([{ status: 200, text: `oops ${jwt} boom` }]);
|
||||
const logged: Array<{ body?: string }> = [];
|
||||
const result = await performNetRefresh({
|
||||
currentToken: STORED_WT,
|
||||
baseUrl: 'https://api.plaud.ai',
|
||||
post,
|
||||
log: (_message, payload) => logged.push(payload as { body?: string }),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
const bodies = logged
|
||||
.map((p) => p?.body)
|
||||
.filter((b): b is string => typeof b === 'string')
|
||||
.join(' ');
|
||||
expect(bodies).toContain('[redacted-token]');
|
||||
expect(bodies).not.toContain(jwt);
|
||||
});
|
||||
|
||||
it('never throws when the injected log sink throws', async () => {
|
||||
const post: SessionPost = () => Promise.reject(new Error('down'));
|
||||
await expect(
|
||||
performNetRefresh({
|
||||
currentToken: STORED_WT,
|
||||
baseUrl: 'https://api.plaud.ai',
|
||||
post,
|
||||
log: () => {
|
||||
throw new Error('logger boom');
|
||||
},
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import {
|
||||
computeRefreshDelay,
|
||||
decodeJwtExpMs,
|
||||
isFreshAccessToken,
|
||||
isRefreshDue,
|
||||
jwtTyp,
|
||||
shouldStopProactiveRefresh,
|
||||
REFRESH_LEAD_MS,
|
||||
REFRESH_MAX_DELAY_MS,
|
||||
REFRESH_MIN_DELAY_MS,
|
||||
REFRESH_OPAQUE_FALLBACK_MS,
|
||||
REFRESH_RETRY_BACKOFF_MS,
|
||||
} from '../plaud-refresh';
|
||||
|
||||
// Build a minimal unsigned JWT with the given header `typ` and payload `exp`
|
||||
// (seconds). The helpers only read unverified claims (typ, exp), so an unsigned
|
||||
// token with a dummy signature segment is a faithful fixture.
|
||||
function makeJwt(typ: string, expSeconds: number | null): string {
|
||||
const b64url = (obj: unknown): string =>
|
||||
Buffer.from(JSON.stringify(obj))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
const header = b64url({ alg: 'HS256', typ });
|
||||
const payload = b64url(expSeconds === null ? { sub: 'x' } : { sub: 'x', exp: expSeconds });
|
||||
return `${header}.${payload}.sig`;
|
||||
}
|
||||
|
||||
const NOW = 1_780_000_000_000; // fixed clock in ms
|
||||
const FUTURE_EXP_S = Math.floor(NOW / 1000) + 24 * 3600; // +24h
|
||||
const PAST_EXP_S = Math.floor(NOW / 1000) - 60; // 1 min ago
|
||||
|
||||
const FRESH_WT = makeJwt('WT', FUTURE_EXP_S);
|
||||
const EXPIRED_WT = makeJwt('WT', PAST_EXP_S);
|
||||
const FRESH_UT = makeJwt('UT', FUTURE_EXP_S); // the user token the refresh endpoint returns
|
||||
const FRESH_WRT = makeJwt('WRT', FUTURE_EXP_S);
|
||||
// Mirrors the token that produced the window storm: header typ JWT (not WT)
|
||||
// with an exp ~137 days out, whose exp-driven delay overflows setTimeout's
|
||||
// 32-bit signed range (2,147,483,647 ms) and used to fire immediately.
|
||||
const LONG_EXP_S = Math.floor(NOW / 1000) + 137 * 24 * 3600;
|
||||
const LONG_LIVED_JWT = makeJwt('JWT', LONG_EXP_S);
|
||||
const NO_EXP_WT = makeJwt('WT', null);
|
||||
|
||||
describe('jwtTyp / decodeJwtExpMs', () => {
|
||||
it('reads the header typ', () => {
|
||||
expect(jwtTyp(FRESH_WT)).toBe('WT');
|
||||
expect(jwtTyp(FRESH_UT)).toBe('UT');
|
||||
expect(jwtTyp(FRESH_WRT)).toBe('WRT');
|
||||
expect(jwtTyp(`bearer ${FRESH_WT}`)).toBe('WT');
|
||||
});
|
||||
|
||||
it('returns null for a non-JWT', () => {
|
||||
expect(jwtTyp('not-a-token')).toBeNull();
|
||||
expect(decodeJwtExpMs('not-a-token')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads exp as milliseconds', () => {
|
||||
expect(decodeJwtExpMs(FRESH_WT)).toBe(FUTURE_EXP_S * 1000);
|
||||
});
|
||||
|
||||
it('returns null when exp is absent', () => {
|
||||
expect(decodeJwtExpMs(makeJwt('WT', null))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFreshAccessToken', () => {
|
||||
it('accepts a WT with a future exp', () => {
|
||||
expect(isFreshAccessToken(FRESH_WT, NOW)).toBe(true);
|
||||
});
|
||||
it('rejects an expired WT', () => {
|
||||
expect(isFreshAccessToken(EXPIRED_WT, NOW)).toBe(false);
|
||||
});
|
||||
it('rejects a UT (the refresh-endpoint token type) even if unexpired', () => {
|
||||
expect(isFreshAccessToken(FRESH_UT, NOW)).toBe(false);
|
||||
});
|
||||
it('rejects a WRT even if unexpired', () => {
|
||||
expect(isFreshAccessToken(FRESH_WRT, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRefreshDelay', () => {
|
||||
it('schedules a normal 24h token at exp minus the lead, unclamped', () => {
|
||||
const delay = computeRefreshDelay(FRESH_WT, 0, NOW);
|
||||
expect(delay).toBe(FUTURE_EXP_S * 1000 - REFRESH_LEAD_MS - NOW);
|
||||
expect(delay).toBeLessThan(REFRESH_MAX_DELAY_MS);
|
||||
});
|
||||
|
||||
it('clamps a long-lived token under the 32-bit setTimeout ceiling', () => {
|
||||
// Prove the fixture matters: unclamped, this delay overflows setTimeout.
|
||||
const unclamped = LONG_EXP_S * 1000 - REFRESH_LEAD_MS - NOW;
|
||||
expect(unclamped).toBeGreaterThan(2_147_483_647);
|
||||
const delay = computeRefreshDelay(LONG_LIVED_JWT, 0, NOW);
|
||||
expect(delay).toBe(REFRESH_MAX_DELAY_MS);
|
||||
expect(delay).toBeLessThan(2_147_483_647);
|
||||
});
|
||||
|
||||
it('floors a past-due token at the minimum delay', () => {
|
||||
expect(computeRefreshDelay(EXPIRED_WT, 0, NOW)).toBe(REFRESH_MIN_DELAY_MS);
|
||||
});
|
||||
|
||||
it('polls hourly for a token with no decodable exp', () => {
|
||||
expect(computeRefreshDelay(NO_EXP_WT, 0, NOW)).toBe(REFRESH_OPAQUE_FALLBACK_MS);
|
||||
expect(computeRefreshDelay('not-a-token', 0, NOW)).toBe(REFRESH_OPAQUE_FALLBACK_MS);
|
||||
});
|
||||
|
||||
it('uses the failure backoff on a streak, clamped to the last entry', () => {
|
||||
expect(computeRefreshDelay(FRESH_WT, 1, NOW)).toBe(REFRESH_RETRY_BACKOFF_MS[0]);
|
||||
expect(computeRefreshDelay(FRESH_WT, 4, NOW)).toBe(
|
||||
REFRESH_RETRY_BACKOFF_MS[REFRESH_RETRY_BACKOFF_MS.length - 1],
|
||||
);
|
||||
expect(computeRefreshDelay(FRESH_WT, 9, NOW)).toBe(
|
||||
REFRESH_RETRY_BACKOFF_MS[REFRESH_RETRY_BACKOFF_MS.length - 1],
|
||||
);
|
||||
});
|
||||
|
||||
it('backoff ignores the token exp (still inside the clamp)', () => {
|
||||
expect(computeRefreshDelay(LONG_LIVED_JWT, 1, NOW)).toBe(REFRESH_RETRY_BACKOFF_MS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRefreshDue', () => {
|
||||
it('is not due for a 24h token well outside the lead window', () => {
|
||||
expect(isRefreshDue(FRESH_WT, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('is not due for the long-lived non-WT token that caused the storm', () => {
|
||||
// The skip-if-fresh guard: an early (clamped) timer fire on this token
|
||||
// must be a no-op re-arm, never a refresh attempt.
|
||||
expect(isRefreshDue(LONG_LIVED_JWT, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('is due once exp is inside the lead window', () => {
|
||||
const soon = makeJwt('WT', Math.floor(NOW / 1000) + 3 * 60); // +3 min < 5 min lead
|
||||
expect(isRefreshDue(soon, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('is due for an expired token', () => {
|
||||
expect(isRefreshDue(EXPIRED_WT, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('is due for a token with no decodable exp (matches the hourly poll)', () => {
|
||||
expect(isRefreshDue(NO_EXP_WT, NOW)).toBe(true);
|
||||
expect(isRefreshDue('not-a-token', NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldStopProactiveRefresh', () => {
|
||||
const LEN = REFRESH_RETRY_BACKOFF_MS.length;
|
||||
// An SSO/paste session has no stored WRT; an email session has one.
|
||||
const NO_WRT = false;
|
||||
const HAS_WRT = true;
|
||||
|
||||
it('keeps retrying while the streak is below the backoff ladder length', () => {
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, 0, NO_WRT, NOW)).toBe(false);
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, LEN - 1, NO_WRT, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up on an exhausted, due SSO session (no stored WRT)', () => {
|
||||
// The SSO case: no partition cookies to refresh from, so re-arming would
|
||||
// just hammer the endpoint. Stop and wait for a manual reconnect.
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, LEN, NO_WRT, NOW)).toBe(true);
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, LEN + 5, NO_WRT, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('never gives up on a session with a stored WRT, even when exhausted and due', () => {
|
||||
// An email/password session keeps its refreshable partition; an exhausted
|
||||
// streak there may be a transient failure, so it must keep retrying and
|
||||
// recover on its own rather than be abandoned.
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, LEN, HAS_WRT, NOW)).toBe(false);
|
||||
expect(shouldStopProactiveRefresh(EXPIRED_WT, LEN + 5, HAS_WRT, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('never abandons a token that still has ample life, even at a high streak', () => {
|
||||
// Defensive: a successful sign-in resets the streak to 0, so this pairing
|
||||
// is not expected in practice, but the predicate must not give up on a
|
||||
// fresh token if it ever occurs.
|
||||
expect(shouldStopProactiveRefresh(FRESH_WT, LEN + 5, NO_WRT, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an opaque token as due, so a persistently failing SSO one also gives up', () => {
|
||||
expect(shouldStopProactiveRefresh(NO_EXP_WT, LEN, NO_WRT, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,6 @@ import {
|
|||
isUsableUserToken,
|
||||
readTokenClientId,
|
||||
} from '../plaud-token';
|
||||
import { jwtTyp } from '../plaud-refresh';
|
||||
|
||||
// Build a minimal unsigned JWT from a header and payload object. The helpers
|
||||
// only read unverified claims, so an unsigned token with a dummy signature
|
||||
|
|
@ -179,17 +178,3 @@ describe('decodeJwtPayload', () => {
|
|||
expect(decodeJwtPayload('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The refresh-neutralization predicate: main.ts gates every refresh path on
|
||||
// jwtTyp(token) === 'WT'. A long-lived user token must read as non-'WT' (so
|
||||
// refresh never runs and never clobbers it with a 24h WT), while a real
|
||||
// workspace token still reads as 'WT'.
|
||||
describe('refresh neutralization predicate (jwtTyp)', () => {
|
||||
it('reads the long-lived user token as a non-WT typ', () => {
|
||||
expect(jwtTyp(LONG_LIVED_TOKEN)).not.toBe('WT');
|
||||
});
|
||||
|
||||
it('reads the workspace token as WT', () => {
|
||||
expect(jwtTyp(WORKSPACE_TOKEN)).toBe('WT');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
__tests__/reconnect-routing.test.ts
Normal file
45
__tests__/reconnect-routing.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { preferWindowForReconnect } from '../reconnect-routing';
|
||||
|
||||
// Reconnect must reopen the surface that can actually re-auth the account:
|
||||
// the embedded window for email sessions, the browser flow for Google/Apple.
|
||||
// Pre-0.32.0 sessions have no recorded method; for those the stored legacy
|
||||
// refresh token (only ever written by the embedded email window) is the
|
||||
// signal.
|
||||
describe('preferWindowForReconnect', () => {
|
||||
const noLegacy = () => null;
|
||||
|
||||
it('routes a recorded window session to the embedded window', () => {
|
||||
expect(preferWindowForReconnect('window', noLegacy)).toBe(true);
|
||||
// The recorded method wins even if a stale legacy token disagrees.
|
||||
expect(preferWindowForReconnect('window', () => '')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes a recorded browser session to the browser flow', () => {
|
||||
expect(preferWindowForReconnect('browser', noLegacy)).toBe(false);
|
||||
// The recorded method wins even if a legacy WRT is still stored.
|
||||
expect(preferWindowForReconnect('browser', () => 'legacy-wrt')).toBe(false);
|
||||
});
|
||||
|
||||
describe('legacy sessions (no recorded method)', () => {
|
||||
it('routes to the window when a legacy refresh token is stored', () => {
|
||||
expect(preferWindowForReconnect('', () => 'legacy-wrt')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes to the browser when no legacy token exists', () => {
|
||||
expect(preferWindowForReconnect('', noLegacy)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a blank or whitespace legacy value as absent', () => {
|
||||
expect(preferWindowForReconnect('', () => '')).toBe(false);
|
||||
expect(preferWindowForReconnect('', () => ' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('routes to the browser when the legacy reader throws', () => {
|
||||
expect(
|
||||
preferWindowForReconnect('', () => {
|
||||
throw new Error('secret storage unavailable');
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -174,7 +174,7 @@ export function selectAutoSyncCandidates(
|
|||
|
||||
/**
|
||||
* In-memory auto-sync run state. `paused` short-circuits ticks after an auth
|
||||
* failure (a rejected/expired 24h token) until the user re-auths;
|
||||
* failure (a rejected or expired session token) until the user re-auths;
|
||||
* `consecutiveTransientFailures` is available for optional backoff.
|
||||
*/
|
||||
export interface AutoSyncState {
|
||||
|
|
|
|||
|
|
@ -2166,7 +2166,7 @@ export class ImportModal extends Modal {
|
|||
}
|
||||
|
||||
/**
|
||||
* The token was rejected mid-batch (an expired/revoked ~24h session). Show
|
||||
* The token was rejected mid-batch (an expired or revoked session). Show
|
||||
* how much completed and surface A1's inline re-auth (the Sign-in CTA and the
|
||||
* SSO expander). A successful sign-in does NOT auto-resume; it advances to
|
||||
* renderResumeReady so the user explicitly chooses to resume the unprocessed
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ export interface ImportRunDeps {
|
|||
* - 'aborted': `observer.shouldAbort()` was truthy (modal closed mid-run).
|
||||
* - 'cancelled': the user cancelled a per-file duplicate prompt.
|
||||
* - 'auth-failed': a re-authable auth failure happened mid-batch, i.e. one
|
||||
* where `categoryAllowsReauth` is true (a rejected/expired ~24h token, or
|
||||
* where `categoryAllowsReauth` is true (a rejected/expired session token, or
|
||||
* a token that went missing). The loop stops on the FIRST such failure
|
||||
* instead of failing every remaining recording with the same error; the
|
||||
* modal offers inline re-auth + resume on this stop.
|
||||
|
|
@ -427,7 +427,7 @@ export async function runImport(deps: ImportRunDeps): Promise<ImportRunOutcome>
|
|||
);
|
||||
const classification = classifyError(err);
|
||||
|
||||
// A rejected token mid-batch (an expired or revoked ~24h session) is
|
||||
// A rejected token mid-batch (an expired or revoked session) is
|
||||
// a batch-level terminal condition, not a per-recording failure:
|
||||
// continuing the loop would fail every remaining recording with the
|
||||
// same auth error and still report stop:'completed'. Stop on the
|
||||
|
|
|
|||
550
main.ts
550
main.ts
|
|
@ -23,19 +23,9 @@ import { ImportModal, classifyError } from "./import-modal";
|
|||
import { BufferedDebugLogger } from "./debug-logger";
|
||||
import {
|
||||
clearPlaudLoginSession,
|
||||
isAccessToken,
|
||||
openPlaudLogin,
|
||||
} from "./plaud-login";
|
||||
import { isUsableUserToken } from "./plaud-token";
|
||||
import {
|
||||
computeRefreshDelay,
|
||||
isFreshAccessToken,
|
||||
isRefreshDue,
|
||||
jwtTyp,
|
||||
shouldStopProactiveRefresh,
|
||||
REFRESH_RETRY_BACKOFF_MS,
|
||||
} from "./plaud-refresh";
|
||||
import { buildPartitionPost, performNetRefresh } from "./plaud-refresh-net";
|
||||
import {
|
||||
NoteWriter,
|
||||
DEFAULT_NOTE_NAME_TEMPLATE,
|
||||
|
|
@ -85,22 +75,20 @@ import {
|
|||
INITIAL_AUTO_SYNC_STATE,
|
||||
type AutoSyncState,
|
||||
} from "./auto-sync";
|
||||
import {
|
||||
preferWindowForReconnect,
|
||||
type SignInMethod,
|
||||
} from "./reconnect-routing";
|
||||
|
||||
// Stable SecretStorage id for a token captured by the in-app sign-in flow.
|
||||
// Re-running sign-in overwrites it, mirroring "replace my token".
|
||||
const CAPTURED_SECRET_ID = "plaud-importer-token";
|
||||
|
||||
// JWT header `typ` of the 24h workspace token. The silent refresh mints one of
|
||||
// these, so it must never run against a stored long-lived user token (a
|
||||
// different `typ`): a refresh would overwrite the ~300-day credential with a
|
||||
// 24h WT and silently reinstate daily expiry. The refresh paths gate on this.
|
||||
const WORKSPACE_TOKEN_TYP = "WT";
|
||||
|
||||
// Stable SecretStorage id for the rotating refresh token (typ WRT) captured at
|
||||
// sign-in and rotated on each silent refresh. A credential, so it lives in
|
||||
// SecretStorage, never data.json. Lowercase-alphanumeric-with-dashes so
|
||||
// setSecret accepts it.
|
||||
const CAPTURED_REFRESH_SECRET_ID = "plaud-importer-refresh-token";
|
||||
// Legacy secret id for the paired refresh token (typ WRT) that pre-0.32.0
|
||||
// email sign-ins stored. The refresh subsystem is gone; the secret is only
|
||||
// ever blanked (sign-out, fresh captures) and read once as the migration
|
||||
// signal for routing Reconnect (a stored WRT means an email-window session).
|
||||
const LEGACY_REFRESH_SECRET_ID = "plaud-importer-refresh-token";
|
||||
|
||||
// Plaud web app, opened in the system browser for the browser-based sign-in
|
||||
// flow (where Google/Apple SSO work, unlike an embedded webview).
|
||||
|
|
@ -286,12 +274,6 @@ const DUPLICATE_HANDLING_NAME = "Duplicate handling for manual imports";
|
|||
const DUPLICATE_HANDLING_DESC =
|
||||
"Controls what happens when you run Import recent recordings and a note for the recording already exists. Skip keeps your copy, overwrite replaces it, and ask each time prompts you for each one. Automatic sync ignores this and never prompts.";
|
||||
|
||||
// Description for the silent-refresh toggle (Release B). Held in a const so the
|
||||
// declarative (1.13+) and imperative (1.12) settings paths show identical text
|
||||
// and the sentence-case lint inspects one literal.
|
||||
const KEEP_SESSION_ALIVE_DESC =
|
||||
"On by default. Kept for older short-lived sessions: it renews such a session in the background before its roughly 24-hour token expires. Sessions captured by the current sign-in use Plaud's long-lived account token (good for months), which needs no renewal, so this setting does nothing for them. Renewal only ever replaces the stored token after it succeeds, so turning this off, or a failed renewal, simply falls back to the reconnect prompt. Use the 'Refresh session now' command to check session health at any time.";
|
||||
|
||||
// [label, template] preset buttons. All dashes, so every preset is filename-safe.
|
||||
// ISO/US/EU cover the common date orders; putting the date after {{title}} (the
|
||||
// "date at the end" example in the reference) is left to the user to type.
|
||||
|
|
@ -516,18 +498,18 @@ interface PlaudImporterSettings {
|
|||
// Auto-sync (issue #5): a background timer that imports new recordings and
|
||||
// re-imports (overwrites) changed ones on an interval, using the saved
|
||||
// default import options. OFF by default: the connection is reverse-
|
||||
// engineered, the ~24h token forces periodic re-auth, and a detected change
|
||||
// OVERWRITES the note and its artifacts (Plaud wins over local edits).
|
||||
// engineered, an expired session pauses it until a re-auth, and a detected
|
||||
// change OVERWRITES the note and its artifacts (Plaud wins over local
|
||||
// edits).
|
||||
autoSyncEnabled: boolean;
|
||||
// Minutes between auto-sync ticks. Coerced to [15, 1440]; default 60.
|
||||
autoSyncIntervalMinutes: number;
|
||||
// Silent session refresh (Release B). Only meaningful for legacy short-lived
|
||||
// (~24h WT) sessions: it renews them in the background before expiry. The
|
||||
// current sign-in stores the long-lived user token, for which refresh is
|
||||
// neutralized (running it would reinstate daily expiry). ON by default
|
||||
// because it is fail-safe: the stored token is only ever replaced after a
|
||||
// refresh succeeds; a failure falls back to the reconnect prompt.
|
||||
keepSessionAlive: boolean;
|
||||
// How the current session was captured: the embedded email window or the
|
||||
// browser/bookmarklet flow. Routes Reconnect to the sign-in method that can
|
||||
// work for the account (Google/Apple cannot complete in the embedded
|
||||
// window). Empty for sessions from before 0.32.0; those fall back to the
|
||||
// legacy stored-WRT heuristic in reconnectPrefersWindow.
|
||||
signInMethod: SignInMethod;
|
||||
// Schema version for one-time settings migrations. Absent (pre-0.21.0) reads
|
||||
// as 0. Version 1 rewrote the subfolder/note-name date templates from the old
|
||||
// bespoke lowercase tokens to real Moment tokens (issue #30). Bumped only when
|
||||
|
|
@ -581,7 +563,7 @@ const DEFAULT_SETTINGS: PlaudImporterSettings = {
|
|||
autoUpdatePlaudTitle: false,
|
||||
autoSyncEnabled: false,
|
||||
autoSyncIntervalMinutes: 60,
|
||||
keepSessionAlive: true,
|
||||
signInMethod: "",
|
||||
settingsVersion: 1,
|
||||
};
|
||||
|
||||
|
|
@ -662,15 +644,6 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
private autoSyncIntervalId: number | undefined;
|
||||
private autoSyncFirstRunTimeoutId: number | undefined;
|
||||
private autoSyncState: AutoSyncState = INITIAL_AUTO_SYNC_STATE;
|
||||
// Silent session refresh (Release B). The proactive timer fires ~5 min before
|
||||
// the access token expires; onunload and reconcileTokenRefresh clear it.
|
||||
private refreshTimeoutId: number | undefined;
|
||||
// Re-entrancy guard so a scheduled, reactive, and manual refresh can never
|
||||
// overlap and clobber each other's token write.
|
||||
private refreshInFlight = false;
|
||||
// Consecutive proactive-refresh failures, used only to back off the retry
|
||||
// cadence. Reset to 0 on any success or a fresh interactive sign-in.
|
||||
private refreshFailureStreak = 0;
|
||||
// Single-flight coordination between the manual modal and background ticks.
|
||||
// Two independent flags rather than one shared boolean, so the modal's
|
||||
// open/close never clobbers a tick's in-flight state and vice versa. An
|
||||
|
|
@ -732,18 +705,6 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Force one silent session refresh immediately and report the result. The
|
||||
// validation gate for Release B: it works regardless of whether the current
|
||||
// token is fresh, so the whole refresh path can be proven in seconds instead
|
||||
// of waiting ~24h for a natural expiry. Also a user-facing fallback.
|
||||
this.addCommand({
|
||||
id: "refresh-session-now",
|
||||
name: "Refresh session now",
|
||||
callback: () => {
|
||||
void this.refreshSessionCommand();
|
||||
},
|
||||
});
|
||||
|
||||
// Render the left-rail ribbon icon only when the user has opted
|
||||
// in via settings. updateRibbonIcon() is idempotent and is also
|
||||
// called from the settings toggle so enabling/disabling takes
|
||||
|
|
@ -869,11 +830,6 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
// The client exists now, so a scheduled tick can run. Starts the
|
||||
// timer only when auto-sync is enabled; deferred first run is inside.
|
||||
this.reconcileAutoSync();
|
||||
// Schedule the silent session refresh from the stored token's expiry.
|
||||
// Independent of auto-sync: keeping the session alive also serves
|
||||
// manual imports. A past-due token refreshes promptly (delay clamps to
|
||||
// the minimum), so reopening Obsidian after the token expired recovers.
|
||||
this.reconcileTokenRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -893,12 +849,6 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
window.clearTimeout(this.autoSyncFirstRunTimeoutId);
|
||||
this.autoSyncFirstRunTimeoutId = undefined;
|
||||
}
|
||||
// Clear the silent-refresh timer. An in-flight refresh checks `disposed`
|
||||
// after its await before writing, so no token is persisted post-unload.
|
||||
if (this.refreshTimeoutId !== undefined) {
|
||||
window.clearTimeout(this.refreshTimeoutId);
|
||||
this.refreshTimeoutId = undefined;
|
||||
}
|
||||
// Hide any sticky action notice (e.g. an auth-pause "Reconnect") so its
|
||||
// click handler cannot open sign-in or save settings after unload.
|
||||
for (const notice of this.actionNotices) {
|
||||
|
|
@ -1617,26 +1567,6 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
} catch (err) {
|
||||
const classification = classifyError(err);
|
||||
const outcome = tickOutcomeForCategory(classification.category);
|
||||
// Reactive silent refresh: an expired/rejected token may be recoverable
|
||||
// without user action. Try one refresh before pausing. Only for
|
||||
// token-rejected (an expired session), not not-configured (nothing to
|
||||
// refresh from). A success clears the failure and schedules a soon retry.
|
||||
if (
|
||||
outcome === "auth" &&
|
||||
classification.category === "token-rejected" &&
|
||||
this.settings.keepSessionAlive &&
|
||||
!this.disposed
|
||||
) {
|
||||
const refreshed = await this.tryRefreshSession("reactive");
|
||||
if (refreshed && !this.disposed) {
|
||||
this.refreshFailureStreak = 0;
|
||||
this.autoSyncState = nextAutoSyncState(this.autoSyncState, "ok");
|
||||
this.reconcileTokenRefresh();
|
||||
this.scheduleFollowUpTick();
|
||||
this.logAutoSync("tick auth failure recovered via silent refresh");
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.autoSyncState = nextAutoSyncState(this.autoSyncState, outcome);
|
||||
if (outcome === "auth") {
|
||||
// The auth outcome covers both a rejected/expired token and a
|
||||
|
|
@ -1706,8 +1636,8 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
/**
|
||||
* Schedule one soon-ish auto-sync tick (~1s), reusing the first-run timeout
|
||||
* slot so reconcileAutoSync() and onunload() clear it. No-op when auto-sync is
|
||||
* disabled. Shared by the re-auth resume and the reactive-refresh recovery so
|
||||
* an untracked setTimeout can never fire after disable/reschedule/unload.
|
||||
* disabled. Routed through one place so an untracked setTimeout can never
|
||||
* fire after disable/reschedule/unload.
|
||||
*/
|
||||
private scheduleFollowUpTick(): void {
|
||||
if (!this.settings.autoSyncEnabled) return;
|
||||
|
|
@ -1720,355 +1650,33 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
}, 1000);
|
||||
}
|
||||
|
||||
// ---- Silent session refresh (Release B) -----------------------------
|
||||
// ---- Reconnect routing and legacy credential cleanup ----------------
|
||||
|
||||
/**
|
||||
* Blank any stored refresh token. Called whenever the access token is
|
||||
* replaced WITHOUT a matching fresh refresh token, so a later silent refresh
|
||||
* cannot use a previous session's WRT to authenticate as, and overwrite the
|
||||
* new token with, the wrong account.
|
||||
* Blank the legacy stored refresh token (pre-0.32.0 sessions). Called on
|
||||
* every fresh capture and on sign-out so the legacy WRT can never masquerade
|
||||
* as the routing signal for a newer session.
|
||||
*/
|
||||
private clearStoredRefreshToken(): void {
|
||||
try {
|
||||
this.app.secretStorage.setSecret(CAPTURED_REFRESH_SECRET_ID, "");
|
||||
this.app.secretStorage.setSecret(LEGACY_REFRESH_SECRET_ID, "");
|
||||
} catch (err) {
|
||||
console.error("Plaud importer: failed to blank refresh token", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** The currently-active access token (trimmed), or null when none is stored. */
|
||||
private currentAccessToken(): string | null {
|
||||
if (this.settings.secretId.length === 0) return null;
|
||||
const token = this.app.secretStorage.getSecret(this.settings.secretId);
|
||||
return token !== null && token.trim().length > 0 ? token.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a refresh token (typ WRT) is stored. The embedded-window email
|
||||
* sign-in captures and keeps a WRT; a browser/bookmarklet (SSO) capture never
|
||||
* does and blanks it (see storeAccessToken). So WRT presence is a reliable
|
||||
* proxy for "this session came from the embedded window, whose partition holds
|
||||
* the cookies the windowless refresh needs." Used to route the Reconnect
|
||||
* surfaces to the sign-in method that can actually work for this account.
|
||||
* Routes Reconnect to the sign-in surface that can re-auth this account.
|
||||
* The decision logic lives in reconnect-routing.ts (pure, unit-tested);
|
||||
* this wrapper only supplies the settings value and the legacy-secret
|
||||
* reader.
|
||||
*/
|
||||
private hasStoredRefreshToken(): boolean {
|
||||
try {
|
||||
const wrt = this.app.secretStorage.getSecret(CAPTURED_REFRESH_SECRET_ID);
|
||||
return wrt !== null && wrt.trim().length > 0;
|
||||
} catch (err) {
|
||||
console.error("Plaud importer: failed to read refresh token", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt one silent session refresh via the direct, windowless path only
|
||||
* (POST /auth/refresh-user-token over the partition cookies, then the
|
||||
* workspace-token mint; see plaud-refresh-net.ts). A background refresh
|
||||
* NEVER opens a window: the old hidden-window fallback loaded the full
|
||||
* Plaud web app, whose login page leaked popup tabs into the default
|
||||
* browser. On failure the caller pauses and prompts the user; only a user
|
||||
* click ever opens the interactive sign-in window.
|
||||
*
|
||||
* FAIL-SAFE: the stored token is replaced only when the refresh yields a
|
||||
* fresh WT (typ WT, future exp). Any failure leaves every stored value
|
||||
* untouched and returns false. Never throws.
|
||||
*
|
||||
* Serialized against BOTH a concurrent silent refresh (`refreshInFlight`)
|
||||
* and a visible interactive sign-in (`reauthInFlight`): a refresh landing
|
||||
* mid-sign-in would race the interactive capture's token write.
|
||||
*/
|
||||
private async tryRefreshSession(
|
||||
reason: "scheduled" | "reactive" | "manual",
|
||||
): Promise<boolean> {
|
||||
if (this.disposed || this.refreshInFlight || this.reauthInFlight) {
|
||||
return false;
|
||||
}
|
||||
// Neutralized for the long-lived user token. A refresh mints a 24h WT and
|
||||
// applyRefreshedToken would write it back, replacing the ~300-day token and
|
||||
// silently reinstating daily expiry. Only a stored WT is refreshable, so a
|
||||
// non-WT stored token skips the refresh (scheduled, reactive, and manual all
|
||||
// funnel through here). The stored token stays untouched.
|
||||
const active = this.currentAccessToken();
|
||||
if (active !== null && jwtTyp(active) !== WORKSPACE_TOKEN_TYP) {
|
||||
this.logAutoSync(
|
||||
`session refresh skipped (${reason}): stored token is a long-lived user token, which needs no refresh`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.refreshInFlight = true;
|
||||
// Also hold the interactive-sign-in gate: a manual "Sign in" storing its
|
||||
// capture mid-refresh would clobber this path's token write (and vice
|
||||
// versa).
|
||||
this.reauthInFlight = true;
|
||||
try {
|
||||
return await this.tryNetRefresh(reason);
|
||||
} catch (err) {
|
||||
// A refresh bug must log, not throw into the timer or the auto-sync tick.
|
||||
console.error("Plaud importer: silent session refresh failed", err);
|
||||
return false;
|
||||
} finally {
|
||||
this.refreshInFlight = false;
|
||||
this.reauthInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The direct, windowless refresh. Reads `wid`/`client_id` off the stored
|
||||
* token, runs the two-step refresh over the partition cookie jar, and hands
|
||||
* the candidate WT to the shared fail-safe. Returns false (never throws)
|
||||
* when the session.fetch surface is missing, no token is stored, or any
|
||||
* step fails; the refresh is then simply a failure (no window fallback).
|
||||
*/
|
||||
private async tryNetRefresh(
|
||||
reason: "scheduled" | "reactive" | "manual",
|
||||
): Promise<boolean> {
|
||||
const post = buildPartitionPost();
|
||||
if (post === null) {
|
||||
// No session.fetch on this runtime; silent refresh cannot run here.
|
||||
this.logAutoSync(
|
||||
"net refresh unavailable: no partition session.fetch transport (Electron remote/session/fetch missing)",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const token = this.currentAccessToken();
|
||||
if (token === null) {
|
||||
this.logAutoSync("net refresh skipped: no stored access token");
|
||||
return false;
|
||||
}
|
||||
// Snapshot the credential the refresh is renewing. applyRefreshedToken
|
||||
// writes its result ONLY if this exact credential is still stored when the
|
||||
// network round-trip returns, so a concurrent sign-out, paste, deep-link,
|
||||
// or another refresh cannot be silently overwritten by this stale result.
|
||||
const startingSecretId = this.settings.secretId;
|
||||
const result = await performNetRefresh({
|
||||
currentToken: token,
|
||||
baseUrl: this.settings.apiBaseUrl,
|
||||
post,
|
||||
log: (message, payload) => this.logAutoSync(message, payload),
|
||||
});
|
||||
if (this.disposed || result === null) {
|
||||
return false;
|
||||
}
|
||||
return this.applyRefreshedToken(
|
||||
result.token,
|
||||
result.refreshToken,
|
||||
result.apiBaseUrl,
|
||||
reason,
|
||||
{ token, secretId: startingSecretId },
|
||||
private reconnectPrefersWindow(): boolean {
|
||||
return preferWindowForReconnect(this.settings.signInMethod, () =>
|
||||
this.app.secretStorage.getSecret(LEGACY_REFRESH_SECRET_ID),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-safe for the refresh path. Replaces the stored secrets ONLY when the
|
||||
* candidate decodes as a fresh workspace token (typ WT, future exp); a
|
||||
* non-WT or already-expired value is rejected and nothing is written, so a
|
||||
* failed refresh can never make things worse than the current stored token.
|
||||
*
|
||||
* `startedFrom` is the credential the refresh was renewing (captured before
|
||||
* the network round-trip). The write is applied only if that exact credential
|
||||
* is still stored, so a concurrent sign-out (token cleared), paste/deep-link
|
||||
* (long-lived or a different account's token), or another refresh cannot be
|
||||
* silently overwritten by this now-stale result. When the credential changed,
|
||||
* the current one is kept and the return reports whether the session is still
|
||||
* healthy (a usable token is stored), so the reactive caller does not pause a
|
||||
* session that a concurrent capture just made good.
|
||||
*/
|
||||
private async applyRefreshedToken(
|
||||
candidate: string,
|
||||
refreshToken: string | null,
|
||||
apiBaseUrl: string | null,
|
||||
reason: "scheduled" | "reactive" | "manual",
|
||||
startedFrom: { token: string; secretId: string },
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!isAccessToken(candidate) ||
|
||||
!isFreshAccessToken(candidate, Date.now())
|
||||
) {
|
||||
this.logAutoSync(
|
||||
`session refresh (${reason}, direct) produced a token that is not a fresh WT; keeping existing`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Optimistic-concurrency check at write time. The refresh does not hold a
|
||||
// lock across its network round-trip, and the capture paths
|
||||
// (storeAccessToken, clearSignIn) do not take the refresh gate, so the
|
||||
// stored credential can change while a refresh is in flight. Only write if
|
||||
// it is still exactly what this refresh started from.
|
||||
const stored = this.currentAccessToken();
|
||||
if (stored !== startedFrom.token || this.settings.secretId !== startedFrom.secretId) {
|
||||
const healthy = stored !== null && isUsableUserToken(stored);
|
||||
this.logAutoSync(
|
||||
`session refresh (${reason}, direct) not applied: the stored credential changed mid-refresh; keeping the current one (healthy=${healthy})`,
|
||||
);
|
||||
return healthy;
|
||||
}
|
||||
const token = candidate.trim().replace(/^bearer\s+/i, "");
|
||||
// Update the active secret in place (keeps a user's chosen secret slot),
|
||||
// falling back to the captured-token slot when none is linked yet.
|
||||
const targetSecretId =
|
||||
this.settings.secretId.length > 0
|
||||
? this.settings.secretId
|
||||
: CAPTURED_SECRET_ID;
|
||||
this.app.secretStorage.setSecret(targetSecretId, token);
|
||||
this.settings.secretId = targetSecretId;
|
||||
// Keep the paired refresh token if this refresh produced one; otherwise
|
||||
// leave the stored one so a future attempt can still use it.
|
||||
if (refreshToken !== null && refreshToken.trim().length > 0) {
|
||||
this.app.secretStorage.setSecret(
|
||||
CAPTURED_REFRESH_SECRET_ID,
|
||||
refreshToken.trim().replace(/^bearer\s+/i, ""),
|
||||
);
|
||||
}
|
||||
if (apiBaseUrl !== null) {
|
||||
this.settings.apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
await this.saveSettings();
|
||||
this.logAutoSync(`session refreshed (${reason}, direct)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start, stop, or reschedule the proactive session-refresh timer to match
|
||||
* settings and the stored token's expiry. Idempotent: clears the existing
|
||||
* timer first. No-op (and cleared) when the feature is off or no token is
|
||||
* stored. Called from onLayoutReady, the toggle, and after any token change.
|
||||
*/
|
||||
reconcileTokenRefresh(): void {
|
||||
if (this.refreshTimeoutId !== undefined) {
|
||||
window.clearTimeout(this.refreshTimeoutId);
|
||||
this.refreshTimeoutId = undefined;
|
||||
}
|
||||
if (this.disposed || !this.settings.keepSessionAlive) return;
|
||||
const token = this.currentAccessToken();
|
||||
// Nothing to refresh proactively until a token exists; a fresh sign-in
|
||||
// calls this again to (re)schedule.
|
||||
if (token === null) return;
|
||||
// A long-lived user token (typ != WT) has a ~300-day life and needs no
|
||||
// proactive refresh; scheduling one would only risk clobbering it with a
|
||||
// 24h WT. Do not arm the timer. tryRefreshSession also self-guards, so an
|
||||
// already-armed timer that fires is a no-op too.
|
||||
if (jwtTyp(token) !== WORKSPACE_TOKEN_TYP) {
|
||||
this.logAutoSync(
|
||||
"proactive refresh not scheduled: stored token is a long-lived user token",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// An SSO/paste session (no stored WRT) that has failed the whole backoff
|
||||
// ladder on an already-due token cannot self-heal unattended: it has no
|
||||
// partition cookies for the windowless refresh. Stop re-arming so we do
|
||||
// not hammer Plaud's rate-limited refresh endpoint hourly forever; the
|
||||
// Reconnect notice is the recovery path. An email session (WRT present) is
|
||||
// left retrying, since its failures may be transient. A fresh sign-in
|
||||
// resets the streak to 0 and reschedules normally.
|
||||
if (
|
||||
shouldStopProactiveRefresh(
|
||||
token,
|
||||
this.refreshFailureStreak,
|
||||
this.hasStoredRefreshToken(),
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
this.logAutoSync(
|
||||
"proactive refresh exhausted its backoff on an unrefreshable session; awaiting reconnect",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Backoff on a failure streak, expiry-driven otherwise, and always
|
||||
// clamped under setTimeout's 32-bit ceiling (a long-lived token used to
|
||||
// overflow the delay and fire immediately). See plaud-refresh.ts.
|
||||
const delay = computeRefreshDelay(
|
||||
token,
|
||||
this.refreshFailureStreak,
|
||||
Date.now(),
|
||||
);
|
||||
this.refreshTimeoutId = window.setTimeout(() => {
|
||||
this.refreshTimeoutId = undefined;
|
||||
void this.runScheduledRefresh();
|
||||
}, delay);
|
||||
this.logAutoSync("session refresh scheduled", {
|
||||
delayMs: delay,
|
||||
failureStreak: this.refreshFailureStreak,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The proactive-timer callback: refresh, resume any auth-paused sync on
|
||||
* success, then reschedule (from the new token's expiry on success, or a
|
||||
* backoff on failure). Skips the refresh entirely (and just re-arms) while
|
||||
* the stored token still has life beyond the lead window, so an early fire
|
||||
* never refreshes a token that does not need it. Guarded so it never runs
|
||||
* after unload or a disable.
|
||||
*/
|
||||
private async runScheduledRefresh(): Promise<void> {
|
||||
if (this.disposed || !this.settings.keepSessionAlive) return;
|
||||
// The timer can legitimately fire long before expiry: the schedule is
|
||||
// clamped to REFRESH_MAX_DELAY_MS for long-lived tokens, and a backoff
|
||||
// retry can outlive the failure that armed it (e.g. after a fresh
|
||||
// sign-in). Refreshing a token that does not need it is what produced
|
||||
// the spurious hourly refresh storm, so re-arm and wait instead.
|
||||
const token = this.currentAccessToken();
|
||||
if (token !== null && !isRefreshDue(token, Date.now())) {
|
||||
this.refreshFailureStreak = 0;
|
||||
this.reconcileTokenRefresh();
|
||||
return;
|
||||
}
|
||||
const ok = await this.tryRefreshSession("scheduled");
|
||||
if (this.disposed) return;
|
||||
if (ok) {
|
||||
this.refreshFailureStreak = 0;
|
||||
this.resumeAutoSyncIfPaused();
|
||||
} else {
|
||||
this.refreshFailureStreak = Math.min(
|
||||
this.refreshFailureStreak + 1,
|
||||
REFRESH_RETRY_BACKOFF_MS.length,
|
||||
);
|
||||
}
|
||||
this.reconcileTokenRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual "Refresh session now" command. Forces one refresh and reports the
|
||||
* outcome. On success, resumes any paused sync and reschedules from the new
|
||||
* expiry. On failure, tells the user to sign in again if imports fail — the
|
||||
* stored token is never cleared by a failed refresh.
|
||||
*/
|
||||
private async refreshSessionCommand(): Promise<void> {
|
||||
if (!this.client) {
|
||||
new Notice("Plaud importer: still starting up. Try again in a moment.");
|
||||
return;
|
||||
}
|
||||
// A long-lived user token (typ != WT) needs no refresh and cannot be
|
||||
// refreshed (a refresh would clobber it with a 24h WT). Report its state
|
||||
// directly instead of running the WT refresh path, which would return false
|
||||
// and misfire the "could not refresh, sign in again" failure notice. A
|
||||
// still-live token is healthy; an expired one routes to reconnect.
|
||||
const active = this.currentAccessToken();
|
||||
if (active !== null && jwtTyp(active) !== WORKSPACE_TOKEN_TYP) {
|
||||
new Notice(
|
||||
isUsableUserToken(active)
|
||||
? "Plaud importer: your session uses a long-lived token and does not need refreshing. It stays valid for months."
|
||||
: "Plaud importer: your Plaud session has expired. Sign in again from settings to reconnect.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
new Notice("Plaud importer: refreshing your session…");
|
||||
const ok = await this.tryRefreshSession("manual");
|
||||
if (this.disposed) return;
|
||||
if (ok) {
|
||||
this.refreshFailureStreak = 0;
|
||||
this.resumeAutoSyncIfPaused();
|
||||
this.reconcileTokenRefresh();
|
||||
new Notice(
|
||||
"Plaud importer: session refreshed. Background sync and imports can keep running.",
|
||||
);
|
||||
} else {
|
||||
new Notice(
|
||||
"Plaud importer: could not refresh the session automatically. If imports start failing, sign in again from settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a sticky notice carrying one inline action link. Used by the
|
||||
* auth-pause path and the manual commands so a disconnected session offers
|
||||
|
|
@ -2133,13 +1741,13 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
* captured, so a caller (e.g. a failed command) can retry itself on success.
|
||||
*/
|
||||
async reconnectFromNotice(onReconnected?: () => unknown): Promise<boolean> {
|
||||
// A session with no stored WRT came from the browser/bookmarklet (SSO)
|
||||
// flow: Google and Apple do not complete in the embedded window, so
|
||||
// routing it there dead-ends. Open the browser + bookmarklet + paste flow
|
||||
// instead. That flow finishes asynchronously when the user pastes, so this
|
||||
// returns false now; the paste handler resumes any paused sync and runs
|
||||
// A browser/bookmarklet (SSO) session cannot re-auth in the embedded
|
||||
// window (Google and Apple do not complete there), so routing it there
|
||||
// dead-ends. Open the browser + bookmarklet + paste flow instead. That
|
||||
// flow finishes asynchronously when the user pastes, so this returns
|
||||
// false now; the paste handler resumes any paused sync and runs
|
||||
// onReconnected (e.g. a backfill retry) itself.
|
||||
if (!this.hasStoredRefreshToken()) {
|
||||
if (!this.reconnectPrefersWindow()) {
|
||||
this.openBrowserReconnect(onReconnected);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2179,8 +1787,8 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
*/
|
||||
private openBrowserReconnect(onReconnected?: () => unknown): void {
|
||||
// One sign-in surface at a time, sharing the same gate as the embedded
|
||||
// window and the silent refresh: two captures racing on the partition would
|
||||
// clobber each other's token write. Held for the modal's whole lifetime and
|
||||
// window: two captures racing on the partition would clobber each
|
||||
// other's token write. Held for the modal's whole lifetime and
|
||||
// cleared when it closes; paste-success, cancel, and dismiss all route
|
||||
// through the modal's onClose.
|
||||
if (this.reauthInFlight) {
|
||||
|
|
@ -2451,7 +2059,13 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
typeof rawVersion === "number" && Number.isFinite(rawVersion)
|
||||
? rawVersion
|
||||
: 0;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, stored ?? {});
|
||||
// 0.32.0 removed the keepSessionAlive setting with the refresh
|
||||
// subsystem. Object.assign copies unknown stored keys through, so drop
|
||||
// the stale key here or it rides along in data.json forever.
|
||||
const merged: PlaudImporterSettings & { keepSessionAlive?: unknown } =
|
||||
Object.assign({}, DEFAULT_SETTINGS, stored ?? {});
|
||||
delete merged.keepSessionAlive;
|
||||
this.settings = merged;
|
||||
// Repair a blank stored output folder back to the default. The
|
||||
// declarative control can persist an empty string; consumers expect a
|
||||
// non-empty folder name.
|
||||
|
|
@ -2543,11 +2157,11 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
// Wipe the stored token value(s). Obsidian's SecretStorage exposes no
|
||||
// delete call (only set/get/list), so the secret entry itself cannot be
|
||||
// removed; blanking the value is the most thorough removal available. The
|
||||
// refresh token is blanked too so a cleared session cannot silently refresh.
|
||||
// legacy refresh token is blanked too so no credential outlives sign-out.
|
||||
for (const id of new Set([
|
||||
this.settings.secretId,
|
||||
CAPTURED_SECRET_ID,
|
||||
CAPTURED_REFRESH_SECRET_ID,
|
||||
LEGACY_REFRESH_SECRET_ID,
|
||||
])) {
|
||||
if (id.length > 0) {
|
||||
try {
|
||||
|
|
@ -2558,10 +2172,10 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
this.settings.secretId = "";
|
||||
// A cleared plugin has no session, so there is no sign-in method to route
|
||||
// a Reconnect from until the next capture records one.
|
||||
this.settings.signInMethod = "";
|
||||
await this.saveSettings();
|
||||
// No token remains, so cancel any pending proactive refresh.
|
||||
this.refreshFailureStreak = 0;
|
||||
this.reconcileTokenRefresh();
|
||||
return { sessionCleared };
|
||||
}
|
||||
|
||||
|
|
@ -2597,26 +2211,14 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
}
|
||||
this.app.secretStorage.setSecret(CAPTURED_SECRET_ID, result.token);
|
||||
this.settings.secretId = CAPTURED_SECRET_ID;
|
||||
// Keep the paired refresh token (typ WRT) for the silent-refresh path.
|
||||
// It flies during login; store it stripped of any bearer prefix. When
|
||||
// this sign-in did not capture a WRT, blank any stale one so a silent
|
||||
// refresh cannot resurrect the previous session with a mismatched token.
|
||||
if (result.refreshToken !== null && result.refreshToken.trim().length > 0) {
|
||||
this.app.secretStorage.setSecret(
|
||||
CAPTURED_REFRESH_SECRET_ID,
|
||||
result.refreshToken.trim().replace(/^bearer\s+/i, ""),
|
||||
);
|
||||
} else {
|
||||
this.clearStoredRefreshToken();
|
||||
}
|
||||
// Record how this session was captured so Reconnect reopens the same
|
||||
// surface, and blank any legacy WRT so it cannot shadow that signal.
|
||||
this.settings.signInMethod = "window";
|
||||
this.clearStoredRefreshToken();
|
||||
if (result.apiBaseUrl !== null) {
|
||||
this.settings.apiBaseUrl = result.apiBaseUrl;
|
||||
}
|
||||
await this.saveSettings();
|
||||
// A fresh sign-in clears any prior refresh-failure backoff and
|
||||
// reschedules the proactive refresh from the new token's expiry.
|
||||
this.refreshFailureStreak = 0;
|
||||
this.reconcileTokenRefresh();
|
||||
return true;
|
||||
} finally {
|
||||
this.reauthInFlight = false;
|
||||
|
|
@ -2710,17 +2312,12 @@ export default class PlaudImporterPlugin extends Plugin {
|
|||
}
|
||||
this.app.secretStorage.setSecret(CAPTURED_SECRET_ID, token);
|
||||
this.settings.secretId = CAPTURED_SECRET_ID;
|
||||
// A pasted/deep-linked token carries no refresh token (only the WT), so
|
||||
// blank any stale WRT from a previous session; a silent refresh must not
|
||||
// resurrect that session and overwrite this token with the wrong account's.
|
||||
// The refresh then relies on the partition cookies until the next in-app
|
||||
// sign-in captures a fresh WRT.
|
||||
// A pasted/deep-linked token came through the browser flow. Record that
|
||||
// so Reconnect routes there, and blank any legacy WRT from a previous
|
||||
// session so it cannot shadow the recorded method.
|
||||
this.settings.signInMethod = "browser";
|
||||
this.clearStoredRefreshToken();
|
||||
await this.saveSettings();
|
||||
// A newly pasted token clears any refresh backoff and reschedules the
|
||||
// proactive refresh from its expiry.
|
||||
this.refreshFailureStreak = 0;
|
||||
this.reconcileTokenRefresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3209,12 +2806,6 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
|
|||
"1440": "Once a day",
|
||||
},
|
||||
);
|
||||
this.addToggleRow(
|
||||
containerEl,
|
||||
"Keep the session alive automatically",
|
||||
KEEP_SESSION_ALIVE_DESC,
|
||||
"keepSessionAlive",
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName("Transcript rendering").setHeading();
|
||||
this.addToggleRow(
|
||||
|
|
@ -3275,6 +2866,15 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
|
|||
.setValue(this.plugin.settings.secretId)
|
||||
.onChange(async (id) => {
|
||||
this.plugin.settings.secretId = id;
|
||||
// A manually linked secret has unknown provenance, so the
|
||||
// recorded sign-in method no longer describes the active
|
||||
// credential; clear it and let Reconnect fall back to the
|
||||
// legacy heuristic. Re-linking the plugin-captured secret
|
||||
// keeps its recorded method, which still describes that
|
||||
// token.
|
||||
if (id !== CAPTURED_SECRET_ID) {
|
||||
this.plugin.settings.signInMethod = "";
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
// A freshly stored token is a resume trigger for a paused
|
||||
// auto-sync.
|
||||
|
|
@ -4323,11 +3923,6 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Keep the session alive automatically",
|
||||
desc: KEEP_SESSION_ALIVE_DESC,
|
||||
control: { type: "toggle", key: "keepSessionAlive" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -4496,11 +4091,6 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
|
|||
if (key === "autoSyncEnabled" && this.plugin.settings.autoSyncEnabled) {
|
||||
this.plugin.resumeAutoSyncIfPaused();
|
||||
}
|
||||
} else if (key === "keepSessionAlive") {
|
||||
// Start or stop the proactive refresh timer to match the toggle. When
|
||||
// turned on with a token already past its refresh point, this schedules
|
||||
// a prompt refresh (the delay clamps to the minimum).
|
||||
this.plugin.reconcileTokenRefresh();
|
||||
} else if (key === "debug") {
|
||||
// Update the live logger's enabled flag in place so the change takes
|
||||
// effect on the next API call without reinstantiating the client.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"package:brat": "npm run build && node package-brat.mjs",
|
||||
"lint": "eslint main.ts plaud-client.ts plaud-client-re.ts plaud-login.ts plaud-token.ts plaud-refresh.ts plaud-refresh-net.ts import-core.ts import-modal.ts import-runner.ts attachment-importer.ts note-writer.ts folder-catalog.ts debug-logger.ts vault-index.ts __tests__/plaud-client-re.test.ts __tests__/plaud-token.test.ts __tests__/plaud-login-origin.test.ts __tests__/summary-metadata.test.ts __tests__/plaud-refresh.test.ts __tests__/plaud-refresh-net.test.ts __tests__/import-modal.test.ts __tests__/import-runner.test.ts __tests__/note-writer.test.ts __tests__/folder-catalog.test.ts __tests__/attachment-importer.test.ts __tests__/debug-logger.test.ts __tests__/vault-index.test.ts --max-warnings 0 && node scripts/check-submission.mjs && node scripts/check-icons.mjs",
|
||||
"lint": "eslint main.ts plaud-client.ts plaud-client-re.ts plaud-login.ts reconnect-routing.ts plaud-token.ts import-core.ts import-modal.ts import-runner.ts attachment-importer.ts note-writer.ts folder-catalog.ts debug-logger.ts vault-index.ts __tests__/plaud-client-re.test.ts __tests__/plaud-token.test.ts __tests__/plaud-login-origin.test.ts __tests__/reconnect-routing.test.ts __tests__/summary-metadata.test.ts __tests__/import-modal.test.ts __tests__/import-runner.test.ts __tests__/note-writer.test.ts __tests__/folder-catalog.test.ts __tests__/attachment-importer.test.ts __tests__/debug-logger.test.ts __tests__/vault-index.test.ts --max-warnings 0 && node scripts/check-submission.mjs && node scripts/check-icons.mjs",
|
||||
"test": "jest --passWithNoTests",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
|
|
|
|||
135
plaud-login.ts
135
plaud-login.ts
|
|
@ -20,11 +20,6 @@
|
|||
// future exp; see plaud-token.ts). That guard, not the key name, is what keeps
|
||||
// a neighboring profile/ID JWT (no `exp`) or a stale expired token from ever
|
||||
// being stored.
|
||||
//
|
||||
// A session-level `webRequest.onSendHeaders` listener still records the paired
|
||||
// refresh token (typ WRT) so the (now inert for a long-lived token) silent
|
||||
// refresh path and the reconnect-routing heuristic keep working until that
|
||||
// subsystem is removed. It no longer sources the STORED credential.
|
||||
|
||||
import { App, Platform } from 'obsidian';
|
||||
import { NoopDebugLogger, type DebugLogger } from './debug-logger';
|
||||
|
|
@ -41,52 +36,7 @@ const PLAUD_LOGIN_URL = 'https://web.plaud.ai';
|
|||
// not have to sign in every time. Isolated from Obsidian's own web sessions.
|
||||
const PLAUD_PARTITION = 'persist:plaud-importer';
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
// Match patterns for Plaud API hosts (covers regional hosts like api-euc1).
|
||||
const SESSION_FILTER = { urls: ['*://*.plaud.ai/*'] };
|
||||
// A JWT, optionally bearer-prefixed.
|
||||
const JWT_RE = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
|
||||
|
||||
// Plaud's data API tags tokens by type in the JWT header `typ`. The workspace
|
||||
// ACCESS token used for /file/* data calls is `WT`; the paired REFRESH token is
|
||||
// `WRT`. During login the web app sends the refresh token (WRT) in an
|
||||
// Authorization header before the access token (WT), so a "grab the first
|
||||
// Authorization header" capture stores the WRT and the data API then rejects it
|
||||
// with `status: -3901 "token type does not match parse mode"`. Only accept the
|
||||
// access token. Verified against a live web.plaud.ai session on 2026-06-18.
|
||||
const ACCESS_TOKEN_TYP = 'WT';
|
||||
// The paired REFRESH token type. The web app sends the WRT in an Authorization
|
||||
// header during login before the WT; we now keep it (in addition to the WT) so
|
||||
// the silent-refresh path can send it as a bearer if that is the credential the
|
||||
// refresh endpoint wants. See plaud-refresh.ts.
|
||||
const REFRESH_TOKEN_TYP = 'WRT';
|
||||
|
||||
// Reads the JWT header `typ` from a (possibly bearer-prefixed) token, or null
|
||||
// when the value is not a decodable JWT.
|
||||
function jwtTyp(value: string): string | null {
|
||||
const match = value.replace(/^bearer\s+/i, '').match(JWT_RE);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
const seg = match[0].split('.')[0].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = seg + '='.repeat((4 - (seg.length % 4)) % 4);
|
||||
try {
|
||||
// atob is always present in Obsidian's Electron renderer (and Node 18+);
|
||||
// avoid the Node `Buffer` global, which is untyped in the marketplace
|
||||
// scan's type-checked lint and trips the no-unsafe-* rules.
|
||||
const header = JSON.parse(atob(padded)) as Record<string, unknown>;
|
||||
return typeof header.typ === 'string' ? header.typ : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccessToken(value: string): boolean {
|
||||
return jwtTyp(value) === ACCESS_TOKEN_TYP;
|
||||
}
|
||||
|
||||
export function isRefreshToken(value: string): boolean {
|
||||
return jwtTyp(value) === REFRESH_TOKEN_TYP;
|
||||
}
|
||||
|
||||
// Reads the long-lived user token and the regional API host from the page's
|
||||
// own localStorage. `getItem` returns the raw stored string (no JSON-quote
|
||||
|
|
@ -115,12 +65,6 @@ export interface PlaudLoginResult {
|
|||
readonly token: string;
|
||||
/** Regional API origin if discoverable, else null. */
|
||||
readonly apiBaseUrl: string | null;
|
||||
/**
|
||||
* The paired refresh token (typ WRT) if it flew by during login, else null.
|
||||
* Kept for the silent-refresh path (plaud-refresh.ts); the WT is what the
|
||||
* data API uses. May carry a "bearer " prefix.
|
||||
*/
|
||||
readonly refreshToken: string | null;
|
||||
}
|
||||
|
||||
export interface PlaudLoginOptions {
|
||||
|
|
@ -137,17 +81,7 @@ interface ProbeResult {
|
|||
// Minimal Electron surface accessed at runtime via window.require('electron').
|
||||
// Methods may be absent on builds that disable the remote module, so all access
|
||||
// is guarded.
|
||||
interface WebRequestDetails {
|
||||
requestHeaders?: Record<string, string>;
|
||||
}
|
||||
interface WebRequestLike {
|
||||
onSendHeaders(
|
||||
filter: { urls: string[] },
|
||||
listener: ((details: WebRequestDetails) => void) | null,
|
||||
): void;
|
||||
}
|
||||
interface SessionLike {
|
||||
webRequest?: WebRequestLike;
|
||||
// Electron Session storage controls. Present on real builds; guarded at the
|
||||
// call site because they may be absent where the remote module is disabled.
|
||||
clearStorageData?(): Promise<void>;
|
||||
|
|
@ -164,7 +98,7 @@ interface WebContentsLike {
|
|||
removeAllListeners?(eventName: string): unknown;
|
||||
// Deny/allow popups and new windows the loaded page requests. Present on real
|
||||
// Electron builds; guarded at the call site. We deny all: the sign-in only
|
||||
// needs the main frame's own API call, never a popup, and the Plaud web app
|
||||
// needs the main frame itself, never a popup, and the Plaud web app
|
||||
// otherwise spawns feedback/analytics popups that Obsidian routes to the
|
||||
// system browser.
|
||||
setWindowOpenHandler?(
|
||||
|
|
@ -254,8 +188,9 @@ export async function clearPlaudLoginSession(): Promise<boolean> {
|
|||
|
||||
/**
|
||||
* Open the Plaud sign-in window. Resolves with the captured token (and region)
|
||||
* once the web app makes an authenticated request, or null if the user closes
|
||||
* the window first or the BrowserWindow API is unavailable on this build.
|
||||
* once the signed-in page's localStorage holds a usable long-lived token, or
|
||||
* null if the user closes the window first or the BrowserWindow API is
|
||||
* unavailable on this build.
|
||||
*/
|
||||
export function openPlaudLogin(
|
||||
app: App,
|
||||
|
|
@ -273,11 +208,6 @@ class PlaudLoginSession {
|
|||
private win: BrowserWindowLike | null = null;
|
||||
private pollHandle: number | null = null;
|
||||
private settled = false;
|
||||
// The paired refresh token (typ WRT) seen during login, kept for the silent
|
||||
// refresh path and the reconnect-routing heuristic. It flies during login,
|
||||
// so it is set by the time the localStorage token read settles the session.
|
||||
private capturedRefresh: string | null = null;
|
||||
private webRequestSession: SessionLike | null = null;
|
||||
|
||||
constructor(
|
||||
options: PlaudLoginOptions,
|
||||
|
|
@ -288,9 +218,8 @@ class PlaudLoginSession {
|
|||
}
|
||||
|
||||
start(): void {
|
||||
// Arm the session-level capture BEFORE the window loads, so the first
|
||||
// authenticated request the web app makes is recorded automatically.
|
||||
this.armSessionCapture();
|
||||
// Present the window as plain desktop Chrome before it loads.
|
||||
this.applySessionUserAgent();
|
||||
|
||||
const remote = requireElectron()?.remote;
|
||||
const BrowserWindow = remote?.BrowserWindow;
|
||||
|
|
@ -354,7 +283,7 @@ class PlaudLoginSession {
|
|||
// Deny every popup / new window the page requests, BEFORE loading it. The
|
||||
// Plaud web app fires window.open on load (feedback widget, analytics, an
|
||||
// auth-redirect popup); without a deny handler the host routes those to
|
||||
// the system browser. We only need the main frame's own API call.
|
||||
// the system browser. We only need the main frame itself.
|
||||
if (typeof contents.setWindowOpenHandler === 'function') {
|
||||
try {
|
||||
// The deny must reach Electron SYNCHRONOUSLY in the main process. A
|
||||
|
|
@ -447,15 +376,8 @@ class PlaudLoginSession {
|
|||
if (value.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.note('token captured', 'note', {
|
||||
apiBaseUrl,
|
||||
refreshTokenCaptured: this.capturedRefresh !== null,
|
||||
});
|
||||
this.settle({
|
||||
token: value,
|
||||
apiBaseUrl,
|
||||
refreshToken: this.capturedRefresh,
|
||||
});
|
||||
this.note('token captured', 'note', { apiBaseUrl });
|
||||
this.settle({ token: value, apiBaseUrl });
|
||||
this.closeWindow();
|
||||
}
|
||||
|
||||
|
|
@ -472,7 +394,7 @@ class PlaudLoginSession {
|
|||
}
|
||||
}
|
||||
|
||||
private armSessionCapture(): void {
|
||||
private applySessionUserAgent(): void {
|
||||
const session = requireElectron()?.remote?.session?.fromPartition(
|
||||
PLAUD_PARTITION,
|
||||
);
|
||||
|
|
@ -487,42 +409,6 @@ class PlaudLoginSession {
|
|||
this.note(`set user-agent failed: ${String(err)}`, 'error');
|
||||
}
|
||||
}
|
||||
if (typeof session.webRequest?.onSendHeaders !== 'function') {
|
||||
this.note('session header capture unavailable; no WRT will be recorded');
|
||||
return;
|
||||
}
|
||||
this.webRequestSession = session;
|
||||
try {
|
||||
session.webRequest.onSendHeaders(SESSION_FILTER, (details) => {
|
||||
const headers = details.requestHeaders ?? {};
|
||||
const auth = headers.Authorization ?? headers.authorization;
|
||||
if (typeof auth !== 'string') {
|
||||
return;
|
||||
}
|
||||
// The STORED credential is now the long-lived user token read from
|
||||
// localStorage (see the poll). Here we only keep the paired refresh
|
||||
// token (typ WRT) for the silent-refresh path (inert for a
|
||||
// long-lived token) and the reconnect-routing heuristic in main.ts.
|
||||
if (isRefreshToken(auth)) {
|
||||
this.capturedRefresh = auth;
|
||||
}
|
||||
});
|
||||
this.note('session header capture armed');
|
||||
} catch (err) {
|
||||
this.note(`session capture setup failed: ${String(err)}`, 'error');
|
||||
this.webRequestSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
private teardownSessionCapture(): void {
|
||||
if (this.webRequestSession?.webRequest !== undefined) {
|
||||
try {
|
||||
this.webRequestSession.webRequest.onSendHeaders(SESSION_FILTER, null);
|
||||
} catch {
|
||||
// Best effort; nothing actionable if teardown fails.
|
||||
}
|
||||
}
|
||||
this.webRequestSession = null;
|
||||
}
|
||||
|
||||
private parseProbe(raw: unknown): ProbeResult | null {
|
||||
|
|
@ -542,7 +428,6 @@ class PlaudLoginSession {
|
|||
}
|
||||
this.settled = true;
|
||||
this.stopPolling();
|
||||
this.teardownSessionCapture();
|
||||
// Close the window on every settle path (success closes it too, via
|
||||
// captureToken). Guarded/idempotent; a no-op once 'closed' has fired.
|
||||
this.closeWindow();
|
||||
|
|
|
|||
|
|
@ -1,415 +0,0 @@
|
|||
// Direct, windowless session refresh (Release B, the ONLY background path).
|
||||
//
|
||||
// Two API calls on the persistent Plaud sign-in partition. The hidden
|
||||
// BrowserWindow re-capture this replaced is gone: a background refresh never
|
||||
// opens a window (the hidden window's login page leaked web.plaud.ai popup
|
||||
// tabs into the default browser). Reverse-engineered from a full login HAR
|
||||
// on 2026-07-06 (see dev-docs/plaud-importer/2026-07-06-token-refresh-mechanism.md,
|
||||
// "WT-mint endpoint IDENTIFIED"). Both the user token (UT) and workspace token
|
||||
// (WT) live ~24h, so after a day both are stale and the refresh is two steps:
|
||||
//
|
||||
// 1. POST /auth/refresh-user-token empty body; the URT cookie mints a
|
||||
// fresh UT and rotates the URT cookie
|
||||
// 2. POST /user-app/auth/workspace/token/{wid} body {}; the fresh UT cookie
|
||||
// mints the 24h WT the data API needs
|
||||
//
|
||||
// Auth on both calls is the partition's httpOnly cookies, NOT a bearer header
|
||||
// (the real requests carry no Authorization; the login response body even
|
||||
// returns empty token strings). A session-bound transport is therefore
|
||||
// mandatory: it auto-attaches those cookies AND persists call 1's rotated
|
||||
// Set-Cookie into the jar so call 2 sees the fresh UT. Manual cookie forwarding
|
||||
// would drop the rotation between the two calls.
|
||||
//
|
||||
// FAIL-SAFE: this module only ever RETURNS a candidate token; it never writes
|
||||
// storage. The caller re-validates (typ WT, future exp) before replacing
|
||||
// anything, and on any null result the background pauses and prompts the user
|
||||
// to reconnect. Every step is guarded and the orchestrator never throws.
|
||||
//
|
||||
// Hands-on validated 2026-07-09: a manual "Refresh session now" on 0.28.0
|
||||
// completed this path live end to end (debug log: "net refresh succeeded via
|
||||
// the direct (windowless) path", next refresh scheduled ~24h out). A failure
|
||||
// here is benign: it returns null and the caller treats the refresh as failed.
|
||||
|
||||
import { readJwtPayloadClaim } from './plaud-refresh';
|
||||
|
||||
// The Plaud web app's sign-in partition. Mirrors PLAUD_PARTITION in
|
||||
// plaud-login.ts (kept local so this module never imports the login-window
|
||||
// module).
|
||||
const PLAUD_PARTITION = 'persist:plaud-importer';
|
||||
|
||||
const REFRESH_USER_TOKEN_PATH = '/auth/refresh-user-token';
|
||||
const WORKSPACE_TOKEN_PATH_PREFIX = '/user-app/auth/workspace/token/';
|
||||
|
||||
// Bound each refresh POST so a stalled request cannot hang the refresh path
|
||||
// forever. The two calls each complete in a couple of seconds normally; 30s is
|
||||
// generous headroom. An abort rejects the post, performNetRefresh catches it
|
||||
// and returns null, and the refresh reports failure cleanly.
|
||||
const NET_REFRESH_TIMEOUT_MS = 30 * 1000;
|
||||
|
||||
// Plaud's "success" status in a JSON envelope. Anything else is treated as a
|
||||
// failure (fall back), except the region-redirect status handled below.
|
||||
const STATUS_OK = 0;
|
||||
// Region-mismatch soft redirect: HTTP 200 whose body carries the regional host
|
||||
// in data.domains.api. Matches detectRegionRedirect() in plaud-client-re.ts.
|
||||
const STATUS_REGION_REDIRECT = -302;
|
||||
|
||||
// Only ever talk to Plaud's own hosts, even when a redirect body names the
|
||||
// target. A tampered redirect must not steer a cookie-authenticated call to an
|
||||
// arbitrary origin.
|
||||
const ALLOWED_HOST_SUFFIX = '.plaud.ai';
|
||||
const ALLOWED_EXACT_HOSTS = new Set(['plaud.ai', 'api.plaud.ai']);
|
||||
|
||||
/** A single session-bound POST. Injected so tests drive it without Electron. */
|
||||
export interface SessionPost {
|
||||
(
|
||||
url: string,
|
||||
body: string,
|
||||
headers: Readonly<Record<string, string>>,
|
||||
): Promise<{ status: number; text: string }>;
|
||||
}
|
||||
|
||||
export interface NetRefreshDeps {
|
||||
/** The stored workspace token (may be expired). Source of `wid`/`client_id`. */
|
||||
readonly currentToken: string;
|
||||
/** Current API origin, no trailing slash, e.g. `https://api.plaud.ai`. */
|
||||
readonly baseUrl: string;
|
||||
/** Session-bound transport (partition cookie jar). */
|
||||
readonly post: SessionPost;
|
||||
/**
|
||||
* Optional diagnostic sink. Called at each step/failure so one debug run
|
||||
* pinpoints why the direct path fell back to the window. Only ever receives
|
||||
* HTTP status, the envelope's numeric status, and a short body snippet of a
|
||||
* FAILING response; never a token value.
|
||||
*/
|
||||
readonly log?: (message: string, payload?: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* First 200 chars of a response body, for a failure diagnostic, with any
|
||||
* JWT-shaped substring redacted first. A failing auth/refresh response should
|
||||
* not carry a token, but redacting guarantees the debug logger's no-secrets
|
||||
* contract holds even if one ever slips into an error body.
|
||||
*/
|
||||
function bodySnippet(text: string): string {
|
||||
const redacted = text.replace(
|
||||
/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
||||
'[redacted-token]',
|
||||
);
|
||||
return redacted.length > 200 ? `${redacted.slice(0, 200)}…` : redacted;
|
||||
}
|
||||
|
||||
/** A human-readable `message`/`msg` field off an envelope, when present. */
|
||||
function envelopeMessage(envelope: Record<string, unknown>): string | undefined {
|
||||
if (typeof envelope.message === 'string') return envelope.message;
|
||||
if (typeof envelope.msg === 'string') return envelope.msg;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface NetRefreshResult {
|
||||
/** The fresh workspace token (typ WT). Caller validates before storing. */
|
||||
readonly token: string;
|
||||
/** The rotated workspace refresh token, when the mint returned one. */
|
||||
readonly refreshToken: string | null;
|
||||
/** A regional API origin to persist, when a redirect moved us. */
|
||||
readonly apiBaseUrl: string | null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/** Parse a JSON envelope, or null when the text is not a JSON object. */
|
||||
function parseEnvelope(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `wid` claim off the stored token, validated to look like a workspace id.
|
||||
* Exported for direct unit testing.
|
||||
*/
|
||||
export function extractWorkspaceId(token: string): string | null {
|
||||
const wid = readJwtPayloadClaim(token, 'wid');
|
||||
return wid !== null && wid.startsWith('ws_') ? wid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a URL to its origin (scheme + host, no path/query/userinfo) IF it is
|
||||
* a trusted Plaud https host, else null. The cookie-authenticated POSTs attach
|
||||
* the partition's httpOnly Plaud session, so every target host this module talks
|
||||
* to must pass through here first: a malformed or tampered base/redirect must
|
||||
* never be able to steer those cookies at an arbitrary origin. Exported for
|
||||
* unit testing.
|
||||
*/
|
||||
export function normalizeTrustedOrigin(raw: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const trusted =
|
||||
ALLOWED_EXACT_HOSTS.has(host) || host.endsWith(ALLOWED_HOST_SUFFIX);
|
||||
return trusted ? `${parsed.protocol}//${parsed.host}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a validated regional origin from a redirect envelope, or null when the
|
||||
* body is not a redirect or the target is not a trusted Plaud https host.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function readRegionRedirect(envelope: Record<string, unknown>): string | null {
|
||||
if (envelope.status !== STATUS_REGION_REDIRECT) {
|
||||
return null;
|
||||
}
|
||||
const data = envelope.data;
|
||||
if (!isRecord(data)) {
|
||||
return null;
|
||||
}
|
||||
const domains = data.domains;
|
||||
if (!isRecord(domains)) {
|
||||
return null;
|
||||
}
|
||||
const api = domains.api;
|
||||
return typeof api === 'string' ? normalizeTrustedOrigin(api) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the workspace token + rotated refresh token from a mint response,
|
||||
* or null when the envelope is not a success carrying a `workspace_token`
|
||||
* string. Exported for unit testing.
|
||||
*/
|
||||
export function parseWorkspaceTokenResponse(
|
||||
envelope: Record<string, unknown>,
|
||||
): { token: string; refreshToken: string | null } | null {
|
||||
if (envelope.status !== STATUS_OK) {
|
||||
return null;
|
||||
}
|
||||
const data = envelope.data;
|
||||
if (!isRecord(data)) {
|
||||
return null;
|
||||
}
|
||||
const token = data.workspace_token;
|
||||
if (typeof token !== 'string' || token.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const refresh = data.refresh_token;
|
||||
return {
|
||||
token,
|
||||
refreshToken:
|
||||
typeof refresh === 'string' && refresh.length > 0 ? refresh : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Headers common to Plaud's browser API calls, minus auth (cookies) and the
|
||||
// per-request nonce. app-platform/edit-from track the token's client_id so the
|
||||
// server's parse-mode check agrees (see plaud-client-re.ts). x-device-id is
|
||||
// deliberately omitted: it lives in the partition's localStorage, out of reach
|
||||
// without a window, and the mint call authenticates by cookie. If the server
|
||||
// turns out to require it, this path fails and the refresh reports failure.
|
||||
function baseHeaders(clientId: string): Record<string, string> {
|
||||
return {
|
||||
accept: 'application/json, text/plain, */*',
|
||||
'content-type': 'application/json',
|
||||
'app-platform': clientId,
|
||||
'app-language': 'en',
|
||||
'edit-from': clientId,
|
||||
origin: 'https://web.plaud.ai',
|
||||
referer: 'https://web.plaud.ai/',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the two-step direct refresh. Returns a candidate WT (never stores it),
|
||||
* or null on any failure so the caller can pause and prompt the user. Never
|
||||
* throws.
|
||||
*/
|
||||
export async function performNetRefresh(
|
||||
deps: NetRefreshDeps,
|
||||
): Promise<NetRefreshResult | null> {
|
||||
// Wrap the injected sink so a throwing logger can never break this function's
|
||||
// "never throws" contract (it runs in the background refresh timer).
|
||||
const log = (message: string, payload?: unknown): void => {
|
||||
try {
|
||||
deps.log?.(message, payload);
|
||||
} catch {
|
||||
// A logging failure must not propagate into the timer or the sync tick.
|
||||
}
|
||||
};
|
||||
try {
|
||||
const wid = extractWorkspaceId(deps.currentToken);
|
||||
if (wid === null) {
|
||||
log('net refresh aborted: stored token has no wid claim to mint against');
|
||||
return null;
|
||||
}
|
||||
const clientId = readJwtPayloadClaim(deps.currentToken, 'client_id') ?? 'web';
|
||||
const headers = baseHeaders(clientId);
|
||||
|
||||
// Validate the starting host BEFORE any cookie-bearing POST: a malformed
|
||||
// or tampered stored base must never send the partition's Plaud session
|
||||
// cookies to a non-Plaud origin. Redirect targets are validated the same
|
||||
// way inside the loop.
|
||||
let base = normalizeTrustedOrigin(deps.baseUrl);
|
||||
if (base === null) {
|
||||
log('net refresh aborted: stored base URL is not a trusted Plaud host', {
|
||||
baseUrl: deps.baseUrl,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let apiBaseUrl: string | null = null;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const res = await deps.post(
|
||||
`${base}${REFRESH_USER_TOKEN_PATH}`,
|
||||
'{}',
|
||||
headers,
|
||||
);
|
||||
const envelope = parseEnvelope(res.text);
|
||||
if (envelope === null) {
|
||||
log('net refresh step 1 (refresh-user-token): non-JSON response', {
|
||||
httpStatus: res.status,
|
||||
body: bodySnippet(res.text),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (envelope.status === STATUS_OK) {
|
||||
break;
|
||||
}
|
||||
const redirect = readRegionRedirect(envelope);
|
||||
if (redirect === null || attempt === 1) {
|
||||
log('net refresh step 1 (refresh-user-token): non-OK envelope', {
|
||||
httpStatus: res.status,
|
||||
envelopeStatus: envelope.status,
|
||||
message: envelopeMessage(envelope),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
base = redirect;
|
||||
apiBaseUrl = redirect;
|
||||
}
|
||||
|
||||
// Step 2: mint the workspace token. The fresh UT cookie from step 1 is
|
||||
// already in the session jar, so this call just needs the cookie + wid.
|
||||
const mint = await deps.post(
|
||||
`${base}${WORKSPACE_TOKEN_PATH_PREFIX}${wid}`,
|
||||
'{}',
|
||||
headers,
|
||||
);
|
||||
const mintEnvelope = parseEnvelope(mint.text);
|
||||
if (mintEnvelope === null) {
|
||||
log('net refresh step 2 (workspace token mint): non-JSON response', {
|
||||
httpStatus: mint.status,
|
||||
body: bodySnippet(mint.text),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const parsed = parseWorkspaceTokenResponse(mintEnvelope);
|
||||
if (parsed === null) {
|
||||
log('net refresh step 2 (workspace token mint): no workspace_token', {
|
||||
httpStatus: mint.status,
|
||||
envelopeStatus: mintEnvelope.status,
|
||||
message: envelopeMessage(mintEnvelope),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
log('net refresh succeeded via the direct (windowless) path');
|
||||
return {
|
||||
token: parsed.token,
|
||||
refreshToken: parsed.refreshToken,
|
||||
apiBaseUrl,
|
||||
};
|
||||
} catch (err) {
|
||||
// A refresh bug must never throw into the timer or the auto-sync tick.
|
||||
log('net refresh threw', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Electron runtime adapter (guarded; untyped remote surface) --------------
|
||||
|
||||
interface ElectronSessionFetchResponse {
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
interface ElectronSessionLike {
|
||||
fetch?(
|
||||
url: string,
|
||||
options: {
|
||||
method: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
credentials?: 'include' | 'omit' | 'same-origin';
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ElectronSessionFetchResponse>;
|
||||
}
|
||||
interface ElectronRemoteLike {
|
||||
session?: { fromPartition(partition: string): ElectronSessionLike };
|
||||
}
|
||||
interface ElectronLike {
|
||||
remote?: ElectronRemoteLike;
|
||||
}
|
||||
|
||||
type SessionWithFetch = ElectronSessionLike & {
|
||||
fetch: NonNullable<ElectronSessionLike['fetch']>;
|
||||
};
|
||||
|
||||
function hasFetch(session: ElectronSessionLike): session is SessionWithFetch {
|
||||
return typeof session.fetch === 'function';
|
||||
}
|
||||
|
||||
function requireElectron(): ElectronLike | null {
|
||||
const req = (window as { require?: (id: string) => unknown }).require;
|
||||
if (typeof req !== 'function') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return req('electron') as ElectronLike;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a session-bound POST over the sign-in partition using Electron's
|
||||
* `session.fetch` (Electron 28+, present on Obsidian 1.11.4's runtime). Returns
|
||||
* null when the remote/session/fetch surface is unavailable, in which case the
|
||||
* silent refresh cannot run on this build.
|
||||
*/
|
||||
export function buildPartitionPost(): SessionPost | null {
|
||||
const session = requireElectron()?.remote?.session?.fromPartition(
|
||||
PLAUD_PARTITION,
|
||||
);
|
||||
if (session === undefined || !hasFetch(session)) {
|
||||
return null;
|
||||
}
|
||||
return async (url, body, headers) => {
|
||||
// Member call (not a detached reference) so `this` stays bound to session.
|
||||
// credentials:'include' is REQUIRED: these POSTs authenticate purely with
|
||||
// the partition's httpOnly Plaud cookies, and fetch's default same-origin
|
||||
// credentials mode would send none (the call has no document origin that
|
||||
// matches api.plaud.ai), so the refresh would silently 401/return empty.
|
||||
// The abort signal bounds the whole call (request AND body read): the
|
||||
// caller holds refreshInFlight/reauthInFlight until this settles, so a
|
||||
// stalled request with no timeout would wedge every future refresh and
|
||||
// the manual Sign in until restart.
|
||||
const res = await session.fetch(url, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { ...headers },
|
||||
credentials: 'include',
|
||||
signal: AbortSignal.timeout(NET_REFRESH_TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
return { status: res.status, text };
|
||||
};
|
||||
}
|
||||
187
plaud-refresh.ts
187
plaud-refresh.ts
|
|
@ -1,187 +0,0 @@
|
|||
// JWT helpers and scheduling math for the silent session refresh (Release B).
|
||||
//
|
||||
// The refresh itself is the direct, windowless two-step call in
|
||||
// plaud-refresh-net.ts (POST /auth/refresh-user-token over the partition
|
||||
// cookies, then the workspace-token mint). The background NEVER opens a
|
||||
// window: when the windowless refresh fails, the caller pauses and prompts the
|
||||
// user to reconnect. The old hidden-window fallback is gone; its login page
|
||||
// leaked web.plaud.ai popup tabs into the default browser.
|
||||
//
|
||||
// These pure helpers decode the WT's `exp`, compute the proactive-refresh
|
||||
// schedule (including the 32-bit setTimeout clamp and the skip-if-fresh
|
||||
// guard), and validate a captured token is a fresh WT (fail-safe: never
|
||||
// replace the stored token with something that is not a future-dated WT).
|
||||
|
||||
// A JWT, optionally bearer-prefixed.
|
||||
const JWT_RE = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/;
|
||||
const ACCESS_TOKEN_TYP = 'WT';
|
||||
|
||||
// Silent-refresh scheduling. Refresh this long before the access token's `exp`
|
||||
// so a slow round-trip still lands before expiry.
|
||||
export const REFRESH_LEAD_MS = 5 * 60 * 1000;
|
||||
// Never schedule a refresh sooner than this, so a past-due or about-to-expire
|
||||
// token triggers one prompt refresh rather than a tight loop.
|
||||
export const REFRESH_MIN_DELAY_MS = 30 * 1000;
|
||||
// When the stored token is opaque (no decodable `exp`), poll at this cadence as
|
||||
// a fallback so a session can still be kept alive.
|
||||
export const REFRESH_OPAQUE_FALLBACK_MS = 60 * 60 * 1000;
|
||||
// window.setTimeout stores its delay as a 32-bit signed int; anything above
|
||||
// 2,147,483,647 ms (about 24.8 days) overflows and fires almost immediately.
|
||||
// A long-lived token (a ~137-day exp was observed in the wild) would otherwise
|
||||
// schedule an instant, spurious refresh whose failure loop reopened a sign-in
|
||||
// window every backoff interval. Cap the schedule well under the ceiling; an
|
||||
// early wake-up is a no-op because the runner re-checks token life before
|
||||
// refreshing (isRefreshDue).
|
||||
export const REFRESH_MAX_DELAY_MS = 20 * 24 * 60 * 60 * 1000;
|
||||
// Backoff after a failed refresh, indexed by (failure streak - 1) and clamped to
|
||||
// the last entry. Keeps a dead 30-day refresh token from hammering the endpoint
|
||||
// (Plaud counts refreshes per hour) while still retrying periodically.
|
||||
export const REFRESH_RETRY_BACKOFF_MS = [
|
||||
5 * 60 * 1000,
|
||||
15 * 60 * 1000,
|
||||
30 * 60 * 1000,
|
||||
60 * 60 * 1000,
|
||||
];
|
||||
|
||||
/** Reads the JWT header `typ`, or null when the value is not a decodable JWT. */
|
||||
export function jwtTyp(value: string): string | null {
|
||||
const match = value.replace(/^bearer\s+/i, '').match(JWT_RE);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
const header = decodeJwtSegment(match[0].split('.')[0]);
|
||||
return header !== null && typeof header.typ === 'string' ? header.typ : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the JWT `exp` claim as unix MILLISECONDS, or null when the token is not
|
||||
* a decodable JWT or has no numeric `exp`. Used to schedule a refresh ~5 min
|
||||
* before the access token expires and to enforce the fail-safe future-exp check.
|
||||
*/
|
||||
export function decodeJwtExpMs(value: string): number | null {
|
||||
const match = value.replace(/^bearer\s+/i, '').match(JWT_RE);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
const parts = match[0].split('.');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const payload = decodeJwtSegment(parts[1]);
|
||||
if (payload === null || typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
|
||||
return null;
|
||||
}
|
||||
return payload.exp * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the stored token is close enough to expiry (inside the lead
|
||||
* window) that a proactive refresh should actually run. An opaque token (no
|
||||
* decodable `exp`) counts as due, matching the hourly-poll fallback. Guards
|
||||
* the scheduled runner so an early timer fire (the REFRESH_MAX_DELAY_MS clamp,
|
||||
* or a backoff retry that outlived its failure) never refreshes a token that
|
||||
* still has ample life.
|
||||
*/
|
||||
export function isRefreshDue(token: string, nowMs: number): boolean {
|
||||
const expMs = decodeJwtExpMs(token);
|
||||
return expMs === null || expMs - nowMs <= REFRESH_LEAD_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay until the next proactive-refresh timer fire. On a failure streak the
|
||||
* delay is the retry backoff; otherwise it is expiry-driven (`exp` minus the
|
||||
* lead), floored at REFRESH_MIN_DELAY_MS for past-due tokens, with an hourly
|
||||
* poll for opaque tokens. Always clamped to REFRESH_MAX_DELAY_MS so the value
|
||||
* stays inside setTimeout's 32-bit range.
|
||||
*/
|
||||
export function computeRefreshDelay(
|
||||
token: string,
|
||||
failureStreak: number,
|
||||
nowMs: number,
|
||||
): number {
|
||||
let delay: number;
|
||||
if (failureStreak > 0) {
|
||||
const index = Math.min(failureStreak - 1, REFRESH_RETRY_BACKOFF_MS.length - 1);
|
||||
delay = REFRESH_RETRY_BACKOFF_MS[index];
|
||||
} else {
|
||||
const expMs = decodeJwtExpMs(token);
|
||||
delay =
|
||||
expMs === null
|
||||
? REFRESH_OPAQUE_FALLBACK_MS
|
||||
: Math.max(REFRESH_MIN_DELAY_MS, expMs - REFRESH_LEAD_MS - nowMs);
|
||||
}
|
||||
return Math.min(delay, REFRESH_MAX_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the proactive refresh should stop re-arming: it has failed through
|
||||
* the entire backoff ladder on an already-due token that has NO stored refresh
|
||||
* token. A missing WRT is the SSO/paste signature (the embedded-window sign-in
|
||||
* keeps a WRT, a browser/bookmarklet capture blanks it), and such a session has
|
||||
* no partition cookies for the windowless refresh to authenticate with, so it
|
||||
* can never self-heal unattended; retrying just hammers Plaud's rate-limited
|
||||
* refresh endpoint. Recovery comes from the one-click Reconnect notice instead.
|
||||
*
|
||||
* A session WITH a stored WRT (an email/password sign-in) is left alone even on
|
||||
* an exhausted streak: its failures may be transient (network/Plaud blip) and
|
||||
* its partition session is still refreshable, so the existing backoff keeps
|
||||
* retrying and recovers on its own. Kept pure so the give-up rule is
|
||||
* unit-testable without the plugin runtime.
|
||||
*/
|
||||
export function shouldStopProactiveRefresh(
|
||||
token: string,
|
||||
failureStreak: number,
|
||||
hasRefreshToken: boolean,
|
||||
nowMs: number,
|
||||
): boolean {
|
||||
return (
|
||||
!hasRefreshToken &&
|
||||
failureStreak >= REFRESH_RETRY_BACKOFF_MS.length &&
|
||||
isRefreshDue(token, nowMs)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string claim from the JWT payload, or null when the value is not a
|
||||
* decodable JWT or the claim is absent / not a string. Used by the direct
|
||||
* net-refresh path to pull `wid` (workspace id) and `client_id` off the stored
|
||||
* workspace token without a separate API round-trip.
|
||||
*/
|
||||
export function readJwtPayloadClaim(value: string, claim: string): string | null {
|
||||
const match = value.replace(/^bearer\s+/i, '').match(JWT_RE);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
const parts = match[0].split('.');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
const payload = decodeJwtSegment(parts[1]);
|
||||
if (payload === null) {
|
||||
return null;
|
||||
}
|
||||
const raw = payload[claim];
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : null;
|
||||
}
|
||||
|
||||
function decodeJwtSegment(seg: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const b64 = seg.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||
// atob is always present in Obsidian's Electron renderer (and Node 18+);
|
||||
// avoid the Node `Buffer` global, which is untyped in the marketplace
|
||||
// scan's type-checked lint and trips the no-unsafe-* rules.
|
||||
return JSON.parse(atob(padded)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the token decodes as an access token (typ WT) with a future exp. */
|
||||
export function isFreshAccessToken(token: string, nowMs: number): boolean {
|
||||
if (jwtTyp(token) !== ACCESS_TOKEN_TYP) {
|
||||
return false;
|
||||
}
|
||||
const exp = decodeJwtExpMs(token);
|
||||
return exp !== null && exp > nowMs;
|
||||
}
|
||||
|
|
@ -103,9 +103,8 @@ export function readTokenClientId(value: string): string | null {
|
|||
* a real re-auth writes a fresh token. `nowMs` is injectable for tests.
|
||||
*
|
||||
* A paired REFRESH token (typ `WRT`) is rejected outright: it also carries a
|
||||
* future `exp` and a `client_id`, but the data API `-3901`s it, and storing it
|
||||
* as the credential would also fool the refresh-neutralization guard (which
|
||||
* treats any non-`WT` token as a long-lived user token).
|
||||
* future `exp` and a `client_id`, but the data API `-3901`s it, so storing it
|
||||
* as the credential would strand the session on an unusable token.
|
||||
*
|
||||
* Validate the claims, never the key name.
|
||||
*/
|
||||
|
|
|
|||
29
reconnect-routing.ts
Normal file
29
reconnect-routing.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Decides which sign-in surface a Reconnect prompt should open. Pure logic,
|
||||
// extracted from the plugin class so the routing (including the pre-0.32.0
|
||||
// migration fallback) is unit-testable without an Obsidian harness.
|
||||
|
||||
export type SignInMethod = '' | 'window' | 'browser';
|
||||
|
||||
/**
|
||||
* True when Reconnect should open the embedded email sign-in window; false
|
||||
* routes to the browser/bookmarklet flow (Google and Apple cannot complete in
|
||||
* the embedded window). Prefers the recorded sign-in method. A session from
|
||||
* before 0.32.0 has none recorded; for those, a stored legacy refresh token
|
||||
* (typ WRT) is the signal, because only the embedded email window ever stored
|
||||
* one. A reader failure counts as "no legacy token": misrouting a legacy email
|
||||
* user to the browser flow still lets them sign in, while the reverse
|
||||
* (an SSO user sent to the embedded window) dead-ends.
|
||||
*/
|
||||
export function preferWindowForReconnect(
|
||||
method: SignInMethod,
|
||||
readLegacyRefreshToken: () => string | null,
|
||||
): boolean {
|
||||
if (method === 'window') return true;
|
||||
if (method === 'browser') return false;
|
||||
try {
|
||||
const legacy = readLegacyRefreshToken();
|
||||
return legacy !== null && legacy.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue