Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s

This commit is contained in:
2026-08-15 17:37:57 +05:00
parent 1774496cf9
commit 438e5af0ad
56 changed files with 5090 additions and 119 deletions

View File

@@ -9,4 +9,12 @@ describe('required Supabase runtime config', () => {
it('reports missing credentials clearly', () => {
expect(() => assertRequiredSupabaseConfig({ public: {} })).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
})
it('rejects credentials from different Supabase projects', () => {
expect(() => assertRequiredSupabaseConfig({
supabaseUrl: 'https://server-project.supabase.co',
supabaseServiceRoleKey: 'service',
public: { supabaseUrl: 'https://public-project.supabase.co', supabaseAnonKey: 'anon' },
})).toThrow('must point to the same project')
})
})

View File

@@ -15,4 +15,10 @@ export function assertRequiredSupabaseConfig(config: unknown): void {
const details = result.error.issues.map(issue => issue.message).join('; ')
throw new Error(`Invalid Supabase runtime configuration: ${details}`)
}
const serverOrigin = new URL(result.data.supabaseUrl).origin
const publicOrigin = new URL(result.data.public.supabaseUrl).origin
if (serverOrigin !== publicOrigin) {
throw new Error('Invalid Supabase runtime configuration: SUPABASE_URL and NUXT_PUBLIC_SUPABASE_URL must point to the same project')
}
}

View File

@@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { stageTwoDatabase } from './stage-two-supabase'
describe('stage-two Supabase client', () => {
afterEach(() => vi.unstubAllGlobals())
it('accepts successful PostgREST return=minimal responses with an empty body', async () => {
vi.stubGlobal('useRuntimeConfig', () => ({
supabaseUrl: 'https://example.supabase.co',
supabaseServiceRoleKey: 'service-role-key',
public: { supabaseAnonKey: 'anon-key' },
}))
vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 201 })))
await expect(stageTwoDatabase('invites', {
method: 'POST',
prefer: 'return=minimal',
body: '{}',
})).resolves.toBeUndefined()
})
})

View File

@@ -0,0 +1,126 @@
import { z } from 'zod'
import type { H3Event } from 'h3'
import { moderate13Plus } from '@dng/shared'
const UuidSchema = z.string().uuid()
const AuthEmailSchema = z.preprocess(
value => value === null || value === '' ? undefined : value,
z.string().email().optional(),
)
const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema })
interface RequestOptions extends RequestInit {
prefer?: string
}
export interface StageTwoUser {
id: string
email?: string
}
export class StageTwoDatabaseError extends Error {
constructor(public status: number, message: string, public code?: string) {
super(message)
}
}
function runtime() {
const config = useRuntimeConfig()
const url = z.string().url().parse(config.supabaseUrl)
const serviceKey = z.string().min(1).parse(config.supabaseServiceRoleKey)
const anonKey = z.string().min(1).parse(config.public.supabaseAnonKey)
return { url: url.replace(/\/$/, ''), serviceKey, anonKey }
}
export function stageTwoUuid(value: unknown, label = 'id'): string {
const parsed = UuidSchema.safeParse(value)
if (!parsed.success) throw createError({ statusCode: 400, statusMessage: `Invalid ${label}.` })
return parsed.data
}
export function requireStageTwoSafeText(text: string): void {
const result = moderate13Plus(text)
if (!result.allowed) {
throw createError({ statusCode: 422, statusMessage: `Content is outside the 13+ policy: ${result.categories.join(', ')}.` })
}
}
export async function stageTwoDatabase<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { url, serviceKey } = runtime()
const response = await fetch(new URL(`/rest/v1/${path}`, url), {
...options,
headers: {
apikey: serviceKey,
Authorization: `Bearer ${serviceKey}`,
'Content-Type': 'application/json',
...(options.prefer ? { Prefer: options.prefer } : {}),
...options.headers,
},
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as { code?: string; message?: string } | null
throw new StageTwoDatabaseError(response.status, payload?.message || `Supabase request failed (${response.status}).`, payload?.code)
}
if (response.status === 204) return undefined as T
// PostgREST commonly returns 201/200 with an empty body for
// `Prefer: return=minimal`; parsing that as JSON throws after a successful
// database mutation and incorrectly turns the route into HTTP 500.
const text = await response.text()
if (!text.trim()) return undefined as T
return JSON.parse(text) as T
}
export function stageTwoRpc<T>(name: string, body: Record<string, unknown>): Promise<T> {
return stageTwoDatabase<T>(`rpc/${name}`, { method: 'POST', body: JSON.stringify(body) })
}
export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser> {
const authorization = getHeader(event, 'authorization')
if (!authorization?.startsWith('Bearer ') || authorization.length <= 7) {
throw createError({ statusCode: 401, statusMessage: 'A Supabase access token is required.' })
}
const { url, anonKey } = runtime()
const response = await fetch(new URL('/auth/v1/user', url), {
headers: { apikey: anonKey, Authorization: authorization },
})
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.' })
const profiles = await stageTwoDatabase<Array<{ id: string }>>(`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.' })
return parsed.data
}
export async function requireCampaignAccess(campaignId: string, userId: string, ownerOnly = false) {
const campaigns = await stageTwoDatabase<Array<Record<string, unknown>>>(
`campaigns?select=*&id=eq.${campaignId}&limit=1`,
)
const campaign = campaigns[0]
if (!campaign) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
if (campaign.owner_id === userId) return { campaign, owner: true }
if (ownerOnly) throw createError({ statusCode: 403, statusMessage: 'Only the campaign owner can do that.' })
const members = await stageTwoDatabase<Array<{ id: string; active: boolean }>>(
`campaign_members?select=id,active&campaign_id=eq.${campaignId}&user_id=eq.${userId}&active=eq.true&limit=1`,
)
if (!members[0]) throw createError({ statusCode: 404, statusMessage: 'Campaign not found.' })
return { campaign, owner: false, memberId: members[0].id }
}
export function stageTwoApiError(error: unknown): never {
if (error && typeof error === 'object' && 'statusCode' in error) throw error
if (error instanceof StageTwoDatabaseError) {
if (error.code === 'PGRST202' || error.code === 'PGRST205') {
throw createError({
statusCode: 503,
statusMessage: 'The Supabase database schema is not installed or is out of date. Apply supabase/bootstrap.sql.',
})
}
const conflict = /duplicate key|already exists|not claimable/i.test(error.message)
throw createError({ statusCode: conflict ? 409 : error.status >= 500 ? 502 : 400, statusMessage: error.message })
}
if (error instanceof z.ZodError) {
throw createError({ statusCode: 400, statusMessage: error.issues.map(issue => issue.message).join('; ') })
}
throw createError({ statusCode: 500, statusMessage: error instanceof Error ? error.message : 'Unexpected server error.' })
}