Skip to content

Commit a7f1a05

Browse files
committed
fix(cache): harden invalidation helpers + align fingerprint expiry
Two bot-review findings on commit 6cbb21d: 1. **Invalidation helpers could mask successful mutations** (cursor-bot, src/lib/response-cache.ts:717 + src/lib/api/issues.ts:571). `invalidateCachedResponsesMatching` called `getIdentityFingerprint()` outside the `readdir` try/catch, so a DB failure inside the fingerprint lookup could throw up through callers. In `updateIssueStatus` (org-scoped path) the `invalidateIssueCaches` call was also unguarded, and in `mergeIssues` the per-ID invalidation sat inside the outer try/catch meant to only handle 204s — either could turn a successful mutation into a user-visible error. Fix: push the guard into the helpers themselves. Every invalidation helper now wraps its body in try/catch with an explicit "Never throws" contract in the JSDoc. Callers no longer need to remember; future call sites get the same safety automatically. The existing caller-side try/catch in `createProject` / `deleteProject` stays as defense-in-depth. 2. **`getIdentityFingerprint` skipped expired-token check vs `getAuthConfig`** (cursor-bot, src/lib/db/auth.ts:325). An expired access-only OAuth row (no refresh_token) is unusable — `getAuthConfig` falls past it to the env token, and the API client sends the env token on the next request. But the old fingerprint still hashed the stale access token, bucketing cache reads/writes under a dead identity while requests went under the env identity. Result: stale cached data from a previous user could leak to the env-token user. Fix: mirror `getAuthConfig`'s expiry semantics. Skip the access token when it has `expires_at` in the past and no refresh token; fall through to the env-token / anonymous path. Added two tests covering this and the access-token-rotation-with-refresh-token stability case.
1 parent 6cbb21d commit a7f1a05

6 files changed

Lines changed: 212 additions & 121 deletions

File tree

AGENTS.md

Lines changed: 57 additions & 60 deletions
Large diffs are not rendered by default.

src/lib/api/issues.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -717,24 +717,30 @@ export async function getSharedIssue(
717717
* operation, not per-issue-affected, since the list sweep is a full
718718
* cache-directory scan.
719719
*
720-
* Best-effort: failures are swallowed inside the helper — the mutation
721-
* has already succeeded and a stale cache entry is strictly better than
722-
* surfacing a filesystem error to the user.
720+
* **Never throws.** Called from post-mutation paths where the server
721+
* has already committed the change; surfacing a cache-housekeeping
722+
* error to the caller would cause the mutation to appear failed. All
723+
* failures are swallowed — stale cache is strictly better than a false
724+
* error on a successful mutation.
723725
*/
724726
async function invalidateIssueDetailCaches(
725727
regionUrl: string,
726728
orgSlug: string,
727729
issueId: string
728730
): Promise<void> {
729-
const base = stripTrailingSlash(regionUrl);
730-
const encodedOrg = encodeURIComponent(orgSlug);
731-
const encodedId = encodeURIComponent(issueId);
732-
await Promise.all([
733-
invalidateCachedResponse(
734-
`${base}/api/0/organizations/${encodedOrg}/issues/${encodedId}/`
735-
),
736-
invalidateCachedResponse(`${base}/api/0/issues/${encodedId}/`),
737-
]);
731+
try {
732+
const base = stripTrailingSlash(regionUrl);
733+
const encodedOrg = encodeURIComponent(orgSlug);
734+
const encodedId = encodeURIComponent(issueId);
735+
await Promise.all([
736+
invalidateCachedResponse(
737+
`${base}/api/0/organizations/${encodedOrg}/issues/${encodedId}/`
738+
),
739+
invalidateCachedResponse(`${base}/api/0/issues/${encodedId}/`),
740+
]);
741+
} catch {
742+
// Non-fatal — see JSDoc.
743+
}
738744
}
739745

740746
/**
@@ -746,16 +752,22 @@ async function invalidateIssueDetailCaches(
746752
* {@link invalidateIssueDetailCaches} and call
747753
* {@link invalidateOrgIssueList} once at the end, to avoid N+1 full
748754
* cache scans.
755+
*
756+
* **Never throws** — same reasoning as {@link invalidateIssueDetailCaches}.
749757
*/
750758
async function invalidateIssueCaches(
751759
regionUrl: string,
752760
orgSlug: string,
753761
issueId: string
754762
): Promise<void> {
755-
await Promise.all([
756-
invalidateIssueDetailCaches(regionUrl, orgSlug, issueId),
757-
invalidateOrgIssueList(regionUrl, orgSlug),
758-
]);
763+
try {
764+
await Promise.all([
765+
invalidateIssueDetailCaches(regionUrl, orgSlug, issueId),
766+
invalidateOrgIssueList(regionUrl, orgSlug),
767+
]);
768+
} catch {
769+
// Non-fatal — see JSDoc.
770+
}
759771
}
760772

761773
/**
@@ -764,13 +776,19 @@ async function invalidateIssueCaches(
764776
* The list endpoint is keyed by query string (sort, cursor, query,
765777
* statsPeriod, ...) so we have to do a prefix sweep rather than a
766778
* single URL invalidation.
779+
*
780+
* **Never throws** — same reasoning as {@link invalidateIssueDetailCaches}.
767781
*/
768782
async function invalidateOrgIssueList(
769783
regionUrl: string,
770784
orgSlug: string
771785
): Promise<void> {
772-
const base = stripTrailingSlash(regionUrl);
773-
await invalidateCachedResponsesMatching(
774-
`${base}/api/0/organizations/${encodeURIComponent(orgSlug)}/issues/`
775-
);
786+
try {
787+
const base = stripTrailingSlash(regionUrl);
788+
await invalidateCachedResponsesMatching(
789+
`${base}/api/0/organizations/${encodeURIComponent(orgSlug)}/issues/`
790+
);
791+
} catch {
792+
// Non-fatal — see JSDoc.
793+
}
776794
}

src/lib/api/projects.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,23 @@ export async function deleteProject(
266266
*
267267
* Wraps the pattern-match invalidator to keep the call sites short and
268268
* document *why* we clear these specific prefixes.
269+
*
270+
* **Never throws.** Called from post-mutation paths where the server
271+
* has already committed the change; surfacing a cache-housekeeping
272+
* error to the caller would cause the mutation to appear failed. This
273+
* lets `createProject` / `deleteProject` drop their own try/catch
274+
* wrappers — the helper is self-contained.
269275
*/
270276
async function invalidateOrgProjectCache(orgSlug: string): Promise<void> {
271-
const regionUrl = await resolveOrgRegion(orgSlug);
272-
const base = stripTrailingSlash(regionUrl);
273-
await invalidateCachedResponsesMatching(
274-
`${base}/api/0/organizations/${encodeURIComponent(orgSlug)}/projects/`
275-
);
277+
try {
278+
const regionUrl = await resolveOrgRegion(orgSlug);
279+
const base = stripTrailingSlash(regionUrl);
280+
await invalidateCachedResponsesMatching(
281+
`${base}/api/0/organizations/${encodeURIComponent(orgSlug)}/projects/`
282+
);
283+
} catch {
284+
// Non-fatal — see JSDoc.
285+
}
276286
}
277287

278288
/** Result of searching for projects by slug across all organizations. */

src/lib/db/auth.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,22 +309,36 @@ export function getIdentityFingerprint(): string {
309309
// Otherwise prefer the OAuth refresh token when present (stable
310310
// across hourly access-token rotation). Access-only rows without a
311311
// refresh_token fall through to hashing the access token itself.
312+
//
313+
// Must mirror `getAuthConfig`'s expiry semantics: an access-only row
314+
// (no refresh_token) with a past `expires_at` is unusable — the API
315+
// client will fall back to the env token for the actual request. If
316+
// we still hash it here, the cache namespace diverges from the
317+
// identity that sends the request and we could serve a previous
318+
// user's cached data to the env-token user.
312319
const dbRow = withDbSpan("getIdentityFingerprint", () => {
313320
const db = getDatabase();
314321
return db
315-
.query("SELECT token, refresh_token FROM auth WHERE id = 1")
322+
.query("SELECT token, refresh_token, expires_at FROM auth WHERE id = 1")
316323
.get() as
317-
| { token: string | null; refresh_token: string | null }
324+
| {
325+
token: string | null;
326+
refresh_token: string | null;
327+
expires_at: number | null;
328+
}
318329
| undefined;
319330
});
320331
if (dbRow?.refresh_token) {
332+
// Keyed by refresh_token — stable across access-token rotation,
333+
// including the about-to-expire case where the API client will
334+
// refresh the token before the next request.
321335
return hashIdentity("oauth", dbRow.refresh_token);
322336
}
323-
if (dbRow?.token) {
337+
if (dbRow?.token && !(dbRow.expires_at && Date.now() > dbRow.expires_at)) {
324338
return hashIdentity("oauth-access", dbRow.token);
325339
}
326340

327-
// Finally, fall back to the env token when no OAuth is stored.
341+
// Finally, fall back to the env token when no usable OAuth is stored.
328342
const envToken = getRawEnvToken();
329343
if (envToken) {
330344
return hashIdentity("env", envToken);

src/lib/response-cache.ts

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -697,45 +697,57 @@ export async function invalidateCachedResponse(url: string): Promise<void> {
697697
export async function invalidateCachedResponsesMatching(
698698
prefix: string
699699
): Promise<void> {
700-
const cacheDir = getCacheDir();
701-
let files: string[];
700+
// Whole-body try/catch: this helper is called from post-mutation
701+
// paths (`project create`, `issue resolve`, `mergeIssues`, …) where
702+
// a successful mutation has already returned from the API. Any throw
703+
// from here — \`getIdentityFingerprint\` hitting a DB error, a
704+
// filesystem race, an unexpected IO failure — must not propagate,
705+
// because the caller cannot undo the mutation and users would see
706+
// the operation as "failed" despite the server having accepted it.
707+
// A stale cache entry is strictly preferable to a false error.
702708
try {
703-
files = await readdir(cacheDir);
704-
} catch (error) {
705-
if (isNotFound(error)) {
709+
const cacheDir = getCacheDir();
710+
let files: string[];
711+
try {
712+
files = await readdir(cacheDir);
713+
} catch (error) {
714+
if (isNotFound(error)) {
715+
return;
716+
}
717+
// Unknown read error — best-effort: skip
706718
return;
707719
}
708-
// Unknown read error — best-effort: skip
709-
return;
710-
}
711720

712-
const jsonFiles = files.filter((f) => f.endsWith(".json"));
713-
if (jsonFiles.length === 0) {
714-
return;
715-
}
721+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
722+
if (jsonFiles.length === 0) {
723+
return;
724+
}
716725

717-
const currentIdentity = getIdentityFingerprint();
726+
const currentIdentity = getIdentityFingerprint();
718727

719-
await cacheIO.map(jsonFiles, async (file) => {
720-
const filePath = join(cacheDir, file);
721-
try {
722-
const raw = await readFile(filePath, "utf-8");
723-
const entry = JSON.parse(raw) as CacheEntry;
724-
// Identity gate: only delete entries the current identity wrote.
725-
// Missing `identity` field = legacy entry from before this check
726-
// existed; skip rather than risk crossing identities.
727-
if (entry.identity !== currentIdentity) {
728-
return;
729-
}
730-
if (entry.url?.startsWith(prefix)) {
731-
await unlink(filePath).catch(() => {
732-
// Best-effort: another process may have deleted it
733-
});
728+
await cacheIO.map(jsonFiles, async (file) => {
729+
const filePath = join(cacheDir, file);
730+
try {
731+
const raw = await readFile(filePath, "utf-8");
732+
const entry = JSON.parse(raw) as CacheEntry;
733+
// Identity gate: only delete entries the current identity wrote.
734+
// Missing `identity` field = legacy entry from before this check
735+
// existed; skip rather than risk crossing identities.
736+
if (entry.identity !== currentIdentity) {
737+
return;
738+
}
739+
if (entry.url?.startsWith(prefix)) {
740+
await unlink(filePath).catch(() => {
741+
// Best-effort: another process may have deleted it
742+
});
743+
}
744+
} catch {
745+
// Unparseable or missing — leave to cleanup
734746
}
735-
} catch {
736-
// Unparseable or missing — leave to cleanup
737-
}
738-
});
747+
});
748+
} catch {
749+
// Best-effort — see whole-body comment above.
750+
}
739751
}
740752

741753
/**

test/lib/db/auth.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,44 @@ describe("getIdentityFingerprint", () => {
279279
const oauthFp = getIdentityFingerprint();
280280
expect(envFp).not.toBe(oauthFp);
281281
});
282+
283+
test("expired access-only OAuth token falls through to env token", () => {
284+
// Mirrors getAuthConfig: an expired token with no refresh token is
285+
// unusable — the API client will fall back to the env token for
286+
// the next request. If the fingerprint still used the stale access
287+
// token, cache reads/writes would land under the dead OAuth
288+
// namespace while requests go under the env identity, serving
289+
// another user's cached data.
290+
//
291+
// setAuthToken(token, expiresIn: -1) writes an already-expired row
292+
// with no refresh_token (third arg omitted).
293+
setAuthToken("expired_access", -1);
294+
process.env.SENTRY_AUTH_TOKEN = "env_token";
295+
296+
const fp = getIdentityFingerprint();
297+
298+
// The fingerprint should match the env-token identity, not the
299+
// stale DB token.
300+
delete process.env.SENTRY_AUTH_TOKEN;
301+
// Clear the expired DB row to isolate: anon is the only other
302+
// possibility this case could collapse into.
303+
setAuthToken("", -1); // idempotent clear not available; rely on positive check below
304+
process.env.SENTRY_AUTH_TOKEN = "env_token";
305+
const envOnlyFp = getIdentityFingerprint();
306+
expect(fp).toBe(envOnlyFp);
307+
});
308+
309+
test("expired access-only OAuth token with refresh_token uses the refresh token", () => {
310+
// An expired access token that has a refresh token is still usable
311+
// — the API client will perform an OAuth refresh. Fingerprint
312+
// should key off the stable refresh token, not the (about-to-be-
313+
// rotated) access token.
314+
setAuthToken("expired_access", -1, "live_refresh");
315+
const fp = getIdentityFingerprint();
316+
317+
// Rotate the access token; refresh stays the same. Fingerprint
318+
// must not change.
319+
setAuthToken("fresh_access", 3600, "live_refresh");
320+
expect(getIdentityFingerprint()).toBe(fp);
321+
});
282322
});

0 commit comments

Comments
 (0)