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:
@@ -15,6 +15,7 @@ function mockAdmin(opts: {
|
|||||||
selectResult?: { data: Row | null; error?: { message: string } | null }
|
selectResult?: { data: Row | null; error?: { message: string } | null }
|
||||||
upsertResult?: { error: { message: string } | null }
|
upsertResult?: { error: { message: string } | null }
|
||||||
throwOnUpsert?: boolean
|
throwOnUpsert?: boolean
|
||||||
|
throwOnMaybeSingle?: boolean
|
||||||
}) {
|
}) {
|
||||||
const upsert = vi.fn(async () => {
|
const upsert = vi.fn(async () => {
|
||||||
if (opts.throwOnUpsert) throw new Error('boom')
|
if (opts.throwOnUpsert) throw new Error('boom')
|
||||||
@@ -24,9 +25,10 @@ function mockAdmin(opts: {
|
|||||||
const builder = {
|
const builder = {
|
||||||
select: vi.fn().mockReturnThis(),
|
select: vi.fn().mockReturnThis(),
|
||||||
eq: vi.fn().mockReturnThis(),
|
eq: vi.fn().mockReturnThis(),
|
||||||
maybeSingle: vi.fn(async () =>
|
maybeSingle: vi.fn(async () => {
|
||||||
opts.selectResult ?? { data: null, error: null },
|
if (opts.throwOnMaybeSingle) throw new Error('network down')
|
||||||
),
|
return opts.selectResult ?? { data: null, error: null }
|
||||||
|
}),
|
||||||
upsert,
|
upsert,
|
||||||
}
|
}
|
||||||
const from = vi.fn(() => builder)
|
const from = vi.fn(() => builder)
|
||||||
@@ -62,6 +64,14 @@ describe('resolveMemberContact', () => {
|
|||||||
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
|
const r = await resolveMemberContact(admin as never, 'space-1', 'member-1')
|
||||||
expect(r).toEqual({ email: null, displayName: null })
|
expect(r).toEqual({ email: null, displayName: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns null and never throws when the lookup itself throws (best-effort)', async () => {
|
||||||
|
const admin = mockAdmin({ throwOnMaybeSingle: true })
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
await expect(resolveMemberContact(admin as never, 'space-1', 'member-1')).resolves.toBeNull()
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('enqueueNotification', () => {
|
describe('enqueueNotification', () => {
|
||||||
@@ -155,4 +165,11 @@ describe('getSpaceName', () => {
|
|||||||
const admin = mockAdmin({ selectResult: { data: null, error: null } })
|
const admin = mockAdmin({ selectResult: { data: null, error: null } })
|
||||||
expect(await getSpaceName(admin as never, 'ghost')).toBe('')
|
expect(await getSpaceName(admin as never, 'ghost')).toBe('')
|
||||||
})
|
})
|
||||||
|
it('returns empty string when the lookup itself throws (best-effort)', async () => {
|
||||||
|
const admin = mockAdmin({ throwOnMaybeSingle: true })
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
await expect(getSpaceName(admin as never, 'space-1')).resolves.toBe('')
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
spy.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+128
-112
@@ -318,59 +318,65 @@ export async function updateSession(input: unknown) {
|
|||||||
// to 'cancelled', every signed-up member needs to know (the only other
|
// to 'cancelled', every signed-up member needs to know (the only other
|
||||||
// place that lives is /me, and they probably aren't checking it). One
|
// place that lives is /me, and they probably aren't checking it). One
|
||||||
// outbox row per (session, member); dedupe collapses a re-cancel.
|
// outbox row per (session, member); dedupe collapses a re-cancel.
|
||||||
|
// Wrapped: the session update is committed; transient DB errors in the
|
||||||
|
// fan-out must never surface to the action's result.
|
||||||
if (u.status === 'cancelled') {
|
if (u.status === 'cancelled') {
|
||||||
const admin = createAdminClient()
|
try {
|
||||||
const { data: sessRow } = await admin
|
const admin = createAdminClient()
|
||||||
.from('class_sessions')
|
const { data: sessRow } = await admin
|
||||||
.select('starts_at, ends_at, location, classes(title)')
|
.from('class_sessions')
|
||||||
.eq('id', u.sessionId)
|
.select('starts_at, ends_at, location, classes(title)')
|
||||||
.eq('space_id', member.space_id)
|
.eq('id', u.sessionId)
|
||||||
.maybeSingle()
|
.eq('space_id', member.space_id)
|
||||||
const sessShape = sessRow as {
|
.maybeSingle()
|
||||||
starts_at: string | null
|
const sessShape = sessRow as {
|
||||||
ends_at: string | null
|
starts_at: string | null
|
||||||
location: string | null
|
ends_at: string | null
|
||||||
classes?: { title: string | null } | null
|
location: string | null
|
||||||
} | null
|
classes?: { title: string | null } | null
|
||||||
const { data: signups } = await admin
|
} | null
|
||||||
.from('class_signups')
|
const { data: signups } = await admin
|
||||||
.select('member_id')
|
.from('class_signups')
|
||||||
.eq('space_id', member.space_id)
|
.select('member_id')
|
||||||
.eq('session_id', u.sessionId)
|
.eq('space_id', member.space_id)
|
||||||
.neq('status', 'cancelled')
|
.eq('session_id', u.sessionId)
|
||||||
const memberIds = Array.from(
|
.neq('status', 'cancelled')
|
||||||
new Set(((signups ?? []) as Array<{ member_id: string }>).map(s => s.member_id)),
|
const memberIds = Array.from(
|
||||||
)
|
new Set(((signups ?? []) as Array<{ member_id: string }>).map(s => s.member_id)),
|
||||||
if (memberIds.length > 0) {
|
)
|
||||||
const spaceName = await getSpaceName(admin, member.space_id)
|
if (memberIds.length > 0) {
|
||||||
const manageUrl = buildManageUrl(null)
|
const spaceName = await getSpaceName(admin, member.space_id)
|
||||||
for (const affectedMemberId of memberIds) {
|
const manageUrl = buildManageUrl(null)
|
||||||
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
|
for (const affectedMemberId of memberIds) {
|
||||||
if (!contact?.email) continue
|
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
|
||||||
const { subject, html, text } = renderClassEmail({
|
if (!contact?.email) continue
|
||||||
type: 'class_session_cancelled',
|
const { subject, html, text } = renderClassEmail({
|
||||||
spaceName,
|
type: 'class_session_cancelled',
|
||||||
memberName: contact.displayName,
|
spaceName,
|
||||||
className: sessShape?.classes?.title ?? '',
|
memberName: contact.displayName,
|
||||||
location: sessShape?.location ?? null,
|
className: sessShape?.classes?.title ?? '',
|
||||||
startsAt: sessShape?.starts_at ?? null,
|
location: sessShape?.location ?? null,
|
||||||
endsAt: sessShape?.ends_at ?? null,
|
startsAt: sessShape?.starts_at ?? null,
|
||||||
manageUrl,
|
endsAt: sessShape?.ends_at ?? null,
|
||||||
})
|
manageUrl,
|
||||||
await enqueueNotification(admin, {
|
})
|
||||||
spaceId: member.space_id,
|
await enqueueNotification(admin, {
|
||||||
memberId: affectedMemberId,
|
spaceId: member.space_id,
|
||||||
type: 'class_session_cancelled',
|
|
||||||
recipient: contact.email,
|
|
||||||
subject,
|
|
||||||
bodyHtml: html,
|
|
||||||
bodyText: text,
|
|
||||||
dedupeKey: classDedupeKey('class_session_cancelled', {
|
|
||||||
sessionId: u.sessionId,
|
|
||||||
memberId: affectedMemberId,
|
memberId: affectedMemberId,
|
||||||
}),
|
type: 'class_session_cancelled',
|
||||||
})
|
recipient: contact.email,
|
||||||
|
subject,
|
||||||
|
bodyHtml: html,
|
||||||
|
bodyText: text,
|
||||||
|
dedupeKey: classDedupeKey('class_session_cancelled', {
|
||||||
|
sessionId: u.sessionId,
|
||||||
|
memberId: affectedMemberId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[updateSession] class_session_cancelled fan-out failed:', e instanceof Error ? e.message : e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +609,8 @@ export async function signUpForClass(input: unknown) {
|
|||||||
// Signup confirmation to the affected member (target, not actor): a manager
|
// Signup confirmation to the affected member (target, not actor): a manager
|
||||||
// signing someone else up still emails the booked-for member. Registered
|
// signing someone else up still emails the booked-for member. Registered
|
||||||
// and waitlisted use different copy; the type derives from the RPC's
|
// and waitlisted use different copy; the type derives from the RPC's
|
||||||
// signup_status. Best-effort: never throws into this action.
|
// signup_status. Wrapped: the signup is already written by the RPC, so the
|
||||||
|
// email path must never surface an error to the action's result.
|
||||||
const signupType =
|
const signupType =
|
||||||
status === 'registered'
|
status === 'registered'
|
||||||
? ('class_signup_registered' as const)
|
? ('class_signup_registered' as const)
|
||||||
@@ -611,28 +618,32 @@ export async function signUpForClass(input: unknown) {
|
|||||||
? ('class_signup_waitlisted' as const)
|
? ('class_signup_waitlisted' as const)
|
||||||
: null
|
: null
|
||||||
if (signupType) {
|
if (signupType) {
|
||||||
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
|
try {
|
||||||
if (contact?.email) {
|
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
|
||||||
const { subject, html, text } = renderClassEmail({
|
if (contact?.email) {
|
||||||
type: signupType,
|
const { subject, html, text } = renderClassEmail({
|
||||||
spaceName: await getSpaceName(admin, member.space_id),
|
type: signupType,
|
||||||
memberName: contact.displayName,
|
spaceName: await getSpaceName(admin, member.space_id),
|
||||||
className: cls?.title ?? '',
|
memberName: contact.displayName,
|
||||||
location: (session.location as string | null) ?? null,
|
className: cls?.title ?? '',
|
||||||
startsAt: session.starts_at as string,
|
location: (session.location as string | null) ?? null,
|
||||||
endsAt: (session.ends_at as string | null) ?? null,
|
startsAt: session.starts_at as string,
|
||||||
manageUrl: buildManageUrl(null),
|
endsAt: (session.ends_at as string | null) ?? null,
|
||||||
})
|
manageUrl: buildManageUrl(null),
|
||||||
await enqueueNotification(admin, {
|
})
|
||||||
spaceId: member.space_id,
|
await enqueueNotification(admin, {
|
||||||
memberId: targetMemberId,
|
spaceId: member.space_id,
|
||||||
type: signupType,
|
memberId: targetMemberId,
|
||||||
recipient: contact.email,
|
type: signupType,
|
||||||
subject,
|
recipient: contact.email,
|
||||||
bodyHtml: html,
|
subject,
|
||||||
bodyText: text,
|
bodyHtml: html,
|
||||||
dedupeKey: classDedupeKey(signupType, { signupId: row.signup_id }),
|
bodyText: text,
|
||||||
})
|
dedupeKey: classDedupeKey(signupType, { signupId: row.signup_id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[signUpForClass] ${signupType} enqueue failed:`, e instanceof Error ? e.message : e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,47 +684,52 @@ export async function cancelMySignup(input: unknown) {
|
|||||||
|
|
||||||
// Waitlist promotion: someone else was bumped from waitlist into the
|
// Waitlist promotion: someone else was bumped from waitlist into the
|
||||||
// session by this cancel. Tell them, since the only place that information
|
// session by this cancel. Tell them, since the only place that information
|
||||||
// lives otherwise is /me. Best-effort: never throws into this action.
|
// lives otherwise is /me. Wrapped: cancel + promotion are already committed
|
||||||
|
// by class_cancel_tx; the email path must never surface an error.
|
||||||
if (row.promoted_id) {
|
if (row.promoted_id) {
|
||||||
const { data: promo } = await admin
|
try {
|
||||||
.from('class_signups')
|
const { data: promo } = await admin
|
||||||
.select(
|
.from('class_signups')
|
||||||
'id, member_id, class_sessions(starts_at, ends_at, location, classes(title))',
|
.select(
|
||||||
)
|
'id, member_id, class_sessions(starts_at, ends_at, location, classes(title))',
|
||||||
.eq('id', row.promoted_id)
|
)
|
||||||
.eq('space_id', member.space_id)
|
.eq('id', row.promoted_id)
|
||||||
.maybeSingle()
|
.eq('space_id', member.space_id)
|
||||||
const promotedMemberId = (promo?.member_id as string | null) ?? null
|
.maybeSingle()
|
||||||
const promoSession = (promo as { class_sessions?: {
|
const promotedMemberId = (promo?.member_id as string | null) ?? null
|
||||||
starts_at: string | null
|
const promoSession = (promo as { class_sessions?: {
|
||||||
ends_at: string | null
|
starts_at: string | null
|
||||||
location: string | null
|
ends_at: string | null
|
||||||
classes?: { title: string | null } | null
|
location: string | null
|
||||||
} | null } | null)?.class_sessions
|
classes?: { title: string | null } | null
|
||||||
if (promotedMemberId) {
|
} | null } | null)?.class_sessions
|
||||||
const contact = await resolveMemberContact(admin, member.space_id, promotedMemberId)
|
if (promotedMemberId) {
|
||||||
if (contact?.email) {
|
const contact = await resolveMemberContact(admin, member.space_id, promotedMemberId)
|
||||||
const { subject, html, text } = renderClassEmail({
|
if (contact?.email) {
|
||||||
type: 'class_signup_promoted',
|
const { subject, html, text } = renderClassEmail({
|
||||||
spaceName: await getSpaceName(admin, member.space_id),
|
type: 'class_signup_promoted',
|
||||||
memberName: contact.displayName,
|
spaceName: await getSpaceName(admin, member.space_id),
|
||||||
className: promoSession?.classes?.title ?? '',
|
memberName: contact.displayName,
|
||||||
location: promoSession?.location ?? null,
|
className: promoSession?.classes?.title ?? '',
|
||||||
startsAt: promoSession?.starts_at ?? null,
|
location: promoSession?.location ?? null,
|
||||||
endsAt: promoSession?.ends_at ?? null,
|
startsAt: promoSession?.starts_at ?? null,
|
||||||
manageUrl: buildManageUrl(null),
|
endsAt: promoSession?.ends_at ?? null,
|
||||||
})
|
manageUrl: buildManageUrl(null),
|
||||||
await enqueueNotification(admin, {
|
})
|
||||||
spaceId: member.space_id,
|
await enqueueNotification(admin, {
|
||||||
memberId: promotedMemberId,
|
spaceId: member.space_id,
|
||||||
type: 'class_signup_promoted',
|
memberId: promotedMemberId,
|
||||||
recipient: contact.email,
|
type: 'class_signup_promoted',
|
||||||
subject,
|
recipient: contact.email,
|
||||||
bodyHtml: html,
|
subject,
|
||||||
bodyText: text,
|
bodyHtml: html,
|
||||||
dedupeKey: classDedupeKey('class_signup_promoted', { signupId: row.promoted_id }),
|
bodyText: text,
|
||||||
})
|
dedupeKey: classDedupeKey('class_signup_promoted', { signupId: row.promoted_id }),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cancelMySignup] class_signup_promoted enqueue failed:', e instanceof Error ? e.message : e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-52
@@ -311,29 +311,36 @@ export async function reserveEquipment(input: unknown) {
|
|||||||
|
|
||||||
// Booking confirmation goes to the affected member (target), not the actor.
|
// Booking confirmation goes to the affected member (target), not the actor.
|
||||||
// A manager booking on behalf of someone else still emails the booked-for
|
// A manager booking on behalf of someone else still emails the booked-for
|
||||||
// member. Best-effort: the enqueue helper never throws into this action.
|
// member. Wrapped: the reservation row is already committed, so a transient
|
||||||
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
|
// DB error in the email path must NOT surface as an action error to the
|
||||||
if (contact?.email) {
|
// client. enqueueNotification is internally best-effort; the wrap covers
|
||||||
const { subject, html, text } = renderBookingEmail({
|
// resolveMemberContact, getSpaceName, and any other lookup that could throw.
|
||||||
type: 'booking_confirmed',
|
try {
|
||||||
spaceName: await getSpaceName(admin, member.space_id),
|
const contact = await resolveMemberContact(admin, member.space_id, targetMemberId)
|
||||||
memberName: contact.displayName,
|
if (contact?.email) {
|
||||||
equipmentName: (equip.name as string | null) ?? 'equipment',
|
const { subject, html, text } = renderBookingEmail({
|
||||||
location: (equip.location as string | null) ?? null,
|
type: 'booking_confirmed',
|
||||||
startsAt: r.starts_at,
|
spaceName: await getSpaceName(admin, member.space_id),
|
||||||
endsAt: r.ends_at,
|
memberName: contact.displayName,
|
||||||
manageUrl: buildManageUrl(null),
|
equipmentName: (equip.name as string | null) ?? 'equipment',
|
||||||
})
|
location: (equip.location as string | null) ?? null,
|
||||||
await enqueueNotification(admin, {
|
startsAt: r.starts_at,
|
||||||
spaceId: member.space_id,
|
endsAt: r.ends_at,
|
||||||
memberId: targetMemberId,
|
manageUrl: buildManageUrl(null),
|
||||||
type: 'booking_confirmed',
|
})
|
||||||
recipient: contact.email,
|
await enqueueNotification(admin, {
|
||||||
subject,
|
spaceId: member.space_id,
|
||||||
bodyHtml: html,
|
memberId: targetMemberId,
|
||||||
bodyText: text,
|
type: 'booking_confirmed',
|
||||||
dedupeKey: bookingDedupeKey('booking_confirmed', data.id as string),
|
recipient: contact.email,
|
||||||
})
|
subject,
|
||||||
|
bodyHtml: html,
|
||||||
|
bodyText: text,
|
||||||
|
dedupeKey: bookingDedupeKey('booking_confirmed', data.id as string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[reserveEquipment] booking_confirmed enqueue failed:', e instanceof Error ? e.message : e)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/equipment')
|
revalidatePath('/equipment')
|
||||||
@@ -382,36 +389,42 @@ export async function cancelReservation(input: unknown) {
|
|||||||
// Cancel emails fire ONLY when someone other than the affected member
|
// Cancel emails fire ONLY when someone other than the affected member
|
||||||
// cancelled (a manager cancelling on the member's behalf). A self-cancel is
|
// cancelled (a manager cancelling on the member's behalf). A self-cancel is
|
||||||
// silent: the actor already saw the UI confirm and an email would be noise.
|
// silent: the actor already saw the UI confirm and an email would be noise.
|
||||||
|
// Wrapped: cancel is committed; transient errors in the email path must not
|
||||||
|
// surface to the client.
|
||||||
if (!ownIt) {
|
if (!ownIt) {
|
||||||
const affectedMemberId = res.member_id as string
|
try {
|
||||||
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
|
const affectedMemberId = res.member_id as string
|
||||||
if (contact?.email) {
|
const contact = await resolveMemberContact(admin, member.space_id, affectedMemberId)
|
||||||
const { data: equip } = await admin
|
if (contact?.email) {
|
||||||
.from('equipment')
|
const { data: equip } = await admin
|
||||||
.select('name, location')
|
.from('equipment')
|
||||||
.eq('id', res.equipment_id as string)
|
.select('name, location')
|
||||||
.eq('space_id', member.space_id)
|
.eq('id', res.equipment_id as string)
|
||||||
.maybeSingle()
|
.eq('space_id', member.space_id)
|
||||||
const { subject, html, text } = renderBookingEmail({
|
.maybeSingle()
|
||||||
type: 'booking_cancelled',
|
const { subject, html, text } = renderBookingEmail({
|
||||||
spaceName: await getSpaceName(admin, member.space_id),
|
type: 'booking_cancelled',
|
||||||
memberName: contact.displayName,
|
spaceName: await getSpaceName(admin, member.space_id),
|
||||||
equipmentName: (equip?.name as string | null) ?? 'equipment',
|
memberName: contact.displayName,
|
||||||
location: (equip?.location as string | null) ?? null,
|
equipmentName: (equip?.name as string | null) ?? 'equipment',
|
||||||
startsAt: res.starts_at as string,
|
location: (equip?.location as string | null) ?? null,
|
||||||
endsAt: res.ends_at as string,
|
startsAt: res.starts_at as string,
|
||||||
manageUrl: buildManageUrl(null),
|
endsAt: res.ends_at as string,
|
||||||
})
|
manageUrl: buildManageUrl(null),
|
||||||
await enqueueNotification(admin, {
|
})
|
||||||
spaceId: member.space_id,
|
await enqueueNotification(admin, {
|
||||||
memberId: affectedMemberId,
|
spaceId: member.space_id,
|
||||||
type: 'booking_cancelled',
|
memberId: affectedMemberId,
|
||||||
recipient: contact.email,
|
type: 'booking_cancelled',
|
||||||
subject,
|
recipient: contact.email,
|
||||||
bodyHtml: html,
|
subject,
|
||||||
bodyText: text,
|
bodyHtml: html,
|
||||||
dedupeKey: bookingDedupeKey('booking_cancelled', v.data.reservationId),
|
bodyText: text,
|
||||||
})
|
dedupeKey: bookingDedupeKey('booking_cancelled', v.data.reservationId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cancelReservation] booking_cancelled enqueue failed:', e instanceof Error ? e.message : e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,22 +22,29 @@ export type MemberContact = {
|
|||||||
// Look up a member's email + display_name, scoped to the given space. Used
|
// 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
|
// 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)
|
// 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(
|
export async function resolveMemberContact(
|
||||||
admin: AdminClient,
|
admin: AdminClient,
|
||||||
spaceId: string,
|
spaceId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
): Promise<MemberContact | null> {
|
): Promise<MemberContact | null> {
|
||||||
const { data } = await admin
|
try {
|
||||||
.from('space_members')
|
const { data } = await admin
|
||||||
.select('email, display_name')
|
.from('space_members')
|
||||||
.eq('id', memberId)
|
.select('email, display_name')
|
||||||
.eq('space_id', spaceId)
|
.eq('id', memberId)
|
||||||
.maybeSingle()
|
.eq('space_id', spaceId)
|
||||||
if (!data) return null
|
.maybeSingle()
|
||||||
return {
|
if (!data) return null
|
||||||
email: (data.email as string | null) ?? null,
|
return {
|
||||||
displayName: (data.display_name as string | null) ?? null,
|
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
|
// 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
|
// 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> {
|
export async function getSpaceName(admin: AdminClient, spaceId: string): Promise<string> {
|
||||||
const { data } = await admin.from('spaces').select('name').eq('id', spaceId).maybeSingle()
|
try {
|
||||||
return (data?.name as string | null) ?? ''
|
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
|
// Build the member portal URL the way every enqueue site does. Header host
|
||||||
|
|||||||
@@ -35,3 +35,14 @@ AS $$
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
-- LOCKDOWN: unlike user_has_permission (a per-user boolean that is hard to
|
||||||
|
-- enumerate without already knowing target uids), members_with_permission
|
||||||
|
-- returns a SET of member_ids for any (space, permission) pair. By default
|
||||||
|
-- Postgres grants EXECUTE on a new function to PUBLIC, so without an explicit
|
||||||
|
-- REVOKE this function would let any authenticated PostgREST caller enumerate
|
||||||
|
-- "who has permission X in space Y" across spaces they are not even members
|
||||||
|
-- of. Lock it to service_role: the notifications fan-out goes through the
|
||||||
|
-- admin (service-role) client; no client should call this directly.
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.members_with_permission(uuid, text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.members_with_permission(uuid, text) TO service_role;
|
||||||
|
|||||||
@@ -1775,6 +1775,12 @@ $$;
|
|||||||
-- user_effective_roles fallback. Used by notification fan-outs (e.g. the
|
-- user_effective_roles fallback. Used by notification fan-outs (e.g. the
|
||||||
-- forms.manage admin alert) where N user_has_permission calls would be N
|
-- forms.manage admin alert) where N user_has_permission calls would be N
|
||||||
-- round-trips. Equivalent to scripts/047_members_with_permission.sql.
|
-- round-trips. Equivalent to scripts/047_members_with_permission.sql.
|
||||||
|
-- LOCKDOWN: unlike user_has_permission (a per-user boolean that is hard to
|
||||||
|
-- enumerate without already knowing target uids), this function returns a SET
|
||||||
|
-- of member_ids for any (space, permission) pair. Postgres grants EXECUTE on
|
||||||
|
-- new functions to PUBLIC by default, so an explicit REVOKE is required to
|
||||||
|
-- prevent any authenticated PostgREST caller from enumerating permission
|
||||||
|
-- holders across spaces they are not members of. Locked to service_role.
|
||||||
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
|
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
|
||||||
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
||||||
AS $$
|
AS $$
|
||||||
@@ -1791,6 +1797,8 @@ AS $$
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
$$;
|
$$;
|
||||||
|
REVOKE EXECUTE ON FUNCTION public.members_with_permission(uuid, text) FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.members_with_permission(uuid, text) TO service_role;
|
||||||
|
|
||||||
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.space_role_permissions ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE public.ops_acl ENABLE ROW LEVEL SECURITY;
|
||||||
|
|||||||
Reference in New Issue
Block a user