From f9a86fd173c7212e7f6b9b1b576a269e0f52ee52 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 03:42:55 +0300 Subject: [PATCH 1/5] fix(notifications): accept dashboard JWT for silent update pushes Management notification routes used API-key-only middleware, so the dashboard Bearer session failed with Invalid apikey when queuing silent update checks after promoting builtin. Co-authored-by: Cursor --- .../_backend/public/notifications/index.ts | 26 +++++++++---------- tests/native-notifications-api.unit.test.ts | 1 + 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/supabase/functions/_backend/public/notifications/index.ts b/supabase/functions/_backend/public/notifications/index.ts index a2f345ce8a..7d41d1ba8d 100644 --- a/supabase/functions/_backend/public/notifications/index.ts +++ b/supabase/functions/_backend/public/notifications/index.ts @@ -4,7 +4,7 @@ import type { NativeNotificationEvent, NativeNotificationPlatform, NativeNotific import type { Permission } from '../../utils/rbac.ts' import { sql } from 'drizzle-orm' import { BRES, createHono, parseBody, quickError, simpleError, simpleRateLimit, useCors } from '../../utils/hono.ts' -import { middlewareKey } from '../../utils/hono_middleware.ts' +import { middlewareAuth } from '../../utils/hono_middleware.ts' import { createNotificationEventProof, createNotificationIdentityProof, @@ -682,7 +682,7 @@ app.post('/sync', async (c) => { }) }) -app.post('/recipients/proof', middlewareKey(), async (c) => { +app.post('/recipients/proof', middlewareAuth(), async (c) => { const body = await parseBody(c) const appId = assertString(body.appId, 'appId', 128) const externalId = assertString(body.externalId, 'externalId', 512) @@ -690,7 +690,7 @@ app.post('/recipients/proof', middlewareKey(), async (c) => { return c.json({ identityProof: await createNotificationIdentityProof(c, appId, externalId) }) }) -app.post('/recipients/lookup', middlewareKey(), async (c) => { +app.post('/recipients/lookup', middlewareAuth(), async (c) => { const body = await parseBody<{ appId: string, externalId?: string, recipientKey?: string, limit?: number }>(c) const appId = assertString(body.appId, 'appId', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) @@ -701,18 +701,18 @@ app.post('/recipients/lookup', middlewareKey(), async (c) => { return c.json({ recipientKey, devices: devices.map(publicDevice), count: devices.length }) }) -app.get('/settings', middlewareKey(), async (c) => { +app.get('/settings', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) return c.json(await getNotificationSettings(c, appId)) }) -app.put('/settings', middlewareKey(), async (c) => { +app.put('/settings', middlewareAuth(), async (c) => { const body = await parseBody(c) return c.json(await upsertNotificationSettings(c, body)) }) -app.post('/badge', middlewareKey(), async (c) => { +app.post('/badge', middlewareAuth(), async (c) => { const body = await parseBody(c) const appId = assertString(body.appId, 'appId', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) @@ -740,7 +740,7 @@ app.post('/badge', middlewareKey(), async (c) => { return c.json({ ...BRES, campaignId, queued, queuedBuckets: plan.buckets.length, targeted: null, badgeRevision }) }) -app.post('/update-check', middlewareKey(), async (c) => { +app.post('/update-check', middlewareAuth(), async (c) => { const body = await parseBody(c) const appId = assertString(body.appId, 'appId', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) @@ -792,7 +792,7 @@ app.post('/update-check', middlewareKey(), async (c) => { return c.json({ ...BRES, campaignId, queued, queuedBuckets: plan.buckets.length, targeted: null }) }) -app.post('/send', middlewareKey(), async (c) => { +app.post('/send', middlewareAuth(), async (c) => { const body = await parseBody(c) const appId = assertString(body.appId, 'appId', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) @@ -828,7 +828,7 @@ app.post('/send', middlewareKey(), async (c) => { return c.json({ ...BRES, campaignId, queued, queuedBuckets: plan.buckets.length, targeted: null }) }) -app.get('/campaigns', middlewareKey(), async (c) => { +app.get('/campaigns', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) let pgClient: ReturnType | undefined @@ -850,12 +850,12 @@ app.get('/campaigns', middlewareKey(), async (c) => { } }) -app.post('/campaigns', middlewareKey(), async (c) => { +app.post('/campaigns', middlewareAuth(), async (c) => { const body = await parseBody(c) return c.json(await createCampaignRecord(c, body)) }) -app.get('/stats', middlewareKey(), async (c) => { +app.get('/stats', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) const days = Number(c.req.query('days') ?? 30) @@ -864,7 +864,7 @@ app.get('/stats', middlewareKey(), async (c) => { return c.json({ data }) }) -app.get('/providers', middlewareKey(), async (c) => { +app.get('/providers', middlewareAuth(), async (c) => { const appId = assertString(c.req.query('app_id'), 'app_id', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) let pgClient: ReturnType | undefined @@ -885,7 +885,7 @@ app.get('/providers', middlewareKey(), async (c) => { } }) -app.put('/providers', middlewareKey(), async (c) => { +app.put('/providers', middlewareAuth(), async (c) => { const body = await parseBody(c) const appId = assertString(body.appId, 'appId', 128) await assertAppPermission(c, NOTIFICATION_MANAGE_PERMISSION, appId) diff --git a/tests/native-notifications-api.unit.test.ts b/tests/native-notifications-api.unit.test.ts index 2c078d4d31..a7d3a45dea 100644 --- a/tests/native-notifications-api.unit.test.ts +++ b/tests/native-notifications-api.unit.test.ts @@ -15,6 +15,7 @@ const { })) vi.mock('../supabase/functions/_backend/utils/hono_middleware.ts', () => ({ + middlewareAuth: () => async (_c: unknown, next: () => Promise) => next(), middlewareKey: () => async (_c: unknown, next: () => Promise) => next(), middlewareV2: () => async (_c: unknown, next: () => Promise) => next(), })) From dcdda3c7269ac3373685a7774d7e38212e5c43aa Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 03:53:22 +0300 Subject: [PATCH 2/5] chore: retrigger CI after concurrency cancel Co-authored-by: Cursor From 671e8056cb7acd1bb21b636f6caeeecd79e308c3 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:47:24 +0300 Subject: [PATCH 3/5] fix(api): return success builtin payload for channel kill-switch Devices on an OTA bundle with a channel pointed at builtin need a success { version: 'builtin' } response (no error/kind) so the plugin can reset. Already-on-builtin stays on the dedicated already_on_builtin path. Co-authored-by: Cursor --- .../_backend/plugin_runtime/utils/update.ts | 15 +++++++-------- tests/updates.test.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index 4b72c05eb3..eb32e435bf 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/update.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/update.ts @@ -494,6 +494,8 @@ export async function updateWithPG( } // TODO: check why this event is send with wrong version_name await sendStatsAndDevice(c, device, [{ action: 'noNew', versionName: version.name }]) + if (version.name === 'builtin') + return updateError200(c, 'already_on_builtin', 'Already on builtin') return updateError200(c, 'no_new_version_available', 'No new version available') } @@ -665,14 +667,11 @@ export async function updateWithPG( } } if (version.name === 'builtin' && greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { - if (body.version_name === 'builtin' && version.name === 'builtin') { - return updateError200(c, 'already_on_builtin', 'Already on builtin') - } - else { - return updateError200(c, 'already_on_builtin', 'Already on builtin', { - version: 'builtin', - }) - } + // Channel kill-switch / revert: success payload with version builtin and NO error/kind, + // so the plugin reaches its builtin reset path (_reset / setNextBundle). + // (Already-on-builtin is handled above via version_name === version.name.) + await sendStatsAndDevice(c, device, [{ action: 'get', versionName: 'builtin' }]) + return c.json({ version: 'builtin' }, 200) } else if (version.name === 'builtin' && !greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { return updateError200(c, 'revert_to_builtin_plugin_version_too_old', 'revert_to_builtin used, but plugin version is too old') diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 57e78fae00..aa620a5a09 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -456,8 +456,19 @@ describe('[POST] /updates', () => { expect(response.status).toBe(200) const json = await response.json() - expect(json.error).toBe('already_on_builtin') + // Kill-switch: device on OTA must get success { version: 'builtin' } with no error/kind + // so the plugin can reset to the store binary. expect(json.version).toBe('builtin') + expect(json.error).toBeUndefined() + expect(json.kind).toBeUndefined() + + const alreadyOnBuiltin = getBaseData(APP_NAME_UPDATE) + alreadyOnBuiltin.version_name = 'builtin' + const upToDateResponse = await postUpdate(alreadyOnBuiltin) + expect(upToDateResponse.status).toBe(200) + const upToDateJson = await upToDateResponse.json() + expect(upToDateJson.error).toBe('already_on_builtin') + expect(upToDateJson.kind).toBe('up_to_date') } finally { await supabase From e7569aa2e0419f475991a777c0a1f174adaf40eb Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:58:33 +0300 Subject: [PATCH 4/5] fix(api): detect already-on-builtin after version_name rewrite plugin_parser maps version_name "builtin" to version_build, so compare version_name === version_build for the store-binary up-to-date case. Co-authored-by: Cursor --- .../_backend/plugin_runtime/utils/update.ts | 12 +++++++----- tests/updates.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index eb32e435bf..30a324b39c 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/update.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/update.ts @@ -494,8 +494,6 @@ export async function updateWithPG( } // TODO: check why this event is send with wrong version_name await sendStatsAndDevice(c, device, [{ action: 'noNew', versionName: version.name }]) - if (version.name === 'builtin') - return updateError200(c, 'already_on_builtin', 'Already on builtin') return updateError200(c, 'no_new_version_available', 'No new version available') } @@ -667,9 +665,13 @@ export async function updateWithPG( } } if (version.name === 'builtin' && greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { - // Channel kill-switch / revert: success payload with version builtin and NO error/kind, - // so the plugin reaches its builtin reset path (_reset / setNextBundle). - // (Already-on-builtin is handled above via version_name === version.name.) + // plugin_parser rewrites version_name "builtin" -> version_build, so we cannot + // compare version_name === "builtin" here. Store binary => names match build; + // OTA bundle => version_name is the live bundle and differs from version_build. + if (version_name === version_build) { + return updateError200(c, 'already_on_builtin', 'Already on builtin') + } + // Kill-switch: tell plugin to reset to the store binary (no error/kind). await sendStatsAndDevice(c, device, [{ action: 'get', versionName: 'builtin' }]) return c.json({ version: 'builtin' }, 200) } diff --git a/tests/updates.test.ts b/tests/updates.test.ts index aa620a5a09..58436d9aad 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -462,6 +462,8 @@ describe('[POST] /updates', () => { expect(json.error).toBeUndefined() expect(json.kind).toBeUndefined() + // Device on store binary: plugin sends version_name "builtin" (rewritten to + // version_build) or already reports version_name === version_build. const alreadyOnBuiltin = getBaseData(APP_NAME_UPDATE) alreadyOnBuiltin.version_name = 'builtin' const upToDateResponse = await postUpdate(alreadyOnBuiltin) @@ -469,6 +471,14 @@ describe('[POST] /updates', () => { const upToDateJson = await upToDateResponse.json() expect(upToDateJson.error).toBe('already_on_builtin') expect(upToDateJson.kind).toBe('up_to_date') + + const alreadyOnNativeBuild = getBaseData(APP_NAME_UPDATE) + alreadyOnNativeBuild.version_name = alreadyOnNativeBuild.version_build + const nativeResponse = await postUpdate(alreadyOnNativeBuild) + expect(nativeResponse.status).toBe(200) + const nativeJson = await nativeResponse.json() + expect(nativeJson.error).toBe('already_on_builtin') + expect(nativeJson.kind).toBe('up_to_date') } finally { await supabase From a8e30a77ef218ca79cb9a8f1b40d9fd9f4371db2 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 05:13:58 +0300 Subject: [PATCH 5/5] fix(test): align countDevices mock with options-arg signature Main changed countDevices to take a platform/updatedAt options object; update the private analytics unit expectation so merge CI stays green. Co-authored-by: Cursor --- tests/private-analytics-validation.unit.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/private-analytics-validation.unit.test.ts b/tests/private-analytics-validation.unit.test.ts index bbe15725b5..59f8a5f89e 100644 --- a/tests/private-analytics-validation.unit.test.ts +++ b/tests/private-analytics-validation.unit.test.ts @@ -161,7 +161,10 @@ describe('private analytics route validation', () => { [], '2.0.0', undefined, - 'android', + { + platform: 'android', + updatedAt: { gt: undefined, lte: undefined }, + }, ) }) })