diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index 4b72c05eb3..30a324b39c 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/update.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/update.ts @@ -665,14 +665,15 @@ export async function updateWithPG( } } if (version.name === 'builtin' && greaterOrEqual(parse(plugin_version), parse('6.2.0'))) { - if (body.version_name === 'builtin' && version.name === 'builtin') { + // 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') } - else { - return updateError200(c, 'already_on_builtin', 'Already on builtin', { - version: '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) } 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..58436d9aad 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -456,8 +456,29 @@ 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() + + // 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) + expect(upToDateResponse.status).toBe(200) + 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