@@ -1900,10 +2014,18 @@ export function ScheduleHostRoute(handle: Handle) {
const appOrigin =
typeof window === 'undefined' ? '' : window.location.origin
const attendeePath = `/s/${encodeURIComponent(shareToken)}`
- const hostPath = `${attendeePath}/${encodeURIComponent(hostAccessToken)}`
+ const hostPath = hostAccessToken
+ ? `${attendeePath}/${encodeURIComponent(hostAccessToken)}`
+ : null
const attendeeUrl = appOrigin ? `${appOrigin}${attendeePath}` : attendeePath
- const hostUrl = appOrigin ? `${appOrigin}${hostPath}` : hostPath
+ const hostUrl = hostPath ? (appOrigin ? `${appOrigin}${hostPath}` : hostPath) : null
const scheduleTitle = currentSnapshot?.schedule.title.trim() ?? ''
+ const ownerUserId = currentSnapshot?.schedule.ownerUserId ?? null
+ const isOwnedByCurrentSession =
+ ownerUserId !== null && authSession?.id === ownerUserId
+ const isClaimedByAnotherAccount =
+ ownerUserId !== null && authSession !== null && authSession.id !== ownerUserId
+ const loginRedirectPath = `/login?redirectTo=${encodeURIComponent(getPathWithSearch())}`
if (isLoading && !currentSnapshot) {
setDocumentTitle(toAppTitle('Loading host dashboard'))
@@ -2017,56 +2139,167 @@ export function ScheduleHostRoute(handle: Handle) {
-
-
- Host dashboard link
-
-
-
- {hostUrl}
-
-
- void copyValueToClipboard('Host link', hostUrl),
- }}
+ {hostUrl ? (
+
+
+ Host dashboard link
+
+
- {renderCopyIcon()}
-
+
+ {hostUrl}
+
+
+ void copyValueToClipboard('Host link', hostUrl),
+ }}
+ css={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 34,
+ height: 34,
+ padding: 0,
+ borderRadius: radius.sm,
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.surface,
+ color: colors.text,
+ cursor: 'pointer',
+ }}
+ >
+ {renderCopyIcon()}
+
+
-
+ ) : (
+
+
+ Private host-link fallback
+
+
+ You are using logged-in host access. Your original private
+ host link still works if you saved it.
+
+
+ )}
+
+
+
+ Returning host access
+
+ {accessMode === 'account' ? (
+
+ You are managing this schedule from your account
+ {authSession ? ` (${authSession.email})` : ''}. The private host
+ link still works as a fallback.
+
+ ) : isOwnedByCurrentSession ? (
+
+ This schedule is already saved to your account. Open it any time
+ from Your schedules or keep
+ using the private host link.
+
+ ) : isClaimedByAnotherAccount ? (
+
+ This private host link already belongs to a different account.
+ You can still use the link directly.
+
+ ) : authSession ? (
+
+
+ Signed in as {authSession.email}. Save this schedule so you can
+ reopen it from Your schedules.
+
+
+
void claimSchedule() }}
+ disabled={isClaimingSchedule}
+ css={{
+ padding: `${spacing.xs} ${spacing.md}`,
+ borderRadius: radius.sm,
+ border: `1px solid ${colors.border}`,
+ backgroundColor: colors.surface,
+ color: colors.text,
+ cursor: isClaimingSchedule ? 'wait' : 'pointer',
+ }}
+ >
+ {isClaimingSchedule
+ ? 'Saving to account…'
+ : 'Save this schedule to your account'}
+
+
Your schedules
+
+
+ ) : authSessionLoaded ? (
+
+
+ Want an easier return path? Sign in, then save this schedule to
+ your account without changing attendee access.
+
+
+
+ ) : (
+
+ Checking whether you are already signed in…
+
+ )}
+
+ {claimMessage ?? '\u00a0'}
+
{
+ const scheduleTitle = `Planning session ${Date.now()}`
+ await page.goto('/')
+ await page.getByLabel('Schedule title').fill(scheduleTitle)
+ await page.getByLabel('Your name').fill('Host Original')
+ await page.getByRole('button', { name: 'Create share link' }).click()
+ await expect(page).toHaveURL(/\/s\/[a-z0-9]+\/[a-z0-9]+$/i)
+
+ const { shareToken, hostAccessToken } = parseHostRouteTokens(page.url())
+ expect(shareToken).not.toBe('')
+ expect(hostAccessToken).not.toBe('')
+ if (!shareToken || !hostAccessToken) {
+ throw new Error('Expected private host route tokens after schedule creation.')
+ }
+
+ await expect(
+ page.getByRole('link', { name: 'Sign in to save this schedule' }),
+ ).toBeVisible()
+ await page.getByRole('link', { name: 'Sign in to save this schedule' }).click()
+ await expect(page).toHaveURL(/\/login/)
+
+ await page.getByLabel('Email address').fill('host@example.com')
+ await page.getByRole('button', { name: 'Create sign-in link' }).click()
+ const openLink = page.getByRole('link', { name: 'Open sign-in link' })
+ await expect(openLink).toBeVisible()
+ await openLink.click()
+
+ await expect(page).toHaveURL(
+ new RegExp(`/s/${shareToken}/${hostAccessToken}$`, 'i'),
+ )
+ await expect(
+ page.getByRole('button', { name: 'Save this schedule to your account' }),
+ ).toBeVisible()
+ await page
+ .getByRole('button', { name: 'Save this schedule to your account' })
+ .click()
+ await expect(
+ page.getByText('Saved to your account. You can reopen it from Your schedules.'),
+ ).toBeVisible()
+
+ await page
+ .locator('#main-content')
+ .getByRole('link', { name: 'Your schedules' })
+ .click()
+ await expect(page).toHaveURL('/account/schedules')
+ await expect(page.getByRole('heading', { name: 'Your schedules' })).toBeVisible()
+ await expect(page.getByText(scheduleTitle)).toBeVisible()
+
+ await page
+ .getByRole('article')
+ .filter({ hasText: scheduleTitle })
+ .getByRole('link', { name: 'Open dashboard' })
+ .click()
+ await expect(page).toHaveURL(`/account/schedules/${shareToken}`)
+ await expect(
+ page.getByText(/You are managing this schedule from your account/),
+ ).toBeVisible()
+
+ await page.getByLabel('Edit host name for Host Original').click()
+ const hostNameInput = page.getByLabel(
+ 'Submission name input for Host Original',
+ )
+ await expect(hostNameInput).toBeVisible()
+ await hostNameInput.fill('Host Account')
+ await hostNameInput.press('Enter')
+ await expect(page.getByText('Host settings synced.')).toBeVisible()
+ await expect(
+ page.getByLabel('Edit host name for Host Account'),
+ ).toBeVisible()
+
+ await page.reload()
+ await expect(page).toHaveURL(`/account/schedules/${shareToken}`)
+ await expect(
+ page.getByLabel('Edit host name for Host Account'),
+ ).toBeVisible()
+})
diff --git a/extra/04.with-accounts/migrations/0006-host-accounts.sql b/extra/04.with-accounts/migrations/0006-host-accounts.sql
new file mode 100644
index 0000000..fd8b4c4
--- /dev/null
+++ b/extra/04.with-accounts/migrations/0006-host-accounts.sql
@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY NOT NULL,
+ email TEXT NOT NULL UNIQUE,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS host_login_tokens (
+ id TEXT PRIMARY KEY NOT NULL,
+ user_id TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_host_login_tokens_user_id
+ ON host_login_tokens(user_id);
+CREATE INDEX IF NOT EXISTS idx_host_login_tokens_expires_at
+ ON host_login_tokens(expires_at);
+
+ALTER TABLE schedules ADD COLUMN owner_user_id TEXT;
+ALTER TABLE schedules ADD COLUMN claimed_at TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_schedules_owner_user_id
+ ON schedules(owner_user_id);
diff --git a/extra/04.with-accounts/server/auth-redirect.ts b/extra/04.with-accounts/server/auth-redirect.ts
new file mode 100644
index 0000000..a95bab3
--- /dev/null
+++ b/extra/04.with-accounts/server/auth-redirect.ts
@@ -0,0 +1,27 @@
+function normalizeRedirectTo(value: string | null) {
+ if (!value) return null
+ if (!value.startsWith('/')) return null
+ if (value.startsWith('//')) return null
+ return value
+}
+
+export function normalizeRedirectPath(
+ value: string | null | undefined,
+ fallback = '/account/schedules',
+) {
+ return normalizeRedirectTo(value ?? null) ?? fallback
+}
+
+export function redirectToLogin(
+ request: Request,
+ options: { redirectTo?: string } = {},
+) {
+ const requestUrl = new URL(request.url)
+ const target = normalizeRedirectPath(
+ options.redirectTo ?? `${requestUrl.pathname}${requestUrl.search}`,
+ '/account/schedules',
+ )
+ const loginUrl = new URL('/login', requestUrl)
+ loginUrl.searchParams.set('redirectTo', target)
+ return Response.redirect(loginUrl, 302)
+}
diff --git a/extra/04.with-accounts/server/auth-session.ts b/extra/04.with-accounts/server/auth-session.ts
new file mode 100644
index 0000000..c18a2ad
--- /dev/null
+++ b/extra/04.with-accounts/server/auth-session.ts
@@ -0,0 +1,80 @@
+import { createCookie } from '@remix-run/cookie'
+
+const sessionMaxAgeSeconds = 60 * 60 * 24 * 7
+
+export type AuthSession = {
+ id: string
+ email: string
+}
+
+let sessionCookie: ReturnType | null = null
+let sessionSecret: string | null = null
+
+export function setAuthSessionSecret(secret: string) {
+ if (!secret) {
+ throw new Error('Missing COOKIE_SECRET for session signing.')
+ }
+
+ if (sessionCookie && sessionSecret === secret) {
+ return
+ }
+
+ sessionSecret = secret
+ sessionCookie = createCookie('epic-scheduler_session', {
+ httpOnly: true,
+ sameSite: 'Lax',
+ path: '/',
+ maxAge: sessionMaxAgeSeconds,
+ secrets: [secret],
+ })
+}
+
+function getSessionCookie() {
+ if (!sessionCookie) {
+ throw new Error('Session cookie not configured. Call setAuthSessionSecret.')
+ }
+
+ return sessionCookie
+}
+
+function isAuthSession(value: unknown): value is AuthSession {
+ if (!value || typeof value !== 'object') return false
+ const record = value as Record
+ return (
+ typeof record.id === 'string' &&
+ record.id.length > 0 &&
+ typeof record.email === 'string' &&
+ record.email.length > 0
+ )
+}
+
+export async function createAuthCookie(session: AuthSession, secure: boolean) {
+ return getSessionCookie().serialize(JSON.stringify(session), { secure })
+}
+
+export async function destroyAuthCookie(secure: boolean) {
+ return getSessionCookie().serialize('', {
+ secure,
+ maxAge: 0,
+ expires: new Date(0),
+ })
+}
+
+export async function readAuthSession(request: Request) {
+ const cookieHeader = request.headers.get('Cookie')
+ if (!cookieHeader) return null
+
+ const stored = await getSessionCookie().parse(cookieHeader)
+ if (!stored || typeof stored !== 'string') return null
+
+ try {
+ const parsed = JSON.parse(stored)
+ if (isAuthSession(parsed)) {
+ return parsed
+ }
+ } catch {
+ return null
+ }
+
+ return null
+}
diff --git a/extra/04.with-accounts/server/handler.ts b/extra/04.with-accounts/server/handler.ts
index a38cbfe..a511bdc 100644
--- a/extra/04.with-accounts/server/handler.ts
+++ b/extra/04.with-accounts/server/handler.ts
@@ -1,9 +1,11 @@
+import { setAuthSessionSecret } from './auth-session.ts'
import { getEnv } from './env.ts'
import { createAppRouter } from './router.ts'
export async function handleRequest(request: Request, env: Env) {
try {
const appEnv = getEnv(env)
+ setAuthSessionSecret(appEnv.COOKIE_SECRET)
const router = createAppRouter(appEnv)
return await router.fetch(request)
} catch (error) {
diff --git a/extra/04.with-accounts/server/handlers/account-schedules-read.ts b/extra/04.with-accounts/server/handlers/account-schedules-read.ts
new file mode 100644
index 0000000..d514b74
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/account-schedules-read.ts
@@ -0,0 +1,32 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { readAuthSession } from '#server/auth-session.ts'
+import { listSchedulesOwnedByUser } from '#shared/schedule-store.ts'
+import { type AppEnv } from '#types/env-schema.ts'
+import { type routes } from '#server/routes.ts'
+
+export function createAccountSchedulesReadHandler(
+ appEnv: Pick,
+) {
+ return {
+ middleware: [],
+ async action({ request }) {
+ const session = await readAuthSession(request)
+ if (!session) {
+ return Response.json(
+ { ok: false, error: 'Sign in required.' },
+ { status: 401 },
+ )
+ }
+
+ const schedules = await listSchedulesOwnedByUser(appEnv.APP_DB, session.id)
+ return Response.json({
+ ok: true,
+ email: session.email,
+ schedules,
+ })
+ },
+ } satisfies BuildAction<
+ typeof routes.accountSchedulesRead.method,
+ typeof routes.accountSchedulesRead.pattern
+ >
+}
diff --git a/extra/04.with-accounts/server/handlers/app-pages.ts b/extra/04.with-accounts/server/handlers/app-pages.ts
index ad29c7d..3ea6795 100644
--- a/extra/04.with-accounts/server/handlers/app-pages.ts
+++ b/extra/04.with-accounts/server/handlers/app-pages.ts
@@ -1,8 +1,67 @@
import { type BuildAction } from 'remix/fetch-router'
+import { readAuthSession } from '#server/auth-session.ts'
+import { redirectToLogin } from '#server/auth-redirect.ts'
import { Layout } from '#server/layout.ts'
import { render } from '#server/render.ts'
import { type routes } from '#server/routes.ts'
+export const loginPage = {
+ middleware: [],
+ async action({ request }) {
+ const session = await readAuthSession(request)
+ if (session) {
+ return Response.redirect(new URL('/account/schedules', request.url), 302)
+ }
+ return render(
+ Layout({
+ title: 'Host login | Epic Scheduler',
+ description:
+ 'Use a one-time sign-in link to get back to the schedules you have claimed.',
+ }),
+ )
+ },
+} satisfies BuildAction
+
+export const accountSchedulesPage = {
+ middleware: [],
+ async action({ request }) {
+ const session = await readAuthSession(request)
+ if (!session) {
+ return redirectToLogin(request)
+ }
+ return render(
+ Layout({
+ title: 'Your schedules | Epic Scheduler',
+ description:
+ 'Open the schedules you have claimed without hunting for a saved host link.',
+ }),
+ )
+ },
+} satisfies BuildAction<
+ typeof routes.accountSchedulesPage.method,
+ typeof routes.accountSchedulesPage.pattern
+>
+
+export const accountSchedulePage = {
+ middleware: [],
+ async action({ request }) {
+ const session = await readAuthSession(request)
+ if (!session) {
+ return redirectToLogin(request)
+ }
+ return render(
+ Layout({
+ title: 'Saved host dashboard | Epic Scheduler',
+ description:
+ 'Manage a claimed schedule from your account while preserving host-link fallback access.',
+ }),
+ )
+ },
+} satisfies BuildAction<
+ typeof routes.accountSchedulePage.method,
+ typeof routes.accountSchedulePage.pattern
+>
+
export const schedulePage = {
middleware: [],
async action() {
diff --git a/extra/04.with-accounts/server/handlers/login-request.ts b/extra/04.with-accounts/server/handlers/login-request.ts
new file mode 100644
index 0000000..29c9e65
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/login-request.ts
@@ -0,0 +1,85 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { getRequestIp, logAuditEvent } from '#server/audit-log.ts'
+import { normalizeRedirectPath } from '#server/auth-redirect.ts'
+import { createHostLoginRequest } from '#shared/host-account-store.ts'
+import { type AppEnv } from '#types/env-schema.ts'
+import { type routes } from '#server/routes.ts'
+import { isRecordValue } from './schedule-handler-utils.ts'
+
+type LoginRequestBody = {
+ email?: unknown
+ redirectTo?: unknown
+}
+
+export function createLoginRequestHandler(appEnv: Pick) {
+ return {
+ middleware: [],
+ async action({ request, url }) {
+ let body: LoginRequestBody
+ try {
+ const parsed = await request.json()
+ if (!isRecordValue(parsed)) {
+ return Response.json(
+ { ok: false, error: 'Invalid JSON payload.' },
+ { status: 400 },
+ )
+ }
+ body = parsed as LoginRequestBody
+ } catch {
+ return Response.json(
+ { ok: false, error: 'Invalid JSON payload.' },
+ { status: 400 },
+ )
+ }
+
+ const email = typeof body.email === 'string' ? body.email : ''
+ const redirectTo = normalizeRedirectPath(
+ typeof body.redirectTo === 'string' ? body.redirectTo : null,
+ )
+ const requestIp = getRequestIp(request) ?? undefined
+
+ try {
+ const loginRequest = await createHostLoginRequest(appEnv.APP_DB, {
+ email,
+ })
+ const loginUrl = new URL('/login/verify', url)
+ loginUrl.searchParams.set('token', loginRequest.loginToken)
+ loginUrl.searchParams.set('redirectTo', redirectTo)
+
+ void logAuditEvent({
+ category: 'auth',
+ action: 'login_link_requested',
+ result: 'success',
+ email: loginRequest.email,
+ ip: requestIp,
+ path: url.pathname,
+ })
+
+ return Response.json({
+ ok: true,
+ email: loginRequest.email,
+ expiresAt: loginRequest.expiresAt,
+ loginLink: loginUrl.toString(),
+ })
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Unable to create sign-in link.'
+ void logAuditEvent({
+ category: 'auth',
+ action: 'login_link_requested',
+ result: 'failure',
+ email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: message,
+ })
+ return Response.json({ ok: false, error: message }, { status: 400 })
+ }
+ },
+ } satisfies BuildAction<
+ typeof routes.loginRequest.method,
+ typeof routes.loginRequest.pattern
+ >
+}
diff --git a/extra/04.with-accounts/server/handlers/login-verify.ts b/extra/04.with-accounts/server/handlers/login-verify.ts
new file mode 100644
index 0000000..1319483
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/login-verify.ts
@@ -0,0 +1,61 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { getRequestIp, logAuditEvent } from '#server/audit-log.ts'
+import { createAuthCookie } from '#server/auth-session.ts'
+import { normalizeRedirectPath } from '#server/auth-redirect.ts'
+import { consumeHostLoginToken } from '#shared/host-account-store.ts'
+import { type AppEnv } from '#types/env-schema.ts'
+import { type routes } from '#server/routes.ts'
+
+export function createLoginVerifyHandler(appEnv: Pick) {
+ return {
+ middleware: [],
+ async action({ request, url }) {
+ const loginToken = url.searchParams.get('token') ?? ''
+ const redirectTo = normalizeRedirectPath(url.searchParams.get('redirectTo'))
+ const requestIp = getRequestIp(request) ?? undefined
+
+ const loginResult = await consumeHostLoginToken(appEnv.APP_DB, loginToken)
+ if (loginResult.status !== 'valid') {
+ const loginUrl = new URL('/login', url)
+ loginUrl.searchParams.set('redirectTo', redirectTo)
+ loginUrl.searchParams.set(
+ 'error',
+ loginResult.status === 'expired' ? 'expired-link' : 'invalid-link',
+ )
+ void logAuditEvent({
+ category: 'auth',
+ action: 'login_link_verified',
+ result: 'failure',
+ ip: requestIp,
+ path: url.pathname,
+ reason: loginResult.status,
+ })
+ return Response.redirect(loginUrl, 302)
+ }
+
+ const cookie = await createAuthCookie(
+ loginResult.session,
+ url.protocol === 'https:',
+ )
+ void logAuditEvent({
+ category: 'auth',
+ action: 'login_link_verified',
+ result: 'success',
+ email: loginResult.session.email,
+ ip: requestIp,
+ path: url.pathname,
+ })
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: redirectTo,
+ 'Set-Cookie': cookie,
+ },
+ })
+ },
+ } satisfies BuildAction<
+ typeof routes.loginVerify.method,
+ typeof routes.loginVerify.pattern
+ >
+}
diff --git a/extra/04.with-accounts/server/handlers/logout.ts b/extra/04.with-accounts/server/handlers/logout.ts
new file mode 100644
index 0000000..e1d7767
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/logout.ts
@@ -0,0 +1,20 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { destroyAuthCookie, readAuthSession } from '#server/auth-session.ts'
+import { normalizeRedirectPath } from '#server/auth-redirect.ts'
+import { type routes } from '#server/routes.ts'
+
+export const logout = {
+ middleware: [],
+ async action({ request, url }) {
+ const session = await readAuthSession(request)
+ const redirectTo = normalizeRedirectPath(url.searchParams.get('redirectTo'), '/')
+ const cookie = await destroyAuthCookie(url.protocol === 'https:')
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: session ? redirectTo : '/',
+ 'Set-Cookie': cookie,
+ },
+ })
+ },
+} satisfies BuildAction
diff --git a/extra/04.with-accounts/server/handlers/schedule-claim.ts b/extra/04.with-accounts/server/handlers/schedule-claim.ts
new file mode 100644
index 0000000..f566140
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/schedule-claim.ts
@@ -0,0 +1,99 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { readAuthSession } from '#server/auth-session.ts'
+import {
+ claimScheduleOwnership,
+ getScheduleSnapshot,
+ verifyScheduleHostAccessToken,
+} from '#shared/schedule-store.ts'
+import { type AppEnv } from '#types/env-schema.ts'
+import { type routes } from '#server/routes.ts'
+import { getShareToken } from './schedule-handler-utils.ts'
+
+function isClaimValidationError(message: string) {
+ return /(not found|required|claimed)/i.test(message)
+}
+
+export function createScheduleClaimHandler(appEnv: Pick) {
+ return {
+ middleware: [],
+ async action({ request, url }) {
+ const shareToken = getShareToken(url.pathname)
+ if (!shareToken) {
+ return Response.json(
+ { ok: false, error: 'Missing schedule token.' },
+ { status: 400 },
+ )
+ }
+
+ const session = await readAuthSession(request)
+ if (!session) {
+ return Response.json(
+ { ok: false, error: 'Sign in required.' },
+ { status: 401 },
+ )
+ }
+
+ const providedHostToken = request.headers.get('X-Host-Token')?.trim()
+ if (!providedHostToken) {
+ return Response.json(
+ { ok: false, error: 'Missing host access token.' },
+ { status: 401 },
+ )
+ }
+
+ const hostAccessVerification = await verifyScheduleHostAccessToken(
+ appEnv.APP_DB,
+ shareToken,
+ providedHostToken,
+ )
+ if (hostAccessVerification === 'not-found') {
+ return Response.json(
+ { ok: false, error: 'Schedule not found.' },
+ { status: 404 },
+ )
+ }
+ if (hostAccessVerification !== 'valid') {
+ return Response.json(
+ { ok: false, error: 'Invalid host access token.' },
+ { status: 403 },
+ )
+ }
+
+ try {
+ await claimScheduleOwnership(appEnv.APP_DB, {
+ shareToken,
+ ownerUserId: session.id,
+ })
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : 'Unable to claim schedule.'
+ const status = /not found/i.test(message)
+ ? 404
+ : isClaimValidationError(message)
+ ? 409
+ : 500
+ if (status === 500) {
+ console.error('schedule claim failed:', error)
+ }
+ return Response.json({ ok: false, error: message }, { status })
+ }
+
+ const snapshot = await getScheduleSnapshot(appEnv.APP_DB, shareToken)
+ if (!snapshot) {
+ return Response.json(
+ { ok: false, error: 'Schedule not found.' },
+ { status: 404 },
+ )
+ }
+
+ return Response.json({
+ ok: true,
+ accountPath: `/account/schedules/${encodeURIComponent(shareToken)}`,
+ snapshot,
+ })
+ },
+ } satisfies BuildAction<
+ typeof routes.scheduleClaim.method,
+ typeof routes.scheduleClaim.pattern
+ >
+}
diff --git a/extra/04.with-accounts/server/handlers/schedule-host-auth.ts b/extra/04.with-accounts/server/handlers/schedule-host-auth.ts
new file mode 100644
index 0000000..91314ad
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/schedule-host-auth.ts
@@ -0,0 +1,102 @@
+import { readAuthSession } from '#server/auth-session.ts'
+import {
+ verifyScheduleHostAccessToken,
+ verifyScheduleOwnerAccess,
+} from '#shared/schedule-store.ts'
+
+type D1DatabaseLike = Pick['APP_DB']
+
+type AuthorizedHostAccess =
+ | {
+ ok: true
+ accessMethod: 'host-link'
+ session: null
+ }
+ | {
+ ok: true
+ accessMethod: 'account'
+ session: NonNullable>>
+ }
+ | {
+ ok: false
+ response: Response
+ }
+
+export async function authorizeHostScheduleRequest(params: {
+ db: D1DatabaseLike
+ request: Request
+ shareToken: string
+}) {
+ const providedHostToken = params.request.headers.get('X-Host-Token')?.trim()
+ if (providedHostToken) {
+ const hostAccessVerification = await verifyScheduleHostAccessToken(
+ params.db,
+ params.shareToken,
+ providedHostToken,
+ )
+ if (hostAccessVerification === 'not-found') {
+ return {
+ ok: false,
+ response: Response.json(
+ { ok: false, error: 'Schedule not found.' },
+ { status: 404 },
+ ),
+ } satisfies AuthorizedHostAccess
+ }
+ if (hostAccessVerification !== 'valid') {
+ return {
+ ok: false,
+ response: Response.json(
+ { ok: false, error: 'Invalid host access token.' },
+ { status: 403 },
+ ),
+ } satisfies AuthorizedHostAccess
+ }
+ return {
+ ok: true,
+ accessMethod: 'host-link',
+ session: null,
+ } satisfies AuthorizedHostAccess
+ }
+
+ const session = await readAuthSession(params.request)
+ if (!session) {
+ return {
+ ok: false,
+ response: Response.json(
+ { ok: false, error: 'Sign in required.' },
+ { status: 401 },
+ ),
+ } satisfies AuthorizedHostAccess
+ }
+
+ const ownerAccess = await verifyScheduleOwnerAccess(
+ params.db,
+ params.shareToken,
+ session.id,
+ )
+ if (ownerAccess === 'not-found') {
+ return {
+ ok: false,
+ response: Response.json(
+ { ok: false, error: 'Schedule not found.' },
+ { status: 404 },
+ ),
+ } satisfies AuthorizedHostAccess
+ }
+ if (ownerAccess !== 'valid') {
+ return {
+ ok: false,
+ response: Response.json(
+ { ok: false, error: 'You do not have access to this schedule.' },
+ { status: 403 },
+ ),
+ } satisfies AuthorizedHostAccess
+ }
+
+ return {
+ ok: true,
+ accessMethod: 'account',
+ session,
+ } satisfies AuthorizedHostAccess
+}
diff --git a/extra/04.with-accounts/server/handlers/schedule-host-read.ts b/extra/04.with-accounts/server/handlers/schedule-host-read.ts
index 02f61b3..1dd7452 100644
--- a/extra/04.with-accounts/server/handlers/schedule-host-read.ts
+++ b/extra/04.with-accounts/server/handlers/schedule-host-read.ts
@@ -1,11 +1,9 @@
import { type BuildAction } from 'remix/fetch-router'
-import {
- getScheduleSnapshot,
- verifyScheduleHostAccessToken,
-} from '#shared/schedule-store.ts'
+import { getScheduleSnapshot } from '#shared/schedule-store.ts'
import { type AppEnv } from '#types/env-schema.ts'
import { type routes } from '#server/routes.ts'
import { getShareToken } from './schedule-handler-utils.ts'
+import { authorizeHostScheduleRequest } from './schedule-host-auth.ts'
export function createScheduleHostReadHandler(appEnv: Pick) {
return {
@@ -19,31 +17,12 @@ export function createScheduleHostReadHandler(appEnv: Pick) {
)
}
- const providedHostToken = request.headers.get('X-Host-Token')?.trim()
- if (!providedHostToken) {
- return Response.json(
- { ok: false, error: 'Missing host access token.' },
- { status: 401 },
- )
- }
-
- const hostAccessVerification = await verifyScheduleHostAccessToken(
- appEnv.APP_DB,
+ const authorization = await authorizeHostScheduleRequest({
+ db: appEnv.APP_DB,
+ request,
shareToken,
- providedHostToken,
- )
- if (hostAccessVerification === 'not-found') {
- return Response.json(
- { ok: false, error: 'Schedule not found.' },
- { status: 404 },
- )
- }
- if (hostAccessVerification !== 'valid') {
- return Response.json(
- { ok: false, error: 'Invalid host access token.' },
- { status: 403 },
- )
- }
+ })
+ if (!authorization.ok) return authorization.response
const snapshot = await getScheduleSnapshot(appEnv.APP_DB, shareToken)
if (!snapshot) {
diff --git a/extra/04.with-accounts/server/handlers/schedule-host-update.ts b/extra/04.with-accounts/server/handlers/schedule-host-update.ts
index ce82883..5772840 100644
--- a/extra/04.with-accounts/server/handlers/schedule-host-update.ts
+++ b/extra/04.with-accounts/server/handlers/schedule-host-update.ts
@@ -2,11 +2,11 @@ import { type BuildAction } from 'remix/fetch-router'
import {
getScheduleSnapshot,
updateScheduleHostSettings,
- verifyScheduleHostAccessToken,
} from '#shared/schedule-store.ts'
import { type AppEnv } from '#types/env-schema.ts'
import { type routes } from '#server/routes.ts'
import { getShareToken, isRecordValue } from './schedule-handler-utils.ts'
+import { authorizeHostScheduleRequest } from './schedule-host-auth.ts'
type HostUpdateRequest = {
hostName?: unknown
@@ -66,30 +66,12 @@ export function createScheduleHostUpdateHandler(
{ status: 400 },
)
}
- const providedHostToken = request.headers.get('X-Host-Token')?.trim()
- if (!providedHostToken) {
- return Response.json(
- { ok: false, error: 'Missing host access token.' },
- { status: 401 },
- )
- }
- const hostAccessVerification = await verifyScheduleHostAccessToken(
- appEnv.APP_DB,
+ const authorization = await authorizeHostScheduleRequest({
+ db: appEnv.APP_DB,
+ request,
shareToken,
- providedHostToken,
- )
- if (hostAccessVerification === 'not-found') {
- return Response.json(
- { ok: false, error: 'Schedule not found.' },
- { status: 404 },
- )
- }
- if (hostAccessVerification !== 'valid') {
- return Response.json(
- { ok: false, error: 'Invalid host access token.' },
- { status: 403 },
- )
- }
+ })
+ if (!authorization.ok) return authorization.response
let body: HostUpdateRequest
try {
diff --git a/extra/04.with-accounts/server/handlers/session.ts b/extra/04.with-accounts/server/handlers/session.ts
new file mode 100644
index 0000000..3967234
--- /dev/null
+++ b/extra/04.with-accounts/server/handlers/session.ts
@@ -0,0 +1,15 @@
+import { type BuildAction } from 'remix/fetch-router'
+import { readAuthSession } from '#server/auth-session.ts'
+import { type routes } from '#server/routes.ts'
+
+export const session = {
+ middleware: [],
+ async action({ request }) {
+ const authSession = await readAuthSession(request)
+ return Response.json({
+ ok: true,
+ authenticated: authSession !== null,
+ session: authSession,
+ })
+ },
+} satisfies BuildAction
diff --git a/extra/04.with-accounts/server/normalize-email.ts b/extra/04.with-accounts/server/normalize-email.ts
new file mode 100644
index 0000000..907a603
--- /dev/null
+++ b/extra/04.with-accounts/server/normalize-email.ts
@@ -0,0 +1,3 @@
+export function normalizeEmail(email: string) {
+ return email.trim().toLowerCase()
+}
diff --git a/extra/04.with-accounts/server/router.ts b/extra/04.with-accounts/server/router.ts
index 9b0f3b7..8bacd3d 100644
--- a/extra/04.with-accounts/server/router.ts
+++ b/extra/04.with-accounts/server/router.ts
@@ -1,16 +1,28 @@
import { createRouter } from 'remix/fetch-router'
import { type AppEnv } from '#types/env-schema.ts'
+import { createAccountSchedulesReadHandler } from './handlers/account-schedules-read.ts'
import { robotsTxt, sitemapXml } from './handlers/seo-assets.ts'
import { createHealthHandler } from './handlers/health.ts'
+import { createLoginRequestHandler } from './handlers/login-request.ts'
+import { createLoginVerifyHandler } from './handlers/login-verify.ts'
import { createScheduleHostReadHandler } from './handlers/schedule-host-read.ts'
-import { scheduleHostPage, schedulePage } from './handlers/app-pages.ts'
+import {
+ accountSchedulePage,
+ accountSchedulesPage,
+ loginPage,
+ scheduleHostPage,
+ schedulePage,
+} from './handlers/app-pages.ts'
import { home } from './handlers/home.ts'
+import { logout } from './handlers/logout.ts'
import { createScheduleCreateHandler } from './handlers/schedule-create.ts'
+import { createScheduleClaimHandler } from './handlers/schedule-claim.ts'
import { createScheduleDeleteSubmissionHandler } from './handlers/schedule-delete-submission.ts'
import { createScheduleHostUpdateHandler } from './handlers/schedule-host-update.ts'
import { createScheduleRenameSubmissionHandler } from './handlers/schedule-rename-submission.ts'
import { createScheduleReadHandler } from './handlers/schedule-read.ts'
import { createScheduleSubmitAvailabilityHandler } from './handlers/schedule-submit-availability.ts'
+import { session } from './handlers/session.ts'
import {
blogIndex,
blogPost,
@@ -32,6 +44,10 @@ export function createAppRouter(appEnv: AppEnv) {
})
router.map(routes.home, home)
+ router.map(routes.loginPage, loginPage)
+ router.map(routes.loginVerify, createLoginVerifyHandler(appEnv))
+ router.map(routes.accountSchedulesPage, accountSchedulesPage)
+ router.map(routes.accountSchedulePage, accountSchedulePage)
router.map(routes.schedulePage, schedulePage)
router.map(routes.scheduleHostPage, scheduleHostPage)
router.map(routes.howItWorks, howItWorks)
@@ -43,9 +59,17 @@ export function createAppRouter(appEnv: AppEnv) {
router.map(routes.robotsTxt, robotsTxt)
router.map(routes.sitemapXml, sitemapXml)
router.map(routes.health, createHealthHandler(appEnv))
+ router.map(routes.session, session)
+ router.map(routes.loginRequest, createLoginRequestHandler(appEnv))
+ router.map(routes.logout, logout)
+ router.map(
+ routes.accountSchedulesRead,
+ createAccountSchedulesReadHandler(appEnv),
+ )
router.map(routes.scheduleCreate, createScheduleCreateHandler(appEnv))
router.map(routes.scheduleRead, createScheduleReadHandler(appEnv))
router.map(routes.scheduleHostRead, createScheduleHostReadHandler(appEnv))
+ router.map(routes.scheduleClaim, createScheduleClaimHandler(appEnv))
router.map(
routes.scheduleSubmitAvailability,
createScheduleSubmitAvailabilityHandler(appEnv),
diff --git a/extra/04.with-accounts/server/routes.ts b/extra/04.with-accounts/server/routes.ts
index 814fe83..0058774 100644
--- a/extra/04.with-accounts/server/routes.ts
+++ b/extra/04.with-accounts/server/routes.ts
@@ -2,6 +2,10 @@ import { post, route } from 'remix/fetch-router/routes'
export const routes = route({
home: '/',
+ loginPage: '/login',
+ loginVerify: '/login/verify',
+ accountSchedulesPage: '/account/schedules',
+ accountSchedulePage: '/account/schedules/:shareToken',
schedulePage: '/s/:shareToken',
scheduleHostPage: '/s/:shareToken/:hostAccessToken',
howItWorks: '/how-it-works',
@@ -13,9 +17,14 @@ export const routes = route({
robotsTxt: '/robots.txt',
sitemapXml: '/sitemap.xml',
health: '/health',
+ session: '/api/session',
+ loginRequest: post('/api/login'),
+ logout: post('/logout'),
+ accountSchedulesRead: '/api/account/schedules',
scheduleCreate: post('/api/schedules'),
scheduleRead: '/api/schedules/:shareToken',
scheduleHostRead: '/api/schedules/:shareToken/host-snapshot',
+ scheduleClaim: post('/api/schedules/:shareToken/claim'),
scheduleSubmitAvailability: post('/api/schedules/:shareToken/availability'),
scheduleDeleteSubmission: post(
'/api/schedules/:shareToken/submission-delete',
diff --git a/extra/04.with-accounts/shared/host-account-store.ts b/extra/04.with-accounts/shared/host-account-store.ts
new file mode 100644
index 0000000..33c4e90
--- /dev/null
+++ b/extra/04.with-accounts/shared/host-account-store.ts
@@ -0,0 +1,210 @@
+type PreparedStatementLike = {
+ bind(...values: Array): PreparedStatementLike
+ run(): Promise
+ first(): Promise
+ all(): Promise<{ results: Array }>
+}
+
+type D1DatabaseLike = {
+ prepare(query: string): PreparedStatementLike
+}
+
+type UserRow = {
+ id: string
+ email: string
+}
+
+type HostLoginTokenRow = {
+ id: string
+ user_id: string
+ email: string
+ expires_at: string
+}
+
+export type HostScheduleSummary = {
+ shareToken: string
+ title: string
+ createdAt: string
+ claimedAt: string | null
+}
+
+const hostLoginTokenLifetimeMs = 15 * 60_000
+const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+function toHex(bytes: Uint8Array) {
+ return Array.from(bytes)
+ .map((value) => value.toString(16).padStart(2, '0'))
+ .join('')
+}
+
+function normalizeEmail(email: string) {
+ return email.trim().toLowerCase()
+}
+
+function isValidEmailAddress(value: string) {
+ return emailPattern.test(value)
+}
+
+export function createHostLoginToken() {
+ return `${crypto.randomUUID().replace(/-/g, '')}${crypto.randomUUID().replace(/-/g, '')}`
+}
+
+export async function hashHostLoginToken(token: string) {
+ const digest = await crypto.subtle.digest(
+ 'SHA-256',
+ new TextEncoder().encode(token),
+ )
+ return toHex(new Uint8Array(digest))
+}
+
+export async function ensureHostUser(db: D1DatabaseLike, email: string) {
+ const normalizedEmail = normalizeEmail(email)
+ if (!normalizedEmail || !isValidEmailAddress(normalizedEmail)) {
+ throw new Error('A valid email address is required.')
+ }
+
+ const existingUser = await db
+ .prepare(
+ `SELECT
+ id,
+ email
+ FROM users
+ WHERE email = ?1
+ LIMIT 1`,
+ )
+ .bind(normalizedEmail)
+ .first()
+ if (existingUser) {
+ return existingUser
+ }
+
+ const userId = crypto.randomUUID()
+ await db
+ .prepare(
+ `INSERT INTO users (
+ id,
+ email,
+ created_at,
+ updated_at
+ ) VALUES (?1, ?2, ?3, ?3)`,
+ )
+ .bind(userId, normalizedEmail, new Date().toISOString())
+ .run()
+
+ return {
+ id: userId,
+ email: normalizedEmail,
+ }
+}
+
+export async function createHostLoginRequest(
+ db: D1DatabaseLike,
+ input: { email: string },
+) {
+ const user = await ensureHostUser(db, input.email)
+ const loginToken = createHostLoginToken()
+ const loginTokenHash = await hashHostLoginToken(loginToken)
+ const now = new Date()
+ const expiresAt = new Date(now.getTime() + hostLoginTokenLifetimeMs).toISOString()
+
+ await db
+ .prepare(
+ `INSERT INTO host_login_tokens (
+ id,
+ user_id,
+ token_hash,
+ expires_at,
+ created_at
+ ) VALUES (?1, ?2, ?3, ?4, ?5)`,
+ )
+ .bind(crypto.randomUUID(), user.id, loginTokenHash, expiresAt, now.toISOString())
+ .run()
+
+ return {
+ email: user.email,
+ loginToken,
+ expiresAt,
+ }
+}
+
+export async function consumeHostLoginToken(
+ db: D1DatabaseLike,
+ providedLoginToken: string,
+) {
+ const normalizedLoginToken = providedLoginToken.trim()
+ if (!normalizedLoginToken) {
+ return { status: 'invalid' as const }
+ }
+
+ const loginTokenHash = await hashHostLoginToken(normalizedLoginToken)
+ const tokenRow = await db
+ .prepare(
+ `SELECT
+ host_login_tokens.id,
+ host_login_tokens.user_id,
+ users.email,
+ host_login_tokens.expires_at
+ FROM host_login_tokens
+ INNER JOIN users ON users.id = host_login_tokens.user_id
+ WHERE host_login_tokens.token_hash = ?1
+ LIMIT 1`,
+ )
+ .bind(loginTokenHash)
+ .first()
+
+ if (!tokenRow) {
+ return { status: 'invalid' as const }
+ }
+
+ await db
+ .prepare(
+ `DELETE FROM host_login_tokens
+ WHERE id = ?1`,
+ )
+ .bind(tokenRow.id)
+ .run()
+
+ const expiresAtMs = Date.parse(tokenRow.expires_at)
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) {
+ return { status: 'expired' as const }
+ }
+
+ return {
+ status: 'valid' as const,
+ session: {
+ id: tokenRow.user_id,
+ email: tokenRow.email,
+ },
+ }
+}
+
+export async function listSchedulesForHostUser(
+ db: D1DatabaseLike,
+ userId: string,
+) {
+ const rows = await db
+ .prepare(
+ `SELECT
+ share_token,
+ title,
+ created_at,
+ claimed_at
+ FROM schedules
+ WHERE owner_user_id = ?1
+ ORDER BY COALESCE(claimed_at, created_at) DESC, created_at DESC`,
+ )
+ .bind(userId)
+ .all<{
+ share_token: string
+ title: string
+ created_at: string
+ claimed_at: string | null
+ }>()
+
+ return rows.results.map((row) => ({
+ shareToken: row.share_token,
+ title: row.title,
+ createdAt: row.created_at,
+ claimedAt: row.claimed_at ?? null,
+ })) satisfies Array
+}
diff --git a/extra/04.with-accounts/shared/schedule-store.ts b/extra/04.with-accounts/shared/schedule-store.ts
index ce58b1e..ddbca18 100644
--- a/extra/04.with-accounts/shared/schedule-store.ts
+++ b/extra/04.with-accounts/shared/schedule-store.ts
@@ -8,6 +8,8 @@ export type ScheduleRecord = {
rangeStartUtc: string
rangeEndUtc: string
createdAt: string
+ ownerUserId: string | null
+ claimedAt: string | null
}
export type AttendeeRecord = {
@@ -93,6 +95,8 @@ type ScheduleRow = {
range_start_utc: string
range_end_utc: string
created_at: string
+ owner_user_id: string | null
+ claimed_at: string | null
}
type AttendeeRow = {
@@ -121,6 +125,13 @@ type ScheduleHostAccessTokenRow = {
host_access_token_hash: string | null
}
+export type HostScheduleSummary = {
+ shareToken: string
+ title: string
+ createdAt: string
+ claimedAt: string | null
+}
+
export function createShareToken() {
return crypto.randomUUID().replace(/-/g, '').slice(0, 16)
}
@@ -232,6 +243,8 @@ function toScheduleRecord(row: ScheduleRow): ScheduleRecord {
rangeStartUtc: row.range_start_utc,
rangeEndUtc: row.range_end_utc,
createdAt: row.created_at,
+ ownerUserId: row.owner_user_id ?? null,
+ claimedAt: row.claimed_at ?? null,
}
}
@@ -331,7 +344,9 @@ export async function getScheduleByShareToken(
interval_minutes,
range_start_utc,
range_end_utc,
- created_at
+ created_at,
+ owner_user_id,
+ claimed_at
FROM schedules
WHERE share_token = ?1
LIMIT 1`,
@@ -366,6 +381,84 @@ export async function verifyScheduleHostAccessToken(
return row.host_access_token_hash === providedTokenHash ? 'valid' : 'invalid'
}
+export async function verifyScheduleOwnerAccess(
+ db: D1DatabaseLike,
+ shareToken: string,
+ ownerUserId: string,
+) {
+ const schedule = await getScheduleByShareToken(db, shareToken)
+ if (!schedule) return 'not-found' as const
+ return schedule.ownerUserId === ownerUserId ? ('valid' as const) : ('invalid' as const)
+}
+
+export async function claimScheduleOwnership(
+ db: D1DatabaseLike,
+ input: { shareToken: string; ownerUserId: string },
+) {
+ const schedule = await getScheduleByShareToken(db, input.shareToken)
+ if (!schedule) {
+ throw new Error('Schedule not found.')
+ }
+ if (schedule.ownerUserId && schedule.ownerUserId !== input.ownerUserId) {
+ throw new Error('Schedule already claimed by another account.')
+ }
+ if (schedule.ownerUserId === input.ownerUserId) {
+ return {
+ scheduleId: schedule.id,
+ claimed: false,
+ claimedAt: schedule.claimedAt,
+ }
+ }
+
+ const claimedAt = new Date().toISOString()
+ await db
+ .prepare(
+ `UPDATE schedules
+ SET owner_user_id = ?2,
+ claimed_at = ?3
+ WHERE id = ?1`,
+ )
+ .bind(schedule.id, input.ownerUserId, claimedAt)
+ .run()
+
+ return {
+ scheduleId: schedule.id,
+ claimed: true,
+ claimedAt,
+ }
+}
+
+export async function listSchedulesOwnedByUser(
+ db: D1DatabaseLike,
+ ownerUserId: string,
+) {
+ const rows = await db
+ .prepare(
+ `SELECT
+ share_token,
+ title,
+ created_at,
+ claimed_at
+ FROM schedules
+ WHERE owner_user_id = ?1
+ ORDER BY COALESCE(claimed_at, created_at) DESC, created_at DESC`,
+ )
+ .bind(ownerUserId)
+ .all<{
+ share_token: string
+ title: string
+ created_at: string
+ claimed_at: string | null
+ }>()
+
+ return rows.results.map((row) => ({
+ shareToken: row.share_token,
+ title: row.title,
+ createdAt: row.created_at,
+ claimedAt: row.claimed_at ?? null,
+ })) satisfies Array
+}
+
export async function createSchedule(
db: D1DatabaseLike,
input: ScheduleInsertInput,
diff --git a/extra/04.with-accounts/tools/prepare-e2e-env.ts b/extra/04.with-accounts/tools/prepare-e2e-env.ts
index d738133..8d1693a 100644
--- a/extra/04.with-accounts/tools/prepare-e2e-env.ts
+++ b/extra/04.with-accounts/tools/prepare-e2e-env.ts
@@ -1,18 +1,32 @@
-import { copyFileSync, existsSync } from 'node:fs'
+import {
+ appendFileSync,
+ copyFileSync,
+ existsSync,
+ readFileSync,
+} from 'node:fs'
import { join } from 'node:path'
const envPath = join(process.cwd(), '.env')
-if (existsSync(envPath)) {
- process.exit(0)
-}
-
const examplePath = join(process.cwd(), '.env.example')
-if (!existsSync(examplePath)) {
+
+if (!existsSync(envPath) && !existsSync(examplePath)) {
console.error(
'Missing .env and .env.example; cannot prepare E2E environment.',
)
process.exit(1)
}
-copyFileSync(examplePath, envPath)
-console.log('Created .env from .env.example for E2E tests.')
+if (!existsSync(envPath)) {
+ copyFileSync(examplePath, envPath)
+ console.log('Created .env from .env.example for E2E tests.')
+}
+
+const envContents = readFileSync(envPath, 'utf8')
+if (!/^COOKIE_SECRET=/m.test(envContents)) {
+ const fallbackSecret = 'e2e-cookie-secret'
+ appendFileSync(
+ envPath,
+ `${envContents.endsWith('\n') || envContents.length === 0 ? '' : '\n'}COOKIE_SECRET=${fallbackSecret}\n`,
+ )
+ console.log('Added COOKIE_SECRET to .env for E2E tests.')
+}
diff --git a/extra/04.with-accounts/types/env-schema.ts b/extra/04.with-accounts/types/env-schema.ts
index c85264d..9e717ff 100644
--- a/extra/04.with-accounts/types/env-schema.ts
+++ b/extra/04.with-accounts/types/env-schema.ts
@@ -55,11 +55,19 @@ const optionalCommitShaSchema = createSchema(
},
)
+const requiredSecretStringSchema = createSchema((value, context) => {
+ if (typeof value !== 'string') return fail('Expected string', context.path)
+ const trimmed = value.trim()
+ if (!trimmed) return fail('Expected non-empty string', context.path)
+ return { value: trimmed }
+})
+
export const EnvSchema = object({
APP_DB: d1DatabaseSchema,
SCHEDULE_ROOM: durableObjectNamespaceSchema,
APP_BASE_URL: optionalUrlStringSchema,
APP_COMMIT_SHA: optionalCommitShaSchema,
+ COOKIE_SECRET: requiredSecretStringSchema,
})
export type AppEnv = InferOutput