[eric] apps: a refused unpublish stops reporting success, so an app that is still live says so (ENG-283)

This commit is contained in:
ciregenz
2026-08-14 22:17:53 -07:00
parent cc3d70deaa
commit 3cbe575b71
3 changed files with 97 additions and 2 deletions
@@ -137,7 +137,7 @@ const PublishModal: React.FC<Props> = ({ outputId, outputName, open, onClose })
try {
await unpublishApp(outputId);
dispatch(setOutputPublishState({ id: outputId, published_slug: null, published_url: null, publish_status: null }));
setToast('App unpublished');
setToast('App unpublished. The public link can take a minute or two to go dark.');
onClose();
} catch (e: any) {
setConfirmUnpublish(false);
@@ -0,0 +1,84 @@
// A refused takedown is not a takedown (ENG-283).
//
// /outputs/unpublish answers HTTP 200 with {ok:false,error} whenever the app could not actually be
// pulled off the internet: signed out, offline, or the hosting service refused. This helper used to
// read only res.ok, so every one of those cases toasted "App unpublished", cleared the card's
// publish state, and left the app serving to anyone with the link, with no way to retry. Same class
// as ENG-309 one surface over: never let "the request completed" stand in for "the thing happened".
import test from 'node:test';
import assert from 'node:assert/strict';
import { unpublishApp } from './publishApi';
function stubFetch(res: { ok?: boolean; status?: number; body?: unknown; nonJson?: boolean }): () => void {
const real = (globalThis as any).fetch;
(globalThis as any).fetch = async () => ({
ok: res.ok ?? true,
status: res.status ?? 200,
json: async () => {
if (res.nonJson) throw new SyntaxError('Unexpected token');
return res.body;
},
});
return () => { (globalThis as any).fetch = real; };
}
async function rejection(fn: () => Promise<unknown>): Promise<Error | null> {
try {
await fn();
return null;
} catch (e) {
return e as Error;
}
}
test('an explicit ok:true resolves', async () => {
const restore = stubFetch({ body: { ok: true } });
try {
await unpublishApp('out-1');
} finally {
restore();
}
});
test('a 200 that says ok:false throws with the server reason', async () => {
const restore = stubFetch({ body: { ok: false, error: 'Sign in to your OpenSwarm account to manage published apps.' } });
try {
const err = await rejection(() => unpublishApp('out-1'));
assert.ok(err, 'a refused takedown must not resolve; the app is still on the internet');
assert.match(err!.message, /Sign in/, 'the user needs the actual reason, not a generic failure');
} finally {
restore();
}
});
test('a body with no ok field is treated as a failure, not a success', async () => {
// Only an explicit success counts. Anything else means we do not know that the app came down.
for (const body of [{}, { ok: 'yes' }, null, { error: 'boom' }]) {
const restore = stubFetch({ body });
try {
assert.ok(await rejection(() => unpublishApp('out-1')), `ambiguous body ${JSON.stringify(body)} must not read as success`);
} finally {
restore();
}
}
});
test('an unparseable body fails closed', async () => {
const restore = stubFetch({ nonJson: true });
try {
assert.ok(await rejection(() => unpublishApp('out-1')));
} finally {
restore();
}
});
test('an HTTP error still throws, and says the app may still be live', async () => {
const restore = stubFetch({ ok: false, status: 500, body: {} });
try {
const err = await rejection(() => unpublishApp('out-1'));
assert.match(err!.message, /still be live/i);
} finally {
restore();
}
});
@@ -39,11 +39,22 @@ export async function publishApp(
return (await res.json()) as PublishResult;
}
/** Only an explicit {ok:true} is a takedown. The route answers HTTP 200 with {ok:false,error} when it
* was refused (signed out, offline, cloud error), so reading res.ok alone told the user their app was
* off the internet while it kept serving, and cleared the publish state so they could not retry. */
export async function unpublishApp(outputId: string): Promise<void> {
const res = await fetch(`${OUTPUTS_API}/unpublish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ output_id: outputId }),
});
if (!res.ok) throw new Error("We couldn't unpublish this app.");
const fallback = "We couldn't unpublish this app. It may still be live.";
if (!res.ok) throw new Error(fallback);
let body: { ok?: boolean; error?: string } | null = null;
try {
body = await res.json();
} catch {
throw new Error(fallback);
}
if (body?.ok !== true) throw new Error(body?.error || fallback);
}