From 8189f39e2750f9621dfcddcf686a224cf9ee38c5 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 16:11:57 +0200 Subject: [PATCH 01/43] initial commit --- apps/app/.env.example | 8 + .../integrations/[provider]/callback/route.ts | 4 +- apps/app/src/env.js | 10 + apps/app/src/features/integrations/CONTEXT.md | 38 ++- .../api/integration-connection-procedures.ts | 66 +++- .../api/integration-connections.test.ts | 2 + .../api/integration-page-procedures.ts | 6 +- .../integrations/api/integration.schema.ts | 22 +- .../src/features/integrations/contracts.ts | 35 ++- apps/app/src/features/integrations/server.ts | 5 +- .../integrations/server/base-provider.ts | 109 +++++-- ...lback.test.ts => connect-callback.test.ts} | 145 +++++++-- ...{oauth-callback.ts => connect-callback.ts} | 81 +++-- .../server/connection-token.test.ts | 108 +++++++ .../integrations/server/connection-token.ts | 72 +++++ .../server/providers/github/app-auth.ts | 160 ++++++++++ .../server/providers/github/provider.test.ts | 284 ++++++++++++++++++ .../server/providers/github/provider.ts | 86 ++++++ .../integrations/server/providers/notion.ts | 19 +- .../features/integrations/server/registry.ts | 17 ++ .../server/sync-source-freshness.test.ts | 19 +- .../server/sync-source-freshness.ts | 16 +- .../components/org-integrations-card.tsx | 147 +++++++-- .../components/integration-buttons.tsx | 14 +- .../extractors/integration-extractors.ts | 10 +- .../page-picker/page-picker-content.tsx | 4 +- .../page-picker/use-page-picker-controller.ts | 4 +- .../notebook/sources/sources-panel.tsx | 6 +- .../settings/components/org-settings-form.tsx | 8 +- .../settings/i18n/org-settings.types.ts | 6 + .../settings/i18n/orgSettings.i18n.de.json | 12 +- .../settings/i18n/orgSettings.i18n.en.json | 12 +- docs/runbooks/github-app.md | 137 +++++++++ docs/setup.md | 7 +- .../migration.sql | 13 + packages/db/schema/integration.prisma | 12 +- 36 files changed, 1541 insertions(+), 163 deletions(-) rename apps/app/src/features/integrations/server/{oauth-callback.test.ts => connect-callback.test.ts} (76%) rename apps/app/src/features/integrations/server/{oauth-callback.ts => connect-callback.ts} (73%) create mode 100644 apps/app/src/features/integrations/server/connection-token.test.ts create mode 100644 apps/app/src/features/integrations/server/connection-token.ts create mode 100644 apps/app/src/features/integrations/server/providers/github/app-auth.ts create mode 100644 apps/app/src/features/integrations/server/providers/github/provider.test.ts create mode 100644 apps/app/src/features/integrations/server/providers/github/provider.ts create mode 100644 docs/runbooks/github-app.md create mode 100644 packages/db/migrations/20260828120000_github_app_installation/migration.sql diff --git a/apps/app/.env.example b/apps/app/.env.example index 6024272..a91f8fa 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -63,3 +63,11 @@ STRIPE_PRICE_SEAT_STARTER="" STRIPE_PRICE_SEAT_BUSINESS="" STRIPE_PRICE_SEAT_PRO="" STRIPE_PORTAL_CONFIGURATION_ID="" + +# GitHub App — organization integration. Required: register the app first +# (docs/runbooks/github-app.md). The slug is the last segment of the app's +# public URL, github.com/apps/. The private key is the PEM GitHub gives +# you once, with its newlines escaped as \n so it survives this file. +GITHUB_APP_SLUG="" +GITHUB_APP_ID="" +GITHUB_APP_PRIVATE_KEY="" diff --git a/apps/app/src/app/api/integrations/[provider]/callback/route.ts b/apps/app/src/app/api/integrations/[provider]/callback/route.ts index be81a2d..7dd5330 100644 --- a/apps/app/src/app/api/integrations/[provider]/callback/route.ts +++ b/apps/app/src/app/api/integrations/[provider]/callback/route.ts @@ -1,10 +1,10 @@ import type { NextRequest } from "next/server"; -import { handleIntegrationOAuthCallback } from "@/features/integrations/server"; +import { handleIntegrationConnectCallback } from "@/features/integrations/server"; export function GET( request: NextRequest, context: { params: Promise<{ provider: string }> }, ) { - return handleIntegrationOAuthCallback(request, context); + return handleIntegrationConnectCallback(request, context); } diff --git a/apps/app/src/env.js b/apps/app/src/env.js index aa805b1..6e7253c 100644 --- a/apps/app/src/env.js +++ b/apps/app/src/env.js @@ -46,6 +46,12 @@ export const env = createEnv({ NOTION_CLIENT_ID: z.string().min(1), NOTION_CLIENT_SECRET: z.string().min(1), + /** GitHub App credentials — see docs/runbooks/github-app.md */ + GITHUB_APP_SLUG: z.string().min(1), + GITHUB_APP_ID: z.string().min(1), + /** PEM private key; newlines may be escaped as \n for .env files. */ + GITHUB_APP_PRIVATE_KEY: z.string().min(1), + AI_GATEWAY_API_KEY: z.string().min(1), /** Gateway model ID used when the client selects Scibly AI (scibly/default) */ SCIBLY_DEFAULT_CHAT_MODEL: z @@ -117,6 +123,10 @@ export const env = createEnv({ NOTION_CLIENT_ID: process.env.NOTION_CLIENT_ID, NOTION_CLIENT_SECRET: process.env.NOTION_CLIENT_SECRET, + GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG, + GITHUB_APP_ID: process.env.GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, + AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, SCIBLY_DEFAULT_CHAT_MODEL: process.env.SCIBLY_DEFAULT_CHAT_MODEL, SCIBLY_DEFAULT_IMAGE_MODEL: process.env.SCIBLY_DEFAULT_IMAGE_MODEL, diff --git a/apps/app/src/features/integrations/CONTEXT.md b/apps/app/src/features/integrations/CONTEXT.md index 6425707..6e211b5 100644 --- a/apps/app/src/features/integrations/CONTEXT.md +++ b/apps/app/src/features/integrations/CONTEXT.md @@ -1,29 +1,46 @@ # Integrations -How an organization's own documents, living in someone else's system, become -material a notebook can read — and how a change made over there is noticed back -here. Nothing in this context writes a course; it only supplies and re-checks -what a source was made from. +How an organization's own material, living in someone else's system, becomes +reachable from here — and how a change made over there is noticed back here. +Mostly that material is documents a notebook can read, but a provider is worth +connecting even when it offers none. Nothing in this context writes a course; it +only supplies and re-checks what a source was made from. ## Language ### The connection **Provider**: -An outside system pages can be pulled from — Notion, Confluence, SharePoint. -Adding one is adding a provider, not an integration. +An outside system an organization connects to — Notion, GitHub, Confluence. +Either its material is pages a notebook can import, or it is only read from +elsewhere; a provider is worth connecting either way, and only the first kind is +ever shown to a notebook. Adding one is adding a provider, not an integration. _Avoid_: service, vendor, app **Connection**: One organization's authorised link to one provider, and the credential behind it. At most one per provider per organization, made by the person who authorised -it. +it. The credential comes in one of two shapes — stored OAuth tokens, or an +installation — and a connection is only ever one of them. _Avoid_: integration (the context, not the record), account, credential +**Installation**: +What a provider connected by letting an app in, rather than by granting tokens, +leaves behind: an id standing for what the app was let onto. It is not a token — +the token it stands for is minted from the app's own key for the one call that +needs it and never written down. +_Avoid_: token, app, integration + +**Grant**: +A named piece of a workspace an installation was let at — a GitHub repository. +Only a provider that hands access out piece by piece has any; a workspace given +whole grants nothing to list. +_Avoid_: repository (GitHub's word for one), scope, permission, resource + **Workspace**: The container on the provider's side that a connection can reach — a Notion -workspace, a Confluence site. Reconnecting to a different one does not carry the -old one's pages across. +workspace, a Confluence site, the GitHub account an app was installed on. +Reconnecting to a different one does not carry the old one's pages across. _Avoid_: site, tenant, organization (ours, and never theirs) **Page**: @@ -60,7 +77,8 @@ _Avoid_: fetch, check, sync (the run, not the turn) **Refresh**: Getting a new access token for a connection whose old one expired. Said only of -credentials — content is never refreshed, it is synced. +stored credentials — content is never refreshed, it is synced, and an +installation never is either: its token is minted afresh each time. _Avoid_: renew, re-sync **Watermark**: diff --git a/apps/app/src/features/integrations/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index 9bfd656..daf79c1 100644 --- a/apps/app/src/features/integrations/api/integration-connection-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-connection-procedures.ts @@ -1,24 +1,34 @@ -import type { IntegrationProviderId } from "../contracts"; +import type { + IntegrationProviderId, + PageIntegrationProviderId, +} from "../contracts"; import { AppError } from "@scibly/api/application-error"; import { protectedProcedure } from "@scibly/api/trpc"; import { db } from "@scibly/db"; import { resolveOrg } from "@/features/organizations/server"; -import { decryptApiKey } from "@/lib/crypto/api-key"; import { signOAuthState } from "@/lib/crypto/oauth-state"; +import { resolveConnectionToken } from "../server/connection-token"; import { detachSourcesFromConnection } from "../server/detach-sources"; -import { getProvider, listProviders } from "../server/registry"; +import { + getPageProvider, + getProvider, + listProviders, +} from "../server/registry"; import { disconnectIntegrationSchema, getAuthUrlSchema, + listGrantsSchema, listPageChildrenSchema, orgSlugInput, searchPagesSchema, } from "./integration.schema"; -export async function resolveConnection( +// No credential is touched here — enough for anything that only needs to know +// the connection exists. +export async function resolveConnectionRow( organizationId: string, providerId: IntegrationProviderId, ) { @@ -34,13 +44,33 @@ export async function resolveConnection( message: `No ${providerId} integration connected for this organization.`, }); } + return { connection, provider: getProvider(providerId) }; +} + +export async function resolveConnection( + organizationId: string, + providerId: IntegrationProviderId, +) { + const resolved = await resolveConnectionRow(organizationId, providerId); return { - connection, - provider: getProvider(providerId), - token: decryptApiKey(connection.accessTokenEncrypted), + ...resolved, + token: await resolveConnectionToken(resolved.connection), }; } +// Page-shaped work goes through here instead: the provider it hands back is +// one that has pages. +export async function resolvePageConnection( + organizationId: string, + providerId: PageIntegrationProviderId, +) { + const { connection, token } = await resolveConnection( + organizationId, + providerId, + ); + return { connection, token, provider: getPageProvider(providerId) }; +} + export const integrationConnectionProcedures = { list: protectedProcedure.input(orgSlugInput).query(async ({ input, ctx }) => { const { organization } = await resolveOrg( @@ -63,6 +93,7 @@ export const integrationConnectionProcedures = { const allProviders = listProviders().map((provider) => ({ providerId: provider.providerId, displayName: provider.displayName, + listsGrants: provider.listsGrants, })); return { connections, allProviders }; }), @@ -111,6 +142,23 @@ export const integrationConnectionProcedures = { return { success: true }; }), + // Its own procedure rather than part of `list`, so the settings page never + // waits on a provider that is slow or down. + listGrants: protectedProcedure + .input(listGrantsSchema) + .query(async ({ input, ctx }) => { + const { organization } = await resolveOrg( + input.orgSlug, + ctx.session.user.id, + "admin_or_owner", + ); + const { provider, token } = await resolveConnection( + organization.id, + input.provider, + ); + return { grants: await provider.listGrants(token) }; + }), + searchPages: protectedProcedure .input(searchPagesSchema) .query(async ({ input, ctx }) => { @@ -119,7 +167,7 @@ export const integrationConnectionProcedures = { ctx.session.user.id, "admin_or_owner", ); - const { provider, token } = await resolveConnection( + const { provider, token } = await resolvePageConnection( organization.id, input.provider, ); @@ -134,7 +182,7 @@ export const integrationConnectionProcedures = { ctx.session.user.id, "admin_or_owner", ); - const { provider, token } = await resolveConnection( + const { provider, token } = await resolvePageConnection( organization.id, input.provider, ); diff --git a/apps/app/src/features/integrations/api/integration-connections.test.ts b/apps/app/src/features/integrations/api/integration-connections.test.ts index 15d1f13..aa7da40 100644 --- a/apps/app/src/features/integrations/api/integration-connections.test.ts +++ b/apps/app/src/features/integrations/api/integration-connections.test.ts @@ -112,6 +112,7 @@ describe("LA1 one door only", () => { "linkPage", "linkPages", "list", + "listGrants", "listPageChildren", "resyncSource", "searchPages", @@ -208,6 +209,7 @@ describe("LR who may see, who may change", () => { INTEGRATION_PROVIDERS.map((providerId) => ({ providerId, displayName: expect.any(String), + listsGrants: expect.any(Boolean), })), ); }); diff --git a/apps/app/src/features/integrations/api/integration-page-procedures.ts b/apps/app/src/features/integrations/api/integration-page-procedures.ts index e9c7fef..b7ba9a2 100644 --- a/apps/app/src/features/integrations/api/integration-page-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-page-procedures.ts @@ -14,7 +14,7 @@ import { linkPagesSchema, resyncSourceSchema, } from "./integration.schema"; -import { resolveConnection } from "./integration-connection-procedures"; +import { resolveConnectionRow } from "./integration-connection-procedures"; async function resolveLinkedNotebook( orgSlug: string, @@ -45,7 +45,7 @@ export const integrationPageProcedures = { input.notebookId, userId, ); - const { connection } = await resolveConnection( + const { connection } = await resolveConnectionRow( organization.id, input.provider, ); @@ -68,7 +68,7 @@ export const integrationPageProcedures = { input.notebookId, ctx.session.user.id, ); - const { connection } = await resolveConnection( + const { connection } = await resolveConnectionRow( organization.id, input.provider, ); diff --git a/apps/app/src/features/integrations/api/integration.schema.ts b/apps/app/src/features/integrations/api/integration.schema.ts index 71a93d2..1e27a8e 100644 --- a/apps/app/src/features/integrations/api/integration.schema.ts +++ b/apps/app/src/features/integrations/api/integration.schema.ts @@ -1,12 +1,19 @@ import { z } from "zod"; -import { INTEGRATION_PROVIDERS } from "../contracts"; +import { + INTEGRATION_PROVIDERS, + PAGE_INTEGRATION_PROVIDERS, +} from "../contracts"; export const orgSlugInput = z.object({ orgSlug: z.string() }); // An unrecognised provider is a bad request here, before any org is resolved or any row is read. export const providerInput = z.enum(INTEGRATION_PROVIDERS); +// Anything page-shaped narrows further: a provider without pages has nothing +// to search, browse, or link. +export const pageProviderInput = z.enum(PAGE_INTEGRATION_PROVIDERS); + export const getAuthUrlSchema = z.object({ orgSlug: z.string(), provider: providerInput, @@ -18,16 +25,21 @@ export const disconnectIntegrationSchema = z.object({ provider: providerInput, }); -export const searchPagesSchema = z.object({ +export const listGrantsSchema = z.object({ orgSlug: z.string(), provider: providerInput, +}); + +export const searchPagesSchema = z.object({ + orgSlug: z.string(), + provider: pageProviderInput, query: z.string().default(""), }); export const linkPageSchema = z.object({ notebookId: z.string(), orgSlug: z.string(), - provider: providerInput, + provider: pageProviderInput, pageId: z.string(), pageTitle: z.string(), pageUrl: z.string().url(), @@ -36,7 +48,7 @@ export const linkPageSchema = z.object({ export const linkPagesSchema = z.object({ notebookId: z.string(), orgSlug: z.string(), - provider: providerInput, + provider: pageProviderInput, pages: z .array( z.object({ @@ -56,7 +68,7 @@ export const resyncSourceSchema = z.object({ export const listPageChildrenSchema = z.object({ orgSlug: z.string(), - provider: providerInput, + provider: pageProviderInput, pageId: z.string(), nodeType: z.enum(["page", "database"]).default("page"), }); diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index b0739a5..a8723a4 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -1,8 +1,17 @@ // Kept dependency-free so the client bundle (input schemas, settings card) never pulls in a provider SDK. -export const INTEGRATION_PROVIDERS = ["NOTION"] as const; +export const INTEGRATION_PROVIDERS = ["NOTION", "GITHUB"] as const; export type IntegrationProviderId = (typeof INTEGRATION_PROVIDERS)[number]; +// The only providers a notebook is offered as a source. A provider is worth +// connecting before it has pages — see `ReadOnlyIntegrationProvider`. +export const PAGE_INTEGRATION_PROVIDERS = [ + "NOTION", +] as const satisfies readonly IntegrationProviderId[]; + +export type PageIntegrationProviderId = + (typeof PAGE_INTEGRATION_PROVIDERS)[number]; + // A provider's raw `?error=` is always mapped to `provider_denied` or `provider_error` first — it must never be echoed into the query string. export const INTEGRATION_CALLBACK_ERRORS = [ "provider_denied", @@ -41,6 +50,14 @@ export interface IntegrationPageRevision { lastEdited: Date; } +// A named part of a workspace a connection reaches — a repository an +// installation was given. A workspace handed over whole grants nothing to list. +export interface IntegrationGrant { + id: string; + name: string; + url: string; +} + export interface OAuthTokens { accessToken: string; refreshToken?: string; @@ -48,3 +65,19 @@ export interface OAuthTokens { workspaceId?: string; workspaceName?: string; } + +// What an installed app leaves behind instead of tokens. The token it stands +// for is minted per call and never stored. +export interface AppInstallation { + installationId: string; + workspaceId?: string; + workspaceName?: string; +} + +// Which shape a connection holds decides both the columns it is written to and +// how its token is later got. +export type IntegrationCredential = + | ({ kind: "oauth_tokens" } & OAuthTokens) + | ({ kind: "app_installation" } & AppInstallation); + +export type IntegrationCredentialKind = IntegrationCredential["kind"]; diff --git a/apps/app/src/features/integrations/server.ts b/apps/app/src/features/integrations/server.ts index 70635bf..7045acb 100644 --- a/apps/app/src/features/integrations/server.ts +++ b/apps/app/src/features/integrations/server.ts @@ -1,9 +1,10 @@ import "server-only"; export { integrationRouter } from "./api/integration.router"; +export { handleIntegrationConnectCallback } from "./server/connect-callback"; +export { resolveConnectionToken } from "./server/connection-token"; export { buildIntegrationNotebookTools } from "./server/notebook-tools"; -export { handleIntegrationOAuthCallback } from "./server/oauth-callback"; -export { getProvider, listProviders } from "./server/registry"; +export { getPageProvider, getProvider, listProviders } from "./server/registry"; export { acquireSyncLease, continueSyncLease, diff --git a/apps/app/src/features/integrations/server/base-provider.ts b/apps/app/src/features/integrations/server/base-provider.ts index 735c53e..faf86c4 100644 --- a/apps/app/src/features/integrations/server/base-provider.ts +++ b/apps/app/src/features/integrations/server/base-provider.ts @@ -1,20 +1,89 @@ import type { + IntegrationCredential, + IntegrationCredentialKind, + IntegrationGrant, IntegrationPage, IntegrationPageContent, IntegrationPageRevision, IntegrationProviderId, OAuthTokens, + PageIntegrationProviderId, } from "../contracts"; +// Which of the two the provider's redirect carries is decided by its +// `credential`, not by the caller. +export interface ConnectCallbackParams { + code: string | null; + installationId: string | null; +} + +/** + * A provider saying the credential behind a connection is gone on its side — + * the app uninstalled, the grant revoked. Nothing a reconnect cannot fix, and + * distinct from a call that merely failed, which is why it is worth its own + * type: only this one means the stored connection is now fiction. + */ +export class IntegrationRevokedError extends Error { + constructor(readonly providerId: IntegrationProviderId) { + super(`The ${providerId} connection no longer exists on the provider.`); + this.name = "IntegrationRevokedError"; + } +} + +/** + * The connection itself: how one is authorised, and what it is worth once made. + * What the provider behind it is then good for belongs to a subclass — every + * provider is either a `PageIntegrationProvider` or a + * `ReadOnlyIntegrationProvider`. + */ export abstract class BaseIntegrationProvider { abstract readonly providerId: IntegrationProviderId; abstract readonly displayName: string; + /** Which credential shape a finished connect leaves behind. */ + abstract readonly credential: IntegrationCredentialKind; + + /** Whether the workspace is reached piece by piece — see `listGrants`. */ + readonly listsGrants: boolean = false; + + abstract getAuthUrl(state: string, redirectUri: string): string; + + /** Turn what the provider's redirect carried into the credential to store. */ + abstract completeConnect( + params: ConnectCallbackParams, + redirectUri: string, + ): Promise; + + async refreshToken(_refreshToken: string): Promise { + throw new Error( + `${this.providerId} does not support token refresh. Reconnect the integration.`, + ); + } + + /** What the connection reaches, when access was handed out piece by piece. */ + listGrants(_token: string): Promise { + return Promise.resolve([]); + } +} + +/** + * A provider whose material is pages: the only kind a notebook can import a + * source from, and the only kind `PAGE_INTEGRATION_PROVIDERS` names. + */ +export abstract class PageIntegrationProvider extends BaseIntegrationProvider { + abstract readonly providerId: PageIntegrationProviderId; + abstract searchPages( token: string, query: string, ): Promise; + abstract fetchPageContent( + token: string, + pageId: string, + ): Promise; + + /** Pages held inside another; none unless the provider nests them. */ listChildren(_token: string, _pageId: string): Promise { return Promise.resolve([]); } @@ -26,11 +95,7 @@ export abstract class BaseIntegrationProvider { return Promise.resolve([]); } - abstract fetchPageContent( - token: string, - pageId: string, - ): Promise; - + /** The cheap edited-at marker a poll checks, when the provider offers one. */ getPageRevision( _token: string, _pageId: string, @@ -38,20 +103,28 @@ export abstract class BaseIntegrationProvider { return Promise.resolve(null); } - abstract getAuthUrl(state: string, redirectUri: string): string; - - abstract exchangeCode( - code: string, - redirectUri: string, - ): Promise; - - async refreshToken(_refreshToken: string): Promise { - throw new Error( - `${this.providerId} does not support token refresh. Reconnect the integration.`, - ); - } - + /** What a poll asks for. Nothing, unless the provider can say what changed. */ pollModifiedPages(_token: string, _since: Date): Promise { return Promise.resolve([]); } } + +/** + * A provider that offers no pages — GitHub, Jira, Slack. Connected for what is + * read out of it elsewhere, never shown to a notebook as a source. + */ +export abstract class ReadOnlyIntegrationProvider extends BaseIntegrationProvider {} + +/** A provider whose token is minted per use instead of stored. */ +export interface AppInstallationProvider extends BaseIntegrationProvider { + readonly credential: "app_installation"; + mintAccessToken(installationId: string): Promise; +} + +export function mintsInstallationTokens( + provider: BaseIntegrationProvider, +): provider is AppInstallationProvider { + return ( + provider.credential === "app_installation" && "mintAccessToken" in provider + ); +} diff --git a/apps/app/src/features/integrations/server/oauth-callback.test.ts b/apps/app/src/features/integrations/server/connect-callback.test.ts similarity index 76% rename from apps/app/src/features/integrations/server/oauth-callback.test.ts rename to apps/app/src/features/integrations/server/connect-callback.test.ts index 2e6075d..77340ea 100644 --- a/apps/app/src/features/integrations/server/oauth-callback.test.ts +++ b/apps/app/src/features/integrations/server/connect-callback.test.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@scibly/db"; import type { MockInstance } from "vitest"; -import type { OAuthTokens } from "../contracts"; +import type { IntegrationCredential } from "../contracts"; +import type { ConnectCallbackParams } from "./base-provider"; import { defaultLocale } from "@scibly/i18n/constants"; import { NextRequest } from "next/server"; @@ -10,7 +11,8 @@ import { decryptApiKey } from "@/lib/crypto/api-key"; import { signOAuthState } from "@/lib/crypto/oauth-state"; // Exercises the full route handler; only the database, session, membership -// policy, and provider token exchange are mocked. The state signer is real. +// policy, and what the provider makes of its own callback are mocked. The state +// signer is real. const APP_URL = "http://localhost:3000"; const SETTINGS = `${APP_URL}/de/profile/org/acme/settings`; @@ -36,20 +38,33 @@ vi.mock("@scibly/db", () => ({ db })); vi.mock("@scibly/auth/session", () => ({ getSession })); vi.mock("@/features/organizations/server", () => ({ requireOrgMember })); -const { handleIntegrationOAuthCallback } = await import("./oauth-callback"); +const { handleIntegrationConnectCallback } = await import("./connect-callback"); const { PROVIDERS } = await import("./registry"); -const TOKENS: OAuthTokens = { +const TOKENS: IntegrationCredential = { + kind: "oauth_tokens", accessToken: "secret-access-token", refreshToken: "secret-refresh-token", workspaceId: "workspace-1", workspaceName: "Acme HQ", }; -let exchangeCode: MockInstance< - (code: string, redirectUri: string) => Promise +const INSTALLATION: IntegrationCredential = { + kind: "app_installation", + installationId: "42", + workspaceId: "github-account-1", + workspaceName: "acme-inc", +}; + +type CompleteConnect = MockInstance< + ( + params: ConnectCallbackParams, + redirectUri: string, + ) => Promise >; +let completeConnect: CompleteConnect; + function state(overrides: Partial[0]> = {}) { return signOAuthState({ orgSlug: "acme", @@ -68,7 +83,7 @@ async function callback( for (const [key, value] of Object.entries(query)) { if (value !== undefined) url.searchParams.set(key, value); } - return handleIntegrationOAuthCallback(new NextRequest(url), { + return handleIntegrationConnectCallback(new NextRequest(url), { params: Promise.resolve({ provider: providerParam }), }); } @@ -101,14 +116,14 @@ beforeEach(() => { db.integrationConnection.upsert.mockResolvedValue({}); db.notebookSource.updateMany.mockResolvedValue({ count: 0 }); - exchangeCode = vi - .spyOn(PROVIDERS.NOTION, "exchangeCode") + completeConnect = vi + .spyOn(PROVIDERS.NOTION, "completeConnect") .mockResolvedValue(TOKENS); }); afterEach(() => { vi.useRealTimers(); - exchangeCode.mockRestore(); + completeConnect.mockRestore(); }); describe("LA the door", () => { @@ -126,7 +141,7 @@ describe("LA the door", () => { const response = await callback({ code: "auth-code", state: state() }); expect(refusal(response)).toBe("session_mismatch"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); expect(db.integrationConnection.upsert).not.toHaveBeenCalled(); }); @@ -136,7 +151,7 @@ describe("LA the door", () => { const response = await callback({ code: "auth-code", state: state() }); expect(refusal(response)).toBe("session_mismatch"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); }); it("LA5 refuses somebody who is no longer an admin of the org", async () => { @@ -145,7 +160,7 @@ describe("LA the door", () => { const response = await callback({ code: "auth-code", state: state() }); expect(refusal(response)).toBe("forbidden"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); expect(db.integrationConnection.upsert).not.toHaveBeenCalled(); }); @@ -190,7 +205,7 @@ describe("LA the door", () => { ); expect(refusal(response)).toBe("state_mismatch"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); }); it("LA8 accepts a path segment in any case, since the state decides", async () => { @@ -223,7 +238,7 @@ describe("LA the door", () => { const response = await callback({ code: "auth-code", state: given }); expect(refusal(response)).toBe(reason); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); expect(db.integrationConnection.upsert).not.toHaveBeenCalled(); }, ); @@ -235,21 +250,21 @@ describe("LA the door", () => { const response = await callback({ code: "auth-code", state: stale }); expect(refusal(response)).toBe("expired_state"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); }); it("LA7 refuses a callback carrying no code", async () => { const response = await callback({ state: state() }); expect(refusal(response)).toBe("missing_params"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); }); it("LA7 refuses a callback the user declined", async () => { const response = await callback({ error: "access_denied", state: state() }); expect(refusal(response)).toBe("provider_denied"); - expect(exchangeCode).not.toHaveBeenCalled(); + expect(completeConnect).not.toHaveBeenCalled(); }); }); @@ -268,7 +283,10 @@ describe("LS what is stored", () => { }); it("LS1 stores no refresh token when the provider issues none", async () => { - exchangeCode.mockResolvedValue({ accessToken: "only-access" }); + completeConnect.mockResolvedValue({ + kind: "oauth_tokens", + accessToken: "only-access", + }); await callback({ code: "auth-code", state: state() }); @@ -364,16 +382,99 @@ describe("LS what is stored", () => { it("LS3 exchanges the code against the redirect URI this app publishes", async () => { await callback({ code: "auth-code", state: state() }, "NoTiOn"); - expect(exchangeCode).toHaveBeenCalledWith( - "auth-code", + expect(completeConnect).toHaveBeenCalledWith( + { code: "auth-code", installationId: null }, `${APP_URL}/api/integrations/notion/callback`, ); }); }); +// GitHub comes back from an install, not from an OAuth grant: the callback +// carries an installation id and no code, and what is stored is the +// installation rather than a token. +describe("LS what an installation stores", () => { + let install: CompleteConnect; + + function githubState() { + return state({ provider: "GITHUB" }); + } + + async function githubCallback(query: Record) { + return callback(query, "github"); + } + + beforeEach(() => { + install = vi + .spyOn(PROVIDERS.GITHUB, "completeConnect") + .mockResolvedValue(INSTALLATION); + }); + + afterEach(() => { + install.mockRestore(); + }); + + it("LS1 stores the installation id and no token at all", async () => { + const response = await githubCallback({ + installation_id: "42", + setup_action: "install", + state: githubState(), + }); + + expect(refusal(response)).toBeNull(); + expect(upserted().create).toMatchObject({ + provider: "GITHUB", + installationId: "42", + accessTokenEncrypted: null, + refreshTokenEncrypted: null, + tokenExpiresAt: null, + workspaceName: "acme-inc", + }); + }); + + it("LA7 refuses an install callback carrying no installation, code or not", async () => { + const response = await githubCallback({ + code: "auth-code", + state: githubState(), + }); + + expect(refusal(response)).toBe("missing_params"); + expect(install).not.toHaveBeenCalled(); + }); + + it("LS4 installing on a different GitHub account detaches the old one's sources", async () => { + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-gh", + workspaceId: "github-account-old", + }); + + await githubCallback({ installation_id: "42", state: githubState() }); + + expect(db.notebookSource.updateMany).toHaveBeenCalledWith({ + where: { integrationId: "conn-gh" }, + data: { + integrationId: null, + warning: expect.stringContaining("different workspace"), + }, + }); + }); + + it("LS3 reinstalling on the same account keeps its sources and takes the new id", async () => { + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-gh", + workspaceId: "github-account-1", + }); + install.mockResolvedValue({ ...INSTALLATION, installationId: "99" }); + + await githubCallback({ installation_id: "99", state: githubState() }); + + expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(upserted().update).toMatchObject({ installationId: "99" }); + }); +}); + describe("LF what a failure tells the admin", () => { it("LF1 sends a failed exchange back to the org's settings with a code", async () => { - exchangeCode.mockRejectedValue(new Error("notion said no")); + completeConnect.mockRejectedValue(new Error("notion said no")); const error = vi .spyOn(console, "error") .mockImplementation(() => undefined); diff --git a/apps/app/src/features/integrations/server/oauth-callback.ts b/apps/app/src/features/integrations/server/connect-callback.ts similarity index 73% rename from apps/app/src/features/integrations/server/oauth-callback.ts rename to apps/app/src/features/integrations/server/connect-callback.ts index 62360b2..5ed6698 100644 --- a/apps/app/src/features/integrations/server/oauth-callback.ts +++ b/apps/app/src/features/integrations/server/connect-callback.ts @@ -1,7 +1,12 @@ import type { IntegrationCallbackError, + IntegrationCredential, IntegrationProviderId, } from "../contracts"; +import type { + BaseIntegrationProvider, + ConnectCallbackParams, +} from "./base-provider"; import { getSession } from "@scibly/auth/session"; import { db } from "@scibly/db"; @@ -31,7 +36,7 @@ type CallbackDestination = { type ValidCallback = CallbackDestination & { provider: IntegrationProviderId; - code: string; + params: ConnectCallbackParams; orgSlug: string; connectedByUserId: string; }; @@ -49,6 +54,23 @@ function providerError(oauthError: string): IntegrationCallbackError { return oauthError === "access_denied" ? "provider_denied" : "provider_error"; } +// An OAuth provider sends back a code to redeem, an app installation the id of +// the installation just made. Only the one the provider deals in is looked at. +function readCallbackParams( + searchParams: URLSearchParams, + provider: BaseIntegrationProvider, +): ConnectCallbackParams | null { + const params: ConnectCallbackParams = { + code: searchParams.get("code"), + installationId: searchParams.get("installation_id"), + }; + const required = + provider.credential === "app_installation" + ? params.installationId + : params.code; + return required ? params : null; +} + function validateCallback( req: NextRequest, providerParam: string, @@ -67,7 +89,6 @@ function validateCallback( }; const oauthError = searchParams.get("error"); - const code = searchParams.get("code"); const state = searchParams.get("state"); if (!state) { @@ -98,9 +119,6 @@ function validateCallback( if (oauthError) { return { ok: false, destination, reason: providerError(oauthError) }; } - if (!code) { - return { ok: false, destination, reason: "missing_params" }; - } if (!orgSlug || !userId || !isIntegrationProvider(provider)) { return { ok: false, destination, reason: "invalid_state" }; } @@ -109,12 +127,17 @@ function validateCallback( return { ok: false, destination, reason: "state_mismatch" }; } + const params = readCallbackParams(searchParams, getProvider(provider)); + if (!params) { + return { ok: false, destination, reason: "missing_params" }; + } + return { ok: true, callback: { ...destination, provider, - code, + params, orgSlug, connectedByUserId: userId, }, @@ -144,7 +167,27 @@ async function authorizeCallback( } } -async function exchangeAndPersistConnection( +// The two shapes use disjoint columns, and each connect clears the other's. +function credentialColumns(credential: IntegrationCredential) { + if (credential.kind === "app_installation") { + return { + accessTokenEncrypted: null, + refreshTokenEncrypted: null, + tokenExpiresAt: null, + installationId: credential.installationId, + }; + } + return { + accessTokenEncrypted: encryptApiKey(credential.accessToken), + refreshTokenEncrypted: credential.refreshToken + ? encryptApiKey(credential.refreshToken) + : null, + tokenExpiresAt: credential.expiresAt ?? null, + installationId: null, + }; +} + +async function completeAndPersistConnection( callback: ValidCallback, organizationId: string, ) { @@ -160,15 +203,15 @@ async function exchangeAndPersistConnection( select: { id: true, workspaceId: true }, }); - const tokens = await getProvider(callback.provider).exchangeCode( - callback.code, + const credential = await getProvider(callback.provider).completeConnect( + callback.params, redirectUri, ); if ( existing?.workspaceId && - tokens.workspaceId && - existing.workspaceId !== tokens.workspaceId + credential.workspaceId && + existing.workspaceId !== credential.workspaceId ) { await detachSourcesFromConnection( existing.id, @@ -178,13 +221,9 @@ async function exchangeAndPersistConnection( } const connectionData = { - accessTokenEncrypted: encryptApiKey(tokens.accessToken), - refreshTokenEncrypted: tokens.refreshToken - ? encryptApiKey(tokens.refreshToken) - : null, - tokenExpiresAt: tokens.expiresAt ?? null, - workspaceId: tokens.workspaceId ?? null, - workspaceName: tokens.workspaceName ?? null, + ...credentialColumns(credential), + workspaceId: credential.workspaceId ?? null, + workspaceName: credential.workspaceName ?? null, connectedByUserId: callback.connectedByUserId, }; @@ -201,7 +240,7 @@ async function exchangeAndPersistConnection( }); } -export async function handleIntegrationOAuthCallback( +export async function handleIntegrationConnectCallback( req: NextRequest, { params }: { params: Promise<{ provider: string }> }, ) { @@ -218,13 +257,13 @@ export async function handleIntegrationOAuthCallback( } try { - await exchangeAndPersistConnection(callback, authorization.organizationId); + await completeAndPersistConnection(callback, authorization.organizationId); return NextResponse.redirect( `${callback.settingsUrl}?${INTEGRATION_CONNECTED_QUERY_PARAM}=${callback.provider.toLowerCase()}`, ); } catch (err) { console.error( - `[IntegrationCallback] ${callback.provider} token exchange failed:`, + `[IntegrationCallback] ${callback.provider} connect failed:`, err, ); return errorRedirect(callback, "token_exchange_failed"); diff --git a/apps/app/src/features/integrations/server/connection-token.test.ts b/apps/app/src/features/integrations/server/connection-token.test.ts new file mode 100644 index 0000000..e945b44 --- /dev/null +++ b/apps/app/src/features/integrations/server/connection-token.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + integrationConnection: { deleteMany: vi.fn() }, + notebookSource: { updateMany: vi.fn() }, +})); +const registry = vi.hoisted(() => ({ getProvider: vi.fn() })); +const crypto = vi.hoisted(() => ({ decryptApiKey: vi.fn() })); + +vi.mock("@scibly/db", () => ({ db })); +vi.mock("./registry", () => registry); +vi.mock("@/lib/crypto/api-key", () => crypto); + +const { resolveConnectionToken } = await import("./connection-token"); +const { IntegrationRevokedError } = await import("./base-provider"); + +const INSTALLED = { + id: "conn_1", + provider: "GITHUB", + accessTokenEncrypted: null, + installationId: "42", +}; + +function installationProvider(mintAccessToken: () => Promise) { + return { + providerId: "GITHUB", + credential: "app_installation", + mintAccessToken, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("K1 the credential a connection turns into", () => { + it("K1 mints a fresh token for an installation rather than reading one", async () => { + registry.getProvider.mockReturnValue( + installationProvider(() => Promise.resolve("ghs_minted")), + ); + + await expect(resolveConnectionToken(INSTALLED)).resolves.toBe("ghs_minted"); + expect(crypto.decryptApiKey).not.toHaveBeenCalled(); + }); + + it("K1 decrypts what an OAuth connection stored", async () => { + registry.getProvider.mockReturnValue({ + providerId: "NOTION", + credential: "oauth_tokens", + }); + crypto.decryptApiKey.mockReturnValue("secret_notion"); + + await expect( + resolveConnectionToken({ + id: "conn_2", + provider: "NOTION", + accessTokenEncrypted: "cipher", + installationId: null, + }), + ).resolves.toBe("secret_notion"); + }); +}); + +describe("K2 a connection revoked on the provider's side", () => { + beforeEach(() => { + registry.getProvider.mockReturnValue( + installationProvider(() => + Promise.reject(new IntegrationRevokedError("GITHUB")), + ), + ); + }); + + it("K2 deletes the connection it can no longer stand for", async () => { + await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow(); + + expect(db.integrationConnection.deleteMany).toHaveBeenCalledWith({ + where: { id: "conn_1" }, + }); + }); + + it("K2 detaches its sources first, exactly as a disconnect does", async () => { + await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow(); + + const [args] = db.notebookSource.updateMany.mock.calls[0] as [ + { where: { integrationId: string }; data: { warning: string } }, + ]; + expect(args.where.integrationId).toBe("conn_1"); + expect(args.data.warning).toMatch(/GITHUB integration was disconnected/); + }); + + it("K2 says so in its own application code, so the client can explain", async () => { + await expect(resolveConnectionToken(INSTALLED)).rejects.toMatchObject({ + applicationCode: "integration.revoked", + code: "NOT_FOUND", + }); + }); + + it("K2 leaves an ordinary minting failure alone", async () => { + registry.getProvider.mockReturnValue( + installationProvider(() => Promise.reject(new Error("GitHub is down"))), + ); + + await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow( + "GitHub is down", + ); + expect(db.integrationConnection.deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/features/integrations/server/connection-token.ts b/apps/app/src/features/integrations/server/connection-token.ts new file mode 100644 index 0000000..f73b8c0 --- /dev/null +++ b/apps/app/src/features/integrations/server/connection-token.ts @@ -0,0 +1,72 @@ +import type { IntegrationProviderId } from "../contracts"; + +import { AppError } from "@scibly/api/application-error"; +import { db } from "@scibly/db"; + +import { decryptApiKey } from "@/lib/crypto/api-key"; + +import { + IntegrationRevokedError, + mintsInstallationTokens, +} from "./base-provider"; +import { detachSourcesFromConnection } from "./detach-sources"; +import { getProvider } from "./registry"; + +// The one place that turns what a connection stores into the token its API +// calls carry: an OAuth connection keeps an encrypted token, an app +// installation keeps only its id and mints a fresh token here for each use. +export interface ConnectionCredential { + id: string; + provider: IntegrationProviderId | string; + accessTokenEncrypted: string | null; + installationId: string | null; +} + +function unusable(provider: string): AppError { + return new AppError({ + code: "BAD_REQUEST", + applicationCode: "api.bad_request", + message: `The ${provider} connection holds no usable credential. Reconnect the integration.`, + }); +} + +// Its own application code so the client can say what happened rather than +// showing a connection that has already been taken away. +function revoked(provider: string): AppError { + return new AppError({ + code: "NOT_FOUND", + applicationCode: "integration.revoked", + message: `The ${provider} integration was removed on ${provider}'s side, so the connection was removed here too. Connect again to resume.`, + }); +} + +// Uninstalling the app is the provider's own disconnect, just announced +// nowhere: the id we hold is dead and no later call can revive it. Treat it +// exactly as a disconnect pressed here, so the two sides agree again. +async function forgetRevokedConnection( + connection: ConnectionCredential, + providerId: IntegrationProviderId, +): Promise { + await detachSourcesFromConnection(connection.id, providerId, "disconnected"); + await db.integrationConnection.deleteMany({ where: { id: connection.id } }); +} + +export async function resolveConnectionToken( + connection: ConnectionCredential, +): Promise { + const provider = getProvider(connection.provider); + + if (mintsInstallationTokens(provider)) { + if (!connection.installationId) throw unusable(provider.providerId); + try { + return await provider.mintAccessToken(connection.installationId); + } catch (error) { + if (!(error instanceof IntegrationRevokedError)) throw error; + await forgetRevokedConnection(connection, provider.providerId); + throw revoked(provider.providerId); + } + } + + if (!connection.accessTokenEncrypted) throw unusable(provider.providerId); + return decryptApiKey(connection.accessTokenEncrypted); +} diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts new file mode 100644 index 0000000..7731410 --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -0,0 +1,160 @@ +import crypto from "crypto"; + +import { env } from "@/env"; + +// The app's private key never leaves this module: every call a connection +// makes carries an installation access token minted here for that call and +// dropped afterwards, which is why none is ever written down. + +const GITHUB_API = "https://api.github.com"; + +// GitHub rejects a JWT issued ahead of its own clock and caps the lifetime at +// ten minutes; both bounds are taken with room to spare. +const JWT_BACKDATE_SECONDS = 60; +const JWT_LIFETIME_SECONDS = 8 * 60; + +export interface GitHubAppConfig { + appSlug: string; + appId: string; + privateKey: string; +} + +export interface GitHubInstallation { + installationId: string; + accountId: string; + accountLogin: string; +} + +export interface GitHubRepository { + id: number; + full_name: string; + html_url: string; +} + +export function readGitHubAppConfig(): GitHubAppConfig { + return { + appSlug: env.GITHUB_APP_SLUG, + appId: env.GITHUB_APP_ID, + // A PEM survives a .env file only with its newlines escaped, so both + // spellings are normalised to the one OpenSSL will parse. + privateKey: env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, "\n"), + }; +} + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString("base64url"); +} + +/** A short-lived assertion that this is the app — never an installation. */ +export function signAppJwt(config: GitHubAppConfig, now = new Date()): string { + const issuedAt = Math.floor(now.getTime() / 1000) - JWT_BACKDATE_SECONDS; + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const payload = base64url( + JSON.stringify({ + iat: issuedAt, + exp: issuedAt + JWT_LIFETIME_SECONDS, + iss: config.appId, + }), + ); + const signature = crypto + .createSign("RSA-SHA256") + .update(`${header}.${payload}`) + .sign(config.privateKey); + + return `${header}.${payload}.${base64url(signature)}`; +} + +/** Carries GitHub's status out, so a caller can tell a gone installation + * apart from a network or permission failure. */ +export class GitHubRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "GitHubRequestError"; + } +} + +async function githubRequest( + path: string, + init: { method: "GET" | "POST"; authorization: string }, +): Promise { + const response = await fetch(`${GITHUB_API}${path}`, { + method: init.method, + headers: { + accept: "application/vnd.github+json", + authorization: init.authorization, + "x-github-api-version": "2022-11-28", + }, + cache: "no-store", + }); + + if (!response.ok) { + // Only GitHub's status and message are carried out: the request was + // authorised with a JWT or a minted token, and neither belongs in an error + // a caller may log. + const message = await response + .json() + .then((body: { message?: string }) => body.message) + .catch(() => undefined); + throw new GitHubRequestError( + `GitHub ${init.method} ${path} failed: ${response.status}${message ? ` ${message}` : ""}`, + response.status, + ); + } + // SAFETY: the body is GitHub's documented response for the path the caller + // asked for, and every field GitHub may omit is checked before it is used. + return (await response.json()) as T; +} + +interface InstallationResponse { + id: number; + account: { id: number; login: string } | null; +} + +/** Who the app was installed on, asked as the app itself. */ +export async function fetchInstallation( + config: GitHubAppConfig, + installationId: string, +): Promise { + const installation = await githubRequest( + `/app/installations/${encodeURIComponent(installationId)}`, + { method: "GET", authorization: `Bearer ${signAppJwt(config)}` }, + ); + if (!installation.account) { + throw new Error( + `GitHub installation ${installationId} names no account to connect to.`, + ); + } + return { + installationId: String(installation.id), + accountId: String(installation.account.id), + accountLogin: installation.account.login, + }; +} + +/** Mint the hour-long token this installation stands for. Never stored. */ +export async function mintInstallationToken( + config: GitHubAppConfig, + installationId: string, +): Promise { + const minted = await githubRequest<{ token: string }>( + `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, + { method: "POST", authorization: `Bearer ${signAppJwt(config)}` }, + ); + return minted.token; +} + +/** The repositories the installation was given. */ +export async function fetchInstallationRepositories( + token: string, +): Promise { + const { repositories } = await githubRequest<{ + repositories?: GitHubRepository[]; + }>("/installation/repositories?per_page=100", { + method: "GET", + authorization: `Bearer ${token}`, + }); + return repositories ?? []; +} diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts new file mode 100644 index 0000000..b5927ff --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Nothing of GitHub is mocked but the network: the app JWT is really signed, +// and really verified here against the public half of a key generated for this +// file. + +const mockEnv = vi.hoisted(() => ({}) as Record); + +vi.mock("@/env", () => ({ env: mockEnv })); + +const { createVerify, generateKeyPairSync } = await import("crypto"); + +const KEYS = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); + +const CONFIGURED = { + GITHUB_APP_SLUG: "scibly-dev", + GITHUB_APP_ID: "123456", + GITHUB_APP_PRIVATE_KEY: KEYS.privateKey, +}; + +const { GitHubProvider } = await import("./provider"); +const { readGitHubAppConfig, signAppJwt } = await import("./app-auth"); +const { + IntegrationRevokedError, + PageIntegrationProvider, + ReadOnlyIntegrationProvider, +} = await import("../../base-provider"); + +const NOW = new Date("2026-08-28T12:00:00.000Z"); + +const fetchMock = vi.fn(); + +function ok(body: unknown) { + return { ok: true, status: 200, json: () => Promise.resolve(body) }; +} + +function failed(status: number, body: unknown) { + return { ok: false, status, json: () => Promise.resolve(body) }; +} + +function lastRequest() { + const call = fetchMock.mock.calls.at(-1); + if (!call) throw new Error("nothing was fetched"); + return { + url: String(call[0]), + init: call[1] as { method: string; headers: Record }, + }; +} + +function decodeJwt(token: string) { + const [header, payload] = token.split("."); + return { + header: JSON.parse( + Buffer.from(String(header), "base64url").toString(), + ) as Record, + payload: JSON.parse( + Buffer.from(String(payload), "base64url").toString(), + ) as Record, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + vi.clearAllMocks(); + for (const key of Object.keys(mockEnv)) delete mockEnv[key]; + Object.assign(mockEnv, CONFIGURED); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("GH1 the app's own assertion", () => { + it("GH1 signs an RS256 JWT that verifies against the app's public key", () => { + const token = signAppJwt(readGitHubAppConfig()); + const [header, payload, signature] = token.split("."); + + expect(decodeJwt(token).header).toEqual({ alg: "RS256", typ: "JWT" }); + expect( + createVerify("RSA-SHA256") + .update(`${header}.${payload}`) + .verify(KEYS.publicKey, Buffer.from(String(signature), "base64url")), + ).toBe(true); + }); + + it("GH1 claims the app id, backdated and inside GitHub's ten-minute cap", () => { + const { payload } = decodeJwt(signAppJwt(readGitHubAppConfig())); + const nowSeconds = Math.floor(NOW.getTime() / 1000); + + expect(payload.iss).toBe("123456"); + expect(Number(payload.iat)).toBeLessThan(nowSeconds); + expect(Number(payload.exp) - nowSeconds).toBeLessThanOrEqual(600); + expect(Number(payload.exp)).toBeGreaterThan(nowSeconds); + }); + + it("GH1 signs with a key whose newlines were escaped to survive a .env file", () => { + mockEnv.GITHUB_APP_PRIVATE_KEY = KEYS.privateKey.replace(/\n/g, "\\n"); + + expect(() => signAppJwt(readGitHubAppConfig())).not.toThrow(); + }); +}); + +describe("GH3 starting the install", () => { + it("GH3 sends the admin to the app's install page carrying the state", () => { + const url = new URL(new GitHubProvider().getAuthUrl("state-1", "unused")); + + expect(url.origin + url.pathname).toBe( + "https://github.com/apps/scibly-dev/installations/new", + ); + expect(url.searchParams.get("state")).toBe("state-1"); + }); +}); + +describe("GH4 what the callback becomes", () => { + it("GH4 turns an installation id into the account it was installed on", async () => { + fetchMock.mockResolvedValue( + ok({ id: 42, account: { id: 777, login: "acme-inc" } }), + ); + + const credential = await new GitHubProvider().completeConnect({ + code: null, + installationId: "42", + }); + + expect(credential).toEqual({ + kind: "app_installation", + installationId: "42", + workspaceId: "777", + workspaceName: "acme-inc", + }); + }); + + it("GH4 asks about the installation as the app itself, with a signed JWT", async () => { + fetchMock.mockResolvedValue( + ok({ id: 42, account: { id: 777, login: "acme-inc" } }), + ); + + await new GitHubProvider().completeConnect({ + code: null, + installationId: "42", + }); + const { url, init } = lastRequest(); + + expect(url).toBe("https://api.github.com/app/installations/42"); + expect(init.method).toBe("GET"); + expect( + decodeJwt(init.headers.authorization.split(" ")[1] ?? "").payload, + ).toMatchObject({ iss: "123456" }); + }); + + it("GH4 refuses a callback that names no installation", async () => { + await expect( + new GitHubProvider().completeConnect({ + code: "auth-code", + installationId: null, + }), + ).rejects.toThrow(/no installation/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("GH4 refuses an installation GitHub gives no account for", async () => { + fetchMock.mockResolvedValue(ok({ id: 42, account: null })); + + await expect( + new GitHubProvider().completeConnect({ + code: null, + installationId: "42", + }), + ).rejects.toThrow(/names no account/i); + }); +}); + +describe("GH5 the minted token", () => { + it("GH5 mints against the installation and hands back only the token", async () => { + fetchMock.mockResolvedValue( + ok({ token: "ghs_minted", expires_at: "2026-08-28T13:00:00Z" }), + ); + + const token = await new GitHubProvider().mintAccessToken("42"); + const { url, init } = lastRequest(); + + expect(token).toBe("ghs_minted"); + expect(url).toBe( + "https://api.github.com/app/installations/42/access_tokens", + ); + expect(init.method).toBe("POST"); + }); + + it("GH5 keeps the private key out of every request it makes", async () => { + fetchMock.mockResolvedValue(ok({ token: "ghs_minted" })); + + await new GitHubProvider().mintAccessToken("42"); + + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain("PRIVATE KEY"); + }); + + it("GH5 carries GitHub's own complaint out, and no credential with it", async () => { + fetchMock.mockResolvedValue(failed(500, { message: "Server Error" })); + + await expect(new GitHubProvider().mintAccessToken("42")).rejects.toThrow( + /500 Server Error/, + ); + await expect( + new GitHubProvider().mintAccessToken("42"), + ).rejects.not.toThrow(/PRIVATE KEY|eyJ/); + }); + + it("GH5 reads a 404 as the installation being gone, not as a failed call", async () => { + fetchMock.mockResolvedValue(failed(404, { message: "Not Found" })); + + await expect( + new GitHubProvider().mintAccessToken("42"), + ).rejects.toBeInstanceOf(IntegrationRevokedError); + }); + + it("GH5 leaves every other refusal to the caller as an ordinary failure", async () => { + fetchMock.mockResolvedValue(failed(403, { message: "Forbidden" })); + + await expect( + new GitHubProvider().mintAccessToken("42"), + ).rejects.not.toBeInstanceOf(IntegrationRevokedError); + }); +}); + +describe("GH6 what the installation reaches", () => { + it("GH6 lists each repository as a grant, asked for with the minted token", async () => { + fetchMock.mockResolvedValue( + ok({ + total_count: 2, + repositories: [ + { + id: 1, + full_name: "acme-inc/api", + html_url: "https://github.com/acme-inc/api", + }, + { + id: 2, + full_name: "acme-inc/web", + html_url: "https://github.com/acme-inc/web", + }, + ], + }), + ); + + const grants = await new GitHubProvider().listGrants("ghs_minted"); + + expect(grants).toEqual([ + { id: "1", name: "acme-inc/api", url: "https://github.com/acme-inc/api" }, + { id: "2", name: "acme-inc/web", url: "https://github.com/acme-inc/web" }, + ]); + expect(lastRequest().init.headers.authorization).toBe("Bearer ghs_minted"); + }); + + it("GH6 says the connection reaches nothing rather than failing", async () => { + fetchMock.mockResolvedValue(ok({ total_count: 0, repositories: [] })); + + await expect( + new GitHubProvider().listGrants("ghs_minted"), + ).resolves.toEqual([]); + }); +}); + +describe("GH7 what GitHub is not asked for", () => { + it("GH7 is not a provider a notebook can import pages from", () => { + const provider = new GitHubProvider(); + + expect(provider).toBeInstanceOf(ReadOnlyIntegrationProvider); + expect(provider).not.toBeInstanceOf(PageIntegrationProvider); + expect(provider.listsGrants).toBe(true); + }); + + it("GH7 has no refresh: a stored token is not what it holds", async () => { + await expect(new GitHubProvider().refreshToken("nope")).rejects.toThrow( + /does not support token refresh/, + ); + }); +}); diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts new file mode 100644 index 0000000..9f4828b --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -0,0 +1,86 @@ +import type { + IntegrationCredential, + IntegrationGrant, +} from "../../../contracts"; +import type { + AppInstallationProvider, + ConnectCallbackParams, +} from "../../base-provider"; + +import { + IntegrationRevokedError, + ReadOnlyIntegrationProvider, +} from "../../base-provider"; +import { + fetchInstallation, + fetchInstallationRepositories, + GitHubRequestError, + mintInstallationToken, + readGitHubAppConfig, +} from "./app-auth"; + +// GitHub is connected by installing an app on an account, not by an OAuth +// grant, so what comes back is an installation id. The workspace behind it is +// the account id — not the installation id, which a reinstall replaces — so +// that is what tells a reconnect from a move to a different organization. +export class GitHubProvider + extends ReadOnlyIntegrationProvider + implements AppInstallationProvider +{ + readonly providerId = "GITHUB"; + readonly displayName = "GitHub"; + readonly credential = "app_installation"; + readonly listsGrants = true; + + // The redirect back is the app's registered setup URL, so unlike OAuth there + // is nothing to pass here; only the state rides along and comes back. + getAuthUrl(state: string, _redirectUri: string): string { + const { appSlug } = readGitHubAppConfig(); + const url = new URL( + `https://github.com/apps/${encodeURIComponent(appSlug)}/installations/new`, + ); + url.searchParams.set("state", state); + return url.toString(); + } + + async completeConnect( + params: ConnectCallbackParams, + ): Promise { + if (!params.installationId) { + throw new Error("GitHub returned no installation to connect to."); + } + const installation = await fetchInstallation( + readGitHubAppConfig(), + params.installationId, + ); + return { + kind: "app_installation", + installationId: installation.installationId, + workspaceId: installation.accountId, + workspaceName: installation.accountLogin, + }; + } + + // GitHub answers 404 for an installation that no longer exists, which is + // what an uninstall on its side looks like from here — the id we hold is + // simply gone, and no token will ever be minted from it again. + async mintAccessToken(installationId: string): Promise { + try { + return await mintInstallationToken(readGitHubAppConfig(), installationId); + } catch (error) { + if (error instanceof GitHubRequestError && error.status === 404) { + throw new IntegrationRevokedError(this.providerId); + } + throw error; + } + } + + async listGrants(token: string): Promise { + const repositories = await fetchInstallationRepositories(token); + return repositories.map((repository) => ({ + id: String(repository.id), + name: repository.full_name, + url: repository.html_url, + })); + } +} diff --git a/apps/app/src/features/integrations/server/providers/notion.ts b/apps/app/src/features/integrations/server/providers/notion.ts index 5d38fdb..8550162 100644 --- a/apps/app/src/features/integrations/server/providers/notion.ts +++ b/apps/app/src/features/integrations/server/providers/notion.ts @@ -1,15 +1,16 @@ import type { + IntegrationCredential, IntegrationPage, IntegrationPageContent, IntegrationPageRevision, - OAuthTokens, } from "../../contracts"; +import type { ConnectCallbackParams } from "../base-provider"; import { Client, isFullPage } from "@notionhq/client"; import { env } from "@/env"; -import { BaseIntegrationProvider } from "../base-provider"; +import { PageIntegrationProvider } from "../base-provider"; import { collectNotionChildPages, extractNotionPageIcon, @@ -17,9 +18,10 @@ import { listNotionDatabasePages, } from "./notion-pages"; -export class NotionProvider extends BaseIntegrationProvider { +export class NotionProvider extends PageIntegrationProvider { readonly providerId = "NOTION"; readonly displayName = "Notion"; + readonly credential = "oauth_tokens"; getAuthUrl(state: string, redirectUri: string): string { const url = new URL("https://api.notion.com/v1/oauth/authorize"); @@ -31,15 +33,22 @@ export class NotionProvider extends BaseIntegrationProvider { return url.toString(); } - async exchangeCode(code: string, redirectUri: string): Promise { + async completeConnect( + params: ConnectCallbackParams, + redirectUri: string, + ): Promise { + if (!params.code) { + throw new Error("Notion returned no authorisation code to exchange."); + } const response = await new Client().oauth.token({ client_id: env.NOTION_CLIENT_ID, client_secret: env.NOTION_CLIENT_SECRET, grant_type: "authorization_code", - code, + code: params.code, redirect_uri: redirectUri, }); return { + kind: "oauth_tokens", accessToken: response.access_token, workspaceId: response.workspace_id, workspaceName: response.workspace_name ?? undefined, diff --git a/apps/app/src/features/integrations/server/registry.ts b/apps/app/src/features/integrations/server/registry.ts index 52eac75..b5464a9 100644 --- a/apps/app/src/features/integrations/server/registry.ts +++ b/apps/app/src/features/integrations/server/registry.ts @@ -4,10 +4,13 @@ import type { BaseIntegrationProvider } from "./base-provider"; import { AppError } from "@scibly/api/application-error"; import { INTEGRATION_PROVIDERS } from "../contracts"; +import { PageIntegrationProvider } from "./base-provider"; +import { GitHubProvider } from "./providers/github/provider"; import { NotionProvider } from "./providers/notion"; export const PROVIDERS = { NOTION: new NotionProvider(), + GITHUB: new GitHubProvider(), } satisfies Record; export function isIntegrationProvider( @@ -27,6 +30,20 @@ export function getProvider(providerId: string): BaseIntegrationProvider { return PROVIDERS[providerId]; } +// For the page picker and everything downstream of it: a provider id that came +// off a row or a request is only good here if the provider actually has pages. +export function getPageProvider(providerId: string): PageIntegrationProvider { + const provider = getProvider(providerId); + if (!(provider instanceof PageIntegrationProvider)) { + throw new AppError({ + code: "BAD_REQUEST", + applicationCode: "api.bad_request", + message: `${provider.providerId} offers no pages to read.`, + }); + } + return provider; +} + export function listProviders(): BaseIntegrationProvider[] { return Object.values(PROVIDERS); } diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts index b873ba6..b1cdc46 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts @@ -2,6 +2,7 @@ import { notLapsedSubscription } from "@scibly/api/entitlement"; import { routes } from "@scibly/routes"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PAGE_INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; const db = vi.hoisted(() => ({ @@ -15,7 +16,10 @@ const db = vi.hoisted(() => ({ })); const provider = vi.hoisted(() => ({ pollModifiedPages: vi.fn() })); -const registry = vi.hoisted(() => ({ getProvider: vi.fn() })); +const registry = vi.hoisted(() => ({ + getProvider: vi.fn(), + getPageProvider: vi.fn(), +})); const crypto = vi.hoisted(() => ({ decryptApiKey: vi.fn() })); vi.mock("@scibly/db", async () => { @@ -138,6 +142,7 @@ beforeEach(() => { hops: 1, }); registry.getProvider.mockReturnValue(provider); + registry.getPageProvider.mockReturnValue(provider); crypto.decryptApiKey.mockReturnValue("plain-token"); provider.pollModifiedPages.mockResolvedValue([]); }); @@ -220,6 +225,16 @@ describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for", () => { ]); }); + it("owes only connections to a provider that has pages to poll", async () => { + await loadOwedConnections(LEASE, NOW); + + const [args] = db.integrationConnection.findMany.mock.calls[0]; + + expect(args.where.provider).toEqual({ + in: [...PAGE_INTEGRATION_PROVIDERS], + }); + }); + it("KF3: excludes a connection still inside its backoff", async () => { await loadOwedConnections(LEASE, NOW); @@ -338,7 +353,7 @@ describe("KF1: one broken integration", () => { { case: "a provider the registry does not know", break: () => - registry.getProvider.mockImplementation((name: string) => { + registry.getPageProvider.mockImplementation((name: string) => { if (name === "gone") throw new Error("Unknown provider: gone"); return provider; }), diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.ts b/apps/app/src/features/integrations/server/sync-source-freshness.ts index 8087ee6..48a097b 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.ts @@ -4,8 +4,9 @@ import { db, Prisma } from "@scibly/db"; import { routes } from "@scibly/routes"; import { env } from "@/env"; -import { getProvider } from "@/features/integrations/server/registry"; -import { decryptApiKey } from "@/lib/crypto/api-key"; +import { PAGE_INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; +import { resolveConnectionToken } from "@/features/integrations/server/connection-token"; +import { getPageProvider } from "@/features/integrations/server/registry"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; // No webhook exists for any integration, so this scheduled poll is the only way a changed page is noticed; `lastPolledAt` is the per-connection watermark. @@ -117,7 +118,8 @@ export async function releaseSyncLease(lease: SyncLease): Promise { type SyncConnection = { id: string; provider: string; - accessTokenEncrypted: string; + accessTokenEncrypted: string | null; + installationId: string | null; lastPolledAt: Date | null; consecutiveFailures: number; }; @@ -133,6 +135,9 @@ export async function loadOwedConnections( return db.integrationConnection.findMany({ where: { organization: subscribedOrganization(now), + // A connection to a provider without pages has nothing that could go + // stale, so a poll would only spend a call to learn that. + provider: { in: [...PAGE_INTEGRATION_PROVIDERS] }, OR: [ { lastAttemptedAt: null }, { lastAttemptedAt: { lt: lease.chainStartedAt } }, @@ -143,6 +148,7 @@ export async function loadOwedConnections( id: true, provider: true, accessTokenEncrypted: true, + installationId: true, lastPolledAt: true, consecutiveFailures: true, }, @@ -242,8 +248,8 @@ async function syncConnection( const pollFrom = getPollingStart(connection.lastPolledAt, now); let modifiedIds: Set; try { - const provider = getProvider(connection.provider); - const token = decryptApiKey(connection.accessTokenEncrypted); + const provider = getPageProvider(connection.provider); + const token = await resolveConnectionToken(connection); const pages = await provider.pollModifiedPages(token, pollFrom); modifiedIds = new Set(pages.map((page) => page.id)); } catch (error) { diff --git a/apps/app/src/features/integrations/settings/components/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations-card.tsx index 7068e7d..d2c6a08 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations-card.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations-card.tsx @@ -1,16 +1,11 @@ "use client"; +import type { IntegrationProviderId } from "@/features/integrations/contracts"; import type { OrgSettingsPage } from "@/features/organizations/contracts"; import { Button } from "@scibly/ui/components/button"; -import { - CheckCircle2, - ExternalLink, - Plug, - Unplug, - XCircle, -} from "lucide-react"; -import { useState } from "react"; +import { CheckCircle2, ExternalLink, Unplug, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { api } from "@/shared/api/trpc/client"; @@ -29,17 +24,31 @@ export const NotionIcon = ({ className }: { className?: string }) => { ); }; -const PROVIDER_ICONS = new Map< - string, +export const GitHubIcon = ({ className }: { className?: string }) => { + return ( + + ); +}; + +const PROVIDER_ICONS = { + NOTION: NotionIcon, + GITHUB: GitHubIcon, +} satisfies Record< + IntegrationProviderId, React.ComponentType<{ className?: string }> ->([["NOTION", NotionIcon]]); +>; -function renderProviderIcon(providerId: string) { - const IconComponent = PROVIDER_ICONS.get(providerId); - return IconComponent ? ( +function renderProviderIcon(providerId: IntegrationProviderId) { + const IconComponent = PROVIDER_ICONS[providerId]; + return ( - ) : ( - ); } @@ -50,12 +59,17 @@ interface OrgIntegrationsCardProps { } type ProviderRowProps = { - provider: { providerId: string; displayName: string }; + provider: { + providerId: IntegrationProviderId; + displayName: string; + listsGrants?: boolean; + }; connection?: { workspaceName: string | null }; isDisconnecting: boolean; isConnectPending: boolean; isDisconnectPending: boolean; t: OrgSettingsPage["integrations"]; + orgSlug: string; onConnect: () => void; onDisconnect: () => void; }; @@ -124,26 +138,96 @@ export const ProviderAction = ({ ); }; +// Its own query, so the card renders at once and only this strip waits on the +// provider. +export const ProviderGrants = ({ + orgSlug, + provider, + t, +}: { + orgSlug: string; + provider: IntegrationProviderId; + t: OrgSettingsPage["integrations"]; +}) => { + const utils = api.useUtils(); + const { data, isPending, isError, error } = + api.integration.listGrants.useQuery({ + orgSlug, + provider, + }); + + // The token this query needs is minted per call, so this strip is where an + // uninstall on the provider's side first shows up. The server has already + // dropped the connection by the time the error arrives; refetching the list + // is what takes the row off the page. + const wasRevoked = error?.data?.applicationCode === "integration.revoked"; + useEffect(() => { + if (!wasRevoked) return; + toast.error(t.revokedNotice, { id: `integration-revoked-${provider}` }); + void utils.integration.list.invalidate({ orgSlug }); + }, [wasRevoked, provider, orgSlug, t.revokedNotice, utils]); + + if (isPending) { + return

{t.grantsLoading}

; + } + if (isError) { + return

{t.grantsError}

; + } + if (data.grants.length === 0) { + return

{t.grantsEmpty}

; + } + return ( +
+

+ {t.grantsTitle} +

+ +
+ ); +}; + export const ProviderRow = (props: ProviderRowProps) => { - const { provider, connection, t } = props; + const { provider, connection, orgSlug, t } = props; - const providerLabels: Record = t.providers; return ( -
-
-
- {renderProviderIcon(provider.providerId)} +
+
+
+
+ {renderProviderIcon(provider.providerId)} +
+
+

+ {t.providers[provider.providerId]} +

+ +
-
-

- {providerLabels[provider.providerId] ?? provider.displayName} -

- +
+
-
- -
+ {connection && provider.listsGrants ? ( + + ) : null}
); }; @@ -188,6 +272,7 @@ export function OrgIntegrationsCard({ void; + onPickerOpen: (providerKey: PageIntegrationProviderId) => void; disabled?: boolean; } -// Buttons come from INTEGRATION_PROVIDERS, the set the API actually accepts — -// PROVIDER_DISPLAY is cosmetic only and never gates which providers render. +// Buttons come from PAGE_INTEGRATION_PROVIDERS, the connectable providers that +// actually offer pages to import — a provider connected for something else has +// nothing to show a page picker. PROVIDER_DISPLAY is cosmetic only and never +// gates which providers render. export function IntegrationButtons({ connectedProviders, t, @@ -38,7 +40,7 @@ export function IntegrationButtons({ }: IntegrationButtonsProps) { return ( <> - {INTEGRATION_PROVIDERS.map((providerKey) => { + {PAGE_INTEGRATION_PROVIDERS.map((providerKey) => { const meta = PROVIDER_DISPLAY.get(providerKey) ?? PROVIDER_DISPLAY_FALLBACK; const isConnected = connectedProviders.some( diff --git a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts index 87a6275..6cff113 100644 --- a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts +++ b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts @@ -2,8 +2,10 @@ import type { ExtractableSource, SourceExtractor } from "./types"; import { db } from "@scibly/db"; -import { getProvider } from "@/features/integrations/server"; -import { decryptApiKey } from "@/lib/crypto/api-key"; +import { + getPageProvider, + resolveConnectionToken, +} from "@/features/integrations/server"; async function resolveIntegration(source: ExtractableSource) { if (!source.integrationId || !source.externalId) { @@ -33,8 +35,8 @@ async function resolveIntegration(source: ExtractableSource) { } return { - provider: getProvider(connection.provider), - token: decryptApiKey(connection.accessTokenEncrypted), + provider: getPageProvider(connection.provider), + token: await resolveConnectionToken(connection), externalId: source.externalId, }; } diff --git a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx index a594964..1797b1f 100644 --- a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx +++ b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx @@ -1,6 +1,6 @@ "use client"; -import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { PageIntegrationProviderId } from "@/features/integrations/contracts"; import type { NotebookTranslations } from "../../i18n/notebook.types"; import { useState } from "react"; @@ -27,7 +27,7 @@ export interface PagePickerContentProps { onOpenChange: (open: boolean) => void; notebookId: string; orgSlug: string; - provider: IntegrationProviderId; + provider: PageIntegrationProviderId; totalSourceCount: number; sourceLimit: number; diff --git a/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts b/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts index 97928c7..78fbe6c 100644 --- a/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts +++ b/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts @@ -1,6 +1,6 @@ "use client"; -import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { PageIntegrationProviderId } from "@/features/integrations/contracts"; import type { RouterOutputs } from "@/shared/api/trpc/client"; import type { BreadcrumbEntry, @@ -58,7 +58,7 @@ export function usePagePickerNavigation(setQuery: (query: string) => void) { export function usePagePickerPages( orgSlug: string, - provider: IntegrationProviderId, + provider: PageIntegrationProviderId, query: string, navigation: ReturnType, ) { diff --git a/apps/app/src/features/notebook/sources/sources-panel.tsx b/apps/app/src/features/notebook/sources/sources-panel.tsx index dbd2dfb..7d3b5b0 100644 --- a/apps/app/src/features/notebook/sources/sources-panel.tsx +++ b/apps/app/src/features/notebook/sources/sources-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { PageIntegrationProviderId } from "@/features/integrations/contracts"; import type { RouterOutputs } from "@/shared/api/trpc/client"; import type { NotebookTranslations } from "../i18n/notebook.types"; @@ -43,7 +43,7 @@ function useIntegrationPicker( ensureNotebook: () => Promise, ) { const [pickerState, setPickerState] = useState<{ - provider: IntegrationProviderId; + provider: PageIntegrationProviderId; notebookId: string; } | null>(null); const { data } = api.integration.list.useQuery( @@ -67,7 +67,7 @@ function useIntegrationPicker( ), ); }, [pickerState, sources]); - const open = (provider: IntegrationProviderId) => { + const open = (provider: PageIntegrationProviderId) => { if (atLimit) return; void ensureNotebook().then((notebookId) => setPickerState({ provider, notebookId }), diff --git a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx index 9115d42..d892e4e 100644 --- a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx +++ b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx @@ -45,8 +45,11 @@ function useOAuthResultNotifications( useEffect(() => { const url = new URL(window.location.href); if (integrationConnected) { + // React runs this effect twice in development, and the callback lands on + // a fresh mount either way — a stable id keeps one toast on screen. toast.success( `${integrationConnected.toUpperCase()} connected successfully.`, + { id: `integration-connected-${integrationConnected}` }, ); void trpcUtils.integration.list.invalidate(); url.searchParams.delete(INTEGRATION_CONNECTED_QUERY_PARAM); @@ -67,13 +70,16 @@ function useOAuthResultNotifications( org_not_found: "Organization not found.", forbidden: "You need to be an admin or owner to connect an integration.", token_exchange_failed: - "Connection failed. Check that your redirect URI is registered in Notion.", + "Connection failed. The provider rejected the credentials scibly sent.", } satisfies Record; const known = INTEGRATION_CALLBACK_ERRORS.find( (code) => code === integrationError, ); toast.error( known ? messages[known] : "Connection failed. Please try again.", + { + id: `integration-error-${integrationError}`, + }, ); url.searchParams.delete(INTEGRATION_ERROR_QUERY_PARAM); router.replace(url.pathname + url.search); diff --git a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts index 72b7a93..67423c8 100644 --- a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts +++ b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts @@ -124,8 +124,14 @@ export type OrgSettingsPage = { confirmDisconnectDescription: string; disconnectedSuccessfully: string; connectedSuccessfully: string; + grantsTitle: string; + grantsLoading: string; + grantsEmpty: string; + grantsError: string; + revokedNotice: string; providers: { NOTION: string; + GITHUB: string; }; }; }; diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json index d1e956e..f5050d3 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json @@ -112,8 +112,8 @@ "byoaiLockedRemovalNote": "Einen Endpunkt zu entfernen oder zurück zu Scibly AI zu wechseln, bleibt möglich." }, "integrations": { - "title": "Wissensquellen-Integrationen", - "description": "Verbinde externe Wissensdatenbanken, damit KI-Creator-Notebooks Seiten als RAG-Quellen importieren können.", + "title": "Integrationen", + "description": "Verbinde externe Dienste mit deiner Organisation — Wissensdatenbanken, aus denen KI-Creator-Notebooks Seiten importieren, und Code-Hosts, die Scibly lesen kann.", "connectButton": "Verbinden", "disconnectButton": "Trennen", "connectedStatus": "Verbunden", @@ -124,8 +124,14 @@ "confirmDisconnectDescription": "Bestehende Quellen bleiben in deinen Notebooks, aber die erneute Synchronisierung funktioniert erst wieder nach erneuter Verbindung.", "disconnectedSuccessfully": "Integration getrennt.", "connectedSuccessfully": "Integration erfolgreich verbunden.", + "grantsTitle": "Hat Zugriff auf", + "grantsLoading": "Zugriffe werden geladen …", + "grantsEmpty": "Diese Verbindung hat noch auf nichts Zugriff erhalten.", + "grantsError": "Die Zugriffe dieser Verbindung konnten nicht geladen werden.", + "revokedNotice": "Diese Integration wurde auf Anbieterseite entfernt, daher wurde die Verbindung auch hier entfernt. Verbinde erneut, um fortzufahren.", "providers": { - "NOTION": "Notion" + "NOTION": "Notion", + "GITHUB": "GitHub" } } } diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json index 9b97464..90df409 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json @@ -112,8 +112,8 @@ "byoaiLockedRemovalNote": "Removing an endpoint or switching back to Scibly AI stays available." }, "integrations": { - "title": "Knowledge Source Integrations", - "description": "Connect external knowledge bases so AI Creator notebooks can import pages as RAG sources.", + "title": "Integrations", + "description": "Connect external services to your organization — knowledge bases AI Creator notebooks can import pages from, and code hosts scibly can read.", "connectButton": "Connect", "disconnectButton": "Disconnect", "connectedStatus": "Connected", @@ -124,8 +124,14 @@ "confirmDisconnectDescription": "Existing sources will remain in your notebooks, but re-sync will no longer work until you reconnect.", "disconnectedSuccessfully": "Integration disconnected.", "connectedSuccessfully": "Integration connected successfully.", + "grantsTitle": "Has access to", + "grantsLoading": "Loading access…", + "grantsEmpty": "This connection has not been given access to anything yet.", + "grantsError": "Couldn't load what this connection can reach.", + "revokedNotice": "This integration was removed on the provider's side, so the connection has been removed here too. Connect again to resume.", "providers": { - "NOTION": "Notion" + "NOTION": "Notion", + "GITHUB": "GitHub" } } } diff --git a/docs/runbooks/github-app.md b/docs/runbooks/github-app.md new file mode 100644 index 0000000..aefae40 --- /dev/null +++ b/docs/runbooks/github-app.md @@ -0,0 +1,137 @@ +# Runbook: registering the GitHub App + +Scibly connects a GitHub organization by having it **install a GitHub App**, +not by an OAuth grant. What gets stored on the org's connection is the +installation id; the token it stands for is minted from the app's private key +for each call and never written down. So before anyone can press *Connect* on +GitHub in an organization's integration settings, the app itself has to exist — +once for development, once for production. + +This is a one-time task per environment. You need admin rights on the GitHub +account or organization that will own the app. + +## 1. Create the app + +Go to **Settings → Developer settings → GitHub Apps → New GitHub App**, on your +personal account (dev) or on the organization that should own it (prod). + +| Field | Development | Production | +| --- | --- | --- | +| **GitHub App name** | `Scibly (dev)` — names are globally unique, so add your own suffix if it's taken | `Scibly` | +| **Homepage URL** | `http://localhost:3001` | your app URL | +| **Setup URL** (under *Post installation*) | `http://localhost:3001/api/integrations/github/callback` | `${NEXT_PUBLIC_APP_URL}/api/integrations/github/callback` | +| **Redirect on update** | checked | checked | +| **Callback URL** (under *Identifying and authorizing users*) | leave blank | leave blank | +| **Request user authorization (OAuth) during installation** | **unchecked** | **unchecked** | +| **Webhook → Active** | unchecked | unchecked | +| **Where can this GitHub App be installed?** | *Only on this account* | *Any account* | + +The **Setup URL is the one that matters**: when someone finishes installing the +app, GitHub sends their browser there with `installation_id`, `setup_action`, +and the signed `state` scibly put on the install link. That route +([callback/route.ts](../../apps/app/src/app/api/integrations/[provider]/callback/route.ts)) +is what turns the installation into a connection. Leave the OAuth callback +blank and the user-authorization box unchecked — scibly never asks GitHub for a +user token, only for the installation. + +*Redirect on update* is checked so that changing which repositories the +installation can reach comes back through the same route and refreshes the +connection, rather than dead-ending on GitHub. + +**Webhooks are off** because scibly has no receiver for them yet. When one +lands, turn *Active* on, point the webhook URL at it, generate a secret with +`openssl rand -hex 32`, and add it to the app's env — this runbook and +`apps/app/src/env.js` should gain the variable in the same change. + +## 2. Permissions + +Under **Permissions → Repository permissions**, grant read-only and nothing +more: + +| Permission | Access | Why | +| --- | --- | --- | +| **Metadata** | Read-only | mandatory; lists the repositories the installation reaches | +| **Contents** | Read-only | reading files in a repository | +| **Pull requests** | Read-only | reading pull requests and their discussion | +| **Issues** | Read-only | reading issues and their discussion | + +Leave every organization and account permission at *No access*. Scibly never +writes to GitHub, so a write permission here is a liability with no upside. + +If you add a permission later, GitHub does **not** grant it to existing +installations — each installing organization has to approve the new permission +before it takes effect. + +## 3. Collect the credentials + +On the app's settings page: + +- **App ID** — shown at the top → `GITHUB_APP_ID` +- **Public link** at the bottom, `https://github.com/apps/` — the last + segment is the slug → `GITHUB_APP_SLUG` +- **Private keys → Generate a private key** — downloads a `.pem` **once**; + GitHub keeps no copy → `GITHUB_APP_PRIVATE_KEY` + +The private key is the app's whole identity: anyone holding it can mint tokens +for every installation. Keep it out of the repository and out of the database — +scibly reads it from the environment only, and it never leaves +[app-auth.ts](../../apps/app/src/features/integrations/server/providers/github/app-auth.ts). + +A PEM is multi-line and a `.env` file is not, so escape its newlines: + +```bash +awk 'BEGIN{ORS="\\n"} {print}' scibly.private-key.pem +``` + +Paste the result into `apps/app/.env` (both spellings are accepted — a real +multi-line value in a secret manager works too): + +``` +GITHUB_APP_SLUG="scibly-dev" +GITHUB_APP_ID="123456" +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\n" +``` + +All three are required by the env schema, like Notion's: the app refuses to +boot without them rather than failing at the moment someone presses *Connect*. + +## 4. Verify + +1. Restart `pnpm dev` so the new variables are picked up. +2. Open an organization's settings as an owner or admin → **Integrations** → + *Connect* on GitHub. +3. GitHub asks which account to install on and which repositories to give it. + Pick a couple rather than *All repositories* — it makes the next step + readable. +4. You land back on the settings page with GitHub connected, showing the + account it was installed on and the repositories the installation reaches. +5. *Disconnect* removes the connection on scibly's side. It does **not** + uninstall the app on GitHub — that is the org's own call, under + **Settings → Applications → Installed GitHub Apps**. Reconnecting an + installation that is still in place goes through without a second install. +6. Uninstalling on GitHub instead settles itself the other way. Nothing is + pushed to scibly — there is no webhook — so the connection stands until the + next call needs a token, at which point GitHub answers 404 for an + installation that is gone. That is read as a revoked connection rather than + a failed call: the sources are detached, the connection is deleted, and the + settings page says so. Uninstall the app, reload the page, and the row + should go back to *Not connected*. + +Installing on a *different* GitHub account is a workspace change: the sources +the old account's connection created are detached, exactly as reconnecting a +different Notion workspace behaves. + +## Troubleshooting + +- **`Invalid environment variables: GITHUB_APP_…`** at boot — the variable is + unset or empty. Restart dev after editing `.env`. +- **`error:1E08010C:DECODER routines::unsupported`** on connect — the PEM + didn't survive the `.env` file. Its newlines have to be real or escaped as + `\n`; a key pasted as one unbroken line cannot be parsed. +- **`GitHub GET /app/installations/... failed: 404`** *during connect* — the + installation id belongs to a different app than `GITHUB_APP_ID`. Usual cause: + dev credentials against a production install, or the other way round. The + same 404 *after* connect means the app was uninstalled, and is handled rather + than reported: the connection is deleted. +- **`401 'Issued at' is in the future`** — the machine's clock is ahead of + GitHub's by more than the minute the signing already backdates. Fix the clock. diff --git a/docs/setup.md b/docs/setup.md index 064c2e7..a098ae6 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -36,10 +36,15 @@ cp packages/db/.env.example packages/db/.env - `DATABASE_URL` in all four should point at the same database. - `apps/app/.env`'s schema (`apps/app/src/env.js`) validates required variables at build/dev time. For a minimal local run without every - third-party integration (AWS S3, Stripe, Notion, PostHog, ...), set + third-party integration (AWS S3, Stripe, Notion, GitHub, PostHog, ...), set `SKIP_ENV_VALIDATION=true` and leave those blank — the app boots, but features that depend on a missing credential (media uploads, billing, Notion import, ...) won't work until it's supplied. +- `GITHUB_APP_SLUG`, `GITHUB_APP_ID`, and `GITHUB_APP_PRIVATE_KEY` are + required by that schema, like Notion's credentials. They come from a GitHub + App you register once per environment — see + [runbooks/github-app.md](runbooks/github-app.md), which covers the dev and + prod registrations and where each credential comes from. - `COLLAB_TOKEN_SECRET` must be the **same value** in `apps/app/.env` and `apps/collab/.env` (min. 32 characters) — it signs the short-lived token each editor session uses to open a collab room. Generate one with diff --git a/packages/db/migrations/20260828120000_github_app_installation/migration.sql b/packages/db/migrations/20260828120000_github_app_installation/migration.sql new file mode 100644 index 0000000..1850908 --- /dev/null +++ b/packages/db/migrations/20260828120000_github_app_installation/migration.sql @@ -0,0 +1,13 @@ +-- GitHub connects by installing an app on an organization, not by an OAuth +-- grant, so it brings a second credential shape rather than a second set of +-- tokens. +ALTER TYPE "integration_provider" ADD VALUE 'GITHUB'; + +-- The installation is the credential. The token it stands for lasts an hour +-- and is minted from the app's private key on each use, so there is nothing +-- to encrypt and nothing to store. +ALTER TABLE "integration_connection" ADD COLUMN "installationId" TEXT; + +-- ...which leaves an installation-backed connection with no access token at +-- all. Existing rows all have one; the column only stops being required. +ALTER TABLE "integration_connection" ALTER COLUMN "accessTokenEncrypted" DROP NOT NULL; diff --git a/packages/db/schema/integration.prisma b/packages/db/schema/integration.prisma index 5bb6c32..410fdfa 100644 --- a/packages/db/schema/integration.prisma +++ b/packages/db/schema/integration.prisma @@ -1,5 +1,6 @@ enum IntegrationProvider { NOTION + GITHUB CONFLUENCE SHAREPOINT @@ -11,12 +12,19 @@ model IntegrationConnection { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) provider IntegrationProvider - /// AES-256-GCM encrypted access token - accessTokenEncrypted String + /// AES-256-GCM encrypted access token. Null for providers whose credential + /// is an installation rather than a token — see `installationId`. + accessTokenEncrypted String? /// Encrypted refresh token (if applicable) refreshTokenEncrypted String? /// Token expiry (for providers with expiring tokens) tokenExpiresAt DateTime? + /// The app installation this connection is, for providers connected by + /// installing an app rather than by an OAuth grant (GitHub). It is the whole + /// credential: the short-lived token it stands for is minted per call from + /// the app's private key and never written here. Exclusive with the token + /// columns above — a connection is one credential shape or the other. + installationId String? /// Provider workspace/site ID (e.g. Notion workspace ID) workspaceId String? /// Human-readable workspace name for display From 077b48671863b96cd068f474542ff6f4eba8b844 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 18:05:13 +0200 Subject: [PATCH 02/43] feat: self-hosted Inngest as the background-work engine Background work that outlives a request moves to Inngest, self-hosted rather than Inngest Cloud so the Docker deployment and the hosted app run one code path. Replaces the hand-rolled lease-and-chain cron pattern for anything new. - Serve route at /api/inngest, client and functions in apps/app/src/lib/inngest - heartbeat demo function (cron + event trigger, retries: 2) to prove wiring - inngest/inngest container in compose, on its own database on the existing Postgres, created by a one-shot that runs on every `up` - INNGEST_BASE_URL, INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY required with no defaults; INNGEST_DEV switches signing explicitly - ADR 0004 records the decision and the rejected alternatives Closes #8 Co-Authored-By: Claude Opus 5 --- .env.example | 7 + README.md | 16 +- apps/app/.env.example | 8 + apps/app/package.json | 1 + apps/app/src/app/api/inngest/route.ts | 18 + apps/app/src/env.js | 16 + apps/app/src/lib/inngest/client.ts | 14 + .../src/lib/inngest/functions/heartbeat.ts | 32 + .../src/lib/inngest/functions/index.test.ts | 15 + apps/app/src/lib/inngest/functions/index.ts | 3 + docker-compose.yml | 39 +- .../0004-inngest-self-hosted-orchestration.md | 40 + docs/architecture.md | 12 +- docs/docker.md | 57 +- docs/setup.md | 29 + package.json | 3 + pnpm-lock.yaml | 2042 ++++++++++++++++- turbo.json | 4 + 18 files changed, 2259 insertions(+), 97 deletions(-) create mode 100644 apps/app/src/app/api/inngest/route.ts create mode 100644 apps/app/src/lib/inngest/client.ts create mode 100644 apps/app/src/lib/inngest/functions/heartbeat.ts create mode 100644 apps/app/src/lib/inngest/functions/index.test.ts create mode 100644 apps/app/src/lib/inngest/functions/index.ts create mode 100644 docs/adr/0004-inngest-self-hosted-orchestration.md diff --git a/.env.example b/.env.example index f078dac..8cc7266 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,13 @@ NEXT_PUBLIC_ENV="production" COLLAB_PORT="4000" COLLAB_TRUST_PROXY="false" +# ── Inngest (background work) ──────────────────────────────────────────── +# Generate each with: openssl rand -hex 32 +INNGEST_EVENT_KEY="" +INNGEST_SIGNING_KEY="" +# Run dashboard on the host. The app uses the compose network, so this is host-only. +INNGEST_PORT="8288" + # ── Optional integrations ──────────────────────────────────────────────── # With SKIP_ENV_VALIDATION=true (default here), every var below can stay # blank — the apps boot fine, but the feature behind a missing credential diff --git a/README.md b/README.md index 5beb65f..07efa88 100644 --- a/README.md +++ b/README.md @@ -61,13 +61,14 @@ ee/ # enterprise-only code, separately licensed — see ee/README.md Fastest path — Docker: ```bash -cp .env.example .env # fill in COLLAB_TOKEN_SECRET, BETTER_AUTH_SECRET +cp .env.example .env # fill in COLLAB_TOKEN_SECRET, BETTER_AUTH_SECRET, + # INNGEST_EVENT_KEY, INNGEST_SIGNING_KEY docker compose up -d --build ``` -Spins up Postgres and all three apps in one go. Full walkthrough, including -deploying to a real domain and optional third-party integrations, is in -[docs/docker.md](docs/docker.md). +Spins up Postgres, the Inngest background-work engine, and all three apps in +one go. Full walkthrough, including deploying to a real domain and optional +third-party integrations, is in [docs/docker.md](docs/docker.md). From source instead — Node ≥22, pnpm 10.33, a Postgres database: @@ -80,9 +81,10 @@ pnpm dev ``` `pnpm dev` starts every app together (`apps/app` on :3001, `apps/web` on -:3000, `apps/collab` on :4000). Full walkthrough, including what each -environment variable is for and how to run a single app on its own, is in -[docs/setup.md](docs/setup.md). +:3000, `apps/collab` on :4000); `pnpm dev:inngest` alongside it starts the +Inngest dev server on :8288, which is what runs background work. Full +walkthrough, including what each environment variable is for and how to run a +single app on its own, is in [docs/setup.md](docs/setup.md). ## Contributing diff --git a/apps/app/.env.example b/apps/app/.env.example index 6024272..4c05e96 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -34,6 +34,14 @@ MEDIA_BUCKET_NAME="startup-prod-media" # Generate with: openssl rand -hex 32 CRON_SECRET="" +# Inngest. These defaults are the local `inngest dev` server, which runs +# unsigned and ignores both keys. Against a real server: openssl rand -hex 32 +INNGEST_BASE_URL="http://localhost:8288" +INNGEST_EVENT_KEY="local-dev-event-key" +INNGEST_SIGNING_KEY="0000000000000000000000000000000000000000000000000000000000000000" +# Set "false" to talk to a real self-hosted server — the keys must then be its keys. +# INNGEST_DEV="false" + # Short-lived collaboration room token signing (minimum 32 characters). # The exact same value must be configured for the app and collab services. # If omitted by the app, BETTER_AUTH_SECRET is used for compatibility. diff --git a/apps/app/package.json b/apps/app/package.json index 41567e2..1a5f9b9 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -131,6 +131,7 @@ "concurrently": "^9.1.2", "framer-motion": "^11.18.2", "geist": "^1.3.1", + "inngest": "^4.18.1", "katex": "^0.16.21", "lottie-react": "^2.4.1", "lucide-react": "^0.436.0", diff --git a/apps/app/src/app/api/inngest/route.ts b/apps/app/src/app/api/inngest/route.ts new file mode 100644 index 0000000..c30e649 --- /dev/null +++ b/apps/app/src/app/api/inngest/route.ts @@ -0,0 +1,18 @@ +import { serve } from "inngest/next"; +import { connection, type NextRequest } from "next/server"; + +import { inngest } from "@/lib/inngest/client"; +import { inngestFunctions } from "@/lib/inngest/functions"; + +export const maxDuration = 300; + +const handler = serve({ client: inngest, functions: inngestFunctions }); + +// Under `cacheComponents` a `GET` handler is prerendered unless it reaches for +// request-time data, and this one's answer depends on the request's headers. +export async function GET(request: NextRequest, context: unknown) { + await connection(); + return handler.GET(request, context); +} + +export const { POST, PUT } = handler; diff --git a/apps/app/src/env.js b/apps/app/src/env.js index aa805b1..dec6720 100644 --- a/apps/app/src/env.js +++ b/apps/app/src/env.js @@ -71,6 +71,17 @@ export const env = createEnv({ ), /** HMAC key shared only by the app token issuer and collab verifier. */ COLLAB_TOKEN_SECRET: z.string().min(32), + + INNGEST_BASE_URL: z.string().url(), + INNGEST_EVENT_KEY: z.string().min(1), + INNGEST_SIGNING_KEY: z + .string() + .regex( + /^(?:[0-9a-f]{2})+$/i, + "INNGEST_SIGNING_KEY must be bare hex with an even number of characters and no `signkey-` prefix", + ), + /** `z.enum`, not `z.coerce.boolean()`, which reads the string `"false"` as true. */ + INNGEST_DEV: z.enum(["true", "false"]).optional(), }, client: { @@ -124,6 +135,11 @@ export const env = createEnv({ COLLAB_TOKEN_SECRET: process.env.COLLAB_TOKEN_SECRET ?? process.env.BETTER_AUTH_SECRET, + INNGEST_BASE_URL: process.env.INNGEST_BASE_URL, + INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY, + INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY, + INNGEST_DEV: process.env.INNGEST_DEV, + // client side variables NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, diff --git a/apps/app/src/lib/inngest/client.ts b/apps/app/src/lib/inngest/client.ts new file mode 100644 index 0000000..e6e116e --- /dev/null +++ b/apps/app/src/lib/inngest/client.ts @@ -0,0 +1,14 @@ +import { Inngest } from "inngest"; + +import { env } from "@/env"; + +export const inngest = new Inngest({ + id: "scibly-app", + baseUrl: env.INNGEST_BASE_URL, + isDev: + env.INNGEST_DEV === undefined + ? env.NODE_ENV === "development" + : env.INNGEST_DEV === "true", + eventKey: env.INNGEST_EVENT_KEY, + signingKey: env.INNGEST_SIGNING_KEY, +}); diff --git a/apps/app/src/lib/inngest/functions/heartbeat.ts b/apps/app/src/lib/inngest/functions/heartbeat.ts new file mode 100644 index 0000000..8b88573 --- /dev/null +++ b/apps/app/src/lib/inngest/functions/heartbeat.ts @@ -0,0 +1,32 @@ +import { inngest } from "../client"; + +export const HEARTBEAT_EVENT = "scibly/heartbeat.requested"; + +export const heartbeat = inngest.createFunction( + { + id: "heartbeat", + name: "Heartbeat", + retries: 2, + triggers: [{ cron: "*/15 * * * *" }, { event: HEARTBEAT_EVENT }], + }, + async ({ event, step }) => { + const beatAt = await step.run("record-beat", () => + new Date().toISOString(), + ); + + await step.run("fail-when-asked", () => { + const data: unknown = event.data; + if ( + typeof data === "object" && + data !== null && + "fail" in data && + data.fail === true + ) { + throw new Error("Heartbeat failed on request"); + } + return null; + }); + + return { beatAt, trigger: event.name }; + }, +); diff --git a/apps/app/src/lib/inngest/functions/index.test.ts b/apps/app/src/lib/inngest/functions/index.test.ts new file mode 100644 index 0000000..fc166ed --- /dev/null +++ b/apps/app/src/lib/inngest/functions/index.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { inngestFunctions } from "."; + +describe("inngestFunctions", () => { + it("is what the serve route registers, so it must not be empty", () => { + expect(inngestFunctions.length).toBeGreaterThan(0); + }); + + it("has no duplicate ids, which would silently replace one at sync time", () => { + const ids = inngestFunctions.map((fn) => fn.id()); + + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/app/src/lib/inngest/functions/index.ts b/apps/app/src/lib/inngest/functions/index.ts new file mode 100644 index 0000000..e0168fc --- /dev/null +++ b/apps/app/src/lib/inngest/functions/index.ts @@ -0,0 +1,3 @@ +import { heartbeat } from "./heartbeat"; + +export const inngestFunctions = [heartbeat]; diff --git a/docker-compose.yml b/docker-compose.yml index 2853c46..149aaa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,5 @@ -# Self-hosted Scibly: Postgres + all three apps. See docs/docker.md. +# Self-hosted Scibly: Postgres, the Inngest background-work engine, and all +# three apps. See docs/docker.md. services: postgres: # pgvector, not plain postgres: an old migration (embeddings, since @@ -34,6 +35,41 @@ services: postgres: condition: service_healthy + # A postgres init script only runs on a fresh volume, which would skip every + # already-running install — so this one-shot creates the database on every `up`. + inngest-db: + image: pgvector/pgvector:pg16 + environment: + PGPASSWORD: ${POSTGRES_PASSWORD:-scibly} + entrypoint: + - sh + - -c + - > + psql -h postgres -U ${POSTGRES_USER:-scibly} -d ${POSTGRES_DB:-scibly} + -tc "SELECT 1 FROM pg_database WHERE datname = 'inngest'" | grep -q 1 + || psql -h postgres -U ${POSTGRES_USER:-scibly} -d ${POSTGRES_DB:-scibly} + -c "CREATE DATABASE inngest" + depends_on: + postgres: + condition: service_healthy + + # See docs/adr/0004-inngest-self-hosted-orchestration.md. + inngest: + # Matches the inngest-cli devDependency, so dev and production run one version. + image: inngest/inngest:v1.44.0 + restart: unless-stopped + # `-u` is polled, not called once, so neither service has to wait for the other. + command: inngest start -u http://app:3001/api/inngest + environment: + INNGEST_POSTGRES_URI: postgresql://${POSTGRES_USER:-scibly}:${POSTGRES_PASSWORD:-scibly}@postgres:5432/inngest + INNGEST_EVENT_KEY: ${INNGEST_EVENT_KEY:?INNGEST_EVENT_KEY is required - see .env.example} + INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY:?INNGEST_SIGNING_KEY is required - see .env.example} + ports: + - "${INNGEST_PORT:-8288}:8288" + depends_on: + inngest-db: + condition: service_completed_successfully + collab: build: context: . @@ -63,6 +99,7 @@ services: env_file: .env environment: DATABASE_URL: postgresql://${POSTGRES_USER:-scibly}:${POSTGRES_PASSWORD:-scibly}@postgres:5432/${POSTGRES_DB:-scibly} + INNGEST_BASE_URL: http://inngest:8288 ports: - "3001:3001" depends_on: diff --git a/docs/adr/0004-inngest-self-hosted-orchestration.md b/docs/adr/0004-inngest-self-hosted-orchestration.md new file mode 100644 index 0000000..f4b7ef4 --- /dev/null +++ b/docs/adr/0004-inngest-self-hosted-orchestration.md @@ -0,0 +1,40 @@ +# Background work runs on a self-hosted Inngest + +Anything that outlives a request, so scheduled syncs, long generations, and +anything that has to retry, is an Inngest function. Functions live in +`apps/app/src/lib/inngest/`, get listed in `functions/index.ts`, and are served +from one route at `/api/inngest`. The engine driving them is the +`inngest/inngest` container in `docker-compose.yml`, on its own database on the +Postgres already there. Not Inngest Cloud, not a hosted queue. + +This replaces hand-rolled cron chaining, where a route takes a lease row, runs +one step, then calls itself through `after()` before the platform timeout. +`apps/app/src/app/api/cron/sync-integrations/route.ts` is the last one. It stays +until integration sync moves over. + +## Why + +Chaining is a scheduler, a queue, a retry policy, and a run log written by hand, +and only the parts we noticed we needed. A step that dies mid-way leaves a lease +to expire and no record of what happened. + +Self-hosted rather than Inngest Cloud because Scibly ships as a container people +run themselves. An engine that phones a vendor means either a second, weaker +code path for on-prem or an Inngest account as a condition of installing. Vercel +Queues and Workflows lose on the same point, since they exist only inside Vercel. + +## Consequences + +- `INNGEST_BASE_URL`, `INNGEST_EVENT_KEY`, and `INNGEST_SIGNING_KEY` are all + required with no defaults, and the two keys have to match the ones the server + started with. A deployment with no server to point at fails to boot rather + than silently dropping background work. `INNGEST_DEV` switches signing + explicitly instead of inferring it from whether a URL is set. +- Inngest owns a separate `inngest` database. Backups that dump only `scibly` + hold no run history. +- The server calls the app over HTTP, so the app is a service the engine reaches + rather than a worker that dials out. Any topology has to allow that. +- `maxDuration` on `/api/inngest` bounds one step, not a run. A model call that + might outlast it belongs in `step.ai.infer`, which parks the request on the + server instead. +- Development needs `pnpm dev:inngest` running alongside `pnpm dev`. diff --git a/docs/architecture.md b/docs/architecture.md index 76dce8c..8a51882 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,14 @@ # Architecture -Three deployables, one Postgres database, a layer of shared packages — -pnpm + Turborepo monorepo. +Three deployables, one Postgres database, a background-work engine, a layer +of shared packages — pnpm + Turborepo monorepo. ```mermaid flowchart TB App["apps/app\nthe product (:3001)"] Web["apps/web\nmarketing site (:3000)"] Collab["apps/collab\nrealtime editor sync (:4000)"] + Inngest["inngest\nbackground work (:8288)"] Shared["packages/\ndb, auth, api, ui, ..."] EE["ee/\nStripe billing (separately licensed)"] DB[(PostgreSQL)] @@ -18,6 +19,8 @@ flowchart TB Shared --> DB Shared -. plugs in .-> EE App -. "Yjs over WebSocket" .-> Collab + Inngest -- "invokes /api/inngest" --> App + Inngest --> DB ``` - **`apps/app`** — the product: notebook (AI drafts a course from an @@ -28,6 +31,11 @@ flowchart TB auth and billing with `apps/app`. - **`apps/collab`** — a standalone Hocuspocus/Yjs server for realtime course editing, deployed separately from the two Next.js apps. +- **`inngest`** — the self-hosted background-work engine: schedules, retries, + and records every function that outlives a request. It isn't code in this + repo, it's a container that calls `apps/app` back over HTTP; the functions + live in `apps/app/src/lib/inngest/`. See + [ADR 0004](adr/0004-inngest-self-hosted-orchestration.md). - **`packages/`** — shared code: `db` (Prisma/Postgres schema, the source of truth), `auth` (better-auth), `api` (tRPC + entitlement), plus `ui`, `i18n`, `email`, `observability`, and lower-level helpers. diff --git a/docs/docker.md b/docs/docker.md index 4b001d7..111ba9d 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,8 +1,9 @@ # Docker self-hosting The fastest way to run all of Scibly on your own infrastructure: one -`docker compose up` spins up Postgres and all three apps. For running from -source with pnpm instead (contributing, debugging), see +`docker compose up` spins up Postgres, the Inngest background-work engine, +and all three apps. For running from source with pnpm instead (contributing, +debugging), see [setup.md](setup.md); [architecture.md](architecture.md) has the map of what each service is. @@ -20,6 +21,15 @@ Open `.env` and set, at minimum: `openssl rand -base64 32`. `COLLAB_TOKEN_SECRET` signs the token each editor session uses to open a collab room; it must be identical for the `app` and `collab` containers, which sharing one `.env` guarantees. +- `INNGEST_EVENT_KEY` and `INNGEST_SIGNING_KEY` — generate each with + `openssl rand -hex 32`. The signing key must be **bare hex with no + `signkey-` prefix**: the server refuses to start on anything else, and the + SDK carries a prefix through into the hash it signs with, so a prefix on + one side alone means every call fails to verify. Compose refuses to start + without both. They're the whole + contract between the app and the Inngest server: the event key + authenticates events the app sends, the signing key signs calls in both + directions. Sharing one `.env` keeps the two sides in agreement. - `POSTGRES_PASSWORD` — anything other than the default if this will be reachable from outside your machine. @@ -35,11 +45,20 @@ docker compose up -d --build This builds three app images (`apps/app`, `apps/web`, `apps/collab`), starts Postgres, runs `prisma migrate deploy` once via a one-shot `migrate` -service, then starts every app: +service, starts the Inngest server, then starts every app: - `apps/app` → http://localhost:3001 (the product) - `apps/web` → http://localhost:3000 (marketing site) - `apps/collab` → ws://localhost:4000 (realtime editor sync) +- `inngest` → http://localhost:8288 (background-work dashboard) + +The Inngest server is where scheduled and background work actually runs — +see [ADR 0004](adr/0004-inngest-self-hosted-orchestration.md). It calls back +into `app` at `/api/inngest` to execute each step, and the `heartbeat` +function beats every 15 minutes, so the dashboard has something in it within +the first quarter hour of a fresh install. Publishing :8288 is convenient +rather than required — nothing else needs it, so drop the `ports:` mapping +if the host is exposed. `docker compose logs -f` to follow all services, `docker compose down` to stop them (add `-v` to also drop the Postgres volume and start clean). @@ -70,15 +89,15 @@ feature behind a missing one won't work. Fill in what you need, leave the rest. Full detail on each is in [setup.md's Optional integrations](setup.md#optional-integrations); the short version: -| Vars | Powers | -| ----------------------------------------------------------------| ------------------------------------------------ | -| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google sign-in | -| `RESEND_API_KEY` | Transactional email | -| `AWS_*`, `MEDIA_BUCKET_NAME` | Media uploads (notebook sources, images) | -| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | Notion import | -| `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`, `ENCRYPTION_KEY` | AI course generation, BYOAI key storage | -| `STRIPE_*` | Billing (`ee/` only) — see [ee/README.md](../ee/README.md) | -| `NEXT_PUBLIC_POSTHOG_*` | Product analytics | +| Vars | Powers | +| -------------------------------------------------------- | ---------------------------------------------------------- | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google sign-in | +| `RESEND_API_KEY` | Transactional email | +| `AWS_*`, `MEDIA_BUCKET_NAME` | Media uploads (notebook sources, images) | +| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | Notion import | +| `AI_GATEWAY_API_KEY`, `OPENAI_API_KEY`, `ENCRYPTION_KEY` | AI course generation, BYOAI key storage | +| `STRIPE_*` | Billing (`ee/` only) — see [ee/README.md](../ee/README.md) | +| `NEXT_PUBLIC_POSTHOG_*` | Product analytics | Once every credential your deployment needs is filled in, set `SKIP_ENV_VALIDATION=false` and rebuild — validation errors on startup then @@ -86,6 +105,11 @@ mean a var is missing rather than a feature silently not working. ## Database +Inngest keeps its config and run history in a separate `inngest` database on +the same Postgres server, created on every `up` by the one-shot `inngest-db` +service if it isn't there yet. A backup that dumps only `scibly` won't +contain it. + The `migrate` service runs `prisma migrate deploy` — safe to re-run, applies only pending migrations. To seed demo data or open Prisma Studio against the compose Postgres from your host machine: @@ -103,10 +127,17 @@ defaults; add a `ports:` mapping on the `postgres` service in ## Troubleshooting - **App container exits immediately on first boot** — check `docker compose - logs migrate`; the app/web/collab containers wait on it succeeding, but a +logs migrate`; the app/web/collab containers wait on it succeeding, but a bad `POSTGRES_PASSWORD`/`DATABASE_URL` mismatch surfaces there first. - **"COLLAB_TOKEN_SECRET must be at least 32 characters"** (from the `collab` container) — generate one with `openssl rand -base64 32` and set it in `.env`. - **Changed a `NEXT_PUBLIC_*` var but the app still shows the old value** — those are build-time only; run `docker compose build` again. +- **"INNGEST_EVENT_KEY is required"** on `up` — both Inngest keys are + mandatory; see step 1. +- **The Inngest dashboard is empty / functions never sync** — the server + polls `http://app:3001/api/inngest` for the function list, so it has + nothing to show until `app` is up. `docker compose logs inngest` shows the + poll failing if it can't reach it. Repeated `403`s there mean the app and + the server disagree about `INNGEST_SIGNING_KEY`. diff --git a/docs/setup.md b/docs/setup.md index 064c2e7..300f20e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -49,6 +49,15 @@ cp packages/db/.env.example packages/db/.env - `apps/web/.env` mounts the same better-auth handler as `apps/app`, so any Stripe vars you set (see [Optional integrations](#optional-integrations) below) need to be mirrored there too. +- `INNGEST_BASE_URL`, `INNGEST_EVENT_KEY`, and `INNGEST_SIGNING_KEY` are all + required, and `apps/app/.env.example` ships values that work as-is against + the local dev server `pnpm dev:inngest` starts (see step 4). Development runs + Inngest in dev mode, where traffic is unsigned and the two keys are + ignored; they matter once the app points at a real self-hosted server + (`docker compose` does — see [docker.md](docker.md)), where they must match + the values that server was started with. Generate each with + `openssl rand -hex 32`. `INNGEST_DEV="false"` forces signed traffic from a + dev session, for working against a real server locally. ## Optional integrations @@ -122,6 +131,21 @@ every app through Turborepo: To run a single app instead: `pnpm --filter @scibly/app run dev` (or `@scibly/web`, `@scibly/collab`). +Background work needs a second terminal — nothing schedules or executes an +Inngest function without it: + +```bash +pnpm dev:inngest +``` + +That's the Inngest dev server, dashboard on http://localhost:8288, pointed at +`apps/app`'s serve route (`/api/inngest`). It picks up whatever +`apps/app/src/lib/inngest/functions/index.ts` registers, re-syncing on its own +as you edit. `heartbeat` is there to prove the wiring: it runs every 15 +minutes, and sending `scibly/heartbeat.requested` with `{ "fail": true }` from +the dashboard's event tester makes it fail, so you can watch the three +attempts `retries: 2` produces. + ## Checks ```bash @@ -140,6 +164,11 @@ pnpm validate # check + test:unit + test:e2e - **Env validation fails on vars you don't have credentials for yet** — set `SKIP_ENV_VALIDATION=true` in `apps/app/.env` while you get the app running, then fill credentials in as you need the features behind them. +- **Background functions never run** — `pnpm dev` does not start the Inngest + dev server; `pnpm dev:inngest` does, separately (see step 4). With it + running, http://localhost:8288 lists `heartbeat` under Functions; if it + doesn't, the app wasn't reachable at http://localhost:3001/api/inngest when + the server polled it. - **i18n or editor-schema errors on `dev`/`build`** — both `apps/app` and `apps/web` run `predev`/`prebuild` hooks (`pnpm i18n:merge`, and for `apps/app` also `pnpm schema:generate`) automatically; if you're invoking diff --git a/package.json b/package.json index d2d832c..e231788 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ }, "scripts": { "dev": "node scripts/dev.mjs", + "dev:inngest": "inngest-cli dev --no-discovery -u http://localhost:3001/api/inngest", "start": "turbo run start --filter=./apps/*", "start:app": "turbo run start --filter=./apps/app", "start:web": "turbo run start --filter=./apps/web", @@ -22,6 +23,7 @@ }, "devDependencies": { "agent-browser": "^0.33.2", + "inngest-cli": "^1.44.0", "turbo": "^2.9.10" }, "packageManager": "pnpm@10.33.0", @@ -52,6 +54,7 @@ "unrs-resolver" ], "onlyBuiltDependencies": [ + "inngest-cli", "sharp" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1720a35..ebe91d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: agent-browser: specifier: ^0.33.2 version: 0.33.2 + inngest-cli: + specifier: ^1.44.0 + version: 1.44.0 turbo: specifier: ^2.9.10 version: 2.9.14 @@ -318,6 +321,9 @@ importers: geist: specifier: ^1.3.1 version: 1.7.0(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + inngest: + specifier: ^4.18.1 + version: 4.18.1(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(hono@4.12.29)(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(zod@4.4.3) katex: specifier: ^0.16.21 version: 0.16.47 @@ -468,7 +474,7 @@ importers: version: 10.0.0 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.6(vitest@4.1.6) @@ -510,10 +516,10 @@ importers: version: 5.9.3 vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 6.1.1(typescript@5.9.3)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) vitest: specifier: ^4.1.5 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) apps/collab: dependencies: @@ -556,7 +562,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) apps/web: dependencies: @@ -668,7 +674,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) dotenv: specifier: ^17.3.1 version: 17.4.2 @@ -701,7 +707,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) ee/billing: dependencies: @@ -747,7 +753,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) ee/organizations-billing: dependencies: @@ -802,7 +808,7 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) eslint: specifier: ^9.39.2 version: 9.39.4(jiti@2.7.0) @@ -817,7 +823,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/api: dependencies: @@ -902,7 +908,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/auth: dependencies: @@ -1205,7 +1211,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/observability: dependencies: @@ -1257,7 +1263,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.7.0 - version: 4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) eslint: specifier: ^9.39.2 version: 9.39.4(jiti@2.7.0) @@ -1278,7 +1284,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/routes: dependencies: @@ -1359,7 +1365,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) packages/ui: dependencies: @@ -2048,6 +2054,9 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bufbuild/protobuf@2.14.0': + resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -2348,6 +2357,15 @@ packages: '@formatjs/intl-localematcher@0.5.10': resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hocuspocus/common@4.0.0': resolution: {integrity: sha512-7BE8TsKBkdiOZO6tfm3ny6bIHPbxkIZb3hsYdVn/X5xbXI8n8w9pnE6pXgEMKQhJm6zsWsa9IDRJIp/c9u+DmA==} @@ -2730,6 +2748,17 @@ packages: cpu: [x64] os: [win32] + '@inngest/ai@0.1.7': + resolution: {integrity: sha512-5xWatW441jacGf9czKEZdgAmkvoy7GS2tp7X8GSbdGeRXzjisHR6vM+q8DQbv6rqRsmQoCQ5iShh34MguELvUQ==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2804,6 +2833,9 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jpwilliams/waitgroup@2.1.1': + resolution: {integrity: sha512-0CxRhNfkvFCTLZBKGvKxY2FYtYW1yWhO2McLqBL0X5UWvYjIf9suH8anKW/DNutl369A75Ewyoh2iJMwBZ2tRg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2820,6 +2852,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} @@ -3080,14 +3115,489 @@ packages: resolution: {integrity: sha512-lZ3JGBCd6O6MNHWn/58QcUqX1FgmlcODcx/EaUEEpuxLXF5tSi+v29Vzoz8mZ6JgDWDn5pMzzjB69QevYjQQZA==} engines: {node: '>=18'} + '@opentelemetry/api-logs@0.203.0': + resolution: {integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.221.0': + resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/auto-instrumentations-node@0.79.0': + resolution: {integrity: sha512-qL53aIjdw56sRDqz6LXD9h15vPTJgPpqv80rbsnRjzhuC9VqZ58fgk/lx0SdECJ2rcu8keeji5ZgjzJwiQZ0fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.221.0': + resolution: {integrity: sha512-uE9y56Zdi9Gt/RdxYnVOo3YmFZkKJJMA0gqtBe8wh8gdtF5Asqe+Oh/TWiDtFb1s+31jNY4CWgnfIB1KOITfFA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.221.0': + resolution: {integrity: sha512-txG1G0IrYSsKKMeiWZfj/i5cQmWB+h+hf3HzPpF3RqZVwp+iQQEIsv8Vtmzy6RWVdHdJZfygmVrBI39YTBvWcw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.221.0': + resolution: {integrity: sha512-nKXkr4Tomi6fjYVOf+ytcW3dZAVr4v4Bv5gsT6dr2gvpUPJpKgHB4XbMufMsPotRE3g0XH2GwVVCkN2w6SON+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.221.0': + resolution: {integrity: sha512-AH6EY+47gXFaWYgG3hfeOneGiE9xIZGtDBk+9g0sM8NZWzsQhhmqPbQQXJzS7pyCh5jRRr2nYNXVrkCmoojRvQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.221.0': + resolution: {integrity: sha512-KOgCtO15FC6C1T/xOqBcr7EyUs7B+7yomGNb5Y97d3s38rPbCCk5sewkmE2b0/itOkQ/PptX8CLlD+kn2mEtTg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.221.0': + resolution: {integrity: sha512-sRfCKbOzgy8xZQV2as0RzIZlnCmCseCKZGLfRcrpo2CBngJDr+rPtX0zkG0+oUCV5kfQPUoW3W3C96Ag3Y/Clg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.221.0': + resolution: {integrity: sha512-YMF4LveY2I3yhw61rn6nmC9FE8U24IZHPeKU1Duc5+sbwjMd8FwZAwba318ImdThCg/HuVQvhm2y6bfgNPnfYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.221.0': + resolution: {integrity: sha512-kW79a20qWESIuAdDrxzg9WKM98twV/NBWBFRAH57ap/+ssZhiCo0hckzKT0zpuwR/gSHrFAQhJL0bYDrnEM34g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.221.0': + resolution: {integrity: sha512-zXminlZedtq9LvOW64CnNkOqk15zV75k8JgtdTuWFge6+jk2m4GmAUm6L2eIiG1o2a2bZxXw2PDrszm+bps0IA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.221.0': + resolution: {integrity: sha512-AySXiKoC+meiWm6zdVj5T2LnPDZuatveBby1cMOeQteIWsYXAUxs8Sru13G2pVSPrUXz6vF+og7QVBX6GdC/oQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.221.0': + resolution: {integrity: sha512-Z9i2T7vgZbWe9rSLYxXVIbeW+XyzUq4rZanW3ZyVNwVDqCsh0EJKUgBWWQ0CZfeuUA+RQPzKgJQHMuWAUnKqXw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.10.0': + resolution: {integrity: sha512-7gsvgf0UDoJ4l9ObrwBmz5G/ZogiPk+lq+g5GpLp24YQF/vPM/BSsnOfcLnfinast5ASUgLo78uSC/ObjlnXgg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.68.0': + resolution: {integrity: sha512-U9Fc3C061q+AGxP3xEJTIAJtBduY1GL21J4SjOSxCmmls4UUva16jzQ5ZkunQe0pKalrRjZ/DlZ+wgfgQxqjBw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.73.0': + resolution: {integrity: sha512-N2BZFlWmVt2zjpiqPnfmIlj7tV/wfKSZCFF/laLAnJSTZjSEdc9JYSXW+KUV0FMUNDfpLCeAcI1xDMdGLdxFJg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.76.0': + resolution: {integrity: sha512-gg2QaDtWeFezRt2mAl9vBQ38y60tzUShKg1KA9uTgsMJimUHaBnB3X8kEH9Of8jKWT4DDoDcSA37gPcoEc0hiA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.66.0': + resolution: {integrity: sha512-IqYQC1dav35NHlD5nYnpBXK8tI6KJ9/MIt8LYKFxwlhMIwfBCfavaklyP8NtVKoJ0WZKzX2v9Sh+xGK1XvSniQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.66.0': + resolution: {integrity: sha512-4ksN7PfXLg7raDyXIjIrtxxzuuDlUx6Fh0s8VXojdl+nn2o0xkYa9jrq1f9Bfqhm+Sce3GOa1RjE3ycvAwk6Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.64.0': + resolution: {integrity: sha512-D1Tpom3BpY8g29FFOEQ2FZioVFjyXwXHsh3BOn2BHcg7Taipg+yc+DPGUwvdR4WZrKnNMAG/+FBXNa0S0KhJYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.37.0': + resolution: {integrity: sha512-ezhn6D0DSUZkwLBGdfnr+PAENvB92AhbEH/dkcSLYB8dbQiw/NQ8y19jOn3E6MZTt0+FD1YyHtrJS2skDO4nDQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.38.0': + resolution: {integrity: sha512-OmOVadK0m7sdlvMwbt1gb2iVUCyvVNDo3x5JLGgnKggLTJBgTcQxgMl3pAhFAMzWGo9URuMxuh3Bphy9Pb9nZw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.64.0': + resolution: {integrity: sha512-La6s9SdKgojZQVFD7AclQBYe3WioVe6zicJswM3QPPPHpCufJYnw8rO/G9o2Yl/OUeS7PYpzwHh4N6lexzbEcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.69.0': + resolution: {integrity: sha512-91pHMujQgDyhEQrdg8RriMBrRZ/qPaJ0Y2dopQ6lHjW5YjoeytWi8ruM//T6f5o0D95hnqRlv79Pel1lGPqaYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.40.0': + resolution: {integrity: sha512-p39axaaYVKhnl5l4M+1aiXmxrAG2HuTti7DHxs2jDJRst828y5iwqUZLC1UWIKIhW9FfdV5gogXg+nRRhSc0EA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.64.0': + resolution: {integrity: sha512-wM939j8Ox5BBHoA0r/p9etdpyS3GcUf/sfrUx1dtZdqGKU4ZcBxLyPD8QntwFkSI9PHql+rRejTCA6Btktz8Kg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.69.0': + resolution: {integrity: sha512-vyKzuiBoEulV1FjMSe4iiuwZedt+nNAuaSVOh/3WxjDIuGQ9WsH+0ohd9snhIY6guAjYOGjqvTiYLs6K+OTQQg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.221.0': + resolution: {integrity: sha512-1U45172SiPWG1MPfrgLItIuXZO/RfJqt5sxsrdlKN1NRV0pUtv7lbpgB1nShwP/SGSKaAFkovbVg3a1hfy3mpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.67.0': + resolution: {integrity: sha512-cIXIN4vZXm6aI4yz+4oUIRnkiAxCIpONrMhnGTI+ILKKEsIXP8Uselfr9663+TnisrgRLB7kKp+SoOuGJRHGtw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-host-metrics@0.4.0': + resolution: {integrity: sha512-jnFyX2sTn2B+9mjsL3qgAHzpxdaia4/FV2GSlf9QrpaiMi6O0M0lA9JZTsf4FTvuOwrIngCk+MeVXjsxgBXXyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.221.0': + resolution: {integrity: sha512-oIP91CPIANuYr09tGFElPFKAh6JUar+awJf1kBRYlaeo9b0gDwZHEB2zBfFlvdNFHm0wAVutMZODVi5smKT30g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.69.0': + resolution: {integrity: sha512-I9sZtxXWZ1tRXtRNTEVxpokGtXy6RL1SZhtPVh7zxH78t8ar71V5Dx4bnQiUjKTDzItpC73krD8c0/cEWA9oLg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.30.0': + resolution: {integrity: sha512-/p/D4etxJpJGB0VrS+kqF8WfVAMFWf5ybhY0mjzIEd/d/T68+nxTWJaI5MJXFiyOnBUFuMlun12VJt+QJDCSZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.65.0': + resolution: {integrity: sha512-rJTT12VlDnL6wOWfxnBvkTUIzW2ju+7nqlToMy1tlqL0j6ohlVPxryMCS5h6UqE3CFpq7HtHqVObQHZgrA37KA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.69.0': + resolution: {integrity: sha512-fxuA8jFOdqQzJV9Sitd0dk+zns7RQCFe19ia3LHex5oLiQPaaQovBv37jndX/zAZw6EBORATePHE8OQUwraPCQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.65.0': + resolution: {integrity: sha512-s2KisLZ82iDvCF2QbsV1k1wrz3DMSBP9OiMfmNn5oSyaNT7jcNphR4uxr7WjwH+ssucuvhKWqKzJ2vdx7KRMVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.64.0': + resolution: {integrity: sha512-ek34tp7Qjci4CLahXybJ3aaixU1d2j28X5JSXSXbp6/rIiFGjMCAXJWw3FeiYmA82D/gV/wzPhL7r7m/p4gUzw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.74.0': + resolution: {integrity: sha512-GRnHu69YLQUYgguuYkKi6wpizMY4r7gLC08rSq8cg41p6t7+1YIy5nXoGC61NA7KCZUE9jbcDnzd45i32IblZg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.67.0': + resolution: {integrity: sha512-iEBgNrychD36qI16X/V8WZb2JabjQPE+pyrkLU320ApZyhGVsYU9LH3Nqt4Mkg1nFsa9qWhRQrCfhoANCgT6EA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.67.0': + resolution: {integrity: sha512-AmviR7l0xMxhC83scY3u+NkkT6blhD/xK9tPi9nYtjNG1gwPtMgZjYOa3f9lGOwpXs/EwN7wiyAgxiO4KTcENA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.67.0': + resolution: {integrity: sha512-G4aRrVKcd2Aodqi7WzRZ3LQJNKrM8BpsXEVMHrOb9s8Lg0jZXNvlLY0NWL1yVJrjnqI/unwEsp2aVHkVImPMeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.67.0': + resolution: {integrity: sha512-lXb7pjobd2i/9Gmihf9wrOM0MgnDYBxOJq7uWEhZOqULodNFLyPCRUnWxSIbPQdUzlU36QtHjuAeaEBUJnqXkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.65.0': + resolution: {integrity: sha512-W82H8UvaSrWynpI510CNJbq2Aq6L4/zuR/dAvoCZVYeRiChi0LHMI8i3rPe3Tmau2WBwE0jimaWgOs0GCfIHbQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.19.0': + resolution: {integrity: sha512-zHz7m/aUMDyAap7UMzaaxDkbmxEyUOfMBfh+7KICNwTBmIOypMnuUWwXWAfIJLgl+2dnZlNzxZRknULeS4YIhg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.46.0': + resolution: {integrity: sha512-nqxQvbp7HvsVPyDdgiZADPQX6B6ZUtLfm+XPuJHtQ3anxIcqU6qhJlrDIEM2LrZMShqf7bs84RHfDt2rgsp7hg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.73.0': + resolution: {integrity: sha512-yf3tBVwLHB9cZNNPSToNrthx36ouPe4FctFxy7ya6vSJ6gaiKjNfA/IgFFeuBpZflEQhy6aesPqzZo8ZjFkvNg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.67.0': + resolution: {integrity: sha512-Hb6phi2x1bq23OIiesj4imQvXs9Y5MMLtpgXH9hOuMm9LpBYz2cxPNgpgZ/XATuRclP1eRPoSl399o5XKwfoIA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.69.0': + resolution: {integrity: sha512-lyCIEW89cYhMwaUSMBzsKHdwH2wOoqmuwXOARJneo9UL55govLIUCbYYAJ457oM8kdKADymlY4+SUW0DKQeIHw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.66.0': + resolution: {integrity: sha512-9zbnL0ML2jFgJmmG1XPQTqwopCogC8eAtUQ0SXvYb+Ux2yuOBOvSg9XvRk/hcQf6WsRMll47cELCMC+IaI9I+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.65.0': + resolution: {integrity: sha512-ti9tDLFLhLoev5U/cGeQpFTlOjFFwrFbieZmwTFql9z8EijXDzFE7a3+3mgnxGl9CZR0luKCwFD/o51NPU+Ngg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.34.0': + resolution: {integrity: sha512-Yb3PcmuK/iIOWY49GSEcSGl7fR4r6UqhZnuy5EWvFaqKqqs9SYnQeyQUEAbnBnSougRpwFBDbdN1SSGcvMhbtQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.68.0': + resolution: {integrity: sha512-Bhd0KApVBYV4WQMZZbKRYfvev7SudvCtSn6b36uyUbKowOMEoGpnVoIvm0rlrrBR0KmkcV3Y37SngCGUrTm3lg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.40.0': + resolution: {integrity: sha512-zTNNxs+KUJf1J+lHzeTDxAIZdVJYvQ8mvGUfyiWcVFgVdl7+4XV+wOBMSd1tZcRRlopfcVODDCOVMx/N7+zvcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.31.0': + resolution: {integrity: sha512-qunCfgSFV+bjRdAYkWIjVX38jIN/Xj80CiERXXdzYAmdigCFvJPB3AY3j43bjJlkhgbJJSCGdbvQUSut8QIiyQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.65.0': + resolution: {integrity: sha512-hWSPnS530deRa+ttzY+QiGmgsK7aQHpqxbQRm56yO1j4qnIXuYYHaoSJ8/4lLOEcXB+hZs0mAAJ6QiMl1aaiGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.203.0': + resolution: {integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.221.0': + resolution: {integrity: sha512-cCk80Z/iRDf/5gfsKMB4f74LqVA5yKETB/9ojPzVW/6/f70iu89nJvGxsFCxx4XfSohaOofkU19kiYm84AiAlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.221.0': + resolution: {integrity: sha512-UFPIq80OH3Ns/oPFHRj14d4DTOxUo+MUFU8hUiCq5jTqFhdeJnfVSANHT+xp92409cA+oxzvlZCe6NM1wvCuBA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.221.0': + resolution: {integrity: sha512-rQDmNgyiGCTrescjnzH2ntVyUKVIq6I2UjuK8+stT/Xg0ZOT71FVJqwjFdspQl6Yol/Yqsut9bDo+ame8oTmDQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.221.0': + resolution: {integrity: sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-aws-xray@2.2.0': + resolution: {integrity: sha512-Yjvt2EjL+tfpkVOdKbhTPgpM4SIAez9nG6Q/QjQ3yfcJcjIWp59ph70SLfvmkSL6++3DCnuBG3iWcB18PwWavQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-b3@2.10.0': + resolution: {integrity: sha512-GnA5B24H+1w8BO21J0q+IWNB0z1v+AGbcquTdIt/dufibhnhgxaA8YKvz0I3akRZhB1jHT+/tlzK+qlAjEDybQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.10.0': + resolution: {integrity: sha512-yw/IX8DL470dSMZJoE82ScfYGp7JWZ/G8kFJo35ZILUVTB2jFPTOaioN+8s09pH0RHsWNhweVZb+ZnjJJpCChg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.36.0': + resolution: {integrity: sha512-s75zJV1ShpYL5nk2cODfZY05Haw2hGxcfEFMu3ymvh2QU3HrhXaCW+rmNkhXhRrO8YophMFTyVdb7iCDleC/JQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.21.0': + resolution: {integrity: sha512-Veavy+khoywR+Hv065SU5jucFTGTiW1KXo39CsJ+8wqdYYz8jiRJPnQ20Kd+X9HbV2+Abb0l5CrJIdxK1ZOqBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.29.0': + resolution: {integrity: sha512-lWm0vjjlQMoc4Xvvd+dW/OZWT/SI4w+cIN7kbm8KimIZCr1EpAyvyQ7WEOrGoBoXCpQCrsZ18uKTooDgiBCGIw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.12': + resolution: {integrity: sha512-EJRFfIY26whY0w5RDxMRXlfBDgDS001JYMHuOVuDBBsRrV4MBqoVajR9B0L9Vy728+w/HNVnSQkpJFacFr+klg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.56.0': + resolution: {integrity: sha512-H8yNeqTsuapbXs6MLZTtelfUCk+5D8jD3+KosCJaXOyx5gl3EWWvs70HbNXTUO4VYLxccySFYJYVCd8YMM0NJw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.221.0': + resolution: {integrity: sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.10.0': + resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.221.0': + resolution: {integrity: sha512-UbYuvtBrQQB5Prsh9KOKy4kxzexFxfMs5MkteHeWMoswsEB7kiNhyUVkAOFW/qsEzNHtrkgyghrD2ilZJa+5YA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.10.0': + resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.41.1': resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@opentelemetry/sql-common@0.42.0': + resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@oxc-project/types@0.130.0': resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} @@ -3095,6 +3605,10 @@ packages: resolution: {integrity: sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -3211,6 +3725,33 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -4575,6 +5116,14 @@ packages: y-protocols: ^1.0.1 yjs: ^13.6.23 + '@traceloop/ai-semantic-conventions@0.20.0': + resolution: {integrity: sha512-bvivhZU6U8TW4TKktYnjdTi+7GE4WxI8epaGjawalSKDunmxaA+4UVFQ+4tSCBvp2Scby+gnYNaTZSrtABfOlQ==} + engines: {node: '>=14'} + + '@traceloop/instrumentation-anthropic@0.20.0': + resolution: {integrity: sha512-xQcPxVrKr3yT9+ZEM3skYXikJc/ocZlGDIcsBQ3mMwL3Weq1QL7jx/uGLXvrSO2Yh0DWUjWI6Q/oiRCEUM6P8w==} + engines: {node: '>=14'} + '@trpc/client@11.4.1': resolution: {integrity: sha512-h28HKqxOBu35Q3f7h2chOjkQnwmIFdZDqG6NxovPaxEGcUmQWdo63mthlPSiMThXpy9J1AUA8q4uZZ4a5d1JVA==} peerDependencies: @@ -4632,6 +5181,9 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/aws-lambda@8.10.162': + resolution: {integrity: sha512-Fn658grtLOci1oxi1391vvDWJRKNGWRSqfxRkmN/Iy3c0tQH1USMKEXcPYHLvope+ZgTFocx9FRQJx1muBL6qw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4644,12 +5196,18 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + '@types/canvas-confetti@1.9.0': resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -4813,21 +5371,39 @@ packages: '@types/mdx@2.0.14': resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/negotiator@0.6.4': resolution: {integrity: sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==} '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -4848,6 +5424,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -5207,6 +5786,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -5222,6 +5806,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -5259,6 +5847,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5267,6 +5859,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -5493,12 +6089,18 @@ packages: better-result@2.9.2: resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -5581,6 +6183,9 @@ packages: caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + canonicalize@1.0.8: + resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==} + canvas-confetti@1.9.4: resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} @@ -5623,6 +6228,10 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -5636,6 +6245,9 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -5743,6 +6355,9 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5933,6 +6548,10 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -6109,6 +6728,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.20.0: resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} @@ -6189,6 +6811,9 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -6483,6 +7108,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} @@ -6524,6 +7153,13 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + framer-motion@11.18.2: resolution: {integrity: sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==} peerDependencies: @@ -6561,6 +7197,14 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.4: + resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==} + engines: {node: '>=18'} + geist@1.7.0: resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} peerDependencies: @@ -6631,6 +7275,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -6658,6 +7307,10 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -6706,6 +7359,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -6832,6 +7488,13 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + + import-in-the-middle@3.3.3: + resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} + engines: {node: '>=18'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -6858,6 +7521,50 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + inngest-cli@1.44.0: + resolution: {integrity: sha512-Z21HkFQlrfCXKiu+uWncrBg3Mn/NwzicFwVPcDfMe4N0bkBblVzbXpfGndIxfHAnR9+FdCw807TCWw9vQ0JeLQ==} + hasBin: true + + inngest@4.18.1: + resolution: {integrity: sha512-jCssLkQzvhnKOdiLucA/bX7fYNoeJBdrJa3MGaqWGkIy5L/CL/rurPV/Mh2VDt+IGOnCi71pucML3EvJJCeOjg==} + engines: {node: '>=20'} + peerDependencies: + '@sveltejs/kit': '>=1.27.3' + '@vercel/node': '>=2.15.9' + aws-lambda: '>=1.0.7' + express: '>=4.19.2' + fastify: '>=4.21.0' + h3: '>=1.8.1' + hono: '>=4.2.7' + koa: '>=2.14.2' + next: '>=12.0.0' + react: '>=18.0.0' + typescript: '>=5.8.0' + zod: ^4.0.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + '@vercel/node': + optional: true + aws-lambda: + optional: true + express: + optional: true + fastify: + optional: true + h3: + optional: true + hono: + optional: true + koa: + optional: true + next: + optional: true + react: + optional: true + typescript: + optional: true + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -7058,6 +7765,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7235,6 +7945,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -7256,6 +7969,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -7488,6 +8204,9 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -7825,6 +8544,9 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -7836,6 +8558,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -7843,6 +8569,13 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@11.18.1: resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} @@ -7945,6 +8678,11 @@ packages: sass: optional: true + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.2: resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} @@ -7952,6 +8690,19 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -8060,6 +8811,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} @@ -8102,6 +8856,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -8430,6 +9188,10 @@ packages: prosemirror-view@1.41.8: resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -8701,6 +9463,14 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resend@4.8.0: resolution: {integrity: sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA==} engines: {node: '>=18'} @@ -8742,6 +9512,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -8982,6 +9756,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -9012,6 +9790,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -9096,6 +9878,12 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.33.5: + resolution: {integrity: sha512-0v8l1CwFOAjfkv6ynpMrv3YGjH0M7PWCpZwusr8J1TEoQFPK7WXO6gbeAiandaWoh7vbMdnFtDqVotJVnLJtIg==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -9118,6 +9906,16 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temporal-polyfill@0.2.5: + resolution: {integrity: sha512-ye47xp8Cb0nDguAhrrDS1JT1SzwEV9e26sSsrWzVu+yPZ7LzceEcH0i2gci9jWfOfSCCgM3Qv5nOYShVUUFUXA==} + + temporal-spec@0.2.4: + resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -9173,6 +9971,9 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -9319,6 +10120,10 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + ulid@2.4.0: + resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -9561,9 +10366,16 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + web-vitals@5.3.0: resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -9585,6 +10397,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + when-exit@2.1.5: resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} @@ -9625,6 +10440,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -9699,6 +10518,15 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -10503,6 +11331,8 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bufbuild/protobuf@2.14.0': {} + '@chevrotain/types@11.1.2': {} '@csstools/color-helpers@5.1.0': {} @@ -10736,6 +11566,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.2 + '@hocuspocus/common@4.0.0': dependencies: lib0: 0.2.117 @@ -11016,6 +11858,24 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@inngest/ai@0.1.7': + dependencies: + '@types/node': 22.20.1 + typescript: 5.9.3 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -11188,6 +12048,8 @@ snapshots: '@types/yargs': 17.0.35 chalk: 4.1.2 + '@jpwilliams/waitgroup@2.1.1': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -11207,6 +12069,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@kurkle/color@0.3.4': {} '@lifeomic/attempt@3.1.0': {} @@ -11358,58 +12222,748 @@ snapshots: '@next/swc-linux-x64-gnu@16.2.3': optional: true - '@next/swc-linux-x64-gnu@16.3.0': - optional: true + '@next/swc-linux-x64-gnu@16.3.0': + optional: true + + '@next/swc-linux-x64-musl@16.2.3': + optional: true + + '@next/swc-linux-x64-musl@16.3.0': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.3': + optional: true + + '@next/swc-win32-arm64-msvc@16.3.0': + optional: true + + '@next/swc-win32-x64-msvc@16.2.3': + optional: true + + '@next/swc-win32-x64-msvc@16.3.0': + optional: true + + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + + '@nodable/entities@2.1.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@notionhq/client@5.22.0': {} + + '@opentelemetry/api-logs@0.203.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.221.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.79.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.73.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.76.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-host-metrics': 0.4.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.30.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.19.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.46.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.73.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.34.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.40.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.36.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.12(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.73.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-aws-xray': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.162 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.76.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-host-metrics@0.4.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + systeminformation: 5.33.5 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.30.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.19.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.46.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.73.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.34.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.40.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.203.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + import-in-the-middle: 3.3.3 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.221.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-aws-xray@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 - '@next/swc-linux-x64-musl@16.2.3': - optional: true + '@opentelemetry/propagator-b3@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@next/swc-linux-x64-musl@16.3.0': - optional: true + '@opentelemetry/propagator-jaeger@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@next/swc-win32-arm64-msvc@16.2.3': - optional: true + '@opentelemetry/redis-common@0.38.3': {} - '@next/swc-win32-arm64-msvc@16.3.0': - optional: true + '@opentelemetry/resource-detector-alibaba-cloud@0.36.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) - '@next/swc-win32-x64-msvc@16.2.3': - optional: true + '@opentelemetry/resource-detector-aws@2.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 - '@next/swc-win32-x64-msvc@16.3.0': - optional: true + '@opentelemetry/resource-detector-azure@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 - '@noble/ciphers@2.2.0': {} + '@opentelemetry/resource-detector-container@0.8.12(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) - '@noble/hashes@2.2.0': {} + '@opentelemetry/resource-detector-gcp@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.4 + transitivePeerDependencies: + - supports-color - '@nodable/entities@2.1.0': {} + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 - '@nodelib/fs.scandir@2.1.5': + '@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 - '@nodelib/fs.stat@2.0.5': {} + '@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) - '@nodelib/fs.walk@1.2.8': + '@opentelemetry/sdk-node@0.221.0(@opentelemetry/api@1.9.1)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/configuration': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color - '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 - '@notionhq/client@5.22.0': {} + '@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) - '@opentelemetry/api@1.9.1': - optional: true + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 '@opentelemetry/semantic-conventions@1.41.1': {} + '@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@oxc-project/types@0.130.0': {} '@oxlint/plugins@1.78.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -11573,6 +13127,26 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.0.1': @@ -12865,6 +14439,21 @@ snapshots: y-protocols: 1.0.7(yjs@13.6.30) yjs: 13.6.30 + '@traceloop/ai-semantic-conventions@0.20.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@traceloop/instrumentation-anthropic@0.20.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@traceloop/ai-semantic-conventions': 0.20.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@trpc/client@11.4.1(@trpc/server@11.4.1(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@trpc/server': 11.4.1(typescript@5.9.3) @@ -12908,6 +14497,8 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/aws-lambda@8.10.162': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -12929,6 +14520,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 20.19.41 + '@types/canvas-confetti@1.9.0': {} '@types/chai@5.2.3': @@ -12936,6 +14531,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.41 + '@types/cookie@0.6.0': {} '@types/cors@2.8.19': @@ -13131,14 +14730,26 @@ snapshots: '@types/mdx@2.0.14': optional: true + '@types/memcached@2.2.10': + dependencies: + '@types/node': 20.19.41 + '@types/ms@2.1.0': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 20.19.41 + '@types/negotiator@0.6.4': {} '@types/node@20.19.41': dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -13148,6 +14759,20 @@ snapshots: undici-types: 7.24.6 optional: true + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 20.19.41 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.20.0 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 20.19.41 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + '@types/pg@8.20.0': dependencies: '@types/node': 20.19.41 @@ -13170,6 +14795,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/tedious@4.0.14': + dependencies: + '@types/node': 20.19.41 + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -13498,7 +15127,7 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vitejs/plugin-react@4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + '@vitejs/plugin-react@4.7.0(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.0) @@ -13506,14 +15135,14 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': dependencies: @@ -13527,7 +15156,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/expect@4.1.6': dependencies: @@ -13538,29 +15167,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) '@vitest/pretty-format@4.1.6': dependencies: @@ -13589,7 +15218,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/utils@4.1.6': dependencies: @@ -13614,6 +15243,10 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -13625,8 +15258,9 @@ snapshots: acorn@8.16.0: {} - acorn@8.18.0: - optional: true + acorn@8.18.0: {} + + adm-zip@0.5.18: {} agent-base@7.1.4: {} @@ -13663,12 +15297,16 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -13887,7 +15525,7 @@ snapshots: prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -13912,6 +15550,8 @@ snapshots: better-result@2.9.2: {} + bignumber.js@9.3.1: {} + bowser@2.14.1: {} brace-expansion@1.1.14: @@ -13919,6 +15559,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -14021,6 +15665,8 @@ snapshots: caniuse-lite@1.0.30001809: {} + canonicalize@1.0.8: {} + canvas-confetti@1.9.4: {} ccount@2.0.1: {} @@ -14054,6 +15700,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@3.0.0: {} + ci-info@3.9.0: {} citty@0.1.6: @@ -14064,6 +15712,8 @@ snapshots: cjs-module-lexer@1.4.3: {} + cjs-module-lexer@2.2.1: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -14183,6 +15833,12 @@ snapshots: - supports-color - ts-node + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -14393,6 +16049,8 @@ snapshots: damerau-levenshtein@1.0.8: {} + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -14550,6 +16208,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + effect@3.20.0: dependencies: '@standard-schema/spec': 1.1.0 @@ -14698,6 +16358,8 @@ snapshots: es-module-lexer@2.1.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -15124,6 +16786,11 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fflate@0.4.8: {} fflate@0.8.3: {} @@ -15170,6 +16837,12 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + framer-motion@11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 11.18.1 @@ -15203,6 +16876,23 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.4: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + geist@1.7.0(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -15274,6 +16964,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -15302,6 +17001,8 @@ snapshots: globrex@0.1.2: {} + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -15353,6 +17054,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -15581,6 +17287,19 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.18.0 + acorn-import-attributes: 1.9.5(acorn@8.18.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.3.3: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 2.3.2 + module-details-from-path: 1.0.4 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -15601,6 +17320,51 @@ snapshots: inline-style-parser@0.2.7: {} + inngest-cli@1.44.0: + dependencies: + adm-zip: 0.5.18 + debug: 4.4.3 + node-fetch: 2.7.0 + tar: 7.5.22 + transitivePeerDependencies: + - encoding + - supports-color + + inngest@4.18.1(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(hono@4.12.29)(next@16.3.0(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(zod@4.4.3): + dependencies: + '@bufbuild/protobuf': 2.14.0 + '@inngest/ai': 0.1.7 + '@jpwilliams/waitgroup': 2.1.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/auto-instrumentations-node': 0.79.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + '@standard-schema/spec': 1.1.0 + '@traceloop/instrumentation-anthropic': 0.20.0 + '@types/debug': 4.1.13 + '@types/ms': 2.1.0 + canonicalize: 1.0.8 + cross-fetch: 4.1.0 + debug: 4.4.3 + hash.js: 1.1.7 + json-stringify-safe: 5.0.1 + ms: 2.1.3 + temporal-polyfill: 0.2.5 + ulid: 2.4.0 + zod: 4.4.3 + optionalDependencies: + hono: 4.12.29 + next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(@types/node@20.19.41)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + typescript: 5.9.3 + transitivePeerDependencies: + - '@opentelemetry/core' + - encoding + - supports-color + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -15812,6 +17576,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -16196,6 +17966,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -16210,6 +17984,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -16376,6 +18152,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.camelcase@4.3.0: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -16998,6 +18776,8 @@ snapshots: mini-svg-data-uri@1.4.4: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -17010,10 +18790,20 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + minimist@1.2.8: {} minipass@7.1.3: {} + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + module-details-from-path@1.0.4: {} + motion-dom@11.18.1: dependencies: motion-utils: 11.18.1 @@ -17114,6 +18904,8 @@ snapshots: - '@types/node' - babel-plugin-macros + node-domexception@1.0.0: {} + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 @@ -17123,6 +18915,16 @@ snapshots: node-fetch-native@1.6.7: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} node-releases@2.0.44: {} @@ -17241,6 +19043,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} parent-module@1.0.1: @@ -17285,6 +19089,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.4.0 @@ -17586,6 +19395,20 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 20.19.41 + long: 5.3.2 + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -17954,6 +19777,21 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resend@4.8.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@react-email/render': 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -17993,6 +19831,10 @@ snapshots: reusify@1.1.0: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + robust-predicates@3.0.3: {} rolldown@1.0.1: @@ -18342,6 +20184,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -18402,6 +20250,10 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + strip-bom@3.0.0: {} strip-bom@4.0.0: {} @@ -18465,6 +20317,8 @@ snapshots: symbol-tree@3.2.4: {} + systeminformation@5.33.5: {} + tagged-tag@1.0.0: {} tailwind-merge@2.6.1: {} @@ -18479,6 +20333,20 @@ snapshots: tapable@2.3.3: {} + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temporal-polyfill@0.2.5: + dependencies: + temporal-spec: 0.2.4 + + temporal-spec@0.2.4: {} + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.6 @@ -18525,6 +20393,8 @@ snapshots: dependencies: tldts: 6.1.86 + tr46@0.0.3: {} + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -18670,6 +20540,8 @@ snapshots: uint8array-extras@1.5.0: {} + ulid@2.4.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -18855,17 +20727,17 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3): + vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -18878,8 +20750,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.3 + yaml: 2.9.0 - vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3): + vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -18892,8 +20765,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.3 + yaml: 2.9.0 - vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3): + vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -18906,11 +20780,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.3 + yaml: 2.9.0 - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@25.0.1)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -18927,7 +20802,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -18939,10 +20814,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -18959,7 +20834,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -18971,10 +20846,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -18991,7 +20866,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -19003,10 +20878,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + vitest@4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.9.5)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(happy-dom@20.11.1)(jsdom@26.1.0)(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -19023,7 +20898,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + vite: 8.0.13(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -19047,8 +20922,12 @@ snapshots: web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} + web-vitals@5.3.0: {} + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -19065,6 +20944,11 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + when-exit@2.1.5: {} which-boxed-primitive@1.1.1: @@ -19127,6 +21011,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: @@ -19160,6 +21050,10 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/turbo.json b/turbo.json index edeb1f8..6150973 100644 --- a/turbo.json +++ b/turbo.json @@ -44,6 +44,10 @@ "OPENAI_API_KEY", "ENCRYPTION_KEY", "CRON_SECRET", + "INNGEST_BASE_URL", + "INNGEST_EVENT_KEY", + "INNGEST_SIGNING_KEY", + "INNGEST_DEV", "NEXT_PUBLIC_FREE_ACCESS_FLAG", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", From 43acdc4cd0988f136bff7eabece91724c13e0607 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 18:27:08 +0200 Subject: [PATCH 03/43] Add the integrations refactor plan and four codebase conventions The plan covers 15 behavior-preserving steps plus the behavior-changing fixes found along the way, which stay in their own section. The reference additions are the conventions this review surfaced: CONTEXT.md vocabulary binds identifiers, third-party JSON is parsed not cast, registries are exhaustive over their id union, and URLs come from @scibly/routes. Co-Authored-By: Claude Opus 5 --- .../skills/refactor/references/codebase.md | 33 + refactor-plan.md | 1365 +++++++++++++++++ 2 files changed, 1398 insertions(+) create mode 100644 refactor-plan.md diff --git a/.agents/skills/refactor/references/codebase.md b/.agents/skills/refactor/references/codebase.md index f035a7c..5bd9c46 100644 --- a/.agents/skills/refactor/references/codebase.md +++ b/.agents/skills/refactor/references/codebase.md @@ -230,6 +230,29 @@ const items = orderItemsSchema.parse(data); - Proxy matcher literals stay inline in each app's `proxy.ts`; alignment tested against `PROXY_MATCHER` from `@scibly/observability/proxy/matcher`. +## Domain vocabulary (`CONTEXT.md`) + +- A feature folder's `CONTEXT.md` is binding on **identifiers**, not just prose: + module, file, type, function, and constant names must use its terms, and must + not use anything on an `_Avoid_` list. Diff the names in a feature against its + `CONTEXT.md` before reviewing anything else about naming — drift there is a + maintainability finding with a citable source, not a matter of taste. +- When a refactor names a concept the `CONTEXT.md` does not have, add the term + there in the same change (create the file lazily if the feature has none). + +## Third-party HTTP responses + +- Any JSON coming back from a provider or external API is parsed with a Zod + schema before use — never `as T`, never an interface asserted over + `response.json()`. The repo idiom is + `someSchema.parse(await response.json())` (see + `apps/app/src/features/organizations/settings/server/endpoint-probe.ts`). +- A `// SAFETY:` comment claiming the shape is documented, or that callers check + the fields, is not a substitute for a parse — it is a finding in its own + right, because nothing keeps the comment true. +- A shared request helper takes the schema as a parameter rather than a type + argument, so parsing cannot be forgotten at a call site. + ## Misc conventions - Zod is v4 (pinned via pnpm override) — flag v3-only idioms. @@ -239,6 +262,16 @@ const items = orderItemsSchema.parse(data); rather than re-wrapping Radix directly. - Env access goes through the typed env (`@t3-oss/env-nextjs`, `apps/app/env.js`) — raw `process.env` reads in app code are findings. +- URLs are built by `@scibly/routes`, never string-concatenated or + template-literalled at the call site. The package already loads the base URLs + through `loadPackageEnv`, so routing a URL through it usually removes a raw + `process.env` read as well. A URL assembled inline — especially one an + external service will redirect to — is a finding. +- A registry keyed by a known id union must be exhaustive over it: + `satisfies Record`, so adding a member fails to compile until + every registry is updated. `Map` plus a default entry is a + finding — it turns a missing case into a silent wrong-looking UI, and parallel + registries drift apart without anything failing. ## Refactor plan and execution requirements diff --git a/refactor-plan.md b/refactor-plan.md new file mode 100644 index 0000000..7d6a5bc --- /dev/null +++ b/refactor-plan.md @@ -0,0 +1,1365 @@ +# Refactor Plan: Integrations — GitHub App provider, connect callback, sync chain + +Date: 2026-08-28 · Branch: `claude/github-issue-9-2fd543` · Analyzed at commit `8189f39` (merge-base with `main`: `2fa7c44`) + +Scope: the branch diff (`git diff $(git merge-base HEAD main)...HEAD`) — 36 files, +1541/−163 — plus +files heavily entangled with it (`base-provider.ts`, `registry.ts`, `contracts.ts`, the notebook +source ingestion extractors). Revision 2 folds in the user's directives of 2026-08-28; every change +from revision 1 is marked **[user-directed]**. + +## Baseline + +Every step below must return the tree to exactly this state. Both commands were green at `8189f39`. + +| Command | Status at baseline | +| --- | --- | +| `pnpm check` (i18n + typecheck + lint — the fast gate) | **exit 0**, 32/32 tasks successful | +| `pnpm --filter @scibly/app run test` | **176 passed / 3 skipped** files · **2642 passed / 6 skipped** tests | + +Suppression census for the in-scope tree (`grep -rn "eslint-disable|@ts-ignore|@ts-expect-error"` over +`features/integrations`, `features/notebook/sources`, `features/organizations/settings`): **exactly one**, +at `apps/app/src/features/organizations/settings/components/org-settings-form.tsx:87`. No step may add a +second; Step 13 removes this one. + +### Coverage assessment + +Server-side logic is well covered and the tests read as specifications (requirement-tagged `describe` +blocks, shared builders): + +| File | Test | Lines of test | +| --- | --- | --- | +| `server/sync-source-freshness.ts` | ✅ `sync-source-freshness.test.ts` | 738 | +| `server/connect-callback.ts` | ✅ `connect-callback.test.ts` | 542 | +| `api/integration-connection-procedures.ts` | ✅ `api/integration-connections.test.ts` | 345 | +| `server/providers/github/provider.ts` | ✅ `provider.test.ts` | 284 | +| `server/connection-token.ts` | ✅ `connection-token.test.ts` | 108 | +| `server/providers/notion.ts` | ✅ (covered via provider tests) | — | + +Gaps that matter to this plan: + +- **`settings/components/org-integrations-card.tsx` (307 lines) has no test**, and Step 10 restructures it + into eight files. The repo does test components (23 `*.test.tsx` files, e.g. + `features/organizations/settings/components/org-ai-config-card.test.tsx`), so this is a convention gap, + not a policy. → **Phase 0**. +- `server/providers/github/app-auth.ts` (160 lines) is only exercised indirectly through mocks in + `provider.test.ts`. No step below restructures it, so no characterization test is required; its issues + are listed under Behavior-changing fixes. +- `api/integration-page-procedures.ts` (131 lines) and `page-picker/use-page-picker-controller.ts` + (193 lines) have no tests. No step below restructures them. + +## Constraints + +- **Behavior preservation is the prime directive.** Current behavior is the spec, quirks included. Where a + step would change observable behavior it has been moved out of the step list into + *Behavior-changing fixes*, which ship separately and only on the user's say-so. + **Two sanctioned exceptions**, both user-directed and both carrying an explicit *Behavior delta* line: + **Step 7** (one owed-connections query per hop) and **Step 12** (disconnect confirmation dialog). +- All conventions in `.claude/skills/refactor/references/codebase.md` apply, in particular: + - one React component per `.tsx` file (skeletons and empty states included); kebab-case filenames; + PascalCase components; feature context subfolders under `components/`; + - **no `createElement` and no lowercase JSX-returning helpers** in feature UI; + - **no lint/type suppressions** — they are fixed properly, never re-silenced or `_`-prefixed; + - env access through the typed `@/env`; raw `process.env` reads are findings; + - user-facing strings must be translated; + - **URLs are built by `@scibly/routes`, never by string concatenation at the call site** + *(added to `codebase.md` by this run)* **[user-directed]**; + - **multi-write invariants go through `prisma.$transaction`** *(the rule already existed; this run + sharpened it — see the audit below)* **[user-directed]**. +- The domain vocabulary in `apps/app/src/features/integrations/CONTEXT.md` is binding, including each + term's `_Avoid_` list. Steps 6 and 8 exist to bring the sync module back into it. +- Verification for every step: `pnpm check` **and** `pnpm --filter @scibly/app run test` return to the + baseline above, plus a suppression grep over the touched files showing no new + `eslint-disable|@ts-ignore|@ts-expect-error`. + +### Transaction audit **[user-directed]** + +"Always use DB transactions when possible" was applied as a sweep, not a slogan. Every multi-write +sequence in the in-scope tree, and what it needs: + +| Site | Writes | State | Where it is handled | +| --- | --- | --- | --- | +| `server/connection-token.ts:50-51` (`forgetRevokedConnection`) | detach sources → delete connection | **not atomic** | BF-6 | +| `server/connect-callback.ts:216` → `:231` | detach sources → upsert connection | **not atomic**, and a provider round-trip sits between the read and the writes | BF-11 | +| `api/integration-connection-procedures.ts:135-142` (`disconnect`) | detach sources → delete connection | **not atomic** | BF-17 | +| `server/sync-source-freshness.ts:266-267` | `markChangedSourcesStale` → `recordPollSuccess` | **not atomic**, but deliberately so — see below | BF-18 | +| `server/detach-sources.ts:12` | one `updateMany` | single write, fine | — | + +**Enabling change, shared by BF-6/BF-11/BF-17:** `detachSourcesFromConnection` +(`server/detach-sources.ts:7`) closes over the module-level `db` client, so it cannot participate in a +caller's transaction as written. All three fixes depend on it taking an optional transaction client: + +```ts +export async function detachSourcesFromConnection( + connectionId: string, + provider: IntegrationProviderId, + reason: DetachReason, + client: Prisma.TransactionClient = db, +) { … } +``` + +That signature change is behavior-preserving on its own (the default keeps every current call site +identical), so it can ship as a preparatory commit ahead of whichever BF goes first. + +**Where a transaction is deliberately *not* the answer:** the sync hop. Wrapping +`markChangedSourcesStale` + `recordPollSuccess` in a transaction is correct in principle, but a hop holds +its DB work across provider HTTP calls; a transaction spanning those would hold a connection open for the +length of a network round-trip, which `packages/db`'s serverless pooling is explicitly tuned against. The +right shape there is the narrow one — `$transaction([markChangedSourcesStale, recordPollSuccess])` as a +batch **after** the provider call returns, never around it. BF-18 records this. + +## Phase 0 — Safety net + +### P0.1: Characterize `OrgIntegrationsCard` before splitting it + +- **Files:** create `apps/app/src/features/integrations/settings/components/org-integrations-card.test.tsx` +- **Why:** Step 10 moves eight JSX-returning functions into eight files. Nothing currently proves the card + still renders the same thing afterwards. +- **Pin down current behavior, quirks included:** + 1. A provider with no connection renders the connect affordance; a connected one renders + `ProviderStatus` with the workspace name. + 2. `renderProviderIcon` picks `NotionIcon` for `NOTION` and `GitHubIcon` for `GITHUB`. + 3. With `allProviders.length === 0` the card renders the **hardcoded English** string + `No integrations available.` (`org-integrations-card.tsx:301`). Pin the current string — Step 13 + changes it deliberately, and this test is what proves nothing else did. + 4. **Quirk, pin it as-is:** after a *failed* disconnect the row's button stays `disabled`, because + `disconnectingId` is cleared only in `onSuccess` (`:255`) and not in `onError` (`:258`). This is + listed as a behavior fix (BF-13) — the characterization test locks in today's behavior so the + refactor cannot silently change it, and BF-13 updates the test when it ships. + 5. `ProviderGrants` renders the grant list from `api.integration.listGrants` and fires its + revoked-toast effect when `wasRevoked` flips. **Pin that today every grant is rendered**, so + BF-9's first-4-plus-modal change is visible as a deliberate edit to this test rather than a silent + drift. +- **Follow** `org-ai-config-card.test.tsx` for the local render-test idiom (tRPC mocking, `t` fixture). +- **Verify:** `pnpm --filter @scibly/app run test` — new file passes, total count rises, nothing else moves. + +## Refactor steps (ordered) + +Each step is independently shippable: after it lands, the fast gate passes and behavior is unchanged. No +step depends on a later one. Ordered safest-first, respecting dependencies. Steps 7 and 12 are the two +user-directed exceptions to behavior preservation and say so in a **Behavior delta** line. + +### Step 1: Remove `CONFLUENCE` and `SHAREPOINT` from the `IntegrationProvider` enum **[user-directed]** + +- **Files:** `packages/db/schema/integration.prisma`, new migration under `packages/db/migrations/`, + `apps/app/src/features/notebook/chat/provider-display.tsx`, + `apps/app/src/features/integrations/server/connect-callback.test.ts` +- **Now:** `packages/db/schema/integration.prisma:1-8` carries four providers: + ```prisma + enum IntegrationProvider { + NOTION + GITHUB + CONFLUENCE + SHAREPOINT + + @@map("integration_provider") + } + ``` + `CONFLUENCE` and `SHAREPOINT` date from the original integrations migration + (`20260605233958_add_integrations_and_source_lineage/migration.sql:8`) and have **no provider + implementation, no connect path, and no way to produce a row**: `server/registry.ts` builds only + `NOTION` and `GITHUB`, and `contracts.ts:3` lists only those two, so `getAuthUrl` rejects anything else + before a row could be written. They are schema-level placeholders for work that was never done. +- **Target:** a two-member enum, following the **existing precedent in this repo** for removing an enum + value — `packages/db/migrations/20260706170000_remove_docx_source_type/migration.sql`, which guards, + renames, recreates, re-types, and drops: + ```sql + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM "integration_connection" WHERE "provider" IN ('CONFLUENCE','SHAREPOINT')) THEN + RAISE EXCEPTION 'Cannot remove CONFLUENCE/SHAREPOINT: rows still reference them'; + END IF; + END $$; + + ALTER TYPE "integration_provider" RENAME TO "integration_provider_old"; + CREATE TYPE "integration_provider" AS ENUM ('NOTION', 'GITHUB'); + ALTER TABLE "integration_connection" ALTER COLUMN "provider" TYPE "integration_provider" + USING ("provider"::text::"integration_provider"); + DROP TYPE "integration_provider_old"; + ``` + The guard is the point: it turns "someone somehow has a CONFLUENCE row" from silent data loss into a + failed migration. Confirm the column list against the schema before writing it — `provider` appears on + `integration_connection`; grep the generated client for any other column typed `IntegrationProvider`. + + Two consumers must be fixed in the same commit or the gate goes red: + - `notebook/chat/provider-display.tsx:41-46` has a `"CONFLUENCE"` entry (with its own + `confluenceLogo` component at `:8`). Delete the entry and the component. `PROVIDER_DISPLAY` is a + `Map` so this is not a compile error — it is a **grep-found** change, which is exactly + the drift Step 14 removes structurally. + - `server/connect-callback.test.ts:220-228` uses `SHAREPOINT` as its fixture for "a provider the + registry cannot build": + ```ts + it("LP2 refuses a state naming a provider the registry cannot build", async () => { + const response = await callback( + { code: "auth-code", state: state({ provider: "SHAREPOINT" }) }, + "sharepoint", + ); + expect(refusal(response)).toBe("invalid_state"); + ``` + **Keep the test, change the fixture.** The requirement it pins (LP2) is real and stays real; after + this step `"SHAREPOINT"` is simply a string outside the enum rather than one inside it, which is the + more realistic forged-state input anyway. Use a clearly-not-a-provider literal such as + `"NOT_A_PROVIDER"` and leave the assertion untouched. Do **not** delete the case. `:204`'s + `"confluence"` is a *path segment*, not an enum value — it exercises LA8 (path/state mismatch) and + stays valid; leave it, or swap it for another non-matching segment if it reads oddly. +- **Risk:** medium — this is the only step that touches the database. The enum-swap SQL is copied from a + migration that already shipped, so the shape is proven; the risks are (a) missing a column that uses the + type, which the `ALTER TABLE` list must cover, and (b) `prisma generate` needing to run before the + typecheck sees the new enum. Run the migration against a scratch database first and confirm the guard + fires when a `CONFLUENCE` row is planted. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; then + `grep -rn "CONFLUENCE\|SHAREPOINT" apps/app/src packages/db/schema/integration.prisma` returns nothing + outside `packages/db/migrations/` (history is immutable) and `apps/web` (marketing copy about the + Confluence *product*, unrelated). + +### Step 2: Derive `IntegrationProviderId` from the Prisma enum **[revised by Step 1]** + +- **Files:** `apps/app/src/features/integrations/contracts.ts`, + `apps/app/src/features/integrations/server/connection-token.ts` +- **Depends on:** Step 1 +- **Now:** `contracts.ts:3` hand-writes the provider union: + ```ts + export const INTEGRATION_PROVIDERS = ["NOTION", "GITHUB"] as const; + export type IntegrationProviderId = (typeof INTEGRATION_PROVIDERS)[number]; + ``` + Prisma independently generates the `IntegrationProvider` enum + (`packages/db/schema/generated/prisma/enums.ts:75-82`), re-exported through `packages/db/src/enums.ts`. + The two drift by hand. That drift is why `connection-token.ts:20` types the field as + `provider: IntegrationProviderId | string` — a union TypeScript immediately collapses to `string`, so + the narrow half documents an intent the compiler never enforces. +- **Target:** with Step 1 landed, the two lists are the *same* list, so the literal array stops being a + deliberate subset and becomes pure duplication. Keep the runtime array (`z.enum(INTEGRATION_PROVIDERS)` + at `api/integration.schema.ts:11` needs a value, and `satisfies Record` + exhaustiveness checks need the union), but tie it to the enum so a future schema change is a compile + error: + ```ts + import type { IntegrationProvider } from "@scibly/db/enums"; + + /** + * Every provider the schema knows and the registry can build — since the + * CONFLUENCE/SHAREPOINT placeholders were dropped these are the same set. + * The `satisfies` makes a schema change that this list has not followed a + * compile error rather than a runtime surprise. + */ + export const INTEGRATION_PROVIDERS = [ + "NOTION", + "GITHUB", + ] as const satisfies readonly IntegrationProvider[]; + ``` + A `satisfies` catches an *added* member being misspelled but not an added member being ignored. If the + team wants full bidirectional enforcement, add the one-line exhaustiveness assertion beside it: + ```ts + type _AllProvidersListed = IntegrationProvider extends (typeof INTEGRATION_PROVIDERS)[number] + ? true + : never; + ``` + Prefer the assertion — with the enum now equal to the implemented set, "add a provider to the schema and + forget the app" is the exact failure worth catching. Then narrow `ConnectionCredential.provider` to + `IntegrationProviderId` and let `getProvider` (`server/registry.ts:22`, which already narrows through + `isIntegrationProvider`) be the single place a raw DB string is widened. +- **Risk:** low. If narrowing `ConnectionCredential.provider` surfaces a call site that really does pass a + raw DB string, route it through `isIntegrationProvider` rather than re-widening the type. + `contracts.ts` must stay dependency-free of provider SDKs — `@scibly/db/enums` is a type-only import + of a generated file and does not pull Prisma into the client bundle. Confirm with a `type` import. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. + +### Step 3: Remove `CONFLUENCE_PAGE` and `SHAREPOINT_PAGE` from `NotebookSourceType` **[user-directed]** + +- **Files:** `packages/db/schema/notebook.prisma`, new migration under `packages/db/migrations/`, + `apps/app/src/shared/content/sources/constants.ts`, + `apps/app/src/features/notebook/workspace/utils/constants.ts`, + `packages/course-content/src/types.ts` +- **Depends on:** nothing (independent of Steps 1–2), but ship it after Step 1 so the two enum migrations + are reviewed one at a time +- **Now:** the same two dead providers have matching source types + (`packages/db/schema/notebook.prisma:5-6`), reachable from four places, none of which can ever produce + one — the only page provider is Notion: + - `shared/content/sources/constants.ts:22-23` — `SOURCE_TYPES.CONFLUENCE_PAGE` / `SHAREPOINT_PAGE` + - `shared/content/sources/constants.ts:51-52` — `MAX_FILE_SIZE` entries, both `0`, present only + because the object is `satisfies Record` + - `notebook/workspace/utils/constants.ts:143-157` — `SOURCE_DISPLAY_MAP` entries keyed + `"confluence_page"` / `"sharepoint_page"` + - `packages/course-content/src/types.ts:32-33` — two members of a hand-written string union +- **Target:** drop both members from the Prisma enum with the same guarded migration shape as Step 1 + (guarding `SELECT 1 FROM "notebook_source" WHERE "type" IN ('CONFLUENCE_PAGE','SHAREPOINT_PAGE')`), then + delete the four consumers. `MAX_FILE_SIZE` and `SOURCE_TYPES` shrink together — the `satisfies` keeps + them honest, so removing one without the other is a compile error, which is the desired behavior. +- **Risk:** **wider blast radius than Step 1 and the one place to be careful.** `SourceType` is a + cross-package type: `packages/course-content` re-declares it by hand rather than importing it, so the + compiler will *not* connect the two — that file must be edited by grep, not by following errors. Check + `packages/course-content` consumers for a `switch` over source type that would now be missing a case + (an exhaustive switch getting *fewer* cases is safe; a `default` that relied on them is not). + `SOURCE_DISPLAY_MAP` is `Map` with a `DEFAULT_SOURCE_DISPLAY` fallback + (`workspace/utils/constants.ts:161-168`), so its entries are dead weight rather than a type error — + again grep, not compile. + **Guard clause:** if production has any `notebook_source` row with either type, the migration must fail + rather than coerce. Run the guard query against a production snapshot before writing the migration; if + rows exist, stop and bring the finding back rather than shipping the step. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; then + `grep -rn "CONFLUENCE_PAGE\|SHAREPOINT_PAGE\|confluence_page\|sharepoint_page" apps packages ee` + returns nothing outside `packages/db/migrations/`. + +### Step 4: Let `@scibly/routes` own the integration callback URL **[user-directed]** + +- **Files:** `packages/routes/src/index.ts`, + `apps/app/src/features/integrations/api/integration-connection-procedures.ts`, + `apps/app/src/features/integrations/server/connect-callback.ts` +- **Now:** the same URL is built twice, by hand, from two different env sources: + ```ts + // api/integration-connection-procedures.ts:112 — RAW process.env + const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/integrations/${input.provider.toLowerCase()}/callback`; + + // server/connect-callback.ts:194 — typed env + const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/integrations/${callback.provider.toLowerCase()}/callback`; + ``` + Notion requires the token-exchange `redirect_uri` to match the authorize-time one byte for byte, so a + divergence breaks connect with only `token_exchange_failed` to show for it. The raw `process.env` read + also violates codebase.md's typed-env rule, and an unset value yields the literal string `undefined`. +- **Target:** the routes package already owns every other app URL, including the sibling cron route that + this very feature calls (`packages/routes/src/index.ts:193-198`): + ```ts + api: { + cron: { + syncIntegrations: toAppUrl(`${BASE_API_PATH}/cron/sync-integrations`), + }, + oembed: toAppUrl(`${BASE_API_PATH}/oembed`), + }, + ``` + Add the callback beside them: + ```ts + api: { + cron: { … }, + oembed: toAppUrl(`${BASE_API_PATH}/oembed`), + integrations: { + callback: (provider: string) => + toAppUrl(`${BASE_API_PATH}/integrations/${provider.toLowerCase()}/callback`), + }, + }, + ``` + Both call sites become `routes.app.api.integrations.callback(input.provider)`. This also deletes the raw + `process.env` read outright rather than converting it: `packages/routes/src/env.ts` already loads + `NEXT_PUBLIC_APP_URL` through `loadPackageEnv`, so the typed-env rule is satisfied by construction. + + Type the parameter as `string`, not `IntegrationProviderId` — `packages/routes` must not depend on an + app-level type, and the argument is lowercased into a path segment either way. **Sweep in the same + commit:** `grep -rn 'NEXT_PUBLIC_APP_URL\|NEXT_PUBLIC_WEB_URL' apps/app/src apps/web/src` for other + hand-built URLs that belong in the routes package; fold in any that are a one-line move and list the + rest here rather than growing this step. +- **Risk:** low, but this is the string an external provider validates. Confirm the produced value is + byte-identical to today's for both `NOTION` and `GITHUB` — same lowercasing, no trailing slash, and note + `toAppUrl` uses `String.concat` with a leading-slash guard, so `BASE_API_PATH` already starting with `/` + is correct and does not double up. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — `connect-callback.test.ts` and + `integration-connections.test.ts` both exercise these paths. Then + `grep -rn 'api/integrations/' apps/app/src --include='*.ts' --include='*.tsx'` shows the literal path + only inside `packages/routes` and the App Router folder name itself. + +### Step 5: Deduplicate `orgSlugInput` **[user-directed]** + +- **Files:** `packages/schemas/src/schema/organization/index.ts`, + `apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts`, + `apps/app/src/features/integrations/api/integration.schema.ts`, plus the importers listed below +- **Now:** the identical schema is declared twice, in two features: + ```ts + // features/organizations/settings/api/org-ai-config.schemas.ts:9 + export const orgSlugInput = z.object({ orgSlug: z.string() }); + + // features/integrations/api/integration.schema.ts:8 + export const orgSlugInput = z.object({ orgSlug: z.string() }); + ``` + The first is consumed by `org-ai-query-procedures.ts:24,59` and by **nine** procedures in + `billing-procedures.ts` (`:34,69,83,89,95,101,111,121,131`, the last four via `.extend()`); the second + by `integration-connection-procedures.ts:75`. A third variant is inlined rather than reused — + `integration.schema.ts:18` and `org-ai-config.schemas.ts:13` both write `orgSlug: z.string()` inside a + larger object. +- **Target:** one declaration in `packages/schemas/src/schema/organization/index.ts`, which is exactly + where codebase.md's boundary rule puts it ("Zod schemas belong in `packages/schemas`, not inline next to + a router"), and which both features already reach as `@scibly/schemas/organization` (the package's + `exports` map is `"./*": "./src/schema/*/index.ts"`): + ```ts + /** The org a procedure acts on, addressed the way the URL addresses it. */ + export const orgSlugInput = z.object({ orgSlug: z.string() }); + ``` + Both feature modules re-export it so their call sites keep importing from their own schema file: + ```ts + import { orgSlugInput } from "@scibly/schemas/organization"; + export { orgSlugInput }; + ``` + That keeps `.extend()` working as the local idiom and makes the change a one-line edit per feature + rather than fourteen import rewrites. Fold the two inlined `orgSlug: z.string()` occurrences into + `orgSlugInput.extend({ … })` in the same commit — that is what makes this a dedup rather than a move. +- **Risk:** low, with one thing to check: `packages/schemas` imports `from "zod/v4"` while both feature + files import `from "zod"` (resolved to v4 by the pnpm override). Confirm the two specifiers produce the + same `ZodObject` at runtime — if `.extend()` on the shared object misbehaves in `billing-procedures.ts`, + that is the cause, and the fix is to align the import specifier, never to re-declare the schema. + `packages/schemas` has its own Jest suite (`pnpm --filter @scibly/schemas run test`); run it too. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; + `pnpm --filter @scibly/schemas run test`; then + `grep -rn 'z.object({ orgSlug: z.string() })' apps packages` returns exactly one hit. + +### Step 6: Rename the sync module to the CONTEXT vocabulary + +- **Files:** `apps/app/src/features/integrations/server/sync-source-freshness.ts`, + `apps/app/src/features/integrations/server/sync-source-freshness.test.ts`, + `apps/app/src/features/integrations/server.ts`, + `apps/app/src/app/api/cron/sync-integrations/route.ts`, + `apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts` +- **Now:** the module names three core concepts with words `CONTEXT.md` explicitly lists under `_Avoid_`, + and gives one concept two names in the same file: + - `SYNC_BATCH_SIZE` (`:18`) — Chain `_Avoid_: batch` + - `syncConnection` (`:235`) — Poll `_Avoid_: sync (the run, not the turn)`; this function *is* a poll + - `runSyncStep` / `SyncStepResult` (`:275`, `:270`) — "step" is a third name for a Hop, while the same + file already says hop at `:20` (`SYNC_HOP_DEADLINE_MS`), `:22` (`MAX_SYNC_HOPS`), `:283` + (`hopStartedAt`), `:315` (`"Hop failed:"`), and the caller wraps it as `startHop` + (`app/api/cron/sync-integrations/route.ts:21`) + - `postToSyncRoute` (`:321`) — names the transport where the domain says Chain + - `loadSyncableSources` (`:162`), `recordAttempt` (`:181`), `recordPollSuccess` (`:191`) all take the + parameter name `integrationId` while every call site passes `connection.id` — Connection + `_Avoid_: integration (the context, not the record)` +- **Target:** pure renames, no logic touched: + + | Now | Target | + | --- | --- | + | `syncConnection` | `pollConnection` | + | `runSyncStep` | `runSyncHop` | + | `SyncStepResult` | `SyncHopResult` | + | `postToSyncRoute` | `handOffChain` | + | `SYNC_BATCH_SIZE` | `SYNC_HOP_CONNECTION_LIMIT` | + | `integrationId` params (3 fns) | `connectionId` | + + The **DB column** `NotebookSource.integrationId` keeps its name (renaming it needs a migration and is + out of scope), so the Prisma filters become `where: { integrationId: connectionId }` — the mismatch then + lives in exactly one visible place per query instead of being smeared across the parameter names. Apply + the same `integrationId` → `connectionId` rename to `resolveIntegration` in + `integration-extractors.ts:10`. +- **Risk:** very low — no behavior, no exported *shape*. The one thing to get right is the re-export list + in `server.ts:8-13` and the two call sites in `app/api/cron/sync-integrations/route.ts:7,23`; the + typechecker catches any miss. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — 738 lines of tests must pass unchanged + apart from the renamed imports. `git diff` should contain no logic hunks. + +### Step 7: Collapse the two owed-connections queries into one **[user-directed]** + +- **Files:** `apps/app/src/features/integrations/server/sync-source-freshness.ts`, + `sync-source-freshness.test.ts` +- **Depends on:** Step 6 (names below are post-rename) +- **Now:** `runSyncHop` calls `loadOwedConnections` once at `:277` to get the hop's work, then **again** at + `:308` purely to decide whether to continue the chain: + ```ts + const connections = await loadOwedConnections(lease, new Date()); // :277 + for (const connection of connections) { … } // serial loop + const remaining = await loadOwedConnections(lease, new Date()); // :308 + const continued = remaining.length > 0 && hops < MAX_SYNC_HOPS; + ``` + The second query does the same `findMany` — same filters, same ordering, same `take` — and its result is + used only as a boolean. Two round trips per hop where one would do. +- **Target:** ask for one row more than the hop can use, and let the surplus answer the question: + ```ts + const owed = await loadOwedConnections(lease, now, SYNC_HOP_CONNECTION_LIMIT + 1); + const connections = owed.slice(0, SYNC_HOP_CONNECTION_LIMIT); + const moreOwed = owed.length > SYNC_HOP_CONNECTION_LIMIT; + ``` + This is sound because of how the query already excludes work in flight + (`sync-source-freshness.ts:135-140`): + ```ts + OR: [{ lastAttemptedAt: null }, { lastAttemptedAt: { lt: lease.chainStartedAt } }], + ``` + Every connection this hop touches gets `lastAttemptedAt = now` (`recordAttempt`), which is `>= + chainStartedAt`, so it is *already* excluded from any later query in the same chain. The second query's + only job was to re-derive "is there anything left", and the `+1` row derives it without a round trip. + Note the deadline path already sets `deadlineReached = true` and short-circuits, so this only applies + when the loop drained a full batch. +- **Behavior delta — read this before shipping.** This is **not** perfectly behavior-preserving, and the + difference is worth stating precisely rather than hiding: + - **Today:** the continue decision is made *after* the hop's ~4 minutes of work, so a connection whose + `nextPollAfter` elapsed *during* the hop is seen and the chain continues for it. + - **After:** the decision is made *before* the work, so that connection is missed and waits for the next + chain — which, on the daily cron (`apps/app/vercel.json` → `"0 4 * * *"`), means the next day. + - **How narrow:** `SYNC_BACKOFF_MS` is `[0, 0, 0, 6h, 1d, 3d]`, and `recordPollFailure` writes + `nextPollAfter: delay > 0 ? … : null`. So only a connection with **≥4 consecutive failures** has a + non-null `nextPollAfter` at all. The window is a ~4-minute crossing on a 6h-or-longer timer, for a + connection that is already failing, on the last hop of a chain. Everything else — connections with + `nextPollAfter: null`, and any hop that is not the last — is bit-identical. + - **Recommendation:** ship it. The delta costs a repeatedly-failing connection one extra day in a rare + window; the fix removes one DB round trip per hop, every hop, forever. If that trade is unwanted, the + alternative that preserves behavior exactly is to keep the second query but make it + `count({ …, take: 1 })` instead of a full `findMany` — cheaper, same semantics, but still a round trip. +- **Risk:** low mechanically. `loadOwedConnections` gains a limit parameter, so give it a default of + `SYNC_HOP_CONNECTION_LIMIT` and keep the existing tests calling it unchanged. The trap is slicing: + `connections` must be the sliced array everywhere downstream, or the hop polls `LIMIT + 1` connections + and the deadline budget is off by one. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. The + `describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for")` block is the one that matters; + its existing assertions must pass unchanged. **Add** a case asserting `db.integrationConnection.findMany` + is called **once** per hop — that is the assertion that keeps the second query from creeping back. + +### Step 8: Split the sync module into `server/sync/` + +- **Files:** delete `apps/app/src/features/integrations/server/sync-source-freshness.ts` (343 lines) and + `sync-source-freshness.test.ts` (738 lines); create the folder below; update + `apps/app/src/features/integrations/server.ts:8-13` +- **Depends on:** Steps 6 and 7 (rename and fix first, then move — otherwise the diff mixes all three and + none of them is reviewable) +- **Now:** one file stacks four concerns that change for different reasons and share no state beyond + `SyncLease`: the lease (`:60`, `:94`, `:111`), connection selection (`:131`), per-connection polling + (`:175`, `:203`, `:235`), and the chain handoff (`:275`, `:321`). The test file already groups along + exactly these seams (`describe("KW1/KW4/KW5: the interval a poll covers")`, + `describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for")`, + `describe("KC2/KC3: the singleton lease")`). +- **Target:** three modules plus a barrel, each with its test beside it: + + | New file | Exports | + | --- | --- | + | `server/sync/sync-lease.ts` | `acquireSyncLease`, `continueSyncLease`, `releaseSyncLease`, `type SyncLease`; module-private `SYNC_LEASE_MS`, `SYNC_LEASE_ID` | + | `server/sync/poll-connection.ts` | `pollConnection`, `loadOwedConnections`, `getPollingStart`, `backoffMs`, `type SyncConnection`, `type SyncRunTotals`; module-private `loadSyncableSources`, `markChangedSourcesStale`, `recordAttempt`, `recordPollSuccess`, `recordPollFailure`, `SYNC_WINDOW_FLOOR_MS`, `SYNC_CLOCK_SKEW_MS`, `SYNC_HOP_CONNECTION_LIMIT`, `SYNC_BACKOFF_MS`, `SYNC_BACKOFF_CAP_MS` | + | `server/sync/run-sync-hop.ts` | `runSyncHop`, `type SyncHopResult`; module-private `handOffChain`, `SYNC_HOP_DEADLINE_MS`, `MAX_SYNC_HOPS` | + | `server/sync/index.ts` | barrel re-exporting exactly what `server.ts` re-exports today | + + Keep the backoff ladder in `poll-connection.ts` with the bookkeeping that writes it — splitting the + table from `recordPollFailure` would put one invariant in two modules. Split the test file the same + three ways, moving each `describe` block beside its new module. +- **Risk:** moderate — it is the largest mechanical change in the plan. The failure mode is an import + cycle (`run-sync-hop` → `poll-connection` → `sync-lease` must stay a straight line, no back-edges) and a + missed re-export from `server.ts`. Both are compile errors, not silent breaks. Constants that are + currently exported but only used inside the module should become module-private; if a test imports one, + keep it exported rather than loosening the test. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — the same assertions must pass, now + spread over three files with the total test count unchanged. Confirm + `apps/app/src/features/integrations/server.ts` re-exports the identical surface: + `git show HEAD~1:apps/app/src/features/integrations/server.ts` vs. the new one. + +### Step 9: Collapse the provider class hierarchy (the code-judo move) + +- **Files:** `apps/app/src/features/integrations/server/base-provider.ts`, + `apps/app/src/features/integrations/server/connection-token.ts`, + `apps/app/src/features/integrations/server/registry.ts`, + `apps/app/src/features/integrations/server/providers/notion.ts`, + `apps/app/src/features/integrations/server/providers/github/provider.ts` +- **Now:** two providers are served by a four-way type split — `BaseIntegrationProvider`, + `PageIntegrationProvider`, an **empty** `ReadOnlyIntegrationProvider` marker class, and a separate + `AppInstallationProvider` *interface* reached through a `mintsInstallationTokens` type guard. Each + capability is expressed a different way, and three of the four expressions are dead weight: + - `refreshToken` (`:57`) — **no production caller anywhere.** The only reference in the repo is the test + at `providers/github/provider.test.ts:280`. No provider overrides it. The `tokenExpiresAt` column it + would key off is written (`connect-callback.ts:176,185`) and **read nowhere**; + `resolveConnectionToken` never checks expiry. + - `ReadOnlyIntegrationProvider` — an empty subclass carrying no members. + - `listsGrants` (`:52`) — a boolean flag that only restates whether a subclass overrode `listGrants`. + - `PageIntegrationProvider`'s four base implementations (`listChildren`, `listDatabasePages`, + `getPageRevision`, `pollModifiedPages`) return empty/null for a subclass that never uses them — Notion + overrides all four and is the only page provider. + - `AppInstallationProvider` + `mintsInstallationTokens` — a single-implementor interface plus a runtime + type guard, to express "this provider mints tokens instead of storing them". +- **Target:** one abstract class where a capability is an optional method, and *having* the method is the + capability — deleting the entire "capability as a position in a type hierarchy" category: + ```ts + export abstract class IntegrationProvider { + abstract readonly providerId: IntegrationProviderId; + abstract readonly displayName: string; + abstract readonly credential: IntegrationCredentialKind; + + abstract getAuthUrl(state: string, redirectUri: string): string; + abstract completeConnect( + params: ConnectCallbackParams, + redirectUri: string, + ): Promise; + + /** Present only on providers connected by letting an app in. */ + mintAccessToken?(installationId: string): Promise; + /** Present only on providers that hand access out piece by piece. */ + listGrants?(token: string): Promise; + } + + export abstract class PageIntegrationProvider extends IntegrationProvider { + abstract searchPages(...): ...; + abstract fetchPageContent(...): ...; + abstract listChildren(...): ...; + abstract listDatabasePages(...): ...; + abstract getPageRevision(...): ...; + abstract pollModifiedPages(...): ...; + } + ``` + Deletions: `refreshToken`, `ReadOnlyIntegrationProvider`, `listsGrants`, `AppInstallationProvider`, + `mintsInstallationTokens`. `PageIntegrationProvider` keeps its four methods but as `abstract` — Notion + already implements every one, so nothing changes at runtime and a future page provider is forced to + decide rather than silently inheriting "returns nothing". + + Call sites become presence checks: + ```ts + // connection-token.ts — was: if (mintsInstallationTokens(provider)) { … } + if (provider.mintAccessToken) { … } + // callers of listGrants — was: if (provider.listsGrants) { … } + const grants = await provider.listGrants?.(token) ?? []; + ``` + Two consumers of `listsGrants` need updating together: `registry.ts`'s provider descriptor (whatever + feeds `provider.listsGrants` into the client payload) and + `settings/components/org-integrations-card.tsx:224`'s render guard + (`connection && provider.listsGrants ? : null`). The client-facing descriptor must + keep *some* boolean — the browser cannot check for a server method — so keep a `listsGrants` field on + the **descriptor** while deleting it from the **class**, computed once in `registry.ts` as + `listsGrants: Boolean(provider.listGrants)`. That is the whole point: one place derives it, instead of + every provider restating it. + + `PAGE_INTEGRATION_PROVIDERS` in `contracts.ts` stays as it is — it is the client-visible contract and + must not learn about server classes. + + **Decision recorded:** `refreshToken` is *deleted*, not wired up. Deleting it preserves behavior exactly + (zero production callers, `tokenExpiresAt` never read); wiring it up would add token-refresh behavior + that does not exist today, which is a feature, not a refactor. If an expiring-token provider is added + later, refresh gets designed then, against a real requirement. `tokenExpiresAt` keeps being written — + dropping the column needs a migration and is out of scope. +- **Risk:** the type guard `mintsInstallationTokens` checks + `credential === "app_installation" && "mintAccessToken" in provider`; an optional method drops the + `credential` half of that check. Confirm `connection-token.ts`'s branch order still sends + OAuth-credential providers down the `accessTokenEncrypted` path — the `credential` discriminant stays on + the class, so assert on it if the presence check alone reads as weaker. Verify Notion never grows a + `mintAccessToken`. This is the step most likely to surface a `refreshToken` reference in a test: update + `providers/github/provider.test.ts:280` by **deleting** that case (it asserts the throw of a method that + no longer exists), not by keeping the method alive for the test. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. Then confirm the deletions are real: + `grep -rn "refreshToken\|ReadOnlyIntegrationProvider\|mintsInstallationTokens\|AppInstallationProvider" apps/app/src packages` + should return nothing outside `contracts.ts`'s `OAuthTokens.refreshToken` field (the wire shape Notion's + OAuth response carries — that stays) and the `refreshTokenEncrypted` DB column; + `grep -rn "listsGrants" apps/app/src` should show only the descriptor in `registry.ts` and its two + readers. + +### Step 10: Split `org-integrations-card.tsx` into a component folder + +- **Files:** delete `apps/app/src/features/integrations/settings/components/org-integrations-card.tsx` + (307 lines); create `apps/app/src/features/integrations/settings/components/org-integrations/`; update + the re-export at `apps/app/src/features/integrations/client.ts:3` +- **Depends on:** Phase 0 P0.1 +- **Now:** one file defines **eight** JSX-returning functions — `NotionIcon` (`:14`), `GitHubIcon` (`:27`), + `renderProviderIcon` (`:48`, lowercase and returning JSX), `ProviderStatus` (`:77`), `ProviderAction` + (`:99`), `ProviderGrants` (`:143`), `ProviderRow` (`:203`), `OrgIntegrationsCard` (`:235`) — plus the + `PROVIDER_ICONS` registry (`:40`). This PR grew the file by 147 lines. codebase.md: one component per + file, and files made of several larger components must be split into a folder. +- **Target:** nine files in `org-integrations/`, following the existing feature-subfolder pattern: + + | New file | Export | + | --- | --- | + | `org-integrations-card.tsx` | `OrgIntegrationsCard` — container: the `api.integration.list` query, row mapping, empty state | + | `provider-row.tsx` | `ProviderRow`, `export type ProviderRowProps` (imported by siblings) | + | `provider-status.tsx` | `ProviderStatus` | + | `provider-action.tsx` | `ProviderAction` | + | `provider-grants.tsx` | `ProviderGrants` — owns the `listGrants` query and the revoked-toast effect | + | `provider-icon.tsx` | `ProviderIcon` + `PROVIDER_ICONS`; **replaces** `renderProviderIcon`, call site becomes `` | + | `notion-icon.tsx` | `NotionIcon` | + | `github-icon.tsx` | `GitHubIcon` | + | `use-org-integrations.ts` | `useOrgIntegrations` — the list query, both mutations, `disconnectingId` state | + + The lowercase `renderProviderIcon` becoming a real `ProviderIcon` component is the point of the + `provider-icon.tsx` file, not an incidental rename — codebase.md forbids lowercase JSX-returning helpers. +- **Risk:** the largest surface-area change in the plan, but every piece is a move. The real risks are + (a) the `client.ts:3` re-export path, which is what the rest of the app imports, and (b) accidentally + changing the empty-state string or the disconnect-state quirk while moving them — P0.1 is what catches + that. Do **not** fix the `isDisconnecting`/`isDisconnectPending` triple-boolean or the stuck-button quirk + in this step; the first is Step 11's, the second is BF-13. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — P0.1 must pass **unmodified**, which is + the proof the split changed nothing. Then + `grep -rn "createElement\|eslint-disable\|@ts-ignore\|@ts-expect-error" apps/app/src/features/integrations/settings/components/org-integrations/` + returns nothing. + +### Step 11: Collapse the disconnect state to one derived boolean + +- **Files:** `apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx`, + `provider-action.tsx`, `use-org-integrations.ts` +- **Depends on:** Step 10 +- **Now:** three overlapping booleans describe one operation — `isDisconnecting` (`:68`), + `isConnectPending` (`:69`), `isDisconnectPending` (`:70`) — and two of them are consumed as a single + condition anyway: `disabled={isDisconnecting || isDisconnectPending}` (`:116`). +- **Target:** one `isBusy` derived inside `useOrgIntegrations` as + `disconnectingId === providerId && disconnectMutation.isPending`, passed down as a single prop. + `isConnectPending` stays — it is a genuinely different operation. +- **Risk:** low, but note that `isDisconnecting || isDisconnectPending` and + `isDisconnecting && isDisconnectPending` are **not** the same condition. Today the button is disabled if + *either* is true; the `&&` form is strictly narrower and would re-enable the button in the window where + `disconnectingId` is set but the mutation has not started. Keep the `||` semantics unless BF-13 ships + first, in which case the two collapse to the same thing. If in doubt, ship BF-13 before this step. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — P0.1's quirk assertion (item 4) must + still pass. + +### Step 12: Add the disconnect confirmation dialog **[user-directed]** + +- **Files:** create + `apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx`; + edit `provider-action.tsx`, `provider-row.tsx`, `org-integrations-card.tsx`, `use-org-integrations.ts` +- **Depends on:** Steps 10 and 11 +- **Now:** clicking Disconnect fires the mutation immediately + (`org-integrations-card.tsx:114`, `onClick={onDisconnect}`). Meanwhile two translated keys have existed + since the feature landed and are read by nothing: + ```json + "confirmDisconnectTitle": "Disconnect integration?", + "confirmDisconnectDescription": "Existing sources will remain in your notebooks, but re-sync will no longer work until you reconnect." + ``` + (`orgSettings.i18n.en.json:123-124`, mirrored in `.de.json`, typed at `org-settings.types.ts:123-124`.) + Revision 1 of this plan proposed deleting them as dead copy; the user's direction is to build the dialog + instead, so they become live keys and Step 13 no longer touches them. +- **Target:** reuse the repo's existing confirmation idiom rather than inventing one. The model is + `apps/app/src/features/organizations/members/components/modals/remove-member-dialog.tsx`, which composes + `AlertDialog` from `apps/app/src/shared/ui/components/alert-dialog.tsx` and is driven by a nullable id: + ```tsx + export function DisconnectIntegrationDialog({ + provider, // IntegrationProviderId | null — non-null means open + onConfirm, + onClose, + t, + }: { + provider: IntegrationProviderId | null; + onConfirm: () => void; + onClose: () => void; + t: OrgSettingsPage["integrations"]; + }) { + return ( + !open && onClose()}> + + + {t.confirmDisconnectTitle} + {t.confirmDisconnectDescription} + + + {t.cancelButton} + + {t.disconnectButton} + + + + + ); + } + ``` + `useOrgIntegrations` grows one piece of state — `pendingDisconnect: IntegrationProviderId | null` — and + `ProviderAction`'s `onDisconnect` sets it instead of calling the mutation. The mutation moves behind + `onConfirm`. The dialog is rendered **once** by `OrgIntegrationsCard`, not per row, so there is one + instance regardless of provider count. + + **Two things to get right, both of which the model file gets wrong or does not cover:** + 1. `remove-member-dialog.tsx:62` hardcodes `Cancel` in English. + Do **not** copy that. Add a `cancelButton` key to the `integrations` block in both locale files and + `org-settings.types.ts` — `pnpm check`'s i18n task enforces the pair. + 2. `isBusy` from Step 11 must keep gating the row's button, and the dialog's confirm button needs its + own pending state, or a double-click confirms twice. +- **Behavior delta:** disconnecting now takes two clicks instead of one. That is the requested change; it + is called out here rather than buried because P0.1's disconnect assertions must be updated in the same + commit, and any e2e flow that clicks Disconnect will need the extra step. +- **Risk:** low-moderate. `AlertDialog` is Radix-based and already used in this app, so no new dependency. + The one real hazard is state leaking between rows: `pendingDisconnect` must be cleared on close *and* on + success, or the next Disconnect click opens the dialog for the previous provider. Add a render test + covering open → cancel → open-a-different-provider. +- **Verify:** `pnpm check` (its i18n task gates the new `cancelButton` key in both locales); + `pnpm --filter @scibly/app run test` — P0.1 updated in the same commit, plus the new dialog test. Then + `grep -rn "confirmDisconnectTitle\|confirmDisconnectDescription" apps/app/src` shows a **reader**, not + just the two JSON declarations and the type. + +### Step 13: Translate the hardcoded strings and remove the suppression + +- **Files:** `apps/app/src/features/organizations/settings/components/org-settings-form.tsx`, + `apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json`, + `orgSettings.i18n.de.json`, `org-settings.types.ts`, + `apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx`, + `apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts` +- **Now:** three clusters of untranslated user-facing copy, and the branch's only lint suppression: + 1. `org-settings-form.tsx:61-74` — ten `IntegrationCallbackError` messages hardcoded in English, plus + the success toast at `:51` (`` `${integrationConnected.toUpperCase()} connected successfully.` ``) + and the fallback at `:79`. Meanwhile `org-settings.types.ts:121-126` already declares + `connectedSuccessfully`, `disconnectedSuccessfully`, `workspaceLabel`, `connectedBy`, + `confirmDisconnectTitle`, `confirmDisconnectDescription` — translated in both locale files, and all + but `disconnectedSuccessfully` read by nothing (until Step 12 wires up the last two). + 2. `org-integrations-card.tsx:301` — `No integrations available.` in a component whose every other + string comes from `t`. + 3. `use-page-picker-controller.ts:163-171` — three toasts with hand-rolled English pluralization + (`` `${count} page${count !== 1 ? "s" : ""} added` ``), inside a hook that already receives `props.t` + and uses it on the next line (`:173`). + And `org-settings-form.tsx:87`: + ```ts + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + ``` +- **Target:** + - Move the ten callback-error messages into `orgSettings.i18n.en.json`/`.de.json` under `integrations`, + typed in `org-settings.types.ts`; pass `t.integrations` into the effect. Use the already-present + `connectedSuccessfully` key at `:51`. + - Add `noProvidersAvailable`; render `{t.noProvidersAvailable}`. Per Step 10's layout this belongs in its + own `org-integrations-empty.tsx`. + - Add `pagesAdded`, `pagesAddedWithSkipped`, `allAlreadyLinked` to the notebook `pagePicker` + translations, rendered with the existing `{name}`-style placeholder substitution used at + `components/integration-buttons.tsx:65`. + - **Replace** the suppression with a `useRef` run-once guard (or list the genuinely stable deps). + codebase.md is explicit: suppressions are fixed properly, never re-silenced and never `_`-prefixed. + - Delete the two keys that stay dead — `workspaceLabel` and `connectedBy` — from the type and both JSON + files. **`confirmDisconnectTitle` and `confirmDisconnectDescription` are now read by Step 12's dialog + and must be kept** *(this reverses revision 1, which deleted all four)*. If Step 12 has not shipped + when this step does, keep all four and delete nothing — deleting keys a queued step needs is the one + ordering mistake here that the gate will not catch. +- **Risk:** this is the one step that changes rendered text, from English literals to translated keys. For + `en` the strings must be **byte-identical** to today's, or P0.1 and any snapshot will (correctly) fail. + The `useRef` guard must preserve the current once-per-mount semantics including React 18 double-invoke + in development — the existing comment at `:45-46` explains why the toast id is stable; keep that. + `pnpm check` runs the i18n check, so a key present in `en` but missing in `de` fails the gate. +- **Verify:** `pnpm check` (its i18n task is the real gate here); + `pnpm --filter @scibly/app run test`; then + `grep -rn "eslint-disable\|@ts-ignore\|@ts-expect-error" apps/app/src/features/organizations/settings apps/app/src/features/integrations apps/app/src/features/notebook/sources` + returns **nothing** — down from the one suppression at baseline. + +### Step 14: Unify the provider display registry + +- **Files:** `apps/app/src/features/integrations/settings/components/org-integrations/provider-icon.tsx`, + `apps/app/src/features/notebook/chat/provider-display.tsx` +- **Depends on:** Steps 1 and 10 +- **Now:** two independent provider-display registries under different names, which **disagree about which + providers exist**: + ```ts + // org-integrations-card.tsx:40 — exhaustive over IntegrationProviderId + const PROVIDER_ICONS = { NOTION: NotionIcon, GITHUB: GitHubIcon } + satisfies Record>; + + // notebook/chat/provider-display.tsx:31 — keyed by bare string + export const PROVIDER_DISPLAY = new Map([ + ["NOTION", …], ["CONFLUENCE", …], // no GITHUB + ]); + ``` + So a GitHub source in the notebook picker falls through to `PROVIDER_DISPLAY_FALLBACK`. Step 1 removes + the `CONFLUENCE` entry; this step removes the *reason* a second registry could diverge at all. + + There is a **third** registry with the same shape: + `notebook/workspace/utils/constants.ts:102` — `SOURCE_DISPLAY_MAP: Map` + with a `DEFAULT_SOURCE_DISPLAY` fallback, keyed by lowercased `SourceType`. It is keyed by source type + rather than provider, so it is not merged here, but it is the same anti-pattern (`Map` + + fallback = silent divergence) and it is why the convention added to `codebase.md` is worth having. +- **Target:** one registry keyed by `IntegrationProviderId` and declared + `satisfies Record` — so adding a provider fails to compile until its display + entry exists — living next to `contracts.ts`, read by both the settings card and the notebook picker. + Drop `PROVIDER_DISPLAY_FALLBACK` once the map is exhaustive. +- **Risk:** **this step changes what the notebook picker renders for a GitHub source** — today the + fallback, afterwards the real GitHub entry. That is arguably fixing a bug rather than preserving + behavior. Ship it as a deliberate, visible change, or hold it. Note also that + `provider-display.tsx` defines the camelCase components `confluenceLogo` and `providerLogoFallback`, + which codebase.md's React rules forbid; `confluenceLogo` is already deleted by Step 1, and renaming + `providerLogoFallback` → `ProviderLogoFallback` belongs here since this step is already rewriting the + file. `NotionLogoIcon` is already shared (`provider-display.tsx:6,37`, `source-list-item.tsx:7,75`) — + reuse it rather than adding a third Notion icon. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — `source-list-item.test.tsx` exercises + the notebook-side rendering. + +### Step 15: Delete `pageCount` from the integration page contract + +- **Files:** `apps/app/src/features/integrations/contracts.ts`, + `apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts` +- **Now:** `IntegrationPageContent.pageCount` (`contracts.ts:44`) is declared and forwarded into + persistence (`integration-extractors.ts:58`), but the only implementation of `fetchPageContent` + (`providers/notion.ts:147-152`) never sets it — so it is always `undefined` for integration sources. + It belongs to the PDF path (`ingestion/parsers/pdf-parser.ts:98`), which sets it on its own extractor + type. +- **Target:** drop the field from the interface and the forwarding line. If the persistence layer requires + the key, pass `undefined` explicitly at the one call site rather than routing it through the contract. +- **Risk:** low — confirm with `grep -rn "pageCount" apps/app/src` that the PDF path's own `pageCount` is + a separate type and is untouched. +- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. + +## Behavior-changing fixes (separate — these are NOT refactors) + +None of these belong in the steps above. They change what the code does; the user decides if and when they +ship. Ordered by severity. **BF-1 warrants attention before this branch reaches production.** + +### BF-1 · CRITICAL · Cross-tenant GitHub installation takeover + +- **Where:** `apps/app/src/features/integrations/server/providers/github/provider.ts:52`, reached from + `server/connect-callback.ts:65` +- **Evidence:** the callback reads the installation id straight from the query string — + ```ts + // connect-callback.ts:65 + installationId: searchParams.get("installation_id"), + ``` + — and the provider resolves it with the **app's own JWT**, which can see every installation of the app + on any account: + ```ts + // providers/github/provider.ts:52 + const installation = await fetchInstallation(readGitHubAppConfig(), params.installationId); + ``` + ```ts + // providers/github/app-auth.ts — fetchInstallation + await githubRequest(`/app/installations/${encodeURIComponent(installationId)}`, + { method: "GET", authorization: `Bearer ${signAppJwt(config)}` }); + ``` + Nothing binds that id to the person completing the connect. `validateCallback` only proves the `state` + was issued by us (HMAC-SHA256, timing-safe, 10-minute TTL — but **no nonce, so replayable**), and + `authorizeCallback` only proves the caller is an admin of **their own** org. + `grep -rn "user/installations\|user-to-server\|oauth/access_token" apps/app/src` returns **nothing** — + there is no ownership check anywhere. `docs/runbooks/github-app.md:25` confirms this is by design today: + *"Request user authorization (OAuth) during installation — **unchecked**"*, with the note "scibly never + asks GitHub for a user token, only for the installation." +- **Impact:** an admin of any org calls `integration.getAuthUrl({ provider: "GITHUB" })` for their own + org, then hits + `/api/integrations/github/callback?state=&installation_id=`. + The connection persists pointing at the victim's installation. `listGrants` then mints a real + installation token and returns the victim org's **private repository names and URLs**, and the stored + connection carries the app's Contents/Issues/PR read access. Installation ids are small sequential + integers, so finding other tenants of this app is trivial enumeration. Any scibly customer who connects + GitHub is readable by any other scibly customer. +- **Recommendation:** enable *Request user authorization (OAuth) during installation* on the GitHub App, + exchange the `code` GitHub returns for a user-to-server token, and verify `GET /user/installations` + contains the submitted `installation_id` **before** persisting. Update + `docs/runbooks/github-app.md:25` in the same change — the runbook currently instructs operators into the + vulnerable configuration. Give the state a single-use nonce while in here + (`apps/app/src/lib/crypto/oauth-state.ts` has none). + +#### Answering the question: does this narrow who can use the connection? **[user-directed]** + +> *"If we set OAuth as recommended, can we still connect it such that every member of an org has access — +> or more specifically admins and owners?"* + +**Org-wide access is unaffected. Only who may complete the connect changes, and only slightly.** + +1. **The user token is used once and thrown away.** It exists solely to answer "can *this* person see + *that* installation?" at connect time. It is never written to `integration_connection` — the schema has + no column for it — so its 8-hour expiry is irrelevant and no refresh is needed. +2. **Every later call is unchanged.** `resolveConnectionToken` → `mintAccessToken` signs a JWT with the + app's own RSA private key and exchanges it for an *installation* token + (`app-auth.ts`, `provider.ts:52`). That token is scoped to the installation, not to a person. So + `listGrants`, source ingestion, and the daily sync behave identically for every member of the org + regardless of who connected it — which is exactly the property `CONTEXT.md` describes under + **Installation**: *"the token it stands for is minted from the app's own key… and never written down."* +3. **Scibly-side authorization does not move.** `authorizeCallback` already calls + `requireOrgMember(organization.id, session.user.id, "admin_or_owner")`, and `getAuthUrl` / + `disconnect` / `listGrants` all call `resolveOrg(..., "admin_or_owner")`. Admins and owners remain the + ones who can connect and disconnect; ordinary members remain able to *use* the connection through + notebooks. None of that is touched. +4. **The one real change:** the scibly admin who completes the connect must also be a GitHub user who can + see that installation. A scibly admin with no GitHub relationship to the org being installed onto could + no longer complete the connect — someone with GitHub access would have to. In practice that is the + person who clicked "Install" on GitHub anyway, since GitHub redirects *them* to the callback. +5. **A plain member never reaches this at all.** The integrations settings page is admin/owner-only, so + ordinary members see no providers and have no connect button — the "does a plain member still see it" + question does not arise on the scibly side. The person completing a connect is always an admin or an + owner, both before and after this change. +6. **What is left to verify is GitHub-side, and only for that admin.** GitHub documents + `GET /user/installations` as returning installations "that the authenticated user has explicit + permission (`:read`, `:write`, or `:admin`) to access" — **repository-access-based, not org-role-based**, + so it does not require GitHub org ownership. Confirm against a real install that the account clicking + through actually sees the installation; if some legitimate connector does not, accept **either** proof: + the user token lists the installation, **or** the user is an admin/owner of the GitHub org that + `fetchInstallation` reports as the installation's account. Both close the takeover. + +### BF-2 · HIGH · Stored XSS — `javascript:` URLs pass `z.url()` and are rendered into `href` + +- **Where:** validation via Zod, rendered at + `apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx:205` (`href={item.externalUrl}`) +- **Evidence:** verified empirically against the pinned **zod 4.4.3** in this repo: + ``` + "javascript:alert(1)" -> string().url(): true | z.url(): true + "data:text/html," -> string().url(): true | z.url(): true + "vbscript:x" -> string().url(): true | z.url(): true + ``` + Zod's URL check only asks whether `new URL()` parses, which accepts any scheme. So yes — **this is + really needed**; `z.string().url()` is not doing the job anyone reading it assumes it does. +- **Impact:** a value that reaches `externalUrl` is stored and later rendered as a clickable link. A + `javascript:` href executes in the victim's session on click. +- **Recommendation — one shared schema, reused deliberately** **[user-directed]:** + + Put it in `packages/schemas/src/schema/common/index.ts` (new folder; the package's `exports` map + `"./*": "./src/schema/*/index.ts"` picks it up with no config change), so both apps and packages reach + it as `@scibly/common`: + ```ts + /** + * A URL safe to put in an `href` or fetch: https only. + * Zod's own `.url()` accepts any scheme `new URL()` parses — including + * `javascript:`, `data:` and `vbscript:` — so it is not a safety check. + * Use this anywhere a URL is stored, rendered as a link, or fetched. + */ + export const httpsUrl = (message = "Must be a valid https:// URL") => + z.url({ protocol: /^https$/, message }); + ``` + Take the message as a parameter rather than hardcoding it — the repo has `zod-i18n.ts` and several call + sites pass their own copy today. + + **Reuse at these sites (all currently `z.string().url()`):** + + | Site | Field | Why it qualifies | + | --- | --- | --- | + | `features/integrations/api/integration.schema.ts:45` | `pageUrl` | flows to `externalUrl`, rendered as `href` — the actual XSS path | + | `features/integrations/api/integration.schema.ts:57` | `url` | same | + | `features/course-authoring/.../course-validation.ts:39` | thumbnail | rendered as an image src | + | `packages/schemas/src/schema/organization/index.ts:13` | `createOrganizationSchema.logo` | rendered, org-wide | + | `packages/schemas/src/schema/organization/index.ts:28` | `updateOrganizationSchema.logo` | same | + | `packages/schemas/src/schema/user/index.ts:235` | user image | rendered | + | `features/.../image-schemas.ts:79,139` | image URLs | rendered | + + **Deliberately NOT changed — do not sweep these:** + - `features/organizations/settings/api/org-ai-config.schemas.ts:32,46` — BYOAI `baseUrl`. Self-hosters + legitimately point this at `http://localhost:11434` (Ollama) or an internal host. Forcing https here + breaks a supported configuration. It is also server-to-server, never rendered as a link. + - `notebook-tools.ts:17` — the web-fetch tool's argument, whose own doc comment says *"public HTTP or + HTTPS URL"*. Narrowing it changes what the agent can fetch. If it is tightened later, that is a + product decision, not this fix. + + **Defend at the render site too.** Rows already in the database were never checked, so schema-only + validation leaves stored payloads live. Gate `source-list-item-actions.tsx:205` on the parsed protocol + before rendering the anchor — belt and braces, and it is the half that protects existing data. +- **Rollout note:** this rejects input that used to be accepted, which is why it sits here rather than in + the step list. Before shipping, run a read-only census: + `SELECT DISTINCT split_part("externalUrl", ':', 1) FROM "notebook_source" WHERE "externalUrl" IS NOT NULL;` + If anything other than `https` (and possibly `http`) appears, decide the migration story first. + +### BF-3 · HIGH · A vanished connection row kills the entire sync chain + +- **Where:** `server/sync-source-freshness.ts:181` (`recordAttempt`), reached from `:244`, `:266`, `:267` +- **Evidence:** `recordAttempt` uses `update`, which throws Prisma `P2025` when the row is gone: + ```ts + await db.integrationConnection.update({ where: { id: integrationId }, data }); + ``` + `syncConnection`'s `try/catch` (`:250-263`) wraps **only** the provider poll. The `recordAttempt` at + `:244` (empty-sources branch), `markChangedSourcesStale` at `:266`, and `recordPollSuccess` at `:267` + are all outside it. A throw there escapes to `runSyncStep`'s outer catch (`:314`), which releases the + lease and returns `{ continued: false }`. +- **Impact:** if a user disconnects (`integration-connection-procedures.ts:140` deletes the row) or + `forgetRevokedConnection` deletes it while a hop is running, **every remaining connection in the chain + is dropped for that run** — and since the cron is daily (`apps/app/vercel.json` → `"0 4 * * *"`), + "that run" means a whole day. +- **Recommendation:** use `updateMany({ where: { id } })`, a no-op on a missing row, and wrap the + per-connection body so no single connection can abort the hop. + +### BF-4 · HIGH · Reconnecting does not clear the backoff, so a fixed integration stays dark + +- **Where:** `server/connect-callback.ts:223-241` +- **Evidence:** the upsert's `update` branch carries only credentials and workspace: + ```ts + const connectionData = { + ...credentialColumns(credential), + workspaceId: credential.workspaceId ?? null, + workspaceName: credential.workspaceName ?? null, + connectedByUserId: callback.connectedByUserId, + }; + ``` + `consecutiveFailures`, `nextPollAfter` and `lastPolledAt` survive untouched. +- **Impact:** a connection whose token was revoked accumulates failures until `nextPollAfter` is up to + **7 days** out (`SYNC_BACKOFF_CAP_MS`). The admin reconnects, the settings card shows healthy, and + `loadOwedConnections` keeps excluding it for the rest of the backoff. On a workspace change the stale + `lastPolledAt` from the *previous* workspace is carried over too. +- **Recommendation:** add `consecutiveFailures: 0, nextPollAfter: null` to `connectionData`, and + `lastPolledAt: null` on the workspace-changed path. Ships naturally inside BF-11's transaction. + +### BF-5 · HIGH · A failed chain handoff is reported as success and strands the lease + +- **Where:** `server/sync-source-freshness.ts:321-341` +- **Evidence:** the response status is never inspected: + ```ts + await fetch(routes.app.api.cron.syncIntegrations, { + method: "POST", + headers: { authorization: `Bearer ${env.CRON_SECRET}`, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + ``` + and the `!env.CRON_SECRET` branch at `:322-327` logs and **returns normally**. Either way + `runSyncStep` reaches `return { totals, continued: true }` (`:313`) without calling `releaseSyncLease`. +- **Impact:** a 401 or 500 on the handoff — or an unset `CRON_SECRET` — leaves the chain dead while the + lease is held for its full `SYNC_LEASE_MS` (10 minutes), blocking every trigger in between, with only a + console line to show for it. +- **Recommendation:** check `response.ok`, log the status, release the lease when the handoff did not + land, and return `continued: false` so the next trigger starts a fresh chain immediately. + +### BF-6 · HIGH · A single 404 permanently deletes a connection and detaches all its sources + +- **Where:** `server/connection-token.ts:50-51` (`forgetRevokedConnection`) +- **Evidence:** + ```ts + await detachSourcesFromConnection(connection.id, providerId, "disconnected"); + await db.integrationConnection.deleteMany({ where: { id: connection.id } }); + ``` + Two unrelated writes, **no `$transaction`**. `providers/github/provider.ts:71` maps **any** 404 to + `IntegrationRevokedError`, and GitHub answers 404 for every installation the *presenting app* cannot + see — including a `GITHUB_APP_ID`/`GITHUB_APP_PRIVATE_KEY` pair pointing at a different app. +- **Impact:** one misconfigured deploy (staging key in prod, a rotated or re-created app) destroys **every + org's** GitHub connection and rewrites `warning` on all their sources — triggered from a read path + (`listGrants`), with no undo and no confirmation. If the `deleteMany` fails after the detach commits, + sources are orphaned while the settings card still shows the integration connected. +- **Recommendation — two independent fixes, both wanted:** + 1. **Atomicity** **[user-directed]:** wrap both writes in one `prisma.$transaction`, which requires the + `detachSourcesFromConnection(..., client)` signature change described under *Transaction audit*: + ```ts + await db.$transaction(async (tx) => { + await detachSourcesFromConnection(connection.id, providerId, "disconnected", tx); + await tx.integrationConnection.deleteMany({ where: { id: connection.id } }); + }); + ``` + 2. **Blast radius:** require corroboration before deleting anything — confirm the app itself is + reachable via `GET /app/installations`, or mark the connection `revokedAt` and let an explicit + disconnect/reconnect clean it up. A transaction makes the destruction atomic; it does not make it + correct. + +### BF-7 · MEDIUM · Backoff cap equals the polling-window floor, so a recovered connection loses changes + +- **Where:** `server/sync-source-freshness.ts:34` and `:175-179` +- **Evidence:** `SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 7` and + `SYNC_WINDOW_FLOOR_MS = TimeHelpers.IN_MS.DAY * 7` — **identical**. `getPollingStart` clamps with + `Math.max(lastPolledAt - skew, now - SYNC_WINDOW_FLOOR_MS)`. +- **Impact:** after the escalating ladder (6h → 1d → 3d → 7d …) a recovering connection is easily 10+ days + past its watermark, so its first successful poll asks only about the last 7 days and the intervening + edits are never marked stale — no warning, no second chance. This contradicts `CONTEXT.md`'s Watermark + definition: *"the next success covers the whole gap."* +- **Recommendation:** the cap must sit strictly below the floor — cap the backoff at 24h — or, when + `now - lastPolledAt > SYNC_WINDOW_FLOOR_MS`, fall back to a per-source revision re-check instead of the + changed-since query. The floor itself is deliberate and pinned by tests; do not move it. + +### BF-8 · MEDIUM · Three ingestion entry points bypass the `source.ingest` rate limit + +- **Where:** `api/integration-page-procedures.ts` — `linkPages` (`:39`), `linkPage` (`:63`), + `resyncSource` (`:101`, calling `ingestOrRefreshSource` directly at `:126`) +- **Evidence:** every other ingestion path goes through `boundedIngest` + (`notebook/sources/api/bounded-ingest.ts:8-13`), which wraps `withRateLimit` on endpoint + `source.ingest` — used at `source.router.ts:154`, `source-upload-procedures.ts:129` and `:202`. These + three procedures are plain `protectedProcedure` with no limiter. +- **Impact:** the abusable ingestion work is reachable unmetered, and `linkPages` fans out up to 20 + ingestions per call. +- **Recommendation:** route all three through `boundedIngest`, matching the rest of the ingestion surface. + +### BF-9 · MEDIUM · GitHub repository list is silently truncated at 100 + +- **Where:** `server/providers/github/app-auth.ts:153-159` (server) and + `settings/components/org-integrations-card.tsx:143-202` (`ProviderGrants`, client) +- **Evidence:** `githubRequest("/installation/repositories?per_page=100", …)` — `total_count` in the + response is ignored and no `page=` parameter is ever sent. On the client, `ProviderGrants` then renders + **every** returned grant as a flex-wrap chip: + ```tsx + + ``` + So an org-wide install on a 400-repo account shows 100 chips, in a settings card, with nothing + indicating the other 300 exist — contradicting `CONTEXT.md`'s **Grant** as *what the installation was + let at*. +- **Recommendation — fix both halves** **[user-directed]:** + + **Server — page until complete.** Loop on `page` until the accumulated length reaches `total_count`, + with a hard page cap (say 10 pages / 1000 grants) so a pathological account cannot hang a hop. Return + the total alongside the list so the client can be honest about a cap that *was* hit: + ```ts + return { grants, totalCount, truncated: grants.length < totalCount }; + ``` + `listGrants` is a query on the settings page, not on the sync path, so the extra round trips cost a + slower settings card and nothing else. Do this together with BF-10's schema parsing — it is the same + function. + + **Client — first four, then a modal.** `ProviderGrants` renders the first 4 chips and, when there are + more, a `242 more` affordance that opens a dialog listing all of them: + ```tsx + const VISIBLE_GRANTS = 4; + const visible = data.grants.slice(0, VISIBLE_GRANTS); + const hiddenCount = data.grants.length - VISIBLE_GRANTS; + ``` + - The affordance is a ` - ); - } - return ( - - ); -}; - -// Its own query, so the card renders at once and only this strip waits on the -// provider. -export const ProviderGrants = ({ - orgSlug, - provider, - t, -}: { - orgSlug: string; - provider: IntegrationProviderId; - t: OrgSettingsPage["integrations"]; -}) => { - const utils = api.useUtils(); - const { data, isPending, isError, error } = - api.integration.listGrants.useQuery({ - orgSlug, - provider, - }); - - // The token this query needs is minted per call, so this strip is where an - // uninstall on the provider's side first shows up. The server has already - // dropped the connection by the time the error arrives; refetching the list - // is what takes the row off the page. - const wasRevoked = error?.data?.applicationCode === "integration.revoked"; - useEffect(() => { - if (!wasRevoked) return; - toast.error(t.revokedNotice, { id: `integration-revoked-${provider}` }); - void utils.integration.list.invalidate({ orgSlug }); - }, [wasRevoked, provider, orgSlug, t.revokedNotice, utils]); - - if (isPending) { - return

{t.grantsLoading}

; - } - if (isError) { - return

{t.grantsError}

; - } - if (data.grants.length === 0) { - return

{t.grantsEmpty}

; - } - return ( -
-

- {t.grantsTitle} -

- -
- ); -}; - -export const ProviderRow = (props: ProviderRowProps) => { - const { provider, connection, orgSlug, t } = props; - - return ( -
-
-
-
- {renderProviderIcon(provider.providerId)} -
-
-

- {t.providers[provider.providerId]} -

- -
-
-
- -
-
- {connection && provider.listsGrants ? ( - - ) : null} -
- ); -}; - -export function OrgIntegrationsCard({ - orgSlug, - lang, - t, -}: OrgIntegrationsCardProps) { - const utils = api.useUtils(); - const [disconnectingId, setDisconnectingId] = useState(null); - - const { data } = api.integration.list.useQuery({ orgSlug }); - - const getAuthUrlMutation = api.integration.getAuthUrl.useMutation({ - onSuccess: ({ authUrl }) => { - window.location.href = authUrl; - }, - onError: (err) => toast.error(err.message), - }); - - const disconnectMutation = api.integration.disconnect.useMutation({ - onSuccess: () => { - toast.success(t.disconnectedSuccessfully); - setDisconnectingId(null); - void utils.integration.list.invalidate({ orgSlug }); - }, - onError: (err) => toast.error(err.message), - }); - - const connections = data?.connections ?? []; - const allProviders = data?.allProviders ?? []; - - return ( - -
- {allProviders.map((p) => { - const connection = connections.find( - (c) => c.provider === p.providerId, - ); - return ( - { - setDisconnectingId(p.providerId); - disconnectMutation.mutate({ - orgSlug, - provider: p.providerId, - }); - }} - onConnect={() => - getAuthUrlMutation.mutate({ - orgSlug, - provider: p.providerId, - lang, - }) - } - /> - ); - })} - - {allProviders.length === 0 && ( -

- No integrations available. -

- )} -
-
- ); -} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/github-icon.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/github-icon.tsx new file mode 100644 index 0000000..3219513 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/github-icon.tsx @@ -0,0 +1,12 @@ +export const GitHubIcon = ({ className }: { className?: string }) => { + return ( + + ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/notion-icon.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/notion-icon.tsx new file mode 100644 index 0000000..5f1738a --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/notion-icon.tsx @@ -0,0 +1,12 @@ +export const NotionIcon = ({ className }: { className?: string }) => { + return ( + + ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx similarity index 100% rename from apps/app/src/features/integrations/settings/components/org-integrations-card.test.tsx rename to apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx new file mode 100644 index 0000000..69707aa --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -0,0 +1,62 @@ +"use client"; + +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { SettingsCard } from "@/shared/ui/settings-card"; + +import { ProviderRow } from "./provider-row"; +import { useOrgIntegrations } from "./use-org-integrations"; + +interface OrgIntegrationsCardProps { + orgSlug: string; + lang: string; + t: OrgSettingsPage["integrations"]; +} + +export function OrgIntegrationsCard({ + orgSlug, + lang, + t, +}: OrgIntegrationsCardProps) { + const { + connections, + allProviders, + disconnectingId, + isConnectPending, + isDisconnectPending, + connect, + disconnect, + } = useOrgIntegrations({ orgSlug, lang, t }); + + return ( + +
+ {allProviders.map((p) => { + const connection = connections.find( + (c) => c.provider === p.providerId, + ); + return ( + disconnect(p.providerId)} + onConnect={() => connect(p.providerId)} + /> + ); + })} + + {allProviders.length === 0 && ( +

+ No integrations available. +

+ )} +
+
+ ); +} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx new file mode 100644 index 0000000..a1378ef --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx @@ -0,0 +1,46 @@ +import type { ProviderRowProps } from "./provider-row"; + +import { Button } from "@scibly/ui/components/button"; +import { ExternalLink, Unplug } from "lucide-react"; + +export const ProviderAction = ({ + provider, + connection, + isDisconnecting, + isConnectPending, + isDisconnectPending, + t, + onConnect, + onDisconnect, +}: ProviderRowProps) => { + if (connection) { + return ( + + ); + } + return ( + + ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx new file mode 100644 index 0000000..039ffe1 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { ExternalLink } from "lucide-react"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { api } from "@/shared/api/trpc/client"; + +// Its own query, so the card renders at once and only this strip waits on the +// provider. +export const ProviderGrants = ({ + orgSlug, + provider, + t, +}: { + orgSlug: string; + provider: IntegrationProviderId; + t: OrgSettingsPage["integrations"]; +}) => { + const utils = api.useUtils(); + const { data, isPending, isError, error } = + api.integration.listGrants.useQuery({ + orgSlug, + provider, + }); + + // The token this query needs is minted per call, so this strip is where an + // uninstall on the provider's side first shows up. The server has already + // dropped the connection by the time the error arrives; refetching the list + // is what takes the row off the page. + const wasRevoked = error?.data?.applicationCode === "integration.revoked"; + useEffect(() => { + if (!wasRevoked) return; + toast.error(t.revokedNotice, { id: `integration-revoked-${provider}` }); + void utils.integration.list.invalidate({ orgSlug }); + }, [wasRevoked, provider, orgSlug, t.revokedNotice, utils]); + + if (isPending) { + return

{t.grantsLoading}

; + } + if (isError) { + return

{t.grantsError}

; + } + if (data.grants.length === 0) { + return

{t.grantsEmpty}

; + } + return ( +
+

+ {t.grantsTitle} +

+ +
+ ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-icon.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-icon.tsx new file mode 100644 index 0000000..23a0a8c --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-icon.tsx @@ -0,0 +1,23 @@ +import type { IntegrationProviderId } from "@/features/integrations/contracts"; + +import { GitHubIcon } from "./github-icon"; +import { NotionIcon } from "./notion-icon"; + +const PROVIDER_ICONS = { + NOTION: NotionIcon, + GITHUB: GitHubIcon, +} satisfies Record< + IntegrationProviderId, + React.ComponentType<{ className?: string }> +>; + +export const ProviderIcon = ({ + providerId, +}: { + providerId: IntegrationProviderId; +}) => { + const IconComponent = PROVIDER_ICONS[providerId]; + return ( + + ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx new file mode 100644 index 0000000..c6f3da7 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx @@ -0,0 +1,55 @@ +import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { ProviderAction } from "./provider-action"; +import { ProviderGrants } from "./provider-grants"; +import { ProviderIcon } from "./provider-icon"; +import { ProviderStatus } from "./provider-status"; + +export type ProviderRowProps = { + provider: { + providerId: IntegrationProviderId; + displayName: string; + listsGrants?: boolean; + }; + connection?: { workspaceName: string | null }; + isDisconnecting: boolean; + isConnectPending: boolean; + isDisconnectPending: boolean; + t: OrgSettingsPage["integrations"]; + orgSlug: string; + onConnect: () => void; + onDisconnect: () => void; +}; + +export const ProviderRow = (props: ProviderRowProps) => { + const { provider, connection, orgSlug, t } = props; + + return ( +
+
+
+
+ +
+
+

+ {t.providers[provider.providerId]} +

+ +
+
+
+ +
+
+ {connection && provider.listsGrants ? ( + + ) : null} +
+ ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-status.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-status.tsx new file mode 100644 index 0000000..b817a06 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-status.tsx @@ -0,0 +1,25 @@ +import type { ProviderRowProps } from "./provider-row"; + +import { CheckCircle2, XCircle } from "lucide-react"; + +export const ProviderStatus = ({ + connection, + t, +}: Pick) => { + if (!connection) { + return ( +

+ + {t.notConnectedStatus} +

+ ); + } + return ( +

+ + {connection.workspaceName + ? `${t.connectedStatus} · ${connection.workspaceName}` + : t.connectedStatus} +

+ ); +}; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts new file mode 100644 index 0000000..f68f7ba --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -0,0 +1,56 @@ +"use client"; + +import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { useState } from "react"; +import { toast } from "sonner"; + +import { api } from "@/shared/api/trpc/client"; + +// Everything the card does apart from rendering: what there is to show, and +// the two things a row can ask for. +export function useOrgIntegrations({ + orgSlug, + lang, + t, +}: { + orgSlug: string; + lang: string; + t: OrgSettingsPage["integrations"]; +}) { + const utils = api.useUtils(); + const [disconnectingId, setDisconnectingId] = useState(null); + + const { data } = api.integration.list.useQuery({ orgSlug }); + + const getAuthUrlMutation = api.integration.getAuthUrl.useMutation({ + onSuccess: ({ authUrl }) => { + window.location.href = authUrl; + }, + onError: (err) => toast.error(err.message), + }); + + const disconnectMutation = api.integration.disconnect.useMutation({ + onSuccess: () => { + toast.success(t.disconnectedSuccessfully); + setDisconnectingId(null); + void utils.integration.list.invalidate({ orgSlug }); + }, + onError: (err) => toast.error(err.message), + }); + + return { + connections: data?.connections ?? [], + allProviders: data?.allProviders ?? [], + disconnectingId, + isConnectPending: getAuthUrlMutation.isPending, + isDisconnectPending: disconnectMutation.isPending, + connect: (provider: IntegrationProviderId) => + getAuthUrlMutation.mutate({ orgSlug, provider, lang }), + disconnect: (provider: IntegrationProviderId) => { + setDisconnectingId(provider); + disconnectMutation.mutate({ orgSlug, provider }); + }, + }; +} From 905274b28631ad1f9b70ef3e75394364e761c2b4 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 18:54:02 +0200 Subject: [PATCH 15/43] Collapse the disconnect state to one derived boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three overlapping booleans described one operation, and two of them were only ever read as a single condition: `disabled={isDisconnecting || isDisconnectPending}`. Now one `isBusy` is derived where the state lives and passed down as one prop. The `||` is kept deliberately. `isDisconnecting && isDisconnectPending` is a strictly narrower condition that would re-enable the button in the window where a row has been marked as disconnecting but the mutation has not started — the two windows overlap and neither contains the other, so both are asked. `isConnectPending` stays: it is a different operation, not a third name for this one. Verified: pnpm check 32/32; vitest 2656 tests, unchanged — including the characterization test's stuck-button quirk, which is left exactly as it was. Co-Authored-By: Claude Opus 5 --- .../components/org-integrations/org-integrations-card.tsx | 6 ++---- .../components/org-integrations/provider-action.tsx | 5 ++--- .../settings/components/org-integrations/provider-row.tsx | 4 ++-- .../components/org-integrations/use-org-integrations.ts | 7 +++++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx index 69707aa..db7133e 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -21,9 +21,8 @@ export function OrgIntegrationsCard({ const { connections, allProviders, - disconnectingId, isConnectPending, - isDisconnectPending, + isBusy, connect, disconnect, } = useOrgIntegrations({ orgSlug, lang, t }); @@ -41,8 +40,7 @@ export function OrgIntegrationsCard({ provider={p} orgSlug={orgSlug} connection={connection} - isDisconnecting={disconnectingId === p.providerId} - isDisconnectPending={isDisconnectPending} + isBusy={isBusy(p.providerId)} isConnectPending={isConnectPending} t={t} onDisconnect={() => disconnect(p.providerId)} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx index a1378ef..14cea8f 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx @@ -6,9 +6,8 @@ import { ExternalLink, Unplug } from "lucide-react"; export const ProviderAction = ({ provider, connection, - isDisconnecting, + isBusy, isConnectPending, - isDisconnectPending, t, onConnect, onDisconnect, @@ -20,7 +19,7 @@ export const ProviderAction = ({ variant="ghost" size="sm" onClick={onDisconnect} - disabled={isDisconnecting || isDisconnectPending} + disabled={isBusy} className="gap-1.5 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/30 dark:hover:text-red-400" aria-label={`${t.disconnectButton} ${provider.displayName}`} > diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx index c6f3da7..f227b64 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx @@ -13,9 +13,9 @@ export type ProviderRowProps = { listsGrants?: boolean; }; connection?: { workspaceName: string | null }; - isDisconnecting: boolean; + /** Whether this row's disconnect is out of reach for the moment. */ + isBusy: boolean; isConnectPending: boolean; - isDisconnectPending: boolean; t: OrgSettingsPage["integrations"]; orgSlug: string; onConnect: () => void; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts index f68f7ba..2746d6c 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -43,9 +43,12 @@ export function useOrgIntegrations({ return { connections: data?.connections ?? [], allProviders: data?.allProviders ?? [], - disconnectingId, isConnectPending: getAuthUrlMutation.isPending, - isDisconnectPending: disconnectMutation.isPending, + // A row's disconnect is out of reach while it is the one being + // disconnected, and while any disconnect is in flight. The two windows + // overlap but neither contains the other, so both are asked. + isBusy: (provider: IntegrationProviderId) => + disconnectingId === provider || disconnectMutation.isPending, connect: (provider: IntegrationProviderId) => getAuthUrlMutation.mutate({ orgSlug, provider, lang }), disconnect: (provider: IntegrationProviderId) => { From 3e10e9a4cd3e90321ac306aa872fb01fccd1677c Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 18:57:35 +0200 Subject: [PATCH 16/43] Ask before disconnecting an integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnecting fired the moment the button was clicked, while two translated keys written for a confirmation had sat unread since the feature landed. They now have a reader. `useOrgIntegrations` holds one `pendingDisconnect`, and one dialog serves the whole card — which is why the state has to be cleared on close as well as on success: a second Disconnect click must not open the dialog still asking about the provider before it. There is a test for exactly that sequence. The confirm button has its own pending state, so a second click while the mutation is in flight cannot disconnect twice, and `isBusy` still gates the row's own button. `cancelButton` is a new key in both locales rather than the hardcoded English "Cancel" the model dialog in members/ uses. Behavior delta, deliberate and requested: disconnecting now takes two clicks. The characterization test's disconnect assertions are updated in this commit for that reason. Verified: pnpm check 32/32; vitest 179 files, 2659 tests (+3). Co-Authored-By: Claude Opus 5 --- .../disconnect-integration-dialog.tsx | 58 ++++++++++++++++++ .../org-integrations-card.test.tsx | 59 ++++++++++++++++++- .../org-integrations-card.tsx | 16 ++++- .../org-integrations/use-org-integrations.ts | 17 +++++- .../settings/i18n/org-settings.types.ts | 1 + .../settings/i18n/orgSettings.i18n.de.json | 1 + .../settings/i18n/orgSettings.i18n.en.json | 1 + 7 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx new file mode 100644 index 0000000..0150257 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/components/alert-dialog"; + +// One instance for the whole card, not one per row: which provider is being +// asked about is the state, so there is nothing per-row to hold. +export function DisconnectIntegrationDialog({ + provider, + isConfirming, + onConfirm, + onClose, + t, +}: { + /** The provider being asked about; non-null is what opens the dialog. */ + provider: IntegrationProviderId | null; + isConfirming: boolean; + onConfirm: () => void; + onClose: () => void; + t: OrgSettingsPage["integrations"]; +}) { + return ( + !open && onClose()} + > + + + {t.confirmDisconnectTitle} + + {t.confirmDisconnectDescription} + + + + {t.cancelButton} + + {t.disconnectButton} + + + + + ); +} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index 53b2061..8f5c238 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -45,6 +45,9 @@ const t = { description: "Connect the systems your material lives in.", connectButton: "Connect", disconnectButton: "Disconnect", + cancelButton: "Keep it", + confirmDisconnectTitle: "Disconnect integration?", + confirmDisconnectDescription: "Sources stay; re-syncing stops.", connectedStatus: "Connected", notConnectedStatus: "Not connected", disconnectedSuccessfully: "Disconnected.", @@ -88,6 +91,16 @@ const card = () => const button = (container: HTMLElement, label: string) => container.querySelector(`button[aria-label="${label}"]`); +// The confirmation is portalled out of the card, so it is found on the page +// rather than inside it. +const dialog = () => + document.body.querySelector("[role='alertdialog']"); + +const inDialog = (label: string) => + Array.from(dialog()?.querySelectorAll("button") ?? []).find( + (candidate) => candidate.textContent === label, + ); + beforeEach(() => { vi.clearAllMocks(); lists([NOTION]); @@ -156,16 +169,59 @@ describe("what a click asks for", () => { ); }); - it("asks to disconnect the provider whose row was clicked", () => { + it("asks before disconnecting rather than disconnecting", () => { lists([NOTION], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); fireEvent.click(button(card(), "Disconnect Notion")!); + expect(dialog()?.textContent).toContain("Disconnect integration?"); + expect(disconnectMutate).not.toHaveBeenCalled(); + }); + + it("disconnects the provider the confirmation was opened for", () => { + lists([NOTION], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); + fireEvent.click(button(card(), "Disconnect Notion")!); + + fireEvent.click(inDialog("Disconnect")!); + expect(disconnectMutate).toHaveBeenCalledWith( { orgSlug: "acme", provider: "NOTION" }, expect.anything(), ); }); + + it("disconnects nothing when the confirmation is refused", () => { + lists([NOTION], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); + fireEvent.click(button(card(), "Disconnect Notion")!); + + fireEvent.click(inDialog("Keep it")!); + + expect(dialog()).toBeNull(); + expect(disconnectMutate).not.toHaveBeenCalled(); + }); + + // One dialog serves every row, so the provider it is asking about has to be + // the one just clicked, not the one clicked before. + it("asks about the row just clicked, not the row refused before it", () => { + lists( + [NOTION, GITHUB], + [ + { provider: "NOTION", workspaceName: "Acme HQ" }, + { provider: "GITHUB", workspaceName: "acme-inc" }, + ], + ); + const container = card(); + fireEvent.click(button(container, "Disconnect Notion")!); + fireEvent.click(inDialog("Keep it")!); + + fireEvent.click(button(container, "Disconnect GitHub")!); + fireEvent.click(inDialog("Disconnect")!); + + expect(disconnectMutate).toHaveBeenCalledWith( + { orgSlug: "acme", provider: "GITHUB" }, + expect.anything(), + ); + }); }); // Pinned as-is, not endorsed: `disconnectingId` is cleared in `onSuccess` and @@ -182,6 +238,7 @@ describe("a disconnect that failed", () => { const container = card(); fireEvent.click(button(container, "Disconnect Notion")!); + fireEvent.click(inDialog("Disconnect")!); expect(button(container, "Disconnect Notion")?.disabled).toBe(true); expect(toastError).toHaveBeenCalledWith("provider said no"); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx index db7133e..dbcbabd 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -4,6 +4,7 @@ import type { OrgSettingsPage } from "@/features/organizations/contracts"; import { SettingsCard } from "@/shared/ui/settings-card"; +import { DisconnectIntegrationDialog } from "./disconnect-integration-dialog"; import { ProviderRow } from "./provider-row"; import { useOrgIntegrations } from "./use-org-integrations"; @@ -24,7 +25,11 @@ export function OrgIntegrationsCard({ isConnectPending, isBusy, connect, - disconnect, + pendingDisconnect, + askToDisconnect, + cancelDisconnect, + confirmDisconnect, + isConfirmingDisconnect, } = useOrgIntegrations({ orgSlug, lang, t }); return ( @@ -43,7 +48,7 @@ export function OrgIntegrationsCard({ isBusy={isBusy(p.providerId)} isConnectPending={isConnectPending} t={t} - onDisconnect={() => disconnect(p.providerId)} + onDisconnect={() => askToDisconnect(p.providerId)} onConnect={() => connect(p.providerId)} /> ); @@ -55,6 +60,13 @@ export function OrgIntegrationsCard({

)}
+ ); } diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts index 2746d6c..31d6cca 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -21,6 +21,11 @@ export function useOrgIntegrations({ }) { const utils = api.useUtils(); const [disconnectingId, setDisconnectingId] = useState(null); + // Which provider the confirmation is being asked about. One piece of state + // for the whole card, so a second Disconnect click cannot open the dialog + // still holding the last provider. + const [pendingDisconnect, setPendingDisconnect] = + useState(null); const { data } = api.integration.list.useQuery({ orgSlug }); @@ -35,6 +40,7 @@ export function useOrgIntegrations({ onSuccess: () => { toast.success(t.disconnectedSuccessfully); setDisconnectingId(null); + setPendingDisconnect(null); void utils.integration.list.invalidate({ orgSlug }); }, onError: (err) => toast.error(err.message), @@ -51,9 +57,14 @@ export function useOrgIntegrations({ disconnectingId === provider || disconnectMutation.isPending, connect: (provider: IntegrationProviderId) => getAuthUrlMutation.mutate({ orgSlug, provider, lang }), - disconnect: (provider: IntegrationProviderId) => { - setDisconnectingId(provider); - disconnectMutation.mutate({ orgSlug, provider }); + pendingDisconnect, + askToDisconnect: setPendingDisconnect, + cancelDisconnect: () => setPendingDisconnect(null), + isConfirmingDisconnect: disconnectMutation.isPending, + confirmDisconnect: () => { + if (!pendingDisconnect) return; + setDisconnectingId(pendingDisconnect); + disconnectMutation.mutate({ orgSlug, provider: pendingDisconnect }); }, }; } diff --git a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts index 67423c8..b1e4f20 100644 --- a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts +++ b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts @@ -116,6 +116,7 @@ export type OrgSettingsPage = { description: string; connectButton: string; disconnectButton: string; + cancelButton: string; connectedStatus: string; notConnectedStatus: string; workspaceLabel: string; diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json index f5050d3..70e8601 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json @@ -116,6 +116,7 @@ "description": "Verbinde externe Dienste mit deiner Organisation — Wissensdatenbanken, aus denen KI-Creator-Notebooks Seiten importieren, und Code-Hosts, die Scibly lesen kann.", "connectButton": "Verbinden", "disconnectButton": "Trennen", + "cancelButton": "Abbrechen", "connectedStatus": "Verbunden", "notConnectedStatus": "Nicht verbunden", "workspaceLabel": "Workspace", diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json index 90df409..d38f4c7 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json @@ -116,6 +116,7 @@ "description": "Connect external services to your organization — knowledge bases AI Creator notebooks can import pages from, and code hosts scibly can read.", "connectButton": "Connect", "disconnectButton": "Disconnect", + "cancelButton": "Cancel", "connectedStatus": "Connected", "notConnectedStatus": "Not connected", "workspaceLabel": "Workspace", From 1efdcf0551d0bf2aedc5c4465562581e27c91028 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 19:33:18 +0200 Subject: [PATCH 17/43] Take the last hardcoded strings out of the settings and picker views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user-facing string in the integrations settings card and the page picker now comes from the dictionary, so German readers see German. The ten OAuth callback messages move into orgSettings under `callbackErrors`, keyed by `IntegrationCallbackError` and typed in `org-settings.types.ts`; the fallback becomes `callbackErrorFallback`, and the success toast uses the `connectedSuccessfully` key that was already sitting there unused. The one thing that string no longer carries is the provider's own name — the key it now reads is written for both providers, and the toast already fires on the page the provider was connected from. The `react-hooks/exhaustive-deps` suppression on that effect is replaced rather than re-silenced: a ref makes the body run once, which is what the empty dep array was standing in for, and the real deps are listed. `workspaceLabel` and `connectedBy` are deleted from all three dictionary files — nothing has read them since the card was written. The card's empty state becomes `org-integrations-empty.tsx` per the folder layout from the split, reading `noProvidersAvailable`. The page picker's three toasts had English pluralization built into the template literal. They move to `pagesAdded`, `pagesAddedWithSkipped` and `allAlreadyLinked`, which spell the plural the way every sibling key in that block does — `page(s)`. Verified: pnpm check 32/32, 179 files / 2659 tests green, and no suppression left anywhere under settings, integrations or sources. Co-Authored-By: Claude Opus 5 --- .../org-integrations-card.test.tsx | 5 +- .../org-integrations-card.tsx | 7 +-- .../org-integrations-empty.tsx | 13 ++++ .../sources/i18n/sources.i18n.de.json | 5 +- .../sources/i18n/sources.i18n.en.json | 5 +- .../page-picker/use-page-picker-controller.ts | 9 +-- .../settings/components/org-settings-form.tsx | 62 +++++++++---------- .../settings/i18n/org-settings.types.ts | 18 +++++- .../settings/i18n/orgSettings.i18n.de.json | 16 ++++- .../settings/i18n/orgSettings.i18n.en.json | 16 ++++- 10 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-empty.tsx diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index 8f5c238..a38d809 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -56,6 +56,7 @@ const t = { grantsEmpty: "No repositories.", grantsError: "Could not load repositories.", revokedNotice: "The connection was removed on the provider's side.", + noProvidersAvailable: "Nothing to connect to.", providers: { NOTION: "Notion", GITHUB: "GitHub" }, } as OrgSettingsPage["integrations"]; @@ -152,10 +153,10 @@ describe("which mark stands for which provider", () => { }); describe("a card with no providers to offer", () => { - it("says so in a string that was never translated", () => { + it("says so", () => { lists([]); - expect(card().textContent).toContain("No integrations available."); + expect(card().textContent).toContain("Nothing to connect to."); }); }); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx index dbcbabd..bf9634b 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -5,6 +5,7 @@ import type { OrgSettingsPage } from "@/features/organizations/contracts"; import { SettingsCard } from "@/shared/ui/settings-card"; import { DisconnectIntegrationDialog } from "./disconnect-integration-dialog"; +import { OrgIntegrationsEmpty } from "./org-integrations-empty"; import { ProviderRow } from "./provider-row"; import { useOrgIntegrations } from "./use-org-integrations"; @@ -54,11 +55,7 @@ export function OrgIntegrationsCard({ ); })} - {allProviders.length === 0 && ( -

- No integrations available. -

- )} + {allProviders.length === 0 && }
+ {t.noProvidersAvailable} +

+ ); +} diff --git a/apps/app/src/features/notebook/sources/i18n/sources.i18n.de.json b/apps/app/src/features/notebook/sources/i18n/sources.i18n.de.json index 4726471..69e6362 100644 --- a/apps/app/src/features/notebook/sources/i18n/sources.i18n.de.json +++ b/apps/app/src/features/notebook/sources/i18n/sources.i18n.de.json @@ -50,7 +50,10 @@ "openInSourceLabel": "In Notion öffnen", "browseDatabase": "Datenbank durchsuchen", "browseSubpages": "Unterseiten durchsuchen", - "failedToLink": "Seite konnte nicht verknüpft werden" + "failedToLink": "Seite konnte nicht verknüpft werden", + "pagesAdded": "{count} Seite(n) erfolgreich hinzugefügt", + "pagesAddedWithSkipped": "{count} Seite(n) hinzugefügt ({skipped} bereits verknüpft)", + "allAlreadyLinked": "Alle ausgewählten Seiten sind bereits mit diesem Notebook verknüpft." }, "statusPending": "Ausstehend", "statusProcessing": "Verarbeitung", diff --git a/apps/app/src/features/notebook/sources/i18n/sources.i18n.en.json b/apps/app/src/features/notebook/sources/i18n/sources.i18n.en.json index ceaa4a3..6b25dec 100644 --- a/apps/app/src/features/notebook/sources/i18n/sources.i18n.en.json +++ b/apps/app/src/features/notebook/sources/i18n/sources.i18n.en.json @@ -50,7 +50,10 @@ "openInSourceLabel": "Open in Notion", "browseDatabase": "Browse database", "browseSubpages": "Browse subpages", - "failedToLink": "Failed to link page" + "failedToLink": "Failed to link page", + "pagesAdded": "{count} page(s) added successfully", + "pagesAddedWithSkipped": "{count} page(s) added ({skipped} already linked)", + "allAlreadyLinked": "All selected pages are already linked to this notebook." }, "statusPending": "Pending", "statusProcessing": "Processing", diff --git a/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts b/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts index 78fbe6c..fd3d172 100644 --- a/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts +++ b/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts @@ -162,13 +162,14 @@ export function useLinkSelectedPages( if (count > 0) { toast.success( result.skipped > 0 - ? `${count} page${count !== 1 ? "s" : ""} added (${result.skipped} already linked)` - : `${count} page${count !== 1 ? "s" : ""} added successfully`, + ? props.t.pagesAddedWithSkipped + .replace("{count}", String(count)) + .replace("{skipped}", String(result.skipped)) + : props.t.pagesAdded.replace("{count}", String(count)), ); props.onLinked(); props.onOpenChange(false); - } else - toast.info("All selected pages are already linked to this notebook."); + } else toast.info(props.t.allAlreadyLinked); }, onError: (error) => toast.error(error.message ?? props.t.failedToLink), }); diff --git a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx index d892e4e..6a08bb0 100644 --- a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx +++ b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx @@ -2,7 +2,6 @@ import type { inferRouterOutputs } from "@trpc/server"; import type { z } from "zod"; -import type { IntegrationCallbackError } from "@/features/integrations/contracts"; import type { DictionaryPages } from "@/i18n/types"; import type { AppRouter } from "@/server/api/root"; @@ -14,7 +13,7 @@ import { } from "@scibly/routes"; import { updateOrganizationSchema } from "@scibly/schemas/organization"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm, type UseFormReturn, useWatch } from "react-hook-form"; import { toast } from "sonner"; @@ -37,20 +36,28 @@ export type OrgSettingsTranslations = DictionaryPages["orgSettings"]; export type OrgForm = UseFormReturn; function useOAuthResultNotifications( + t: OrgSettingsTranslations["integrations"], integrationConnected?: string, integrationError?: string, ) { const router = useRouter(); const trpcUtils = api.useUtils(); + // What the callback left in the query string is read once and then taken out + // of the url, so re-running this on any later render would be reading a + // result that has already been reported. React also runs the effect twice in + // development, and the ref is what makes the second run a no-op. + const reported = useRef(false); useEffect(() => { + if (reported.current) return; + reported.current = true; + const url = new URL(window.location.href); if (integrationConnected) { - // React runs this effect twice in development, and the callback lands on - // a fresh mount either way — a stable id keeps one toast on screen. - toast.success( - `${integrationConnected.toUpperCase()} connected successfully.`, - { id: `integration-connected-${integrationConnected}` }, - ); + // The callback lands on a fresh mount either way — a stable id keeps one + // toast on screen. + toast.success(t.connectedSuccessfully, { + id: `integration-connected-${integrationConnected}`, + }); void trpcUtils.integration.list.invalidate(); url.searchParams.delete(INTEGRATION_CONNECTED_QUERY_PARAM); router.replace(url.pathname + url.search); @@ -58,34 +65,21 @@ function useOAuthResultNotifications( } if (!integrationError) return; - const messages = { - provider_denied: "Access denied. You cancelled the authorization.", - provider_error: "The provider rejected the connection. Please try again.", - missing_params: "The connection link was incomplete. Please try again.", - invalid_state: "Invalid OAuth state. Please try again.", - expired_state: "The connection link expired. Please try again.", - state_mismatch: "OAuth state mismatch. Please try again.", - session_mismatch: - "You are signed in as a different user than the one who started the connection.", - org_not_found: "Organization not found.", - forbidden: "You need to be an admin or owner to connect an integration.", - token_exchange_failed: - "Connection failed. The provider rejected the credentials scibly sent.", - } satisfies Record; const known = INTEGRATION_CALLBACK_ERRORS.find( (code) => code === integrationError, ); - toast.error( - known ? messages[known] : "Connection failed. Please try again.", - { - id: `integration-error-${integrationError}`, - }, - ); + toast.error(known ? t.callbackErrors[known] : t.callbackErrorFallback, { + id: `integration-error-${integrationError}`, + }); url.searchParams.delete(INTEGRATION_ERROR_QUERY_PARAM); router.replace(url.pathname + url.search); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [ + t, + integrationConnected, + integrationError, + router, + trpcUtils.integration.list, + ]); } function useOrganizationMutations( @@ -177,7 +171,11 @@ export function OrgSettingsForm({ integrationConnected?: string; integrationError?: string; }) { - useOAuthResultNotifications(integrationConnected, integrationError); + useOAuthResultNotifications( + t.integrations, + integrationConnected, + integrationError, + ); const controller = useOrgSettingsController(t, org); const { dirtyFields } = controller.form.formState; return ( diff --git a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts index b1e4f20..cb5411b 100644 --- a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts +++ b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts @@ -119,8 +119,6 @@ export type OrgSettingsPage = { cancelButton: string; connectedStatus: string; notConnectedStatus: string; - workspaceLabel: string; - connectedBy: string; confirmDisconnectTitle: string; confirmDisconnectDescription: string; disconnectedSuccessfully: string; @@ -130,6 +128,22 @@ export type OrgSettingsPage = { grantsEmpty: string; grantsError: string; revokedNotice: string; + noProvidersAvailable: string; + callbackErrorFallback: string; + // Keyed by `IntegrationCallbackError`, spelled out rather than imported: + // this file is the shape of a dictionary, not of the integrations feature. + callbackErrors: { + provider_denied: string; + provider_error: string; + missing_params: string; + invalid_state: string; + expired_state: string; + state_mismatch: string; + session_mismatch: string; + org_not_found: string; + forbidden: string; + token_exchange_failed: string; + }; providers: { NOTION: string; GITHUB: string; diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json index 70e8601..8467552 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json @@ -119,8 +119,6 @@ "cancelButton": "Abbrechen", "connectedStatus": "Verbunden", "notConnectedStatus": "Nicht verbunden", - "workspaceLabel": "Workspace", - "connectedBy": "Verbunden von", "confirmDisconnectTitle": "Integration trennen?", "confirmDisconnectDescription": "Bestehende Quellen bleiben in deinen Notebooks, aber die erneute Synchronisierung funktioniert erst wieder nach erneuter Verbindung.", "disconnectedSuccessfully": "Integration getrennt.", @@ -129,6 +127,20 @@ "grantsLoading": "Zugriffe werden geladen …", "grantsEmpty": "Diese Verbindung hat noch auf nichts Zugriff erhalten.", "grantsError": "Die Zugriffe dieser Verbindung konnten nicht geladen werden.", + "noProvidersAvailable": "Keine Integrationen verfügbar.", + "callbackErrorFallback": "Die Verbindung ist fehlgeschlagen. Bitte versuche es erneut.", + "callbackErrors": { + "provider_denied": "Zugriff verweigert. Du hast die Autorisierung abgebrochen.", + "provider_error": "Der Anbieter hat die Verbindung abgelehnt. Bitte versuche es erneut.", + "missing_params": "Der Verbindungslink war unvollständig. Bitte versuche es erneut.", + "invalid_state": "Ungültiger OAuth-State. Bitte versuche es erneut.", + "expired_state": "Der Verbindungslink ist abgelaufen. Bitte versuche es erneut.", + "state_mismatch": "Der OAuth-State stimmt nicht überein. Bitte versuche es erneut.", + "session_mismatch": "Du bist als anderer Benutzer angemeldet als derjenige, der die Verbindung gestartet hat.", + "org_not_found": "Organisation nicht gefunden.", + "forbidden": "Du musst Administrator oder Inhaber sein, um eine Integration zu verbinden.", + "token_exchange_failed": "Die Verbindung ist fehlgeschlagen. Der Anbieter hat die von Scibly gesendeten Zugangsdaten abgelehnt." + }, "revokedNotice": "Diese Integration wurde auf Anbieterseite entfernt, daher wurde die Verbindung auch hier entfernt. Verbinde erneut, um fortzufahren.", "providers": { "NOTION": "Notion", diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json index d38f4c7..c7bfb31 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json @@ -119,8 +119,6 @@ "cancelButton": "Cancel", "connectedStatus": "Connected", "notConnectedStatus": "Not connected", - "workspaceLabel": "Workspace", - "connectedBy": "Connected by", "confirmDisconnectTitle": "Disconnect integration?", "confirmDisconnectDescription": "Existing sources will remain in your notebooks, but re-sync will no longer work until you reconnect.", "disconnectedSuccessfully": "Integration disconnected.", @@ -129,6 +127,20 @@ "grantsLoading": "Loading access…", "grantsEmpty": "This connection has not been given access to anything yet.", "grantsError": "Couldn't load what this connection can reach.", + "noProvidersAvailable": "No integrations available.", + "callbackErrorFallback": "Connection failed. Please try again.", + "callbackErrors": { + "provider_denied": "Access denied. You cancelled the authorization.", + "provider_error": "The provider rejected the connection. Please try again.", + "missing_params": "The connection link was incomplete. Please try again.", + "invalid_state": "Invalid OAuth state. Please try again.", + "expired_state": "The connection link expired. Please try again.", + "state_mismatch": "OAuth state mismatch. Please try again.", + "session_mismatch": "You are signed in as a different user than the one who started the connection.", + "org_not_found": "Organization not found.", + "forbidden": "You need to be an admin or owner to connect an integration.", + "token_exchange_failed": "Connection failed. The provider rejected the credentials scibly sent." + }, "revokedNotice": "This integration was removed on the provider's side, so the connection has been removed here too. Connect again to resume.", "providers": { "NOTION": "Notion", From f0a28d41c01744b697d9c2eab4f1e295d085da84 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 20:07:44 +0200 Subject: [PATCH 18/43] Key the picker's provider display by the union it is read with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PROVIDER_DISPLAY` was a `Map` behind a `PROVIDER_DISPLAY_FALLBACK`, so a page provider added without an entry rendered a nameless grey box instead of failing to build. It is now a record declared `satisfies Record`, which is the union every caller already reaches it with: `integration-buttons` maps `PAGE_INTEGRATION_PROVIDERS`, and `PagePickerContentProps.provider` is that same union. The lookup is total, so the fallback and the camelCase `providerLogoFallback` component behind it are deleted. The plan called for merging this with the settings card's `PROVIDER_ICONS` on the grounds that a GitHub source falls through to the fallback in the notebook picker. It cannot: `GITHUB` is not in `PAGE_INTEGRATION_PROVIDERS`, so it never reaches either caller, and both are typed to make that unrepresentable. With no bug to fix, merging would only force one of the two Notion marks — the radix glyph here, the brand mark in settings — to change for nothing, so the registries stay separate. Each is now exhaustive over its own correct key type, which is what the step was actually for; neither can silently diverge. The file moves from `notebook/chat/` to `notebook/sources/`, next to the two components that read it, and takes `ProviderDisplayConfig` with it — that interface only lived in `workspace/utils/constants.ts` because the old file needed JSX for the fallback logo, and its own comment said so. Verified: pnpm check 32/32, 179 files / 2659 tests green. Co-Authored-By: Claude Opus 5 --- .../notebook/chat/provider-display.tsx | 40 ------------------- .../components/integration-buttons.tsx | 11 ++--- .../sources/integration-page-picker.tsx | 8 +--- .../notebook/sources/provider-display.ts | 25 ++++++++++++ .../notebook/workspace/utils/constants.ts | 12 ------ 5 files changed, 31 insertions(+), 65 deletions(-) delete mode 100644 apps/app/src/features/notebook/chat/provider-display.tsx create mode 100644 apps/app/src/features/notebook/sources/provider-display.ts diff --git a/apps/app/src/features/notebook/chat/provider-display.tsx b/apps/app/src/features/notebook/chat/provider-display.tsx deleted file mode 100644 index d677657..0000000 --- a/apps/app/src/features/notebook/chat/provider-display.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import type React from "react"; -import type { ProviderDisplayConfig } from "../workspace/utils/constants"; - -import { NotionLogoIcon } from "@radix-ui/react-icons"; - -const providerLogoFallback: React.FC> = ( - props, -) => ( - - - - -); - -// Add an entry here for every integration provider. -// Both SourcesPanel and IntegrationPagePicker import from this map. -export const PROVIDER_DISPLAY = new Map([ - [ - "NOTION", - { - name: "Notion", - subtitle: "Browse your Notion workspace and add pages as sources", - Logo: NotionLogoIcon, - }, - ], -]); - -export const PROVIDER_DISPLAY_FALLBACK: ProviderDisplayConfig = { - name: "External Source", - subtitle: "Browse and add pages as notebook sources", - Logo: providerLogoFallback, -}; diff --git a/apps/app/src/features/notebook/sources/components/integration-buttons.tsx b/apps/app/src/features/notebook/sources/components/integration-buttons.tsx index 23c2911..c829981 100644 --- a/apps/app/src/features/notebook/sources/components/integration-buttons.tsx +++ b/apps/app/src/features/notebook/sources/components/integration-buttons.tsx @@ -12,10 +12,7 @@ import { ExternalLink } from "lucide-react"; import { PAGE_INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; -import { - PROVIDER_DISPLAY, - PROVIDER_DISPLAY_FALLBACK, -} from "../../chat/provider-display"; +import { PROVIDER_DISPLAY } from "../provider-display"; interface ConnectedProvider { provider: string; @@ -31,7 +28,8 @@ interface IntegrationButtonsProps { // Buttons come from PAGE_INTEGRATION_PROVIDERS, the connectable providers that // actually offer pages to import — a provider connected for something else has // nothing to show a page picker. PROVIDER_DISPLAY is cosmetic only and never -// gates which providers render. +// gates which providers render — it is keyed by the same union, so every +// provider iterated here has an entry. export function IntegrationButtons({ connectedProviders, t, @@ -41,8 +39,7 @@ export function IntegrationButtons({ return ( <> {PAGE_INTEGRATION_PROVIDERS.map((providerKey) => { - const meta = - PROVIDER_DISPLAY.get(providerKey) ?? PROVIDER_DISPLAY_FALLBACK; + const meta = PROVIDER_DISPLAY[providerKey]; const isConnected = connectedProviders.some( (cp) => cp.provider === providerKey, ); diff --git a/apps/app/src/features/notebook/sources/integration-page-picker.tsx b/apps/app/src/features/notebook/sources/integration-page-picker.tsx index c3432ef..d26be99 100644 --- a/apps/app/src/features/notebook/sources/integration-page-picker.tsx +++ b/apps/app/src/features/notebook/sources/integration-page-picker.tsx @@ -9,18 +9,14 @@ import { DialogTitle, } from "@scibly/ui/components/dialog"; -import { - PROVIDER_DISPLAY, - PROVIDER_DISPLAY_FALLBACK, -} from "../chat/provider-display"; import { PagePickerContent } from "./page-picker/page-picker-content"; +import { PROVIDER_DISPLAY } from "./provider-display"; export function IntegrationPagePicker({ open, ...content }: PagePickerContentProps & { open: boolean }) { - const meta = - PROVIDER_DISPLAY.get(content.provider) ?? PROVIDER_DISPLAY_FALLBACK; + const meta = PROVIDER_DISPLAY[content.provider]; return ( diff --git a/apps/app/src/features/notebook/sources/provider-display.ts b/apps/app/src/features/notebook/sources/provider-display.ts new file mode 100644 index 0000000..2c61a22 --- /dev/null +++ b/apps/app/src/features/notebook/sources/provider-display.ts @@ -0,0 +1,25 @@ +import type React from "react"; +import type { PageIntegrationProviderId } from "@/features/integrations/contracts"; + +import { NotionLogoIcon } from "@radix-ui/react-icons"; + +interface ProviderDisplayConfig { + readonly name: string; + + readonly subtitle: string; + + readonly Logo: React.ComponentType<{ className?: string }>; +} + +// Only a provider a notebook can import pages from ever reaches the picker, so +// this is keyed by that union rather than by every connectable provider. The +// `satisfies` is what makes adding a page provider fail to compile until it has +// an entry — the map this replaced was keyed by bare string behind a fallback, +// which meant a missing entry rendered a blank box instead. +export const PROVIDER_DISPLAY = { + NOTION: { + name: "Notion", + subtitle: "Browse your Notion workspace and add pages as sources", + Logo: NotionLogoIcon, + }, +} satisfies Record; diff --git a/apps/app/src/features/notebook/workspace/utils/constants.ts b/apps/app/src/features/notebook/workspace/utils/constants.ts index 92a745a..995f469 100644 --- a/apps/app/src/features/notebook/workspace/utils/constants.ts +++ b/apps/app/src/features/notebook/workspace/utils/constants.ts @@ -1,5 +1,3 @@ -import type React from "react"; - import { BookOpen, FileQuestion, @@ -15,16 +13,6 @@ import { Volume2, } from "lucide-react"; -// Interface only — the actual map + logos live in provider-display.tsx (JSX cannot be in a .ts file). - -export interface ProviderDisplayConfig { - readonly name: string; - - readonly subtitle: string; - - readonly Logo: React.ComponentType<{ className?: string }>; -} - interface StudioToolConfig { readonly id: string; readonly Icon: LucideIcon; From e9ab92e84b58a3b6da236444e68b6cecb0cf459c Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 20:24:41 +0200 Subject: [PATCH 19/43] Stop routing a file's page count through a provider's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IntegrationPageContent.pageCount` was declared on the contract and forwarded into persistence, but no provider has ever set it — the one implementation of `fetchPageContent` returns text, title and `lastEdited`. It was reading as `undefined` on every integration source. The field belongs to the PDF path, which sets its own on `parsers/types.ts` and forwards it through `ExtractedContent`. That is untouched; only the integration contract and the line copying the absent value lose it. `ExtractedContent.pageCount` stays optional, so the persisted value is the same `undefined` it always was. Verified: pnpm check 32/32, 179 files / 2659 tests green. Co-Authored-By: Claude Opus 5 --- apps/app/src/features/integrations/contracts.ts | 1 - .../sources/ingestion/extractors/integration-extractors.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index 7e78640..f914a2d 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -55,7 +55,6 @@ export interface IntegrationPage { export interface IntegrationPageContent { text: string; title: string; - pageCount?: number; lastEdited: Date; } diff --git a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts index fe64b6c..c95bd42 100644 --- a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts +++ b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts @@ -55,9 +55,11 @@ export const notionPageExtractor: SourceExtractor = { await resolveSourceConnection(source); const content = await provider.fetchPageContent(token, externalId); + // No `pageCount`: it counts the pages of a parsed file, and a provider's + // page is one page. The field stays optional on `ExtractedContent` for the + // PDF path, which is the only thing that has ever set it. return { text: content.text, - pageCount: content.pageCount, title: content.title, lastEdited: content.lastEdited, }; From dde1549a49ae1902318009345016d70c677cea93 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:07:46 +0200 Subject: [PATCH 20/43] Let the three client-side integration failures recover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BF-13: a failed disconnect cleared nothing, so the row's button latched disabled and the user was told it failed and then could not retry. The ids clear in `onSettled` now. P0.1 pinned the old behavior deliberately; its assertion flips, and the mock calls `onSettled` after `onError` the way react-query does. BF-14: both `ensureNotebook()` chains in the sources panel were `void …then(…)` with no catch, so a server error meant the picker button did nothing at all and the paste dialog silently ate the typed text. Both route through `reportSourceError`, which every other mutation here already uses. BF-12: selection was bounded by the plan's remaining source room, which is 1e9 on any paid plan, while `linkPagesSchema` caps a request at 20 pages — so "select all" on a large Notion parent built a batch the server rejected whole, surfacing as a raw Zod message. The cap becomes `MAX_LINKED_PAGES_PER_REQUEST` in `contracts.ts`, read by both the schema and the picker, and selection clamps to the lower of the two ceilings. The select-all bar's max is now the total this picker can actually reach rather than the plan limit, so the cap is visible and the counter turns amber at it. Verified: pnpm check 32/32, 179 files / 2659 tests green. Co-Authored-By: Claude Opus 5 --- .../integrations/api/integration.schema.ts | 3 ++- .../src/features/integrations/contracts.ts | 5 +++++ .../org-integrations-card.test.tsx | 13 ++++++------ .../org-integrations/use-org-integrations.ts | 8 +++++-- .../page-picker/page-picker-content.tsx | 13 ++++++++++-- .../page-picker-select-all-bar.tsx | 10 +++++---- .../notebook/sources/sources-panel.tsx | 21 ++++++++++++++++--- 7 files changed, 55 insertions(+), 18 deletions(-) diff --git a/apps/app/src/features/integrations/api/integration.schema.ts b/apps/app/src/features/integrations/api/integration.schema.ts index e4cc9f1..0f6670b 100644 --- a/apps/app/src/features/integrations/api/integration.schema.ts +++ b/apps/app/src/features/integrations/api/integration.schema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { INTEGRATION_PROVIDERS, + MAX_LINKED_PAGES_PER_REQUEST, PAGE_INTEGRATION_PROVIDERS, } from "../contracts"; @@ -56,7 +57,7 @@ export const linkPagesSchema = z.object({ }), ) .min(1) - .max(20), + .max(MAX_LINKED_PAGES_PER_REQUEST), }); export const resyncSourceSchema = z.object({ diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index f914a2d..ef2600f 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -26,6 +26,11 @@ export const PAGE_INTEGRATION_PROVIDERS = [ export type PageIntegrationProviderId = (typeof PAGE_INTEGRATION_PROVIDERS)[number]; +// One request's worth of pages. `linkPagesSchema` caps the input with it and +// the picker clamps its selection to it, so the two cannot drift into a batch +// the server rejects wholesale. +export const MAX_LINKED_PAGES_PER_REQUEST = 20; + // A provider's raw `?error=` is always mapped to `provider_denied` or `provider_error` first — it must never be echoed into the query string. export const INTEGRATION_CALLBACK_ERRORS = [ "provider_denied", diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index a38d809..ab50e0d 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -225,15 +225,16 @@ describe("what a click asks for", () => { }); }); -// Pinned as-is, not endorsed: `disconnectingId` is cleared in `onSuccess` and -// nowhere else, so a failure leaves the row's own button stuck. Reported as a -// behavior fix; this test is what stops a refactor changing it by accident. describe("a disconnect that failed", () => { - it("leaves the button of the row that failed disabled", () => { + it("says so and hands the row's button back", () => { lists([NOTION], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); disconnectMutate.mockImplementation( - (_input, options: { onError: (error: Error) => void }) => { + ( + _input, + options: { onError: (error: Error) => void; onSettled: () => void }, + ) => { options.onError(new Error("provider said no")); + options.onSettled(); }, ); const container = card(); @@ -241,8 +242,8 @@ describe("a disconnect that failed", () => { fireEvent.click(button(container, "Disconnect Notion")!); fireEvent.click(inDialog("Disconnect")!); - expect(button(container, "Disconnect Notion")?.disabled).toBe(true); expect(toastError).toHaveBeenCalledWith("provider said no"); + expect(button(container, "Disconnect Notion")?.disabled).toBe(false); }); }); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts index 31d6cca..c000f13 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -39,11 +39,15 @@ export function useOrgIntegrations({ const disconnectMutation = api.integration.disconnect.useMutation({ onSuccess: () => { toast.success(t.disconnectedSuccessfully); - setDisconnectingId(null); - setPendingDisconnect(null); void utils.integration.list.invalidate({ orgSlug }); }, onError: (err) => toast.error(err.message), + // Settled, not success: a failure used to leave the row latched disabled, + // so the user was told the disconnect failed and then could not retry it. + onSettled: () => { + setDisconnectingId(null); + setPendingDisconnect(null); + }, }); return { diff --git a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx index 1797b1f..400c0f5 100644 --- a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx +++ b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx @@ -5,6 +5,8 @@ import type { NotebookTranslations } from "../../i18n/notebook.types"; import { useState } from "react"; +import { MAX_LINKED_PAGES_PER_REQUEST } from "@/features/integrations/contracts"; + import { PagePickerBreadcrumbs } from "./page-picker-breadcrumbs"; import { PagePickerFooter } from "./page-picker-footer"; import { PagePickerList } from "./page-picker-list"; @@ -75,7 +77,7 @@ export const PagePickerBody = ({ selectablePages={selection.selectablePages} selected={selection.selected} totalSourceCount={props.totalSourceCount} - sourceLimit={props.sourceLimit} + maxTotal={props.totalSourceCount + remaining} allVisibleSelected={selection.allVisibleSelected} t={props.t} onToggleSelectAll={selection.toggleSelectAll} @@ -128,7 +130,14 @@ export function PagePickerContent({ t, onLinked, }; - const remaining = Math.max(0, sourceLimit - totalSourceCount); + // Two ceilings, and the lower one wins: how many sources the plan still has + // room for, and how many pages one link request may carry. On a paid plan + // the first is effectively unlimited, so without the second "select all" on + // a large parent used to build a batch the server rejected whole. + const remaining = Math.min( + Math.max(0, sourceLimit - totalSourceCount), + MAX_LINKED_PAGES_PER_REQUEST, + ); const [query, setQuery] = useState(""); const navigation = usePagePickerNavigation(setQuery); const pageState = usePagePickerPages(orgSlug, provider, query, navigation); diff --git a/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx index d5bd455..48d7a6c 100644 --- a/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx +++ b/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx @@ -14,7 +14,9 @@ interface PagePickerSelectAllBarProps { selected: Set; totalSourceCount: number; - sourceLimit: number; + // Not the plan's source limit: the highest total this picker can actually + // reach, which is the lower of the plan's room and one request's page cap. + maxTotal: number; allVisibleSelected: boolean; t: T; onToggleSelectAll: () => void; @@ -24,7 +26,7 @@ export function PagePickerSelectAllBar({ selectablePages, selected, totalSourceCount, - sourceLimit, + maxTotal, allVisibleSelected, t, onToggleSelectAll, @@ -59,14 +61,14 @@ export function PagePickerSelectAllBar({ = sourceLimit + projectedTotal >= maxTotal ? "text-amber-600 dark:text-amber-400" : "text-neutral-400", )} > {t.pagesSelected .replace("{count}", String(projectedTotal)) - .replace("{max}", String(sourceLimit))} + .replace("{max}", String(maxTotal))}
); diff --git a/apps/app/src/features/notebook/sources/sources-panel.tsx b/apps/app/src/features/notebook/sources/sources-panel.tsx index 7d3b5b0..b5a5bfe 100644 --- a/apps/app/src/features/notebook/sources/sources-panel.tsx +++ b/apps/app/src/features/notebook/sources/sources-panel.tsx @@ -41,6 +41,7 @@ function useIntegrationPicker( orgSlug: string, atLimit: boolean, ensureNotebook: () => Promise, + entitlementCopy: NotebookTranslations["sources"]["entitlement"], ) { const [pickerState, setPickerState] = useState<{ provider: PageIntegrationProviderId; @@ -69,9 +70,15 @@ function useIntegrationPicker( }, [pickerState, sources]); const open = (provider: PageIntegrationProviderId) => { if (atLimit) return; - void ensureNotebook().then((notebookId) => - setPickerState({ provider, notebookId }), - ); + void ensureNotebook() + .then((notebookId) => setPickerState({ provider, notebookId })) + .catch((error) => + reportSourceError( + "[SourcesPanel] Could not open the page picker:", + error, + entitlementCopy, + ), + ); }; return { pickerState, @@ -156,6 +163,13 @@ export const SourcesPanelPresentation = ( .ensureNotebook() .then((notebookId) => props.addText.mutate({ notebookId, name, content }), + ) + .catch((error) => + reportSourceError( + "[SourcesPanel] Add text failed:", + error, + props.t.sources.entitlement, + ), ); }} isLoading={props.addText.isPending} @@ -245,6 +259,7 @@ export function SourcesPanel({ t, notebookId, orgSlug }: SourcesPanelProps) { orgSlug, uploadsDisabled, ensureNotebook, + t.sources.entitlement, ); return ( From a8bb3feedce06aea2dd359394dff6172ad387ee0 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:13:34 +0200 Subject: [PATCH 21/43] Refuse a url whose scheme the browser would execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `z.url()` only asks whether `new URL()` parses the string, so `javascript:alert(1)`, `data:text/html,...` and `vbscript:x` all passed it — and several of those values are stored and later rendered as an `href` or an `src`. `httpsUrl()` in the new `@scibly/schemas/common` is the shared https-only check; it now backs the linked-page urls, the org logo, the course thumbnail and the notebook media urls. Schema-only validation would leave rows written before today live, so the anchor in `source-list-item-actions.tsx` re-checks `externalUrl` before rendering it. That is the half that protects existing data. Left alone on purpose: the BYOAI `baseUrl` and the web-fetch tool argument (self-hosters point those at `http://localhost`, and neither is rendered), and the profile image, which already parses the protocol and accepts http by design. Co-Authored-By: Claude Opus 5 --- .../api/integration.schema.test.ts | 32 +++++++++++++++++++ .../integrations/api/integration.schema.ts | 5 +-- .../notebook/media/tools/image-schemas.ts | 5 +-- .../components/source-list-item-actions.tsx | 12 +++++-- .../content/course/course-validation.ts | 3 +- packages/schemas/src/schema/common/index.ts | 15 +++++++++ .../schemas/src/schema/organization/index.ts | 6 ++-- 7 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 apps/app/src/features/integrations/api/integration.schema.test.ts create mode 100644 packages/schemas/src/schema/common/index.ts diff --git a/apps/app/src/features/integrations/api/integration.schema.test.ts b/apps/app/src/features/integrations/api/integration.schema.test.ts new file mode 100644 index 0000000..1ad1a63 --- /dev/null +++ b/apps/app/src/features/integrations/api/integration.schema.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { linkPageSchema } from "./integration.schema"; + +// The url a provider hands back is stored and later rendered as an `href`, so +// the schema is the first of the two places that has to reject a scheme the +// browser would execute. (The second is the anchor in +// `source-list-item-actions.tsx`, which guards rows written before this.) +describe("linkPageSchema pageUrl", () => { + const link = (pageUrl: string) => + linkPageSchema.safeParse({ + notebookId: "n1", + orgSlug: "acme", + provider: "NOTION", + pageId: "p1", + pageTitle: "Roadmap", + pageUrl, + }).success; + + it("takes an https page", () => { + expect(link("https://www.notion.so/Roadmap-abc123")).toBe(true); + }); + + it.each([ + "javascript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "http://www.notion.so/Roadmap-abc123", + ])("refuses %s", (pageUrl) => { + expect(link(pageUrl)).toBe(false); + }); +}); diff --git a/apps/app/src/features/integrations/api/integration.schema.ts b/apps/app/src/features/integrations/api/integration.schema.ts index 0f6670b..65ac2f3 100644 --- a/apps/app/src/features/integrations/api/integration.schema.ts +++ b/apps/app/src/features/integrations/api/integration.schema.ts @@ -1,3 +1,4 @@ +import { httpsUrl } from "@scibly/schemas/common"; import { orgSlugInput } from "@scibly/schemas/organization"; import { z } from "zod"; @@ -41,7 +42,7 @@ export const linkPageSchema = z.object({ provider: pageProviderInput, pageId: z.string(), pageTitle: z.string(), - pageUrl: z.string().url(), + pageUrl: httpsUrl(), }); export const linkPagesSchema = z.object({ @@ -53,7 +54,7 @@ export const linkPagesSchema = z.object({ z.object({ id: z.string(), title: z.string(), - url: z.string().url(), + url: httpsUrl(), }), ) .min(1) diff --git a/apps/app/src/features/notebook/media/tools/image-schemas.ts b/apps/app/src/features/notebook/media/tools/image-schemas.ts index 5b0c982..37d17b1 100644 --- a/apps/app/src/features/notebook/media/tools/image-schemas.ts +++ b/apps/app/src/features/notebook/media/tools/image-schemas.ts @@ -1,3 +1,4 @@ +import { httpsUrl } from "@scibly/schemas/common"; import { z } from "zod"; export const GENERATED_IMAGE_PAGE_SIZE = 12; @@ -76,7 +77,7 @@ export const generateImageInputSchema = z.object({ export const generateImageOutputSchema = z.object({ imageId: z.string(), - url: z.string().url(), + url: httpsUrl(), prompt: z.string(), alt: z.string(), mediaType: z.literal("image/webp"), @@ -136,7 +137,7 @@ export const listNotebookMediaInputSchema = z.object({ const listNotebookMediaItemSchema = z.object({ id: z.string(), - url: z.url(), + url: httpsUrl(), alt: z.string(), prompt: z.string(), width: z.number().int().positive().optional(), diff --git a/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx b/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx index 51ecd90..96dedee 100644 --- a/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx +++ b/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx @@ -4,6 +4,7 @@ import type { ReactElement } from "react"; import type { RouterOutputs } from "@/shared/api/trpc/client"; import type { NotebookTranslations } from "../../i18n/notebook.types"; +import { httpsUrl } from "@scibly/schemas/common"; import { Download, ExternalLink, @@ -167,6 +168,13 @@ export function SourceListItemActions({ onResync, onDelete, }: SourceListItemActionsProps) { + // Rows stored before the link schemas were tightened were never + // protocol-checked, so a `javascript:` url already in the database would + // otherwise land straight in this href. + const externalHref = httpsUrl().safeParse(item.externalUrl).success + ? item.externalUrl + : null; + return (
@@ -199,10 +207,10 @@ export function SourceListItemActions({ onClick={onRetry} /> - {item.externalUrl ? ( + {externalHref ? ( event.stopPropagation()} diff --git a/apps/app/src/shared/content/course/course-validation.ts b/apps/app/src/shared/content/course/course-validation.ts index 2411de5..95517ad 100644 --- a/apps/app/src/shared/content/course/course-validation.ts +++ b/apps/app/src/shared/content/course/course-validation.ts @@ -1,4 +1,5 @@ import { CourseMode, LessonIcon } from "@scibly/db/enums"; +import { httpsUrl } from "@scibly/schemas/common"; import { z } from "zod"; import { lessonDescriptionSchema } from "@/shared/content/learning/lesson-description"; @@ -36,7 +37,7 @@ export const updateCourseUpdatesSchema = z.object({ description: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), - thumbnail: z.string().url().nullable().optional(), + thumbnail: httpsUrl().nullable().optional(), passingScorePct: z.number().int().min(0).max(100).nullable().optional(), maxTries: z.number().int().min(1).nullable().optional(), allowAnonymous: z.boolean().optional(), diff --git a/packages/schemas/src/schema/common/index.ts b/packages/schemas/src/schema/common/index.ts new file mode 100644 index 0000000..9982a40 --- /dev/null +++ b/packages/schemas/src/schema/common/index.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +/** + * A URL safe to put in an `href`, an `src`, or a fetch: https only. + * + * Zod's own `.url()` only asks whether `new URL()` parses the string, which + * accepts any scheme — `javascript:`, `data:` and `vbscript:` all pass it. So + * it is a shape check, not a safety check. Use this anywhere a URL is stored, + * rendered as a link or an image, or fetched. + * + * The message is a parameter because several call sites already pass their own + * copy; the default covers the rest. + */ +export const httpsUrl = (message = "Must be a valid https:// URL") => + z.url({ protocol: /^https$/, message }); diff --git a/packages/schemas/src/schema/organization/index.ts b/packages/schemas/src/schema/organization/index.ts index aa821b7..83c34ce 100644 --- a/packages/schemas/src/schema/organization/index.ts +++ b/packages/schemas/src/schema/organization/index.ts @@ -1,5 +1,7 @@ import { z } from "zod/v4"; +import { httpsUrl } from "../common"; + /** The org a procedure acts on, addressed the way the URL addresses it. */ export const orgSlugInput = z.object({ orgSlug: z.string() }); @@ -13,7 +15,7 @@ export const createOrganizationSchema = z.object({ /^[a-z0-9-]+$/, "Slug may only contain lowercase letters, numbers and hyphens", ), - logo: z.string().url().optional(), + logo: httpsUrl().optional(), }); export const updateOrganizationSchema = z.object({ @@ -28,5 +30,5 @@ export const updateOrganizationSchema = z.object({ "Slug may only contain lowercase letters, numbers and hyphens", ) .optional(), - logo: z.string().url().optional(), + logo: httpsUrl().optional(), }); From 916132c3aa6dc81ee7936c3f108bcecb0f379cfd Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:18:02 +0200 Subject: [PATCH 22/43] Charge page linking and resync to the indexing rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry, upload and replace all reserve a slot from `source.ingest` before extraction runs. Linking a page and resyncing one went straight to `ingestOrRefreshSource`, so the one entry point that can start twenty extractions in a single call was the one nobody counted. `boundedLink` puts the whole batch on that same ceiling — one slot per request however many pages it carries, since the schema already caps the batch, and refunded when every page turned out to be linked already. Co-Authored-By: Claude Opus 5 --- .../api/integration-page-procedures.ts | 58 ++++++++++--------- apps/app/src/features/notebook/server.ts | 1 + .../sources/api/bounded-ingest.test.ts | 28 ++++++++- .../notebook/sources/api/bounded-ingest.ts | 36 ++++++++++-- 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/apps/app/src/features/integrations/api/integration-page-procedures.ts b/apps/app/src/features/integrations/api/integration-page-procedures.ts index b7ba9a2..7a46fcd 100644 --- a/apps/app/src/features/integrations/api/integration-page-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-page-procedures.ts @@ -2,7 +2,8 @@ import { AppError } from "@scibly/api/application-error"; import { protectedProcedure } from "@scibly/api/trpc"; import { - ingestOrRefreshSource, + boundedIngest, + boundedLink, linkNotebookPages, resolveNotebook, resolveOwnedNotebookSource, @@ -49,43 +50,48 @@ export const integrationPageProcedures = { organization.id, input.provider, ); - const { sourceIds, skipped } = await linkNotebookPages({ - notebookId: input.notebookId, - organizationId: organization.id, - actorId: userId, - provider: input.provider, - connectionId: connection.id, - pages: input.pages, - }); + const { sourceIds, skipped } = await boundedLink(userId, () => + linkNotebookPages({ + notebookId: input.notebookId, + organizationId: organization.id, + actorId: userId, + provider: input.provider, + connectionId: connection.id, + pages: input.pages, + }), + ); return { sourceIds, skipped }; }), linkPage: protectedProcedure .input(linkPageSchema) .mutation(async ({ input, ctx }) => { + const userId = ctx.session.user.id; const { organization } = await resolveLinkedNotebook( input.orgSlug, input.notebookId, - ctx.session.user.id, + userId, ); const { connection } = await resolveConnectionRow( organization.id, input.provider, ); - const result = await linkNotebookPages({ - notebookId: input.notebookId, - organizationId: organization.id, - actorId: ctx.session.user.id, - provider: input.provider, - connectionId: connection.id, - pages: [ - { - id: input.pageId, - title: input.pageTitle, - url: input.pageUrl, - }, - ], - }); + const result = await boundedLink(userId, () => + linkNotebookPages({ + notebookId: input.notebookId, + organizationId: organization.id, + actorId: userId, + provider: input.provider, + connectionId: connection.id, + pages: [ + { + id: input.pageId, + title: input.pageTitle, + url: input.pageUrl, + }, + ], + }), + ); const sourceId = result.sourceIds[0]; const ingestion = result.ingestions[0]; if (!sourceId || !ingestion) { @@ -123,9 +129,7 @@ export const integrationPageProcedures = { "This source's integration was disconnected. Reconnect the integration and re-link the page to resume syncing.", }); } - const ingestion = await ingestOrRefreshSource(source.id, { - actorId: userId, - }); + const ingestion = await boundedIngest(userId, source.id); return { sourceId: source.id, ingestion }; }), }; diff --git a/apps/app/src/features/notebook/server.ts b/apps/app/src/features/notebook/server.ts index c9dd8c3..dd390c5 100644 --- a/apps/app/src/features/notebook/server.ts +++ b/apps/app/src/features/notebook/server.ts @@ -10,6 +10,7 @@ export { persistMessages, } from "./chat/server/messages"; export { buildImageNotebookTools } from "./media/tools/image-notebook-tools"; +export { boundedIngest, boundedLink } from "./sources/api/bounded-ingest"; export { assertOrgCanAffordIngest } from "./sources/ingestion/ingest-funding"; export { ingestOrRefreshSource, diff --git a/apps/app/src/features/notebook/sources/api/bounded-ingest.test.ts b/apps/app/src/features/notebook/sources/api/bounded-ingest.test.ts index dae16ad..c32cabb 100644 --- a/apps/app/src/features/notebook/sources/api/bounded-ingest.test.ts +++ b/apps/app/src/features/notebook/sources/api/bounded-ingest.test.ts @@ -16,9 +16,9 @@ vi.mock("@scibly/db", async (importOriginal) => ({ ...(await importOriginal()), db, })); -vi.mock("@/features/notebook/server", () => ingestion); +vi.mock("../ingestion/ingest-source", () => ingestion); -const { boundedIngest } = await import("./bounded-ingest"); +const { boundedIngest, boundedLink } = await import("./bounded-ingest"); const AUTHOR = "user-author"; const SOURCE = "src-syllabus"; @@ -92,3 +92,27 @@ describe("what an indexing request costs its author", () => { expect(ingestion.ingestOrRefreshSource).not.toHaveBeenCalled(); }); }); + +describe("what a page-link request costs its author", () => { + it("L1: one batch spends one slot, however many pages it linked", async () => { + await boundedLink(AUTHOR, () => + Promise.resolve({ sourceIds: ["a", "b", "c"] }), + ); + + expect(spent()).toBe(1); + }); + + it("L2: a batch whose pages were all linked already hands its slot back", async () => { + await boundedLink(AUTHOR, () => Promise.resolve({ sourceIds: [] })); + + expect(spent()).toBe(0); + }); + + it("L3: linking draws on the same hourly slots as indexing", async () => { + live.setSpent(AUTHOR, ENDPOINT, HOURLY_SLOTS); + + await expect( + boundedLink(AUTHOR, () => Promise.resolve({ sourceIds: ["a"] })), + ).rejects.toThrow("Too many indexing requests"); + }); +}); diff --git a/apps/app/src/features/notebook/sources/api/bounded-ingest.ts b/apps/app/src/features/notebook/sources/api/bounded-ingest.ts index aadbc57..d5e7bd9 100644 --- a/apps/app/src/features/notebook/sources/api/bounded-ingest.ts +++ b/apps/app/src/features/notebook/sources/api/bounded-ingest.ts @@ -1,22 +1,46 @@ import { withRateLimit } from "@scibly/api/rate-limit"; import { db } from "@scibly/db"; -import { ingestOrRefreshSource } from "@/features/notebook/server"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; -// Retry, upload confirmation, and replacement confirmation all trigger the same extraction work, so they share one rate-limit ceiling instead of three. +import { ingestOrRefreshSource } from "../ingestion/ingest-source"; + +// Retry, upload confirmation, replacement confirmation, page linking and resync +// all trigger the same extraction work, so they share one rate-limit ceiling +// instead of five. +const INGEST_LIMIT = { + endpoint: "source.ingest", + maxPerWindow: 30, + tooManyRequestsMessage: + "Too many indexing requests. Please try again in a bit.", +} as const; + export function boundedIngest(userId: string, sourceId: string) { return withRateLimit( { db, identifier: userId, - endpoint: "source.ingest", - maxPerWindow: 30, - tooManyRequestsMessage: - "Too many indexing requests. Please try again in a bit.", + ...INGEST_LIMIT, refundIf: (result) => result.status === SOURCE_STATUS.PROCESSING, }, () => ingestOrRefreshSource(sourceId, { actorId: userId }), ); } + +// A linked batch costs one slot however many pages it carries: the schema caps +// the batch, and a request that linked nothing did no extraction to pay for. +export function boundedLink( + userId: string, + link: () => Promise, +) { + return withRateLimit( + { + db, + identifier: userId, + ...INGEST_LIMIT, + refundIf: (result: T) => result.sourceIds.length === 0, + }, + link, + ); +} From ac76d78c8c88f817dd185e6c0a71dd0ecb9f7531 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:25:07 +0200 Subject: [PATCH 23/43] Let a detach run inside its caller's transaction Behavior-preserving on its own: the client defaults to `db`, so every existing call keeps writing exactly as it did. The three callers that pair a detach with a delete or an upsert can now commit both halves together. Co-Authored-By: Claude Opus 5 --- .../src/features/integrations/server/detach-sources.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/app/src/features/integrations/server/detach-sources.ts b/apps/app/src/features/integrations/server/detach-sources.ts index 335c67b..5959de3 100644 --- a/apps/app/src/features/integrations/server/detach-sources.ts +++ b/apps/app/src/features/integrations/server/detach-sources.ts @@ -1,15 +1,19 @@ import type { IntegrationProviderId } from "../contracts"; -import { db } from "@scibly/db"; +import { db, type Prisma } from "@scibly/db"; type DetachReason = "disconnected" | "workspace_changed"; +// Every caller detaches as one half of a pair — the other half deletes or +// replaces the connection row — so the transaction client is a parameter and +// the two halves commit together. export async function detachSourcesFromConnection( connectionId: string, provider: IntegrationProviderId, reason: DetachReason, + tx: Prisma.TransactionClient | typeof db = db, ) { - await db.notebookSource.updateMany({ + await tx.notebookSource.updateMany({ where: { integrationId: connectionId }, data: { integrationId: null, From e536faa50c495e7fc21a2c7890934ac428563051 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:25:07 +0200 Subject: [PATCH 24/43] Disconnect an integration in one transaction The detach and the delete were two independent writes. A failure between them left the connection row listed as live with none of its sources attached to it, and a retry then had nothing left to warn the author about. Co-Authored-By: Claude Opus 5 --- .../api/integration-connection-procedures.ts | 20 +++++++---- .../api/integration-connections.test.ts | 33 +++++++++++-------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/apps/app/src/features/integrations/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index f9b039e..db52d72 100644 --- a/apps/app/src/features/integrations/api/integration-connection-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-connection-procedures.ts @@ -134,13 +134,21 @@ export const integrationConnectionProcedures = { select: { id: true }, }); + // One transaction: a detach that committed without its delete would + // leave the connection listed as live with none of its sources attached, + // and the next disconnect would have nothing left to warn on. if (connection) { - await detachSourcesFromConnection( - connection.id, - input.provider, - "disconnected", - ); - await db.integrationConnection.delete({ where: { id: connection.id } }); + await db.$transaction(async (tx) => { + await detachSourcesFromConnection( + connection.id, + input.provider, + "disconnected", + tx, + ); + await tx.integrationConnection.delete({ + where: { id: connection.id }, + }); + }); } return { success: true }; }), diff --git a/apps/app/src/features/integrations/api/integration-connections.test.ts b/apps/app/src/features/integrations/api/integration-connections.test.ts index aa7da40..e46a267 100644 --- a/apps/app/src/features/integrations/api/integration-connections.test.ts +++ b/apps/app/src/features/integrations/api/integration-connections.test.ts @@ -20,18 +20,24 @@ import { // Real tRPC caller over the real router, so input validation runs for real. // `db` and `resolveOrg` are mocked; `detachSourcesFromConnection` is not. -const db = vi.hoisted(() => ({ - integrationConnection: { - findUnique: vi.fn(), - findMany: vi.fn(), - delete: vi.fn(), - create: vi.fn(), - upsert: vi.fn(), - }, - notebookSource: { updateMany: vi.fn(), deleteMany: vi.fn() }, - notebookSourceChunk: { deleteMany: vi.fn() }, - scene: { deleteMany: vi.fn() }, -})); +const db = vi.hoisted(() => { + const client = { + integrationConnection: { + findUnique: vi.fn(), + findMany: vi.fn(), + delete: vi.fn(), + create: vi.fn(), + upsert: vi.fn(), + }, + notebookSource: { updateMany: vi.fn(), deleteMany: vi.fn() }, + notebookSourceChunk: { deleteMany: vi.fn() }, + scene: { deleteMany: vi.fn() }, + // The doubled client is handed straight back, so a write made through the + // transaction is still observed on the same spy. + $transaction: vi.fn((run: (tx: unknown) => unknown) => run(client)), + }; + return client; +}); const resolveOrg = vi.hoisted(() => vi.fn()); vi.mock("@scibly/db", () => ({ db })); @@ -40,7 +46,8 @@ vi.mock("@/features/organizations/server", () => ({ resolveOrg, })); vi.mock("@/features/notebook/server", () => ({ - ingestOrRefreshSource: vi.fn(), + boundedIngest: vi.fn(), + boundedLink: vi.fn((_userId: string, link: () => unknown) => link()), linkNotebookPages: vi.fn(), resolveNotebook: vi.fn(), resolveOwnedNotebookSource: vi.fn(), From ae78dcefc5b31477481b3e4b8fad1c744c451f59 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:25:19 +0200 Subject: [PATCH 25/43] Confirm an installation is gone before throwing the connection away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single 404 from the token-minting endpoint deleted the connection and detached every source hanging off it — unrecoverable except by reconnecting and re-linking every page, and decided in the middle of an unrelated read. The provider now asks the app whether the installation is still listed, and anything short of a second 404 stays an ordinary failure that changes nothing. When it is really gone, the detach and the delete commit together, as they already do for a disconnect pressed here. Co-Authored-By: Claude Opus 5 --- .../server/connection-token.test.ts | 33 ++++++++++++++++--- .../integrations/server/connection-token.ts | 11 +++++-- .../server/providers/github/provider.test.ts | 16 +++++++++ .../server/providers/github/provider.ts | 27 +++++++++++++-- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/apps/app/src/features/integrations/server/connection-token.test.ts b/apps/app/src/features/integrations/server/connection-token.test.ts index b48db85..98af80f 100644 --- a/apps/app/src/features/integrations/server/connection-token.test.ts +++ b/apps/app/src/features/integrations/server/connection-token.test.ts @@ -2,10 +2,24 @@ import type { ConnectionCredential } from "./connection-token"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const db = vi.hoisted(() => ({ - integrationConnection: { deleteMany: vi.fn() }, - notebookSource: { updateMany: vi.fn() }, -})); +// `$transaction` hands the same doubled client back, so the two writes are +// still observed individually — what is under test is that both happen, and +// that they happen through the transaction. +const db: { + integrationConnection: { deleteMany: ReturnType }; + notebookSource: { updateMany: ReturnType }; + $transaction: ReturnType; +} = vi.hoisted(() => { + const client = { + integrationConnection: { deleteMany: vi.fn() }, + notebookSource: { updateMany: vi.fn() }, + $transaction: vi.fn(), + }; + client.$transaction.mockImplementation((run: (tx: unknown) => unknown) => + run(client), + ); + return client; +}); const registry = vi.hoisted(() => ({ getProvider: vi.fn() })); const crypto = vi.hoisted(() => ({ decryptApiKey: vi.fn() })); @@ -33,6 +47,9 @@ function installationProvider(mintAccessToken: () => Promise) { beforeEach(() => { vi.clearAllMocks(); + db.$transaction.mockImplementation((run: (tx: unknown) => unknown) => + run(db), + ); }); describe("K1 the credential a connection turns into", () => { @@ -90,6 +107,14 @@ describe("K2 a connection revoked on the provider's side", () => { expect(args.data.warning).toMatch(/GITHUB integration was disconnected/); }); + it("K2 gives up both halves together if either fails", async () => { + db.$transaction.mockRejectedValue(new Error("deadlock")); + + await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow("deadlock"); + expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(db.integrationConnection.deleteMany).not.toHaveBeenCalled(); + }); + it("K2 says so in its own application code, so the client can explain", async () => { await expect(resolveConnectionToken(INSTALLED)).rejects.toMatchObject({ applicationCode: "integration.revoked", diff --git a/apps/app/src/features/integrations/server/connection-token.ts b/apps/app/src/features/integrations/server/connection-token.ts index f9ece77..f762ef1 100644 --- a/apps/app/src/features/integrations/server/connection-token.ts +++ b/apps/app/src/features/integrations/server/connection-token.ts @@ -44,8 +44,15 @@ async function forgetRevokedConnection( connection: ConnectionCredential, providerId: IntegrationProviderId, ): Promise { - await detachSourcesFromConnection(connection.id, providerId, "disconnected"); - await db.integrationConnection.deleteMany({ where: { id: connection.id } }); + await db.$transaction(async (tx) => { + await detachSourcesFromConnection( + connection.id, + providerId, + "disconnected", + tx, + ); + await tx.integrationConnection.deleteMany({ where: { id: connection.id } }); + }); } export async function resolveConnectionToken( diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index 895f718..4f0a955 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -217,6 +217,22 @@ describe("GH5 the minted token", () => { ).rejects.toBeInstanceOf(IntegrationRevokedError); }); + it("GH5 asks the app whether the installation is really gone before saying so", async () => { + // Acting on "revoked" deletes the connection and detaches every source + // hanging off it, so a mint that 404s while the installation is still + // listed stays an ordinary failure. + fetchMock + .mockResolvedValueOnce(failed(404, { message: "Not Found" })) + .mockResolvedValueOnce(ok({ id: 42, account: { id: 7, login: "acme" } })); + + await expect( + new GitHubProvider().mintAccessToken("42"), + ).rejects.not.toBeInstanceOf(IntegrationRevokedError); + expect(lastRequest().url).toBe( + "https://api.github.com/app/installations/42", + ); + }); + it("GH5 leaves every other refusal to the caller as an ordinary failure", async () => { fetchMock.mockResolvedValue(failed(403, { message: "Forbidden" })); diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts index e2f712d..e317692 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -3,6 +3,7 @@ import type { IntegrationGrant, } from "../../../contracts"; import type { ConnectCallbackParams } from "../../base-provider"; +import type { GitHubAppConfig } from "./app-auth"; import { IntegrationProvider, @@ -16,6 +17,18 @@ import { readGitHubAppConfig, } from "./app-auth"; +async function installationIsGone( + config: GitHubAppConfig, + installationId: string, +): Promise { + try { + await fetchInstallation(config, installationId); + return false; + } catch (error) { + return error instanceof GitHubRequestError && error.status === 404; + } +} + // GitHub is connected by installing an app on an account, not by an OAuth // grant, so what comes back is an installation id. The workspace behind it is // the account id — not the installation id, which a reinstall replaces — so @@ -56,12 +69,20 @@ export class GitHubProvider extends IntegrationProvider { // GitHub answers 404 for an installation that no longer exists, which is // what an uninstall on its side looks like from here — the id we hold is - // simply gone, and no token will ever be minted from it again. + // simply gone, and no token will ever be minted from it again. Acting on + // that throws the connection away and detaches every source hanging off it, + // so one 404 from one endpoint is not enough to go on: the app is asked + // directly whether the installation is still there, and anything short of a + // second 404 stays an ordinary failure that changes nothing. async mintAccessToken(installationId: string): Promise { + const config = readGitHubAppConfig(); try { - return await mintInstallationToken(readGitHubAppConfig(), installationId); + return await mintInstallationToken(config, installationId); } catch (error) { - if (error instanceof GitHubRequestError && error.status === 404) { + if (!(error instanceof GitHubRequestError) || error.status !== 404) { + throw error; + } + if (await installationIsGone(config, installationId)) { throw new IntegrationRevokedError(this.providerId); } throw error; From 26dc9453f8343dba9791f80f6411f615464c5bae Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:25:19 +0200 Subject: [PATCH 26/43] Decide a reconnect against the row it is replacing The callback read the existing connection, then went off to the provider, then acted on what it had read. Two callbacks landing together both saw the pre-connect workspace and each decided independently whether to detach. The read now happens inside the transaction that does the writing; the provider round trip stays outside it, since holding a row lock across a network call holds it for as long as the provider takes. The same write clears the backoff: a reconnect is the answer to whatever the polls were failing on, so the connection is eligible on the next chain rather than hours from now. A workspace change also drops the watermark, which belonged to a workspace this connection no longer points at. Co-Authored-By: Claude Opus 5 --- .../server/connect-callback.test.ts | 81 +++++++++++++++++-- .../integrations/server/connect-callback.ts | 72 ++++++++++------- 2 files changed, 118 insertions(+), 35 deletions(-) diff --git a/apps/app/src/features/integrations/server/connect-callback.test.ts b/apps/app/src/features/integrations/server/connect-callback.test.ts index 5aa52ac..fe38857 100644 --- a/apps/app/src/features/integrations/server/connect-callback.test.ts +++ b/apps/app/src/features/integrations/server/connect-callback.test.ts @@ -23,14 +23,20 @@ type UpsertArgs = Pick< "where" | "create" | "update" >; -const db = vi.hoisted(() => ({ - organization: { findUnique: vi.fn() }, - integrationConnection: { - findUnique: vi.fn(), - upsert: vi.fn<(args: UpsertArgs) => Promise>(), - }, - notebookSource: { updateMany: vi.fn() }, -})); +const db = vi.hoisted(() => { + const client = { + organization: { findUnique: vi.fn() }, + integrationConnection: { + findUnique: vi.fn(), + upsert: vi.fn<(args: UpsertArgs) => Promise>(), + }, + notebookSource: { updateMany: vi.fn() }, + // The doubled client is handed straight back, so the reads and writes the + // callback makes inside the transaction land on the same spies. + $transaction: vi.fn((run: (tx: unknown) => unknown) => run(client)), + }; + return client; +}); const getSession = vi.hoisted(() => vi.fn()); const requireOrgMember = vi.hoisted(() => vi.fn()); @@ -112,6 +118,9 @@ beforeEach(() => { getSession.mockResolvedValue({ user: { id: "admin-1" } }); requireOrgMember.mockResolvedValue({ role: "admin" }); db.organization.findUnique.mockResolvedValue({ id: "org-1" }); + db.$transaction.mockImplementation((run: (tx: unknown) => unknown) => + run(db), + ); db.integrationConnection.findUnique.mockResolvedValue(null); db.integrationConnection.upsert.mockResolvedValue({}); db.notebookSource.updateMany.mockResolvedValue({ count: 0 }); @@ -369,6 +378,62 @@ describe("LS what is stored", () => { expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); }); + it("LS4 clears the backoff the failing polls built up", async () => { + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-1", + workspaceId: "workspace-1", + }); + + await callback({ code: "auth-code", state: state() }); + const { create, update } = upserted(); + + expect(update).toMatchObject({ + consecutiveFailures: 0, + nextPollAfter: null, + }); + expect(create).toMatchObject({ + consecutiveFailures: 0, + nextPollAfter: null, + }); + }); + + it("LS4 drops the watermark when the workspace changed, and keeps it when it did not", async () => { + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-1", + workspaceId: "workspace-old", + }); + await callback({ code: "auth-code", state: state() }); + expect(upserted().update).toMatchObject({ lastPolledAt: null }); + + vi.clearAllMocks(); + db.$transaction.mockImplementation((run: (tx: unknown) => unknown) => + run(db), + ); + db.integrationConnection.upsert.mockResolvedValue({}); + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-1", + workspaceId: "workspace-1", + }); + await callback({ code: "auth-code", state: state() }); + expect(upserted().update).not.toHaveProperty("lastPolledAt"); + }); + + it("LS4 reads what it is replacing inside the transaction that replaces it", async () => { + db.integrationConnection.findUnique.mockResolvedValue({ + id: "conn-1", + workspaceId: "workspace-old", + }); + + await callback({ code: "auth-code", state: state() }); + + expect(db.$transaction).toHaveBeenCalledTimes(1); + const opened = db.$transaction.mock.invocationCallOrder[0] ?? 0; + expect( + db.integrationConnection.findUnique.mock.invocationCallOrder[0], + ).toBeGreaterThan(opened); + expect(completeConnect.mock.invocationCallOrder[0]).toBeLessThan(opened); + }); + it("LS5 credits whoever authorised it, on a first connect and on a refresh", async () => { getSession.mockResolvedValue({ user: { id: "admin-2" } }); diff --git a/apps/app/src/features/integrations/server/connect-callback.ts b/apps/app/src/features/integrations/server/connect-callback.ts index 84dc2d9..04f6d02 100644 --- a/apps/app/src/features/integrations/server/connect-callback.ts +++ b/apps/app/src/features/integrations/server/connect-callback.ts @@ -192,32 +192,17 @@ async function completeAndPersistConnection( ) { const redirectUri = routes.app.api.integrations.callback(callback.provider); - const existing = await db.integrationConnection.findUnique({ - where: { - organizationId_provider: { - organizationId, - provider: callback.provider, - }, - }, - select: { id: true, workspaceId: true }, - }); - + // The provider round trip stays outside the transaction: it is a network + // call, and holding a row lock across it would be a lock held for as long as + // the provider feels like taking. const credential = await getProvider(callback.provider).completeConnect( callback.params, redirectUri, ); - if ( - existing?.workspaceId && - credential.workspaceId && - existing.workspaceId !== credential.workspaceId - ) { - await detachSourcesFromConnection( - existing.id, - callback.provider, - "workspace_changed", - ); - } + const where = { + organizationId_provider: { organizationId, provider: callback.provider }, + }; const connectionData = { ...credentialColumns(credential), @@ -225,17 +210,50 @@ async function completeAndPersistConnection( workspaceName: credential.workspaceName ?? null, connectedByUserId: callback.connectedByUserId, + + // A reconnect is the answer to whatever the polls were failing on, so the + // backoff the failures built up does not outlive it: the connection is + // eligible again on the next chain rather than hours from now. + consecutiveFailures: 0, + nextPollAfter: null, }; - await db.integrationConnection.upsert({ - where: { - organizationId_provider: { + await db.$transaction(async (tx) => { + // Read inside the transaction, not before the provider call: two callbacks + // landing together would otherwise both see the pre-connect workspace and + // decide independently whether to detach. + const existing = await tx.integrationConnection.findUnique({ + where, + select: { id: true, workspaceId: true }, + }); + + const movedWorkspace = + existing?.workspaceId && + credential.workspaceId && + existing.workspaceId !== credential.workspaceId; + + if (movedWorkspace) { + await detachSourcesFromConnection( + existing.id, + callback.provider, + "workspace_changed", + tx, + ); + } + + await tx.integrationConnection.upsert({ + where, + create: { organizationId, provider: callback.provider, + ...connectionData, }, - }, - create: { organizationId, provider: callback.provider, ...connectionData }, - update: connectionData, + // A different workspace shares none of the old one's history, so the + // watermark that decided what had already been seen goes with it. + update: movedWorkspace + ? { ...connectionData, lastPolledAt: null } + : connectionData, + }); }); } From fa7c793e5b2265b2a7d8617ce7fed04a0eade730 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:34:59 +0200 Subject: [PATCH 27/43] Keep one bad connection from taking a whole sync hop down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordAttempt` used `update`, which throws P2025 when the row is gone — and resolving a revoked token is itself what deletes a connection, so a poll racing its own cleanup crashed the hop and left the rest of the batch unpolled. `updateMany` makes a missing row a no-op, and the hop loop now catches whatever else a connection can throw at it. The success writes go out as one batch: the watermark is a claim that the sources this poll found changed have been marked stale, so advancing it without them would put those changes permanently behind the window. Co-Authored-By: Claude Opus 5 --- .../server/sync/poll-connection.test.ts | 73 ++++++++++++++++++- .../server/sync/poll-connection.ts | 71 +++++++++++------- .../server/sync/run-sync-hop.test.ts | 12 ++- .../integrations/server/sync/run-sync-hop.ts | 14 +++- 4 files changed, 137 insertions(+), 33 deletions(-) diff --git a/apps/app/src/features/integrations/server/sync/poll-connection.test.ts b/apps/app/src/features/integrations/server/sync/poll-connection.test.ts index deef98f..d1de64b 100644 --- a/apps/app/src/features/integrations/server/sync/poll-connection.test.ts +++ b/apps/app/src/features/integrations/server/sync/poll-connection.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PAGE_INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; const db = vi.hoisted(() => ({ - integrationConnection: { findMany: vi.fn() }, + integrationConnection: { findMany: vi.fn(), updateMany: vi.fn() }, + notebookSource: { findMany: vi.fn(), updateMany: vi.fn() }, + $transaction: vi.fn(), })); vi.mock("@scibly/db", async () => { @@ -12,10 +14,21 @@ vi.mock("@scibly/db", async () => { return { db, Prisma: client.Prisma }; }); +const provider = vi.hoisted(() => ({ pollModifiedPages: vi.fn() })); + +vi.mock("@/features/integrations/server/registry", () => ({ + getPageProvider: () => provider, +})); + +vi.mock("@/features/integrations/server/connection-token", () => ({ + resolveConnectionToken: vi.fn(async () => "token"), +})); + const { backoffMs, getPollingStart, loadOwedConnections, + pollConnection, SYNC_CLOCK_SKEW_MS, SYNC_HOP_CONNECTION_LIMIT, SYNC_WINDOW_FLOOR_MS, @@ -36,6 +49,24 @@ beforeEach(() => { vi.resetAllMocks(); db.integrationConnection.findMany.mockResolvedValue([]); + db.$transaction.mockResolvedValue([{ count: 0 }, { count: 1 }]); +}); + +const CONNECTION = { + id: "conn-1", + provider: "NOTION" as const, + accessTokenEncrypted: "cipher", + installationId: null, + lastPolledAt: new Date(NOW.getTime() - DAY), + consecutiveFailures: 0, +}; + +const emptyTotals = () => ({ + polled: 0, + connectionsFailed: 0, + connectionsEmpty: 0, + marked: 0, + unchanged: 0, }); describe("KW1/KW4/KW5: the interval a poll covers", () => { @@ -138,3 +169,43 @@ describe("KF3: the backoff ladder", () => { expect(backoffMs(99)).toBe(7 * DAY); }); }); + +describe("KP1/KP2: what a poll commits", () => { + it("KP1: marks the changed sources and advances the watermark in one batch", async () => { + db.notebookSource.findMany.mockResolvedValue([ + { id: "src-changed", externalId: "page-a" }, + { id: "src-same", externalId: "page-b" }, + ]); + provider.pollModifiedPages.mockResolvedValue([{ id: "page-a" }]); + db.$transaction.mockResolvedValue([{ count: 1 }, { count: 1 }]); + const totals = emptyTotals(); + + await pollConnection(CONNECTION, totals); + + // One `$transaction` call, and both writes were handed to it rather than + // awaited on their own. + expect(db.$transaction).toHaveBeenCalledTimes(1); + expect(db.$transaction.mock.calls[0][0]).toHaveLength(2); + expect(db.notebookSource.updateMany.mock.calls[0][0].where).toEqual({ + id: { in: ["src-changed"] }, + }); + expect( + db.integrationConnection.updateMany.mock.calls[0][0].data, + ).toMatchObject({ consecutiveFailures: 0, nextPollAfter: null }); + expect(totals).toMatchObject({ polled: 1, marked: 1, unchanged: 1 }); + }); + + it("KP2: records an attempt on a connection that may already be gone", async () => { + db.notebookSource.findMany.mockResolvedValue([]); + const totals = emptyTotals(); + + await pollConnection(CONNECTION, totals); + + // `updateMany`, so a connection deleted mid-poll is a no-op, not a P2025. + expect(db.integrationConnection.updateMany).toHaveBeenCalledWith({ + where: { id: CONNECTION.id }, + data: { lastAttemptedAt: expect.any(Date) }, + }); + expect(totals.connectionsEmpty).toBe(1); + }); +}); diff --git a/apps/app/src/features/integrations/server/sync/poll-connection.ts b/apps/app/src/features/integrations/server/sync/poll-connection.ts index d42e3f0..cc6c1e8 100644 --- a/apps/app/src/features/integrations/server/sync/poll-connection.ts +++ b/apps/app/src/features/integrations/server/sync/poll-connection.ts @@ -109,28 +109,20 @@ export function getPollingStart(lastPolledAt: Date | null, now: Date): Date { return new Date(Math.max(lastPolledAt.getTime() - SYNC_CLOCK_SKEW_MS, floor)); } +// `updateMany`, not `update`: a poll can outlive the connection it is polling — +// resolving the token is itself what deletes a connection the provider says is +// gone — and a row that is no longer there is nothing left to record, not an +// error that should take the hop down with it. async function recordAttempt( connectionId: string, - data: Prisma.IntegrationConnectionUpdateInput, + data: Prisma.IntegrationConnectionUpdateManyMutationInput, ): Promise { - await db.integrationConnection.update({ + await db.integrationConnection.updateMany({ where: { id: connectionId }, data, }); } -async function recordPollSuccess( - connectionId: string, - pollStartedAt: Date, -): Promise { - await recordAttempt(connectionId, { - lastPolledAt: pollStartedAt, - lastAttemptedAt: new Date(), - consecutiveFailures: 0, - nextPollAfter: null, - }); -} - async function recordPollFailure( connection: SyncConnection, now: Date, @@ -144,22 +136,46 @@ async function recordPollFailure( }); } -async function markChangedSourcesStale( +// One batch, because the watermark is a claim about the marks: it says +// everything up to `pollStartedAt` has been accounted for, which is only true +// if the sources this poll found changed were actually marked stale. Advancing +// it on its own would put those changes permanently behind the window. +async function commitPollSuccess( + connectionId: string, + pollStartedAt: Date, sources: SyncableSource[], modifiedIds: Set, totals: SyncRunTotals, ): Promise { - const changed = sources.filter( - (source) => - source.externalId !== null && modifiedIds.has(source.externalId), - ); - totals.unchanged += sources.length - changed.length; - if (changed.length === 0) return; - - const marked = await db.notebookSource.updateMany({ - where: { id: { in: changed.map((source) => source.id) } }, - data: { staleAt: new Date() }, - }); + const changedIds = sources + .filter( + (source) => + source.externalId !== null && modifiedIds.has(source.externalId), + ) + .map((source) => source.id); + totals.unchanged += sources.length - changedIds.length; + + const succeeded = { + lastPolledAt: pollStartedAt, + lastAttemptedAt: new Date(), + consecutiveFailures: 0, + nextPollAfter: null, + }; + if (changedIds.length === 0) { + await recordAttempt(connectionId, succeeded); + return; + } + + const [marked] = await db.$transaction([ + db.notebookSource.updateMany({ + where: { id: { in: changedIds } }, + data: { staleAt: new Date() }, + }), + db.integrationConnection.updateMany({ + where: { id: connectionId }, + data: succeeded, + }), + ]); totals.marked += marked.count; } @@ -194,6 +210,5 @@ export async function pollConnection( } totals.polled += 1; - await markChangedSourcesStale(sources, modifiedIds, totals); - await recordPollSuccess(connection.id, now); + await commitPollSuccess(connection.id, now, sources, modifiedIds, totals); } diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts index 0c337b5..c8316ea 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts @@ -4,9 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; const db = vi.hoisted(() => ({ - integrationConnection: { findMany: vi.fn(), update: vi.fn() }, + integrationConnection: { findMany: vi.fn(), updateMany: vi.fn() }, notebookSource: { findMany: vi.fn(), updateMany: vi.fn() }, integrationSyncLease: { updateMany: vi.fn() }, + $transaction: vi.fn(), })); const provider = vi.hoisted(() => ({ pollModifiedPages: vi.fn() })); @@ -100,7 +101,7 @@ function markedStale(): string[] { } function writtenTo(connectionId: string) { - const call = db.integrationConnection.update.mock.calls.find( + const call = db.integrationConnection.updateMany.mock.calls.find( ([args]) => args.where.id === connectionId, ); return call?.[0].data; @@ -114,7 +115,12 @@ beforeEach(() => { fetchMock.mockResolvedValue({ ok: true }); db.integrationConnection.findMany.mockResolvedValue([]); - db.integrationConnection.update.mockResolvedValue({}); + db.integrationConnection.updateMany.mockResolvedValue({ count: 1 }); + // The batched writes are handed over as promises the caller already started, + // so the doubled transaction is just the settle. + db.$transaction.mockImplementation(async (ops: Promise[]) => + Promise.all(ops), + ); db.notebookSource.findMany.mockResolvedValue([]); db.notebookSource.updateMany.mockImplementation( async ({ where }: { where: { id: { in: string[] } } }) => ({ diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.ts index 19ef67a..a623570 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.ts @@ -53,7 +53,19 @@ export async function runSyncHop(lease: SyncLease): Promise { const connections = owed.slice(0, SYNC_HOP_CONNECTION_LIMIT); let deadlineReached = false; for (const connection of connections) { - await pollConnection(connection, totals); + // A connection the hop cannot even record an attempt for — its database + // writes failing, not its provider, which `pollConnection` handles — must + // not take the other nine down with it. The chain moves on; `nextPollAfter` + // is untouched, so the connection is simply owed again next hop. + try { + await pollConnection(connection, totals); + } catch (error) { + totals.connectionsFailed += 1; + console.error( + `[IntegrationFreshness] Connection ${connection.id} aborted the poll:`, + error, + ); + } if (Date.now() - hopStartedAt >= SYNC_HOP_DEADLINE_MS) { deadlineReached = true; break; From e86f4166ff8950f19dc4316d957af1be0aa0761a Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:35:48 +0200 Subject: [PATCH 28/43] Stop backing a connection off past the window it can return to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap sat at 7 days, exactly the floor `getPollingStart` can reach back across. A connection parked there came back to a window starting after the changes it was parked through — permanently missed, with the watermark advancing over them. The ladder now plateaus at its last rung. Co-Authored-By: Claude Opus 5 --- .../integrations/server/sync/poll-connection.test.ts | 6 +++++- .../features/integrations/server/sync/poll-connection.ts | 6 +++++- .../features/integrations/server/sync/run-sync-hop.test.ts | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/app/src/features/integrations/server/sync/poll-connection.test.ts b/apps/app/src/features/integrations/server/sync/poll-connection.test.ts index d1de64b..1434d21 100644 --- a/apps/app/src/features/integrations/server/sync/poll-connection.test.ts +++ b/apps/app/src/features/integrations/server/sync/poll-connection.test.ts @@ -166,7 +166,11 @@ describe("KF3: the backoff ladder", () => { expect(backoffMs(0)).toBe(0); expect(backoffMs(3)).toBe(6 * HOUR); - expect(backoffMs(99)).toBe(7 * DAY); + expect(backoffMs(99)).toBe(3 * DAY); + }); + + it("KF3: never backs a connection off past the window it could return to", () => { + expect(backoffMs(99)).toBeLessThan(SYNC_WINDOW_FLOOR_MS); }); }); diff --git a/apps/app/src/features/integrations/server/sync/poll-connection.ts b/apps/app/src/features/integrations/server/sync/poll-connection.ts index cc6c1e8..995d76d 100644 --- a/apps/app/src/features/integrations/server/sync/poll-connection.ts +++ b/apps/app/src/features/integrations/server/sync/poll-connection.ts @@ -26,7 +26,11 @@ const SYNC_BACKOFF_MS: readonly number[] = [ TimeHelpers.IN_MS.DAY, TimeHelpers.IN_MS.DAY * 3, ]; -const SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 7; +// The ladder plateaus at its last rung rather than at the window floor: a gap +// as long as `SYNC_WINDOW_FLOOR_MS` is exactly the gap `getPollingStart` can no +// longer reach back across, so a connection backed off that far would return to +// a window that starts after the changes it was backed off through. +const SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 3; export interface SyncRunTotals { polled: number; diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts index c8316ea..95469aa 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts @@ -366,9 +366,10 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { { failures: 3, case: "24h for the fourth", expected: DAY }, { failures: 4, case: "72h for the fifth", expected: 3 * DAY }, { + // The plateau stays inside the window a returning poll can still cover. failures: 8, - case: "capped at 7 days however long it stays broken", - expected: 7 * DAY, + case: "capped at 3 days however long it stays broken", + expected: 3 * DAY, }, ])( "KF3: backs a failing connection off — $case", From 92e7289d40bd60ba809fd4cee985d9adf39407e7 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:36:59 +0200 Subject: [PATCH 29/43] Read the handoff response before calling the chain continued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused handoff — a rotated CRON_SECRET answering 401, a dropped connection — was reported as a continuation and left the lease held, so the chain was over while every later cron tick backed off from a lease nobody owned. The hop now checks the status and gives the lease back. Co-Authored-By: Claude Opus 5 --- .../server/sync/run-sync-hop.test.ts | 33 ++++++++++++++----- .../integrations/server/sync/run-sync-hop.ts | 25 +++++++++++--- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts index 95469aa..29bcfea 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts @@ -557,14 +557,31 @@ describe("KC1/KC5/KC6: how a chain ends", () => { }); }); - it("does not lose the remaining connections when the handoff cannot be delivered", async () => { - owedPastTheLimit(connection({ id: "conn-later" })); - sources({ id: "src-a", externalId: "page-a" }); - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); + it.each([ + { + case: "the request never lands", + arrange: () => fetchMock.mockRejectedValue(new Error("ECONNREFUSED")), + }, + { + case: "the next hop refuses it", + arrange: () => fetchMock.mockResolvedValue({ ok: false, status: 401 }), + }, + ])( + "gives the lease back when the handoff fails because $case", + async ({ arrange }) => { + owedPastTheLimit(connection({ id: "conn-later" })); + sources({ id: "src-a", externalId: "page-a" }); + arrange(); - const { continued } = await runSyncHop(LEASE); + const { continued } = await runSyncHop(LEASE); - expect(continued).toBe(true); - expect(writtenTo("conn-later")).toBeUndefined(); - }); + // No hop is coming, so the next scheduled run must be free to start one. + expect(continued).toBe(false); + expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ + where: { id: "singleton", token: LEASE.token }, + data: { heartbeatAt: new Date(0) }, + }); + expect(writtenTo("conn-later")).toBeUndefined(); + }, + ); }); diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.ts index a623570..07bc424 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.ts @@ -79,7 +79,13 @@ export async function runSyncHop(lease: SyncLease): Promise { return { totals, continued: false }; } - await handOffChain({ token: lease.token }); + if (!(await handOffChain({ token: lease.token }))) { + // The chain stops here either way; releasing says so, so the next + // scheduled run starts a fresh one instead of waiting out a lease no + // hop is holding any more. + await releaseSyncLease(lease); + return { totals, continued: false }; + } return { totals, continued: true }; } catch (error) { console.error("[IntegrationFreshness] Hop failed:", error); @@ -88,15 +94,19 @@ export async function runSyncHop(lease: SyncLease): Promise { } } -async function handOffChain(body: { token: string }): Promise { +// Whether the next hop actually picked the chain up. A 401 from a rotated +// `CRON_SECRET` answers with a response, not an exception, so an unread status +// is the same silence as a refused connection: the chain is gone and the lease +// it left behind says otherwise. +async function handOffChain(body: { token: string }): Promise { if (!env.CRON_SECRET) { console.error( "[IntegrationFreshness] CRON_SECRET is not configured; chain not continued", ); - return; + return false; } try { - await fetch(routes.app.api.cron.syncIntegrations, { + const response = await fetch(routes.app.api.cron.syncIntegrations, { method: "POST", headers: { authorization: `Bearer ${env.CRON_SECRET}`, @@ -104,10 +114,17 @@ async function handOffChain(body: { token: string }): Promise { }, body: JSON.stringify(body), }); + if (!response.ok) { + console.error( + `[IntegrationFreshness] Handoff refused with ${response.status}; chain not continued`, + ); + } + return response.ok; } catch (error) { console.error( "[IntegrationFreshness] Failed to continue the chain:", error, ); + return false; } } From cf9980a47c52e26776b56fd54c9864bac68ee522 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:38:26 +0200 Subject: [PATCH 30/43] Spend the sync lease token on each hop instead of reusing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token names the chain and travels in the handoff request, so a retried or duplicated delivery could continue the same lease twice and run two hops at once — the concurrency the lease exists to prevent. It is now a one-hop ticket: rotated on continue, and rotated on release so a handoff still in flight cannot revive a chain that already ended. Co-Authored-By: Claude Opus 5 --- .../server/sync/run-sync-hop.test.ts | 6 ++--- .../server/sync/sync-lease.test.ts | 27 +++++++++++++++---- .../integrations/server/sync/sync-lease.ts | 15 ++++++++--- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts index 29bcfea..a5d5cf3 100644 --- a/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts +++ b/apps/app/src/features/integrations/server/sync/run-sync-hop.test.ts @@ -490,7 +490,7 @@ describe("KC1/KC5/KC6: how a chain ends", () => { expect(fetchMock).not.toHaveBeenCalled(); expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, + data: { token: expect.any(String), heartbeatAt: new Date(0) }, }); }); @@ -553,7 +553,7 @@ describe("KC1/KC5/KC6: how a chain ends", () => { expect(continued).toBe(false); expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, + data: { token: expect.any(String), heartbeatAt: new Date(0) }, }); }); @@ -579,7 +579,7 @@ describe("KC1/KC5/KC6: how a chain ends", () => { expect(continued).toBe(false); expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, + data: { token: expect.any(String), heartbeatAt: new Date(0) }, }); expect(writtenTo("conn-later")).toBeUndefined(); }, diff --git a/apps/app/src/features/integrations/server/sync/sync-lease.test.ts b/apps/app/src/features/integrations/server/sync/sync-lease.test.ts index 1f69398..3ea2a5d 100644 --- a/apps/app/src/features/integrations/server/sync/sync-lease.test.ts +++ b/apps/app/src/features/integrations/server/sync/sync-lease.test.ts @@ -36,13 +36,19 @@ beforeEach(() => { db.integrationSyncLease.updateMany.mockResolvedValue({ count: 1 }); db.integrationSyncLease.create.mockResolvedValue({ id: "singleton" }); - db.integrationSyncLease.findUnique.mockResolvedValue({ - token: LEASE.token, + // The row a hop reads back is the row its own update just wrote. + db.integrationSyncLease.findUnique.mockImplementation(async () => ({ + token: rotatedTo(), chainStartedAt: CHAIN_STARTED_AT, hops: 1, - }); + })); }); +function rotatedTo(): string { + const [args] = db.integrationSyncLease.updateMany.mock.calls[0]; + return args.data.token; +} + describe("KC2/KC3: the singleton lease", () => { it("takes the lease when the one on record is stale", async () => { db.integrationSyncLease.updateMany.mockResolvedValue({ count: 1 }); @@ -84,7 +90,7 @@ describe("KC2/KC3: the singleton lease", () => { const lease = await continueSyncLease("lease-token"); expect(lease).toEqual({ - token: "lease-token", + token: rotatedTo(), chainStartedAt: CHAIN_STARTED_AT, hops: 1, }); @@ -92,6 +98,15 @@ describe("KC2/KC3: the singleton lease", () => { expect(args.data.hops).toEqual({ increment: 1 }); }); + it("KC2: spends the token, so a duplicated handoff cannot run a second hop", async () => { + const first = await continueSyncLease("lease-token"); + + expect(first?.token).not.toBe("lease-token"); + // The row no longer answers to the token the duplicate is carrying. + db.integrationSyncLease.updateMany.mockResolvedValue({ count: 0 }); + expect(await continueSyncLease("lease-token")).toBeNull(); + }); + it.each([ { case: "its token was taken over or released", @@ -119,7 +134,9 @@ describe("KC2/KC3: the singleton lease", () => { expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, + data: { token: expect.any(String), heartbeatAt: new Date(0) }, }); + // And not to the token it was released with. + expect(rotatedTo()).not.toBe(LEASE.token); }); }); diff --git a/apps/app/src/features/integrations/server/sync/sync-lease.ts b/apps/app/src/features/integrations/server/sync/sync-lease.ts index ec99107..56dc1e7 100644 --- a/apps/app/src/features/integrations/server/sync/sync-lease.ts +++ b/apps/app/src/features/integrations/server/sync/sync-lease.ts @@ -48,12 +48,17 @@ export async function acquireSyncLease(): Promise { } } +// The token is a one-hop ticket, not a name for the chain: it travels in the +// handoff request, and a retried or duplicated delivery would otherwise let two +// hops continue the same lease at once — the exact concurrency the lease +// exists to prevent. Rotating it on the way in makes the second delivery lose. export async function continueSyncLease( token: string, ): Promise { + const next = crypto.randomUUID(); const held = await db.integrationSyncLease.updateMany({ where: { id: SYNC_LEASE_ID, token }, - data: { heartbeatAt: new Date(), hops: { increment: 1 } }, + data: { token: next, heartbeatAt: new Date(), hops: { increment: 1 } }, }); if (held.count === 0) return null; @@ -61,13 +66,15 @@ export async function continueSyncLease( where: { id: SYNC_LEASE_ID }, select: { token: true, chainStartedAt: true, hops: true }, }); - if (!row || row.token !== token) return null; - return { token, chainStartedAt: row.chainStartedAt, hops: row.hops }; + if (!row || row.token !== next) return null; + return { token: next, chainStartedAt: row.chainStartedAt, hops: row.hops }; } export async function releaseSyncLease(lease: SyncLease): Promise { await db.integrationSyncLease.updateMany({ where: { id: SYNC_LEASE_ID, token: lease.token }, - data: { heartbeatAt: new Date(0) }, + // Rotated as well as expired: a handoff still in flight when the hop gave + // up must not be able to revive a chain that has already ended. + data: { token: crypto.randomUUID(), heartbeatAt: new Date(0) }, }); } From a343e3861f1413749dc9677449a15eb101e4815d Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:41:04 +0200 Subject: [PATCH 31/43] Put a deadline on every provider request a sync hop makes `fetch` waits forever and the Notion SDK waits a minute, while a hop has four to poll ten connections. One hung request could spend the entire hop and strand the rest of the batch unpolled, hop after hop. Both clients now give up at thirty seconds. Co-Authored-By: Claude Opus 5 --- .../server/providers/github/app-auth.ts | 6 +++++ .../server/providers/github/provider.test.ts | 14 +++++++++++- .../integrations/server/providers/notion.ts | 22 +++++++++++++------ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index 7731410..64208ce 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -76,6 +76,8 @@ export class GitHubRequestError extends Error { } } +const GITHUB_TIMEOUT_MS = 30_000; + async function githubRequest( path: string, init: { method: "GET" | "POST"; authorization: string }, @@ -88,6 +90,10 @@ async function githubRequest( "x-github-api-version": "2022-11-28", }, cache: "no-store", + // `fetch` waits forever by default. A sync hop polls up to ten connections + // inside a four-minute deadline, so one hung request must not be able to + // spend the whole hop and strand the rest of the batch unpolled. + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), }); if (!response.ok) { diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index 4f0a955..acae18a 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -44,7 +44,11 @@ function lastRequest() { if (!call) throw new Error("nothing was fetched"); return { url: String(call[0]), - init: call[1] as { method: string; headers: Record }, + init: call[1] as { + method: string; + headers: Record; + signal?: AbortSignal; + }, }; } @@ -190,6 +194,14 @@ describe("GH5 the minted token", () => { expect(init.method).toBe("POST"); }); + it("GH5 gives up on a request that hangs rather than eating a whole sync hop", async () => { + fetchMock.mockResolvedValue(ok({ token: "ghs_minted" })); + + await new GitHubProvider().mintAccessToken("42"); + + expect(lastRequest().init.signal).toBeInstanceOf(AbortSignal); + }); + it("GH5 keeps the private key out of every request it makes", async () => { fetchMock.mockResolvedValue(ok({ token: "ghs_minted" })); diff --git a/apps/app/src/features/integrations/server/providers/notion.ts b/apps/app/src/features/integrations/server/providers/notion.ts index 8550162..3ba50a5 100644 --- a/apps/app/src/features/integrations/server/providers/notion.ts +++ b/apps/app/src/features/integrations/server/providers/notion.ts @@ -18,6 +18,14 @@ import { listNotionDatabasePages, } from "./notion-pages"; +// A sync hop polls up to ten connections inside a four-minute deadline, so a +// Notion request that hangs must give up long before the hop does. The SDK's +// own default is a minute, which one call could spend twice over on retries. +const NOTION_TIMEOUT_MS = 30_000; + +const notionClient = (auth?: string) => + new Client({ auth, timeoutMs: NOTION_TIMEOUT_MS }); + export class NotionProvider extends PageIntegrationProvider { readonly providerId = "NOTION"; readonly displayName = "Notion"; @@ -40,7 +48,7 @@ export class NotionProvider extends PageIntegrationProvider { if (!params.code) { throw new Error("Notion returned no authorisation code to exchange."); } - const response = await new Client().oauth.token({ + const response = await notionClient().oauth.token({ client_id: env.NOTION_CLIENT_ID, client_secret: env.NOTION_CLIENT_SECRET, grant_type: "authorization_code", @@ -56,7 +64,7 @@ export class NotionProvider extends PageIntegrationProvider { } async searchPages(token: string, query: string): Promise { - const response = await new Client({ auth: token }).search({ + const response = await notionClient(token).search({ query, filter: { value: "page", property: "object" }, sort: { direction: "descending", timestamp: "last_edited_time" }, @@ -75,7 +83,7 @@ export class NotionProvider extends PageIntegrationProvider { token: string, since: Date, ): Promise { - const notion = new Client({ auth: token }); + const notion = notionClient(token); const sinceIso = since.toISOString(); const pages: IntegrationPage[] = []; let cursor: string | undefined; @@ -111,21 +119,21 @@ export class NotionProvider extends PageIntegrationProvider { token: string, pageId: string, ): Promise { - return collectNotionChildPages(new Client({ auth: token }), pageId); + return collectNotionChildPages(notionClient(token), pageId); } async listDatabasePages( token: string, databaseId: string, ): Promise { - return listNotionDatabasePages(new Client({ auth: token }), databaseId); + return listNotionDatabasePages(notionClient(token), databaseId); } async getPageRevision( token: string, pageId: string, ): Promise { - const page = await new Client({ auth: token }).pages.retrieve({ + const page = await notionClient(token).pages.retrieve({ page_id: pageId, }); if (!isFullPage(page)) return null; @@ -139,7 +147,7 @@ export class NotionProvider extends PageIntegrationProvider { token: string, pageId: string, ): Promise { - const notion = new Client({ auth: token }); + const notion = notionClient(token); const [revision, markdownResponse] = await Promise.all([ this.getPageRevision(token, pageId), notion.pages.retrieveMarkdown({ page_id: pageId }), From 99b767c03878654ed92af420cc17a6b2de53f021 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:44:01 +0200 Subject: [PATCH 32/43] Parse GitHub's responses instead of asserting their shape `githubRequest` cast the body to whatever the caller named it, and the comment defending the cast claimed a guarantee nobody checks. A schema per path turns an unexpected body into an error at the request, not an undefined three frames later. Co-Authored-By: Claude Opus 5 --- .../server/providers/github/app-auth.ts | 49 +++++++++++++------ .../server/providers/github/provider.test.ts | 8 +++ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index 64208ce..e13dfbd 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -1,4 +1,5 @@ import crypto from "crypto"; +import { z } from "zod"; import { env } from "@/env"; @@ -78,9 +79,14 @@ export class GitHubRequestError extends Error { const GITHUB_TIMEOUT_MS = 30_000; +// The schema is a parameter rather than a type argument: a cast would describe +// the body GitHub is documented to send, which is not the same claim as the +// body it did send — an unexpected shape belongs in a thrown error here, not in +// an undefined three call frames away. async function githubRequest( path: string, init: { method: "GET" | "POST"; authorization: string }, + schema: z.ZodType, ): Promise { const response = await fetch(`${GITHUB_API}${path}`, { method: init.method, @@ -109,24 +115,37 @@ async function githubRequest( response.status, ); } - // SAFETY: the body is GitHub's documented response for the path the caller - // asked for, and every field GitHub may omit is checked before it is used. - return (await response.json()) as T; + return schema.parse(await response.json()); } -interface InstallationResponse { - id: number; - account: { id: number; login: string } | null; -} +const installationResponse = z.object({ + id: z.number(), + account: z.object({ id: z.number(), login: z.string() }).nullable(), +}); + +const mintedTokenResponse = z.object({ token: z.string() }); + +const repositoriesResponse = z.object({ + repositories: z + .array( + z.object({ + id: z.number(), + full_name: z.string(), + html_url: z.string(), + }), + ) + .optional(), +}); /** Who the app was installed on, asked as the app itself. */ export async function fetchInstallation( config: GitHubAppConfig, installationId: string, ): Promise { - const installation = await githubRequest( + const installation = await githubRequest( `/app/installations/${encodeURIComponent(installationId)}`, { method: "GET", authorization: `Bearer ${signAppJwt(config)}` }, + installationResponse, ); if (!installation.account) { throw new Error( @@ -145,9 +164,10 @@ export async function mintInstallationToken( config: GitHubAppConfig, installationId: string, ): Promise { - const minted = await githubRequest<{ token: string }>( + const minted = await githubRequest( `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, { method: "POST", authorization: `Bearer ${signAppJwt(config)}` }, + mintedTokenResponse, ); return minted.token; } @@ -156,11 +176,10 @@ export async function mintInstallationToken( export async function fetchInstallationRepositories( token: string, ): Promise { - const { repositories } = await githubRequest<{ - repositories?: GitHubRepository[]; - }>("/installation/repositories?per_page=100", { - method: "GET", - authorization: `Bearer ${token}`, - }); + const { repositories } = await githubRequest( + "/installation/repositories?per_page=100", + { method: "GET", authorization: `Bearer ${token}` }, + repositoriesResponse, + ); return repositories ?? []; } diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index acae18a..757fa21 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -202,6 +202,14 @@ describe("GH5 the minted token", () => { expect(lastRequest().init.signal).toBeInstanceOf(AbortSignal); }); + it("GH5 refuses a body that is not the shape it asked for", async () => { + fetchMock.mockResolvedValue(ok({ token: 12345 })); + + // A cast would have handed a number down as the access token and failed + // somewhere with no GitHub in the stack trace. + await expect(new GitHubProvider().mintAccessToken("42")).rejects.toThrow(); + }); + it("GH5 keeps the private key out of every request it makes", async () => { fetchMock.mockResolvedValue(ok({ token: "ghs_minted" })); From 10618416197655efbb6ac36eefb88469f774ef13 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:50:00 +0200 Subject: [PATCH 33/43] Show what an installation reaches past the first hundred repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grants strip asked GitHub for one page and rendered it as the whole list, so an installation on a large organisation silently lost every repository past the hundredth — and rendered hundreds of chips when it did not. It now pages to a budget, shows the first four with a "{count} more" button, and lists the rest in a scrollable dialog that says how many of the total it managed to fetch. Co-Authored-By: Claude Opus 5 --- .../api/integration-connection-procedures.ts | 4 +- .../src/features/integrations/contracts.ts | 8 +++ .../integrations/server/base-provider.ts | 4 +- .../server/providers/github/app-auth.ts | 44 +++++++++++-- .../server/providers/github/provider.test.ts | 30 ++++++++- .../server/providers/github/provider.ts | 20 +++--- .../org-integrations-card.test.tsx | 28 ++++++++ .../provider-grants-dialog.tsx | 65 +++++++++++++++++++ .../org-integrations/provider-grants.tsx | 30 ++++++++- .../settings/i18n/org-settings.types.ts | 2 + .../settings/i18n/orgSettings.i18n.de.json | 2 + .../settings/i18n/orgSettings.i18n.en.json | 2 + 12 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx diff --git a/apps/app/src/features/integrations/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index db52d72..7cb772c 100644 --- a/apps/app/src/features/integrations/api/integration-connection-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-connection-procedures.ts @@ -167,7 +167,9 @@ export const integrationConnectionProcedures = { organization.id, input.provider, ); - return { grants: (await provider.listGrants?.(token)) ?? [] }; + return ( + (await provider.listGrants?.(token)) ?? { grants: [], totalCount: 0 } + ); }), searchPages: protectedProcedure diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index ef2600f..1295a97 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -76,6 +76,14 @@ export interface IntegrationGrant { url: string; } +// The count is what the provider says it granted, which a listing that stopped +// at its page budget does not have all of. Fewer grants than `totalCount` is +// how the settings page knows it is showing a prefix, not the whole of it. +export interface IntegrationGrantList { + grants: IntegrationGrant[]; + totalCount: number; +} + export interface OAuthTokens { accessToken: string; refreshToken?: string; diff --git a/apps/app/src/features/integrations/server/base-provider.ts b/apps/app/src/features/integrations/server/base-provider.ts index 15643c9..00bb5be 100644 --- a/apps/app/src/features/integrations/server/base-provider.ts +++ b/apps/app/src/features/integrations/server/base-provider.ts @@ -1,7 +1,7 @@ import type { IntegrationCredential, IntegrationCredentialKind, - IntegrationGrant, + IntegrationGrantList, IntegrationPage, IntegrationPageContent, IntegrationPageRevision, @@ -54,7 +54,7 @@ export abstract class IntegrationProvider { mintAccessToken?(installationId: string): Promise; /** Present only on a provider that hands its workspace out piece by piece. */ - listGrants?(token: string): Promise; + listGrants?(token: string): Promise; } /** diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index e13dfbd..8789880 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -126,6 +126,7 @@ const installationResponse = z.object({ const mintedTokenResponse = z.object({ token: z.string() }); const repositoriesResponse = z.object({ + total_count: z.number(), repositories: z .array( z.object({ @@ -172,14 +173,43 @@ export async function mintInstallationToken( return minted.token; } +const REPOS_PER_PAGE = 100; + +// An installation on a large organisation can reach thousands of repositories. +// Listing them is a settings-page nicety, so it walks a bounded number of pages +// and says when it stopped early rather than spending a request per hundred +// until GitHub runs out. +const MAX_REPOSITORY_PAGES = 10; + +export interface GitHubRepositoryList { + repositories: GitHubRepository[]; + /** What GitHub says the installation reaches, listed or not. */ + totalCount: number; +} + /** The repositories the installation was given. */ export async function fetchInstallationRepositories( token: string, -): Promise { - const { repositories } = await githubRequest( - "/installation/repositories?per_page=100", - { method: "GET", authorization: `Bearer ${token}` }, - repositoriesResponse, - ); - return repositories ?? []; +): Promise { + const repositories: GitHubRepository[] = []; + let totalCount = 0; + + for (let page = 1; page <= MAX_REPOSITORY_PAGES; page += 1) { + const body = await githubRequest( + `/installation/repositories?per_page=${REPOS_PER_PAGE}&page=${page}`, + { method: "GET", authorization: `Bearer ${token}` }, + repositoriesResponse, + ); + totalCount = body.total_count; + const returned = body.repositories ?? []; + repositories.push(...returned); + // A short page is the last page, whatever the count claims. + if (returned.length < REPOS_PER_PAGE) break; + if (repositories.length >= totalCount) break; + } + + return { + repositories, + totalCount: Math.max(totalCount, repositories.length), + }; } diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index 757fa21..5701a0a 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -282,13 +282,39 @@ describe("GH6 what the installation reaches", () => { }), ); - const grants = await new GitHubProvider().listGrants("ghs_minted"); + const { grants, totalCount } = await new GitHubProvider().listGrants( + "ghs_minted", + ); expect(grants).toEqual([ { id: "1", name: "acme-inc/api", url: "https://github.com/acme-inc/api" }, { id: "2", name: "acme-inc/web", url: "https://github.com/acme-inc/web" }, ]); + expect(totalCount).toBe(2); expect(lastRequest().init.headers.authorization).toBe("Bearer ghs_minted"); + // A page that came back short is the last page; no second request for it. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("GH6 walks past the first hundred, and says so when it stops early", async () => { + const page = Array.from({ length: 100 }, (_, index) => ({ + id: index, + full_name: `acme-inc/repo-${index}`, + html_url: `https://github.com/acme-inc/repo-${index}`, + })); + fetchMock.mockResolvedValue(ok({ total_count: 1500, repositories: page })); + + const { grants, totalCount } = await new GitHubProvider().listGrants( + "ghs_minted", + ); + + // Ten pages is the budget: the settings strip stops there and admits it + // rather than spending fifteen requests to render a list nobody reads. + expect(fetchMock).toHaveBeenCalledTimes(10); + // Fewer than the count: the settings page reads that as "showing a prefix". + expect(grants).toHaveLength(1000); + expect(totalCount).toBe(1500); + expect(lastRequest().url).toContain("page=10"); }); it("GH6 says the connection reaches nothing rather than failing", async () => { @@ -296,7 +322,7 @@ describe("GH6 what the installation reaches", () => { await expect( new GitHubProvider().listGrants("ghs_minted"), - ).resolves.toEqual([]); + ).resolves.toEqual({ grants: [], totalCount: 0 }); }); }); diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts index e317692..6f27f34 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -1,6 +1,6 @@ import type { IntegrationCredential, - IntegrationGrant, + IntegrationGrantList, } from "../../../contracts"; import type { ConnectCallbackParams } from "../../base-provider"; import type { GitHubAppConfig } from "./app-auth"; @@ -89,12 +89,16 @@ export class GitHubProvider extends IntegrationProvider { } } - async listGrants(token: string): Promise { - const repositories = await fetchInstallationRepositories(token); - return repositories.map((repository) => ({ - id: String(repository.id), - name: repository.full_name, - url: repository.html_url, - })); + async listGrants(token: string): Promise { + const { repositories, totalCount } = + await fetchInstallationRepositories(token); + return { + grants: repositories.map((repository) => ({ + id: String(repository.id), + name: repository.full_name, + url: repository.html_url, + })), + totalCount, + }; } } diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index ab50e0d..6546b15 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -55,6 +55,8 @@ const t = { grantsLoading: "Loading repositories…", grantsEmpty: "No repositories.", grantsError: "Could not load repositories.", + grantsMore: "{count} more", + grantsShown: "Showing {shown} of {total}.", revokedNotice: "The connection was removed on the provider's side.", noProvidersAvailable: "Nothing to connect to.", providers: { NOTION: "Notion", GITHUB: "GitHub" }, @@ -72,6 +74,12 @@ function lists(allProviders: unknown[], connections: unknown[] = []): void { } function grants(...names: string[]): void { + grantsOf(names.length, ...names); +} + +// The count the provider reported can exceed what it listed: a listing that +// stopped at its page budget is what the strip has to summarise. +function grantsOf(totalCount: number, ...names: string[]): void { useGrants.mockReturnValue({ data: { grants: names.map((name, index) => ({ @@ -79,6 +87,7 @@ function grants(...names: string[]): void { name, url: `https://github.com/${name}`, })), + totalCount, }, isPending: false, isError: false, @@ -265,6 +274,25 @@ describe("the grants strip", () => { expect(container.textContent).toContain("acme-inc/docs"); }); + it("keeps the strip short and puts the rest behind a count", () => { + lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); + grants(...Array.from({ length: 9 }, (_, i) => `acme-inc/repo-${i}`)); + + const container = card(); + + // Four repositories and the button standing for the other five. + expect(container.querySelectorAll("li")).toHaveLength(5); + expect(container.textContent).toContain("5 more"); + expect(container.textContent).not.toContain("acme-inc/repo-8"); + }); + + it("counts what the provider reported, not what it managed to list", () => { + lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); + grantsOf(1500, ...Array.from({ length: 10 }, (_, i) => `acme-inc/r-${i}`)); + + expect(card().textContent).toContain("1496 more"); + }); + it("says the connection reaches nothing rather than showing an empty list", () => { lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); grants(); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx new file mode 100644 index 0000000..f49681b --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { IntegrationGrant } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@scibly/ui/components/dialog"; +import { ExternalLink } from "lucide-react"; + +import { ScrollArea } from "@/shared/ui/components/scroll-area"; + +// An installation on a large organisation reaches hundreds of repositories. +// The strip shows a handful; the whole list lives here, where it can scroll. +export function ProviderGrantsDialog({ + open, + onOpenChange, + grants, + totalCount, + t, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + grants: IntegrationGrant[]; + totalCount: number; + t: OrgSettingsPage["integrations"]; +}) { + return ( + + + + {t.grantsTitle} + {/* Says "showing 1000 of 1500" when the listing stopped at its page + budget, so a partial list never looks like the whole of it. */} + + {t.grantsShown + .replace("{shown}", String(grants.length)) + .replace("{total}", String(totalCount))} + + + + + + + + ); +} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx index 039ffe1..415b13f 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx @@ -4,11 +4,16 @@ import type { IntegrationProviderId } from "@/features/integrations/contracts"; import type { OrgSettingsPage } from "@/features/organizations/contracts"; import { ExternalLink } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { api } from "@/shared/api/trpc/client"; +import { ProviderGrantsDialog } from "./provider-grants-dialog"; + +// Enough to recognise the connection at a glance; the rest is a click away. +const VISIBLE_GRANTS = 4; + // Its own query, so the card renders at once and only this strip waits on the // provider. export const ProviderGrants = ({ @@ -20,6 +25,7 @@ export const ProviderGrants = ({ provider: IntegrationProviderId; t: OrgSettingsPage["integrations"]; }) => { + const [showAll, setShowAll] = useState(false); const utils = api.useUtils(); const { data, isPending, isError, error } = api.integration.listGrants.useQuery({ @@ -47,13 +53,15 @@ export const ProviderGrants = ({ if (data.grants.length === 0) { return

{t.grantsEmpty}

; } + const hidden = data.totalCount - VISIBLE_GRANTS; + return ( ); }; diff --git a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts index cb5411b..0a5b224 100644 --- a/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts +++ b/apps/app/src/features/organizations/settings/i18n/org-settings.types.ts @@ -127,6 +127,8 @@ export type OrgSettingsPage = { grantsLoading: string; grantsEmpty: string; grantsError: string; + grantsMore: string; + grantsShown: string; revokedNotice: string; noProvidersAvailable: string; callbackErrorFallback: string; diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json index 8467552..de33187 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json @@ -127,6 +127,8 @@ "grantsLoading": "Zugriffe werden geladen …", "grantsEmpty": "Diese Verbindung hat noch auf nichts Zugriff erhalten.", "grantsError": "Die Zugriffe dieser Verbindung konnten nicht geladen werden.", + "grantsMore": "{count} weitere", + "grantsShown": "{shown} von {total} werden angezeigt.", "noProvidersAvailable": "Keine Integrationen verfügbar.", "callbackErrorFallback": "Die Verbindung ist fehlgeschlagen. Bitte versuche es erneut.", "callbackErrors": { diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json index c7bfb31..5d62f41 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json @@ -127,6 +127,8 @@ "grantsLoading": "Loading access…", "grantsEmpty": "This connection has not been given access to anything yet.", "grantsError": "Couldn't load what this connection can reach.", + "grantsMore": "{count} more", + "grantsShown": "Showing {shown} of {total}.", "noProvidersAvailable": "No integrations available.", "callbackErrorFallback": "Connection failed. Please try again.", "callbackErrors": { From a66b9c4e81f4c9034295e46befefc5d0826517b6 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:56:26 +0200 Subject: [PATCH 34/43] feat: move integration freshness sync onto Inngest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduled poll ran as a Vercel cron that grabbed a singleton lease row and then chained POSTs to itself, hop by hop, until every due connection had had its turn. The lease existed because two overlapping chains would double the provider quota and race the same watermark; the hops existed because one serverless invocation could not outlive the work. Inngest already solves both. A cron function lists the due connections and sends one event each; a per-connection function does the actual poll. Retries, the concurrency cap that keeps a provider from being hammered, and the failure hook are declarations on the function rather than logic in the route. Observable behaviour is unchanged: same 04:00 schedule, same selection order, same backoff tiers, same watermark semantics. Deleted with the mechanism they served: `IntegrationSyncLease`, the `/api/cron/sync-integrations` route and its guard, the `crons` entry in `vercel.json`, and `CRON_SECRET` — nothing reads it once the last cron route is gone. Functions are feature-owned and collected in `src/server/inngest.ts`, the composition root for background work, mirroring how `api/root.ts` collects tRPC routers. `src/lib/` may not import from `src/features/`, so the collection cannot live beside the client. Closes #10 Co-Authored-By: Claude Opus 5 --- .env.example | 3 - apps/app/.env.example | 4 - .../api/cron/sync-integrations/route.test.ts | 168 ----- .../app/api/cron/sync-integrations/route.ts | 74 --- apps/app/src/app/api/cron/testing.ts | 24 - apps/app/src/app/api/inngest/route.ts | 5 +- apps/app/src/env.js | 12 - apps/app/src/features/integrations/CONTEXT.md | 25 +- apps/app/src/features/integrations/server.ts | 7 +- .../server/integration-sync.test.ts | 95 +++ .../integrations/server/integration-sync.ts | 77 +++ .../server/sync-source-freshness.test.ts | 582 +++++------------- .../server/sync-source-freshness.ts | 334 +++------- .../src/lib/inngest/functions/heartbeat.ts | 32 - apps/app/src/lib/inngest/functions/index.ts | 3 - .../index.test.ts => server/inngest.test.ts} | 6 +- apps/app/src/server/inngest.ts | 6 + .../shared/api/cron/cron-route-guard.test.ts | 88 --- .../src/shared/api/cron/cron-route-guard.ts | 38 -- apps/app/vercel.json | 8 +- .../0004-inngest-self-hosted-orchestration.md | 24 +- docs/docker.md | 5 +- docs/setup.md | 21 +- .../migration.sql | 5 + packages/db/schema/integration.prisma | 43 +- packages/routes/src/index.ts | 3 - turbo.json | 1 - 27 files changed, 443 insertions(+), 1250 deletions(-) delete mode 100644 apps/app/src/app/api/cron/sync-integrations/route.test.ts delete mode 100644 apps/app/src/app/api/cron/sync-integrations/route.ts delete mode 100644 apps/app/src/app/api/cron/testing.ts create mode 100644 apps/app/src/features/integrations/server/integration-sync.test.ts create mode 100644 apps/app/src/features/integrations/server/integration-sync.ts delete mode 100644 apps/app/src/lib/inngest/functions/heartbeat.ts delete mode 100644 apps/app/src/lib/inngest/functions/index.ts rename apps/app/src/{lib/inngest/functions/index.test.ts => server/inngest.test.ts} (61%) create mode 100644 apps/app/src/server/inngest.ts delete mode 100644 apps/app/src/shared/api/cron/cron-route-guard.test.ts delete mode 100644 apps/app/src/shared/api/cron/cron-route-guard.ts create mode 100644 packages/db/migrations/20260828120000_drop_integration_sync_lease/migration.sql diff --git a/.env.example b/.env.example index 8cc7266..bdf482e 100644 --- a/.env.example +++ b/.env.example @@ -78,9 +78,6 @@ STRIPE_PRICE_SEAT_BUSINESS="" STRIPE_PRICE_SEAT_PRO="" STRIPE_PORTAL_CONFIGURATION_ID="" -# Bearer token for /api/cron/* (apps/app). Generate with: openssl rand -hex 32 -CRON_SECRET="" - NEXT_PUBLIC_POSTHOG_ENABLED="false" NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_HOST="https://eu.i.posthog.com" diff --git a/apps/app/.env.example b/apps/app/.env.example index 4c05e96..b09ae77 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -30,10 +30,6 @@ AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" MEDIA_BUCKET_NAME="startup-prod-media" -# Vercel Cron — Bearer token for /api/cron/* (required in production) -# Generate with: openssl rand -hex 32 -CRON_SECRET="" - # Inngest. These defaults are the local `inngest dev` server, which runs # unsigned and ignores both keys. Against a real server: openssl rand -hex 32 INNGEST_BASE_URL="http://localhost:8288" diff --git a/apps/app/src/app/api/cron/sync-integrations/route.test.ts b/apps/app/src/app/api/cron/sync-integrations/route.test.ts deleted file mode 100644 index badd890..0000000 --- a/apps/app/src/app/api/cron/sync-integrations/route.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type * as NextServer from "next/server"; - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { cronRequest, runDeferredWork } from "../testing"; - -// Auth is covered in cron-route-guard.test.ts and the sync run in the sync-source-freshness -// suite; this suite only tests what the route itself decides — chaining, response-before-work, -// and failure messages. - -const sync = vi.hoisted(() => ({ - acquireSyncLease: vi.fn(), - continueSyncLease: vi.fn(), - runSyncStep: vi.fn(), -})); - -const env = vi.hoisted(() => ({ CRON_SECRET: "test-cron-secret" })); -const afterMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/env", () => ({ env })); -vi.mock("@/features/integrations/server", () => sync); -vi.mock("next/server", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, after: afterMock }; -}); - -const { GET, POST } = await import("./route"); - -const ROUTE_URL = "https://app.test/api/cron/sync-integrations"; -const SECRET = "test-cron-secret"; -const LEASE = { token: "lease-token", chainStartedAt: new Date(), hops: 0 }; - -beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "error").mockImplementation(() => undefined); - env.CRON_SECRET = SECRET; - sync.acquireSyncLease.mockResolvedValue(LEASE); - sync.continueSyncLease.mockResolvedValue(LEASE); - sync.runSyncStep.mockResolvedValue({ totals: {}, continued: false }); -}); - -describe("KD4: the route is behind the shared door", () => { - it.each([ - { name: "GET", handler: GET }, - { name: "POST", handler: POST }, - ])( - "$name refuses a caller presenting the wrong secret", - async ({ handler }) => { - const response = await handler( - cronRequest(ROUTE_URL, { method: "GET", secret: "wrong-secret" }), - ); - - expect(response.status).toBe(401); - expect(sync.acquireSyncLease).not.toHaveBeenCalled(); - expect(sync.continueSyncLease).not.toHaveBeenCalled(); - expect(afterMock).not.toHaveBeenCalled(); - }, - ); - - it("refuses a caller presenting no secret at all", async () => { - const response = await GET(cronRequest(ROUTE_URL, { method: "GET" })); - - expect(response.status).toBe(401); - expect(afterMock).not.toHaveBeenCalled(); - }); - - it("fails closed when the deployment has no secret configured", async () => { - env.CRON_SECRET = ""; - - const response = await GET( - cronRequest(ROUTE_URL, { method: "GET", secret: "anything" }), - ); - - expect(response.status).toBe(500); - expect(sync.acquireSyncLease).not.toHaveBeenCalled(); - }); -}); - -describe("KD5: a run that cannot start", () => { - it.each([ - { name: "GET", handler: GET }, - { name: "POST", handler: POST }, - ])( - "$name answers 500 without naming a provider or a connection", - async ({ handler }) => { - sync.acquireSyncLease.mockRejectedValue( - new Error("connect ECONNREFUSED db.internal:5432 for org acme"), - ); - - const response = await handler( - cronRequest(ROUTE_URL, { method: "GET", secret: SECRET }), - ); - - expect(response.status).toBe(500); - expect(await response.json()).toEqual({ error: "Sync failed" }); - }, - ); -}); - -describe("KC2: exactly one chain at a time", () => { - it.each([ - { name: "the daily cron", handler: GET }, - { name: "a fresh kick", handler: POST }, - ])( - "$name joins the running chain rather than starting a second", - async ({ handler }) => { - sync.acquireSyncLease.mockResolvedValue(null); - - const response = await handler( - cronRequest(ROUTE_URL, { method: "GET", secret: SECRET }), - ); - - expect(await response.json()).toEqual({ ok: true, joined: true }); - expect(afterMock).not.toHaveBeenCalled(); - expect(sync.runSyncStep).not.toHaveBeenCalled(); - }, - ); - - it("stops a hop whose token is no longer the chain's", async () => { - sync.continueSyncLease.mockResolvedValue(null); - - const response = await POST( - cronRequest(ROUTE_URL, { - method: "POST", - secret: SECRET, - body: { token: "stale" }, - }), - ); - - expect(await response.json()).toEqual({ ok: true, joined: true }); - expect(afterMock).not.toHaveBeenCalled(); - }); -}); - -describe("the chain's hops", () => { - it("continues on the token its predecessor held rather than taking a new lease", async () => { - const response = await POST( - cronRequest(ROUTE_URL, { - method: "POST", - secret: SECRET, - body: { token: "lease-token" }, - }), - ); - - expect(response.status).toBe(200); - expect(sync.continueSyncLease).toHaveBeenCalledWith("lease-token"); - expect(sync.acquireSyncLease).not.toHaveBeenCalled(); - }); - - it("treats a kick carrying no token as a cold start", async () => { - await POST(cronRequest(ROUTE_URL, { method: "POST", secret: SECRET })); - - expect(sync.acquireSyncLease).toHaveBeenCalledTimes(1); - expect(sync.continueSyncLease).not.toHaveBeenCalled(); - }); - - it("answers before it polls anything, so the predecessor is not held open", async () => { - const response = await GET( - cronRequest(ROUTE_URL, { method: "GET", secret: SECRET }), - ); - - expect(await response.json()).toEqual({ ok: true, started: true }); - expect(sync.runSyncStep).not.toHaveBeenCalled(); - - await runDeferredWork(afterMock); - expect(sync.runSyncStep).toHaveBeenCalledWith(LEASE); - }); -}); diff --git a/apps/app/src/app/api/cron/sync-integrations/route.ts b/apps/app/src/app/api/cron/sync-integrations/route.ts deleted file mode 100644 index 2ca7588..0000000 --- a/apps/app/src/app/api/cron/sync-integrations/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { after, NextResponse } from "next/server"; -import { z } from "zod"; - -import { - acquireSyncLease, - continueSyncLease, - runSyncStep, - type SyncLease, -} from "@/features/integrations/server"; -import { refuseUnauthorizedCron } from "@/shared/api/cron/cron-route-guard"; - -export const maxDuration = 300; - -const ROUTE_NAME = "sync-integrations"; - -// `POST` self-chains because a large tenant's sync can outrun one serverless invocation; -// progress is tracked as a durable watermark per connection, not in this process's memory. - -// Responds before running the step, so the caller's fetch resolves immediately instead of -// waiting out the whole chain. -function startHop(lease: SyncLease): NextResponse { - after(async () => { - await runSyncStep(lease); - }); - return NextResponse.json({ ok: true, started: true }); -} - -const chainKick = z.object({ token: z.string() }); - -async function readChainToken(request: Request): Promise { - try { - return chainKick.safeParse(await request.json()).data?.token; - } catch { - return undefined; - } -} - -// The response is public before auth is checked, so failures stay generic — -// no provider, connection, or organization details. -function failed(error: unknown): NextResponse { - console.error(`[Cron] ${ROUTE_NAME} failed:`, error); - return NextResponse.json({ error: "Sync failed" }, { status: 500 }); -} - -export async function GET(request: Request) { - const refusal = refuseUnauthorizedCron(request, ROUTE_NAME); - if (refusal) return refusal; - - try { - const lease = await acquireSyncLease(); - - if (!lease) return NextResponse.json({ ok: true, joined: true }); - return startHop(lease); - } catch (error) { - return failed(error); - } -} - -export async function POST(request: Request) { - const refusal = refuseUnauthorizedCron(request, ROUTE_NAME); - if (refusal) return refusal; - - try { - const token = await readChainToken(request); - const lease = token - ? await continueSyncLease(token) - : await acquireSyncLease(); - - if (!lease) return NextResponse.json({ ok: true, joined: true }); - return startHop(lease); - } catch (error) { - return failed(error); - } -} diff --git a/apps/app/src/app/api/cron/testing.ts b/apps/app/src/app/api/cron/testing.ts deleted file mode 100644 index 9288832..0000000 --- a/apps/app/src/app/api/cron/testing.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Mock } from "vitest"; - -export function cronRequest( - url: string, - options: { method: "GET" | "POST"; secret?: string; body?: unknown } = { - method: "GET", - }, -) { - const headers = new Headers(); - if (options.secret !== undefined) { - headers.set("authorization", `Bearer ${options.secret}`); - } - return new Request(url, { - method: options.method, - headers, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }); -} - -export async function runDeferredWork(afterMock: Mock): Promise { - for (const [work] of afterMock.mock.calls) { - await work(); - } -} diff --git a/apps/app/src/app/api/inngest/route.ts b/apps/app/src/app/api/inngest/route.ts index c30e649..65658b5 100644 --- a/apps/app/src/app/api/inngest/route.ts +++ b/apps/app/src/app/api/inngest/route.ts @@ -2,14 +2,13 @@ import { serve } from "inngest/next"; import { connection, type NextRequest } from "next/server"; import { inngest } from "@/lib/inngest/client"; -import { inngestFunctions } from "@/lib/inngest/functions"; +import { inngestFunctions } from "@/server/inngest"; export const maxDuration = 300; const handler = serve({ client: inngest, functions: inngestFunctions }); -// Under `cacheComponents` a `GET` handler is prerendered unless it reaches for -// request-time data, and this one's answer depends on the request's headers. +// Under `cacheComponents` a `GET` handler is prerendered unless it reaches for request-time data, and this one depends on the request headers. export async function GET(request: NextRequest, context: unknown) { await connection(); return handler.GET(request, context); diff --git a/apps/app/src/env.js b/apps/app/src/env.js index dec6720..d5bd57a 100644 --- a/apps/app/src/env.js +++ b/apps/app/src/env.js @@ -58,17 +58,6 @@ export const env = createEnv({ .min(1) .default("google/gemini-3.1-flash-lite-image"), - CRON_SECRET: z - .string() - .min(1) - .optional() - .refine( - (val) => process.env.NODE_ENV !== "production" || val !== undefined, - { - message: - "CRON_SECRET is required in production — without it, cron sync silently never runs (fails closed at request time, but deploys succeed).", - }, - ), /** HMAC key shared only by the app token issuer and collab verifier. */ COLLAB_TOKEN_SECRET: z.string().min(32), @@ -131,7 +120,6 @@ export const env = createEnv({ AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, SCIBLY_DEFAULT_CHAT_MODEL: process.env.SCIBLY_DEFAULT_CHAT_MODEL, SCIBLY_DEFAULT_IMAGE_MODEL: process.env.SCIBLY_DEFAULT_IMAGE_MODEL, - CRON_SECRET: process.env.CRON_SECRET, COLLAB_TOKEN_SECRET: process.env.COLLAB_TOKEN_SECRET ?? process.env.BETTER_AUTH_SECRET, diff --git a/apps/app/src/features/integrations/CONTEXT.md b/apps/app/src/features/integrations/CONTEXT.md index 6425707..ae85de3 100644 --- a/apps/app/src/features/integrations/CONTEXT.md +++ b/apps/app/src/features/integrations/CONTEXT.md @@ -81,17 +81,14 @@ act — what a stale source then does to a course is the notebook's. ### Running the sync -**Chain**: -The sequence of hops that carries one sync through, handing off rather than -running past the time it is allowed. -_Avoid_: batch, queue - -**Hop**: -One slice of a chain: a fixed number of connections, or as many as fit before -the deadline, whichever comes first. -_Avoid_: run, iteration, tick - -**Lease**: -The single permit that lets one chain run at a time. Two chains would ask the -provider twice for the same thing and race each other's watermarks. -_Avoid_: lock, mutex, semaphore +**Due**: +What a connection is when its backoff has passed and its organization still +pays. The sync's whole decision is which connections are due; each one then gets +a poll of its own. +_Avoid_: owed, pending, queued + +**Attempt**: +One try at a poll. Several may be spent on one poll — a provider that times out +is tried again — and only the last one that fails counts against the backoff. +The watermark moves for the one that succeeds; nothing moves for the rest. +_Avoid_: retry (the platform's word for what it does between attempts) diff --git a/apps/app/src/features/integrations/server.ts b/apps/app/src/features/integrations/server.ts index 70635bf..da591f8 100644 --- a/apps/app/src/features/integrations/server.ts +++ b/apps/app/src/features/integrations/server.ts @@ -1,12 +1,7 @@ import "server-only"; export { integrationRouter } from "./api/integration.router"; +export { integrationPoll, integrationSync } from "./server/integration-sync"; export { buildIntegrationNotebookTools } from "./server/notebook-tools"; export { handleIntegrationOAuthCallback } from "./server/oauth-callback"; export { getProvider, listProviders } from "./server/registry"; -export { - acquireSyncLease, - continueSyncLease, - runSyncStep, - type SyncLease, -} from "./server/sync-source-freshness"; diff --git a/apps/app/src/features/integrations/server/integration-sync.test.ts b/apps/app/src/features/integrations/server/integration-sync.test.ts new file mode 100644 index 0000000..f120b17 --- /dev/null +++ b/apps/app/src/features/integrations/server/integration-sync.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sync = vi.hoisted(() => ({ + loadDueConnections: vi.fn(), + pollConnection: vi.fn(), + recordPollFailure: vi.fn(), +})); + +vi.mock("./sync-source-freshness", () => sync); + +const { INTEGRATION_POLL_EVENT, recordFailedPoll, requestDuePolls } = + await import("./integration-sync"); + +const sendEvent = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + sync.loadDueConnections.mockResolvedValue([]); +}); + +describe("KC1: one run per due connection", () => { + it("asks for a poll of every connection that is due", async () => { + sync.loadDueConnections.mockResolvedValue([ + { id: "conn-a", provider: "NOTION" }, + { id: "conn-b", provider: "CONFLUENCE" }, + ]); + + expect(await requestDuePolls(sendEvent)).toEqual({ requested: 2 }); + expect(sendEvent).toHaveBeenCalledWith("request-polls", [ + { + name: INTEGRATION_POLL_EVENT, + data: { connectionId: "conn-a", provider: "NOTION" }, + }, + { + name: INTEGRATION_POLL_EVENT, + data: { connectionId: "conn-b", provider: "CONFLUENCE" }, + }, + ]); + }); + + it("carries the provider, which is what the concurrency cap groups on", async () => { + sync.loadDueConnections.mockResolvedValue([ + { id: "conn-a", provider: "NOTION" }, + ]); + + await requestDuePolls(sendEvent); + + const [, events] = sendEvent.mock.calls[0]; + expect(events[0].data.provider).toBe("NOTION"); + }); + + it("sends nothing when nothing is due", async () => { + expect(await requestDuePolls(sendEvent)).toEqual({ requested: 0 }); + expect(sendEvent).not.toHaveBeenCalled(); + }); +}); + +describe("KF3/KF5: a poll that ran out of retries", () => { + it("records the failure against the connection the run was for", async () => { + await recordFailedPoll( + { connectionId: "conn-broken", provider: "NOTION" }, + new Error("401 from provider"), + ); + + expect(sync.recordPollFailure).toHaveBeenCalledWith( + "conn-broken", + expect.any(Date), + ); + }); + + it("KF5: names the connection and its provider in the log", async () => { + await recordFailedPoll( + { connectionId: "conn-broken", provider: "NOTION" }, + new Error("401 from provider"), + ); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("conn-broken"), + expect.any(Error), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining("NOTION"), + expect.any(Error), + ); + }); + + it("refuses an event that names no connection rather than backing off a guess", async () => { + await expect( + recordFailedPoll({ provider: "NOTION" }, null), + ).rejects.toThrow(); + + expect(sync.recordPollFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app/src/features/integrations/server/integration-sync.ts b/apps/app/src/features/integrations/server/integration-sync.ts new file mode 100644 index 0000000..30c7375 --- /dev/null +++ b/apps/app/src/features/integrations/server/integration-sync.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { inngest } from "@/lib/inngest/client"; + +import { + loadDueConnections, + pollConnection, + recordPollFailure, +} from "./sync-source-freshness"; + +export const INTEGRATION_POLL_EVENT = "scibly/integration-poll.requested"; + +// Notion allows roughly three requests a second against one token, and a poll paginates. +const POLL_CONCURRENCY = 3; + +// Never a credential: the event log is not a place for tokens. +const pollRequest = z.object({ + connectionId: z.string().min(1), + provider: z.string().min(1), +}); + +export async function requestDuePolls( + sendEvent: ( + id: string, + events: { name: string; data: z.infer }[], + ) => Promise, +): Promise<{ requested: number }> { + const due = await loadDueConnections(new Date()); + if (due.length === 0) return { requested: 0 }; + + await sendEvent( + "request-polls", + due.map((connection) => ({ + name: INTEGRATION_POLL_EVENT, + data: { connectionId: connection.id, provider: connection.provider }, + })), + ); + return { requested: due.length }; +} + +export async function recordFailedPoll( + request: unknown, + error: unknown, +): Promise { + const { connectionId, provider } = pollRequest.parse(request); + console.error( + `[IntegrationFreshness] Poll failed for connection ${connectionId} (${provider}):`, + error, + ); + await recordPollFailure(connectionId, new Date()); +} + +export const integrationSync = inngest.createFunction( + { + id: "integration-sync", + name: "Integration sync", + retries: 2, + triggers: [{ cron: "0 4 * * *" }], + }, + ({ step }) => + requestDuePolls(async (id, events) => { + await step.sendEvent(id, events); + }), +); + +export const integrationPoll = inngest.createFunction( + { + id: "integration-poll", + name: "Integration poll", + retries: 2, + concurrency: { key: "event.data.provider", limit: POLL_CONCURRENCY }, + triggers: [{ event: INTEGRATION_POLL_EVENT }], + onFailure: ({ event }) => + recordFailedPoll(event.data.event.data, event.data.error), + }, + ({ event }) => pollConnection(pollRequest.parse(event.data).connectionId), +); diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts index b873ba6..ff382b3 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts @@ -1,49 +1,32 @@ import { notLapsedSubscription } from "@scibly/api/entitlement"; -import { routes } from "@scibly/routes"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; const db = vi.hoisted(() => ({ - integrationConnection: { findMany: vi.fn(), update: vi.fn() }, - notebookSource: { findMany: vi.fn(), updateMany: vi.fn() }, - integrationSyncLease: { - updateMany: vi.fn(), - create: vi.fn(), + integrationConnection: { + findMany: vi.fn(), findUnique: vi.fn(), + update: vi.fn(), }, + notebookSource: { findMany: vi.fn(), updateMany: vi.fn() }, })); const provider = vi.hoisted(() => ({ pollModifiedPages: vi.fn() })); const registry = vi.hoisted(() => ({ getProvider: vi.fn() })); const crypto = vi.hoisted(() => ({ decryptApiKey: vi.fn() })); -vi.mock("@scibly/db", async () => { - const client = await import("@scibly/db/client"); - return { db, Prisma: client.Prisma }; -}); +vi.mock("@scibly/db", () => ({ db })); vi.mock("@/features/integrations/server/registry", () => registry); vi.mock("@/lib/crypto/api-key", () => crypto); -vi.mock("@/env", () => ({ - env: { - CRON_SECRET: "test-cron-secret", - NEXT_PUBLIC_APP_URL: "https://app.test", - }, -})); -const prismaClient = await import("@scibly/db/client"); const { - acquireSyncLease, backoffMs, - continueSyncLease, getPollingStart, - loadOwedConnections, - MAX_SYNC_HOPS, - releaseSyncLease, - runSyncStep, - SYNC_BATCH_SIZE, + loadDueConnections, + pollConnection, + recordPollFailure, SYNC_CLOCK_SKEW_MS, - SYNC_HOP_DEADLINE_MS, SYNC_WINDOW_FLOOR_MS, } = await import("./sync-source-freshness"); @@ -51,38 +34,25 @@ const NOW = new Date("2026-07-27T03:00:00.000Z"); const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; -const CHAIN_STARTED_AT = new Date("2026-07-27T02:59:00.000Z"); -const LEASE = { - token: "lease-token", - chainStartedAt: CHAIN_STARTED_AT, - hops: 0, -}; - type Connection = { id: string; provider: string; accessTokenEncrypted: string; lastPolledAt: Date | null; - consecutiveFailures: number; }; -const fetchMock = vi.fn(); - function connection(overrides: Partial = {}): Connection { return { id: "conn-1", - provider: "notion", + provider: "NOTION", accessTokenEncrypted: "encrypted-token", lastPolledAt: null, - consecutiveFailures: 0, ...overrides, }; } -function owed(batch: Connection[], remaining: Connection[] = []): void { - db.integrationConnection.findMany - .mockResolvedValueOnce(batch) - .mockResolvedValueOnce(remaining); +function stored(row: Partial & { consecutiveFailures?: number }) { + db.integrationConnection.findUnique.mockResolvedValue(row); } function sources(...rows: { id: string; externalId: string | null }[]): void { @@ -108,21 +78,12 @@ function writtenTo(connectionId: string) { return call?.[0].data; } -function uniqueViolation() { - return new prismaClient.Prisma.PrismaClientKnownRequestError( - "Unique constraint failed", - { code: "P2002", clientVersion: "7.8.0" }, - ); -} - beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers({ now: NOW }); - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockResolvedValue({ ok: true }); db.integrationConnection.findMany.mockResolvedValue([]); + db.integrationConnection.findUnique.mockResolvedValue(connection()); db.integrationConnection.update.mockResolvedValue({}); db.notebookSource.findMany.mockResolvedValue([]); db.notebookSource.updateMany.mockImplementation( @@ -130,13 +91,6 @@ beforeEach(() => { count: where.id.in.length, }), ); - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 1 }); - db.integrationSyncLease.create.mockResolvedValue({ id: "singleton" }); - db.integrationSyncLease.findUnique.mockResolvedValue({ - token: LEASE.token, - chainStartedAt: CHAIN_STARTED_AT, - hops: 1, - }); registry.getProvider.mockReturnValue(provider); crypto.decryptApiKey.mockReturnValue("plain-token"); provider.pollModifiedPages.mockResolvedValue([]); @@ -176,10 +130,10 @@ describe("KW1/KW4/KW5: the interval a poll covers", () => { it("asks the provider for the window its own watermark implies", async () => { const lastPolledAt = new Date(NOW.getTime() - 6 * HOUR); - owed([connection({ lastPolledAt })]); + stored(connection({ lastPolledAt })); sources({ id: "src-a", externalId: "page-a" }); - await runSyncStep(LEASE); + await pollConnection("conn-1"); expect(provider.pollModifiedPages).toHaveBeenCalledWith( "plain-token", @@ -188,118 +142,66 @@ describe("KW1/KW4/KW5: the interval a poll covers", () => { }); it("KW5: never reads a source's own sync timestamp to decide the window", async () => { - owed([connection({ lastPolledAt: new Date(NOW.getTime() - HOUR) })]); + stored(connection({ lastPolledAt: new Date(NOW.getTime() - HOUR) })); sources({ id: "src-a", externalId: "page-a" }); - await runSyncStep(LEASE); + await pollConnection("conn-1"); const [args] = db.notebookSource.findMany.mock.calls[0]; expect(args.select).toEqual({ id: true, externalId: true }); }); }); -describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for", () => { - it("takes the least-recently-attempted first, bounded by the batch", async () => { - await loadOwedConnections(LEASE, NOW); +describe("KS1/KS2/KF3/KB1/KB2/KB4: which connections a sync is due to poll", () => { + it("KS2: takes the least-recently-attempted first", async () => { + await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(args.orderBy).toEqual({ lastAttemptedAt: { sort: "asc", nulls: "first" }, }); - expect(args.take).toBe(SYNC_BATCH_SIZE); - }); - - it("KC4: owes only connections not yet attempted in this chain", async () => { - await loadOwedConnections(LEASE, NOW); - - const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(args.where.OR).toEqual([ - { lastAttemptedAt: null }, - { lastAttemptedAt: { lt: CHAIN_STARTED_AT } }, - ]); }); it("KF3: excludes a connection still inside its backoff", async () => { - await loadOwedConnections(LEASE, NOW); + await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - - expect(args.where.AND).toEqual([ - { OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: NOW } }] }, + expect(args.where.OR).toEqual([ + { nextPollAfter: null }, + { nextPollAfter: { lte: NOW } }, ]); }); -}); -describe("KB1/KB2/KB3/KB4: organizations that can pay for what a poll leads to", () => { it("KB1/KB2: owes only connections of an organization with a live subscription", async () => { - await loadOwedConnections(LEASE, NOW); + await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(args.where.organization).toEqual({ subscription: notLapsedSubscription(NOW), }); }); it("KB4: does not restate the affordability rule the debit already owns", async () => { - await loadOwedConnections(LEASE, NOW); + await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(Object.keys(args.where.organization)).toEqual(["subscription"]); }); - it("KB4: charges nothing itself — marking is the whole of what a poll does", async () => { - owed([connection()]); - sources({ id: "src-a", externalId: "page-a" }); - modified("page-a"); - - const { totals } = await runSyncStep(LEASE); - - expect(totals).toMatchObject({ marked: 1 }); - expect(db.notebookSource.updateMany).toHaveBeenCalledWith({ - where: { id: { in: ["src-a"] } }, - data: { staleAt: NOW }, - }); - }); - - it("KB1: a hop whose only connections lapsed polls nothing and ends the chain", async () => { - owed([]); - - const { totals, continued } = await runSyncStep(LEASE); - - expect(provider.pollModifiedPages).not.toHaveBeenCalled(); - expect(totals).toMatchObject({ polled: 0, connectionsFailed: 0 }); - expect(continued).toBe(false); - }); + it("hands out an id and a provider, never a credential", async () => { + await loadDueConnections(NOW); - it("KB3: a restored subscription resumes from the watermark it was skipped with", async () => { - const lastPolledAt = new Date(NOW.getTime() - 3 * DAY); - owed([connection({ id: "conn-restored", lastPolledAt })]); - sources({ id: "src-a", externalId: "page-a" }); - modified("page-a"); - - await runSyncStep(LEASE); - - expect(provider.pollModifiedPages).toHaveBeenCalledWith( - "plain-token", - new Date(lastPolledAt.getTime() - SYNC_CLOCK_SKEW_MS), - ); - expect(writtenTo("conn-restored")).toMatchObject({ - lastPolledAt: NOW, - consecutiveFailures: 0, - nextPollAfter: null, - }); + const [args] = db.integrationConnection.findMany.mock.calls[0]; + expect(args.select).toEqual({ id: true, provider: true }); }); }); describe("KS3/KS4: which sources a connection contributes", () => { it("takes only READY sources with an external page behind them", async () => { - owed([connection({ id: "conn-7" })]); + stored(connection({ id: "conn-7" })); sources({ id: "src-a", externalId: "page-a" }); - await runSyncStep(LEASE); + await pollConnection("conn-7"); const [args] = db.notebookSource.findMany.mock.calls[0]; expect(args.where).toEqual({ @@ -310,134 +212,148 @@ describe("KS3/KS4: which sources a connection contributes", () => { }); it("does not poll a connection with nothing syncable behind it", async () => { - owed([connection()]); sources(); - const { totals } = await runSyncStep(LEASE); + const outcome = await pollConnection("conn-1"); expect(provider.pollModifiedPages).not.toHaveBeenCalled(); - expect(totals.connectionsEmpty).toBe(1); - expect(totals.connectionsFailed).toBe(0); + expect(outcome).toEqual({ status: "empty" }); }); - it("still records the attempt, so an empty connection cannot stall the chain", async () => { - owed([connection()]); + it("still records the attempt, so an empty connection keeps its place in the order", async () => { sources(); - await runSyncStep(LEASE); + await pollConnection("conn-1"); expect(writtenTo("conn-1")).toEqual({ lastAttemptedAt: NOW }); }); }); -describe("KF1: one broken integration", () => { - const broken = connection({ id: "conn-broken" }); - const healthy = connection({ id: "conn-healthy" }); +describe("KR1/KR3: what a poll marks and what it reports", () => { + beforeEach(() => { + sources( + { id: "src-a", externalId: "page-a" }, + { id: "src-b", externalId: "page-b" }, + { id: "src-c", externalId: "page-c" }, + ); + }); + + it("KR1: marks only the sources the provider reported as modified", async () => { + modified("page-b"); + + const outcome = await pollConnection("conn-1"); + + expect(markedStale()).toEqual(["src-b"]); + expect(outcome).toEqual({ status: "polled", marked: 1, unchanged: 2 }); + }); + + it("KR1: marks nothing when the provider reports no changes", async () => { + modified(); + + const outcome = await pollConnection("conn-1"); + + expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(outcome).toEqual({ status: "polled", marked: 0, unchanged: 3 }); + }); + + it("KR3: marks a connection's whole changed set in one write", async () => { + modified("page-a", "page-b", "page-c"); + + const outcome = await pollConnection("conn-1"); + expect(db.notebookSource.updateMany).toHaveBeenCalledTimes(1); + expect(markedStale()).toEqual(["src-a", "src-b", "src-c"]); + expect(outcome).toEqual({ status: "polled", marked: 3, unchanged: 0 }); + }); + + it("KB4: leaves the mark for the ingest to clear, changing nothing else", async () => { + modified("page-a"); + + await pollConnection("conn-1"); + + const [args] = db.notebookSource.updateMany.mock.calls[0]; + expect(args.data).toEqual({ staleAt: NOW }); + }); +}); + +describe("KF1: a poll that cannot run", () => { it.each([ { case: "a provider the registry does not know", break: () => - registry.getProvider.mockImplementation((name: string) => { - if (name === "gone") throw new Error("Unknown provider: gone"); - return provider; + registry.getProvider.mockImplementation(() => { + throw new Error("Unknown provider: gone"); }), - brokenConnection: connection({ id: "conn-broken", provider: "gone" }), + message: "Unknown provider: gone", }, { case: "a credential that will not decrypt", break: () => - crypto.decryptApiKey.mockImplementation((token: string) => { - if (token === "rotated-key") throw new Error("bad ciphertext"); - return "plain-token"; + crypto.decryptApiKey.mockImplementation(() => { + throw new Error("bad ciphertext"); }), - brokenConnection: connection({ - id: "conn-broken", - accessTokenEncrypted: "rotated-key", - }), + message: "bad ciphertext", }, { case: "a poll the provider rejects", - break: () => { - crypto.decryptApiKey.mockImplementation((token: string) => - token === "revoked" ? "revoked" : "plain-token", - ); - const pages = [{ id: "page-a" }]; - provider.pollModifiedPages.mockImplementation(async (token: string) => { - if (token === "revoked") throw new Error("401 from provider"); - return pages; - }); - }, - brokenConnection: connection({ - id: "conn-broken", - accessTokenEncrypted: "revoked", - }), + break: () => + provider.pollModifiedPages.mockRejectedValue( + new Error("401 from provider"), + ), + message: "401 from provider", }, - ])("$case costs that connection and nothing else", async (scenario) => { - owed([scenario.brokenConnection, healthy]); - sources({ id: "src-a", externalId: "page-a" }); - modified("page-a"); - - scenario.break(); - - const { totals } = await runSyncStep(LEASE); + ])( + "$case throws, so Inngest is the one that retries it", + async (scenario) => { + sources({ id: "src-a", externalId: "page-a" }); + scenario.break(); - expect(totals.connectionsFailed).toBe(1); - expect(totals.polled).toBe(1); - expect(totals.marked).toBe(1); - expect(writtenTo("conn-healthy")).toMatchObject({ lastPolledAt: NOW }); - }); + await expect(pollConnection("conn-1")).rejects.toThrow(scenario.message); + }, + ); - it("KF5: names the connection and its provider in the log", async () => { - provider.pollModifiedPages.mockRejectedValue( - new Error("401 from provider"), - ); - owed([connection({ id: "conn-broken", provider: "notion" })]); + it("KW2: a throwing poll writes nothing at all — not the watermark, not the attempt", async () => { + stored(connection({ lastPolledAt: new Date(NOW.getTime() - 3 * DAY) })); sources({ id: "src-a", externalId: "page-a" }); + provider.pollModifiedPages.mockRejectedValue(new Error("provider down")); - await runSyncStep(LEASE); + await expect(pollConnection("conn-1")).rejects.toThrow(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("conn-broken"), - expect.any(Error), - ); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("notion"), - expect.any(Error), - ); + expect(db.integrationConnection.update).not.toHaveBeenCalled(); + expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); }); - it("does not count a failed poll's sources as anything", async () => { - provider.pollModifiedPages.mockRejectedValue( - new Error("401 from provider"), - ); - owed([broken]); - sources({ id: "src-a", externalId: "page-a" }); + it("a connection disconnected before its turn is not polled and not backed off", async () => { + db.integrationConnection.findUnique.mockResolvedValue(null); - const { totals } = await runSyncStep(LEASE); + expect(await pollConnection("conn-gone")).toEqual({ status: "gone" }); + expect(db.integrationConnection.update).not.toHaveBeenCalled(); - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); - expect(totals).toMatchObject({ marked: 0, unchanged: 0 }); + await recordPollFailure("conn-gone", NOW); + expect(db.integrationConnection.update).not.toHaveBeenCalled(); }); }); -describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { - it("KW2: a failed poll leaves the watermark where it was", async () => { - provider.pollModifiedPages.mockRejectedValue(new Error("provider down")); - owed([connection({ lastPolledAt: new Date(NOW.getTime() - 3 * DAY) })]); +describe("KW1/KW2/KF2/KF3/KF4: what an attempt writes down", () => { + it("KW1: the watermark takes the instant the poll started, not the one it ended", async () => { sources({ id: "src-a", externalId: "page-a" }); + provider.pollModifiedPages.mockImplementation(async () => { + vi.advanceTimersByTime(30_000); + return []; + }); - await runSyncStep(LEASE); + await pollConnection("conn-1"); - expect(writtenTo("conn-1")).not.toHaveProperty("lastPolledAt"); + expect(writtenTo("conn-1")).toMatchObject({ + lastPolledAt: NOW, + lastAttemptedAt: new Date(NOW.getTime() + 30_000), + }); }); - it("KF2: a failed poll still moves the attempt timestamp", async () => { - provider.pollModifiedPages.mockRejectedValue(new Error("provider down")); - owed([connection()]); - sources({ id: "src-a", externalId: "page-a" }); + it("KF2: a failure moves the attempt timestamp and the counter", async () => { + stored({ consecutiveFailures: 0 }); - await runSyncStep(LEASE); + await recordPollFailure("conn-1", NOW); expect(writtenTo("conn-1")).toMatchObject({ lastAttemptedAt: NOW, @@ -445,6 +361,14 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { }); }); + it("KW2: a failure leaves the watermark where it was", async () => { + stored({ consecutiveFailures: 0 }); + + await recordPollFailure("conn-1", NOW); + + expect(writtenTo("conn-1")).not.toHaveProperty("lastPolledAt"); + }); + it.each([ { failures: 0, case: "nothing for the first failure", expected: 0 }, { failures: 1, case: "nothing for the second", expected: 0 }, @@ -459,11 +383,9 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { ])( "KF3: backs a failing connection off — $case", async ({ failures, expected }) => { - provider.pollModifiedPages.mockRejectedValue(new Error("provider down")); - owed([connection({ consecutiveFailures: failures })]); - sources({ id: "src-a", externalId: "page-a" }); + stored({ consecutiveFailures: failures }); - await runSyncStep(LEASE); + await recordPollFailure("conn-1", NOW); expect(writtenTo("conn-1")).toMatchObject({ consecutiveFailures: failures + 1, @@ -481,10 +403,9 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { }); it("KF4: any success clears the backoff and the failure count", async () => { - owed([connection({ consecutiveFailures: 4 })]); sources({ id: "src-a", externalId: "page-a" }); - await runSyncStep(LEASE); + await pollConnection("conn-1"); expect(writtenTo("conn-1")).toEqual({ lastPolledAt: NOW, @@ -493,231 +414,4 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { nextPollAfter: null, }); }); - - it("KW3: a connection the hop never reached keeps its watermark", async () => { - const unreached = connection({ id: "conn-later" }); - owed([connection()], [unreached]); - sources({ id: "src-a", externalId: "page-a" }); - - await runSyncStep(LEASE); - - expect(writtenTo("conn-later")).toBeUndefined(); - }); -}); - -describe("KR1/KR3: what the run marks and what it reports", () => { - beforeEach(() => { - owed([connection()]); - sources( - { id: "src-a", externalId: "page-a" }, - { id: "src-b", externalId: "page-b" }, - { id: "src-c", externalId: "page-c" }, - ); - }); - - it("KR1: marks only the sources the provider reported as modified", async () => { - modified("page-b"); - - const { totals } = await runSyncStep(LEASE); - - expect(markedStale()).toEqual(["src-b"]); - expect(totals).toMatchObject({ marked: 1, unchanged: 2 }); - }); - - it("KR1: marks nothing when the provider reports no changes", async () => { - modified(); - - const { totals } = await runSyncStep(LEASE); - - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); - expect(totals).toMatchObject({ polled: 1, marked: 0, unchanged: 3 }); - }); - - it("KR3: marks a connection's whole changed set in one write", async () => { - modified("page-a", "page-b", "page-c"); - - const { totals } = await runSyncStep(LEASE); - - expect(db.notebookSource.updateMany).toHaveBeenCalledTimes(1); - expect(markedStale()).toEqual(["src-a", "src-b", "src-c"]); - expect(totals).toMatchObject({ marked: 3, unchanged: 0 }); - }); - - it("KR3: leaves the mark for the ingest to clear, changing nothing else", async () => { - modified("page-a"); - - await runSyncStep(LEASE); - - const [args] = db.notebookSource.updateMany.mock.calls[0]; - expect(args.data).toEqual({ staleAt: NOW }); - }); -}); - -describe("KC2/KC3: the singleton lease", () => { - it("takes the lease when the one on record is stale", async () => { - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 1 }); - - const lease = await acquireSyncLease(); - - expect(lease).toMatchObject({ hops: 0, token: expect.any(String) }); - expect(db.integrationSyncLease.create).not.toHaveBeenCalled(); - const [args] = db.integrationSyncLease.updateMany.mock.calls[0]; - expect(args.where.heartbeatAt.lt).toBeInstanceOf(Date); - }); - - it("takes the lease when no chain has ever run", async () => { - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 0 }); - - expect(await acquireSyncLease()).toMatchObject({ hops: 0 }); - expect(db.integrationSyncLease.create).toHaveBeenCalledTimes(1); - }); - - it("refuses a second trigger while a live chain holds the lease", async () => { - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 0 }); - db.integrationSyncLease.create.mockRejectedValue(uniqueViolation()); - - expect(await acquireSyncLease()).toBeNull(); - }); - - it("surfaces a database failure rather than silently declining to run", async () => { - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 0 }); - db.integrationSyncLease.create.mockRejectedValue( - new Error("connection lost"), - ); - - await expect(acquireSyncLease()).rejects.toThrow("connection lost"); - }); - - it("KC4: a hop reads the chain's start instant from the row, not from its caller", async () => { - db.integrationSyncLease.updateMany.mockResolvedValue({ count: 1 }); - - const lease = await continueSyncLease("lease-token"); - - expect(lease).toEqual({ - token: "lease-token", - chainStartedAt: CHAIN_STARTED_AT, - hops: 1, - }); - const [args] = db.integrationSyncLease.updateMany.mock.calls[0]; - expect(args.data.hops).toEqual({ increment: 1 }); - }); - - it.each([ - { - case: "its token was taken over or released", - held: { count: 0 }, - row: null, - }, - { - case: "another chain took the row between the update and the read", - held: { count: 1 }, - row: { - token: "someone-elses", - chainStartedAt: CHAIN_STARTED_AT, - hops: 1, - }, - }, - ])("stops a hop whose $case", async ({ held, row }) => { - db.integrationSyncLease.updateMany.mockResolvedValue(held); - db.integrationSyncLease.findUnique.mockResolvedValue(row); - - expect(await continueSyncLease("lease-token")).toBeNull(); - }); - - it("KC3: releasing expires the lease rather than deleting the row", async () => { - await releaseSyncLease(LEASE); - - expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ - where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, - }); - }); -}); - -describe("KC1/KC5/KC6: how a chain ends", () => { - it("hands the lease to a fresh invocation while a connection is still owed", async () => { - owed([connection()], [connection({ id: "conn-later" })]); - sources({ id: "src-a", externalId: "page-a" }); - - const { continued } = await runSyncStep(LEASE); - - expect(continued).toBe(true); - expect(fetchMock).toHaveBeenCalledWith( - routes.app.api.cron.syncIntegrations, - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ token: LEASE.token }), - }), - ); - }); - - it.each([ - { case: "nothing was owed when it started", batch: [] }, - { case: "it reached the last connection owed", batch: [connection()] }, - ])("KC5: stops and releases the lease when $case", async ({ batch }) => { - owed(batch, []); - sources({ id: "src-a", externalId: "page-a" }); - - const { continued } = await runSyncStep(LEASE); - - expect(continued).toBe(false); - expect(fetchMock).not.toHaveBeenCalled(); - expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ - where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, - }); - }); - - it("KC6: hands off mid-batch when a connection turns out to be pathologically slow", async () => { - owed([connection({ id: "conn-slow" }), connection({ id: "conn-next" })]); - sources({ id: "src-a", externalId: "page-a" }); - provider.pollModifiedPages.mockImplementation(async () => { - vi.advanceTimersByTime(SYNC_HOP_DEADLINE_MS + 1_000); - return []; - }); - - const { continued } = await runSyncStep(LEASE); - - expect(continued).toBe(true); - expect(writtenTo("conn-next")).toBeUndefined(); - - expect(db.integrationConnection.findMany).toHaveBeenCalledTimes(1); - }); - - it("KC5: stops loudly at the runaway backstop rather than chaining forever", async () => { - const { totals, continued } = await runSyncStep({ - ...LEASE, - hops: MAX_SYNC_HOPS, - }); - - expect(continued).toBe(false); - expect(totals.polled).toBe(0); - expect(db.integrationConnection.findMany).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("MAX_SYNC_HOPS"), - ); - }); - - it("drops the lease when the hop itself fails, rather than letting it expire", async () => { - db.integrationConnection.findMany.mockRejectedValue(new Error("db gone")); - - const { continued } = await runSyncStep(LEASE); - - expect(continued).toBe(false); - expect(db.integrationSyncLease.updateMany).toHaveBeenCalledWith({ - where: { id: "singleton", token: LEASE.token }, - data: { heartbeatAt: new Date(0) }, - }); - }); - - it("does not lose the remaining connections when the handoff cannot be delivered", async () => { - owed([connection()], [connection({ id: "conn-later" })]); - sources({ id: "src-a", externalId: "page-a" }); - fetchMock.mockRejectedValue(new Error("ECONNREFUSED")); - - const { continued } = await runSyncStep(LEASE); - - expect(continued).toBe(true); - expect(writtenTo("conn-later")).toBeUndefined(); - }); }); diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.ts b/apps/app/src/features/integrations/server/sync-source-freshness.ts index 8087ee6..e5f7a67 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.ts @@ -1,27 +1,17 @@ import { notLapsedSubscription } from "@scibly/api/entitlement"; import { TimeHelpers } from "@scibly/api/rate-limit"; -import { db, Prisma } from "@scibly/db"; -import { routes } from "@scibly/routes"; +import { db } from "@scibly/db"; -import { env } from "@/env"; import { getProvider } from "@/features/integrations/server/registry"; import { decryptApiKey } from "@/lib/crypto/api-key"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; -// No webhook exists for any integration, so this scheduled poll is the only way a changed page is noticed; `lastPolledAt` is the per-connection watermark. +// No integration provider offers a webhook, so this scheduled poll is the only way a changed page is noticed. export const SYNC_CLOCK_SKEW_MS = TimeHelpers.IN_MS.MINUTE; export const SYNC_WINDOW_FLOOR_MS = TimeHelpers.IN_MS.DAY * 7; -export const SYNC_BATCH_SIZE = 10; - -export const SYNC_HOP_DEADLINE_MS = TimeHelpers.IN_MS.MINUTE * 4; - -export const MAX_SYNC_HOPS = 50; - -const SYNC_LEASE_MS = TimeHelpers.IN_MS.MINUTE * 10; - const SYNC_BACKOFF_MS: readonly number[] = [ 0, 0, @@ -32,122 +22,20 @@ const SYNC_BACKOFF_MS: readonly number[] = [ ]; const SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 7; -const SYNC_LEASE_ID = "singleton"; - -export interface SyncLease { - token: string; - chainStartedAt: Date; - hops: number; -} - -interface SyncRunTotals { - polled: number; - - connectionsFailed: number; - - connectionsEmpty: number; - - marked: number; - - unchanged: number; -} - export function backoffMs(consecutiveFailures: number): number { return SYNC_BACKOFF_MS[consecutiveFailures] ?? SYNC_BACKOFF_CAP_MS; } -export async function acquireSyncLease(): Promise { - const token = crypto.randomUUID(); - const chainStartedAt = new Date(); - const taken = await db.integrationSyncLease.updateMany({ - where: { - id: SYNC_LEASE_ID, - heartbeatAt: { lt: new Date(Date.now() - SYNC_LEASE_MS) }, - }, - data: { token, heartbeatAt: new Date(), chainStartedAt, hops: 0 }, - }); - if (taken.count > 0) return { token, chainStartedAt, hops: 0 }; - - try { - await db.integrationSyncLease.create({ - data: { - id: SYNC_LEASE_ID, - token, - heartbeatAt: new Date(), - chainStartedAt, - hops: 0, - }, - }); - return { token, chainStartedAt, hops: 0 }; - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - return null; - } - throw error; - } -} - -export async function continueSyncLease( - token: string, -): Promise { - const held = await db.integrationSyncLease.updateMany({ - where: { id: SYNC_LEASE_ID, token }, - data: { heartbeatAt: new Date(), hops: { increment: 1 } }, - }); - if (held.count === 0) return null; - - const row = await db.integrationSyncLease.findUnique({ - where: { id: SYNC_LEASE_ID }, - select: { token: true, chainStartedAt: true, hops: true }, - }); - if (!row || row.token !== token) return null; - return { token, chainStartedAt: row.chainStartedAt, hops: row.hops }; -} - -export async function releaseSyncLease(lease: SyncLease): Promise { - await db.integrationSyncLease.updateMany({ - where: { id: SYNC_LEASE_ID, token: lease.token }, - data: { heartbeatAt: new Date(0) }, - }); -} - -type SyncConnection = { - id: string; - provider: string; - accessTokenEncrypted: string; - lastPolledAt: Date | null; - consecutiveFailures: number; -}; - -const subscribedOrganization = (now: Date): Prisma.OrganizationWhereInput => ({ - subscription: notLapsedSubscription(now), -}); - -export async function loadOwedConnections( - lease: SyncLease, +export async function loadDueConnections( now: Date, -): Promise { +): Promise<{ id: string; provider: string }[]> { return db.integrationConnection.findMany({ where: { - organization: subscribedOrganization(now), - OR: [ - { lastAttemptedAt: null }, - { lastAttemptedAt: { lt: lease.chainStartedAt } }, - ], - AND: [{ OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }] }], - }, - select: { - id: true, - provider: true, - accessTokenEncrypted: true, - lastPolledAt: true, - consecutiveFailures: true, + organization: { subscription: notLapsedSubscription(now) }, + OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }], }, + select: { id: true, provider: true }, orderBy: { lastAttemptedAt: { sort: "asc", nulls: "first" } }, - take: SYNC_BATCH_SIZE, }); } @@ -172,166 +60,96 @@ export function getPollingStart(lastPolledAt: Date | null, now: Date): Date { return new Date(Math.max(lastPolledAt.getTime() - SYNC_CLOCK_SKEW_MS, floor)); } -async function recordAttempt( - integrationId: string, - data: Prisma.IntegrationConnectionUpdateInput, -): Promise { - await db.integrationConnection.update({ - where: { id: integrationId }, - data, - }); -} - -async function recordPollSuccess( - integrationId: string, - pollStartedAt: Date, -): Promise { - await recordAttempt(integrationId, { - lastPolledAt: pollStartedAt, - lastAttemptedAt: new Date(), - consecutiveFailures: 0, - nextPollAfter: null, - }); -} - -async function recordPollFailure( - connection: SyncConnection, - now: Date, -): Promise { - const failures = connection.consecutiveFailures + 1; - const delay = backoffMs(failures); - await recordAttempt(connection.id, { - lastAttemptedAt: now, - consecutiveFailures: failures, - nextPollAfter: delay > 0 ? new Date(now.getTime() + delay) : null, - }); -} - async function markChangedSourcesStale( sources: SyncableSource[], modifiedIds: Set, - totals: SyncRunTotals, -): Promise { +): Promise<{ marked: number; unchanged: number }> { const changed = sources.filter( (source) => source.externalId !== null && modifiedIds.has(source.externalId), ); - totals.unchanged += sources.length - changed.length; - if (changed.length === 0) return; + const unchanged = sources.length - changed.length; + if (changed.length === 0) return { marked: 0, unchanged }; const marked = await db.notebookSource.updateMany({ where: { id: { in: changed.map((source) => source.id) } }, data: { staleAt: new Date() }, }); - totals.marked += marked.count; + return { marked: marked.count, unchanged }; } -async function syncConnection( - connection: SyncConnection, - totals: SyncRunTotals, -): Promise { +export type PollOutcome = + | { status: "gone" } + | { status: "empty" } + | { status: "polled"; marked: number; unchanged: number }; + +// Throws on purpose: the throw is what Inngest retries. +export async function pollConnection( + connectionId: string, +): Promise { const now = new Date(); - const sources = await loadSyncableSources(connection.id); + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { + id: true, + provider: true, + accessTokenEncrypted: true, + lastPolledAt: true, + }, + }); + if (!connection) return { status: "gone" }; + const sources = await loadSyncableSources(connection.id); if (sources.length === 0) { - totals.connectionsEmpty += 1; - await recordAttempt(connection.id, { lastAttemptedAt: now }); - return; - } - - const pollFrom = getPollingStart(connection.lastPolledAt, now); - let modifiedIds: Set; - try { - const provider = getProvider(connection.provider); - const token = decryptApiKey(connection.accessTokenEncrypted); - const pages = await provider.pollModifiedPages(token, pollFrom); - modifiedIds = new Set(pages.map((page) => page.id)); - } catch (error) { - console.error( - `[IntegrationFreshness] Poll failed for connection ${connection.id} (${connection.provider}):`, - error, - ); - totals.connectionsFailed += 1; - await recordPollFailure(connection, now); - return; + await db.integrationConnection.update({ + where: { id: connection.id }, + data: { lastAttemptedAt: now }, + }); + return { status: "empty" }; } - totals.polled += 1; - await markChangedSourcesStale(sources, modifiedIds, totals); - await recordPollSuccess(connection.id, now); -} + const provider = getProvider(connection.provider); + const token = decryptApiKey(connection.accessTokenEncrypted); + const pages = await provider.pollModifiedPages( + token, + getPollingStart(connection.lastPolledAt, now), + ); -interface SyncStepResult { - totals: SyncRunTotals; - continued: boolean; + const counts = await markChangedSourcesStale( + sources, + new Set(pages.map((page) => page.id)), + ); + await db.integrationConnection.update({ + where: { id: connection.id }, + // The watermark takes `now`, the instant the poll started, so an edit made while it ran is covered by the next poll rather than missed. + data: { + lastPolledAt: now, + lastAttemptedAt: new Date(), + consecutiveFailures: 0, + nextPollAfter: null, + }, + }); + return { status: "polled", ...counts }; } -export async function runSyncStep(lease: SyncLease): Promise { - const totals: SyncRunTotals = { - polled: 0, - connectionsFailed: 0, - connectionsEmpty: 0, - marked: 0, - unchanged: 0, - }; - const hopStartedAt = Date.now(); - - try { - if (lease.hops >= MAX_SYNC_HOPS) { - console.error( - `[IntegrationFreshness] Chain hit MAX_SYNC_HOPS (${MAX_SYNC_HOPS}); stopping. The termination condition is wrong.`, - ); - await releaseSyncLease(lease); - return { totals, continued: false }; - } - - const connections = await loadOwedConnections(lease, new Date()); - let deadlineReached = false; - for (const connection of connections) { - await syncConnection(connection, totals); - if (Date.now() - hopStartedAt >= SYNC_HOP_DEADLINE_MS) { - deadlineReached = true; - break; - } - } - - const owed = deadlineReached - ? true - : (await loadOwedConnections(lease, new Date())).length > 0; - if (!owed) { - await releaseSyncLease(lease); - return { totals, continued: false }; - } - - await postToSyncRoute({ token: lease.token }); - return { totals, continued: true }; - } catch (error) { - console.error("[IntegrationFreshness] Hop failed:", error); - await releaseSyncLease(lease).catch(() => undefined); - return { totals, continued: false }; - } -} +export async function recordPollFailure( + connectionId: string, + now: Date, +): Promise { + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { consecutiveFailures: true }, + }); + if (!connection) return; -async function postToSyncRoute(body: { token: string }): Promise { - if (!env.CRON_SECRET) { - console.error( - "[IntegrationFreshness] CRON_SECRET is not configured; chain not continued", - ); - return; - } - try { - await fetch(routes.app.api.cron.syncIntegrations, { - method: "POST", - headers: { - authorization: `Bearer ${env.CRON_SECRET}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }); - } catch (error) { - console.error( - "[IntegrationFreshness] Failed to continue the chain:", - error, - ); - } + const failures = connection.consecutiveFailures + 1; + const delay = backoffMs(failures); + await db.integrationConnection.update({ + where: { id: connectionId }, + data: { + lastAttemptedAt: now, + consecutiveFailures: failures, + nextPollAfter: delay > 0 ? new Date(now.getTime() + delay) : null, + }, + }); } diff --git a/apps/app/src/lib/inngest/functions/heartbeat.ts b/apps/app/src/lib/inngest/functions/heartbeat.ts deleted file mode 100644 index 8b88573..0000000 --- a/apps/app/src/lib/inngest/functions/heartbeat.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { inngest } from "../client"; - -export const HEARTBEAT_EVENT = "scibly/heartbeat.requested"; - -export const heartbeat = inngest.createFunction( - { - id: "heartbeat", - name: "Heartbeat", - retries: 2, - triggers: [{ cron: "*/15 * * * *" }, { event: HEARTBEAT_EVENT }], - }, - async ({ event, step }) => { - const beatAt = await step.run("record-beat", () => - new Date().toISOString(), - ); - - await step.run("fail-when-asked", () => { - const data: unknown = event.data; - if ( - typeof data === "object" && - data !== null && - "fail" in data && - data.fail === true - ) { - throw new Error("Heartbeat failed on request"); - } - return null; - }); - - return { beatAt, trigger: event.name }; - }, -); diff --git a/apps/app/src/lib/inngest/functions/index.ts b/apps/app/src/lib/inngest/functions/index.ts deleted file mode 100644 index e0168fc..0000000 --- a/apps/app/src/lib/inngest/functions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { heartbeat } from "./heartbeat"; - -export const inngestFunctions = [heartbeat]; diff --git a/apps/app/src/lib/inngest/functions/index.test.ts b/apps/app/src/server/inngest.test.ts similarity index 61% rename from apps/app/src/lib/inngest/functions/index.test.ts rename to apps/app/src/server/inngest.test.ts index fc166ed..cf3eb39 100644 --- a/apps/app/src/lib/inngest/functions/index.test.ts +++ b/apps/app/src/server/inngest.test.ts @@ -1,12 +1,8 @@ import { describe, expect, it } from "vitest"; -import { inngestFunctions } from "."; +import { inngestFunctions } from "./inngest"; describe("inngestFunctions", () => { - it("is what the serve route registers, so it must not be empty", () => { - expect(inngestFunctions.length).toBeGreaterThan(0); - }); - it("has no duplicate ids, which would silently replace one at sync time", () => { const ids = inngestFunctions.map((fn) => fn.id()); diff --git a/apps/app/src/server/inngest.ts b/apps/app/src/server/inngest.ts new file mode 100644 index 0000000..2148abe --- /dev/null +++ b/apps/app/src/server/inngest.ts @@ -0,0 +1,6 @@ +import { + integrationPoll, + integrationSync, +} from "@/features/integrations/server"; + +export const inngestFunctions = [integrationSync, integrationPoll]; diff --git a/apps/app/src/shared/api/cron/cron-route-guard.test.ts b/apps/app/src/shared/api/cron/cron-route-guard.test.ts deleted file mode 100644 index 3f2e0db..0000000 --- a/apps/app/src/shared/api/cron/cron-route-guard.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const env = vi.hoisted(() => ({ CRON_SECRET: "test-cron-secret" })); - -vi.mock("@/env", () => ({ env })); -const { isValidCronSecret, refuseUnauthorizedCron } = - await import("./cron-route-guard"); - -const SECRET = "test-cron-secret"; - -function request(authorization?: string): Request { - const headers = new Headers(); - if (authorization !== undefined) headers.set("authorization", authorization); - return new Request("https://app.test/api/cron/anything", { headers }); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "error").mockImplementation(() => undefined); - env.CRON_SECRET = SECRET; -}); - -describe("KD1: who gets through the door", () => { - it.each([ - { - case: "the bearer the scheduler sends", - header: `Bearer ${SECRET}`, - allowed: true, - }, - { case: "no authorization header at all", header: null, allowed: false }, - { case: "an empty authorization header", header: "", allowed: false }, - { - case: "a wrong secret of exactly the right length", - header: "Bearer tset-cron-secret", - allowed: false, - }, - { - case: "a bearer of a different length", - header: "Bearer short", - allowed: false, - }, - { - case: "the secret with no bearer scheme", - header: SECRET, - allowed: false, - }, - { - case: "the right secret with something appended", - header: `Bearer ${SECRET}x`, - allowed: false, - }, - ])("$case → allowed: $allowed", ({ header, allowed }) => { - expect(isValidCronSecret(header, SECRET)).toBe(allowed); - }); - - it("refuses an unauthorized caller with 401 and nothing about the secret", async () => { - const response = refuseUnauthorizedCron(request("Bearer wrong"), "sync"); - - expect(response?.status).toBe(401); - expect(await response?.json()).toEqual({ error: "Unauthorized" }); - }); - - it("lets an authorized caller proceed", () => { - expect( - refuseUnauthorizedCron(request(`Bearer ${SECRET}`), "sync"), - ).toBeNull(); - }); -}); - -// KD2 (constant-time comparison) can't be tested directly — node:crypto isn't -// mockable here — but its two observable consequences are covered in the KD1 -// table above. - -describe("KD3: a deployment with no secret configured", () => { - it.each([ - { case: "a caller with a plausible bearer", header: "Bearer anything" }, - { case: "a caller with none", header: undefined }, - ])("refuses $case with 500", async ({ header }) => { - env.CRON_SECRET = ""; - - const response = refuseUnauthorizedCron(request(header), "sync"); - - expect(response?.status).toBe(500); - expect(await response?.json()).toEqual({ - error: "CRON_SECRET is not configured", - }); - }); -}); diff --git a/apps/app/src/shared/api/cron/cron-route-guard.ts b/apps/app/src/shared/api/cron/cron-route-guard.ts deleted file mode 100644 index 7ee7980..0000000 --- a/apps/app/src/shared/api/cron/cron-route-guard.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { timingSafeEqual } from "node:crypto"; - -import { NextResponse } from "next/server"; - -import { env } from "@/env"; - -// KD4: shared guard for every cron route (they trigger org-wide polling and -// re-ingestion), so a fix applies everywhere instead of drifting per-copy. - -export function isValidCronSecret( - authHeader: string | null, - secret: string, -): boolean { - if (!authHeader) return false; - const expected = Buffer.from(`Bearer ${secret}`); - const actual = Buffer.from(authHeader); - - if (expected.length !== actual.length) return false; - return timingSafeEqual(expected, actual); -} - -export function refuseUnauthorizedCron( - request: Request, - routeName: string, -): NextResponse | null { - const cronSecret = env.CRON_SECRET; - if (!cronSecret) { - console.error(`[Cron] ${routeName}: CRON_SECRET is not configured`); - return NextResponse.json( - { error: "CRON_SECRET is not configured" }, - { status: 500 }, - ); - } - if (!isValidCronSecret(request.headers.get("authorization"), cronSecret)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - return null; -} diff --git a/apps/app/vercel.json b/apps/app/vercel.json index b3d8f51..b2e045d 100644 --- a/apps/app/vercel.json +++ b/apps/app/vercel.json @@ -1,9 +1,3 @@ { - "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "crons": [ - { - "path": "/api/cron/sync-integrations", - "schedule": "0 4 * * *" - } - ] + "installCommand": "cd ../.. && pnpm install --frozen-lockfile" } diff --git a/docs/adr/0004-inngest-self-hosted-orchestration.md b/docs/adr/0004-inngest-self-hosted-orchestration.md index f4b7ef4..f4dd26c 100644 --- a/docs/adr/0004-inngest-self-hosted-orchestration.md +++ b/docs/adr/0004-inngest-self-hosted-orchestration.md @@ -1,16 +1,19 @@ # Background work runs on a self-hosted Inngest Anything that outlives a request, so scheduled syncs, long generations, and -anything that has to retry, is an Inngest function. Functions live in -`apps/app/src/lib/inngest/`, get listed in `functions/index.ts`, and are served -from one route at `/api/inngest`. The engine driving them is the -`inngest/inngest` container in `docker-compose.yml`, on its own database on the -Postgres already there. Not Inngest Cloud, not a hosted queue. +anything that has to retry, is an Inngest function. A function belongs to the +feature it is about and lives with it; a generic one lives in +`apps/app/src/lib/inngest/`. Either way it is collected in +`apps/app/src/server/inngest.ts` — the composition root, the way +`server/api/root.ts` is tRPC's — and served from one route at `/api/inngest`. +The engine driving them is the `inngest/inngest` container in +`docker-compose.yml`, on its own database on the Postgres already there. Not +Inngest Cloud, not a hosted queue. -This replaces hand-rolled cron chaining, where a route takes a lease row, runs -one step, then calls itself through `after()` before the platform timeout. -`apps/app/src/app/api/cron/sync-integrations/route.ts` is the last one. It stays -until integration sync moves over. +This replaced hand-rolled cron chaining, where a route took a lease row, ran one +step, then called itself through `after()` before the platform timeout. Nothing +does that any more: the integration sync was the last one, and with it went the +lease table, the `CRON_SECRET` door, and the `crons` entry in `vercel.json`. ## Why @@ -37,4 +40,7 @@ Queues and Workflows lose on the same point, since they exist only inside Vercel - `maxDuration` on `/api/inngest` bounds one step, not a run. A model call that might outlast it belongs in `step.ai.infer`, which parks the request on the server instead. +- Fan-out is how a run per work item is got: a cron function lists what is due + and sends one event each, and a per-item function does the work under a + concurrency cap. The item's id travels in the event, never its credential. - Development needs `pnpm dev:inngest` running alongside `pnpm dev`. diff --git a/docs/docker.md b/docs/docker.md index 111ba9d..2ab8c38 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -54,9 +54,8 @@ service, starts the Inngest server, then starts every app: The Inngest server is where scheduled and background work actually runs — see [ADR 0004](adr/0004-inngest-self-hosted-orchestration.md). It calls back -into `app` at `/api/inngest` to execute each step, and the `heartbeat` -function beats every 15 minutes, so the dashboard has something in it within -the first quarter hour of a fresh install. Publishing :8288 is convenient +into `app` at `/api/inngest` to execute each step, and the dashboard is where +you watch a run and its retries. Publishing :8288 is convenient rather than required — nothing else needs it, so drop the `ports:` mapping if the host is exposed. diff --git a/docs/setup.md b/docs/setup.md index 300f20e..1ebfa53 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -7,11 +7,11 @@ steps, or [docker.md](docker.md) instead if you just want it running — ## Prerequisites -| Tool | Minimum | Recommended | -| ---------- | ------------- | ----------------------------------------------------------- | -| Node | ≥22 ([`engines`](../package.json)) | 22 LTS — matches [CI](../.github/workflows/ci.yml); production images run 24 | -| pnpm | — | 10.33.0, exact — pinned in [package.json](../package.json)'s `packageManager` field; `corepack enable` picks it up | -| PostgreSQL | — | any recent version — one database, shared by `apps/app`, `apps/web`, and `apps/collab` | +| Tool | Minimum | Recommended | +| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Node | ≥22 ([`engines`](../package.json)) | 22 LTS — matches [CI](../.github/workflows/ci.yml); production images run 24 | +| pnpm | — | 10.33.0, exact — pinned in [package.json](../package.json)'s `packageManager` field; `corepack enable` picks it up | +| PostgreSQL | — | any recent version — one database, shared by `apps/app`, `apps/web`, and `apps/collab` | ## 1. Install @@ -140,11 +140,10 @@ pnpm dev:inngest That's the Inngest dev server, dashboard on http://localhost:8288, pointed at `apps/app`'s serve route (`/api/inngest`). It picks up whatever -`apps/app/src/lib/inngest/functions/index.ts` registers, re-syncing on its own -as you edit. `heartbeat` is there to prove the wiring: it runs every 15 -minutes, and sending `scibly/heartbeat.requested` with `{ "fail": true }` from -the dashboard's event tester makes it fail, so you can watch the three -attempts `retries: 2` produces. +`apps/app/src/server/inngest.ts` registers, re-syncing on its own as you edit. +Nothing waits for a cron to come round: the dashboard's event tester sends any +event by hand, so `scibly/integration-poll.requested` with a `connectionId` +runs one poll on the spot. ## Checks @@ -166,7 +165,7 @@ pnpm validate # check + test:unit + test:e2e running, then fill credentials in as you need the features behind them. - **Background functions never run** — `pnpm dev` does not start the Inngest dev server; `pnpm dev:inngest` does, separately (see step 4). With it - running, http://localhost:8288 lists `heartbeat` under Functions; if it + running, http://localhost:8288 lists them under Functions; if it doesn't, the app wasn't reachable at http://localhost:3001/api/inngest when the server polled it. - **i18n or editor-schema errors on `dev`/`build`** — both `apps/app` and diff --git a/packages/db/migrations/20260828120000_drop_integration_sync_lease/migration.sql b/packages/db/migrations/20260828120000_drop_integration_sync_lease/migration.sql new file mode 100644 index 0000000..f71feaf --- /dev/null +++ b/packages/db/migrations/20260828120000_drop_integration_sync_lease/migration.sql @@ -0,0 +1,5 @@ +-- The sync runs on Inngest now: a cron function lists the due connections and +-- fans out one run per connection, and Inngest owns the retries. There is no +-- chain to hold a permit for, so nothing takes a lease any more. +-- DropTable +DROP TABLE IF EXISTS "integration_sync_lease"; diff --git a/packages/db/schema/integration.prisma b/packages/db/schema/integration.prisma index 5bb6c32..ed8d4ce 100644 --- a/packages/db/schema/integration.prisma +++ b/packages/db/schema/integration.prisma @@ -11,62 +11,25 @@ model IntegrationConnection { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) provider IntegrationProvider - /// AES-256-GCM encrypted access token accessTokenEncrypted String - /// Encrypted refresh token (if applicable) refreshTokenEncrypted String? - /// Token expiry (for providers with expiring tokens) tokenExpiresAt DateTime? - /// Provider workspace/site ID (e.g. Notion workspace ID) workspaceId String? - /// Human-readable workspace name for display workspaceName String? - /// User who connected the integration connectedByUserId String connectedBy User @relation(fields: [connectedByUserId], references: [id]) - /// KW1/KW2: the scheduled refresh's watermark. Advances only when a poll - /// actually succeeded, so a failed or skipped run costs delay, not the - /// interval's changes — the next success covers the whole gap. + /// Advances only on a successful poll, so a failed run costs delay, not changes. lastPolledAt DateTime? - /// KF2/KC4: advances on every attempt, successful or not. Drives selection - /// and is what lets a chain terminate: an integration that can never succeed - /// would otherwise stay owed forever. + /// Advances on every attempt, successful or not. lastAttemptedAt DateTime? - /// KF3/KF4: consecutive failed polls. Backs the connection off on an - /// escalating schedule; any success resets it to zero. consecutiveFailures Int @default(0) - /// KF3: when this connection may next be polled, written at failure time - /// from `consecutiveFailures`. A column rather than a computed predicate - /// because the delay is per-row, and a backed-off connection has to be - /// excluded by the query — a chain that merely skips it in memory would - /// keep re-selecting it and never terminate. nextPollAfter DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // One connection per provider per org @@unique([organizationId, provider]) @@index([organizationId]) - // KS2: least-recently-attempted first. Nulls sort first under - // `nullsFirst`, so a connection never polled is always at the front. + // Serves the sync's least-recently-attempted-first ordering. @@index([lastAttemptedAt]) @@map("integration_connection") } - -/// KC2: singleton lease guarding the scheduled integration refresh. One row, -/// id `singleton`. Two chains polling the same connection would double the -/// provider quota and race the same watermark. -model IntegrationSyncLease { - id String @id - token String - heartbeatAt DateTime - /// KC4: when the chain this lease belongs to started. A connection attempted - /// at or after this instant has had its turn in this chain, which is both the - /// waste bound and the termination condition. - chainStartedAt DateTime - /// KC5: hops taken. A runaway backstop only — a chain that reaches the limit - /// has a broken termination condition, and that has to be visible. - hops Int @default(0) - - @@map("integration_sync_lease") -} diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index 7dbe897..86de1aa 100644 --- a/packages/routes/src/index.ts +++ b/packages/routes/src/index.ts @@ -191,9 +191,6 @@ export const routes = { }, api: { - cron: { - syncIntegrations: toAppUrl(`${BASE_API_PATH}/cron/sync-integrations`), - }, oembed: toAppUrl(`${BASE_API_PATH}/oembed`), }, }, diff --git a/turbo.json b/turbo.json index 6150973..5cbe37b 100644 --- a/turbo.json +++ b/turbo.json @@ -43,7 +43,6 @@ "NOTION_PERSONAL_ACCESS_TOKEN", "OPENAI_API_KEY", "ENCRYPTION_KEY", - "CRON_SECRET", "INNGEST_BASE_URL", "INNGEST_EVENT_KEY", "INNGEST_SIGNING_KEY", From 7aa535fe43a36606d00b3af9232fdbf0953b58e1 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:56:26 +0200 Subject: [PATCH 35/43] docs: trim schema doc comments to their load-bearing sentence --- .../db/schema/anonymousCourseSession.prisma | 10 +-- .../db/schema/anonymousSessionSource.prisma | 2 - packages/db/schema/billing.prisma | 26 +++----- packages/db/schema/course.prisma | 8 +-- packages/db/schema/courseEnrollment.prisma | 7 +- packages/db/schema/courseVersion.prisma | 3 +- packages/db/schema/lesson.prisma | 3 +- packages/db/schema/notebook.prisma | 64 ++++++------------- packages/db/schema/onboardingStep.prisma | 4 +- packages/db/schema/organization.prisma | 5 +- packages/db/schema/scene.prisma | 22 +++---- packages/db/schema/sceneAnalytics.prisma | 10 +-- packages/db/schema/schema.prisma | 6 -- packages/db/schema/stripe.prisma | 5 +- packages/db/schema/user.prisma | 3 +- 15 files changed, 54 insertions(+), 124 deletions(-) diff --git a/packages/db/schema/anonymousCourseSession.prisma b/packages/db/schema/anonymousCourseSession.prisma index ebe1ddb..b93cf29 100644 --- a/packages/db/schema/anonymousCourseSession.prisma +++ b/packages/db/schema/anonymousCourseSession.prisma @@ -1,6 +1,5 @@ model AnonymousCourseSession { id String @id @default(cuid()) - /// Cookie-based identifier for the anonymous visitor. anonymousId String courseId String course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) @@ -8,7 +7,6 @@ model AnonymousCourseSession { courseVersion CourseVersion @relation(fields: [courseVersionId], references: [id], onDelete: Cascade) status EnrollmentStatus @default(NOT_STARTED) totalSpEarned Int @default(0) - /// Final score percentage after course completion. scorePct Int? triesUsed Int @default(0) startedAt DateTime @default(now()) @@ -17,13 +15,9 @@ model AnonymousCourseSession { updatedAt DateTime @updatedAt sceneAnalytics SceneAnalytics[] - /// Where this session was opened from. Null means the row predates source - /// tracking — deliberately not defaulted to DIRECT, which would retroactively - /// relabel every existing anonymous session as share-link traffic. + /// Null means the row predates source tracking; defaulting it to DIRECT would relabel that history as share-link traffic. sessionSource AnonymousSessionSource? - /// Normalized origin of the embedding site (scheme + host [+ port]), read - /// from the embed document request's referrer. Client-supplied and therefore - /// spoofable: this is reporting, never an authorization input. + /// Read from the embed request's referrer, so spoofable: reporting only, never an authorization input. embedOrigin String? @@unique([anonymousId, courseVersionId]) diff --git a/packages/db/schema/anonymousSessionSource.prisma b/packages/db/schema/anonymousSessionSource.prisma index 66a2b99..a7d4b91 100644 --- a/packages/db/schema/anonymousSessionSource.prisma +++ b/packages/db/schema/anonymousSessionSource.prisma @@ -1,5 +1,3 @@ -/// How an anonymous learner reached the course: the public share link, or a -/// course embedded in a customer's own website. enum AnonymousSessionSource { DIRECT EMBED diff --git a/packages/db/schema/billing.prisma b/packages/db/schema/billing.prisma index bfebbc7..ce5b2d4 100644 --- a/packages/db/schema/billing.prisma +++ b/packages/db/schema/billing.prisma @@ -17,9 +17,7 @@ enum SubscriptionStatus { @@map("subscription_status") } -/// Billable actions. Retrieval search, web fetch, and failed-ingest retries are free. -/// `TOPUP_PURCHASE` is the one value that grants rather than spends; reads that -/// summarise spend count `SPEND_ACTIONS` from `src/topup-catalogue.ts` instead. +/// `TOPUP_PURCHASE` grants rather than spends; spend summaries count `SPEND_ACTIONS` from `src/topup-catalogue.ts`. enum CreditAction { CHAT_MESSAGE IMAGE_GENERATION @@ -37,21 +35,19 @@ enum CreditBucket { @@map("credit_bucket") } -/// Plan limits live as typed constants in `src/plan-catalogue.ts`, not here. -/// Internal organizations are an ordinary plan with unreachable numbers, never a bypass flag. +/// INTERNAL is an ordinary plan with unreachable limits, never a bypass flag; limits live in `src/plan-catalogue.ts`. model OrganizationSubscription { id String @id @default(cuid()) organizationId String @unique organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) plan SubscriptionPlan status SubscriptionStatus @default(ACTIVE) - /// When the subscription entered PAST_DUE and its grace period started; null otherwise. Set once by a guarded write, cleared on recovery. + /// When the grace period started; null otherwise. pastDueSince DateTime? currentPeriodStart DateTime - /// End of the period this row is for, and so the next renewal charge date; - /// null for plans that never bill (TRIAL, INTERNAL). + /// The next renewal charge date; null for plans that never bill (TRIAL, INTERNAL). currentPeriodEnd DateTime? - /// Start of the period whose public-session ceiling warning has already been mailed; a new period re-arms it. Claimed by a guarded `updateMany`. + /// Start of the period whose session-ceiling warning was already mailed; a new period re-arms it. sessionCeilingWarnedFor DateTime? purchasedLearnerSeats Int @default(0) stripeCustomerId String? @unique @@ -62,18 +58,14 @@ model OrganizationSubscription { @@map("organization_subscription") } -/// Debit target for a guarded `updateMany`. `allowanceRemaining` resets each -/// period; `topupRemaining` never expires. +/// `allowanceRemaining` resets each period; `topupRemaining` never expires. model OrganizationCredit { id String @id @default(cuid()) organizationId String @unique organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) allowanceRemaining Int topupRemaining Int @default(0) - /// Highest allowance-warning threshold already mailed this period, as a whole - /// percentage; 0 means neither has fired. Raised by a guarded `updateMany` so - /// concurrent charges crossing one line notify once, and reset with the - /// allowance below. + /// Highest warning threshold already mailed this period, as a whole percentage; 0 means none has fired. notifiedAllowanceThreshold Int @default(0) periodStart DateTime periodEnd DateTime? @@ -83,7 +75,7 @@ model OrganizationCredit { @@map("organization_credit") } -/// Append-only charge audit trail; refunds set `refundedAt` instead of deleting rows. `TOPUP_PURCHASE` rows take their id from the Stripe checkout session, so a re-delivered purchase credits nothing. +/// Append-only: refunds set `refundedAt` rather than deleting, and `TOPUP_PURCHASE` rows take their id from the Stripe checkout session so a re-delivered purchase credits nothing. model CreditLedgerEntry { id String @id @default(cuid()) organizationId String @@ -94,7 +86,7 @@ model CreditLedgerEntry { notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull) action CreditAction creditsCharged Int - /// Which bucket the charge was debited from — a refund must credit the same one. + /// A refund must credit the same bucket it debited. bucket CreditBucket refundedAt DateTime? createdAt DateTime @default(now()) diff --git a/packages/db/schema/course.prisma b/packages/db/schema/course.prisma index 952e5f4..e18f770 100644 --- a/packages/db/schema/course.prisma +++ b/packages/db/schema/course.prisma @@ -3,18 +3,16 @@ model Course { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) title String - /// LESSON courses hold exactly one lesson and open directly into it — - /// the unit an author embeds when a whole course is too much. + /// A LESSON course holds exactly one lesson and opens directly into it. mode CourseMode @default(COURSE) category String? tags String[] @default([]) description String? thumbnail String? - /// Minimum score (0-100 %) a learner must achieve to receive a certificate. null = no minimum. + /// 0-100; null = no minimum score for a certificate. passingScorePct Int? - /// Maximum number of times a learner may complete the course. null = unlimited. + /// null = unlimited. maxTries Int? - /// When true, the course is accessible via a public link without authentication. allowAnonymous Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/db/schema/courseEnrollment.prisma b/packages/db/schema/courseEnrollment.prisma index 1cc2741..f23c8a1 100644 --- a/packages/db/schema/courseEnrollment.prisma +++ b/packages/db/schema/courseEnrollment.prisma @@ -4,8 +4,7 @@ model CourseEnrollment { user User? @relation(fields: [userId], references: [id], onDelete: SetNull) courseId String course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) - /// Denormalised from the course so the billable learner count is an indexed - /// distinct-count over [organizationId, userId] instead of a join. + /// Denormalised from the course so the billable learner count is an indexed distinct-count, not a join. organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) courseVersionId String @@ -14,9 +13,9 @@ model CourseEnrollment { lastActive DateTime? completedAt DateTime? dueDate DateTime? - /// Final score percentage, frozen at the moment of finishing. Never recomputed on read. + /// Frozen at the moment of finishing; never recomputed on read. scorePct Int? - /// How many times this enrollment has been completed (pass or fail). + /// Counts completions, pass or fail. triesUsed Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/db/schema/courseVersion.prisma b/packages/db/schema/courseVersion.prisma index 1cb2450..6f73c2f 100644 --- a/packages/db/schema/courseVersion.prisma +++ b/packages/db/schema/courseVersion.prisma @@ -6,8 +6,7 @@ model CourseVersion { publishedAt DateTime @default(now()) publishedById String? publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull) - /// A later publish retired this version. Set only when the author asks for it - /// at publish time — publishing on its own leaves earlier versions takeable. + /// Set only when the author asks at publish time; publishing alone leaves earlier versions takeable. superseded Boolean @default(false) enrollments CourseEnrollment[] lessons Lesson[] diff --git a/packages/db/schema/lesson.prisma b/packages/db/schema/lesson.prisma index 54d6d7a..8f8a12d 100644 --- a/packages/db/schema/lesson.prisma +++ b/packages/db/schema/lesson.prisma @@ -18,10 +18,9 @@ model Lesson { design Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // Set when this is a published copy belonging to a version; null for draft lessons + // Set on a published copy; null on a draft lesson. courseVersionId String? courseVersion CourseVersion? @relation(fields: [courseVersionId], references: [id], onDelete: Cascade) - // Original draft lesson this was copied from; null for draft lessons sourceLessonId String? sourceLesson Lesson? @relation("LessonSource", fields: [sourceLessonId], references: [id], onDelete: SetNull) publishedCopies Lesson[] @relation("LessonSource") diff --git a/packages/db/schema/notebook.prisma b/packages/db/schema/notebook.prisma index 2f1cd2a..8e2beba 100644 --- a/packages/db/schema/notebook.prisma +++ b/packages/db/schema/notebook.prisma @@ -35,9 +35,7 @@ model Notebook { course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull) promptTemplateId String? promptTemplate PromptTemplate? @relation(fields: [promptTemplateId], references: [id], onDelete: SetNull) - /// Rolling LLM summary standing in for every message up to - /// `chatSummaryThroughMessageId`. Only the model's context shrinks — the - /// stored chat keeps every message. + /// Stands in for every message up to `chatSummaryThroughMessageId` in the model's context; the stored chat keeps every message. chatSummary String? @db.Text chatSummaryThroughMessageId String? createdAt DateTime @default(now()) @@ -61,40 +59,30 @@ model NotebookSource { name String type NotebookSourceType url String? // S3 key - content String? // Full extracted text — inlined verbatim into the chat prompt in Tier 1 - /// Estimated model tokens for `content` (chars / 4). 0 means the row predates - /// full-text storage: its `content` is still the old 10k-char truncation, so - /// it must never be inlined as if it were the whole source. + content String? // Full extracted text, inlined verbatim into the chat prompt + /// Estimated model tokens for `content` (chars / 4). 0 means the row predates full-text storage and its `content` is still the old 10k-char truncation. tokenCount Int @default(0) - /// Digest part 1, in the source's own language: what it covers and is for. + /// In the source's own language: what it covers and is for. summary String? - /// Digest part 2, in the source's own language: flat list of its sections. + /// In the source's own language: flat list of its sections. outline String? status NotebookSourceStatus @default(PENDING) - error String? // Error message if processing failed - warning String? // Non-fatal notice (e.g. content was truncated) - fileSize Int? // File size in bytes - pageCount Int? // Number of pages (for documents) - /// Provider-specific page ID (e.g. Notion page UUID) + error String? + warning String? + fileSize Int? // bytes + pageCount Int? externalId String? - /// Human-readable URL to the original external page externalUrl String? - /// FK to IntegrationConnection — nullable so sources survive disconnected integrations + /// FK to IntegrationConnection, nullable so sources survive a disconnected integration. integrationId String? - /// When content was last fetched from the provider (for re-sync tracking) lastSyncedAt DateTime? - /// SHA-256 content hash (first 16 hex chars) of full extracted text — used for change detection + /// SHA-256 of the extracted text, first 16 hex chars. contentHash String? - /// Set by the freshness poll when the provider reports the page changed, and - /// cleared by the ingestion that opening the notebook triggers. The poll - /// itself never extracts, so this is the whole record that it noticed. + /// Set by the freshness poll, cleared by the ingestion that opening the notebook triggers; the poll never extracts. staleAt DateTime? - /// Set when a claim moves the source into PROCESSING. An expired lease makes - /// the source explicitly retryable by the normal awaited ingestion use case. + /// Lease start; once it expires the source is retryable by the normal awaited ingestion. processingStartedAt DateTime? - /// Fencing token minted by the claim that owns the current PROCESSING run. - /// Every write a run makes is conditional on it, so a run whose claim was - /// taken over or reset underneath it lands nothing. + /// Fencing token: every write a run makes is conditional on it, so a run whose claim was taken over lands nothing. processingToken String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -102,9 +90,7 @@ model NotebookSource { @@unique([notebookId, integrationId, externalId]) @@index([notebookId]) - // KS5: the scheduled refresh selects one integration's syncable sources. - // Without this it sequentially scans the largest table in the product, every - // run, for every connection. + // Without this the freshness poll sequentially scans the largest table in the product, once per connection per run. @@index([integrationId, status]) @@map("notebook_source") } @@ -136,8 +122,6 @@ model NotebookChat { parts Json createdAt DateTime @default(now()) - // Every read of this table is a notebook's messages in timeline order — the - // chat path's tail after the compaction cutoff, the transcript's newest page. @@index([notebookId, createdAt(sort: Desc)]) @@map("notebook_chat") } @@ -159,8 +143,7 @@ model PromptTemplate { enum OrganizationAIModelType { CHAT - /// Inert. The embedding pipeline is gone and nothing loads a model of this - /// type; the value stays only because dropping it would rewrite the type. + /// Inert: the embedding pipeline is gone, and the value stays only because dropping it would rewrite the type. EMBEDDING IMAGE @@ -178,24 +161,17 @@ model OrganizationAIModel { id String @id @default(cuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - /// CHAT = language model for the AI Creator chat, IMAGE = image generation type OrganizationAIModelType @default(CHAT) - /// User-visible name shown in the model selector (e.g. "Ollama Gemma 4") name String - /// OpenAI-compatible base URL (e.g. http://localhost:11434/v1) + /// Must be OpenAI-compatible. baseUrl String - /// AES-256-GCM encrypted API key — never returned to the client + /// Never returned to the client. apiKeyEncrypted String - /// Provider-specific model ID passed to the API (e.g. "gemma4", "gpt-4o") modelId String - /// Optional short description shown in the model selector (e.g. "Fast local inference") description String? - /// Tokens this endpoint accepts. Unlike gateway models it cannot be introspected, - /// so it is asked for; null means assume the conservative default. + /// Asked for, because a self-hosted endpoint cannot be introspected; null means the conservative default. contextWindow Int? - /// IMAGE only: which of the configured image endpoints generation runs on. - /// A CHAT row is chosen by `organizationsAsDefault` or by explicit id, so - /// nothing reads this flag for one. + /// IMAGE only; a CHAT row is chosen by `organizationsAsDefault` or by id, so nothing reads this flag for one. isActive Boolean @default(true) lastTestStatus OrganizationAIModelTestStatus? lastTestedAt DateTime? diff --git a/packages/db/schema/onboardingStep.prisma b/packages/db/schema/onboardingStep.prisma index 803bb17..038d17d 100644 --- a/packages/db/schema/onboardingStep.prisma +++ b/packages/db/schema/onboardingStep.prisma @@ -1,6 +1,4 @@ -/// How far a user got through first-run onboarding. `null` means they have not -/// taken an onboarding action yet — which screen they see is then derived from -/// current data (a pending invitation, an existing membership), not stored. +/// `null` means no onboarding action yet; the screen shown is then derived from current data, not stored. enum OnboardingStep { PLANS COMPLETED diff --git a/packages/db/schema/organization.prisma b/packages/db/schema/organization.prisma index d29ef71..405684c 100644 --- a/packages/db/schema/organization.prisma +++ b/packages/db/schema/organization.prisma @@ -5,10 +5,9 @@ model Organization { logo String? createdAt DateTime metadata String? - /// Owned by the Better Auth Stripe plugin; the domain copy read by the rest - /// of the app lives on `OrganizationSubscription.stripeCustomerId`. + /// Owned by the Better Auth Stripe plugin; the app reads `OrganizationSubscription.stripeCustomerId` instead. stripeCustomerId String? @unique - /// Default chat model for AI Creator (`null` = Scibly AI). + /// `null` = Scibly AI. defaultChatModelId String? defaultChatModel OrganizationAIModel? @relation("OrgDefaultChatModel", fields: [defaultChatModelId], references: [id], onDelete: SetNull) members Member[] diff --git a/packages/db/schema/scene.prisma b/packages/db/schema/scene.prisma index f331d8d..a0435a3 100644 --- a/packages/db/schema/scene.prisma +++ b/packages/db/schema/scene.prisma @@ -12,9 +12,6 @@ enum SceneAnimation { BLUR } -/// Why a draft scene was flagged as outdated. The author needs to tell -/// "the source moved on" from "the source is gone" — the second cannot be -/// resolved by re-reading the source. enum SceneOutdatedReason { SOURCE_CHANGED SOURCE_REMOVED @@ -36,24 +33,23 @@ model Scene { /// Collaborative authoring state. Never returned by learner APIs. documentState Bytes? - /// Immutable, solution-sanitized TipTap JSON rendered by learners. + /// Solution-sanitized TipTap JSON; this is what learners are served. learnerContent Json? - /// Immutable answer key generated with learnerContent at publish time. + /// Answer key, frozen with learnerContent at publish time. gradingManifest Json? - /// Published summary: true when gradingManifest contains question blocks. + /// True when gradingManifest contains question blocks. hasQuestions Boolean @default(false) - /// Maximum achievable SP (basis SP + question-block SP) at publish time. + /// Basis SP + question-block SP, at publish time. maxSp Int? sp Int @default(0) - /// True when a cited source has changed since this scene was generated (draft scenes only). + /// Draft scenes only. isOutdated Boolean @default(false) - /// Why the flag was raised. Null exactly when isOutdated is false. + /// Null exactly when isOutdated is false. outdatedReason SceneOutdatedReason? - // Set when this is a published copy belonging to a version; null for draft scenes + // Set on a published copy; null on a draft scene. courseVersionId String? courseVersion CourseVersion? @relation(fields: [courseVersionId], references: [id], onDelete: Cascade) - // Original draft scene this was copied from; null for draft scenes sourceSceneId String? sourceScene Scene? @relation("SceneSource", fields: [sourceSceneId], references: [id], onDelete: SetNull) publishedCopies Scene[] @relation("SceneSource") @@ -68,9 +64,7 @@ model Scene { @@index([lessonId]) @@index([courseVersionId]) @@index([sourceSceneId]) - // INV-12: partial index `scene_outdated_draft_idx` (WHERE isOutdated AND - // courseVersionId IS NULL) exists via raw migration — Prisma's schema DSL - // cannot express partial indexes, so it isn't declared here. + // Partial index `scene_outdated_draft_idx` (WHERE isOutdated AND courseVersionId IS NULL) exists via raw migration; the DSL cannot express it. @@map("scene") } diff --git a/packages/db/schema/sceneAnalytics.prisma b/packages/db/schema/sceneAnalytics.prisma index 3047f25..c28ee68 100644 --- a/packages/db/schema/sceneAnalytics.prisma +++ b/packages/db/schema/sceneAnalytics.prisma @@ -1,14 +1,10 @@ -/// Per-scene, per-block answer-level analytics shared by both -/// authenticated enrollments and anonymous sessions. -/// Exactly one of enrollmentId / anonymousSessionId must be set. +/// Exactly one of enrollmentId / anonymousSessionId is set. model SceneAnalytics { id String @id @default(cuid()) - /// Authenticated user — set when the row belongs to a CourseEnrollment. enrollmentId String? enrollment CourseEnrollment? @relation(fields: [enrollmentId], references: [id], onDelete: SetNull) - /// Anonymous user — set when the row belongs to an AnonymousCourseSession. anonymousSessionId String? anonymousSession AnonymousCourseSession? @relation(fields: [anonymousSessionId], references: [id], onDelete: SetNull) @@ -20,15 +16,13 @@ model SceneAnalytics { spEarned Int @default(0) completedAt DateTime @default(now()) - /// JSON array of per-block grading results with learner answers. - /// Shape: [{ blockId, blockType, learnerAnswer, achievedPoints, maxPoints, spEarned }] + /// [{ blockId, blockType, learnerAnswer, achievedPoints, maxPoints, spEarned }] gradedBlocks Json? attempt Int @default(1) createdAt DateTime @default(now()) - // Only one analytics row per scene per enrollment or session and attempt @@unique([enrollmentId, lessonId, sceneId, attempt]) @@unique([anonymousSessionId, lessonId, sceneId, attempt]) @@index([enrollmentId]) diff --git a/packages/db/schema/schema.prisma b/packages/db/schema/schema.prisma index 47cd78b..eb0c8df 100644 --- a/packages/db/schema/schema.prisma +++ b/packages/db/schema/schema.prisma @@ -1,6 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client" previewFeatures = ["typedSql"] @@ -9,9 +6,6 @@ generator client { datasource db { provider = "postgresql" - // NOTE: When using mysql or sqlserver, uncomment the @db.Text annotations in model Account below - // Further reading: - // https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#string } // Necessary for BetterAuth diff --git a/packages/db/schema/stripe.prisma b/packages/db/schema/stripe.prisma index 753cdcc..d432d20 100644 --- a/packages/db/schema/stripe.prisma +++ b/packages/db/schema/stripe.prisma @@ -1,7 +1,4 @@ -/// Better Auth Stripe plugin's own bookkeeping table (mirrors `@better-auth/stripe`'s -/// `src/schema.ts`), not the domain model — that's `OrganizationSubscription` / -/// `OrganizationCredit` in `billing.prisma`, kept in sync by -/// `packages/auth/src/billing/sync-subscription.ts` via the plugin's lifecycle hooks. +/// Mirrors `@better-auth/stripe`'s own `src/schema.ts`, not the domain model — that is `billing.prisma`, kept in sync by `packages/auth/src/billing/sync-subscription.ts`. model Subscription { id String @id @default(cuid()) plan String diff --git a/packages/db/schema/user.prisma b/packages/db/schema/user.prisma index a21fad1..7554baa 100644 --- a/packages/db/schema/user.prisma +++ b/packages/db/schema/user.prisma @@ -5,8 +5,7 @@ model User { emailVerified Boolean @default(false) image String? username String? @unique - /// Required by the Better Auth Stripe plugin's schema; unused since billing - /// is organization-scoped (`Organization.stripeCustomerId`), not per-user. + /// Required by the Better Auth Stripe plugin's schema; unused, billing is organization-scoped. stripeCustomerId String? @unique createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt From ac882870e2d48a2b64232af90b7f759e145d7970 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 22:57:59 +0200 Subject: [PATCH 36/43] Make the installer prove the installation is theirs to connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub callback took `installation_id` from the query string at face value and minted tokens against it with the app's own key. Any admin of any organization could substitute another organization's installation id and have it persisted as their connection, reading every repository behind it. Turning on user authorization during installation puts a `code` beside that id. Redeemed, it names the GitHub user standing at the callback, and GitHub is asked whether that user reaches that installation before anything is written. The check is GitHub's own, so org owners and admins connect exactly what they can already reach — and nothing else. Co-Authored-By: Claude Opus 5 --- apps/app/.env.example | 7 +- apps/app/src/env.js | 5 + .../server/providers/github/app-auth.ts | 68 +++++++++++++ .../server/providers/github/provider.test.ts | 99 +++++++++++++++++-- .../server/providers/github/provider.ts | 31 ++++-- docs/runbooks/github-app.md | 49 ++++++--- 6 files changed, 235 insertions(+), 24 deletions(-) diff --git a/apps/app/.env.example b/apps/app/.env.example index a91f8fa..4c375e6 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -67,7 +67,12 @@ STRIPE_PORTAL_CONFIGURATION_ID="" # GitHub App — organization integration. Required: register the app first # (docs/runbooks/github-app.md). The slug is the last segment of the app's # public URL, github.com/apps/. The private key is the PEM GitHub gives -# you once, with its newlines escaped as \n so it survives this file. +# you once, with its newlines escaped as \n so it survives this file. The +# client id and secret are the OAuth half of the same app: the callback redeems +# the code they authorize to check the installer really reaches the +# installation they submitted. GITHUB_APP_SLUG="" GITHUB_APP_ID="" GITHUB_APP_PRIVATE_KEY="" +GITHUB_APP_CLIENT_ID="" +GITHUB_APP_CLIENT_SECRET="" diff --git a/apps/app/src/env.js b/apps/app/src/env.js index 6e7253c..53c1b84 100644 --- a/apps/app/src/env.js +++ b/apps/app/src/env.js @@ -51,6 +51,9 @@ export const env = createEnv({ GITHUB_APP_ID: z.string().min(1), /** PEM private key; newlines may be escaped as \n for .env files. */ GITHUB_APP_PRIVATE_KEY: z.string().min(1), + /** OAuth half of the same app: proves who installed it, at the callback. */ + GITHUB_APP_CLIENT_ID: z.string().min(1), + GITHUB_APP_CLIENT_SECRET: z.string().min(1), AI_GATEWAY_API_KEY: z.string().min(1), /** Gateway model ID used when the client selects Scibly AI (scibly/default) */ @@ -126,6 +129,8 @@ export const env = createEnv({ GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG, GITHUB_APP_ID: process.env.GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, + GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID, + GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET, AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY, SCIBLY_DEFAULT_CHAT_MODEL: process.env.SCIBLY_DEFAULT_CHAT_MODEL, diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index 8789880..78a0b6c 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -8,6 +8,7 @@ import { env } from "@/env"; // dropped afterwards, which is why none is ever written down. const GITHUB_API = "https://api.github.com"; +const GITHUB_OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token"; // GitHub rejects a JWT issued ahead of its own clock and caps the lifetime at // ten minutes; both bounds are taken with room to spare. @@ -18,6 +19,8 @@ export interface GitHubAppConfig { appSlug: string; appId: string; privateKey: string; + clientId: string; + clientSecret: string; } export interface GitHubInstallation { @@ -39,6 +42,8 @@ export function readGitHubAppConfig(): GitHubAppConfig { // A PEM survives a .env file only with its newlines escaped, so both // spellings are normalised to the one OpenSSL will parse. privateKey: env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, "\n"), + clientId: env.GITHUB_APP_CLIENT_ID, + clientSecret: env.GITHUB_APP_CLIENT_SECRET, }; } @@ -173,6 +178,69 @@ export async function mintInstallationToken( return minted.token; } +const userTokenResponse = z.union([ + z.object({ access_token: z.string() }), + z.object({ error: z.string() }), +]); + +/** Redeem the code the install redirect carried for a token that speaks as the + * user who installed — never stored, only used to check what they can reach. */ +export async function exchangeUserToken( + config: GitHubAppConfig, + code: string, +): Promise { + const response = await fetch(GITHUB_OAUTH_TOKEN_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + }), + cache: "no-store", + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }); + if (!response.ok) { + throw new GitHubRequestError( + `GitHub POST ${GITHUB_OAUTH_TOKEN_URL} failed: ${response.status}`, + response.status, + ); + } + const body = userTokenResponse.parse(await response.json()); + // A refused or spent code comes back as a 200 with an error in the body. + if (!("access_token" in body)) { + throw new Error(`GitHub refused the user authorization: ${body.error}`); + } + return body.access_token; +} + +// Asked as the user rather than as the app: the app can see every installation +// it has, so its own answer would say nothing about who is standing at the +// callback. GitHub answers 403 or 404 for an installation the user has no +// access to, which is the whole question — anything else is a failure to +// answer it, and is thrown rather than read as a no. +export async function userCanAccessInstallation( + userToken: string, + installationId: string, +): Promise { + try { + await githubRequest( + `/user/installations/${encodeURIComponent(installationId)}/repositories?per_page=1`, + { method: "GET", authorization: `Bearer ${userToken}` }, + z.object({ total_count: z.number() }), + ); + return true; + } catch (error) { + if ( + error instanceof GitHubRequestError && + (error.status === 403 || error.status === 404) + ) { + return false; + } + throw error; + } +} + const REPOS_PER_PAGE = 100; // An installation on a large organisation can reach thousands of repositories. diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index 5701a0a..b374afa 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -20,6 +20,8 @@ const CONFIGURED = { GITHUB_APP_SLUG: "scibly-dev", GITHUB_APP_ID: "123456", GITHUB_APP_PRIVATE_KEY: KEYS.privateKey, + GITHUB_APP_CLIENT_ID: "Iv23client", + GITHUB_APP_CLIENT_SECRET: "client-secret", }; const { GitHubProvider } = await import("./provider"); @@ -47,11 +49,32 @@ function lastRequest() { init: call[1] as { method: string; headers: Record; + body?: string; signal?: AbortSignal; }, }; } +function requestTo(fragment: string) { + const call = fetchMock.mock.calls.find((one) => + String(one[0]).includes(fragment), + ); + if (!call) throw new Error(`nothing was fetched for ${fragment}`); + return { + url: String(call[0]), + init: call[1] as { method: string; body?: string }, + }; +} + +// What GitHub answers when the code redeems and the user behind it does reach +// the installation they submitted — the two calls that stand between a +// callback's `installation_id` and a connection. +function authorizes() { + fetchMock + .mockResolvedValueOnce(ok({ access_token: "gho_user" })) + .mockResolvedValueOnce(ok({ total_count: 1 })); +} + function decodeJwt(token: string) { const [header, payload] = token.split("."); return { @@ -121,12 +144,13 @@ describe("GH3 starting the install", () => { describe("GH4 what the callback becomes", () => { it("GH4 turns an installation id into the account it was installed on", async () => { - fetchMock.mockResolvedValue( + authorizes(); + fetchMock.mockResolvedValueOnce( ok({ id: 42, account: { id: 777, login: "acme-inc" } }), ); const credential = await new GitHubProvider().completeConnect({ - code: null, + code: "auth-code", installationId: "42", }); @@ -139,12 +163,13 @@ describe("GH4 what the callback becomes", () => { }); it("GH4 asks about the installation as the app itself, with a signed JWT", async () => { - fetchMock.mockResolvedValue( + authorizes(); + fetchMock.mockResolvedValueOnce( ok({ id: 42, account: { id: 777, login: "acme-inc" } }), ); await new GitHubProvider().completeConnect({ - code: null, + code: "auth-code", installationId: "42", }); const { url, init } = lastRequest(); @@ -156,6 +181,27 @@ describe("GH4 what the callback becomes", () => { ).toMatchObject({ iss: "123456" }); }); + it("GH4 redeems the code as the app's OAuth client before trusting anything", async () => { + authorizes(); + fetchMock.mockResolvedValueOnce( + ok({ id: 42, account: { id: 777, login: "acme-inc" } }), + ); + + await new GitHubProvider().completeConnect({ + code: "auth-code", + installationId: "42", + }); + const { url, init } = requestTo("login/oauth/access_token"); + + expect(url).toBe("https://github.com/login/oauth/access_token"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ + client_id: "Iv23client", + client_secret: "client-secret", + code: "auth-code", + }); + }); + it("GH4 refuses a callback that names no installation", async () => { await expect( new GitHubProvider().completeConnect({ @@ -166,12 +212,53 @@ describe("GH4 what the callback becomes", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("GH4 refuses a callback that carries no user authorization to check", async () => { + await expect( + new GitHubProvider().completeConnect({ + code: null, + installationId: "42", + }), + ).rejects.toThrow(/no user authorization/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("GH4 refuses an installation the authorizing user cannot reach", async () => { + fetchMock + .mockResolvedValueOnce(ok({ access_token: "gho_user" })) + .mockResolvedValueOnce(failed(404, { message: "Not Found" })); + + await expect( + new GitHubProvider().completeConnect({ + code: "auth-code", + // Someone else's installation, submitted by an admin of their own org. + installationId: "999", + }), + ).rejects.toThrow(/not one this user can reach/i); + expect( + fetchMock.mock.calls.some((one) => + String(one[0]).includes("/app/installations/"), + ), + ).toBe(false); + }); + + it("GH4 refuses a code GitHub will not redeem", async () => { + fetchMock.mockResolvedValueOnce(ok({ error: "bad_verification_code" })); + + await expect( + new GitHubProvider().completeConnect({ + code: "replayed", + installationId: "42", + }), + ).rejects.toThrow(/bad_verification_code/); + }); + it("GH4 refuses an installation GitHub gives no account for", async () => { - fetchMock.mockResolvedValue(ok({ id: 42, account: null })); + authorizes(); + fetchMock.mockResolvedValueOnce(ok({ id: 42, account: null })); await expect( new GitHubProvider().completeConnect({ - code: null, + code: "auth-code", installationId: "42", }), ).rejects.toThrow(/names no account/i); diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts index 6f27f34..ad0db82 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -10,11 +10,13 @@ import { IntegrationRevokedError, } from "../../base-provider"; import { + exchangeUserToken, fetchInstallation, fetchInstallationRepositories, GitHubRequestError, mintInstallationToken, readGitHubAppConfig, + userCanAccessInstallation, } from "./app-auth"; async function installationIsGone( @@ -38,8 +40,9 @@ export class GitHubProvider extends IntegrationProvider { readonly displayName = "GitHub"; readonly credential = "app_installation"; - // The redirect back is the app's registered setup URL, so unlike OAuth there - // is nothing to pass here; only the state rides along and comes back. + // The redirect back is the app's own registered callback URL, so unlike + // OAuth there is nothing to pass here; the state rides along and comes back + // beside the installation and the code that authorizes it. getAuthUrl(state: string, _redirectUri: string): string { const { appSlug } = readGitHubAppConfig(); const url = new URL( @@ -49,16 +52,32 @@ export class GitHubProvider extends IntegrationProvider { return url.toString(); } + // The installation id arrives as a query parameter on a browser redirect, so + // it is a claim, not a fact: on its own it would let anyone who can pass the + // callback for their own organization name someone else's installation and + // have it persisted as theirs — every repository behind it readable from a + // Scibly org its owners never heard of. The code beside it is the proof. + // Redeemed, it says which GitHub user is standing here, and GitHub is asked + // whether that user reaches this installation at all. async completeConnect( params: ConnectCallbackParams, ): Promise { if (!params.installationId) { throw new Error("GitHub returned no installation to connect to."); } - const installation = await fetchInstallation( - readGitHubAppConfig(), - params.installationId, - ); + if (!params.code) { + throw new Error( + "GitHub returned no user authorization for the installation.", + ); + } + const config = readGitHubAppConfig(); + const userToken = await exchangeUserToken(config, params.code); + if (!(await userCanAccessInstallation(userToken, params.installationId))) { + throw new Error( + `GitHub installation ${params.installationId} is not one this user can reach.`, + ); + } + const installation = await fetchInstallation(config, params.installationId); return { kind: "app_installation", installationId: installation.installationId, diff --git a/docs/runbooks/github-app.md b/docs/runbooks/github-app.md index aefae40..68deba9 100644 --- a/docs/runbooks/github-app.md +++ b/docs/runbooks/github-app.md @@ -19,20 +19,33 @@ personal account (dev) or on the organization that should own it (prod). | --- | --- | --- | | **GitHub App name** | `Scibly (dev)` — names are globally unique, so add your own suffix if it's taken | `Scibly` | | **Homepage URL** | `http://localhost:3001` | your app URL | -| **Setup URL** (under *Post installation*) | `http://localhost:3001/api/integrations/github/callback` | `${NEXT_PUBLIC_APP_URL}/api/integrations/github/callback` | +| **Callback URL** (under *Identifying and authorizing users*) | `http://localhost:3001/api/integrations/github/callback` | `${NEXT_PUBLIC_APP_URL}/api/integrations/github/callback` | +| **Request user authorization (OAuth) during installation** | **checked** | **checked** | +| **Setup URL** (under *Post installation*) | same URL as the callback | same URL as the callback | | **Redirect on update** | checked | checked | -| **Callback URL** (under *Identifying and authorizing users*) | leave blank | leave blank | -| **Request user authorization (OAuth) during installation** | **unchecked** | **unchecked** | | **Webhook → Active** | unchecked | unchecked | | **Where can this GitHub App be installed?** | *Only on this account* | *Any account* | -The **Setup URL is the one that matters**: when someone finishes installing the -app, GitHub sends their browser there with `installation_id`, `setup_action`, -and the signed `state` scibly put on the install link. That route +The **callback URL is the one that matters**: with user authorization checked, +GitHub sends the installer's browser there with `installation_id`, +`setup_action`, a `code`, and the signed `state` scibly put on the install +link. That route ([callback/route.ts](../../apps/app/src/app/api/integrations/[provider]/callback/route.ts)) -is what turns the installation into a connection. Leave the OAuth callback -blank and the user-authorization box unchecked — scibly never asks GitHub for a -user token, only for the installation. +is what turns the installation into a connection. + +**The user-authorization box is a security control, not a nicety.** The +`installation_id` in that redirect is a query parameter, so any signed-in +admin can put any number there — including the id of another organization's +installation, which the app's own key would happily mint tokens for. The +`code` beside it is the part that cannot be forged: scibly redeems it for a +user token and asks GitHub whether *that user* reaches *that installation* +before the connection is written. Uncheck the box and no code arrives, so +every connect fails — which is the intended failure direction. + +That check is GitHub's answer, not scibly's, so it follows GitHub's own +permissions: anyone who can reach the installation's repositories on GitHub +can connect it, and being an owner or admin of the scibly organization is +required on top of that, never instead of it. *Redirect on update* is checked so that changing which repositories the installation can reach comes back through the same route and refreshes the @@ -71,6 +84,9 @@ On the app's settings page: segment is the slug → `GITHUB_APP_SLUG` - **Private keys → Generate a private key** — downloads a `.pem` **once**; GitHub keeps no copy → `GITHUB_APP_PRIVATE_KEY` +- **Client ID**, shown beside the App ID → `GITHUB_APP_CLIENT_ID` +- **Client secrets → Generate a new client secret** — shown **once** → + `GITHUB_APP_CLIENT_SECRET` The private key is the app's whole identity: anyone holding it can mint tokens for every installation. Keep it out of the repository and out of the database — @@ -90,9 +106,11 @@ multi-line value in a secret manager works too): GITHUB_APP_SLUG="scibly-dev" GITHUB_APP_ID="123456" GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\n" +GITHUB_APP_CLIENT_ID="Iv23li..." +GITHUB_APP_CLIENT_SECRET="..." ``` -All three are required by the env schema, like Notion's: the app refuses to +All five are required by the env schema, like Notion's: the app refuses to boot without them rather than failing at the moment someone presses *Connect*. ## 4. Verify @@ -102,7 +120,8 @@ boot without them rather than failing at the moment someone presses *Connect*. *Connect* on GitHub. 3. GitHub asks which account to install on and which repositories to give it. Pick a couple rather than *All repositories* — it makes the next step - readable. + readable. Authorize the app when GitHub asks: that is the step that proves + the installation is yours to connect. 4. You land back on the settings page with GitHub connected, showing the account it was installed on and the repositories the installation reaches. 5. *Disconnect* removes the connection on scibly's side. It does **not** @@ -133,5 +152,13 @@ different Notion workspace behaves. dev credentials against a production install, or the other way round. The same 404 *after* connect means the app was uninstalled, and is handled rather than reported: the connection is deleted. +- **`GitHub returned no user authorization for the installation`** on connect — + *Request user authorization (OAuth) during installation* is unchecked on the + app, so GitHub sent no `code` to verify the installation with. Check it. +- **`... is not one this user can reach`** on connect — the code was redeemed, + and GitHub says the user who authorized it has no access to the installation + they submitted. Either they are connecting an installation belonging to a + GitHub account they are not a member of, or a stale callback URL was replayed + with someone else's `installation_id`. - **`401 'Issued at' is in the future`** — the machine's clock is ahead of GitHub's by more than the minute the signing already backdates. Fix the clock. From c4c9f03679ab9da0eecf988e6680351303ed2063 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 23:07:12 +0200 Subject: [PATCH 37/43] refactor: drop the unused token-refresh scaffolding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshTokenEncrypted` and `tokenExpiresAt` were write-only: set once in the OAuth callback and read by nothing. Both were always NULL besides — Notion's `exchangeCode` returns neither, and Notion is the only provider in the registry. `BaseIntegrationProvider.refreshToken` was a stub whose entire behaviour was to throw, with no caller. Notion access tokens do not expire; they die when a user revokes access, and no refresh call fixes that. A failing poll already retries and then backs off, which is the right outcome for a connection that needs reconnecting by hand. A provider that genuinely needs a refresh path can add these back together with the code that reads them. Co-Authored-By: Claude Opus 5 --- .../src/features/integrations/contracts.ts | 2 -- .../integrations/server/base-provider.ts | 6 ---- .../server/oauth-callback.test.ts | 14 +------- .../integrations/server/oauth-callback.ts | 4 --- .../migration.sql | 7 ++++ packages/db/schema/integration.prisma | 32 +++++++++---------- 6 files changed, 23 insertions(+), 42 deletions(-) create mode 100644 packages/db/migrations/20260828230000_drop_unused_integration_token_columns/migration.sql diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index b0739a5..9e10a1a 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -43,8 +43,6 @@ export interface IntegrationPageRevision { export interface OAuthTokens { accessToken: string; - refreshToken?: string; - expiresAt?: Date; workspaceId?: string; workspaceName?: string; } diff --git a/apps/app/src/features/integrations/server/base-provider.ts b/apps/app/src/features/integrations/server/base-provider.ts index 735c53e..b48e279 100644 --- a/apps/app/src/features/integrations/server/base-provider.ts +++ b/apps/app/src/features/integrations/server/base-provider.ts @@ -45,12 +45,6 @@ export abstract class BaseIntegrationProvider { redirectUri: string, ): Promise; - async refreshToken(_refreshToken: string): Promise { - throw new Error( - `${this.providerId} does not support token refresh. Reconnect the integration.`, - ); - } - pollModifiedPages(_token: string, _since: Date): Promise { return Promise.resolve([]); } diff --git a/apps/app/src/features/integrations/server/oauth-callback.test.ts b/apps/app/src/features/integrations/server/oauth-callback.test.ts index 2e6075d..6a6652f 100644 --- a/apps/app/src/features/integrations/server/oauth-callback.test.ts +++ b/apps/app/src/features/integrations/server/oauth-callback.test.ts @@ -41,7 +41,6 @@ const { PROVIDERS } = await import("./registry"); const TOKENS: OAuthTokens = { accessToken: "secret-access-token", - refreshToken: "secret-refresh-token", workspaceId: "workspace-1", workspaceName: "Acme HQ", }; @@ -254,7 +253,7 @@ describe("LA the door", () => { }); describe("LS what is stored", () => { - it("LS1 encrypts both tokens, and what is stored decrypts back", async () => { + it("LS1 encrypts the access token, and what is stored decrypts back", async () => { await callback({ code: "auth-code", state: state() }); const { create } = upserted(); @@ -262,17 +261,6 @@ describe("LS what is stored", () => { expect(decryptApiKey(String(create.accessTokenEncrypted))).toBe( TOKENS.accessToken, ); - expect(decryptApiKey(String(create.refreshTokenEncrypted))).toBe( - TOKENS.refreshToken, - ); - }); - - it("LS1 stores no refresh token when the provider issues none", async () => { - exchangeCode.mockResolvedValue({ accessToken: "only-access" }); - - await callback({ code: "auth-code", state: state() }); - - expect(upserted().create).toMatchObject({ refreshTokenEncrypted: null }); }); it("LS3 keys the row on the org and the provider, so a second authorisation refreshes it", async () => { diff --git a/apps/app/src/features/integrations/server/oauth-callback.ts b/apps/app/src/features/integrations/server/oauth-callback.ts index 62360b2..2add4cf 100644 --- a/apps/app/src/features/integrations/server/oauth-callback.ts +++ b/apps/app/src/features/integrations/server/oauth-callback.ts @@ -179,10 +179,6 @@ async function exchangeAndPersistConnection( const connectionData = { accessTokenEncrypted: encryptApiKey(tokens.accessToken), - refreshTokenEncrypted: tokens.refreshToken - ? encryptApiKey(tokens.refreshToken) - : null, - tokenExpiresAt: tokens.expiresAt ?? null, workspaceId: tokens.workspaceId ?? null, workspaceName: tokens.workspaceName ?? null, diff --git a/packages/db/migrations/20260828230000_drop_unused_integration_token_columns/migration.sql b/packages/db/migrations/20260828230000_drop_unused_integration_token_columns/migration.sql new file mode 100644 index 0000000..82e21bd --- /dev/null +++ b/packages/db/migrations/20260828230000_drop_unused_integration_token_columns/migration.sql @@ -0,0 +1,7 @@ +-- Both columns were write-only: set at OAuth callback, read by nothing. No +-- provider in the registry issues a refresh token or an expiry, so both were +-- always NULL. A provider that needs them can add them back with the code +-- that reads them. +ALTER TABLE "integration_connection" + DROP COLUMN "refreshTokenEncrypted", + DROP COLUMN "tokenExpiresAt"; diff --git a/packages/db/schema/integration.prisma b/packages/db/schema/integration.prisma index ed8d4ce..6e57b30 100644 --- a/packages/db/schema/integration.prisma +++ b/packages/db/schema/integration.prisma @@ -7,25 +7,23 @@ enum IntegrationProvider { } model IntegrationConnection { - id String @id @default(cuid()) - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) - provider IntegrationProvider - accessTokenEncrypted String - refreshTokenEncrypted String? - tokenExpiresAt DateTime? - workspaceId String? - workspaceName String? - connectedByUserId String - connectedBy User @relation(fields: [connectedByUserId], references: [id]) + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + provider IntegrationProvider + accessTokenEncrypted String + workspaceId String? + workspaceName String? + connectedByUserId String + connectedBy User @relation(fields: [connectedByUserId], references: [id]) /// Advances only on a successful poll, so a failed run costs delay, not changes. - lastPolledAt DateTime? + lastPolledAt DateTime? /// Advances on every attempt, successful or not. - lastAttemptedAt DateTime? - consecutiveFailures Int @default(0) - nextPollAfter DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + lastAttemptedAt DateTime? + consecutiveFailures Int @default(0) + nextPollAfter DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([organizationId, provider]) @@index([organizationId]) From 6a82db5d8b22ddafff711aa4b3006b870c2957b9 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Fri, 28 Aug 2026 23:24:16 +0200 Subject: [PATCH 38/43] Build the providers' URLs from the routes package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub API origin, its OAuth token endpoint, its install page, Notion's authorize endpoint and Notion's page links were five raw strings spread over four provider files. They are external URLs like every other one the routes package already owns, and github.com was spelled out twice — once for our own repository, once for the install page. Co-Authored-By: Claude Opus 5 --- .../server/providers/github/app-auth.ts | 35 ++++++++++--------- .../server/providers/github/provider.ts | 6 ++-- .../server/providers/notion-pages.ts | 3 +- .../integrations/server/providers/notion.ts | 3 +- packages/routes/src/index.ts | 20 ++++++++++- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index 78a0b6c..19b8942 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -1,3 +1,4 @@ +import { routes } from "@scibly/routes"; import crypto from "crypto"; import { z } from "zod"; @@ -7,9 +8,6 @@ import { env } from "@/env"; // makes carries an installation access token minted here for that call and // dropped afterwards, which is why none is ever written down. -const GITHUB_API = "https://api.github.com"; -const GITHUB_OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token"; - // GitHub rejects a JWT issued ahead of its own clock and caps the lifetime at // ten minutes; both bounds are taken with room to spare. const JWT_BACKDATE_SECONDS = 60; @@ -93,19 +91,22 @@ async function githubRequest( init: { method: "GET" | "POST"; authorization: string }, schema: z.ZodType, ): Promise { - const response = await fetch(`${GITHUB_API}${path}`, { - method: init.method, - headers: { - accept: "application/vnd.github+json", - authorization: init.authorization, - "x-github-api-version": "2022-11-28", + const response = await fetch( + `${routes.external.integrations.github.api}${path}`, + { + method: init.method, + headers: { + accept: "application/vnd.github+json", + authorization: init.authorization, + "x-github-api-version": "2022-11-28", + }, + cache: "no-store", + // `fetch` waits forever by default. A sync hop polls up to ten connections + // inside a four-minute deadline, so one hung request must not be able to + // spend the whole hop and strand the rest of the batch unpolled. + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), }, - cache: "no-store", - // `fetch` waits forever by default. A sync hop polls up to ten connections - // inside a four-minute deadline, so one hung request must not be able to - // spend the whole hop and strand the rest of the batch unpolled. - signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), - }); + ); if (!response.ok) { // Only GitHub's status and message are carried out: the request was @@ -189,7 +190,7 @@ export async function exchangeUserToken( config: GitHubAppConfig, code: string, ): Promise { - const response = await fetch(GITHUB_OAUTH_TOKEN_URL, { + const response = await fetch(routes.external.integrations.github.oauthToken, { method: "POST", headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify({ @@ -202,7 +203,7 @@ export async function exchangeUserToken( }); if (!response.ok) { throw new GitHubRequestError( - `GitHub POST ${GITHUB_OAUTH_TOKEN_URL} failed: ${response.status}`, + `GitHub POST ${routes.external.integrations.github.oauthToken} failed: ${response.status}`, response.status, ); } diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts index ad0db82..99f8469 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -5,6 +5,8 @@ import type { import type { ConnectCallbackParams } from "../../base-provider"; import type { GitHubAppConfig } from "./app-auth"; +import { routes } from "@scibly/routes"; + import { IntegrationProvider, IntegrationRevokedError, @@ -45,9 +47,7 @@ export class GitHubProvider extends IntegrationProvider { // beside the installation and the code that authorizes it. getAuthUrl(state: string, _redirectUri: string): string { const { appSlug } = readGitHubAppConfig(); - const url = new URL( - `https://github.com/apps/${encodeURIComponent(appSlug)}/installations/new`, - ); + const url = new URL(routes.external.integrations.github.install(appSlug)); url.searchParams.set("state", state); return url.toString(); } diff --git a/apps/app/src/features/integrations/server/providers/notion-pages.ts b/apps/app/src/features/integrations/server/providers/notion-pages.ts index 5fcceb7..68705b4 100644 --- a/apps/app/src/features/integrations/server/providers/notion-pages.ts +++ b/apps/app/src/features/integrations/server/providers/notion-pages.ts @@ -2,6 +2,7 @@ import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoint import type { IntegrationPage } from "../../contracts"; import { type Client, isFullDatabase, isFullPage } from "@notionhq/client"; +import { routes } from "@scibly/routes"; const CONTAINER_BLOCK_TYPES = new Set([ "column_list", @@ -49,7 +50,7 @@ async function paginateNotion( } function notionPageUrl(id: string): string { - return `https://www.notion.so/${id.replace(/-/g, "")}`; + return routes.external.integrations.notion.page(id); } function blockLastEdited(block: { diff --git a/apps/app/src/features/integrations/server/providers/notion.ts b/apps/app/src/features/integrations/server/providers/notion.ts index 3ba50a5..45aa252 100644 --- a/apps/app/src/features/integrations/server/providers/notion.ts +++ b/apps/app/src/features/integrations/server/providers/notion.ts @@ -7,6 +7,7 @@ import type { import type { ConnectCallbackParams } from "../base-provider"; import { Client, isFullPage } from "@notionhq/client"; +import { routes } from "@scibly/routes"; import { env } from "@/env"; @@ -32,7 +33,7 @@ export class NotionProvider extends PageIntegrationProvider { readonly credential = "oauth_tokens"; getAuthUrl(state: string, redirectUri: string): string { - const url = new URL("https://api.notion.com/v1/oauth/authorize"); + const url = new URL(routes.external.integrations.notion.oauthAuthorize); url.searchParams.set("client_id", env.NOTION_CLIENT_ID); url.searchParams.set("response_type", "code"); url.searchParams.set("owner", "user"); diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index 3b4853d..4bd3ee1 100644 --- a/packages/routes/src/index.ts +++ b/packages/routes/src/index.ts @@ -28,7 +28,8 @@ const toHomeUrl = (path: string) => const toAppUrl = (path: string) => env.NEXT_PUBLIC_APP_URL.concat(path.startsWith("/") ? path : `/${path}`); -const GITHUB_REPO_URL = "https://github.com/scibly-dev/scibly" as const; +const GITHUB_URL = "https://github.com" as const; +const GITHUB_REPO_URL = `${GITHUB_URL}/scibly-dev/scibly` as const; const BASE_AUTH_PATH = "/auth" as const; const BASE_PROFILE_PATH = "/profile" as const; @@ -222,6 +223,23 @@ export const routes = { issues: `${GITHUB_REPO_URL}/issues` as const, file: (path: string) => `${GITHUB_REPO_URL}/blob/main/${path}` as const, }, + + // Where the GitHub and Notion integrations talk to, as opposed to the + // repository above: an install page a browser is sent to, and the two + // origins the server calls. + integrations: { + github: { + api: "https://api.github.com", + oauthToken: `${GITHUB_URL}/login/oauth/access_token` as const, + install: (appSlug: string) => + `${GITHUB_URL}/apps/${encodeURIComponent(appSlug)}/installations/new` as const, + }, + notion: { + oauthAuthorize: "https://api.notion.com/v1/oauth/authorize", + page: (id: string) => + `https://www.notion.so/${id.replace(/-/g, "")}` as const, + }, + }, }, pythonBackend: { From d7cb931c59896d7808fc9b7af63fdebac1986ebd Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Sat, 29 Aug 2026 00:01:49 +0200 Subject: [PATCH 39/43] Name the integration in the revoked toast, and shorten it A reader with two connections could not tell from "this integration" which one just went away, and the sentence explaining that the connection had also been removed here outlived the toast that carried it. Co-Authored-By: Claude Opus 5 --- .../org-integrations/org-integrations-card.test.tsx | 6 +++--- .../components/org-integrations/provider-grants.tsx | 10 ++++++++-- .../settings/i18n/orgSettings.i18n.de.json | 2 +- .../settings/i18n/orgSettings.i18n.en.json | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index 6546b15..1408e69 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -57,7 +57,7 @@ const t = { grantsError: "Could not load repositories.", grantsMore: "{count} more", grantsShown: "Showing {shown} of {total}.", - revokedNotice: "The connection was removed on the provider's side.", + revokedNotice: "Disconnected on {provider}'s side.", noProvidersAvailable: "Nothing to connect to.", providers: { NOTION: "Notion", GITHUB: "GitHub" }, } as OrgSettingsPage["integrations"]; @@ -300,7 +300,7 @@ describe("the grants strip", () => { expect(card().textContent).toContain("No repositories."); }); - it("tells the reader and refetches the list when the grant was revoked", () => { + it("names the provider that went away, and refetches the list", () => { lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); useGrants.mockReturnValue({ data: undefined, @@ -311,7 +311,7 @@ describe("the grants strip", () => { card(); - expect(toastError).toHaveBeenCalledWith(t.revokedNotice, { + expect(toastError).toHaveBeenCalledWith("Disconnected on GitHub's side.", { id: "integration-revoked-GITHUB", }); expect(invalidate).toHaveBeenCalledWith({ orgSlug: "acme" }); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx index 415b13f..2ece786 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx @@ -38,11 +38,17 @@ export const ProviderGrants = ({ // dropped the connection by the time the error arrives; refetching the list // is what takes the row off the page. const wasRevoked = error?.data?.applicationCode === "integration.revoked"; + // Named, because a reader with two connections cannot tell from "this + // integration" which of them just went away. + const revokedNotice = t.revokedNotice.replace( + "{provider}", + t.providers[provider], + ); useEffect(() => { if (!wasRevoked) return; - toast.error(t.revokedNotice, { id: `integration-revoked-${provider}` }); + toast.error(revokedNotice, { id: `integration-revoked-${provider}` }); void utils.integration.list.invalidate({ orgSlug }); - }, [wasRevoked, provider, orgSlug, t.revokedNotice, utils]); + }, [wasRevoked, provider, orgSlug, revokedNotice, utils]); if (isPending) { return

{t.grantsLoading}

; diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json index de33187..3aab956 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.de.json @@ -143,7 +143,7 @@ "forbidden": "Du musst Administrator oder Inhaber sein, um eine Integration zu verbinden.", "token_exchange_failed": "Die Verbindung ist fehlgeschlagen. Der Anbieter hat die von Scibly gesendeten Zugangsdaten abgelehnt." }, - "revokedNotice": "Diese Integration wurde auf Anbieterseite entfernt, daher wurde die Verbindung auch hier entfernt. Verbinde erneut, um fortzufahren.", + "revokedNotice": "Auf {provider}-Seite getrennt. Verbinde erneut, um fortzufahren.", "providers": { "NOTION": "Notion", "GITHUB": "GitHub" diff --git a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json index 5d62f41..fdc60ad 100644 --- a/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json +++ b/apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json @@ -143,7 +143,7 @@ "forbidden": "You need to be an admin or owner to connect an integration.", "token_exchange_failed": "Connection failed. The provider rejected the credentials scibly sent." }, - "revokedNotice": "This integration was removed on the provider's side, so the connection has been removed here too. Connect again to resume.", + "revokedNotice": "Disconnected on {provider}'s side. Connect again to resume.", "providers": { "NOTION": "Notion", "GITHUB": "GitHub" From b76f455d585aae4c8503ac9ed6a487d49fd3ab73 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Sat, 29 Aug 2026 00:30:56 +0200 Subject: [PATCH 40/43] Keep the connection row when its credential goes away A disconnect deleted the row and orphaned every source that pointed at it, so reconnecting the same workspace could not put them back: they kept a warning nobody would ever clear, and a manual re-sync refused them for having no integration. The row is what remembers which workspace those sources came from, so it now outlives the credential. Disconnecting (and a provider-side revocation, which is the same event announced elsewhere) empties the two credential columns and warns the sources without unlinking them; connecting the same workspace back fills the credential in and lifts the warning. A workspace swap still detaches, because there the pages really are gone. Holding neither credential column is the whole of what "disconnected" means, so every reader that used to take a row's existence as proof of a connection now asks `isConnected` instead: the settings list, the procedures that resolve a connection, and the poll that would otherwise spend a chain of attempts on a connection with nothing to poll with. Co-Authored-By: Claude Opus 5 --- .../api/integration-connection-procedures.ts | 26 +++++++--- .../api/integration-connections.test.ts | 45 +++++++++++------ .../api/integration-page-procedures.ts | 17 +++++-- .../server/connect-callback.test.ts | 24 +++++++-- .../integrations/server/connect-callback.ts | 12 ++++- .../integrations/server/connection-state.ts | 24 +++++++++ .../server/connection-token.test.ts | 17 ++++--- .../integrations/server/connection-token.ts | 12 +++-- .../integrations/server/detach-sources.ts | 49 ++++++++++++++----- .../server/sync-source-freshness.test.ts | 19 +++++-- .../server/sync-source-freshness.ts | 20 ++++++-- 11 files changed, 200 insertions(+), 65 deletions(-) create mode 100644 apps/app/src/features/integrations/server/connection-state.ts diff --git a/apps/app/src/features/integrations/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index 7cb772c..d6da9ff 100644 --- a/apps/app/src/features/integrations/api/integration-connection-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-connection-procedures.ts @@ -11,8 +11,13 @@ import { routes } from "@scibly/routes"; import { resolveOrg } from "@/features/organizations/server"; import { signOAuthState } from "@/lib/crypto/oauth-state"; +import { + CONNECTED, + DISCONNECTED_CREDENTIAL, + isConnected, +} from "../server/connection-state"; import { resolveConnectionToken } from "../server/connection-token"; -import { detachSourcesFromConnection } from "../server/detach-sources"; +import { warnSourcesOfLostConnection } from "../server/detach-sources"; import { getPageProvider, getProvider, @@ -38,7 +43,9 @@ export async function resolveConnectionRow( organizationId_provider: { organizationId, provider: providerId }, }, }); - if (!connection) { + // A disconnected connection is a row with no credential left on it. Nothing + // here can use one, so it is as absent as a row that was never written. + if (!connection || !isConnected(connection)) { throw new AppError({ code: "NOT_FOUND", applicationCode: "api.not_found", @@ -80,7 +87,7 @@ export const integrationConnectionProcedures = { "admin_or_owner", ); const connections = await db.integrationConnection.findMany({ - where: { organizationId: organization.id }, + where: { organizationId: organization.id, ...CONNECTED }, select: { id: true, provider: true, @@ -134,19 +141,22 @@ export const integrationConnectionProcedures = { select: { id: true }, }); - // One transaction: a detach that committed without its delete would - // leave the connection listed as live with none of its sources attached, - // and the next disconnect would have nothing left to warn on. + // One transaction: a warning that committed without the credential going + // with it would leave sources warning about a connection still live. if (connection) { await db.$transaction(async (tx) => { - await detachSourcesFromConnection( + await warnSourcesOfLostConnection( connection.id, input.provider, "disconnected", tx, ); - await tx.integrationConnection.delete({ + // The row stays, credential-less. It is what remembers which + // workspace these sources came from, so a reconnect can tell a + // resumed connection from a swapped one. + await tx.integrationConnection.update({ where: { id: connection.id }, + data: DISCONNECTED_CREDENTIAL, }); }); } diff --git a/apps/app/src/features/integrations/api/integration-connections.test.ts b/apps/app/src/features/integrations/api/integration-connections.test.ts index e46a267..928f72e 100644 --- a/apps/app/src/features/integrations/api/integration-connections.test.ts +++ b/apps/app/src/features/integrations/api/integration-connections.test.ts @@ -18,14 +18,14 @@ import { } from "./integration.schema"; // Real tRPC caller over the real router, so input validation runs for real. -// `db` and `resolveOrg` are mocked; `detachSourcesFromConnection` is not. +// `db` and `resolveOrg` are mocked; `warnSourcesOfLostConnection` is not. const db = vi.hoisted(() => { const client = { integrationConnection: { findUnique: vi.fn(), findMany: vi.fn(), - delete: vi.fn(), + update: vi.fn(), create: vi.fn(), upsert: vi.fn(), }, @@ -107,7 +107,7 @@ beforeEach(() => { resolveOrg.mockResolvedValue({ organization: { id: "org-resolved" } }); db.integrationConnection.findMany.mockResolvedValue([]); db.integrationConnection.findUnique.mockResolvedValue({ id: "conn-1" }); - db.integrationConnection.delete.mockResolvedValue({}); + db.integrationConnection.update.mockResolvedValue({}); db.notebookSource.updateMany.mockResolvedValue({ count: 2 }); }); @@ -196,7 +196,7 @@ describe("LR who may see, who may change", () => { ); expect(await refusalCode(() => call(caller()))).toBe("FORBIDDEN"); - expect(db.integrationConnection.delete).not.toHaveBeenCalled(); + expect(db.integrationConnection.update).not.toHaveBeenCalled(); expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); }, ); @@ -205,7 +205,24 @@ describe("LR who may see, who may change", () => { await caller().list({ orgSlug: "acme" }); expect(db.integrationConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { organizationId: "org-resolved" } }), + expect.objectContaining({ + where: expect.objectContaining({ organizationId: "org-resolved" }), + }), + ); + }); + + it("LR2 lists only the connections that still hold a credential", async () => { + await caller().list({ orgSlug: "acme" }); + + expect(db.integrationConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: [ + { accessTokenEncrypted: { not: null } }, + { installationId: { not: null } }, + ], + }), + }), ); }); @@ -240,27 +257,25 @@ describe("LR who may see, who may change", () => { }); describe("LD what a disconnect leaves behind", () => { - it("LD1 deletes the row that holds the tokens", async () => { + it("LD1 wipes the credential off the row and keeps the row", async () => { const result = await caller().disconnect({ orgSlug: "acme", provider: "NOTION", }); - expect(db.integrationConnection.delete).toHaveBeenCalledWith({ + expect(db.integrationConnection.update).toHaveBeenCalledWith({ where: { id: "conn-1" }, + data: { accessTokenEncrypted: null, installationId: null }, }); expect(result).toEqual({ success: true }); }); - it("LD2 detaches the sources it pulled in and tells the author why", async () => { + it("LD2 warns the sources it pulled in and leaves them linked", async () => { await caller().disconnect({ orgSlug: "acme", provider: "NOTION" }); expect(db.notebookSource.updateMany).toHaveBeenCalledWith({ where: { integrationId: "conn-1" }, - data: { - integrationId: null, - warning: expect.stringContaining("disconnected"), - }, + data: { warning: expect.stringContaining("disconnected") }, }); }); @@ -272,13 +287,13 @@ describe("LD what a disconnect leaves behind", () => { expect(db.scene.deleteMany).not.toHaveBeenCalled(); }); - it("LD2 detaches before the row the sources point at is gone", async () => { + it("LD2 warns before the credential the warning is about is gone", async () => { await caller().disconnect({ orgSlug: "acme", provider: "NOTION" }); expect( db.notebookSource.updateMany.mock.invocationCallOrder[0], ).toBeLessThan( - db.integrationConnection.delete.mock.invocationCallOrder[0] ?? 0, + db.integrationConnection.update.mock.invocationCallOrder[0] ?? 0, ); }); @@ -291,7 +306,7 @@ describe("LD what a disconnect leaves behind", () => { }); expect(result).toEqual({ success: true }); - expect(db.integrationConnection.delete).not.toHaveBeenCalled(); + expect(db.integrationConnection.update).not.toHaveBeenCalled(); expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); }); diff --git a/apps/app/src/features/integrations/api/integration-page-procedures.ts b/apps/app/src/features/integrations/api/integration-page-procedures.ts index 7a46fcd..187b7c9 100644 --- a/apps/app/src/features/integrations/api/integration-page-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-page-procedures.ts @@ -1,5 +1,6 @@ import { AppError } from "@scibly/api/application-error"; import { protectedProcedure } from "@scibly/api/trpc"; +import { db } from "@scibly/db"; import { boundedIngest, @@ -10,6 +11,7 @@ import { } from "@/features/notebook/server"; import { resolveOrg } from "@/features/organizations/server"; +import { isConnected } from "../server/connection-state"; import { linkPageSchema, linkPagesSchema, @@ -121,12 +123,21 @@ export const integrationPageProcedures = { message: "This source is not an external integration source.", }); } - if (!source.integrationId) { + // The link survives a disconnect, so having one is not enough: the + // connection it points at has to still hold a credential. + const connection = source.integrationId + ? await db.integrationConnection.findUnique({ + where: { id: source.integrationId }, + select: { accessTokenEncrypted: true, installationId: true }, + }) + : null; + if (!connection || !isConnected(connection)) { throw new AppError({ code: "BAD_REQUEST", applicationCode: "api.bad_request", - message: - "This source's integration was disconnected. Reconnect the integration and re-link the page to resume syncing.", + message: source.integrationId + ? "This source's integration is disconnected. Reconnect it to resume syncing." + : "This source's integration was disconnected. Reconnect the integration and re-link the page to resume syncing.", }); } const ingestion = await boundedIngest(userId, source.id); diff --git a/apps/app/src/features/integrations/server/connect-callback.test.ts b/apps/app/src/features/integrations/server/connect-callback.test.ts index 70ec3c6..8f13e03 100644 --- a/apps/app/src/features/integrations/server/connect-callback.test.ts +++ b/apps/app/src/features/integrations/server/connect-callback.test.ts @@ -300,7 +300,7 @@ describe("LS what is stored", () => { }); }); - it("LS3 re-authorising the same workspace touches no sources", async () => { + it("LS3 re-authorising the same workspace keeps its sources and lifts their warning", async () => { db.integrationConnection.findUnique.mockResolvedValue({ id: "conn-1", workspaceId: "workspace-1", @@ -308,7 +308,13 @@ describe("LS what is stored", () => { await callback({ code: "auth-code", state: state() }); - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(db.notebookSource.updateMany).toHaveBeenCalledWith({ + where: { + integrationId: "conn-1", + warning: expect.stringContaining("NOTION integration is disconnected"), + }, + data: { warning: null }, + }); expect(db.integrationConnection.upsert).toHaveBeenCalledTimes(1); }); @@ -337,7 +343,11 @@ describe("LS what is stored", () => { await callback({ code: "auth-code", state: state() }); - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(db.notebookSource.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ integrationId: null }), + }), + ); }); it("LS4 detaches before the tokens are overwritten", async () => { @@ -355,7 +365,7 @@ describe("LS what is stored", () => { ); }); - it("LD4 reconnecting after a disconnect does not revive the detached sources", async () => { + it("LD4 a connect with nothing to come back to touches no sources", async () => { db.integrationConnection.findUnique.mockResolvedValue(null); await callback({ code: "auth-code", state: state() }); @@ -515,7 +525,11 @@ describe("LS what an installation stores", () => { await githubCallback({ installation_id: "99", state: githubState() }); - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); + expect(db.notebookSource.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ integrationId: null }), + }), + ); expect(upserted().update).toMatchObject({ installationId: "99" }); }); }); diff --git a/apps/app/src/features/integrations/server/connect-callback.ts b/apps/app/src/features/integrations/server/connect-callback.ts index 2ed04c7..62c262e 100644 --- a/apps/app/src/features/integrations/server/connect-callback.ts +++ b/apps/app/src/features/integrations/server/connect-callback.ts @@ -27,7 +27,10 @@ import { requireOrgMember } from "@/features/organizations/server"; import { encryptApiKey } from "@/lib/crypto/api-key"; import { verifyOAuthState } from "@/lib/crypto/oauth-state"; -import { detachSourcesFromConnection } from "./detach-sources"; +import { + clearDisconnectWarning, + warnSourcesOfLostConnection, +} from "./detach-sources"; type CallbackDestination = { settingsUrl: string; @@ -227,12 +230,17 @@ async function completeAndPersistConnection( existing.workspaceId !== credential.workspaceId; if (movedWorkspace) { - await detachSourcesFromConnection( + await warnSourcesOfLostConnection( existing.id, callback.provider, "workspace_changed", tx, ); + } else if (existing) { + // The same workspace coming back is the answer to whatever the sources + // were warning about, so the warning goes with it. They kept their link + // through the disconnect, so there is nothing else to put back. + await clearDisconnectWarning(existing.id, callback.provider, tx); } await tx.integrationConnection.upsert({ diff --git a/apps/app/src/features/integrations/server/connection-state.ts b/apps/app/src/features/integrations/server/connection-state.ts new file mode 100644 index 0000000..a4d9ca1 --- /dev/null +++ b/apps/app/src/features/integrations/server/connection-state.ts @@ -0,0 +1,24 @@ +import type { Prisma } from "@scibly/db"; + +// Disconnecting takes the credential and leaves the row: the sources stay +// linked to it, and the next connect can see which workspace they came from. +// Holding neither credential column is therefore the whole of what +// "disconnected" means — nothing else marks it. +export const DISCONNECTED_CREDENTIAL = { + accessTokenEncrypted: null, + installationId: null, +}; + +export const CONNECTED = { + OR: [ + { accessTokenEncrypted: { not: null } }, + { installationId: { not: null } }, + ], +} satisfies Prisma.IntegrationConnectionWhereInput; + +export function isConnected(connection: { + accessTokenEncrypted: string | null; + installationId: string | null; +}): boolean { + return Boolean(connection.accessTokenEncrypted ?? connection.installationId); +} diff --git a/apps/app/src/features/integrations/server/connection-token.test.ts b/apps/app/src/features/integrations/server/connection-token.test.ts index 98af80f..0915d91 100644 --- a/apps/app/src/features/integrations/server/connection-token.test.ts +++ b/apps/app/src/features/integrations/server/connection-token.test.ts @@ -6,12 +6,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // still observed individually — what is under test is that both happen, and // that they happen through the transaction. const db: { - integrationConnection: { deleteMany: ReturnType }; + integrationConnection: { updateMany: ReturnType }; notebookSource: { updateMany: ReturnType }; $transaction: ReturnType; } = vi.hoisted(() => { const client = { - integrationConnection: { deleteMany: vi.fn() }, + integrationConnection: { updateMany: vi.fn() }, notebookSource: { updateMany: vi.fn() }, $transaction: vi.fn(), }; @@ -89,22 +89,23 @@ describe("K2 a connection revoked on the provider's side", () => { ); }); - it("K2 deletes the connection it can no longer stand for", async () => { + it("K2 takes the credential it can no longer stand for off the row", async () => { await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow(); - expect(db.integrationConnection.deleteMany).toHaveBeenCalledWith({ + expect(db.integrationConnection.updateMany).toHaveBeenCalledWith({ where: { id: "conn_1" }, + data: { accessTokenEncrypted: null, installationId: null }, }); }); - it("K2 detaches its sources first, exactly as a disconnect does", async () => { + it("K2 warns its sources first, exactly as a disconnect does", async () => { await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow(); const [args] = db.notebookSource.updateMany.mock.calls[0] as [ { where: { integrationId: string }; data: { warning: string } }, ]; expect(args.where.integrationId).toBe("conn_1"); - expect(args.data.warning).toMatch(/GITHUB integration was disconnected/); + expect(args.data.warning).toMatch(/GITHUB integration is disconnected/); }); it("K2 gives up both halves together if either fails", async () => { @@ -112,7 +113,7 @@ describe("K2 a connection revoked on the provider's side", () => { await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow("deadlock"); expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); - expect(db.integrationConnection.deleteMany).not.toHaveBeenCalled(); + expect(db.integrationConnection.updateMany).not.toHaveBeenCalled(); }); it("K2 says so in its own application code, so the client can explain", async () => { @@ -130,6 +131,6 @@ describe("K2 a connection revoked on the provider's side", () => { await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow( "GitHub is down", ); - expect(db.integrationConnection.deleteMany).not.toHaveBeenCalled(); + expect(db.integrationConnection.updateMany).not.toHaveBeenCalled(); }); }); diff --git a/apps/app/src/features/integrations/server/connection-token.ts b/apps/app/src/features/integrations/server/connection-token.ts index f762ef1..ac13928 100644 --- a/apps/app/src/features/integrations/server/connection-token.ts +++ b/apps/app/src/features/integrations/server/connection-token.ts @@ -6,7 +6,8 @@ import { db } from "@scibly/db"; import { decryptApiKey } from "@/lib/crypto/api-key"; import { IntegrationRevokedError } from "./base-provider"; -import { detachSourcesFromConnection } from "./detach-sources"; +import { DISCONNECTED_CREDENTIAL } from "./connection-state"; +import { warnSourcesOfLostConnection } from "./detach-sources"; import { getProvider } from "./registry"; // The one place that turns what a connection stores into the token its API @@ -33,7 +34,7 @@ function revoked(provider: string): AppError { return new AppError({ code: "NOT_FOUND", applicationCode: "integration.revoked", - message: `The ${provider} integration was removed on ${provider}'s side, so the connection was removed here too. Connect again to resume.`, + message: `The ${provider} integration was removed on ${provider}'s side, so it was disconnected here too. Connect again to resume.`, }); } @@ -45,13 +46,16 @@ async function forgetRevokedConnection( providerId: IntegrationProviderId, ): Promise { await db.$transaction(async (tx) => { - await detachSourcesFromConnection( + await warnSourcesOfLostConnection( connection.id, providerId, "disconnected", tx, ); - await tx.integrationConnection.deleteMany({ where: { id: connection.id } }); + await tx.integrationConnection.updateMany({ + where: { id: connection.id }, + data: DISCONNECTED_CREDENTIAL, + }); }); } diff --git a/apps/app/src/features/integrations/server/detach-sources.ts b/apps/app/src/features/integrations/server/detach-sources.ts index 5959de3..f829c82 100644 --- a/apps/app/src/features/integrations/server/detach-sources.ts +++ b/apps/app/src/features/integrations/server/detach-sources.ts @@ -3,24 +3,51 @@ import type { IntegrationProviderId } from "../contracts"; import { db, type Prisma } from "@scibly/db"; type DetachReason = "disconnected" | "workspace_changed"; +type Tx = Prisma.TransactionClient | typeof db; -// Every caller detaches as one half of a pair — the other half deletes or -// replaces the connection row — so the transaction client is a parameter and -// the two halves commit together. -export async function detachSourcesFromConnection( +// Written when the connection loses its credential and cleared when it gets one +// back, so both halves have to spell it the same way — a source left holding a +// warning nobody writes any more would never lose it. +export function disconnectWarning(provider: IntegrationProviderId): string { + return `The ${provider} integration is disconnected. Reconnect it to resume syncing.`; +} + +// Every caller warns as one half of a pair — the other half takes the +// credential away — so the transaction client is a parameter and the two halves +// commit together. +export async function warnSourcesOfLostConnection( connectionId: string, provider: IntegrationProviderId, reason: DetachReason, - tx: Prisma.TransactionClient | typeof db = db, + tx: Tx = db, ) { await tx.notebookSource.updateMany({ where: { integrationId: connectionId }, - data: { - integrationId: null, - warning: - reason === "disconnected" - ? `The ${provider} integration was disconnected. This source will no longer sync automatically — reconnect and re-link the page to resume syncing.` - : `The ${provider} integration was reconnected to a different workspace. This source will no longer sync automatically — re-link the page from the new workspace to resume syncing.`, + // A disconnect keeps the link. The connection row outlives it, so + // reconnecting the same workspace picks these sources back up, where + // clearing `integrationId` would have orphaned them for good. A workspace + // change is the case where the link really is dead: the pages it names live + // somewhere we no longer have. + data: + reason === "disconnected" + ? { warning: disconnectWarning(provider) } + : { + integrationId: null, + warning: `The ${provider} integration was reconnected to a different workspace. This source will no longer sync automatically — re-link the page from the new workspace to resume syncing.`, + }, + }); +} + +export async function clearDisconnectWarning( + connectionId: string, + provider: IntegrationProviderId, + tx: Tx = db, +) { + await tx.notebookSource.updateMany({ + where: { + integrationId: connectionId, + warning: disconnectWarning(provider), }, + data: { warning: null }, }); } diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts index a4a210a..14563fc 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.test.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts @@ -170,10 +170,21 @@ describe("KS1/KS2/KF3/KB1/KB2/KB4: which connections a sync is due to poll", () await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(args.where.OR).toEqual([ - { nextPollAfter: null }, - { nextPollAfter: { lte: NOW } }, - ]); + expect(args.where.AND).toContainEqual({ + OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: NOW } }], + }); + }); + + it("KF3: excludes a connection left without a credential by a disconnect", async () => { + await loadDueConnections(NOW); + + const [args] = db.integrationConnection.findMany.mock.calls[0]; + expect(args.where.AND).toContainEqual({ + OR: [ + { accessTokenEncrypted: { not: null } }, + { installationId: { not: null } }, + ], + }); }); it("KB1/KB2: owes only connections of an organization with a live subscription", async () => { diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.ts b/apps/app/src/features/integrations/server/sync-source-freshness.ts index 81e1f84..ee87552 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.ts @@ -3,6 +3,10 @@ import { TimeHelpers } from "@scibly/api/rate-limit"; import { db, type Prisma } from "@scibly/db"; import { PAGE_INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; +import { + CONNECTED, + isConnected, +} from "@/features/integrations/server/connection-state"; import { resolveConnectionToken } from "@/features/integrations/server/connection-token"; import { getPageProvider } from "@/features/integrations/server/registry"; import { SOURCE_STATUS } from "@/shared/content/sources/constants"; @@ -40,7 +44,13 @@ export async function loadDueConnections( // A connection to a provider without pages has nothing that could go // stale, so a poll would only spend a call to learn that. provider: { in: [...PAGE_INTEGRATION_PROVIDERS] }, - OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }], + // Two ORs, so both go through `AND`: a disconnected connection keeps its + // row so its sources keep their link, but there is nothing left to poll + // it with. + AND: [ + CONNECTED, + { OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }] }, + ], }, select: { id: true, provider: true }, orderBy: { lastAttemptedAt: { sort: "asc", nulls: "first" } }, @@ -69,9 +79,9 @@ export function getPollingStart(lastPolledAt: Date | null, now: Date): Date { } // `updateMany`, not `update`: a poll can outlive the connection it is polling — -// resolving the token is itself what deletes a connection the provider says is -// gone — and a row that is no longer there is nothing left to record, not a -// failure worth retrying the whole poll for. +// the organization can disconnect or delete it mid-run — and a row that is no +// longer there is nothing left to record, not a failure worth retrying the +// whole poll for. async function recordAttempt( connectionId: string, data: Prisma.IntegrationConnectionUpdateManyMutationInput, @@ -146,7 +156,7 @@ export async function pollConnection( lastPolledAt: true, }, }); - if (!connection) return { status: "gone" }; + if (!connection || !isConnected(connection)) return { status: "gone" }; const sources = await loadSyncableSources(connection.id); if (sources.length === 0) { From e354ae1617f7411a48cdd25c5f4dde4c6e0b4f9d Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Sat, 29 Aug 2026 00:31:02 +0200 Subject: [PATCH 41/43] Name every GitHub variable the env schema requires The setup guide listed the app id and private key and stopped, but the schema also refuses to boot without the slug, the client id and the client secret. Co-Authored-By: Claude Opus 5 --- docs/setup.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/setup.md b/docs/setup.md index bd4b1c9..9074421 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -40,7 +40,8 @@ cp packages/db/.env.example packages/db/.env `SKIP_ENV_VALIDATION=true` and leave those blank — the app boots, but features that depend on a missing credential (media uploads, billing, Notion import, ...) won't work until it's supplied. -- `GITHUB_APP_SLUG`, `GITHUB_APP_ID`, and `GITHUB_APP_PRIVATE_KEY` are +- `GITHUB_APP_SLUG`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, + `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET` are required by that schema, like Notion's credentials. They come from a GitHub App you register once per environment — see [runbooks/github-app.md](runbooks/github-app.md), which covers the dev and From b4d1e7c8b88472845bc2d4b1190a6a7333fb6962 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Sat, 29 Aug 2026 07:40:06 +0200 Subject: [PATCH 42/43] cleaned up comments --- .../api/integration-connection-procedures.ts | 11 ----- .../api/integration-connections.test.ts | 4 +- .../api/integration.schema.test.ts | 6 +-- .../integrations/api/integration.schema.ts | 3 -- .../src/features/integrations/contracts.ts | 28 ++----------- apps/app/src/features/integrations/server.ts | 2 +- .../integrations/server/base-provider.ts | 25 ----------- .../server/connect-callback.test.ts | 11 +---- .../integrations/server/connect-callback.ts | 18 ++------ .../integrations/server/connection-state.ts | 6 +-- .../server/connection-token.test.ts | 5 +-- .../integrations/server/connection-token.ts | 8 ---- .../integrations/server/detach-sources.ts | 13 +----- .../server/providers/github/app-auth.ts | 42 ++++--------------- .../server/providers/github/provider.test.ts | 11 +---- .../server/providers/github/provider.ts | 29 ++++--------- .../integrations/server/providers/notion.ts | 4 +- .../features/integrations/server/registry.ts | 3 +- .../server/sync-source-freshness.ts | 25 +++-------- .../disconnect-integration-dialog.tsx | 3 -- .../org-integrations-card.test.tsx | 3 -- .../org-integrations-card.tsx | 7 +++- .../org-integrations-empty.tsx | 13 ------ .../provider-grants-dialog.tsx | 4 -- .../org-integrations/provider-grants.tsx | 11 +---- .../org-integrations/provider-row.tsx | 1 - .../org-integrations/use-org-integrations.ts | 10 ----- .../notebook/sources/api/bounded-ingest.ts | 7 +--- .../components/integration-buttons.tsx | 5 --- .../components/source-list-item-actions.tsx | 5 +-- .../extractors/integration-extractors.ts | 4 +- .../page-picker/page-picker-content.tsx | 6 +-- .../page-picker-select-all-bar.tsx | 2 - .../notebook/sources/provider-display.ts | 5 --- .../settings/api/org-ai-config.schemas.ts | 1 - .../settings/components/org-settings-form.tsx | 8 +--- packages/routes/src/index.ts | 3 -- packages/schemas/src/schema/common/index.ts | 13 +----- 38 files changed, 65 insertions(+), 300 deletions(-) delete mode 100644 apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-empty.tsx diff --git a/apps/app/src/features/integrations/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index d6da9ff..1b55e84 100644 --- a/apps/app/src/features/integrations/api/integration-connection-procedures.ts +++ b/apps/app/src/features/integrations/api/integration-connection-procedures.ts @@ -32,8 +32,6 @@ import { searchPagesSchema, } from "./integration.schema"; -// No credential is touched here — enough for anything that only needs to know -// the connection exists. export async function resolveConnectionRow( organizationId: string, providerId: IntegrationProviderId, @@ -43,8 +41,6 @@ export async function resolveConnectionRow( organizationId_provider: { organizationId, provider: providerId }, }, }); - // A disconnected connection is a row with no credential left on it. Nothing - // here can use one, so it is as absent as a row that was never written. if (!connection || !isConnected(connection)) { throw new AppError({ code: "NOT_FOUND", @@ -66,8 +62,6 @@ export async function resolveConnection( }; } -// Page-shaped work goes through here instead: the provider it hands back is -// one that has pages. export async function resolvePageConnection( organizationId: string, providerId: PageIntegrationProviderId, @@ -101,8 +95,6 @@ export const integrationConnectionProcedures = { const allProviders = listProviders().map((provider) => ({ providerId: provider.providerId, displayName: provider.displayName, - // The browser cannot check a server method for itself, so the one place - // that can says whether the provider has one. listsGrants: Boolean(provider.listGrants), })); return { connections, allProviders }; @@ -151,9 +143,6 @@ export const integrationConnectionProcedures = { "disconnected", tx, ); - // The row stays, credential-less. It is what remembers which - // workspace these sources came from, so a reconnect can tell a - // resumed connection from a swapped one. await tx.integrationConnection.update({ where: { id: connection.id }, data: DISCONNECTED_CREDENTIAL, diff --git a/apps/app/src/features/integrations/api/integration-connections.test.ts b/apps/app/src/features/integrations/api/integration-connections.test.ts index 928f72e..2403dd0 100644 --- a/apps/app/src/features/integrations/api/integration-connections.test.ts +++ b/apps/app/src/features/integrations/api/integration-connections.test.ts @@ -32,8 +32,8 @@ const db = vi.hoisted(() => { notebookSource: { updateMany: vi.fn(), deleteMany: vi.fn() }, notebookSourceChunk: { deleteMany: vi.fn() }, scene: { deleteMany: vi.fn() }, - // The doubled client is handed straight back, so a write made through the - // transaction is still observed on the same spy. + // The doubled client is handed straight back, so writes made inside the + // transaction land on the same spy. $transaction: vi.fn((run: (tx: unknown) => unknown) => run(client)), }; return client; diff --git a/apps/app/src/features/integrations/api/integration.schema.test.ts b/apps/app/src/features/integrations/api/integration.schema.test.ts index 1ad1a63..ac938bb 100644 --- a/apps/app/src/features/integrations/api/integration.schema.test.ts +++ b/apps/app/src/features/integrations/api/integration.schema.test.ts @@ -2,10 +2,8 @@ import { describe, expect, it } from "vitest"; import { linkPageSchema } from "./integration.schema"; -// The url a provider hands back is stored and later rendered as an `href`, so -// the schema is the first of the two places that has to reject a scheme the -// browser would execute. (The second is the anchor in -// `source-list-item-actions.tsx`, which guards rows written before this.) +// The url is stored and later rendered as an `href`, so the schema has to reject a +// scheme the browser would execute. describe("linkPageSchema pageUrl", () => { const link = (pageUrl: string) => linkPageSchema.safeParse({ diff --git a/apps/app/src/features/integrations/api/integration.schema.ts b/apps/app/src/features/integrations/api/integration.schema.ts index 65ac2f3..fb266c2 100644 --- a/apps/app/src/features/integrations/api/integration.schema.ts +++ b/apps/app/src/features/integrations/api/integration.schema.ts @@ -8,14 +8,11 @@ import { PAGE_INTEGRATION_PROVIDERS, } from "../contracts"; -// Re-exported so call sites keep importing their own feature's schema module. export { orgSlugInput }; // An unrecognised provider is a bad request here, before any org is resolved or any row is read. export const providerInput = z.enum(INTEGRATION_PROVIDERS); -// Anything page-shaped narrows further: a provider without pages has nothing -// to search, browse, or link. export const pageProviderInput = z.enum(PAGE_INTEGRATION_PROVIDERS); export const getAuthUrlSchema = orgSlugInput.extend({ diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index 1452c92..e44e4b0 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -1,24 +1,14 @@ -// Kept dependency-free so the client bundle (input schemas, settings card) never pulls in a provider SDK. -// `IntegrationProvider` is a type-only import of a generated const object, so it -// is erased at build time and pulls nothing in. +// Kept dependency-free: the client bundle imports this, so it must never pull in a provider SDK. import type { IntegrationProvider } from "@scibly/db/enums"; -// The runtime list: `z.enum` and the picker need a value, and `satisfies` -// exhaustiveness checks need a tuple. `satisfies` ties it to the schema, so a -// member that the schema does not have is a compile error here. export const INTEGRATION_PROVIDERS = [ "NOTION", "GITHUB", ] as const satisfies readonly IntegrationProvider[]; -// The union comes from the schema rather than from the array above, so the two -// cannot drift: a provider added to the Prisma enum and not to this file fails -// the `satisfies Record` in `server/registry.ts`, -// which is the file that would have to build it anyway. export type IntegrationProviderId = IntegrationProvider; -// The only providers a notebook is offered as a source. A provider is worth -// connecting before it has pages — see `PageIntegrationProvider`. +// Not every connectable provider offers pages to import. export const PAGE_INTEGRATION_PROVIDERS = [ "NOTION", ] as const satisfies readonly IntegrationProviderId[]; @@ -26,9 +16,6 @@ export const PAGE_INTEGRATION_PROVIDERS = [ export type PageIntegrationProviderId = (typeof PAGE_INTEGRATION_PROVIDERS)[number]; -// One request's worth of pages. `linkPagesSchema` caps the input with it and -// the picker clamps its selection to it, so the two cannot drift into a batch -// the server rejects wholesale. export const MAX_LINKED_PAGES_PER_REQUEST = 20; // A provider's raw `?error=` is always mapped to `provider_denied` or `provider_error` first — it must never be echoed into the query string. @@ -68,17 +55,14 @@ export interface IntegrationPageRevision { lastEdited: Date; } -// A named part of a workspace a connection reaches — a repository an -// installation was given. A workspace handed over whole grants nothing to list. +// A named part of a workspace a connection reaches — a repository an installation was given. export interface IntegrationGrant { id: string; name: string; url: string; } -// The count is what the provider says it granted, which a listing that stopped -// at its page budget does not have all of. Fewer grants than `totalCount` is -// how the settings page knows it is showing a prefix, not the whole of it. +// Fewer grants than `totalCount` means the listing stopped at its page budget. export interface IntegrationGrantList { grants: IntegrationGrant[]; totalCount: number; @@ -90,16 +74,12 @@ export interface OAuthTokens { workspaceName?: string; } -// What an installed app leaves behind instead of tokens. The token it stands -// for is minted per call and never stored. export interface AppInstallation { installationId: string; workspaceId?: string; workspaceName?: string; } -// Which shape a connection holds decides both the columns it is written to and -// how its token is later got. export type IntegrationCredential = | ({ kind: "oauth_tokens" } & OAuthTokens) | ({ kind: "app_installation" } & AppInstallation); diff --git a/apps/app/src/features/integrations/server.ts b/apps/app/src/features/integrations/server.ts index 094c3e5..eb286a7 100644 --- a/apps/app/src/features/integrations/server.ts +++ b/apps/app/src/features/integrations/server.ts @@ -5,4 +5,4 @@ export { handleIntegrationConnectCallback } from "./server/connect-callback"; export { resolveConnectionToken } from "./server/connection-token"; export { integrationPoll, integrationSync } from "./server/integration-sync"; export { buildIntegrationNotebookTools } from "./server/notebook-tools"; -export { getPageProvider, getProvider, listProviders } from "./server/registry"; +export { getPageProvider } from "./server/registry"; diff --git a/apps/app/src/features/integrations/server/base-provider.ts b/apps/app/src/features/integrations/server/base-provider.ts index 00bb5be..0066726 100644 --- a/apps/app/src/features/integrations/server/base-provider.ts +++ b/apps/app/src/features/integrations/server/base-provider.ts @@ -9,19 +9,11 @@ import type { PageIntegrationProviderId, } from "../contracts"; -// Which of the two the provider's redirect carries is decided by its -// `credential`, not by the caller. export interface ConnectCallbackParams { code: string | null; installationId: string | null; } -/** - * A provider saying the credential behind a connection is gone on its side — - * the app uninstalled, the grant revoked. Nothing a reconnect cannot fix, and - * distinct from a call that merely failed, which is why it is worth its own - * type: only this one means the stored connection is now fiction. - */ export class IntegrationRevokedError extends Error { constructor(readonly providerId: IntegrationProviderId) { super(`The ${providerId} connection no longer exists on the provider.`); @@ -29,38 +21,24 @@ export class IntegrationRevokedError extends Error { } } -/** - * The connection itself: how one is authorised, and what it is worth once made. - * A capability beyond that is an optional method, and having the method is the - * capability — there is no second place where a provider restates what it can - * do. - */ export abstract class IntegrationProvider { abstract readonly providerId: IntegrationProviderId; abstract readonly displayName: string; - /** Which credential shape a finished connect leaves behind. */ abstract readonly credential: IntegrationCredentialKind; abstract getAuthUrl(state: string, redirectUri: string): string; - /** Turn what the provider's redirect carried into the credential to store. */ abstract completeConnect( params: ConnectCallbackParams, redirectUri: string, ): Promise; - /** Present only on a provider whose token is minted per use, not stored. */ mintAccessToken?(installationId: string): Promise; - /** Present only on a provider that hands its workspace out piece by piece. */ listGrants?(token: string): Promise; } -/** - * A provider whose material is pages: the only kind a notebook can import a - * source from, and the only kind `PAGE_INTEGRATION_PROVIDERS` names. - */ export abstract class PageIntegrationProvider extends IntegrationProvider { abstract readonly providerId: PageIntegrationProviderId; @@ -74,7 +52,6 @@ export abstract class PageIntegrationProvider extends IntegrationProvider { pageId: string, ): Promise; - /** Pages held inside another; none unless the provider nests them. */ abstract listChildren( token: string, pageId: string, @@ -85,13 +62,11 @@ export abstract class PageIntegrationProvider extends IntegrationProvider { databaseId: string, ): Promise; - /** The cheap edited-at marker a poll checks, when the provider offers one. */ abstract getPageRevision( token: string, pageId: string, ): Promise; - /** What a poll asks for. Nothing, unless the provider can say what changed. */ abstract pollModifiedPages( token: string, since: Date, diff --git a/apps/app/src/features/integrations/server/connect-callback.test.ts b/apps/app/src/features/integrations/server/connect-callback.test.ts index 8f13e03..55d4144 100644 --- a/apps/app/src/features/integrations/server/connect-callback.test.ts +++ b/apps/app/src/features/integrations/server/connect-callback.test.ts @@ -10,10 +10,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { decryptApiKey } from "@/lib/crypto/api-key"; import { signOAuthState } from "@/lib/crypto/oauth-state"; -// Exercises the full route handler; only the database, session, membership -// policy, and what the provider makes of its own callback are mocked. The state -// signer is real. - const APP_URL = "http://localhost:3000"; const SETTINGS = `${APP_URL}/de/profile/org/acme/settings`; const NOW = new Date("2026-07-27T12:00:00.000Z"); @@ -31,8 +27,8 @@ const db = vi.hoisted(() => { upsert: vi.fn<(args: UpsertArgs) => Promise>(), }, notebookSource: { updateMany: vi.fn() }, - // The doubled client is handed straight back, so the reads and writes the - // callback makes inside the transaction land on the same spies. + // The doubled client is handed straight back, so the transaction's reads and + // writes land on the same spies. $transaction: vi.fn((run: (tx: unknown) => unknown) => run(client)), }; return client; @@ -449,9 +445,6 @@ describe("LS what is stored", () => { }); }); -// GitHub comes back from an install, not from an OAuth grant: the callback -// carries an installation id and no code, and what is stored is the -// installation rather than a token. describe("LS what an installation stores", () => { let install: CompleteConnect; diff --git a/apps/app/src/features/integrations/server/connect-callback.ts b/apps/app/src/features/integrations/server/connect-callback.ts index 62c262e..c80b804 100644 --- a/apps/app/src/features/integrations/server/connect-callback.ts +++ b/apps/app/src/features/integrations/server/connect-callback.ts @@ -56,8 +56,6 @@ function providerError(oauthError: string): IntegrationCallbackError { return oauthError === "access_denied" ? "provider_denied" : "provider_error"; } -// An OAuth provider sends back a code to redeem, an app installation the id of -// the installation just made. Only the one the provider deals in is looked at. function readCallbackParams( searchParams: URLSearchParams, provider: IntegrationProvider, @@ -189,9 +187,7 @@ async function completeAndPersistConnection( ) { const redirectUri = routes.app.api.integrations.callback(callback.provider); - // The provider round trip stays outside the transaction: it is a network - // call, and holding a row lock across it would be a lock held for as long as - // the provider feels like taking. + // Outside the transaction: no row lock is held for as long as the provider takes. const credential = await getProvider(callback.provider).completeConnect( callback.params, redirectUri, @@ -208,17 +204,14 @@ async function completeAndPersistConnection( connectedByUserId: callback.connectedByUserId, - // A reconnect is the answer to whatever the polls were failing on, so the - // backoff the failures built up does not outlive it: the connection is - // eligible again on the next chain rather than hours from now. + // A reconnect answers whatever the polls were failing on, so its backoff does not outlive it. consecutiveFailures: 0, nextPollAfter: null, }; await db.$transaction(async (tx) => { - // Read inside the transaction, not before the provider call: two callbacks - // landing together would otherwise both see the pre-connect workspace and - // decide independently whether to detach. + // Read inside the transaction: two callbacks landing together would otherwise + // both see the pre-connect workspace. const existing = await tx.integrationConnection.findUnique({ where, select: { id: true, workspaceId: true }, @@ -237,9 +230,6 @@ async function completeAndPersistConnection( tx, ); } else if (existing) { - // The same workspace coming back is the answer to whatever the sources - // were warning about, so the warning goes with it. They kept their link - // through the disconnect, so there is nothing else to put back. await clearDisconnectWarning(existing.id, callback.provider, tx); } diff --git a/apps/app/src/features/integrations/server/connection-state.ts b/apps/app/src/features/integrations/server/connection-state.ts index a4d9ca1..077e4ac 100644 --- a/apps/app/src/features/integrations/server/connection-state.ts +++ b/apps/app/src/features/integrations/server/connection-state.ts @@ -1,9 +1,7 @@ import type { Prisma } from "@scibly/db"; -// Disconnecting takes the credential and leaves the row: the sources stay -// linked to it, and the next connect can see which workspace they came from. -// Holding neither credential column is therefore the whole of what -// "disconnected" means — nothing else marks it. +// Holding neither credential column is the whole of what "disconnected" means: the row +// stays so a reconnect can see which workspace its sources came from. export const DISCONNECTED_CREDENTIAL = { accessTokenEncrypted: null, installationId: null, diff --git a/apps/app/src/features/integrations/server/connection-token.test.ts b/apps/app/src/features/integrations/server/connection-token.test.ts index 0915d91..40b3733 100644 --- a/apps/app/src/features/integrations/server/connection-token.test.ts +++ b/apps/app/src/features/integrations/server/connection-token.test.ts @@ -2,9 +2,8 @@ import type { ConnectionCredential } from "./connection-token"; import { beforeEach, describe, expect, it, vi } from "vitest"; -// `$transaction` hands the same doubled client back, so the two writes are -// still observed individually — what is under test is that both happen, and -// that they happen through the transaction. +// `$transaction` hands the same doubled client back, so both writes are still +// observed individually. const db: { integrationConnection: { updateMany: ReturnType }; notebookSource: { updateMany: ReturnType }; diff --git a/apps/app/src/features/integrations/server/connection-token.ts b/apps/app/src/features/integrations/server/connection-token.ts index ac13928..c3603ff 100644 --- a/apps/app/src/features/integrations/server/connection-token.ts +++ b/apps/app/src/features/integrations/server/connection-token.ts @@ -10,9 +10,6 @@ import { DISCONNECTED_CREDENTIAL } from "./connection-state"; import { warnSourcesOfLostConnection } from "./detach-sources"; import { getProvider } from "./registry"; -// The one place that turns what a connection stores into the token its API -// calls carry: an OAuth connection keeps an encrypted token, an app -// installation keeps only its id and mints a fresh token here for each use. export interface ConnectionCredential { id: string; provider: IntegrationProviderId; @@ -28,8 +25,6 @@ function unusable(provider: string): AppError { }); } -// Its own application code so the client can say what happened rather than -// showing a connection that has already been taken away. function revoked(provider: string): AppError { return new AppError({ code: "NOT_FOUND", @@ -38,9 +33,6 @@ function revoked(provider: string): AppError { }); } -// Uninstalling the app is the provider's own disconnect, just announced -// nowhere: the id we hold is dead and no later call can revive it. Treat it -// exactly as a disconnect pressed here, so the two sides agree again. async function forgetRevokedConnection( connection: ConnectionCredential, providerId: IntegrationProviderId, diff --git a/apps/app/src/features/integrations/server/detach-sources.ts b/apps/app/src/features/integrations/server/detach-sources.ts index f829c82..01e03c0 100644 --- a/apps/app/src/features/integrations/server/detach-sources.ts +++ b/apps/app/src/features/integrations/server/detach-sources.ts @@ -5,16 +5,10 @@ import { db, type Prisma } from "@scibly/db"; type DetachReason = "disconnected" | "workspace_changed"; type Tx = Prisma.TransactionClient | typeof db; -// Written when the connection loses its credential and cleared when it gets one -// back, so both halves have to spell it the same way — a source left holding a -// warning nobody writes any more would never lose it. export function disconnectWarning(provider: IntegrationProviderId): string { return `The ${provider} integration is disconnected. Reconnect it to resume syncing.`; } -// Every caller warns as one half of a pair — the other half takes the -// credential away — so the transaction client is a parameter and the two halves -// commit together. export async function warnSourcesOfLostConnection( connectionId: string, provider: IntegrationProviderId, @@ -23,11 +17,8 @@ export async function warnSourcesOfLostConnection( ) { await tx.notebookSource.updateMany({ where: { integrationId: connectionId }, - // A disconnect keeps the link. The connection row outlives it, so - // reconnecting the same workspace picks these sources back up, where - // clearing `integrationId` would have orphaned them for good. A workspace - // change is the case where the link really is dead: the pages it names live - // somewhere we no longer have. + // A disconnect keeps the link so reconnecting the same workspace picks these + // sources back up; a changed workspace is where the link really is dead. data: reason === "disconnected" ? { warning: disconnectWarning(provider) } diff --git a/apps/app/src/features/integrations/server/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts index 19b8942..12a5aad 100644 --- a/apps/app/src/features/integrations/server/providers/github/app-auth.ts +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -4,12 +4,7 @@ import { z } from "zod"; import { env } from "@/env"; -// The app's private key never leaves this module: every call a connection -// makes carries an installation access token minted here for that call and -// dropped afterwards, which is why none is ever written down. - -// GitHub rejects a JWT issued ahead of its own clock and caps the lifetime at -// ten minutes; both bounds are taken with room to spare. +// GitHub rejects a JWT issued ahead of its own clock and caps the lifetime at ten minutes. const JWT_BACKDATE_SECONDS = 60; const JWT_LIFETIME_SECONDS = 8 * 60; @@ -49,7 +44,6 @@ function base64url(value: string | Buffer): string { return Buffer.from(value).toString("base64url"); } -/** A short-lived assertion that this is the app — never an installation. */ export function signAppJwt(config: GitHubAppConfig, now = new Date()): string { const issuedAt = Math.floor(now.getTime() / 1000) - JWT_BACKDATE_SECONDS; const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" })); @@ -68,8 +62,6 @@ export function signAppJwt(config: GitHubAppConfig, now = new Date()): string { return `${header}.${payload}.${base64url(signature)}`; } -/** Carries GitHub's status out, so a caller can tell a gone installation - * apart from a network or permission failure. */ export class GitHubRequestError extends Error { constructor( message: string, @@ -82,10 +74,6 @@ export class GitHubRequestError extends Error { const GITHUB_TIMEOUT_MS = 30_000; -// The schema is a parameter rather than a type argument: a cast would describe -// the body GitHub is documented to send, which is not the same claim as the -// body it did send — an unexpected shape belongs in a thrown error here, not in -// an undefined three call frames away. async function githubRequest( path: string, init: { method: "GET" | "POST"; authorization: string }, @@ -101,17 +89,14 @@ async function githubRequest( "x-github-api-version": "2022-11-28", }, cache: "no-store", - // `fetch` waits forever by default. A sync hop polls up to ten connections - // inside a four-minute deadline, so one hung request must not be able to - // spend the whole hop and strand the rest of the batch unpolled. + // `fetch` waits forever by default, and one hung request must not spend a whole sync hop. signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), }, ); if (!response.ok) { - // Only GitHub's status and message are carried out: the request was - // authorised with a JWT or a minted token, and neither belongs in an error - // a caller may log. + // The request carried a JWT or a minted token, so nothing but GitHub's own + // status and message goes into an error a caller may log. const message = await response .json() .then((body: { message?: string }) => body.message) @@ -144,7 +129,6 @@ const repositoriesResponse = z.object({ .optional(), }); -/** Who the app was installed on, asked as the app itself. */ export async function fetchInstallation( config: GitHubAppConfig, installationId: string, @@ -166,7 +150,6 @@ export async function fetchInstallation( }; } -/** Mint the hour-long token this installation stands for. Never stored. */ export async function mintInstallationToken( config: GitHubAppConfig, installationId: string, @@ -184,8 +167,6 @@ const userTokenResponse = z.union([ z.object({ error: z.string() }), ]); -/** Redeem the code the install redirect carried for a token that speaks as the - * user who installed — never stored, only used to check what they can reach. */ export async function exchangeUserToken( config: GitHubAppConfig, code: string, @@ -215,11 +196,8 @@ export async function exchangeUserToken( return body.access_token; } -// Asked as the user rather than as the app: the app can see every installation -// it has, so its own answer would say nothing about who is standing at the -// callback. GitHub answers 403 or 404 for an installation the user has no -// access to, which is the whole question — anything else is a failure to -// answer it, and is thrown rather than read as a no. +// Asked as the user, not as the app: the app can see every installation it has, so +// only the user's own answer says whether they reach this one. export async function userCanAccessInstallation( userToken: string, installationId: string, @@ -244,19 +222,15 @@ export async function userCanAccessInstallation( const REPOS_PER_PAGE = 100; -// An installation on a large organisation can reach thousands of repositories. -// Listing them is a settings-page nicety, so it walks a bounded number of pages -// and says when it stopped early rather than spending a request per hundred -// until GitHub runs out. +// A large organisation can reach thousands of repositories, and listing them is a +// settings-page nicety, so it stops early and says that it did. const MAX_REPOSITORY_PAGES = 10; export interface GitHubRepositoryList { repositories: GitHubRepository[]; - /** What GitHub says the installation reaches, listed or not. */ totalCount: number; } -/** The repositories the installation was given. */ export async function fetchInstallationRepositories( token: string, ): Promise { diff --git a/apps/app/src/features/integrations/server/providers/github/provider.test.ts b/apps/app/src/features/integrations/server/providers/github/provider.test.ts index b374afa..26a6af4 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.test.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -1,8 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Nothing of GitHub is mocked but the network: the app JWT is really signed, -// and really verified here against the public half of a key generated for this -// file. +// Nothing of GitHub is mocked but the network: the app JWT is really signed, and +// really verified against a key generated for this file. const mockEnv = vi.hoisted(() => ({}) as Record); @@ -66,9 +65,6 @@ function requestTo(fragment: string) { }; } -// What GitHub answers when the code redeems and the user behind it does reach -// the installation they submitted — the two calls that stand between a -// callback's `installation_id` and a connection. function authorizes() { fetchMock .mockResolvedValueOnce(ok({ access_token: "gho_user" })) @@ -325,9 +321,6 @@ describe("GH5 the minted token", () => { }); it("GH5 asks the app whether the installation is really gone before saying so", async () => { - // Acting on "revoked" deletes the connection and detaches every source - // hanging off it, so a mint that 404s while the installation is still - // listed stays an ordinary failure. fetchMock .mockResolvedValueOnce(failed(404, { message: "Not Found" })) .mockResolvedValueOnce(ok({ id: 42, account: { id: 7, login: "acme" } })); diff --git a/apps/app/src/features/integrations/server/providers/github/provider.ts b/apps/app/src/features/integrations/server/providers/github/provider.ts index 99f8469..d2a1ff0 100644 --- a/apps/app/src/features/integrations/server/providers/github/provider.ts +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -33,18 +33,15 @@ async function installationIsGone( } } -// GitHub is connected by installing an app on an account, not by an OAuth -// grant, so what comes back is an installation id. The workspace behind it is -// the account id — not the installation id, which a reinstall replaces — so -// that is what tells a reconnect from a move to a different organization. +// The workspace is the account id, not the installation id a reinstall replaces: only +// the account tells a reconnect from a move to a different organization. export class GitHubProvider extends IntegrationProvider { readonly providerId = "GITHUB"; readonly displayName = "GitHub"; readonly credential = "app_installation"; - // The redirect back is the app's own registered callback URL, so unlike - // OAuth there is nothing to pass here; the state rides along and comes back - // beside the installation and the code that authorizes it. + // The install page redirects to the app's own registered callback, so there is + // no redirect URI to pass. getAuthUrl(state: string, _redirectUri: string): string { const { appSlug } = readGitHubAppConfig(); const url = new URL(routes.external.integrations.github.install(appSlug)); @@ -52,13 +49,8 @@ export class GitHubProvider extends IntegrationProvider { return url.toString(); } - // The installation id arrives as a query parameter on a browser redirect, so - // it is a claim, not a fact: on its own it would let anyone who can pass the - // callback for their own organization name someone else's installation and - // have it persisted as theirs — every repository behind it readable from a - // Scibly org its owners never heard of. The code beside it is the proof. - // Redeemed, it says which GitHub user is standing here, and GitHub is asked - // whether that user reaches this installation at all. + // The installation id is a claim on a browser redirect; the code beside it is the + // proof that the user standing here reaches that installation at all. async completeConnect( params: ConnectCallbackParams, ): Promise { @@ -86,13 +78,8 @@ export class GitHubProvider extends IntegrationProvider { }; } - // GitHub answers 404 for an installation that no longer exists, which is - // what an uninstall on its side looks like from here — the id we hold is - // simply gone, and no token will ever be minted from it again. Acting on - // that throws the connection away and detaches every source hanging off it, - // so one 404 from one endpoint is not enough to go on: the app is asked - // directly whether the installation is still there, and anything short of a - // second 404 stays an ordinary failure that changes nothing. + // Acting on revoked throws the connection away, so one 404 is not enough to go on: + // the app is asked directly whether the installation is really gone. async mintAccessToken(installationId: string): Promise { const config = readGitHubAppConfig(); try { diff --git a/apps/app/src/features/integrations/server/providers/notion.ts b/apps/app/src/features/integrations/server/providers/notion.ts index 45aa252..75ec431 100644 --- a/apps/app/src/features/integrations/server/providers/notion.ts +++ b/apps/app/src/features/integrations/server/providers/notion.ts @@ -19,9 +19,7 @@ import { listNotionDatabasePages, } from "./notion-pages"; -// A sync hop polls up to ten connections inside a four-minute deadline, so a -// Notion request that hangs must give up long before the hop does. The SDK's -// own default is a minute, which one call could spend twice over on retries. +// The SDK waits a minute by default and retries, which a four-minute sync hop cannot afford. const NOTION_TIMEOUT_MS = 30_000; const notionClient = (auth?: string) => diff --git a/apps/app/src/features/integrations/server/registry.ts b/apps/app/src/features/integrations/server/registry.ts index edbfc58..ee00590 100644 --- a/apps/app/src/features/integrations/server/registry.ts +++ b/apps/app/src/features/integrations/server/registry.ts @@ -30,8 +30,6 @@ export function getProvider(providerId: string): IntegrationProvider { return PROVIDERS[providerId]; } -// For the page picker and everything downstream of it: a provider id that came -// off a row or a request is only good here if the provider actually has pages. export function getPageProvider(providerId: string): PageIntegrationProvider { const provider = getProvider(providerId); if (!(provider instanceof PageIntegrationProvider)) { @@ -44,6 +42,7 @@ export function getPageProvider(providerId: string): PageIntegrationProvider { return provider; } +// The annotation is the point: a subclass property widens `providerId` to `string`. export function listProviders(): IntegrationProvider[] { return Object.values(PROVIDERS); } diff --git a/apps/app/src/features/integrations/server/sync-source-freshness.ts b/apps/app/src/features/integrations/server/sync-source-freshness.ts index ee87552..1e08cca 100644 --- a/apps/app/src/features/integrations/server/sync-source-freshness.ts +++ b/apps/app/src/features/integrations/server/sync-source-freshness.ts @@ -25,10 +25,8 @@ const SYNC_BACKOFF_MS: readonly number[] = [ TimeHelpers.IN_MS.DAY, TimeHelpers.IN_MS.DAY * 3, ]; -// The ladder plateaus at its last rung rather than at the window floor: a gap -// as long as `SYNC_WINDOW_FLOOR_MS` is exactly the gap `getPollingStart` can no -// longer reach back across, so a connection backed off that far would return to -// a window that starts after the changes it was backed off through. +// Capped below `SYNC_WINDOW_FLOOR_MS`: a longer backoff would return a connection to a +// window that starts after the changes it slept through. const SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 3; export function backoffMs(consecutiveFailures: number): number { @@ -41,12 +39,8 @@ export async function loadDueConnections( return db.integrationConnection.findMany({ where: { organization: { subscription: notLapsedSubscription(now) }, - // A connection to a provider without pages has nothing that could go - // stale, so a poll would only spend a call to learn that. provider: { in: [...PAGE_INTEGRATION_PROVIDERS] }, - // Two ORs, so both go through `AND`: a disconnected connection keeps its - // row so its sources keep their link, but there is nothing left to poll - // it with. + // Two ORs cannot share one object, so both go through `AND`. AND: [ CONNECTED, { OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }] }, @@ -78,10 +72,7 @@ export function getPollingStart(lastPolledAt: Date | null, now: Date): Date { return new Date(Math.max(lastPolledAt.getTime() - SYNC_CLOCK_SKEW_MS, floor)); } -// `updateMany`, not `update`: a poll can outlive the connection it is polling — -// the organization can disconnect or delete it mid-run — and a row that is no -// longer there is nothing left to record, not a failure worth retrying the -// whole poll for. +// `updateMany` so a poll that outlived its connection records nothing instead of throwing. async function recordAttempt( connectionId: string, data: Prisma.IntegrationConnectionUpdateManyMutationInput, @@ -92,10 +83,8 @@ async function recordAttempt( }); } -// One batch, because the watermark is a claim about the marks: it says -// everything up to `pollStartedAt` has been accounted for, which is only true -// if the sources this poll found changed were actually marked stale. Advancing -// it on its own would put those changes permanently behind the window. +// One batch: advancing the watermark without the marks would put those changes +// permanently behind the window. async function commitPollSuccess( connectionId: string, pollStartedAt: Date, @@ -165,8 +154,6 @@ export async function pollConnection( } const provider = getPageProvider(connection.provider); - // Not the stored token: an app installation stores only its id and mints the - // token it stands for here, per poll. const token = await resolveConnectionToken(connection); const pages = await provider.pollModifiedPages( token, diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx index 0150257..381ea1b 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx @@ -14,8 +14,6 @@ import { AlertDialogTitle, } from "@/shared/ui/components/alert-dialog"; -// One instance for the whole card, not one per row: which provider is being -// asked about is the state, so there is nothing per-row to hold. export function DisconnectIntegrationDialog({ provider, isConfirming, @@ -23,7 +21,6 @@ export function DisconnectIntegrationDialog({ onClose, t, }: { - /** The provider being asked about; non-null is what opens the dialog. */ provider: IntegrationProviderId | null; isConfirming: boolean; onConfirm: () => void; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx index 1408e69..b1f3139 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -210,8 +210,6 @@ describe("what a click asks for", () => { expect(disconnectMutate).not.toHaveBeenCalled(); }); - // One dialog serves every row, so the provider it is asking about has to be - // the one just clicked, not the one clicked before. it("asks about the row just clicked, not the row refused before it", () => { lists( [NOTION, GITHUB], @@ -280,7 +278,6 @@ describe("the grants strip", () => { const container = card(); - // Four repositories and the button standing for the other five. expect(container.querySelectorAll("li")).toHaveLength(5); expect(container.textContent).toContain("5 more"); expect(container.textContent).not.toContain("acme-inc/repo-8"); diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx index bf9634b..6d6701f 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -5,7 +5,6 @@ import type { OrgSettingsPage } from "@/features/organizations/contracts"; import { SettingsCard } from "@/shared/ui/settings-card"; import { DisconnectIntegrationDialog } from "./disconnect-integration-dialog"; -import { OrgIntegrationsEmpty } from "./org-integrations-empty"; import { ProviderRow } from "./provider-row"; import { useOrgIntegrations } from "./use-org-integrations"; @@ -55,7 +54,11 @@ export function OrgIntegrationsCard({ ); })} - {allProviders.length === 0 && } + {allProviders.length === 0 && ( +

+ {t.noProvidersAvailable} +

+ )} - {t.noProvidersAvailable} -

- ); -} diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx index f49681b..9b97a20 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx @@ -14,8 +14,6 @@ import { ExternalLink } from "lucide-react"; import { ScrollArea } from "@/shared/ui/components/scroll-area"; -// An installation on a large organisation reaches hundreds of repositories. -// The strip shows a handful; the whole list lives here, where it can scroll. export function ProviderGrantsDialog({ open, onOpenChange, @@ -34,8 +32,6 @@ export function ProviderGrantsDialog({ {t.grantsTitle} - {/* Says "showing 1000 of 1500" when the listing stopped at its page - budget, so a partial list never looks like the whole of it. */} {t.grantsShown .replace("{shown}", String(grants.length)) diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx index 2ece786..5c7a36d 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx @@ -11,11 +11,8 @@ import { api } from "@/shared/api/trpc/client"; import { ProviderGrantsDialog } from "./provider-grants-dialog"; -// Enough to recognise the connection at a glance; the rest is a click away. const VISIBLE_GRANTS = 4; -// Its own query, so the card renders at once and only this strip waits on the -// provider. export const ProviderGrants = ({ orgSlug, provider, @@ -33,13 +30,9 @@ export const ProviderGrants = ({ provider, }); - // The token this query needs is minted per call, so this strip is where an - // uninstall on the provider's side first shows up. The server has already - // dropped the connection by the time the error arrives; refetching the list - // is what takes the row off the page. + // The server has already dropped the connection by the time this error arrives, + // so refetching the list is what takes the row off the page. const wasRevoked = error?.data?.applicationCode === "integration.revoked"; - // Named, because a reader with two connections cannot tell from "this - // integration" which of them just went away. const revokedNotice = t.revokedNotice.replace( "{provider}", t.providers[provider], diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx index f227b64..ebacff9 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx @@ -13,7 +13,6 @@ export type ProviderRowProps = { listsGrants?: boolean; }; connection?: { workspaceName: string | null }; - /** Whether this row's disconnect is out of reach for the moment. */ isBusy: boolean; isConnectPending: boolean; t: OrgSettingsPage["integrations"]; diff --git a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts index c000f13..69daa1b 100644 --- a/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -8,8 +8,6 @@ import { toast } from "sonner"; import { api } from "@/shared/api/trpc/client"; -// Everything the card does apart from rendering: what there is to show, and -// the two things a row can ask for. export function useOrgIntegrations({ orgSlug, lang, @@ -21,9 +19,6 @@ export function useOrgIntegrations({ }) { const utils = api.useUtils(); const [disconnectingId, setDisconnectingId] = useState(null); - // Which provider the confirmation is being asked about. One piece of state - // for the whole card, so a second Disconnect click cannot open the dialog - // still holding the last provider. const [pendingDisconnect, setPendingDisconnect] = useState(null); @@ -42,8 +37,6 @@ export function useOrgIntegrations({ void utils.integration.list.invalidate({ orgSlug }); }, onError: (err) => toast.error(err.message), - // Settled, not success: a failure used to leave the row latched disabled, - // so the user was told the disconnect failed and then could not retry it. onSettled: () => { setDisconnectingId(null); setPendingDisconnect(null); @@ -54,9 +47,6 @@ export function useOrgIntegrations({ connections: data?.connections ?? [], allProviders: data?.allProviders ?? [], isConnectPending: getAuthUrlMutation.isPending, - // A row's disconnect is out of reach while it is the one being - // disconnected, and while any disconnect is in flight. The two windows - // overlap but neither contains the other, so both are asked. isBusy: (provider: IntegrationProviderId) => disconnectingId === provider || disconnectMutation.isPending, connect: (provider: IntegrationProviderId) => diff --git a/apps/app/src/features/notebook/sources/api/bounded-ingest.ts b/apps/app/src/features/notebook/sources/api/bounded-ingest.ts index d5e7bd9..6be2dc7 100644 --- a/apps/app/src/features/notebook/sources/api/bounded-ingest.ts +++ b/apps/app/src/features/notebook/sources/api/bounded-ingest.ts @@ -5,9 +5,7 @@ import { SOURCE_STATUS } from "@/shared/content/sources/constants"; import { ingestOrRefreshSource } from "../ingestion/ingest-source"; -// Retry, upload confirmation, replacement confirmation, page linking and resync -// all trigger the same extraction work, so they share one rate-limit ceiling -// instead of five. +// Five entry points trigger the same extraction work, so they share one ceiling. const INGEST_LIMIT = { endpoint: "source.ingest", maxPerWindow: 30, @@ -28,8 +26,7 @@ export function boundedIngest(userId: string, sourceId: string) { ); } -// A linked batch costs one slot however many pages it carries: the schema caps -// the batch, and a request that linked nothing did no extraction to pay for. +// A linked batch costs one slot however many pages it carries. export function boundedLink( userId: string, link: () => Promise, diff --git a/apps/app/src/features/notebook/sources/components/integration-buttons.tsx b/apps/app/src/features/notebook/sources/components/integration-buttons.tsx index c829981..e16f78c 100644 --- a/apps/app/src/features/notebook/sources/components/integration-buttons.tsx +++ b/apps/app/src/features/notebook/sources/components/integration-buttons.tsx @@ -25,11 +25,6 @@ interface IntegrationButtonsProps { disabled?: boolean; } -// Buttons come from PAGE_INTEGRATION_PROVIDERS, the connectable providers that -// actually offer pages to import — a provider connected for something else has -// nothing to show a page picker. PROVIDER_DISPLAY is cosmetic only and never -// gates which providers render — it is keyed by the same union, so every -// provider iterated here has an entry. export function IntegrationButtons({ connectedProviders, t, diff --git a/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx b/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx index 96dedee..1ddede3 100644 --- a/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx +++ b/apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx @@ -168,9 +168,8 @@ export function SourceListItemActions({ onResync, onDelete, }: SourceListItemActionsProps) { - // Rows stored before the link schemas were tightened were never - // protocol-checked, so a `javascript:` url already in the database would - // otherwise land straight in this href. + // Rows stored before the link schemas were tightened were never protocol-checked, + // so a `javascript:` url may already be in the database. const externalHref = httpsUrl().safeParse(item.externalUrl).success ? item.externalUrl : null; diff --git a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts index c95bd42..c9802be 100644 --- a/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts +++ b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts @@ -55,9 +55,7 @@ export const notionPageExtractor: SourceExtractor = { await resolveSourceConnection(source); const content = await provider.fetchPageContent(token, externalId); - // No `pageCount`: it counts the pages of a parsed file, and a provider's - // page is one page. The field stays optional on `ExtractedContent` for the - // PDF path, which is the only thing that has ever set it. + // No `pageCount`: it counts the pages of a parsed file, and a provider's page is one page. return { text: content.text, title: content.title, diff --git a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx index 400c0f5..0686de2 100644 --- a/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx +++ b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx @@ -130,10 +130,8 @@ export function PagePickerContent({ t, onLinked, }; - // Two ceilings, and the lower one wins: how many sources the plan still has - // room for, and how many pages one link request may carry. On a paid plan - // the first is effectively unlimited, so without the second "select all" on - // a large parent used to build a batch the server rejected whole. + // On a paid plan the source limit is effectively unlimited, so the per-request cap is + // what keeps "select all" from building a batch the server rejects whole. const remaining = Math.min( Math.max(0, sourceLimit - totalSourceCount), MAX_LINKED_PAGES_PER_REQUEST, diff --git a/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx index 48d7a6c..ae2a110 100644 --- a/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx +++ b/apps/app/src/features/notebook/sources/page-picker/page-picker-select-all-bar.tsx @@ -14,8 +14,6 @@ interface PagePickerSelectAllBarProps { selected: Set; totalSourceCount: number; - // Not the plan's source limit: the highest total this picker can actually - // reach, which is the lower of the plan's room and one request's page cap. maxTotal: number; allVisibleSelected: boolean; t: T; diff --git a/apps/app/src/features/notebook/sources/provider-display.ts b/apps/app/src/features/notebook/sources/provider-display.ts index 2c61a22..9c2314f 100644 --- a/apps/app/src/features/notebook/sources/provider-display.ts +++ b/apps/app/src/features/notebook/sources/provider-display.ts @@ -11,11 +11,6 @@ interface ProviderDisplayConfig { readonly Logo: React.ComponentType<{ className?: string }>; } -// Only a provider a notebook can import pages from ever reaches the picker, so -// this is keyed by that union rather than by every connectable provider. The -// `satisfies` is what makes adding a page provider fail to compile until it has -// an entry — the map this replaced was keyed by bare string behind a fallback, -// which meant a missing entry rendered a blank box instead. export const PROVIDER_DISPLAY = { NOTION: { name: "Notion", diff --git a/apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts b/apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts index b52f3a7..7b9fd3d 100644 --- a/apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts +++ b/apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts @@ -7,7 +7,6 @@ import { byoaiModelDescriptionSchema, } from "@/shared/ai/byoai-model-schema"; -// Re-exported so call sites keep importing their own feature's schema module. export { orgSlugInput }; export const addModelSchema = orgSlugInput diff --git a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx index 6a08bb0..c59f9bf 100644 --- a/apps/app/src/features/organizations/settings/components/org-settings-form.tsx +++ b/apps/app/src/features/organizations/settings/components/org-settings-form.tsx @@ -42,10 +42,8 @@ function useOAuthResultNotifications( ) { const router = useRouter(); const trpcUtils = api.useUtils(); - // What the callback left in the query string is read once and then taken out - // of the url, so re-running this on any later render would be reading a - // result that has already been reported. React also runs the effect twice in - // development, and the ref is what makes the second run a no-op. + // The result is read once and then taken out of the url; the ref is what makes + // React's second development run a no-op. const reported = useRef(false); useEffect(() => { if (reported.current) return; @@ -53,8 +51,6 @@ function useOAuthResultNotifications( const url = new URL(window.location.href); if (integrationConnected) { - // The callback lands on a fresh mount either way — a stable id keeps one - // toast on screen. toast.success(t.connectedSuccessfully, { id: `integration-connected-${integrationConnected}`, }); diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index 77875f2..7f8a765 100644 --- a/packages/routes/src/index.ts +++ b/packages/routes/src/index.ts @@ -221,9 +221,6 @@ export const routes = { file: (path: string) => `${GITHUB_REPO_URL}/blob/main/${path}` as const, }, - // Where the GitHub and Notion integrations talk to, as opposed to the - // repository above: an install page a browser is sent to, and the two - // origins the server calls. integrations: { github: { api: "https://api.github.com", diff --git a/packages/schemas/src/schema/common/index.ts b/packages/schemas/src/schema/common/index.ts index 9982a40..aa79048 100644 --- a/packages/schemas/src/schema/common/index.ts +++ b/packages/schemas/src/schema/common/index.ts @@ -1,15 +1,6 @@ import { z } from "zod"; -/** - * A URL safe to put in an `href`, an `src`, or a fetch: https only. - * - * Zod's own `.url()` only asks whether `new URL()` parses the string, which - * accepts any scheme — `javascript:`, `data:` and `vbscript:` all pass it. So - * it is a shape check, not a safety check. Use this anywhere a URL is stored, - * rendered as a link or an image, or fetched. - * - * The message is a parameter because several call sites already pass their own - * copy; the default covers the rest. - */ +// Zod's own `.url()` accepts any scheme — `javascript:` and `data:` included — so it +// is a shape check, not a safety check. export const httpsUrl = (message = "Must be a valid https:// URL") => z.url({ protocol: /^https$/, message }); From 617b1a7feb1ad9c51300121a49cfefd70debdbf8 Mon Sep 17 00:00:00 2001 From: NiclasDev63 Date: Sat, 29 Aug 2026 07:45:21 +0200 Subject: [PATCH 43/43] Keep the refactor plan out of the epic It was scaffolding for this branch's own work, not something the epic carries forward once the work is in. Co-Authored-By: Claude Opus 5 --- refactor-plan.md | 1365 ---------------------------------------------- 1 file changed, 1365 deletions(-) delete mode 100644 refactor-plan.md diff --git a/refactor-plan.md b/refactor-plan.md deleted file mode 100644 index 7d6a5bc..0000000 --- a/refactor-plan.md +++ /dev/null @@ -1,1365 +0,0 @@ -# Refactor Plan: Integrations — GitHub App provider, connect callback, sync chain - -Date: 2026-08-28 · Branch: `claude/github-issue-9-2fd543` · Analyzed at commit `8189f39` (merge-base with `main`: `2fa7c44`) - -Scope: the branch diff (`git diff $(git merge-base HEAD main)...HEAD`) — 36 files, +1541/−163 — plus -files heavily entangled with it (`base-provider.ts`, `registry.ts`, `contracts.ts`, the notebook -source ingestion extractors). Revision 2 folds in the user's directives of 2026-08-28; every change -from revision 1 is marked **[user-directed]**. - -## Baseline - -Every step below must return the tree to exactly this state. Both commands were green at `8189f39`. - -| Command | Status at baseline | -| --- | --- | -| `pnpm check` (i18n + typecheck + lint — the fast gate) | **exit 0**, 32/32 tasks successful | -| `pnpm --filter @scibly/app run test` | **176 passed / 3 skipped** files · **2642 passed / 6 skipped** tests | - -Suppression census for the in-scope tree (`grep -rn "eslint-disable|@ts-ignore|@ts-expect-error"` over -`features/integrations`, `features/notebook/sources`, `features/organizations/settings`): **exactly one**, -at `apps/app/src/features/organizations/settings/components/org-settings-form.tsx:87`. No step may add a -second; Step 13 removes this one. - -### Coverage assessment - -Server-side logic is well covered and the tests read as specifications (requirement-tagged `describe` -blocks, shared builders): - -| File | Test | Lines of test | -| --- | --- | --- | -| `server/sync-source-freshness.ts` | ✅ `sync-source-freshness.test.ts` | 738 | -| `server/connect-callback.ts` | ✅ `connect-callback.test.ts` | 542 | -| `api/integration-connection-procedures.ts` | ✅ `api/integration-connections.test.ts` | 345 | -| `server/providers/github/provider.ts` | ✅ `provider.test.ts` | 284 | -| `server/connection-token.ts` | ✅ `connection-token.test.ts` | 108 | -| `server/providers/notion.ts` | ✅ (covered via provider tests) | — | - -Gaps that matter to this plan: - -- **`settings/components/org-integrations-card.tsx` (307 lines) has no test**, and Step 10 restructures it - into eight files. The repo does test components (23 `*.test.tsx` files, e.g. - `features/organizations/settings/components/org-ai-config-card.test.tsx`), so this is a convention gap, - not a policy. → **Phase 0**. -- `server/providers/github/app-auth.ts` (160 lines) is only exercised indirectly through mocks in - `provider.test.ts`. No step below restructures it, so no characterization test is required; its issues - are listed under Behavior-changing fixes. -- `api/integration-page-procedures.ts` (131 lines) and `page-picker/use-page-picker-controller.ts` - (193 lines) have no tests. No step below restructures them. - -## Constraints - -- **Behavior preservation is the prime directive.** Current behavior is the spec, quirks included. Where a - step would change observable behavior it has been moved out of the step list into - *Behavior-changing fixes*, which ship separately and only on the user's say-so. - **Two sanctioned exceptions**, both user-directed and both carrying an explicit *Behavior delta* line: - **Step 7** (one owed-connections query per hop) and **Step 12** (disconnect confirmation dialog). -- All conventions in `.claude/skills/refactor/references/codebase.md` apply, in particular: - - one React component per `.tsx` file (skeletons and empty states included); kebab-case filenames; - PascalCase components; feature context subfolders under `components/`; - - **no `createElement` and no lowercase JSX-returning helpers** in feature UI; - - **no lint/type suppressions** — they are fixed properly, never re-silenced or `_`-prefixed; - - env access through the typed `@/env`; raw `process.env` reads are findings; - - user-facing strings must be translated; - - **URLs are built by `@scibly/routes`, never by string concatenation at the call site** - *(added to `codebase.md` by this run)* **[user-directed]**; - - **multi-write invariants go through `prisma.$transaction`** *(the rule already existed; this run - sharpened it — see the audit below)* **[user-directed]**. -- The domain vocabulary in `apps/app/src/features/integrations/CONTEXT.md` is binding, including each - term's `_Avoid_` list. Steps 6 and 8 exist to bring the sync module back into it. -- Verification for every step: `pnpm check` **and** `pnpm --filter @scibly/app run test` return to the - baseline above, plus a suppression grep over the touched files showing no new - `eslint-disable|@ts-ignore|@ts-expect-error`. - -### Transaction audit **[user-directed]** - -"Always use DB transactions when possible" was applied as a sweep, not a slogan. Every multi-write -sequence in the in-scope tree, and what it needs: - -| Site | Writes | State | Where it is handled | -| --- | --- | --- | --- | -| `server/connection-token.ts:50-51` (`forgetRevokedConnection`) | detach sources → delete connection | **not atomic** | BF-6 | -| `server/connect-callback.ts:216` → `:231` | detach sources → upsert connection | **not atomic**, and a provider round-trip sits between the read and the writes | BF-11 | -| `api/integration-connection-procedures.ts:135-142` (`disconnect`) | detach sources → delete connection | **not atomic** | BF-17 | -| `server/sync-source-freshness.ts:266-267` | `markChangedSourcesStale` → `recordPollSuccess` | **not atomic**, but deliberately so — see below | BF-18 | -| `server/detach-sources.ts:12` | one `updateMany` | single write, fine | — | - -**Enabling change, shared by BF-6/BF-11/BF-17:** `detachSourcesFromConnection` -(`server/detach-sources.ts:7`) closes over the module-level `db` client, so it cannot participate in a -caller's transaction as written. All three fixes depend on it taking an optional transaction client: - -```ts -export async function detachSourcesFromConnection( - connectionId: string, - provider: IntegrationProviderId, - reason: DetachReason, - client: Prisma.TransactionClient = db, -) { … } -``` - -That signature change is behavior-preserving on its own (the default keeps every current call site -identical), so it can ship as a preparatory commit ahead of whichever BF goes first. - -**Where a transaction is deliberately *not* the answer:** the sync hop. Wrapping -`markChangedSourcesStale` + `recordPollSuccess` in a transaction is correct in principle, but a hop holds -its DB work across provider HTTP calls; a transaction spanning those would hold a connection open for the -length of a network round-trip, which `packages/db`'s serverless pooling is explicitly tuned against. The -right shape there is the narrow one — `$transaction([markChangedSourcesStale, recordPollSuccess])` as a -batch **after** the provider call returns, never around it. BF-18 records this. - -## Phase 0 — Safety net - -### P0.1: Characterize `OrgIntegrationsCard` before splitting it - -- **Files:** create `apps/app/src/features/integrations/settings/components/org-integrations-card.test.tsx` -- **Why:** Step 10 moves eight JSX-returning functions into eight files. Nothing currently proves the card - still renders the same thing afterwards. -- **Pin down current behavior, quirks included:** - 1. A provider with no connection renders the connect affordance; a connected one renders - `ProviderStatus` with the workspace name. - 2. `renderProviderIcon` picks `NotionIcon` for `NOTION` and `GitHubIcon` for `GITHUB`. - 3. With `allProviders.length === 0` the card renders the **hardcoded English** string - `No integrations available.` (`org-integrations-card.tsx:301`). Pin the current string — Step 13 - changes it deliberately, and this test is what proves nothing else did. - 4. **Quirk, pin it as-is:** after a *failed* disconnect the row's button stays `disabled`, because - `disconnectingId` is cleared only in `onSuccess` (`:255`) and not in `onError` (`:258`). This is - listed as a behavior fix (BF-13) — the characterization test locks in today's behavior so the - refactor cannot silently change it, and BF-13 updates the test when it ships. - 5. `ProviderGrants` renders the grant list from `api.integration.listGrants` and fires its - revoked-toast effect when `wasRevoked` flips. **Pin that today every grant is rendered**, so - BF-9's first-4-plus-modal change is visible as a deliberate edit to this test rather than a silent - drift. -- **Follow** `org-ai-config-card.test.tsx` for the local render-test idiom (tRPC mocking, `t` fixture). -- **Verify:** `pnpm --filter @scibly/app run test` — new file passes, total count rises, nothing else moves. - -## Refactor steps (ordered) - -Each step is independently shippable: after it lands, the fast gate passes and behavior is unchanged. No -step depends on a later one. Ordered safest-first, respecting dependencies. Steps 7 and 12 are the two -user-directed exceptions to behavior preservation and say so in a **Behavior delta** line. - -### Step 1: Remove `CONFLUENCE` and `SHAREPOINT` from the `IntegrationProvider` enum **[user-directed]** - -- **Files:** `packages/db/schema/integration.prisma`, new migration under `packages/db/migrations/`, - `apps/app/src/features/notebook/chat/provider-display.tsx`, - `apps/app/src/features/integrations/server/connect-callback.test.ts` -- **Now:** `packages/db/schema/integration.prisma:1-8` carries four providers: - ```prisma - enum IntegrationProvider { - NOTION - GITHUB - CONFLUENCE - SHAREPOINT - - @@map("integration_provider") - } - ``` - `CONFLUENCE` and `SHAREPOINT` date from the original integrations migration - (`20260605233958_add_integrations_and_source_lineage/migration.sql:8`) and have **no provider - implementation, no connect path, and no way to produce a row**: `server/registry.ts` builds only - `NOTION` and `GITHUB`, and `contracts.ts:3` lists only those two, so `getAuthUrl` rejects anything else - before a row could be written. They are schema-level placeholders for work that was never done. -- **Target:** a two-member enum, following the **existing precedent in this repo** for removing an enum - value — `packages/db/migrations/20260706170000_remove_docx_source_type/migration.sql`, which guards, - renames, recreates, re-types, and drops: - ```sql - DO $$ BEGIN - IF EXISTS (SELECT 1 FROM "integration_connection" WHERE "provider" IN ('CONFLUENCE','SHAREPOINT')) THEN - RAISE EXCEPTION 'Cannot remove CONFLUENCE/SHAREPOINT: rows still reference them'; - END IF; - END $$; - - ALTER TYPE "integration_provider" RENAME TO "integration_provider_old"; - CREATE TYPE "integration_provider" AS ENUM ('NOTION', 'GITHUB'); - ALTER TABLE "integration_connection" ALTER COLUMN "provider" TYPE "integration_provider" - USING ("provider"::text::"integration_provider"); - DROP TYPE "integration_provider_old"; - ``` - The guard is the point: it turns "someone somehow has a CONFLUENCE row" from silent data loss into a - failed migration. Confirm the column list against the schema before writing it — `provider` appears on - `integration_connection`; grep the generated client for any other column typed `IntegrationProvider`. - - Two consumers must be fixed in the same commit or the gate goes red: - - `notebook/chat/provider-display.tsx:41-46` has a `"CONFLUENCE"` entry (with its own - `confluenceLogo` component at `:8`). Delete the entry and the component. `PROVIDER_DISPLAY` is a - `Map` so this is not a compile error — it is a **grep-found** change, which is exactly - the drift Step 14 removes structurally. - - `server/connect-callback.test.ts:220-228` uses `SHAREPOINT` as its fixture for "a provider the - registry cannot build": - ```ts - it("LP2 refuses a state naming a provider the registry cannot build", async () => { - const response = await callback( - { code: "auth-code", state: state({ provider: "SHAREPOINT" }) }, - "sharepoint", - ); - expect(refusal(response)).toBe("invalid_state"); - ``` - **Keep the test, change the fixture.** The requirement it pins (LP2) is real and stays real; after - this step `"SHAREPOINT"` is simply a string outside the enum rather than one inside it, which is the - more realistic forged-state input anyway. Use a clearly-not-a-provider literal such as - `"NOT_A_PROVIDER"` and leave the assertion untouched. Do **not** delete the case. `:204`'s - `"confluence"` is a *path segment*, not an enum value — it exercises LA8 (path/state mismatch) and - stays valid; leave it, or swap it for another non-matching segment if it reads oddly. -- **Risk:** medium — this is the only step that touches the database. The enum-swap SQL is copied from a - migration that already shipped, so the shape is proven; the risks are (a) missing a column that uses the - type, which the `ALTER TABLE` list must cover, and (b) `prisma generate` needing to run before the - typecheck sees the new enum. Run the migration against a scratch database first and confirm the guard - fires when a `CONFLUENCE` row is planted. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; then - `grep -rn "CONFLUENCE\|SHAREPOINT" apps/app/src packages/db/schema/integration.prisma` returns nothing - outside `packages/db/migrations/` (history is immutable) and `apps/web` (marketing copy about the - Confluence *product*, unrelated). - -### Step 2: Derive `IntegrationProviderId` from the Prisma enum **[revised by Step 1]** - -- **Files:** `apps/app/src/features/integrations/contracts.ts`, - `apps/app/src/features/integrations/server/connection-token.ts` -- **Depends on:** Step 1 -- **Now:** `contracts.ts:3` hand-writes the provider union: - ```ts - export const INTEGRATION_PROVIDERS = ["NOTION", "GITHUB"] as const; - export type IntegrationProviderId = (typeof INTEGRATION_PROVIDERS)[number]; - ``` - Prisma independently generates the `IntegrationProvider` enum - (`packages/db/schema/generated/prisma/enums.ts:75-82`), re-exported through `packages/db/src/enums.ts`. - The two drift by hand. That drift is why `connection-token.ts:20` types the field as - `provider: IntegrationProviderId | string` — a union TypeScript immediately collapses to `string`, so - the narrow half documents an intent the compiler never enforces. -- **Target:** with Step 1 landed, the two lists are the *same* list, so the literal array stops being a - deliberate subset and becomes pure duplication. Keep the runtime array (`z.enum(INTEGRATION_PROVIDERS)` - at `api/integration.schema.ts:11` needs a value, and `satisfies Record` - exhaustiveness checks need the union), but tie it to the enum so a future schema change is a compile - error: - ```ts - import type { IntegrationProvider } from "@scibly/db/enums"; - - /** - * Every provider the schema knows and the registry can build — since the - * CONFLUENCE/SHAREPOINT placeholders were dropped these are the same set. - * The `satisfies` makes a schema change that this list has not followed a - * compile error rather than a runtime surprise. - */ - export const INTEGRATION_PROVIDERS = [ - "NOTION", - "GITHUB", - ] as const satisfies readonly IntegrationProvider[]; - ``` - A `satisfies` catches an *added* member being misspelled but not an added member being ignored. If the - team wants full bidirectional enforcement, add the one-line exhaustiveness assertion beside it: - ```ts - type _AllProvidersListed = IntegrationProvider extends (typeof INTEGRATION_PROVIDERS)[number] - ? true - : never; - ``` - Prefer the assertion — with the enum now equal to the implemented set, "add a provider to the schema and - forget the app" is the exact failure worth catching. Then narrow `ConnectionCredential.provider` to - `IntegrationProviderId` and let `getProvider` (`server/registry.ts:22`, which already narrows through - `isIntegrationProvider`) be the single place a raw DB string is widened. -- **Risk:** low. If narrowing `ConnectionCredential.provider` surfaces a call site that really does pass a - raw DB string, route it through `isIntegrationProvider` rather than re-widening the type. - `contracts.ts` must stay dependency-free of provider SDKs — `@scibly/db/enums` is a type-only import - of a generated file and does not pull Prisma into the client bundle. Confirm with a `type` import. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. - -### Step 3: Remove `CONFLUENCE_PAGE` and `SHAREPOINT_PAGE` from `NotebookSourceType` **[user-directed]** - -- **Files:** `packages/db/schema/notebook.prisma`, new migration under `packages/db/migrations/`, - `apps/app/src/shared/content/sources/constants.ts`, - `apps/app/src/features/notebook/workspace/utils/constants.ts`, - `packages/course-content/src/types.ts` -- **Depends on:** nothing (independent of Steps 1–2), but ship it after Step 1 so the two enum migrations - are reviewed one at a time -- **Now:** the same two dead providers have matching source types - (`packages/db/schema/notebook.prisma:5-6`), reachable from four places, none of which can ever produce - one — the only page provider is Notion: - - `shared/content/sources/constants.ts:22-23` — `SOURCE_TYPES.CONFLUENCE_PAGE` / `SHAREPOINT_PAGE` - - `shared/content/sources/constants.ts:51-52` — `MAX_FILE_SIZE` entries, both `0`, present only - because the object is `satisfies Record` - - `notebook/workspace/utils/constants.ts:143-157` — `SOURCE_DISPLAY_MAP` entries keyed - `"confluence_page"` / `"sharepoint_page"` - - `packages/course-content/src/types.ts:32-33` — two members of a hand-written string union -- **Target:** drop both members from the Prisma enum with the same guarded migration shape as Step 1 - (guarding `SELECT 1 FROM "notebook_source" WHERE "type" IN ('CONFLUENCE_PAGE','SHAREPOINT_PAGE')`), then - delete the four consumers. `MAX_FILE_SIZE` and `SOURCE_TYPES` shrink together — the `satisfies` keeps - them honest, so removing one without the other is a compile error, which is the desired behavior. -- **Risk:** **wider blast radius than Step 1 and the one place to be careful.** `SourceType` is a - cross-package type: `packages/course-content` re-declares it by hand rather than importing it, so the - compiler will *not* connect the two — that file must be edited by grep, not by following errors. Check - `packages/course-content` consumers for a `switch` over source type that would now be missing a case - (an exhaustive switch getting *fewer* cases is safe; a `default` that relied on them is not). - `SOURCE_DISPLAY_MAP` is `Map` with a `DEFAULT_SOURCE_DISPLAY` fallback - (`workspace/utils/constants.ts:161-168`), so its entries are dead weight rather than a type error — - again grep, not compile. - **Guard clause:** if production has any `notebook_source` row with either type, the migration must fail - rather than coerce. Run the guard query against a production snapshot before writing the migration; if - rows exist, stop and bring the finding back rather than shipping the step. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; then - `grep -rn "CONFLUENCE_PAGE\|SHAREPOINT_PAGE\|confluence_page\|sharepoint_page" apps packages ee` - returns nothing outside `packages/db/migrations/`. - -### Step 4: Let `@scibly/routes` own the integration callback URL **[user-directed]** - -- **Files:** `packages/routes/src/index.ts`, - `apps/app/src/features/integrations/api/integration-connection-procedures.ts`, - `apps/app/src/features/integrations/server/connect-callback.ts` -- **Now:** the same URL is built twice, by hand, from two different env sources: - ```ts - // api/integration-connection-procedures.ts:112 — RAW process.env - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/integrations/${input.provider.toLowerCase()}/callback`; - - // server/connect-callback.ts:194 — typed env - const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/integrations/${callback.provider.toLowerCase()}/callback`; - ``` - Notion requires the token-exchange `redirect_uri` to match the authorize-time one byte for byte, so a - divergence breaks connect with only `token_exchange_failed` to show for it. The raw `process.env` read - also violates codebase.md's typed-env rule, and an unset value yields the literal string `undefined`. -- **Target:** the routes package already owns every other app URL, including the sibling cron route that - this very feature calls (`packages/routes/src/index.ts:193-198`): - ```ts - api: { - cron: { - syncIntegrations: toAppUrl(`${BASE_API_PATH}/cron/sync-integrations`), - }, - oembed: toAppUrl(`${BASE_API_PATH}/oembed`), - }, - ``` - Add the callback beside them: - ```ts - api: { - cron: { … }, - oembed: toAppUrl(`${BASE_API_PATH}/oembed`), - integrations: { - callback: (provider: string) => - toAppUrl(`${BASE_API_PATH}/integrations/${provider.toLowerCase()}/callback`), - }, - }, - ``` - Both call sites become `routes.app.api.integrations.callback(input.provider)`. This also deletes the raw - `process.env` read outright rather than converting it: `packages/routes/src/env.ts` already loads - `NEXT_PUBLIC_APP_URL` through `loadPackageEnv`, so the typed-env rule is satisfied by construction. - - Type the parameter as `string`, not `IntegrationProviderId` — `packages/routes` must not depend on an - app-level type, and the argument is lowercased into a path segment either way. **Sweep in the same - commit:** `grep -rn 'NEXT_PUBLIC_APP_URL\|NEXT_PUBLIC_WEB_URL' apps/app/src apps/web/src` for other - hand-built URLs that belong in the routes package; fold in any that are a one-line move and list the - rest here rather than growing this step. -- **Risk:** low, but this is the string an external provider validates. Confirm the produced value is - byte-identical to today's for both `NOTION` and `GITHUB` — same lowercasing, no trailing slash, and note - `toAppUrl` uses `String.concat` with a leading-slash guard, so `BASE_API_PATH` already starting with `/` - is correct and does not double up. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — `connect-callback.test.ts` and - `integration-connections.test.ts` both exercise these paths. Then - `grep -rn 'api/integrations/' apps/app/src --include='*.ts' --include='*.tsx'` shows the literal path - only inside `packages/routes` and the App Router folder name itself. - -### Step 5: Deduplicate `orgSlugInput` **[user-directed]** - -- **Files:** `packages/schemas/src/schema/organization/index.ts`, - `apps/app/src/features/organizations/settings/api/org-ai-config.schemas.ts`, - `apps/app/src/features/integrations/api/integration.schema.ts`, plus the importers listed below -- **Now:** the identical schema is declared twice, in two features: - ```ts - // features/organizations/settings/api/org-ai-config.schemas.ts:9 - export const orgSlugInput = z.object({ orgSlug: z.string() }); - - // features/integrations/api/integration.schema.ts:8 - export const orgSlugInput = z.object({ orgSlug: z.string() }); - ``` - The first is consumed by `org-ai-query-procedures.ts:24,59` and by **nine** procedures in - `billing-procedures.ts` (`:34,69,83,89,95,101,111,121,131`, the last four via `.extend()`); the second - by `integration-connection-procedures.ts:75`. A third variant is inlined rather than reused — - `integration.schema.ts:18` and `org-ai-config.schemas.ts:13` both write `orgSlug: z.string()` inside a - larger object. -- **Target:** one declaration in `packages/schemas/src/schema/organization/index.ts`, which is exactly - where codebase.md's boundary rule puts it ("Zod schemas belong in `packages/schemas`, not inline next to - a router"), and which both features already reach as `@scibly/schemas/organization` (the package's - `exports` map is `"./*": "./src/schema/*/index.ts"`): - ```ts - /** The org a procedure acts on, addressed the way the URL addresses it. */ - export const orgSlugInput = z.object({ orgSlug: z.string() }); - ``` - Both feature modules re-export it so their call sites keep importing from their own schema file: - ```ts - import { orgSlugInput } from "@scibly/schemas/organization"; - export { orgSlugInput }; - ``` - That keeps `.extend()` working as the local idiom and makes the change a one-line edit per feature - rather than fourteen import rewrites. Fold the two inlined `orgSlug: z.string()` occurrences into - `orgSlugInput.extend({ … })` in the same commit — that is what makes this a dedup rather than a move. -- **Risk:** low, with one thing to check: `packages/schemas` imports `from "zod/v4"` while both feature - files import `from "zod"` (resolved to v4 by the pnpm override). Confirm the two specifiers produce the - same `ZodObject` at runtime — if `.extend()` on the shared object misbehaves in `billing-procedures.ts`, - that is the cause, and the fix is to align the import specifier, never to re-declare the schema. - `packages/schemas` has its own Jest suite (`pnpm --filter @scibly/schemas run test`); run it too. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`; - `pnpm --filter @scibly/schemas run test`; then - `grep -rn 'z.object({ orgSlug: z.string() })' apps packages` returns exactly one hit. - -### Step 6: Rename the sync module to the CONTEXT vocabulary - -- **Files:** `apps/app/src/features/integrations/server/sync-source-freshness.ts`, - `apps/app/src/features/integrations/server/sync-source-freshness.test.ts`, - `apps/app/src/features/integrations/server.ts`, - `apps/app/src/app/api/cron/sync-integrations/route.ts`, - `apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts` -- **Now:** the module names three core concepts with words `CONTEXT.md` explicitly lists under `_Avoid_`, - and gives one concept two names in the same file: - - `SYNC_BATCH_SIZE` (`:18`) — Chain `_Avoid_: batch` - - `syncConnection` (`:235`) — Poll `_Avoid_: sync (the run, not the turn)`; this function *is* a poll - - `runSyncStep` / `SyncStepResult` (`:275`, `:270`) — "step" is a third name for a Hop, while the same - file already says hop at `:20` (`SYNC_HOP_DEADLINE_MS`), `:22` (`MAX_SYNC_HOPS`), `:283` - (`hopStartedAt`), `:315` (`"Hop failed:"`), and the caller wraps it as `startHop` - (`app/api/cron/sync-integrations/route.ts:21`) - - `postToSyncRoute` (`:321`) — names the transport where the domain says Chain - - `loadSyncableSources` (`:162`), `recordAttempt` (`:181`), `recordPollSuccess` (`:191`) all take the - parameter name `integrationId` while every call site passes `connection.id` — Connection - `_Avoid_: integration (the context, not the record)` -- **Target:** pure renames, no logic touched: - - | Now | Target | - | --- | --- | - | `syncConnection` | `pollConnection` | - | `runSyncStep` | `runSyncHop` | - | `SyncStepResult` | `SyncHopResult` | - | `postToSyncRoute` | `handOffChain` | - | `SYNC_BATCH_SIZE` | `SYNC_HOP_CONNECTION_LIMIT` | - | `integrationId` params (3 fns) | `connectionId` | - - The **DB column** `NotebookSource.integrationId` keeps its name (renaming it needs a migration and is - out of scope), so the Prisma filters become `where: { integrationId: connectionId }` — the mismatch then - lives in exactly one visible place per query instead of being smeared across the parameter names. Apply - the same `integrationId` → `connectionId` rename to `resolveIntegration` in - `integration-extractors.ts:10`. -- **Risk:** very low — no behavior, no exported *shape*. The one thing to get right is the re-export list - in `server.ts:8-13` and the two call sites in `app/api/cron/sync-integrations/route.ts:7,23`; the - typechecker catches any miss. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — 738 lines of tests must pass unchanged - apart from the renamed imports. `git diff` should contain no logic hunks. - -### Step 7: Collapse the two owed-connections queries into one **[user-directed]** - -- **Files:** `apps/app/src/features/integrations/server/sync-source-freshness.ts`, - `sync-source-freshness.test.ts` -- **Depends on:** Step 6 (names below are post-rename) -- **Now:** `runSyncHop` calls `loadOwedConnections` once at `:277` to get the hop's work, then **again** at - `:308` purely to decide whether to continue the chain: - ```ts - const connections = await loadOwedConnections(lease, new Date()); // :277 - for (const connection of connections) { … } // serial loop - const remaining = await loadOwedConnections(lease, new Date()); // :308 - const continued = remaining.length > 0 && hops < MAX_SYNC_HOPS; - ``` - The second query does the same `findMany` — same filters, same ordering, same `take` — and its result is - used only as a boolean. Two round trips per hop where one would do. -- **Target:** ask for one row more than the hop can use, and let the surplus answer the question: - ```ts - const owed = await loadOwedConnections(lease, now, SYNC_HOP_CONNECTION_LIMIT + 1); - const connections = owed.slice(0, SYNC_HOP_CONNECTION_LIMIT); - const moreOwed = owed.length > SYNC_HOP_CONNECTION_LIMIT; - ``` - This is sound because of how the query already excludes work in flight - (`sync-source-freshness.ts:135-140`): - ```ts - OR: [{ lastAttemptedAt: null }, { lastAttemptedAt: { lt: lease.chainStartedAt } }], - ``` - Every connection this hop touches gets `lastAttemptedAt = now` (`recordAttempt`), which is `>= - chainStartedAt`, so it is *already* excluded from any later query in the same chain. The second query's - only job was to re-derive "is there anything left", and the `+1` row derives it without a round trip. - Note the deadline path already sets `deadlineReached = true` and short-circuits, so this only applies - when the loop drained a full batch. -- **Behavior delta — read this before shipping.** This is **not** perfectly behavior-preserving, and the - difference is worth stating precisely rather than hiding: - - **Today:** the continue decision is made *after* the hop's ~4 minutes of work, so a connection whose - `nextPollAfter` elapsed *during* the hop is seen and the chain continues for it. - - **After:** the decision is made *before* the work, so that connection is missed and waits for the next - chain — which, on the daily cron (`apps/app/vercel.json` → `"0 4 * * *"`), means the next day. - - **How narrow:** `SYNC_BACKOFF_MS` is `[0, 0, 0, 6h, 1d, 3d]`, and `recordPollFailure` writes - `nextPollAfter: delay > 0 ? … : null`. So only a connection with **≥4 consecutive failures** has a - non-null `nextPollAfter` at all. The window is a ~4-minute crossing on a 6h-or-longer timer, for a - connection that is already failing, on the last hop of a chain. Everything else — connections with - `nextPollAfter: null`, and any hop that is not the last — is bit-identical. - - **Recommendation:** ship it. The delta costs a repeatedly-failing connection one extra day in a rare - window; the fix removes one DB round trip per hop, every hop, forever. If that trade is unwanted, the - alternative that preserves behavior exactly is to keep the second query but make it - `count({ …, take: 1 })` instead of a full `findMany` — cheaper, same semantics, but still a round trip. -- **Risk:** low mechanically. `loadOwedConnections` gains a limit parameter, so give it a default of - `SYNC_HOP_CONNECTION_LIMIT` and keep the existing tests calling it unchanged. The trap is slicing: - `connections` must be the sliced array everywhere downstream, or the hop polls `LIMIT + 1` connections - and the deadline budget is off by one. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. The - `describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for")` block is the one that matters; - its existing assertions must pass unchanged. **Add** a case asserting `db.integrationConnection.findMany` - is called **once** per hop — that is the assertion that keeps the second query from creeping back. - -### Step 8: Split the sync module into `server/sync/` - -- **Files:** delete `apps/app/src/features/integrations/server/sync-source-freshness.ts` (343 lines) and - `sync-source-freshness.test.ts` (738 lines); create the folder below; update - `apps/app/src/features/integrations/server.ts:8-13` -- **Depends on:** Steps 6 and 7 (rename and fix first, then move — otherwise the diff mixes all three and - none of them is reviewable) -- **Now:** one file stacks four concerns that change for different reasons and share no state beyond - `SyncLease`: the lease (`:60`, `:94`, `:111`), connection selection (`:131`), per-connection polling - (`:175`, `:203`, `:235`), and the chain handoff (`:275`, `:321`). The test file already groups along - exactly these seams (`describe("KW1/KW4/KW5: the interval a poll covers")`, - `describe("KS1/KS2/KC1/KC4: which connections a hop is accountable for")`, - `describe("KC2/KC3: the singleton lease")`). -- **Target:** three modules plus a barrel, each with its test beside it: - - | New file | Exports | - | --- | --- | - | `server/sync/sync-lease.ts` | `acquireSyncLease`, `continueSyncLease`, `releaseSyncLease`, `type SyncLease`; module-private `SYNC_LEASE_MS`, `SYNC_LEASE_ID` | - | `server/sync/poll-connection.ts` | `pollConnection`, `loadOwedConnections`, `getPollingStart`, `backoffMs`, `type SyncConnection`, `type SyncRunTotals`; module-private `loadSyncableSources`, `markChangedSourcesStale`, `recordAttempt`, `recordPollSuccess`, `recordPollFailure`, `SYNC_WINDOW_FLOOR_MS`, `SYNC_CLOCK_SKEW_MS`, `SYNC_HOP_CONNECTION_LIMIT`, `SYNC_BACKOFF_MS`, `SYNC_BACKOFF_CAP_MS` | - | `server/sync/run-sync-hop.ts` | `runSyncHop`, `type SyncHopResult`; module-private `handOffChain`, `SYNC_HOP_DEADLINE_MS`, `MAX_SYNC_HOPS` | - | `server/sync/index.ts` | barrel re-exporting exactly what `server.ts` re-exports today | - - Keep the backoff ladder in `poll-connection.ts` with the bookkeeping that writes it — splitting the - table from `recordPollFailure` would put one invariant in two modules. Split the test file the same - three ways, moving each `describe` block beside its new module. -- **Risk:** moderate — it is the largest mechanical change in the plan. The failure mode is an import - cycle (`run-sync-hop` → `poll-connection` → `sync-lease` must stay a straight line, no back-edges) and a - missed re-export from `server.ts`. Both are compile errors, not silent breaks. Constants that are - currently exported but only used inside the module should become module-private; if a test imports one, - keep it exported rather than loosening the test. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — the same assertions must pass, now - spread over three files with the total test count unchanged. Confirm - `apps/app/src/features/integrations/server.ts` re-exports the identical surface: - `git show HEAD~1:apps/app/src/features/integrations/server.ts` vs. the new one. - -### Step 9: Collapse the provider class hierarchy (the code-judo move) - -- **Files:** `apps/app/src/features/integrations/server/base-provider.ts`, - `apps/app/src/features/integrations/server/connection-token.ts`, - `apps/app/src/features/integrations/server/registry.ts`, - `apps/app/src/features/integrations/server/providers/notion.ts`, - `apps/app/src/features/integrations/server/providers/github/provider.ts` -- **Now:** two providers are served by a four-way type split — `BaseIntegrationProvider`, - `PageIntegrationProvider`, an **empty** `ReadOnlyIntegrationProvider` marker class, and a separate - `AppInstallationProvider` *interface* reached through a `mintsInstallationTokens` type guard. Each - capability is expressed a different way, and three of the four expressions are dead weight: - - `refreshToken` (`:57`) — **no production caller anywhere.** The only reference in the repo is the test - at `providers/github/provider.test.ts:280`. No provider overrides it. The `tokenExpiresAt` column it - would key off is written (`connect-callback.ts:176,185`) and **read nowhere**; - `resolveConnectionToken` never checks expiry. - - `ReadOnlyIntegrationProvider` — an empty subclass carrying no members. - - `listsGrants` (`:52`) — a boolean flag that only restates whether a subclass overrode `listGrants`. - - `PageIntegrationProvider`'s four base implementations (`listChildren`, `listDatabasePages`, - `getPageRevision`, `pollModifiedPages`) return empty/null for a subclass that never uses them — Notion - overrides all four and is the only page provider. - - `AppInstallationProvider` + `mintsInstallationTokens` — a single-implementor interface plus a runtime - type guard, to express "this provider mints tokens instead of storing them". -- **Target:** one abstract class where a capability is an optional method, and *having* the method is the - capability — deleting the entire "capability as a position in a type hierarchy" category: - ```ts - export abstract class IntegrationProvider { - abstract readonly providerId: IntegrationProviderId; - abstract readonly displayName: string; - abstract readonly credential: IntegrationCredentialKind; - - abstract getAuthUrl(state: string, redirectUri: string): string; - abstract completeConnect( - params: ConnectCallbackParams, - redirectUri: string, - ): Promise; - - /** Present only on providers connected by letting an app in. */ - mintAccessToken?(installationId: string): Promise; - /** Present only on providers that hand access out piece by piece. */ - listGrants?(token: string): Promise; - } - - export abstract class PageIntegrationProvider extends IntegrationProvider { - abstract searchPages(...): ...; - abstract fetchPageContent(...): ...; - abstract listChildren(...): ...; - abstract listDatabasePages(...): ...; - abstract getPageRevision(...): ...; - abstract pollModifiedPages(...): ...; - } - ``` - Deletions: `refreshToken`, `ReadOnlyIntegrationProvider`, `listsGrants`, `AppInstallationProvider`, - `mintsInstallationTokens`. `PageIntegrationProvider` keeps its four methods but as `abstract` — Notion - already implements every one, so nothing changes at runtime and a future page provider is forced to - decide rather than silently inheriting "returns nothing". - - Call sites become presence checks: - ```ts - // connection-token.ts — was: if (mintsInstallationTokens(provider)) { … } - if (provider.mintAccessToken) { … } - // callers of listGrants — was: if (provider.listsGrants) { … } - const grants = await provider.listGrants?.(token) ?? []; - ``` - Two consumers of `listsGrants` need updating together: `registry.ts`'s provider descriptor (whatever - feeds `provider.listsGrants` into the client payload) and - `settings/components/org-integrations-card.tsx:224`'s render guard - (`connection && provider.listsGrants ? : null`). The client-facing descriptor must - keep *some* boolean — the browser cannot check for a server method — so keep a `listsGrants` field on - the **descriptor** while deleting it from the **class**, computed once in `registry.ts` as - `listsGrants: Boolean(provider.listGrants)`. That is the whole point: one place derives it, instead of - every provider restating it. - - `PAGE_INTEGRATION_PROVIDERS` in `contracts.ts` stays as it is — it is the client-visible contract and - must not learn about server classes. - - **Decision recorded:** `refreshToken` is *deleted*, not wired up. Deleting it preserves behavior exactly - (zero production callers, `tokenExpiresAt` never read); wiring it up would add token-refresh behavior - that does not exist today, which is a feature, not a refactor. If an expiring-token provider is added - later, refresh gets designed then, against a real requirement. `tokenExpiresAt` keeps being written — - dropping the column needs a migration and is out of scope. -- **Risk:** the type guard `mintsInstallationTokens` checks - `credential === "app_installation" && "mintAccessToken" in provider`; an optional method drops the - `credential` half of that check. Confirm `connection-token.ts`'s branch order still sends - OAuth-credential providers down the `accessTokenEncrypted` path — the `credential` discriminant stays on - the class, so assert on it if the presence check alone reads as weaker. Verify Notion never grows a - `mintAccessToken`. This is the step most likely to surface a `refreshToken` reference in a test: update - `providers/github/provider.test.ts:280` by **deleting** that case (it asserts the throw of a method that - no longer exists), not by keeping the method alive for the test. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. Then confirm the deletions are real: - `grep -rn "refreshToken\|ReadOnlyIntegrationProvider\|mintsInstallationTokens\|AppInstallationProvider" apps/app/src packages` - should return nothing outside `contracts.ts`'s `OAuthTokens.refreshToken` field (the wire shape Notion's - OAuth response carries — that stays) and the `refreshTokenEncrypted` DB column; - `grep -rn "listsGrants" apps/app/src` should show only the descriptor in `registry.ts` and its two - readers. - -### Step 10: Split `org-integrations-card.tsx` into a component folder - -- **Files:** delete `apps/app/src/features/integrations/settings/components/org-integrations-card.tsx` - (307 lines); create `apps/app/src/features/integrations/settings/components/org-integrations/`; update - the re-export at `apps/app/src/features/integrations/client.ts:3` -- **Depends on:** Phase 0 P0.1 -- **Now:** one file defines **eight** JSX-returning functions — `NotionIcon` (`:14`), `GitHubIcon` (`:27`), - `renderProviderIcon` (`:48`, lowercase and returning JSX), `ProviderStatus` (`:77`), `ProviderAction` - (`:99`), `ProviderGrants` (`:143`), `ProviderRow` (`:203`), `OrgIntegrationsCard` (`:235`) — plus the - `PROVIDER_ICONS` registry (`:40`). This PR grew the file by 147 lines. codebase.md: one component per - file, and files made of several larger components must be split into a folder. -- **Target:** nine files in `org-integrations/`, following the existing feature-subfolder pattern: - - | New file | Export | - | --- | --- | - | `org-integrations-card.tsx` | `OrgIntegrationsCard` — container: the `api.integration.list` query, row mapping, empty state | - | `provider-row.tsx` | `ProviderRow`, `export type ProviderRowProps` (imported by siblings) | - | `provider-status.tsx` | `ProviderStatus` | - | `provider-action.tsx` | `ProviderAction` | - | `provider-grants.tsx` | `ProviderGrants` — owns the `listGrants` query and the revoked-toast effect | - | `provider-icon.tsx` | `ProviderIcon` + `PROVIDER_ICONS`; **replaces** `renderProviderIcon`, call site becomes `` | - | `notion-icon.tsx` | `NotionIcon` | - | `github-icon.tsx` | `GitHubIcon` | - | `use-org-integrations.ts` | `useOrgIntegrations` — the list query, both mutations, `disconnectingId` state | - - The lowercase `renderProviderIcon` becoming a real `ProviderIcon` component is the point of the - `provider-icon.tsx` file, not an incidental rename — codebase.md forbids lowercase JSX-returning helpers. -- **Risk:** the largest surface-area change in the plan, but every piece is a move. The real risks are - (a) the `client.ts:3` re-export path, which is what the rest of the app imports, and (b) accidentally - changing the empty-state string or the disconnect-state quirk while moving them — P0.1 is what catches - that. Do **not** fix the `isDisconnecting`/`isDisconnectPending` triple-boolean or the stuck-button quirk - in this step; the first is Step 11's, the second is BF-13. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — P0.1 must pass **unmodified**, which is - the proof the split changed nothing. Then - `grep -rn "createElement\|eslint-disable\|@ts-ignore\|@ts-expect-error" apps/app/src/features/integrations/settings/components/org-integrations/` - returns nothing. - -### Step 11: Collapse the disconnect state to one derived boolean - -- **Files:** `apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx`, - `provider-action.tsx`, `use-org-integrations.ts` -- **Depends on:** Step 10 -- **Now:** three overlapping booleans describe one operation — `isDisconnecting` (`:68`), - `isConnectPending` (`:69`), `isDisconnectPending` (`:70`) — and two of them are consumed as a single - condition anyway: `disabled={isDisconnecting || isDisconnectPending}` (`:116`). -- **Target:** one `isBusy` derived inside `useOrgIntegrations` as - `disconnectingId === providerId && disconnectMutation.isPending`, passed down as a single prop. - `isConnectPending` stays — it is a genuinely different operation. -- **Risk:** low, but note that `isDisconnecting || isDisconnectPending` and - `isDisconnecting && isDisconnectPending` are **not** the same condition. Today the button is disabled if - *either* is true; the `&&` form is strictly narrower and would re-enable the button in the window where - `disconnectingId` is set but the mutation has not started. Keep the `||` semantics unless BF-13 ships - first, in which case the two collapse to the same thing. If in doubt, ship BF-13 before this step. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — P0.1's quirk assertion (item 4) must - still pass. - -### Step 12: Add the disconnect confirmation dialog **[user-directed]** - -- **Files:** create - `apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx`; - edit `provider-action.tsx`, `provider-row.tsx`, `org-integrations-card.tsx`, `use-org-integrations.ts` -- **Depends on:** Steps 10 and 11 -- **Now:** clicking Disconnect fires the mutation immediately - (`org-integrations-card.tsx:114`, `onClick={onDisconnect}`). Meanwhile two translated keys have existed - since the feature landed and are read by nothing: - ```json - "confirmDisconnectTitle": "Disconnect integration?", - "confirmDisconnectDescription": "Existing sources will remain in your notebooks, but re-sync will no longer work until you reconnect." - ``` - (`orgSettings.i18n.en.json:123-124`, mirrored in `.de.json`, typed at `org-settings.types.ts:123-124`.) - Revision 1 of this plan proposed deleting them as dead copy; the user's direction is to build the dialog - instead, so they become live keys and Step 13 no longer touches them. -- **Target:** reuse the repo's existing confirmation idiom rather than inventing one. The model is - `apps/app/src/features/organizations/members/components/modals/remove-member-dialog.tsx`, which composes - `AlertDialog` from `apps/app/src/shared/ui/components/alert-dialog.tsx` and is driven by a nullable id: - ```tsx - export function DisconnectIntegrationDialog({ - provider, // IntegrationProviderId | null — non-null means open - onConfirm, - onClose, - t, - }: { - provider: IntegrationProviderId | null; - onConfirm: () => void; - onClose: () => void; - t: OrgSettingsPage["integrations"]; - }) { - return ( - !open && onClose()}> - - - {t.confirmDisconnectTitle} - {t.confirmDisconnectDescription} - - - {t.cancelButton} - - {t.disconnectButton} - - - - - ); - } - ``` - `useOrgIntegrations` grows one piece of state — `pendingDisconnect: IntegrationProviderId | null` — and - `ProviderAction`'s `onDisconnect` sets it instead of calling the mutation. The mutation moves behind - `onConfirm`. The dialog is rendered **once** by `OrgIntegrationsCard`, not per row, so there is one - instance regardless of provider count. - - **Two things to get right, both of which the model file gets wrong or does not cover:** - 1. `remove-member-dialog.tsx:62` hardcodes `Cancel` in English. - Do **not** copy that. Add a `cancelButton` key to the `integrations` block in both locale files and - `org-settings.types.ts` — `pnpm check`'s i18n task enforces the pair. - 2. `isBusy` from Step 11 must keep gating the row's button, and the dialog's confirm button needs its - own pending state, or a double-click confirms twice. -- **Behavior delta:** disconnecting now takes two clicks instead of one. That is the requested change; it - is called out here rather than buried because P0.1's disconnect assertions must be updated in the same - commit, and any e2e flow that clicks Disconnect will need the extra step. -- **Risk:** low-moderate. `AlertDialog` is Radix-based and already used in this app, so no new dependency. - The one real hazard is state leaking between rows: `pendingDisconnect` must be cleared on close *and* on - success, or the next Disconnect click opens the dialog for the previous provider. Add a render test - covering open → cancel → open-a-different-provider. -- **Verify:** `pnpm check` (its i18n task gates the new `cancelButton` key in both locales); - `pnpm --filter @scibly/app run test` — P0.1 updated in the same commit, plus the new dialog test. Then - `grep -rn "confirmDisconnectTitle\|confirmDisconnectDescription" apps/app/src` shows a **reader**, not - just the two JSON declarations and the type. - -### Step 13: Translate the hardcoded strings and remove the suppression - -- **Files:** `apps/app/src/features/organizations/settings/components/org-settings-form.tsx`, - `apps/app/src/features/organizations/settings/i18n/orgSettings.i18n.en.json`, - `orgSettings.i18n.de.json`, `org-settings.types.ts`, - `apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx`, - `apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts` -- **Now:** three clusters of untranslated user-facing copy, and the branch's only lint suppression: - 1. `org-settings-form.tsx:61-74` — ten `IntegrationCallbackError` messages hardcoded in English, plus - the success toast at `:51` (`` `${integrationConnected.toUpperCase()} connected successfully.` ``) - and the fallback at `:79`. Meanwhile `org-settings.types.ts:121-126` already declares - `connectedSuccessfully`, `disconnectedSuccessfully`, `workspaceLabel`, `connectedBy`, - `confirmDisconnectTitle`, `confirmDisconnectDescription` — translated in both locale files, and all - but `disconnectedSuccessfully` read by nothing (until Step 12 wires up the last two). - 2. `org-integrations-card.tsx:301` — `No integrations available.` in a component whose every other - string comes from `t`. - 3. `use-page-picker-controller.ts:163-171` — three toasts with hand-rolled English pluralization - (`` `${count} page${count !== 1 ? "s" : ""} added` ``), inside a hook that already receives `props.t` - and uses it on the next line (`:173`). - And `org-settings-form.tsx:87`: - ```ts - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - ``` -- **Target:** - - Move the ten callback-error messages into `orgSettings.i18n.en.json`/`.de.json` under `integrations`, - typed in `org-settings.types.ts`; pass `t.integrations` into the effect. Use the already-present - `connectedSuccessfully` key at `:51`. - - Add `noProvidersAvailable`; render `{t.noProvidersAvailable}`. Per Step 10's layout this belongs in its - own `org-integrations-empty.tsx`. - - Add `pagesAdded`, `pagesAddedWithSkipped`, `allAlreadyLinked` to the notebook `pagePicker` - translations, rendered with the existing `{name}`-style placeholder substitution used at - `components/integration-buttons.tsx:65`. - - **Replace** the suppression with a `useRef` run-once guard (or list the genuinely stable deps). - codebase.md is explicit: suppressions are fixed properly, never re-silenced and never `_`-prefixed. - - Delete the two keys that stay dead — `workspaceLabel` and `connectedBy` — from the type and both JSON - files. **`confirmDisconnectTitle` and `confirmDisconnectDescription` are now read by Step 12's dialog - and must be kept** *(this reverses revision 1, which deleted all four)*. If Step 12 has not shipped - when this step does, keep all four and delete nothing — deleting keys a queued step needs is the one - ordering mistake here that the gate will not catch. -- **Risk:** this is the one step that changes rendered text, from English literals to translated keys. For - `en` the strings must be **byte-identical** to today's, or P0.1 and any snapshot will (correctly) fail. - The `useRef` guard must preserve the current once-per-mount semantics including React 18 double-invoke - in development — the existing comment at `:45-46` explains why the toast id is stable; keep that. - `pnpm check` runs the i18n check, so a key present in `en` but missing in `de` fails the gate. -- **Verify:** `pnpm check` (its i18n task is the real gate here); - `pnpm --filter @scibly/app run test`; then - `grep -rn "eslint-disable\|@ts-ignore\|@ts-expect-error" apps/app/src/features/organizations/settings apps/app/src/features/integrations apps/app/src/features/notebook/sources` - returns **nothing** — down from the one suppression at baseline. - -### Step 14: Unify the provider display registry - -- **Files:** `apps/app/src/features/integrations/settings/components/org-integrations/provider-icon.tsx`, - `apps/app/src/features/notebook/chat/provider-display.tsx` -- **Depends on:** Steps 1 and 10 -- **Now:** two independent provider-display registries under different names, which **disagree about which - providers exist**: - ```ts - // org-integrations-card.tsx:40 — exhaustive over IntegrationProviderId - const PROVIDER_ICONS = { NOTION: NotionIcon, GITHUB: GitHubIcon } - satisfies Record>; - - // notebook/chat/provider-display.tsx:31 — keyed by bare string - export const PROVIDER_DISPLAY = new Map([ - ["NOTION", …], ["CONFLUENCE", …], // no GITHUB - ]); - ``` - So a GitHub source in the notebook picker falls through to `PROVIDER_DISPLAY_FALLBACK`. Step 1 removes - the `CONFLUENCE` entry; this step removes the *reason* a second registry could diverge at all. - - There is a **third** registry with the same shape: - `notebook/workspace/utils/constants.ts:102` — `SOURCE_DISPLAY_MAP: Map` - with a `DEFAULT_SOURCE_DISPLAY` fallback, keyed by lowercased `SourceType`. It is keyed by source type - rather than provider, so it is not merged here, but it is the same anti-pattern (`Map` + - fallback = silent divergence) and it is why the convention added to `codebase.md` is worth having. -- **Target:** one registry keyed by `IntegrationProviderId` and declared - `satisfies Record` — so adding a provider fails to compile until its display - entry exists — living next to `contracts.ts`, read by both the settings card and the notebook picker. - Drop `PROVIDER_DISPLAY_FALLBACK` once the map is exhaustive. -- **Risk:** **this step changes what the notebook picker renders for a GitHub source** — today the - fallback, afterwards the real GitHub entry. That is arguably fixing a bug rather than preserving - behavior. Ship it as a deliberate, visible change, or hold it. Note also that - `provider-display.tsx` defines the camelCase components `confluenceLogo` and `providerLogoFallback`, - which codebase.md's React rules forbid; `confluenceLogo` is already deleted by Step 1, and renaming - `providerLogoFallback` → `ProviderLogoFallback` belongs here since this step is already rewriting the - file. `NotionLogoIcon` is already shared (`provider-display.tsx:6,37`, `source-list-item.tsx:7,75`) — - reuse it rather than adding a third Notion icon. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test` — `source-list-item.test.tsx` exercises - the notebook-side rendering. - -### Step 15: Delete `pageCount` from the integration page contract - -- **Files:** `apps/app/src/features/integrations/contracts.ts`, - `apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts` -- **Now:** `IntegrationPageContent.pageCount` (`contracts.ts:44`) is declared and forwarded into - persistence (`integration-extractors.ts:58`), but the only implementation of `fetchPageContent` - (`providers/notion.ts:147-152`) never sets it — so it is always `undefined` for integration sources. - It belongs to the PDF path (`ingestion/parsers/pdf-parser.ts:98`), which sets it on its own extractor - type. -- **Target:** drop the field from the interface and the forwarding line. If the persistence layer requires - the key, pass `undefined` explicitly at the one call site rather than routing it through the contract. -- **Risk:** low — confirm with `grep -rn "pageCount" apps/app/src` that the PDF path's own `pageCount` is - a separate type and is untouched. -- **Verify:** `pnpm check`; `pnpm --filter @scibly/app run test`. - -## Behavior-changing fixes (separate — these are NOT refactors) - -None of these belong in the steps above. They change what the code does; the user decides if and when they -ship. Ordered by severity. **BF-1 warrants attention before this branch reaches production.** - -### BF-1 · CRITICAL · Cross-tenant GitHub installation takeover - -- **Where:** `apps/app/src/features/integrations/server/providers/github/provider.ts:52`, reached from - `server/connect-callback.ts:65` -- **Evidence:** the callback reads the installation id straight from the query string — - ```ts - // connect-callback.ts:65 - installationId: searchParams.get("installation_id"), - ``` - — and the provider resolves it with the **app's own JWT**, which can see every installation of the app - on any account: - ```ts - // providers/github/provider.ts:52 - const installation = await fetchInstallation(readGitHubAppConfig(), params.installationId); - ``` - ```ts - // providers/github/app-auth.ts — fetchInstallation - await githubRequest(`/app/installations/${encodeURIComponent(installationId)}`, - { method: "GET", authorization: `Bearer ${signAppJwt(config)}` }); - ``` - Nothing binds that id to the person completing the connect. `validateCallback` only proves the `state` - was issued by us (HMAC-SHA256, timing-safe, 10-minute TTL — but **no nonce, so replayable**), and - `authorizeCallback` only proves the caller is an admin of **their own** org. - `grep -rn "user/installations\|user-to-server\|oauth/access_token" apps/app/src` returns **nothing** — - there is no ownership check anywhere. `docs/runbooks/github-app.md:25` confirms this is by design today: - *"Request user authorization (OAuth) during installation — **unchecked**"*, with the note "scibly never - asks GitHub for a user token, only for the installation." -- **Impact:** an admin of any org calls `integration.getAuthUrl({ provider: "GITHUB" })` for their own - org, then hits - `/api/integrations/github/callback?state=&installation_id=`. - The connection persists pointing at the victim's installation. `listGrants` then mints a real - installation token and returns the victim org's **private repository names and URLs**, and the stored - connection carries the app's Contents/Issues/PR read access. Installation ids are small sequential - integers, so finding other tenants of this app is trivial enumeration. Any scibly customer who connects - GitHub is readable by any other scibly customer. -- **Recommendation:** enable *Request user authorization (OAuth) during installation* on the GitHub App, - exchange the `code` GitHub returns for a user-to-server token, and verify `GET /user/installations` - contains the submitted `installation_id` **before** persisting. Update - `docs/runbooks/github-app.md:25` in the same change — the runbook currently instructs operators into the - vulnerable configuration. Give the state a single-use nonce while in here - (`apps/app/src/lib/crypto/oauth-state.ts` has none). - -#### Answering the question: does this narrow who can use the connection? **[user-directed]** - -> *"If we set OAuth as recommended, can we still connect it such that every member of an org has access — -> or more specifically admins and owners?"* - -**Org-wide access is unaffected. Only who may complete the connect changes, and only slightly.** - -1. **The user token is used once and thrown away.** It exists solely to answer "can *this* person see - *that* installation?" at connect time. It is never written to `integration_connection` — the schema has - no column for it — so its 8-hour expiry is irrelevant and no refresh is needed. -2. **Every later call is unchanged.** `resolveConnectionToken` → `mintAccessToken` signs a JWT with the - app's own RSA private key and exchanges it for an *installation* token - (`app-auth.ts`, `provider.ts:52`). That token is scoped to the installation, not to a person. So - `listGrants`, source ingestion, and the daily sync behave identically for every member of the org - regardless of who connected it — which is exactly the property `CONTEXT.md` describes under - **Installation**: *"the token it stands for is minted from the app's own key… and never written down."* -3. **Scibly-side authorization does not move.** `authorizeCallback` already calls - `requireOrgMember(organization.id, session.user.id, "admin_or_owner")`, and `getAuthUrl` / - `disconnect` / `listGrants` all call `resolveOrg(..., "admin_or_owner")`. Admins and owners remain the - ones who can connect and disconnect; ordinary members remain able to *use* the connection through - notebooks. None of that is touched. -4. **The one real change:** the scibly admin who completes the connect must also be a GitHub user who can - see that installation. A scibly admin with no GitHub relationship to the org being installed onto could - no longer complete the connect — someone with GitHub access would have to. In practice that is the - person who clicked "Install" on GitHub anyway, since GitHub redirects *them* to the callback. -5. **A plain member never reaches this at all.** The integrations settings page is admin/owner-only, so - ordinary members see no providers and have no connect button — the "does a plain member still see it" - question does not arise on the scibly side. The person completing a connect is always an admin or an - owner, both before and after this change. -6. **What is left to verify is GitHub-side, and only for that admin.** GitHub documents - `GET /user/installations` as returning installations "that the authenticated user has explicit - permission (`:read`, `:write`, or `:admin`) to access" — **repository-access-based, not org-role-based**, - so it does not require GitHub org ownership. Confirm against a real install that the account clicking - through actually sees the installation; if some legitimate connector does not, accept **either** proof: - the user token lists the installation, **or** the user is an admin/owner of the GitHub org that - `fetchInstallation` reports as the installation's account. Both close the takeover. - -### BF-2 · HIGH · Stored XSS — `javascript:` URLs pass `z.url()` and are rendered into `href` - -- **Where:** validation via Zod, rendered at - `apps/app/src/features/notebook/sources/components/source-list-item-actions.tsx:205` (`href={item.externalUrl}`) -- **Evidence:** verified empirically against the pinned **zod 4.4.3** in this repo: - ``` - "javascript:alert(1)" -> string().url(): true | z.url(): true - "data:text/html," -> string().url(): true | z.url(): true - "vbscript:x" -> string().url(): true | z.url(): true - ``` - Zod's URL check only asks whether `new URL()` parses, which accepts any scheme. So yes — **this is - really needed**; `z.string().url()` is not doing the job anyone reading it assumes it does. -- **Impact:** a value that reaches `externalUrl` is stored and later rendered as a clickable link. A - `javascript:` href executes in the victim's session on click. -- **Recommendation — one shared schema, reused deliberately** **[user-directed]:** - - Put it in `packages/schemas/src/schema/common/index.ts` (new folder; the package's `exports` map - `"./*": "./src/schema/*/index.ts"` picks it up with no config change), so both apps and packages reach - it as `@scibly/common`: - ```ts - /** - * A URL safe to put in an `href` or fetch: https only. - * Zod's own `.url()` accepts any scheme `new URL()` parses — including - * `javascript:`, `data:` and `vbscript:` — so it is not a safety check. - * Use this anywhere a URL is stored, rendered as a link, or fetched. - */ - export const httpsUrl = (message = "Must be a valid https:// URL") => - z.url({ protocol: /^https$/, message }); - ``` - Take the message as a parameter rather than hardcoding it — the repo has `zod-i18n.ts` and several call - sites pass their own copy today. - - **Reuse at these sites (all currently `z.string().url()`):** - - | Site | Field | Why it qualifies | - | --- | --- | --- | - | `features/integrations/api/integration.schema.ts:45` | `pageUrl` | flows to `externalUrl`, rendered as `href` — the actual XSS path | - | `features/integrations/api/integration.schema.ts:57` | `url` | same | - | `features/course-authoring/.../course-validation.ts:39` | thumbnail | rendered as an image src | - | `packages/schemas/src/schema/organization/index.ts:13` | `createOrganizationSchema.logo` | rendered, org-wide | - | `packages/schemas/src/schema/organization/index.ts:28` | `updateOrganizationSchema.logo` | same | - | `packages/schemas/src/schema/user/index.ts:235` | user image | rendered | - | `features/.../image-schemas.ts:79,139` | image URLs | rendered | - - **Deliberately NOT changed — do not sweep these:** - - `features/organizations/settings/api/org-ai-config.schemas.ts:32,46` — BYOAI `baseUrl`. Self-hosters - legitimately point this at `http://localhost:11434` (Ollama) or an internal host. Forcing https here - breaks a supported configuration. It is also server-to-server, never rendered as a link. - - `notebook-tools.ts:17` — the web-fetch tool's argument, whose own doc comment says *"public HTTP or - HTTPS URL"*. Narrowing it changes what the agent can fetch. If it is tightened later, that is a - product decision, not this fix. - - **Defend at the render site too.** Rows already in the database were never checked, so schema-only - validation leaves stored payloads live. Gate `source-list-item-actions.tsx:205` on the parsed protocol - before rendering the anchor — belt and braces, and it is the half that protects existing data. -- **Rollout note:** this rejects input that used to be accepted, which is why it sits here rather than in - the step list. Before shipping, run a read-only census: - `SELECT DISTINCT split_part("externalUrl", ':', 1) FROM "notebook_source" WHERE "externalUrl" IS NOT NULL;` - If anything other than `https` (and possibly `http`) appears, decide the migration story first. - -### BF-3 · HIGH · A vanished connection row kills the entire sync chain - -- **Where:** `server/sync-source-freshness.ts:181` (`recordAttempt`), reached from `:244`, `:266`, `:267` -- **Evidence:** `recordAttempt` uses `update`, which throws Prisma `P2025` when the row is gone: - ```ts - await db.integrationConnection.update({ where: { id: integrationId }, data }); - ``` - `syncConnection`'s `try/catch` (`:250-263`) wraps **only** the provider poll. The `recordAttempt` at - `:244` (empty-sources branch), `markChangedSourcesStale` at `:266`, and `recordPollSuccess` at `:267` - are all outside it. A throw there escapes to `runSyncStep`'s outer catch (`:314`), which releases the - lease and returns `{ continued: false }`. -- **Impact:** if a user disconnects (`integration-connection-procedures.ts:140` deletes the row) or - `forgetRevokedConnection` deletes it while a hop is running, **every remaining connection in the chain - is dropped for that run** — and since the cron is daily (`apps/app/vercel.json` → `"0 4 * * *"`), - "that run" means a whole day. -- **Recommendation:** use `updateMany({ where: { id } })`, a no-op on a missing row, and wrap the - per-connection body so no single connection can abort the hop. - -### BF-4 · HIGH · Reconnecting does not clear the backoff, so a fixed integration stays dark - -- **Where:** `server/connect-callback.ts:223-241` -- **Evidence:** the upsert's `update` branch carries only credentials and workspace: - ```ts - const connectionData = { - ...credentialColumns(credential), - workspaceId: credential.workspaceId ?? null, - workspaceName: credential.workspaceName ?? null, - connectedByUserId: callback.connectedByUserId, - }; - ``` - `consecutiveFailures`, `nextPollAfter` and `lastPolledAt` survive untouched. -- **Impact:** a connection whose token was revoked accumulates failures until `nextPollAfter` is up to - **7 days** out (`SYNC_BACKOFF_CAP_MS`). The admin reconnects, the settings card shows healthy, and - `loadOwedConnections` keeps excluding it for the rest of the backoff. On a workspace change the stale - `lastPolledAt` from the *previous* workspace is carried over too. -- **Recommendation:** add `consecutiveFailures: 0, nextPollAfter: null` to `connectionData`, and - `lastPolledAt: null` on the workspace-changed path. Ships naturally inside BF-11's transaction. - -### BF-5 · HIGH · A failed chain handoff is reported as success and strands the lease - -- **Where:** `server/sync-source-freshness.ts:321-341` -- **Evidence:** the response status is never inspected: - ```ts - await fetch(routes.app.api.cron.syncIntegrations, { - method: "POST", - headers: { authorization: `Bearer ${env.CRON_SECRET}`, "content-type": "application/json" }, - body: JSON.stringify(body), - }); - ``` - and the `!env.CRON_SECRET` branch at `:322-327` logs and **returns normally**. Either way - `runSyncStep` reaches `return { totals, continued: true }` (`:313`) without calling `releaseSyncLease`. -- **Impact:** a 401 or 500 on the handoff — or an unset `CRON_SECRET` — leaves the chain dead while the - lease is held for its full `SYNC_LEASE_MS` (10 minutes), blocking every trigger in between, with only a - console line to show for it. -- **Recommendation:** check `response.ok`, log the status, release the lease when the handoff did not - land, and return `continued: false` so the next trigger starts a fresh chain immediately. - -### BF-6 · HIGH · A single 404 permanently deletes a connection and detaches all its sources - -- **Where:** `server/connection-token.ts:50-51` (`forgetRevokedConnection`) -- **Evidence:** - ```ts - await detachSourcesFromConnection(connection.id, providerId, "disconnected"); - await db.integrationConnection.deleteMany({ where: { id: connection.id } }); - ``` - Two unrelated writes, **no `$transaction`**. `providers/github/provider.ts:71` maps **any** 404 to - `IntegrationRevokedError`, and GitHub answers 404 for every installation the *presenting app* cannot - see — including a `GITHUB_APP_ID`/`GITHUB_APP_PRIVATE_KEY` pair pointing at a different app. -- **Impact:** one misconfigured deploy (staging key in prod, a rotated or re-created app) destroys **every - org's** GitHub connection and rewrites `warning` on all their sources — triggered from a read path - (`listGrants`), with no undo and no confirmation. If the `deleteMany` fails after the detach commits, - sources are orphaned while the settings card still shows the integration connected. -- **Recommendation — two independent fixes, both wanted:** - 1. **Atomicity** **[user-directed]:** wrap both writes in one `prisma.$transaction`, which requires the - `detachSourcesFromConnection(..., client)` signature change described under *Transaction audit*: - ```ts - await db.$transaction(async (tx) => { - await detachSourcesFromConnection(connection.id, providerId, "disconnected", tx); - await tx.integrationConnection.deleteMany({ where: { id: connection.id } }); - }); - ``` - 2. **Blast radius:** require corroboration before deleting anything — confirm the app itself is - reachable via `GET /app/installations`, or mark the connection `revokedAt` and let an explicit - disconnect/reconnect clean it up. A transaction makes the destruction atomic; it does not make it - correct. - -### BF-7 · MEDIUM · Backoff cap equals the polling-window floor, so a recovered connection loses changes - -- **Where:** `server/sync-source-freshness.ts:34` and `:175-179` -- **Evidence:** `SYNC_BACKOFF_CAP_MS = TimeHelpers.IN_MS.DAY * 7` and - `SYNC_WINDOW_FLOOR_MS = TimeHelpers.IN_MS.DAY * 7` — **identical**. `getPollingStart` clamps with - `Math.max(lastPolledAt - skew, now - SYNC_WINDOW_FLOOR_MS)`. -- **Impact:** after the escalating ladder (6h → 1d → 3d → 7d …) a recovering connection is easily 10+ days - past its watermark, so its first successful poll asks only about the last 7 days and the intervening - edits are never marked stale — no warning, no second chance. This contradicts `CONTEXT.md`'s Watermark - definition: *"the next success covers the whole gap."* -- **Recommendation:** the cap must sit strictly below the floor — cap the backoff at 24h — or, when - `now - lastPolledAt > SYNC_WINDOW_FLOOR_MS`, fall back to a per-source revision re-check instead of the - changed-since query. The floor itself is deliberate and pinned by tests; do not move it. - -### BF-8 · MEDIUM · Three ingestion entry points bypass the `source.ingest` rate limit - -- **Where:** `api/integration-page-procedures.ts` — `linkPages` (`:39`), `linkPage` (`:63`), - `resyncSource` (`:101`, calling `ingestOrRefreshSource` directly at `:126`) -- **Evidence:** every other ingestion path goes through `boundedIngest` - (`notebook/sources/api/bounded-ingest.ts:8-13`), which wraps `withRateLimit` on endpoint - `source.ingest` — used at `source.router.ts:154`, `source-upload-procedures.ts:129` and `:202`. These - three procedures are plain `protectedProcedure` with no limiter. -- **Impact:** the abusable ingestion work is reachable unmetered, and `linkPages` fans out up to 20 - ingestions per call. -- **Recommendation:** route all three through `boundedIngest`, matching the rest of the ingestion surface. - -### BF-9 · MEDIUM · GitHub repository list is silently truncated at 100 - -- **Where:** `server/providers/github/app-auth.ts:153-159` (server) and - `settings/components/org-integrations-card.tsx:143-202` (`ProviderGrants`, client) -- **Evidence:** `githubRequest("/installation/repositories?per_page=100", …)` — `total_count` in the - response is ignored and no `page=` parameter is ever sent. On the client, `ProviderGrants` then renders - **every** returned grant as a flex-wrap chip: - ```tsx -
- ``` - So an org-wide install on a 400-repo account shows 100 chips, in a settings card, with nothing - indicating the other 300 exist — contradicting `CONTEXT.md`'s **Grant** as *what the installation was - let at*. -- **Recommendation — fix both halves** **[user-directed]:** - - **Server — page until complete.** Loop on `page` until the accumulated length reaches `total_count`, - with a hard page cap (say 10 pages / 1000 grants) so a pathological account cannot hang a hop. Return - the total alongside the list so the client can be honest about a cap that *was* hit: - ```ts - return { grants, totalCount, truncated: grants.length < totalCount }; - ``` - `listGrants` is a query on the settings page, not on the sync path, so the extra round trips cost a - slower settings card and nothing else. Do this together with BF-10's schema parsing — it is the same - function. - - **Client — first four, then a modal.** `ProviderGrants` renders the first 4 chips and, when there are - more, a `242 more` affordance that opens a dialog listing all of them: - ```tsx - const VISIBLE_GRANTS = 4; - const visible = data.grants.slice(0, VISIBLE_GRANTS); - const hiddenCount = data.grants.length - VISIBLE_GRANTS; - ``` - - The affordance is a `