From 44481eb7044a05d0ffd190c44130a4b07f94fa28 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 05:43:40 -0700 Subject: [PATCH 1/4] Add tests for credential-expiry mail-then-claim recovery Covers CL-7209: a credential should stay `active` (and so remain due for a later sweep tick) whenever its reconnect mail was never sent, instead of being claimed as `expired` first and left permanently unnotified on a mail failure or a missing recipient. --- apps/hub/test/credential-expiry-sweep.test.ts | 176 +++++++++++++++++- 1 file changed, 174 insertions(+), 2 deletions(-) diff --git a/apps/hub/test/credential-expiry-sweep.test.ts b/apps/hub/test/credential-expiry-sweep.test.ts index 290bbfd2c..2f7486f0d 100644 --- a/apps/hub/test/credential-expiry-sweep.test.ts +++ b/apps/hub/test/credential-expiry-sweep.test.ts @@ -183,6 +183,43 @@ function notifyDeps(): NotifyDeliveryDeps & { }; } +/** A notify deps whose `mail` throws — simulates a Postgres blip after a + * credential has already been found due for expiry. */ +function throwingNotifyDeps(error: Error): NotifyDeliveryDeps & { + mailed: { tenantId: string; principalId: string }[]; +} { + return { + mailed: [], + mail: async () => { + throw error; + }, + addressing: { + inbox: (recipient) => `${recipient.principalId}@inbox.invalid`, + from: (kind) => `${kind}@notify.invalid`, + }, + dispatch: createInMemoryNotifyDispatchStore(), + sinks: createSinkRegistry(), + }; +} + +/** A notify deps whose `mail` always dedupes — simulates a retry tick + * re-mailing a credential whose notification already went out. */ +function dedupingNotifyDeps(): NotifyDeliveryDeps & { + mailed: { tenantId: string; principalId: string }[]; +} { + return { + mailed: [], + mail: async (items) => + items.map((item) => ({ messageKey: item.externalId, id: null })), + addressing: { + inbox: (recipient) => `${recipient.principalId}@inbox.invalid`, + from: (kind) => `${kind}@notify.invalid`, + }, + dispatch: createInMemoryNotifyDispatchStore(), + sinks: createSinkRegistry(), + }; +} + const now = new Date("2026-08-13T12:00:00.000Z"); describe("tickCredentialExpirySweep", () => { @@ -203,7 +240,7 @@ describe("tickCredentialExpirySweep", () => { ]); }); - test("a credential with no active recipients is claimed but never mailed", async () => { + test("a credential with no active recipients is never claimed, staying due for a later tick", async () => { const store = inMemoryStore([credential({ recipients: [] })]); const notify = notifyDeps(); @@ -214,10 +251,92 @@ describe("tickCredentialExpirySweep", () => { now: () => now, }); - expect(store.claims).toEqual(["cred_1"]); + expect(store.claims).toEqual([]); expect(notify.mailed).toEqual([]); }); + test("a mail failure leaves the credential unclaimed instead of expiring it silently", async () => { + const store = inMemoryStore([credential()]); + const notify = throwingNotifyDeps(new Error("postgres blip")); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual([]); + + // The next tick sees it still `active` and can finish the job. + const retryNotify = notifyDeps(); + await tickCredentialExpirySweep({ + store, + notify: retryNotify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual(["cred_1"]); + expect(retryNotify.mailed).toEqual([ + { tenantId: "tnt_1", principalId: "prn_1" }, + ]); + }); + + test("a mail that dedupes (already sent by a prior tick) still claims the credential", async () => { + const store = inMemoryStore([credential()]); + const notify = dedupingNotifyDeps(); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual(["cred_1"]); + }); + + test("one candidate's mail failure does not abandon the rest of the tick", async () => { + const store = inMemoryStore([ + credential({ credentialId: "cred_1" }), + credential({ credentialId: "cred_2" }), + ]); + const mailed: { tenantId: string; principalId: string }[] = []; + const notify: NotifyDeliveryDeps = { + mail: async (items, opts) => { + if (items[0]?.externalId === "cred_1") { + throw new Error("postgres blip"); + } + return items.map((item, index) => { + const id = `mail-${index}`; + mailed.push({ + tenantId: item.tenantId, + principalId: item.principalId, + }); + opts?.enqueue?.({ id, item }); + return { messageKey: item.externalId, id }; + }); + }, + addressing: { + inbox: (recipient) => `${recipient.principalId}@inbox.invalid`, + from: (kind) => `${kind}@notify.invalid`, + }, + dispatch: createInMemoryNotifyDispatchStore(), + sinks: createSinkRegistry(), + }; + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual(["cred_2"]); + expect(mailed).toEqual([{ tenantId: "tnt_1", principalId: "prn_1" }]); + }); + test("a credential not yet expired is neither claimed nor mailed", async () => { const store = inMemoryStore([ credential({ expiresAt: "2026-08-13T13:00:00.000Z" }), @@ -331,6 +450,59 @@ describe("tickCredentialExpirySweep — MCP oauth_token refresh (CL-6207)", () = ]); }); + test("a failed refresh with no active recipients is never claimed, staying due for a later tick", async () => { + const store = inMemoryStore([], [mcpCredential({ recipients: [] })]); + const notify = notifyDeps(); + const refreshMcpTokens = async (): Promise => ({ + ok: false, + message: "invalid_grant: refresh token revoked", + }); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + refreshMcpTokens, + now: () => now, + }); + + expect(store.mcpClaims).toEqual([]); + expect(notify.mailed).toEqual([]); + }); + + test("a failed refresh whose reconnect mail also fails leaves the credential unclaimed", async () => { + const store = inMemoryStore([], [mcpCredential()]); + const notify = throwingNotifyDeps(new Error("postgres blip")); + const refreshMcpTokens = async (): Promise => ({ + ok: false, + message: "invalid_grant: refresh token revoked", + }); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + refreshMcpTokens, + now: () => now, + }); + + expect(store.mcpClaims).toEqual([]); + + const retryNotify = notifyDeps(); + await tickCredentialExpirySweep({ + store, + notify: retryNotify, + hubUrl: HUB_URL, + refreshMcpTokens, + now: () => now, + }); + + expect(store.mcpClaims).toEqual(["cred_mcp_1"]); + expect(retryNotify.mailed).toEqual([ + { tenantId: "tnt_1", principalId: "prn_1" }, + ]); + }); + test("an api_key mcp credential is never a refresh candidate", async () => { const store = inMemoryStore( [], From ecc837c3dc0e11d2358ae4d5990574a051128f13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 05:44:51 -0700 Subject: [PATCH 2/4] Recover credential-expiry deliveries instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tickCredentialExpirySweep claimed a credential as expired before mailing its reconnect nudge. A throw from the mail step (or zero active recipients) left the credential permanently expired with no notification ever sent and no later tick reconsidering it, since findDueCredentialExpiries only looks at active rows. Reorder to mail first and claim only once that succeeds (or is a harmless dedupe of an already-sent notification — a credential-expired event keys on credentialId alone, not the tick). A credential with no recipient, or whose mail attempt throws, is left active so the sweep's own periodic due-scan naturally retries it later, instead of adding a separate retry mechanism. This means a credential can now stay active past its expiresAt for longer than one tick when nobody can be notified yet or delivery keeps failing; other code checking `active` for "still usable" should keep that in mind. Each candidate's mail attempt is also now isolated in its own try/catch reported through reportError, so one candidate's failure no longer aborts the rest of the tick's due and refreshable candidates. Same reorder applied to the MCP-refresh-failure branch, which had the identical claim-then-mail shape. --- apps/hub/src/credential-expiry-sweep.ts | 71 +++++++++++++++++-------- 1 file changed, 50 insertions(+), 21 deletions(-) diff --git a/apps/hub/src/credential-expiry-sweep.ts b/apps/hub/src/credential-expiry-sweep.ts index 60e894418..6ab82d07b 100644 --- a/apps/hub/src/credential-expiry-sweep.ts +++ b/apps/hub/src/credential-expiry-sweep.ts @@ -28,9 +28,11 @@ import { type } from "arktype"; import { credential, principal, provider } from "@intx/db/schema"; import type { DB } from "@intx/db"; import { getLogger } from "@intx/log"; +import { reportError } from "@corbits/error-sink"; import { deliverCredentialMail, findDueCredentialExpiries, + type CredentialExpiredNotification, type ExpiringCredential, type NotifyDeliveryDeps, } from "@corbits/notify"; @@ -336,8 +338,52 @@ export type CredentialExpirySweepDeps = { }; /** - * One sweep: claim-and-mail every Hugging-Face-style credential expiry - * due at `now`, then refresh (or, on refresh failure, claim-and-mail) + * Mail a credential-expiry reconnect nudge, then claim the credential as + * `expired` only once that mail has gone out (or is already out — a + * `credential-expired` event dedupes on `credentialId` alone, so a + * redelivery from a retried tick is a harmless no-op, never a second + * mail). Claiming is deliberately the LAST step: `active` is the durable + * "still needs a decision" state, and this function leaves a credential + * `active` whenever that decision (mail sent, credential marked expired) + * did not fully land, so the next tick's own due-scan is the retry — + * no separate retry queue is needed. A thrown mail error is reported + * and swallowed here (never `active` → `expired` with the notification + * lost), and never propagates out to abort sibling candidates in the + * same tick. + */ +async function mailThenClaimExpiry( + deps: CredentialExpirySweepDeps, + now: Date, + target: { + readonly credentialId: string; + readonly tenantId: string; + readonly recipients: readonly { tenantId: string; principalId: string }[]; + }, + event: CredentialExpiredNotification, +): Promise { + if (target.recipients.length === 0) { + log.warn`credential ${target.credentialId} has no active recipient to notify; leaving it active until one exists`; + return; + } + try { + await deliverCredentialMail(deps.notify, event); + } catch (err) { + reportError(err, { + operation: "credential-expiry-sweep.deliver", + tenantId: target.tenantId, + extra: { credentialId: target.credentialId }, + }); + return; + } + const claimed = await deps.store.claimExpiry(target.credentialId, now); + // False means another replica already claimed this exact expiry + // between the mail attempt and this claim — not an error. + if (!claimed) return; +} + +/** + * One sweep: mail-then-claim every Hugging-Face-style credential expiry + * due at `now`, then refresh (or, on refresh failure, mail-then-claim) * every MCP oauth_token credential nearing its own real expiry. Exported * (rather than kept as a closure) so a test can drive a single, * deterministic pass without waiting on `setInterval`. @@ -352,16 +398,7 @@ export async function tickCredentialExpirySweep( const due = findDueCredentialExpiries(candidates, now); for (const { credential: expiring, event } of due) { - const claimed = await deps.store.claimExpiry(expiring.credentialId, now); - // False means another replica already claimed this exact expiry - // between `loadActiveCandidates` and this claim — not an error. - if (!claimed) continue; - - if (expiring.recipients.length === 0) { - log.warn`credential ${expiring.credentialId} expired with no active recipient to notify`; - continue; - } - await deliverCredentialMail(deps.notify, event); + await mailThenClaimExpiry(deps, now, expiring, event); } const refreshable = await deps.store.loadRefreshableMcpCandidates( @@ -392,15 +429,7 @@ export async function tickCredentialExpirySweep( } log.warn`mcp oauth refresh failed for credential ${candidate.credentialId} ("${candidate.name}"): ${result.message}`; - const claimed = await deps.store.claimExpiry(candidate.credentialId, now); - // False means another replica already claimed this exact expiry — not an error. - if (!claimed) continue; - - if (candidate.recipients.length === 0) { - log.warn`credential ${candidate.credentialId} expired with no active recipient to notify`; - continue; - } - await deliverCredentialMail(deps.notify, { + await mailThenClaimExpiry(deps, now, candidate, { kind: "credential-expired", tenantId: candidate.tenantId, credentialId: candidate.credentialId, From 48ad3ce83e70df4b6f5a8d3663e675b86ad4451b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 06:07:39 -0700 Subject: [PATCH 3/4] Update docs: flag the non-atomic mail/dispatch seam and its ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deliverNotification's mail write and its dispatch enqueue still have no shared transaction or reconciliation path (CL-7238) — closing that gap needs a seam @corbits/mailbox doesn't expose yet, so it's tracked separately rather than bolted onto this repo. --- packages/notify/src/deliver.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/notify/src/deliver.ts b/packages/notify/src/deliver.ts index 8212e1ec4..505137d32 100644 --- a/packages/notify/src/deliver.ts +++ b/packages/notify/src/deliver.ts @@ -6,6 +6,13 @@ // Fan-out to external sinks is queued strictly after the mail commits, one // dispatch row per (mail row, enabled sink). A sink is never called from this // path: the mail is the durable record, and a copy of it is the worker's job. +// +// That queuing step is NOT atomic with the mail write, and nothing +// reconciles the two if a crash or a throw lands between them (CL-7238): +// a mail row can end up committed with no dispatch row ever queued for it. +// Closing that gap needs a seam `@corbits/mailbox` doesn't expose today +// (an in-transaction hook, or a way to read back an existing row's id by +// key) — see CL-7238 for why this can't be fixed from this package alone. import { parseNotificationEvent, type ApprovalNotification, From 1079991b830108b7bf9b45dcefa240299ab3e8b2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 30 Aug 2026 06:17:46 -0700 Subject: [PATCH 4/4] Bound how long an unnotified credential expiry stays active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the mail-then-claim reorder: leaving a credential `active` forever when nobody can be notified traded a silent notification gap for a silent state-correctness one. The credential is genuinely dead at the provider either way; an unbounded retry turns a transient mail outage (or a slow-to-provision recipient) into a permanent lie in the database, and anything selecting `active` credentials to actually use keeps trusting one that will never work again. mailThenClaimExpiry now takes how long the credential has been due (`dueSince`, its stored `expiresAt`) and claims the expiry anyway once that age crosses a budget, even without ever getting the notification out — reported through reportError with the credential id and how long it went unnotified, so a person can find it without its owner having been told. Two different budgets, since the failure shapes aren't the same kind of problem: - MAX_UNMAILED_CREDENTIAL_AGE_MS (24h): a mail outage is a transient, systemic condition most likely to resolve within a day if it's going to resolve at all. - MAX_UNOWNED_CREDENTIAL_AGE_MS (7 days): zero active recipients is more often a slow-moving tenant-provisioning gap (a new tenant, a departed user being replaced) that deserves more real-world time before writing off the notification as unreachable. RefreshableMcpCredential now carries its own `expiresAt` (already read off the credential row for the MCP-refresh-failure branch's cutoff filter, just not previously projected into the type) so the same bound applies there. This commit necessarily bundles the RefreshableMcpCredential.expiresAt field with the tests exercising it, since the test fixtures cannot be typed against a field the implementation hasn't added yet — splitting tests-first would leave an uncompilable intermediate commit. --- apps/hub/src/credential-expiry-sweep.ts | 119 +++++++++++++++--- apps/hub/test/credential-expiry-sweep.test.ts | 95 ++++++++++++++ 2 files changed, 200 insertions(+), 14 deletions(-) diff --git a/apps/hub/src/credential-expiry-sweep.ts b/apps/hub/src/credential-expiry-sweep.ts index 6ab82d07b..86bfeef2c 100644 --- a/apps/hub/src/credential-expiry-sweep.ts +++ b/apps/hub/src/credential-expiry-sweep.ts @@ -57,6 +57,27 @@ const MCP_REFRESH_LEAD_MS = POLL_INTERVAL_MS; const MCP_OAUTH_CLIENT_NAME = "Corbits Workbench"; const log = getLogger(["hub", "credential-expiry-sweep"]); +// Mail-first-claim-after (see `mailThenClaimExpiry`) leaves a still-due +// credential `active` for as long as it keeps failing to notify anyone — +// on purpose, so a transient mail outage or a not-yet-provisioned +// recipient gets more than one tick to resolve. Left unbounded, though, +// `active` stops meaning "usable": the credential is genuinely dead at +// the provider, and the rest of the system (anything selecting an +// `active` credential to actually use) would keep trusting a row that +// will never work again. Past these ages the sweep gives up on ever +// notifying anyone and claims the expiry anyway, so the stored status +// stops asserting something false — reported loudly, since nobody was +// told. +// +// The two failure shapes get different budgets: a mail outage is a +// transient, systemic condition most likely to resolve within a day if +// it's going to resolve at all, while a credential with zero active +// recipients is more often a slow-moving tenant-provisioning gap (a new +// tenant, a departed user being replaced) that deserves more real-world +// time before writing off the notification as unreachable. +const MAX_UNMAILED_CREDENTIAL_AGE_MS = 24 * 60 * 60 * 1000; +const MAX_UNOWNED_CREDENTIAL_AGE_MS = 7 * 24 * 60 * 60 * 1000; + // The OAuth-connected providers whose tokens expire, and how to name // each in the notification. A second such provider generalizes this // list, never a second parallel sweep. @@ -91,6 +112,10 @@ export type RefreshableMcpCredential = { readonly tokens: OAuthTokens; readonly clientInformation?: OAuthClientInformationMixed; readonly recipients: readonly { tenantId: string; principalId: string }[]; + /** The credential's real `expiresAt` column — when it became due for + * this reconnect-nudge fallback, used to bound how long an unmailed + * expiry is left `active` (see `MAX_UNMAILED_CREDENTIAL_AGE_MS`). */ + readonly expiresAt: Date; }; export type CredentialExpirySweepStore = { @@ -217,7 +242,7 @@ export function createDrizzleCredentialExpirySweepStore( ); const due = rows.filter( - (row) => + (row): row is typeof row & { expiresAt: Date } => row.expiresAt !== null && row.expiresAt.getTime() <= cutoff.getTime(), ); if (due.length === 0) return []; @@ -268,6 +293,7 @@ export function createDrizzleCredentialExpirySweepStore( slug: mcpSlugOf(row.providerName), name: parsedMetadata.name ?? row.providerName, serverUrl: row.apiBaseUrl ?? parsedMetadata.url, + expiresAt: row.expiresAt, tokens: { access_token: accessToken, token_type: "bearer", @@ -350,6 +376,14 @@ export type CredentialExpirySweepDeps = { * and swallowed here (never `active` → `expired` with the notification * lost), and never propagates out to abort sibling candidates in the * same tick. + * + * That "stay active and retry" grace is bounded by `dueSince` (see + * `MAX_UNMAILED_CREDENTIAL_AGE_MS` / `MAX_UNOWNED_CREDENTIAL_AGE_MS`): + * once a credential has been due for longer than its budget with the + * notification still unsent, this claims the expiry anyway rather than + * asserting `active` forever for a credential that is genuinely dead at + * the provider — reported loudly, since giving up here means nobody was + * ever told. */ async function mailThenClaimExpiry( deps: CredentialExpirySweepDeps, @@ -358,21 +392,54 @@ async function mailThenClaimExpiry( readonly credentialId: string; readonly tenantId: string; readonly recipients: readonly { tenantId: string; principalId: string }[]; + readonly dueSince: Date; }, event: CredentialExpiredNotification, ): Promise { + const ageMs = now.getTime() - target.dueSince.getTime(); + if (target.recipients.length === 0) { - log.warn`credential ${target.credentialId} has no active recipient to notify; leaving it active until one exists`; + if (ageMs < MAX_UNOWNED_CREDENTIAL_AGE_MS) { + log.warn`credential ${target.credentialId} has no active recipient to notify; leaving it active until one exists`; + return; + } + reportError( + new Error( + "credential expiry never had an active recipient to notify; claiming it as expired unnotified", + ), + { + operation: "credential-expiry-sweep.abandon-unowned", + tenantId: target.tenantId, + extra: { + credentialId: target.credentialId, + dueSince: target.dueSince.toISOString(), + }, + }, + ); + await deps.store.claimExpiry(target.credentialId, now); return; } + try { await deliverCredentialMail(deps.notify, event); } catch (err) { + if (ageMs < MAX_UNMAILED_CREDENTIAL_AGE_MS) { + reportError(err, { + operation: "credential-expiry-sweep.deliver", + tenantId: target.tenantId, + extra: { credentialId: target.credentialId }, + }); + return; + } reportError(err, { - operation: "credential-expiry-sweep.deliver", + operation: "credential-expiry-sweep.abandon-unmailed", tenantId: target.tenantId, - extra: { credentialId: target.credentialId }, + extra: { + credentialId: target.credentialId, + dueSince: target.dueSince.toISOString(), + }, }); + await deps.store.claimExpiry(target.credentialId, now); return; } const claimed = await deps.store.claimExpiry(target.credentialId, now); @@ -398,7 +465,21 @@ export async function tickCredentialExpirySweep( const due = findDueCredentialExpiries(candidates, now); for (const { credential: expiring, event } of due) { - await mailThenClaimExpiry(deps, now, expiring, event); + // Guaranteed defined and parseable for anything `findDueCredentialExpiries` + // returned; `now` is an unreachable fallback, never a real fallback value. + const dueSince = + expiring.expiresAt !== undefined ? new Date(expiring.expiresAt) : now; + await mailThenClaimExpiry( + deps, + now, + { + credentialId: expiring.credentialId, + tenantId: expiring.tenantId, + recipients: expiring.recipients, + dueSince, + }, + event, + ); } const refreshable = await deps.store.loadRefreshableMcpCandidates( @@ -429,15 +510,25 @@ export async function tickCredentialExpirySweep( } log.warn`mcp oauth refresh failed for credential ${candidate.credentialId} ("${candidate.name}"): ${result.message}`; - await mailThenClaimExpiry(deps, now, candidate, { - kind: "credential-expired", - tenantId: candidate.tenantId, - credentialId: candidate.credentialId, - providerId: `mcp:${candidate.slug}`, - providerLabel: candidate.name, - recipients: [...candidate.recipients], - createdAt: now.toISOString(), - }); + await mailThenClaimExpiry( + deps, + now, + { + credentialId: candidate.credentialId, + tenantId: candidate.tenantId, + recipients: candidate.recipients, + dueSince: candidate.expiresAt, + }, + { + kind: "credential-expired", + tenantId: candidate.tenantId, + credentialId: candidate.credentialId, + providerId: `mcp:${candidate.slug}`, + providerLabel: candidate.name, + recipients: [...candidate.recipients], + createdAt: now.toISOString(), + }, + ); } } diff --git a/apps/hub/test/credential-expiry-sweep.test.ts b/apps/hub/test/credential-expiry-sweep.test.ts index 2f7486f0d..360630fe4 100644 --- a/apps/hub/test/credential-expiry-sweep.test.ts +++ b/apps/hub/test/credential-expiry-sweep.test.ts @@ -56,6 +56,7 @@ type McpCredentialRow = { accessToken: string; status: "active" | "expired"; recipients: readonly { tenantId: string; principalId: string }[]; + expiresAt: Date; }; function mcpCredential( @@ -72,6 +73,7 @@ function mcpCredential( accessToken: "access_old", status: "active", recipients: [{ tenantId: "tnt_1", principalId: "prn_1" }], + expiresAt: new Date("2026-08-13T11:00:00.000Z"), ...overrides, }; } @@ -136,6 +138,7 @@ function inMemoryStore( slug: r.slug, name: r.name, serverUrl: r.serverUrl, + expiresAt: r.expiresAt, tokens: { access_token: r.accessToken, token_type: "bearer", @@ -371,6 +374,45 @@ describe("tickCredentialExpirySweep", () => { expect(store.claims).toEqual([]); expect(notify.mailed).toEqual([]); }); + + test("a credential with no recipient past the unowned-age bound is claimed anyway, unnotified", async () => { + // 8 days before `now` — past MAX_UNOWNED_CREDENTIAL_AGE_MS (7 days). + const store = inMemoryStore([ + credential({ + recipients: [], + expiresAt: "2026-08-05T00:00:00.000Z", + }), + ]); + const notify = notifyDeps(); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual(["cred_1"]); + expect(notify.mailed).toEqual([]); + }); + + test("a mail failure past the unmailed-age bound is claimed anyway, unnotified", async () => { + // 36 hours before `now` — past MAX_UNMAILED_CREDENTIAL_AGE_MS (24h). + const store = inMemoryStore([ + credential({ expiresAt: "2026-08-11T00:00:00.000Z" }), + ]); + const notify = throwingNotifyDeps(new Error("postgres blip")); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + now: () => now, + }); + + expect(store.claims).toEqual(["cred_1"]); + expect(notify.mailed).toEqual([]); + }); }); describe("tickCredentialExpirySweep — MCP oauth_token refresh (CL-6207)", () => { @@ -533,4 +575,57 @@ describe("tickCredentialExpirySweep — MCP oauth_token refresh (CL-6207)", () = expect(store.mcpRefreshes).toEqual([]); expect(store.mcpClaims).toEqual([]); }); + + test("a failed refresh with no recipient past the unowned-age bound is claimed anyway, unnotified", async () => { + // 8 days before `now` — past MAX_UNOWNED_CREDENTIAL_AGE_MS (7 days). + const store = inMemoryStore( + [], + [ + mcpCredential({ + recipients: [], + expiresAt: new Date("2026-08-05T00:00:00.000Z"), + }), + ], + ); + const notify = notifyDeps(); + const refreshMcpTokens = async (): Promise => ({ + ok: false, + message: "invalid_grant: refresh token revoked", + }); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + refreshMcpTokens, + now: () => now, + }); + + expect(store.mcpClaims).toEqual(["cred_mcp_1"]); + expect(notify.mailed).toEqual([]); + }); + + test("a failed refresh whose reconnect mail keeps failing past the unmailed-age bound is claimed anyway", async () => { + // 36 hours before `now` — past MAX_UNMAILED_CREDENTIAL_AGE_MS (24h). + const store = inMemoryStore( + [], + [mcpCredential({ expiresAt: new Date("2026-08-11T00:00:00.000Z") })], + ); + const notify = throwingNotifyDeps(new Error("postgres blip")); + const refreshMcpTokens = async (): Promise => ({ + ok: false, + message: "invalid_grant: refresh token revoked", + }); + + await tickCredentialExpirySweep({ + store, + notify, + hubUrl: HUB_URL, + refreshMcpTokens, + now: () => now, + }); + + expect(store.mcpClaims).toEqual(["cred_mcp_1"]); + expect(notify.mailed).toEqual([]); + }); });