Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
65c620c
feat(backend): track first organization lifecycle in Bento
WcaleNieWolny Aug 3, 2026
d61580a
test(backend): strengthen Bento lifecycle coverage
WcaleNieWolny Aug 3, 2026
6c6f9f6
fix(backend): harden Bento lifecycle updates
WcaleNieWolny Aug 3, 2026
526bd93
fix(backend): fix Bento lifecycle ordering
WcaleNieWolny Aug 3, 2026
b25a1ff
fix(backend): require one queued Bento email change
WcaleNieWolny Aug 3, 2026
0ef08e6
fix(backend): suppress first-org recovery on email changes
WcaleNieWolny Aug 3, 2026
cda36d1
docs: document first-org recovery safety guard
WcaleNieWolny Aug 3, 2026
03b4509
feat(db): queue active organization access changes
WcaleNieWolny Aug 3, 2026
a543df5
fix(backend): harden first-org lifecycle ordering
WcaleNieWolny Aug 3, 2026
56cd6e1
fix(backend): address first-org review findings
WcaleNieWolny Aug 3, 2026
8811d6a
fix(backend): harden first-org trigger validation
WcaleNieWolny Aug 3, 2026
15f704b
fix(backend): harden onboarding deletion races
WcaleNieWolny Aug 3, 2026
335af5b
fix(backend): harden onboarding cleanup retries
WcaleNieWolny Aug 3, 2026
0154b7c
fix(backend): reconcile Bento deletion races
WcaleNieWolny Aug 3, 2026
7df4311
test(backend): assert lock-timeout provisioning
WcaleNieWolny Aug 3, 2026
f24c3c1
fix(backend): abort timed-out Bento cleanup
WcaleNieWolny Aug 3, 2026
4d0f4d2
test(backend): prove Bento abort ordering
WcaleNieWolny Aug 3, 2026
6c3f285
fix(backend): preserve Bento reconciliation errors
WcaleNieWolny Aug 3, 2026
476cade
fix(backend): harden Bento lifecycle ordering
WcaleNieWolny Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { app as on_organization_create } from '../../supabase/functions/_backend
import { app as on_organization_delete } from '../../supabase/functions/_backend/triggers/on_organization_delete.ts'
import { app as on_user_create } from '../../supabase/functions/_backend/triggers/on_user_create.ts'
import { app as on_user_delete } from '../../supabase/functions/_backend/triggers/on_user_delete.ts'
import { app as on_user_org_access } from '../../supabase/functions/_backend/triggers/on_user_org_access.ts'
import { app as on_user_update } from '../../supabase/functions/_backend/triggers/on_user_update.ts'
import { app as on_version_create } from '../../supabase/functions/_backend/triggers/on_version_create.ts'
import { app as on_version_delete } from '../../supabase/functions/_backend/triggers/on_version_delete.ts'
Expand Down Expand Up @@ -191,6 +192,7 @@ appTriggers.route('/on_organization_delete', on_organization_delete)
appTriggers.route('/on_user_create', on_user_create)
appTriggers.route('/on_user_update', on_user_update)
appTriggers.route('/on_user_delete', on_user_delete)
appTriggers.route('/on_user_org_access', on_user_org_access)
appTriggers.route('/on_version_create', on_version_create)
appTriggers.route('/on_version_update', on_version_update)
appTriggers.route('/on_version_delete', on_version_delete)
Expand Down
22 changes: 22 additions & 0 deletions docs/BENTO_EMAIL_PREFERENCES_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ Tag does NOT contain: credit_usage_disabled
Tag does NOT contain: onboarding_disabled
```

#### 3a. First-Organization Recovery

**Entry event**: `user:registered_without_org`

**Exit or goal event**: `user:joined_org`

Capgo adds `onboarding:awaiting_first_org` when a direct registration still has no active organization access. When Capgo observes active direct organization access, it removes that tag and permanently adds `onboarding:first_org_recovery_suppressed` in the same subscriber update. The permanent tag keeps recovery disabled even if Bento applies an older asynchronous registration import afterward. A normalized email-address change permanently adds the same suppression tag to both the old and new Bento aliases so asynchronous subscriber updates can never send this recovery email to a stale address. Deleting a user applies the same permanent suppression and unsubscribes the Bento subscriber before organization-dependent cleanup can return.

Configure the workflow to enroll each subscriber at most once. Queue delivery is at least once, so both lifecycle events can repeat for the same user. Treat `user:joined_org` as a fact used to exit or complete the workflow, not as another entry trigger; the subscriber-level enrollment rule makes repeated `user:registered_without_org` facts harmless.

After the workflow delay, add a final decision immediately before the send step. All three conditions must still be true:

```text
Tag contains: onboarding:awaiting_first_org
Tag does NOT contain: onboarding:first_org_recovery_suppressed
Tag does NOT contain: onboarding_disabled
```

Do not check the suppression tag only when the workflow starts. It is a monotonic safety opt-out that can be added while the workflow is waiting.
Keep this recovery email non-transactional so Bento's global unsubscribe state remains a delivery gate.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#### 4. Weekly Statistics

**Events**: `user:weekly_stats`
Expand Down Expand Up @@ -159,6 +180,7 @@ Tag does NOT contain: device_error_disabled
After configuring Bento, verify the following:

- [ ] Each automation has the correct exclusion filter for its disabled tag
- [ ] The first-organization recovery workflow re-checks `onboarding:awaiting_first_org`, `onboarding:first_org_recovery_suppressed`, and `onboarding_disabled` immediately before sending
- [ ] Test by disabling a preference for a test user and confirming they don't receive that email type
- [ ] Test by re-enabling the preference and confirming they DO receive that email type
- [ ] Verify existing users without the `email_preferences` column still receive emails (tags default to not present = enabled)
Expand Down
11 changes: 10 additions & 1 deletion supabase/functions/_backend/triggers/on_user_create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { Database } from '../utils/supabase.types.ts'
import { Hono } from 'hono/tiny'
import { normalizeBentoEmail, prepareNewUserProvisioning, syncBentoFirstOrgOnUserCreate } from '../utils/bento_first_org.ts'
import { BRES, middlewareAPISecret, triggerValidator } from '../utils/hono.ts'
import { cloudlog } from '../utils/logging.ts'
import { createApiKey } from '../utils/supabase.ts'
Expand All @@ -12,9 +13,17 @@ export const app = new Hono<MiddlewareKeyVariables>()
app.post('/', middlewareAPISecret, triggerValidator('users', 'INSERT'), async (c) => {
const record = c.get('webhookBody') as Database['public']['Tables']['users']['Row']
cloudlog({ requestId: c.get('requestId'), message: 'record', record })
// Configured Bento failures deliberately fail this queue message. Its
// lifecycle tag writes are idempotent, and retrying avoids silently losing
// the only recovery enrollment for a newly registered user.
const shouldProvisionUser = await prepareNewUserProvisioning(c, record)
if (!shouldProvisionUser)
return c.json(BRES)

await createApiKey(c, record.id)
cloudlog({ requestId: c.get('requestId'), message: 'createCustomer stripe' })
await syncUserPreferenceTags(c, record.email, record)
await syncUserPreferenceTags(c, normalizeBentoEmail(record.email), record)
await syncBentoFirstOrgOnUserCreate(c, record)
Comment thread
WcaleNieWolny marked this conversation as resolved.
// "User Joined" should represent a self-signup (technical user expected to onboard),
// not an account created by accepting an org invite.
await sendEventToTracking(c, {
Expand Down
144 changes: 106 additions & 38 deletions supabase/functions/_backend/triggers/on_user_delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { Database } from '../utils/supabase.types.ts'
import { Hono } from 'hono/tiny'
import { unsubscribeBento } from '../utils/bento.ts'
import { BRES, middlewareAPISecret, triggerValidator } from '../utils/hono.ts'
import { normalizeBentoEmail, suppressAndUnsubscribeDeletedUserRecovery } from '../utils/bento_first_org.ts'
import { BRES, middlewareAPISecret, quickError, triggerValidator } from '../utils/hono.ts'
import { cloudlog } from '../utils/logging.ts'
import { cancelSubscription } from '../utils/stripe.ts'
import { supabaseAdmin } from '../utils/supabase.ts'

export const app = new Hono<MiddlewareKeyVariables>()

const BENTO_USER_DELETE_TIMEOUT_MS = 5_000

interface RbacBinding {
org_id?: string | null
principal_id?: string | null
Expand Down Expand Up @@ -253,7 +256,6 @@ function getSingleSuperAdminOrgs(
function buildCleanupPromises(
c: Context,
orgs: Array<{ customer_id: string | null, management_email: string | null }> | null,
record: Database['public']['Tables']['users']['Row'],
) {
const promises: Promise<unknown>[] = []
if (orgs && orgs.length > 0) {
Expand All @@ -269,11 +271,51 @@ function buildCleanupPromises(
}
}

if (record.email) {
promises.push(unsubscribeBento(c, record.email))
return promises
}

async function suppressDeletedUserInBento(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
c: Context,
record: Database['public']['Tables']['users']['Row'],
signal?: AbortSignal,
) {
if (!record.email)
return true

const email = normalizeBentoEmail(record.email)
try {
await suppressAndUnsubscribeDeletedUserRecovery(c, email, signal)
return true
}
catch (error) {
logFailure(c, 'Bento user deletion cleanup failed', error)
return false
}
}

async function suppressDeletedUserInBentoWithinBudget(
c: Context,
record: Database['public']['Tables']['users']['Row'],
) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), BENTO_USER_DELETE_TIMEOUT_MS)
try {
const result = await suppressDeletedUserInBento(c, record, controller.signal)
if (controller.signal.aborted) {
logFailure(c, 'Bento user deletion cleanup timed out')
return false
}
return result
}
finally {
clearTimeout(timeoutId)
}
}

return promises
function finishDeleteUserResponse(c: Context, bentoCleanupSucceeded: boolean, resourceCleanupSucceeded: boolean) {
if (!bentoCleanupSucceeded || !resourceCleanupSucceeded)
quickError(500, 'user_delete_cleanup_failed', 'User deletion cleanup failed')
return c.json(BRES)
}

async function deleteUserImages(
Expand All @@ -282,74 +324,75 @@ async function deleteUserImages(
userId: string,
) {
try {
const { data: files } = await supabase
const { data: files, error: listError } = await supabase
.storage
.from('images')
.list(userId)
if (listError) {
logFailure(c, 'user image listing failed', listError)
return false
}

if (files && files.length > 0) {
const filePaths = files.map(file => `${userId}/${file.name}`)
await supabase
const { error: removeError } = await supabase
.storage
.from('images')
.remove(filePaths)
if (removeError) {
logFailure(c, 'user image deletion failed', removeError)
return false
}
cloudlog({ requestId: c.get('requestId'), message: 'deleted user images', count: files.length, user_id: userId })
}
return true
}
catch (error) {
cloudlog({ requestId: c.get('requestId'), message: 'error deleting user images', error, user_id: userId })
return false
}
}

// on_user_delete - this is called 30 days before the user is actually deleted
// This function is used to cancel the subscriptions of the user's organizations
async function deleteUser(c: Context, record: Database['public']['Tables']['users']['Row']) {
async function cleanupDeletedUserResources(c: Context, record: Database['public']['Tables']['users']['Row']) {
// Process user deletion with timeout protection
const startTime = Date.now()
const supabase = supabaseAdmin(c)
const now = new Date()

// 1. Find organizations where this user is the only RBAC super admin
const directRbacBindings = await fetchDirectRbacBindings(c, supabase, record.id)
if (!directRbacBindings) {
return c.json(BRES)
}
if (!directRbacBindings)
return false

const groupIds = await fetchUserGroupIds(c, supabase, record.id)
if (!groupIds) {
return c.json(BRES)
}
if (!groupIds)
return false

const groupRbacBindings = await fetchGroupRbacBindings(c, supabase, groupIds)
if (!groupRbacBindings) {
return c.json(BRES)
}
if (!groupRbacBindings)
return false

const orgIds = collectCandidateOrgIds(directRbacBindings, groupRbacBindings, now)
if (orgIds.length === 0) {
return c.json(BRES)
}
if (orgIds.length === 0)
return true

// For each org where user is super admin, check if they are the only one
const rbacUserAdmins = await fetchRbacUserAdmins(c, supabase, orgIds)
if (!rbacUserAdmins) {
return c.json(BRES)
}
if (!rbacUserAdmins)
return false

const rbacGroupAdmins = await fetchRbacGroupAdmins(c, supabase, orgIds)
if (!rbacGroupAdmins) {
return c.json(BRES)
}
if (!rbacGroupAdmins)
return false

const activeGroupBindings = rbacGroupAdmins.filter(binding => isBindingActive(binding, now))
const rbacGroupIds = activeGroupBindings
.map(binding => getBindingPrincipalId(binding))
.filter((groupId): groupId is string => Boolean(groupId))

const groupMembers = await fetchGroupMembers(c, supabase, rbacGroupIds)
if (!groupMembers) {
return c.json(BRES)
}
if (!groupMembers)
return false

const groupMembersByGroup = buildGroupMembersByGroup(groupMembers)
const orgAdminUsers = buildOrgAdminUsers(
Expand All @@ -361,19 +404,33 @@ async function deleteUser(c: Context, record: Database['public']['Tables']['user

const singleSuperAdminOrgs = getSingleSuperAdminOrgs(orgIds, orgAdminUsers, record.id)

if (singleSuperAdminOrgs.length === 0) {
return c.json(BRES)
}
if (singleSuperAdminOrgs.length === 0)
return true

const { data: orgs } = await supabaseAdmin(c)
const { data: orgs, error: orgLookupError } = await supabaseAdmin(c)
.from('orgs')
.select('id, customer_id, management_email')
.in('id', singleSuperAdminOrgs)
if (orgLookupError) {
logFailure(c, 'organization cleanup lookup failed', orgLookupError)
return false
}

const promises = buildCleanupPromises(c, orgs ?? null, record)
const promises = buildCleanupPromises(c, orgs ?? null)
promises.push(deleteUserImages(c, supabase, record.id))

await Promise.all(promises)
let cleanupResults: unknown[]
try {
cleanupResults = await Promise.all(promises)
}
catch (error) {
logFailure(c, 'user resource cleanup failed', error)
return false
}
if (cleanupResults.includes(false)) {
logFailure(c, 'user resource cleanup reported a failure')
return false
}

// 4. Track performance metrics
const endTime = Date.now()
Expand All @@ -385,8 +442,19 @@ async function deleteUser(c: Context, record: Database['public']['Tables']['user
duration_ms: duration,
user_id: record.id,
})
return true
}

return c.json(BRES)
// on_user_delete - this is called 30 days before the user is actually deleted.
// Start provider cleanup and core subscription/image cleanup together so a
// slow Bento request cannot consume the queue handler's entire 15-second
// budget before Stripe cancellation begins.
async function deleteUser(c: Context, record: Database['public']['Tables']['users']['Row']) {
const [bentoCleanupSucceeded, resourceCleanupSucceeded] = await Promise.all([
suppressDeletedUserInBentoWithinBudget(c, record),
cleanupDeletedUserResources(c, record),
])
return finishDeleteUserResponse(c, bentoCleanupSucceeded, resourceCleanupSucceeded)
}

app.post('/', middlewareAPISecret, triggerValidator('users', 'DELETE'), async (c) => {
Expand Down
17 changes: 17 additions & 0 deletions supabase/functions/_backend/triggers/on_user_org_access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Database } from '../utils/supabase.types.ts'
import { z } from 'zod'
import { syncBentoFirstOrgOnRoleBindingWrite } from '../utils/bento_first_org.ts'
import { BRES, createHono, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts'
import { version } from '../utils/version.ts'

export const app = createHono('', version)

app.post('/', middlewareAPISecret, triggerValidator('role_bindings', ['INSERT', 'UPDATE']), async (c) => {
const record = c.get('webhookBody') as Partial<Database['public']['Tables']['role_bindings']['Row']>
const bindingId = z.uuid().safeParse(record.id)
if (!bindingId.success)
throw simpleError('invalid_payload', 'Invalid role binding id', { id: record.id })

await syncBentoFirstOrgOnRoleBindingWrite(c, bindingId.data)
return c.json(BRES)
})
15 changes: 13 additions & 2 deletions supabase/functions/_backend/triggers/on_user_update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { Database } from '../utils/supabase.types.ts'
import { Hono } from 'hono/tiny'
import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts'
import { normalizeBentoEmail, syncBentoFirstOrgOnEmailChange } from '../utils/bento_first_org.ts'
import { BRES, middlewareAPISecret, quickError, simpleError, triggerValidator } from '../utils/hono.ts'
import { cleanStoredImageMetadata } from '../utils/image.ts'
import { cloudlog } from '../utils/logging.ts'
import { createApiKey } from '../utils/supabase.ts'
Expand All @@ -21,8 +22,18 @@ app.post('/', middlewareAPISecret, triggerValidator('users', 'UPDATE'), async (c
cloudlog({ requestId: c.get('requestId'), message: 'No id' })
throw simpleError('no_id', 'No id', { record })
}

const newEmail = normalizeBentoEmail(record.email)
const oldEmail = oldRecord?.email ? normalizeBentoEmail(oldRecord.email) : undefined
if (oldEmail && oldEmail !== newEmail) {
const suppressionResult = await syncBentoFirstOrgOnEmailChange(c, oldEmail, newEmail)
if (suppressionResult === false)
quickError(500, 'bento_first_org_suppression_failed', 'Bento first-organization recovery suppression failed')
}

await syncUserPreferenceTags(c, newEmail, record, oldRecord, oldEmail)
Comment thread
WcaleNieWolny marked this conversation as resolved.

await createApiKey(c, record.id)
await syncUserPreferenceTags(c, record.email, record, oldRecord, oldRecord?.email)

const newImagePath = record.image_url
const oldImagePath = oldRecord?.image_url
Expand Down
Loading
Loading