168 lines
7.7 KiB
TypeScript
168 lines
7.7 KiB
TypeScript
// AK-P16a · calendar connection lifecycle (connect / callback / disconnect).
|
|
//
|
|
// OAuth tokens are sealed with the per-tenant DEK envelope (AD-8) — never stored
|
|
// plain, never logged. Writes are audited automatically by the M001 Prisma
|
|
// middleware (DoD 13); connect/disconnect are USER-account events, not CRM
|
|
// activity-stream entries (the activity model is CRM-subject only — the
|
|
// appointment-sync activity belongs to AK-P16b's booking). Webhook subscription is
|
|
// best-effort: a failure degrades the connection with a Klartext hint, it does not
|
|
// block the connect.
|
|
|
|
import { decryptTenantField, encryptTenantField } from "@hivegrid/db/crypto/tenant-envelope.server";
|
|
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
|
|
import { db } from "~/utils/db.server";
|
|
import { googleCalendarAdapter, CalendarNotConfiguredError } from "./adapters/GoogleCalendarAdapter.server";
|
|
import type { CalendarCreds } from "./adapters/CalendarAdapter";
|
|
import { signCalendarOAuthState, verifyCalendarOAuthState } from "./oauthState.server";
|
|
|
|
export class CalendarConnectError extends Error {
|
|
readonly code: "not_configured" | "invalid_state" | "exchange_failed";
|
|
constructor(code: CalendarConnectError["code"]) {
|
|
super(code);
|
|
this.name = "CalendarConnectError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** Build the Google consent URL for the current tenant user. */
|
|
export function buildConnectUrl(input: { tenantId: string; userId: string; tenantSlug: string }): string {
|
|
if (!googleCalendarAdapter.isConfigured()) {
|
|
throw new CalendarConnectError("not_configured");
|
|
}
|
|
const state = signCalendarOAuthState(input);
|
|
return googleCalendarAdapter.buildAuthorizationUrl(state);
|
|
}
|
|
|
|
/**
|
|
* Complete the OAuth callback: verify state, exchange the code, seal the tokens,
|
|
* upsert the per-user connection, and best-effort subscribe the push channel.
|
|
* Returns the tenant slug + ids so the callback route can trigger the initial sync
|
|
* (kept out of this service to avoid a CalendarSyncService import cycle).
|
|
*/
|
|
export async function completeOAuthCallback(input: { code: string; state: string }): Promise<{ tenantSlug: string; tenantId: string; connectionId: string }> {
|
|
const verified = verifyCalendarOAuthState(input.state);
|
|
if (!verified.ok) {
|
|
throw new CalendarConnectError("invalid_state");
|
|
}
|
|
const { tenantId, userId, tenantSlug } = verified.payload;
|
|
const rlsContext = getRlsExecutionContext();
|
|
if (rlsContext?.tenantId !== tenantId || rlsContext.userId !== userId) {
|
|
throw new CalendarConnectError("invalid_state");
|
|
}
|
|
|
|
let exchange: Awaited<ReturnType<typeof googleCalendarAdapter.exchangeCode>>;
|
|
try {
|
|
exchange = await googleCalendarAdapter.exchangeCode(input.code);
|
|
} catch (error) {
|
|
if (error instanceof CalendarNotConfiguredError) {
|
|
throw new CalendarConnectError("not_configured");
|
|
}
|
|
throw new CalendarConnectError("exchange_failed");
|
|
}
|
|
const { tokens, account } = exchange;
|
|
|
|
// Seal refresh + access tokens separately (AD-8). A refresh token is only
|
|
// returned on first consent; keep an existing one if Google omits it.
|
|
const refreshCiphertext = tokens.refreshToken ? await encryptTenantField(tenantId, tokens.refreshToken) : null;
|
|
const accessCiphertext = await encryptTenantField(tenantId, tokens.accessToken);
|
|
|
|
const connection = await db.calendarConnection.upsert({
|
|
where: { tenantId_userId_provider_externalAccountId: { tenantId, userId, provider: "google", externalAccountId: account.accountId } },
|
|
create: {
|
|
tenantId,
|
|
userId,
|
|
provider: "google",
|
|
externalAccountEmail: account.email,
|
|
externalAccountId: account.accountId,
|
|
oauthRefreshTokenCiphertext: refreshCiphertext,
|
|
oauthAccessTokenCiphertext: accessCiphertext,
|
|
oauthAccessTokenExpiresAt: tokens.expiresAt,
|
|
oauthScopes: [...tokens.scopes],
|
|
status: "active",
|
|
},
|
|
update: {
|
|
externalAccountEmail: account.email,
|
|
// Preserve the stored refresh token when Google omits it on re-consent.
|
|
...(refreshCiphertext ? { oauthRefreshTokenCiphertext: refreshCiphertext } : {}),
|
|
oauthAccessTokenCiphertext: accessCiphertext,
|
|
oauthAccessTokenExpiresAt: tokens.expiresAt,
|
|
oauthScopes: [...tokens.scopes],
|
|
status: "active",
|
|
lastErrorText: null,
|
|
archivedAt: null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await trySubscribeWebhook({ tenantId, connectionId: connection.id, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? "" });
|
|
return { tenantSlug, tenantId, connectionId: connection.id };
|
|
}
|
|
|
|
/**
|
|
* Best-effort push-channel subscription for the primary calendar. Needs a public
|
|
* webhook URL (GOOGLE_CALENDAR_WEBHOOK_URL); absent or failing it degrades the
|
|
* connection with a Klartext hint rather than failing the connect.
|
|
*/
|
|
async function trySubscribeWebhook(input: { tenantId: string; connectionId: string; accessToken: string; refreshToken: string }): Promise<void> {
|
|
const webhookUrl = process.env.GOOGLE_CALENDAR_WEBHOOK_URL;
|
|
if (!webhookUrl) {
|
|
return;
|
|
}
|
|
const creds: CalendarCreds = { accessToken: input.accessToken, refreshToken: input.refreshToken };
|
|
// The channel id doubles as the per-connection lookup; the token is the
|
|
// verification secret echoed back as X-Goog-Channel-Token.
|
|
const channelId = `cal_${input.connectionId}`;
|
|
try {
|
|
const sub = await googleCalendarAdapter.subscribeWebhook(creds, "primary", channelId, webhookUrl, input.connectionId);
|
|
await db.calendarConnection.update({
|
|
where: { id: input.connectionId },
|
|
data: { webhookSubscriptionId: sub.subscriptionId, webhookSubscriptionExpiresAt: sub.expiresAt },
|
|
});
|
|
} catch {
|
|
await db.calendarConnection.update({
|
|
where: { id: input.connectionId },
|
|
data: { status: "active", lastErrorText: "calendar.health.webhookSubscriptionFailed" },
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Decrypt a connection's sealed tokens into adapter creds (server-only). */
|
|
export async function loadConnectionCreds(tenantId: string, connectionId: string): Promise<CalendarCreds | null> {
|
|
const row = await db.calendarConnection.findFirst({
|
|
where: { id: connectionId, tenantId },
|
|
select: { oauthRefreshTokenCiphertext: true, oauthAccessTokenCiphertext: true, oauthAccessTokenExpiresAt: true },
|
|
});
|
|
if (!row?.oauthRefreshTokenCiphertext || !row.oauthAccessTokenCiphertext) {
|
|
return null;
|
|
}
|
|
const [refreshToken, accessToken] = await Promise.all([
|
|
decryptTenantField(tenantId, row.oauthRefreshTokenCiphertext),
|
|
decryptTenantField(tenantId, row.oauthAccessTokenCiphertext),
|
|
]);
|
|
return { refreshToken, accessToken, expiresAt: row.oauthAccessTokenExpiresAt };
|
|
}
|
|
|
|
/**
|
|
* Disconnect a connection (owner or admin). Best-effort revoke of the push channel
|
|
* + provider tokens, then soft-revoke locally (status revoked, soft-delete). The
|
|
* external mirror events are optionally removed (M25 disconnect dialog) — deferred
|
|
* to the sync service; here we drop the local connection + its push channel.
|
|
*/
|
|
export async function disconnectConnection(input: { tenantId: string; connectionId: string }): Promise<void> {
|
|
const row = await db.calendarConnection.findFirst({
|
|
where: { id: input.connectionId, tenantId: input.tenantId, archivedAt: null },
|
|
select: { id: true, webhookSubscriptionId: true },
|
|
});
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const creds = await loadConnectionCreds(input.tenantId, input.connectionId);
|
|
if (creds && row.webhookSubscriptionId) {
|
|
await googleCalendarAdapter.unsubscribeWebhook(creds, row.webhookSubscriptionId, row.webhookSubscriptionId).catch(() => undefined);
|
|
}
|
|
await db.calendarConnection.update({
|
|
where: { id: row.id },
|
|
data: { status: "revoked", archivedAt: new Date(), oauthRefreshTokenCiphertext: null, oauthAccessTokenCiphertext: null, webhookSubscriptionId: null },
|
|
});
|
|
}
|