diff --git a/apps/dashboard/scripts/create-dev-auth-account.ts b/apps/dashboard/scripts/create-dev-auth-account.ts new file mode 100644 index 000000000..ed9131e70 --- /dev/null +++ b/apps/dashboard/scripts/create-dev-auth-account.ts @@ -0,0 +1,186 @@ +import { db } from "@notra/db/drizzle"; +import { + members, + organizations, + userBackupCodes, + users, +} from "@notra/db/schema"; +import { WorkOS } from "@workos-inc/node"; +import { and, eq } from "drizzle-orm"; + +const EMAIL = process.env.DEV_AUTH_EMAIL ?? "mfa-demo@demo.notra.dev"; +const PASSWORD = process.env.DEV_AUTH_PASSWORD ?? "MfaDemo-2026!"; +const ORG_SLUG = process.env.DEV_AUTH_ORG_SLUG ?? "mfa-demo"; +const ORG_NAME = "MFA Demo"; +const FIRST_NAME = "MFA"; +const LAST_NAME = "Demo"; +const RESET_MFA = process.argv.includes("--reset-mfa"); + +const apiKey = process.env.WORKOS_API_KEY; +const clientId = process.env.WORKOS_CLIENT_ID; +if (!(apiKey && clientId)) { + throw new Error("WORKOS_API_KEY and WORKOS_CLIENT_ID must be set"); +} +if (!apiKey.startsWith("sk_test")) { + throw new Error("Refusing to run against a non-test WorkOS API key"); +} + +const workos = new WorkOS(apiKey, { clientId }); + +async function ensureWorkOSUser() { + const existing = await workos.userManagement.listUsers({ email: EMAIL }); + const found = existing.data[0]; + if (found) { + await workos.userManagement.updateUser({ + userId: found.id, + password: PASSWORD, + emailVerified: true, + firstName: FIRST_NAME, + lastName: LAST_NAME, + }); + console.log(`WorkOS user exists: ${found.id} (password reset)`); + return found; + } + const created = await workos.userManagement.createUser({ + email: EMAIL, + password: PASSWORD, + emailVerified: true, + firstName: FIRST_NAME, + lastName: LAST_NAME, + }); + console.log(`WorkOS user created: ${created.id}`); + return created; +} + +async function resetFactors(workosUserId: string) { + const factors = await workos.multiFactorAuth.listUserAuthFactors({ + userId: workosUserId, + }); + const totpFactors = factors.data.filter((factor) => factor.type === "totp"); + await Promise.all( + totpFactors.map(async (factor) => { + await workos.multiFactorAuth.deleteFactor(factor.id); + console.log(`Removed ${factor.type} factor ${factor.id}`); + }) + ); + if (totpFactors.length === 0) { + console.log("No TOTP factors to remove"); + } + const skipped = factors.data.length - totpFactors.length; + if (skipped > 0) { + console.warn( + `Left ${skipped} non-TOTP factor(s) in place; they still block password sign-in` + ); + } +} + +async function ensureLocalUser(workosUserId: string) { + const byWorkosId = await db.query.users.findFirst({ + where: eq(users.workosUserId, workosUserId), + }); + if (byWorkosId) { + return byWorkosId; + } + const byEmail = await db.query.users.findFirst({ + where: eq(users.email, EMAIL), + }); + if (byEmail) { + const [linked] = await db + .update(users) + .set({ workosUserId, emailVerified: true }) + .where(eq(users.id, byEmail.id)) + .returning(); + console.log(`Linked local user ${byEmail.id} to WorkOS`); + return linked ?? byEmail; + } + const [created] = await db + .insert(users) + .values({ + id: crypto.randomUUID(), + name: `${FIRST_NAME} ${LAST_NAME}`, + email: EMAIL, + emailVerified: true, + workosUserId, + }) + .returning(); + if (!created) { + throw new Error("Failed to insert local user"); + } + console.log(`Local user created: ${created.id}`); + return created; +} + +async function ensureOrganization(userId: string) { + let organization = await db.query.organizations.findFirst({ + where: eq(organizations.slug, ORG_SLUG), + }); + let createdHere = false; + if (!organization) { + createdHere = true; + const [created] = await db + .insert(organizations) + .values({ + id: crypto.randomUUID(), + name: ORG_NAME, + slug: ORG_SLUG, + createdAt: new Date(), + onboardingCompleted: true, + onboardingDismissed: true, + onboardingAgentRan: true, + }) + .returning(); + organization = created; + console.log(`Organization created: ${ORG_SLUG}`); + } + if (!organization) { + throw new Error("Failed to create organization"); + } + const membership = await db.query.members.findFirst({ + where: and( + eq(members.organizationId, organization.id), + eq(members.userId, userId) + ), + }); + if (!membership && !createdHere) { + throw new Error( + `Organization "${ORG_SLUG}" already exists and the dev account is not a member. Pick another DEV_AUTH_ORG_SLUG rather than joining someone else's organization.` + ); + } + if (!membership) { + await db.insert(members).values({ + id: crypto.randomUUID(), + organizationId: organization.id, + userId, + role: "owner", + createdAt: new Date(), + }); + console.log("Owner membership created"); + } + return organization; +} + +const workosUser = await ensureWorkOSUser(); +const localUser = await ensureLocalUser(workosUser.id); +if (RESET_MFA) { + await resetFactors(workosUser.id); + await db + .delete(userBackupCodes) + .where(eq(userBackupCodes.userId, localUser.id)); + console.log("Cleared backup codes"); +} +if (workosUser.externalId !== localUser.id) { + await workos.userManagement.updateUser({ + userId: workosUser.id, + externalId: localUser.id, + }); +} +const organization = await ensureOrganization(localUser.id); + +console.log("\nDev auth account ready"); +console.log(` Email: ${EMAIL}`); +console.log(` Password: ${PASSWORD}`); +console.log(` Org: /${organization.slug}`); +console.log( + "\nTest: sign in at /login, open Settings → Security, set up the authenticator, sign out, sign in again." +); +process.exit(0); diff --git a/apps/dashboard/scripts/mfa-smoke.ts b/apps/dashboard/scripts/mfa-smoke.ts new file mode 100644 index 000000000..23ba0907a --- /dev/null +++ b/apps/dashboard/scripts/mfa-smoke.ts @@ -0,0 +1,84 @@ +import { AuthenticationException, WorkOS } from "@workos-inc/node"; + +import { generateTotpCode } from "../src/lib/auth/dev-totp"; + +const EMAIL = process.env.DEV_AUTH_EMAIL ?? "mfa-demo@demo.notra.dev"; +const PASSWORD = process.env.DEV_AUTH_PASSWORD ?? "MfaDemo-2026!"; + +const apiKey = process.env.WORKOS_API_KEY; +const clientId = process.env.WORKOS_CLIENT_ID; +if (!(apiKey && clientId)) { + throw new Error("WORKOS_API_KEY and WORKOS_CLIENT_ID must be set"); +} +if (!apiKey.startsWith("sk_test")) { + throw new Error("Refusing to run against a non-test WorkOS API key"); +} + +const workos = new WorkOS(apiKey, { clientId }); + +const user = (await workos.userManagement.listUsers({ email: EMAIL })).data[0]; +if (!user) { + throw new Error( + `No WorkOS user for ${EMAIL}; run create-dev-auth-account.ts` + ); +} + +const enrollment = await workos.multiFactorAuth.createUserAuthFactor({ + userId: user.id, + type: "totp", + totpIssuer: "Notra", + totpUser: EMAIL, +}); +const factorId = enrollment.authenticationFactor.id; +const secret = enrollment.authenticationFactor.totp.secret; +console.log(`1. Enrolled factor ${factorId}`); + +try { + const verification = await workos.multiFactorAuth.verifyChallenge({ + authenticationChallengeId: enrollment.authenticationChallenge.id, + code: await generateTotpCode(secret), + }); + console.log(`2. Enrollment challenge valid: ${verification.valid}`); + + let pendingToken: string | undefined; + try { + await workos.userManagement.authenticateWithPassword({ + clientId, + email: EMAIL, + password: PASSWORD, + }); + console.log( + "3. Password sign-in succeeded WITHOUT an MFA challenge → MFA is not enabled for this WorkOS environment (Dashboard → Authentication → Multi-Factor Auth)." + ); + } catch (error) { + if (!(error instanceof AuthenticationException)) { + throw error; + } + console.log(`3. Password sign-in → ${error.code}`); + if (error.code !== "mfa_challenge") { + throw error; + } + pendingToken = error.pendingAuthenticationToken; + const factors = error.rawData.authentication_factors ?? []; + console.log(` factors in error: ${JSON.stringify(factors)}`); + } + + if (pendingToken) { + const challenge = await workos.multiFactorAuth.challengeFactor({ + authenticationFactorId: factorId, + }); + const response = await workos.userManagement.authenticateWithTotp({ + clientId, + pendingAuthenticationToken: pendingToken, + authenticationChallengeId: challenge.id, + code: await generateTotpCode(secret), + }); + console.log( + `4. authenticateWithTotp → session for ${response.user.email} via ${response.authenticationMethod}` + ); + } +} finally { + await workos.multiFactorAuth.deleteFactor(factorId); + console.log(`5. Cleaned up factor ${factorId}`); +} +process.exit(0); diff --git a/apps/dashboard/src/app/(auth)/login/page.tsx b/apps/dashboard/src/app/(auth)/login/page.tsx index de917e523..d9358f92f 100644 --- a/apps/dashboard/src/app/(auth)/login/page.tsx +++ b/apps/dashboard/src/app/(auth)/login/page.tsx @@ -2,14 +2,15 @@ import { Suspense } from "react"; import { LoginContent } from "@/components/auth/login-content"; import { LoginFormSkeleton } from "@/components/auth/login-form-skeleton"; +import type { LoginPageProps } from "@/types/auth/login-page"; export const instant = true; -export default function Login() { +export default function Login({ searchParams }: LoginPageProps) { return (
}> - +
); diff --git a/apps/dashboard/src/app/auth/social/callback/route.ts b/apps/dashboard/src/app/auth/social/callback/route.ts index f4ca1d2b5..e814045e7 100644 --- a/apps/dashboard/src/app/auth/social/callback/route.ts +++ b/apps/dashboard/src/app/auth/social/callback/route.ts @@ -4,8 +4,11 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import type { NextRequest } from "next/server"; +import { LOGIN_MFA_QUERY_KEY, MFA_ERROR_CODES } from "@/constants/security"; import { SOCIAL_AUTH_STATE_COOKIE } from "@/constants/social-auth"; import { UserSyncError, WorkOSAuthError } from "@/lib/auth/errors"; +import { resolveMfaFlow } from "@/lib/auth/mfa"; +import { storePendingMfaFlow } from "@/lib/auth/mfa-cookies"; import { authenticateResolvingOrgSelection } from "@/lib/auth/org-selection"; import { sanitizeReturnTo } from "@/lib/auth/return-to"; import { syncAuthenticatedUser } from "@/lib/auth/sync"; @@ -14,8 +17,15 @@ import { readWorkOSError } from "@/lib/auth/workos-error"; const VERIFICATION_REQUIRED_CODE = "email_verification_required"; interface SocialCallbackOutcome { - kind: "success" | "failed" | "verification-required"; + kind: + | "success" + | "failed" + | "verification-required" + | "mfa-required" + | "mfa-enrollment-required"; pendingAuthenticationToken?: string; + authenticationChallengeId?: string; + workosUserId?: string; email?: string; } @@ -43,34 +53,74 @@ const exchangeSocialCode = Effect.fn("auth.social.exchangeCode")(function* ( }); }); +const logFailure = (message: string) => + Effect.logWarning("Social sign-in failed").pipe( + Effect.annotateLogs({ error: message }), + Effect.as({ kind: "failed" }) + ); + const mapFailure = (error: WorkOSAuthError | UserSyncError) => { - if (error instanceof WorkOSAuthError) { - const info = readWorkOSError(error.error); - - if ( - info.code === VERIFICATION_REQUIRED_CODE && - info.pendingAuthenticationToken - ) { - const outcome: SocialCallbackOutcome = { - kind: "verification-required", - pendingAuthenticationToken: info.pendingAuthenticationToken, - email: info.email ?? undefined, - }; - return Effect.succeed(outcome); - } + if (!(error instanceof WorkOSAuthError)) { + return logFailure(error.message); } - return Effect.logWarning("Social sign-in failed").pipe( - Effect.annotateLogs({ - error: - error instanceof WorkOSAuthError - ? readWorkOSError(error.error).message - : error.message, - }), - Effect.as({ kind: "failed" }) - ); + const info = readWorkOSError(error.error); + + if ( + info.code === VERIFICATION_REQUIRED_CODE && + info.pendingAuthenticationToken + ) { + const outcome: SocialCallbackOutcome = { + kind: "verification-required", + pendingAuthenticationToken: info.pendingAuthenticationToken, + email: info.email ?? undefined, + }; + return Effect.succeed(outcome); + } + + if ( + info.code === MFA_ERROR_CODES.ENROLLMENT && + info.pendingAuthenticationToken && + info.userId + ) { + return Effect.succeed({ + kind: "mfa-enrollment-required", + pendingAuthenticationToken: info.pendingAuthenticationToken, + workosUserId: info.userId, + email: info.email ?? undefined, + }); + } + + if (info.code === MFA_ERROR_CODES.CHALLENGE) { + return resolveMfaFlow(info, info.email ?? "").pipe( + Effect.flatMap((mfaResult) => { + if (mfaResult?.status !== "mfa-required") { + return logFailure(info.message); + } + return Effect.succeed({ + kind: "mfa-required", + pendingAuthenticationToken: mfaResult.pendingAuthenticationToken, + authenticationChallengeId: mfaResult.authenticationChallengeId, + email: mfaResult.email || undefined, + }); + }), + Effect.catch((mfaError) => + logFailure(readWorkOSError(mfaError.error).message) + ) + ); + } + + return logFailure(info.message); }; +function buildMfaLoginUrl(flowId: string, returnTo: string) { + const params = new URLSearchParams({ + [LOGIN_MFA_QUERY_KEY]: flowId, + returnTo, + }); + return `/login?${params.toString()}`; +} + export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get("code"); const state = request.nextUrl.searchParams.get("state"); @@ -113,6 +163,26 @@ export async function GET(request: NextRequest) { redirect(`/login?${params.toString()}`); } + if (outcome.kind === "mfa-required") { + const flowId = await storePendingMfaFlow({ + kind: "challenge", + pendingAuthenticationToken: outcome.pendingAuthenticationToken ?? "", + authenticationChallengeId: outcome.authenticationChallengeId ?? "", + email: outcome.email ?? "", + }); + redirect(buildMfaLoginUrl(flowId, returnTo)); + } + + if (outcome.kind === "mfa-enrollment-required") { + const flowId = await storePendingMfaFlow({ + kind: "enrollment", + pendingAuthenticationToken: outcome.pendingAuthenticationToken ?? "", + workosUserId: outcome.workosUserId ?? "", + email: outcome.email ?? "", + }); + redirect(buildMfaLoginUrl(flowId, returnTo)); + } + if (outcome.kind === "failed") { redirect("/login?error=social-sign-in-failed"); } diff --git a/apps/dashboard/src/app/design-system/auth-flow/page.tsx b/apps/dashboard/src/app/design-system/auth-flow/page.tsx new file mode 100644 index 000000000..579dc08ac --- /dev/null +++ b/apps/dashboard/src/app/design-system/auth-flow/page.tsx @@ -0,0 +1,5 @@ +import { AuthFlowPlayground } from "@/components/design-system/auth-flow-playground"; + +export default function AuthFlowPage() { + return ; +} diff --git a/apps/dashboard/src/app/design-system/page-client.tsx b/apps/dashboard/src/app/design-system/page-client.tsx index 1cebb2966..28eed1c65 100644 --- a/apps/dashboard/src/app/design-system/page-client.tsx +++ b/apps/dashboard/src/app/design-system/page-client.tsx @@ -368,10 +368,6 @@ function ColorSwatch({ name, label }: { name: string; label: string }) { ); } -/* - * Tailwind must see these class strings literally, so each row spells out its - * own utilities rather than interpolating the token name. - */ const MOTION_DURATIONS = [ { token: "duration-instant", @@ -1758,6 +1754,8 @@ export default function DesignSystemClientPage() { + + diff --git a/apps/dashboard/src/components/auth/login-content.tsx b/apps/dashboard/src/components/auth/login-content.tsx index 35f6a8cc9..cf5d6d3a7 100644 --- a/apps/dashboard/src/components/auth/login-content.tsx +++ b/apps/dashboard/src/components/auth/login-content.tsx @@ -1,35 +1,75 @@ -"use client"; - -import { parseAsString, useQueryState } from "nuqs"; +import type { PendingAuthStep } from "@notra/schemas/types/dashboard/auth"; import { LoginErrorTracker } from "@/components/auth/login-error-tracker"; import { LoginForm } from "@/components/auth/login-form"; +import { SocialEnrollmentResume } from "@/components/auth/social-enrollment-resume"; import { LOGIN_ERROR_MESSAGES } from "@/constants/login-error-messages"; +import { LOGIN_MFA_QUERY_KEY } from "@/constants/security"; +import { readPendingMfaFlow } from "@/lib/auth/mfa-cookies"; +import type { LoginPageProps, LoginPageStart } from "@/types/auth/login-page"; + +async function resolveStart( + mfa: string | undefined, + verify: string | undefined, + email: string | undefined +): Promise { + if (mfa) { + const flow = await readPendingMfaFlow(mfa); + if (flow?.kind === "challenge") { + const { kind: _kind, ...challenge } = flow; + return { pending: { status: "mfa-required", ...challenge } }; + } + if (flow?.kind === "enrollment") { + return { resumeEnrollmentFlowId: mfa }; + } + } + if (verify) { + const pending: PendingAuthStep = { + status: "verification-required", + pendingAuthenticationToken: verify, + email: email ?? "", + }; + return { pending }; + } + return {}; +} + +export async function LoginContent({ searchParams }: LoginPageProps) { + const resolvedSearchParams = await searchParams; + + const readParam = (key: string) => { + const value = resolvedSearchParams[key]; + return typeof value === "string" ? value : undefined; + }; -export function LoginContent() { - const [returnTo] = useQueryState("returnTo", parseAsString); - const [verify] = useQueryState("verify", parseAsString); - const [email] = useQueryState("email", parseAsString); - const [errorKey] = useQueryState("error", parseAsString); + const returnTo = readParam("returnTo"); + const verify = readParam("verify"); + const email = readParam("email"); + const errorKey = readParam("error"); + const mfa = readParam(LOGIN_MFA_QUERY_KEY); const knownErrorKey = errorKey && Object.hasOwn(LOGIN_ERROR_MESSAGES, errorKey) ? errorKey : undefined; + const start = await resolveStart(mfa, verify, email); return ( <> {knownErrorKey ? : null} - + {start.resumeEnrollmentFlowId ? ( + + ) : ( + + )} ); } diff --git a/apps/dashboard/src/components/auth/login-form.tsx b/apps/dashboard/src/components/auth/login-form.tsx index f93904696..2f49741a1 100644 --- a/apps/dashboard/src/components/auth/login-form.tsx +++ b/apps/dashboard/src/components/auth/login-form.tsx @@ -2,14 +2,20 @@ import { POSTHOG_EVENTS } from "@notra/posthog/events"; import { loginSchema } from "@notra/schemas/dashboard/auth/credentials"; -import { LoginForm as SharedLoginForm } from "@notra/ui/components/shared/auth/login-form"; import type { + RedeemBackupCodeInput, SignInWithPasswordInput, VerifyEmailCodeInput, -} from "@notra/ui/lib/auth-types"; + VerifyMfaCodeInput, +} from "@notra/schemas/types/dashboard/auth"; +import { LoginForm as SharedLoginForm } from "@notra/ui/components/shared/auth/login-form"; import { LOGIN_ERROR_CODES } from "@/constants/analytics-events"; import { trackEvent } from "@/lib/analytics/posthog-client"; +import { + redeemBackupCodeAction, + verifyMfaCodeAction, +} from "@/lib/auth/mfa-actions"; import { signInWithPasswordAction, verifyEmailCodeAction, @@ -45,6 +51,26 @@ async function verifyEmailCodeTracked(input: VerifyEmailCodeInput) { return result; } +async function verifyMfaCodeTracked(input: VerifyMfaCodeInput) { + const result = await verifyMfaCodeAction(input); + if (result.status === "error") { + trackEvent(POSTHOG_EVENTS.LOGIN_FAILED, { + error_code: LOGIN_ERROR_CODES.MFA_REJECTED, + }); + } + return result; +} + +async function redeemBackupCodeTracked(input: RedeemBackupCodeInput) { + const result = await redeemBackupCodeAction(input); + if (result.status === "error") { + trackEvent(POSTHOG_EVENTS.LOGIN_FAILED, { + error_code: LOGIN_ERROR_CODES.BACKUP_CODE_REJECTED, + }); + } + return result; +} + export function LoginForm({ returnTo, ...props }: LoginFormProps) { return ( ); } diff --git a/apps/dashboard/src/components/auth/signup-form.tsx b/apps/dashboard/src/components/auth/signup-form.tsx index 88ca8a912..39f7e366a 100644 --- a/apps/dashboard/src/components/auth/signup-form.tsx +++ b/apps/dashboard/src/components/auth/signup-form.tsx @@ -7,16 +7,13 @@ import { AuthFormError } from "@notra/ui/components/shared/auth/auth-form-error" import { AuthFormHeader } from "@notra/ui/components/shared/auth/auth-form-header"; import { AuthOrDivider } from "@notra/ui/components/shared/auth/auth-or-divider"; import { AuthPasswordField } from "@notra/ui/components/shared/auth/auth-password-field"; +import { AuthPendingStep } from "@notra/ui/components/shared/auth/auth-pending-step"; import { AuthSocialButtons } from "@notra/ui/components/shared/auth/auth-social-buttons"; -import { EmailVerificationForm } from "@notra/ui/components/shared/auth/email-verification-form"; import { CtaButton } from "@notra/ui/components/shared/cta-button"; import { Separator } from "@notra/ui/components/ui/separator"; -import type { - AuthMethod, - PendingVerification, - SocialProvider, -} from "@notra/ui/lib/auth-types"; +import { useAuthFlow } from "@notra/ui/hooks/use-auth-flow"; import { setLastUsedLoginMethod } from "@notra/ui/lib/last-login-method"; +import type { AuthMethod, SocialProvider } from "@notra/ui/types/auth"; import { useForm } from "@tanstack/react-form"; import { Loader2Icon } from "lucide-react"; import Link from "next/link"; @@ -27,6 +24,10 @@ import { flushSync } from "react-dom"; import { SignupCreditsBanner } from "@/components/auth/signup-credits-banner"; import { SHOW_SIGNUP_CREDITS_BANNER } from "@/constants/signup-credits"; import { trackEvent } from "@/lib/analytics/posthog-client"; +import { + redeemBackupCodeAction, + verifyMfaCodeAction, +} from "@/lib/auth/mfa-actions"; import { signUpWithPasswordAction, verifyEmailCodeAction, @@ -62,8 +63,7 @@ export function SignupForm({ }: SignupFormProps) { const [authMethod, setAuthMethod] = useState(null); const [formError, setFormError] = useState(null); - const [pendingVerification, setPendingVerification] = - useState(null); + const flow = useAuthFlow({ onSuccess }); const authInFlightRef = useRef(false); const [attributionParams] = useQueryStates(marketingAttributionSearchParams, { history: "replace", @@ -178,20 +178,10 @@ export function SignupForm({ signupMethod: "email", }); - if (result.status === "verification-required") { + flow.applyResult(result); + if (result.status !== "success") { authInFlightRef.current = false; setAuthMethod(null); - setPendingVerification({ - pendingAuthenticationToken: result.pendingAuthenticationToken, - email: result.email, - }); - return; - } - - if (onSuccess) { - onSuccess(); - } else { - window.location.assign(result.redirectTo); } } catch (error) { console.error("Email signup error:", error); @@ -202,16 +192,23 @@ export function SignupForm({ }, }); - if (pendingVerification) { + if (flow.pending) { return ( - { + flow.reset(); + setFormError( + `Backup code accepted. Two-factor authentication was turned off for ${email}. Sign in to continue.` + ); + }} + onResult={flow.applyResult} + redeemBackupCode={redeemBackupCodeAction} returnTo={buildCallbackUrl("email")} + step={flow.pending} verifyEmailCode={verifyEmailCodeAction} + verifyMfaCode={verifyMfaCodeAction} /> ); } @@ -246,8 +243,10 @@ export function SignupForm({ name="email" validators={{ onBlur: ({ value }) => - signupSchema.shape.email.safeParse(value).error?.issues[0] - ?.message, + value.length > 0 + ? signupSchema.shape.email.safeParse(value).error?.issues[0] + ?.message + : undefined, onSubmit: ({ value }) => signupSchema.shape.email.safeParse(value).error?.issues[0] ?.message, @@ -270,8 +269,10 @@ export function SignupForm({ name="password" validators={{ onBlur: ({ value }) => - signupSchema.shape.password.safeParse(value).error?.issues[0] - ?.message, + value.length > 0 + ? signupSchema.shape.password.safeParse(value).error + ?.issues[0]?.message + : undefined, onSubmit: ({ value }) => signupSchema.shape.password.safeParse(value).error?.issues[0] ?.message, diff --git a/apps/dashboard/src/components/auth/social-enrollment-resume.tsx b/apps/dashboard/src/components/auth/social-enrollment-resume.tsx new file mode 100644 index 000000000..ea0e72103 --- /dev/null +++ b/apps/dashboard/src/components/auth/social-enrollment-resume.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { AuthFlowResult } from "@notra/schemas/types/dashboard/auth"; +import { Loader2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { LoginForm } from "@/components/auth/login-form"; +import { resumeSocialEnrollmentAction } from "@/lib/auth/mfa-actions"; +import type { SocialEnrollmentResumeProps } from "@/types/auth/login-page"; + +const RESUME_ERROR_FALLBACK = + "Couldn't continue the two-factor setup. Please sign in again."; + +export function SocialEnrollmentResume({ + flowId, + returnTo, +}: SocialEnrollmentResumeProps) { + const [result, setResult] = useState(null); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) { + return; + } + startedRef.current = true; + resumeSocialEnrollmentAction({ flowId, returnTo }) + .then(setResult) + .catch(() => { + setResult({ status: "error", message: RESUME_ERROR_FALLBACK }); + }); + }, [flowId, returnTo]); + + if (!result) { + return ( +
+ + Preparing two-factor setup +
+ ); + } + + if (result.status === "mfa-enrollment-required") { + return ; + } + + return ( + + ); +} diff --git a/apps/dashboard/src/components/design-system/auth-flow-playground.tsx b/apps/dashboard/src/components/design-system/auth-flow-playground.tsx new file mode 100644 index 000000000..990bb30d8 --- /dev/null +++ b/apps/dashboard/src/components/design-system/auth-flow-playground.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { loginSchema } from "@notra/schemas/dashboard/auth/credentials"; +import { LoginForm } from "@notra/ui/components/shared/auth/login-form"; +import { TwoFactorSettings } from "@notra/ui/components/shared/security/two-factor-settings"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@notra/ui/components/ui/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@notra/ui/components/ui/tabs"; +import { TitleCard } from "@notra/ui/components/ui/title-card"; + +import { + SignedInView, + SimulatorPanel, +} from "@/components/design-system/auth-flow-simulator-panel"; +import { DesignSystemFrame } from "@/components/design-system/design-system-frame"; +import { useAuthFlowPlayground } from "@/components/design-system/use-auth-flow-playground"; +import type { AuthFlowTab } from "@/types/design-system/auth-flow"; + +const validators = { + email: (value: string) => + loginSchema.shape.email.safeParse(value).error?.issues[0]?.message, + password: (value: string) => + loginSchema.shape.password.safeParse(value).error?.issues[0]?.message, +}; + +export function AuthFlowPlayground() { + const flow = useAuthFlowPlayground(); + + return ( + + End-to-end sign-in and two-factor flow against an in-browser stand-in + for WorkOS. TOTP codes are real (RFC 6238). Nothing here talks to + WorkOS or the database. + + } + title="Auth flow playground" + > +
+ flow.setTab(value as AuthFlowTab)} + value={flow.tab} + > + + Sign in + + Security settings + + + + + + + Login + + The real shared login form with simulated server actions. + + + + {flow.session ? ( + flow.setTab("settings")} + onSignOut={flow.signOut} + session={flow.session} + /> + ) : ( +
+ ({ + status: "error", + message: "Email verification is not simulated here.", + })} + verifyMfaCode={flow.verifyMfaCode} + /> +
+ )} +
+
+
+ + + +
+

+ Add a second step when you sign in with your password. +

+ +
+
+
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/design-system/auth-flow-simulator-panel.tsx b/apps/dashboard/src/components/design-system/auth-flow-simulator-panel.tsx new file mode 100644 index 000000000..88dfba454 --- /dev/null +++ b/apps/dashboard/src/components/design-system/auth-flow-simulator-panel.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { Badge } from "@notra/ui/components/ui/badge"; +import { Label } from "@notra/ui/components/ui/label"; +import { Switch } from "@notra/ui/components/ui/switch"; +import { TitleCard } from "@notra/ui/components/ui/title-card"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/button"; +import { TOTP_ISSUER } from "@/constants/security"; +import { generateTotpCode, secondsUntilNextTotp } from "@/lib/auth/dev-totp"; +import type { + AuthenticatorWidgetProps, + SignedInViewProps, + SimulatorPanelProps, +} from "@/types/design-system/auth-flow"; + +const DEFAULT_EMAIL = "jane@company.com"; +const TOTP_TICK_MS = 1000; + +const CLOCK_TIME_ZONE = + typeof Intl === "undefined" + ? "UTC" + : Intl.DateTimeFormat().resolvedOptions().timeZone; +const clockFormatter = new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZone: CLOCK_TIME_ZONE, +}); + +function formatClock(iso: string) { + return clockFormatter.format(new Date(iso)); +} + +function AuthenticatorWidget({ secret }: AuthenticatorWidgetProps) { + const [code, setCode] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilNextTotp()); + + useEffect(() => { + if (!secret) { + return; + } + let cancelled = false; + const refresh = async () => { + const next = await generateTotpCode(secret); + if (!cancelled) { + setCode(next); + setSecondsLeft(secondsUntilNextTotp()); + } + }; + refresh(); + const timer = setInterval(refresh, TOTP_TICK_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [secret]); + + if (!secret) { + return ( +

+ No account enrolled yet. Start enrollment to see codes here. +

+ ); + } + + return ( +
+
+

+ {TOTP_ISSUER} · {DEFAULT_EMAIL} +

+

+ {code ?? "······"} +

+
+ + {secondsLeft}s + +
+ ); +} + +export function SimulatorPanel({ + account, + backupCodeCount, + orgRequiresMfa, + session, + pending, + settingsEnrollmentSecret, + log, + onToggleOrgRequiresMfa, + onReset, +}: SimulatorPanelProps) { + const authenticatorSecret = + account.totpSecret ?? pending?.enrollmentSecret ?? settingsEnrollmentSecret; + + return ( +
+ + Reset + + } + heading="Simulated WorkOS" + > +
+
+
Email
+
{account.email}
+
+
+
Password
+
{account.password}
+
+
+
Authenticator app
+
+ + {account.totpSecret ? "Enrolled" : "Off"} + +
+
+
+
Backup codes
+
+ + {backupCodeCount} + +
+
+
+
Session
+
+ {session ? ( + + password{session.secondFactor ? " + totp" : ""} + + ) : ( + Signed out + )} +
+
+
+
+
+ +

+ Forces enrollment at sign-in when no factor exists. +

+
+ +
+
+ + + + + + + {log.length === 0 ? ( +

Nothing yet.

+ ) : ( +
    + {log.map((entry) => ( +
  1. + + {formatClock(entry.at)} + + {entry.message} +
  2. + ))} +
+ )} +
+
+ ); +} + +export function SignedInView({ + session, + onSignOut, + onOpenSettings, +}: SignedInViewProps) { + return ( +
+ Signed in +
+

{session.email}

+

+ via password + {session.secondFactor ? " + authenticator code" : ""} at{" "} + {formatClock(session.signedInAt)} +

+
+
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/components/design-system/design-system-nav.tsx b/apps/dashboard/src/components/design-system/design-system-nav.tsx index ada09818a..ffd10699f 100644 --- a/apps/dashboard/src/components/design-system/design-system-nav.tsx +++ b/apps/dashboard/src/components/design-system/design-system-nav.tsx @@ -4,6 +4,8 @@ import Link from "next/link"; const LINKS = [ { href: "/design-system#colors", label: "UI kit" }, + { href: "/design-system#auth-mfa", label: "Auth" }, + { href: "/design-system/auth-flow", label: "Auth flow" }, { href: "/design-system#chatgpt-thread", label: "ChatGPT chat" }, { href: "/design-system#claude-chat-thread", label: "Claude chat" }, { href: "/design-system#gemini-thread", label: "Gemini chat" }, diff --git a/apps/dashboard/src/components/design-system/use-auth-flow-playground.ts b/apps/dashboard/src/components/design-system/use-auth-flow-playground.ts new file mode 100644 index 000000000..2f5223262 --- /dev/null +++ b/apps/dashboard/src/components/design-system/use-auth-flow-playground.ts @@ -0,0 +1,457 @@ +"use client"; + +import { BACKUP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; +import type { + AuthFlowResult, + RedeemBackupCodeInput, + RedeemBackupCodeResult, + SignInWithPasswordInput, + VerifyMfaCodeInput, +} from "@notra/schemas/types/dashboard/auth"; +import { normalizeBackupCode } from "@notra/schemas/utils/auth"; +import type { + TotpEnrollmentSubmission, + TotpVerifyResult, +} from "@notra/ui/types/auth"; +import type { + BackupCodesOutcome, + SecurityActionOutcome, +} from "@notra/ui/types/security"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { TOTP_ISSUER } from "@/constants/security"; +import { + buildOtpauthUri, + generateTotpSecret, + verifyTotpCode, +} from "@/lib/auth/dev-totp"; +import type { + AuthFlowTab, + DevAccount, + DevLogEntry, + DevPendingAuth, + DevSession, + DevSettingsEnrollment, +} from "@/types/design-system/auth-flow"; +import { buildPlaceholderQrCode } from "@/utils/design-system-qr"; + +const DEFAULT_EMAIL = "jane@company.com"; +const DEFAULT_PASSWORD = "playground-pass"; +const SIMULATED_LATENCY_MS = 450; +const BACKUP_CODE_COUNT = 10; +const BACKUP_CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; +const MAX_LOG_ENTRIES = 40; + +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +function randomId(prefix: string) { + return `${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`; +} + +function randomBackupCodes() { + const bytes = new Uint8Array(BACKUP_CODE_COUNT * BACKUP_CODE_LENGTH); + crypto.getRandomValues(bytes); + return Array.from({ length: BACKUP_CODE_COUNT }, (_, index) => + Array.from( + bytes.slice(index * BACKUP_CODE_LENGTH, (index + 1) * BACKUP_CODE_LENGTH), + (byte) => BACKUP_CODE_ALPHABET[byte % BACKUP_CODE_ALPHABET.length] + ).join("") + ); +} + +export function useAuthFlowPlayground() { + const [account, setAccount] = useState({ + email: DEFAULT_EMAIL, + password: DEFAULT_PASSWORD, + totpSecret: null, + totpEnrolledAt: null, + }); + const [backupCodes, setBackupCodes] = useState([]); + const [orgRequiresMfa, setOrgRequiresMfa] = useState(false); + const [session, setSession] = useState(null); + const [pending, setPending] = useState(null); + const [log, setLog] = useState([]); + const [tab, setTab] = useState("sign-in"); + const [loginKey, setLoginKey] = useState(0); + + const [settingsEnrollment, setSettingsEnrollment] = + useState(null); + const [isStartingEnrollment, setIsStartingEnrollment] = useState(false); + const [removingFactorId, setRemovingFactorId] = useState(null); + const pendingSessionRef = useRef(null); + const backupCodesRef = useRef([]); + const generationRef = useRef(0); + + function updateBackupCodes( + next: string[] | ((current: string[]) => string[]) + ) { + const value = + typeof next === "function" ? next(backupCodesRef.current) : next; + backupCodesRef.current = value; + setBackupCodes(value); + } + + async function simulateLatency() { + const generation = generationRef.current; + await wait(SIMULATED_LATENCY_MS); + if (generation !== generationRef.current) { + throw new Error("Playground was reset"); + } + } + + const accountRef = useRef(account); + useEffect(() => { + accountRef.current = account; + }, [account]); + + const appendLog = useCallback((message: string) => { + setLog((current) => + [ + { id: randomId("log"), at: new Date().toISOString(), message }, + ...current, + ].slice(0, MAX_LOG_ENTRIES) + ); + }, []); + + function reset() { + generationRef.current += 1; + pendingSessionRef.current = null; + setIsStartingEnrollment(false); + setRemovingFactorId(null); + setAccount({ + email: DEFAULT_EMAIL, + password: DEFAULT_PASSWORD, + totpSecret: null, + totpEnrolledAt: null, + }); + updateBackupCodes([]); + setOrgRequiresMfa(false); + setSession(null); + setPending(null); + setLog([]); + setSettingsEnrollment(null); + setTab("sign-in"); + setLoginKey((key) => key + 1); + } + + function signOut() { + setSession(null); + setSettingsEnrollment(null); + setTab("sign-in"); + setLoginKey((key) => key + 1); + appendLog("Session ended"); + } + + async function signInWithPassword( + input: SignInWithPasswordInput + ): Promise { + await simulateLatency(); + const account = accountRef.current; + const matches = + input.email.trim().toLowerCase() === account.email && + input.password === account.password; + if (!matches) { + appendLog("authenticateWithPassword → invalid credentials"); + return { status: "error", message: "Invalid email or password." }; + } + + if (account.totpSecret) { + const next: DevPendingAuth = { + token: randomId("pending"), + challengeId: randomId("auth_challenge"), + kind: "mfa", + enrollmentSecret: null, + }; + setPending(next); + appendLog("authenticateWithPassword → mfa_challenge, challenge created"); + return { + status: "mfa-required", + pendingAuthenticationToken: next.token, + authenticationChallengeId: next.challengeId, + email: account.email, + }; + } + + if (orgRequiresMfa) { + const secret = generateTotpSecret(); + const next: DevPendingAuth = { + token: randomId("pending"), + challengeId: randomId("auth_challenge"), + kind: "enrollment", + enrollmentSecret: secret, + }; + setPending(next); + appendLog("authenticateWithPassword → mfa_enrollment, factor created"); + return { + status: "mfa-enrollment-required", + pendingAuthenticationToken: next.token, + authenticationChallengeId: next.challengeId, + factorId: "auth_factor_playground", + email: account.email, + qrCode: buildPlaceholderQrCode(1), + secret, + otpauthUri: buildOtpauthUri(secret, TOTP_ISSUER, account.email), + }; + } + + setSession({ + email: account.email, + secondFactor: null, + signedInAt: new Date().toISOString(), + }); + appendLog("authenticateWithPassword → session created"); + return { status: "success", redirectTo: "#signed-in" }; + } + + async function verifyMfaCode( + input: VerifyMfaCodeInput + ): Promise { + await simulateLatency(); + if ( + !pending || + pending.token !== input.pendingAuthenticationToken || + pending.challengeId !== input.authenticationChallengeId + ) { + appendLog("authenticateWithTotp → unknown challenge"); + return { + status: "error", + message: "This sign-in attempt expired. Please start again.", + }; + } + + const secret = + pending.kind === "enrollment" + ? pending.enrollmentSecret + : account.totpSecret; + if (!secret) { + return { status: "error", message: "No authenticator enrolled." }; + } + + const valid = await verifyTotpCode(secret, input.code); + if (!valid) { + appendLog("authenticateWithTotp → invalid code"); + return { + status: "error", + message: "That code didn't work. Please try again.", + }; + } + + const nextSession: DevSession = { + email: account.email, + secondFactor: "totp", + signedInAt: new Date().toISOString(), + }; + setPending(null); + + if (pending.kind !== "enrollment") { + appendLog("authenticateWithTotp → code accepted"); + setSession(nextSession); + return { status: "success", redirectTo: "#signed-in" }; + } + + setAccount((current) => ({ + ...current, + totpSecret: secret, + totpEnrolledAt: new Date().toISOString(), + })); + const issuedCodes = randomBackupCodes(); + updateBackupCodes(issuedCodes); + pendingSessionRef.current = nextSession; + appendLog( + "authenticateWithTotp → factor verified and enrolled, backup codes issued" + ); + return { + status: "enrolled", + redirectTo: "#signed-in", + backupCodes: issuedCodes, + }; + } + + async function redeemBackupCode( + input: RedeemBackupCodeInput + ): Promise { + await simulateLatency(); + if (!pending || pending.challengeId !== input.authenticationChallengeId) { + return { + status: "error", + message: "This sign-in attempt expired. Please start again.", + }; + } + const normalized = normalizeBackupCode(input.code); + if (!backupCodesRef.current.includes(normalized)) { + appendLog("redeemBackupCode → rejected"); + return { + status: "error", + message: "That backup code isn't valid or was already used.", + }; + } + updateBackupCodes([]); + setAccount((current) => ({ + ...current, + totpSecret: null, + totpEnrolledAt: null, + })); + setPending(null); + appendLog("redeemBackupCode → accepted, authenticator removed"); + return { status: "recovered", email: account.email }; + } + + async function startSocialSignIn() { + await simulateLatency(); + appendLog("Social sign-in is not simulated in this playground"); + throw new Error("Social sign-in is not part of this playground."); + } + + async function startSettingsEnrollment() { + setIsStartingEnrollment(true); + await simulateLatency(); + const secret = generateTotpSecret(); + setSettingsEnrollment({ + secret, + qrCode: buildPlaceholderQrCode(2), + otpauthUri: buildOtpauthUri(secret, TOTP_ISSUER, account.email), + }); + setIsStartingEnrollment(false); + appendLog("createUserAuthFactor → totp factor + challenge created"); + } + + async function verifySettingsEnrollment({ + code, + }: TotpEnrollmentSubmission): Promise { + await simulateLatency(); + if (!settingsEnrollment) { + return { ok: false, message: "Start the setup again." }; + } + const valid = await verifyTotpCode(settingsEnrollment.secret, code); + if (!valid) { + appendLog("verifyChallenge → invalid code"); + return { ok: false, message: "That code didn't work. Try again." }; + } + setAccount((current) => ({ + ...current, + totpSecret: settingsEnrollment.secret, + totpEnrolledAt: new Date().toISOString(), + })); + const codes = randomBackupCodes(); + updateBackupCodes(codes); + appendLog("verifyChallenge → factor verified, 2FA on, backup codes issued"); + toast.success("Two-factor authentication is on"); + return { ok: true, backupCodes: codes }; + } + + async function confirmSecondFactor( + confirmationCode: string + ): Promise { + const normalized = normalizeBackupCode(confirmationCode); + if (backupCodesRef.current.includes(normalized)) { + updateBackupCodes((current) => current.filter((c) => c !== normalized)); + appendLog("confirmSecondFactor → backup code accepted"); + return { ok: true }; + } + const secret = accountRef.current.totpSecret; + if (secret && (await verifyTotpCode(secret, confirmationCode))) { + appendLog("confirmSecondFactor → authenticator code accepted"); + return { ok: true }; + } + appendLog("confirmSecondFactor → rejected"); + return { + ok: false, + message: + "That code didn't work. Enter the code from your authenticator app or an unused backup code.", + }; + } + + async function regenerateBackupCodes( + confirmationCode: string + ): Promise { + await simulateLatency(); + const confirmation = await confirmSecondFactor(confirmationCode); + if (!confirmation.ok) { + return confirmation; + } + const codes = randomBackupCodes(); + updateBackupCodes(codes); + appendLog("regenerateBackupCodes → new set issued"); + return { ok: true, codes }; + } + + function cancelSettingsEnrollment() { + setSettingsEnrollment(null); + appendLog("deleteFactor → abandoned enrollment removed"); + } + + async function removeFactor( + factorId: string, + confirmationCode: string + ): Promise { + setRemovingFactorId(factorId); + await simulateLatency(); + const confirmation = await confirmSecondFactor(confirmationCode); + if (!confirmation.ok) { + setRemovingFactorId(null); + return confirmation; + } + setAccount((current) => ({ + ...current, + totpSecret: null, + totpEnrolledAt: null, + })); + updateBackupCodes([]); + setRemovingFactorId(null); + appendLog("deleteFactor → 2FA off"); + toast.success("Two-factor authentication turned off"); + return { ok: true }; + } + + const factors = account.totpSecret + ? [ + { + id: "auth_factor_playground", + issuer: TOTP_ISSUER, + createdAt: account.totpEnrolledAt ?? "", + }, + ] + : []; + + function completeSignIn() { + if (pendingSessionRef.current) { + setSession(pendingSessionRef.current); + pendingSessionRef.current = null; + } + setTab("sign-in"); + } + + return { + account, + backupCodes, + factors, + log, + loginKey, + orgRequiresMfa, + pending, + session, + settingsEnrollment, + isStartingEnrollment, + removingFactorId, + tab, + setTab, + setOrgRequiresMfa, + reset, + signOut, + completeSignIn, + signInWithPassword, + verifyMfaCode, + redeemBackupCode, + startSocialSignIn, + startSettingsEnrollment, + verifySettingsEnrollment, + finishSettingsEnrollment: () => setSettingsEnrollment(null), + cancelSettingsEnrollment, + regenerateBackupCodes, + removeFactor, + }; +} diff --git a/apps/dashboard/src/components/settings/panes/account-pane.tsx b/apps/dashboard/src/components/settings/panes/account-pane.tsx index 21d242720..13e175ae5 100644 --- a/apps/dashboard/src/components/settings/panes/account-pane.tsx +++ b/apps/dashboard/src/components/settings/panes/account-pane.tsx @@ -1,6 +1,7 @@ "use client"; import { Skeleton } from "@notra/ui/components/ui/skeleton"; +import type { SecurityLoadStatus } from "@notra/ui/types/security"; import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; @@ -12,7 +13,22 @@ import { OrganizationsSection } from "@/components/settings/organizations-sectio import { PrivacySection } from "@/components/settings/privacy-section"; import { ProfileSection } from "@/components/settings/profile-section"; import { SettingsPane } from "@/components/settings/settings-pane"; +import { TwoFactorSection } from "@/components/settings/two-factor-section"; import { authClient } from "@/lib/auth/client"; +import { QUERY_KEYS } from "@/utils/query-keys"; + +function resolveSecurityStatus( + isPending: boolean, + isError: boolean +): SecurityLoadStatus { + if (isPending) { + return "loading"; + } + if (isError) { + return "error"; + } + return "ready"; +} export function AccountSettingsPane() { const router = useRouter(); @@ -39,6 +55,18 @@ export function AccountSettingsPane() { enabled: !!user, }); + const securityQuery = useQuery({ + queryKey: [...QUERY_KEYS.AUTH.security, user?.id], + queryFn: async () => { + const result = await authClient.security.getOverview(); + if (result.error) { + throw new Error(result.error.message); + } + return result.data; + }, + enabled: !!user, + }); + if (!user && isSessionPending) { return ( @@ -72,6 +100,16 @@ export function AccountSettingsPane() { email={user.email} hasPasswordAccount={hasPasswordAccount ?? false} /> + securityQuery.refetch()} + status={resolveSecurityStatus( + securityQuery.isPending, + securityQuery.isError + )} + /> @@ -318,7 +320,7 @@ function SettingsModalBody({ -
+
{isOpen && section ? ( ) : null} diff --git a/apps/dashboard/src/components/settings/two-factor-section.tsx b/apps/dashboard/src/components/settings/two-factor-section.tsx new file mode 100644 index 000000000..6d1e3fa8f --- /dev/null +++ b/apps/dashboard/src/components/settings/two-factor-section.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { TwoFactorSettings } from "@notra/ui/components/shared/security/two-factor-settings"; +import { TitleCard } from "@notra/ui/components/ui/title-card"; +import type { + TotpEnrollmentSubmission, + TotpVerifyResult, +} from "@notra/ui/types/auth"; +import type { + BackupCodesOutcome, + SecurityActionOutcome, +} from "@notra/ui/types/security"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { authClient } from "@/lib/auth/client"; +import { errorMessageOr } from "@/lib/utils"; +import type { + ActiveTotpEnrollment, + TwoFactorSectionProps, +} from "@/types/settings/security"; + +export function TwoFactorSection({ + accountLabel, + factors, + backupCodesRemaining, + status, + onRefresh, +}: TwoFactorSectionProps) { + const [enrollment, setEnrollment] = useState( + null + ); + const enrollmentRef = useRef(enrollment); + useEffect(() => { + enrollmentRef.current = enrollment; + }, [enrollment]); + + const [isStartingEnrollment, setIsStartingEnrollment] = useState(false); + const [removingFactorId, setRemovingFactorId] = useState(null); + + useEffect(() => { + return () => { + const current = enrollmentRef.current; + if (current?.kind === "scanning") { + authClient.security + .discardTotpEnrollment({ factorId: current.factorId }) + .catch(() => undefined); + } + }; + }, []); + + async function startEnrollment() { + setIsStartingEnrollment(true); + const result = await authClient.security + .startTotpEnrollment() + .catch(() => null); + setIsStartingEnrollment(false); + if (!result || result.error) { + toast.error( + errorMessageOr( + result?.error?.message, + "Couldn't start two-factor setup" + ) + ); + return; + } + setEnrollment({ kind: "scanning", ...result.data }); + } + + async function removeFactor( + factorId: string, + confirmationCode: string + ): Promise { + setRemovingFactorId(factorId); + const result = await authClient.security + .removeAuthFactor({ factorId, confirmationCode }) + .catch(() => null); + if (!result) { + setRemovingFactorId(null); + return { ok: false, message: "Couldn't remove the authenticator app" }; + } + if (result.error) { + setRemovingFactorId(null); + return { ok: false, message: result.error.message }; + } + toast.success("Two-factor authentication turned off"); + await onRefresh(); + setRemovingFactorId(null); + return { ok: true }; + } + + async function verifyEnrollment({ + code, + }: TotpEnrollmentSubmission): Promise { + if (!enrollment) { + return { ok: false, message: "Start the setup again." }; + } + + const result = await authClient.security.verifyTotpEnrollment({ + factorId: enrollment.factorId, + authenticationChallengeId: enrollment.authenticationChallengeId, + code, + }); + + if (result.error) { + return { ok: false, message: result.error.message }; + } + + setEnrollment({ ...enrollment, kind: "verified" }); + if (result.data.warning) { + toast.warning(result.data.warning); + } else { + toast.success("Two-factor authentication is on"); + } + return { ok: true, backupCodes: result.data.backupCodes ?? undefined }; + } + + async function finishEnrollment() { + setEnrollment(null); + await onRefresh(); + } + + function cancelEnrollment() { + const current = enrollment; + if (current?.kind === "verified") { + void finishEnrollment(); + return; + } + setEnrollment(null); + if (current?.kind === "scanning") { + authClient.security + .discardTotpEnrollment({ factorId: current.factorId }) + .catch(() => undefined); + } + } + + async function regenerateBackupCodes( + confirmationCode: string + ): Promise { + const result = await authClient.security.regenerateBackupCodes({ + confirmationCode, + }); + if (result.error) { + return { ok: false, message: result.error.message }; + } + await onRefresh(); + return { ok: true, codes: result.data.codes }; + } + + return ( + +
+ onRefresh()} + onStartEnrollment={startEnrollment} + onVerifyEnrollment={verifyEnrollment} + removingFactorId={removingFactorId} + status={status} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/constants/actions.ts b/apps/dashboard/src/constants/actions.ts new file mode 100644 index 000000000..70687b762 --- /dev/null +++ b/apps/dashboard/src/constants/actions.ts @@ -0,0 +1,3 @@ +export const ACTION_ERROR_CODES = { + INVALID_INPUT: "invalid_input", +} as const; diff --git a/apps/dashboard/src/constants/analytics-events.ts b/apps/dashboard/src/constants/analytics-events.ts index 00ee96e08..1766209b8 100644 --- a/apps/dashboard/src/constants/analytics-events.ts +++ b/apps/dashboard/src/constants/analytics-events.ts @@ -55,6 +55,8 @@ export const WORKOS_AUTH_METHOD_TO_ANALYTICS: Record< export const LOGIN_ERROR_CODES = { PASSWORD_REJECTED: "password_rejected", VERIFICATION_REJECTED: "verification_rejected", + MFA_REJECTED: "mfa_rejected", + BACKUP_CODE_REJECTED: "backup_code_rejected", } as const; export const PASSWORD_RESET_OUTCOMES = { diff --git a/apps/dashboard/src/constants/design-system-catalog.ts b/apps/dashboard/src/constants/design-system-catalog.ts index 8549ae8b4..c64d5569d 100644 --- a/apps/dashboard/src/constants/design-system-catalog.ts +++ b/apps/dashboard/src/constants/design-system-catalog.ts @@ -79,6 +79,12 @@ export const DESIGN_SYSTEM_CATALOG: DesignSystemCatalogItem[] = [ href: "/design-system#braille-loader", level: "section", }, + { + id: "auth-mfa", + label: "Auth · Two-factor", + href: "/design-system/auth-flow", + level: "section", + }, { id: "claude-session", label: "Claude · Session", @@ -309,11 +315,6 @@ export const DESIGN_SYSTEM_CATALOG: DesignSystemCatalogItem[] = [ }, ]; -/* - * The displayed section number is the entry's position in the list, so it is - * derived here rather than stored on every entry. Inserting a section no longer - * means renumbering every one that follows it. - */ export const DESIGN_SYSTEM_CATALOG_BY_ID = Object.fromEntries( DESIGN_SYSTEM_CATALOG.map((item, index) => [ item.id, diff --git a/apps/dashboard/src/constants/security.ts b/apps/dashboard/src/constants/security.ts new file mode 100644 index 000000000..d595f0886 --- /dev/null +++ b/apps/dashboard/src/constants/security.ts @@ -0,0 +1,22 @@ +export const TOTP_ISSUER = "Notra"; +export const TOTP_FACTOR_TYPE = "totp"; + +export const MFA_ATTEMPT_COOKIE = "notra_mfa_attempt"; +export const MFA_PENDING_COOKIE_PREFIX = "notra_mfa_pending"; +export const TOTP_ENROLLMENT_COOKIE = "notra_totp_enrollment"; +export const MFA_COOKIE_MAX_AGE_SECONDS = 10 * 60; + +export const MFA_ERROR_CODES = { + CHALLENGE: "mfa_challenge", + ENROLLMENT: "mfa_enrollment", +} as const; + +export const SECURITY_ERROR_CODES = { + INVALID_CODE: "invalid_code", + UNAVAILABLE: "unavailable", +} as const; + +export const LOGIN_MFA_QUERY_KEY = "mfa"; + +export const BACKUP_CODE_COUNT = 10; +export const BACKUP_CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; diff --git a/apps/dashboard/src/constants/settings.ts b/apps/dashboard/src/constants/settings.ts index dbc6bba33..884d3bbc0 100644 --- a/apps/dashboard/src/constants/settings.ts +++ b/apps/dashboard/src/constants/settings.ts @@ -65,6 +65,12 @@ export const SETTINGS_NAV_GROUPS: readonly SettingsNavGroup[] = [ "github", "name", "delete account", + "security", + "2fa", + "mfa", + "two-factor", + "authenticator", + "backup codes", ], }, { diff --git a/apps/dashboard/src/lib/actions/errors.ts b/apps/dashboard/src/lib/actions/errors.ts new file mode 100644 index 000000000..9ae022c5b --- /dev/null +++ b/apps/dashboard/src/lib/actions/errors.ts @@ -0,0 +1,7 @@ +import { Data } from "effect"; + +export class ActionFailure extends Data.TaggedError("ActionFailure")<{ + readonly message: string; + readonly code?: string; + readonly cause?: unknown; +}> {} diff --git a/apps/dashboard/src/lib/organizations/run-action.ts b/apps/dashboard/src/lib/actions/run-action.ts similarity index 57% rename from apps/dashboard/src/lib/organizations/run-action.ts rename to apps/dashboard/src/lib/actions/run-action.ts index d11800cab..a081b7507 100644 --- a/apps/dashboard/src/lib/organizations/run-action.ts +++ b/apps/dashboard/src/lib/actions/run-action.ts @@ -1,26 +1,23 @@ import { Effect } from "effect"; -import { OrganizationActionError } from "@/lib/organizations/errors"; +import { ActionFailure } from "@/lib/actions/errors"; import type { ActionResult } from "@/types/organizations/actions"; -export function runOrganizationAction( - effect: Effect.Effect +export function runAction( + effect: Effect.Effect ): Promise> { return Effect.runPromise( effect.pipe( Effect.catchDefect((defect) => Effect.fail( - new OrganizationActionError({ - message: "Something went wrong", - cause: defect, - }) + new ActionFailure({ message: "Something went wrong", cause: defect }) ) ), Effect.match({ onSuccess: (data): ActionResult => ({ data, error: null }), onFailure: (error): ActionResult => ({ data: null, - error: { message: error.message }, + error: { message: error.message, code: error.code }, }), }) ) diff --git a/apps/dashboard/src/lib/organizations/validate-input.ts b/apps/dashboard/src/lib/actions/validate-input.ts similarity index 66% rename from apps/dashboard/src/lib/organizations/validate-input.ts rename to apps/dashboard/src/lib/actions/validate-input.ts index 441cd1706..97cb0fa3b 100644 --- a/apps/dashboard/src/lib/organizations/validate-input.ts +++ b/apps/dashboard/src/lib/actions/validate-input.ts @@ -1,19 +1,21 @@ import { Effect } from "effect"; import type * as z from "zod"; -import { OrganizationActionError } from "@/lib/organizations/errors"; +import { ACTION_ERROR_CODES } from "@/constants/actions"; +import { ActionFailure } from "@/lib/actions/errors"; const DEFAULT_INVALID_INPUT_MESSAGE = "Invalid input"; export function validateActionInput( schema: Schema, input: unknown -): Effect.Effect, OrganizationActionError> { +): Effect.Effect, ActionFailure> { const result = schema.safeParse(input); if (!result.success) { return Effect.fail( - new OrganizationActionError({ + new ActionFailure({ + code: ACTION_ERROR_CODES.INVALID_INPUT, message: result.error.issues[0]?.message ?? DEFAULT_INVALID_INPUT_MESSAGE, }) diff --git a/apps/dashboard/src/lib/auth/auth-flow.ts b/apps/dashboard/src/lib/auth/auth-flow.ts new file mode 100644 index 000000000..e52da7789 --- /dev/null +++ b/apps/dashboard/src/lib/auth/auth-flow.ts @@ -0,0 +1,157 @@ +import { POSTHOG_EVENTS, type PostHogEventName } from "@notra/posthog/events"; +import type { PostHogProperties } from "@notra/posthog/types/posthog"; +import type { AuthFlowResult } from "@notra/schemas/types/dashboard/auth"; +import { saveSession } from "@workos-inc/authkit-nextjs"; +import type { AuthenticationResponse } from "@workos-inc/node"; +import { Effect } from "effect"; + +import { ANALYTICS_AUTH_METHODS } from "@/constants/analytics-events"; +import { toAnalyticsAuthMethod } from "@/lib/analytics/auth-method"; +import { trackServerEvent } from "@/lib/analytics/posthog-server"; +import { readRequestHeaders } from "@/lib/analytics/request-headers"; +import { UserSyncError, WorkOSAuthError } from "@/lib/auth/errors"; +import { resolveMfaFlow } from "@/lib/auth/mfa"; +import { sanitizeReturnTo } from "@/lib/auth/return-to"; +import { syncAuthenticatedUser } from "@/lib/auth/sync"; +import { readWorkOSError } from "@/lib/auth/workos-error"; + +const VERIFICATION_REQUIRED_CODE = "email_verification_required"; +const DEFAULT_POST_LOGIN_PATH = "/callback"; + +export async function trackAuthEvent( + event: PostHogEventName, + properties?: PostHogProperties, + userId?: string | null +) { + const requestHeaders = await readRequestHeaders(); + trackServerEvent({ event, headers: requestHeaders, userId, properties }); +} + +export function getWorkOSClientId() { + const clientId = process.env.WORKOS_CLIENT_ID; + if (!clientId) { + throw new Error("WORKOS_CLIENT_ID must be defined"); + } + return clientId; +} + +export const tryWorkOSAuth = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (error) => new WorkOSAuthError({ error }), + }); + +export const completeAuthentication = Effect.fn("auth.completeSession")( + function* ( + response: AuthenticationResponse, + returnTo?: string | null, + completionEvent?: PostHogEventName + ) { + yield* Effect.tryPromise({ + try: () => + saveSession( + { + accessToken: response.accessToken, + refreshToken: response.refreshToken, + user: response.user, + impersonator: response.impersonator, + authenticationMethod: response.authenticationMethod, + }, + process.env.APP_URL ?? "http://localhost:3000" + ), + catch: (cause) => + new UserSyncError({ message: "Failed to persist session", cause }), + }); + + const localUser = yield* syncAuthenticatedUser({ + workosUser: response.user, + oauthTokens: response.oauthTokens, + authenticationMethod: response.authenticationMethod, + }); + + if (completionEvent) { + yield* Effect.promise(() => + trackAuthEvent( + completionEvent, + { method: toAnalyticsAuthMethod(response.authenticationMethod) }, + localUser.id + ) + ); + } + + return { + redirectTo: sanitizeReturnTo(returnTo ?? null) ?? DEFAULT_POST_LOGIN_PATH, + localUserId: localUser.id, + }; + } +); + +export const signedIn = ({ + redirectTo, +}: { + redirectTo: string; +}): AuthFlowResult => ({ status: "success", redirectTo }); + +const mapAuthFailure = + (email: string) => + (error: WorkOSAuthError | UserSyncError | { message: string }) => { + if (!(error instanceof WorkOSAuthError)) { + return Effect.succeed({ + status: "error", + message: error.message, + }); + } + + const info = readWorkOSError(error.error); + + if ( + info.code === VERIFICATION_REQUIRED_CODE && + info.pendingAuthenticationToken + ) { + const pendingToken = info.pendingAuthenticationToken; + return Effect.promise(() => + trackAuthEvent(POSTHOG_EVENTS.EMAIL_VERIFICATION_REQUIRED, { + method: ANALYTICS_AUTH_METHODS.PASSWORD, + }) + ).pipe( + Effect.as({ + status: "verification-required", + pendingAuthenticationToken: pendingToken, + email, + }) + ); + } + + return resolveMfaFlow(info, email).pipe( + Effect.flatMap((mfaResult) => { + if (!mfaResult) { + return Effect.succeed({ + status: "error", + message: info.message, + }); + } + + const event = + mfaResult.status === "mfa-required" + ? POSTHOG_EVENTS.MFA_CHALLENGE_REQUIRED + : POSTHOG_EVENTS.MFA_ENROLLMENT_REQUIRED; + + return Effect.promise(() => + trackAuthEvent(event, { method: ANALYTICS_AUTH_METHODS.PASSWORD }) + ).pipe(Effect.as(mfaResult)); + }), + Effect.catch((mfaError) => + Effect.succeed({ + status: "error", + message: readWorkOSError(mfaError.error).message, + }) + ) + ); + }; + +export function runAuthFlow( + email: string, + flow: Effect.Effect +): Promise { + return Effect.runPromise(flow.pipe(Effect.catch(mapAuthFailure(email)))); +} diff --git a/apps/dashboard/src/lib/auth/backup-codes.ts b/apps/dashboard/src/lib/auth/backup-codes.ts new file mode 100644 index 000000000..30694e2f3 --- /dev/null +++ b/apps/dashboard/src/lib/auth/backup-codes.ts @@ -0,0 +1,106 @@ +import { createHash, randomInt } from "node:crypto"; + +import { db } from "@notra/db/drizzle"; +import { userBackupCodes } from "@notra/db/schema"; +import { BACKUP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; +import { normalizeBackupCode } from "@notra/schemas/utils/auth"; +import { and, eq, isNull, sql } from "drizzle-orm"; + +import { BACKUP_CODE_ALPHABET, BACKUP_CODE_COUNT } from "@/constants/security"; + +function hashBackupCode(code: string): string { + return createHash("sha256").update(normalizeBackupCode(code)).digest("hex"); +} + +function generateBackupCode(): string { + let code = ""; + for (let index = 0; index < BACKUP_CODE_LENGTH; index += 1) { + code += BACKUP_CODE_ALPHABET[randomInt(BACKUP_CODE_ALPHABET.length)]; + } + return code; +} + +const lockBackupCodes = ( + tx: Parameters[0]>[0], + userId: string +) => tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${userId}))`); + +export async function replaceBackupCodes(userId: string): Promise { + const codes = Array.from({ length: BACKUP_CODE_COUNT }, generateBackupCode); + await db.transaction(async (tx) => { + await lockBackupCodes(tx, userId); + await tx.delete(userBackupCodes).where(eq(userBackupCodes.userId, userId)); + await tx.insert(userBackupCodes).values( + codes.map((code) => ({ + id: crypto.randomUUID(), + userId, + codeHash: hashBackupCode(code), + })) + ); + }); + return codes; +} + +export async function clearBackupCodes(userId: string): Promise { + await db.transaction(async (tx) => { + await lockBackupCodes(tx, userId); + await tx.delete(userBackupCodes).where(eq(userBackupCodes.userId, userId)); + }); +} + +export async function countRemainingBackupCodes( + userId: string +): Promise { + const rows = await db + .select({ id: userBackupCodes.id }) + .from(userBackupCodes) + .where( + and(eq(userBackupCodes.userId, userId), isNull(userBackupCodes.usedAt)) + ); + return rows.length; +} + +export async function hasBackupCodes(userId: string): Promise { + const [row] = await db + .select({ id: userBackupCodes.id }) + .from(userBackupCodes) + .where(eq(userBackupCodes.userId, userId)) + .limit(1); + return Boolean(row); +} + +export async function hasUnusedBackupCode( + userId: string, + code: string +): Promise { + const [row] = await db + .select({ id: userBackupCodes.id }) + .from(userBackupCodes) + .where( + and( + eq(userBackupCodes.userId, userId), + eq(userBackupCodes.codeHash, hashBackupCode(code)), + isNull(userBackupCodes.usedAt) + ) + ) + .limit(1); + return Boolean(row); +} + +export async function consumeBackupCode( + userId: string, + code: string +): Promise { + const rows = await db + .update(userBackupCodes) + .set({ usedAt: new Date() }) + .where( + and( + eq(userBackupCodes.userId, userId), + eq(userBackupCodes.codeHash, hashBackupCode(code)), + isNull(userBackupCodes.usedAt) + ) + ) + .returning({ id: userBackupCodes.id }); + return rows.length > 0; +} diff --git a/apps/dashboard/src/lib/auth/client.ts b/apps/dashboard/src/lib/auth/client.ts index b2ec56943..12e653b38 100644 --- a/apps/dashboard/src/lib/auth/client.ts +++ b/apps/dashboard/src/lib/auth/client.ts @@ -4,6 +4,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { resetPostHogIdentity } from "@/lib/analytics/posthog-client"; import { isNextRedirectError } from "@/lib/auth/redirect-error"; +import { + discardTotpEnrollmentAction, + getSecurityOverviewAction, + regenerateBackupCodesAction, + removeAuthFactorAction, + startTotpEnrollmentAction, + verifyTotpEnrollmentAction, +} from "@/lib/auth/security-actions"; import { deleteUserAction, listAccountsAction, @@ -110,6 +118,14 @@ export const authClient = { requestPasswordReset: requestPasswordResetAction, listAccounts: listAccountsAction, unlinkAccount: unlinkAccountAction, + security: { + getOverview: getSecurityOverviewAction, + startTotpEnrollment: startTotpEnrollmentAction, + verifyTotpEnrollment: verifyTotpEnrollmentAction, + discardTotpEnrollment: discardTotpEnrollmentAction, + removeAuthFactor: removeAuthFactorAction, + regenerateBackupCodes: regenerateBackupCodesAction, + }, organization: { create: createOrganizationAction, update: updateOrganizationAction, diff --git a/apps/dashboard/src/lib/auth/dev-totp.ts b/apps/dashboard/src/lib/auth/dev-totp.ts new file mode 100644 index 000000000..f52b84b98 --- /dev/null +++ b/apps/dashboard/src/lib/auth/dev-totp.ts @@ -0,0 +1,126 @@ +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +const BASE32_BITS_PER_CHAR = 5; +const BYTE_BITS = 8; +const BYTE_MASK = 0xff; +const SECRET_BYTES = 20; +const COUNTER_BYTES = 8; +const TRUNCATION_MASK = 0x0f; +const HOTP_MODULUS = 10 ** 6; +const TOTP_STEP_SECONDS = 30; +const MS_PER_SECOND = 1000; +const NON_BASE32_REGEX = /[^A-Z2-7]/g; + +function base32Encode(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let output = ""; + for (const byte of bytes) { + value = (value << BYTE_BITS) | byte; + bits += BYTE_BITS; + while (bits >= BASE32_BITS_PER_CHAR) { + output += + BASE32_ALPHABET[(value >>> (bits - BASE32_BITS_PER_CHAR)) & 0x1f]; + bits -= BASE32_BITS_PER_CHAR; + } + } + if (bits > 0) { + output += BASE32_ALPHABET[(value << (BASE32_BITS_PER_CHAR - bits)) & 0x1f]; + } + return output; +} + +function base32Decode(secret: string): Uint8Array { + const normalized = secret.toUpperCase().replace(NON_BASE32_REGEX, ""); + const bytes: number[] = []; + let bits = 0; + let value = 0; + for (const char of normalized) { + value = (value << BASE32_BITS_PER_CHAR) | BASE32_ALPHABET.indexOf(char); + bits += BASE32_BITS_PER_CHAR; + if (bits >= BYTE_BITS) { + bytes.push((value >>> (bits - BYTE_BITS)) & BYTE_MASK); + bits -= BYTE_BITS; + } + } + const output = new Uint8Array(new ArrayBuffer(bytes.length)); + output.set(bytes); + return output; +} + +export function generateTotpSecret(): string { + const bytes = new Uint8Array(SECRET_BYTES); + crypto.getRandomValues(bytes); + return base32Encode(bytes); +} + +export function buildOtpauthUri( + secret: string, + issuer: string, + account: string +): string { + const label = encodeURIComponent(`${issuer}:${account}`); + const params = new URLSearchParams({ + secret, + issuer, + algorithm: "SHA1", + digits: "6", + period: String(TOTP_STEP_SECONDS), + }); + return `otpauth://totp/${label}?${params.toString()}`; +} + +async function hotp(secret: string, counter: number): Promise { + const key = await crypto.subtle.importKey( + "raw", + base32Decode(secret), + { name: "HMAC", hash: "SHA-1" }, + false, + ["sign"] + ); + const counterBytes = new Uint8Array(COUNTER_BYTES); + let remaining = counter; + for (let index = COUNTER_BYTES - 1; index >= 0; index -= 1) { + counterBytes[index] = remaining & BYTE_MASK; + remaining = Math.floor(remaining / (BYTE_MASK + 1)); + } + const digest = new Uint8Array( + await crypto.subtle.sign("HMAC", key, counterBytes) + ); + const offset = (digest.at(-1) ?? 0) & TRUNCATION_MASK; + const binary = + (((digest[offset] ?? 0) & 0x7f) << 24) | + (((digest[offset + 1] ?? 0) & BYTE_MASK) << 16) | + (((digest[offset + 2] ?? 0) & BYTE_MASK) << 8) | + ((digest[offset + 3] ?? 0) & BYTE_MASK); + return String(binary % HOTP_MODULUS).padStart(6, "0"); +} + +function totpCounter(timestampMs = Date.now()): number { + return Math.floor(timestampMs / MS_PER_SECOND / TOTP_STEP_SECONDS); +} + +export function secondsUntilNextTotp(timestampMs = Date.now()): number { + const elapsed = Math.floor(timestampMs / MS_PER_SECOND) % TOTP_STEP_SECONDS; + return TOTP_STEP_SECONDS - elapsed; +} + +export function generateTotpCode( + secret: string, + timestampMs = Date.now() +): Promise { + return hotp(secret, totpCounter(timestampMs)); +} + +export async function verifyTotpCode( + secret: string, + code: string, + window = 1 +): Promise { + const counter = totpCounter(); + for (let delta = -window; delta <= window; delta += 1) { + if ((await hotp(secret, counter + delta)) === code) { + return true; + } + } + return false; +} diff --git a/apps/dashboard/src/lib/auth/mfa-actions.ts b/apps/dashboard/src/lib/auth/mfa-actions.ts new file mode 100644 index 000000000..a7562fa9d --- /dev/null +++ b/apps/dashboard/src/lib/auth/mfa-actions.ts @@ -0,0 +1,299 @@ +"use server"; + +import { db } from "@notra/db/drizzle"; +import { users } from "@notra/db/schema"; +import { POSTHOG_EVENTS } from "@notra/posthog/events"; +import { + redeemBackupCodeInputSchema, + resumeSocialEnrollmentInputSchema, + verifyMfaCodeInputSchema, +} from "@notra/schemas/dashboard/auth/mfa"; +import type { + AuthFlowResult, + RedeemBackupCodeInput, + RedeemBackupCodeResult, + ResumeSocialEnrollmentInput, + VerifyMfaCodeInput, +} from "@notra/schemas/types/dashboard/auth"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; + +import { ANALYTICS_AUTH_METHODS } from "@/constants/analytics-events"; +import { TOTP_FACTOR_TYPE } from "@/constants/security"; +import { + completeAuthentication, + getWorkOSClientId, + runAuthFlow, + signedIn, + trackAuthEvent, + tryWorkOSAuth, +} from "@/lib/auth/auth-flow"; +import { + clearBackupCodes, + consumeBackupCode, + hasBackupCodes, + hasUnusedBackupCode, + replaceBackupCodes, +} from "@/lib/auth/backup-codes"; +import { beginTotpEnrollment } from "@/lib/auth/mfa"; +import { + clearAllPendingMfaFlows, + clearMfaAttemptCookie, + clearPendingMfaFlow, + readMfaAttempt, + readPendingMfaFlow, +} from "@/lib/auth/mfa-cookies"; +import { authenticateResolvingOrgSelection } from "@/lib/auth/org-selection"; +import { readWorkOSError } from "@/lib/auth/workos-error"; +import type { MfaAttempt } from "@/types/auth/mfa-cookies"; +import { isAccountRateLimited, ratelimit } from "@/utils/ratelimit"; + +const RATE_LIMITED_MESSAGE = "Too many attempts. Please try again shortly."; +const ATTEMPT_EXPIRED_MESSAGE = + "This sign-in attempt expired. Please start again."; +const BACKUP_CODE_REJECTED_MESSAGE = + "That backup code isn't valid or was already used."; + +async function isMfaVerifyRateLimited(attempt: MfaAttempt) { + if ( + await isAccountRateLimited( + ratelimit.mfaVerify, + `challenge:${attempt.authenticationChallengeId}` + ) + ) { + return true; + } + return isAccountRateLimited( + ratelimit.mfaVerify, + `user:${attempt.workosUserId}` + ); +} + +async function readMatchingAttempt(authenticationChallengeId: string) { + const attempt = await readMfaAttempt(); + return attempt?.authenticationChallengeId === authenticationChallengeId + ? attempt + : null; +} + +const attemptAfterSignIn = (run: () => Promise, what: string) => + Effect.tryPromise(run).pipe( + Effect.catch((error) => + Effect.logWarning(`MFA sign-in succeeded but ${what} failed`).pipe( + Effect.annotateLogs({ error: String(error.cause) }), + Effect.as(null) + ) + ) + ); + +export async function verifyMfaCodeAction( + rawInput: VerifyMfaCodeInput +): Promise { + const parsed = verifyMfaCodeInputSchema.safeParse(rawInput); + + if (!parsed.success) { + return { + status: "error", + message: parsed.error.issues[0]?.message ?? "Invalid code", + }; + } + + const attempt = await readMatchingAttempt( + parsed.data.authenticationChallengeId + ); + if (!attempt) { + return { status: "error", message: ATTEMPT_EXPIRED_MESSAGE }; + } + if (await isMfaVerifyRateLimited(attempt)) { + return { status: "error", message: RATE_LIMITED_MESSAGE }; + } + + return runAuthFlow( + "", + Effect.gen(function* () { + const response = yield* authenticateResolvingOrgSelection(() => + getWorkOS().userManagement.authenticateWithTotp({ + clientId: getWorkOSClientId(), + code: parsed.data.code, + pendingAuthenticationToken: parsed.data.pendingAuthenticationToken, + authenticationChallengeId: parsed.data.authenticationChallengeId, + }) + ); + + const session = yield* completeAuthentication( + response, + parsed.data.returnTo, + POSTHOG_EVENTS.MFA_VERIFIED + ); + yield* Effect.promise(clearMfaAttemptCookie); + yield* Effect.promise(clearAllPendingMfaFlows); + + const alreadyHasCodes = yield* attemptAfterSignIn( + () => hasBackupCodes(session.localUserId), + "checking backup codes" + ); + if (alreadyHasCodes !== false) { + return signedIn(session); + } + + const backupCodes = yield* attemptAfterSignIn( + () => replaceBackupCodes(session.localUserId), + "issuing backup codes" + ); + if (!backupCodes) { + return signedIn(session); + } + return { + status: "enrolled" as const, + redirectTo: session.redirectTo, + backupCodes, + }; + }) + ); +} + +export async function redeemBackupCodeAction( + rawInput: RedeemBackupCodeInput +): Promise { + const parsed = redeemBackupCodeInputSchema.safeParse(rawInput); + if (!parsed.success) { + return { + status: "error", + message: parsed.error.issues[0]?.message ?? "Invalid backup code", + }; + } + + const attempt = await readMatchingAttempt( + parsed.data.authenticationChallengeId + ); + if (!attempt) { + return { status: "error", message: ATTEMPT_EXPIRED_MESSAGE }; + } + const { workosUserId, startedAt } = attempt; + + if (await isAccountRateLimited(ratelimit.backupCode, workosUserId)) { + return { status: "error", message: RATE_LIMITED_MESSAGE }; + } + + const rejected: RedeemBackupCodeResult = { + status: "error", + message: BACKUP_CODE_REJECTED_MESSAGE, + }; + + return Effect.runPromise( + Effect.gen(function* () { + const localUser = yield* Effect.promise(() => + db.query.users.findFirst({ + where: eq(users.workosUserId, workosUserId), + columns: { id: true, email: true }, + }) + ); + if (!localUser) { + return rejected; + } + + const unused = yield* Effect.promise(() => + hasUnusedBackupCode(localUser.id, parsed.data.code) + ); + if (!unused) { + return rejected; + } + + const removeLockedOutFactors = Effect.gen(function* () { + const factors = yield* tryWorkOSAuth(() => + getWorkOS().multiFactorAuth.listUserAuthFactors({ + userId: workosUserId, + }) + ); + const totpFactors = factors.data.filter( + (factor) => factor.type === TOTP_FACTOR_TYPE + ); + const lockedOutFactors = totpFactors.filter( + (factor) => Date.parse(factor.createdAt) <= startedAt + ); + yield* Effect.forEach( + lockedOutFactors, + (factor) => + tryWorkOSAuth(() => + getWorkOS().multiFactorAuth.deleteFactor(factor.id) + ), + { discard: true } + ); + return lockedOutFactors.length === totpFactors.length; + }); + const allFactorsRemoved = yield* removeLockedOutFactors; + yield* Effect.promise(() => + consumeBackupCode(localUser.id, parsed.data.code) + ); + + if (allFactorsRemoved) { + yield* Effect.promise(() => clearBackupCodes(localUser.id)); + } + yield* Effect.promise(clearMfaAttemptCookie); + yield* Effect.promise(() => + trackAuthEvent( + POSTHOG_EVENTS.MFA_BACKUP_CODE_USED, + { method: ANALYTICS_AUTH_METHODS.PASSWORD }, + localUser.id + ) + ); + return { + status: "recovered" as const, + email: localUser.email, + }; + }).pipe( + Effect.catch((error) => + Effect.succeed({ + status: "error", + message: readWorkOSError(error.error).message, + }) + ) + ) + ); +} + +export async function resumeSocialEnrollmentAction( + rawInput: ResumeSocialEnrollmentInput +): Promise { + const parsed = resumeSocialEnrollmentInputSchema.safeParse(rawInput); + if (!parsed.success) { + return { status: "error", message: ATTEMPT_EXPIRED_MESSAGE }; + } + + const flow = await readPendingMfaFlow(parsed.data.flowId); + if (flow?.kind !== "enrollment") { + return { status: "error", message: ATTEMPT_EXPIRED_MESSAGE }; + } + if ( + await isAccountRateLimited( + ratelimit.signIn, + `enrollment:${flow.workosUserId}` + ) + ) { + return { status: "error", message: RATE_LIMITED_MESSAGE }; + } + await clearPendingMfaFlow(parsed.data.flowId); + + return runAuthFlow( + flow.email, + Effect.gen(function* () { + const enrollment = yield* beginTotpEnrollment( + flow.workosUserId, + flow.email + ); + yield* Effect.promise(() => + trackAuthEvent(POSTHOG_EVENTS.MFA_ENROLLMENT_REQUIRED, { + method: ANALYTICS_AUTH_METHODS.UNKNOWN, + }) + ); + const result: AuthFlowResult = { + status: "mfa-enrollment-required", + pendingAuthenticationToken: flow.pendingAuthenticationToken, + email: flow.email, + ...enrollment, + }; + return result; + }) + ); +} diff --git a/apps/dashboard/src/lib/auth/mfa-cookies.ts b/apps/dashboard/src/lib/auth/mfa-cookies.ts new file mode 100644 index 000000000..baa595625 --- /dev/null +++ b/apps/dashboard/src/lib/auth/mfa-cookies.ts @@ -0,0 +1,120 @@ +import "zod/compile"; +// biome-ignore lint/performance/noNamespaceImport: Zod recommended way to import +import * as z from "zod"; + +import { + MFA_ATTEMPT_COOKIE, + MFA_COOKIE_MAX_AGE_SECONDS, + MFA_PENDING_COOKIE_PREFIX, + TOTP_ENROLLMENT_COOKIE, +} from "@/constants/security"; +import { + clearSignedCookie, + clearSignedCookiesWithPrefix, + readSignedCookie, + storeSignedCookie, +} from "@/lib/auth/signed-cookie"; +import type { + MfaAttempt, + PendingMfaFlow, + TotpEnrollmentInProgress, +} from "@/types/auth/mfa-cookies"; + +const mfaAttemptSchema = z.object({ + workosUserId: z.string().min(1), + authenticationChallengeId: z.string().min(1), + startedAt: z.number().int().positive(), +}); + +const pendingMfaFlowSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("challenge"), + pendingAuthenticationToken: z.string().min(1), + authenticationChallengeId: z.string().min(1), + email: z.string(), + }), + z.object({ + kind: z.literal("enrollment"), + pendingAuthenticationToken: z.string().min(1), + workosUserId: z.string().min(1), + email: z.string(), + }), +]); + +const totpEnrollmentInProgressSchema = z.object({ + localUserId: z.string().min(1), + factorId: z.string().min(1), + authenticationChallengeId: z.string().min(1), +}); + +const mfaFlowIdSchema = z.uuid(); + +function getPendingMfaCookieName(flowId: string) { + const parsed = mfaFlowIdSchema.safeParse(flowId); + return parsed.success ? `${MFA_PENDING_COOKIE_PREFIX}_${parsed.data}` : null; +} + +export function storeMfaAttempt(attempt: MfaAttempt) { + return storeSignedCookie( + MFA_ATTEMPT_COOKIE, + attempt, + MFA_COOKIE_MAX_AGE_SECONDS + ); +} + +export function readMfaAttempt() { + return readSignedCookie(MFA_ATTEMPT_COOKIE, mfaAttemptSchema); +} + +export function clearMfaAttemptCookie() { + return clearSignedCookie(MFA_ATTEMPT_COOKIE); +} + +export async function storePendingMfaFlow(flow: PendingMfaFlow) { + const flowId = crypto.randomUUID(); + await storeSignedCookie( + `${MFA_PENDING_COOKIE_PREFIX}_${flowId}`, + flow, + MFA_COOKIE_MAX_AGE_SECONDS + ); + return flowId; +} + +export function readPendingMfaFlow(flowId: string) { + const cookieName = getPendingMfaCookieName(flowId); + return cookieName + ? readSignedCookie(cookieName, pendingMfaFlowSchema) + : Promise.resolve(null); +} + +export function clearAllPendingMfaFlows() { + return clearSignedCookiesWithPrefix(`${MFA_PENDING_COOKIE_PREFIX}_`); +} + +export async function clearPendingMfaFlow(flowId: string) { + const cookieName = getPendingMfaCookieName(flowId); + if (cookieName) { + await clearSignedCookie(cookieName); + } +} + +export function storeTotpEnrollmentInProgress( + enrollment: TotpEnrollmentInProgress +) { + return storeSignedCookie( + TOTP_ENROLLMENT_COOKIE, + enrollment, + MFA_COOKIE_MAX_AGE_SECONDS + ); +} + +export function readTotpEnrollmentInProgress() { + return readSignedCookie( + TOTP_ENROLLMENT_COOKIE, + totpEnrollmentInProgressSchema + ); +} + +export function clearTotpEnrollmentInProgress() { + return clearSignedCookie(TOTP_ENROLLMENT_COOKIE); +} diff --git a/apps/dashboard/src/lib/auth/mfa.ts b/apps/dashboard/src/lib/auth/mfa.ts new file mode 100644 index 000000000..87030f99f --- /dev/null +++ b/apps/dashboard/src/lib/auth/mfa.ts @@ -0,0 +1,109 @@ +import { db } from "@notra/db/drizzle"; +import { users } from "@notra/db/schema"; +import type { AuthFlowResult } from "@notra/schemas/types/dashboard/auth"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; +import { eq } from "drizzle-orm"; +import { Effect } from "effect"; + +import { MFA_ERROR_CODES, TOTP_FACTOR_TYPE } from "@/constants/security"; +import { clearBackupCodes } from "@/lib/auth/backup-codes"; +import { WorkOSAuthError } from "@/lib/auth/errors"; +import { storeMfaAttempt } from "@/lib/auth/mfa-cookies"; +import { createTotpFactor } from "@/lib/auth/workos-mfa"; +import type { WorkOSErrorInfo } from "@/types/auth/workos-error"; + +const tryWorkOS = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (error) => new WorkOSAuthError({ error }), + }); + +const createMfaChallenge = Effect.fn("auth.mfa.createChallenge")(function* ( + authenticationFactorId: string +) { + const challenge = yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.challengeFactor({ authenticationFactorId }) + ); + return challenge.id; +}); + +const rememberAttempt = ( + workosUserId: string, + authenticationChallengeId: string +) => + Effect.promise(() => + storeMfaAttempt({ + workosUserId, + authenticationChallengeId, + startedAt: Date.now(), + }) + ); + +async function forgetFactorState(workosUserId: string) { + const localUser = await db.query.users.findFirst({ + where: eq(users.workosUserId, workosUserId), + columns: { id: true }, + }); + if (!localUser) { + return; + } + await clearBackupCodes(localUser.id); +} + +export const beginTotpEnrollment = Effect.fn("auth.mfa.beginEnrollment")( + function* (workosUserId: string, email: string) { + yield* Effect.promise(() => forgetFactorState(workosUserId)); + const enrollment = yield* tryWorkOS(() => + createTotpFactor(workosUserId, email) + ); + yield* rememberAttempt(workosUserId, enrollment.authenticationChallengeId); + return enrollment; + } +); + +export const resolveMfaFlow = Effect.fn("auth.mfa.resolveFlow")(function* ( + info: WorkOSErrorInfo, + email: string +) { + const pendingAuthenticationToken = info.pendingAuthenticationToken; + if (!pendingAuthenticationToken) { + return null; + } + + const resolvedEmail = email || info.email || ""; + + if (info.code === MFA_ERROR_CODES.CHALLENGE) { + const factor = info.authenticationFactors.find( + (candidate) => candidate.type === TOTP_FACTOR_TYPE + ); + + if (!factor) { + return null; + } + + const authenticationChallengeId = yield* createMfaChallenge(factor.id); + if (info.userId) { + yield* rememberAttempt(info.userId, authenticationChallengeId); + } + const result: AuthFlowResult = { + status: "mfa-required", + pendingAuthenticationToken, + authenticationChallengeId, + email: resolvedEmail, + }; + return result; + } + + if (info.code === MFA_ERROR_CODES.ENROLLMENT && info.userId) { + const enrollment = yield* beginTotpEnrollment(info.userId, resolvedEmail); + const result: AuthFlowResult = { + status: "mfa-enrollment-required", + pendingAuthenticationToken, + email: resolvedEmail, + ...enrollment, + }; + return result; + } + + return null; +}); diff --git a/apps/dashboard/src/lib/auth/password-actions.ts b/apps/dashboard/src/lib/auth/password-actions.ts index 65ae6eb84..02109e69b 100644 --- a/apps/dashboard/src/lib/auth/password-actions.ts +++ b/apps/dashboard/src/lib/auth/password-actions.ts @@ -1,7 +1,6 @@ "use server"; -import { POSTHOG_EVENTS, type PostHogEventName } from "@notra/posthog/events"; -import type { PostHogProperties } from "@notra/posthog/types/posthog"; +import { POSTHOG_EVENTS } from "@notra/posthog/events"; import { forgotPasswordInputSchema, resetPasswordInputSchema, @@ -11,168 +10,31 @@ import { } from "@notra/schemas/dashboard/auth/credentials"; import type { AuthFlowResult, + ForgotPasswordInput, + ResetPasswordInput, SignInWithPasswordInput, + SignUpWithPasswordInput, VerifyEmailCodeInput, -} from "@notra/ui/lib/auth-types"; -import type { Ratelimit } from "@upstash/ratelimit"; -import { getWorkOS, saveSession } from "@workos-inc/authkit-nextjs"; -import type { AuthenticationResponse } from "@workos-inc/node"; +} from "@notra/schemas/types/dashboard/auth"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; import { Effect } from "effect"; -import { headers } from "next/headers"; +import { PASSWORD_RESET_OUTCOMES } from "@/constants/analytics-events"; import { - ANALYTICS_AUTH_METHODS, - PASSWORD_RESET_OUTCOMES, -} from "@/constants/analytics-events"; -import { trackServerEvent } from "@/lib/analytics/posthog-server"; -import { readRequestHeaders } from "@/lib/analytics/request-headers"; -import { UserSyncError, WorkOSAuthError } from "@/lib/auth/errors"; + completeAuthentication, + getWorkOSClientId, + runAuthFlow, + signedIn, + trackAuthEvent, + tryWorkOSAuth, +} from "@/lib/auth/auth-flow"; import { authenticateResolvingOrgSelection } from "@/lib/auth/org-selection"; -import { sanitizeReturnTo } from "@/lib/auth/return-to"; -import { syncAuthenticatedUser } from "@/lib/auth/sync"; import { readWorkOSError } from "@/lib/auth/workos-error"; -import type { - ForgotPasswordInput, - ResetPasswordInput, - SignUpWithPasswordInput, -} from "@/types/auth/password-actions"; -import { getClientIpFromHeaders, ratelimit } from "@/utils/ratelimit"; +import { isRateLimited, ratelimit } from "@/utils/ratelimit"; -async function trackAuthEvent( - event: PostHogEventName, - properties?: PostHogProperties, - userId?: string | null -) { - const requestHeaders = await readRequestHeaders(); - trackServerEvent({ event, headers: requestHeaders, userId, properties }); -} - -const VERIFICATION_REQUIRED_CODE = "email_verification_required"; const NAME_SPLIT_REGEX = /\s+/; -const DEFAULT_POST_LOGIN_PATH = "/callback"; const RATE_LIMITED_MESSAGE = "Too many attempts. Please try again shortly."; -async function isRateLimited(limiter: Ratelimit, email: string) { - if ( - process.env.NODE_ENV !== "production" && - (!process.env.UPSTASH_REDIS_REST_URL || - !process.env.UPSTASH_REDIS_REST_TOKEN) - ) { - return false; - } - - const headersList = await headers(); - const ip = getClientIpFromHeaders(headersList); - const { success } = await limiter.limit(`${ip}:${email.toLowerCase()}`); - return !success; -} - -function getClientId() { - const clientId = process.env.WORKOS_CLIENT_ID; - if (!clientId) { - throw new Error("WORKOS_CLIENT_ID must be defined"); - } - return clientId; -} - -function getAppUrl() { - return process.env.APP_URL ?? "http://localhost:3000"; -} - -const tryWorkOSAuth = (run: () => Promise) => - Effect.tryPromise({ - try: run, - catch: (error) => new WorkOSAuthError({ error }), - }); - -const completeAuthentication = Effect.fn("auth.password.completeSession")( - function* ( - response: AuthenticationResponse, - returnTo?: string | null, - completionEvent?: PostHogEventName - ) { - yield* Effect.tryPromise({ - try: () => - saveSession( - { - accessToken: response.accessToken, - refreshToken: response.refreshToken, - user: response.user, - impersonator: response.impersonator, - authenticationMethod: response.authenticationMethod, - }, - getAppUrl() - ), - catch: (cause) => - new UserSyncError({ message: "Failed to persist session", cause }), - }); - - const localUser = yield* syncAuthenticatedUser({ - workosUser: response.user, - oauthTokens: response.oauthTokens, - authenticationMethod: response.authenticationMethod, - }); - - if (completionEvent) { - yield* Effect.promise(() => - trackAuthEvent( - completionEvent, - { method: ANALYTICS_AUTH_METHODS.PASSWORD }, - localUser.id - ) - ); - } - - const redirectTo = - sanitizeReturnTo(returnTo ?? null) ?? DEFAULT_POST_LOGIN_PATH; - - const result: AuthFlowResult = { status: "success", redirectTo }; - return result; - } -); - -const mapAuthFailure = - (email: string) => - (error: WorkOSAuthError | UserSyncError | { message: string }) => { - if (error instanceof WorkOSAuthError) { - const info = readWorkOSError(error.error); - - if ( - info.code === VERIFICATION_REQUIRED_CODE && - info.pendingAuthenticationToken - ) { - const pendingToken = info.pendingAuthenticationToken; - return Effect.promise(() => - trackAuthEvent(POSTHOG_EVENTS.EMAIL_VERIFICATION_REQUIRED, { - method: ANALYTICS_AUTH_METHODS.PASSWORD, - }) - ).pipe( - Effect.as({ - status: "verification-required", - pendingAuthenticationToken: pendingToken, - email, - }) - ); - } - - return Effect.succeed({ - status: "error", - message: info.message, - }); - } - - return Effect.succeed({ - status: "error", - message: error.message, - }); - }; - -const runAuthFlow = ( - email: string, - flow: Effect.Effect -): Promise => - Effect.runPromise(flow.pipe(Effect.catch(mapAuthFailure(email)))); - export async function signInWithPasswordAction( rawInput: SignInWithPasswordInput ): Promise { @@ -194,13 +56,15 @@ export async function signInWithPasswordAction( Effect.gen(function* () { const response = yield* authenticateResolvingOrgSelection(() => getWorkOS().userManagement.authenticateWithPassword({ - clientId: getClientId(), + clientId: getWorkOSClientId(), email: parsed.data.email, password: parsed.data.password, }) ); - return yield* completeAuthentication(response, parsed.data.returnTo); + return signedIn( + yield* completeAuthentication(response, parsed.data.returnTo) + ); }) ); } @@ -239,13 +103,15 @@ export async function signUpWithPasswordAction( const response = yield* authenticateResolvingOrgSelection(() => getWorkOS().userManagement.authenticateWithPassword({ - clientId: getClientId(), + clientId: getWorkOSClientId(), email: parsed.data.email, password: parsed.data.password, }) ); - return yield* completeAuthentication(response, parsed.data.returnTo); + return signedIn( + yield* completeAuthentication(response, parsed.data.returnTo) + ); }) ); } @@ -267,16 +133,18 @@ export async function verifyEmailCodeAction( Effect.gen(function* () { const response = yield* authenticateResolvingOrgSelection(() => getWorkOS().userManagement.authenticateWithEmailVerification({ - clientId: getClientId(), + clientId: getWorkOSClientId(), code: parsed.data.code, pendingAuthenticationToken: parsed.data.pendingAuthenticationToken, }) ); - return yield* completeAuthentication( - response, - parsed.data.returnTo, - POSTHOG_EVENTS.EMAIL_VERIFIED + return signedIn( + yield* completeAuthentication( + response, + parsed.data.returnTo, + POSTHOG_EVENTS.EMAIL_VERIFIED + ) ); }) ); @@ -304,19 +172,17 @@ export async function forgotPasswordAction( } return Effect.runPromise( - Effect.gen(function* () { - yield* tryWorkOSAuth(() => - getWorkOS().userManagement.createPasswordReset({ email: input.email }) - ); - - yield* Effect.promise(() => - trackAuthEvent(POSTHOG_EVENTS.PASSWORD_RESET_REQUESTED, { - outcome: PASSWORD_RESET_OUTCOMES.SENT, - }) - ); - - return { sent: true }; - }).pipe( + tryWorkOSAuth(() => + getWorkOS().userManagement.createPasswordReset({ email: input.email }) + ).pipe( + Effect.andThen( + Effect.promise(() => + trackAuthEvent(POSTHOG_EVENTS.PASSWORD_RESET_REQUESTED, { + outcome: PASSWORD_RESET_OUTCOMES.SENT, + }) + ) + ), + Effect.as({ sent: true }), Effect.catch((error) => Effect.logWarning("Password reset request failed").pipe( Effect.annotateLogs({ diff --git a/apps/dashboard/src/lib/auth/security-actions.ts b/apps/dashboard/src/lib/auth/security-actions.ts new file mode 100644 index 000000000..0ecbb1953 --- /dev/null +++ b/apps/dashboard/src/lib/auth/security-actions.ts @@ -0,0 +1,447 @@ +"use server"; + +import { POSTHOG_EVENTS, type PostHogEventName } from "@notra/posthog/events"; +import { TOTP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; +import { + discardTotpEnrollmentInputSchema, + regenerateBackupCodesInputSchema, + removeAuthFactorInputSchema, + verifyTotpEnrollmentInputSchema, +} from "@notra/schemas/dashboard/auth/mfa"; +import type { + DiscardTotpEnrollmentInput, + RegenerateBackupCodesInput, + RegenerateBackupCodesResult, + RemoveAuthFactorInput, + SecurityOverview, + StartTotpEnrollmentResult, + TotpFactorSummary, + VerifyTotpEnrollmentInput, + VerifyTotpEnrollmentResult, +} from "@notra/schemas/types/dashboard/auth"; +import { normalizeBackupCode } from "@notra/schemas/utils/auth"; +import type { Ratelimit } from "@upstash/ratelimit"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; +import { Effect } from "effect"; + +import { SECURITY_ERROR_CODES, TOTP_FACTOR_TYPE } from "@/constants/security"; +import { ActionFailure } from "@/lib/actions/errors"; +import { runAction } from "@/lib/actions/run-action"; +import { validateActionInput } from "@/lib/actions/validate-input"; +import { trackServerEvent } from "@/lib/analytics/posthog-server"; +import { readRequestHeaders } from "@/lib/analytics/request-headers"; +import { + clearBackupCodes, + consumeBackupCode, + countRemainingBackupCodes, + hasUnusedBackupCode, + replaceBackupCodes, +} from "@/lib/auth/backup-codes"; +import { + clearTotpEnrollmentInProgress, + readTotpEnrollmentInProgress, + storeTotpEnrollmentInProgress, +} from "@/lib/auth/mfa-cookies"; +import { readWorkOSError } from "@/lib/auth/workos-error"; +import { createTotpFactor } from "@/lib/auth/workos-mfa"; +import { requireSession } from "@/lib/organizations/guards"; +import type { ActionResult } from "@/types/organizations/actions"; +import { isAccountRateLimited, ratelimit } from "@/utils/ratelimit"; + +const RATE_LIMITED_MESSAGE = "Too many attempts. Please try again shortly."; +const INVALID_TOTP_MESSAGE = + "That code didn't work. Check your authenticator app and try again."; +const INVALID_CONFIRMATION_MESSAGE = + "That code didn't work. Enter the code from your authenticator app or an unused backup code."; +const ENROLLMENT_EXPIRED_MESSAGE = + "This setup expired or belongs to another session. Start the setup again."; +const ALREADY_ENABLED_MESSAGE = + "Two-factor authentication is already on. Remove the current authenticator app before adding another."; + +const tryWorkOS = (run: () => Promise) => + Effect.tryPromise({ + try: run, + catch: (cause) => + new ActionFailure({ message: readWorkOSError(cause).message, cause }), + }); + +const tryDb = (run: () => Promise, message: string) => + Effect.tryPromise({ + try: run, + catch: (cause) => new ActionFailure({ message, cause }), + }); + +const attemptDb = (run: () => Promise) => + Effect.tryPromise(run).pipe( + Effect.catch((error) => + Effect.logWarning("Security bookkeeping failed after enrollment").pipe( + Effect.annotateLogs({ error: String(error.cause) }), + Effect.as(null) + ) + ) + ); + +const enforceRateLimit = (limiter: Ratelimit, key: string) => + Effect.promise(() => isAccountRateLimited(limiter, key)).pipe( + Effect.andThen((limited) => + limited + ? Effect.fail(new ActionFailure({ message: RATE_LIMITED_MESSAGE })) + : Effect.void + ) + ); + +const requireSecurityContext = Effect.fn("auth.security.requireContext")( + function* () { + const session = yield* requireSession(); + const workosUserId = session.user.workosUserId; + if (!workosUserId) { + return yield* Effect.fail( + new ActionFailure({ + code: SECURITY_ERROR_CODES.UNAVAILABLE, + message: "Security settings aren't available for this account yet.", + }) + ); + } + + return { + localUserId: session.user.id, + email: session.user.email, + workosUserId, + }; + } +); + +const trackSecurityEvent = (event: PostHogEventName, userId: string) => + Effect.promise(async () => { + const requestHeaders = await readRequestHeaders(); + trackServerEvent({ event, headers: requestHeaders, userId }); + }); + +const listTotpFactors = Effect.fn("auth.security.listTotpFactors")(function* ( + workosUserId: string +) { + const factors = yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.listUserAuthFactors({ + userId: workosUserId, + }) + ); + return factors.data + .filter((factor) => factor.type === TOTP_FACTOR_TYPE) + .map((factor) => ({ + id: factor.id, + issuer: factor.totp?.issuer ?? null, + createdAt: factor.createdAt, + })); +}); + +interface SecurityContext { + localUserId: string; + email: string; + workosUserId: string; +} + +const isTotpCode = (code: string) => + code.length === TOTP_CODE_LENGTH && /^\d+$/.test(code); + +const verifyTotpAgainstFactors = Effect.fn("auth.security.verifyTotp")( + function* (factors: TotpFactorSummary[], code: string) { + for (const factor of factors) { + const challenge = yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.challengeFactor({ + authenticationFactorId: factor.id, + }) + ); + const verification = yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.verifyChallenge({ + authenticationChallengeId: challenge.id, + code, + }) + ).pipe(Effect.catch(() => Effect.succeed({ valid: false }))); + if (verification.valid) { + return true; + } + } + return false; + } +); + +const confirmSecondFactor = Effect.fn("auth.security.confirmSecondFactor")( + function* ( + context: SecurityContext, + factors: TotpFactorSummary[], + code: string + ) { + yield* enforceRateLimit( + ratelimit.mfaVerify, + `confirm:${context.localUserId}` + ); + if (isTotpCode(code)) { + const confirmed = yield* verifyTotpAgainstFactors(factors, code); + if (!confirmed) { + return yield* Effect.fail( + new ActionFailure({ + code: SECURITY_ERROR_CODES.INVALID_CODE, + message: INVALID_CONFIRMATION_MESSAGE, + }) + ); + } + return null; + } + const backupCode = normalizeBackupCode(code); + const unused = yield* tryDb( + () => hasUnusedBackupCode(context.localUserId, backupCode), + "Couldn't check the backup code. Please try again." + ); + if (!unused) { + return yield* Effect.fail( + new ActionFailure({ + code: SECURITY_ERROR_CODES.INVALID_CODE, + message: INVALID_CONFIRMATION_MESSAGE, + }) + ); + } + return backupCode; + } +); + +const withSecondFactor = ( + context: SecurityContext, + factors: TotpFactorSummary[], + code: string, + change: Effect.Effect +) => + Effect.gen(function* () { + const backupCode = yield* confirmSecondFactor(context, factors, code); + return yield* change.pipe( + Effect.tap(() => + backupCode + ? Effect.promise(() => + consumeBackupCode(context.localUserId, backupCode) + ).pipe(Effect.ignore) + : Effect.void + ) + ); + }); + +export async function getSecurityOverviewAction(): Promise< + ActionResult +> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + + const totpFactors = yield* listTotpFactors(context.workosUserId); + const backupCodesRemaining = + totpFactors.length > 0 + ? yield* Effect.promise(() => + countRemainingBackupCodes(context.localUserId) + ) + : 0; + + return { + email: context.email, + totpFactors, + backupCodesRemaining, + }; + }) + ); +} + +export async function startTotpEnrollmentAction(): Promise< + ActionResult +> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + const existing = yield* listTotpFactors(context.workosUserId); + if (existing.length > 0) { + return yield* Effect.fail( + new ActionFailure({ message: ALREADY_ENABLED_MESSAGE }) + ); + } + const enrollment = yield* tryWorkOS(() => + createTotpFactor(context.workosUserId, context.email) + ); + yield* Effect.promise(() => + storeTotpEnrollmentInProgress({ + localUserId: context.localUserId, + factorId: enrollment.factorId, + authenticationChallengeId: enrollment.authenticationChallengeId, + }) + ); + return enrollment; + }) + ); +} + +export async function discardTotpEnrollmentAction( + rawInput: DiscardTotpEnrollmentInput +): Promise> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + const input = yield* validateActionInput( + discardTotpEnrollmentInputSchema, + rawInput + ); + const inProgress = yield* Effect.promise(readTotpEnrollmentInProgress); + if ( + inProgress?.localUserId !== context.localUserId || + inProgress.factorId !== input.factorId + ) { + return { discarded: false }; + } + yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.deleteFactor(input.factorId) + ); + yield* Effect.promise(clearTotpEnrollmentInProgress); + return { discarded: true }; + }) + ); +} + +export async function verifyTotpEnrollmentAction( + rawInput: VerifyTotpEnrollmentInput +): Promise> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + const input = yield* validateActionInput( + verifyTotpEnrollmentInputSchema, + rawInput + ); + yield* enforceRateLimit(ratelimit.mfaVerify, context.localUserId); + + const inProgress = yield* Effect.promise(readTotpEnrollmentInProgress); + const isOwnEnrollment = + inProgress?.localUserId === context.localUserId && + inProgress.factorId === input.factorId && + inProgress.authenticationChallengeId === + input.authenticationChallengeId; + if (!isOwnEnrollment) { + return yield* Effect.fail( + new ActionFailure({ message: ENROLLMENT_EXPIRED_MESSAGE }) + ); + } + + const verification = yield* tryWorkOS(() => + getWorkOS().multiFactorAuth.verifyChallenge({ + authenticationChallengeId: input.authenticationChallengeId, + code: input.code, + }) + ); + if (!verification.valid) { + return yield* Effect.fail( + new ActionFailure({ + code: SECURITY_ERROR_CODES.INVALID_CODE, + message: INVALID_TOTP_MESSAGE, + }) + ); + } + if (verification.challenge.authenticationFactorId !== input.factorId) { + return yield* Effect.fail( + new ActionFailure({ message: ENROLLMENT_EXPIRED_MESSAGE }) + ); + } + yield* Effect.promise(clearTotpEnrollmentInProgress); + + const warnings: string[] = []; + const backupCodes = yield* attemptDb(() => + replaceBackupCodes(context.localUserId) + ); + if (backupCodes === null) { + warnings.push( + "backup codes couldn't be generated. Regenerate them from settings" + ); + } + yield* trackSecurityEvent( + POSTHOG_EVENTS.MFA_FACTOR_ENROLLED, + context.localUserId + ); + return { + verified: true as const, + backupCodes, + warning: + warnings.length > 0 + ? `Two-factor is on, but ${warnings.join(" and ")}.` + : null, + }; + }) + ); +} + +export async function regenerateBackupCodesAction( + rawInput: RegenerateBackupCodesInput +): Promise> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + const input = yield* validateActionInput( + regenerateBackupCodesInputSchema, + rawInput + ); + const factors = yield* listTotpFactors(context.workosUserId); + if (factors.length === 0) { + return yield* Effect.fail( + new ActionFailure({ + message: + "Set up an authenticator app before generating backup codes.", + }) + ); + } + const codes = yield* withSecondFactor( + context, + factors, + input.confirmationCode, + tryDb( + () => replaceBackupCodes(context.localUserId), + "Couldn't generate backup codes. Please try again." + ) + ); + yield* trackSecurityEvent( + POSTHOG_EVENTS.MFA_BACKUP_CODES_REGENERATED, + context.localUserId + ); + return { codes }; + }) + ); +} + +export async function removeAuthFactorAction( + rawInput: RemoveAuthFactorInput +): Promise> { + return runAction( + Effect.gen(function* () { + const context = yield* requireSecurityContext(); + const input = yield* validateActionInput( + removeAuthFactorInputSchema, + rawInput + ); + + const factors = yield* listTotpFactors(context.workosUserId); + if (!factors.some((factor) => factor.id === input.factorId)) { + return yield* Effect.fail( + new ActionFailure({ + message: "That authentication method no longer exists.", + }) + ); + } + yield* withSecondFactor( + context, + factors, + input.confirmationCode, + tryWorkOS(() => + getWorkOS().multiFactorAuth.deleteFactor(input.factorId) + ) + ); + if (factors.length === 1) { + yield* Effect.promise(() => clearBackupCodes(context.localUserId)); + } + yield* trackSecurityEvent( + POSTHOG_EVENTS.MFA_FACTOR_REMOVED, + context.localUserId + ); + return { removed: true as const }; + }) + ); +} diff --git a/apps/dashboard/src/lib/auth/short-lived-cookie.ts b/apps/dashboard/src/lib/auth/short-lived-cookie.ts new file mode 100644 index 000000000..d9bc89d11 --- /dev/null +++ b/apps/dashboard/src/lib/auth/short-lived-cookie.ts @@ -0,0 +1,39 @@ +import { cookies } from "next/headers"; + +export async function readShortLivedCookie( + name: string +): Promise { + const cookieStore = await cookies(); + return cookieStore.get(name)?.value || null; +} + +export async function storeShortLivedCookie( + name: string, + value: string, + maxAgeSeconds: number +) { + const cookieStore = await cookies(); + cookieStore.set({ + name, + value, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: maxAgeSeconds, + }); +} + +export async function clearShortLivedCookie(name: string) { + const cookieStore = await cookies(); + cookieStore.delete({ name, path: "/" }); +} + +export async function clearShortLivedCookiesWithPrefix(prefix: string) { + const cookieStore = await cookies(); + for (const cookie of cookieStore.getAll()) { + if (cookie.name.startsWith(prefix)) { + cookieStore.delete({ name: cookie.name, path: "/" }); + } + } +} diff --git a/apps/dashboard/src/lib/auth/signed-cookie.ts b/apps/dashboard/src/lib/auth/signed-cookie.ts new file mode 100644 index 000000000..cf8ac4ac0 --- /dev/null +++ b/apps/dashboard/src/lib/auth/signed-cookie.ts @@ -0,0 +1,79 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import type * as z from "zod"; + +import { + clearShortLivedCookie, + clearShortLivedCookiesWithPrefix, + readShortLivedCookie, + storeShortLivedCookie, +} from "@/lib/auth/short-lived-cookie"; + +const SIGNATURE_SEPARATOR = "."; +const KEY_CONTEXT = "notra-signed-cookie"; + +function getSigningKey(): Buffer { + const password = process.env.WORKOS_COOKIE_PASSWORD; + if (!password) { + throw new Error("WORKOS_COOKIE_PASSWORD must be defined"); + } + return createHmac("sha256", password).update(KEY_CONTEXT).digest(); +} + +function sign(encodedPayload: string): string { + return createHmac("sha256", getSigningKey()) + .update(encodedPayload) + .digest("base64url"); +} + +export async function storeSignedCookie( + name: string, + payload: unknown, + maxAgeSeconds: number +) { + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + await storeShortLivedCookie( + name, + `${encoded}${SIGNATURE_SEPARATOR}${sign(encoded)}`, + maxAgeSeconds + ); +} + +export async function readSignedCookie( + name: string, + schema: z.ZodType +): Promise { + const raw = await readShortLivedCookie(name); + if (!raw) { + return null; + } + const separatorIndex = raw.lastIndexOf(SIGNATURE_SEPARATOR); + if (separatorIndex === -1) { + return null; + } + const encoded = raw.slice(0, separatorIndex); + const provided = Buffer.from(raw.slice(separatorIndex + 1), "base64url"); + const expected = Buffer.from(sign(encoded), "base64url"); + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + return null; + } + try { + const parsed = schema.safeParse( + JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) + ); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +export function clearSignedCookie(name: string) { + return clearShortLivedCookie(name); +} + +export function clearSignedCookiesWithPrefix(prefix: string) { + return clearShortLivedCookiesWithPrefix(prefix); +} diff --git a/apps/dashboard/src/lib/auth/social-actions.ts b/apps/dashboard/src/lib/auth/social-actions.ts index fa6c3e585..af1816d7e 100644 --- a/apps/dashboard/src/lib/auth/social-actions.ts +++ b/apps/dashboard/src/lib/auth/social-actions.ts @@ -1,7 +1,7 @@ "use server"; import { startSocialSignInInputSchema } from "@notra/schemas/dashboard/auth/social"; -import type { StartSocialSignInInput } from "@notra/ui/lib/auth-types"; +import type { StartSocialSignInInput } from "@notra/schemas/types/dashboard/auth"; import { getWorkOS } from "@workos-inc/authkit-nextjs"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; diff --git a/apps/dashboard/src/lib/auth/user-actions.ts b/apps/dashboard/src/lib/auth/user-actions.ts index 81e784e53..3d25c94df 100644 --- a/apps/dashboard/src/lib/auth/user-actions.ts +++ b/apps/dashboard/src/lib/auth/user-actions.ts @@ -12,14 +12,14 @@ import { getWorkOS, signOut, withAuth } from "@workos-inc/authkit-nextjs"; import { and, eq } from "drizzle-orm"; import { Effect } from "effect"; +import { ActionFailure } from "@/lib/actions/errors"; +import { runAction } from "@/lib/actions/run-action"; +import { validateActionInput } from "@/lib/actions/validate-input"; import { trackServerEvent } from "@/lib/analytics/posthog-server"; import { readRequestHeaders } from "@/lib/analytics/request-headers"; import { clearAuthSessionCookie } from "@/lib/auth/session-cookie"; import { isWorkOSNotFound } from "@/lib/auth/workos-error"; -import { OrganizationActionError } from "@/lib/organizations/errors"; import { requireSession } from "@/lib/organizations/guards"; -import { runOrganizationAction } from "@/lib/organizations/run-action"; -import { validateActionInput } from "@/lib/organizations/validate-input"; import type { SessionUser } from "@/types/auth/session"; import type { SignOutActionOptions, @@ -31,7 +31,7 @@ import type { AccountInfo, ActionResult } from "@/types/organizations/actions"; const tryAction = (run: () => Promise, message: string) => Effect.tryPromise({ try: run, - catch: (cause) => new OrganizationActionError({ message, cause }), + catch: (cause) => new ActionFailure({ message, cause }), }); export async function signOutAction(options?: SignOutActionOptions) { @@ -42,7 +42,7 @@ export async function signOutAction(options?: SignOutActionOptions) { export async function updateUserAction( rawInput: UpdateUserInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput(updateUserInputSchema, rawInput); @@ -79,7 +79,7 @@ export async function updateUserAction( if (!updated) { return yield* Effect.fail( - new OrganizationActionError({ message: "User not found" }) + new ActionFailure({ message: "User not found" }) ); } @@ -110,7 +110,7 @@ export async function updateUserAction( export async function deleteUserAction(): Promise< ActionResult<{ deleted: boolean }> > { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); @@ -178,7 +178,7 @@ export async function deleteUserAction(): Promise< export async function requestPasswordResetAction(): Promise< ActionResult<{ sent: boolean }> > { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); @@ -198,7 +198,7 @@ export async function requestPasswordResetAction(): Promise< export async function listAccountsAction(): Promise< ActionResult > { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); @@ -224,7 +224,7 @@ export async function listAccountsAction(): Promise< export async function unlinkAccountAction( rawInput: UnlinkAccountInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( diff --git a/apps/dashboard/src/lib/auth/workos-error.ts b/apps/dashboard/src/lib/auth/workos-error.ts index 0c17aea94..bbad26827 100644 --- a/apps/dashboard/src/lib/auth/workos-error.ts +++ b/apps/dashboard/src/lib/auth/workos-error.ts @@ -1,13 +1,7 @@ import { workosErrorSchema } from "@notra/schemas/dashboard/auth/workos-error"; import { NotFoundException } from "@workos-inc/node"; -export interface WorkOSErrorInfo { - code: string | null; - message: string; - email: string | null; - pendingAuthenticationToken: string | null; - organizationIds: string[]; -} +import type { WorkOSErrorInfo } from "@/types/auth/workos-error"; export function readWorkOSError(error: unknown): WorkOSErrorInfo { const parsed = workosErrorSchema.safeParse(error); @@ -19,6 +13,8 @@ export function readWorkOSError(error: unknown): WorkOSErrorInfo { email: null, pendingAuthenticationToken: null, organizationIds: [], + authenticationFactors: [], + userId: null, }; } @@ -27,10 +23,16 @@ export function readWorkOSError(error: unknown): WorkOSErrorInfo { return { code: rawData?.code ?? code ?? null, message: rawData?.message ?? message ?? "Something went wrong", - email: rawData?.email ?? null, + email: rawData?.email ?? rawData?.user?.email ?? null, pendingAuthenticationToken: rawData?.pending_authentication_token ?? null, organizationIds: rawData?.organizations?.map((organization) => organization.id) ?? [], + authenticationFactors: + rawData?.authentication_factors?.map((factor) => ({ + id: factor.id, + type: factor.type, + })) ?? [], + userId: rawData?.user?.id ?? null, }; } diff --git a/apps/dashboard/src/lib/auth/workos-mfa.ts b/apps/dashboard/src/lib/auth/workos-mfa.ts new file mode 100644 index 000000000..01cb5c7a5 --- /dev/null +++ b/apps/dashboard/src/lib/auth/workos-mfa.ts @@ -0,0 +1,24 @@ +import type { TotpEnrollment } from "@notra/schemas/types/dashboard/auth"; +import { getWorkOS } from "@workos-inc/authkit-nextjs"; + +import { TOTP_FACTOR_TYPE, TOTP_ISSUER } from "@/constants/security"; + +export async function createTotpFactor( + userId: string, + totpUser: string +): Promise { + const enrollment = await getWorkOS().multiFactorAuth.createUserAuthFactor({ + userId, + type: TOTP_FACTOR_TYPE, + totpIssuer: TOTP_ISSUER, + totpUser, + }); + + return { + factorId: enrollment.authenticationFactor.id, + authenticationChallengeId: enrollment.authenticationChallenge.id, + qrCode: enrollment.authenticationFactor.totp.qrCode, + secret: enrollment.authenticationFactor.totp.secret, + otpauthUri: enrollment.authenticationFactor.totp.uri, + }; +} diff --git a/apps/dashboard/src/lib/organizations/actions.ts b/apps/dashboard/src/lib/organizations/actions.ts index 998a0ad8c..198638881 100644 --- a/apps/dashboard/src/lib/organizations/actions.ts +++ b/apps/dashboard/src/lib/organizations/actions.ts @@ -34,21 +34,21 @@ import { LAST_VISITED_ORGANIZATION_COOKIE, LAST_VISITED_ORGANIZATION_COOKIE_MAX_AGE, } from "@/constants/cookies"; +import { ActionFailure } from "@/lib/actions/errors"; +import { runAction } from "@/lib/actions/run-action"; +import { validateActionInput } from "@/lib/actions/validate-input"; import { identifyOrganizationGroup, trackServerEvent, } from "@/lib/analytics/posthog-server"; import { readRequestHeaders } from "@/lib/analytics/request-headers"; import { readWorkOSError } from "@/lib/auth/workos-error"; -import { OrganizationActionError } from "@/lib/organizations/errors"; import { requireManagerMembership, requireMembership, requireSession, resolveOrganizationId, } from "@/lib/organizations/guards"; -import { runOrganizationAction } from "@/lib/organizations/run-action"; -import { validateActionInput } from "@/lib/organizations/validate-input"; import { ensureWorkOSOrganizationWithMembers, removeMembershipFromWorkOS, @@ -79,7 +79,7 @@ const enforceTeamMembersLimit = Effect.fn( const status = yield* Effect.tryPromise({ try: () => checkTeamMembersLimit(organizationId), catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: TEAM_MEMBER_LIMIT_CHECK_UNAVAILABLE_MESSAGE, cause, }), @@ -87,7 +87,7 @@ const enforceTeamMembersLimit = Effect.fn( if (status === "check-unavailable") { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: TEAM_MEMBER_LIMIT_CHECK_UNAVAILABLE_MESSAGE, }) ); @@ -95,7 +95,7 @@ const enforceTeamMembersLimit = Effect.fn( if (status === "limit-reached") { return yield* Effect.fail( - new OrganizationActionError({ message: TEAM_MEMBER_LIMIT_ERROR_MESSAGE }) + new ActionFailure({ message: TEAM_MEMBER_LIMIT_ERROR_MESSAGE }) ); } }); @@ -103,7 +103,7 @@ const enforceTeamMembersLimit = Effect.fn( const tryDb = (run: () => Promise, message: string) => Effect.tryPromise({ try: run, - catch: (cause) => new OrganizationActionError({ message, cause }), + catch: (cause) => new ActionFailure({ message, cause }), }); const trackOrganizationEvent = Effect.fn( @@ -172,7 +172,7 @@ const tryWorkOS = (run: () => Promise, fallbackMessage: string) => Effect.tryPromise({ try: run, catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: readWorkOSError(cause).message || fallbackMessage, cause, }), @@ -194,7 +194,7 @@ const requireWorkOSOrganizationId = Effect.fn( return yield* ensureWorkOSOrganizationWithMembers(organizationId).pipe( Effect.catch((error) => Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "This organization is not linked to WorkOS yet", cause: error, }) @@ -215,7 +215,7 @@ const requireInvitationManagement = Effect.fn( if (!invitation.organizationId) { return yield* Effect.fail( - new OrganizationActionError({ message: "Invitation not found" }) + new ActionFailure({ message: "Invitation not found" }) ); } @@ -230,7 +230,7 @@ const requireInvitationManagement = Effect.fn( if (!organization) { return yield* Effect.fail( - new OrganizationActionError({ message: "Organization not found" }) + new ActionFailure({ message: "Organization not found" }) ); } @@ -245,7 +245,7 @@ const requireInvitationManagement = Effect.fn( export async function createOrganizationAction( rawInput: CreateOrganizationInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -265,7 +265,7 @@ export async function createOrganizationAction( if (existing) { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "An organization with this slug already exists", }) ); @@ -303,7 +303,7 @@ export async function createOrganizationAction( if (!organization) { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "Organization creation returned no row", }) ); @@ -320,7 +320,7 @@ export async function createOrganizationAction( ).pipe( Effect.andThen( Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "Failed to link organization to WorkOS", cause: error, }) @@ -333,7 +333,7 @@ export async function createOrganizationAction( yield* Effect.tryPromise({ try: () => seedSystemSkills(organizationId), catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: "Failed to seed system skills", cause, }), @@ -355,7 +355,7 @@ export async function createOrganizationAction( metadata: { orgId: organizationId }, }), catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: "Failed to create billing customer", cause, }), @@ -405,7 +405,7 @@ export async function createOrganizationAction( export async function updateOrganizationAction( rawInput: UpdateOrganizationInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -444,7 +444,7 @@ export async function updateOrganizationAction( if (!organization) { return yield* Effect.fail( - new OrganizationActionError({ message: "Organization not found" }) + new ActionFailure({ message: "Organization not found" }) ); } @@ -487,7 +487,7 @@ export async function updateOrganizationAction( export async function listOrganizationsAction(): Promise< ActionResult > { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); @@ -527,7 +527,7 @@ function findOrganizationForSelection(input: SetActiveOrganizationInput) { export async function setActiveOrganizationAction( rawInput: SetActiveOrganizationInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -542,7 +542,7 @@ export async function setActiveOrganizationAction( if (!organization) { return yield* Effect.fail( - new OrganizationActionError({ message: "Organization not found" }) + new ActionFailure({ message: "Organization not found" }) ); } @@ -562,15 +562,10 @@ export async function setActiveOrganizationAction( ); } -/** - * Organization row for a `/[slug]` route, without the member join. Unlike - * `validateOrganizationAccess` this never redirects, so it is safe to call from - * a client query. - */ export async function getOrganizationSummaryAction( rawSlug: string ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const slug = yield* validateActionInput( @@ -588,7 +583,7 @@ export async function getOrganizationSummaryAction( if (!organization) { return yield* Effect.fail( - new OrganizationActionError({ message: "Organization not found" }) + new ActionFailure({ message: "Organization not found" }) ); } @@ -602,7 +597,7 @@ export async function getOrganizationSummaryAction( export async function getFullOrganizationAction(rawInput?: { query?: { organizationId?: string; organizationSlug?: string }; }): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -673,7 +668,7 @@ export async function getFullOrganizationAction(rawInput?: { export async function listMembersAction( rawInput?: ListMembersInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -709,7 +704,7 @@ export async function listMembersAction( export async function updateMemberRoleAction( rawInput: UpdateMemberRoleInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -727,7 +722,7 @@ export async function updateMemberRoleAction( if (!member) { return yield* Effect.fail( - new OrganizationActionError({ message: "Member not found" }) + new ActionFailure({ message: "Member not found" }) ); } @@ -738,7 +733,7 @@ export async function updateMemberRoleAction( if (input.role === "owner" && callerMembership.role !== "owner") { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "Only the organization owner can assign the owner role", }) ); @@ -746,7 +741,7 @@ export async function updateMemberRoleAction( if (member.role === "owner" && input.role !== "owner") { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "The organization owner role cannot be changed", }) ); @@ -802,7 +797,7 @@ export async function updateMemberRoleAction( export async function removeMemberAction( rawInput: RemoveMemberInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -846,7 +841,7 @@ export async function removeMemberAction( if (!member) { return yield* Effect.fail( - new OrganizationActionError({ message: "Member not found" }) + new ActionFailure({ message: "Member not found" }) ); } @@ -858,7 +853,7 @@ export async function removeMemberAction( if (member.role === "owner") { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "The organization owner cannot be removed", }) ); @@ -891,7 +886,7 @@ export async function removeMemberAction( export async function listInvitationsAction(rawInput?: { query?: { organizationId?: string }; }): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -922,7 +917,7 @@ export async function listInvitationsAction(rawInput?: { export async function inviteMemberAction( rawInput: InviteMemberInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const session = yield* requireSession(); const input = yield* validateActionInput( @@ -940,7 +935,7 @@ export async function inviteMemberAction( if (input.role === "owner" && callerMembership.role !== "owner") { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "Only the organization owner can assign the owner role", }) ); @@ -948,7 +943,7 @@ export async function inviteMemberAction( if (!isNotDisposableEmail(input.email)) { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "Disposable email addresses are not allowed", }) ); @@ -1018,7 +1013,7 @@ export async function inviteMemberAction( export async function cancelInvitationAction( rawInput: InvitationActionInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const input = yield* validateActionInput( invitationActionInputSchema, @@ -1046,7 +1041,7 @@ export async function cancelInvitationAction( export async function resendInvitationAction( rawInput: InvitationActionInput ): Promise> { - return runOrganizationAction( + return runAction( Effect.gen(function* () { const input = yield* validateActionInput( invitationActionInputSchema, diff --git a/apps/dashboard/src/lib/organizations/errors.ts b/apps/dashboard/src/lib/organizations/errors.ts index 09e4382e0..1b7fe3886 100644 --- a/apps/dashboard/src/lib/organizations/errors.ts +++ b/apps/dashboard/src/lib/organizations/errors.ts @@ -1,12 +1,5 @@ import { Data } from "effect"; -export class OrganizationActionError extends Data.TaggedError( - "OrganizationActionError" -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - export class WorkOSSyncError extends Data.TaggedError("WorkOSSyncError")<{ readonly message: string; readonly cause: unknown; diff --git a/apps/dashboard/src/lib/organizations/guards.ts b/apps/dashboard/src/lib/organizations/guards.ts index 831914806..e3411ec14 100644 --- a/apps/dashboard/src/lib/organizations/guards.ts +++ b/apps/dashboard/src/lib/organizations/guards.ts @@ -3,8 +3,8 @@ import { members } from "@notra/db/schema"; import { and, eq } from "drizzle-orm"; import { Effect } from "effect"; +import { ActionFailure } from "@/lib/actions/errors"; import { getAuthSession } from "@/lib/auth/server"; -import { OrganizationActionError } from "@/lib/organizations/errors"; import type { AuthSessionData } from "@/types/auth/session"; const MANAGER_ROLES: readonly string[] = ["owner", "admin"]; @@ -14,16 +14,14 @@ export const requireSession = Effect.fn("organizations.guards.requireSession")( const session = yield* Effect.tryPromise({ try: () => getAuthSession(), catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: "Failed to load session", cause, }), }); if (!session) { - return yield* Effect.fail( - new OrganizationActionError({ message: "Unauthorized" }) - ); + return yield* Effect.fail(new ActionFailure({ message: "Unauthorized" })); } return session; @@ -42,7 +40,7 @@ export const requireMembership = Effect.fn( ), }), catch: (cause) => - new OrganizationActionError({ + new ActionFailure({ message: "Failed to check membership", cause, }), @@ -50,7 +48,7 @@ export const requireMembership = Effect.fn( if (!membership) { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "You are not a member of this organization", }) ); @@ -66,7 +64,7 @@ export const requireManagerMembership = Effect.fn( if (!MANAGER_ROLES.includes(membership.role)) { return yield* Effect.fail( - new OrganizationActionError({ + new ActionFailure({ message: "You do not have permission to manage this organization", }) ); @@ -82,7 +80,7 @@ export const resolveOrganizationId = Effect.fn( if (!resolved) { return yield* Effect.fail( - new OrganizationActionError({ message: "No active organization" }) + new ActionFailure({ message: "No active organization" }) ); } diff --git a/apps/dashboard/src/types/auth/login-form.ts b/apps/dashboard/src/types/auth/login-form.ts index dbbc847df..8d8c0fe90 100644 --- a/apps/dashboard/src/types/auth/login-form.ts +++ b/apps/dashboard/src/types/auth/login-form.ts @@ -1,4 +1,4 @@ -import type { LoginFormProps as SharedLoginFormProps } from "@notra/ui/lib/auth-types"; +import type { LoginFormProps as SharedLoginFormProps } from "@notra/ui/types/auth"; export type LoginFormProps = Omit< SharedLoginFormProps, @@ -6,5 +6,7 @@ export type LoginFormProps = Omit< | "validators" | "signInWithPassword" | "verifyEmailCode" + | "verifyMfaCode" + | "redeemBackupCode" | "startSocialSignIn" >; diff --git a/apps/dashboard/src/types/auth/login-page.ts b/apps/dashboard/src/types/auth/login-page.ts new file mode 100644 index 000000000..9259dfbe9 --- /dev/null +++ b/apps/dashboard/src/types/auth/login-page.ts @@ -0,0 +1,15 @@ +import type { PendingAuthStep } from "@notra/schemas/types/dashboard/auth"; + +export interface LoginPageProps { + searchParams: Promise>; +} + +export interface LoginPageStart { + pending?: PendingAuthStep; + resumeEnrollmentFlowId?: string; +} + +export interface SocialEnrollmentResumeProps { + flowId: string; + returnTo?: string; +} diff --git a/apps/dashboard/src/types/auth/mfa-cookies.ts b/apps/dashboard/src/types/auth/mfa-cookies.ts new file mode 100644 index 000000000..2b67a5de5 --- /dev/null +++ b/apps/dashboard/src/types/auth/mfa-cookies.ts @@ -0,0 +1,27 @@ +export interface MfaAttempt { + workosUserId: string; + authenticationChallengeId: string; + startedAt: number; +} + +export interface PendingMfaChallenge { + kind: "challenge"; + pendingAuthenticationToken: string; + authenticationChallengeId: string; + email: string; +} + +export interface PendingMfaEnrollment { + kind: "enrollment"; + pendingAuthenticationToken: string; + workosUserId: string; + email: string; +} + +export type PendingMfaFlow = PendingMfaChallenge | PendingMfaEnrollment; + +export interface TotpEnrollmentInProgress { + localUserId: string; + factorId: string; + authenticationChallengeId: string; +} diff --git a/apps/dashboard/src/types/auth/password-actions.ts b/apps/dashboard/src/types/auth/password-actions.ts deleted file mode 100644 index cf170f7a3..000000000 --- a/apps/dashboard/src/types/auth/password-actions.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface SignUpWithPasswordInput { - email: string; - password: string; - name?: string; - returnTo?: string | null; -} - -export interface ForgotPasswordInput { - email: string; -} - -export interface ResetPasswordInput { - token: string; - newPassword: string; -} diff --git a/apps/dashboard/src/types/auth/workos-error.ts b/apps/dashboard/src/types/auth/workos-error.ts new file mode 100644 index 000000000..2d6f44b64 --- /dev/null +++ b/apps/dashboard/src/types/auth/workos-error.ts @@ -0,0 +1,14 @@ +export interface WorkOSAuthenticationFactorRef { + id: string; + type: string; +} + +export interface WorkOSErrorInfo { + code: string | null; + message: string; + email: string | null; + pendingAuthenticationToken: string | null; + organizationIds: string[]; + authenticationFactors: WorkOSAuthenticationFactorRef[]; + userId: string | null; +} diff --git a/apps/dashboard/src/types/design-system/auth-flow.ts b/apps/dashboard/src/types/design-system/auth-flow.ts new file mode 100644 index 000000000..ab39e08bd --- /dev/null +++ b/apps/dashboard/src/types/design-system/auth-flow.ts @@ -0,0 +1,55 @@ +export interface DevAccount { + email: string; + password: string; + totpSecret: string | null; + totpEnrolledAt: string | null; +} + +export interface DevSession { + email: string; + secondFactor: "totp" | null; + signedInAt: string; +} + +export interface DevPendingAuth { + token: string; + challengeId: string; + kind: "mfa" | "enrollment"; + enrollmentSecret: string | null; +} + +export interface DevLogEntry { + id: string; + at: string; + message: string; +} + +export interface DevSettingsEnrollment { + secret: string; + qrCode: string; + otpauthUri: string; +} + +export type AuthFlowTab = "sign-in" | "settings"; + +export interface AuthenticatorWidgetProps { + secret: string | null; +} + +export interface SimulatorPanelProps { + account: DevAccount; + backupCodeCount: number; + orgRequiresMfa: boolean; + session: DevSession | null; + pending: DevPendingAuth | null; + settingsEnrollmentSecret: string | null; + log: DevLogEntry[]; + onToggleOrgRequiresMfa: (value: boolean) => void; + onReset: () => void; +} + +export interface SignedInViewProps { + session: DevSession; + onSignOut: () => void; + onOpenSettings: () => void; +} diff --git a/apps/dashboard/src/types/organizations/actions.ts b/apps/dashboard/src/types/organizations/actions.ts index 87b79b1a3..41b1de4b3 100644 --- a/apps/dashboard/src/types/organizations/actions.ts +++ b/apps/dashboard/src/types/organizations/actions.ts @@ -6,12 +6,12 @@ import type { export interface ActionError { message: string; + code?: string; } -export interface ActionResult { - data: T | null; - error: ActionError | null; -} +export type ActionResult = + | { data: T; error: null } + | { data: null; error: ActionError }; export type OrganizationRow = typeof organizations.$inferSelect; export type MemberRow = typeof members.$inferSelect; diff --git a/apps/dashboard/src/types/settings/security.ts b/apps/dashboard/src/types/settings/security.ts new file mode 100644 index 000000000..9ccc5a93f --- /dev/null +++ b/apps/dashboard/src/types/settings/security.ts @@ -0,0 +1,19 @@ +import type { TotpFactorSummary } from "@notra/schemas/types/dashboard/auth"; +import type { SecurityLoadStatus } from "@notra/ui/types/security"; + +export interface TwoFactorSectionProps { + accountLabel: string; + factors: TotpFactorSummary[]; + backupCodesRemaining: number | null; + status: SecurityLoadStatus; + onRefresh: () => Promise | void; +} + +export interface ActiveTotpEnrollment { + kind: "scanning" | "verified"; + factorId: string; + authenticationChallengeId: string; + qrCode: string; + secret: string; + otpauthUri: string; +} diff --git a/apps/dashboard/src/utils/design-system-qr.ts b/apps/dashboard/src/utils/design-system-qr.ts new file mode 100644 index 000000000..7730f4e5c --- /dev/null +++ b/apps/dashboard/src/utils/design-system-qr.ts @@ -0,0 +1,40 @@ +const DEMO_QR_MODULES = 21; +const DEMO_QR_SCALE = 8; +const DEMO_FINDER_SIZE = 7; + +function isFinderModule(x: number, y: number) { + const inFinder = (originX: number, originY: number) => { + const dx = x - originX; + const dy = y - originY; + if (dx < 0 || dy < 0 || dx >= DEMO_FINDER_SIZE || dy >= DEMO_FINDER_SIZE) { + return null; + } + const ring = Math.min( + dx, + dy, + DEMO_FINDER_SIZE - 1 - dx, + DEMO_FINDER_SIZE - 1 - dy + ); + return ring !== 1; + }; + const offset = DEMO_QR_MODULES - DEMO_FINDER_SIZE; + return inFinder(0, 0) ?? inFinder(offset, 0) ?? inFinder(0, offset); +} + +export function buildPlaceholderQrCode(seed = 0) { + const rects: string[] = []; + for (let y = 0; y < DEMO_QR_MODULES; y += 1) { + for (let x = 0; x < DEMO_QR_MODULES; x += 1) { + const finder = isFinderModule(x, y); + const filled = finder ?? (x * 7 + y * 13 + x * y + seed) % 3 === 0; + if (filled) { + rects.push( + `` + ); + } + } + } + const size = DEMO_QR_MODULES * DEMO_QR_SCALE; + const svg = `${rects.join("")}`; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} diff --git a/apps/dashboard/src/utils/query-keys.ts b/apps/dashboard/src/utils/query-keys.ts index da67ac2be..d628ec363 100644 --- a/apps/dashboard/src/utils/query-keys.ts +++ b/apps/dashboard/src/utils/query-keys.ts @@ -2,10 +2,8 @@ export const QUERY_KEYS = { AUTH: { session: ["auth", "session"], organizations: ["auth", "organizations"], + security: ["auth", "security"], activeOrganization: ["auth", "activeOrganization"], - // Deliberately nested under `activeOrganization` so the existing - // `invalidateQueries({ queryKey: AUTH.activeOrganization })` call sites - // reach the per-slug summaries by prefix. Keep the first two segments. organizationSummary: (slug: string) => ["auth", "activeOrganization", "summary", slug] as const, }, diff --git a/apps/dashboard/src/utils/ratelimit.ts b/apps/dashboard/src/utils/ratelimit.ts index 59684c178..13e80ba98 100644 --- a/apps/dashboard/src/utils/ratelimit.ts +++ b/apps/dashboard/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import { Ratelimit } from "@upstash/ratelimit"; import { Redis } from "@upstash/redis"; +import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import { COMPANY_LOGO_RATE_LIMIT_PER_QUERY_PER_MINUTE } from "@/constants/company-logo"; @@ -170,6 +171,18 @@ export const ratelimit = { prefix: "ratelimit:auth-social-start", limiter: Ratelimit.slidingWindow(10, "1m"), }), + mfaVerify: new Ratelimit({ + redis, + analytics: true, + prefix: "ratelimit:auth-mfa-verify", + limiter: Ratelimit.slidingWindow(5, "1m"), + }), + backupCode: new Ratelimit({ + redis, + analytics: true, + prefix: "ratelimit:auth-backup-code", + limiter: Ratelimit.slidingWindow(5, "10m"), + }), }; export function getClientIpFromHeaders(headersList: Headers): string { @@ -184,12 +197,42 @@ export function getClientIpFromHeaders(headersList: Headers): string { } export function getClientIp(request: NextRequest): string { - // Vercel injects this header at its trusted network boundary. Do not fall - // back to generic forwarding headers: outside Vercel they are supplied by - // the client unless the deployment configures its own trusted proxy. if (process.env.VERCEL !== "1") { return "unknown"; } return request.headers.get("x-vercel-forwarded-for")?.trim() || "unknown"; } + +export async function isRateLimited( + limiter: Ratelimit, + key: string +): Promise { + if (shouldSkipRateLimiting()) { + return false; + } + + const headersList = await headers(); + const ip = getClientIpFromHeaders(headersList); + const { success } = await limiter.limit(`${ip}:${key.toLowerCase()}`); + return !success; +} + +export async function isAccountRateLimited( + limiter: Ratelimit, + key: string +): Promise { + if (shouldSkipRateLimiting()) { + return false; + } + const { success } = await limiter.limit(key.toLowerCase()); + return !success; +} + +function shouldSkipRateLimiting() { + return ( + process.env.NODE_ENV !== "production" && + (!process.env.UPSTASH_REDIS_REST_URL || + !process.env.UPSTASH_REDIS_REST_TOKEN) + ); +} diff --git a/bun.lock b/bun.lock index 16c04d568..f998a97db 100644 --- a/bun.lock +++ b/bun.lock @@ -569,6 +569,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.4", + "@notra/schemas": "workspace:*", "@shadcn/react": "^0.2.0", "@shikijs/transformers": "^3.21.0", "@tailwindcss/typography": "^0.5.19", diff --git a/packages/db/migrations/0090_user_auth_security.sql b/packages/db/migrations/0090_user_auth_security.sql new file mode 100644 index 000000000..bb67a8d22 --- /dev/null +++ b/packages/db/migrations/0090_user_auth_security.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS "user_backup_codes" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "code_hash" text NOT NULL, + "used_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_backup_codes" ADD CONSTRAINT "user_backup_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_backup_codes_userId_idx" ON "user_backup_codes" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "user_backup_codes_userId_codeHash_uidx" ON "user_backup_codes" USING btree ("user_id","code_hash");--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "user_auth_factor_labels" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "factor_id" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_auth_factor_labels_factor_id_unique" UNIQUE("factor_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "user_auth_factor_labels" ADD CONSTRAINT "user_auth_factor_labels_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "user_auth_factor_labels_userId_idx" ON "user_auth_factor_labels" USING btree ("user_id"); diff --git a/packages/db/migrations/0100_complete_robin_chapel.sql b/packages/db/migrations/0100_complete_robin_chapel.sql new file mode 100644 index 000000000..1d78017ff --- /dev/null +++ b/packages/db/migrations/0100_complete_robin_chapel.sql @@ -0,0 +1,11 @@ +CREATE TABLE "user_backup_codes" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "code_hash" text NOT NULL, + "used_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_backup_codes" ADD CONSTRAINT "user_backup_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_backup_codes_userId_idx" ON "user_backup_codes" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "user_backup_codes_userId_codeHash_uidx" ON "user_backup_codes" USING btree ("user_id","code_hash"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0100_snapshot.json b/packages/db/migrations/meta/0100_snapshot.json new file mode 100644 index 000000000..03b770279 --- /dev/null +++ b/packages/db/migrations/meta/0100_snapshot.json @@ -0,0 +1,12479 @@ +{ + "id": "b4bf04a4-bad3-4b39-88cd-8c9708cca859", + "prevId": "59e1d159-c5a7-4bfb-a551-e9e7e7314104", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_feedback": { + "name": "agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'api'" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_client": { + "name": "agent_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_model": { + "name": "agent_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_version": { + "name": "tool_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context_url": { + "name": "context_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentFeedback_organizationId_createdAt_idx": { + "name": "agentFeedback_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentFeedback_organizationId_status_idx": { + "name": "agentFeedback_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentFeedback_projectId_idx": { + "name": "agentFeedback_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentFeedback_organizationId_idempotencyKey_uidx": { + "name": "agentFeedback_organizationId_idempotencyKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_feedback_organization_id_organizations_id_fk": { + "name": "agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_feedback_project_id_projects_id_fk": { + "name": "agent_feedback_project_id_projects_id_fk", + "tableFrom": "agent_feedback", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_sessions": { + "name": "agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eve_session_id": { + "name": "eve_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "continuation_token": { + "name": "continuation_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_index": { + "name": "stream_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agentSessions_eveSessionId_uidx": { + "name": "agentSessions_eveSessionId_uidx", + "columns": [ + { + "expression": "eve_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_organizationId_idx": { + "name": "agentSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agentSessions_chatId_idx": { + "name": "agentSessions_chatId_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_sessions_organization_id_organizations_id_fk": { + "name": "agent_sessions_organization_id_organizations_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_sessions_chat_id_chat_sessions_id_fk": { + "name": "agent_sessions_chat_id_chat_sessions_id_fk", + "tableFrom": "agent_sessions", + "tableTo": "chat_sessions", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_actions": { + "name": "autonomy_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_action_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "external_ref": { + "name": "external_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyActions_organizationId_idx": { + "name": "autonomyActions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_runId_idx": { + "name": "autonomyActions_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_org_capability_idempotency_uidx": { + "name": "autonomyActions_org_capability_idempotency_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capability_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyActions_organizationId_status_idx": { + "name": "autonomyActions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_actions_organization_id_organizations_id_fk": { + "name": "autonomy_actions_organization_id_organizations_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_run_id_autonomy_runs_id_fk": { + "name": "autonomy_actions_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_actions_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_actions_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_actions", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_checkpoints": { + "name": "autonomy_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyCheckpoints_organizationId_idx": { + "name": "autonomyCheckpoints_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyCheckpoints_runId_idx": { + "name": "autonomyCheckpoints_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_checkpoints_organization_id_organizations_id_fk": { + "name": "autonomy_checkpoints_organization_id_organizations_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_run_id_autonomy_runs_id_fk": { + "name": "autonomy_checkpoints_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_checkpoints_task_id_autonomy_tasks_id_fk": { + "name": "autonomy_checkpoints_task_id_autonomy_tasks_id_fk", + "tableFrom": "autonomy_checkpoints", + "tableTo": "autonomy_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_claims": { + "name": "autonomy_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_key": { + "name": "claim_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyClaims_scope_claimKey_uidx": { + "name": "autonomyClaims_scope_claimKey_uidx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyClaims_expiresAt_idx": { + "name": "autonomyClaims_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_claims_organization_id_organizations_id_fk": { + "name": "autonomy_claims_organization_id_organizations_id_fk", + "tableFrom": "autonomy_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_controller_leases": { + "name": "autonomy_controller_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_token": { + "name": "owner_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fencing_token": { + "name": "fencing_token", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyControllerLeases_organizationId_idx": { + "name": "autonomyControllerLeases_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_controller_leases_organization_id_organizations_id_fk": { + "name": "autonomy_controller_leases_organization_id_organizations_id_fk", + "tableFrom": "autonomy_controller_leases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_goals": { + "name": "autonomy_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_goal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "origin_signal_ids": { + "name": "origin_signal_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyGoals_organizationId_idx": { + "name": "autonomyGoals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_mandateId_idx": { + "name": "autonomyGoals_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyGoals_organizationId_status_idx": { + "name": "autonomyGoals_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_goals_organization_id_organizations_id_fk": { + "name": "autonomy_goals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_goals_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_goals_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_goals", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_mandates": { + "name": "autonomy_mandates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy": { + "name": "policy", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_mandate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyMandates_organizationId_idx": { + "name": "autonomyMandates_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyMandates_organizationId_name_uidx": { + "name": "autonomyMandates_organizationId_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_mandates_organization_id_organizations_id_fk": { + "name": "autonomy_mandates_organization_id_organizations_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_mandates_created_by_user_id_users_id_fk": { + "name": "autonomy_mandates_created_by_user_id_users_id_fk", + "tableFrom": "autonomy_mandates", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_outbox": { + "name": "autonomy_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyOutbox_organizationId_idx": { + "name": "autonomyOutbox_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_org_destination_dedupeKey_uidx": { + "name": "autonomyOutbox_org_destination_dedupeKey_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyOutbox_status_nextAttemptAt_idx": { + "name": "autonomyOutbox_status_nextAttemptAt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_outbox_organization_id_organizations_id_fk": { + "name": "autonomy_outbox_organization_id_organizations_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_outbox_run_id_autonomy_runs_id_fk": { + "name": "autonomy_outbox_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_outbox", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_runs": { + "name": "autonomy_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_id": { + "name": "mandate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mandate_version": { + "name": "mandate_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger": { + "name": "trigger", + "type": "autonomy_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "planner_input_hash": { + "name": "planner_input_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_output": { + "name": "planner_output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "autonomy_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyRuns_organizationId_idx": { + "name": "autonomyRuns_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_mandateId_idx": { + "name": "autonomyRuns_mandateId_idx", + "columns": [ + { + "expression": "mandate_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_goalId_idx": { + "name": "autonomyRuns_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyRuns_organizationId_status_idx": { + "name": "autonomyRuns_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_runs_organization_id_organizations_id_fk": { + "name": "autonomy_runs_organization_id_organizations_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_mandate_id_autonomy_mandates_id_fk": { + "name": "autonomy_runs_mandate_id_autonomy_mandates_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_mandates", + "columnsFrom": [ + "mandate_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_runs_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_runs_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_runs", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_signals": { + "name": "autonomy_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "autonomy_signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "coalesced_into_signal_id": { + "name": "coalesced_into_signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomySignals_organizationId_idx": { + "name": "autonomySignals_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_dedupeHash_uidx": { + "name": "autonomySignals_organizationId_dedupeHash_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomySignals_organizationId_status_occurredAt_idx": { + "name": "autonomySignals_organizationId_status_occurredAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_signals_organization_id_organizations_id_fk": { + "name": "autonomy_signals_organization_id_organizations_id_fk", + "tableFrom": "autonomy_signals", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomySignals_coalescedIntoSignalId_fk": { + "name": "autonomySignals_coalescedIntoSignalId_fk", + "tableFrom": "autonomy_signals", + "tableTo": "autonomy_signals", + "columnsFrom": [ + "coalesced_into_signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.autonomy_tasks": { + "name": "autonomy_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_name": { + "name": "capability_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capability_version": { + "name": "capability_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "depends_on_task_ids": { + "name": "depends_on_task_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "autonomy_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wait_until": { + "name": "wait_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "autonomyTasks_organizationId_idx": { + "name": "autonomyTasks_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_goalId_idx": { + "name": "autonomyTasks_goalId_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_runId_idx": { + "name": "autonomyTasks_runId_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "autonomyTasks_organizationId_status_waitUntil_idx": { + "name": "autonomyTasks_organizationId_status_waitUntil_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wait_until", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "autonomy_tasks_organization_id_organizations_id_fk": { + "name": "autonomy_tasks_organization_id_organizations_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_goal_id_autonomy_goals_id_fk": { + "name": "autonomy_tasks_goal_id_autonomy_goals_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "autonomy_tasks_run_id_autonomy_runs_id_fk": { + "name": "autonomy_tasks_run_id_autonomy_runs_id_fk", + "tableFrom": "autonomy_tasks", + "tableTo": "autonomy_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_assets": { + "name": "brand_guideline_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_asset_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "brand_guideline_asset_variant", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineAssets_guidelineId_idx": { + "name": "brandGuidelineAssets_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_idx": { + "name": "brandGuidelineAssets_guideline_kind_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineAssets_guideline_kind_variant_uidx": { + "name": "brandGuidelineAssets_guideline_kind_variant_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_assets_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_assets_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_assets", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_colors": { + "name": "brand_guideline_colors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_color_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'custom'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "light_value": { + "name": "light_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dark_value": { + "name": "dark_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineColors_guidelineId_idx": { + "name": "brandGuidelineColors_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineColors_guideline_role_idx": { + "name": "brandGuidelineColors_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_colors_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_colors_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_colors", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_fonts": { + "name": "brand_guideline_fonts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "brand_guideline_font_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_height": { + "name": "line_height", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineFonts_guidelineId_idx": { + "name": "brandGuidelineFonts_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineFonts_guideline_role_idx": { + "name": "brandGuidelineFonts_guideline_role_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_fonts_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_fonts", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_screenshots": { + "name": "brand_guideline_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "brand_guideline_screenshot_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_page": { + "name": "full_page", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineScreenshots_guidelineId_idx": { + "name": "brandGuidelineScreenshots_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineScreenshots_guideline_kind_uidx": { + "name": "brandGuidelineScreenshots_guideline_kind_uidx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_screenshots_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_screenshots", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guideline_tokens": { + "name": "brand_guideline_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guideline_id": { + "name": "guideline_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "brand_guideline_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelineTokens_guidelineId_idx": { + "name": "brandGuidelineTokens_guidelineId_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelineTokens_guideline_type_idx": { + "name": "brandGuidelineTokens_guideline_type_idx", + "columns": [ + { + "expression": "guideline_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk": { + "name": "brand_guideline_tokens_guideline_id_brand_guidelines_id_fk", + "tableFrom": "brand_guideline_tokens", + "tableTo": "brand_guidelines", + "columnsFrom": [ + "guideline_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_guidelines": { + "name": "brand_guidelines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_guideline_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_generated_at": { + "name": "last_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_generation_error": { + "name": "last_generation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandGuidelines_brandSettingsId_uidx": { + "name": "brandGuidelines_brandSettingsId_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandGuidelines_status_idx": { + "name": "brandGuidelines_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_guidelines_brand_settings_id_brand_settings_id_fk": { + "name": "brand_guidelines_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_guidelines", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_references": { + "name": "brand_references", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "reference_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_snapshot_key": { + "name": "source_snapshot_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_content_hash": { + "name": "source_content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_captured_at": { + "name": "source_captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_document_id": { + "name": "supermemory_document_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_memory_id": { + "name": "supermemory_memory_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supermemory_synced_at": { + "name": "supermemory_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "supermemory_last_sync_error": { + "name": "supermemory_last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applicable_to": { + "name": "applicable_to", + "type": "applicable_platform[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['all']::applicable_platform[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandReferences_brandSettingsId_idx": { + "name": "brandReferences_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandReferences_brandSettingsId_sourceUrl_idx": { + "name": "brandReferences_brandSettingsId_sourceUrl_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_references_brand_settings_id_brand_settings_id_fk": { + "name": "brand_references_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_references", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_settings": { + "name": "brand_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Default'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tone_profile": { + "name": "tone_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_tone": { + "name": "custom_tone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_instructions": { + "name": "custom_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'English'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSettings_org_name_uidx": { + "name": "brandSettings_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_org_default_uidx": { + "name": "brandSettings_org_default_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"brand_settings\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSettings_organizationId_idx": { + "name": "brandSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_settings_organization_id_organizations_id_fk": { + "name": "brand_settings_organization_id_organizations_id_fk", + "tableFrom": "brand_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "brandSettings_toneProfile_check": { + "name": "brandSettings_toneProfile_check", + "value": "\"brand_settings\".\"tone_profile\" IS NULL OR \"brand_settings\".\"tone_profile\" IN ('Conversational', 'Professional', 'Casual', 'Formal')" + } + }, + "isRLSEnabled": false + }, + "public.brand_sitemap_pages": { + "name": "brand_sitemap_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sitemap_id": { + "name": "sitemap_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "brand_sitemap_page_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redirect_target": { + "name": "redirect_target", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "text_ratio": { + "name": "text_ratio", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "internal_links": { + "name": "internal_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_links": { + "name": "external_links", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "crawled_at": { + "name": "crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemapPages_sitemapId_idx": { + "name": "brandSitemapPages_sitemapId_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_idx": { + "name": "brandSitemapPages_sitemap_category_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_category_wordCount_idx": { + "name": "brandSitemapPages_sitemap_category_wordCount_idx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "word_count", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemapPages_sitemap_url_uidx": { + "name": "brandSitemapPages_sitemap_url_uidx", + "columns": [ + { + "expression": "sitemap_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk": { + "name": "brand_sitemap_pages_sitemap_id_brand_sitemaps_id_fk", + "tableFrom": "brand_sitemap_pages", + "tableTo": "brand_sitemaps", + "columnsFrom": [ + "sitemap_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.brand_sitemaps": { + "name": "brand_sitemaps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "brand_sitemap_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "total_pages": { + "name": "total_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_pages": { + "name": "indexed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_pages": { + "name": "failed_pages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "context_dev_meta": { + "name": "context_dev_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_crawl_started_at": { + "name": "last_crawl_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawled_at": { + "name": "last_crawled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_crawl_error": { + "name": "last_crawl_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brandSitemaps_brandSettingsId_idx": { + "name": "brandSitemaps_brandSettingsId_idx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "brandSitemaps_brandSettings_url_uidx": { + "name": "brandSitemaps_brandSettings_url_uidx", + "columns": [ + { + "expression": "brand_settings_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "brand_sitemaps_brand_settings_id_brand_settings_id_fk": { + "name": "brand_sitemaps_brand_settings_id_brand_settings_id_fk", + "tableFrom": "brand_sitemaps", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_attachments": { + "name": "chat_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatAttachments_organizationId_createdAt_idx": { + "name": "chatAttachments_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatAttachments_userId_idx": { + "name": "chatAttachments_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_attachments_organization_id_organizations_id_fk": { + "name": "chat_attachments_organization_id_organizations_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_attachments_user_id_users_id_fk": { + "name": "chat_attachments_user_id_users_id_fk", + "tableFrom": "chat_attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_attachments_key_unique": { + "name": "chat_attachments_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_sessions": { + "name": "chat_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_channel_source": { + "name": "external_channel_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_channel_id": { + "name": "external_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chatSessions_organizationId_idx": { + "name": "chatSessions_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_organizationId_deletedAt_idx": { + "name": "chatSessions_organizationId_deletedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_project_idx": { + "name": "chatSessions_org_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_content_deleted_updated_idx": { + "name": "chatSessions_org_content_deleted_updated_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chatSessions_org_externalChannel_uidx": { + "name": "chatSessions_org_externalChannel_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_sessions\".\"external_channel_source\" IN ('discord', 'slack') AND \"chat_sessions\".\"external_channel_id\" IS NOT NULL AND \"chat_sessions\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_sessions_organization_id_organizations_id_fk": { + "name": "chat_sessions_organization_id_organizations_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_content_id_posts_id_fk": { + "name": "chat_sessions_content_id_posts_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "posts", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_sessions_project_id_projects_id_fk": { + "name": "chat_sessions_project_id_projects_id_fk", + "tableFrom": "chat_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_social_accounts": { + "name": "connected_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connectedSocialAccounts_organizationId_idx": { + "name": "connectedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connectedSocialAccounts_org_provider_account_uidx": { + "name": "connectedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_social_accounts_organization_id_organizations_id_fk": { + "name": "connected_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "connected_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_publications": { + "name": "content_publications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_request_number": { + "name": "pull_request_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pull_request_url": { + "name": "pull_request_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentPublications_organizationId_idx": { + "name": "contentPublications_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentPublications_postId_idx": { + "name": "contentPublications_postId_idx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentPublications_repository_pullRequest_uidx": { + "name": "contentPublications_repository_pullRequest_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pull_request_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentPublications_open_post_uidx": { + "name": "contentPublications_open_post_uidx", + "columns": [ + { + "expression": "post_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"content_publications\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentPublications_org_owner_repo_pr_idx": { + "name": "contentPublications_org_owner_repo_pr_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pull_request_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_publications_organization_id_organizations_id_fk": { + "name": "content_publications_organization_id_organizations_id_fk", + "tableFrom": "content_publications", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_publications_post_id_posts_id_fk": { + "name": "content_publications_post_id_posts_id_fk", + "tableFrom": "content_publications", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "content_publications_repository_id_github_integrations_id_fk": { + "name": "content_publications_repository_id_github_integrations_id_fk", + "tableFrom": "content_publications", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_trigger_lookback_windows": { + "name": "content_trigger_lookback_windows", + "schema": "", + "columns": { + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window": { + "name": "window", + "type": "lookback_window", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk": { + "name": "content_trigger_lookback_windows_trigger_id_content_triggers_id_fk", + "tableFrom": "content_trigger_lookback_windows", + "tableTo": "content_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.content_triggers": { + "name": "content_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Untitled Schedule'" + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_config": { + "name": "source_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_config": { + "name": "output_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_publish": { + "name": "auto_publish", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contentTriggers_organizationId_idx": { + "name": "contentTriggers_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contentTriggers_organization_dedupe_uidx": { + "name": "contentTriggers_organization_dedupe_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "content_triggers_organization_id_organizations_id_fk": { + "name": "content_triggers_organization_id_organizations_id_fk", + "tableFrom": "content_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discussion_comments": { + "name": "discussion_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback_id": { + "name": "feedback_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shelf_source_id": { + "name": "shelf_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "discussionComments_feedback_idx": { + "name": "discussionComments_feedback_idx", + "columns": [ + { + "expression": "feedback_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "discussionComments_shelf_idx": { + "name": "discussionComments_shelf_idx", + "columns": [ + { + "expression": "shelf_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discussion_comments_organization_id_organizations_id_fk": { + "name": "discussion_comments_organization_id_organizations_id_fk", + "tableFrom": "discussion_comments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discussion_comments_feedback_id_agent_feedback_id_fk": { + "name": "discussion_comments_feedback_id_agent_feedback_id_fk", + "tableFrom": "discussion_comments", + "tableTo": "agent_feedback", + "columnsFrom": [ + "feedback_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discussion_comments_shelf_source_id_geo_shelf_sources_id_fk": { + "name": "discussion_comments_shelf_source_id_geo_shelf_sources_id_fk", + "tableFrom": "discussion_comments", + "tableTo": "geo_shelf_sources", + "columnsFrom": [ + "shelf_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discussion_comments_user_id_users_id_fk": { + "name": "discussion_comments_user_id_users_id_fk", + "tableFrom": "discussion_comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "discussion_comments_parent_id_discussion_comments_id_fk": { + "name": "discussion_comments_parent_id_discussion_comments_id_fk", + "tableFrom": "discussion_comments", + "tableTo": "discussion_comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "discussionComments_target_check": { + "name": "discussionComments_target_check", + "value": "num_nonnulls(\"discussion_comments\".\"feedback_id\", \"discussion_comments\".\"shelf_source_id\") = 1" + }, + "discussionComments_depth_check": { + "name": "discussionComments_depth_check", + "value": "\"discussion_comments\".\"depth\" between 0 and 5" + } + }, + "isRLSEnabled": false + }, + "public.discussion_reactions": { + "name": "discussion_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "discussionReactions_unique": { + "name": "discussionReactions_unique", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "emoji", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discussion_reactions_comment_id_discussion_comments_id_fk": { + "name": "discussion_reactions_comment_id_discussion_comments_id_fk", + "tableFrom": "discussion_reactions", + "tableTo": "discussion_comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discussion_reactions_user_id_users_id_fk": { + "name": "discussion_reactions_user_id_users_id_fk", + "tableFrom": "discussion_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_agent_readiness_reports": { + "name": "geo_agent_readiness_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "score_label": { + "name": "score_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "score_breakdown": { + "name": "score_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "issues": { + "name": "issues", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "eligible_checks": { + "name": "eligible_checks", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "report_url": { + "name": "report_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scanned_at": { + "name": "scanned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoAgentReadinessReports_organizationId_idx": { + "name": "geoAgentReadinessReports_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoAgentReadinessReports_projectId_createdAt_idx": { + "name": "geoAgentReadinessReports_projectId_createdAt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoAgentReadinessReports_projectId_running_uidx": { + "name": "geoAgentReadinessReports_projectId_running_uidx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"geo_agent_readiness_reports\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_agent_readiness_reports_organization_id_organizations_id_fk": { + "name": "geo_agent_readiness_reports_organization_id_organizations_id_fk", + "tableFrom": "geo_agent_readiness_reports", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_agent_readiness_reports_project_id_projects_id_fk": { + "name": "geo_agent_readiness_reports_project_id_projects_id_fk", + "tableFrom": "geo_agent_readiness_reports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_competitors": { + "name": "geo_competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "synonyms": { + "name": "synonyms", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoCompetitors_organizationId_idx": { + "name": "geoCompetitors_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoCompetitors_projectId_idx": { + "name": "geoCompetitors_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoCompetitors_projectId_name_uidx": { + "name": "geoCompetitors_projectId_name_uidx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_competitors_organization_id_organizations_id_fk": { + "name": "geo_competitors_organization_id_organizations_id_fk", + "tableFrom": "geo_competitors", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_competitors_project_id_projects_id_fk": { + "name": "geo_competitors_project_id_projects_id_fk", + "tableFrom": "geo_competitors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_content_briefs": { + "name": "geo_content_briefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brief": { + "name": "brief", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "auto_approved": { + "name": "auto_approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "humanized": { + "name": "humanized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rescan_scan_id": { + "name": "rescan_scan_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rescan_requested_at": { + "name": "rescan_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoContentBriefs_organizationId_createdAt_idx": { + "name": "geoContentBriefs_organizationId_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoContentBriefs_projectId_status_idx": { + "name": "geoContentBriefs_projectId_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoContentBriefs_project_source_updated_idx": { + "name": "geoContentBriefs_project_source_updated_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoContentBriefs_open_source_uidx": { + "name": "geoContentBriefs_open_source_uidx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"geo_content_briefs\".\"source_kind\" <> 'manual' AND \"geo_content_briefs\".\"source_id\" IS NOT NULL AND \"geo_content_briefs\".\"status\" IN ('draft', 'approved', 'writing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_content_briefs_organization_id_organizations_id_fk": { + "name": "geo_content_briefs_organization_id_organizations_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_content_briefs_project_id_projects_id_fk": { + "name": "geo_content_briefs_project_id_projects_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_content_briefs_brand_settings_id_brand_settings_id_fk": { + "name": "geo_content_briefs_brand_settings_id_brand_settings_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "geo_content_briefs_created_by_user_id_users_id_fk": { + "name": "geo_content_briefs_created_by_user_id_users_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "geo_content_briefs_collection_id_post_collections_id_fk": { + "name": "geo_content_briefs_collection_id_post_collections_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "geo_content_briefs_post_id_posts_id_fk": { + "name": "geo_content_briefs_post_id_posts_id_fk", + "tableFrom": "geo_content_briefs", + "tableTo": "posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_mention_checks": { + "name": "geo_mention_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scan_id": { + "name": "scan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence_id": { + "name": "sequence_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "persona_id": { + "name": "persona_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "persona_snapshot": { + "name": "persona_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "turn": { + "name": "turn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mentioned": { + "name": "mentioned", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "owned_source_cited": { + "name": "owned_source_cited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sentiment": { + "name": "sentiment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "competitors": { + "name": "competitors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "grounding": { + "name": "grounding", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"queries\":[],\"sources\":[]}'::jsonb" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'English'" + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "zdr_enforced": { + "name": "zdr_enforced", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "judge_tokens": { + "name": "judge_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoMentionChecks_organizationId_capturedAt_idx": { + "name": "geoMentionChecks_organizationId_capturedAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_projectId_capturedAt_idx": { + "name": "geoMentionChecks_projectId_capturedAt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_projectEnginePrompt_idx": { + "name": "geoMentionChecks_projectEnginePrompt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "engine", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "prompt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_project_captured_cover_idx": { + "name": "geoMentionChecks_project_captured_cover_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "engine", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "prompt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mentioned", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_source_cited", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sentiment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_project_prompt_engine_captured_idx": { + "name": "geoMentionChecks_project_prompt_engine_captured_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "prompt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "engine", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_competitors_idx": { + "name": "geoMentionChecks_competitors_idx", + "columns": [ + { + "expression": "competitors", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "geoMentionChecks_sequence_turn_engine_captured_idx": { + "name": "geoMentionChecks_sequence_turn_engine_captured_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "engine", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"geo_mention_checks\".\"sequence_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_scanId_idx": { + "name": "geoMentionChecks_scanId_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_personaId_capturedAt_idx": { + "name": "geoMentionChecks_personaId_capturedAt_idx", + "columns": [ + { + "expression": "persona_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoMentionChecks_scanEnginePromptTurnLanguage_uidx": { + "name": "geoMentionChecks_scanEnginePromptTurnLanguage_uidx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "engine", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "prompt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_mention_checks_organization_id_organizations_id_fk": { + "name": "geo_mention_checks_organization_id_organizations_id_fk", + "tableFrom": "geo_mention_checks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_mention_checks_project_id_projects_id_fk": { + "name": "geo_mention_checks_project_id_projects_id_fk", + "tableFrom": "geo_mention_checks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_mention_checks_scan_id_geo_scans_id_fk": { + "name": "geo_mention_checks_scan_id_geo_scans_id_fk", + "tableFrom": "geo_mention_checks", + "tableTo": "geo_scans", + "columnsFrom": [ + "scan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_mention_checks_persona_id_geo_personas_id_fk": { + "name": "geo_mention_checks_persona_id_geo_personas_id_fk", + "tableFrom": "geo_mention_checks", + "tableTo": "geo_personas", + "columnsFrom": [ + "persona_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "geoMentionChecks_personaSnapshot_check": { + "name": "geoMentionChecks_personaSnapshot_check", + "value": "\"geo_mention_checks\".\"persona_id\" IS NULL OR \"geo_mention_checks\".\"persona_snapshot\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.geo_persona_memories": { + "name": "geo_persona_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "persona_id": { + "name": "persona_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPersonaMemories_personaId_idx": { + "name": "geoPersonaMemories_personaId_idx", + "columns": [ + { + "expression": "persona_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPersonaMemories_projectId_idx": { + "name": "geoPersonaMemories_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_persona_memories_persona_id_geo_personas_id_fk": { + "name": "geo_persona_memories_persona_id_geo_personas_id_fk", + "tableFrom": "geo_persona_memories", + "tableTo": "geo_personas", + "columnsFrom": [ + "persona_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_persona_memories_organization_id_organizations_id_fk": { + "name": "geo_persona_memories_organization_id_organizations_id_fk", + "tableFrom": "geo_persona_memories", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_persona_memories_project_id_projects_id_fk": { + "name": "geo_persona_memories_project_id_projects_id_fk", + "tableFrom": "geo_persona_memories", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_personas": { + "name": "geo_personas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_style": { + "name": "search_style", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "conversation_prompts": { + "name": "conversation_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPersonas_organizationId_idx": { + "name": "geoPersonas_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPersonas_projectId_idx": { + "name": "geoPersonas_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_personas_organization_id_organizations_id_fk": { + "name": "geo_personas_organization_id_organizations_id_fk", + "tableFrom": "geo_personas", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_personas_project_id_projects_id_fk": { + "name": "geo_personas_project_id_projects_id_fk", + "tableFrom": "geo_personas", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prompt_sequences": { + "name": "geo_prompt_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "steps": { + "name": "steps", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPromptSequences_organizationId_idx": { + "name": "geoPromptSequences_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPromptSequences_projectId_idx": { + "name": "geoPromptSequences_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prompt_sequences_organization_id_organizations_id_fk": { + "name": "geo_prompt_sequences_organization_id_organizations_id_fk", + "tableFrom": "geo_prompt_sequences", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_prompt_sequences_project_id_projects_id_fk": { + "name": "geo_prompt_sequences_project_id_projects_id_fk", + "tableFrom": "geo_prompt_sequences", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prompt_suggestions": { + "name": "geo_prompt_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'search_console'" + }, + "source_keywords": { + "name": "source_keywords", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "accepted_prompt_id": { + "name": "accepted_prompt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPromptSuggestions_organizationId_status_idx": { + "name": "geoPromptSuggestions_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPromptSuggestions_organizationId_prompt_uidx": { + "name": "geoPromptSuggestions_organizationId_prompt_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "prompt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prompt_suggestions_organization_id_organizations_id_fk": { + "name": "geo_prompt_suggestions_organization_id_organizations_id_fk", + "tableFrom": "geo_prompt_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_prompt_suggestions_accepted_prompt_id_geo_prompts_id_fk": { + "name": "geo_prompt_suggestions_accepted_prompt_id_geo_prompts_id_fk", + "tableFrom": "geo_prompt_suggestions", + "tableTo": "geo_prompts", + "columnsFrom": [ + "accepted_prompt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prompts": { + "name": "geo_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoPrompts_organizationId_idx": { + "name": "geoPrompts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPrompts_projectId_idx": { + "name": "geoPrompts_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoPrompts_projectId_createdAt_idx": { + "name": "geoPrompts_projectId_createdAt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prompts_organization_id_organizations_id_fk": { + "name": "geo_prompts_organization_id_organizations_id_fk", + "tableFrom": "geo_prompts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_prompts_project_id_projects_id_fk": { + "name": "geo_prompts_project_id_projects_id_fk", + "tableFrom": "geo_prompts", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_prospect_reports": { + "name": "geo_prospect_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "company_domain": { + "name": "company_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "visibility_score": { + "name": "visibility_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prompt_count": { + "name": "prompt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "report": { + "name": "report", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoProspectReports_organizationId_idx": { + "name": "geoProspectReports_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoProspectReports_shareToken_uidx": { + "name": "geoProspectReports_shareToken_uidx", + "columns": [ + { + "expression": "share_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_prospect_reports_organization_id_organizations_id_fk": { + "name": "geo_prospect_reports_organization_id_organizations_id_fk", + "tableFrom": "geo_prospect_reports", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_prospect_reports_created_by_user_id_users_id_fk": { + "name": "geo_prospect_reports_created_by_user_id_users_id_fk", + "tableFrom": "geo_prospect_reports", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_scan_events": { + "name": "geo_scan_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scan_id": { + "name": "scan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "engine": { + "name": "engine", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_key": { + "name": "task_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "geoScanEvents_scanId_startedAt_idx": { + "name": "geoScanEvents_scanId_startedAt_idx", + "columns": [ + { + "expression": "scan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_scan_events_scan_id_geo_scans_id_fk": { + "name": "geo_scan_events_scan_id_geo_scans_id_fk", + "tableFrom": "geo_scan_events", + "tableTo": "geo_scans", + "columnsFrom": [ + "scan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_scans": { + "name": "geo_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "plan_summary": { + "name": "plan_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failed_stage": { + "name": "failed_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retryable": { + "name": "retryable", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_usd": { + "name": "total_usd", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "checks_total": { + "name": "checks_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checks_failed": { + "name": "checks_failed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mentions": { + "name": "mentions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_by_role": { + "name": "usage_by_role", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "geoScans_organizationId_idx": { + "name": "geoScans_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoScans_projectId_startedAt_idx": { + "name": "geoScans_projectId_startedAt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_scans_organization_id_organizations_id_fk": { + "name": "geo_scans_organization_id_organizations_id_fk", + "tableFrom": "geo_scans", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_scans_project_id_projects_id_fk": { + "name": "geo_scans_project_id_projects_id_fk", + "tableFrom": "geo_scans", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_settings": { + "name": "geo_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "competitors": { + "name": "competitors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "conversion_paths": { + "name": "conversion_paths", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "domains": { + "name": "domains", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "engines": { + "name": "engines", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "enforce_zdr": { + "name": "enforce_zdr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "track_without_search": { + "name": "track_without_search", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "non_zdr_approved_engines": { + "name": "non_zdr_approved_engines", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "paused_auto_prompt_ids": { + "name": "paused_auto_prompt_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "removed_auto_prompt_ids": { + "name": "removed_auto_prompt_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "ignored_gap_prompt_ids": { + "name": "ignored_gap_prompt_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scan_interval_hours": { + "name": "scan_interval_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "sentiment_attempted_at": { + "name": "sentiment_attempted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_scan_at": { + "name": "next_scan_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scan_lease_until": { + "name": "scan_lease_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scan_started_at": { + "name": "scan_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_scan_at": { + "name": "last_scan_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoSettings_organizationId_idx": { + "name": "geoSettings_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoSettings_projectId_uidx": { + "name": "geoSettings_projectId_uidx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_settings_organization_id_organizations_id_fk": { + "name": "geo_settings_organization_id_organizations_id_fk", + "tableFrom": "geo_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_settings_project_id_projects_id_fk": { + "name": "geo_settings_project_id_projects_id_fk", + "tableFrom": "geo_settings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.geo_shelf_sources": { + "name": "geo_shelf_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ownership": { + "name": "ownership", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetch_status": { + "name": "fetch_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "placements": { + "name": "placements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "opportunity": { + "name": "opportunity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "geoShelfSources_organizationId_idx": { + "name": "geoShelfSources_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoShelfSources_projectId_updatedAt_idx": { + "name": "geoShelfSources_projectId_updatedAt_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "geoShelfSources_projectId_url_uidx": { + "name": "geoShelfSources_projectId_url_uidx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "geo_shelf_sources_organization_id_organizations_id_fk": { + "name": "geo_shelf_sources_organization_id_organizations_id_fk", + "tableFrom": "geo_shelf_sources", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_shelf_sources_project_id_projects_id_fk": { + "name": "geo_shelf_sources_project_id_projects_id_fk", + "tableFrom": "geo_shelf_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "geo_shelf_sources_created_by_user_id_users_id_fk": { + "name": "geo_shelf_sources_created_by_user_id_users_id_fk", + "tableFrom": "geo_shelf_sources", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubAppInstallations_organizationId_idx": { + "name": "githubAppInstallations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_createdByUserId_idx": { + "name": "githubAppInstallations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubAppInstallations_organization_installation_uidx": { + "name": "githubAppInstallations_organization_installation_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_organization_id_organizations_id_fk": { + "name": "github_app_installations_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_app_installations_created_by_user_id_users_id_fk": { + "name": "github_app_installations_created_by_user_id_users_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integrations": { + "name": "github_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_installation_id": { + "name": "github_app_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_id": { + "name": "github_repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repository_private": { + "name": "github_repository_private", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_enabled": { + "name": "repository_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "githubIntegrations_organizationId_idx": { + "name": "githubIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_createdByUserId_idx": { + "name": "githubIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "githubIntegrations_organization_owner_repo_uidx": { + "name": "githubIntegrations_organization_owner_repo_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_integrations_organization_id_organizations_id_fk": { + "name": "github_integrations_organization_id_organizations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_created_by_user_id_users_id_fk": { + "name": "github_integrations_created_by_user_id_users_id_fk", + "tableFrom": "github_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_integrations_github_app_installation_id_github_app_installations_id_fk": { + "name": "github_integrations_github_app_installation_id_github_app_installations_id_fk", + "tableFrom": "github_integrations", + "tableTo": "github_app_installations", + "columnsFrom": [ + "github_app_installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_search_console_integrations": { + "name": "google_search_console_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_account_email": { + "name": "google_account_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "qstash_schedule_id": { + "name": "qstash_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disconnecting_at": { + "name": "disconnecting_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "top_queries": { + "name": "top_queries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "googleSearchConsoleIntegrations_organizationId_uidx": { + "name": "googleSearchConsoleIntegrations_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "googleSearchConsoleIntegrations_createdByUserId_idx": { + "name": "googleSearchConsoleIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_search_console_integrations_organization_id_organizations_id_fk": { + "name": "google_search_console_integrations_organization_id_organizations_id_fk", + "tableFrom": "google_search_console_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_search_console_integrations_created_by_user_id_users_id_fk": { + "name": "google_search_console_integrations_created_by_user_id_users_id_fk", + "tableFrom": "google_search_console_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.granola_integrations": { + "name": "granola_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "granolaIntegrations_organizationId_idx": { + "name": "granolaIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "granolaIntegrations_createdByUserId_idx": { + "name": "granolaIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "granola_integrations_organization_id_organizations_id_fk": { + "name": "granola_integrations_organization_id_organizations_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "granola_integrations_created_by_user_id_users_id_fk": { + "name": "granola_integrations_created_by_user_id_users_id_fk", + "tableFrom": "granola_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linear_integrations": { + "name": "linear_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_access_token": { + "name": "encrypted_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_organization_id": { + "name": "linear_organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linear_organization_name": { + "name": "linear_organization_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_name": { + "name": "linear_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_webhook_secret": { + "name": "encrypted_webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "linearIntegrations_organizationId_idx": { + "name": "linearIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_createdByUserId_idx": { + "name": "linearIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_team_uidx": { + "name": "linearIntegrations_org_linearOrg_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linearIntegrations_org_linearOrg_no_team_uidx": { + "name": "linearIntegrations_org_linearOrg_no_team_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "linear_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"linear_integrations\".\"linear_team_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linear_integrations_organization_id_organizations_id_fk": { + "name": "linear_integrations_organization_id_organizations_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "linear_integrations_created_by_user_id_users_id_fk": { + "name": "linear_integrations_created_by_user_id_users_id_fk", + "tableFrom": "linear_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_credentials": { + "name": "mcp_oauth_credentials", + "schema": "", + "columns": { + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_tokens": { + "name": "encrypted_tokens", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_refresh_at": { + "name": "access_token_refresh_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connected'" + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_lease_id": { + "name": "refresh_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_lease_expires_at": { + "name": "refresh_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthCredentials_organizationId_idx": { + "name": "mcpOAuthCredentials_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthCredentials_connectedByUserId_idx": { + "name": "mcpOAuthCredentials_connectedByUserId_idx", + "columns": [ + { + "expression": "connected_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_credentials_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_organization_id_organizations_id_fk": { + "name": "mcp_oauth_credentials_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_credentials_connected_by_user_id_users_id_fk": { + "name": "mcp_oauth_credentials_connected_by_user_id_users_id_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "users", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthCredentials_org_server_fk": { + "name": "mcpOAuthCredentials_org_server_fk", + "tableFrom": "mcp_oauth_credentials", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpOAuthCredentials_status_check": { + "name": "mcpOAuthCredentials_status_check", + "value": "\"mcp_oauth_credentials\".\"status\" IN ('connected', 'refreshing', 'reauth_required')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_oauth_pending_authorizations": { + "name": "mcp_oauth_pending_authorizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_code_verifier": { + "name": "encrypted_code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_client_information": { + "name": "encrypted_client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_authorization_server_information": { + "name": "encrypted_authorization_server_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpOAuthPendingAuthorizations_organizationId_idx": { + "name": "mcpOAuthPendingAuthorizations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_userId_idx": { + "name": "mcpOAuthPendingAuthorizations_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_serverIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_serverIntegrationId_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpOAuthPendingAuthorizations_expiresAt_idx": { + "name": "mcpOAuthPendingAuthorizations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk": { + "name": "mcp_oauth_pending_authorizations_organization_id_organizations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_user_id_users_id_fk": { + "name": "mcp_oauth_pending_authorizations_user_id_users_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_oauth_pending_authorizations_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_org_server_fk": { + "name": "mcpOAuthPendingAuthorizations_org_server_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk": { + "name": "mcpOAuthPendingAuthorizations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_oauth_pending_authorizations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_oauth_pending_authorizations_state_hash_unique": { + "name": "mcp_oauth_pending_authorizations_state_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "state_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_integrations": { + "name": "mcp_server_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'connection'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_light_url": { + "name": "logo_light_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_dark_url": { + "name": "logo_dark_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_featured_at": { + "name": "store_featured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "store_source_integration_id": { + "name": "store_source_integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_status": { + "name": "store_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "encrypted_headers": { + "name": "encrypted_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_tool_sync_at": { + "name": "last_tool_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tool_sync_status": { + "name": "tool_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "tool_sync_error": { + "name": "tool_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "indexed_tool_count": { + "name": "indexed_tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpServerIntegrations_resourceType_idx": { + "name": "mcpServerIntegrations_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeStatus_idx": { + "name": "mcpServerIntegrations_storeStatus_idx", + "columns": [ + { + "expression": "store_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_organizationId_idx": { + "name": "mcpServerIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_createdByUserId_idx": { + "name": "mcpServerIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeSourceIntegrationId_idx": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_idx", + "columns": [ + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_id_uidx": { + "name": "mcpServerIntegrations_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_resourceType_name_uidx": { + "name": "mcpServerIntegrations_org_resourceType_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_org_storeSource_uidx": { + "name": "mcpServerIntegrations_org_storeSource_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "store_source_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"store_source_integration_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpServerIntegrations_storeListing_slug_uidx": { + "name": "mcpServerIntegrations_storeListing_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_server_integrations\".\"resource_type\" = 'store_listing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_integrations_organization_id_organizations_id_fk": { + "name": "mcp_server_integrations_organization_id_organizations_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_integrations_created_by_user_id_users_id_fk": { + "name": "mcp_server_integrations_created_by_user_id_users_id_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpServerIntegrations_storeSourceIntegrationId_fk": { + "name": "mcpServerIntegrations_storeSourceIntegrationId_fk", + "tableFrom": "mcp_server_integrations", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "store_source_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcpServerIntegrations_authType_check": { + "name": "mcpServerIntegrations_authType_check", + "value": "\"mcp_server_integrations\".\"auth_type\" IN ('none', 'headers', 'oauth')" + }, + "mcpServerIntegrations_storeStatus_check": { + "name": "mcpServerIntegrations_storeStatus_check", + "value": "\"mcp_server_integrations\".\"store_status\" IN ('draft', 'pending_review', 'live', 'rejected')" + }, + "mcpServerIntegrations_resourceType_check": { + "name": "mcpServerIntegrations_resourceType_check", + "value": "\"mcp_server_integrations\".\"resource_type\" IN ('connection', 'store_listing')" + }, + "mcpServerIntegrations_category_check": { + "name": "mcpServerIntegrations_category_check", + "value": "\"mcp_server_integrations\".\"category\" IS NULL OR \"mcp_server_integrations\".\"category\" IN ('AI', 'Source control', 'Project management', 'Communication', 'Design', 'Notes', 'Deploys', 'Productivity', 'Marketing', 'Publishing')" + }, + "mcpServerIntegrations_resourceState_check": { + "name": "mcpServerIntegrations_resourceState_check", + "value": "(\n (\"mcp_server_integrations\".\"resource_type\" = 'store_listing' AND \"mcp_server_integrations\".\"store_source_integration_id\" IS NULL)\n OR\n (\"mcp_server_integrations\".\"resource_type\" = 'connection' AND \"mcp_server_integrations\".\"store_status\" = 'draft' AND \"mcp_server_integrations\".\"review_note\" IS NULL AND \"mcp_server_integrations\".\"submitted_at\" IS NULL AND \"mcp_server_integrations\".\"reviewed_at\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.mcp_session_tool_activations": { + "name": "mcp_session_tool_activations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mcp_tool_index_id": { + "name": "mcp_tool_index_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_query": { + "name": "source_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcpSessionToolActivations_session_tool_uidx": { + "name": "mcpSessionToolActivations_session_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_tool_index_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_session_idx": { + "name": "mcpSessionToolActivations_session_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "surface", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpSessionToolActivations_expiresAt_idx": { + "name": "mcpSessionToolActivations_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_session_tool_activations_organization_id_organizations_id_fk": { + "name": "mcp_session_tool_activations_organization_id_organizations_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk": { + "name": "mcp_session_tool_activations_mcp_tool_index_id_mcp_tool_index_id_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "mcp_tool_index_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpSessionToolActivations_org_tool_fk": { + "name": "mcpSessionToolActivations_org_tool_fk", + "tableFrom": "mcp_session_tool_activations", + "tableTo": "mcp_tool_index", + "columnsFrom": [ + "organization_id", + "mcp_tool_index_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tool_index": { + "name": "mcp_tool_index", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_integration_id": { + "name": "server_integration_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_tool_name": { + "name": "server_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_tool_name": { + "name": "runtime_tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_present": { + "name": "action_phrase_present", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_phrase_past": { + "name": "action_phrase_past", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema_hash": { + "name": "schema_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_indexed_at": { + "name": "last_indexed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcpToolIndex_server_tool_uidx": { + "name": "mcpToolIndex_server_tool_uidx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "server_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_id_uidx": { + "name": "mcpToolIndex_org_id_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_org_runtime_tool_uidx": { + "name": "mcpToolIndex_org_runtime_tool_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "runtime_tool_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_organizationId_status_idx": { + "name": "mcpToolIndex_organizationId_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_serverIntegrationId_status_idx": { + "name": "mcpToolIndex_serverIntegrationId_status_idx", + "columns": [ + { + "expression": "server_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcpToolIndex_searchText_gin_idx": { + "name": "mcpToolIndex_searchText_gin_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "mcp_tool_index_organization_id_organizations_id_fk": { + "name": "mcp_tool_index_organization_id_organizations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk": { + "name": "mcp_tool_index_server_integration_id_mcp_server_integrations_id_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "server_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcpToolIndex_org_server_fk": { + "name": "mcpToolIndex_org_server_fk", + "tableFrom": "mcp_tool_index", + "tableTo": "mcp_server_integrations", + "columnsFrom": [ + "organization_id", + "server_integration_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "members_organizationId_idx": { + "name": "members_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_userId_idx": { + "name": "members_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_organizationId_userId_uidx": { + "name": "members_organizationId_userId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_suggestions": { + "name": "onboarding_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "onboarding_suggestion_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dismissed": { + "name": "dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboardingSuggestions_org_type_idx": { + "name": "onboardingSuggestions_org_type_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "onboarding_suggestions_organization_id_organizations_id_fk": { + "name": "onboarding_suggestions_organization_id_organizations_id_fk", + "tableFrom": "onboarding_suggestions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_settings": { + "name": "organization_notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_content_creation": { + "name": "scheduled_content_creation", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "scheduled_content_failed": { + "name": "scheduled_content_failed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "scheduled_content_skipped": { + "name": "scheduled_content_skipped", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "marketing_emails": { + "name": "marketing_emails", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "daily_summary": { + "name": "daily_summary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orgNotificationSettings_organizationId_uidx": { + "name": "orgNotificationSettings_organizationId_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_notification_settings_organization_id_organizations_id_fk": { + "name": "organization_notification_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_notification_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_source": { + "name": "heard_about_notra_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "heard_about_notra_other": { + "name": "heard_about_notra_other", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geo_ingest_token_generation": { + "name": "geo_ingest_token_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "feedback_ingest_token_generation": { + "name": "feedback_ingest_token_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_dismissed": { + "name": "onboarding_dismissed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_ran": { + "name": "onboarding_agent_ran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_agent_started_at": { + "name": "onboarding_agent_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workos_org_id": { + "name": "workos_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "organizations_workos_org_id_unique": { + "name": "organizations_workos_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workos_org_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_collections": { + "name": "post_collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "post_collection_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_source": { + "name": "name_source", + "type": "post_collection_name_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generated'" + }, + "content_types": { + "name": "content_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expected_post_count": { + "name": "expected_post_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completed_post_count": { + "name": "completed_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_collections_org_created_at_idx": { + "name": "post_collections_org_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_source_idx": { + "name": "post_collections_source_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_org_project_idx": { + "name": "post_collections_org_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "post_collections_chat_source_uidx": { + "name": "post_collections_chat_source_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"post_collections\".\"source\" = 'chat' AND \"post_collections\".\"source_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_collections_organization_id_organizations_id_fk": { + "name": "post_collections_organization_id_organizations_id_fk", + "tableFrom": "post_collections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_collections_project_id_projects_id_fk": { + "name": "post_collections_project_id_projects_id_fk", + "tableFrom": "post_collections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.posts": { + "name": "posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations": { + "name": "recommendations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_subtype": { + "name": "content_subtype", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_metadata": { + "name": "source_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "github_publish": { + "name": "github_publish", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_org_slug_uidx": { + "name": "posts_org_slug_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"posts\".\"slug\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_id_idx": { + "name": "posts_org_createdAt_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_collection_id_idx": { + "name": "posts_collection_id_idx", + "columns": [ + { + "expression": "collection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_createdAt_status_idx": { + "name": "posts_org_createdAt_status_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_published_createdAt_idx": { + "name": "posts_org_published_createdAt_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"posts\".\"status\" = 'published'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_org_content_type_updated_at_idx": { + "name": "posts_org_content_type_updated_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_organization_id_organizations_id_fk": { + "name": "posts_organization_id_organizations_id_fk", + "tableFrom": "posts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "posts_collection_id_post_collections_id_fk": { + "name": "posts_collection_id_post_collections_id_fk", + "tableFrom": "posts", + "tableTo": "post_collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brand_settings_id": { + "name": "brand_settings_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sample": { + "name": "is_sample", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_organizationId_idx": { + "name": "projects_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_organizationId_sample_uidx": { + "name": "projects_organizationId_sample_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"projects\".\"is_sample\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_brand_settings_id_brand_settings_id_fk": { + "name": "projects_brand_settings_id_brand_settings_id_fk", + "tableFrom": "projects", + "tableTo": "brand_settings", + "columnsFrom": [ + "brand_settings_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository_outputs": { + "name": "repository_outputs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_type": { + "name": "output_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repositoryOutputs_repositoryId_idx": { + "name": "repositoryOutputs_repositoryId_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositoryOutputs_repository_outputType_uidx": { + "name": "repositoryOutputs_repository_outputType_uidx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "output_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repository_outputs_repository_id_github_integrations_id_fk": { + "name": "repository_outputs_repository_id_github_integrations_id_fk", + "tableFrom": "repository_outputs", + "tableTo": "github_integrations", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_organizationId_idx": { + "name": "skills_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_org_name_uidx": { + "name": "skills_org_name_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_organization_id_organizations_id_fk": { + "name": "skills_organization_id_organizations_id_fk", + "tableFrom": "skills", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_integrations": { + "name": "slack_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_bot_token": { + "name": "encrypted_bot_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_bot_user_id": { + "name": "slack_bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_channel_ids": { + "name": "allowed_channel_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slackIntegrations_organizationId_idx": { + "name": "slackIntegrations_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_createdByUserId_idx": { + "name": "slackIntegrations_createdByUserId_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slackIntegrations_teamId_uidx": { + "name": "slackIntegrations_teamId_uidx", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_integrations_organization_id_organizations_id_fk": { + "name": "slack_integrations_organization_id_organizations_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_integrations_created_by_user_id_users_id_fk": { + "name": "slack_integrations_created_by_user_id_users_id_fk", + "tableFrom": "slack_integrations", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_connections": { + "name": "social_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socialConnections_userId_provider_uidx": { + "name": "socialConnections_userId_provider_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socialConnections_userId_idx": { + "name": "socialConnections_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "socialConnections_provider_providerAccountId_idx": { + "name": "socialConnections_provider_providerAccountId_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_connections_user_id_users_id_fk": { + "name": "social_connections_user_id_users_id_fk", + "tableFrom": "social_connections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_experiments": { + "name": "social_experiments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hypothesis": { + "name": "hypothesis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_a_post_id": { + "name": "variant_a_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_b_post_id": { + "name": "variant_b_post_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "winner": { + "name": "winner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "socialExperiments_organizationId_idx": { + "name": "socialExperiments_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_experiments_organization_id_organizations_id_fk": { + "name": "social_experiments_organization_id_organizations_id_fk", + "tableFrom": "social_experiments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_social_accounts": { + "name": "tracked_social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verified_type": { + "name": "verified_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trackedSocialAccounts_organizationId_idx": { + "name": "trackedSocialAccounts_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trackedSocialAccounts_org_provider_account_uidx": { + "name": "trackedSocialAccounts_org_provider_account_uidx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracked_social_accounts_organization_id_organizations_id_fk": { + "name": "tracked_social_accounts_organization_id_organizations_id_fk", + "tableFrom": "tracked_social_accounts", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_backup_codes": { + "name": "user_backup_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_backup_codes_userId_idx": { + "name": "user_backup_codes_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_backup_codes_userId_codeHash_uidx": { + "name": "user_backup_codes_userId_codeHash_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_backup_codes_user_id_users_id_fk": { + "name": "user_backup_codes_user_id_users_id_fk", + "tableFrom": "user_backup_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hide_personal_data": { + "name": "hide_personal_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_agent_stats": { + "name": "show_agent_stats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workos_user_id": { + "name": "workos_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_workos_user_id_unique": { + "name": "users_workos_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workos_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.applicable_platform": { + "name": "applicable_platform", + "schema": "public", + "values": [ + "all", + "twitter", + "linkedin", + "blog" + ] + }, + "public.autonomy_action_status": { + "name": "autonomy_action_status", + "schema": "public", + "values": [ + "pending", + "executing", + "succeeded", + "failed", + "unknown", + "compensated", + "canceled" + ] + }, + "public.autonomy_goal_status": { + "name": "autonomy_goal_status", + "schema": "public", + "values": [ + "open", + "in_progress", + "blocked", + "completed", + "abandoned" + ] + }, + "public.autonomy_mandate_status": { + "name": "autonomy_mandate_status", + "schema": "public", + "values": [ + "active", + "paused", + "revoked" + ] + }, + "public.autonomy_outbox_status": { + "name": "autonomy_outbox_status", + "schema": "public", + "values": [ + "pending", + "attempting", + "delivered", + "failed", + "canceled" + ] + }, + "public.autonomy_run_status": { + "name": "autonomy_run_status", + "schema": "public", + "values": [ + "planning", + "executing", + "completed", + "failed", + "canceled" + ] + }, + "public.autonomy_run_trigger": { + "name": "autonomy_run_trigger", + "schema": "public", + "values": [ + "signal", + "wake", + "manual", + "repair" + ] + }, + "public.autonomy_signal_status": { + "name": "autonomy_signal_status", + "schema": "public", + "values": [ + "pending", + "coalesced", + "processed", + "discarded" + ] + }, + "public.autonomy_task_status": { + "name": "autonomy_task_status", + "schema": "public", + "values": [ + "pending", + "ready", + "running", + "waiting", + "completed", + "failed", + "canceled" + ] + }, + "public.brand_guideline_asset_kind": { + "name": "brand_guideline_asset_kind", + "schema": "public", + "values": [ + "logo", + "wordmark" + ] + }, + "public.brand_guideline_asset_variant": { + "name": "brand_guideline_asset_variant", + "schema": "public", + "values": [ + "light", + "dark" + ] + }, + "public.brand_guideline_color_role": { + "name": "brand_guideline_color_role", + "schema": "public", + "values": [ + "primary", + "secondary", + "accent", + "background", + "foreground", + "neutral", + "custom" + ] + }, + "public.brand_guideline_font_role": { + "name": "brand_guideline_font_role", + "schema": "public", + "values": [ + "heading", + "body", + "button", + "unknown" + ] + }, + "public.brand_guideline_screenshot_kind": { + "name": "brand_guideline_screenshot_kind", + "schema": "public", + "values": [ + "desktop_hero", + "desktop_full_page", + "mobile_hero" + ] + }, + "public.brand_guideline_status": { + "name": "brand_guideline_status", + "schema": "public", + "values": [ + "queued", + "generating", + "ready", + "failed" + ] + }, + "public.brand_guideline_token_type": { + "name": "brand_guideline_token_type", + "schema": "public", + "values": [ + "spacing", + "radius", + "shadow", + "component", + "unknown" + ] + }, + "public.brand_sitemap_page_category": { + "name": "brand_sitemap_page_category", + "schema": "public", + "values": [ + "crawled", + "redirect", + "queued", + "failed" + ] + }, + "public.brand_sitemap_status": { + "name": "brand_sitemap_status", + "schema": "public", + "values": [ + "queued", + "crawling", + "ready", + "failed" + ] + }, + "public.lookback_window": { + "name": "lookback_window", + "schema": "public", + "values": [ + "current_day", + "yesterday", + "last_7_days", + "last_14_days", + "last_30_days" + ] + }, + "public.onboarding_suggestion_type": { + "name": "onboarding_suggestion_type", + "schema": "public", + "values": [ + "schedule_automation", + "event_automation" + ] + }, + "public.post_collection_name_source": { + "name": "post_collection_name_source", + "schema": "public", + "values": [ + "generated", + "user", + "backfill" + ] + }, + "public.post_collection_source": { + "name": "post_collection_source", + "schema": "public", + "values": [ + "manual", + "chat", + "schedule", + "automation", + "api", + "backfill" + ] + }, + "public.post_status": { + "name": "post_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.reference_type": { + "name": "reference_type", + "schema": "public", + "values": [ + "twitter_post", + "linkedin_post", + "blog_post", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0f189a613..0122698c8 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -701,6 +701,13 @@ "when": 1790019760563, "tag": "0099_sticky_triathlon", "breakpoints": true + }, + { + "idx": 100, + "version": "7", + "when": 1790112228943, + "tag": "0100_complete_robin_chapel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e2eff9505..bc363be47 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -85,7 +85,7 @@ export const users = pgTable("users", { createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), role: text("role"), banned: boolean("banned").default(false), @@ -96,6 +96,26 @@ export const users = pgTable("users", { workosUserId: text("workos_user_id").unique(), }); +export const userBackupCodes = pgTable( + "user_backup_codes", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + codeHash: text("code_hash").notNull(), + usedAt: timestamp("used_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("user_backup_codes_userId_idx").on(table.userId), + uniqueIndex("user_backup_codes_userId_codeHash_uidx").on( + table.userId, + table.codeHash + ), + ] +); + export const chatSessions = pgTable( "chat_sessions", { @@ -120,7 +140,7 @@ export const chatSessions = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -172,7 +192,7 @@ export const agentSessions = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -223,7 +243,7 @@ export const socialConnections = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -239,39 +259,27 @@ export const socialConnections = pgTable( ] ); -export const organizations = pgTable( - "organizations", - { - id: text("id").primaryKey(), - name: text("name").notNull(), - slug: text("slug").notNull().unique(), - logo: text("logo"), - createdAt: timestamp("created_at").notNull(), - metadata: text("metadata"), - heardAboutNotraSource: text("heard_about_notra_source"), - heardAboutNotraOther: text("heard_about_notra_other"), - geoIngestTokenGeneration: integer("geo_ingest_token_generation") - .notNull() - .default(1), - feedbackIngestTokenGeneration: integer("feedback_ingest_token_generation") - .notNull() - .default(1), - onboardingCompleted: boolean("onboarding_completed") - .default(false) - .notNull(), - onboardingDismissed: boolean("onboarding_dismissed") - .default(false) - .notNull(), - onboardingAgentRan: boolean("onboarding_agent_ran") - .default(false) - .notNull(), - onboardingAgentStartedAt: timestamp("onboarding_agent_started_at"), - workosOrgId: text("workos_org_id").unique(), - } - // No extra indexes: `slug` already carries a unique constraint - // (`organizations_slug_unique`) that Postgres backs with a unique index, so a - // second identical index would only double index maintenance on every write. -); +export const organizations = pgTable("organizations", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), + heardAboutNotraSource: text("heard_about_notra_source"), + heardAboutNotraOther: text("heard_about_notra_other"), + geoIngestTokenGeneration: integer("geo_ingest_token_generation") + .notNull() + .default(1), + feedbackIngestTokenGeneration: integer("feedback_ingest_token_generation") + .notNull() + .default(1), + onboardingCompleted: boolean("onboarding_completed").default(false).notNull(), + onboardingDismissed: boolean("onboarding_dismissed").default(false).notNull(), + onboardingAgentRan: boolean("onboarding_agent_ran").default(false).notNull(), + onboardingAgentStartedAt: timestamp("onboarding_agent_started_at"), + workosOrgId: text("workos_org_id").unique(), +}); export const members = pgTable( "members", @@ -289,8 +297,6 @@ export const members = pgTable( (table) => [ index("members_organizationId_idx").on(table.organizationId), index("members_userId_idx").on(table.userId), - // Serves the membership lookup that runs on every authenticated request and - // enforces one membership row per (organization, user). uniqueIndex("members_organizationId_userId_uidx").on( table.organizationId, table.userId @@ -319,7 +325,7 @@ export const githubAppInstallations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -361,7 +367,7 @@ export const githubIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -396,7 +402,7 @@ export const linearIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -434,7 +440,7 @@ export const slackIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -461,7 +467,7 @@ export const granolaIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -514,7 +520,7 @@ export const mcpServerIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -602,7 +608,7 @@ export const mcpOAuthCredentials = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -655,7 +661,7 @@ export const mcpOAuthPendingAuthorizations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -715,7 +721,7 @@ export const mcpToolIndex = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -810,7 +816,7 @@ export const contentTriggers = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -874,7 +880,7 @@ export const brandSettings = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -992,7 +998,7 @@ export const brandReferences = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1018,7 +1024,7 @@ export const brandGuidelines = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1045,7 +1051,7 @@ export const brandGuidelineColors = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1074,7 +1080,7 @@ export const brandGuidelineFonts = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1102,7 +1108,7 @@ export const brandGuidelineTokens = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1136,7 +1142,7 @@ export const brandGuidelineAssets = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1173,7 +1179,7 @@ export const brandGuidelineScreenshots = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1206,7 +1212,7 @@ export const brandSitemaps = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1239,7 +1245,7 @@ export const brandSitemapPages = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1248,7 +1254,6 @@ export const brandSitemapPages = pgTable( table.sitemapId, table.category ), - // The gaps program takes the top pages by word count per crawled sitemap. index("brandSitemapPages_sitemap_category_wordCount_idx").on( table.sitemapId, table.category, @@ -1278,7 +1283,7 @@ export const connectedSocialAccounts = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1310,7 +1315,7 @@ export const trackedSocialAccounts = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1344,7 +1349,7 @@ export const organizationNotificationSettings = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1369,7 +1374,7 @@ export const projects = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1414,7 +1419,7 @@ export const agentFeedback = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1462,15 +1467,11 @@ export const geoSettings = pgTable( .notNull() .default(sql`ARRAY[]::text[]`), languages: text("languages").array(), - // null = track the default engine set; otherwise a subset of GEO_ENGINES. engines: text("engines").array(), - // Pro feature: ask every model host for zero data retention. enforceZdr: boolean("enforce_zdr").notNull().default(true), - // Hidden setting: also run every search-capable model without web search. trackWithoutSearch: boolean("track_without_search") .notNull() .default(false), - // Engines without a ZDR host the user explicitly approved anyway. nonZdrApprovedEngines: text("non_zdr_approved_engines") .array() .notNull() @@ -1483,7 +1484,6 @@ export const geoSettings = pgTable( .array() .notNull() .default(sql`ARRAY[]::text[]`), - // Prompt gaps the user ignored; hidden from Content Gaps and the planner. ignoredGapPromptIds: text("ignored_gap_prompt_ids") .array() .notNull() @@ -1492,16 +1492,13 @@ export const geoSettings = pgTable( scanIntervalHours: integer("scan_interval_hours").notNull().default(24), sentimentAttemptedAt: timestamp("sentiment_attempted_at"), nextScanAt: timestamp("next_scan_at"), - // Cron-sweep lease: while set and in the future the row is off limits to - // other sweeps. Kept separate from `next_scan_at` so a retried tick never - // loses the slot it is scanning for. scanLeaseUntil: timestamp("scan_lease_until"), scanStartedAt: timestamp("scan_started_at"), lastScanAt: timestamp("last_scan_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1530,14 +1527,12 @@ export const geoPrompts = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ index("geoPrompts_organizationId_idx").on(table.organizationId), index("geoPrompts_projectId_idx").on(table.projectId), - // The prompt list and the gaps program both read a project's prompts in - // `created_at desc` order, which currently costs a heap sort. index("geoPrompts_projectId_createdAt_idx").on( table.projectId, table.createdAt.desc() @@ -1564,7 +1559,7 @@ export const geoPromptSequences = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1598,7 +1593,7 @@ export const geoPersonas = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1653,7 +1648,7 @@ export const geoCompetitors = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1707,7 +1702,7 @@ export const geoShelfSources = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1785,8 +1780,6 @@ export const geoMentionChecks = pgTable( engine: text("engine").notNull(), promptId: text("prompt_id").notNull(), sequenceId: text("sequence_id"), - // Set on turns played by a simulated buyer persona; null for tracked - // prompts and hand-written conversations. personaId: text("persona_id").references(() => geoPersonas.id, { onDelete: "cascade", }), @@ -1816,8 +1809,6 @@ export const geoMentionChecks = pgTable( promptTokens: integer("prompt_tokens"), outputTokens: integer("output_tokens"), reasoningTokens: integer("reasoning_tokens"), - // Whether the engine call ran with zero data retention enforced. Null on - // rows written before the column existed or when the route did not say. zdrEnforced: boolean("zdr_enforced"), durationMs: integer("duration_ms"), costUsd: real("cost_usd"), @@ -1844,11 +1835,6 @@ export const geoMentionChecks = pgTable( table.promptId, table.capturedAt ), - // Covering index for the GEO analytics window: the aggregate procedures - // (overview, timeseries, competitor/language share) read only these columns, - // while the table averages ~900 B/row because of answer/grounding/excerpt. - // drizzle-orm 0.45 has no `INCLUDE` support, so the payload columns are - // trailing key columns instead of index-only payload. index("geoMentionChecks_project_captured_cover_idx").on( table.projectId, table.capturedAt, @@ -1862,9 +1848,6 @@ export const geoMentionChecks = pgTable( table.sentiment, table.sequenceId ), - // Matches the `distinct on (prompt_id, engine) ... order by captured_at desc` - // shape used by promptResultSummaries/promptResults/competitorDetail/gaps; - // the existing projectEnginePrompt index has the leading columns swapped. index("geoMentionChecks_project_prompt_engine_captured_idx").on( table.projectId, table.promptId, @@ -1872,8 +1855,6 @@ export const geoMentionChecks = pgTable( table.capturedAt.desc() ), index("geoMentionChecks_competitors_idx").using("gin", table.competitors), - // sequenceResults orders by exactly this tuple; `sequence_id` appears in no - // other index. index("geoMentionChecks_sequence_turn_engine_captured_idx") .on(table.sequenceId, table.turn, table.engine, table.capturedAt.desc()) .where(sql`${table.sequenceId} IS NOT NULL`), @@ -1948,7 +1929,7 @@ export const geoAgentReadinessReports = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -1994,7 +1975,7 @@ export const googleSearchConsoleIntegrations = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2040,7 +2021,7 @@ export const geoPromptSuggestions = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2099,7 +2080,7 @@ export const geoContentBriefs = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2111,9 +2092,6 @@ export const geoContentBriefs = pgTable( table.projectId, table.status ), - // Serves the `distinct on (source_kind, source_id) ... order by updated_at - // desc` read in the gaps program; the partial unique index below excludes - // published/archived briefs and so cannot serve it. index("geoContentBriefs_project_source_updated_idx").on( table.projectId, table.sourceKind, @@ -2148,7 +2126,7 @@ export const socialExperiments = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2181,7 +2159,7 @@ export const postCollections = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2229,7 +2207,7 @@ export const posts = pgTable( status: postStatusEnum("status").default("draft").notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2242,18 +2220,14 @@ export const posts = pgTable( table.id ), index("posts_collection_id_idx").on(table.collectionId), - // content.metrics.get aggregates a year of posts by (created_at, status); - // posts_org_createdAt_id_idx lacks `status` and forces a heap fetch per row. index("posts_org_createdAt_status_idx").on( table.organizationId, table.createdAt, table.status ), - // Adoption analytics looks up the org's first published post. index("posts_org_published_createdAt_idx") .on(table.organizationId, table.createdAt) .where(sql`${table.status} = 'published'`), - // The gaps program reads the newest posts of one content type per org. index("posts_org_content_type_updated_at_idx").on( table.organizationId, table.contentType, @@ -2288,7 +2262,7 @@ export const contentPublications = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2324,7 +2298,7 @@ export const skills = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2333,11 +2307,6 @@ export const skills = pgTable( ] ); -/** - * Prospect-facing GEO reports built in the console and shared via `/r/{shareToken}`. - * The report body is denormalised into a few columns for listing; the JSON is - * the source of truth and includes the raw model answers from the scan. - */ export const geoProspectReports = pgTable( "geo_prospect_reports", { @@ -2348,7 +2317,6 @@ export const geoProspectReports = pgTable( createdByUserId: text("created_by_user_id").references(() => users.id, { onDelete: "set null", }), - /** Unguessable token used in the public share link. */ shareToken: text("share_token").notNull(), status: text("status", { enum: GEO_PROSPECT_REPORT_STATUSES }) .notNull() @@ -2363,7 +2331,7 @@ export const geoProspectReports = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2429,7 +2397,7 @@ export const autonomyMandates = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2467,7 +2435,7 @@ export const autonomySignals = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2518,7 +2486,7 @@ export const autonomyGoals = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2570,7 +2538,7 @@ export const autonomyRuns = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2622,7 +2590,7 @@ export const autonomyTasks = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2671,7 +2639,7 @@ export const autonomyActions = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2741,7 +2709,7 @@ export const autonomyOutbox = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2772,7 +2740,7 @@ export const autonomyClaims = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ @@ -2797,7 +2765,7 @@ export const autonomyControllerLeases = pgTable( createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at") .defaultNow() - .$onUpdate(() => /* @__PURE__ */ new Date()) + .$onUpdate(() => new Date()) .notNull(), }, (table) => [ diff --git a/packages/posthog/src/events.ts b/packages/posthog/src/events.ts index 3af8c7e66..17762c323 100644 --- a/packages/posthog/src/events.ts +++ b/packages/posthog/src/events.ts @@ -7,6 +7,13 @@ export const POSTHOG_EVENTS = { LOGIN_FAILED: "login_failed", PASSWORD_RESET_REQUESTED: "password_reset_requested", PASSWORD_RESET_COMPLETED: "password_reset_completed", + MFA_CHALLENGE_REQUIRED: "mfa_challenge_required", + MFA_ENROLLMENT_REQUIRED: "mfa_enrollment_required", + MFA_VERIFIED: "mfa_verified", + MFA_FACTOR_ENROLLED: "mfa_factor_enrolled", + MFA_FACTOR_REMOVED: "mfa_factor_removed", + MFA_BACKUP_CODE_USED: "mfa_backup_code_used", + MFA_BACKUP_CODES_REGENERATED: "mfa_backup_codes_regenerated", LOGOUT: "logout", CALLBACK_ROUTED: "callback_routed", diff --git a/packages/schemas/src/constants/dashboard/auth.ts b/packages/schemas/src/constants/dashboard/auth.ts new file mode 100644 index 000000000..51ab46153 --- /dev/null +++ b/packages/schemas/src/constants/dashboard/auth.ts @@ -0,0 +1,2 @@ +export const TOTP_CODE_LENGTH = 6; +export const BACKUP_CODE_LENGTH = 8; diff --git a/packages/schemas/src/schemas/dashboard/auth/mfa.ts b/packages/schemas/src/schemas/dashboard/auth/mfa.ts new file mode 100644 index 000000000..cfb50cd9e --- /dev/null +++ b/packages/schemas/src/schemas/dashboard/auth/mfa.ts @@ -0,0 +1,80 @@ +import "zod/compile"; +import { + BACKUP_CODE_LENGTH, + TOTP_CODE_LENGTH, +} from "@notra/schemas/constants/dashboard/auth"; +import { returnToSchema } from "@notra/schemas/dashboard/auth/return-to"; +import { normalizeBackupCode } from "@notra/schemas/utils/auth"; +// biome-ignore lint/performance/noNamespaceImport: Zod recommended way to import +import * as z from "zod"; + +const AUTH_TOKEN_MAX_LENGTH = 4096; +const WORKOS_ID_MAX_LENGTH = 128; +const ONE_TIME_CODE_REGEX = new RegExp(`^\\d{${TOTP_CODE_LENGTH}}$`); + +export const totpCodeSchema = z + .string() + .regex( + ONE_TIME_CODE_REGEX, + `Enter the ${TOTP_CODE_LENGTH}-digit code from your authenticator app` + ); + +const workosIdSchema = (label: string) => + z.string().min(1, `${label} is missing`).max(WORKOS_ID_MAX_LENGTH); + +export const verifyMfaCodeInputSchema = z.object({ + pendingAuthenticationToken: z + .string() + .min(1, "Sign-in session is missing") + .max(AUTH_TOKEN_MAX_LENGTH), + authenticationChallengeId: workosIdSchema("Challenge"), + code: totpCodeSchema, + returnTo: returnToSchema, +}); + +const BACKUP_CODE_REGEX = new RegExp(`^[a-z0-9]{${BACKUP_CODE_LENGTH}}$`); + +export const backupCodeSchema = z + .string() + .transform(normalizeBackupCode) + .pipe(z.string().regex(BACKUP_CODE_REGEX, "Enter a valid backup code")); + +export const redeemBackupCodeInputSchema = z.object({ + authenticationChallengeId: workosIdSchema("Challenge"), + code: backupCodeSchema, + returnTo: returnToSchema, +}); + +export const secondFactorCodeSchema = z + .string() + .trim() + .refine( + (value) => + ONE_TIME_CODE_REGEX.test(value) || + BACKUP_CODE_REGEX.test(normalizeBackupCode(value)), + "Enter the code from your authenticator app or a backup code" + ); + +export const verifyTotpEnrollmentInputSchema = z.object({ + factorId: workosIdSchema("Factor"), + authenticationChallengeId: workosIdSchema("Challenge"), + code: totpCodeSchema, +}); + +export const discardTotpEnrollmentInputSchema = z.object({ + factorId: workosIdSchema("Factor"), +}); + +export const removeAuthFactorInputSchema = z.object({ + factorId: workosIdSchema("Factor"), + confirmationCode: secondFactorCodeSchema, +}); + +export const regenerateBackupCodesInputSchema = z.object({ + confirmationCode: secondFactorCodeSchema, +}); + +export const resumeSocialEnrollmentInputSchema = z.object({ + flowId: z.uuid("Sign-in session is missing"), + returnTo: returnToSchema, +}); diff --git a/packages/schemas/src/schemas/dashboard/auth/workos-error.ts b/packages/schemas/src/schemas/dashboard/auth/workos-error.ts index 4abb1799b..05a93c8f2 100644 --- a/packages/schemas/src/schemas/dashboard/auth/workos-error.ts +++ b/packages/schemas/src/schemas/dashboard/auth/workos-error.ts @@ -12,6 +12,12 @@ export const workosErrorSchema = z.looseObject({ email: z.string().optional(), pending_authentication_token: z.string().optional(), organizations: z.array(z.looseObject({ id: z.string() })).optional(), + authentication_factors: z + .array(z.looseObject({ id: z.string(), type: z.string() })) + .optional(), + user: z + .looseObject({ id: z.string(), email: z.string().optional() }) + .optional(), }) .optional(), }); diff --git a/packages/schemas/src/types/dashboard/auth.ts b/packages/schemas/src/types/dashboard/auth.ts new file mode 100644 index 000000000..1f05d563c --- /dev/null +++ b/packages/schemas/src/types/dashboard/auth.ts @@ -0,0 +1,137 @@ +import type * as z from "zod"; + +import type { + forgotPasswordInputSchema, + resetPasswordInputSchema, + signInWithPasswordInputSchema, + signUpWithPasswordInputSchema, + verifyEmailCodeInputSchema, +} from "../../schemas/dashboard/auth/credentials.js"; +import type { + discardTotpEnrollmentInputSchema, + redeemBackupCodeInputSchema, + regenerateBackupCodesInputSchema, + removeAuthFactorInputSchema, + resumeSocialEnrollmentInputSchema, + verifyMfaCodeInputSchema, + verifyTotpEnrollmentInputSchema, +} from "../../schemas/dashboard/auth/mfa.js"; +import type { startSocialSignInInputSchema } from "../../schemas/dashboard/auth/social.js"; + +export type SignInWithPasswordInput = z.input< + typeof signInWithPasswordInputSchema +>; +export type SignUpWithPasswordInput = z.input< + typeof signUpWithPasswordInputSchema +>; +export type VerifyEmailCodeInput = z.input; +export type ForgotPasswordInput = z.input; +export type ResetPasswordInput = z.input; +export type VerifyMfaCodeInput = z.input; +export type RedeemBackupCodeInput = z.input; +export type StartSocialSignInInput = z.input< + typeof startSocialSignInInputSchema +>; +export type VerifyTotpEnrollmentInput = z.input< + typeof verifyTotpEnrollmentInputSchema +>; +export type DiscardTotpEnrollmentInput = z.input< + typeof discardTotpEnrollmentInputSchema +>; +export type RemoveAuthFactorInput = z.input; +export type RegenerateBackupCodesInput = z.input< + typeof regenerateBackupCodesInputSchema +>; +export type ResumeSocialEnrollmentInput = z.input< + typeof resumeSocialEnrollmentInputSchema +>; + +export interface TotpEnrollmentSecrets { + qrCode: string; + secret: string; + otpauthUri: string; +} + +export interface TotpEnrollment extends TotpEnrollmentSecrets { + factorId: string; + authenticationChallengeId: string; +} + +export interface TotpFactorSummary { + id: string; + issuer: string | null; + createdAt: string; +} + +export interface AuthFlowSuccess { + status: "success"; + redirectTo: string; +} + +export interface AuthFlowEnrolled { + status: "enrolled"; + redirectTo: string; + backupCodes: string[]; +} + +export interface AuthFlowVerificationRequired { + status: "verification-required"; + pendingAuthenticationToken: string; + email: string; +} + +export interface AuthFlowMfaRequired { + status: "mfa-required"; + pendingAuthenticationToken: string; + authenticationChallengeId: string; + email: string; +} + +export interface AuthFlowMfaEnrollmentRequired extends TotpEnrollment { + status: "mfa-enrollment-required"; + pendingAuthenticationToken: string; + email: string; +} + +export interface AuthFlowError { + status: "error"; + message: string; +} + +export type AuthFlowResult = + | AuthFlowSuccess + | AuthFlowEnrolled + | AuthFlowVerificationRequired + | AuthFlowMfaRequired + | AuthFlowMfaEnrollmentRequired + | AuthFlowError; + +export type PendingAuthStep = + | AuthFlowVerificationRequired + | AuthFlowMfaRequired + | AuthFlowMfaEnrollmentRequired; + +export interface AuthFlowRecovered { + status: "recovered"; + email: string; +} + +export type RedeemBackupCodeResult = AuthFlowRecovered | AuthFlowError; + +export interface SecurityOverview { + email: string; + totpFactors: TotpFactorSummary[]; + backupCodesRemaining: number; +} + +export type StartTotpEnrollmentResult = TotpEnrollment; + +export interface VerifyTotpEnrollmentResult { + verified: true; + backupCodes: string[] | null; + warning: string | null; +} + +export interface RegenerateBackupCodesResult { + codes: string[]; +} diff --git a/packages/schemas/src/utils/auth.ts b/packages/schemas/src/utils/auth.ts new file mode 100644 index 000000000..e3ff3449f --- /dev/null +++ b/packages/schemas/src/utils/auth.ts @@ -0,0 +1,5 @@ +const BACKUP_CODE_SEPARATOR_REGEX = /[\s-]/g; + +export function normalizeBackupCode(code: string): string { + return code.toLowerCase().replace(BACKUP_CODE_SEPARATOR_REGEX, ""); +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 11336373b..febc99d96 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ "@dnd-kit/utilities": "^3.2.2", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.4", + "@notra/schemas": "workspace:*", "@shadcn/react": "^0.2.0", "@shikijs/transformers": "^3.21.0", "@tailwindcss/typography": "^0.5.19", diff --git a/packages/ui/src/components/shared/auth/auth-email-field.tsx b/packages/ui/src/components/shared/auth/auth-email-field.tsx index 0d94b19a4..160ff2b6f 100644 --- a/packages/ui/src/components/shared/auth/auth-email-field.tsx +++ b/packages/ui/src/components/shared/auth/auth-email-field.tsx @@ -1,6 +1,6 @@ "use client"; -import type { AuthEmailFieldProps } from "../../../lib/auth-types"; +import type { AuthEmailFieldProps } from "../../../types/auth"; import { Input } from "../../ui/input"; import { Label } from "../../ui/label"; import { AuthFieldError } from "./auth-field-error"; diff --git a/packages/ui/src/components/shared/auth/auth-field-error.tsx b/packages/ui/src/components/shared/auth/auth-field-error.tsx index fa85df74d..0154ba7fd 100644 --- a/packages/ui/src/components/shared/auth/auth-field-error.tsx +++ b/packages/ui/src/components/shared/auth/auth-field-error.tsx @@ -8,7 +8,7 @@ import { useReducedMotion, } from "motion/react"; import { TRANSITION } from "@notra/ui/lib/motion"; -import type { AuthFieldErrorProps } from "../../../lib/auth-types"; +import type { AuthFieldErrorProps } from "../../../types/auth"; export function AuthFieldError({ id, error }: AuthFieldErrorProps) { const reduceMotion = useReducedMotion(); diff --git a/packages/ui/src/components/shared/auth/auth-form-error.tsx b/packages/ui/src/components/shared/auth/auth-form-error.tsx index 928019fe9..49f2abc2b 100644 --- a/packages/ui/src/components/shared/auth/auth-form-error.tsx +++ b/packages/ui/src/components/shared/auth/auth-form-error.tsx @@ -11,7 +11,7 @@ import { useReducedMotion, } from "motion/react"; import { TRANSITION } from "@notra/ui/lib/motion"; -import type { AuthFormErrorProps } from "../../../lib/auth-types"; +import type { AuthFormErrorProps } from "../../../types/auth"; export function AuthFormError({ error, className }: AuthFormErrorProps) { const reduceMotion = useReducedMotion(); diff --git a/packages/ui/src/components/shared/auth/auth-form-header.tsx b/packages/ui/src/components/shared/auth/auth-form-header.tsx index c49addbc7..8a0b6bb17 100644 --- a/packages/ui/src/components/shared/auth/auth-form-header.tsx +++ b/packages/ui/src/components/shared/auth/auth-form-header.tsx @@ -1,4 +1,4 @@ -import type { AuthFormHeaderProps } from "../../../lib/auth-types"; +import type { AuthFormHeaderProps } from "../../../types/auth"; export function AuthFormHeader({ title, description }: AuthFormHeaderProps) { if (!(title || description)) { diff --git a/packages/ui/src/components/shared/auth/auth-password-field.tsx b/packages/ui/src/components/shared/auth/auth-password-field.tsx index beaa4df76..b966390b9 100644 --- a/packages/ui/src/components/shared/auth/auth-password-field.tsx +++ b/packages/ui/src/components/shared/auth/auth-password-field.tsx @@ -3,7 +3,7 @@ import { ViewIcon, ViewOffSlashIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { useState } from "react"; -import type { AuthPasswordFieldProps } from "../../../lib/auth-types"; +import type { AuthPasswordFieldProps } from "../../../types/auth"; import { Input } from "../../ui/input"; import { Label } from "../../ui/label"; import { AuthFieldError } from "./auth-field-error"; diff --git a/packages/ui/src/components/shared/auth/auth-pending-step.tsx b/packages/ui/src/components/shared/auth/auth-pending-step.tsx new file mode 100644 index 000000000..e160fbfbf --- /dev/null +++ b/packages/ui/src/components/shared/auth/auth-pending-step.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { AuthPendingStepProps } from "../../../types/auth"; +import { EmailVerificationForm } from "./email-verification-form"; +import { MfaChallengeForm } from "./mfa-challenge-form"; +import { MfaEnrollmentForm } from "./mfa-enrollment-form"; + +export function AuthPendingStep({ + step, + returnTo, + onResult, + onFinish, + onBack, + onRecovered, + verifyEmailCode, + verifyMfaCode, + redeemBackupCode, +}: AuthPendingStepProps) { + switch (step.status) { + case "verification-required": + return ( + + ); + case "mfa-required": + return ( + + ); + case "mfa-enrollment-required": + return ( + + ); + default: { + const exhaustive: never = step; + return exhaustive; + } + } +} diff --git a/packages/ui/src/components/shared/auth/auth-social-buttons.tsx b/packages/ui/src/components/shared/auth/auth-social-buttons.tsx index 29fedfdf5..6b8898f7c 100644 --- a/packages/ui/src/components/shared/auth/auth-social-buttons.tsx +++ b/packages/ui/src/components/shared/auth/auth-social-buttons.tsx @@ -4,7 +4,7 @@ import { Loader2Icon } from "lucide-react"; import type { AuthSocialButtonsProps, SocialProvider, -} from "../../../lib/auth-types"; +} from "../../../types/auth"; import { Badge } from "../../ui/badge"; import { Github } from "../../ui/svgs/github"; import { Google } from "../../ui/svgs/google"; diff --git a/packages/ui/src/components/shared/auth/email-verification-form.tsx b/packages/ui/src/components/shared/auth/email-verification-form.tsx index 258f67a10..f1b7330d0 100644 --- a/packages/ui/src/components/shared/auth/email-verification-form.tsx +++ b/packages/ui/src/components/shared/auth/email-verification-form.tsx @@ -1,21 +1,19 @@ "use client"; +import { TOTP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; import { Loader2Icon } from "lucide-react"; import { useRef, useState } from "react"; -import type { EmailVerificationFormProps } from "../../../lib/auth-types"; -import { Input } from "../../ui/input"; -import { Label } from "../../ui/label"; +import type { EmailVerificationFormProps } from "../../../types/auth"; import { CtaButton } from "../cta-button"; -import { AuthFormError } from "./auth-form-error"; import { AuthFormHeader } from "./auth-form-header"; +import { TotpCodeInput } from "./totp-code-input"; -const NON_DIGIT_REGEX = /\D/g; +const VERIFY_ERROR_FALLBACK = "Verification failed. Please try again."; export function EmailVerificationForm({ - pendingAuthenticationToken, - email, + step, returnTo, - onSuccess, + onResult, verifyEmailCode, }: EmailVerificationFormProps) { const [code, setCode] = useState(""); @@ -23,81 +21,65 @@ export function EmailVerificationForm({ const [isPending, setIsPending] = useState(false); const requestIdRef = useRef(0); - async function handleVerify() { + async function handleVerify(submittedCode: string) { + if (submittedCode.length !== TOTP_CODE_LENGTH || isPending) { + return; + } const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; setFormError(null); setIsPending(true); const result = await verifyEmailCode({ - pendingAuthenticationToken, - code, + pendingAuthenticationToken: step.pendingAuthenticationToken, + code: submittedCode, returnTo, }).catch(() => null); - if (result?.status === "success") { - if (requestIdRef.current === requestId) { - if (onSuccess) { - onSuccess(); - } else { - window.location.assign(result.redirectTo); - } - } + if (requestIdRef.current !== requestId) { return; } - - const nextError = - result?.status === "error" - ? result.message - : "Verification failed. Please try again."; - - setFormError((previous) => - requestIdRef.current === requestId ? nextError : previous - ); - setIsPending((previous) => - requestIdRef.current === requestId ? false : previous + if (result && onResult(result)) { + return; + } + setFormError( + result?.status === "error" ? result.message : VERIFY_ERROR_FALLBACK ); + setCode(""); + setIsPending(false); } return (
{ event.preventDefault(); - handleVerify(); + handleVerify(code); }} > -
- - - setCode(event.target.value.replace(NON_DIGIT_REGEX, "")) - } - placeholder="000000" - value={code} - /> -
+
- - {isPending ? ( diff --git a/packages/ui/src/components/shared/auth/login-form.tsx b/packages/ui/src/components/shared/auth/login-form.tsx index 985381770..257e656ea 100644 --- a/packages/ui/src/components/shared/auth/login-form.tsx +++ b/packages/ui/src/components/shared/auth/login-form.tsx @@ -4,12 +4,12 @@ import { useForm } from "@tanstack/react-form"; import { Loader2Icon } from "lucide-react"; import Link from "next/link"; import { useRef, useState, useSyncExternalStore } from "react"; +import { useAuthFlow } from "../../../hooks/use-auth-flow"; import type { AuthMethod, LoginFormProps, - PendingVerification, SocialProvider, -} from "../../../lib/auth-types"; +} from "../../../types/auth"; import { getLastUsedLoginMethod, setLastUsedLoginMethod, @@ -23,10 +23,11 @@ import { AuthFormError } from "./auth-form-error"; import { AuthFormHeader } from "./auth-form-header"; import { AuthOrDivider } from "./auth-or-divider"; import { AuthPasswordField } from "./auth-password-field"; +import { AuthPendingStep } from "./auth-pending-step"; import { AuthSocialButtons } from "./auth-social-buttons"; -import { EmailVerificationForm } from "./email-verification-form"; const LOGIN_ERROR_FALLBACK = "Failed to sign in. Please try again."; +const SOCIAL_ERROR_FALLBACK = "Social sign-in failed. Please try again."; const noop = () => { return; @@ -34,6 +35,11 @@ const noop = () => { const subscribeToNothing = () => noop; const returnNull = () => null; +const validateFilledField = ( + validate: (value: string) => string | undefined, + value: string +) => (value.length > 0 ? validate(value) : undefined); + export function LoginForm({ title = "Welcome back", description = "Log in to pick up where your team left off.", @@ -42,114 +48,127 @@ export function LoginForm({ showSignupLink = true, showForgotPasswordLink = true, initialError, - initialPendingVerification, + initialPending, callbackPath, validators, signInWithPassword, verifyEmailCode, + verifyMfaCode, + redeemBackupCode, startSocialSignIn, }: LoginFormProps) { const [authMethod, setAuthMethod] = useState(null); const [formError, setFormError] = useState( initialError ?? null ); - const [pendingVerification, setPendingVerification] = - useState(initialPendingVerification ?? null); const authInFlightRef = useRef(false); + const flow = useAuthFlow({ initialPending, onSuccess }); const lastMethod = useSyncExternalStore( subscribeToNothing, getLastUsedLoginMethod, returnNull ); const isAuthLoading = authMethod !== null; - const callbackURL = returnTo ?? callbackPath; - function handleSocialLogin(provider: SocialProvider) { + function releaseAuth() { + authInFlightRef.current = false; + setAuthMethod(null); + } + + function startRedirectSignIn( + method: AuthMethod, + start: () => Promise, + fallbackError: string + ) { if (authInFlightRef.current) { return; } - setFormError(null); authInFlightRef.current = true; - setAuthMethod(provider); - setLastUsedLoginMethod(provider); - startSocialSignIn({ provider, returnTo: callbackURL }).catch((error) => { + setAuthMethod(method); + setLastUsedLoginMethod(method); + start().catch((error) => { if (isNextRedirectError(error)) { return; } - authInFlightRef.current = false; - setAuthMethod(null); - setFormError("Social sign-in failed. Please try again."); + releaseAuth(); + setFormError(fallbackError); }); } - const form = useForm({ - defaultValues: { - email: "", - password: "", - }, - onSubmit: async ({ value }) => { - if (authInFlightRef.current) { - return; + async function submitPassword(email: string, password: string) { + if (authInFlightRef.current) { + return; + } + setFormError(null); + authInFlightRef.current = true; + setAuthMethod("email"); + try { + const result = await signInWithPassword({ + email, + password, + returnTo: callbackURL, + }); + if (result.status === "success") { + setLastUsedLoginMethod("email"); + } + if (!flow.applyResult(result)) { + setFormError( + result.status === "error" + ? result.message || LOGIN_ERROR_FALLBACK + : LOGIN_ERROR_FALLBACK + ); } + if (result.status !== "success") { + releaseAuth(); + } + } catch (error) { + console.error("Email login error:", error); + setFormError(LOGIN_ERROR_FALLBACK); + releaseAuth(); + } + } + const form = useForm({ + defaultValues: { email: "", password: "" }, + onSubmit: async ({ value }) => { if (validators.email(value.email) || validators.password(value.password)) { return; } - - setFormError(null); - authInFlightRef.current = true; - setAuthMethod("email"); - try { - const result = await signInWithPassword({ - email: value.email, - password: value.password, - returnTo: callbackURL, - }); - - if (result.status === "error") { - setFormError(result.message || LOGIN_ERROR_FALLBACK); - authInFlightRef.current = false; - setAuthMethod(null); - return; - } - - if (result.status === "verification-required") { - authInFlightRef.current = false; - setAuthMethod(null); - setPendingVerification({ - pendingAuthenticationToken: result.pendingAuthenticationToken, - email: result.email, - }); - return; - } - - setLastUsedLoginMethod("email"); - if (onSuccess) { - onSuccess(); - } else { - window.location.assign(result.redirectTo); - } - } catch (error) { - console.error("Email login error:", error); - setFormError(LOGIN_ERROR_FALLBACK); - authInFlightRef.current = false; - setAuthMethod(null); - } + await submitPassword(value.email, value.password); }, }); - if (pendingVerification) { + function resetToSignIn() { + flow.reset(); + setFormError(null); + } + + async function handleRecovered(recoveredEmail: string) { + flow.reset(); + const { email, password } = form.state.values; + if (email && password) { + await submitPassword(email, password); + return; + } + setFormError( + `Backup code accepted. Two-factor authentication was turned off for ${recoveredEmail}. Sign in again to continue.` + ); + } + + if (flow.pending) { return ( - ); } @@ -163,7 +182,13 @@ export function LoginForm({ authMethod={authMethod} disabled={isAuthLoading} lastMethod={lastMethod} - onSelect={handleSocialLogin} + onSelect={(provider: SocialProvider) => + startRedirectSignIn( + provider, + () => startSocialSignIn({ provider, returnTo: callbackURL }), + SOCIAL_ERROR_FALLBACK + ) + } /> @@ -182,7 +207,8 @@ export function LoginForm({ validators.email(value), + onBlur: ({ value }) => + validateFilledField(validators.email, value), onSubmit: ({ value }) => validators.email(value), }} > @@ -202,7 +228,8 @@ export function LoginForm({ validators.password(value), + onBlur: ({ value }) => + validateFilledField(validators.password, value), onSubmit: ({ value }) => validators.password(value), }} > diff --git a/packages/ui/src/components/shared/auth/mfa-challenge-form.tsx b/packages/ui/src/components/shared/auth/mfa-challenge-form.tsx new file mode 100644 index 000000000..68157213c --- /dev/null +++ b/packages/ui/src/components/shared/auth/mfa-challenge-form.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { + BACKUP_CODE_LENGTH, + TOTP_CODE_LENGTH, +} from "@notra/schemas/constants/dashboard/auth"; +import { normalizeBackupCode } from "@notra/schemas/utils/auth"; +import { Loader2Icon } from "lucide-react"; +import { useRef, useState } from "react"; + +import type { + ChallengeMode, + MfaChallengeFormProps, + MfaSubmitButtonProps, +} from "../../../types/auth"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Label } from "../../ui/label"; +import { BackupCodesPanel } from "../security/backup-codes-panel"; +import { CtaButton } from "../cta-button"; +import { AuthFormError } from "./auth-form-error"; +import { AuthFormHeader } from "./auth-form-header"; +import { TotpCodeInput } from "./totp-code-input"; + +const MFA_ERROR_FALLBACK = "That code didn't work. Please try again."; + +function SubmitButton({ + isPending, + disabled, + pendingLabel, + label, +}: MfaSubmitButtonProps) { + return ( + + {isPending ? ( + <> + + {pendingLabel} + + ) : ( + label + )} + + ); +} + +export function MfaChallengeForm({ + step, + returnTo, + onResult, + onFinish, + onBack, + onRecovered, + verifyMfaCode, + redeemBackupCode, +}: MfaChallengeFormProps) { + const [mode, setMode] = useState("totp"); + const [code, setCode] = useState(""); + const [backupCode, setBackupCode] = useState(""); + const [issuedCodes, setIssuedCodes] = useState<{ + codes: string[]; + redirectTo: string; + } | null>(null); + const [formError, setFormError] = useState(null); + const [isPending, setIsPending] = useState(false); + const requestIdRef = useRef(0); + + function beginRequest() { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + setFormError(null); + setIsPending(true); + return () => requestIdRef.current === requestId; + } + + function fail(message: string | undefined) { + setFormError(message || MFA_ERROR_FALLBACK); + setCode(""); + setBackupCode(""); + setIsPending(false); + } + + async function handleVerify(submittedCode: string) { + if (submittedCode.length !== TOTP_CODE_LENGTH || isPending) { + return; + } + const isCurrent = beginRequest(); + const result = await verifyMfaCode({ + pendingAuthenticationToken: step.pendingAuthenticationToken, + authenticationChallengeId: step.authenticationChallengeId, + code: submittedCode, + returnTo, + }).catch(() => null); + if (!isCurrent()) { + return; + } + if (result?.status === "enrolled") { + setIsPending(false); + setIssuedCodes({ + codes: result.backupCodes, + redirectTo: result.redirectTo, + }); + return; + } + if (result && onResult(result)) { + return; + } + fail(result?.status === "error" ? result.message : undefined); + } + + async function handleBackupCode() { + if (isPending || !backupCodeReady) { + return; + } + const isCurrent = beginRequest(); + const result = await redeemBackupCode({ + authenticationChallengeId: step.authenticationChallengeId, + code: backupCode, + returnTo, + }).catch(() => null); + if (!isCurrent()) { + return; + } + if (result?.status === "recovered") { + onRecovered(result.email); + return; + } + fail(result?.message); + } + + function switchMode(next: ChallengeMode) { + setMode(next); + setFormError(null); + setCode(""); + setBackupCode(""); + } + + const backupCodeReady = + normalizeBackupCode(backupCode).length === BACKUP_CODE_LENGTH; + + if (issuedCodes) { + return ( +
+ + onFinish(issuedCodes.redirectTo)} + /> +
+ ); + } + + if (mode === "backup") { + return ( +
+ + { + event.preventDefault(); + handleBackupCode(); + }} + > +
+ + setBackupCode(event.target.value)} + placeholder="xxxx-xxxx" + spellCheck={false} + value={backupCode} + /> +
+
+ + +
+ + +
+ ); + } + + const description = step.email + ? `Enter the 6-digit code from your authenticator app to finish signing in as ${step.email}.` + : "Enter the 6-digit code from your authenticator app to finish signing in."; + + return ( +
+ +
{ + event.preventDefault(); + handleVerify(code); + }} + > + +
+ + +
+ +
+ + {onBack && ( + + )} +
+
+ ); +} diff --git a/packages/ui/src/components/shared/auth/mfa-enrollment-form.tsx b/packages/ui/src/components/shared/auth/mfa-enrollment-form.tsx new file mode 100644 index 000000000..5e78d3de2 --- /dev/null +++ b/packages/ui/src/components/shared/auth/mfa-enrollment-form.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useRef } from "react"; + +import type { + MfaEnrollmentFormProps, + TotpEnrollmentSubmission, + TotpVerifyResult, +} from "../../../types/auth"; +import { AuthFormHeader } from "./auth-form-header"; +import { TotpEnrollmentPanel } from "./totp-enrollment-panel"; + +const ENROLLMENT_ERROR_FALLBACK = "That code didn't work. Please try again."; + +export function MfaEnrollmentForm({ + step, + returnTo, + onResult, + onFinish, + onBack, + verifyMfaCode, +}: MfaEnrollmentFormProps) { + const redirectToRef = useRef(null); + + async function handleSubmit({ + code, + }: TotpEnrollmentSubmission): Promise { + const result = await verifyMfaCode({ + pendingAuthenticationToken: step.pendingAuthenticationToken, + authenticationChallengeId: step.authenticationChallengeId, + code, + returnTo, + }).catch(() => null); + + if (!result) { + return { ok: false, message: ENROLLMENT_ERROR_FALLBACK }; + } + if (result.status === "enrolled") { + redirectToRef.current = result.redirectTo; + return { ok: true, backupCodes: result.backupCodes }; + } + if (onResult(result)) { + return { ok: true }; + } + return { + ok: false, + message: + result.status === "error" + ? result.message || ENROLLMENT_ERROR_FALLBACK + : ENROLLMENT_ERROR_FALLBACK, + }; + } + + const description = step.email + ? `Your organization requires a second step when signing in as ${step.email}.` + : "Your organization requires a second step when signing in."; + + return ( +
+ + { + if (redirectToRef.current) { + onFinish(redirectToRef.current); + } + }} + onSubmit={handleSubmit} + otpauthUri={step.otpauthUri} + qrCode={step.qrCode} + secret={step.secret} + submitLabel="Verify and sign in" + /> +
+ ); +} diff --git a/packages/ui/src/components/shared/auth/totp-code-input.tsx b/packages/ui/src/components/shared/auth/totp-code-input.tsx new file mode 100644 index 000000000..003fcd1f0 --- /dev/null +++ b/packages/ui/src/components/shared/auth/totp-code-input.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { TOTP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; +import { cn } from "@notra/ui/lib/utils"; +import type { TotpCodeInputProps } from "../../../types/auth"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "../../ui/input-otp"; +import { Label } from "../../ui/label"; +import { AuthFieldError } from "./auth-field-error"; + +const SLOT_INDEXES = Array.from( + { length: TOTP_CODE_LENGTH }, + (_, index) => index +); + +export function TotpCodeInput({ + id, + value, + onChange, + onComplete, + label = "Verification code", + error, + disabled = false, + autoFocus = false, + className, +}: TotpCodeInputProps) { + const errorId = `${id}-error`; + const hasError = Boolean(error); + + return ( +
+ + + + {SLOT_INDEXES.map((index) => ( + + ))} + + + +
+ ); +} diff --git a/packages/ui/src/components/shared/auth/totp-enrollment-panel.tsx b/packages/ui/src/components/shared/auth/totp-enrollment-panel.tsx new file mode 100644 index 000000000..1332ceb9c --- /dev/null +++ b/packages/ui/src/components/shared/auth/totp-enrollment-panel.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { CheckmarkCircle02Icon, Copy01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { TOTP_CODE_LENGTH } from "@notra/schemas/constants/dashboard/auth"; +import { Loader2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import type { + CopyValueFieldProps, + EnrollmentStep, + StepActionsProps, + TotpEnrollmentPanelProps, + TotpVerifyResult, +} from "../../../types/auth"; +import { Button } from "../../ui/button"; +import { BackupCodesPanel } from "../security/backup-codes-panel"; +import { StepTransition } from "../security/step-transition"; +import { TotpCodeInput } from "./totp-code-input"; + +const ENROLLMENT_ERROR_FALLBACK = "That code didn't work. Please try again."; +const COPIED_RESET_MS = 2000; +const QR_CODE_SIZE = 176; +const WHITESPACE_REGEX = /\s+/g; +const SECRET_GROUP_REGEX = /.{1,4}/g; + +function formatSecret(secret: string) { + const compact = secret.replace(WHITESPACE_REGEX, ""); + return compact.match(SECRET_GROUP_REGEX)?.join(" ") ?? compact; +} + +function CopyValueField({ + label, + value, + display, +}: CopyValueFieldProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setCopied(false); + }, COPIED_RESET_MS); + } catch { + setCopied(false); + } + } + + return ( +
+

{label}

+
+ + {display ?? value} + + +
+
+ ); +} + +function StepActions({ secondary, children }: StepActionsProps) { + return ( +
+
{secondary}
+
{children}
+
+ ); +} + +export function TotpEnrollmentPanel({ + qrCode, + secret, + otpauthUri, + accountLabel, + submitLabel = "Turn on two-factor", + cancelLabel = "Cancel", + doneLabel = "Done", + onSubmit, + onCancel, + onDone, +}: TotpEnrollmentPanelProps) { + const [step, setStep] = useState("scan"); + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const [backupCodes, setBackupCodes] = useState(null); + const isMountedRef = useRef(true); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + async function handleSubmit(submittedCode: string) { + if (submittedCode.length !== TOTP_CODE_LENGTH || isPending) { + return; + } + + setError(null); + setIsPending(true); + + let result: TotpVerifyResult; + try { + result = await onSubmit({ code: submittedCode }); + } catch { + result = { ok: false, message: ENROLLMENT_ERROR_FALLBACK }; + } + if (!isMountedRef.current) { + return; + } + setIsPending(false); + + if (!result.ok) { + setError(result.message || ENROLLMENT_ERROR_FALLBACK); + setCode(""); + return; + } + + if (result.backupCodes && result.backupCodes.length > 0) { + setBackupCodes(result.backupCodes); + setStep("backup"); + return; + } + + onDone?.(); + } + + const isFirstStep = step === "scan"; + const secondaryButton = + isFirstStep && !onCancel ? null : ( + + ); + + const qrAltText = accountLabel + ? `QR code to add ${accountLabel} to an authenticator app` + : "QR code to add this account to an authenticator app"; + + let content: React.ReactNode; + + if (step === "backup" && backupCodes) { + content = ( + + ); + } else if (step === "code") { + content = ( +
{ + event.preventDefault(); + handleSubmit(code); + }} + > + + + {secondaryButton} + + + + ); + } else if (step === "manual") { + content = ( +
+

+ In your authenticator app, add an account with this key and + time-based codes. +

+ + + setStep("scan")} + type="button" + variant="link" + > + Scan QR code instead + + } + > + {secondaryButton} + + +
+ ); + } else { + content = ( +
+

+ Scan this with 1Password, Google Authenticator, or Authy. +

+
+ {qrAltText} +
+ setStep("manual")} + type="button" + variant="link" + > + Can't scan it? + + } + > + {secondaryButton} + + +
+ ); + } + + return {content}; +} diff --git a/packages/ui/src/components/shared/security/backup-codes-panel.tsx b/packages/ui/src/components/shared/security/backup-codes-panel.tsx new file mode 100644 index 000000000..e1f9e82e0 --- /dev/null +++ b/packages/ui/src/components/shared/security/backup-codes-panel.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { + CheckmarkCircle02Icon, + Copy01Icon, + Download01Icon, + PrinterIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@notra/ui/lib/utils"; +import type { BackupCodesPanelProps } from "../../../types/security"; +import { Button } from "../../ui/button"; + +const COPIED_RESET_MS = 2000; +const FILE_NAME = "backup-codes.txt"; + +function buildExportText( + codes: string[], + issuer: string, + accountLabel?: string +) { + const header = [ + `${issuer} backup codes`, + accountLabel ? `Account: ${accountLabel}` : null, + `Generated: ${new Date().toISOString()}`, + "", + "Each code can be used once if you lose access to your authenticator app.", + "", + ] + .filter((line) => line !== null) + .join("\n"); + return `${header}${codes.join("\n")}\n`; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +export function BackupCodesPanel({ + codes, + issuer = "Notra", + accountLabel, + doneLabel = "Done", + onDone, + className, +}: BackupCodesPanelProps) { + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const copiedTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + }; + }, []); + + const exportText = () => buildExportText(codes, issuer, accountLabel); + + async function copyCodes() { + try { + await navigator.clipboard.writeText(codes.join("\n")); + setCopied(true); + setError(null); + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + } + copiedTimeoutRef.current = setTimeout(() => { + setCopied(false); + }, COPIED_RESET_MS); + } catch { + setError("Couldn't copy. Select the codes and copy them manually."); + } + } + + function downloadCodes() { + const blob = new Blob([exportText()], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${issuer.toLowerCase()}-${FILE_NAME}`; + anchor.click(); + URL.revokeObjectURL(url); + } + + function printCodes() { + const printWindow = window.open("", "_blank", "width=480,height=640"); + if (!printWindow) { + setError("Your browser blocked the print window."); + return; + } + const rows = codes + .map((code) => `
  • ${escapeHtml(code)}
  • `) + .join(""); + printWindow.document.write( + `${escapeHtml(issuer)} backup codes

    ${escapeHtml(issuer)} backup codes

    ${accountLabel ? `${escapeHtml(accountLabel)} · ` : ""}Each code works once.

      ${rows}
    ` + ); + printWindow.document.close(); + printWindow.focus(); + printWindow.print(); + } + + return ( +
    +
    +

    Save your backup codes

    +

    + Each works once if you lose your device. They won't be shown + again. +

    +
    + +
      + {codes.map((code) => ( +
    • + {code} +
    • + ))} +
    + +
    + + + +
    + + {error &&

    {error}

    } + + {onDone && ( +
    + +
    + )} +
    + ); +} diff --git a/packages/ui/src/components/shared/security/format-security-date.ts b/packages/ui/src/components/shared/security/format-security-date.ts new file mode 100644 index 000000000..0415624a0 --- /dev/null +++ b/packages/ui/src/components/shared/security/format-security-date.ts @@ -0,0 +1,12 @@ +import { format, isValid } from "date-fns"; + +export function formatSecurityDate(value: string | null | undefined) { + if (!value) { + return null; + } + const date = new Date(value); + if (!isValid(date)) { + return null; + } + return format(date, "MMM d, yyyy"); +} diff --git a/packages/ui/src/components/shared/security/second-factor-confirm.tsx b/packages/ui/src/components/shared/security/second-factor-confirm.tsx new file mode 100644 index 000000000..4072a9614 --- /dev/null +++ b/packages/ui/src/components/shared/security/second-factor-confirm.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Loader2Icon } from "lucide-react"; +import { useId, useState } from "react"; + +import type { SecondFactorConfirmProps } from "../../../types/security"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Label } from "../../ui/label"; + +const CONFIRM_ERROR_FALLBACK = "That code didn't work. Please try again."; + +export function SecondFactorConfirm({ + title, + description, + confirmLabel, + destructive = false, + onConfirm, + onCancel, +}: SecondFactorConfirmProps) { + const inputId = useId(); + const errorId = `${inputId}-error`; + const [code, setCode] = useState(""); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + + async function submit() { + if (isPending || code.trim().length === 0) { + return; + } + setError(null); + setIsPending(true); + const result = await onConfirm(code).catch(() => ({ + ok: false as const, + message: CONFIRM_ERROR_FALLBACK, + })); + setIsPending(false); + if (!result.ok) { + setError(result.message || CONFIRM_ERROR_FALLBACK); + setCode(""); + } + } + + return ( +
    { + event.preventDefault(); + submit(); + }} + > +
    +

    {title}

    +

    {description}

    +
    +
    + + setCode(event.target.value)} + placeholder="123456 or xxxx-xxxx" + spellCheck={false} + value={code} + /> + {error && ( + + )} +
    +
    + + +
    +
    + ); +} diff --git a/packages/ui/src/components/shared/security/security-load-error.tsx b/packages/ui/src/components/shared/security/security-load-error.tsx new file mode 100644 index 000000000..267960bdd --- /dev/null +++ b/packages/ui/src/components/shared/security/security-load-error.tsx @@ -0,0 +1,15 @@ +import type { SecurityLoadErrorProps } from "../../../types/security"; +import { Button } from "../../ui/button"; + +export function SecurityLoadError({ message, onRetry }: SecurityLoadErrorProps) { + return ( +
    +

    {message}

    + {onRetry && ( + + )} +
    + ); +} diff --git a/packages/ui/src/components/shared/security/security-method-row.tsx b/packages/ui/src/components/shared/security/security-method-row.tsx new file mode 100644 index 000000000..e9cf65b44 --- /dev/null +++ b/packages/ui/src/components/shared/security/security-method-row.tsx @@ -0,0 +1,35 @@ +import { HugeiconsIcon } from "@hugeicons/react"; + +import { cn } from "@notra/ui/lib/utils"; +import type { SecurityMethodRowProps } from "../../../types/security"; + +export function SecurityMethodRow({ + icon, + title, + description, + action, + children, + className, +}: SecurityMethodRowProps) { + return ( +
    +
    +
    +
    + +
    +
    +
    {title}
    + {description && ( +

    {description}

    + )} +
    +
    + {action && ( +
    {action}
    + )} +
    + {children &&
    {children}
    } +
    + ); +} diff --git a/packages/ui/src/components/shared/security/step-transition.tsx b/packages/ui/src/components/shared/security/step-transition.tsx new file mode 100644 index 000000000..5c3471a6b --- /dev/null +++ b/packages/ui/src/components/shared/security/step-transition.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { + AnimatePresence, + domAnimation, + LazyMotion, + m, + useReducedMotion, +} from "motion/react"; + +import { TRANSITION } from "@notra/ui/lib/motion"; +import type { StepTransitionProps } from "../../../types/security"; + +const ENTER_OFFSET = 6; + +export function StepTransition({ + stepKey, + children, + className, +}: StepTransitionProps) { + const reduceMotion = useReducedMotion(); + const offset = reduceMotion ? 0 : ENTER_OFFSET; + + return ( + + + + {children} + + + + ); +} diff --git a/packages/ui/src/components/shared/security/two-factor-settings.tsx b/packages/ui/src/components/shared/security/two-factor-settings.tsx new file mode 100644 index 000000000..6c38fd428 --- /dev/null +++ b/packages/ui/src/components/shared/security/two-factor-settings.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { + Add01Icon, + ArrowReloadHorizontalIcon, + Delete02Icon, + SmartPhone01Icon, + SquareLockPasswordIcon, + TwoFactorAccessIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Loader2Icon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import type { + BackupCodesRowProps, + FactorListProps, + TwoFactorSettingsProps, +} from "../../../types/security"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Skeleton } from "../../ui/skeleton"; +import { TotpEnrollmentPanel } from "../auth/totp-enrollment-panel"; +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "../responsive-dialog"; +import { BackupCodesPanel } from "./backup-codes-panel"; +import { SecondFactorConfirm } from "./second-factor-confirm"; +import { StepTransition } from "./step-transition"; +import { formatSecurityDate } from "./format-security-date"; +import { SecurityLoadError } from "./security-load-error"; +import { SecurityMethodRow } from "./security-method-row"; + +function BackupCodesRow({ + remaining, + accountLabel, + onRegenerate, +}: BackupCodesRowProps) { + const [isConfirming, setIsConfirming] = useState(false); + const [codes, setCodes] = useState(null); + + async function regenerate(confirmationCode: string) { + const result = await onRegenerate(confirmationCode); + if (!result.ok) { + return result; + } + setIsConfirming(false); + setCodes(result.codes); + return { ok: true as const }; + } + + let description = "One-time codes for when your device isn't around."; + if (typeof remaining === "number" && remaining > 0) { + description = `${remaining} unused ${remaining === 1 ? "code" : "codes"} left.`; + } else if (remaining === 0) { + description = "All codes used. Generate a new set."; + } + + let body: ReactNode = null; + let bodyKey = "empty"; + if (codes) { + bodyKey = "codes"; + body = ( + setCodes(null)} + /> + ); + } else if (isConfirming) { + bodyKey = "confirm"; + body = ( + setIsConfirming(false)} + onConfirm={regenerate} + title="Regenerate backup codes?" + /> + ); + } + + return ( + setIsConfirming(true)} + size="sm" + type="button" + variant="outline" + > + + Regenerate + + ) + } + description={description} + icon={SquareLockPasswordIcon} + title="Backup codes" + > + {body && {body}} + + ); +} + +function FactorList({ + factors, + removingFactorId, + onRemoveFactor, +}: FactorListProps) { + const [confirmingFactorId, setConfirmingFactorId] = useState( + null + ); + + async function remove(factorId: string, confirmationCode: string) { + const result = await onRemoveFactor(factorId, confirmationCode); + if (result.ok) { + setConfirmingFactorId(null); + } + return result; + } + + return ( +
      + {factors.map((factor) => { + const addedOn = formatSecurityDate(factor.createdAt); + const isRemoving = removingFactorId === factor.id; + const isConfirming = confirmingFactorId === factor.id; + const factorName = factor.issuer ?? "Authenticator app"; + return ( +
    • +
      +
      + +
      +

      {factorName}

      + {addedOn && ( +

      + Added {addedOn} +

      + )} +
      +
      + +
      + {isConfirming && ( + setConfirmingFactorId(null)} + onConfirm={(code) => remove(factor.id, code)} + title="Turn off two-factor authentication?" + /> + )} +
    • + ); + })} +
    + ); +} + +export function TwoFactorSettings({ + factors, + status, + enrollment, + isStartingEnrollment, + removingFactorId, + accountLabel, + onStartEnrollment, + onVerifyEnrollment, + onCancelEnrollment, + onEnrollmentDone, + onRemoveFactor, + onRetry, + backupCodesRemaining, + onRegenerateBackupCodes, +}: TwoFactorSettingsProps) { + if (status === "loading") { + return ; + } + + if (status === "error") { + return ( + + ); + } + + const isEnabled = factors.length > 0; + + const body: ReactNode = isEnabled ? ( + + ) : null; + + const action = + isEnabled ? null : ( + + ); + + const bodyKey = isEnabled ? "factors" : "empty"; + + return ( +
    + { + if (!open) { + onCancelEnrollment(); + } + }} + open={enrollment !== null} + > + + + Set up two-factor authentication + + Adds a code check whenever you sign in with your password. + + + {enrollment && ( + + )} + + + + Authenticator app + {isEnabled && On} + + } + > + {body && {body}} + + {isEnabled && ( + + )} +
    + ); +} diff --git a/packages/ui/src/components/ui/input-otp.tsx b/packages/ui/src/components/ui/input-otp.tsx new file mode 100644 index 000000000..c02c12382 --- /dev/null +++ b/packages/ui/src/components/ui/input-otp.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { OTPField } from "@base-ui/react/otp-field"; +import type * as React from "react"; + +import { cn } from "@notra/ui/lib/utils"; + +function InputOTP({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function InputOTPSlot({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot }; diff --git a/packages/ui/src/hooks/use-auth-flow.ts b/packages/ui/src/hooks/use-auth-flow.ts new file mode 100644 index 000000000..a9b0ebca7 --- /dev/null +++ b/packages/ui/src/hooks/use-auth-flow.ts @@ -0,0 +1,43 @@ +"use client"; + +import type { PendingAuthStep } from "@notra/schemas/types/dashboard/auth"; +import { useState } from "react"; + +import type { ApplyAuthResult, UseAuthFlowOptions } from "../types/auth"; + +export function useAuthFlow({ initialPending, onSuccess }: UseAuthFlowOptions) { + const [pending, setPending] = useState( + initialPending ?? null + ); + + function finish(redirectTo: string) { + if (onSuccess) { + onSuccess(); + } else { + window.location.assign(redirectTo); + } + } + + const applyResult: ApplyAuthResult = (result) => { + switch (result.status) { + case "success": + case "enrolled": + finish(result.redirectTo); + return true; + case "verification-required": + case "mfa-required": + case "mfa-enrollment-required": + setPending(result); + return true; + default: + return false; + } + }; + + return { + pending, + applyResult, + finish, + reset: () => setPending(null), + }; +} diff --git a/packages/ui/src/lib/auth-types.ts b/packages/ui/src/lib/auth-types.ts deleted file mode 100644 index fa69e687b..000000000 --- a/packages/ui/src/lib/auth-types.ts +++ /dev/null @@ -1,121 +0,0 @@ -export type SocialProvider = "google" | "github"; - -export type AuthMethod = "email" | "google" | "github"; - -export interface PendingVerification { - pendingAuthenticationToken: string; - email: string; -} - -export interface SignInWithPasswordInput { - email: string; - password: string; - returnTo?: string | null; -} - -export interface VerifyEmailCodeInput { - pendingAuthenticationToken: string; - code: string; - returnTo?: string | null; -} - -export interface StartSocialSignInInput { - provider: string; - returnTo?: string | null; -} - -export interface AuthFlowSuccess { - status: "success"; - redirectTo: string; -} - -export interface AuthFlowVerificationRequired { - status: "verification-required"; - pendingAuthenticationToken: string; - email: string; -} - -export interface AuthFlowError { - status: "error"; - message: string; -} - -export type AuthFlowResult = - | AuthFlowSuccess - | AuthFlowVerificationRequired - | AuthFlowError; - -export interface AuthFormHeaderProps { - title?: string; - description?: string; -} - -export interface AuthSocialButtonsProps { - authMethod: AuthMethod | null; - disabled: boolean; - lastMethod?: string | null; - onSelect: (provider: SocialProvider) => void; -} - -export interface AuthFieldErrorProps { - id: string; - error?: string; -} - -export interface AuthFormErrorProps { - error: string | null; - className?: string; -} - -export interface AuthEmailFieldProps { - id: string; - label: string; - value: string; - error?: string; - disabled: boolean; - placeholder: string; - onBlur: () => void; - onChange: (value: string) => void; -} - -export interface AuthPasswordFieldProps { - id: string; - value: string; - error?: string; - disabled: boolean; - placeholder: string; - autoComplete: string; - onBlur: () => void; - onChange: (value: string) => void; -} - -export interface EmailVerificationFormProps { - pendingAuthenticationToken: string; - email: string; - returnTo?: string | null; - onSuccess?: () => void; - verifyEmailCode: (input: VerifyEmailCodeInput) => Promise; -} - -export interface LoginFieldValidators { - email: (value: string) => string | undefined; - password: (value: string) => string | undefined; -} - -export interface LoginFormProps { - title?: string; - description?: string; - onSuccess?: () => void; - returnTo?: string; - showSignupLink?: boolean; - showForgotPasswordLink?: boolean; - initialError?: string; - initialPendingVerification?: PendingVerification; - callbackPath: string; - validators: LoginFieldValidators; - signInWithPassword: ( - input: SignInWithPasswordInput - ) => Promise; - verifyEmailCode: (input: VerifyEmailCodeInput) => Promise; - startSocialSignIn: (input: StartSocialSignInInput) => Promise; -} diff --git a/packages/ui/src/types/auth.ts b/packages/ui/src/types/auth.ts new file mode 100644 index 000000000..023ac10dc --- /dev/null +++ b/packages/ui/src/types/auth.ts @@ -0,0 +1,195 @@ +import type { + AuthFlowMfaEnrollmentRequired, + AuthFlowMfaRequired, + AuthFlowResult, + AuthFlowVerificationRequired, + PendingAuthStep, + RedeemBackupCodeInput, + RedeemBackupCodeResult, + SignInWithPasswordInput, + StartSocialSignInInput, + VerifyEmailCodeInput, + VerifyMfaCodeInput, +} from "@notra/schemas/types/dashboard/auth"; + +import type { ReactNode } from "react"; + +export type SocialProvider = "google" | "github"; +export type AuthMethod = "email" | SocialProvider; +export type ChallengeMode = "totp" | "backup"; +export type EnrollmentStep = "scan" | "manual" | "code" | "backup"; + +export interface MfaSubmitButtonProps { + isPending: boolean; + disabled: boolean; + pendingLabel: string; + label: string; +} + +export interface CopyValueFieldProps { + label: string; + value: string; + display?: string; +} + +export interface StepActionsProps { + secondary?: ReactNode; + children: ReactNode; +} + +export type SignInWithPassword = ( + input: SignInWithPasswordInput +) => Promise; +export type VerifyEmailCode = ( + input: VerifyEmailCodeInput +) => Promise; +export type VerifyMfaCode = (input: VerifyMfaCodeInput) => Promise; +export type RedeemBackupCode = ( + input: RedeemBackupCodeInput +) => Promise; +export type StartSocialSignIn = (input: StartSocialSignInInput) => Promise; +export type ApplyAuthResult = (result: AuthFlowResult) => boolean; + +export interface TotpEnrollmentSubmission { + code: string; +} + +export type TotpVerifyResult = + | { ok: true; backupCodes?: string[] } + | { ok: false; message: string }; + +export interface UseAuthFlowOptions { + initialPending?: PendingAuthStep; + onSuccess?: () => void; +} + +export interface AuthFormHeaderProps { + title?: string; + description?: string; +} + +export interface AuthSocialButtonsProps { + authMethod: AuthMethod | null; + disabled: boolean; + lastMethod?: string | null; + onSelect: (provider: SocialProvider) => void; +} + +export interface AuthFieldErrorProps { + id: string; + error?: string; +} + +export interface AuthFormErrorProps { + error: string | null; + className?: string; +} + +export interface AuthEmailFieldProps { + id: string; + label: string; + value: string; + error?: string; + disabled: boolean; + placeholder: string; + onBlur: () => void; + onChange: (value: string) => void; +} + +export interface AuthPasswordFieldProps { + id: string; + value: string; + error?: string; + disabled: boolean; + placeholder: string; + autoComplete: string; + onBlur: () => void; + onChange: (value: string) => void; +} + +export interface TotpCodeInputProps { + id: string; + value: string; + onChange: (value: string) => void; + onComplete?: (value: string) => void; + label?: string; + error?: string | null; + disabled?: boolean; + autoFocus?: boolean; + className?: string; +} + +export interface EmailVerificationFormProps { + step: AuthFlowVerificationRequired; + returnTo?: string | null; + onResult: ApplyAuthResult; + verifyEmailCode: VerifyEmailCode; +} + +export interface MfaChallengeFormProps { + step: AuthFlowMfaRequired; + returnTo?: string | null; + onResult: ApplyAuthResult; + onFinish: (redirectTo: string) => void; + onBack?: () => void; + onRecovered: (email: string) => void; + verifyMfaCode: VerifyMfaCode; + redeemBackupCode: RedeemBackupCode; +} + +export interface MfaEnrollmentFormProps { + step: AuthFlowMfaEnrollmentRequired; + returnTo?: string | null; + onResult: ApplyAuthResult; + onFinish: (redirectTo: string) => void; + onBack?: () => void; + verifyMfaCode: VerifyMfaCode; +} + +export interface TotpEnrollmentPanelProps { + qrCode: string; + secret: string; + otpauthUri: string; + accountLabel?: string; + submitLabel?: string; + cancelLabel?: string; + doneLabel?: string; + onSubmit: (submission: TotpEnrollmentSubmission) => Promise; + onCancel?: () => void; + onDone?: () => void; +} + +export interface AuthPendingStepProps { + step: PendingAuthStep; + returnTo?: string | null; + onResult: ApplyAuthResult; + onFinish: (redirectTo: string) => void; + onBack: () => void; + onRecovered: (email: string) => void; + verifyEmailCode: VerifyEmailCode; + verifyMfaCode: VerifyMfaCode; + redeemBackupCode: RedeemBackupCode; +} + +export interface LoginFieldValidators { + email: (value: string) => string | undefined; + password: (value: string) => string | undefined; +} + +export interface LoginFormProps { + title?: string; + description?: string; + onSuccess?: () => void; + returnTo?: string; + showSignupLink?: boolean; + showForgotPasswordLink?: boolean; + initialError?: string; + initialPending?: PendingAuthStep; + callbackPath: string; + validators: LoginFieldValidators; + signInWithPassword: SignInWithPassword; + verifyEmailCode: VerifyEmailCode; + verifyMfaCode: VerifyMfaCode; + redeemBackupCode: RedeemBackupCode; + startSocialSignIn: StartSocialSignIn; +} diff --git a/packages/ui/src/types/security.ts b/packages/ui/src/types/security.ts new file mode 100644 index 000000000..0233d997c --- /dev/null +++ b/packages/ui/src/types/security.ts @@ -0,0 +1,95 @@ +import type { IconSvgElement } from "@hugeicons/react"; +import type { + TotpEnrollmentSecrets, + TotpFactorSummary, +} from "@notra/schemas/types/dashboard/auth"; +import type { ReactNode } from "react"; + +import type { TotpEnrollmentSubmission, TotpVerifyResult } from "./auth"; + +export type SecurityLoadStatus = "loading" | "ready" | "error"; + +export type BackupCodesOutcome = + | { ok: true; codes: string[] } + | { ok: false; message: string }; + +export type SecurityActionOutcome = + | { ok: true } + | { ok: false; message: string }; + +export interface SecondFactorConfirmProps { + title: string; + description: string; + confirmLabel: string; + destructive?: boolean; + onConfirm: (code: string) => Promise; + onCancel: () => void; +} + +export interface BackupCodesPanelProps { + codes: string[]; + issuer?: string; + accountLabel?: string; + doneLabel?: string; + onDone?: () => void; + className?: string; +} + +export interface SecurityMethodRowProps { + icon: IconSvgElement; + title: ReactNode; + description?: ReactNode; + action?: ReactNode; + children?: ReactNode; + className?: string; +} + +export interface SecurityLoadErrorProps { + message: string; + onRetry?: () => void; +} + +export interface StepTransitionProps { + stepKey: string; + children: ReactNode; + className?: string; +} + +export interface BackupCodesRowProps { + remaining: number | null; + accountLabel?: string; + onRegenerate: (confirmationCode: string) => Promise; +} + +export interface FactorListProps { + factors: TotpFactorSummary[]; + removingFactorId: string | null; + onRemoveFactor: ( + factorId: string, + confirmationCode: string + ) => Promise; +} + +export interface TwoFactorSettingsProps { + factors: TotpFactorSummary[]; + status: SecurityLoadStatus; + enrollment: TotpEnrollmentSecrets | null; + isStartingEnrollment: boolean; + removingFactorId: string | null; + backupCodesRemaining: number | null; + accountLabel?: string; + onStartEnrollment: () => void; + onVerifyEnrollment: ( + submission: TotpEnrollmentSubmission + ) => Promise; + onCancelEnrollment: () => void; + onEnrollmentDone: () => void; + onRemoveFactor: ( + factorId: string, + confirmationCode: string + ) => Promise; + onRegenerateBackupCodes: ( + confirmationCode: string + ) => Promise; + onRetry?: () => void; +}