Openstatus www.openstatus.dev

feat: vercel log drains ๐Ÿ“ (#272)

* feat: vercel integration
package

* wip:

* docs: readme

* wip: vercel integration

* chore: bump zod-bird

* wip: vercel integration

* wip:

* ๐Ÿš€

* ๐Ÿš€

* ๐Ÿ”ฅ

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
eeb14ec6 ed7b38e5

+2445 -114
+2 -5
.eslintrc.cjs
··· 6 6 parserOptions: { 7 7 ecmaVersion: "latest", 8 8 tsconfigRootDir: __dirname, 9 - project: [ 10 - "./apps/*/tsconfig.json", 11 - "./packages/*/tsconfig.json", 12 - ], 9 + project: ["./apps/*/tsconfig.json", "./packages/**/*/tsconfig.json"], 13 10 }, 14 11 settings: { 15 12 next: { ··· 18 15 }, 19 16 }; 20 17 21 - module.exports = config; 18 + module.exports = config;
+10
apps/web/.env.example
··· 52 52 NEXT_PUBLIC_URL= 53 53 54 54 NEXT_PUBLIC_SENTRY_DSN='' 55 + 56 + # Vercel Log Drains 57 + INTEGRATION_SECRET= 58 + LOG_DRAIN_SECRET= 59 + 60 + # Marketplace Integration 61 + VERCEL_CLIENT_ID= 62 + VERCEL_CLIENT_SECRET= 63 + VERCEL_REDIRECT_URI= 64 + AES_KEY=
+1
apps/web/package.json
··· 19 19 "@openstatus/plans": "workspace:*", 20 20 "@openstatus/tinybird": "workspace:*", 21 21 "@openstatus/upstash": "workspace:*", 22 + "@openstatus/vercel": "workspace:*", 22 23 "@radix-ui/react-accordion": "1.1.2", 23 24 "@radix-ui/react-alert-dialog": "1.0.4", 24 25 "@radix-ui/react-avatar": "^1.0.3",
+1
apps/web/src/app/api/integrations/vercel/callback/route.ts
··· 1 + export { GET } from "@openstatus/vercel/src/routes/callback";
+3
apps/web/src/app/api/integrations/vercel/configure/page.ts
··· 1 + import { Configure } from "@openstatus/vercel"; 2 + 3 + export default Configure;
+1
apps/web/src/app/api/integrations/vercel/route.ts
··· 1 + export { config, POST } from "@openstatus/vercel/src/routes/webhook";
+17
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/loading.tsx
··· 1 + import { Header } from "@/components/dashboard/header"; 2 + import { Skeleton } from "@/components/ui/skeleton"; 3 + 4 + export default function Loading() { 5 + return ( 6 + <div className="grid gap-6 md:grid-cols-1 md:gap-8"> 7 + <div className="col-span-full flex w-full justify-between"> 8 + <Header.Skeleton> 9 + <Skeleton className="h-9 w-20" /> 10 + </Header.Skeleton> 11 + </div> 12 + <Skeleton className="h-4 w-24" /> 13 + <Skeleton className="h-48 w-full" /> 14 + <Skeleton className="h-48 w-full" /> 15 + </div> 16 + ); 17 + }
+44
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/page.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + 5 + import { Container } from "@/components/dashboard/container"; 6 + import { Header } from "@/components/dashboard/header"; 7 + import { Badge } from "@/components/ui/badge"; 8 + import { Button } from "@/components/ui/button"; 9 + import { api } from "@/trpc/client"; 10 + 11 + export default async function IncidentPage({ 12 + params, 13 + }: { 14 + params: { workspaceSlug: string }; 15 + }) { 16 + const workspace = await api.workspace.getWorkspace.query({ 17 + slug: params.workspaceSlug, 18 + }); 19 + 20 + return ( 21 + <div className="grid gap-6 md:grid-cols-2 md:gap-8"> 22 + <Header title="Integrations" description="All our integrations"></Header> 23 + 24 + <Container 25 + title="Vercel" 26 + key={"vercel"} 27 + description="Connect your Vercel Project get insights." 28 + actions={[ 29 + <a 30 + href={ 31 + workspace?.id === 1 32 + ? "https://vercel.com/integrations/openstatus-staging/new" 33 + : "#" 34 + } 35 + target="_blank" 36 + key={"vercel"} 37 + > 38 + <Button>{workspace?.id === 1 ? "Configure" : "Coming soon"}</Button> 39 + </a>, 40 + ]} 41 + ></Container> 42 + </div> 43 + ); 44 + }
+122
apps/web/src/app/app/(dashboard)/[workspaceSlug]/integrations/vercel/configure/page.tsx
··· 1 + import { revalidatePath } from "next/cache"; 2 + import { cookies } from "next/headers"; 3 + import { redirect } from "next/navigation"; 4 + 5 + import { SubmitButton } from "@openstatus/vercel/src/components/submit-button"; 6 + import { 7 + createLogDrain, 8 + deleteLogDrain, 9 + getLogDrains, 10 + getProjects, 11 + } from "@openstatus/vercel/src/libs/client"; 12 + import { decrypt } from "@openstatus/vercel/src/libs/crypto"; 13 + 14 + import { api } from "@/trpc/server"; 15 + 16 + export default async function Configure({ 17 + params, 18 + }: { 19 + params: { workspaceSlug: string }; 20 + }) { 21 + const iv = cookies().get("iv")?.value; 22 + const encryptedToken = cookies().get("token")?.value; 23 + const teamId = cookies().get("teamId")?.value; 24 + 25 + if (!iv || !encryptedToken) { 26 + /** Redirect to access new token */ 27 + return redirect("/app"); 28 + } 29 + 30 + const token = decrypt( 31 + Buffer.from(iv || "", "base64url"), 32 + Buffer.from(encryptedToken || "", "base64url"), 33 + ).toString(); 34 + 35 + let logDrains = await getLogDrains(token, teamId); 36 + 37 + const projects = await getProjects(token, teamId); 38 + 39 + for (const project of projects.projects) { 40 + console.log({ project }); 41 + console.log("create integration"); 42 + console.log(project.id); 43 + api.integration.createIntegration.mutate({ 44 + workspaceSlug: params.workspaceSlug, 45 + input: { 46 + name: "Vercel", 47 + data: JSON.stringify(project), 48 + externalId: project.id, 49 + }, 50 + }); 51 + } 52 + // Create integration project if it doesn't exist 53 + 54 + if (logDrains.length === 0) { 55 + logDrains = [ 56 + await createLogDrain( 57 + token, 58 + // @ts-expect-error We need more data - but this is a demo 59 + { 60 + deliveryFormat: "json", 61 + name: "OpenStatus Log Drain", 62 + // TODO: update with correct url 63 + url: "https://6be9-2a0d-3344-2324-1e04-4dc7-d06a-a389-48c0.ngrok-free.app/api/integrations/vercel", 64 + sources: ["static", "lambda", "build", "edge", "external"], 65 + // headers: { "key": "value"} 66 + }, 67 + teamId, 68 + ), 69 + ]; 70 + } 71 + 72 + // TODO: automatically create log drain on installation 73 + // async function create(formData: FormData) { 74 + // "use server"; 75 + // await createLogDrain( 76 + // token, 77 + // // @ts-expect-error We need more data - but this is a demo 78 + // { 79 + // deliveryFormat: "json", 80 + // name: "OpenStatus Log Drain", 81 + // // TODO: update with correct url 82 + // url: "https://6be9-2a0d-3344-2324-1e04-4dc7-d06a-a389-48c0.ngrok-free.app/api/integrations/vercel", 83 + // sources: ["static", "lambda", "edge", "external"], 84 + // // headers: { "key": "value"} 85 + // }, 86 + // teamId, 87 + // ); 88 + // revalidatePath("/"); 89 + // } 90 + 91 + async function _delete(formData: FormData) { 92 + "use server"; 93 + const id = formData.get("id")?.toString(); 94 + console.log({ id }); 95 + if (id) { 96 + await deleteLogDrain(token, id, String(teamId)); 97 + revalidatePath("/"); 98 + } 99 + } 100 + 101 + return ( 102 + <div className="flex w-full flex-col items-center justify-center"> 103 + <div className="border-border m-3 grid w-full max-w-xl gap-3 rounded-lg border p-6 backdrop-blur-[2px]"> 104 + <h1 className="font-cal text-2xl">Configure Vercel Integration</h1> 105 + <ul> 106 + {logDrains.map((item) => ( 107 + <li 108 + key={item.id} 109 + className="flex items-center justify-between gap-2" 110 + > 111 + <p>{item.name}</p> 112 + <form action={_delete}> 113 + <input name="id" value={item.id} className="hidden" /> 114 + <SubmitButton>Remove integration</SubmitButton> 115 + </form> 116 + </li> 117 + ))} 118 + </ul> 119 + </div> 120 + </div> 121 + ); 122 + }
+2
apps/web/src/components/icons.tsx
··· 9 9 MessageCircle, 10 10 PanelTop, 11 11 Pencil, 12 + Plug, 12 13 Search, 13 14 SearchCheck, 14 15 Siren, ··· 42 43 trash: Trash, 43 44 twitter: TwitterIcon, 44 45 globe: Globe, 46 + plug: Plug, 45 47 discord: ({ ...props }: LucideProps) => ( 46 48 <svg viewBox="0 0 640 512" {...props}> 47 49 <path
+6
apps/web/src/config/pages.ts
··· 28 28 href: "/incidents", 29 29 icon: "siren", 30 30 }, 31 + { 32 + title: "Integrations", 33 + description: "Where you can see all the integrations.", 34 + href: "/integrations", 35 + icon: "plug", 36 + }, 31 37 // ... 32 38 ];
+1
apps/web/src/env.ts
··· 3 3 4 4 import "@openstatus/db/env.mjs"; 5 5 import "@openstatus/analytics/env"; 6 + import "@openstatus/vercel/env"; 6 7 7 8 export const env = createEnv({ 8 9 server: {
+37
apps/web/src/middleware.ts
··· 42 42 subdomain = candidate; 43 43 } 44 44 } 45 + if (host && host.includes("ngrok-free.app")) { 46 + return null; 47 + } 45 48 // In case the host is a custom domain 46 49 if ( 47 50 host && ··· 73 76 ], 74 77 ignoredRoutes: ["/api/og", "/discord", "github"], // FIXME: we should check the `publicRoutes` 75 78 beforeAuth: before, 79 + debug: false, 76 80 async afterAuth(auth, req) { 77 81 // handle users who aren't authenticated 78 82 if (!auth.userId && !auth.isPublicRoute) { ··· 112 116 return NextResponse.redirect(orgSelection); 113 117 } 114 118 } else { 119 + console.log("redirecting to onboarding"); 115 120 // return NextResponse.redirect(new URL("/app/onboarding", req.url)); 116 121 // probably redirect to onboarding 117 122 // or find a way to wait for the webhook 118 123 } 124 + console.log("redirecting to onboarding"); 125 + return; 126 + } 127 + if ( 128 + auth.userId && 129 + req.nextUrl.pathname === "/app/integrations/vercel/configure" 130 + ) { 131 + const userQuery = db 132 + .select() 133 + .from(user) 134 + .where(eq(user.tenantId, auth.userId)) 135 + .as("userQuery"); 136 + const result = await db 137 + .select() 138 + .from(usersToWorkspaces) 139 + .innerJoin(userQuery, eq(userQuery.id, usersToWorkspaces.userId)) 140 + .all(); 141 + if (result.length > 0) { 142 + const currentWorkspace = await db 143 + .select() 144 + .from(workspace) 145 + .where(eq(workspace.id, result[0].users_to_workspaces.workspaceId)) 146 + .get(); 147 + if (currentWorkspace) { 148 + const configure = new URL( 149 + `/app/${currentWorkspace.slug}/integrations/vercel/configure`, 150 + req.url, 151 + ); 152 + return NextResponse.redirect(configure); 153 + } 154 + } 119 155 } 120 156 }, 121 157 }); ··· 124 160 matcher: [ 125 161 "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", 126 162 "/", 163 + "/app/integrations/vercel/configure", 127 164 "/(api/webhook|api/trpc)(.*)", 128 165 "/(!api/checker/:path*|!api/og|!api/ping)", 129 166 ],
+5 -1
apps/web/tailwind.config.ts
··· 3 3 /** @type {import('tailwindcss').Config} */ 4 4 module.exports = { 5 5 darkMode: ["class"], 6 - content: ["src/**/*.{ts,tsx}"], 6 + content: [ 7 + "src/**/*.{ts,tsx}", 8 + // for vercel integration 9 + "../../packages/integrations/**/*.{ts,tsx}", 10 + ], 7 11 theme: { 8 12 container: { 9 13 center: true,
+2
packages/api/src/edge.ts
··· 1 1 import { domainRouter } from "./router/domain"; 2 2 import { incidentRouter } from "./router/incident"; 3 + import { integrationRouter } from "./router/integration"; 3 4 import { monitorRouter } from "./router/monitor"; 4 5 import { pageRouter } from "./router/page"; 5 6 import { workspaceRouter } from "./router/workspace"; ··· 12 13 page: pageRouter, 13 14 incident: incidentRouter, 14 15 domain: domainRouter, 16 + integration: integrationRouter, 15 17 });
+44
packages/api/src/router/integration.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { and, eq, inArray } from "@openstatus/db"; 4 + import { 5 + insertIntegrationSchema, 6 + integration, 7 + } from "@openstatus/db/src/schema"; 8 + 9 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 10 + import { hasUserAccessToWorkspace } from "./utils"; 11 + 12 + export const integrationRouter = createTRPCRouter({ 13 + createIntegration: protectedProcedure 14 + .input( 15 + z.object({ workspaceSlug: z.string(), input: insertIntegrationSchema }), 16 + ) 17 + .mutation(async (opts) => { 18 + const result = await hasUserAccessToWorkspace({ 19 + workspaceSlug: opts.input.workspaceSlug, 20 + ctx: opts.ctx, 21 + }); 22 + if (!result) return; 23 + 24 + const exists = await opts.ctx.db 25 + .select() 26 + .from(integration) 27 + .where( 28 + and( 29 + eq(integration.workspaceId, result.workspace.id), 30 + eq(integration.externalId, opts.input.input.externalId), 31 + ), 32 + ) 33 + .get(); 34 + 35 + if (exists) { 36 + return; 37 + } 38 + await opts.ctx.db 39 + .insert(integration) 40 + .values({ ...opts.input.input, workspaceId: result.workspace.id }) 41 + .returning() 42 + .get(); 43 + }), 44 + });
+11
packages/db/drizzle/0007_complex_frog_thor.sql
··· 1 + CREATE TABLE `integration` ( 2 + `id` integer PRIMARY KEY NOT NULL, 3 + `name` text(256) NOT NULL, 4 + `workspace_id` integer, 5 + `credential` text, 6 + `external_id` text NOT NULL, 7 + `created_at` integer DEFAULT (strftime('%s', 'now')), 8 + `updated_at` integer DEFAULT (strftime('%s', 'now')), 9 + `data` text NOT NULL, 10 + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action 11 + );
+29 -86
packages/db/drizzle/meta/0006_snapshot.json
··· 58 58 "name": "incident_workspace_id_workspace_id_fk", 59 59 "tableFrom": "incident", 60 60 "tableTo": "workspace", 61 - "columnsFrom": [ 62 - "workspace_id" 63 - ], 64 - "columnsTo": [ 65 - "id" 66 - ], 61 + "columnsFrom": ["workspace_id"], 62 + "columnsTo": ["id"], 67 63 "onDelete": "no action", 68 64 "onUpdate": "no action" 69 65 } ··· 132 128 "name": "incident_update_incident_id_incident_id_fk", 133 129 "tableFrom": "incident_update", 134 130 "tableTo": "incident", 135 - "columnsFrom": [ 136 - "incident_id" 137 - ], 138 - "columnsTo": [ 139 - "id" 140 - ], 131 + "columnsFrom": ["incident_id"], 132 + "columnsTo": ["id"], 141 133 "onDelete": "cascade", 142 134 "onUpdate": "no action" 143 135 } ··· 169 161 "name": "incidents_to_monitors_monitor_id_monitor_id_fk", 170 162 "tableFrom": "incidents_to_monitors", 171 163 "tableTo": "monitor", 172 - "columnsFrom": [ 173 - "monitor_id" 174 - ], 175 - "columnsTo": [ 176 - "id" 177 - ], 164 + "columnsFrom": ["monitor_id"], 165 + "columnsTo": ["id"], 178 166 "onDelete": "cascade", 179 167 "onUpdate": "no action" 180 168 }, ··· 182 170 "name": "incidents_to_monitors_incident_id_incident_id_fk", 183 171 "tableFrom": "incidents_to_monitors", 184 172 "tableTo": "incident", 185 - "columnsFrom": [ 186 - "incident_id" 187 - ], 188 - "columnsTo": [ 189 - "id" 190 - ], 173 + "columnsFrom": ["incident_id"], 174 + "columnsTo": ["id"], 191 175 "onDelete": "cascade", 192 176 "onUpdate": "no action" 193 177 } 194 178 }, 195 179 "compositePrimaryKeys": { 196 180 "incidents_to_monitors_monitor_id_incident_id_pk": { 197 - "columns": [ 198 - "incident_id", 199 - "monitor_id" 200 - ] 181 + "columns": ["incident_id", "monitor_id"] 201 182 } 202 183 }, 203 184 "uniqueConstraints": {} ··· 283 264 "indexes": { 284 265 "page_slug_unique": { 285 266 "name": "page_slug_unique", 286 - "columns": [ 287 - "slug" 288 - ], 267 + "columns": ["slug"], 289 268 "isUnique": true 290 269 } 291 270 }, ··· 294 273 "name": "page_workspace_id_workspace_id_fk", 295 274 "tableFrom": "page", 296 275 "tableTo": "workspace", 297 - "columnsFrom": [ 298 - "workspace_id" 299 - ], 300 - "columnsTo": [ 301 - "id" 302 - ], 276 + "columnsFrom": ["workspace_id"], 277 + "columnsTo": ["id"], 303 278 "onDelete": "cascade", 304 279 "onUpdate": "no action" 305 280 } ··· 398 373 }, 399 374 "method": { 400 375 "name": "method", 401 - "type": "text(5)", 376 + "type": "text(2)", 402 377 "primaryKey": false, 403 378 "notNull": false, 404 379 "autoincrement": false, ··· 434 409 "name": "monitor_workspace_id_workspace_id_fk", 435 410 "tableFrom": "monitor", 436 411 "tableTo": "workspace", 437 - "columnsFrom": [ 438 - "workspace_id" 439 - ], 440 - "columnsTo": [ 441 - "id" 442 - ], 412 + "columnsFrom": ["workspace_id"], 413 + "columnsTo": ["id"], 443 414 "onDelete": "no action", 444 415 "onUpdate": "no action" 445 416 } ··· 471 442 "name": "monitors_to_pages_monitor_id_monitor_id_fk", 472 443 "tableFrom": "monitors_to_pages", 473 444 "tableTo": "monitor", 474 - "columnsFrom": [ 475 - "monitor_id" 476 - ], 477 - "columnsTo": [ 478 - "id" 479 - ], 445 + "columnsFrom": ["monitor_id"], 446 + "columnsTo": ["id"], 480 447 "onDelete": "cascade", 481 448 "onUpdate": "no action" 482 449 }, ··· 484 451 "name": "monitors_to_pages_page_id_page_id_fk", 485 452 "tableFrom": "monitors_to_pages", 486 453 "tableTo": "page", 487 - "columnsFrom": [ 488 - "page_id" 489 - ], 490 - "columnsTo": [ 491 - "id" 492 - ], 454 + "columnsFrom": ["page_id"], 455 + "columnsTo": ["id"], 493 456 "onDelete": "cascade", 494 457 "onUpdate": "no action" 495 458 } 496 459 }, 497 460 "compositePrimaryKeys": { 498 461 "monitors_to_pages_monitor_id_page_id_pk": { 499 - "columns": [ 500 - "monitor_id", 501 - "page_id" 502 - ] 462 + "columns": ["monitor_id", "page_id"] 503 463 } 504 464 }, 505 465 "uniqueConstraints": {} ··· 573 533 "indexes": { 574 534 "user_tenant_id_unique": { 575 535 "name": "user_tenant_id_unique", 576 - "columns": [ 577 - "tenant_id" 578 - ], 536 + "columns": ["tenant_id"], 579 537 "isUnique": true 580 538 } 581 539 }, ··· 607 565 "name": "users_to_workspaces_user_id_user_id_fk", 608 566 "tableFrom": "users_to_workspaces", 609 567 "tableTo": "user", 610 - "columnsFrom": [ 611 - "user_id" 612 - ], 613 - "columnsTo": [ 614 - "id" 615 - ], 568 + "columnsFrom": ["user_id"], 569 + "columnsTo": ["id"], 616 570 "onDelete": "no action", 617 571 "onUpdate": "no action" 618 572 }, ··· 620 574 "name": "users_to_workspaces_workspace_id_workspace_id_fk", 621 575 "tableFrom": "users_to_workspaces", 622 576 "tableTo": "workspace", 623 - "columnsFrom": [ 624 - "workspace_id" 625 - ], 626 - "columnsTo": [ 627 - "id" 628 - ], 577 + "columnsFrom": ["workspace_id"], 578 + "columnsTo": ["id"], 629 579 "onDelete": "no action", 630 580 "onUpdate": "no action" 631 581 } 632 582 }, 633 583 "compositePrimaryKeys": { 634 584 "users_to_workspaces_user_id_workspace_id_pk": { 635 - "columns": [ 636 - "user_id", 637 - "workspace_id" 638 - ] 585 + "columns": ["user_id", "workspace_id"] 639 586 } 640 587 }, 641 588 "uniqueConstraints": {} ··· 719 666 "indexes": { 720 667 "workspace_slug_unique": { 721 668 "name": "workspace_slug_unique", 722 - "columns": [ 723 - "slug" 724 - ], 669 + "columns": ["slug"], 725 670 "isUnique": true 726 671 }, 727 672 "workspace_stripe_id_unique": { 728 673 "name": "workspace_stripe_id_unique", 729 - "columns": [ 730 - "stripe_id" 731 - ], 674 + "columns": ["stripe_id"], 732 675 "isUnique": true 733 676 } 734 677 }, ··· 743 686 "tables": {}, 744 687 "columns": {} 745 688 } 746 - } 689 + }
+827
packages/db/drizzle/meta/0007_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "23bfc90d-3bb1-4077-81d8-8f2e874f9c62", 5 + "prevId": "fee77e21-b52b-49c0-b109-5ccc37821935", 6 + "tables": { 7 + "incident": { 8 + "name": "incident", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status": { 18 + "name": "status", 19 + "type": "text(4)", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "title": { 25 + "name": "title", 26 + "type": "text(256)", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "workspace_id": { 32 + "name": "workspace_id", 33 + "type": "integer", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "created_at": { 39 + "name": "created_at", 40 + "type": "integer", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false, 44 + "default": "(strftime('%s', 'now'))" 45 + }, 46 + "updated_at": { 47 + "name": "updated_at", 48 + "type": "integer", 49 + "primaryKey": false, 50 + "notNull": false, 51 + "autoincrement": false, 52 + "default": "(strftime('%s', 'now'))" 53 + } 54 + }, 55 + "indexes": {}, 56 + "foreignKeys": { 57 + "incident_workspace_id_workspace_id_fk": { 58 + "name": "incident_workspace_id_workspace_id_fk", 59 + "tableFrom": "incident", 60 + "tableTo": "workspace", 61 + "columnsFrom": [ 62 + "workspace_id" 63 + ], 64 + "columnsTo": [ 65 + "id" 66 + ], 67 + "onDelete": "no action", 68 + "onUpdate": "no action" 69 + } 70 + }, 71 + "compositePrimaryKeys": {}, 72 + "uniqueConstraints": {} 73 + }, 74 + "incident_update": { 75 + "name": "incident_update", 76 + "columns": { 77 + "id": { 78 + "name": "id", 79 + "type": "integer", 80 + "primaryKey": true, 81 + "notNull": true, 82 + "autoincrement": false 83 + }, 84 + "status": { 85 + "name": "status", 86 + "type": "text(4)", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false 90 + }, 91 + "date": { 92 + "name": "date", 93 + "type": "integer", 94 + "primaryKey": false, 95 + "notNull": true, 96 + "autoincrement": false 97 + }, 98 + "message": { 99 + "name": "message", 100 + "type": "text", 101 + "primaryKey": false, 102 + "notNull": true, 103 + "autoincrement": false 104 + }, 105 + "incident_id": { 106 + "name": "incident_id", 107 + "type": "integer", 108 + "primaryKey": false, 109 + "notNull": true, 110 + "autoincrement": false 111 + }, 112 + "created_at": { 113 + "name": "created_at", 114 + "type": "integer", 115 + "primaryKey": false, 116 + "notNull": false, 117 + "autoincrement": false, 118 + "default": "(strftime('%s', 'now'))" 119 + }, 120 + "updated_at": { 121 + "name": "updated_at", 122 + "type": "integer", 123 + "primaryKey": false, 124 + "notNull": false, 125 + "autoincrement": false, 126 + "default": "(strftime('%s', 'now'))" 127 + } 128 + }, 129 + "indexes": {}, 130 + "foreignKeys": { 131 + "incident_update_incident_id_incident_id_fk": { 132 + "name": "incident_update_incident_id_incident_id_fk", 133 + "tableFrom": "incident_update", 134 + "tableTo": "incident", 135 + "columnsFrom": [ 136 + "incident_id" 137 + ], 138 + "columnsTo": [ 139 + "id" 140 + ], 141 + "onDelete": "cascade", 142 + "onUpdate": "no action" 143 + } 144 + }, 145 + "compositePrimaryKeys": {}, 146 + "uniqueConstraints": {} 147 + }, 148 + "incidents_to_monitors": { 149 + "name": "incidents_to_monitors", 150 + "columns": { 151 + "monitor_id": { 152 + "name": "monitor_id", 153 + "type": "integer", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "incident_id": { 159 + "name": "incident_id", 160 + "type": "integer", 161 + "primaryKey": false, 162 + "notNull": true, 163 + "autoincrement": false 164 + } 165 + }, 166 + "indexes": {}, 167 + "foreignKeys": { 168 + "incidents_to_monitors_monitor_id_monitor_id_fk": { 169 + "name": "incidents_to_monitors_monitor_id_monitor_id_fk", 170 + "tableFrom": "incidents_to_monitors", 171 + "tableTo": "monitor", 172 + "columnsFrom": [ 173 + "monitor_id" 174 + ], 175 + "columnsTo": [ 176 + "id" 177 + ], 178 + "onDelete": "cascade", 179 + "onUpdate": "no action" 180 + }, 181 + "incidents_to_monitors_incident_id_incident_id_fk": { 182 + "name": "incidents_to_monitors_incident_id_incident_id_fk", 183 + "tableFrom": "incidents_to_monitors", 184 + "tableTo": "incident", 185 + "columnsFrom": [ 186 + "incident_id" 187 + ], 188 + "columnsTo": [ 189 + "id" 190 + ], 191 + "onDelete": "cascade", 192 + "onUpdate": "no action" 193 + } 194 + }, 195 + "compositePrimaryKeys": { 196 + "incidents_to_monitors_monitor_id_incident_id_pk": { 197 + "columns": [ 198 + "incident_id", 199 + "monitor_id" 200 + ] 201 + } 202 + }, 203 + "uniqueConstraints": {} 204 + }, 205 + "integration": { 206 + "name": "integration", 207 + "columns": { 208 + "id": { 209 + "name": "id", 210 + "type": "integer", 211 + "primaryKey": true, 212 + "notNull": true, 213 + "autoincrement": false 214 + }, 215 + "name": { 216 + "name": "name", 217 + "type": "text(256)", 218 + "primaryKey": false, 219 + "notNull": true, 220 + "autoincrement": false 221 + }, 222 + "workspace_id": { 223 + "name": "workspace_id", 224 + "type": "integer", 225 + "primaryKey": false, 226 + "notNull": false, 227 + "autoincrement": false 228 + }, 229 + "credential": { 230 + "name": "credential", 231 + "type": "text", 232 + "primaryKey": false, 233 + "notNull": false, 234 + "autoincrement": false 235 + }, 236 + "external_id": { 237 + "name": "external_id", 238 + "type": "text", 239 + "primaryKey": false, 240 + "notNull": true, 241 + "autoincrement": false 242 + }, 243 + "created_at": { 244 + "name": "created_at", 245 + "type": "integer", 246 + "primaryKey": false, 247 + "notNull": false, 248 + "autoincrement": false, 249 + "default": "(strftime('%s', 'now'))" 250 + }, 251 + "updated_at": { 252 + "name": "updated_at", 253 + "type": "integer", 254 + "primaryKey": false, 255 + "notNull": false, 256 + "autoincrement": false, 257 + "default": "(strftime('%s', 'now'))" 258 + }, 259 + "data": { 260 + "name": "data", 261 + "type": "text", 262 + "primaryKey": false, 263 + "notNull": true, 264 + "autoincrement": false 265 + } 266 + }, 267 + "indexes": {}, 268 + "foreignKeys": { 269 + "integration_workspace_id_workspace_id_fk": { 270 + "name": "integration_workspace_id_workspace_id_fk", 271 + "tableFrom": "integration", 272 + "tableTo": "workspace", 273 + "columnsFrom": [ 274 + "workspace_id" 275 + ], 276 + "columnsTo": [ 277 + "id" 278 + ], 279 + "onDelete": "no action", 280 + "onUpdate": "no action" 281 + } 282 + }, 283 + "compositePrimaryKeys": {}, 284 + "uniqueConstraints": {} 285 + }, 286 + "page": { 287 + "name": "page", 288 + "columns": { 289 + "id": { 290 + "name": "id", 291 + "type": "integer", 292 + "primaryKey": true, 293 + "notNull": true, 294 + "autoincrement": false 295 + }, 296 + "workspace_id": { 297 + "name": "workspace_id", 298 + "type": "integer", 299 + "primaryKey": false, 300 + "notNull": true, 301 + "autoincrement": false 302 + }, 303 + "title": { 304 + "name": "title", 305 + "type": "text", 306 + "primaryKey": false, 307 + "notNull": true, 308 + "autoincrement": false 309 + }, 310 + "description": { 311 + "name": "description", 312 + "type": "text", 313 + "primaryKey": false, 314 + "notNull": true, 315 + "autoincrement": false 316 + }, 317 + "icon": { 318 + "name": "icon", 319 + "type": "text(256)", 320 + "primaryKey": false, 321 + "notNull": false, 322 + "autoincrement": false, 323 + "default": "''" 324 + }, 325 + "slug": { 326 + "name": "slug", 327 + "type": "text(256)", 328 + "primaryKey": false, 329 + "notNull": true, 330 + "autoincrement": false 331 + }, 332 + "custom_domain": { 333 + "name": "custom_domain", 334 + "type": "text(256)", 335 + "primaryKey": false, 336 + "notNull": true, 337 + "autoincrement": false 338 + }, 339 + "published": { 340 + "name": "published", 341 + "type": "integer", 342 + "primaryKey": false, 343 + "notNull": false, 344 + "autoincrement": false, 345 + "default": false 346 + }, 347 + "created_at": { 348 + "name": "created_at", 349 + "type": "integer", 350 + "primaryKey": false, 351 + "notNull": false, 352 + "autoincrement": false, 353 + "default": "(strftime('%s', 'now'))" 354 + }, 355 + "updated_at": { 356 + "name": "updated_at", 357 + "type": "integer", 358 + "primaryKey": false, 359 + "notNull": false, 360 + "autoincrement": false, 361 + "default": "(strftime('%s', 'now'))" 362 + } 363 + }, 364 + "indexes": { 365 + "page_slug_unique": { 366 + "name": "page_slug_unique", 367 + "columns": [ 368 + "slug" 369 + ], 370 + "isUnique": true 371 + } 372 + }, 373 + "foreignKeys": { 374 + "page_workspace_id_workspace_id_fk": { 375 + "name": "page_workspace_id_workspace_id_fk", 376 + "tableFrom": "page", 377 + "tableTo": "workspace", 378 + "columnsFrom": [ 379 + "workspace_id" 380 + ], 381 + "columnsTo": [ 382 + "id" 383 + ], 384 + "onDelete": "cascade", 385 + "onUpdate": "no action" 386 + } 387 + }, 388 + "compositePrimaryKeys": {}, 389 + "uniqueConstraints": {} 390 + }, 391 + "monitor": { 392 + "name": "monitor", 393 + "columns": { 394 + "id": { 395 + "name": "id", 396 + "type": "integer", 397 + "primaryKey": true, 398 + "notNull": true, 399 + "autoincrement": false 400 + }, 401 + "job_type": { 402 + "name": "job_type", 403 + "type": "text(3)", 404 + "primaryKey": false, 405 + "notNull": true, 406 + "autoincrement": false, 407 + "default": "'other'" 408 + }, 409 + "periodicity": { 410 + "name": "periodicity", 411 + "type": "text(6)", 412 + "primaryKey": false, 413 + "notNull": true, 414 + "autoincrement": false, 415 + "default": "'other'" 416 + }, 417 + "status": { 418 + "name": "status", 419 + "type": "text(2)", 420 + "primaryKey": false, 421 + "notNull": true, 422 + "autoincrement": false, 423 + "default": "'inactive'" 424 + }, 425 + "active": { 426 + "name": "active", 427 + "type": "integer", 428 + "primaryKey": false, 429 + "notNull": false, 430 + "autoincrement": false, 431 + "default": false 432 + }, 433 + "regions": { 434 + "name": "regions", 435 + "type": "text", 436 + "primaryKey": false, 437 + "notNull": true, 438 + "autoincrement": false, 439 + "default": "''" 440 + }, 441 + "url": { 442 + "name": "url", 443 + "type": "text(512)", 444 + "primaryKey": false, 445 + "notNull": true, 446 + "autoincrement": false 447 + }, 448 + "name": { 449 + "name": "name", 450 + "type": "text(256)", 451 + "primaryKey": false, 452 + "notNull": true, 453 + "autoincrement": false, 454 + "default": "''" 455 + }, 456 + "description": { 457 + "name": "description", 458 + "type": "text", 459 + "primaryKey": false, 460 + "notNull": true, 461 + "autoincrement": false, 462 + "default": "''" 463 + }, 464 + "headers": { 465 + "name": "headers", 466 + "type": "text", 467 + "primaryKey": false, 468 + "notNull": false, 469 + "autoincrement": false, 470 + "default": "''" 471 + }, 472 + "body": { 473 + "name": "body", 474 + "type": "text", 475 + "primaryKey": false, 476 + "notNull": false, 477 + "autoincrement": false, 478 + "default": "''" 479 + }, 480 + "method": { 481 + "name": "method", 482 + "type": "text(2)", 483 + "primaryKey": false, 484 + "notNull": false, 485 + "autoincrement": false, 486 + "default": "'GET'" 487 + }, 488 + "workspace_id": { 489 + "name": "workspace_id", 490 + "type": "integer", 491 + "primaryKey": false, 492 + "notNull": false, 493 + "autoincrement": false 494 + }, 495 + "created_at": { 496 + "name": "created_at", 497 + "type": "integer", 498 + "primaryKey": false, 499 + "notNull": false, 500 + "autoincrement": false, 501 + "default": "(strftime('%s', 'now'))" 502 + }, 503 + "updated_at": { 504 + "name": "updated_at", 505 + "type": "integer", 506 + "primaryKey": false, 507 + "notNull": false, 508 + "autoincrement": false, 509 + "default": "(strftime('%s', 'now'))" 510 + } 511 + }, 512 + "indexes": {}, 513 + "foreignKeys": { 514 + "monitor_workspace_id_workspace_id_fk": { 515 + "name": "monitor_workspace_id_workspace_id_fk", 516 + "tableFrom": "monitor", 517 + "tableTo": "workspace", 518 + "columnsFrom": [ 519 + "workspace_id" 520 + ], 521 + "columnsTo": [ 522 + "id" 523 + ], 524 + "onDelete": "no action", 525 + "onUpdate": "no action" 526 + } 527 + }, 528 + "compositePrimaryKeys": {}, 529 + "uniqueConstraints": {} 530 + }, 531 + "monitors_to_pages": { 532 + "name": "monitors_to_pages", 533 + "columns": { 534 + "monitor_id": { 535 + "name": "monitor_id", 536 + "type": "integer", 537 + "primaryKey": false, 538 + "notNull": true, 539 + "autoincrement": false 540 + }, 541 + "page_id": { 542 + "name": "page_id", 543 + "type": "integer", 544 + "primaryKey": false, 545 + "notNull": true, 546 + "autoincrement": false 547 + } 548 + }, 549 + "indexes": {}, 550 + "foreignKeys": { 551 + "monitors_to_pages_monitor_id_monitor_id_fk": { 552 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 553 + "tableFrom": "monitors_to_pages", 554 + "tableTo": "monitor", 555 + "columnsFrom": [ 556 + "monitor_id" 557 + ], 558 + "columnsTo": [ 559 + "id" 560 + ], 561 + "onDelete": "cascade", 562 + "onUpdate": "no action" 563 + }, 564 + "monitors_to_pages_page_id_page_id_fk": { 565 + "name": "monitors_to_pages_page_id_page_id_fk", 566 + "tableFrom": "monitors_to_pages", 567 + "tableTo": "page", 568 + "columnsFrom": [ 569 + "page_id" 570 + ], 571 + "columnsTo": [ 572 + "id" 573 + ], 574 + "onDelete": "cascade", 575 + "onUpdate": "no action" 576 + } 577 + }, 578 + "compositePrimaryKeys": { 579 + "monitors_to_pages_monitor_id_page_id_pk": { 580 + "columns": [ 581 + "monitor_id", 582 + "page_id" 583 + ] 584 + } 585 + }, 586 + "uniqueConstraints": {} 587 + }, 588 + "user": { 589 + "name": "user", 590 + "columns": { 591 + "id": { 592 + "name": "id", 593 + "type": "integer", 594 + "primaryKey": true, 595 + "notNull": true, 596 + "autoincrement": false 597 + }, 598 + "tenant_id": { 599 + "name": "tenant_id", 600 + "type": "text(256)", 601 + "primaryKey": false, 602 + "notNull": false, 603 + "autoincrement": false 604 + }, 605 + "first_name": { 606 + "name": "first_name", 607 + "type": "text", 608 + "primaryKey": false, 609 + "notNull": false, 610 + "autoincrement": false, 611 + "default": "''" 612 + }, 613 + "last_name": { 614 + "name": "last_name", 615 + "type": "text", 616 + "primaryKey": false, 617 + "notNull": false, 618 + "autoincrement": false, 619 + "default": "''" 620 + }, 621 + "email": { 622 + "name": "email", 623 + "type": "text", 624 + "primaryKey": false, 625 + "notNull": false, 626 + "autoincrement": false, 627 + "default": "''" 628 + }, 629 + "photo_url": { 630 + "name": "photo_url", 631 + "type": "text", 632 + "primaryKey": false, 633 + "notNull": false, 634 + "autoincrement": false, 635 + "default": "''" 636 + }, 637 + "created_at": { 638 + "name": "created_at", 639 + "type": "integer", 640 + "primaryKey": false, 641 + "notNull": false, 642 + "autoincrement": false, 643 + "default": "(strftime('%s', 'now'))" 644 + }, 645 + "updated_at": { 646 + "name": "updated_at", 647 + "type": "integer", 648 + "primaryKey": false, 649 + "notNull": false, 650 + "autoincrement": false, 651 + "default": "(strftime('%s', 'now'))" 652 + } 653 + }, 654 + "indexes": { 655 + "user_tenant_id_unique": { 656 + "name": "user_tenant_id_unique", 657 + "columns": [ 658 + "tenant_id" 659 + ], 660 + "isUnique": true 661 + } 662 + }, 663 + "foreignKeys": {}, 664 + "compositePrimaryKeys": {}, 665 + "uniqueConstraints": {} 666 + }, 667 + "users_to_workspaces": { 668 + "name": "users_to_workspaces", 669 + "columns": { 670 + "user_id": { 671 + "name": "user_id", 672 + "type": "integer", 673 + "primaryKey": false, 674 + "notNull": true, 675 + "autoincrement": false 676 + }, 677 + "workspace_id": { 678 + "name": "workspace_id", 679 + "type": "integer", 680 + "primaryKey": false, 681 + "notNull": true, 682 + "autoincrement": false 683 + } 684 + }, 685 + "indexes": {}, 686 + "foreignKeys": { 687 + "users_to_workspaces_user_id_user_id_fk": { 688 + "name": "users_to_workspaces_user_id_user_id_fk", 689 + "tableFrom": "users_to_workspaces", 690 + "tableTo": "user", 691 + "columnsFrom": [ 692 + "user_id" 693 + ], 694 + "columnsTo": [ 695 + "id" 696 + ], 697 + "onDelete": "no action", 698 + "onUpdate": "no action" 699 + }, 700 + "users_to_workspaces_workspace_id_workspace_id_fk": { 701 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 702 + "tableFrom": "users_to_workspaces", 703 + "tableTo": "workspace", 704 + "columnsFrom": [ 705 + "workspace_id" 706 + ], 707 + "columnsTo": [ 708 + "id" 709 + ], 710 + "onDelete": "no action", 711 + "onUpdate": "no action" 712 + } 713 + }, 714 + "compositePrimaryKeys": { 715 + "users_to_workspaces_user_id_workspace_id_pk": { 716 + "columns": [ 717 + "user_id", 718 + "workspace_id" 719 + ] 720 + } 721 + }, 722 + "uniqueConstraints": {} 723 + }, 724 + "workspace": { 725 + "name": "workspace", 726 + "columns": { 727 + "id": { 728 + "name": "id", 729 + "type": "integer", 730 + "primaryKey": true, 731 + "notNull": true, 732 + "autoincrement": false 733 + }, 734 + "slug": { 735 + "name": "slug", 736 + "type": "text", 737 + "primaryKey": false, 738 + "notNull": true, 739 + "autoincrement": false 740 + }, 741 + "name": { 742 + "name": "name", 743 + "type": "text", 744 + "primaryKey": false, 745 + "notNull": false, 746 + "autoincrement": false 747 + }, 748 + "stripe_id": { 749 + "name": "stripe_id", 750 + "type": "text(256)", 751 + "primaryKey": false, 752 + "notNull": false, 753 + "autoincrement": false 754 + }, 755 + "subscription_id": { 756 + "name": "subscription_id", 757 + "type": "text", 758 + "primaryKey": false, 759 + "notNull": false, 760 + "autoincrement": false 761 + }, 762 + "plan": { 763 + "name": "plan", 764 + "type": "text(2)", 765 + "primaryKey": false, 766 + "notNull": false, 767 + "autoincrement": false 768 + }, 769 + "ends_at": { 770 + "name": "ends_at", 771 + "type": "integer", 772 + "primaryKey": false, 773 + "notNull": false, 774 + "autoincrement": false 775 + }, 776 + "paid_until": { 777 + "name": "paid_until", 778 + "type": "integer", 779 + "primaryKey": false, 780 + "notNull": false, 781 + "autoincrement": false 782 + }, 783 + "created_at": { 784 + "name": "created_at", 785 + "type": "integer", 786 + "primaryKey": false, 787 + "notNull": false, 788 + "autoincrement": false, 789 + "default": "(strftime('%s', 'now'))" 790 + }, 791 + "updated_at": { 792 + "name": "updated_at", 793 + "type": "integer", 794 + "primaryKey": false, 795 + "notNull": false, 796 + "autoincrement": false, 797 + "default": "(strftime('%s', 'now'))" 798 + } 799 + }, 800 + "indexes": { 801 + "workspace_slug_unique": { 802 + "name": "workspace_slug_unique", 803 + "columns": [ 804 + "slug" 805 + ], 806 + "isUnique": true 807 + }, 808 + "workspace_stripe_id_unique": { 809 + "name": "workspace_stripe_id_unique", 810 + "columns": [ 811 + "stripe_id" 812 + ], 813 + "isUnique": true 814 + } 815 + }, 816 + "foreignKeys": {}, 817 + "compositePrimaryKeys": {}, 818 + "uniqueConstraints": {} 819 + } 820 + }, 821 + "enums": {}, 822 + "_meta": { 823 + "schemas": {}, 824 + "tables": {}, 825 + "columns": {} 826 + } 827 + }
+7
packages/db/drizzle/meta/_journal.json
··· 50 50 "when": 1692646649111, 51 51 "tag": "0006_tired_anita_blake", 52 52 "breakpoints": true 53 + }, 54 + { 55 + "idx": 7, 56 + "version": "5", 57 + "when": 1694362217174, 58 + "tag": "0007_complex_frog_thor", 59 + "breakpoints": true 53 60 } 54 61 ] 55 62 }
+1
packages/db/src/schema/index.ts
··· 1 1 export * from "./incident"; 2 + export * from "./integration"; 2 3 export * from "./page"; 3 4 export * from "./monitor"; 4 5 export * from "./user";
+28
packages/db/src/schema/integration.ts
··· 1 + import { sql } from "drizzle-orm"; 2 + import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 4 + 5 + import { workspace } from "./workspace"; 6 + 7 + export const integration = sqliteTable("integration", { 8 + id: integer("id").primaryKey(), 9 + name: text("name", { length: 256 }).notNull(), // Should be vercel or other 10 + 11 + workspaceId: integer("workspace_id").references(() => workspace.id), 12 + 13 + // Not used yet but we might need to get store something for the integration webhook url and or secret 14 + credential: text("credential", { mode: "json" }), 15 + 16 + externalId: text("external_id").notNull(), // the id of the integration in the external service 17 + 18 + createdAt: integer("created_at", { mode: "timestamp" }).default( 19 + sql`(strftime('%s', 'now'))`, 20 + ), 21 + updatedAt: integer("updated_at", { mode: "timestamp" }).default( 22 + sql`(strftime('%s', 'now'))`, 23 + ), 24 + 25 + data: text("data", { mode: "json" }).notNull(), 26 + }); 27 + 28 + export const insertIntegrationSchema = createInsertSchema(integration);
+12
packages/integrations/vercel/.env.example
··· 1 + # Log Drains 2 + # add from the first form - 'x-vercel-verify' 3 + INTEGRATION_SECRET= 4 + # after verify, in the modal box - 'x-vercel-signature' 5 + LOG_DRAIN_SECRET= 6 + 7 + 8 + # Marketplace Integration 9 + VERCEL_CLIENT_ID= 10 + VERCEL_CLIENT_SECRET= 11 + VERCEL_REDIRECT_URI= 12 + AES_KEY=
+60
packages/integrations/vercel/README.md
··· 1 + # Log drain integration for Vercel 2 + 3 + Seamless installement into Vercel's log drains for your project. 4 + 5 + > Inspired by 6 + > [@valeriangalliat/vercel-custom-log-drain](https://github.com/valeriangalliat/vercel-custom-log-drain). 7 + 8 + ## Description 9 + 10 + When integrating the Vercel App into your project. Every request to 11 + [openstatus.dev](https://openstatus.dev) will include the key inside of the 12 + `Headers`. 13 + 14 + Once we have the `access_token` token we will be able to `createLogDrain` as 15 + authentificated user. The token is only expires after 30 minutes and is required 16 + for the user to access the integration within vercel. 17 + 18 + When creating the log drain, we should include a custom header to it, with an 19 + `OpenStatus-Vercel-TOKEN: "os_xxx-yyy"` token from Unkey. That way, it is easy 20 + to revoke tokens. We can test without but should include it. Check with Andreas 21 + how a good use case an look like. 22 + 23 + The new created log drain will point towards our `/api/integrations/vercel`. We 24 + will then be able to filter them and ingest Tinybird the the logs we want to 25 + keep. Maybe, we can start first by ingesting only errors. 26 + 27 + 1. We can start simple an only create the log drain for the user. nothing else 28 + 2. Once clear we will have to create a little `/configure` page where the user, 29 + authentificated within vercel, can update the log drain integration easily. 30 + 31 + The UI for the Integration should be the bare minimum. The UX should be in the 32 + focus. Default shadcn/ui components will do it. 33 + 34 + - white background 35 + - `DataTable` filled with `getLogDrains` data: 36 + - createdAt 37 + - name 38 + - projectId 39 + - dropdown menu: data can be deleted 40 + - 'Connect'-button to add log drains to different projet 41 + 42 + <!-- Redirect URL aka callback explaination --> 43 + 44 + ### Files 45 + 46 + - `webhook.ts`: ingests Tinybird with the log drains 47 + - `callback.ts`: callback for getting an access token 48 + - `configure.ts`: simple page to configure your vercel integration authorized as 49 + vercel user through access token 50 + - `client.ts`: includes all important REST API calls to vercel, updating the 51 + users project 52 + 53 + ### API Scopes 54 + 55 + The following scopes are necessary: 56 + 57 + - Log Drains (Read/Write): be able to send a request with the payload 58 + - Projects (Read): connect log drains with projects 59 + 60 + > We might require more scopes.
+13
packages/integrations/vercel/env.ts
··· 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 + 4 + export const env = createEnv({ 5 + server: { 6 + INTEGRATION_SECRET: z.string().min(1), 7 + TINY_BIRD_API_KEY: z.string().min(1), 8 + }, 9 + runtimeEnv: { 10 + INTEGRATION_SECRET: process.env.INTEGRATION_SECRET, 11 + TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, 12 + }, 13 + });
+21
packages/integrations/vercel/package.json
··· 1 + { 2 + "name": "@openstatus/vercel", 3 + "version": "0.0.0", 4 + "main": "src/index.ts", 5 + "description": "Log drains Vercel integration.", 6 + "dependencies": { 7 + "@openstatus/tinybird": "workspace:*", 8 + "@t3-oss/env-core": "0.6.0", 9 + "react": "18.2.0", 10 + "react-dom": "18.2.0", 11 + "zod": "^3.21.4" 12 + }, 13 + "devDependencies": { 14 + "@types/node": "20.3.1", 15 + "@types/react": "18.2.12", 16 + "@types/react-dom": "18.2.5", 17 + "next": "13.4.12", 18 + "tsconfig": "workspace:*", 19 + "typescript": "5.1.6" 20 + } 21 + }
+3
packages/integrations/vercel/src/components/integration-card.tsx
··· 1 + export const VercelIntegrationCard = () => { 2 + return <></>; 3 + };
+22
packages/integrations/vercel/src/components/submit-button.tsx
··· 1 + "use client"; 2 + 3 + import { experimental_useFormStatus as useFormStatus } from "react-dom"; 4 + 5 + export function SubmitButton({ 6 + disabled, 7 + children, 8 + }: { 9 + disabled?: boolean; 10 + children: React.ReactNode; 11 + }) { 12 + const { pending } = useFormStatus(); 13 + return ( 14 + <button 15 + type="submit" 16 + disabled={pending || disabled} 17 + className="bg-foreground text-background rounded-md px-2 py-1 disabled:cursor-not-allowed disabled:opacity-60" 18 + > 19 + {pending ? "Loading" : children} 20 + </button> 21 + ); 22 + }
+4
packages/integrations/vercel/src/index.ts
··· 1 + export * from "./libs/schema"; 2 + export * from "./routes/webhook"; 3 + export * from "./routes/callback"; 4 + export * from "./pages/configure";
+157
packages/integrations/vercel/src/libs/client.ts
··· 1 + // CREDITS: https://github.com/valeriangalliat/vercel-custom-log-drain/blob/43043c095475c9fac279e5fec8976497ee1ea9b6/clients/vercel.ts 2 + 3 + import { projectsSchema } from "./schema"; 4 + 5 + async function fetchOk( 6 + url: string, 7 + init?: RequestInit | undefined, 8 + ): Promise<Response> { 9 + const res = await fetch(url, init); 10 + 11 + if (!res.ok) { 12 + const contentType = res.headers.get("content-type"); 13 + let body: string | object | null; 14 + 15 + try { 16 + const isJson = contentType && contentType.includes("json"); 17 + body = await (isJson ? res.json() : res.text()); 18 + } catch (err) { 19 + body = null; 20 + } 21 + 22 + console.log({ body }); 23 + 24 + throw Object.assign(new Error(`Failed to fetch: ${url}`), { 25 + res, 26 + body, 27 + }); 28 + } 29 + 30 + return res; 31 + } 32 + 33 + const base = "https://api.vercel.com"; 34 + 35 + export async function getToken(code: string): Promise<string> { 36 + const url = `${base}/v2/oauth/access_token`; 37 + 38 + const res = await fetchOk(url, { 39 + method: "POST", 40 + headers: { 41 + "Content-Type": "application/x-www-form-urlencoded", 42 + }, 43 + body: new URLSearchParams({ 44 + client_id: process.env.VERCEL_CLIENT_ID || "", 45 + client_secret: process.env.VERCEL_CLIENT_SECRET || "", 46 + code, 47 + redirect_uri: process.env.VERCEL_REDIRECT_URI || "", 48 + }), 49 + }); 50 + 51 + const json = await res.json(); 52 + 53 + return json.access_token; 54 + } 55 + 56 + // Type from <https://vercel.com/docs/log-drains> 57 + export type LogDrain = { 58 + /** The oauth2 client application id that created this log drain */ 59 + clientId: string; 60 + /** The client configuration this log drain was created with */ 61 + configurationId: string; 62 + /** A timestamp that tells you when the log drain was created */ 63 + createdAt: number; 64 + /** The unique identifier of the log drain. Always prefixed with `ld_` */ 65 + id: string; 66 + /** The type of log format */ 67 + deliveryFormat: "json" | "ndjson" | "syslog"; 68 + /** The name of the log drain */ 69 + name: string; 70 + /** The identifier of the team or user whose events will trigger the log drain */ 71 + ownerId: string; 72 + /** The identifier of the project this log drain is associated with */ 73 + projectId?: string | null; 74 + /** The URL to call when logs are generated */ 75 + url: string; 76 + /** TODO: add correct description and check if correct */ 77 + headers?: Record<string, string>; 78 + /** The sources from which logs are currently being delivered to this log drain */ 79 + sources?: ( 80 + | "static" 81 + | "lambda" 82 + | "build" 83 + | "edge" 84 + | "external" 85 + | "deployment" 86 + )[]; // REMINDER: creating a log drain with "deployment" source won't work 87 + }; 88 + 89 + function getQuery(teamId?: string) { 90 + return teamId ? `?teamId=${encodeURIComponent(teamId)}` : ""; 91 + } 92 + 93 + export async function getLogDrains( 94 + token: string, 95 + teamId?: string, 96 + ): Promise<LogDrain[]> { 97 + const url = `${base}/v2/integrations/log-drains${getQuery(teamId)}`; 98 + 99 + const res = await fetchOk(url, { 100 + headers: { 101 + Authorization: `Bearer ${token}`, 102 + }, 103 + }); 104 + 105 + return await res.json(); 106 + } 107 + 108 + export async function getProjects(token: string, teamId?: string) { 109 + const url = `${base}/v9/projects${getQuery(teamId)}`; 110 + 111 + const res = await fetchOk(url, { 112 + headers: { 113 + Authorization: `Bearer ${token}`, 114 + }, 115 + }); 116 + 117 + const json = await res.json(); 118 + console.log(json); 119 + return projectsSchema.parse(json); 120 + } 121 + 122 + export async function createLogDrain( 123 + token: string, 124 + logDrain: LogDrain, 125 + teamId?: string, 126 + ): Promise<LogDrain> { 127 + const url = `${base}/v2/integrations/log-drains${getQuery(teamId)}`; 128 + 129 + console.log({ token, logDrain, teamId }); 130 + 131 + const res = await fetchOk(url, { 132 + method: "post", 133 + headers: { 134 + Authorization: `Bearer ${token}`, 135 + }, 136 + body: JSON.stringify(logDrain), 137 + }); 138 + 139 + return await res.json(); 140 + } 141 + 142 + export async function deleteLogDrain( 143 + token: string, 144 + id: string, 145 + teamId?: string, 146 + ): Promise<void> { 147 + const url = `${base}/v1/integrations/log-drains/${encodeURIComponent( 148 + id, 149 + )}${getQuery(teamId)}`; 150 + 151 + await fetchOk(url, { 152 + method: "delete", 153 + headers: { 154 + Authorization: `Bearer ${token}`, 155 + }, 156 + }); 157 + }
+16
packages/integrations/vercel/src/libs/crypto.ts
··· 1 + // CREDITS: https://github.com/valeriangalliat/vercel-custom-log-drain/blob/master/utils/crypto.ts 2 + 3 + import crypto from "crypto"; 4 + 5 + /** generate key via `node -p "crypto.randomBytes(32).toString('base64url')"` */ 6 + const key = Buffer.from(process.env.AES_KEY || "", "base64url"); 7 + 8 + export function encrypt(iv: Buffer, data: Buffer): Buffer { 9 + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); 10 + return Buffer.concat([cipher.update(data), cipher.final()]); 11 + } 12 + 13 + export function decrypt(iv: Buffer, encrypted: Buffer): Buffer { 14 + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); 15 + return Buffer.concat([decipher.update(encrypted), decipher.final()]); 16 + }
+613
packages/integrations/vercel/src/libs/schema.ts
··· 1 + import * as z from "zod"; 2 + 3 + /** 4 + * If the response of the request returns an HTTP statusCode with a value of -1, 5 + * that means there was no response returned and the lambda crashed. 6 + * In the same response, if the value of proxy.statusCode is returned with -1, 7 + * that means the revalidation occurred in the background. 8 + */ 9 + 10 + // https://vercel.com/docs/observability/log-drains-overview/log-drains-reference#json-log-drains 11 + export const logDrainSchema = z.object({ 12 + id: z.string().optional(), 13 + timestamp: z.number().optional(), 14 + type: z 15 + .enum([ 16 + "middleware-invocation", 17 + "stdout", 18 + "stderr", 19 + "edge-function-invocation", 20 + "fatal", 21 + ]) 22 + .optional(), 23 + edgeType: z.enum(["edge-function", "middleware"]).optional(), 24 + requestId: z.string().optional(), 25 + statusCode: z.number().optional(), 26 + message: z.string().optional(), 27 + projectId: z.string().optional(), 28 + deploymentId: z.string().optional(), 29 + buildId: z.string().optional(), 30 + source: z.enum(["external", "lambda", "edge", "static", "build"]), 31 + host: z.string().optional(), 32 + environment: z.string().optional(), 33 + branch: z.string().optional(), 34 + destination: z.string().optional(), 35 + path: z.string().optional(), 36 + entrypoint: z.string().optional(), 37 + proxy: z 38 + .object({ 39 + timestamp: z.number().optional(), 40 + region: z.string().optional(), // TODO: use regions enum? 41 + method: z.string().optional(), // TODO: use methods enum? 42 + vercelCache: z.string().optional(), // TODO: use "HIT" / "MISS" enum? 43 + statusCode: z.number().optional(), 44 + path: z.string().optional(), 45 + host: z.string().optional(), 46 + scheme: z.string().optional(), 47 + clientIp: z.string().optional(), 48 + userAgent: z.array(z.string()).optional(), 49 + }) 50 + .optional(), 51 + }); 52 + 53 + export const logDrainSchemaArray = z.array(logDrainSchema); 54 + 55 + export const projectSchema = z.object({ 56 + accountId: z.string(), 57 + analytics: z 58 + .object({ 59 + id: z.string(), 60 + canceledAt: z.number().nullable(), 61 + disabledAt: z.number(), 62 + enabledAt: z.number(), 63 + paidAt: z.number().optional(), 64 + sampleRatePercent: z.number().optional().nullable(), 65 + spendLimitInDollars: z.number().optional().nullable(), 66 + }) 67 + .optional(), 68 + autoExposeSystemEnvs: z.boolean().optional(), 69 + autoAssignCustomDomains: z.boolean().optional(), 70 + autoAssignCustomDomainsUpdatedBy: z.string().optional(), 71 + buildCommand: z.string().optional().nullable(), 72 + commandForIgnoringBuildStep: z.string().optional().nullable(), 73 + connectConfigurationId: z.string().optional().nullable(), 74 + connectBuildsEnabled: z.boolean().optional(), 75 + createdAt: z.number().optional(), 76 + customerSupportCodeVisibility: z.boolean().optional(), 77 + crons: z 78 + .object({ 79 + enabledAt: z.number(), 80 + disabledAt: z.number().nullable(), 81 + updatedAt: z.number(), 82 + deploymentId: z.string().nullable(), 83 + definitions: z.array( 84 + z.object({ 85 + host: z.string(), 86 + path: z.string(), 87 + schedule: z.string(), 88 + }), 89 + ), 90 + }) 91 + .optional(), 92 + dataCache: z 93 + .object({ 94 + userDisabled: z.boolean(), 95 + storageSizeBytes: z.number().optional().nullable(), 96 + unlimited: z.boolean().optional(), 97 + }) 98 + .optional(), 99 + devCommand: z.string().optional().nullable(), 100 + directoryListing: z.boolean(), 101 + installCommand: z.string().optional().nullable(), 102 + env: z 103 + .array( 104 + z.object({ 105 + target: z 106 + .union([ 107 + z.array( 108 + z.union([ 109 + z.literal("production"), 110 + z.literal("preview"), 111 + z.literal("development"), 112 + z.literal("preview"), 113 + z.literal("development"), 114 + ]), 115 + ), 116 + z.union([ 117 + z.literal("production"), 118 + z.literal("preview"), 119 + z.literal("development"), 120 + z.literal("preview"), 121 + z.literal("development"), 122 + ]), 123 + ]) 124 + .optional(), 125 + type: z.union([ 126 + z.literal("secret"), 127 + z.literal("system"), 128 + z.literal("encrypted"), 129 + z.literal("plain"), 130 + z.literal("sensitive"), 131 + ]), 132 + id: z.string().optional(), 133 + key: z.string(), 134 + value: z.string(), 135 + configurationId: z.string().optional().nullable(), 136 + createdAt: z.number().optional(), 137 + updatedAt: z.number().optional(), 138 + createdBy: z.string().optional().nullable(), 139 + updatedBy: z.string().optional().nullable(), 140 + gitBranch: z.string().optional(), 141 + edgeConfigId: z.string().optional().nullable(), 142 + edgeConfigTokenId: z.string().optional().nullable(), 143 + contentHint: z 144 + .union([ 145 + z.object({ 146 + type: z.literal("redis-url"), 147 + storeId: z.string(), 148 + }), 149 + z.object({ 150 + type: z.literal("redis-rest-api-url"), 151 + storeId: z.string(), 152 + }), 153 + z.object({ 154 + type: z.literal("redis-rest-api-token"), 155 + storeId: z.string(), 156 + }), 157 + z.object({ 158 + type: z.literal("redis-rest-api-read-only-token"), 159 + storeId: z.string(), 160 + }), 161 + z.object({ 162 + type: z.literal("blob-read-write-token"), 163 + storeId: z.string(), 164 + }), 165 + z.object({ 166 + type: z.literal("postgres-url"), 167 + storeId: z.string(), 168 + }), 169 + z.object({ 170 + type: z.literal("postgres-url-non-pooling"), 171 + storeId: z.string(), 172 + }), 173 + z.object({ 174 + type: z.literal("postgres-prisma-url"), 175 + storeId: z.string(), 176 + }), 177 + z.object({ 178 + type: z.literal("postgres-user"), 179 + storeId: z.string(), 180 + }), 181 + z.object({ 182 + type: z.literal("postgres-host"), 183 + storeId: z.string(), 184 + }), 185 + z.object({ 186 + type: z.literal("postgres-password"), 187 + storeId: z.string(), 188 + }), 189 + z.object({ 190 + type: z.literal("postgres-database"), 191 + storeId: z.string(), 192 + }), 193 + ]) 194 + .optional(), 195 + decrypted: z.boolean().optional(), 196 + }), 197 + ) 198 + .optional(), 199 + framework: z 200 + .union([ 201 + z.literal("blitzjs"), 202 + z.literal("nextjs"), 203 + z.literal("gatsby"), 204 + z.literal("remix"), 205 + z.literal("astro"), 206 + z.literal("hexo"), 207 + z.literal("eleventy"), 208 + z.literal("docusaurus-2"), 209 + z.literal("docusaurus"), 210 + z.literal("preact"), 211 + z.literal("solidstart"), 212 + z.literal("dojo"), 213 + z.literal("ember"), 214 + z.literal("vue"), 215 + z.literal("scully"), 216 + z.literal("ionic-angular"), 217 + z.literal("angular"), 218 + z.literal("polymer"), 219 + z.literal("svelte"), 220 + z.literal("sveltekit"), 221 + z.literal("sveltekit-1"), 222 + z.literal("ionic-react"), 223 + z.literal("create-react-app"), 224 + z.literal("gridsome"), 225 + z.literal("umijs"), 226 + z.literal("sapper"), 227 + z.literal("saber"), 228 + z.literal("stencil"), 229 + z.literal("nuxtjs"), 230 + z.literal("redwoodjs"), 231 + z.literal("hugo"), 232 + z.literal("jekyll"), 233 + z.literal("brunch"), 234 + z.literal("middleman"), 235 + z.literal("zola"), 236 + z.literal("hydrogen"), 237 + z.literal("vite"), 238 + z.literal("vitepress"), 239 + z.literal("vuepress"), 240 + z.literal("parcel"), 241 + z.literal("sanity"), 242 + z.literal("storybook"), 243 + ]) 244 + .optional(), 245 + gitForkProtection: z.boolean().optional(), 246 + gitLFS: z.boolean().optional(), 247 + id: z.string(), 248 + latestDeployments: z 249 + .array( 250 + z.object({ 251 + alias: z.array(z.string()).optional(), 252 + aliasAssigned: z.union([z.number(), z.boolean(), z.null()]).nullish(), 253 + aliasError: z 254 + .object({ 255 + code: z.string(), 256 + message: z.string(), 257 + }) 258 + .optional() 259 + .nullable(), 260 + aliasFinal: z.string().optional().nullable(), 261 + automaticAliases: z.array(z.string()).optional(), 262 + builds: z 263 + .array( 264 + z.object({ 265 + use: z.string(), 266 + src: z.string().optional(), 267 + dest: z.string().optional(), 268 + }), 269 + ) 270 + .optional(), 271 + connectBuildsEnabled: z.boolean().optional(), 272 + connectConfigurationId: z.string().optional(), 273 + createdAt: z.number(), 274 + createdIn: z.string(), 275 + creator: z 276 + .object({ 277 + email: z.string(), 278 + githubLogin: z.string().optional(), 279 + gitlabLogin: z.string().optional(), 280 + uid: z.string(), 281 + username: z.string(), 282 + }) 283 + .nullable(), 284 + deploymentHostname: z.string(), 285 + name: z.string(), 286 + forced: z.boolean().optional(), 287 + id: z.string(), 288 + meta: z.record(z.string()).optional(), 289 + monorepoManager: z.string().optional().nullable(), 290 + plan: z.union([ 291 + z.literal("pro"), 292 + z.literal("enterprise"), 293 + z.literal("hobby"), 294 + z.literal("oss"), 295 + ]), 296 + private: z.boolean(), 297 + readyState: z.union([ 298 + z.literal("BUILDING"), 299 + z.literal("ERROR"), 300 + z.literal("INITIALIZING"), 301 + z.literal("QUEUED"), 302 + z.literal("READY"), 303 + z.literal("CANCELED"), 304 + ]), 305 + readySubstate: z 306 + .union([z.literal("STAGED"), z.literal("PROMOTED")]) 307 + .optional(), 308 + requestedAt: z.number().optional(), 309 + target: z.string().optional().nullable(), 310 + teamId: z.string().optional().nullable(), 311 + type: z.literal("LAMBDAS"), 312 + url: z.string(), 313 + userId: z.string(), 314 + withCache: z.boolean().optional(), 315 + checksConclusion: z 316 + .union([ 317 + z.literal("succeeded"), 318 + z.literal("failed"), 319 + z.literal("skipped"), 320 + z.literal("canceled"), 321 + ]) 322 + .optional(), 323 + checksState: z 324 + .union([ 325 + z.literal("registered"), 326 + z.literal("running"), 327 + z.literal("completed"), 328 + ]) 329 + .optional(), 330 + readyAt: z.number().optional(), 331 + buildingAt: z.number().optional(), 332 + previewCommentsEnabled: z.boolean().optional(), 333 + }), 334 + ) 335 + .optional(), 336 + link: z 337 + .union([ 338 + z.object({ 339 + org: z.string().optional(), 340 + repo: z.string().optional(), 341 + repoId: z.number().optional(), 342 + type: z.literal("github").optional(), 343 + createdAt: z.number().optional(), 344 + deployHooks: z.array( 345 + z.object({ 346 + createdAt: z.number().optional(), 347 + id: z.string(), 348 + name: z.string(), 349 + ref: z.string(), 350 + url: z.string(), 351 + }), 352 + ), 353 + gitCredentialId: z.string().optional(), 354 + updatedAt: z.number().optional(), 355 + sourceless: z.boolean().optional(), 356 + productionBranch: z.string().optional(), 357 + }), 358 + z.object({ 359 + projectId: z.string().optional(), 360 + projectName: z.string().optional(), 361 + projectNameWithNamespace: z.string().optional(), 362 + projectNamespace: z.string().optional(), 363 + projectUrl: z.string().optional(), 364 + type: z.literal("gitlab").optional(), 365 + createdAt: z.number().optional(), 366 + deployHooks: z.array( 367 + z.object({ 368 + createdAt: z.number().optional(), 369 + id: z.string(), 370 + name: z.string(), 371 + ref: z.string(), 372 + url: z.string(), 373 + }), 374 + ), 375 + gitCredentialId: z.string().optional(), 376 + updatedAt: z.number().optional(), 377 + sourceless: z.boolean().optional(), 378 + productionBranch: z.string().optional(), 379 + }), 380 + z.object({ 381 + name: z.string().optional(), 382 + slug: z.string().optional(), 383 + owner: z.string().optional(), 384 + type: z.literal("bitbucket").optional(), 385 + uuid: z.string().optional(), 386 + workspaceUuid: z.string().optional(), 387 + createdAt: z.number().optional(), 388 + deployHooks: z.array( 389 + z.object({ 390 + createdAt: z.number().optional(), 391 + id: z.string(), 392 + name: z.string(), 393 + ref: z.string(), 394 + url: z.string(), 395 + }), 396 + ), 397 + gitCredentialId: z.string().optional(), 398 + updatedAt: z.number().optional(), 399 + sourceless: z.boolean().optional(), 400 + productionBranch: z.string().optional(), 401 + }), 402 + ]) 403 + .optional(), 404 + name: z.string(), 405 + nodeVersion: z.union([ 406 + z.literal("18.x"), 407 + z.literal("16.x"), 408 + z.literal("14.x"), 409 + z.literal("12.x"), 410 + z.literal("10.x"), 411 + ]), 412 + outputDirectory: z.string().optional().nullable(), 413 + passwordProtection: z.record(z.unknown()).optional().nullable(), 414 + productionDeploymentsFastLane: z.boolean().optional(), 415 + publicSource: z.boolean().optional().nullable(), 416 + rootDirectory: z.string().optional().nullable(), 417 + serverlessFunctionRegion: z.string().optional().nullable(), 418 + skipGitConnectDuringLink: z.boolean().optional(), 419 + sourceFilesOutsideRootDirectory: z.boolean().optional(), 420 + ssoProtection: z 421 + .object({ 422 + deploymentType: z.union([ 423 + z.literal("all"), 424 + z.literal("preview"), 425 + z.literal("prod_deployment_urls_and_all_previews"), 426 + ]), 427 + }) 428 + .optional() 429 + .nullable(), 430 + targets: z 431 + .record( 432 + z 433 + .object({ 434 + alias: z.array(z.string()).optional(), 435 + aliasAssigned: z.union([z.number(), z.boolean(), z.null()]).nullish(), 436 + aliasError: z 437 + .object({ 438 + code: z.string(), 439 + message: z.string(), 440 + }) 441 + .optional() 442 + .nullable(), 443 + aliasFinal: z.string().optional().nullable(), 444 + automaticAliases: z.array(z.string()).optional(), 445 + builds: z 446 + .array( 447 + z.object({ 448 + use: z.string(), 449 + src: z.string().optional(), 450 + dest: z.string().optional(), 451 + }), 452 + ) 453 + .optional(), 454 + connectBuildsEnabled: z.boolean().optional(), 455 + connectConfigurationId: z.string().optional(), 456 + createdAt: z.number(), 457 + createdIn: z.string(), 458 + creator: z 459 + .object({ 460 + email: z.string(), 461 + githubLogin: z.string().optional(), 462 + gitlabLogin: z.string().optional(), 463 + uid: z.string(), 464 + username: z.string(), 465 + }) 466 + .nullable(), 467 + deploymentHostname: z.string(), 468 + name: z.string(), 469 + forced: z.boolean().optional(), 470 + id: z.string(), 471 + meta: z.record(z.string()).optional(), 472 + monorepoManager: z.string().optional().nullable(), 473 + plan: z.union([ 474 + z.literal("pro"), 475 + z.literal("enterprise"), 476 + z.literal("hobby"), 477 + z.literal("oss"), 478 + ]), 479 + private: z.boolean(), 480 + readyState: z.union([ 481 + z.literal("BUILDING"), 482 + z.literal("ERROR"), 483 + z.literal("INITIALIZING"), 484 + z.literal("QUEUED"), 485 + z.literal("READY"), 486 + z.literal("CANCELED"), 487 + ]), 488 + readySubstate: z 489 + .union([z.literal("STAGED"), z.literal("PROMOTED")]) 490 + .optional(), 491 + requestedAt: z.number().optional(), 492 + target: z.string().optional().nullable(), 493 + teamId: z.string().optional().nullable(), 494 + type: z.literal("LAMBDAS"), 495 + url: z.string(), 496 + userId: z.string(), 497 + withCache: z.boolean().optional(), 498 + checksConclusion: z 499 + .union([ 500 + z.literal("succeeded"), 501 + z.literal("failed"), 502 + z.literal("skipped"), 503 + z.literal("canceled"), 504 + ]) 505 + .optional(), 506 + checksState: z 507 + .union([ 508 + z.literal("registered"), 509 + z.literal("running"), 510 + z.literal("completed"), 511 + ]) 512 + .optional(), 513 + readyAt: z.number().optional(), 514 + buildingAt: z.number().optional(), 515 + previewCommentsEnabled: z.boolean().optional(), 516 + }) 517 + .nullable(), 518 + ) 519 + .optional(), 520 + transferCompletedAt: z.number().optional(), 521 + transferStartedAt: z.number().optional(), 522 + transferToAccountId: z.string().optional(), 523 + transferredFromAccountId: z.string().optional(), 524 + updatedAt: z.number().optional(), 525 + live: z.boolean().optional(), 526 + enablePreviewFeedback: z.boolean().optional().nullable(), 527 + permissions: z.object({}).optional(), 528 + lastRollbackTarget: z.record(z.unknown()).optional().nullable(), 529 + lastAliasRequest: z 530 + .object({ 531 + fromDeploymentId: z.string(), 532 + toDeploymentId: z.string(), 533 + jobStatus: z.union([ 534 + z.literal("succeeded"), 535 + z.literal("failed"), 536 + z.literal("skipped"), 537 + z.literal("pending"), 538 + z.literal("in-progress"), 539 + ]), 540 + requestedAt: z.number(), 541 + type: z.union([z.literal("promote"), z.literal("rollback")]), 542 + }) 543 + .optional() 544 + .nullable(), 545 + hasFloatingAliases: z.boolean().optional(), 546 + protectionBypass: z 547 + .record( 548 + z.union([ 549 + z.object({ 550 + createdAt: z.number(), 551 + createdBy: z.string(), 552 + scope: z.union([ 553 + z.literal("shareable-link"), 554 + z.literal("automation-bypass"), 555 + ]), 556 + }), 557 + z.object({ 558 + createdAt: z.number(), 559 + lastUpdatedAt: z.number(), 560 + lastUpdatedBy: z.string(), 561 + access: z.union([z.literal("requested"), z.literal("granted")]), 562 + scope: z.literal("user"), 563 + }), 564 + ]), 565 + ) 566 + .optional(), 567 + hasActiveBranches: z.boolean().optional(), 568 + trustedIps: z 569 + .union([ 570 + z.object({ 571 + deploymentType: z.union([ 572 + z.literal("all"), 573 + z.literal("preview"), 574 + z.literal("prod_deployment_urls_and_all_previews"), 575 + z.literal("production"), 576 + ]), 577 + addresses: z.array( 578 + z.object({ 579 + value: z.string(), 580 + note: z.string().optional(), 581 + }), 582 + ), 583 + protectionMode: z.union([ 584 + z.literal("additional"), 585 + z.literal("exclusive"), 586 + ]), 587 + }), 588 + z.object({ 589 + deploymentType: z.union([ 590 + z.literal("all"), 591 + z.literal("preview"), 592 + z.literal("prod_deployment_urls_and_all_previews"), 593 + z.literal("production"), 594 + ]), 595 + }), 596 + ]) 597 + .optional(), 598 + gitComments: z 599 + .object({ 600 + onPullRequest: z.boolean(), 601 + onCommit: z.boolean(), 602 + }) 603 + .optional(), 604 + }); 605 + 606 + export const projectsSchema = z.object({ 607 + projects: z.array(projectSchema), 608 + pagination: z.object({ 609 + count: z.number(), 610 + next: z.number().nullable(), 611 + prev: z.number().nullable(), 612 + }), 613 + });
+12
packages/integrations/vercel/src/libs/tinybird.ts
··· 1 + import { Tinybird } from "@openstatus/tinybird"; 2 + 3 + import { logDrainSchema } from "./schema"; 4 + 5 + const tb = new Tinybird({ token: process.env.TINY_BIRD_API_KEY || "" }); // should we use t3-env? 6 + 7 + export function publishVercelLogDrain() { 8 + return tb.buildIngestEndpoint({ 9 + datasource: "vercel_log_drain__v0", 10 + event: logDrainSchema, 11 + }); 12 + }
+107
packages/integrations/vercel/src/pages/configure.tsx
··· 1 + import { revalidatePath } from "next/cache"; 2 + import { cookies } from "next/headers"; 3 + import { redirect } from "next/navigation"; 4 + 5 + import { SubmitButton } from "../components/submit-button"; 6 + import { 7 + createLogDrain, 8 + deleteLogDrain, 9 + getLogDrains, 10 + getProjects, 11 + } from "../libs/client"; 12 + import { decrypt } from "../libs/crypto"; 13 + 14 + export async function Configure() { 15 + const iv = cookies().get("iv")?.value; 16 + const encryptedToken = cookies().get("token")?.value; 17 + const teamId = cookies().get("teamId")?.value; 18 + 19 + if (!iv || !encryptedToken) { 20 + /** Redirect to access new token */ 21 + return redirect("/app"); 22 + } 23 + 24 + const token = decrypt( 25 + Buffer.from(iv || "", "base64url"), 26 + Buffer.from(encryptedToken || "", "base64url"), 27 + ).toString(); 28 + 29 + let logDrains = await getLogDrains(token, teamId); 30 + 31 + const projects = await getProjects(token, teamId); 32 + 33 + for (const project of projects.projects) { 34 + console.log({ project }); 35 + console.log("create integration"); 36 + } 37 + // Create integration project if it doesn't exist 38 + 39 + if (logDrains.length === 0) { 40 + logDrains = [ 41 + await createLogDrain( 42 + token, 43 + // @ts-expect-error We need more data - but this is a demo 44 + { 45 + deliveryFormat: "json", 46 + name: "OpenStatus Log Drain", 47 + // TODO: update with correct url 48 + url: "https://wwww.openstatus.dev/api/integrations/vercel", 49 + sources: ["static", "lambda", "edge", "external"], 50 + // headers: { "key": "value"} 51 + }, 52 + teamId, 53 + ), 54 + ]; 55 + } 56 + 57 + // TODO: automatically create log drain on installation 58 + async function create(formData: FormData) { 59 + "use server"; 60 + await createLogDrain( 61 + token, 62 + // @ts-expect-error We need more data - but this is a demo 63 + { 64 + deliveryFormat: "json", 65 + name: "OpenStatus Log Drain", 66 + // TODO: update with correct url 67 + url: "https://wwww.openstatus.dev/api/integrations/vercel", 68 + sources: ["static", "lambda", "edge", "external"], 69 + // headers: { "key": "value"} 70 + }, 71 + teamId, 72 + ); 73 + revalidatePath("/"); 74 + } 75 + 76 + async function _delete(formData: FormData) { 77 + "use server"; 78 + const id = formData.get("id")?.toString(); 79 + console.log({ id }); 80 + if (id) { 81 + await deleteLogDrain(token, id, String(teamId)); 82 + revalidatePath("/"); 83 + } 84 + } 85 + 86 + return ( 87 + <div className="flex w-full flex-col items-center justify-center"> 88 + <div className="border-border m-3 grid w-full max-w-xl gap-3 rounded-lg border p-6 backdrop-blur-[2px]"> 89 + <h1 className="font-cal text-2xl">Configure Vercel Integration</h1> 90 + <ul> 91 + {logDrains.map((item) => ( 92 + <li 93 + key={item.id} 94 + className="flex items-center justify-between gap-2" 95 + > 96 + <p>{item.name}</p> 97 + <form action={_delete}> 98 + <input name="id" value={item.id} className="hidden" /> 99 + <SubmitButton>Remove integration</SubmitButton> 100 + </form> 101 + </li> 102 + ))} 103 + </ul> 104 + </div> 105 + </div> 106 + ); 107 + }
+54
packages/integrations/vercel/src/routes/callback.ts
··· 1 + import crypto from "crypto"; 2 + import { NextResponse } from "next/server"; 3 + import type { NextRequest } from "next/server"; 4 + 5 + import { getToken } from "../libs/client"; 6 + import { encrypt } from "../libs/crypto"; 7 + 8 + export async function GET(req: NextRequest) { 9 + const { searchParams } = new URL(req.url); 10 + const code = searchParams.get("code"); 11 + const next = searchParams.get("next"); 12 + const teamId = searchParams.get("teamId"); 13 + console.log({ code, next, teamId }); 14 + if (!code || !next) { 15 + throw new Error("Missing `code` or `next` param"); 16 + } 17 + 18 + /** Get access token from Vercel */ 19 + const token = await getToken(code); 20 + 21 + // TODO: automatically install log drains to the allowed projects 22 + 23 + const iv = crypto.randomBytes(16); 24 + const encryptedToken = encrypt(iv, Buffer.from(token)); 25 + 26 + // redirect to vercel's integration page after installation 27 + const res = NextResponse.redirect( 28 + `https://wwww.openstatus.dev/app/integrations/vercel/configure`, 29 + ); 30 + 31 + console.log("Setting cookies"); 32 + /** Encrypt access token and add to Cookies */ 33 + res.cookies.set("token", encryptedToken.toString("base64url")); 34 + res.cookies.set("iv", iv.toString("base64url")); 35 + 36 + if (teamId) { 37 + res.cookies.set("teamId", teamId); 38 + } 39 + 40 + return res; 41 + } 42 + 43 + /** 44 + * The code below is only needed if we allow the user 45 + * to send the secret link to a team mate e.g. 46 + */ 47 + function getSecretUrl(iv: Buffer, encryptedToken: Buffer) { 48 + const baseUrl = new URL(process.env.VRCL_REDIRECT_URI!); 49 + baseUrl.pathname = "/configure"; 50 + baseUrl.searchParams.set("token", encryptedToken.toString("base64url")); 51 + baseUrl.searchParams.set("iv", iv.toString("base64url")); 52 + const secretUrl = baseUrl.toString(); 53 + return secretUrl; 54 + }
+76
packages/integrations/vercel/src/routes/webhook.ts
··· 1 + import { log } from "console"; 2 + import crypto from "crypto"; 3 + import { NextResponse } from "next/server"; 4 + import * as z from "zod"; 5 + 6 + import { env } from "../../env"; 7 + import { logDrainSchemaArray } from "../libs/schema"; 8 + import { publishVercelLogDrain } from "../libs/tinybird"; 9 + 10 + export const config = { 11 + api: { 12 + bodyParser: false, 13 + }, 14 + }; 15 + 16 + /** 17 + * Ingest log drains from Vercel. 18 + */ 19 + export async function POST(request: Request) { 20 + const rawBody = await request.text(); 21 + // const rawBodyBuffer = Buffer.from(rawBody, "utf-8"); 22 + // // const bodySignature = sha1(rawBodyBuffer, "LOG_DRAIN_SECRET"); 23 + 24 + // // /** 25 + // // * Validates the signature of the request 26 + // // * Uncomment for 'Test Log Drain' in Vercel 27 + // // */ 28 + // // if (bodySignature !== request.headers.get("x-vercel-signature")) { 29 + // // return NextResponse.json( 30 + // // { 31 + // // code: "invalid_signature", 32 + // // error: "signature didn't match", 33 + // // }, 34 + // // { status: 401 }, 35 + // // ); 36 + // // } 37 + 38 + /** 39 + * Returns a header verification to Vercel, so the route can be added as a log drain 40 + */ 41 + if (z.object({}).safeParse(JSON.parse(rawBody)).success) { 42 + return NextResponse.json( 43 + { code: "ok" }, 44 + { status: 200, headers: { "x-vercel-verify": env.INTEGRATION_SECRET } }, 45 + ); 46 + } 47 + 48 + /** 49 + * Validates the body of the request 50 + */ 51 + const logDrains = logDrainSchemaArray.safeParse(JSON.parse(rawBody)); 52 + 53 + if (logDrains.success) { 54 + // We are only pushing the logs that are not stdout or stderr 55 + const data = logDrains.data.filter( 56 + (log) => log.type !== "stdout" && log.type !== "stderr", 57 + ); 58 + 59 + for (const event of data) { 60 + // FIXME: Zod-bird is broken 61 + await publishVercelLogDrain()(event); 62 + } 63 + 64 + return NextResponse.json({ code: "ok" }, { status: 200 }); 65 + } else { 66 + console.error("Error parsing logDrains", logDrains.error); 67 + } 68 + return NextResponse.json({ error: logDrains.error }, { status: 500 }); 69 + } 70 + 71 + /** 72 + * Creates a sha1 hash from a buffer and a secret 73 + */ 74 + function sha1(data: Buffer, secret: string) { 75 + return crypto.createHmac("sha1", secret).update(data).digest("hex"); 76 + }
+4
packages/integrations/vercel/tsconfig.json
··· 1 + { 2 + "extends": "tsconfig/nextjs.json", 3 + "include": ["src", "*.ts"] 4 + }
+29
packages/tinybird/datasources/vercel_log_drain.datasource
··· 1 + VERSION 1 2 + 3 + SCHEMA > 4 + `branch` String `json:$.branch` , 5 + `deploymentId` String `json:$.deploymentId` , 6 + `environment` String `json:$.environment` , 7 + `host` String `json:$.host` , 8 + `id` String `json:$.id` , 9 + `message` String `json:$.message` , 10 + `path` String `json:$.path` , 11 + `projectId` String `json:$.projectId` , 12 + `proxy_clientIp` String `json:$.proxy.clientIp` , 13 + `proxy_host` String `json:$.proxy.host` , 14 + `proxy_method` String `json:$.proxy.method` , 15 + `proxy_vercelCache` String `json:$.proxy.vercelCache` , 16 + `proxy_path` String `json:$.proxy.path` , 17 + `proxy_region` String `json:$.proxy.region` , 18 + `proxy_scheme` String `json:$.proxy.scheme` , 19 + `proxy_timestamp` Int64 `json:$.proxy.timestamp` , 20 + `proxy_status` String `json:$.proxy.status` , 21 + `proxy_userAgent` Array(String) `json:$.proxy.userAgent[:]` , 22 + `requestId` String `json:$.requestId` , 23 + `type` String `json:$.type` , 24 + `source` String `json:$.source` , 25 + `timestamp` Int64 `json:$.timestamp` , 26 + 27 + ENGINE "MergeTree" 28 + ENGINE_SORTING_KEY "requestId, projectId" 29 + ENGINE_PARTITION_KEY "toYYYYMMDD(fromUnixTimestamp64Milli(timestamp))"
+40 -21
pnpm-lock.yaml
··· 145 145 '@openstatus/upstash': 146 146 specifier: workspace:* 147 147 version: link:../../packages/upstash 148 + '@openstatus/vercel': 149 + specifier: workspace:* 150 + version: link:../../packages/integrations/vercel 148 151 '@radix-ui/react-accordion': 149 152 specifier: 1.1.2 150 153 version: 1.1.2(@types/react-dom@18.2.5)(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) ··· 578 581 specifier: 5.1.6 579 582 version: 5.1.6 580 583 584 + packages/integrations/vercel: 585 + dependencies: 586 + '@openstatus/tinybird': 587 + specifier: workspace:* 588 + version: link:../../tinybird 589 + '@t3-oss/env-core': 590 + specifier: 0.6.0 591 + version: 0.6.0(typescript@5.1.6)(zod@3.21.4) 592 + react: 593 + specifier: 18.2.0 594 + version: 18.2.0 595 + react-dom: 596 + specifier: 18.2.0 597 + version: 18.2.0(react@18.2.0) 598 + zod: 599 + specifier: ^3.21.4 600 + version: 3.21.4 601 + devDependencies: 602 + '@types/node': 603 + specifier: 20.3.1 604 + version: 20.3.1 605 + '@types/react': 606 + specifier: 18.2.12 607 + version: 18.2.12 608 + '@types/react-dom': 609 + specifier: 18.2.5 610 + version: 18.2.5 611 + next: 612 + specifier: 13.4.12 613 + version: 13.4.12(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) 614 + tsconfig: 615 + specifier: workspace:* 616 + version: link:../../tsconfig 617 + typescript: 618 + specifier: 5.1.6 619 + version: 5.1.6 620 + 581 621 packages/plans: 582 622 dependencies: 583 623 '@openstatus/db': ··· 2535 2575 2536 2576 /@next/env@13.4.12: 2537 2577 resolution: {integrity: sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ==} 2538 - dev: false 2539 2578 2540 2579 /@next/env@13.4.19: 2541 2580 resolution: {integrity: sha512-FsAT5x0jF2kkhNkKkukhsyYOrRqtSxrEhfliniIq0bwWbuXLgyt3Gv0Ml+b91XwjwArmuP7NxCiGd++GGKdNMQ==} ··· 2553 2592 cpu: [arm64] 2554 2593 os: [darwin] 2555 2594 requiresBuild: true 2556 - dev: false 2557 2595 optional: true 2558 2596 2559 2597 /@next/swc-darwin-arm64@13.4.19: ··· 2571 2609 cpu: [x64] 2572 2610 os: [darwin] 2573 2611 requiresBuild: true 2574 - dev: false 2575 2612 optional: true 2576 2613 2577 2614 /@next/swc-darwin-x64@13.4.19: ··· 2589 2626 cpu: [arm64] 2590 2627 os: [linux] 2591 2628 requiresBuild: true 2592 - dev: false 2593 2629 optional: true 2594 2630 2595 2631 /@next/swc-linux-arm64-gnu@13.4.19: ··· 2607 2643 cpu: [arm64] 2608 2644 os: [linux] 2609 2645 requiresBuild: true 2610 - dev: false 2611 2646 optional: true 2612 2647 2613 2648 /@next/swc-linux-arm64-musl@13.4.19: ··· 2625 2660 cpu: [x64] 2626 2661 os: [linux] 2627 2662 requiresBuild: true 2628 - dev: false 2629 2663 optional: true 2630 2664 2631 2665 /@next/swc-linux-x64-gnu@13.4.19: ··· 2643 2677 cpu: [x64] 2644 2678 os: [linux] 2645 2679 requiresBuild: true 2646 - dev: false 2647 2680 optional: true 2648 2681 2649 2682 /@next/swc-linux-x64-musl@13.4.19: ··· 2661 2694 cpu: [arm64] 2662 2695 os: [win32] 2663 2696 requiresBuild: true 2664 - dev: false 2665 2697 optional: true 2666 2698 2667 2699 /@next/swc-win32-arm64-msvc@13.4.19: ··· 2679 2711 cpu: [ia32] 2680 2712 os: [win32] 2681 2713 requiresBuild: true 2682 - dev: false 2683 2714 optional: true 2684 2715 2685 2716 /@next/swc-win32-ia32-msvc@13.4.19: ··· 2697 2728 cpu: [x64] 2698 2729 os: [win32] 2699 2730 requiresBuild: true 2700 - dev: false 2701 2731 optional: true 2702 2732 2703 2733 /@next/swc-win32-x64-msvc@13.4.19: ··· 4999 5029 resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==} 5000 5030 dependencies: 5001 5031 tslib: 2.6.1 5002 - dev: false 5003 5032 5004 5033 /@t3-oss/env-core@0.4.1(typescript@5.1.6)(zod@3.21.4): 5005 5034 resolution: {integrity: sha512-JZI8vxlHwtiZO7OYS3qSX9Ngt7UcdqsugLwhBwx7UVi5wp1PtRo2tzMyNoBEGbfHdmkd2QU9IbvYjqtaLUA7TQ==} ··· 6261 6290 engines: {node: '>=10.16.0'} 6262 6291 dependencies: 6263 6292 streamsearch: 1.1.0 6264 - dev: false 6265 6293 6266 6294 /bytes@3.1.0: 6267 6295 resolution: {integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==} ··· 6528 6556 6529 6557 /client-only@0.0.1: 6530 6558 resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} 6531 - dev: false 6532 6559 6533 6560 /clipanion@3.2.1(typanion@3.13.0): 6534 6561 resolution: {integrity: sha512-dYFdjLb7y1ajfxQopN05mylEpK9ZX0sO1/RfMXdfmwjlIsPkbh4p7A682x++zFPLDCo1x3p82dtljHf5cW2LKA==} ··· 8717 8744 8718 8745 /glob-to-regexp@0.4.1: 8719 8746 resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} 8720 - dev: false 8721 8747 8722 8748 /glob@7.1.6: 8723 8749 resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} ··· 11100 11126 transitivePeerDependencies: 11101 11127 - '@babel/core' 11102 11128 - babel-plugin-macros 11103 - dev: false 11104 11129 11105 11130 /next@13.4.19(@babel/core@7.22.9)(@opentelemetry/api@1.4.1)(react-dom@18.2.0)(react@18.2.0): 11106 11131 resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==} ··· 11860 11885 nanoid: 3.3.6 11861 11886 picocolors: 1.0.0 11862 11887 source-map-js: 1.0.2 11863 - dev: false 11864 11888 11865 11889 /postcss@8.4.21: 11866 11890 resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} ··· 12214 12238 loose-envify: 1.4.0 12215 12239 react: 18.2.0 12216 12240 scheduler: 0.23.0 12217 - dev: false 12218 12241 12219 12242 /react-email@1.9.4: 12220 12243 resolution: {integrity: sha512-DNUQb7xzAlMga2ZppG57bnWhJnqOEcTYzxNvLA4IVCiYJkgPNVukFMOZVG2OuQ0W8ddiF6bLZBKDZHnnIenbpw==} ··· 12908 12931 resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} 12909 12932 dependencies: 12910 12933 loose-envify: 1.4.0 12911 - dev: false 12912 12934 12913 12935 /scroll-into-view-if-needed@3.0.10: 12914 12936 resolution: {integrity: sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg==} ··· 13198 13220 /streamsearch@1.1.0: 13199 13221 resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} 13200 13222 engines: {node: '>=10.0.0'} 13201 - dev: false 13202 13223 13203 13224 /string-width@4.2.3: 13204 13225 resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} ··· 13350 13371 '@babel/core': 7.22.9 13351 13372 client-only: 0.0.1 13352 13373 react: 18.2.0 13353 - dev: false 13354 13374 13355 13375 /stylis@4.3.0: 13356 13376 resolution: {integrity: sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==} ··· 14685 14705 dependencies: 14686 14706 glob-to-regexp: 0.4.1 14687 14707 graceful-fs: 4.2.11 14688 - dev: false 14689 14708 14690 14709 /wcwidth@1.0.1: 14691 14710 resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
+1 -1
pnpm-workspace.yaml
··· 1 1 packages: 2 2 - "apps/*" 3 - - "packages/*" 3 + - "packages/**/*" 4 4 - "packages/config/*" 5 5 - "packages/emails/.react-email"