···11-import { redirect } from '@sveltejs/kit'
22-import type { Handle } from '@sveltejs/kit'
11+import { redirect, type Handle } from '@sveltejs/kit'
32import { sequence } from '@sveltejs/kit/hooks'
43import { createServerClient } from '@supabase/ssr'
54import { PUBLIC_SUPABASE_PUBLISHABLE_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
···11export { default as Button } from './Button.svelte'
22-export { default as FormButton } from './FormButton.svelte'
22+export { default as LinkButton } from './LinkButton.svelte'
···11+import Field from './Field.svelte'
22+import Set from './FieldSet.svelte'
33+import Legend from './FieldLegend.svelte'
44+import Group from './FieldGroup.svelte'
55+import Content from './FieldContent.svelte'
66+import Label from './FieldLabel.svelte'
77+import Title from './FieldTitle.svelte'
88+import Description from './FieldDescription.svelte'
99+import Error from './FieldError.svelte'
1010+1111+export {
1212+ Field,
1313+ Set,
1414+ Legend,
1515+ Group,
1616+ Content,
1717+ Label,
1818+ Title,
1919+ Description,
2020+ Error,
2121+ //
2222+ Set as FieldSet,
2323+ Legend as FieldLegend,
2424+ Group as FieldGroup,
2525+ Content as FieldContent,
2626+ Label as FieldLabel,
2727+ Title as FieldTitle,
2828+ Description as FieldDescription,
2929+ Error as FieldError,
3030+}
···11export { default as CharacterCounter } from './CharacterCounter.svelte'
22-export { default as ClearButton } from './ClearButton.svelte'
33-export { default as HelpText } from './HelpText.svelte'
42export { default as TextAreaInput } from './TextAreaInput.svelte'
53export { default as TextInput } from './TextInput.svelte'
+11
app/src/lib/queries/core.ts
···11+export type ResultOk<T> = { data: T; error: null }
22+export type ResultError<E> = { data: null; error: E }
33+export type ResultEnum<T, E> = ResultOk<T> | ResultError<E>
44+55+export function resultOk<T>(data: T): ResultOk<T> {
66+ return { data: data, error: null }
77+}
88+99+export function resultError<E>(error: E): ResultError<E> {
1010+ return { data: null, error: error }
1111+}
+22
app/src/lib/queries/isEmailAvailable.ts
···11+import type { PostgrestError } from '@supabase/supabase-js'
22+import { resultError, resultOk, type ResultError, type ResultOk } from './core'
33+import type { DbClient } from '$lib/utils'
44+55+export type EmailAvailableResult = ResultOk<{ isAvailable: boolean }> | ResultError<PostgrestError>
66+77+/**
88+ * Checks if a user has already registered an email address
99+ * within the database.
1010+ */
1111+export async function isEmailAvailable(
1212+ dbClient: DbClient,
1313+ email: string,
1414+): Promise<EmailAvailableResult> {
1515+ const { data, error } = await dbClient
1616+ .from('profiles')
1717+ .select()
1818+ .ilike('email', email)
1919+ .maybeSingle()
2020+2121+ return error ? resultError(error) : resultOk({ isAvailable: data === null })
2222+}
+90
app/src/lib/queries/isUsernameAvailable.ts
···11+import type { PostgrestError } from '@supabase/supabase-js/dist/index.cjs'
22+import type { ResultError, ResultOk } from './core'
33+import { resultError, resultOk } from './core'
44+import type { DbClient } from '$lib/utils'
55+66+/**
77+ * - 'taken': another user has taken the username
88+ * - 'reserved': the username is a keyword reserved by the system
99+ */
1010+export type UsernameAvailablityReason = 'taken' | 'reserved'
1111+export type UsernameAvailablityResult =
1212+ | ResultOk<
1313+ | {
1414+ isAvailable: true
1515+ }
1616+ | {
1717+ isAvailable: false
1818+ reason: UsernameAvailablityReason
1919+ }
2020+ >
2121+ | ResultError<PostgrestError>
2222+2323+const reservedUsernames = new Set([
2424+ // administrative-like access
2525+ 'sysadmin',
2626+ 'admin',
2727+ 'administrator',
2828+ 'root',
2929+ 'sysroot',
3030+ 'system',
3131+ 'www',
3232+ // users + user routes
3333+ 'user',
3434+ 'username',
3535+ 'password',
3636+ 'change-password',
3737+ 'reset-password',
3838+ 'email',
3939+ 'settings',
4040+ 'prefs',
4141+ 'preferences',
4242+ // tools and services
4343+ 'resend',
4444+ 'supabase',
4545+ 'cloudflare',
4646+ // actions
4747+ 'edit',
4848+ 'delete',
4949+ 'new',
5050+ 'create',
5151+ 'protect',
5252+ // js types
5353+ 'void',
5454+ 'undefined',
5555+ 'null',
5656+])
5757+5858+export function isUsernameReserved(username: string): boolean {
5959+ return !reservedUsernames.has(username)
6060+}
6161+6262+/**
6363+ * Checks if a username is available by checking:
6464+ * - it's not already been taken by another user
6565+ * - it's not reserved by the system
6666+ */
6767+export async function isUsernameAvailable(
6868+ dbClient: DbClient,
6969+ username: string,
7070+): Promise<UsernameAvailablityResult> {
7171+ // username is resolved to lowercase to case-insensitively
7272+ // compare against any elements in the reserved set
7373+ if (isUsernameReserved(username.toLowerCase())) {
7474+ return resultOk({ isAvailable: false, reason: 'reserved' })
7575+ }
7676+7777+ const { data, error } = await dbClient
7878+ .from('profiles')
7979+ .select()
8080+ .ilike('username', username)
8181+ .maybeSingle()
8282+8383+ if (error) {
8484+ return resultError(error)
8585+ }
8686+8787+ return typeof data?.username === 'string'
8888+ ? resultOk({ isAvailable: false, reason: 'taken' })
8989+ : resultOk({ isAvailable: true })
9090+}
+4
app/src/lib/resendClient.ts
···11+import { Resend } from 'resend'
22+import { RESEND_KEY } from '$env/static/private'
33+44+export const resend = new Resend(RESEND_KEY)
+41
app/src/lib/schemas/auth.ts
···11+import z from 'zod'
22+33+/* auth primitive schemas */
44+// TODO: fix this schema, because zod's email primitives are weird
55+export const emailSchema = z.string()
66+export const usernameSchema = z
77+ .string()
88+ .refine((s) => /\w/.test(s.charAt(0)), { error: 'Username must start with a letter.' })
99+ .refine((s) => /[\w\d-_]+/.test(s), {
1010+ error: 'Username may only contain letters, digits, hyphen, and underscore.',
1111+ })
1212+export const passwordSchema = z.string().min(8).max(64)
1313+1414+/* auth form schemas */
1515+export const signInSchema = z.object({
1616+ email: emailSchema,
1717+ password: passwordSchema,
1818+})
1919+2020+export const signUpSchema = z.object({
2121+ email: emailSchema,
2222+ username: usernameSchema,
2323+ password: passwordSchema,
2424+})
2525+2626+export const resetPasswordSchema = z.object({
2727+ email: emailSchema,
2828+})
2929+3030+export const changePasswordSchema = z.object({
3131+ newPassword: passwordSchema,
3232+})
3333+3434+/* compile-time types */
3535+export type Html5EmailSchema = z.infer<typeof emailSchema>
3636+export type UsernameSchema = z.infer<typeof usernameSchema>
3737+export type PasswordSchema = z.infer<typeof passwordSchema>
3838+export type SignInSchema = z.infer<typeof signInSchema>
3939+export type SignUpSchema = z.infer<typeof signUpSchema>
4040+export type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>
4141+export type ChangePasswordSchema = z.infer<typeof changePasswordSchema>
+38
app/src/lib/utils.ts
···11+import type { SupabaseClient } from '@supabase/supabase-js/dist/index.cjs'
22+import { clsx, type ClassValue } from 'clsx'
33+import { twMerge } from 'tailwind-merge'
44+import type { Database } from '../database.types'
55+66+/**
77+ * Generates a consistent, human-readable page title
88+ * intended for within a `<title>` element.
99+ */
1010+export const pageTitle = (title?: string) => {
1111+ const prefix = 'The Drifting Starlight'
1212+ const separator = '\u2022'
1313+1414+ if (title === undefined) {
1515+ return prefix
1616+ }
1717+1818+ return `${title} ${separator} ${prefix}`
1919+}
2020+2121+/**
2222+ * allows elegantly merging classes (including tailwind classes),
2323+ * without conflicting with Svelte's class attribute type
2424+ */
2525+export function cn(...inputs: ClassValue[]) {
2626+ return twMerge(clsx(inputs))
2727+}
2828+2929+/**
3030+ * a postgreSQL table with a strongly typed database schema
3131+ */
3232+export type DbClient = SupabaseClient<Database>
3333+3434+// eslint-disable-next-line @typescript-eslint/no-explicit-any
3535+export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T
3636+// eslint-disable-next-line @typescript-eslint/no-explicit-any
3737+export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T
3838+export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>
···11+<script lang="ts">
22+import type { SvelteHTMLElements } from 'svelte/elements'
33+import { FormHeader } from '$form/Form'
44+55+type AuthHeaderRootElement = SvelteHTMLElements['header']
66+type AuthHeaderProps = AuthHeaderRootElement & {
77+ ctaText?: string
88+}
99+let { ctaText }: AuthHeaderProps = $props()
1010+</script>
1111+1212+<FormHeader>
1313+ {#snippet titleText()}
1414+ {ctaText} to The Drifting Starlight Campaign
1515+ {/snippet}
1616+ {#snippet descText()}
1717+ To aboard the Astral Express is a blessing.
1818+ Very few can fathom such an engineering marvel
1919+ capable of taking course through the cosmos.
2020+ And yet, it exists beyond all doubt.
2121+ {/snippet}
2222+</FormHeader>
+15
app/src/routes/(auth)/AuthPageLayout.svelte
···11+<script lang="ts">
22+import type { WithChildren } from 'bits-ui'
33+import type { SvelteHTMLElements } from 'svelte/elements';
44+import { PageLayout } from '$ui/Site'
55+66+type AuthPageRootElement = SvelteHTMLElements['div']
77+type AuthPageLayoutProps = WithChildren<AuthPageRootElement>
88+let { children }: AuthPageLayoutProps = $props()
99+</script>
1010+1111+<PageLayout display="flex" direction="col" class="items-center justify-center h-screen">
1212+ <div class="flex flex-col gap-6 text-left w-112.5">
1313+ {@render children?.()}
1414+ </div>
1515+</PageLayout>
+3
app/src/routes/(auth)/components.ts
···11+export { default as AuthFooter } from './AuthFooter.svelte'
22+export { default as AuthHeader } from './AuthHeader.svelte'
33+export { default as AuthPageLayout } from './AuthPageLayout.svelte'