From cd898c3b136d46283f6c21f0077ee8edcd2136aa Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:02:29 +0300 Subject: [PATCH 1/6] fix(api): allow JWT for notifications and fix builtin kill-switch Dashboard silent push used JWT against middlewareKey-only routes. Channel builtin revert returned already_on_builtin with kind, so the plugin never reached its reset path for devices still on an OTA bundle. Co-authored-by: Cursor --- .../_backend/plugin_runtime/utils/update.ts | 12 ++++----- .../_backend/public/notifications/index.ts | 26 +++++++++---------- tests/native-notifications-api.unit.test.ts | 1 + tests/updates.test.ts | 13 +++++++++- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index 4b72c05eb3..3426ca38a3 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/update.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/update.ts @@ -665,14 +665,14 @@ export async function updateWithPG( } } if (version.name === 'builtin' && greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { - if (body.version_name === 'builtin' && version.name === 'builtin') { + // Already on builtin: up-to-date signal (error + kind). Plugin treats this as no-op. + if (body.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). + 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/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(), })) 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 595ca63937815f6453f2ff0bc44d771aed02f711 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:10:34 +0300 Subject: [PATCH 2/6] fix(api): return already_on_builtin when device already matches The version_name equality path ran before the builtin branch, so already-on-builtin devices never hit the dedicated error code. Co-authored-by: Cursor --- supabase/functions/_backend/plugin_runtime/utils/update.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index 3426ca38a3..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,12 +667,9 @@ export async function updateWithPG( } } if (version.name === 'builtin' && greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { - // Already on builtin: up-to-date signal (error + kind). Plugin treats this as no-op. - if (body.version_name === 'builtin') { - return updateError200(c, 'already_on_builtin', 'Already on 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) } From 39180c6752dbfefe700e77f1fb19a9ef3bc893f4 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:34:40 +0300 Subject: [PATCH 3/6] chore: retrigger CI after concurrency cancel Co-authored-by: Cursor From 6689590a4c108007db24de2d434603c59e0d42b5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 04:47:26 +0300 Subject: [PATCH 4/6] 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 up-to-date case and keep the OTA kill-switch success payload when they differ. 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 fa50956b7a38eb0c7b5df18e5742e3a1e03b44a5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 05:16:00 +0300 Subject: [PATCH 5/6] chore: retrigger push-only CI Co-authored-by: Cursor From d22c47d87a478ac0b3454265043c3c99dd823ba5 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 31 Jul 2026 05:29:56 +0300 Subject: [PATCH 6/6] chore: retrigger CI for shard 5 flake Co-authored-by: Cursor