From 33ff879e90acebcca20b2caaf17d08d0c06c9360 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Tue, 18 Aug 2026 20:26:24 +0500 Subject: [PATCH] Require email account authentication --- .env.example | 1 - PLAN.md | 6 +- README.md | 8 +- apps/web/components/AppShell.vue | 8 +- apps/web/components/AuthCard.vue | 28 ++++++ apps/web/composables/useDngAuth.ts | 82 +++++++++++++++--- apps/web/pages/auth/callback.vue | 4 +- apps/web/pages/auth/forgot-password.vue | 33 +++++++ apps/web/pages/auth/reset-password.vue | 54 ++++++++++++ apps/web/pages/auth/sign-in.vue | 45 ++++++++++ apps/web/pages/auth/sign-up.vue | 80 +++++++++++++++++ apps/web/pages/dashboard.vue | 2 +- apps/web/pages/index.vue | 42 +-------- apps/web/pages/join/[token].vue | 3 +- apps/web/server/utils/stage-two-supabase.ts | 9 +- scripts/smoke-multiplayer.mjs | 39 ++++++--- supabase/bootstrap.sql | 85 ++++++++++++++++++- .../0008_required_email_accounts.sql | 78 +++++++++++++++++ 18 files changed, 523 insertions(+), 84 deletions(-) create mode 100644 apps/web/components/AuthCard.vue create mode 100644 apps/web/pages/auth/forgot-password.vue create mode 100644 apps/web/pages/auth/reset-password.vue create mode 100644 apps/web/pages/auth/sign-in.vue create mode 100644 apps/web/pages/auth/sign-up.vue create mode 100644 supabase/migrations/0008_required_email_accounts.sql diff --git a/.env.example b/.env.example index 1723519..3054a53 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,5 @@ REDIS_URL= AI_JOB_POLL_INTERVAL_MS=1000 # Alpha controls -INVITE_ALLOWLIST=founder@example.com DAILY_AI_TOKEN_LIMIT=250000 NUXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/PLAN.md b/PLAN.md index 554dc4a..e593e0b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -19,7 +19,7 @@ Dungeons & Ground — англоязычная веб‑платформа дл ### Пользовательский путь -1. Вход по email magic link для пользователей из allowlist. +1. Регистрация обязательного аккаунта по имени, email и паролю; вход по email/password и восстановление пароля через письмо. 2. Создание приватной вселенной через чат с AI‑соавтором. 3. AI задаёт 3–5 уточняющих вопросов о жанре, атмосфере, конфликте и желаемой роли игроков. 4. Создаётся редактируемый стартовый набор: @@ -124,7 +124,7 @@ Desktop‑версия позднее создаётся через Tauri и и ### Недели 1–2 — фундамент - Monorepo, Nuxt/Nitro, worker, CI и окружения. -- Supabase Auth, allowlist, RLS и базовая схема данных. +- Supabase email/password Auth, email confirmation/recovery, RLS и базовая схема данных. - Англоязычный UI: login, dashboard, world list. - OpenRouter adapter, учёт токенов, дневные квоты и безопасное хранение ключа. - Первый вертикальный тест: действие игрока → worker → ответ AI. @@ -183,7 +183,7 @@ Desktop‑версия позднее создаётся через Tauri и и ## 6. Зафиксированные ограничения - Название `Dungeons & Ground` считается финальным, но перед публичным релизом обязательны проверка товарных знаков, домена и визуального сходства. -- Первая версия: web, English-first, invite-only, private worlds. +- Первая версия: web, English-first, обязательные email-аккаунты, private worlds и приглашения в кампании. - Контент: 13+, без explicit‑материалов. - OpenRouter оплачивает проект; пользователи не вводят собственные ключи. - Оплаты нет — используются дневные, пользовательские и кампанийные лимиты. diff --git a/README.md b/README.md index 60ce249..652bb4d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ pnpm dev:all Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second. -To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal. +To verify the complete hosted-Supabase multiplayer path (two temporary email accounts, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal. Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix. @@ -47,7 +47,7 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18 ## Production services 1. Copy the Supabase **Session pooler** connection URI from **Connect** into `SUPABASE_DB_URL`. `pnpm dev:all` now detects an empty project and creates the complete schema automatically. `pnpm db:ensure` runs the same guarded bootstrap without starting the app. Manual SQL Editor installation remains available through `supabase/bootstrap.sql`. -2. In Supabase Auth settings, enable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000` and add `http://localhost:3000/auth/callback` as a Redirect URL. Email magic-link accounts remain optional and must be added to `public.allowlist`. Before external testing, enable CAPTCHA and review Supabase's anonymous sign-in rate limits so disposable guests cannot be used to evade AI quotas. +2. In Supabase Auth settings, enable the **Email** provider and disable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000`; add `http://localhost:3000/auth/callback` and `http://localhost:3000/auth/reset-password` as Redirect URLs. Configure SMTP before external testing so confirmation and recovery email is delivered reliably. CAPTCHA and Auth rate limits remain recommended. 3. Run `pnpm dev:worker` alongside the web process. Provision Redis only when BullMQ delivery is desired; otherwise the worker polls the Supabase outbox. 4. Configure either `OPENROUTER_API_KEY` (default model `deepseek/deepseek-v4-flash`) or set `AI_PROVIDER=deepseek` with `DEEPSEEK_API_KEY` (default model `deepseek-v4-flash`, endpoint `https://api.deepseek.com/chat/completions`). @@ -55,10 +55,10 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile ## Weeks 3–4 flow -1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative. +1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow. 2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload. 3. Review every starting-world field, including the owner-only hidden threat, then confirm it. 4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way. -5. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`. +5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`. 6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting. 7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus. diff --git a/apps/web/components/AppShell.vue b/apps/web/components/AppShell.vue index 49919fb..11cbd02 100644 --- a/apps/web/components/AppShell.vue +++ b/apps/web/components/AppShell.vue @@ -4,12 +4,12 @@ const { session, restore, signOut } = useDngAuth() onMounted(() => void restore()) const initials = computed(() => { - const email = session.value?.user.email ?? '' - return email.slice(0, 2).toUpperCase() || 'G' + const displayName = session.value?.user.user_metadata?.display_name + const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? '' + return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G' }) async function leave() { - if (session.value?.user.is_anonymous && !window.confirm('This guest account cannot be recovered after sign out. Leave anyway?')) return await signOut() await navigateTo('/') } @@ -22,7 +22,7 @@ async function leave() {
PRIVATE ALPHA / {{ section }}
- +
D&G
diff --git a/apps/web/components/AuthCard.vue b/apps/web/components/AuthCard.vue new file mode 100644 index 0000000..5c5b09a --- /dev/null +++ b/apps/web/components/AuthCard.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/apps/web/composables/useDngAuth.ts b/apps/web/composables/useDngAuth.ts index 56b077d..227dde5 100644 --- a/apps/web/composables/useDngAuth.ts +++ b/apps/web/composables/useDngAuth.ts @@ -19,6 +19,13 @@ interface AuthResponse { user: DngAuthUser } +interface SignUpResponse { + access_token?: string | null + refresh_token?: string | null + expires_in?: number | null + user: DngAuthUser +} + const STORAGE_KEY = 'dng-auth-session' export function useDngAuth() { @@ -48,6 +55,10 @@ export function useDngAuth() { } } + function isEmailAccount(user: DngAuthUser | undefined): boolean { + return Boolean(user?.email && !user.is_anonymous) + } + function parseStoredSession(value: string): DngSession | null { try { const saved = JSON.parse(value) as Partial @@ -97,7 +108,11 @@ export function useDngAuth() { } async function restore() { - if (!import.meta.client || hydrated.value) return session.value + if (!import.meta.client) return session.value + if (hydrated.value) { + if (session.value && !isEmailAccount(session.value.user)) persist(null) + return session.value + } let restoredFromStorage = false const hash = new URLSearchParams(window.location.hash.replace(/^#/, '')) @@ -133,37 +148,76 @@ export function useDngAuth() { await refresh() } } + if (session.value && !isEmailAccount(session.value.user)) persist(null) hydrated.value = true return session.value } - async function requestMagicLink(email: string) { + async function signUp(displayName: string, email: string, password: string) { const redirectTo = `${window.location.origin}/auth/callback` - await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, { - method: 'POST', - headers: authHeaders(), - body: { email: email.trim().toLowerCase(), create_user: true }, - }) - } - - async function signInAnonymously() { - const response = await $fetch(`${config.public.supabaseUrl}/auth/v1/signup`, { + const response = await $fetch(`${config.public.supabaseUrl}/auth/v1/signup?redirect_to=${encodeURIComponent(redirectTo)}`, { method: 'POST', headers: authHeaders(), body: { - data: { display_name: 'Guest Adventurer' }, - gotrue_meta_security: {}, + email: email.trim().toLowerCase(), + password, + data: { display_name: displayName.trim() }, }, }) + if (response.access_token && response.refresh_token && response.expires_in) { + const next = fromResponse({ + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_in: response.expires_in, + user: response.user, + }) + persist(next) + hydrated.value = true + return { session: next, needsEmailConfirmation: false } + } + return { session: null, needsEmailConfirmation: true } + } + + async function signIn(email: string, password: string) { + const response = await $fetch(`${config.public.supabaseUrl}/auth/v1/token?grant_type=password`, { + method: 'POST', + headers: authHeaders(), + body: { email: email.trim().toLowerCase(), password }, + }) const next = fromResponse(response) + if (!isEmailAccount(next.user)) throw new Error('An email account is required.') persist(next) hydrated.value = true return next } + async function requestPasswordReset(email: string) { + const redirectTo = `${window.location.origin}/auth/reset-password` + await $fetch(`${config.public.supabaseUrl}/auth/v1/recover?redirect_to=${encodeURIComponent(redirectTo)}`, { + method: 'POST', + headers: authHeaders(), + body: { email: email.trim().toLowerCase() }, + }) + } + + async function updatePassword(password: string) { + const token = await accessToken() + const user = normalizeUser(await $fetch(`${config.public.supabaseUrl}/auth/v1/user`, { + method: 'PUT', + headers: authHeaders(token), + body: { password }, + })) + if (session.value) persist({ ...session.value, user }) + return user + } + async function accessToken() { await restore() if (!session.value) throw new Error('Enter the alpha to continue.') + if (!isEmailAccount(session.value.user)) { + persist(null) + throw new Error('Sign in with an email account to continue.') + } if (session.value.expiresAt <= Date.now() + 60_000) await refresh() if (!session.value) throw new Error('Your session expired. Sign in again.') return session.value.accessToken @@ -184,5 +238,5 @@ export function useDngAuth() { hydrated.value = true } - return { session, hydrated, restore, refresh, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate } + return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate } } diff --git a/apps/web/pages/auth/callback.vue b/apps/web/pages/auth/callback.vue index 0dcc905..1f8b9a3 100644 --- a/apps/web/pages/auth/callback.vue +++ b/apps/web/pages/auth/callback.vue @@ -19,8 +19,8 @@ onMounted(async () => { diff --git a/apps/web/pages/auth/forgot-password.vue b/apps/web/pages/auth/forgot-password.vue new file mode 100644 index 0000000..0e1af23 --- /dev/null +++ b/apps/web/pages/auth/forgot-password.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/web/pages/auth/reset-password.vue b/apps/web/pages/auth/reset-password.vue new file mode 100644 index 0000000..fd16711 --- /dev/null +++ b/apps/web/pages/auth/reset-password.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web/pages/auth/sign-in.vue b/apps/web/pages/auth/sign-in.vue new file mode 100644 index 0000000..3e4207d --- /dev/null +++ b/apps/web/pages/auth/sign-in.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/web/pages/auth/sign-up.vue b/apps/web/pages/auth/sign-up.vue new file mode 100644 index 0000000..5acb231 --- /dev/null +++ b/apps/web/pages/auth/sign-up.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/apps/web/pages/dashboard.vue b/apps/web/pages/dashboard.vue index 69670b2..b9454c0 100644 --- a/apps/web/pages/dashboard.vue +++ b/apps/web/pages/dashboard.vue @@ -74,7 +74,7 @@ onMounted(async () => {

RECEIVING PRIVATE CAMPAIGNS…

-

{{ error }} SIGN IN AGAIN

+

{{ error }} SIGN IN AGAIN

{{ campaign.status.toUpperCase() }} CAMPAIGN
PRIVATE MULTIPLAYER

{{ campaign.title }}

{{ campaign.current_scene }}

CONTINUE STORY{{ new Date(campaign.updated_at).toLocaleDateString() }}
diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index b465b32..e142042 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -1,9 +1,5 @@ diff --git a/apps/web/pages/join/[token].vue b/apps/web/pages/join/[token].vue index ca38629..56e7e82 100644 --- a/apps/web/pages/join/[token].vue +++ b/apps/web/pages/join/[token].vue @@ -28,11 +28,12 @@ onMounted(async () => {
PARTY INVITE

JOINING CAMPAIGN…

Verifying your seat at the table.

-
PARTY INVITE

ENTER THE ALPHA FIRST

Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.

ENTER THE ALPHA →
+
PARTY INVITE

ACCOUNT REQUIRED

Your invite is saved. Sign in or create an email account, and this campaign will open automatically.

SIGN IN →CREATE ACCOUNT
INVITE ERROR

THE DOOR STAYED SHUT

{{ message }}

BACK TO DASHBOARD
diff --git a/apps/web/server/utils/stage-two-supabase.ts b/apps/web/server/utils/stage-two-supabase.ts index 724a7cb..8ff5527 100644 --- a/apps/web/server/utils/stage-two-supabase.ts +++ b/apps/web/server/utils/stage-two-supabase.ts @@ -7,7 +7,11 @@ const AuthEmailSchema = z.preprocess( value => value === null || value === '' ? undefined : value, z.string().email().optional(), ) -const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema }) +const AuthUserSchema = z.object({ + id: UuidSchema, + email: AuthEmailSchema, + is_anonymous: z.boolean().optional().default(false), +}) interface RequestOptions extends RequestInit { prefer?: string @@ -95,6 +99,9 @@ export async function requireStageTwoUser(event: H3Event): Promise if (!response.ok) throw createError({ statusCode: 401, statusMessage: 'The access token is invalid or expired.' }) const parsed = AuthUserSchema.safeParse(await response.json()) if (!parsed.success) throw createError({ statusCode: 401, statusMessage: 'Supabase returned an invalid user.' }) + if (parsed.data.is_anonymous || !parsed.data.email) { + throw createError({ statusCode: 403, statusMessage: 'A verified email account is required.' }) + } const profiles = await stageTwoDatabase>(`profiles?select=id&id=eq.${parsed.data.id}&limit=1`) if (!profiles[0]) throw createError({ statusCode: 403, statusMessage: 'This account is not enabled for the alpha.' }) diff --git a/scripts/smoke-multiplayer.mjs b/scripts/smoke-multiplayer.mjs index 5a3f147..6abe300 100644 --- a/scripts/smoke-multiplayer.mjs +++ b/scripts/smoke-multiplayer.mjs @@ -38,14 +38,30 @@ async function jsonRequest(url, options = {}) { return payload } -async function createGuest(label) { - const session = await jsonRequest(`${supabaseUrl}/auth/v1/signup`, { +function serviceHeaders() { + return { + apikey: serviceKey, + ...(serviceKey.split('.').length === 3 ? { Authorization: `Bearer ${serviceKey}` } : {}), + 'Content-Type': 'application/json', + } +} + +async function createEmailAccount(label) { + const email = `smoke-${randomUUID()}@example.com` + const password = `Smoke-${randomUUID()}!aA1` + const user = await jsonRequest(`${supabaseUrl}/auth/v1/admin/users`, { + method: 'POST', + headers: serviceHeaders(), + body: JSON.stringify({ email, password, email_confirm: true, user_metadata: { display_name: label } }), + }) + const session = await jsonRequest(`${supabaseUrl}/auth/v1/token?grant_type=password`, { method: 'POST', headers: { apikey: anonKey, 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: { display_name: label }, gotrue_meta_security: {} }), + body: JSON.stringify({ email, password }), }) - if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete anonymous session.') - return { token: session.access_token, id: session.user.id } + if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete email session.') + if (session.user.id !== user.id) throw new Error('[smoke] Email login returned the wrong user.') + return { token: session.access_token, id: session.user.id, email } } function appRequest(path, token, options = {}) { @@ -56,15 +72,10 @@ function appRequest(path, token, options = {}) { } async function servicePatch(path, body) { - const serviceAuthorization = serviceKey.split('.').length === 3 - ? { Authorization: `Bearer ${serviceKey}` } - : {} await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, { method: 'PATCH', headers: { - apikey: serviceKey, - ...serviceAuthorization, - 'Content-Type': 'application/json', + ...serviceHeaders(), Prefer: 'return=representation', }, body: JSON.stringify(body), @@ -72,10 +83,10 @@ async function servicePatch(path, body) { } const suffix = Date.now().toString(36).toUpperCase() -console.log('[smoke] Creating two isolated guest sessions…') +console.log('[smoke] Creating two confirmed email accounts…') const [owner, player] = await Promise.all([ - createGuest(`Smoke Owner ${suffix}`), - createGuest(`Smoke Player ${suffix}`), + createEmailAccount(`Smoke Owner ${suffix}`), + createEmailAccount(`Smoke Player ${suffix}`), ]) console.log('[smoke] Creating a private world and campaign through the public API…') diff --git a/supabase/bootstrap.sql b/supabase/bootstrap.sql index 14cd163..4cf465a 100644 --- a/supabase/bootstrap.sql +++ b/supabase/bootstrap.sql @@ -2175,6 +2175,89 @@ grant execute on function public.dng_schema_version() to service_role; notify pgrst, 'reload schema'; +-- ============================================================================ +-- 0008_required_email_accounts.sql +-- ============================================================================ + +-- D&G now uses recoverable email/password accounts. Keep the legacy function +-- names so existing auth.users triggers are upgraded in place. + +create or replace function public.create_profile_for_allowlisted_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_display_name text; +begin + if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then + raise exception using + errcode = '42501', + message = 'A Dungeons & Ground account requires an email address.'; + end if; + + v_display_name := coalesce( + nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''), + split_part(new.email, '@', 1), + 'Adventurer' + ); + + insert into public.profiles(id, display_name) + values (new.id, left(v_display_name, 80)) + on conflict (id) do update + set display_name = excluded.display_name; + + return new; +end; +$$; + +revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated; + +create or replace function public.enforce_alpha_email_allowlist() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then + raise exception using + errcode = '42501', + message = 'A Dungeons & Ground account requires an email address.'; + end if; + return new; +end; +$$; + +revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated; + +-- Installations may contain confirmed email users created before the D&G +-- schema. Give every non-anonymous email account a profile without touching +-- existing campaign ownership or display names. +insert into public.profiles(id, display_name) +select + auth_user.id, + left(coalesce( + nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''), + split_part(auth_user.email, '@', 1), + 'Adventurer' + ), 80) +from auth.users auth_user +where not coalesce(auth_user.is_anonymous, false) + and nullif(btrim(auth_user.email), '') is not null +on conflict (id) do nothing; + +create or replace function public.dng_schema_version() +returns integer language sql stable security definer set search_path = '' as $$ + select 8; +$$; + +revoke all on function public.dng_schema_version() from public, anon, authenticated; +grant execute on function public.dng_schema_version() to service_role; + +notify pgrst, 'reload schema'; + do $$ begin @@ -2199,7 +2282,7 @@ begin if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then raise exception 'D&G bootstrap verification failed: character creation RPC is missing'; end if; - if public.dng_schema_version() <> 7 then + if public.dng_schema_version() <> 8 then raise exception 'D&G bootstrap verification failed: unexpected schema version'; end if; end; diff --git a/supabase/migrations/0008_required_email_accounts.sql b/supabase/migrations/0008_required_email_accounts.sql new file mode 100644 index 0000000..322d415 --- /dev/null +++ b/supabase/migrations/0008_required_email_accounts.sql @@ -0,0 +1,78 @@ +-- D&G now uses recoverable email/password accounts. Keep the legacy function +-- names so existing auth.users triggers are upgraded in place. + +create or replace function public.create_profile_for_allowlisted_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_display_name text; +begin + if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then + raise exception using + errcode = '42501', + message = 'A Dungeons & Ground account requires an email address.'; + end if; + + v_display_name := coalesce( + nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''), + split_part(new.email, '@', 1), + 'Adventurer' + ); + + insert into public.profiles(id, display_name) + values (new.id, left(v_display_name, 80)) + on conflict (id) do update + set display_name = excluded.display_name; + + return new; +end; +$$; + +revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated; + +create or replace function public.enforce_alpha_email_allowlist() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then + raise exception using + errcode = '42501', + message = 'A Dungeons & Ground account requires an email address.'; + end if; + return new; +end; +$$; + +revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated; + +-- Installations may contain confirmed email users created before the D&G +-- schema. Give every non-anonymous email account a profile without touching +-- existing campaign ownership or display names. +insert into public.profiles(id, display_name) +select + auth_user.id, + left(coalesce( + nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''), + split_part(auth_user.email, '@', 1), + 'Adventurer' + ), 80) +from auth.users auth_user +where not coalesce(auth_user.is_anonymous, false) + and nullif(btrim(auth_user.email), '') is not null +on conflict (id) do nothing; + +create or replace function public.dng_schema_version() +returns integer language sql stable security definer set search_path = '' as $$ + select 8; +$$; + +revoke all on function public.dng_schema_version() from public, anon, authenticated; +grant execute on function public.dng_schema_version() to service_role; + +notify pgrst, 'reload schema';