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/.env.example b/.env.example index f078dac..bdf482e 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 @@ -71,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/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..972df80 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -30,9 +30,13 @@ 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" +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. @@ -63,3 +67,16 @@ 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. 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/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/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 new file mode 100644 index 0000000..65658b5 --- /dev/null +++ b/apps/app/src/app/api/inngest/route.ts @@ -0,0 +1,17 @@ +import { serve } from "inngest/next"; +import { connection, type NextRequest } from "next/server"; + +import { inngest } from "@/lib/inngest/client"; +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 depends on the request 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/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..fa60399 100644 --- a/apps/app/src/env.js +++ b/apps/app/src/env.js @@ -46,6 +46,15 @@ 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), + /** 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) */ SCIBLY_DEFAULT_CHAT_MODEL: z @@ -58,19 +67,19 @@ 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), + + 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: { @@ -117,13 +126,23 @@ 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, + 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, 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, + 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/features/integrations/CONTEXT.md b/apps/app/src/features/integrations/CONTEXT.md index 6425707..ef0209b 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. +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, 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**: @@ -81,17 +99,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/api/integration-connection-procedures.ts b/apps/app/src/features/integrations/api/integration-connection-procedures.ts index 9bfd656..1b55e84 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,38 @@ -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 { routes } from "@scibly/routes"; import { resolveOrg } from "@/features/organizations/server"; -import { decryptApiKey } from "@/lib/crypto/api-key"; import { signOAuthState } from "@/lib/crypto/oauth-state"; -import { detachSourcesFromConnection } from "../server/detach-sources"; -import { getProvider, listProviders } from "../server/registry"; +import { + CONNECTED, + DISCONNECTED_CREDENTIAL, + isConnected, +} from "../server/connection-state"; +import { resolveConnectionToken } from "../server/connection-token"; +import { warnSourcesOfLostConnection } from "../server/detach-sources"; +import { + getPageProvider, + getProvider, + listProviders, +} from "../server/registry"; import { disconnectIntegrationSchema, getAuthUrlSchema, + listGrantsSchema, listPageChildrenSchema, orgSlugInput, searchPagesSchema, } from "./integration.schema"; -export async function resolveConnection( +export async function resolveConnectionRow( organizationId: string, providerId: IntegrationProviderId, ) { @@ -27,20 +41,38 @@ export async function resolveConnection( organizationId_provider: { organizationId, provider: providerId }, }, }); - if (!connection) { + if (!connection || !isConnected(connection)) { throw new AppError({ code: "NOT_FOUND", applicationCode: "api.not_found", 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), }; } +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( @@ -49,7 +81,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, @@ -63,6 +95,7 @@ export const integrationConnectionProcedures = { const allProviders = listProviders().map((provider) => ({ providerId: provider.providerId, displayName: provider.displayName, + listsGrants: Boolean(provider.listGrants), })); return { connections, allProviders }; }), @@ -78,7 +111,7 @@ export const integrationConnectionProcedures = { userId: ctx.session.user.id, lang: input.lang, }); - const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/integrations/${input.provider.toLowerCase()}/callback`; + const redirectUri = routes.app.api.integrations.callback(input.provider); return { authUrl: provider.getAuthUrl(state, redirectUri) }; }), @@ -100,17 +133,44 @@ export const integrationConnectionProcedures = { select: { id: true }, }); + // One transaction: a warning that committed without the credential going + // with it would leave sources warning about a connection still live. if (connection) { - await detachSourcesFromConnection( - connection.id, - input.provider, - "disconnected", - ); - await db.integrationConnection.delete({ where: { id: connection.id } }); + await db.$transaction(async (tx) => { + await warnSourcesOfLostConnection( + connection.id, + input.provider, + "disconnected", + tx, + ); + await tx.integrationConnection.update({ + where: { id: connection.id }, + data: DISCONNECTED_CREDENTIAL, + }); + }); } 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 ( + (await provider.listGrants?.(token)) ?? { grants: [], totalCount: 0 } + ); + }), + searchPages: protectedProcedure .input(searchPagesSchema) .query(async ({ input, ctx }) => { @@ -119,7 +179,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 +194,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..2403dd0 100644 --- a/apps/app/src/features/integrations/api/integration-connections.test.ts +++ b/apps/app/src/features/integrations/api/integration-connections.test.ts @@ -18,20 +18,26 @@ 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. - -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() }, -})); +// `db` and `resolveOrg` are mocked; `warnSourcesOfLostConnection` is not. + +const db = vi.hoisted(() => { + const client = { + integrationConnection: { + findUnique: vi.fn(), + findMany: vi.fn(), + update: 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 writes made inside the + // transaction land 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(), @@ -100,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 }); }); @@ -112,6 +119,7 @@ describe("LA1 one door only", () => { "linkPage", "linkPages", "list", + "listGrants", "listPageChildren", "resyncSource", "searchPages", @@ -188,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(); }, ); @@ -197,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 } }, + ], + }), + }), ); }); @@ -208,6 +233,7 @@ describe("LR who may see, who may change", () => { INTEGRATION_PROVIDERS.map((providerId) => ({ providerId, displayName: expect.any(String), + listsGrants: expect.any(Boolean), })), ); }); @@ -231,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") }, }); }); @@ -263,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, ); }); @@ -282,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 e9c7fef..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,20 +1,23 @@ import { AppError } from "@scibly/api/application-error"; import { protectedProcedure } from "@scibly/api/trpc"; +import { db } from "@scibly/db"; import { - ingestOrRefreshSource, + boundedIngest, + boundedLink, linkNotebookPages, resolveNotebook, resolveOwnedNotebookSource, } from "@/features/notebook/server"; import { resolveOrg } from "@/features/organizations/server"; +import { isConnected } from "../server/connection-state"; import { linkPageSchema, linkPagesSchema, resyncSourceSchema, } from "./integration.schema"; -import { resolveConnection } from "./integration-connection-procedures"; +import { resolveConnectionRow } from "./integration-connection-procedures"; async function resolveLinkedNotebook( orgSlug: string, @@ -45,47 +48,52 @@ export const integrationPageProcedures = { input.notebookId, userId, ); - const { connection } = await resolveConnection( + const { connection } = await resolveConnectionRow( 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 resolveConnection( + 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) { @@ -115,17 +123,24 @@ 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 ingestOrRefreshSource(source.id, { - actorId: userId, - }); + const ingestion = await boundedIngest(userId, source.id); return { sourceId: source.id, ingestion }; }), }; 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..ac938bb --- /dev/null +++ b/apps/app/src/features/integrations/api/integration.schema.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { linkPageSchema } from "./integration.schema"; + +// 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({ + 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 71a93d2..fb266c2 100644 --- a/apps/app/src/features/integrations/api/integration.schema.ts +++ b/apps/app/src/features/integrations/api/integration.schema.ts @@ -1,52 +1,61 @@ +import { httpsUrl } from "@scibly/schemas/common"; +import { orgSlugInput } from "@scibly/schemas/organization"; import { z } from "zod"; -import { INTEGRATION_PROVIDERS } from "../contracts"; +import { + INTEGRATION_PROVIDERS, + MAX_LINKED_PAGES_PER_REQUEST, + PAGE_INTEGRATION_PROVIDERS, +} from "../contracts"; -export const orgSlugInput = z.object({ orgSlug: z.string() }); +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); -export const getAuthUrlSchema = z.object({ - orgSlug: z.string(), +export const pageProviderInput = z.enum(PAGE_INTEGRATION_PROVIDERS); + +export const getAuthUrlSchema = orgSlugInput.extend({ provider: providerInput, lang: z.string().default("en"), }); -export const disconnectIntegrationSchema = z.object({ - orgSlug: z.string(), +export const disconnectIntegrationSchema = orgSlugInput.extend({ provider: providerInput, }); -export const searchPagesSchema = z.object({ - orgSlug: z.string(), +export const listGrantsSchema = orgSlugInput.extend({ provider: providerInput, +}); + +export const searchPagesSchema = orgSlugInput.extend({ + 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(), + pageUrl: httpsUrl(), }); export const linkPagesSchema = z.object({ notebookId: z.string(), orgSlug: z.string(), - provider: providerInput, + provider: pageProviderInput, pages: z .array( z.object({ id: z.string(), title: z.string(), - url: z.string().url(), + url: httpsUrl(), }), ) .min(1) - .max(20), + .max(MAX_LINKED_PAGES_PER_REQUEST), }); export const resyncSourceSchema = z.object({ @@ -54,9 +63,8 @@ export const resyncSourceSchema = z.object({ orgSlug: z.string(), }); -export const listPageChildrenSchema = z.object({ - orgSlug: z.string(), - provider: providerInput, +export const listPageChildrenSchema = orgSlugInput.extend({ + provider: pageProviderInput, pageId: z.string(), nodeType: z.enum(["page", "database"]).default("page"), }); diff --git a/apps/app/src/features/integrations/client.ts b/apps/app/src/features/integrations/client.ts index 7de8517..aec2ea9 100644 --- a/apps/app/src/features/integrations/client.ts +++ b/apps/app/src/features/integrations/client.ts @@ -1,3 +1,3 @@ "use client"; -export { OrgIntegrationsCard } from "./settings/components/org-integrations-card"; +export { OrgIntegrationsCard } from "./settings/components/org-integrations/org-integrations-card"; diff --git a/apps/app/src/features/integrations/contracts.ts b/apps/app/src/features/integrations/contracts.ts index b0739a5..e44e4b0 100644 --- a/apps/app/src/features/integrations/contracts.ts +++ b/apps/app/src/features/integrations/contracts.ts @@ -1,7 +1,22 @@ -// Kept dependency-free so the client bundle (input schemas, settings card) never pulls in a provider SDK. -export const INTEGRATION_PROVIDERS = ["NOTION"] as const; +// Kept dependency-free: the client bundle imports this, so it must never pull in a provider SDK. +import type { IntegrationProvider } from "@scibly/db/enums"; -export type IntegrationProviderId = (typeof INTEGRATION_PROVIDERS)[number]; +export const INTEGRATION_PROVIDERS = [ + "NOTION", + "GITHUB", +] as const satisfies readonly IntegrationProvider[]; + +export type IntegrationProviderId = IntegrationProvider; + +// Not every connectable provider offers pages to import. +export const PAGE_INTEGRATION_PROVIDERS = [ + "NOTION", +] as const satisfies readonly IntegrationProviderId[]; + +export type PageIntegrationProviderId = + (typeof PAGE_INTEGRATION_PROVIDERS)[number]; + +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 = [ @@ -32,7 +47,6 @@ export interface IntegrationPage { export interface IntegrationPageContent { text: string; title: string; - pageCount?: number; lastEdited: Date; } @@ -41,10 +55,33 @@ export interface IntegrationPageRevision { lastEdited: Date; } +// A named part of a workspace a connection reaches — a repository an installation was given. +export interface IntegrationGrant { + id: string; + name: string; + url: string; +} + +// Fewer grants than `totalCount` means the listing stopped at its page budget. +export interface IntegrationGrantList { + grants: IntegrationGrant[]; + totalCount: number; +} + export interface OAuthTokens { accessToken: string; - refreshToken?: string; - expiresAt?: Date; workspaceId?: string; workspaceName?: string; } + +export interface AppInstallation { + installationId: string; + workspaceId?: string; + workspaceName?: string; +} + +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..eb286a7 100644 --- a/apps/app/src/features/integrations/server.ts +++ b/apps/app/src/features/integrations/server.ts @@ -1,12 +1,8 @@ import "server-only"; export { integrationRouter } from "./api/integration.router"; +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 { handleIntegrationOAuthCallback } from "./server/oauth-callback"; -export { getProvider, listProviders } from "./server/registry"; -export { - acquireSyncLease, - continueSyncLease, - runSyncStep, - type SyncLease, -} from "./server/sync-source-freshness"; +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 735c53e..0066726 100644 --- a/apps/app/src/features/integrations/server/base-provider.ts +++ b/apps/app/src/features/integrations/server/base-provider.ts @@ -1,57 +1,74 @@ import type { + IntegrationCredential, + IntegrationCredentialKind, + IntegrationGrantList, IntegrationPage, IntegrationPageContent, IntegrationPageRevision, IntegrationProviderId, - OAuthTokens, + PageIntegrationProviderId, } from "../contracts"; -export abstract class BaseIntegrationProvider { +export interface ConnectCallbackParams { + code: string | null; + installationId: string | null; +} + +export class IntegrationRevokedError extends Error { + constructor(readonly providerId: IntegrationProviderId) { + super(`The ${providerId} connection no longer exists on the provider.`); + this.name = "IntegrationRevokedError"; + } +} + +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; + + mintAccessToken?(installationId: string): Promise; + + listGrants?(token: string): Promise; +} + +export abstract class PageIntegrationProvider extends IntegrationProvider { + abstract readonly providerId: PageIntegrationProviderId; + abstract searchPages( token: string, query: string, ): Promise; - listChildren(_token: string, _pageId: string): Promise { - return Promise.resolve([]); - } - - listDatabasePages( - _token: string, - _databaseId: string, - ): Promise { - return Promise.resolve([]); - } - abstract fetchPageContent( token: string, pageId: string, ): Promise; - getPageRevision( - _token: string, - _pageId: string, - ): Promise { - return Promise.resolve(null); - } - - abstract getAuthUrl(state: string, redirectUri: string): string; + abstract listChildren( + token: string, + pageId: string, + ): Promise; - abstract exchangeCode( - code: string, - redirectUri: string, - ): Promise; + abstract listDatabasePages( + token: string, + databaseId: string, + ): Promise; - async refreshToken(_refreshToken: string): Promise { - throw new Error( - `${this.providerId} does not support token refresh. Reconnect the integration.`, - ); - } + abstract getPageRevision( + token: string, + pageId: string, + ): Promise; - pollModifiedPages(_token: string, _since: Date): Promise { - return Promise.resolve([]); - } + abstract pollModifiedPages( + token: string, + since: Date, + ): Promise; } 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 63% 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..55d4144 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"; @@ -9,9 +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 provider token exchange 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"); @@ -21,14 +19,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 transaction's reads and + // writes 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()); @@ -36,20 +40,32 @@ 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 +84,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 }), }); } @@ -97,18 +113,21 @@ 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 }); - 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 +145,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 +155,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 +164,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(); }); @@ -186,11 +205,11 @@ describe("LA the door", () => { it("LA8 refuses when the path segment names a different provider than the state", async () => { const response = await callback( { code: "auth-code", state: state() }, - "confluence", + "github", ); 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 () => { @@ -204,8 +223,8 @@ describe("LA the door", () => { 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", + { code: "auth-code", state: state({ provider: "NOT_A_PROVIDER" }) }, + "not_a_provider", ); expect(refusal(response)).toBe("invalid_state"); @@ -223,7 +242,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,26 +254,26 @@ 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(); }); }); 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 +281,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 () => { @@ -288,7 +296,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", @@ -296,7 +304,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); }); @@ -325,7 +339,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 () => { @@ -343,7 +361,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() }); @@ -351,6 +369,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" } }); @@ -364,16 +438,98 @@ 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`, ); }); }); +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, + 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.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ integrationId: null }), + }), + ); + 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 60% rename from apps/app/src/features/integrations/server/oauth-callback.ts rename to apps/app/src/features/integrations/server/connect-callback.ts index 62360b2..c80b804 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 { + ConnectCallbackParams, + IntegrationProvider, +} from "./base-provider"; import { getSession } from "@scibly/auth/session"; import { db } from "@scibly/db"; @@ -14,7 +19,6 @@ import { } from "@scibly/routes"; import { type NextRequest, NextResponse } from "next/server"; -import { env } from "@/env"; import { getProvider, isIntegrationProvider, @@ -23,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; @@ -31,7 +38,7 @@ type CallbackDestination = { type ValidCallback = CallbackDestination & { provider: IntegrationProviderId; - code: string; + params: ConnectCallbackParams; orgSlug: string; connectedByUserId: string; }; @@ -49,6 +56,21 @@ function providerError(oauthError: string): IntegrationCallbackError { return oauthError === "access_denied" ? "provider_denied" : "provider_error"; } +function readCallbackParams( + searchParams: URLSearchParams, + provider: IntegrationProvider, +): 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,64 +167,89 @@ 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, + installationId: credential.installationId, + }; + } + return { + accessTokenEncrypted: encryptApiKey(credential.accessToken), + installationId: null, + }; +} + +async function completeAndPersistConnection( callback: ValidCallback, organizationId: string, ) { - const redirectUri = `${env.NEXT_PUBLIC_APP_URL}/api/integrations/${callback.provider.toLowerCase()}/callback`; - - const existing = await db.integrationConnection.findUnique({ - where: { - organizationId_provider: { - organizationId, - provider: callback.provider, - }, - }, - select: { id: true, workspaceId: true }, - }); + const redirectUri = routes.app.api.integrations.callback(callback.provider); - const tokens = await getProvider(callback.provider).exchangeCode( - callback.code, + // 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, ); - if ( - existing?.workspaceId && - tokens.workspaceId && - existing.workspaceId !== tokens.workspaceId - ) { - await detachSourcesFromConnection( - existing.id, - callback.provider, - "workspace_changed", - ); - } + const where = { + organizationId_provider: { organizationId, provider: callback.provider }, + }; 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, + + // A reconnect answers whatever the polls were failing on, so its backoff does not outlive it. + consecutiveFailures: 0, + nextPollAfter: null, }; - await db.integrationConnection.upsert({ - where: { - organizationId_provider: { + await db.$transaction(async (tx) => { + // 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 }, + }); + + const movedWorkspace = + existing?.workspaceId && + credential.workspaceId && + existing.workspaceId !== credential.workspaceId; + + if (movedWorkspace) { + await warnSourcesOfLostConnection( + existing.id, + callback.provider, + "workspace_changed", + tx, + ); + } else if (existing) { + await clearDisconnectWarning(existing.id, callback.provider, 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, + }); }); } -export async function handleIntegrationOAuthCallback( +export async function handleIntegrationConnectCallback( req: NextRequest, { params }: { params: Promise<{ provider: string }> }, ) { @@ -218,13 +266,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-state.ts b/apps/app/src/features/integrations/server/connection-state.ts new file mode 100644 index 0000000..077e4ac --- /dev/null +++ b/apps/app/src/features/integrations/server/connection-state.ts @@ -0,0 +1,22 @@ +import type { Prisma } from "@scibly/db"; + +// 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, +}; + +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 new file mode 100644 index 0000000..40b3733 --- /dev/null +++ b/apps/app/src/features/integrations/server/connection-token.test.ts @@ -0,0 +1,135 @@ +import type { ConnectionCredential } from "./connection-token"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// `$transaction` hands the same doubled client back, so both writes are still +// observed individually. +const db: { + integrationConnection: { updateMany: ReturnType }; + notebookSource: { updateMany: ReturnType }; + $transaction: ReturnType; +} = vi.hoisted(() => { + const client = { + integrationConnection: { updateMany: 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() })); + +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: ConnectionCredential = { + id: "conn_1", + provider: "GITHUB", + accessTokenEncrypted: null, + installationId: "42", +}; + +function installationProvider(mintAccessToken: () => Promise) { + return { + providerId: "GITHUB", + credential: "app_installation", + mintAccessToken, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + db.$transaction.mockImplementation((run: (tx: unknown) => unknown) => + run(db), + ); +}); + +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 takes the credential it can no longer stand for off the row", async () => { + await expect(resolveConnectionToken(INSTALLED)).rejects.toThrow(); + + expect(db.integrationConnection.updateMany).toHaveBeenCalledWith({ + where: { id: "conn_1" }, + data: { accessTokenEncrypted: null, installationId: null }, + }); + }); + + 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 is 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.updateMany).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", + 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.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 new file mode 100644 index 0000000..c3603ff --- /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 } from "./base-provider"; +import { DISCONNECTED_CREDENTIAL } from "./connection-state"; +import { warnSourcesOfLostConnection } from "./detach-sources"; +import { getProvider } from "./registry"; + +export interface ConnectionCredential { + id: string; + provider: IntegrationProviderId; + 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.`, + }); +} + +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 it was disconnected here too. Connect again to resume.`, + }); +} + +async function forgetRevokedConnection( + connection: ConnectionCredential, + providerId: IntegrationProviderId, +): Promise { + await db.$transaction(async (tx) => { + await warnSourcesOfLostConnection( + connection.id, + providerId, + "disconnected", + tx, + ); + await tx.integrationConnection.updateMany({ + where: { id: connection.id }, + data: DISCONNECTED_CREDENTIAL, + }); + }); +} + +export async function resolveConnectionToken( + connection: ConnectionCredential, +): Promise { + const provider = getProvider(connection.provider); + + if (provider.mintAccessToken) { + 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/detach-sources.ts b/apps/app/src/features/integrations/server/detach-sources.ts index 335c67b..01e03c0 100644 --- a/apps/app/src/features/integrations/server/detach-sources.ts +++ b/apps/app/src/features/integrations/server/detach-sources.ts @@ -1,22 +1,44 @@ import type { IntegrationProviderId } from "../contracts"; -import { db } from "@scibly/db"; +import { db, type Prisma } from "@scibly/db"; type DetachReason = "disconnected" | "workspace_changed"; +type Tx = Prisma.TransactionClient | typeof db; -export async function detachSourcesFromConnection( +export function disconnectWarning(provider: IntegrationProviderId): string { + return `The ${provider} integration is disconnected. Reconnect it to resume syncing.`; +} + +export async function warnSourcesOfLostConnection( connectionId: string, provider: IntegrationProviderId, reason: DetachReason, + tx: Tx = db, ) { - await db.notebookSource.updateMany({ + 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 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) } + : { + 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/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/providers/github/app-auth.ts b/apps/app/src/features/integrations/server/providers/github/app-auth.ts new file mode 100644 index 0000000..12a5aad --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/app-auth.ts @@ -0,0 +1,258 @@ +import { routes } from "@scibly/routes"; +import crypto from "crypto"; +import { z } from "zod"; + +import { env } from "@/env"; + +// 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; + +export interface GitHubAppConfig { + appSlug: string; + appId: string; + privateKey: string; + clientId: string; + clientSecret: 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"), + clientId: env.GITHUB_APP_CLIENT_ID, + clientSecret: env.GITHUB_APP_CLIENT_SECRET, + }; +} + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString("base64url"); +} + +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)}`; +} + +export class GitHubRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "GitHubRequestError"; + } +} + +const GITHUB_TIMEOUT_MS = 30_000; + +async function githubRequest( + path: string, + init: { method: "GET" | "POST"; authorization: string }, + schema: z.ZodType, +): Promise { + 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, and one hung request must not spend a whole sync hop. + signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS), + }, + ); + + if (!response.ok) { + // 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) + .catch(() => undefined); + throw new GitHubRequestError( + `GitHub ${init.method} ${path} failed: ${response.status}${message ? ` ${message}` : ""}`, + response.status, + ); + } + return schema.parse(await response.json()); +} + +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({ + total_count: z.number(), + repositories: z + .array( + z.object({ + id: z.number(), + full_name: z.string(), + html_url: z.string(), + }), + ) + .optional(), +}); + +export async function fetchInstallation( + config: GitHubAppConfig, + installationId: string, +): Promise { + const installation = await githubRequest( + `/app/installations/${encodeURIComponent(installationId)}`, + { method: "GET", authorization: `Bearer ${signAppJwt(config)}` }, + installationResponse, + ); + 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, + }; +} + +export async function mintInstallationToken( + config: GitHubAppConfig, + installationId: string, +): Promise { + const minted = await githubRequest( + `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, + { method: "POST", authorization: `Bearer ${signAppJwt(config)}` }, + mintedTokenResponse, + ); + return minted.token; +} + +const userTokenResponse = z.union([ + z.object({ access_token: z.string() }), + z.object({ error: z.string() }), +]); + +export async function exchangeUserToken( + config: GitHubAppConfig, + code: string, +): Promise { + const response = await fetch(routes.external.integrations.github.oauthToken, { + 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 ${routes.external.integrations.github.oauthToken} 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, 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, +): 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; + +// 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[]; + totalCount: number; +} + +export async function fetchInstallationRepositories( + token: string, +): 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 new file mode 100644 index 0000000..26a6af4 --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/provider.test.ts @@ -0,0 +1,415 @@ +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 against 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, + GITHUB_APP_CLIENT_ID: "Iv23client", + GITHUB_APP_CLIENT_SECRET: "client-secret", +}; + +const { GitHubProvider } = await import("./provider"); +const { readGitHubAppConfig, signAppJwt } = await import("./app-auth"); +const { IntegrationRevokedError, PageIntegrationProvider } = + 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; + 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 }, + }; +} + +function authorizes() { + fetchMock + .mockResolvedValueOnce(ok({ access_token: "gho_user" })) + .mockResolvedValueOnce(ok({ total_count: 1 })); +} + +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 () => { + authorizes(); + fetchMock.mockResolvedValueOnce( + ok({ id: 42, account: { id: 777, login: "acme-inc" } }), + ); + + const credential = await new GitHubProvider().completeConnect({ + code: "auth-code", + 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 () => { + authorizes(); + fetchMock.mockResolvedValueOnce( + ok({ id: 42, account: { id: 777, login: "acme-inc" } }), + ); + + await new GitHubProvider().completeConnect({ + code: "auth-code", + 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 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({ + code: "auth-code", + installationId: null, + }), + ).rejects.toThrow(/no installation/i); + 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 () => { + authorizes(); + fetchMock.mockResolvedValueOnce(ok({ id: 42, account: null })); + + await expect( + new GitHubProvider().completeConnect({ + code: "auth-code", + 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 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 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" })); + + 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 asks the app whether the installation is really gone before saying so", async () => { + 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" })); + + 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, 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 () => { + fetchMock.mockResolvedValue(ok({ total_count: 0, repositories: [] })); + + await expect( + new GitHubProvider().listGrants("ghs_minted"), + ).resolves.toEqual({ grants: [], totalCount: 0 }); + }); +}); + +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).not.toBeInstanceOf(PageIntegrationProvider); + }); +}); 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..d2a1ff0 --- /dev/null +++ b/apps/app/src/features/integrations/server/providers/github/provider.ts @@ -0,0 +1,110 @@ +import type { + IntegrationCredential, + IntegrationGrantList, +} from "../../../contracts"; +import type { ConnectCallbackParams } from "../../base-provider"; +import type { GitHubAppConfig } from "./app-auth"; + +import { routes } from "@scibly/routes"; + +import { + IntegrationProvider, + IntegrationRevokedError, +} from "../../base-provider"; +import { + exchangeUserToken, + fetchInstallation, + fetchInstallationRepositories, + GitHubRequestError, + mintInstallationToken, + readGitHubAppConfig, + userCanAccessInstallation, +} 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; + } +} + +// 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 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)); + url.searchParams.set("state", state); + return url.toString(); + } + + // 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 { + if (!params.installationId) { + throw new Error("GitHub returned no installation to connect to."); + } + 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, + workspaceId: installation.accountId, + workspaceName: installation.accountLogin, + }; + } + + // 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 { + return await mintInstallationToken(config, installationId); + } catch (error) { + if (!(error instanceof GitHubRequestError) || error.status !== 404) { + throw error; + } + if (await installationIsGone(config, installationId)) { + throw new IntegrationRevokedError(this.providerId); + } + throw error; + } + } + + 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/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 5d38fdb..75ec431 100644 --- a/apps/app/src/features/integrations/server/providers/notion.ts +++ b/apps/app/src/features/integrations/server/providers/notion.ts @@ -1,15 +1,17 @@ import type { + IntegrationCredential, IntegrationPage, IntegrationPageContent, IntegrationPageRevision, - OAuthTokens, } from "../../contracts"; +import type { ConnectCallbackParams } from "../base-provider"; import { Client, isFullPage } from "@notionhq/client"; +import { routes } from "@scibly/routes"; import { env } from "@/env"; -import { BaseIntegrationProvider } from "../base-provider"; +import { PageIntegrationProvider } from "../base-provider"; import { collectNotionChildPages, extractNotionPageIcon, @@ -17,12 +19,19 @@ import { listNotionDatabasePages, } from "./notion-pages"; -export class NotionProvider extends BaseIntegrationProvider { +// 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) => + new Client({ auth, timeoutMs: NOTION_TIMEOUT_MS }); + +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"); + 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"); @@ -31,15 +40,22 @@ export class NotionProvider extends BaseIntegrationProvider { return url.toString(); } - async exchangeCode(code: string, redirectUri: string): Promise { - const response = await new Client().oauth.token({ + async completeConnect( + params: ConnectCallbackParams, + redirectUri: string, + ): Promise { + if (!params.code) { + throw new Error("Notion returned no authorisation code to exchange."); + } + const response = await notionClient().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, @@ -47,7 +63,7 @@ export class NotionProvider extends BaseIntegrationProvider { } 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" }, @@ -66,7 +82,7 @@ export class NotionProvider extends BaseIntegrationProvider { 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; @@ -102,21 +118,21 @@ export class NotionProvider extends BaseIntegrationProvider { 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; @@ -130,7 +146,7 @@ export class NotionProvider extends BaseIntegrationProvider { 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 }), diff --git a/apps/app/src/features/integrations/server/registry.ts b/apps/app/src/features/integrations/server/registry.ts index 52eac75..ee00590 100644 --- a/apps/app/src/features/integrations/server/registry.ts +++ b/apps/app/src/features/integrations/server/registry.ts @@ -1,14 +1,17 @@ import type { IntegrationProviderId } from "../contracts"; -import type { BaseIntegrationProvider } from "./base-provider"; +import type { IntegrationProvider } 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(), -} satisfies Record; + GITHUB: new GitHubProvider(), +} satisfies Record; export function isIntegrationProvider( providerId: string, @@ -16,7 +19,7 @@ export function isIntegrationProvider( return INTEGRATION_PROVIDERS.some((known) => known === providerId); } -export function getProvider(providerId: string): BaseIntegrationProvider { +export function getProvider(providerId: string): IntegrationProvider { if (!isIntegrationProvider(providerId)) { throw new AppError({ code: "NOT_FOUND", @@ -27,6 +30,19 @@ export function getProvider(providerId: string): BaseIntegrationProvider { return PROVIDERS[providerId]; } -export function listProviders(): BaseIntegrationProvider[] { +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; +} + +// 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.test.ts b/apps/app/src/features/integrations/server/sync-source-freshness.test.ts index b873ba6..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 @@ -1,49 +1,33 @@ 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(), + updateMany: vi.fn(), }, + notebookSource: { findMany: vi.fn(), updateMany: vi.fn() }, + $transaction: vi.fn(), })); const provider = vi.hoisted(() => ({ pollModifiedPages: vi.fn() })); -const registry = vi.hoisted(() => ({ getProvider: vi.fn() })); -const crypto = vi.hoisted(() => ({ decryptApiKey: vi.fn() })); +const registry = vi.hoisted(() => ({ getPageProvider: vi.fn() })); +const token = vi.hoisted(() => ({ resolveConnectionToken: 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", - }, -})); +vi.mock("@/features/integrations/server/connection-token", () => token); -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 +35,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 { @@ -102,43 +73,30 @@ 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; } -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.update.mockResolvedValue({}); + db.integrationConnection.findUnique.mockResolvedValue(connection()); + db.integrationConnection.updateMany.mockResolvedValue({ count: 1 }); db.notebookSource.findMany.mockResolvedValue([]); db.notebookSource.updateMany.mockImplementation( async ({ where }: { where: { id: { in: string[] } } }) => ({ 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"); + db.$transaction.mockImplementation(async (ops: Promise[]) => + Promise.all(ops), + ); + registry.getPageProvider.mockReturnValue(provider); + token.resolveConnectionToken.mockResolvedValue("plain-token"); provider.pollModifiedPages.mockResolvedValue([]); }); @@ -176,10 +134,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 +146,77 @@ 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); + it("KF3: excludes a connection still inside its backoff", async () => { + await loadDueConnections(NOW); const [args] = db.integrationConnection.findMany.mock.calls[0]; - expect(args.where.OR).toEqual([ - { lastAttemptedAt: null }, - { lastAttemptedAt: { lt: CHAIN_STARTED_AT } }, - ]); + expect(args.where.AND).toContainEqual({ + OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: NOW } }], + }); }); - it("KF3: excludes a connection still inside its backoff", async () => { - await loadOwedConnections(LEASE, 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).toEqual([ - { OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: NOW } }] }, - ]); + expect(args.where.AND).toContainEqual({ + OR: [ + { accessTokenEncrypted: { not: null } }, + { installationId: { not: null } }, + ], + }); }); -}); -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("hands out an id and a provider, never a credential", async () => { + await loadDueConnections(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("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 +227,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.getPageProvider.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"; - }), - brokenConnection: connection({ - id: "conn-broken", - accessTokenEncrypted: "rotated-key", - }), + token.resolveConnectionToken.mockRejectedValue( + new Error("bad ciphertext"), + ), + 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.updateMany).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.updateMany).not.toHaveBeenCalled(); - expect(db.notebookSource.updateMany).not.toHaveBeenCalled(); - expect(totals).toMatchObject({ marked: 0, unchanged: 0 }); + await recordPollFailure("conn-gone", NOW); + expect(db.integrationConnection.updateMany).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 +376,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 }, @@ -453,17 +392,15 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { { failures: 4, case: "72h for the fifth", expected: 3 * DAY }, { 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", 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, @@ -477,14 +414,13 @@ describe("KW2/KF2/KF3/KF4: what an attempt writes down", () => { expect(backoffMs(0)).toBe(0); expect(backoffMs(3)).toBe(6 * HOUR); - expect(backoffMs(99)).toBe(7 * DAY); + expect(backoffMs(99)).toBe(3 * DAY); }); 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 +429,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..1e08cca 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,22 @@ 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 { env } from "@/env"; -import { getProvider } from "@/features/integrations/server/registry"; -import { decryptApiKey } from "@/lib/crypto/api-key"; +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"; -// 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, @@ -30,124 +25,29 @@ 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; - -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; -} +// 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 { 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 } }, + organization: { subscription: notLapsedSubscription(now) }, + provider: { in: [...PAGE_INTEGRATION_PROVIDERS] }, + // Two ORs cannot share one object, so both go through `AND`. + AND: [ + CONNECTED, + { OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }] }, ], - AND: [{ OR: [{ nextPollAfter: null }, { nextPollAfter: { lte: now } }] }], - }, - select: { - id: true, - provider: true, - accessTokenEncrypted: true, - lastPolledAt: true, - consecutiveFailures: true, }, + select: { id: true, provider: true }, orderBy: { lastAttemptedAt: { sort: "asc", nulls: "first" } }, - take: SYNC_BATCH_SIZE, }); } @@ -172,166 +72,118 @@ export function getPollingStart(lastPolledAt: Date | null, now: Date): Date { return new Date(Math.max(lastPolledAt.getTime() - SYNC_CLOCK_SKEW_MS, floor)); } +// `updateMany` so a poll that outlived its connection records nothing instead of throwing. async function recordAttempt( - integrationId: string, - data: Prisma.IntegrationConnectionUpdateInput, + connectionId: string, + data: Prisma.IntegrationConnectionUpdateManyMutationInput, ): Promise { - await db.integrationConnection.update({ - where: { id: integrationId }, + await db.integrationConnection.updateMany({ + where: { id: connectionId }, data, }); } -async function recordPollSuccess( - integrationId: string, +// One batch: advancing the watermark without the marks would put those changes +// permanently behind the window. +async function commitPollSuccess( + connectionId: string, pollStartedAt: Date, -): Promise { - await recordAttempt(integrationId, { + sources: SyncableSource[], + modifiedIds: Set, +): Promise<{ marked: number; unchanged: number }> { + const changedIds = sources + .filter( + (source) => + source.externalId !== null && modifiedIds.has(source.externalId), + ) + .map((source) => source.id); + const unchanged = sources.length - changedIds.length; + + const succeeded = { + // The watermark takes the instant the poll started, so an edit made while + // it ran is covered by the next poll rather than missed. 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 { - const changed = sources.filter( - (source) => - source.externalId !== null && modifiedIds.has(source.externalId), - ); - totals.unchanged += sources.length - changed.length; - if (changed.length === 0) return; + }; + if (changedIds.length === 0) { + await recordAttempt(connectionId, succeeded); + return { marked: 0, unchanged }; + } - const marked = await db.notebookSource.updateMany({ - where: { id: { in: changed.map((source) => source.id) } }, - data: { staleAt: new Date() }, + const [marked] = await db.$transaction([ + db.notebookSource.updateMany({ + where: { id: { in: changedIds } }, + data: { staleAt: new Date() }, + }), + db.integrationConnection.updateMany({ + where: { id: connectionId }, + data: succeeded, + }), + ]); + return { marked: marked.count, unchanged }; +} + +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 connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { + id: true, + provider: true, + accessTokenEncrypted: true, + installationId: true, + lastPolledAt: true, + }, }); - totals.marked += marked.count; -} + if (!connection || !isConnected(connection)) return { status: "gone" }; -async function syncConnection( - connection: SyncConnection, - totals: SyncRunTotals, -): Promise { - const now = new Date(); const sources = await loadSyncableSources(connection.id); - if (sources.length === 0) { - totals.connectionsEmpty += 1; await recordAttempt(connection.id, { lastAttemptedAt: now }); - return; + return { status: "empty" }; } - 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; - } - - totals.polled += 1; - await markChangedSourcesStale(sources, modifiedIds, totals); - await recordPollSuccess(connection.id, now); -} + const provider = getPageProvider(connection.provider); + const token = await resolveConnectionToken(connection); + const pages = await provider.pollModifiedPages( + token, + getPollingStart(connection.lastPolledAt, now), + ); -interface SyncStepResult { - totals: SyncRunTotals; - continued: boolean; + const counts = await commitPollSuccess( + connection.id, + now, + sources, + new Set(pages.map((page) => page.id)), + ); + 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 recordAttempt(connectionId, { + lastAttemptedAt: now, + consecutiveFailures: failures, + nextPollAfter: delay > 0 ? new Date(now.getTime() + delay) : null, + }); } 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 deleted file mode 100644 index 7068e7d..0000000 --- a/apps/app/src/features/integrations/settings/components/org-integrations-card.tsx +++ /dev/null @@ -1,222 +0,0 @@ -"use client"; - -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 { toast } from "sonner"; - -import { api } from "@/shared/api/trpc/client"; -import { SettingsCard } from "@/shared/ui/settings-card"; - -export const NotionIcon = ({ className }: { className?: string }) => { - return ( - - ); -}; - -const PROVIDER_ICONS = new Map< - string, - React.ComponentType<{ className?: string }> ->([["NOTION", NotionIcon]]); - -function renderProviderIcon(providerId: string) { - const IconComponent = PROVIDER_ICONS.get(providerId); - return IconComponent ? ( - - ) : ( - - ); -} - -interface OrgIntegrationsCardProps { - orgSlug: string; - lang: string; - t: OrgSettingsPage["integrations"]; -} - -type ProviderRowProps = { - provider: { providerId: string; displayName: string }; - connection?: { workspaceName: string | null }; - isDisconnecting: boolean; - isConnectPending: boolean; - isDisconnectPending: boolean; - t: OrgSettingsPage["integrations"]; - onConnect: () => void; - onDisconnect: () => void; -}; - -export const ProviderStatus = ({ - connection, - t, -}: Pick) => { - if (!connection) { - return ( -

- - {t.notConnectedStatus} -

- ); - } - return ( -

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

- ); -}; - -export const ProviderAction = ({ - provider, - connection, - isDisconnecting, - isConnectPending, - isDisconnectPending, - t, - onConnect, - onDisconnect, -}: ProviderRowProps) => { - if (connection) { - return ( - - ); - } - return ( - - ); -}; - -export const ProviderRow = (props: ProviderRowProps) => { - const { provider, connection, t } = props; - - const providerLabels: Record = t.providers; - return ( -
-
-
- {renderProviderIcon(provider.providerId)} -
-
-

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

- -
-
-
- -
-
- ); -}; - -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/disconnect-integration-dialog.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx new file mode 100644 index 0000000..381ea1b --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/disconnect-integration-dialog.tsx @@ -0,0 +1,55 @@ +"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"; + +export function DisconnectIntegrationDialog({ + provider, + isConfirming, + onConfirm, + onClose, + t, +}: { + 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/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/org-integrations-card.test.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx new file mode 100644 index 0000000..b1f3139 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.test.tsx @@ -0,0 +1,316 @@ +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { fireEvent, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const useList = vi.hoisted(() => vi.fn()); +const useGrants = vi.hoisted(() => vi.fn()); +const invalidate = vi.hoisted(() => vi.fn()); +const authUrlMutate = vi.hoisted(() => vi.fn()); +const disconnectMutate = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); +const toastSuccess = vi.hoisted(() => vi.fn()); + +// The two mutations are handed their own callbacks, so a test decides which one +// a click runs — that is how a *failed* disconnect is staged. +vi.mock("@/shared/api/trpc/client", () => ({ + api: { + useUtils: () => ({ integration: { list: { invalidate } } }), + integration: { + list: { useQuery: useList }, + listGrants: { useQuery: useGrants }, + getAuthUrl: { + useMutation: (options: unknown) => ({ + mutate: (input: unknown) => authUrlMutate(input, options), + isPending: false, + }), + }, + disconnect: { + useMutation: (options: unknown) => ({ + mutate: (input: unknown) => disconnectMutate(input, options), + isPending: false, + }), + }, + }, + }, +})); +vi.mock("sonner", () => ({ + toast: { error: toastError, success: toastSuccess }, +})); + +const { OrgIntegrationsCard } = await import("./org-integrations-card"); + +const t = { + title: "Integrations", + 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.", + grantsTitle: "Repositories", + grantsLoading: "Loading repositories…", + grantsEmpty: "No repositories.", + grantsError: "Could not load repositories.", + grantsMore: "{count} more", + grantsShown: "Showing {shown} of {total}.", + revokedNotice: "Disconnected on {provider}'s side.", + noProvidersAvailable: "Nothing to connect to.", + providers: { NOTION: "Notion", GITHUB: "GitHub" }, +} as OrgSettingsPage["integrations"]; + +const NOTION = { providerId: "NOTION" as const, displayName: "Notion" }; +const GITHUB = { + providerId: "GITHUB" as const, + displayName: "GitHub", + listsGrants: true, +}; + +function lists(allProviders: unknown[], connections: unknown[] = []): void { + useList.mockReturnValue({ data: { allProviders, connections } }); +} + +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) => ({ + id: String(index), + name, + url: `https://github.com/${name}`, + })), + totalCount, + }, + isPending: false, + isError: false, + error: null, + }); +} + +const card = () => + render().container; + +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]); + grants(); +}); + +describe("what a row says about a provider", () => { + it("offers to connect a provider no connection was made to", () => { + const container = card(); + + expect(button(container, "Connect Notion")?.disabled).toBe(false); + expect(container.textContent).toContain("Not connected"); + }); + + it("names the workspace a connection reaches", () => { + lists([NOTION], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); + + const container = card(); + + expect(container.textContent).toContain("Connected · Acme HQ"); + expect(button(container, "Disconnect Notion")).not.toBeNull(); + }); + + it("says only connected when the workspace has no name", () => { + lists([NOTION], [{ provider: "NOTION", workspaceName: null }]); + + expect(card().textContent).toContain("Connected"); + }); +}); + +describe("which mark stands for which provider", () => { + // The two icons are told apart by the one thing that is entirely theirs: the + // path each draws. + const NOTION_PATH = "M4.459 4.208"; + const GITHUB_PATH = "M12 .5C5.37"; + + const drawn = (container: HTMLElement) => + container.querySelector("svg path")?.getAttribute("d") ?? ""; + + it("draws Notion's mark for NOTION", () => { + expect(drawn(card())).toContain(NOTION_PATH); + }); + + it("draws GitHub's mark for GITHUB", () => { + lists([GITHUB]); + + expect(drawn(card())).toContain(GITHUB_PATH); + }); +}); + +describe("a card with no providers to offer", () => { + it("says so", () => { + lists([]); + + expect(card().textContent).toContain("Nothing to connect to."); + }); +}); + +describe("what a click asks for", () => { + it("asks for the auth url of the provider whose row was clicked", () => { + fireEvent.click(button(card(), "Connect Notion")!); + + expect(authUrlMutate).toHaveBeenCalledWith( + { orgSlug: "acme", provider: "NOTION", lang: "en" }, + expect.anything(), + ); + }); + + 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(); + }); + + 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(), + ); + }); +}); + +describe("a disconnect that failed", () => { + 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; onSettled: () => void }, + ) => { + options.onError(new Error("provider said no")); + options.onSettled(); + }, + ); + const container = card(); + + fireEvent.click(button(container, "Disconnect Notion")!); + fireEvent.click(inDialog("Disconnect")!); + + expect(toastError).toHaveBeenCalledWith("provider said no"); + expect(button(container, "Disconnect Notion")?.disabled).toBe(false); + }); +}); + +describe("the grants strip", () => { + it("appears only for a connected provider that hands access out", () => { + lists([NOTION, GITHUB], [{ provider: "NOTION", workspaceName: "Acme HQ" }]); + grants("acme-inc/api"); + + expect(card().textContent).not.toContain("Repositories"); + }); + + it("renders every grant the connection reaches", () => { + lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); + grants("acme-inc/api", "acme-inc/web", "acme-inc/docs"); + + const container = card(); + + expect(container.querySelectorAll("li")).toHaveLength(3); + 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(); + + 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(); + + expect(card().textContent).toContain("No repositories."); + }); + + it("names the provider that went away, and refetches the list", () => { + lists([GITHUB], [{ provider: "GITHUB", workspaceName: "acme-inc" }]); + useGrants.mockReturnValue({ + data: undefined, + isPending: false, + isError: true, + error: { data: { applicationCode: "integration.revoked" } }, + }); + + card(); + + 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/org-integrations-card.tsx b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx new file mode 100644 index 0000000..6d6701f --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/org-integrations-card.tsx @@ -0,0 +1,72 @@ +"use client"; + +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"; + +interface OrgIntegrationsCardProps { + orgSlug: string; + lang: string; + t: OrgSettingsPage["integrations"]; +} + +export function OrgIntegrationsCard({ + orgSlug, + lang, + t, +}: OrgIntegrationsCardProps) { + const { + connections, + allProviders, + isConnectPending, + isBusy, + connect, + pendingDisconnect, + askToDisconnect, + cancelDisconnect, + confirmDisconnect, + isConfirmingDisconnect, + } = useOrgIntegrations({ orgSlug, lang, t }); + + return ( + +
+ {allProviders.map((p) => { + const connection = connections.find( + (c) => c.provider === p.providerId, + ); + return ( + askToDisconnect(p.providerId)} + onConnect={() => connect(p.providerId)} + /> + ); + })} + + {allProviders.length === 0 && ( +

+ {t.noProvidersAvailable} +

+ )} +
+ +
+ ); +} 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..14cea8f --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-action.tsx @@ -0,0 +1,45 @@ +import type { ProviderRowProps } from "./provider-row"; + +import { Button } from "@scibly/ui/components/button"; +import { ExternalLink, Unplug } from "lucide-react"; + +export const ProviderAction = ({ + provider, + connection, + isBusy, + isConnectPending, + t, + onConnect, + onDisconnect, +}: ProviderRowProps) => { + if (connection) { + return ( + + ); + } + return ( + + ); +}; 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..9b97a20 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants-dialog.tsx @@ -0,0 +1,61 @@ +"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"; + +export function ProviderGrantsDialog({ + open, + onOpenChange, + grants, + totalCount, + t, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + grants: IntegrationGrant[]; + totalCount: number; + t: OrgSettingsPage["integrations"]; +}) { + return ( + + + + {t.grantsTitle} + + {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 new file mode 100644 index 0000000..5c7a36d --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-grants.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { IntegrationProviderId } from "@/features/integrations/contracts"; +import type { OrgSettingsPage } from "@/features/organizations/contracts"; + +import { ExternalLink } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { api } from "@/shared/api/trpc/client"; + +import { ProviderGrantsDialog } from "./provider-grants-dialog"; + +const VISIBLE_GRANTS = 4; + +export const ProviderGrants = ({ + orgSlug, + provider, + t, +}: { + orgSlug: string; + provider: IntegrationProviderId; + t: OrgSettingsPage["integrations"]; +}) => { + const [showAll, setShowAll] = useState(false); + const utils = api.useUtils(); + const { data, isPending, isError, error } = + api.integration.listGrants.useQuery({ + orgSlug, + provider, + }); + + // 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"; + const revokedNotice = t.revokedNotice.replace( + "{provider}", + t.providers[provider], + ); + useEffect(() => { + if (!wasRevoked) return; + toast.error(revokedNotice, { id: `integration-revoked-${provider}` }); + void utils.integration.list.invalidate({ orgSlug }); + }, [wasRevoked, provider, orgSlug, revokedNotice, utils]); + + if (isPending) { + return

{t.grantsLoading}

; + } + if (isError) { + return

{t.grantsError}

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

{t.grantsEmpty}

; + } + const hidden = data.totalCount - VISIBLE_GRANTS; + + return ( +
+

+ {t.grantsTitle} +

+
    + {data.grants.slice(0, VISIBLE_GRANTS).map((grant) => ( +
  • + + {grant.name} + + +
  • + ))} + {hidden > 0 ? ( +
  • + +
  • + ) : null} +
+ +
+ ); +}; 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..ebacff9 --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/provider-row.tsx @@ -0,0 +1,54 @@ +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 }; + isBusy: boolean; + isConnectPending: 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..69daa1b --- /dev/null +++ b/apps/app/src/features/integrations/settings/components/org-integrations/use-org-integrations.ts @@ -0,0 +1,64 @@ +"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"; + +export function useOrgIntegrations({ + orgSlug, + lang, + t, +}: { + orgSlug: string; + lang: string; + t: OrgSettingsPage["integrations"]; +}) { + const utils = api.useUtils(); + const [disconnectingId, setDisconnectingId] = useState(null); + const [pendingDisconnect, setPendingDisconnect] = + 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); + void utils.integration.list.invalidate({ orgSlug }); + }, + onError: (err) => toast.error(err.message), + onSettled: () => { + setDisconnectingId(null); + setPendingDisconnect(null); + }, + }); + + return { + connections: data?.connections ?? [], + allProviders: data?.allProviders ?? [], + isConnectPending: getAuthUrlMutation.isPending, + isBusy: (provider: IntegrationProviderId) => + disconnectingId === provider || disconnectMutation.isPending, + connect: (provider: IntegrationProviderId) => + getAuthUrlMutation.mutate({ orgSlug, provider, lang }), + 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/notebook/chat/provider-display.tsx b/apps/app/src/features/notebook/chat/provider-display.tsx deleted file mode 100644 index 141c9c1..0000000 --- a/apps/app/src/features/notebook/chat/provider-display.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import type React from "react"; -import type { ProviderDisplayConfig } from "../workspace/utils/constants"; - -import { NotionLogoIcon } from "@radix-ui/react-icons"; - -const confluenceLogo: React.FC> = (props) => ( - - - -); - -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, - }, - ], - [ - "CONFLUENCE", - { - name: "Confluence", - subtitle: "Browse your Confluence space and add pages as sources", - Logo: confluenceLogo, - }, - ], -]); - -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/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/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..6be2dc7 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,43 @@ 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"; + +// Five entry points trigger the same extraction work, so they share one ceiling. +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. +export function boundedLink( + userId: string, + link: () => Promise, +) { + return withRateLimit( + { + db, + identifier: userId, + ...INGEST_LIMIT, + refundIf: (result: T) => result.sourceIds.length === 0, + }, + link, + ); +} 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 ebebe20..e16f78c 100644 --- a/apps/app/src/features/notebook/sources/components/integration-buttons.tsx +++ b/apps/app/src/features/notebook/sources/components/integration-buttons.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 { @@ -10,12 +10,9 @@ import { } from "@radix-ui/react-hover-card"; import { ExternalLink } from "lucide-react"; -import { INTEGRATION_PROVIDERS } from "@/features/integrations/contracts"; +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; @@ -24,12 +21,10 @@ interface ConnectedProvider { interface IntegrationButtonsProps { connectedProviders: ConnectedProvider[]; t: NotebookTranslations["sources"]; - onPickerOpen: (providerKey: IntegrationProviderId) => 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. export function IntegrationButtons({ connectedProviders, t, @@ -38,9 +33,8 @@ export function IntegrationButtons({ }: IntegrationButtonsProps) { return ( <> - {INTEGRATION_PROVIDERS.map((providerKey) => { - const meta = - PROVIDER_DISPLAY.get(providerKey) ?? PROVIDER_DISPLAY_FALLBACK; + {PAGE_INTEGRATION_PROVIDERS.map((providerKey) => { + const meta = PROVIDER_DISPLAY[providerKey]; const isConnected = connectedProviders.some( (cp) => cp.provider === providerKey, ); 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..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 @@ -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,12 @@ export function SourceListItemActions({ onResync, onDelete, }: SourceListItemActionsProps) { + // 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; + return (
@@ -199,10 +206,10 @@ export function SourceListItemActions({ onClick={onRetry} /> - {item.externalUrl ? ( + {externalHref ? ( event.stopPropagation()} 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/ingestion/extractors/integration-extractors.ts b/apps/app/src/features/notebook/sources/ingestion/extractors/integration-extractors.ts index 87a6275..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 @@ -2,10 +2,12 @@ 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) { +async function resolveSourceConnection(source: ExtractableSource) { if (!source.integrationId || !source.externalId) { throw new Error( `Integration source ${source.type} missing integrationId or 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, }; } @@ -43,17 +45,19 @@ export const notionPageExtractor: SourceExtractor = { isIntegration: true, async getRevision(source) { - const { provider, token, externalId } = await resolveIntegration(source); + const { provider, token, externalId } = + await resolveSourceConnection(source); return provider.getPageRevision(token, externalId); }, async extract(source) { - const { provider, token, externalId } = await resolveIntegration(source); + const { provider, token, externalId } = + 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. return { text: content.text, - pageCount: content.pageCount, title: content.title, lastEdited: content.lastEdited, }; 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/page-picker/page-picker-content.tsx b/apps/app/src/features/notebook/sources/page-picker/page-picker-content.tsx index a594964..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 @@ -1,10 +1,12 @@ "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"; +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"; @@ -27,7 +29,7 @@ export interface PagePickerContentProps { onOpenChange: (open: boolean) => void; notebookId: string; orgSlug: string; - provider: IntegrationProviderId; + provider: PageIntegrationProviderId; totalSourceCount: number; sourceLimit: number; @@ -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,12 @@ export function PagePickerContent({ t, onLinked, }; - const remaining = Math.max(0, sourceLimit - totalSourceCount); + // 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, + ); 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..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,7 +14,7 @@ interface PagePickerSelectAllBarProps { selected: Set; totalSourceCount: number; - sourceLimit: number; + maxTotal: number; allVisibleSelected: boolean; t: T; onToggleSelectAll: () => void; @@ -24,7 +24,7 @@ export function PagePickerSelectAllBar({ selectablePages, selected, totalSourceCount, - sourceLimit, + maxTotal, allVisibleSelected, t, onToggleSelectAll, @@ -59,14 +59,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/page-picker/use-page-picker-controller.ts b/apps/app/src/features/notebook/sources/page-picker/use-page-picker-controller.ts index 97928c7..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 @@ -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, ) { @@ -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/notebook/sources/provider-display.ts b/apps/app/src/features/notebook/sources/provider-display.ts new file mode 100644 index 0000000..9c2314f --- /dev/null +++ b/apps/app/src/features/notebook/sources/provider-display.ts @@ -0,0 +1,20 @@ +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 }>; +} + +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/sources/sources-panel.tsx b/apps/app/src/features/notebook/sources/sources-panel.tsx index dbd2dfb..b5a5bfe 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"; @@ -41,9 +41,10 @@ function useIntegrationPicker( orgSlug: string, atLimit: boolean, ensureNotebook: () => Promise, + entitlementCopy: NotebookTranslations["sources"]["entitlement"], ) { const [pickerState, setPickerState] = useState<{ - provider: IntegrationProviderId; + provider: PageIntegrationProviderId; notebookId: string; } | null>(null); const { data } = api.integration.list.useQuery( @@ -67,11 +68,17 @@ function useIntegrationPicker( ), ); }, [pickerState, sources]); - const open = (provider: IntegrationProviderId) => { + 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 ( diff --git a/apps/app/src/features/notebook/workspace/utils/constants.ts b/apps/app/src/features/notebook/workspace/utils/constants.ts index 4ee664d..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; @@ -140,22 +128,6 @@ const SOURCE_DISPLAY_MAP = new Map([ "bg-neutral-50 text-neutral-800 border-neutral-200 dark:bg-neutral-900/40 dark:text-neutral-200 dark:border-neutral-700/50", }, ], - [ - "confluence_page", - { - icon: "ExternalLink", - theme: - "bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-950/20 dark:text-blue-400 dark:border-blue-900/30", - }, - ], - [ - "sharepoint_page", - { - icon: "FileText", - theme: - "bg-teal-50 text-teal-600 border-teal-100 dark:bg-teal-950/20 dark:text-teal-400 dark:border-teal-900/30", - }, - ], ]); const DEFAULT_SOURCE_DISPLAY: SourceDisplayConfig = { 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 d0528f3..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 @@ -1,3 +1,4 @@ +import { orgSlugInput } from "@scibly/schemas/organization"; import { z } from "zod"; import { BYOAI_MODEL_TYPES } from "@/shared/ai/byoai/types"; @@ -6,11 +7,10 @@ import { byoaiModelDescriptionSchema, } from "@/shared/ai/byoai-model-schema"; -export const orgSlugInput = z.object({ orgSlug: z.string() }); +export { orgSlugInput }; -export const addModelSchema = z - .object({ - orgSlug: z.string(), +export const addModelSchema = orgSlugInput + .extend({ name: z.string().min(1, "Name is required"), baseUrl: z.string().url("Must be a valid URL"), apiKey: z.string().optional(), @@ -25,8 +25,7 @@ export const addModelSchema = z { message: "API key is required", path: ["apiKey"] }, ); -export const updateModelSchema = z.object({ - orgSlug: z.string(), +export const updateModelSchema = orgSlugInput.extend({ id: z.string(), name: z.string().min(1).optional(), baseUrl: z.string().url().optional(), @@ -36,13 +35,11 @@ export const updateModelSchema = z.object({ contextWindow: byoaiContextWindowSchema.nullish(), }); -export const deleteModelSchema = z.object({ - orgSlug: z.string(), +export const deleteModelSchema = orgSlugInput.extend({ id: z.string(), }); -export const connectionInputSchema = z.object({ - orgSlug: z.string(), +export const connectionInputSchema = orgSlugInput.extend({ baseUrl: z.string().url(), apiKey: z.string().optional(), modelId: z.string().min(1), 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..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 @@ -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,17 +36,24 @@ 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(); + // 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; + reported.current = true; + const url = new URL(window.location.href); if (integrationConnected) { - toast.success( - `${integrationConnected.toUpperCase()} connected successfully.`, - ); + 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); @@ -55,31 +61,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. Check that your redirect URI is registered in Notion.", - } satisfies Record; const known = INTEGRATION_CALLBACK_ERRORS.find( (code) => code === integrationError, ); - toast.error( - known ? messages[known] : "Connection failed. Please try again.", - ); + 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( @@ -171,7 +167,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 72b7a93..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 @@ -116,16 +116,39 @@ export type OrgSettingsPage = { description: string; connectButton: string; disconnectButton: string; + cancelButton: string; connectedStatus: string; notConnectedStatus: string; - workspaceLabel: string; - connectedBy: string; confirmDisconnectTitle: string; confirmDisconnectDescription: string; disconnectedSuccessfully: string; connectedSuccessfully: string; + grantsTitle: string; + grantsLoading: string; + grantsEmpty: string; + grantsError: string; + grantsMore: string; + grantsShown: 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 d1e956e..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 @@ -112,20 +112,41 @@ "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", + "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.", "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.", + "grantsMore": "{count} weitere", + "grantsShown": "{shown} von {total} werden angezeigt.", + "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": "Auf {provider}-Seite getrennt. 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..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 @@ -112,20 +112,41 @@ "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", + "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.", "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.", + "grantsMore": "{count} more", + "grantsShown": "Showing {shown} of {total}.", + "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": "Disconnected on {provider}'s side. Connect again to resume.", "providers": { - "NOTION": "Notion" + "NOTION": "Notion", + "GITHUB": "GitHub" } } } 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/server/inngest.test.ts b/apps/app/src/server/inngest.test.ts new file mode 100644 index 0000000..cf3eb39 --- /dev/null +++ b/apps/app/src/server/inngest.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { inngestFunctions } from "./inngest"; + +describe("inngestFunctions", () => { + 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/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/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/apps/app/src/shared/content/sources/constants.ts b/apps/app/src/shared/content/sources/constants.ts index ff2e15e..e689b38 100644 --- a/apps/app/src/shared/content/sources/constants.ts +++ b/apps/app/src/shared/content/sources/constants.ts @@ -19,8 +19,6 @@ export const SOURCE_TYPES = { TEXT: "TEXT", NOTION_PAGE: "NOTION_PAGE", - CONFLUENCE_PAGE: "CONFLUENCE_PAGE", - SHAREPOINT_PAGE: "SHAREPOINT_PAGE", } as const; export type SourceType = (typeof SOURCE_TYPES)[keyof typeof SOURCE_TYPES]; @@ -48,8 +46,6 @@ export const MAX_FILE_SIZE = { TEXT: 5 * 1024 * 1024, NOTION_PAGE: 0, - CONFLUENCE_PAGE: 0, - SHAREPOINT_PAGE: 0, } as const satisfies Record; // A source still waiting for a file this recently granted is excused from the 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/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..f4dd26c --- /dev/null +++ b/docs/adr/0004-inngest-self-hosted-orchestration.md @@ -0,0 +1,46 @@ +# 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. 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 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 + +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. +- 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/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..2ab8c38 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,19 @@ 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 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. `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 +88,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 +104,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 +126,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/runbooks/github-app.md b/docs/runbooks/github-app.md new file mode 100644 index 0000000..68deba9 --- /dev/null +++ b/docs/runbooks/github-app.md @@ -0,0 +1,164 @@ +# 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 | +| **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 | +| **Webhook → Active** | unchecked | unchecked | +| **Where can this GitHub App be installed?** | *Only on this account* | *Any account* | + +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. + +**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 +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` +- **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 — +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" +GITHUB_APP_CLIENT_ID="Iv23li..." +GITHUB_APP_CLIENT_SECRET="..." +``` + +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 + +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. 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** + 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. +- **`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. diff --git a/docs/setup.md b/docs/setup.md index 064c2e7..9074421 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 @@ -36,10 +36,16 @@ 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`, `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 + 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 @@ -49,6 +55,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 +137,20 @@ 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/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 ```bash @@ -140,6 +169,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 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 `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/packages/course-content/src/types.ts b/packages/course-content/src/types.ts index b4a42ac..0b6086f 100644 --- a/packages/course-content/src/types.ts +++ b/packages/course-content/src/types.ts @@ -28,9 +28,7 @@ export type GroundTruthCourse = { export type GroundTruthSourceType = | "PDF" | "TEXT" - | "NOTION_PAGE" - | "CONFLUENCE_PAGE" - | "SHAREPOINT_PAGE"; + | "NOTION_PAGE"; export type GroundTruthDemoSource = { key: string; 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/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/migrations/20260828130000_remove_confluence_sharepoint_providers/migration.sql b/packages/db/migrations/20260828130000_remove_confluence_sharepoint_providers/migration.sql new file mode 100644 index 0000000..82850a2 --- /dev/null +++ b/packages/db/migrations/20260828130000_remove_confluence_sharepoint_providers/migration.sql @@ -0,0 +1,24 @@ +-- CONFLUENCE and SHAREPOINT were schema-level placeholders from the original +-- integrations migration: no provider implementation, no connect path, and no +-- way to produce a row (the registry builds only NOTION and GITHUB, and +-- getAuthUrl rejects anything else before a row could be written). Remove them. +-- Guarded defensively below in case of manual DB edits. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "integration_connection" + WHERE "provider" IN ('CONFLUENCE', 'SHAREPOINT') + ) THEN + RAISE EXCEPTION 'Cannot remove CONFLUENCE/SHAREPOINT enum values: 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"; diff --git a/packages/db/migrations/20260828131000_remove_confluence_sharepoint_source_types/migration.sql b/packages/db/migrations/20260828131000_remove_confluence_sharepoint_source_types/migration.sql new file mode 100644 index 0000000..947e189 --- /dev/null +++ b/packages/db/migrations/20260828131000_remove_confluence_sharepoint_source_types/migration.sql @@ -0,0 +1,23 @@ +-- CONFLUENCE_PAGE and SHAREPOINT_PAGE matched the CONFLUENCE/SHAREPOINT +-- provider placeholders removed in the previous migration. Notion is the only +-- provider that offers pages, so nothing could ever have created a row of +-- either type. Guarded defensively below in case of manual DB edits. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM "notebook_source" + WHERE "type" IN ('CONFLUENCE_PAGE', 'SHAREPOINT_PAGE') + ) THEN + RAISE EXCEPTION 'Cannot remove CONFLUENCE_PAGE/SHAREPOINT_PAGE enum values: rows still reference them'; + END IF; +END $$; + +ALTER TYPE "notebook_source_type" RENAME TO "notebook_source_type_old"; + +CREATE TYPE "notebook_source_type" AS ENUM ('PDF', 'TEXT', 'NOTION_PAGE'); + +ALTER TABLE "notebook_source" + ALTER COLUMN "type" TYPE "notebook_source_type" + USING ("type"::text::"notebook_source_type"); + +DROP TYPE "notebook_source_type_old"; 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/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/integration.prisma b/packages/db/schema/integration.prisma index 5bb6c32..44ac58e 100644 --- a/packages/db/schema/integration.prisma +++ b/packages/db/schema/integration.prisma @@ -1,7 +1,6 @@ enum IntegrationProvider { NOTION - CONFLUENCE - SHAREPOINT + GITHUB @@map("integration_provider") } @@ -11,62 +10,33 @@ 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) + /// AES-256-GCM encrypted access token. Null for providers whose credential is + /// an installation rather than a token — see `installationId`. + accessTokenEncrypted String? + /// 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 token it stands for is minted per call and never written + /// here. Exclusive with the token columns above. + installationId String? 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. + /// When this connection may next be polled, written at failure time from + /// `consecutiveFailures`. A column rather than a computed predicate because a + /// backed-off connection has to be excluded by the query that selects it. 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/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..876b5a9 100644 --- a/packages/db/schema/notebook.prisma +++ b/packages/db/schema/notebook.prisma @@ -2,8 +2,6 @@ enum NotebookSourceType { PDF TEXT NOTION_PAGE - CONFLUENCE_PAGE - SHAREPOINT_PAGE @@map("notebook_source_type") } @@ -35,9 +33,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 +57,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 +88,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 +120,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 +141,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 +159,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 diff --git a/packages/routes/src/index.ts b/packages/routes/src/index.ts index 7dbe897..7f8a765 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; @@ -191,10 +192,15 @@ export const routes = { }, api: { - cron: { - syncIntegrations: toAppUrl(`${BASE_API_PATH}/cron/sync-integrations`), - }, oembed: toAppUrl(`${BASE_API_PATH}/oembed`), + integrations: { + // A provider validates this byte for byte between the authorize call + // and the token exchange, so both sides must build it from here. + callback: (provider: string) => + toAppUrl( + `${BASE_API_PATH}/integrations/${provider.toLowerCase()}/callback`, + ), + }, }, }, @@ -214,6 +220,20 @@ export const routes = { issues: `${GITHUB_REPO_URL}/issues` as const, file: (path: string) => `${GITHUB_REPO_URL}/blob/main/${path}` as const, }, + + 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: { diff --git a/packages/schemas/src/schema/common/index.ts b/packages/schemas/src/schema/common/index.ts new file mode 100644 index 0000000..aa79048 --- /dev/null +++ b/packages/schemas/src/schema/common/index.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +// 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 }); diff --git a/packages/schemas/src/schema/organization/index.ts b/packages/schemas/src/schema/organization/index.ts index cb85654..83c34ce 100644 --- a/packages/schemas/src/schema/organization/index.ts +++ b/packages/schemas/src/schema/organization/index.ts @@ -1,5 +1,10 @@ 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() }); + export const createOrganizationSchema = z.object({ name: z.string().min(2).max(100), slug: z @@ -10,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({ @@ -25,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(), }); 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..5cbe37b 100644 --- a/turbo.json +++ b/turbo.json @@ -43,7 +43,10 @@ "NOTION_PERSONAL_ACCESS_TOKEN", "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",