mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Agents need current repository access before they can start work. > - Managed GitHub access checks run while webhook delivery can update the same grant. > - Pull request events update webhook health but do not change repository permissions. > - The refresh guard treated those health updates as access changes and could exhaust its retry. > - This change keeps repository preparation available during normal pull request activity. ## Linked Issues or Issue Description **What happened?** A pull request webhook can make a managed GitHub repository refresh fail with `github_access_changed`. Repository discovery then omits the failed connection. Workspace admission can report that repository access is no longer available, even after the checkout succeeds. **Expected behavior** Pull request activity must preserve valid repository access. Installation changes and newer access snapshots must still stop a stale refresh. **Steps to reproduce** 1. Start a managed GitHub access refresh. 2. Process a pull request edit while the provider request is in flight. 3. Repeat on the bounded retry. The old guard rejects both checks because `lastWebhookAt` changes. ## What Changed - Use `accessRevision` and `lastAccessRefreshAt` as the existing access fences. Stop using webhook delivery time as an access fence. - Allow the existing commit merge to retain the latest webhook timestamp and health after a pull request event. - Exercise real webhook processing and PostgreSQL during refresh. Cover a pull request event, an access conflict followed by that event, and installation suspension. - Document the distinction between access changes and delivery health. ## Verification - Red: both pull request race cases failed on the old code with `github_access_changed`. - Green: all seven focused refresh cases pass. These include the existing concurrent access-change checks and the new suspension case. - Command: `pnpm exec vitest run server/src/__tests__/tool-access-service.test.ts -t 'binds a managed GitHub identity'`. - Full affected group: 526 tests pass across the tool-access, repository, and webhook suites. - `pnpm -r typecheck` and `pnpm build`: pass. - Current-head CI: 54 checks pass. The duplicate Paperclip review is neutral. Two Storybook checks are skipped. Greptile and the completed Paperclip review both rate this commit 5/5 with no findings. There are no unresolved review threads. - The duplicate local `pnpm test:run` did not complete. Its first four chat shards passed. It was stopped after CI completed on this commit; the full CI test matrix passed. The complete affected local group passed separately. - Retained earlier failure: the first affected-group attempt passed 525 tests and had one socket hang-up in the profile attention feed test. That test passed alone without changes. The unchanged base tool-access suite also passed all 498 tests, and the complete fixed affected group then passed. The cause of the socket closure remains unproven. - Environment: source checkout on macOS arm64, Node 26.4.0, pnpm 9.15.4, disposable PostgreSQL. Provider responses and Cloud transport use controlled fixtures. The webhook handler, refresh logic, transactions, and saved grant reads are real. - Live acceptance remains unverified. The available CLI token cannot list GitHub App installations. The affected staging deployment still serves the old code. This PR does not deploy or recover the original task. ## Risks - Removing an access fence could admit stale permission data. The access revision and refresh timestamp checks remain. The real suspension webhook regression confirms that stale provider data cannot restore removed access. - This change adds no retry, timer, schema change, or credential forwarding. ## Model Used OpenAI Codex, based on GPT-6. The exact runtime model ID and context window are not exposed in this session. Used reasoning, code editing, and terminal tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
398 lines
19 KiB
TypeScript
398 lines
19 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { notifyGitHubCredentialChange } from "../github-connection-notifications.js";
|
|
import type { ActorInfo } from "./types.js";
|
|
import { loadGitHubGrantMetadata } from "./github-access-discovery.js";
|
|
import { measureRunPhase } from "../execution-preparation-timing.js";
|
|
import { measureRunTransaction } from "../execution-transaction-timing.js";
|
|
import { and, eq, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companies, connectionGrants, toolConnections } from "@paperclipai/db";
|
|
import { conflict, HttpError, notFound, unprocessable } from "../../errors.js";
|
|
import { PaperclipCloudConnectorError, type PaperclipCloudConnector } from "../paperclip-cloud-connector.js";
|
|
|
|
import type { createOAuthGrantRefresh } from "./oauth/grant-refresh.js";
|
|
import type { OAuthCredentialStore } from "./oauth/credential-store.js";
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value))
|
|
return value as Record<string, unknown>;
|
|
return {};
|
|
}
|
|
|
|
type RecordGitHubAttempt = (grant: typeof connectionGrants.$inferSelect) => void;
|
|
|
|
type GitHubContinuityDependencies = {
|
|
now: () => Date;
|
|
currentCloudConnector: () => PaperclipCloudConnector | null;
|
|
refreshOAuthGrantCredentials: ReturnType<typeof createOAuthGrantRefresh>;
|
|
resolveOAuthGrantSecret: OAuthCredentialStore["resolveOAuthGrantSecret"];
|
|
};
|
|
|
|
export function createGitHubGrantContinuity(db: Db, { now, currentCloudConnector, refreshOAuthGrantCredentials, resolveOAuthGrantSecret }: GitHubContinuityDependencies) {
|
|
async function refreshManagedGitHubGrantAccess(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
initialGrant: typeof connectionGrants.$inferSelect,
|
|
actor?: ActorInfo,
|
|
recordAttempt?: RecordGitHubAttempt,
|
|
) {
|
|
await actor?.managementContext?.reauthorize();
|
|
return measureRunPhase("connectors.github.refresh", async () => {
|
|
try {
|
|
return await refreshManagedGitHubGrantAccessOnce(
|
|
connection,
|
|
initialGrant,
|
|
actor,
|
|
recordAttempt,
|
|
);
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof HttpError) ||
|
|
asRecord(error.details).code !== "github_access_changed"
|
|
)
|
|
throw error;
|
|
const [current] = await measureRunPhase("connectors.github.grants", () => db
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, initialGrant.id),
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
),
|
|
));
|
|
if (!current || current.status !== "active") throw error;
|
|
return refreshManagedGitHubGrantAccessOnce(connection, current, actor, recordAttempt);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshManagedGitHubGrantAccessOnce(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
initialGrant: typeof connectionGrants.$inferSelect,
|
|
actor?: ActorInfo,
|
|
recordAttempt?: RecordGitHubAttempt,
|
|
) {
|
|
await actor?.managementContext?.reauthorize();
|
|
let grant = await measureRunPhase("connectors.github.credentials", () => refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: initialGrant.id,
|
|
actor,
|
|
}));
|
|
recordAttempt?.(grant);
|
|
const resolveAccessToken = async () => measureRunPhase("connectors.github.credentials", async () => {
|
|
const accessRef = grant.credentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.access_token",
|
|
);
|
|
if (!accessRef)
|
|
throw unprocessable("GitHub authorization must be reconnected", {
|
|
code: "oauth_reauthorization_required",
|
|
});
|
|
return (
|
|
await resolveOAuthGrantSecret(
|
|
connection,
|
|
grant,
|
|
accessRef,
|
|
actor,
|
|
undefined,
|
|
)
|
|
).value;
|
|
});
|
|
let metadata;
|
|
let accessToken = await resolveAccessToken();
|
|
try {
|
|
metadata = await measureRunPhase("connectors.github.repositories", () => loadGitHubGrantMetadata(
|
|
accessToken,
|
|
fetch,
|
|
grant.providerTenant?.github?.appSlug,
|
|
));
|
|
} catch (error) {
|
|
const providerCode =
|
|
error instanceof HttpError ? asRecord(error.details).code : null;
|
|
if (providerCode !== "oauth_reauthorization_required") throw error;
|
|
// GitHub may invalidate an access token before its recorded expiry. If an
|
|
// expiring token pair exists, rotate it once under the same durable lease
|
|
// and CAS path, then repeat /user before surfacing reconnect-required.
|
|
grant = await measureRunPhase("connectors.github.credentials", () => refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
grantId: initialGrant.id,
|
|
forceRefresh: true,
|
|
actor,
|
|
}));
|
|
recordAttempt?.(grant);
|
|
accessToken = await resolveAccessToken();
|
|
try {
|
|
metadata = await measureRunPhase("connectors.github.repositories", () => loadGitHubGrantMetadata(
|
|
accessToken,
|
|
fetch,
|
|
grant.providerTenant?.github?.appSlug,
|
|
));
|
|
} catch (retryError) {
|
|
const retryCode =
|
|
retryError instanceof HttpError
|
|
? asRecord(retryError.details).code
|
|
: null;
|
|
if (retryCode === "oauth_reauthorization_required") {
|
|
await measureRunPhase("connectors.github.grant_write", () => db
|
|
.update(connectionGrants)
|
|
.set({ status: "needs_reauthorization", updatedAt: now() })
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
));
|
|
}
|
|
throw retryError;
|
|
}
|
|
}
|
|
const { updated } = await measureRunTransaction(db, "connectors.github.refresh", async (tx) => {
|
|
await notifyGitHubCredentialChange(tx, connection);
|
|
const [currentGrant] = await measureRunPhase("connectors.github.grant_lock", () => tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1));
|
|
if (!currentGrant || currentGrant.status === "revoked")
|
|
throw notFound("GitHub authorization not found");
|
|
const previousGitHub = currentGrant.providerTenant?.github;
|
|
const initialGitHub = grant.providerTenant?.github;
|
|
// Access revisions fence installation changes, and refresh timestamps also
|
|
// protect legacy snapshots. Ordinary PR webhooks update delivery health
|
|
// without changing access; preserve that health when this refresh commits.
|
|
if (
|
|
previousGitHub?.accessRevision !== initialGitHub?.accessRevision ||
|
|
previousGitHub?.lastAccessRefreshAt !==
|
|
initialGitHub?.lastAccessRefreshAt
|
|
) {
|
|
throw conflict("GitHub access changed during refresh. Try again.", {
|
|
code: "github_access_changed",
|
|
});
|
|
}
|
|
const providerTenant = {
|
|
...(currentGrant.providerTenant ?? {}),
|
|
github: {
|
|
...metadata,
|
|
bindingSync: {
|
|
id: randomUUID(),
|
|
removeInstallationIds: [...new Set([
|
|
...(previousGitHub?.bindingSync?.removeInstallationIds ?? []),
|
|
...(previousGitHub?.installationIds ?? []),
|
|
])].filter(id => !metadata.installationIds.includes(id)),
|
|
},
|
|
...(previousGitHub?.lastWebhookAt
|
|
? { lastWebhookAt: previousGitHub.lastWebhookAt }
|
|
: {}),
|
|
webhookHealth:
|
|
previousGitHub?.webhookHealth ?? metadata.webhookHealth,
|
|
},
|
|
};
|
|
const [updated] = await measureRunPhase("connectors.github.grant_write", () => tx
|
|
.update(connectionGrants)
|
|
.set({
|
|
providerTenant,
|
|
status: "active",
|
|
updatedAt: now(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, grant.id),
|
|
eq(connectionGrants.companyId, grant.companyId),
|
|
),
|
|
)
|
|
.returning());
|
|
if (!updated) throw notFound("GitHub authorization not found");
|
|
return { updated, previousGitHub };
|
|
});
|
|
|
|
return measureRunPhase("connectors.github.webhooks", () => syncGitHubGrantBindings(connection, updated, actor, accessToken, recordAttempt));
|
|
}
|
|
|
|
async function syncGitHubGrantBindings(
|
|
connection: typeof toolConnections.$inferSelect,
|
|
initialGrant: typeof connectionGrants.$inferSelect,
|
|
actor?: ActorInfo,
|
|
accessToken?: string,
|
|
recordAttempt?: RecordGitHubAttempt,
|
|
) {
|
|
await actor?.managementContext?.reauthorize();
|
|
const cloudConnector = currentCloudConnector();
|
|
if (!initialGrant.providerTenant?.github?.bindingSync) return initialGrant;
|
|
if (!cloudConnector) throw unprocessable("GitHub webhook delivery is not configured");
|
|
const grant = accessToken || initialGrant.providerTenant.github.installationIds.length === 0 ? initialGrant : await refreshOAuthGrantCredentials({
|
|
companyId: connection.companyId, connectionId: connection.id, grantId: initialGrant.id, actor,
|
|
});
|
|
const accessRef = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token");
|
|
if (!accessRef && grant.providerTenant?.github?.installationIds.length) throw unprocessable("GitHub authorization must be reconnected", { code: "oauth_reauthorization_required" });
|
|
const token = accessToken ?? (accessRef ? (await resolveOAuthGrantSecret(connection, grant, accessRef, actor, undefined)).value : undefined);
|
|
// Serialize external binding changes with grant/connection lifecycle writes.
|
|
// Requests occur only for saved debt; no idle lock or connection is retained.
|
|
return db.transaction(async tx => {
|
|
// Company deletion locks its parent before cascading to grants/connections.
|
|
// Keep that order while retaining admission through the external handoff.
|
|
const [company] = await tx.select({ status: companies.status }).from(companies)
|
|
.where(eq(companies.id, connection.companyId)).for("share").limit(1);
|
|
if (!company) return grant;
|
|
const [current] = await tx.select().from(connectionGrants).where(and(
|
|
eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
)).for("update").limit(1);
|
|
if (!current || current.status !== "active") return current ?? grant;
|
|
const [liveConnection] = await tx.select().from(toolConnections).where(and(
|
|
eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId),
|
|
)).for("update").limit(1);
|
|
if (!liveConnection || liveConnection.status !== "active" || !liveConnection.enabled) return current;
|
|
if (company.status !== "active") return current;
|
|
const github = current.providerTenant?.github;
|
|
if (!github?.bindingSync || github.bindingSync.id !== grant.providerTenant?.github?.bindingSync?.id) return current;
|
|
const subject = current.kind === "agent" && current.subjectAgentId ? `agent:${current.subjectAgentId}`
|
|
: current.kind === "user" ? current.subjectUserId : null;
|
|
if (!subject) throw unprocessable("GitHub webhook subject is unavailable");
|
|
recordAttempt?.(grant);
|
|
const changes = [
|
|
...github.installationIds.map(installationId => ({ installationId, active: true as const })),
|
|
...github.bindingSync.removeInstallationIds.filter(id => !github.installationIds.includes(id))
|
|
.map(installationId => ({ installationId, active: false as const })),
|
|
];
|
|
for (let offset = 0; offset < changes.length; offset += 4) {
|
|
const outcomes = await Promise.allSettled(changes.slice(offset, offset + 4).map(change => cloudConnector.setWebhookBinding({
|
|
subject, companyId: connection.companyId, id: `${current.id}_${change.installationId}`,
|
|
installationId: change.installationId, connectionId: connection.id, grantId: current.id,
|
|
...change.active ? { active: true, accessToken: token! } : { active: false },
|
|
})));
|
|
const failure = outcomes.find(outcome => outcome.status === "rejected");
|
|
if (failure?.status === "rejected") throw failure.reason;
|
|
}
|
|
await notifyGitHubCredentialChange(tx, connection);
|
|
const [settled] = await tx.update(connectionGrants).set({
|
|
providerTenant: sql`${connectionGrants.providerTenant} #- '{github,bindingSync}' #- '{github,continuityRetry}'`,
|
|
updatedAt: now(),
|
|
}).where(and(eq(connectionGrants.id, current.id), eq(connectionGrants.companyId, connection.companyId))).returning();
|
|
return settled!;
|
|
});
|
|
}
|
|
|
|
async function retainRetry(connection: typeof toolConnections.$inferSelect, grantId: string, attemptedRevision: string, error?: unknown, minimumMs = 30_000) {
|
|
await db.transaction(async tx => {
|
|
const [current] = await tx.select().from(connectionGrants).where(and(
|
|
eq(connectionGrants.id, grantId), eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id), eq(connectionGrants.status, "active"),
|
|
)).for("update").limit(1);
|
|
// Backoff belongs to the attempted credential/installation revision.
|
|
if (!current?.providerTenant?.github || githubContinuityRevision(current) !== attemptedRevision) return;
|
|
const attempts = Math.min(16, (current.providerTenant.github.continuityRetry?.attempts ?? 0) + 1);
|
|
const advised = error instanceof PaperclipCloudConnectorError ? error.retryAt
|
|
: error instanceof HttpError ? asRecord(error.details).retryAt : undefined;
|
|
const at = new Date(Math.max(now().getTime() + Math.max(minimumMs, Math.min(60 * 60_000, 30_000 * 2 ** Math.min(attempts - 1, 7))),
|
|
typeof advised === "number" ? advised : 0)).toISOString();
|
|
await notifyGitHubCredentialChange(tx, connection);
|
|
await tx.update(connectionGrants).set({
|
|
providerTenant: sql`jsonb_set(${connectionGrants.providerTenant}, '{github,continuityRetry}', ${JSON.stringify({
|
|
at, attempts, revision: attemptedRevision,
|
|
})}::jsonb)`,
|
|
updatedAt: now(),
|
|
}).where(and(eq(connectionGrants.id, current.id), eq(connectionGrants.companyId, connection.companyId)));
|
|
});
|
|
}
|
|
|
|
async function continuityRows(companyId?: string) {
|
|
return db
|
|
.select({ grant: connectionGrants, connection: toolConnections })
|
|
.from(connectionGrants)
|
|
.innerJoin(toolConnections, and(
|
|
eq(toolConnections.id, connectionGrants.connectionId),
|
|
eq(toolConnections.companyId, connectionGrants.companyId),
|
|
))
|
|
.where(and(
|
|
companyId ? eq(connectionGrants.companyId, companyId) : undefined,
|
|
eq(connectionGrants.status, "active"),
|
|
eq(toolConnections.status, "active"),
|
|
eq(toolConnections.enabled, true),
|
|
sql`${toolConnections.config}->>'sourceTemplateKey' = 'github'`,
|
|
sql`${toolConnections.config}->'oauth'->>'connectorProfile' = 'github.code'`,
|
|
sql`${connectionGrants.providerTenant}->'github' is not null`,
|
|
sql`exists (select 1 from companies where id = ${connectionGrants.companyId} and status = 'active')`,
|
|
));
|
|
}
|
|
|
|
async function nextGitHubConnectionContinuityAt(companyId?: string): Promise<number | null> {
|
|
const deadlines = (await continuityRows(companyId)).flatMap(({ grant, connection }) => {
|
|
const deadline = githubGrantContinuityDeadline(connection, grant);
|
|
return deadline === null ? [] : [deadline];
|
|
});
|
|
return deadlines.length ? Math.min(...deadlines) : null;
|
|
}
|
|
|
|
async function sweepGitHubConnectionContinuity(companyId?: string) {
|
|
const rows = await continuityRows(companyId);
|
|
const due = rows.filter(({ grant, connection }) => {
|
|
const deadline = githubGrantContinuityDeadline(connection, grant);
|
|
return deadline !== null && deadline <= now().getTime();
|
|
});
|
|
let refreshed = 0;
|
|
let failed = 0;
|
|
for (const row of due) {
|
|
let attemptedRevision = githubContinuityRevision(row.grant);
|
|
const recordAttempt: RecordGitHubAttempt = grant => { attemptedRevision = githubContinuityRevision(grant); };
|
|
try {
|
|
const actor: ActorInfo = { actorType: "system", actorId: "github-connection-continuity" };
|
|
const updated = row.grant.providerTenant?.github?.bindingSync
|
|
? await syncGitHubGrantBindings(row.connection, row.grant, actor, undefined, recordAttempt)
|
|
: await refreshManagedGitHubGrantAccess(row.connection, row.grant, actor, recordAttempt);
|
|
// A successful provider response can still leave an unchanged/short-lived
|
|
// credential due. Retain that obligation with bounded durable backoff.
|
|
const next = githubGrantContinuityDeadline(row.connection, updated);
|
|
if (next !== null && next <= now().getTime()) await retainRetry(row.connection, updated.id, githubContinuityRevision(updated), undefined, 60 * 60_000);
|
|
refreshed += 1;
|
|
} catch (error) {
|
|
await retainRetry(row.connection, row.grant.id, attemptedRevision, error);
|
|
failed += 1;
|
|
}
|
|
}
|
|
return { checked: rows.length, due: due.length, refreshed, failed };
|
|
}
|
|
async function githubConnectionContinuitySchedule(companyId?: string) {
|
|
const schedule = new Map<string, number>();
|
|
for (const { grant, connection } of await continuityRows(companyId)) {
|
|
const dueAt = githubGrantContinuityDeadline(connection, grant);
|
|
if (dueAt !== null) schedule.set(connection.companyId, Math.min(schedule.get(connection.companyId) ?? Infinity, dueAt));
|
|
}
|
|
return [...schedule].map(([companyId, dueAt]) => ({ id: companyId, companyId, dueAt }));
|
|
}
|
|
return { githubConnectionContinuitySchedule, refreshManagedGitHubGrantAccess, syncGitHubGrantBindings, sweepGitHubConnectionContinuity, nextGitHubConnectionContinuityAt };
|
|
}
|
|
|
|
/** Credential expiry and provider continuity are obligations, not a scan cadence. */
|
|
export function githubGrantContinuityDeadline(
|
|
connection: Pick<typeof toolConnections.$inferSelect, "config">,
|
|
grant: Pick<typeof connectionGrants.$inferSelect, "providerTenant">,
|
|
): number | null {
|
|
const config = asRecord(connection.config);
|
|
if (config.sourceTemplateKey !== "github" || asRecord(config.oauth).connectorProfile !== "github.code"
|
|
|| !grant.providerTenant?.github) return null;
|
|
const oauth = grant.providerTenant.oauth;
|
|
const access = grant.providerTenant.github;
|
|
const parsed = (value: string | undefined) => {
|
|
const time = value ? Date.parse(value) : Number.NaN;
|
|
return Number.isFinite(time) ? time : 0;
|
|
};
|
|
const continuityMs = 30 * 24 * 60 * 60_000;
|
|
const credentialDeadline = typeof oauth?.accessTokenExpiresAt === "string"
|
|
? Math.min(parsed(oauth.accessTokenExpiresAt) - 60 * 60_000, oauth.refreshedAt ? parsed(oauth.refreshedAt) + continuityMs : 0)
|
|
: access.lastAccessRefreshAt ? parsed(access.lastAccessRefreshAt) + continuityMs : 0;
|
|
const deadline = access.bindingSync ? 0 : credentialDeadline;
|
|
return access.continuityRetry?.revision === githubContinuityRevision(grant)
|
|
? Math.max(deadline, parsed(access.continuityRetry.at)) : deadline;
|
|
}
|
|
|
|
export function githubContinuityRevision(grant: Pick<typeof connectionGrants.$inferSelect, "providerTenant">): string {
|
|
return JSON.stringify([grant.providerTenant?.oauth?.accessTokenExpiresAt, grant.providerTenant?.oauth?.refreshedAt,
|
|
grant.providerTenant?.github?.lastAccessRefreshAt, grant.providerTenant?.github?.bindingSync?.id]);
|
|
}
|