Openstatus www.openstatus.dev

feat: custom api key (#1715)

* custom api key

* phase 2

* implment phase 3

* implment phase 4

* implement phase 5

* implement phase 6

* format

* fix build

* ci: apply automated fixes

* fixing stuff

* ci: apply automated fixes

* fix test

* fmt

* use react-hook-form

* ci: apply automated fixes

* improve pr

* improve pr

* improve db

* fix: ui

* fix: truncate text

* fix: copy button

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Maximilian Kaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
autofix-ci[bot]
Maximilian Kaske
and committed by
GitHub
a40cde27 2d569402

+4440 -147
+1 -1
apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx
··· 17 17 const queryClient = getQueryClient(); 18 18 await queryClient.prefetchQuery(trpc.member.list.queryOptions()); 19 19 await queryClient.prefetchQuery(trpc.invitation.list.queryOptions()); 20 - await queryClient.prefetchQuery(trpc.apiKey.get.queryOptions()); 20 + await queryClient.prefetchQuery(trpc.apiKeyRouter.getAll.queryOptions()); 21 21 22 22 return ( 23 23 <HydrateClient>
+64 -16
apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
··· 1 + import { QuickActions } from "@/components/dropdowns/quick-actions"; 1 2 import { 2 3 Table, 3 4 TableBody, 4 5 TableCell, 5 6 TableHead, 7 + TableHeader, 6 8 TableRow, 7 9 } from "@/components/ui/table"; 10 + import { formatDate } from "@/lib/formatter"; 11 + import { useTRPC } from "@/lib/trpc/client"; 8 12 import type { RouterOutputs } from "@openstatus/api"; 13 + import { useMutation } from "@tanstack/react-query"; 9 14 10 - type ApiKey = RouterOutputs["apiKey"]["get"]; 15 + type ApiKey = RouterOutputs["apiKeyRouter"]["getAll"][number]; 11 16 12 - export function DataTable({ apiKey }: { apiKey: ApiKey }) { 17 + export function DataTable({ 18 + apiKeys, 19 + refetch, 20 + }: { 21 + apiKeys: ApiKey[]; 22 + refetch: () => void; 23 + }) { 24 + const trpc = useTRPC(); 25 + const revokeApiKeyMutation = useMutation( 26 + trpc.apiKeyRouter.revoke.mutationOptions({ 27 + onSuccess: () => refetch(), 28 + }), 29 + ); 30 + 13 31 return ( 14 - <Table> 15 - <TableBody> 16 - <TableRow className="[&>:not(:last-child)]:border-r"> 17 - <TableHead className="h-auto bg-muted/50">Created At</TableHead> 18 - <TableCell> 19 - {new Date(apiKey.createdAt).toLocaleDateString()} 20 - </TableCell> 21 - </TableRow> 22 - <TableRow className="[&>:not(:last-child)]:border-r"> 23 - <TableHead className="h-auto bg-muted/50">Token</TableHead> 24 - <TableCell>{apiKey.start}...</TableCell> 25 - </TableRow> 26 - </TableBody> 27 - </Table> 32 + <div className="overflow-x-auto"> 33 + <Table> 34 + <TableHeader> 35 + <TableRow> 36 + <TableHead>Name</TableHead> 37 + <TableHead>Description</TableHead> 38 + <TableHead>Prefix</TableHead> 39 + <TableHead>Expires</TableHead> 40 + <TableHead> 41 + <span className="sr-only">Actions</span> 42 + </TableHead> 43 + </TableRow> 44 + </TableHeader> 45 + <TableBody> 46 + {apiKeys.map((apiKey) => ( 47 + <TableRow key={apiKey.id}> 48 + <TableCell className="font-medium">{apiKey.name}</TableCell> 49 + <TableCell className="max-w-[200px] truncate text-muted-foreground"> 50 + {apiKey.description ?? "-"} 51 + </TableCell> 52 + <TableCell> 53 + <code className="text-xs">{apiKey.prefix}...</code> 54 + </TableCell> 55 + <TableCell className="text-sm"> 56 + {apiKey.expiresAt ? formatDate(apiKey.expiresAt) : "-"} 57 + </TableCell> 58 + <TableCell> 59 + <div className="flex justify-end"> 60 + <QuickActions 61 + deleteAction={{ 62 + confirmationValue: apiKey.name ?? "api key", 63 + submitAction: async () => 64 + await revokeApiKeyMutation.mutateAsync({ 65 + keyId: apiKey.id, 66 + }), 67 + }} 68 + /> 69 + </div> 70 + </TableCell> 71 + </TableRow> 72 + ))} 73 + </TableBody> 74 + </Table> 75 + </div> 28 76 ); 29 77 }
+209 -50
apps/dashboard/src/components/forms/settings/form-api-key.tsx
··· 7 7 } from "@/components/content/empty-state"; 8 8 import { EmptyStateContainer } from "@/components/content/empty-state"; 9 9 import { DataTable } from "@/components/data-table/settings/api-key/data-table"; 10 - import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; 11 10 import { 12 11 FormCard, 13 12 FormCardContent, ··· 26 25 AlertDialogTitle, 27 26 } from "@/components/ui/alert-dialog"; 28 27 import { Button } from "@/components/ui/button"; 28 + import { Calendar } from "@/components/ui/calendar"; 29 + import { 30 + Dialog, 31 + DialogContent, 32 + DialogDescription, 33 + DialogFooter, 34 + DialogHeader, 35 + DialogTitle, 36 + DialogTrigger, 37 + } from "@/components/ui/dialog"; 38 + import { 39 + Form, 40 + FormControl, 41 + FormField, 42 + FormItem, 43 + FormLabel, 44 + FormMessage, 45 + } from "@/components/ui/form"; 46 + import { Input } from "@/components/ui/input"; 47 + import { 48 + Popover, 49 + PopoverContent, 50 + PopoverTrigger, 51 + } from "@/components/ui/popover"; 52 + import { Textarea } from "@/components/ui/textarea"; 29 53 import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; 30 54 import { useTRPC } from "@/lib/trpc/client"; 55 + import { cn } from "@/lib/utils"; 56 + import { zodResolver } from "@hookform/resolvers/zod"; 31 57 import { useMutation, useQuery } from "@tanstack/react-query"; 32 58 import { isTRPCClientError } from "@trpc/client"; 33 - import { Copy } from "lucide-react"; 59 + import { format } from "date-fns"; 60 + import { CalendarIcon, Check, Copy } from "lucide-react"; 34 61 import { useState, useTransition } from "react"; 62 + import { useForm } from "react-hook-form"; 35 63 import { toast } from "sonner"; 64 + import { z } from "zod"; 36 65 37 66 // we should prefetch the api key on the server (layout) 38 67 68 + const schema = z.object({ 69 + name: z.string().min(1, "Name is required"), 70 + description: z.string().optional(), 71 + expiresAt: z.string().optional(), 72 + }); 73 + 74 + type FormValues = z.infer<typeof schema>; 75 + 39 76 export function FormApiKey() { 40 77 const trpc = useTRPC(); 41 78 const [isPending, startTransition] = useTransition(); 42 - const { copy } = useCopyToClipboard(); 79 + const { copy, isCopied } = useCopyToClipboard(); 43 80 const [result, setResult] = useState<{ 44 - keyId: string; 81 + token: string; 45 82 key: string; 46 83 } | null>(null); 84 + const [createDialogOpen, setCreateDialogOpen] = useState(false); 85 + 86 + const form = useForm<FormValues>({ 87 + resolver: zodResolver(schema), 88 + defaultValues: { 89 + name: "", 90 + description: "", 91 + expiresAt: "", 92 + }, 93 + }); 94 + 47 95 const { data: workspace } = useQuery( 48 96 trpc.workspace.getWorkspace.queryOptions(), 49 97 ); 50 - const { data: apiKey, refetch } = useQuery(trpc.apiKey.get.queryOptions()); 98 + const { data: apiKeys = [], refetch } = useQuery( 99 + trpc.apiKeyRouter.getAll.queryOptions(), 100 + ); 51 101 const createApiKeyMutation = useMutation( 52 - trpc.apiKey.create.mutationOptions({ 102 + trpc.apiKeyRouter.create.mutationOptions({ 53 103 onSuccess: (data) => { 54 104 if (data) { 55 - setResult(data); 105 + refetch(); 106 + setResult({ token: data.token, key: data.key.name }); 107 + setCreateDialogOpen(false); 108 + form.reset(); 56 109 } else { 57 110 throw new Error("Failed to create API key"); 58 111 } 59 112 }, 60 113 }), 61 114 ); 62 - const revokeApiKeyMutation = useMutation( 63 - trpc.apiKey.revoke.mutationOptions({ 64 - onSuccess: () => refetch(), 65 - }), 66 - ); 67 115 68 - async function createAction() { 69 - if (isPending || !workspace) return; 116 + function createAction(values: FormValues) { 117 + if (isPending || !workspace) { 118 + return; 119 + } 70 120 71 121 startTransition(async () => { 72 122 try { 73 123 const promise = createApiKeyMutation.mutateAsync({ 74 - ownerId: workspace.id, 124 + name: values.name.trim(), 125 + description: values.description?.trim() || undefined, 126 + expiresAt: values.expiresAt ? new Date(values.expiresAt) : undefined, 75 127 }); 76 128 toast.promise(promise, { 77 129 loading: "Creating...", ··· 93 145 return ( 94 146 <FormCard> 95 147 <FormCardHeader> 96 - <FormCardTitle>API Key</FormCardTitle> 148 + <FormCardTitle>API Keys</FormCardTitle> 97 149 <FormCardDescription> 98 - Create and revoke your API key. 150 + Create and manage your API keys. 99 151 </FormCardDescription> 100 152 </FormCardHeader> 101 153 <FormCardContent> 102 - {!apiKey ? ( 154 + {apiKeys.length === 0 ? ( 103 155 <EmptyStateContainer> 104 - <EmptyStateTitle>No API key</EmptyStateTitle> 156 + <EmptyStateTitle>No API keys</EmptyStateTitle> 105 157 <EmptyStateDescription> 106 158 Access your data via API. 107 159 </EmptyStateDescription> 108 160 </EmptyStateContainer> 109 161 ) : ( 110 - <DataTable apiKey={apiKey} /> 162 + <DataTable apiKeys={apiKeys} refetch={refetch} /> 111 163 )} 112 164 </FormCardContent> 113 165 <FormCardFooter> ··· 122 174 </Link> 123 175 . 124 176 </FormCardFooterInfo> 125 - {!apiKey ? ( 126 - <Button size="sm" onClick={createAction}> 127 - Create 128 - </Button> 129 - ) : ( 130 - <FormAlertDialog 131 - confirmationValue="API Key" 132 - submitAction={async () => { 133 - await revokeApiKeyMutation.mutateAsync({ 134 - keyId: apiKey.keyId, 135 - }); 136 - }} 137 - /> 138 - )} 177 + <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}> 178 + <DialogTrigger asChild> 179 + <Button size="sm">Create</Button> 180 + </DialogTrigger> 181 + <DialogContent> 182 + <Form {...form}> 183 + <form onSubmit={form.handleSubmit(createAction)}> 184 + <DialogHeader> 185 + <DialogTitle>Create API Key</DialogTitle> 186 + <DialogDescription> 187 + Create a new API key to access your workspace data. 188 + </DialogDescription> 189 + </DialogHeader> 190 + <div className="space-y-4"> 191 + <FormField 192 + control={form.control} 193 + name="name" 194 + render={({ field }) => ( 195 + <FormItem> 196 + <FormLabel>Name</FormLabel> 197 + <FormControl> 198 + <Input placeholder="Production API" {...field} /> 199 + </FormControl> 200 + <FormMessage /> 201 + </FormItem> 202 + )} 203 + /> 204 + <FormField 205 + control={form.control} 206 + name="description" 207 + render={({ field }) => ( 208 + <FormItem> 209 + <FormLabel>Description</FormLabel> 210 + <FormControl> 211 + <Textarea 212 + placeholder="Used for production deployment" 213 + rows={3} 214 + {...field} 215 + /> 216 + </FormControl> 217 + <FormMessage /> 218 + </FormItem> 219 + )} 220 + /> 221 + <FormField 222 + control={form.control} 223 + name="expiresAt" 224 + render={({ field }) => ( 225 + <FormItem className="flex flex-col"> 226 + <FormLabel>Expiration Date</FormLabel> 227 + <Popover modal> 228 + <FormControl> 229 + <PopoverTrigger asChild> 230 + <Button 231 + type="button" 232 + variant="outline" 233 + size="sm" 234 + className={cn( 235 + "w-[240px] pl-3 text-left font-normal", 236 + !field.value && "text-muted-foreground", 237 + )} 238 + > 239 + {field.value ? ( 240 + format(new Date(field.value), "PPP") 241 + ) : ( 242 + <span>Pick a date</span> 243 + )} 244 + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> 245 + </Button> 246 + </PopoverTrigger> 247 + </FormControl> 248 + <PopoverContent 249 + className="pointer-events-auto w-auto p-0" 250 + align="start" 251 + > 252 + <Calendar 253 + mode="single" 254 + selected={ 255 + field.value ? new Date(field.value) : undefined 256 + } 257 + onSelect={(date) => { 258 + if (!date) { 259 + field.onChange(""); 260 + return; 261 + } 262 + // Convert to ISO string and take only the date part (YYYY-MM-DD) 263 + const dateString = date 264 + .toISOString() 265 + .split("T")[0]; 266 + field.onChange(dateString); 267 + }} 268 + disabled={(date) => { 269 + const today = new Date(); 270 + today.setHours(0, 0, 0, 0); 271 + const compareDate = new Date(date); 272 + compareDate.setHours(0, 0, 0, 0); 273 + return compareDate < today; 274 + }} 275 + initialFocus 276 + /> 277 + </PopoverContent> 278 + </Popover> 279 + <FormMessage /> 280 + </FormItem> 281 + )} 282 + /> 283 + </div> 284 + <DialogFooter className="mt-4"> 285 + <Button 286 + variant="outline" 287 + type="button" 288 + onClick={() => setCreateDialogOpen(false)} 289 + > 290 + Cancel 291 + </Button> 292 + <Button type="submit" disabled={isPending}> 293 + Create 294 + </Button> 295 + </DialogFooter> 296 + </form> 297 + </Form> 298 + </DialogContent> 299 + </Dialog> 139 300 </FormCardFooter> 140 301 <AlertDialog open={!!result} onOpenChange={() => setResult(null)}> 141 302 <AlertDialogContent> 142 303 <AlertDialogHeader> 143 - <AlertDialogTitle>API Key</AlertDialogTitle> 304 + <AlertDialogTitle>API Key Created</AlertDialogTitle> 144 305 <AlertDialogDescription> 145 306 Ensure you copy your API key before closing this dialog. You will 146 307 not see it again. 147 308 </AlertDialogDescription> 148 309 </AlertDialogHeader> 149 - <div className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-2"> 150 - <code className="flex-1 font-mono text-sm">{result?.key}</code> 310 + <div> 151 311 <Button 152 - variant="ghost" 153 - size="icon" 154 - className="h-8 w-8" 312 + variant="outline" 313 + size="sm" 155 314 onClick={() => { 156 - copy(result?.key || "", { withToast: true }); 315 + copy(result?.token || "", { 316 + successMessage: "Copied API key to clipboard", 317 + }); 157 318 }} 158 319 > 159 - <Copy className="h-4 w-4" /> 320 + <code>{result?.token}</code> 321 + {isCopied ? ( 322 + <Check size={16} className="text-muted-foreground" /> 323 + ) : ( 324 + <Copy size={16} className="text-muted-foreground" /> 325 + )} 160 326 </Button> 161 327 </div> 162 328 <AlertDialogFooter> 163 - <Button 164 - onClick={() => { 165 - refetch(); 166 - setResult(null); 167 - }} 168 - > 169 - Done 170 - </Button> 329 + <Button onClick={() => setResult(null)}>Done</Button> 171 330 </AlertDialogFooter> 172 331 </AlertDialogContent> 173 332 </AlertDialog>
+1 -1
apps/dashboard/src/lib/trpc/shared.ts
··· 11 11 return "http://localhost:3000"; // Local dev and Docker (internal calls) 12 12 }; 13 13 14 - const lambdas = ["stripeRouter", "emailRouter"]; 14 + const lambdas = ["stripeRouter", "emailRouter", "apiKeyRouter"]; 15 15 16 16 export const endingLink = (opts?: { 17 17 fetch?: typeof fetch;
+62 -4
apps/server/src/libs/middlewares/auth.ts
··· 5 5 import { env } from "@/env"; 6 6 import { OpenStatusApiError } from "@/libs/errors"; 7 7 import type { Variables } from "@/types"; 8 + import { getLogger } from "@logtape/logtape"; 8 9 import { db, eq } from "@openstatus/db"; 9 10 import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; 11 + import { apiKey } from "@openstatus/db/src/schema/api-keys"; 12 + import { 13 + shouldUpdateLastUsed, 14 + verifyApiKeyHash, 15 + } from "@openstatus/db/src/utils/api-key"; 16 + 17 + const logger = getLogger("api-server"); 10 18 11 19 export async function authMiddleware( 12 20 c: Context<{ Variables: Variables }, "/*">, ··· 78 86 }> { 79 87 if (env.NODE_ENV === "production") { 80 88 /** 81 - * The Unkey api key starts with `os_` - that's how we can differentiate if we 82 - * want to roll out our own key verification in the future. 83 - * > We cannot use `os_` as a prefix for our own keys. 89 + * Both custom and Unkey API keys use the `os_` prefix for seamless transition. 90 + * Custom keys are checked first in the database, then falls back to Unkey. 84 91 */ 85 92 if (key.startsWith("os_")) { 93 + // Validate token format before database query 94 + if (!/^os_[a-f0-9]{32}$/.test(key)) { 95 + return { 96 + result: { valid: false }, 97 + error: { message: "Invalid API Key format" }, 98 + }; 99 + } 100 + 101 + // 1. Try custom DB first 102 + const prefix = key.slice(0, 11); // "os_" (3 chars) + 8 hex chars = 11 total 103 + const customKey = await db 104 + .select() 105 + .from(apiKey) 106 + .where(eq(apiKey.prefix, prefix)) 107 + .get(); 108 + 109 + if (customKey) { 110 + // Verify hash using bcrypt-compatible verification 111 + if (!(await verifyApiKeyHash(key, customKey.hashedToken))) { 112 + return { 113 + result: { valid: false }, 114 + error: { message: "Invalid API Key" }, 115 + }; 116 + } 117 + // Check expiration 118 + if (customKey.expiresAt && customKey.expiresAt < new Date()) { 119 + return { 120 + result: { valid: false }, 121 + error: { message: "API Key expired" }, 122 + }; 123 + } 124 + 125 + // Update lastUsedAt (debounced) 126 + if (shouldUpdateLastUsed(customKey.lastUsedAt)) { 127 + await db 128 + .update(apiKey) 129 + .set({ lastUsedAt: new Date() }) 130 + .where(eq(apiKey.id, customKey.id)); 131 + } 132 + return { 133 + result: { valid: true, ownerId: String(customKey.workspaceId) }, 134 + }; 135 + } 136 + 137 + // 2. Fall back to Unkey (transition period) 86 138 const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); 87 139 const res = await keysVerifyKey(unkey, { key }); 88 140 if (!res.ok) { 89 - console.error("Unkey Error", res.error?.message); 141 + logger.error("Unkey Error {*}", { ...res.error }); 90 142 return { 91 143 result: { valid: false, ownerId: undefined }, 92 144 error: { message: "Invalid API verification" }, 93 145 }; 146 + } 147 + // Add deprecation header when Unkey key is used 148 + if (res.value.data.valid) { 149 + logger.info("Unkey key used - Workspace: {workspaceId}", { 150 + workspace: res.value.data.identity?.externalId, 151 + }); 94 152 } 95 153 return { 96 154 result: {
+5 -5
package.json
··· 32 32 }, 33 33 "packageManager": "pnpm@10.26.0", 34 34 "pnpm": { 35 - "onlyBuiltDependencies": ["@sentry/cli", "@swc/core", "sharp"], 36 - "overrides": { 37 - "@react-email/preview-server": "npm:next@16.0.10" 38 - } 35 + "onlyBuiltDependencies": ["@sentry/cli", "@swc/core", "sharp"] 39 36 }, 40 37 "name": "openstatus", 41 - "workspaces": ["apps/*", "packages/**/*"] 38 + "workspaces": ["apps/*", "packages/**/*", "packages/config/*"], 39 + "overrides": { 40 + "@react-email/preview-server": "npm:next@16.0.10" 41 + } 42 42 }
-2
packages/api/src/edge.ts
··· 1 - import { apiKeyRouter } from "./router/apiKey"; 2 1 import { blobRouter } from "./router/blob"; 3 2 import { checkerRouter } from "./router/checker"; 4 3 import { domainRouter } from "./router/domain"; ··· 23 22 24 23 // Deployed to /trpc/edge/** 25 24 export const edgeRouter = createTRPCRouter({ 26 - apiKey: apiKeyRouter, 27 25 workspace: workspaceRouter, 28 26 monitor: monitorRouter, 29 27 page: pageRouter,
+2
packages/api/src/lambda.ts
··· 1 + import { apiKeyRouter } from "./router/apiKey"; 1 2 import { emailRouter } from "./router/email"; 2 3 import { stripeRouter } from "./router/stripe"; 3 4 import { createTRPCRouter } from "./trpc"; ··· 5 6 export const lambdaRouter = createTRPCRouter({ 6 7 stripeRouter: stripeRouter, 7 8 emailRouter: emailRouter, 9 + apiKeyRouter: apiKeyRouter, 8 10 }); 9 11 10 12 export { stripe } from "./router/stripe/shared";
+53 -44
packages/api/src/router/apiKey.ts
··· 1 - import { UnkeyCore } from "@unkey/api/core"; 2 - import { apisListKeys } from "@unkey/api/funcs/apisListKeys"; 3 - import { keysCreateKey } from "@unkey/api/funcs/keysCreateKey"; 4 - import { keysDeleteKey } from "@unkey/api/funcs/keysDeleteKey"; 5 1 import { z } from "zod"; 6 2 7 3 import { Events } from "@openstatus/analytics"; 8 4 import { db, eq } from "@openstatus/db"; 9 5 import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 6 + import { createApiKeySchema } from "@openstatus/db/src/schema/api-keys/validation"; 10 7 11 8 import { TRPCError } from "@trpc/server"; 12 - import { env } from "../env"; 9 + import { 10 + createApiKey as createCustomApiKey, 11 + getApiKeys, 12 + revokeApiKey, 13 + } from "../service/apiKey"; 13 14 import { createTRPCRouter, protectedProcedure } from "../trpc"; 14 15 15 16 export const apiKeyRouter = createTRPCRouter({ 16 17 create: protectedProcedure 17 18 .meta({ track: Events.CreateAPI }) 18 - .input(z.object({ ownerId: z.number() })) 19 + .input(createApiKeySchema) 19 20 .mutation(async ({ input, ctx }) => { 20 - const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); 21 - 21 + // Verify user has access to the workspace 22 22 const allowedWorkspaces = await db 23 23 .select() 24 24 .from(usersToWorkspaces) ··· 29 29 30 30 const allowedIds = allowedWorkspaces.map((i) => i.workspace.id); 31 31 32 - if (!allowedIds.includes(input.ownerId)) { 32 + if (!allowedIds.includes(ctx.workspace.id)) { 33 33 throw new TRPCError({ 34 34 code: "UNAUTHORIZED", 35 35 message: "Unauthorized", 36 36 }); 37 37 } 38 38 39 - const res = await keysCreateKey(unkey, { 40 - apiId: env.UNKEY_API_ID, 41 - externalId: String(input.ownerId), 42 - prefix: "os", 43 - }); 44 - 45 - if (!res.ok) { 46 - throw new TRPCError({ 47 - code: "BAD_REQUEST", 48 - message: res.error.message, 49 - }); 50 - } 39 + // Create the API key using the custom service 40 + const { token, key } = await createCustomApiKey( 41 + ctx.workspace.id, 42 + ctx.user.id, 43 + input.name, 44 + input.description, 45 + input.expiresAt, 46 + ); 51 47 52 - return res.value.data; 48 + // Return both the key details and the full token (one-time display) 49 + return { 50 + token, 51 + key, 52 + }; 53 53 }), 54 54 55 55 revoke: protectedProcedure 56 56 .meta({ track: Events.RevokeAPI }) 57 - .input(z.object({ keyId: z.string() })) 58 - .mutation(async ({ input }) => { 59 - const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); 57 + .input(z.object({ keyId: z.number() })) 58 + .mutation(async ({ input, ctx }) => { 59 + // Revoke the key with workspace ownership verification 60 + const success = await revokeApiKey(input.keyId, ctx.workspace.id); 60 61 61 - const res = await keysDeleteKey(unkey, { keyId: input.keyId }); 62 - 63 - if (!res.ok) { 62 + if (!success) { 64 63 throw new TRPCError({ 65 - code: "BAD_REQUEST", 66 - message: res.error.message, 64 + code: "NOT_FOUND", 65 + message: "API key not found or unauthorized", 67 66 }); 68 67 } 69 68 70 - return res.value; 69 + return; 71 70 }), 72 71 73 - get: protectedProcedure.query(async ({ ctx }) => { 74 - const unkey = new UnkeyCore({ rootKey: env.UNKEY_TOKEN }); 72 + getAll: protectedProcedure.query(async ({ ctx }) => { 73 + // Get all API keys for the workspace 74 + const keys = await getApiKeys(ctx.workspace.id); 75 75 76 - const res = await apisListKeys(unkey, { 77 - externalId: String(ctx.workspace.id), 78 - apiId: env.UNKEY_API_ID, 79 - }); 76 + // Fetch user information for each key's creator 77 + const keysWithUserInfo = await Promise.all( 78 + keys.map(async (key) => { 79 + const creator = await db 80 + .select({ 81 + id: user.id, 82 + email: user.email, 83 + firstName: user.firstName, 84 + lastName: user.lastName, 85 + }) 86 + .from(user) 87 + .where(eq(user.id, key.createdById)) 88 + .get(); 80 89 81 - if (!res.ok) { 82 - throw new TRPCError({ 83 - code: "BAD_REQUEST", 84 - message: res.error.message, 85 - }); 86 - } 90 + return { 91 + ...key, 92 + createdBy: creator, 93 + }; 94 + }), 95 + ); 87 96 88 - return res.value.data[0]; 97 + return keysWithUserInfo; 89 98 }), 90 99 });
+434
packages/api/src/service/apiKey.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + 3 + import { db, eq } from "@openstatus/db"; 4 + import { apiKey } from "@openstatus/db/src/schema"; 5 + import { verifyApiKeyHash } from "@openstatus/db/src/utils/api-key"; 6 + 7 + import { 8 + createApiKey, 9 + getApiKeys, 10 + revokeApiKey, 11 + updateLastUsed, 12 + verifyApiKey, 13 + } from "./apiKey"; 14 + 15 + // Test data setup 16 + let testWorkspaceId: number; 17 + let testUserId: number; 18 + let testApiKeyId: number; 19 + let testToken: string; 20 + 21 + beforeAll(async () => { 22 + // Clean up any existing test data 23 + await db.delete(apiKey).where(eq(apiKey.name, "Test API Key")); 24 + await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description")); 25 + await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration")); 26 + 27 + // Use existing test workspace and user from seed data 28 + testWorkspaceId = 1; 29 + testUserId = 1; 30 + }); 31 + 32 + afterAll(async () => { 33 + // Clean up test data 34 + await db.delete(apiKey).where(eq(apiKey.name, "Test API Key")); 35 + await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Description")); 36 + await db.delete(apiKey).where(eq(apiKey.name, "Test Key with Expiration")); 37 + }); 38 + 39 + describe("createApiKey", () => { 40 + test("should create API key with minimal parameters", async () => { 41 + const result = await createApiKey( 42 + testWorkspaceId, 43 + testUserId, 44 + "Test API Key", 45 + ); 46 + 47 + expect(result).toBeDefined(); 48 + expect(result.token).toMatch(/^os_[a-f0-9]{32}$/); 49 + expect(result.key).toMatchObject({ 50 + name: "Test API Key", 51 + workspaceId: testWorkspaceId, 52 + createdById: testUserId, 53 + description: null, 54 + expiresAt: null, 55 + }); 56 + expect(result.key.prefix).toBe(result.token.slice(0, 11)); 57 + expect(await verifyApiKeyHash(result.token, result.key.hashedToken)).toBe( 58 + true, 59 + ); 60 + 61 + // Save for later tests 62 + testApiKeyId = result.key.id; 63 + testToken = result.token; 64 + }); 65 + 66 + test("should create API key with description", async () => { 67 + const description = "This is a test API key for integration testing"; 68 + const result = await createApiKey( 69 + testWorkspaceId, 70 + testUserId, 71 + "Test Key with Description", 72 + description, 73 + ); 74 + 75 + expect(result.key.description).toBe(description); 76 + }); 77 + 78 + test("should create API key with expiration", async () => { 79 + const expiresAt = new Date(Date.now() + 86400000); // 1 day from now 80 + const result = await createApiKey( 81 + testWorkspaceId, 82 + testUserId, 83 + "Test Key with Expiration", 84 + undefined, 85 + expiresAt, 86 + ); 87 + 88 + // SQLite stores timestamps with second precision, so compare with tolerance 89 + expect(result.key.expiresAt?.getTime()).toBeCloseTo( 90 + expiresAt.getTime(), 91 + -4, 92 + ); 93 + }); 94 + 95 + test("should create API key with both description and expiration", async () => { 96 + const description = "Full featured key"; 97 + const expiresAt = new Date(Date.now() + 86400000); 98 + const result = await createApiKey( 99 + testWorkspaceId, 100 + testUserId, 101 + "Full Featured Key", 102 + description, 103 + expiresAt, 104 + ); 105 + 106 + expect(result.key).toMatchObject({ 107 + name: "Full Featured Key", 108 + description, 109 + expiresAt, 110 + }); 111 + 112 + // Clean up 113 + await db.delete(apiKey).where(eq(apiKey.id, result.key.id)); 114 + }); 115 + 116 + test("should generate unique tokens", async () => { 117 + const result1 = await createApiKey( 118 + testWorkspaceId, 119 + testUserId, 120 + "Unique Key 1", 121 + ); 122 + const result2 = await createApiKey( 123 + testWorkspaceId, 124 + testUserId, 125 + "Unique Key 2", 126 + ); 127 + 128 + expect(result1.token).not.toBe(result2.token); 129 + expect(result1.key.prefix).not.toBe(result2.key.prefix); 130 + expect(result1.key.hashedToken).not.toBe(result2.key.hashedToken); 131 + 132 + // Clean up 133 + await db.delete(apiKey).where(eq(apiKey.id, result1.key.id)); 134 + await db.delete(apiKey).where(eq(apiKey.id, result2.key.id)); 135 + }); 136 + }); 137 + 138 + describe("verifyApiKey", () => { 139 + test("should verify valid API key", async () => { 140 + const result = await verifyApiKey(testToken); 141 + 142 + expect(result).not.toBeNull(); 143 + expect(result).toMatchObject({ 144 + id: testApiKeyId, 145 + name: "Test API Key", 146 + workspaceId: testWorkspaceId, 147 + createdById: testUserId, 148 + }); 149 + }); 150 + 151 + test("should return null for invalid token format", async () => { 152 + const invalidToken = "os_invalid"; 153 + const result = await verifyApiKey(invalidToken); 154 + 155 + expect(result).toBeNull(); 156 + }); 157 + 158 + test("should return null for non-existent token", async () => { 159 + const nonExistentToken = `os_${"a".repeat(32)}`; 160 + const result = await verifyApiKey(nonExistentToken); 161 + 162 + expect(result).toBeNull(); 163 + }); 164 + 165 + test("should return null for token with incorrect hash", async () => { 166 + // Create a token with same prefix but different hash 167 + const wrongToken = testToken.slice(0, 11) + "0".repeat(24); 168 + const result = await verifyApiKey(wrongToken); 169 + 170 + expect(result).toBeNull(); 171 + }); 172 + 173 + test("should return null for expired token", async () => { 174 + // Create an expired key 175 + const expiredDate = new Date(Date.now() - 86400000); // 1 day ago 176 + const expiredKey = await createApiKey( 177 + testWorkspaceId, 178 + testUserId, 179 + "Expired Key", 180 + undefined, 181 + expiredDate, 182 + ); 183 + 184 + const result = await verifyApiKey(expiredKey.token); 185 + 186 + expect(result).toBeNull(); 187 + 188 + // Clean up 189 + await db.delete(apiKey).where(eq(apiKey.id, expiredKey.key.id)); 190 + }); 191 + 192 + test("should verify token that expires in the future", async () => { 193 + // Create a key that expires in the future 194 + const futureDate = new Date(Date.now() + 86400000); // 1 day from now 195 + const futureKey = await createApiKey( 196 + testWorkspaceId, 197 + testUserId, 198 + "Future Expiry Key", 199 + undefined, 200 + futureDate, 201 + ); 202 + 203 + const result = await verifyApiKey(futureKey.token); 204 + 205 + expect(result).not.toBeNull(); 206 + expect(result?.id).toBe(futureKey.key.id); 207 + 208 + // Clean up 209 + await db.delete(apiKey).where(eq(apiKey.id, futureKey.key.id)); 210 + }); 211 + }); 212 + 213 + describe("revokeApiKey", () => { 214 + test("should revoke API key successfully", async () => { 215 + // Create a key to revoke 216 + const keyToRevoke = await createApiKey( 217 + testWorkspaceId, 218 + testUserId, 219 + "Key to Revoke", 220 + ); 221 + 222 + const result = await revokeApiKey(keyToRevoke.key.id, testWorkspaceId); 223 + 224 + expect(result).toBe(true); 225 + 226 + // Verify key is deleted 227 + const deletedKey = await db 228 + .select() 229 + .from(apiKey) 230 + .where(eq(apiKey.id, keyToRevoke.key.id)) 231 + .get(); 232 + 233 + expect(deletedKey).toBeUndefined(); 234 + }); 235 + 236 + test("should return false for non-existent key", async () => { 237 + const result = await revokeApiKey(999999, testWorkspaceId); 238 + 239 + expect(result).toBe(false); 240 + }); 241 + 242 + test("should return false when workspace ID doesn't match", async () => { 243 + // Create a key 244 + const key = await createApiKey(testWorkspaceId, testUserId, "Test Key"); 245 + 246 + // Try to revoke with wrong workspace ID 247 + const result = await revokeApiKey(key.key.id, 999); 248 + 249 + expect(result).toBe(false); 250 + 251 + // Verify key still exists 252 + const stillExists = await db 253 + .select() 254 + .from(apiKey) 255 + .where(eq(apiKey.id, key.key.id)) 256 + .get(); 257 + 258 + expect(stillExists).toBeDefined(); 259 + 260 + // Clean up 261 + await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); 262 + }); 263 + }); 264 + 265 + describe("getApiKeys", () => { 266 + test("should get all API keys for a workspace", async () => { 267 + // Create multiple keys 268 + const key1 = await createApiKey( 269 + testWorkspaceId, 270 + testUserId, 271 + "Workspace Key 1", 272 + ); 273 + const key2 = await createApiKey( 274 + testWorkspaceId, 275 + testUserId, 276 + "Workspace Key 2", 277 + ); 278 + const key3 = await createApiKey( 279 + testWorkspaceId, 280 + testUserId, 281 + "Workspace Key 3", 282 + ); 283 + 284 + const keys = await getApiKeys(testWorkspaceId); 285 + 286 + // Should include at least the 3 keys we just created plus the test key from earlier 287 + expect(keys.length).toBeGreaterThanOrEqual(4); 288 + expect(keys.some((k) => k.name === "Workspace Key 1")).toBe(true); 289 + expect(keys.some((k) => k.name === "Workspace Key 2")).toBe(true); 290 + expect(keys.some((k) => k.name === "Workspace Key 3")).toBe(true); 291 + 292 + // All keys should belong to the test workspace 293 + keys.forEach((key) => { 294 + expect(key.workspaceId).toBe(testWorkspaceId); 295 + }); 296 + 297 + // Clean up 298 + await db.delete(apiKey).where(eq(apiKey.id, key1.key.id)); 299 + await db.delete(apiKey).where(eq(apiKey.id, key2.key.id)); 300 + await db.delete(apiKey).where(eq(apiKey.id, key3.key.id)); 301 + }); 302 + 303 + test("should return empty array for workspace with no keys", async () => { 304 + // Use a non-existent workspace ID 305 + const keys = await getApiKeys(999999); 306 + 307 + expect(keys).toEqual([]); 308 + }); 309 + 310 + test("should not include keys from other workspaces", async () => { 311 + // Assuming there might be other workspaces, verify isolation 312 + const keys = await getApiKeys(testWorkspaceId); 313 + 314 + keys.forEach((key) => { 315 + expect(key.workspaceId).toBe(testWorkspaceId); 316 + }); 317 + }); 318 + }); 319 + 320 + describe("updateLastUsed", () => { 321 + test("should update lastUsedAt when never used", async () => { 322 + const key = await createApiKey( 323 + testWorkspaceId, 324 + testUserId, 325 + "Never Used Key", 326 + ); 327 + 328 + const result = await updateLastUsed(key.key.id, null); 329 + 330 + expect(result).toBe(true); 331 + 332 + // Verify the update 333 + const updatedKey = await db 334 + .select() 335 + .from(apiKey) 336 + .where(eq(apiKey.id, key.key.id)) 337 + .get(); 338 + 339 + expect(updatedKey?.lastUsedAt).not.toBeNull(); 340 + expect(updatedKey?.lastUsedAt).toBeInstanceOf(Date); 341 + 342 + // Clean up 343 + await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); 344 + }); 345 + 346 + test("should update lastUsedAt when debounce period has passed", async () => { 347 + const key = await createApiKey( 348 + testWorkspaceId, 349 + testUserId, 350 + "Debounce Test Key", 351 + ); 352 + 353 + // Set lastUsedAt to 10 minutes ago (beyond 5-minute debounce) 354 + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); 355 + await db 356 + .update(apiKey) 357 + .set({ lastUsedAt: tenMinutesAgo }) 358 + .where(eq(apiKey.id, key.key.id)); 359 + 360 + const result = await updateLastUsed(key.key.id, tenMinutesAgo); 361 + 362 + expect(result).toBe(true); 363 + 364 + // Verify the update 365 + const updatedKey = await db 366 + .select() 367 + .from(apiKey) 368 + .where(eq(apiKey.id, key.key.id)) 369 + .get(); 370 + 371 + expect(updatedKey?.lastUsedAt?.getTime()).toBeGreaterThan( 372 + tenMinutesAgo.getTime(), 373 + ); 374 + 375 + // Clean up 376 + await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); 377 + }); 378 + 379 + test("should not update lastUsedAt within debounce period", async () => { 380 + const key = await createApiKey( 381 + testWorkspaceId, 382 + testUserId, 383 + "Recent Use Key", 384 + ); 385 + 386 + // Set lastUsedAt to 2 minutes ago (within 5-minute debounce) 387 + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); 388 + await db 389 + .update(apiKey) 390 + .set({ lastUsedAt: twoMinutesAgo }) 391 + .where(eq(apiKey.id, key.key.id)); 392 + 393 + const result = await updateLastUsed(key.key.id, twoMinutesAgo); 394 + 395 + expect(result).toBe(false); 396 + 397 + // Verify no update occurred (compare with tolerance due to SQLite timestamp precision) 398 + const updatedKey = await db 399 + .select() 400 + .from(apiKey) 401 + .where(eq(apiKey.id, key.key.id)) 402 + .get(); 403 + 404 + expect(updatedKey?.lastUsedAt?.getTime()).toBeCloseTo( 405 + twoMinutesAgo.getTime(), 406 + -4, 407 + ); 408 + 409 + // Clean up 410 + await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); 411 + }); 412 + 413 + test("should update at exactly 5 minutes (boundary test)", async () => { 414 + const key = await createApiKey( 415 + testWorkspaceId, 416 + testUserId, 417 + "Boundary Test Key", 418 + ); 419 + 420 + // Set lastUsedAt to exactly 5 minutes and 1ms ago 421 + const fiveMinutesAgo = new Date(Date.now() - (5 * 60 * 1000 + 1)); 422 + await db 423 + .update(apiKey) 424 + .set({ lastUsedAt: fiveMinutesAgo }) 425 + .where(eq(apiKey.id, key.key.id)); 426 + 427 + const result = await updateLastUsed(key.key.id, fiveMinutesAgo); 428 + 429 + expect(result).toBe(true); 430 + 431 + // Clean up 432 + await db.delete(apiKey).where(eq(apiKey.id, key.key.id)); 433 + }); 434 + });
+149
packages/api/src/service/apiKey.ts
··· 1 + import { eq } from "@openstatus/db"; 2 + import { db } from "@openstatus/db"; 3 + import { apiKey } from "@openstatus/db/src/schema"; 4 + import { 5 + shouldUpdateLastUsed as checkShouldUpdateLastUsed, 6 + generateApiKey as generateKey, 7 + verifyApiKeyHash, 8 + } from "@openstatus/db/src/utils/api-key"; 9 + 10 + /** 11 + * Creates a new API key for a workspace 12 + * @param workspaceId - The workspace ID 13 + * @param createdById - The ID of the user creating the key 14 + * @param name - The name of the API key 15 + * @param description - Optional description for the key 16 + * @param expiresAt - Optional expiration date 17 + * @returns The full token (only shown once) and the created key details 18 + */ 19 + export async function createApiKey( 20 + workspaceId: number, 21 + createdById: number, 22 + name: string, 23 + description?: string, 24 + expiresAt?: Date, 25 + ): Promise<{ token: string; key: typeof apiKey.$inferSelect }> { 26 + const { token, prefix, hash } = await generateKey(); 27 + 28 + const [key] = await db 29 + .insert(apiKey) 30 + .values({ 31 + name, 32 + description, 33 + prefix, 34 + hashedToken: hash, 35 + workspaceId, 36 + createdById, 37 + expiresAt, 38 + }) 39 + .returning(); 40 + 41 + if (!key) { 42 + throw new Error("Failed to create API key"); 43 + } 44 + 45 + return { token, key }; 46 + } 47 + 48 + /** 49 + * Verifies an API key token 50 + * @param token - The API key token to verify 51 + * @returns The API key details if valid, null otherwise 52 + */ 53 + export async function verifyApiKey( 54 + token: string, 55 + ): Promise<typeof apiKey.$inferSelect | null> { 56 + // Validate token format before database query 57 + if (!/^os_[a-f0-9]{32}$/.test(token)) { 58 + return null; 59 + } 60 + 61 + // Extract prefix from token 62 + const prefix = token.slice(0, 11); // "os_" + 8 chars = 11 total 63 + 64 + // Look up key by prefix 65 + const key = await db 66 + .select() 67 + .from(apiKey) 68 + .where(eq(apiKey.prefix, prefix)) 69 + .get(); 70 + 71 + if (!key) { 72 + return null; 73 + } 74 + 75 + // Verify hash using bcrypt-compatible verification 76 + if (!(await verifyApiKeyHash(token, key.hashedToken))) { 77 + return null; 78 + } 79 + 80 + // Check expiration 81 + if (key.expiresAt && key.expiresAt < new Date()) { 82 + return null; 83 + } 84 + 85 + return key; 86 + } 87 + 88 + /** 89 + * Revokes (deletes) an API key 90 + * @param id - The API key ID 91 + * @param workspaceId - The workspace ID for ownership verification 92 + * @returns True if successfully revoked, false otherwise 93 + */ 94 + export async function revokeApiKey( 95 + id: number, 96 + workspaceId: number, 97 + ): Promise<boolean> { 98 + // First, verify the key exists and belongs to the workspace 99 + const key = await db.select().from(apiKey).where(eq(apiKey.id, id)).get(); 100 + 101 + if (!key || key.workspaceId !== workspaceId) { 102 + return false; 103 + } 104 + 105 + // Delete the key 106 + await db.delete(apiKey).where(eq(apiKey.id, id)); 107 + 108 + return true; 109 + } 110 + 111 + /** 112 + * Gets all API keys for a workspace 113 + * @param workspaceId - The workspace ID 114 + * @returns Array of API keys for the workspace 115 + */ 116 + export async function getApiKeys( 117 + workspaceId: number, 118 + ): Promise<Array<typeof apiKey.$inferSelect>> { 119 + const keys = await db 120 + .select() 121 + .from(apiKey) 122 + .where(eq(apiKey.workspaceId, workspaceId)) 123 + .all(); 124 + 125 + return keys; 126 + } 127 + 128 + /** 129 + * Updates the lastUsedAt timestamp for an API key (with debouncing) 130 + * @param id - The API key ID 131 + * @param lastUsedAt - The current lastUsedAt value (or null) 132 + * @returns True if updated, false if skipped due to debounce 133 + */ 134 + export async function updateLastUsed( 135 + id: number, 136 + lastUsedAt: Date | null, 137 + ): Promise<boolean> { 138 + // Check if update is needed (5-minute debounce) 139 + if (!checkShouldUpdateLastUsed(lastUsedAt)) { 140 + return false; 141 + } 142 + 143 + await db 144 + .update(apiKey) 145 + .set({ lastUsedAt: new Date() }) 146 + .where(eq(apiKey.id, id)); 147 + 148 + return true; 149 + }
+18
packages/db/drizzle/0052_illegal_killraven.sql
··· 1 + CREATE TABLE `api_key` ( 2 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 + `name` text NOT NULL, 4 + `description` text, 5 + `prefix` text NOT NULL, 6 + `hashed_token` text NOT NULL, 7 + `workspace_id` integer NOT NULL, 8 + `created_by_id` integer NOT NULL, 9 + `created_at` integer DEFAULT (strftime('%s', 'now')), 10 + `expires_at` integer, 11 + `last_used_at` integer, 12 + FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE cascade, 13 + FOREIGN KEY (`created_by_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade 14 + ); 15 + --> statement-breakpoint 16 + CREATE UNIQUE INDEX `api_key_prefix_unique` ON `api_key` (`prefix`);--> statement-breakpoint 17 + CREATE UNIQUE INDEX `api_key_hashed_token_unique` ON `api_key` (`hashed_token`);--> statement-breakpoint 18 + CREATE INDEX `api_key_prefix_idx` ON `api_key` (`prefix`);
+3071
packages/db/drizzle/meta/0052_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "cc596587-5474-42a7-b638-1b7c771fbc5e", 5 + "prevId": "e8d9889c-8d28-4311-83e6-9fd28c840595", 6 + "tables": { 7 + "workspace": { 8 + "name": "workspace", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "integer", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "slug": { 18 + "name": "slug", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "name": { 25 + "name": "name", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false 30 + }, 31 + "stripe_id": { 32 + "name": "stripe_id", 33 + "type": "text(256)", 34 + "primaryKey": false, 35 + "notNull": false, 36 + "autoincrement": false 37 + }, 38 + "subscription_id": { 39 + "name": "subscription_id", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "plan": { 46 + "name": "plan", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": false, 50 + "autoincrement": false 51 + }, 52 + "ends_at": { 53 + "name": "ends_at", 54 + "type": "integer", 55 + "primaryKey": false, 56 + "notNull": false, 57 + "autoincrement": false 58 + }, 59 + "paid_until": { 60 + "name": "paid_until", 61 + "type": "integer", 62 + "primaryKey": false, 63 + "notNull": false, 64 + "autoincrement": false 65 + }, 66 + "limits": { 67 + "name": "limits", 68 + "type": "text", 69 + "primaryKey": false, 70 + "notNull": true, 71 + "autoincrement": false, 72 + "default": "'{}'" 73 + }, 74 + "created_at": { 75 + "name": "created_at", 76 + "type": "integer", 77 + "primaryKey": false, 78 + "notNull": false, 79 + "autoincrement": false, 80 + "default": "(strftime('%s', 'now'))" 81 + }, 82 + "updated_at": { 83 + "name": "updated_at", 84 + "type": "integer", 85 + "primaryKey": false, 86 + "notNull": false, 87 + "autoincrement": false, 88 + "default": "(strftime('%s', 'now'))" 89 + }, 90 + "dsn": { 91 + "name": "dsn", 92 + "type": "text", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false 96 + } 97 + }, 98 + "indexes": { 99 + "workspace_slug_unique": { 100 + "name": "workspace_slug_unique", 101 + "columns": [ 102 + "slug" 103 + ], 104 + "isUnique": true 105 + }, 106 + "workspace_stripe_id_unique": { 107 + "name": "workspace_stripe_id_unique", 108 + "columns": [ 109 + "stripe_id" 110 + ], 111 + "isUnique": true 112 + }, 113 + "workspace_id_dsn_unique": { 114 + "name": "workspace_id_dsn_unique", 115 + "columns": [ 116 + "id", 117 + "dsn" 118 + ], 119 + "isUnique": true 120 + } 121 + }, 122 + "foreignKeys": {}, 123 + "compositePrimaryKeys": {}, 124 + "uniqueConstraints": {}, 125 + "checkConstraints": {} 126 + }, 127 + "account": { 128 + "name": "account", 129 + "columns": { 130 + "user_id": { 131 + "name": "user_id", 132 + "type": "integer", 133 + "primaryKey": false, 134 + "notNull": true, 135 + "autoincrement": false 136 + }, 137 + "type": { 138 + "name": "type", 139 + "type": "text", 140 + "primaryKey": false, 141 + "notNull": true, 142 + "autoincrement": false 143 + }, 144 + "provider": { 145 + "name": "provider", 146 + "type": "text", 147 + "primaryKey": false, 148 + "notNull": true, 149 + "autoincrement": false 150 + }, 151 + "provider_account_id": { 152 + "name": "provider_account_id", 153 + "type": "text", 154 + "primaryKey": false, 155 + "notNull": true, 156 + "autoincrement": false 157 + }, 158 + "refresh_token": { 159 + "name": "refresh_token", 160 + "type": "text", 161 + "primaryKey": false, 162 + "notNull": false, 163 + "autoincrement": false 164 + }, 165 + "access_token": { 166 + "name": "access_token", 167 + "type": "text", 168 + "primaryKey": false, 169 + "notNull": false, 170 + "autoincrement": false 171 + }, 172 + "expires_at": { 173 + "name": "expires_at", 174 + "type": "integer", 175 + "primaryKey": false, 176 + "notNull": false, 177 + "autoincrement": false 178 + }, 179 + "token_type": { 180 + "name": "token_type", 181 + "type": "text", 182 + "primaryKey": false, 183 + "notNull": false, 184 + "autoincrement": false 185 + }, 186 + "scope": { 187 + "name": "scope", 188 + "type": "text", 189 + "primaryKey": false, 190 + "notNull": false, 191 + "autoincrement": false 192 + }, 193 + "id_token": { 194 + "name": "id_token", 195 + "type": "text", 196 + "primaryKey": false, 197 + "notNull": false, 198 + "autoincrement": false 199 + }, 200 + "session_state": { 201 + "name": "session_state", 202 + "type": "text", 203 + "primaryKey": false, 204 + "notNull": false, 205 + "autoincrement": false 206 + } 207 + }, 208 + "indexes": {}, 209 + "foreignKeys": { 210 + "account_user_id_user_id_fk": { 211 + "name": "account_user_id_user_id_fk", 212 + "tableFrom": "account", 213 + "tableTo": "user", 214 + "columnsFrom": [ 215 + "user_id" 216 + ], 217 + "columnsTo": [ 218 + "id" 219 + ], 220 + "onDelete": "cascade", 221 + "onUpdate": "no action" 222 + } 223 + }, 224 + "compositePrimaryKeys": { 225 + "account_provider_provider_account_id_pk": { 226 + "columns": [ 227 + "provider", 228 + "provider_account_id" 229 + ], 230 + "name": "account_provider_provider_account_id_pk" 231 + } 232 + }, 233 + "uniqueConstraints": {}, 234 + "checkConstraints": {} 235 + }, 236 + "session": { 237 + "name": "session", 238 + "columns": { 239 + "session_token": { 240 + "name": "session_token", 241 + "type": "text", 242 + "primaryKey": true, 243 + "notNull": true, 244 + "autoincrement": false 245 + }, 246 + "user_id": { 247 + "name": "user_id", 248 + "type": "integer", 249 + "primaryKey": false, 250 + "notNull": true, 251 + "autoincrement": false 252 + }, 253 + "expires": { 254 + "name": "expires", 255 + "type": "integer", 256 + "primaryKey": false, 257 + "notNull": true, 258 + "autoincrement": false 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "session_user_id_user_id_fk": { 264 + "name": "session_user_id_user_id_fk", 265 + "tableFrom": "session", 266 + "tableTo": "user", 267 + "columnsFrom": [ 268 + "user_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {}, 279 + "checkConstraints": {} 280 + }, 281 + "user": { 282 + "name": "user", 283 + "columns": { 284 + "id": { 285 + "name": "id", 286 + "type": "integer", 287 + "primaryKey": true, 288 + "notNull": true, 289 + "autoincrement": false 290 + }, 291 + "tenant_id": { 292 + "name": "tenant_id", 293 + "type": "text(256)", 294 + "primaryKey": false, 295 + "notNull": false, 296 + "autoincrement": false 297 + }, 298 + "first_name": { 299 + "name": "first_name", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": false, 303 + "autoincrement": false, 304 + "default": "''" 305 + }, 306 + "last_name": { 307 + "name": "last_name", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": false, 311 + "autoincrement": false, 312 + "default": "''" 313 + }, 314 + "photo_url": { 315 + "name": "photo_url", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": false, 319 + "autoincrement": false, 320 + "default": "''" 321 + }, 322 + "name": { 323 + "name": "name", 324 + "type": "text", 325 + "primaryKey": false, 326 + "notNull": false, 327 + "autoincrement": false 328 + }, 329 + "email": { 330 + "name": "email", 331 + "type": "text", 332 + "primaryKey": false, 333 + "notNull": false, 334 + "autoincrement": false, 335 + "default": "''" 336 + }, 337 + "emailVerified": { 338 + "name": "emailVerified", 339 + "type": "integer", 340 + "primaryKey": false, 341 + "notNull": false, 342 + "autoincrement": false 343 + }, 344 + "created_at": { 345 + "name": "created_at", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false, 349 + "autoincrement": false, 350 + "default": "(strftime('%s', 'now'))" 351 + }, 352 + "updated_at": { 353 + "name": "updated_at", 354 + "type": "integer", 355 + "primaryKey": false, 356 + "notNull": false, 357 + "autoincrement": false, 358 + "default": "(strftime('%s', 'now'))" 359 + } 360 + }, 361 + "indexes": { 362 + "user_tenant_id_unique": { 363 + "name": "user_tenant_id_unique", 364 + "columns": [ 365 + "tenant_id" 366 + ], 367 + "isUnique": true 368 + } 369 + }, 370 + "foreignKeys": {}, 371 + "compositePrimaryKeys": {}, 372 + "uniqueConstraints": {}, 373 + "checkConstraints": {} 374 + }, 375 + "users_to_workspaces": { 376 + "name": "users_to_workspaces", 377 + "columns": { 378 + "user_id": { 379 + "name": "user_id", 380 + "type": "integer", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "workspace_id": { 386 + "name": "workspace_id", 387 + "type": "integer", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "role": { 393 + "name": "role", 394 + "type": "text", 395 + "primaryKey": false, 396 + "notNull": true, 397 + "autoincrement": false, 398 + "default": "'member'" 399 + }, 400 + "created_at": { 401 + "name": "created_at", 402 + "type": "integer", 403 + "primaryKey": false, 404 + "notNull": false, 405 + "autoincrement": false, 406 + "default": "(strftime('%s', 'now'))" 407 + } 408 + }, 409 + "indexes": {}, 410 + "foreignKeys": { 411 + "users_to_workspaces_user_id_user_id_fk": { 412 + "name": "users_to_workspaces_user_id_user_id_fk", 413 + "tableFrom": "users_to_workspaces", 414 + "tableTo": "user", 415 + "columnsFrom": [ 416 + "user_id" 417 + ], 418 + "columnsTo": [ 419 + "id" 420 + ], 421 + "onDelete": "no action", 422 + "onUpdate": "no action" 423 + }, 424 + "users_to_workspaces_workspace_id_workspace_id_fk": { 425 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 426 + "tableFrom": "users_to_workspaces", 427 + "tableTo": "workspace", 428 + "columnsFrom": [ 429 + "workspace_id" 430 + ], 431 + "columnsTo": [ 432 + "id" 433 + ], 434 + "onDelete": "no action", 435 + "onUpdate": "no action" 436 + } 437 + }, 438 + "compositePrimaryKeys": { 439 + "users_to_workspaces_user_id_workspace_id_pk": { 440 + "columns": [ 441 + "user_id", 442 + "workspace_id" 443 + ], 444 + "name": "users_to_workspaces_user_id_workspace_id_pk" 445 + } 446 + }, 447 + "uniqueConstraints": {}, 448 + "checkConstraints": {} 449 + }, 450 + "verification_token": { 451 + "name": "verification_token", 452 + "columns": { 453 + "identifier": { 454 + "name": "identifier", 455 + "type": "text", 456 + "primaryKey": false, 457 + "notNull": true, 458 + "autoincrement": false 459 + }, 460 + "token": { 461 + "name": "token", 462 + "type": "text", 463 + "primaryKey": false, 464 + "notNull": true, 465 + "autoincrement": false 466 + }, 467 + "expires": { 468 + "name": "expires", 469 + "type": "integer", 470 + "primaryKey": false, 471 + "notNull": true, 472 + "autoincrement": false 473 + } 474 + }, 475 + "indexes": {}, 476 + "foreignKeys": {}, 477 + "compositePrimaryKeys": { 478 + "verification_token_identifier_token_pk": { 479 + "columns": [ 480 + "identifier", 481 + "token" 482 + ], 483 + "name": "verification_token_identifier_token_pk" 484 + } 485 + }, 486 + "uniqueConstraints": {}, 487 + "checkConstraints": {} 488 + }, 489 + "status_report_to_monitors": { 490 + "name": "status_report_to_monitors", 491 + "columns": { 492 + "monitor_id": { 493 + "name": "monitor_id", 494 + "type": "integer", 495 + "primaryKey": false, 496 + "notNull": true, 497 + "autoincrement": false 498 + }, 499 + "status_report_id": { 500 + "name": "status_report_id", 501 + "type": "integer", 502 + "primaryKey": false, 503 + "notNull": true, 504 + "autoincrement": false 505 + }, 506 + "created_at": { 507 + "name": "created_at", 508 + "type": "integer", 509 + "primaryKey": false, 510 + "notNull": false, 511 + "autoincrement": false, 512 + "default": "(strftime('%s', 'now'))" 513 + } 514 + }, 515 + "indexes": {}, 516 + "foreignKeys": { 517 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 518 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 519 + "tableFrom": "status_report_to_monitors", 520 + "tableTo": "monitor", 521 + "columnsFrom": [ 522 + "monitor_id" 523 + ], 524 + "columnsTo": [ 525 + "id" 526 + ], 527 + "onDelete": "cascade", 528 + "onUpdate": "no action" 529 + }, 530 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 531 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 532 + "tableFrom": "status_report_to_monitors", 533 + "tableTo": "status_report", 534 + "columnsFrom": [ 535 + "status_report_id" 536 + ], 537 + "columnsTo": [ 538 + "id" 539 + ], 540 + "onDelete": "cascade", 541 + "onUpdate": "no action" 542 + } 543 + }, 544 + "compositePrimaryKeys": { 545 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 546 + "columns": [ 547 + "monitor_id", 548 + "status_report_id" 549 + ], 550 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 551 + } 552 + }, 553 + "uniqueConstraints": {}, 554 + "checkConstraints": {} 555 + }, 556 + "status_report": { 557 + "name": "status_report", 558 + "columns": { 559 + "id": { 560 + "name": "id", 561 + "type": "integer", 562 + "primaryKey": true, 563 + "notNull": true, 564 + "autoincrement": false 565 + }, 566 + "status": { 567 + "name": "status", 568 + "type": "text", 569 + "primaryKey": false, 570 + "notNull": true, 571 + "autoincrement": false 572 + }, 573 + "title": { 574 + "name": "title", 575 + "type": "text(256)", 576 + "primaryKey": false, 577 + "notNull": true, 578 + "autoincrement": false 579 + }, 580 + "workspace_id": { 581 + "name": "workspace_id", 582 + "type": "integer", 583 + "primaryKey": false, 584 + "notNull": false, 585 + "autoincrement": false 586 + }, 587 + "page_id": { 588 + "name": "page_id", 589 + "type": "integer", 590 + "primaryKey": false, 591 + "notNull": false, 592 + "autoincrement": false 593 + }, 594 + "created_at": { 595 + "name": "created_at", 596 + "type": "integer", 597 + "primaryKey": false, 598 + "notNull": false, 599 + "autoincrement": false, 600 + "default": "(strftime('%s', 'now'))" 601 + }, 602 + "updated_at": { 603 + "name": "updated_at", 604 + "type": "integer", 605 + "primaryKey": false, 606 + "notNull": false, 607 + "autoincrement": false, 608 + "default": "(strftime('%s', 'now'))" 609 + } 610 + }, 611 + "indexes": {}, 612 + "foreignKeys": { 613 + "status_report_workspace_id_workspace_id_fk": { 614 + "name": "status_report_workspace_id_workspace_id_fk", 615 + "tableFrom": "status_report", 616 + "tableTo": "workspace", 617 + "columnsFrom": [ 618 + "workspace_id" 619 + ], 620 + "columnsTo": [ 621 + "id" 622 + ], 623 + "onDelete": "no action", 624 + "onUpdate": "no action" 625 + }, 626 + "status_report_page_id_page_id_fk": { 627 + "name": "status_report_page_id_page_id_fk", 628 + "tableFrom": "status_report", 629 + "tableTo": "page", 630 + "columnsFrom": [ 631 + "page_id" 632 + ], 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onDelete": "cascade", 637 + "onUpdate": "no action" 638 + } 639 + }, 640 + "compositePrimaryKeys": {}, 641 + "uniqueConstraints": {}, 642 + "checkConstraints": {} 643 + }, 644 + "status_report_update": { 645 + "name": "status_report_update", 646 + "columns": { 647 + "id": { 648 + "name": "id", 649 + "type": "integer", 650 + "primaryKey": true, 651 + "notNull": true, 652 + "autoincrement": false 653 + }, 654 + "status": { 655 + "name": "status", 656 + "type": "text", 657 + "primaryKey": false, 658 + "notNull": true, 659 + "autoincrement": false 660 + }, 661 + "date": { 662 + "name": "date", 663 + "type": "integer", 664 + "primaryKey": false, 665 + "notNull": true, 666 + "autoincrement": false 667 + }, 668 + "message": { 669 + "name": "message", 670 + "type": "text", 671 + "primaryKey": false, 672 + "notNull": true, 673 + "autoincrement": false 674 + }, 675 + "status_report_id": { 676 + "name": "status_report_id", 677 + "type": "integer", 678 + "primaryKey": false, 679 + "notNull": true, 680 + "autoincrement": false 681 + }, 682 + "created_at": { 683 + "name": "created_at", 684 + "type": "integer", 685 + "primaryKey": false, 686 + "notNull": false, 687 + "autoincrement": false, 688 + "default": "(strftime('%s', 'now'))" 689 + }, 690 + "updated_at": { 691 + "name": "updated_at", 692 + "type": "integer", 693 + "primaryKey": false, 694 + "notNull": false, 695 + "autoincrement": false, 696 + "default": "(strftime('%s', 'now'))" 697 + } 698 + }, 699 + "indexes": {}, 700 + "foreignKeys": { 701 + "status_report_update_status_report_id_status_report_id_fk": { 702 + "name": "status_report_update_status_report_id_status_report_id_fk", 703 + "tableFrom": "status_report_update", 704 + "tableTo": "status_report", 705 + "columnsFrom": [ 706 + "status_report_id" 707 + ], 708 + "columnsTo": [ 709 + "id" 710 + ], 711 + "onDelete": "cascade", 712 + "onUpdate": "no action" 713 + } 714 + }, 715 + "compositePrimaryKeys": {}, 716 + "uniqueConstraints": {}, 717 + "checkConstraints": {} 718 + }, 719 + "integration": { 720 + "name": "integration", 721 + "columns": { 722 + "id": { 723 + "name": "id", 724 + "type": "integer", 725 + "primaryKey": true, 726 + "notNull": true, 727 + "autoincrement": false 728 + }, 729 + "name": { 730 + "name": "name", 731 + "type": "text(256)", 732 + "primaryKey": false, 733 + "notNull": true, 734 + "autoincrement": false 735 + }, 736 + "workspace_id": { 737 + "name": "workspace_id", 738 + "type": "integer", 739 + "primaryKey": false, 740 + "notNull": false, 741 + "autoincrement": false 742 + }, 743 + "credential": { 744 + "name": "credential", 745 + "type": "text", 746 + "primaryKey": false, 747 + "notNull": false, 748 + "autoincrement": false 749 + }, 750 + "external_id": { 751 + "name": "external_id", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": true, 755 + "autoincrement": false 756 + }, 757 + "created_at": { 758 + "name": "created_at", 759 + "type": "integer", 760 + "primaryKey": false, 761 + "notNull": false, 762 + "autoincrement": false, 763 + "default": "(strftime('%s', 'now'))" 764 + }, 765 + "updated_at": { 766 + "name": "updated_at", 767 + "type": "integer", 768 + "primaryKey": false, 769 + "notNull": false, 770 + "autoincrement": false, 771 + "default": "(strftime('%s', 'now'))" 772 + }, 773 + "data": { 774 + "name": "data", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false 779 + } 780 + }, 781 + "indexes": {}, 782 + "foreignKeys": { 783 + "integration_workspace_id_workspace_id_fk": { 784 + "name": "integration_workspace_id_workspace_id_fk", 785 + "tableFrom": "integration", 786 + "tableTo": "workspace", 787 + "columnsFrom": [ 788 + "workspace_id" 789 + ], 790 + "columnsTo": [ 791 + "id" 792 + ], 793 + "onDelete": "no action", 794 + "onUpdate": "no action" 795 + } 796 + }, 797 + "compositePrimaryKeys": {}, 798 + "uniqueConstraints": {}, 799 + "checkConstraints": {} 800 + }, 801 + "page": { 802 + "name": "page", 803 + "columns": { 804 + "id": { 805 + "name": "id", 806 + "type": "integer", 807 + "primaryKey": true, 808 + "notNull": true, 809 + "autoincrement": false 810 + }, 811 + "workspace_id": { 812 + "name": "workspace_id", 813 + "type": "integer", 814 + "primaryKey": false, 815 + "notNull": true, 816 + "autoincrement": false 817 + }, 818 + "title": { 819 + "name": "title", 820 + "type": "text", 821 + "primaryKey": false, 822 + "notNull": true, 823 + "autoincrement": false 824 + }, 825 + "description": { 826 + "name": "description", 827 + "type": "text", 828 + "primaryKey": false, 829 + "notNull": true, 830 + "autoincrement": false 831 + }, 832 + "icon": { 833 + "name": "icon", 834 + "type": "text(256)", 835 + "primaryKey": false, 836 + "notNull": false, 837 + "autoincrement": false, 838 + "default": "''" 839 + }, 840 + "slug": { 841 + "name": "slug", 842 + "type": "text(256)", 843 + "primaryKey": false, 844 + "notNull": true, 845 + "autoincrement": false 846 + }, 847 + "custom_domain": { 848 + "name": "custom_domain", 849 + "type": "text(256)", 850 + "primaryKey": false, 851 + "notNull": true, 852 + "autoincrement": false 853 + }, 854 + "published": { 855 + "name": "published", 856 + "type": "integer", 857 + "primaryKey": false, 858 + "notNull": false, 859 + "autoincrement": false, 860 + "default": false 861 + }, 862 + "force_theme": { 863 + "name": "force_theme", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": true, 867 + "autoincrement": false, 868 + "default": "'system'" 869 + }, 870 + "password": { 871 + "name": "password", 872 + "type": "text(256)", 873 + "primaryKey": false, 874 + "notNull": false, 875 + "autoincrement": false 876 + }, 877 + "password_protected": { 878 + "name": "password_protected", 879 + "type": "integer", 880 + "primaryKey": false, 881 + "notNull": false, 882 + "autoincrement": false, 883 + "default": false 884 + }, 885 + "access_type": { 886 + "name": "access_type", 887 + "type": "text", 888 + "primaryKey": false, 889 + "notNull": false, 890 + "autoincrement": false, 891 + "default": "'public'" 892 + }, 893 + "auth_email_domains": { 894 + "name": "auth_email_domains", 895 + "type": "text", 896 + "primaryKey": false, 897 + "notNull": false, 898 + "autoincrement": false 899 + }, 900 + "homepage_url": { 901 + "name": "homepage_url", 902 + "type": "text(256)", 903 + "primaryKey": false, 904 + "notNull": false, 905 + "autoincrement": false 906 + }, 907 + "contact_url": { 908 + "name": "contact_url", 909 + "type": "text(256)", 910 + "primaryKey": false, 911 + "notNull": false, 912 + "autoincrement": false 913 + }, 914 + "legacy_page": { 915 + "name": "legacy_page", 916 + "type": "integer", 917 + "primaryKey": false, 918 + "notNull": true, 919 + "autoincrement": false, 920 + "default": true 921 + }, 922 + "configuration": { 923 + "name": "configuration", 924 + "type": "text", 925 + "primaryKey": false, 926 + "notNull": false, 927 + "autoincrement": false 928 + }, 929 + "show_monitor_values": { 930 + "name": "show_monitor_values", 931 + "type": "integer", 932 + "primaryKey": false, 933 + "notNull": false, 934 + "autoincrement": false, 935 + "default": true 936 + }, 937 + "created_at": { 938 + "name": "created_at", 939 + "type": "integer", 940 + "primaryKey": false, 941 + "notNull": false, 942 + "autoincrement": false, 943 + "default": "(strftime('%s', 'now'))" 944 + }, 945 + "updated_at": { 946 + "name": "updated_at", 947 + "type": "integer", 948 + "primaryKey": false, 949 + "notNull": false, 950 + "autoincrement": false, 951 + "default": "(strftime('%s', 'now'))" 952 + } 953 + }, 954 + "indexes": { 955 + "page_slug_unique": { 956 + "name": "page_slug_unique", 957 + "columns": [ 958 + "slug" 959 + ], 960 + "isUnique": true 961 + } 962 + }, 963 + "foreignKeys": { 964 + "page_workspace_id_workspace_id_fk": { 965 + "name": "page_workspace_id_workspace_id_fk", 966 + "tableFrom": "page", 967 + "tableTo": "workspace", 968 + "columnsFrom": [ 969 + "workspace_id" 970 + ], 971 + "columnsTo": [ 972 + "id" 973 + ], 974 + "onDelete": "cascade", 975 + "onUpdate": "no action" 976 + } 977 + }, 978 + "compositePrimaryKeys": {}, 979 + "uniqueConstraints": {}, 980 + "checkConstraints": {} 981 + }, 982 + "monitor": { 983 + "name": "monitor", 984 + "columns": { 985 + "id": { 986 + "name": "id", 987 + "type": "integer", 988 + "primaryKey": true, 989 + "notNull": true, 990 + "autoincrement": false 991 + }, 992 + "job_type": { 993 + "name": "job_type", 994 + "type": "text", 995 + "primaryKey": false, 996 + "notNull": true, 997 + "autoincrement": false, 998 + "default": "'http'" 999 + }, 1000 + "periodicity": { 1001 + "name": "periodicity", 1002 + "type": "text", 1003 + "primaryKey": false, 1004 + "notNull": true, 1005 + "autoincrement": false, 1006 + "default": "'other'" 1007 + }, 1008 + "status": { 1009 + "name": "status", 1010 + "type": "text", 1011 + "primaryKey": false, 1012 + "notNull": true, 1013 + "autoincrement": false, 1014 + "default": "'active'" 1015 + }, 1016 + "active": { 1017 + "name": "active", 1018 + "type": "integer", 1019 + "primaryKey": false, 1020 + "notNull": false, 1021 + "autoincrement": false, 1022 + "default": false 1023 + }, 1024 + "regions": { 1025 + "name": "regions", 1026 + "type": "text", 1027 + "primaryKey": false, 1028 + "notNull": true, 1029 + "autoincrement": false, 1030 + "default": "''" 1031 + }, 1032 + "url": { 1033 + "name": "url", 1034 + "type": "text(2048)", 1035 + "primaryKey": false, 1036 + "notNull": true, 1037 + "autoincrement": false 1038 + }, 1039 + "name": { 1040 + "name": "name", 1041 + "type": "text(256)", 1042 + "primaryKey": false, 1043 + "notNull": true, 1044 + "autoincrement": false, 1045 + "default": "''" 1046 + }, 1047 + "external_name": { 1048 + "name": "external_name", 1049 + "type": "text", 1050 + "primaryKey": false, 1051 + "notNull": false, 1052 + "autoincrement": false 1053 + }, 1054 + "description": { 1055 + "name": "description", 1056 + "type": "text", 1057 + "primaryKey": false, 1058 + "notNull": true, 1059 + "autoincrement": false, 1060 + "default": "''" 1061 + }, 1062 + "headers": { 1063 + "name": "headers", 1064 + "type": "text", 1065 + "primaryKey": false, 1066 + "notNull": false, 1067 + "autoincrement": false, 1068 + "default": "''" 1069 + }, 1070 + "body": { 1071 + "name": "body", 1072 + "type": "text", 1073 + "primaryKey": false, 1074 + "notNull": false, 1075 + "autoincrement": false, 1076 + "default": "''" 1077 + }, 1078 + "method": { 1079 + "name": "method", 1080 + "type": "text", 1081 + "primaryKey": false, 1082 + "notNull": false, 1083 + "autoincrement": false, 1084 + "default": "'GET'" 1085 + }, 1086 + "workspace_id": { 1087 + "name": "workspace_id", 1088 + "type": "integer", 1089 + "primaryKey": false, 1090 + "notNull": false, 1091 + "autoincrement": false 1092 + }, 1093 + "timeout": { 1094 + "name": "timeout", 1095 + "type": "integer", 1096 + "primaryKey": false, 1097 + "notNull": true, 1098 + "autoincrement": false, 1099 + "default": 45000 1100 + }, 1101 + "degraded_after": { 1102 + "name": "degraded_after", 1103 + "type": "integer", 1104 + "primaryKey": false, 1105 + "notNull": false, 1106 + "autoincrement": false 1107 + }, 1108 + "assertions": { 1109 + "name": "assertions", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": false, 1113 + "autoincrement": false 1114 + }, 1115 + "otel_endpoint": { 1116 + "name": "otel_endpoint", 1117 + "type": "text", 1118 + "primaryKey": false, 1119 + "notNull": false, 1120 + "autoincrement": false 1121 + }, 1122 + "otel_headers": { 1123 + "name": "otel_headers", 1124 + "type": "text", 1125 + "primaryKey": false, 1126 + "notNull": false, 1127 + "autoincrement": false 1128 + }, 1129 + "public": { 1130 + "name": "public", 1131 + "type": "integer", 1132 + "primaryKey": false, 1133 + "notNull": false, 1134 + "autoincrement": false, 1135 + "default": false 1136 + }, 1137 + "retry": { 1138 + "name": "retry", 1139 + "type": "integer", 1140 + "primaryKey": false, 1141 + "notNull": false, 1142 + "autoincrement": false, 1143 + "default": 3 1144 + }, 1145 + "follow_redirects": { 1146 + "name": "follow_redirects", 1147 + "type": "integer", 1148 + "primaryKey": false, 1149 + "notNull": false, 1150 + "autoincrement": false, 1151 + "default": true 1152 + }, 1153 + "created_at": { 1154 + "name": "created_at", 1155 + "type": "integer", 1156 + "primaryKey": false, 1157 + "notNull": false, 1158 + "autoincrement": false, 1159 + "default": "(strftime('%s', 'now'))" 1160 + }, 1161 + "updated_at": { 1162 + "name": "updated_at", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": false, 1166 + "autoincrement": false, 1167 + "default": "(strftime('%s', 'now'))" 1168 + }, 1169 + "deleted_at": { 1170 + "name": "deleted_at", 1171 + "type": "integer", 1172 + "primaryKey": false, 1173 + "notNull": false, 1174 + "autoincrement": false 1175 + } 1176 + }, 1177 + "indexes": {}, 1178 + "foreignKeys": { 1179 + "monitor_workspace_id_workspace_id_fk": { 1180 + "name": "monitor_workspace_id_workspace_id_fk", 1181 + "tableFrom": "monitor", 1182 + "tableTo": "workspace", 1183 + "columnsFrom": [ 1184 + "workspace_id" 1185 + ], 1186 + "columnsTo": [ 1187 + "id" 1188 + ], 1189 + "onDelete": "no action", 1190 + "onUpdate": "no action" 1191 + } 1192 + }, 1193 + "compositePrimaryKeys": {}, 1194 + "uniqueConstraints": {}, 1195 + "checkConstraints": {} 1196 + }, 1197 + "monitors_to_pages": { 1198 + "name": "monitors_to_pages", 1199 + "columns": { 1200 + "monitor_id": { 1201 + "name": "monitor_id", 1202 + "type": "integer", 1203 + "primaryKey": false, 1204 + "notNull": true, 1205 + "autoincrement": false 1206 + }, 1207 + "page_id": { 1208 + "name": "page_id", 1209 + "type": "integer", 1210 + "primaryKey": false, 1211 + "notNull": true, 1212 + "autoincrement": false 1213 + }, 1214 + "created_at": { 1215 + "name": "created_at", 1216 + "type": "integer", 1217 + "primaryKey": false, 1218 + "notNull": false, 1219 + "autoincrement": false, 1220 + "default": "(strftime('%s', 'now'))" 1221 + }, 1222 + "order": { 1223 + "name": "order", 1224 + "type": "integer", 1225 + "primaryKey": false, 1226 + "notNull": false, 1227 + "autoincrement": false, 1228 + "default": 0 1229 + }, 1230 + "monitor_group_id": { 1231 + "name": "monitor_group_id", 1232 + "type": "integer", 1233 + "primaryKey": false, 1234 + "notNull": false, 1235 + "autoincrement": false 1236 + }, 1237 + "group_order": { 1238 + "name": "group_order", 1239 + "type": "integer", 1240 + "primaryKey": false, 1241 + "notNull": false, 1242 + "autoincrement": false, 1243 + "default": 0 1244 + } 1245 + }, 1246 + "indexes": {}, 1247 + "foreignKeys": { 1248 + "monitors_to_pages_monitor_id_monitor_id_fk": { 1249 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 1250 + "tableFrom": "monitors_to_pages", 1251 + "tableTo": "monitor", 1252 + "columnsFrom": [ 1253 + "monitor_id" 1254 + ], 1255 + "columnsTo": [ 1256 + "id" 1257 + ], 1258 + "onDelete": "cascade", 1259 + "onUpdate": "no action" 1260 + }, 1261 + "monitors_to_pages_page_id_page_id_fk": { 1262 + "name": "monitors_to_pages_page_id_page_id_fk", 1263 + "tableFrom": "monitors_to_pages", 1264 + "tableTo": "page", 1265 + "columnsFrom": [ 1266 + "page_id" 1267 + ], 1268 + "columnsTo": [ 1269 + "id" 1270 + ], 1271 + "onDelete": "cascade", 1272 + "onUpdate": "no action" 1273 + }, 1274 + "monitors_to_pages_monitor_group_id_monitor_group_id_fk": { 1275 + "name": "monitors_to_pages_monitor_group_id_monitor_group_id_fk", 1276 + "tableFrom": "monitors_to_pages", 1277 + "tableTo": "monitor_group", 1278 + "columnsFrom": [ 1279 + "monitor_group_id" 1280 + ], 1281 + "columnsTo": [ 1282 + "id" 1283 + ], 1284 + "onDelete": "cascade", 1285 + "onUpdate": "no action" 1286 + } 1287 + }, 1288 + "compositePrimaryKeys": { 1289 + "monitors_to_pages_monitor_id_page_id_pk": { 1290 + "columns": [ 1291 + "monitor_id", 1292 + "page_id" 1293 + ], 1294 + "name": "monitors_to_pages_monitor_id_page_id_pk" 1295 + } 1296 + }, 1297 + "uniqueConstraints": {}, 1298 + "checkConstraints": {} 1299 + }, 1300 + "page_subscriber": { 1301 + "name": "page_subscriber", 1302 + "columns": { 1303 + "id": { 1304 + "name": "id", 1305 + "type": "integer", 1306 + "primaryKey": true, 1307 + "notNull": true, 1308 + "autoincrement": false 1309 + }, 1310 + "email": { 1311 + "name": "email", 1312 + "type": "text", 1313 + "primaryKey": false, 1314 + "notNull": true, 1315 + "autoincrement": false 1316 + }, 1317 + "page_id": { 1318 + "name": "page_id", 1319 + "type": "integer", 1320 + "primaryKey": false, 1321 + "notNull": true, 1322 + "autoincrement": false 1323 + }, 1324 + "token": { 1325 + "name": "token", 1326 + "type": "text", 1327 + "primaryKey": false, 1328 + "notNull": false, 1329 + "autoincrement": false 1330 + }, 1331 + "accepted_at": { 1332 + "name": "accepted_at", 1333 + "type": "integer", 1334 + "primaryKey": false, 1335 + "notNull": false, 1336 + "autoincrement": false 1337 + }, 1338 + "expires_at": { 1339 + "name": "expires_at", 1340 + "type": "integer", 1341 + "primaryKey": false, 1342 + "notNull": false, 1343 + "autoincrement": false 1344 + }, 1345 + "created_at": { 1346 + "name": "created_at", 1347 + "type": "integer", 1348 + "primaryKey": false, 1349 + "notNull": false, 1350 + "autoincrement": false, 1351 + "default": "(strftime('%s', 'now'))" 1352 + }, 1353 + "updated_at": { 1354 + "name": "updated_at", 1355 + "type": "integer", 1356 + "primaryKey": false, 1357 + "notNull": false, 1358 + "autoincrement": false, 1359 + "default": "(strftime('%s', 'now'))" 1360 + } 1361 + }, 1362 + "indexes": {}, 1363 + "foreignKeys": { 1364 + "page_subscriber_page_id_page_id_fk": { 1365 + "name": "page_subscriber_page_id_page_id_fk", 1366 + "tableFrom": "page_subscriber", 1367 + "tableTo": "page", 1368 + "columnsFrom": [ 1369 + "page_id" 1370 + ], 1371 + "columnsTo": [ 1372 + "id" 1373 + ], 1374 + "onDelete": "cascade", 1375 + "onUpdate": "no action" 1376 + } 1377 + }, 1378 + "compositePrimaryKeys": {}, 1379 + "uniqueConstraints": {}, 1380 + "checkConstraints": {} 1381 + }, 1382 + "notification": { 1383 + "name": "notification", 1384 + "columns": { 1385 + "id": { 1386 + "name": "id", 1387 + "type": "integer", 1388 + "primaryKey": true, 1389 + "notNull": true, 1390 + "autoincrement": false 1391 + }, 1392 + "name": { 1393 + "name": "name", 1394 + "type": "text", 1395 + "primaryKey": false, 1396 + "notNull": true, 1397 + "autoincrement": false 1398 + }, 1399 + "provider": { 1400 + "name": "provider", 1401 + "type": "text", 1402 + "primaryKey": false, 1403 + "notNull": true, 1404 + "autoincrement": false 1405 + }, 1406 + "data": { 1407 + "name": "data", 1408 + "type": "text", 1409 + "primaryKey": false, 1410 + "notNull": false, 1411 + "autoincrement": false, 1412 + "default": "'{}'" 1413 + }, 1414 + "workspace_id": { 1415 + "name": "workspace_id", 1416 + "type": "integer", 1417 + "primaryKey": false, 1418 + "notNull": false, 1419 + "autoincrement": false 1420 + }, 1421 + "created_at": { 1422 + "name": "created_at", 1423 + "type": "integer", 1424 + "primaryKey": false, 1425 + "notNull": false, 1426 + "autoincrement": false, 1427 + "default": "(strftime('%s', 'now'))" 1428 + }, 1429 + "updated_at": { 1430 + "name": "updated_at", 1431 + "type": "integer", 1432 + "primaryKey": false, 1433 + "notNull": false, 1434 + "autoincrement": false, 1435 + "default": "(strftime('%s', 'now'))" 1436 + } 1437 + }, 1438 + "indexes": {}, 1439 + "foreignKeys": { 1440 + "notification_workspace_id_workspace_id_fk": { 1441 + "name": "notification_workspace_id_workspace_id_fk", 1442 + "tableFrom": "notification", 1443 + "tableTo": "workspace", 1444 + "columnsFrom": [ 1445 + "workspace_id" 1446 + ], 1447 + "columnsTo": [ 1448 + "id" 1449 + ], 1450 + "onDelete": "no action", 1451 + "onUpdate": "no action" 1452 + } 1453 + }, 1454 + "compositePrimaryKeys": {}, 1455 + "uniqueConstraints": {}, 1456 + "checkConstraints": {} 1457 + }, 1458 + "notification_trigger": { 1459 + "name": "notification_trigger", 1460 + "columns": { 1461 + "id": { 1462 + "name": "id", 1463 + "type": "integer", 1464 + "primaryKey": true, 1465 + "notNull": true, 1466 + "autoincrement": false 1467 + }, 1468 + "monitor_id": { 1469 + "name": "monitor_id", 1470 + "type": "integer", 1471 + "primaryKey": false, 1472 + "notNull": false, 1473 + "autoincrement": false 1474 + }, 1475 + "notification_id": { 1476 + "name": "notification_id", 1477 + "type": "integer", 1478 + "primaryKey": false, 1479 + "notNull": false, 1480 + "autoincrement": false 1481 + }, 1482 + "cron_timestamp": { 1483 + "name": "cron_timestamp", 1484 + "type": "integer", 1485 + "primaryKey": false, 1486 + "notNull": true, 1487 + "autoincrement": false 1488 + } 1489 + }, 1490 + "indexes": { 1491 + "notification_id_monitor_id_crontimestampe": { 1492 + "name": "notification_id_monitor_id_crontimestampe", 1493 + "columns": [ 1494 + "notification_id", 1495 + "monitor_id", 1496 + "cron_timestamp" 1497 + ], 1498 + "isUnique": true 1499 + } 1500 + }, 1501 + "foreignKeys": { 1502 + "notification_trigger_monitor_id_monitor_id_fk": { 1503 + "name": "notification_trigger_monitor_id_monitor_id_fk", 1504 + "tableFrom": "notification_trigger", 1505 + "tableTo": "monitor", 1506 + "columnsFrom": [ 1507 + "monitor_id" 1508 + ], 1509 + "columnsTo": [ 1510 + "id" 1511 + ], 1512 + "onDelete": "cascade", 1513 + "onUpdate": "no action" 1514 + }, 1515 + "notification_trigger_notification_id_notification_id_fk": { 1516 + "name": "notification_trigger_notification_id_notification_id_fk", 1517 + "tableFrom": "notification_trigger", 1518 + "tableTo": "notification", 1519 + "columnsFrom": [ 1520 + "notification_id" 1521 + ], 1522 + "columnsTo": [ 1523 + "id" 1524 + ], 1525 + "onDelete": "cascade", 1526 + "onUpdate": "no action" 1527 + } 1528 + }, 1529 + "compositePrimaryKeys": {}, 1530 + "uniqueConstraints": {}, 1531 + "checkConstraints": {} 1532 + }, 1533 + "notifications_to_monitors": { 1534 + "name": "notifications_to_monitors", 1535 + "columns": { 1536 + "monitor_id": { 1537 + "name": "monitor_id", 1538 + "type": "integer", 1539 + "primaryKey": false, 1540 + "notNull": true, 1541 + "autoincrement": false 1542 + }, 1543 + "notification_id": { 1544 + "name": "notification_id", 1545 + "type": "integer", 1546 + "primaryKey": false, 1547 + "notNull": true, 1548 + "autoincrement": false 1549 + }, 1550 + "created_at": { 1551 + "name": "created_at", 1552 + "type": "integer", 1553 + "primaryKey": false, 1554 + "notNull": false, 1555 + "autoincrement": false, 1556 + "default": "(strftime('%s', 'now'))" 1557 + } 1558 + }, 1559 + "indexes": {}, 1560 + "foreignKeys": { 1561 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1562 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1563 + "tableFrom": "notifications_to_monitors", 1564 + "tableTo": "monitor", 1565 + "columnsFrom": [ 1566 + "monitor_id" 1567 + ], 1568 + "columnsTo": [ 1569 + "id" 1570 + ], 1571 + "onDelete": "cascade", 1572 + "onUpdate": "no action" 1573 + }, 1574 + "notifications_to_monitors_notification_id_notification_id_fk": { 1575 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1576 + "tableFrom": "notifications_to_monitors", 1577 + "tableTo": "notification", 1578 + "columnsFrom": [ 1579 + "notification_id" 1580 + ], 1581 + "columnsTo": [ 1582 + "id" 1583 + ], 1584 + "onDelete": "cascade", 1585 + "onUpdate": "no action" 1586 + } 1587 + }, 1588 + "compositePrimaryKeys": { 1589 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1590 + "columns": [ 1591 + "monitor_id", 1592 + "notification_id" 1593 + ], 1594 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1595 + } 1596 + }, 1597 + "uniqueConstraints": {}, 1598 + "checkConstraints": {} 1599 + }, 1600 + "monitor_status": { 1601 + "name": "monitor_status", 1602 + "columns": { 1603 + "monitor_id": { 1604 + "name": "monitor_id", 1605 + "type": "integer", 1606 + "primaryKey": false, 1607 + "notNull": true, 1608 + "autoincrement": false 1609 + }, 1610 + "region": { 1611 + "name": "region", 1612 + "type": "text", 1613 + "primaryKey": false, 1614 + "notNull": true, 1615 + "autoincrement": false, 1616 + "default": "''" 1617 + }, 1618 + "status": { 1619 + "name": "status", 1620 + "type": "text", 1621 + "primaryKey": false, 1622 + "notNull": true, 1623 + "autoincrement": false, 1624 + "default": "'active'" 1625 + }, 1626 + "created_at": { 1627 + "name": "created_at", 1628 + "type": "integer", 1629 + "primaryKey": false, 1630 + "notNull": false, 1631 + "autoincrement": false, 1632 + "default": "(strftime('%s', 'now'))" 1633 + }, 1634 + "updated_at": { 1635 + "name": "updated_at", 1636 + "type": "integer", 1637 + "primaryKey": false, 1638 + "notNull": false, 1639 + "autoincrement": false, 1640 + "default": "(strftime('%s', 'now'))" 1641 + } 1642 + }, 1643 + "indexes": { 1644 + "monitor_status_idx": { 1645 + "name": "monitor_status_idx", 1646 + "columns": [ 1647 + "monitor_id", 1648 + "region" 1649 + ], 1650 + "isUnique": false 1651 + } 1652 + }, 1653 + "foreignKeys": { 1654 + "monitor_status_monitor_id_monitor_id_fk": { 1655 + "name": "monitor_status_monitor_id_monitor_id_fk", 1656 + "tableFrom": "monitor_status", 1657 + "tableTo": "monitor", 1658 + "columnsFrom": [ 1659 + "monitor_id" 1660 + ], 1661 + "columnsTo": [ 1662 + "id" 1663 + ], 1664 + "onDelete": "cascade", 1665 + "onUpdate": "no action" 1666 + } 1667 + }, 1668 + "compositePrimaryKeys": { 1669 + "monitor_status_monitor_id_region_pk": { 1670 + "columns": [ 1671 + "monitor_id", 1672 + "region" 1673 + ], 1674 + "name": "monitor_status_monitor_id_region_pk" 1675 + } 1676 + }, 1677 + "uniqueConstraints": {}, 1678 + "checkConstraints": {} 1679 + }, 1680 + "invitation": { 1681 + "name": "invitation", 1682 + "columns": { 1683 + "id": { 1684 + "name": "id", 1685 + "type": "integer", 1686 + "primaryKey": true, 1687 + "notNull": true, 1688 + "autoincrement": false 1689 + }, 1690 + "email": { 1691 + "name": "email", 1692 + "type": "text", 1693 + "primaryKey": false, 1694 + "notNull": true, 1695 + "autoincrement": false 1696 + }, 1697 + "role": { 1698 + "name": "role", 1699 + "type": "text", 1700 + "primaryKey": false, 1701 + "notNull": true, 1702 + "autoincrement": false, 1703 + "default": "'member'" 1704 + }, 1705 + "workspace_id": { 1706 + "name": "workspace_id", 1707 + "type": "integer", 1708 + "primaryKey": false, 1709 + "notNull": true, 1710 + "autoincrement": false 1711 + }, 1712 + "token": { 1713 + "name": "token", 1714 + "type": "text", 1715 + "primaryKey": false, 1716 + "notNull": true, 1717 + "autoincrement": false 1718 + }, 1719 + "expires_at": { 1720 + "name": "expires_at", 1721 + "type": "integer", 1722 + "primaryKey": false, 1723 + "notNull": true, 1724 + "autoincrement": false 1725 + }, 1726 + "created_at": { 1727 + "name": "created_at", 1728 + "type": "integer", 1729 + "primaryKey": false, 1730 + "notNull": false, 1731 + "autoincrement": false, 1732 + "default": "(strftime('%s', 'now'))" 1733 + }, 1734 + "accepted_at": { 1735 + "name": "accepted_at", 1736 + "type": "integer", 1737 + "primaryKey": false, 1738 + "notNull": false, 1739 + "autoincrement": false 1740 + } 1741 + }, 1742 + "indexes": {}, 1743 + "foreignKeys": {}, 1744 + "compositePrimaryKeys": {}, 1745 + "uniqueConstraints": {}, 1746 + "checkConstraints": {} 1747 + }, 1748 + "incident": { 1749 + "name": "incident", 1750 + "columns": { 1751 + "id": { 1752 + "name": "id", 1753 + "type": "integer", 1754 + "primaryKey": true, 1755 + "notNull": true, 1756 + "autoincrement": false 1757 + }, 1758 + "title": { 1759 + "name": "title", 1760 + "type": "text", 1761 + "primaryKey": false, 1762 + "notNull": true, 1763 + "autoincrement": false, 1764 + "default": "''" 1765 + }, 1766 + "summary": { 1767 + "name": "summary", 1768 + "type": "text", 1769 + "primaryKey": false, 1770 + "notNull": true, 1771 + "autoincrement": false, 1772 + "default": "''" 1773 + }, 1774 + "status": { 1775 + "name": "status", 1776 + "type": "text", 1777 + "primaryKey": false, 1778 + "notNull": true, 1779 + "autoincrement": false, 1780 + "default": "'triage'" 1781 + }, 1782 + "monitor_id": { 1783 + "name": "monitor_id", 1784 + "type": "integer", 1785 + "primaryKey": false, 1786 + "notNull": false, 1787 + "autoincrement": false 1788 + }, 1789 + "workspace_id": { 1790 + "name": "workspace_id", 1791 + "type": "integer", 1792 + "primaryKey": false, 1793 + "notNull": false, 1794 + "autoincrement": false 1795 + }, 1796 + "started_at": { 1797 + "name": "started_at", 1798 + "type": "integer", 1799 + "primaryKey": false, 1800 + "notNull": true, 1801 + "autoincrement": false, 1802 + "default": "(strftime('%s', 'now'))" 1803 + }, 1804 + "acknowledged_at": { 1805 + "name": "acknowledged_at", 1806 + "type": "integer", 1807 + "primaryKey": false, 1808 + "notNull": false, 1809 + "autoincrement": false 1810 + }, 1811 + "acknowledged_by": { 1812 + "name": "acknowledged_by", 1813 + "type": "integer", 1814 + "primaryKey": false, 1815 + "notNull": false, 1816 + "autoincrement": false 1817 + }, 1818 + "resolved_at": { 1819 + "name": "resolved_at", 1820 + "type": "integer", 1821 + "primaryKey": false, 1822 + "notNull": false, 1823 + "autoincrement": false 1824 + }, 1825 + "resolved_by": { 1826 + "name": "resolved_by", 1827 + "type": "integer", 1828 + "primaryKey": false, 1829 + "notNull": false, 1830 + "autoincrement": false 1831 + }, 1832 + "incident_screenshot_url": { 1833 + "name": "incident_screenshot_url", 1834 + "type": "text", 1835 + "primaryKey": false, 1836 + "notNull": false, 1837 + "autoincrement": false 1838 + }, 1839 + "recovery_screenshot_url": { 1840 + "name": "recovery_screenshot_url", 1841 + "type": "text", 1842 + "primaryKey": false, 1843 + "notNull": false, 1844 + "autoincrement": false 1845 + }, 1846 + "auto_resolved": { 1847 + "name": "auto_resolved", 1848 + "type": "integer", 1849 + "primaryKey": false, 1850 + "notNull": false, 1851 + "autoincrement": false, 1852 + "default": false 1853 + }, 1854 + "created_at": { 1855 + "name": "created_at", 1856 + "type": "integer", 1857 + "primaryKey": false, 1858 + "notNull": false, 1859 + "autoincrement": false, 1860 + "default": "(strftime('%s', 'now'))" 1861 + }, 1862 + "updated_at": { 1863 + "name": "updated_at", 1864 + "type": "integer", 1865 + "primaryKey": false, 1866 + "notNull": false, 1867 + "autoincrement": false, 1868 + "default": "(strftime('%s', 'now'))" 1869 + } 1870 + }, 1871 + "indexes": { 1872 + "incident_monitor_id_started_at_unique": { 1873 + "name": "incident_monitor_id_started_at_unique", 1874 + "columns": [ 1875 + "monitor_id", 1876 + "started_at" 1877 + ], 1878 + "isUnique": true 1879 + } 1880 + }, 1881 + "foreignKeys": { 1882 + "incident_monitor_id_monitor_id_fk": { 1883 + "name": "incident_monitor_id_monitor_id_fk", 1884 + "tableFrom": "incident", 1885 + "tableTo": "monitor", 1886 + "columnsFrom": [ 1887 + "monitor_id" 1888 + ], 1889 + "columnsTo": [ 1890 + "id" 1891 + ], 1892 + "onDelete": "set default", 1893 + "onUpdate": "no action" 1894 + }, 1895 + "incident_workspace_id_workspace_id_fk": { 1896 + "name": "incident_workspace_id_workspace_id_fk", 1897 + "tableFrom": "incident", 1898 + "tableTo": "workspace", 1899 + "columnsFrom": [ 1900 + "workspace_id" 1901 + ], 1902 + "columnsTo": [ 1903 + "id" 1904 + ], 1905 + "onDelete": "no action", 1906 + "onUpdate": "no action" 1907 + }, 1908 + "incident_acknowledged_by_user_id_fk": { 1909 + "name": "incident_acknowledged_by_user_id_fk", 1910 + "tableFrom": "incident", 1911 + "tableTo": "user", 1912 + "columnsFrom": [ 1913 + "acknowledged_by" 1914 + ], 1915 + "columnsTo": [ 1916 + "id" 1917 + ], 1918 + "onDelete": "no action", 1919 + "onUpdate": "no action" 1920 + }, 1921 + "incident_resolved_by_user_id_fk": { 1922 + "name": "incident_resolved_by_user_id_fk", 1923 + "tableFrom": "incident", 1924 + "tableTo": "user", 1925 + "columnsFrom": [ 1926 + "resolved_by" 1927 + ], 1928 + "columnsTo": [ 1929 + "id" 1930 + ], 1931 + "onDelete": "no action", 1932 + "onUpdate": "no action" 1933 + } 1934 + }, 1935 + "compositePrimaryKeys": {}, 1936 + "uniqueConstraints": {}, 1937 + "checkConstraints": {} 1938 + }, 1939 + "monitor_tag": { 1940 + "name": "monitor_tag", 1941 + "columns": { 1942 + "id": { 1943 + "name": "id", 1944 + "type": "integer", 1945 + "primaryKey": true, 1946 + "notNull": true, 1947 + "autoincrement": false 1948 + }, 1949 + "workspace_id": { 1950 + "name": "workspace_id", 1951 + "type": "integer", 1952 + "primaryKey": false, 1953 + "notNull": true, 1954 + "autoincrement": false 1955 + }, 1956 + "name": { 1957 + "name": "name", 1958 + "type": "text", 1959 + "primaryKey": false, 1960 + "notNull": true, 1961 + "autoincrement": false 1962 + }, 1963 + "color": { 1964 + "name": "color", 1965 + "type": "text", 1966 + "primaryKey": false, 1967 + "notNull": true, 1968 + "autoincrement": false 1969 + }, 1970 + "created_at": { 1971 + "name": "created_at", 1972 + "type": "integer", 1973 + "primaryKey": false, 1974 + "notNull": false, 1975 + "autoincrement": false, 1976 + "default": "(strftime('%s', 'now'))" 1977 + }, 1978 + "updated_at": { 1979 + "name": "updated_at", 1980 + "type": "integer", 1981 + "primaryKey": false, 1982 + "notNull": false, 1983 + "autoincrement": false, 1984 + "default": "(strftime('%s', 'now'))" 1985 + } 1986 + }, 1987 + "indexes": {}, 1988 + "foreignKeys": { 1989 + "monitor_tag_workspace_id_workspace_id_fk": { 1990 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1991 + "tableFrom": "monitor_tag", 1992 + "tableTo": "workspace", 1993 + "columnsFrom": [ 1994 + "workspace_id" 1995 + ], 1996 + "columnsTo": [ 1997 + "id" 1998 + ], 1999 + "onDelete": "cascade", 2000 + "onUpdate": "no action" 2001 + } 2002 + }, 2003 + "compositePrimaryKeys": {}, 2004 + "uniqueConstraints": {}, 2005 + "checkConstraints": {} 2006 + }, 2007 + "monitor_tag_to_monitor": { 2008 + "name": "monitor_tag_to_monitor", 2009 + "columns": { 2010 + "monitor_id": { 2011 + "name": "monitor_id", 2012 + "type": "integer", 2013 + "primaryKey": false, 2014 + "notNull": true, 2015 + "autoincrement": false 2016 + }, 2017 + "monitor_tag_id": { 2018 + "name": "monitor_tag_id", 2019 + "type": "integer", 2020 + "primaryKey": false, 2021 + "notNull": true, 2022 + "autoincrement": false 2023 + }, 2024 + "created_at": { 2025 + "name": "created_at", 2026 + "type": "integer", 2027 + "primaryKey": false, 2028 + "notNull": false, 2029 + "autoincrement": false, 2030 + "default": "(strftime('%s', 'now'))" 2031 + } 2032 + }, 2033 + "indexes": {}, 2034 + "foreignKeys": { 2035 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 2036 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 2037 + "tableFrom": "monitor_tag_to_monitor", 2038 + "tableTo": "monitor", 2039 + "columnsFrom": [ 2040 + "monitor_id" 2041 + ], 2042 + "columnsTo": [ 2043 + "id" 2044 + ], 2045 + "onDelete": "cascade", 2046 + "onUpdate": "no action" 2047 + }, 2048 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 2049 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 2050 + "tableFrom": "monitor_tag_to_monitor", 2051 + "tableTo": "monitor_tag", 2052 + "columnsFrom": [ 2053 + "monitor_tag_id" 2054 + ], 2055 + "columnsTo": [ 2056 + "id" 2057 + ], 2058 + "onDelete": "cascade", 2059 + "onUpdate": "no action" 2060 + } 2061 + }, 2062 + "compositePrimaryKeys": { 2063 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 2064 + "columns": [ 2065 + "monitor_id", 2066 + "monitor_tag_id" 2067 + ], 2068 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 2069 + } 2070 + }, 2071 + "uniqueConstraints": {}, 2072 + "checkConstraints": {} 2073 + }, 2074 + "application": { 2075 + "name": "application", 2076 + "columns": { 2077 + "id": { 2078 + "name": "id", 2079 + "type": "integer", 2080 + "primaryKey": true, 2081 + "notNull": true, 2082 + "autoincrement": false 2083 + }, 2084 + "name": { 2085 + "name": "name", 2086 + "type": "text", 2087 + "primaryKey": false, 2088 + "notNull": false, 2089 + "autoincrement": false 2090 + }, 2091 + "dsn": { 2092 + "name": "dsn", 2093 + "type": "text", 2094 + "primaryKey": false, 2095 + "notNull": false, 2096 + "autoincrement": false 2097 + }, 2098 + "workspace_id": { 2099 + "name": "workspace_id", 2100 + "type": "integer", 2101 + "primaryKey": false, 2102 + "notNull": false, 2103 + "autoincrement": false 2104 + }, 2105 + "created_at": { 2106 + "name": "created_at", 2107 + "type": "integer", 2108 + "primaryKey": false, 2109 + "notNull": false, 2110 + "autoincrement": false, 2111 + "default": "(strftime('%s', 'now'))" 2112 + }, 2113 + "updated_at": { 2114 + "name": "updated_at", 2115 + "type": "integer", 2116 + "primaryKey": false, 2117 + "notNull": false, 2118 + "autoincrement": false, 2119 + "default": "(strftime('%s', 'now'))" 2120 + } 2121 + }, 2122 + "indexes": { 2123 + "application_dsn_unique": { 2124 + "name": "application_dsn_unique", 2125 + "columns": [ 2126 + "dsn" 2127 + ], 2128 + "isUnique": true 2129 + } 2130 + }, 2131 + "foreignKeys": { 2132 + "application_workspace_id_workspace_id_fk": { 2133 + "name": "application_workspace_id_workspace_id_fk", 2134 + "tableFrom": "application", 2135 + "tableTo": "workspace", 2136 + "columnsFrom": [ 2137 + "workspace_id" 2138 + ], 2139 + "columnsTo": [ 2140 + "id" 2141 + ], 2142 + "onDelete": "no action", 2143 + "onUpdate": "no action" 2144 + } 2145 + }, 2146 + "compositePrimaryKeys": {}, 2147 + "uniqueConstraints": {}, 2148 + "checkConstraints": {} 2149 + }, 2150 + "maintenance": { 2151 + "name": "maintenance", 2152 + "columns": { 2153 + "id": { 2154 + "name": "id", 2155 + "type": "integer", 2156 + "primaryKey": true, 2157 + "notNull": true, 2158 + "autoincrement": false 2159 + }, 2160 + "title": { 2161 + "name": "title", 2162 + "type": "text(256)", 2163 + "primaryKey": false, 2164 + "notNull": true, 2165 + "autoincrement": false 2166 + }, 2167 + "message": { 2168 + "name": "message", 2169 + "type": "text", 2170 + "primaryKey": false, 2171 + "notNull": true, 2172 + "autoincrement": false 2173 + }, 2174 + "from": { 2175 + "name": "from", 2176 + "type": "integer", 2177 + "primaryKey": false, 2178 + "notNull": true, 2179 + "autoincrement": false 2180 + }, 2181 + "to": { 2182 + "name": "to", 2183 + "type": "integer", 2184 + "primaryKey": false, 2185 + "notNull": true, 2186 + "autoincrement": false 2187 + }, 2188 + "workspace_id": { 2189 + "name": "workspace_id", 2190 + "type": "integer", 2191 + "primaryKey": false, 2192 + "notNull": false, 2193 + "autoincrement": false 2194 + }, 2195 + "page_id": { 2196 + "name": "page_id", 2197 + "type": "integer", 2198 + "primaryKey": false, 2199 + "notNull": false, 2200 + "autoincrement": false 2201 + }, 2202 + "created_at": { 2203 + "name": "created_at", 2204 + "type": "integer", 2205 + "primaryKey": false, 2206 + "notNull": false, 2207 + "autoincrement": false, 2208 + "default": "(strftime('%s', 'now'))" 2209 + }, 2210 + "updated_at": { 2211 + "name": "updated_at", 2212 + "type": "integer", 2213 + "primaryKey": false, 2214 + "notNull": false, 2215 + "autoincrement": false, 2216 + "default": "(strftime('%s', 'now'))" 2217 + } 2218 + }, 2219 + "indexes": {}, 2220 + "foreignKeys": { 2221 + "maintenance_workspace_id_workspace_id_fk": { 2222 + "name": "maintenance_workspace_id_workspace_id_fk", 2223 + "tableFrom": "maintenance", 2224 + "tableTo": "workspace", 2225 + "columnsFrom": [ 2226 + "workspace_id" 2227 + ], 2228 + "columnsTo": [ 2229 + "id" 2230 + ], 2231 + "onDelete": "no action", 2232 + "onUpdate": "no action" 2233 + }, 2234 + "maintenance_page_id_page_id_fk": { 2235 + "name": "maintenance_page_id_page_id_fk", 2236 + "tableFrom": "maintenance", 2237 + "tableTo": "page", 2238 + "columnsFrom": [ 2239 + "page_id" 2240 + ], 2241 + "columnsTo": [ 2242 + "id" 2243 + ], 2244 + "onDelete": "cascade", 2245 + "onUpdate": "no action" 2246 + } 2247 + }, 2248 + "compositePrimaryKeys": {}, 2249 + "uniqueConstraints": {}, 2250 + "checkConstraints": {} 2251 + }, 2252 + "maintenance_to_monitor": { 2253 + "name": "maintenance_to_monitor", 2254 + "columns": { 2255 + "maintenance_id": { 2256 + "name": "maintenance_id", 2257 + "type": "integer", 2258 + "primaryKey": false, 2259 + "notNull": true, 2260 + "autoincrement": false 2261 + }, 2262 + "monitor_id": { 2263 + "name": "monitor_id", 2264 + "type": "integer", 2265 + "primaryKey": false, 2266 + "notNull": true, 2267 + "autoincrement": false 2268 + }, 2269 + "created_at": { 2270 + "name": "created_at", 2271 + "type": "integer", 2272 + "primaryKey": false, 2273 + "notNull": false, 2274 + "autoincrement": false, 2275 + "default": "(strftime('%s', 'now'))" 2276 + } 2277 + }, 2278 + "indexes": {}, 2279 + "foreignKeys": { 2280 + "maintenance_to_monitor_maintenance_id_maintenance_id_fk": { 2281 + "name": "maintenance_to_monitor_maintenance_id_maintenance_id_fk", 2282 + "tableFrom": "maintenance_to_monitor", 2283 + "tableTo": "maintenance", 2284 + "columnsFrom": [ 2285 + "maintenance_id" 2286 + ], 2287 + "columnsTo": [ 2288 + "id" 2289 + ], 2290 + "onDelete": "cascade", 2291 + "onUpdate": "no action" 2292 + }, 2293 + "maintenance_to_monitor_monitor_id_monitor_id_fk": { 2294 + "name": "maintenance_to_monitor_monitor_id_monitor_id_fk", 2295 + "tableFrom": "maintenance_to_monitor", 2296 + "tableTo": "monitor", 2297 + "columnsFrom": [ 2298 + "monitor_id" 2299 + ], 2300 + "columnsTo": [ 2301 + "id" 2302 + ], 2303 + "onDelete": "cascade", 2304 + "onUpdate": "no action" 2305 + } 2306 + }, 2307 + "compositePrimaryKeys": { 2308 + "maintenance_to_monitor_maintenance_id_monitor_id_pk": { 2309 + "columns": [ 2310 + "maintenance_id", 2311 + "monitor_id" 2312 + ], 2313 + "name": "maintenance_to_monitor_maintenance_id_monitor_id_pk" 2314 + } 2315 + }, 2316 + "uniqueConstraints": {}, 2317 + "checkConstraints": {} 2318 + }, 2319 + "check": { 2320 + "name": "check", 2321 + "columns": { 2322 + "id": { 2323 + "name": "id", 2324 + "type": "integer", 2325 + "primaryKey": true, 2326 + "notNull": true, 2327 + "autoincrement": true 2328 + }, 2329 + "regions": { 2330 + "name": "regions", 2331 + "type": "text", 2332 + "primaryKey": false, 2333 + "notNull": true, 2334 + "autoincrement": false, 2335 + "default": "''" 2336 + }, 2337 + "url": { 2338 + "name": "url", 2339 + "type": "text(4096)", 2340 + "primaryKey": false, 2341 + "notNull": true, 2342 + "autoincrement": false 2343 + }, 2344 + "headers": { 2345 + "name": "headers", 2346 + "type": "text", 2347 + "primaryKey": false, 2348 + "notNull": false, 2349 + "autoincrement": false, 2350 + "default": "''" 2351 + }, 2352 + "body": { 2353 + "name": "body", 2354 + "type": "text", 2355 + "primaryKey": false, 2356 + "notNull": false, 2357 + "autoincrement": false, 2358 + "default": "''" 2359 + }, 2360 + "method": { 2361 + "name": "method", 2362 + "type": "text", 2363 + "primaryKey": false, 2364 + "notNull": false, 2365 + "autoincrement": false, 2366 + "default": "'GET'" 2367 + }, 2368 + "count_requests": { 2369 + "name": "count_requests", 2370 + "type": "integer", 2371 + "primaryKey": false, 2372 + "notNull": false, 2373 + "autoincrement": false, 2374 + "default": 1 2375 + }, 2376 + "workspace_id": { 2377 + "name": "workspace_id", 2378 + "type": "integer", 2379 + "primaryKey": false, 2380 + "notNull": false, 2381 + "autoincrement": false 2382 + }, 2383 + "created_at": { 2384 + "name": "created_at", 2385 + "type": "integer", 2386 + "primaryKey": false, 2387 + "notNull": false, 2388 + "autoincrement": false, 2389 + "default": "(strftime('%s', 'now'))" 2390 + } 2391 + }, 2392 + "indexes": {}, 2393 + "foreignKeys": { 2394 + "check_workspace_id_workspace_id_fk": { 2395 + "name": "check_workspace_id_workspace_id_fk", 2396 + "tableFrom": "check", 2397 + "tableTo": "workspace", 2398 + "columnsFrom": [ 2399 + "workspace_id" 2400 + ], 2401 + "columnsTo": [ 2402 + "id" 2403 + ], 2404 + "onDelete": "no action", 2405 + "onUpdate": "no action" 2406 + } 2407 + }, 2408 + "compositePrimaryKeys": {}, 2409 + "uniqueConstraints": {}, 2410 + "checkConstraints": {} 2411 + }, 2412 + "monitor_run": { 2413 + "name": "monitor_run", 2414 + "columns": { 2415 + "id": { 2416 + "name": "id", 2417 + "type": "integer", 2418 + "primaryKey": true, 2419 + "notNull": true, 2420 + "autoincrement": false 2421 + }, 2422 + "workspace_id": { 2423 + "name": "workspace_id", 2424 + "type": "integer", 2425 + "primaryKey": false, 2426 + "notNull": false, 2427 + "autoincrement": false 2428 + }, 2429 + "monitor_id": { 2430 + "name": "monitor_id", 2431 + "type": "integer", 2432 + "primaryKey": false, 2433 + "notNull": false, 2434 + "autoincrement": false 2435 + }, 2436 + "runned_at": { 2437 + "name": "runned_at", 2438 + "type": "integer", 2439 + "primaryKey": false, 2440 + "notNull": false, 2441 + "autoincrement": false 2442 + }, 2443 + "created_at": { 2444 + "name": "created_at", 2445 + "type": "integer", 2446 + "primaryKey": false, 2447 + "notNull": false, 2448 + "autoincrement": false, 2449 + "default": "(strftime('%s', 'now'))" 2450 + } 2451 + }, 2452 + "indexes": {}, 2453 + "foreignKeys": { 2454 + "monitor_run_workspace_id_workspace_id_fk": { 2455 + "name": "monitor_run_workspace_id_workspace_id_fk", 2456 + "tableFrom": "monitor_run", 2457 + "tableTo": "workspace", 2458 + "columnsFrom": [ 2459 + "workspace_id" 2460 + ], 2461 + "columnsTo": [ 2462 + "id" 2463 + ], 2464 + "onDelete": "no action", 2465 + "onUpdate": "no action" 2466 + }, 2467 + "monitor_run_monitor_id_monitor_id_fk": { 2468 + "name": "monitor_run_monitor_id_monitor_id_fk", 2469 + "tableFrom": "monitor_run", 2470 + "tableTo": "monitor", 2471 + "columnsFrom": [ 2472 + "monitor_id" 2473 + ], 2474 + "columnsTo": [ 2475 + "id" 2476 + ], 2477 + "onDelete": "no action", 2478 + "onUpdate": "no action" 2479 + } 2480 + }, 2481 + "compositePrimaryKeys": {}, 2482 + "uniqueConstraints": {}, 2483 + "checkConstraints": {} 2484 + }, 2485 + "private_location": { 2486 + "name": "private_location", 2487 + "columns": { 2488 + "id": { 2489 + "name": "id", 2490 + "type": "integer", 2491 + "primaryKey": true, 2492 + "notNull": true, 2493 + "autoincrement": false 2494 + }, 2495 + "name": { 2496 + "name": "name", 2497 + "type": "text", 2498 + "primaryKey": false, 2499 + "notNull": true, 2500 + "autoincrement": false 2501 + }, 2502 + "token": { 2503 + "name": "token", 2504 + "type": "text", 2505 + "primaryKey": false, 2506 + "notNull": true, 2507 + "autoincrement": false 2508 + }, 2509 + "last_seen_at": { 2510 + "name": "last_seen_at", 2511 + "type": "integer", 2512 + "primaryKey": false, 2513 + "notNull": false, 2514 + "autoincrement": false 2515 + }, 2516 + "workspace_id": { 2517 + "name": "workspace_id", 2518 + "type": "integer", 2519 + "primaryKey": false, 2520 + "notNull": false, 2521 + "autoincrement": false 2522 + }, 2523 + "created_at": { 2524 + "name": "created_at", 2525 + "type": "integer", 2526 + "primaryKey": false, 2527 + "notNull": false, 2528 + "autoincrement": false, 2529 + "default": "(strftime('%s', 'now'))" 2530 + }, 2531 + "updated_at": { 2532 + "name": "updated_at", 2533 + "type": "integer", 2534 + "primaryKey": false, 2535 + "notNull": false, 2536 + "autoincrement": false, 2537 + "default": "(strftime('%s', 'now'))" 2538 + } 2539 + }, 2540 + "indexes": {}, 2541 + "foreignKeys": { 2542 + "private_location_workspace_id_workspace_id_fk": { 2543 + "name": "private_location_workspace_id_workspace_id_fk", 2544 + "tableFrom": "private_location", 2545 + "tableTo": "workspace", 2546 + "columnsFrom": [ 2547 + "workspace_id" 2548 + ], 2549 + "columnsTo": [ 2550 + "id" 2551 + ], 2552 + "onDelete": "no action", 2553 + "onUpdate": "no action" 2554 + } 2555 + }, 2556 + "compositePrimaryKeys": {}, 2557 + "uniqueConstraints": {}, 2558 + "checkConstraints": {} 2559 + }, 2560 + "private_location_to_monitor": { 2561 + "name": "private_location_to_monitor", 2562 + "columns": { 2563 + "private_location_id": { 2564 + "name": "private_location_id", 2565 + "type": "integer", 2566 + "primaryKey": false, 2567 + "notNull": false, 2568 + "autoincrement": false 2569 + }, 2570 + "monitor_id": { 2571 + "name": "monitor_id", 2572 + "type": "integer", 2573 + "primaryKey": false, 2574 + "notNull": false, 2575 + "autoincrement": false 2576 + }, 2577 + "created_at": { 2578 + "name": "created_at", 2579 + "type": "integer", 2580 + "primaryKey": false, 2581 + "notNull": false, 2582 + "autoincrement": false, 2583 + "default": "(strftime('%s', 'now'))" 2584 + }, 2585 + "deleted_at": { 2586 + "name": "deleted_at", 2587 + "type": "integer", 2588 + "primaryKey": false, 2589 + "notNull": false, 2590 + "autoincrement": false 2591 + } 2592 + }, 2593 + "indexes": {}, 2594 + "foreignKeys": { 2595 + "private_location_to_monitor_private_location_id_private_location_id_fk": { 2596 + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", 2597 + "tableFrom": "private_location_to_monitor", 2598 + "tableTo": "private_location", 2599 + "columnsFrom": [ 2600 + "private_location_id" 2601 + ], 2602 + "columnsTo": [ 2603 + "id" 2604 + ], 2605 + "onDelete": "cascade", 2606 + "onUpdate": "no action" 2607 + }, 2608 + "private_location_to_monitor_monitor_id_monitor_id_fk": { 2609 + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", 2610 + "tableFrom": "private_location_to_monitor", 2611 + "tableTo": "monitor", 2612 + "columnsFrom": [ 2613 + "monitor_id" 2614 + ], 2615 + "columnsTo": [ 2616 + "id" 2617 + ], 2618 + "onDelete": "cascade", 2619 + "onUpdate": "no action" 2620 + } 2621 + }, 2622 + "compositePrimaryKeys": {}, 2623 + "uniqueConstraints": {}, 2624 + "checkConstraints": {} 2625 + }, 2626 + "monitor_group": { 2627 + "name": "monitor_group", 2628 + "columns": { 2629 + "id": { 2630 + "name": "id", 2631 + "type": "integer", 2632 + "primaryKey": true, 2633 + "notNull": true, 2634 + "autoincrement": false 2635 + }, 2636 + "workspace_id": { 2637 + "name": "workspace_id", 2638 + "type": "integer", 2639 + "primaryKey": false, 2640 + "notNull": true, 2641 + "autoincrement": false 2642 + }, 2643 + "page_id": { 2644 + "name": "page_id", 2645 + "type": "integer", 2646 + "primaryKey": false, 2647 + "notNull": true, 2648 + "autoincrement": false 2649 + }, 2650 + "name": { 2651 + "name": "name", 2652 + "type": "text", 2653 + "primaryKey": false, 2654 + "notNull": true, 2655 + "autoincrement": false 2656 + }, 2657 + "created_at": { 2658 + "name": "created_at", 2659 + "type": "integer", 2660 + "primaryKey": false, 2661 + "notNull": false, 2662 + "autoincrement": false, 2663 + "default": "(strftime('%s', 'now'))" 2664 + }, 2665 + "updated_at": { 2666 + "name": "updated_at", 2667 + "type": "integer", 2668 + "primaryKey": false, 2669 + "notNull": false, 2670 + "autoincrement": false, 2671 + "default": "(strftime('%s', 'now'))" 2672 + } 2673 + }, 2674 + "indexes": {}, 2675 + "foreignKeys": { 2676 + "monitor_group_workspace_id_workspace_id_fk": { 2677 + "name": "monitor_group_workspace_id_workspace_id_fk", 2678 + "tableFrom": "monitor_group", 2679 + "tableTo": "workspace", 2680 + "columnsFrom": [ 2681 + "workspace_id" 2682 + ], 2683 + "columnsTo": [ 2684 + "id" 2685 + ], 2686 + "onDelete": "cascade", 2687 + "onUpdate": "no action" 2688 + }, 2689 + "monitor_group_page_id_page_id_fk": { 2690 + "name": "monitor_group_page_id_page_id_fk", 2691 + "tableFrom": "monitor_group", 2692 + "tableTo": "page", 2693 + "columnsFrom": [ 2694 + "page_id" 2695 + ], 2696 + "columnsTo": [ 2697 + "id" 2698 + ], 2699 + "onDelete": "cascade", 2700 + "onUpdate": "no action" 2701 + } 2702 + }, 2703 + "compositePrimaryKeys": {}, 2704 + "uniqueConstraints": {}, 2705 + "checkConstraints": {} 2706 + }, 2707 + "viewer": { 2708 + "name": "viewer", 2709 + "columns": { 2710 + "id": { 2711 + "name": "id", 2712 + "type": "integer", 2713 + "primaryKey": true, 2714 + "notNull": true, 2715 + "autoincrement": false 2716 + }, 2717 + "name": { 2718 + "name": "name", 2719 + "type": "text", 2720 + "primaryKey": false, 2721 + "notNull": false, 2722 + "autoincrement": false 2723 + }, 2724 + "email": { 2725 + "name": "email", 2726 + "type": "text", 2727 + "primaryKey": false, 2728 + "notNull": false, 2729 + "autoincrement": false 2730 + }, 2731 + "emailVerified": { 2732 + "name": "emailVerified", 2733 + "type": "integer", 2734 + "primaryKey": false, 2735 + "notNull": false, 2736 + "autoincrement": false 2737 + }, 2738 + "image": { 2739 + "name": "image", 2740 + "type": "text", 2741 + "primaryKey": false, 2742 + "notNull": false, 2743 + "autoincrement": false 2744 + }, 2745 + "created_at": { 2746 + "name": "created_at", 2747 + "type": "integer", 2748 + "primaryKey": false, 2749 + "notNull": false, 2750 + "autoincrement": false, 2751 + "default": "(strftime('%s', 'now'))" 2752 + }, 2753 + "updated_at": { 2754 + "name": "updated_at", 2755 + "type": "integer", 2756 + "primaryKey": false, 2757 + "notNull": false, 2758 + "autoincrement": false, 2759 + "default": "(strftime('%s', 'now'))" 2760 + } 2761 + }, 2762 + "indexes": { 2763 + "viewer_email_unique": { 2764 + "name": "viewer_email_unique", 2765 + "columns": [ 2766 + "email" 2767 + ], 2768 + "isUnique": true 2769 + } 2770 + }, 2771 + "foreignKeys": {}, 2772 + "compositePrimaryKeys": {}, 2773 + "uniqueConstraints": {}, 2774 + "checkConstraints": {} 2775 + }, 2776 + "viewer_accounts": { 2777 + "name": "viewer_accounts", 2778 + "columns": { 2779 + "user_id": { 2780 + "name": "user_id", 2781 + "type": "text", 2782 + "primaryKey": false, 2783 + "notNull": true, 2784 + "autoincrement": false 2785 + }, 2786 + "type": { 2787 + "name": "type", 2788 + "type": "text", 2789 + "primaryKey": false, 2790 + "notNull": true, 2791 + "autoincrement": false 2792 + }, 2793 + "provider": { 2794 + "name": "provider", 2795 + "type": "text", 2796 + "primaryKey": false, 2797 + "notNull": true, 2798 + "autoincrement": false 2799 + }, 2800 + "providerAccountId": { 2801 + "name": "providerAccountId", 2802 + "type": "text", 2803 + "primaryKey": false, 2804 + "notNull": true, 2805 + "autoincrement": false 2806 + }, 2807 + "refresh_token": { 2808 + "name": "refresh_token", 2809 + "type": "text", 2810 + "primaryKey": false, 2811 + "notNull": false, 2812 + "autoincrement": false 2813 + }, 2814 + "access_token": { 2815 + "name": "access_token", 2816 + "type": "text", 2817 + "primaryKey": false, 2818 + "notNull": false, 2819 + "autoincrement": false 2820 + }, 2821 + "expires_at": { 2822 + "name": "expires_at", 2823 + "type": "integer", 2824 + "primaryKey": false, 2825 + "notNull": false, 2826 + "autoincrement": false 2827 + }, 2828 + "token_type": { 2829 + "name": "token_type", 2830 + "type": "text", 2831 + "primaryKey": false, 2832 + "notNull": false, 2833 + "autoincrement": false 2834 + }, 2835 + "scope": { 2836 + "name": "scope", 2837 + "type": "text", 2838 + "primaryKey": false, 2839 + "notNull": false, 2840 + "autoincrement": false 2841 + }, 2842 + "id_token": { 2843 + "name": "id_token", 2844 + "type": "text", 2845 + "primaryKey": false, 2846 + "notNull": false, 2847 + "autoincrement": false 2848 + }, 2849 + "session_state": { 2850 + "name": "session_state", 2851 + "type": "text", 2852 + "primaryKey": false, 2853 + "notNull": false, 2854 + "autoincrement": false 2855 + } 2856 + }, 2857 + "indexes": {}, 2858 + "foreignKeys": { 2859 + "viewer_accounts_user_id_viewer_id_fk": { 2860 + "name": "viewer_accounts_user_id_viewer_id_fk", 2861 + "tableFrom": "viewer_accounts", 2862 + "tableTo": "viewer", 2863 + "columnsFrom": [ 2864 + "user_id" 2865 + ], 2866 + "columnsTo": [ 2867 + "id" 2868 + ], 2869 + "onDelete": "cascade", 2870 + "onUpdate": "no action" 2871 + } 2872 + }, 2873 + "compositePrimaryKeys": { 2874 + "viewer_accounts_provider_providerAccountId_pk": { 2875 + "columns": [ 2876 + "provider", 2877 + "providerAccountId" 2878 + ], 2879 + "name": "viewer_accounts_provider_providerAccountId_pk" 2880 + } 2881 + }, 2882 + "uniqueConstraints": {}, 2883 + "checkConstraints": {} 2884 + }, 2885 + "viewer_session": { 2886 + "name": "viewer_session", 2887 + "columns": { 2888 + "session_token": { 2889 + "name": "session_token", 2890 + "type": "text", 2891 + "primaryKey": true, 2892 + "notNull": true, 2893 + "autoincrement": false 2894 + }, 2895 + "user_id": { 2896 + "name": "user_id", 2897 + "type": "integer", 2898 + "primaryKey": false, 2899 + "notNull": true, 2900 + "autoincrement": false 2901 + }, 2902 + "expires": { 2903 + "name": "expires", 2904 + "type": "integer", 2905 + "primaryKey": false, 2906 + "notNull": true, 2907 + "autoincrement": false 2908 + } 2909 + }, 2910 + "indexes": {}, 2911 + "foreignKeys": { 2912 + "viewer_session_user_id_viewer_id_fk": { 2913 + "name": "viewer_session_user_id_viewer_id_fk", 2914 + "tableFrom": "viewer_session", 2915 + "tableTo": "viewer", 2916 + "columnsFrom": [ 2917 + "user_id" 2918 + ], 2919 + "columnsTo": [ 2920 + "id" 2921 + ], 2922 + "onDelete": "cascade", 2923 + "onUpdate": "no action" 2924 + } 2925 + }, 2926 + "compositePrimaryKeys": {}, 2927 + "uniqueConstraints": {}, 2928 + "checkConstraints": {} 2929 + }, 2930 + "api_key": { 2931 + "name": "api_key", 2932 + "columns": { 2933 + "id": { 2934 + "name": "id", 2935 + "type": "integer", 2936 + "primaryKey": true, 2937 + "notNull": true, 2938 + "autoincrement": true 2939 + }, 2940 + "name": { 2941 + "name": "name", 2942 + "type": "text", 2943 + "primaryKey": false, 2944 + "notNull": true, 2945 + "autoincrement": false 2946 + }, 2947 + "description": { 2948 + "name": "description", 2949 + "type": "text", 2950 + "primaryKey": false, 2951 + "notNull": false, 2952 + "autoincrement": false 2953 + }, 2954 + "prefix": { 2955 + "name": "prefix", 2956 + "type": "text", 2957 + "primaryKey": false, 2958 + "notNull": true, 2959 + "autoincrement": false 2960 + }, 2961 + "hashed_token": { 2962 + "name": "hashed_token", 2963 + "type": "text", 2964 + "primaryKey": false, 2965 + "notNull": true, 2966 + "autoincrement": false 2967 + }, 2968 + "workspace_id": { 2969 + "name": "workspace_id", 2970 + "type": "integer", 2971 + "primaryKey": false, 2972 + "notNull": true, 2973 + "autoincrement": false 2974 + }, 2975 + "created_by_id": { 2976 + "name": "created_by_id", 2977 + "type": "integer", 2978 + "primaryKey": false, 2979 + "notNull": true, 2980 + "autoincrement": false 2981 + }, 2982 + "created_at": { 2983 + "name": "created_at", 2984 + "type": "integer", 2985 + "primaryKey": false, 2986 + "notNull": false, 2987 + "autoincrement": false, 2988 + "default": "(strftime('%s', 'now'))" 2989 + }, 2990 + "expires_at": { 2991 + "name": "expires_at", 2992 + "type": "integer", 2993 + "primaryKey": false, 2994 + "notNull": false, 2995 + "autoincrement": false 2996 + }, 2997 + "last_used_at": { 2998 + "name": "last_used_at", 2999 + "type": "integer", 3000 + "primaryKey": false, 3001 + "notNull": false, 3002 + "autoincrement": false 3003 + } 3004 + }, 3005 + "indexes": { 3006 + "api_key_prefix_unique": { 3007 + "name": "api_key_prefix_unique", 3008 + "columns": [ 3009 + "prefix" 3010 + ], 3011 + "isUnique": true 3012 + }, 3013 + "api_key_hashed_token_unique": { 3014 + "name": "api_key_hashed_token_unique", 3015 + "columns": [ 3016 + "hashed_token" 3017 + ], 3018 + "isUnique": true 3019 + }, 3020 + "api_key_prefix_idx": { 3021 + "name": "api_key_prefix_idx", 3022 + "columns": [ 3023 + "prefix" 3024 + ], 3025 + "isUnique": false 3026 + } 3027 + }, 3028 + "foreignKeys": { 3029 + "api_key_workspace_id_workspace_id_fk": { 3030 + "name": "api_key_workspace_id_workspace_id_fk", 3031 + "tableFrom": "api_key", 3032 + "tableTo": "workspace", 3033 + "columnsFrom": [ 3034 + "workspace_id" 3035 + ], 3036 + "columnsTo": [ 3037 + "id" 3038 + ], 3039 + "onDelete": "cascade", 3040 + "onUpdate": "no action" 3041 + }, 3042 + "api_key_created_by_id_user_id_fk": { 3043 + "name": "api_key_created_by_id_user_id_fk", 3044 + "tableFrom": "api_key", 3045 + "tableTo": "user", 3046 + "columnsFrom": [ 3047 + "created_by_id" 3048 + ], 3049 + "columnsTo": [ 3050 + "id" 3051 + ], 3052 + "onDelete": "cascade", 3053 + "onUpdate": "no action" 3054 + } 3055 + }, 3056 + "compositePrimaryKeys": {}, 3057 + "uniqueConstraints": {}, 3058 + "checkConstraints": {} 3059 + } 3060 + }, 3061 + "views": {}, 3062 + "enums": {}, 3063 + "_meta": { 3064 + "schemas": {}, 3065 + "tables": {}, 3066 + "columns": {} 3067 + }, 3068 + "internal": { 3069 + "indexes": {} 3070 + } 3071 + }
+7
packages/db/drizzle/meta/_journal.json
··· 365 365 "when": 1767362130713, 366 366 "tag": "0051_fuzzy_red_hulk", 367 367 "breakpoints": true 368 + }, 369 + { 370 + "idx": 52, 371 + "version": "6", 372 + "when": 1767797078062, 373 + "tag": "0052_illegal_killraven", 374 + "breakpoints": true 368 375 } 369 376 ] 370 377 }
+2
packages/db/package.json
··· 18 18 "@openstatus/regions": "workspace:*", 19 19 "@openstatus/theme-store": "workspace:*", 20 20 "@t3-oss/env-core": "0.13.10", 21 + "bcryptjs": "3.0.3", 21 22 "drizzle-orm": "0.44.4", 22 23 "drizzle-zod": "0.8.3", 23 24 "zod": "4.1.13" 24 25 }, 25 26 "devDependencies": { 26 27 "@openstatus/tsconfig": "workspace:*", 28 + "@types/bcryptjs": "3.0.0", 27 29 "@types/node": "22.10.2", 28 30 "drizzle-kit": "0.31.4", 29 31 "next-auth": "5.0.0-beta.29",
-1
packages/db/src/index.ts
··· 1 1 export * as schema from "./schema"; 2 2 export * from "drizzle-orm"; 3 3 export * from "./db"; 4 - export * from "./utils"; 5 4 // doing this because the external module not working see : https://github.com/vercel/next.js/issues/43433 6 5 // export * from "./sync-db";
+41
packages/db/src/schema/api-keys/api_key.ts
··· 1 + import { relations, sql } from "drizzle-orm"; 2 + import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + 4 + import { user } from "../users"; 5 + import { workspace } from "../workspaces"; 6 + 7 + export const apiKey = sqliteTable( 8 + "api_key", 9 + { 10 + id: integer("id").primaryKey({ autoIncrement: true }), 11 + name: text("name").notNull(), 12 + description: text("description"), 13 + prefix: text("prefix").notNull().unique(), 14 + hashedToken: text("hashed_token").notNull().unique(), 15 + workspaceId: integer("workspace_id") 16 + .notNull() 17 + .references(() => workspace.id, { onDelete: "cascade" }), 18 + createdById: integer("created_by_id") 19 + .notNull() 20 + .references(() => user.id, { onDelete: "cascade" }), 21 + createdAt: integer("created_at", { mode: "timestamp" }).default( 22 + sql`(strftime('%s', 'now'))`, 23 + ), 24 + expiresAt: integer("expires_at", { mode: "timestamp" }), 25 + lastUsedAt: integer("last_used_at", { mode: "timestamp" }), 26 + }, 27 + (table) => ({ 28 + prefixIdx: index("api_key_prefix_idx").on(table.prefix), 29 + }), 30 + ); 31 + 32 + export const apiKeyRelations = relations(apiKey, ({ one }) => ({ 33 + workspace: one(workspace, { 34 + fields: [apiKey.workspaceId], 35 + references: [workspace.id], 36 + }), 37 + createdBy: one(user, { 38 + fields: [apiKey.createdById], 39 + references: [user.id], 40 + }), 41 + }));
+2
packages/db/src/schema/api-keys/index.ts
··· 1 + export * from "./api_key"; 2 + export * from "./validation";
+22
packages/db/src/schema/api-keys/validation.ts
··· 1 + import { createInsertSchema, createSelectSchema } from "drizzle-zod"; 2 + import { z } from "zod"; 3 + 4 + import { apiKey } from "./api_key"; 5 + 6 + export const insertApiKeySchema = createInsertSchema(apiKey, { 7 + name: z.string().min(1, "Name is required"), 8 + description: z.string().optional(), 9 + expiresAt: z.date().optional(), 10 + }); 11 + 12 + export const selectApiKeySchema = createSelectSchema(apiKey); 13 + 14 + export const createApiKeySchema = z.object({ 15 + name: z.string().min(1, "Name is required"), 16 + description: z.string().optional(), 17 + expiresAt: z.date().optional(), 18 + }); 19 + 20 + export type InsertApiKey = z.infer<typeof insertApiKeySchema>; 21 + export type ApiKey = z.infer<typeof selectApiKeySchema>; 22 + export type CreateApiKeyInput = z.infer<typeof createApiKeySchema>;
+1
packages/db/src/schema/index.ts
··· 18 18 export * from "./private_locations"; 19 19 export * from "./monitor_groups"; 20 20 export * from "./viewers"; 21 + export * from "./api-keys";
-18
packages/db/src/utils.ts
··· 1 - import type { Column } from "drizzle-orm"; 2 - import { sql } from "drizzle-orm"; 3 - import type { 4 - SQLiteTable, 5 - SQLiteUpdateSetSource, 6 - } from "drizzle-orm/sqlite-core"; 7 - 8 - export function conflictUpdateSet<TTable extends SQLiteTable>( 9 - table: TTable, 10 - columns: (keyof TTable["_"]["columns"] & keyof TTable)[], 11 - ): SQLiteUpdateSetSource<TTable> { 12 - return Object.assign( 13 - {}, 14 - ...columns.map((k) => ({ 15 - [k]: sql`excluded.${(table[k] as Column).name}`, 16 - })), 17 - ) as SQLiteUpdateSetSource<TTable>; 18 - }
+194
packages/db/src/utils/api-key.test.ts
··· 1 + import { describe, expect, it } from "bun:test"; 2 + import { 3 + generateApiKey, 4 + hashApiKey, 5 + shouldUpdateLastUsed, 6 + verifyApiKeyHash, 7 + } from "./api-key"; 8 + 9 + describe("API Key Utilities", () => { 10 + describe("generateApiKey", () => { 11 + it("should generate a token with correct format", async () => { 12 + const { token, prefix, hash } = await generateApiKey(); 13 + 14 + // Token should start with "os_" and be 35 chars total (os_ + 32 hex) 15 + expect(token).toMatch(/^os_[a-f0-9]{32}$/); 16 + expect(token.length).toBe(35); 17 + }); 18 + 19 + it("should generate a prefix with correct format", async () => { 20 + const { prefix } = await generateApiKey(); 21 + 22 + // Prefix should be "os_" + 8 chars = 11 chars total 23 + expect(prefix).toMatch(/^os_[a-f0-9]{8}$/); 24 + expect(prefix.length).toBe(11); 25 + }); 26 + 27 + it("should generate unique tokens", async () => { 28 + const key1 = await generateApiKey(); 29 + const key2 = await generateApiKey(); 30 + 31 + expect(key1.token).not.toBe(key2.token); 32 + expect(key1.hash).not.toBe(key2.hash); 33 + }); 34 + 35 + it("should generate prefix from token start", async () => { 36 + const { token, prefix } = await generateApiKey(); 37 + 38 + expect(token.slice(0, 11)).toBe(prefix); 39 + }); 40 + }); 41 + 42 + describe("hashApiKey", () => { 43 + it("should generate hash that can verify the token", async () => { 44 + const { token, hash } = await generateApiKey(); 45 + 46 + expect(await verifyApiKeyHash(token, hash)).toBe(true); 47 + }); 48 + }); 49 + 50 + describe("hashApiKey", () => { 51 + it("should generate different hashes for different tokens", async () => { 52 + const hash1 = await hashApiKey("os_token1"); 53 + const hash2 = await hashApiKey("os_token2"); 54 + 55 + expect(hash1).not.toBe(hash2); 56 + }); 57 + 58 + it("should generate a valid bcrypt hash", async () => { 59 + const hash = await hashApiKey("os_test_token"); 60 + 61 + // Bcrypt hashes start with $2a$, $2b$, or $2y$ 62 + expect(hash).toMatch(/^\$2[aby]\$/); 63 + }); 64 + 65 + it("should generate hash that can verify the original token", async () => { 66 + const token = "os_test_token_12345"; 67 + const hash = await hashApiKey(token); 68 + 69 + expect(await verifyApiKeyHash(token, hash)).toBe(true); 70 + }); 71 + 72 + it("should generate different hashes for same token on multiple calls", async () => { 73 + const token = "os_same_token"; 74 + const hash1 = await hashApiKey(token); 75 + const hash2 = await hashApiKey(token); 76 + 77 + // bcrypt uses salt, so same input produces different hashes 78 + expect(hash1).not.toBe(hash2); 79 + // But both should verify the token 80 + expect(await verifyApiKeyHash(token, hash1)).toBe(true); 81 + expect(await verifyApiKeyHash(token, hash2)).toBe(true); 82 + }); 83 + }); 84 + 85 + describe("verifyApiKeyHash", () => { 86 + it("should return true for valid bcrypt hash with correct token", async () => { 87 + const token = "os_valid_token_12345"; 88 + const hash = await hashApiKey(token); 89 + 90 + expect(await verifyApiKeyHash(token, hash)).toBe(true); 91 + }); 92 + 93 + it("should return false for valid bcrypt hash with wrong token", async () => { 94 + const correctToken = "os_correct_token"; 95 + const wrongToken = "os_wrong_token"; 96 + const hash = await hashApiKey(correctToken); 97 + 98 + expect(await verifyApiKeyHash(wrongToken, hash)).toBe(false); 99 + }); 100 + 101 + it("should return false for non-bcrypt hash format", async () => { 102 + const token = "os_test_token"; 103 + const invalidHash = "not_a_bcrypt_hash"; 104 + 105 + expect(await verifyApiKeyHash(token, invalidHash)).toBe(false); 106 + }); 107 + 108 + it("should return false for SHA-256 hash format", async () => { 109 + const token = "os_test_token"; 110 + // SHA-256 hashes are 64 hex characters 111 + const sha256Hash = 112 + "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"; 113 + 114 + expect(await verifyApiKeyHash(token, sha256Hash)).toBe(false); 115 + }); 116 + 117 + it("should return false for empty hash", async () => { 118 + const token = "os_test_token"; 119 + 120 + expect(await verifyApiKeyHash(token, "")).toBe(false); 121 + }); 122 + 123 + it("should return false for empty token with valid hash", async () => { 124 + const hash = await hashApiKey("os_some_token"); 125 + 126 + expect(await verifyApiKeyHash("", hash)).toBe(false); 127 + }); 128 + 129 + it("should handle bcrypt hashes with different cost factors", async () => { 130 + const token = "os_test_token"; 131 + const hash = await hashApiKey(token); 132 + 133 + // Should work regardless of the $2a$, $2b$, or $2y$ variant 134 + expect(await verifyApiKeyHash(token, hash)).toBe(true); 135 + }); 136 + }); 137 + 138 + describe("shouldUpdateLastUsed", () => { 139 + it("should return true when lastUsedAt is null", () => { 140 + expect(shouldUpdateLastUsed(null)).toBe(true); 141 + }); 142 + 143 + it("should return true when enough time has passed", () => { 144 + const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); 145 + expect(shouldUpdateLastUsed(sixMinutesAgo, 5)).toBe(true); 146 + }); 147 + 148 + it("should return false when not enough time has passed", () => { 149 + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); 150 + expect(shouldUpdateLastUsed(twoMinutesAgo, 5)).toBe(false); 151 + }); 152 + 153 + it("should respect custom debounce period", () => { 154 + const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000); 155 + expect(shouldUpdateLastUsed(threeMinutesAgo, 2)).toBe(true); 156 + expect(shouldUpdateLastUsed(threeMinutesAgo, 4)).toBe(false); 157 + }); 158 + 159 + it("should return false when just updated", () => { 160 + const justNow = new Date(); 161 + expect(shouldUpdateLastUsed(justNow, 5)).toBe(false); 162 + }); 163 + 164 + it("should handle boundary case at exact debounce time", () => { 165 + const exactlyFiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); 166 + // At exactly the debounce time, it should not update (needs to be > not >=) 167 + expect(shouldUpdateLastUsed(exactlyFiveMinutesAgo, 5)).toBe(false); 168 + }); 169 + 170 + it("should handle boundary case just after debounce time", () => { 171 + const justOverFiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000 - 1); 172 + expect(shouldUpdateLastUsed(justOverFiveMinutesAgo, 5)).toBe(true); 173 + }); 174 + 175 + it("should use default debounce of 5 minutes when not specified", () => { 176 + const fourMinutesAgo = new Date(Date.now() - 4 * 60 * 1000); 177 + const sixMinutesAgo = new Date(Date.now() - 6 * 60 * 1000); 178 + 179 + expect(shouldUpdateLastUsed(fourMinutesAgo)).toBe(false); 180 + expect(shouldUpdateLastUsed(sixMinutesAgo)).toBe(true); 181 + }); 182 + 183 + it("should handle zero debounce period", () => { 184 + const oneSecondAgo = new Date(Date.now() - 1000); 185 + expect(shouldUpdateLastUsed(oneSecondAgo, 0)).toBe(true); 186 + }); 187 + 188 + it("should handle very long debounce periods", () => { 189 + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); 190 + expect(shouldUpdateLastUsed(oneHourAgo, 120)).toBe(false); // 2 hours 191 + expect(shouldUpdateLastUsed(oneHourAgo, 30)).toBe(true); // 30 minutes 192 + }); 193 + }); 194 + });
+63
packages/db/src/utils/api-key.ts
··· 1 + // biome-ignore lint/style/useNodejsImportProtocol: <explanation> 2 + import crypto from "crypto"; 3 + import bcrypt from "bcryptjs"; 4 + 5 + /** 6 + * Generates a new API key with token, prefix, and hash 7 + * @returns Object containing the full token, prefix for lookup, and SHA-256 hash 8 + */ 9 + export async function generateApiKey(): Promise<{ 10 + token: string; 11 + prefix: string; 12 + hash: string; 13 + }> { 14 + const randomBytes = crypto.randomBytes(16).toString("hex"); // 32 hex chars 15 + const token = `os_${randomBytes}`; 16 + const prefix = token.slice(0, 11); // "os_" (3 chars) + 8 hex chars = 11 total 17 + const hash = await bcrypt.hash(token, 10); 18 + return { token, prefix, hash }; 19 + } 20 + 21 + /** 22 + * Hashes an API key token using bcrypt 23 + * @param token - The API key token to hash 24 + * @returns The bcrypt hash of the token 25 + */ 26 + export async function hashApiKey(token: string): Promise<string> { 27 + return bcrypt.hash(token, 10); 28 + } 29 + 30 + /** 31 + * Verifies an API key token against a stored hash 32 + * Supports both bcrypt hashes (new) and SHA-256 hashes (legacy) for migration 33 + * @param token - The API key token to verify 34 + * @param storedHash - The stored hash to verify against 35 + * @returns True if the token matches the hash 36 + */ 37 + export async function verifyApiKeyHash( 38 + token: string, 39 + storedHash: string, 40 + ): Promise<boolean> { 41 + // Check if it's a bcrypt hash (starts with $2a$, $2b$, or $2y$) 42 + if (storedHash.startsWith("$2")) { 43 + return bcrypt.compare(token, storedHash); 44 + } 45 + 46 + // Unknown hash format 47 + return false; 48 + } 49 + 50 + /** 51 + * Determines if lastUsedAt should be updated based on debounce period 52 + * @param lastUsedAt - The last time the key was used (or null) 53 + * @param debounceMinutes - Minutes to wait before updating again (default: 5) 54 + * @returns True if lastUsedAt should be updated 55 + */ 56 + export function shouldUpdateLastUsed( 57 + lastUsedAt: Date | null, 58 + debounceMinutes = 5, 59 + ): boolean { 60 + if (!lastUsedAt) return true; 61 + const diffMs = Date.now() - lastUsedAt.getTime(); 62 + return diffMs > debounceMinutes * 60 * 1000; 63 + }
+1
packages/db/src/utils/index.ts
··· 1 + export * from "./api-key";
+38 -5
pnpm-lock.yaml
··· 4 4 autoInstallPeers: true 5 5 excludeLinksFromLockfile: false 6 6 7 - overrides: 8 - '@react-email/preview-server': npm:next@16.0.10 9 - 10 7 importers: 11 8 12 9 .: ··· 1323 1320 '@t3-oss/env-core': 1324 1321 specifier: 0.13.10 1325 1322 version: 0.13.10(typescript@5.9.3)(zod@4.1.13) 1323 + bcryptjs: 1324 + specifier: 3.0.3 1325 + version: 3.0.3 1326 1326 drizzle-orm: 1327 1327 specifier: 0.44.4 1328 1328 version: 0.44.4(@libsql/client@0.15.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(bun-types@1.3.5) ··· 1336 1336 '@openstatus/tsconfig': 1337 1337 specifier: workspace:* 1338 1338 version: link:../tsconfig 1339 + '@types/bcryptjs': 1340 + specifier: 3.0.0 1341 + version: 3.0.0 1339 1342 '@types/node': 1340 1343 specifier: 22.10.2 1341 1344 version: 22.10.2 ··· 1389 1392 specifier: workspace:* 1390 1393 version: link:../tsconfig 1391 1394 '@react-email/preview-server': 1392 - specifier: npm:next@16.0.10 1393 - version: next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 1395 + specifier: 5.1.1 1396 + version: 5.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 1394 1397 '@types/node': 1395 1398 specifier: 22.10.2 1396 1399 version: 22.10.2 ··· 5429 5432 peerDependencies: 5430 5433 react: ^18.0 || ^19.0 || ^19.0.0-rc 5431 5434 5435 + '@react-email/preview-server@5.1.1': 5436 + resolution: {integrity: sha512-prMwnjWY9h57HL3MU2NRrj6ePjN7+A23vRwOjpVFczSW5InjitODFG1fRPKpdmIrRtVvj8XHdr7KDW0QXD8tow==} 5437 + 5432 5438 '@react-email/preview@0.0.14': 5433 5439 resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} 5434 5440 engines: {node: '>=20.0.0'} ··· 6400 6406 '@types/babel__traverse@7.28.0': 6401 6407 resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} 6402 6408 6409 + '@types/bcryptjs@3.0.0': 6410 + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} 6411 + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. 6412 + 6403 6413 '@types/braces@3.0.5': 6404 6414 resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} 6405 6415 ··· 6922 6932 bcp-47@2.1.0: 6923 6933 resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} 6924 6934 6935 + bcryptjs@3.0.3: 6936 + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} 6937 + hasBin: true 6938 + 6925 6939 bignumber.js@9.3.1: 6926 6940 resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} 6927 6941 ··· 15112 15126 marked: 15.0.12 15113 15127 react: 19.2.3 15114 15128 15129 + '@react-email/preview-server@5.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': 15130 + dependencies: 15131 + next: 16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) 15132 + transitivePeerDependencies: 15133 + - '@babel/core' 15134 + - '@opentelemetry/api' 15135 + - '@playwright/test' 15136 + - babel-plugin-macros 15137 + - babel-plugin-react-compiler 15138 + - react 15139 + - react-dom 15140 + - sass 15141 + 15115 15142 '@react-email/preview@0.0.14(react@19.2.3)': 15116 15143 dependencies: 15117 15144 react: 19.2.3 ··· 16206 16233 dependencies: 16207 16234 '@babel/types': 7.28.5 16208 16235 16236 + '@types/bcryptjs@3.0.0': 16237 + dependencies: 16238 + bcryptjs: 3.0.3 16239 + 16209 16240 '@types/braces@3.0.5': {} 16210 16241 16211 16242 '@types/bun@1.3.5': ··· 16877 16908 is-alphabetical: 2.0.1 16878 16909 is-alphanumerical: 2.0.1 16879 16910 is-decimal: 2.0.1 16911 + 16912 + bcryptjs@3.0.3: {} 16880 16913 16881 16914 bignumber.js@9.3.1: {} 16882 16915