Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 19 additions & 146 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@eslint/compat": "^2.0.4",
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.16.0",
"@lucide/svelte": "^1.17.0",
"@neondatabase/serverless": "^1.0.2",
"@sveltejs/adapter-vercel": "^6.3.3",
"@sveltejs/kit": "^2.57.0",
Expand Down Expand Up @@ -61,7 +61,8 @@
"@anthropic-ai/sdk": "^0.98.0",
"@browserbasehq/sdk": "^2.12.0",
"@sentry/sveltekit": "^10",
"clerk-sveltekit": "^0.4.3",
"@workos/authkit-sveltekit": "^0.3.0",
"jose": "^6.2.3",
"octokit": "^5.0.5",
"openai": "^6.39.0",
"pg": "^8.21.0",
Expand Down
10 changes: 2 additions & 8 deletions src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@ declare global {
namespace App {
// interface Error {}
interface Locals {
session?: {
userId: string;
claims?: unknown;
};
auth?: {
userId: string;
claims?: unknown;
};
auth: import('@workos/authkit-sveltekit').AuthKitAuth;
bearerUserId?: string;
}
// interface PageData {}
// interface PageState {}
Expand Down
9 changes: 0 additions & 9 deletions src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/public';
import { initializeClerkClient } from 'clerk-sveltekit/client';
import posthog from 'posthog-js';
import { PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public';
import type { HandleClientError } from '@sveltejs/kit';
Expand All @@ -17,13 +15,6 @@ Sentry.init({
sendDefaultPii: true
});

initializeClerkClient(env.PUBLIC_CLERK_PUBLISHABLE_KEY ?? '', {
afterSignInUrl: '/runs',
afterSignUpUrl: '/runs',
signInUrl: '/sign-in',
signUpUrl: '/sign-up'
});

export async function init() {
posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, {
api_host: '/ingest',
Expand Down
19 changes: 11 additions & 8 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { sequence } from '@sveltejs/kit/hooks';
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/private';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { handleClerk } from 'clerk-sveltekit/server';
import { authKitHandle, configureAuthKit } from '@workos/authkit-sveltekit';
import { authenticateBearer, guardProtectedPath } from '$lib/server/auth';
import { getPostHogClient } from '$lib/server/posthog';

const clerk = handleClerk(env.CLERK_SECRET_KEY ?? '', {
protectedPaths: []
configureAuthKit({
clientId: env.WORKOS_CLIENT_ID ?? '',
apiKey: env.WORKOS_API_KEY ?? '',
redirectUri: env.WORKOS_REDIRECT_URI ?? '',
cookiePassword: env.WORKOS_COOKIE_PASSWORD ?? ''
});
Comment on lines +9 to 14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 All four configureAuthKit parameters fall back to an empty string when the corresponding env var is absent. A missing cookiePassword in particular will cause iron-webcrypto to throw a crypto error at request time (it requires a password of at least 32 characters), making the failure happen silently on the first authenticated request rather than at startup. Asserting these values are present at boot makes misconfiguration immediately visible.

Suggested change
configureAuthKit({
clientId: env.WORKOS_CLIENT_ID ?? '',
apiKey: env.WORKOS_API_KEY ?? '',
redirectUri: env.WORKOS_REDIRECT_URI ?? '',
cookiePassword: env.WORKOS_COOKIE_PASSWORD ?? ''
});
const missingVars = ['WORKOS_CLIENT_ID', 'WORKOS_API_KEY', 'WORKOS_REDIRECT_URI', 'WORKOS_COOKIE_PASSWORD'].filter(
(key) => !env[key as keyof typeof env]
);
if (missingVars.length > 0) {
throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`);
}
configureAuthKit({
clientId: env.WORKOS_CLIENT_ID!,
apiKey: env.WORKOS_API_KEY!,
redirectUri: env.WORKOS_REDIRECT_URI!,
cookiePassword: env.WORKOS_COOKIE_PASSWORD!
});

Fix in Cursor Fix in Codex


const ingestProxy: Handle = async ({ event, resolve }) => {
Expand Down Expand Up @@ -48,12 +51,12 @@ export const handle: Handle = sequence(
Sentry.sentryHandle(),
ingestProxy,
async ({ event, resolve }) => {
const response = await clerk({
const response = await authKitHandle()({
event,
resolve: async (clerkEvent) => {
await authenticateBearer(clerkEvent);
guardProtectedPath(clerkEvent);
return resolve(clerkEvent);
resolve: async (authEvent) => {
await authenticateBearer(authEvent);
guardProtectedPath(authEvent);
return resolve(authEvent);
}
});
return response;
Expand Down
13 changes: 10 additions & 3 deletions src/lib/components/app-shell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
import { Activity, GitBranch, Menu, X } from '@lucide/svelte';
import AuthCorner from '$lib/components/auth-corner.svelte';

let { children, userId = null }: { children: import('svelte').Snippet; userId?: string | null } =
$props();
let {
children,
userId = null,
email = null
}: {
children: import('svelte').Snippet;
userId?: string | null;
email?: string | null;
} = $props();
let mobileOpen = $state(false);

const nav = [
Expand Down Expand Up @@ -87,7 +94,7 @@
</nav>

<div class="mt-auto border-t border-border/60 px-4 py-3">
<AuthCorner {userId} />
<AuthCorner {userId} {email} />
</div>
</aside>

Expand Down
60 changes: 22 additions & 38 deletions src/lib/components/auth-corner.svelte
Original file line number Diff line number Diff line change
@@ -1,46 +1,30 @@
<script lang="ts">
import SignedIn from 'clerk-sveltekit/client/SignedIn.svelte';
import SignedOut from 'clerk-sveltekit/client/SignedOut.svelte';
import UserButton from 'clerk-sveltekit/client/UserButton.svelte';
import ClerkLoaded from 'clerk-sveltekit/client/ClerkLoaded.svelte';
import ClerkLoading from 'clerk-sveltekit/client/ClerkLoading.svelte';
import DotMatrixLoader from '$lib/components/dotmatrix-loader.svelte';
import { resolve } from '$app/paths';

let { userId = null }: { userId?: string | null } = $props();

const loadingLabel = $derived(userId ? 'Loading account' : 'Loading sign in status');
let { userId = null, email = null }: { userId?: string | null; email?: string | null } = $props();
</script>

<div class="flex shrink-0 items-center">
<ClerkLoading>
<div
class="flex items-center justify-center {userId ? 'size-8' : 'h-8 w-16'}"
aria-hidden="true"
>
<DotMatrixLoader variant="inline" ariaLabel={loadingLabel} />
{#if userId}
<div class="flex min-w-0 flex-col items-end gap-1.5">
{#if email}
<span class="max-w-full truncate font-mono text-[10px] text-muted-foreground">{email}</span>
{/if}
<form method="POST" action={resolve('/sign-out')}>
<button
type="submit"
class="rounded-md border border-border bg-card px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:bg-secondary"
>
Sign out
</button>
</form>
</div>
<span class="sr-only">{loadingLabel}</span>
</ClerkLoading>
<ClerkLoaded>
<SignedIn>
<UserButton
afterSignOutUrl="/sign-in"
appearance={{
elements: {
rootBox: 'flex items-center',
avatarBox: 'h-8 w-8 rounded-md ring-1 ring-border'
}
}}
/>
</SignedIn>
<SignedOut>
<a
class="rounded-md border border-border bg-card px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:bg-secondary"
href={resolve('/sign-in')}
>
Sign in
</a>
</SignedOut>
</ClerkLoaded>
{:else}
<a
class="rounded-md border border-border bg-card px-3 py-1.5 font-mono text-xs text-foreground transition-colors hover:bg-secondary"
href={resolve('/sign-in')}
>
Sign in
</a>
{/if}
</div>
27 changes: 19 additions & 8 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { env } from '$env/dynamic/private';
import { verifyToken } from '@clerk/backend';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { readPostAuthRedirectUrl as readPostAuthRedirectUrlFromSearchParams } from '$lib/auth-redirect';
import { error, redirect, type RequestEvent } from '@sveltejs/kit';

export { safeRedirectPath } from '$lib/auth-redirect';

let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;

function getJwks(clientId: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL(`https://api.workos.com/sso/jwks/${clientId}`));
}
return jwks;
}

export function readPostAuthRedirectUrl(event: RequestEvent, fallback?: `/${string}`) {
return readPostAuthRedirectUrlFromSearchParams(event.url.searchParams, fallback);
}

export function readUserId(event: RequestEvent) {
return event.locals.auth?.userId ?? event.locals.session?.userId;
return event.locals.auth?.user?.id ?? event.locals.bearerUserId ?? undefined;
}

export function requireUserId(event: RequestEvent) {
Expand All @@ -20,16 +29,18 @@ export function requireUserId(event: RequestEvent) {
}

export async function authenticateBearer(event: RequestEvent) {
if (event.locals.session || event.locals.auth?.userId) return;
if (event.locals.auth?.user?.id) return;
const header = event.request.headers.get('authorization');
const token = header?.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token || !env.CLERK_SECRET_KEY) return;
if (!token || !env.WORKOS_CLIENT_ID) return;
try {
const claims = await verifyToken(token, {
secretKey: env.CLERK_SECRET_KEY,
issuer: (issuer) => issuer.startsWith('https://clerk.') && issuer.includes('.clerk.accounts')
const { payload } = await jwtVerify(token, getJwks(env.WORKOS_CLIENT_ID), {
issuer: 'https://api.workos.com',
audience: env.WORKOS_CLIENT_ID
});
event.locals.auth = { userId: claims.sub, claims };
if (typeof payload.sub === 'string') {
event.locals.bearerUserId = payload.sub;
}
} catch {
// Invalid bearer tokens fall through to the normal protected-route 401.
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/(app)/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readUserId } from '$lib/server/auth';

export function load(event) {
return {
userId: readUserId(event)
userId: readUserId(event),
email: event.locals.auth?.user?.email ?? null
};
}
2 changes: 1 addition & 1 deletion src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
{#if data.userId}
<span class="sr-only">Signed in</span>
{/if}
<AppShell userId={data.userId}>
<AppShell userId={data.userId} email={data.email}>
<div class="relative min-h-full">
<RouteNavigationLoader scope="app" />
{@render children()}
Expand Down
7 changes: 5 additions & 2 deletions src/routes/(marketing)/sign-in/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { authKit } from '@workos/authkit-sveltekit';
import { readPostAuthRedirectUrl, readUserId } from '$lib/server/auth';

export function load(event) {
export async function load(event) {
if (readUserId(event)) {
throw redirect(303, readPostAuthRedirectUrl(event));
}
return {};
return {
signInUrl: await authKit.getSignInUrl({ returnTo: readPostAuthRedirectUrl(event) })
};
}
31 changes: 7 additions & 24 deletions src/routes/(marketing)/sign-in/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import AuthWidgetLoading from '$lib/components/auth-widget-loading.svelte';
import PostAuthRedirect from '$lib/components/post-auth-redirect.svelte';
import ClerkLoaded from 'clerk-sveltekit/client/ClerkLoaded.svelte';
import ClerkLoading from 'clerk-sveltekit/client/ClerkLoading.svelte';
import SignIn from 'clerk-sveltekit/client/SignIn.svelte';
import { safeRedirectPath } from '$lib/auth-redirect';

const redirectUrl = $derived(
safeRedirectPath(
page.url.searchParams.get('redirect_url') ??
page.url.searchParams.get('redirectUrl') ??
page.url.searchParams.get('redirectAfterAuth')
)
);
let { data }: { data: { signInUrl: string } } = $props();

onMount(() => {
window.location.href = data.signInUrl;
});
</script>

<svelte:head><title>Sign in | FlightLog</title></svelte:head>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The redirect to the WorkOS sign-in URL is wired only through onMount, so a user with JavaScript disabled will land on a loading spinner that never resolves. Adding a <noscript> meta-refresh provides a functional fallback without changing the JS path.

Suggested change
<svelte:head><title>Sign in | FlightLog</title></svelte:head>
<svelte:head>
<title>Sign in | FlightLog</title>
<noscript><meta http-equiv="refresh" content="0; url={data.signInUrl}" /></noscript>
</svelte:head>

Fix in Cursor Fix in Codex

Expand All @@ -26,16 +19,6 @@
</p>
<h1 class="mt-2 text-2xl font-semibold text-foreground">Sign in to FlightLog</h1>
</div>
<ClerkLoading>
<AuthWidgetLoading />
</ClerkLoading>
<ClerkLoaded let:clerk>
{#if clerk?.user}
<PostAuthRedirect to={redirectUrl} />
<AuthWidgetLoading />
{:else}
<SignIn {redirectUrl} signUpUrl="/sign-up" />
{/if}
</ClerkLoaded>
<AuthWidgetLoading />
</section>
</main>
11 changes: 7 additions & 4 deletions src/routes/(marketing)/sign-up/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { readPostAuthRedirectUrl, readUserId } from '$lib/server/auth';
import { authKit } from '@workos/authkit-sveltekit';
import { readUserId } from '$lib/server/auth';

export function load(event) {
export async function load(event) {
if (readUserId(event)) {
throw redirect(303, readPostAuthRedirectUrl(event));
throw redirect(303, '/runs');
}
return {};
return {
signUpUrl: await authKit.getSignUpUrl({ returnTo: '/runs' })
};
}
17 changes: 8 additions & 9 deletions src/routes/(marketing)/sign-up/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import AuthWidgetLoading from '$lib/components/auth-widget-loading.svelte';
import ClerkLoaded from 'clerk-sveltekit/client/ClerkLoaded.svelte';
import ClerkLoading from 'clerk-sveltekit/client/ClerkLoading.svelte';
import SignUp from 'clerk-sveltekit/client/SignUp.svelte';

let { data }: { data: { signUpUrl: string } } = $props();

onMount(() => {
window.location.href = data.signUpUrl;
});
</script>

<svelte:head><title>Sign up | FlightLog</title></svelte:head>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same JS-only redirect issue as sign-in/+page.svelte — without a <noscript> fallback, users without JavaScript see an infinite loading state.

Suggested change
<svelte:head><title>Sign up | FlightLog</title></svelte:head>
<svelte:head>
<title>Sign up | FlightLog</title>
<noscript><meta http-equiv="refresh" content="0; url={data.signUpUrl}" /></noscript>
</svelte:head>

Fix in Cursor Fix in Codex

Expand All @@ -15,11 +19,6 @@
</p>
<h1 class="mt-2 text-2xl font-semibold text-foreground">Create your FlightLog account</h1>
</div>
<ClerkLoading>
<AuthWidgetLoading />
</ClerkLoading>
<ClerkLoaded>
<SignUp redirectUrl="/runs" signInUrl="/sign-in" />
</ClerkLoaded>
<AuthWidgetLoading />
</section>
</main>
7 changes: 7 additions & 0 deletions src/routes/callback/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { authKit } from '@workos/authkit-sveltekit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async (event) => {
const handler = authKit.handleCallback();
return handler(event);
};
6 changes: 6 additions & 0 deletions src/routes/sign-out/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { authKit } from '@workos/authkit-sveltekit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async (event) => {
return authKit.signOut(event);
};
Loading