notifications(P4f): audit fixes, lock down 047 + best-effort enqueue paths
Two findings from an ultrathink audit of the Phase 4 work. (1) members_with_permission was callable by any PostgREST client. Postgres grants EXECUTE on new functions to PUBLIC by default, and this codebase has no existing REVOKE pattern. The function returns a SET of member_ids for any (space, permission) pair, so without a lockdown ANY authenticated caller could enumerate "who has permission X in space Y" for spaces they are not even members of, learning member_ids and the permission distribution. user_has_permission has a narrower exposure (per-uid boolean) and is needed by anon/authenticated for RLS-policy evaluation, so it stays callable; the new function is locked to service_role since the only legitimate caller (the form-submission fan-out) goes through the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM PUBLIC and GRANT TO service_role. (2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and classes.ts (signUp, cancelMySignup waitlist promotion, updateSession session-cancellation fan-out) used the shared helpers, but the helpers themselves were not internally best-effort. The Stripe webhook's original enqueueDues wrapped its entire body in try/catch so a thrown member-lookup error could never bubble into the money path; my refactor removed that inner wrap and relied on enqueueNotification's best-effort. resolveMemberContact and getSpaceName could still throw on a transient supabase network error, which would surface to the action return value after the underlying mutation already committed: the client would see an error even though their booking, signup, or session-cancellation succeeded. Fixed at both layers: the helpers now swallow throws internally (returning null / empty), and each call site in equipment/classes is wrapped in try/catch around the lookup chain so secondary action-specific queries (equipment name+location lookup, promoted-signup session lookup, session-cancellation signup fan-out) also cannot leak. forms.submitForm already had the wrap from P4d. 2 new helper tests pin the best-effort behavior; suite 567, build clean.
This commit is contained in:
@@ -22,22 +22,29 @@ export type MemberContact = {
|
||||
// Look up a member's email + display_name, scoped to the given space. Used
|
||||
// by every enqueue site to derive a recipient from a member id. Returns null
|
||||
// if the member is not in that space (cross-tenant safety: the (id, space_id)
|
||||
// filter pins the lookup).
|
||||
// filter pins the lookup) OR if the lookup itself fails (best-effort: never
|
||||
// throws into the calling action, so a transient DB error cannot turn a
|
||||
// successful domain mutation into a client-visible error).
|
||||
export async function resolveMemberContact(
|
||||
admin: AdminClient,
|
||||
spaceId: string,
|
||||
memberId: string,
|
||||
): Promise<MemberContact | null> {
|
||||
const { data } = await admin
|
||||
.from('space_members')
|
||||
.select('email, display_name')
|
||||
.eq('id', memberId)
|
||||
.eq('space_id', spaceId)
|
||||
.maybeSingle()
|
||||
if (!data) return null
|
||||
return {
|
||||
email: (data.email as string | null) ?? null,
|
||||
displayName: (data.display_name as string | null) ?? null,
|
||||
try {
|
||||
const { data } = await admin
|
||||
.from('space_members')
|
||||
.select('email, display_name')
|
||||
.eq('id', memberId)
|
||||
.eq('space_id', spaceId)
|
||||
.maybeSingle()
|
||||
if (!data) return null
|
||||
return {
|
||||
email: (data.email as string | null) ?? null,
|
||||
displayName: (data.display_name as string | null) ?? null,
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[notifications] resolveMemberContact threw:', e instanceof Error ? e.message : e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,10 +98,17 @@ export async function enqueueNotification(
|
||||
|
||||
// Read the space's name once per caller. Used for brand-neutral copy that
|
||||
// injects the space name (this is a generic multi-space platform; never
|
||||
// hard-code a tenant). Returns empty string on miss so renders still work.
|
||||
// hard-code a tenant). Returns empty string on miss or on lookup failure
|
||||
// (best-effort, same rationale as resolveMemberContact); the renderers all
|
||||
// fall back to "your hackerspace" so the email still composes.
|
||||
export async function getSpaceName(admin: AdminClient, spaceId: string): Promise<string> {
|
||||
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
|
||||
return (data?.name as string | null) ?? ''
|
||||
try {
|
||||
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
|
||||
return (data?.name as string | null) ?? ''
|
||||
} catch (e) {
|
||||
console.error('[notifications] getSpaceName threw:', e instanceof Error ? e.message : e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Build the member portal URL the way every enqueue site does. Header host
|
||||
|
||||
Reference in New Issue
Block a user