Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal file
21
apps/web/server/utils/stage-two-supabase.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
126
apps/web/server/utils/stage-two-supabase.ts
Normal file
126
apps/web/server/utils/stage-two-supabase.ts
Normal 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.' })
|
||||
}
|
||||
Reference in New Issue
Block a user