diff --git a/package-lock.json b/package-lock.json
index b0eb7b2..7e59644 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
"drizzle-orm": "^0.45.2",
"envalid": "^8.1.1",
"i18next": "^26.2.0",
- "nitro": "^3.0.260415-beta",
+ "nitro": "^3.0.260429-beta",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-error-boundary": "^6.0.0",
@@ -5417,7 +5417,7 @@
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -5578,7 +5578,7 @@
"version": "25.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -9376,7 +9376,7 @@
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -12123,6 +12123,7 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -12150,9 +12151,9 @@
"license": "MIT"
},
"node_modules/nitro": {
- "version": "3.0.260415-beta",
- "resolved": "https://registry.npmjs.org/nitro/-/nitro-3.0.260415-beta.tgz",
- "integrity": "sha512-J0ntJERWtIdvweZdmkCiF8eOFvP9fIAJR2gpeIDrHbAlYavK41WQfADo/YoZ/LF7RMTZBiPaH/pt2s/nPru9Iw==",
+ "version": "3.0.260429-beta",
+ "resolved": "https://registry.npmjs.org/nitro/-/nitro-3.0.260429-beta.tgz",
+ "integrity": "sha512-KweLVCUN5X9v9g+4yxAyRcz3FcOlnjmt9FyrAIWDxJETJmNT7I0JV0clgsONjo2nI0U5gwedXYA3RaNtF5XWzg==",
"license": "MIT",
"dependencies": {
"consola": "^3.4.2",
@@ -12165,7 +12166,7 @@
"ocache": "^0.1.4",
"ofetch": "^2.0.0-alpha.3",
"ohash": "^2.0.11",
- "rolldown": "^1.0.0-rc.15",
+ "rolldown": "^1.0.0-rc.17",
"srvx": "^0.11.15",
"unenv": "^2.0.0-rc.24",
"unstorage": "^2.0.0-alpha.7"
@@ -12177,11 +12178,11 @@
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "@vercel/queue": "^0.1.4",
+ "@vercel/queue": "^0.1.6",
"dotenv": "*",
"giget": "*",
"jiti": "^2.6.1",
- "rollup": "^4.60.1",
+ "rollup": "^4.60.2",
"vite": "^7 || ^8",
"xml2js": "^0.6.2",
"zephyr-agent": "^0.2.0"
@@ -12955,6 +12956,7 @@
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -13822,7 +13824,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -14955,7 +14957,7 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
@@ -14978,6 +14980,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14994,6 +14997,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15010,6 +15014,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15026,6 +15031,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15042,6 +15048,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15058,6 +15065,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15074,6 +15082,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15090,6 +15099,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15106,6 +15116,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15122,6 +15133,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15138,6 +15150,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15154,6 +15167,7 @@
"cpu": [
"loong64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15170,6 +15184,7 @@
"cpu": [
"mips64el"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15186,6 +15201,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15202,6 +15218,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15218,6 +15235,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15234,6 +15252,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15250,6 +15269,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15266,6 +15286,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15282,6 +15303,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15298,6 +15320,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15314,6 +15337,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15330,6 +15354,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15346,6 +15371,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15362,6 +15388,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15378,6 +15405,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -15391,7 +15419,7 @@
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
- "devOptional": true,
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -15457,7 +15485,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -15525,7 +15553,7 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/unenv": {
@@ -16020,6 +16048,7 @@
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
diff --git a/package.json b/package.json
index bbeae63..8bed862 100644
--- a/package.json
+++ b/package.json
@@ -81,7 +81,7 @@
"drizzle-orm": "^0.45.2",
"envalid": "^8.1.1",
"i18next": "^26.2.0",
- "nitro": "^3.0.260415-beta",
+ "nitro": "^3.0.260429-beta",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-error-boundary": "^6.0.0",
diff --git a/src/components/dashboard/DashboardShell.tsx b/src/components/dashboard/DashboardShell.tsx
index 0d3fec3..458f14c 100644
--- a/src/components/dashboard/DashboardShell.tsx
+++ b/src/components/dashboard/DashboardShell.tsx
@@ -99,6 +99,15 @@ function DashboardContent({ onStateChange }: Props) {
}
}
+ async function handleDetach() {
+ try {
+ await BunnyCdnGhost.detachPullZone()
+ onStateChange()
+ } catch (e) {
+ setMsg({ text: localizeError(e, t), status: 'danger' })
+ }
+ }
+
if (!pullZone?.exists) return {t('dashboard.noPullZone')}
const pz = resolvePullZone(pullZone)
const onError = (m: string) => setMsg({ text: m, status: 'danger' })
@@ -168,7 +177,7 @@ function DashboardContent({ onStateChange }: Props) {
{t('dashboard.tabs.account')}
-
+
diff --git a/src/components/dashboard/tabs/AccountTab.tsx b/src/components/dashboard/tabs/AccountTab.tsx
index 81d6840..a26e866 100644
--- a/src/components/dashboard/tabs/AccountTab.tsx
+++ b/src/components/dashboard/tabs/AccountTab.tsx
@@ -25,9 +25,10 @@ interface Props {
onPatched: () => void
onError: (message: string) => void
onDelete: () => Promise
+ onDetach: () => Promise
}
-export function AccountTab({ onPatched, onError, onDelete }: Props) {
+export function AccountTab({ onPatched, onError, onDelete, onDetach }: Props) {
const { t } = useTranslation()
const apiKeyStatus = BunnyCdnGhost.getApiKeyStatus().use()
@@ -63,14 +64,24 @@ export function AccountTab({ onPatched, onError, onDelete }: Props) {
}
const [confirmDelete, setConfirmDelete] = useState(false)
- const [deleting, setDeleting] = useState(false)
+ const [busy, setBusy] = useState<'delete' | 'detach' | null>(null)
async function handleDelete() {
- setDeleting(true)
+ setBusy('delete')
try {
await onDelete()
} finally {
- setDeleting(false)
+ setBusy(null)
+ setConfirmDelete(false)
+ }
+ }
+
+ async function handleDetach() {
+ setBusy('detach')
+ try {
+ await onDetach()
+ } finally {
+ setBusy(null)
setConfirmDelete(false)
}
}
@@ -133,16 +144,31 @@ export function AccountTab({ onPatched, onError, onDelete }: Props) {
) : (
-
-
- {t('dashboard.dangerZone.confirmWarning')}
-
-
+
+ {t('dashboard.dangerZone.confirmChoice')}
+
+
+ {t('dashboard.dangerZone.detachHint')}
+
+
+
+
+ {t('dashboard.dangerZone.deleteHint')}
{/* @ts-expect-error — flow remote typing */}
-
+
+
+ setConfirmDelete(false)}
+ isDisabled={busy !== null}
+ >
{t('common.actions.cancel')}
diff --git a/src/domain/pull-zone.ts b/src/domain/pull-zone.ts
index b5acb83..b105c63 100644
--- a/src/domain/pull-zone.ts
+++ b/src/domain/pull-zone.ts
@@ -307,6 +307,27 @@ export async function removeCustomHostname(
return { dnsCleared }
}
+/**
+ * Drops the pull-zone link from the extension without touching bunny.net.
+ * Use case: the user wants to uninstall the extension but keep the zone at
+ * bunny.net (and continue managing it from the bunny.net dashboard).
+ *
+ * The mittwald CNAME is intentionally *not* cleaned up here: the user
+ * keeps the zone, so the CNAME is probably what they want to keep too.
+ */
+export function detachPullZone(db: AppDatabase, extensionInstanceId: string): { pullZoneId: number } {
+ const pullZone = db.select().from(pullZones).where(eq(pullZones.instanceId, extensionInstanceId)).get()
+ if (!pullZone) {
+ throw createAppError(ErrorType.NOT_FOUND, 'Keine Pull Zone vorhanden.', {
+ retryable: false,
+ code: 'PULL_ZONE_NOT_FOUND',
+ })
+ }
+ log.info(`Detaching pull zone ${pullZone.id} from instance ${extensionInstanceId} (bunny zone preserved)`)
+ db.delete(pullZones).where(eq(pullZones.instanceId, extensionInstanceId)).run()
+ return { pullZoneId: pullZone.id }
+}
+
export function loadInstanceAndPullZone(db: AppDatabase, extensionInstanceId: string) {
const instance = db.select().from(extensionInstances).where(eq(extensionInstances.id, extensionInstanceId)).get()
if (!instance?.encryptedApiKey) {
diff --git a/src/ghosts.ts b/src/ghosts.ts
index 5977222..fb4fe2f 100644
--- a/src/ghosts.ts
+++ b/src/ghosts.ts
@@ -5,6 +5,7 @@ import {
createPullZoneFn,
deleteApiKeyFn,
deletePullZoneFn,
+ detachPullZoneFn,
getApiKeyStatusFn,
getDomainsFn,
getPullZoneStatusFn,
@@ -22,6 +23,7 @@ const bunnycdnClient = {
getDomains: getDomainsFn,
createPullZone: createPullZoneFn,
deletePullZone: deletePullZoneFn,
+ detachPullZone: detachPullZoneFn,
getPullZoneStatus: getPullZoneStatusFn,
getStats: getStatsFn,
purgeCache: purgeCacheFn,
diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json
index 6e6075d..330c028 100644
--- a/src/i18n/locales/de.json
+++ b/src/i18n/locales/de.json
@@ -393,11 +393,15 @@
},
"dangerZone": {
"label": "CDN-Setup entfernen",
- "description": "Die Pull Zone, der DNS-Eintrag (CNAME) und alle CDN-Einstellungen werden unwiderruflich gelöscht. Deine Domain zeigt danach wieder direkt auf den mittwald-Server.",
+ "description": "Beim 'Komplett löschen' werden Pull Zone, DNS-Eintrag und alle Extension-Daten unwiderruflich entfernt. Bei 'Verknüpfung trennen' bleibt die Pull Zone bei bunny.net erhalten — du verwaltest sie dort selbst weiter.",
"deleteCta": "CDN-Setup entfernen",
- "confirmWarning": "Bist du sicher? Die Pull Zone bei bunny.net und der DNS-Eintrag bei mittwald werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
+ "confirmChoice": "Wie möchtest du fortfahren?",
+ "deleteFinal": "Komplett löschen",
+ "deleteHint": "Löscht die Pull Zone bei bunny.net, entfernt den DNS-Eintrag (CNAME) und alle Extension-Daten. Kann nicht rückgängig gemacht werden.",
"deleting": "Wird gelöscht…",
- "deleteFinal": "Endgültig löschen"
+ "detachFinal": "Verknüpfung trennen",
+ "detachHint": "Lässt die Pull Zone bei bunny.net unangetastet — du verwaltest sie weiter über das bunny.net Dashboard. Der DNS-Eintrag bleibt vorerst bestehen.",
+ "detaching": "Wird getrennt…"
},
"integrationTab": {
"heading": "Integration",
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 5c8bca0..8c69c34 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -393,11 +393,15 @@
},
"dangerZone": {
"label": "Remove CDN setup",
- "description": "The pull zone, DNS record (CNAME), and all CDN settings will be removed irreversibly. Your domain will point directly to the mittwald server again.",
+ "description": "'Delete permanently' removes the pull zone, DNS record, and all extension data irreversibly. 'Detach' keeps the pull zone at bunny.net so you can keep managing it from the bunny.net dashboard.",
"deleteCta": "Remove CDN setup",
- "confirmWarning": "Are you sure? The pull zone at bunny.net and the DNS record at mittwald will be deleted. This action cannot be undone.",
+ "confirmChoice": "How would you like to proceed?",
+ "deleteFinal": "Delete permanently",
+ "deleteHint": "Deletes the pull zone at bunny.net, removes the DNS record (CNAME), and all extension data. Cannot be undone.",
"deleting": "Deleting…",
- "deleteFinal": "Delete permanently"
+ "detachFinal": "Detach",
+ "detachHint": "Leaves the pull zone at bunny.net intact — you keep managing it via the bunny.net dashboard. The DNS record remains in place for now.",
+ "detaching": "Detaching…"
},
"integrationTab": {
"heading": "Integration",
diff --git a/src/serverFunctions/index.ts b/src/serverFunctions/index.ts
index 1b0bc06..45c34a0 100644
--- a/src/serverFunctions/index.ts
+++ b/src/serverFunctions/index.ts
@@ -9,6 +9,7 @@ export {
addCustomHostnameFn,
createPullZoneFn,
deletePullZoneFn,
+ detachPullZoneFn,
getPullZoneStatusFn,
purgeCacheFn,
removeCustomHostnameFn,
diff --git a/src/serverFunctions/pull-zone.ts b/src/serverFunctions/pull-zone.ts
index b9c0845..6bbd8b0 100644
--- a/src/serverFunctions/pull-zone.ts
+++ b/src/serverFunctions/pull-zone.ts
@@ -5,6 +5,7 @@ import {
addCustomHostname as addCustomHostnameDomain,
createPullZone as createPullZoneDomain,
type DnsClient,
+ detachPullZone as detachPullZoneDomain,
removeCustomHostname as removeCustomHostnameDomain,
unconfigureDnsCname,
} from '~/domain/pull-zone'
@@ -215,6 +216,22 @@ export const deletePullZoneFn = createServerFn({ method: 'POST' })
},
)
+/**
+ * Drops the pull-zone link from the extension without touching bunny.net:
+ * the bunny zone, its hostnames, and the mittwald CNAME stay in place.
+ * Use case: user wants to uninstall the extension but keep managing the
+ * pull zone directly via the bunny.net dashboard.
+ */
+export const detachPullZoneFn = createServerFn({ method: 'POST' })
+ .middleware([authMiddlewareWithAccessToken])
+ .handler(async ({ context }: { context: { extensionInstanceId: string } }) => {
+ const db = getDb()
+ requireEnabled(db, context.extensionInstanceId)
+ const { pullZoneId } = detachPullZoneDomain(db, context.extensionInstanceId)
+ invalidateCached(pullZoneId)
+ return { success: true }
+ })
+
export const getPullZoneStatusFn = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }: { context: { extensionInstanceId: string } }) => {
diff --git a/tests/unit/create-pull-zone.test.ts b/tests/unit/create-pull-zone.test.ts
index 54c1b6a..57cf932 100644
--- a/tests/unit/create-pull-zone.test.ts
+++ b/tests/unit/create-pull-zone.test.ts
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { addCustomHostname, createPullZone, removeCustomHostname } from '~/domain/pull-zone'
+import { addCustomHostname, createPullZone, detachPullZone, removeCustomHostname } from '~/domain/pull-zone'
import { extensionInstances, pullZones } from '~/server/db/schema'
import { createTestDb, seedInstance } from '../helpers/db'
@@ -578,3 +578,68 @@ describe('domain/removeCustomHostname', () => {
})
})
})
+
+describe('domain/detachPullZone', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ })
+
+ it('deletes the DB row and does NOT call bunny.deletePullZone', async () => {
+ const db = createTestDb()
+ seedInstance(db)
+ db.insert(pullZones)
+ .values({
+ id: 300,
+ instanceId: 'inst-1',
+ cdnDomain: 'detach.b-cdn.net',
+ originUrl: 'https://example.com',
+ cdnMode: 'asset',
+ customHostname: 'cdn.example.com',
+ createdAt: new Date(),
+ })
+ .run()
+
+ const bunny = await import('~/server/bunnycdn')
+ const result = detachPullZone(db, 'inst-1')
+
+ expect(result.pullZoneId).toBe(300)
+ expect(bunny.deletePullZone).not.toHaveBeenCalled()
+ expect(bunny.removeHostname).not.toHaveBeenCalled()
+
+ const row = db.select().from(pullZones).where(eq(pullZones.instanceId, 'inst-1')).get()
+ expect(row).toBeUndefined()
+ })
+
+ it('throws NOT_FOUND when no pull zone is linked', () => {
+ const db = createTestDb()
+ seedInstance(db)
+
+ let thrown: unknown
+ try {
+ detachPullZone(db, 'inst-1')
+ } catch (e) {
+ thrown = e
+ }
+ expect(thrown).toMatchObject({ type: 'NOT_FOUND', code: 'PULL_ZONE_NOT_FOUND' })
+ })
+
+ it('works without an API key (no bunny interaction needed)', () => {
+ const db = createTestDb()
+ seedInstance(db)
+ // Note: no encryptedApiKey set on the instance. Detach must still succeed.
+ db.insert(pullZones)
+ .values({
+ id: 301,
+ instanceId: 'inst-1',
+ cdnDomain: 'nokey.b-cdn.net',
+ originUrl: 'https://example.com',
+ cdnMode: 'asset',
+ customHostname: null,
+ createdAt: new Date(),
+ })
+ .run()
+
+ const result = detachPullZone(db, 'inst-1')
+ expect(result.pullZoneId).toBe(301)
+ })
+})