Compare commits

...
Author SHA1 Message Date
enricobuehler 8f9c72877e Merge pull request 'An SDK fix could never reach an installed plugin — the runner now carries it' (#117) from worktree-runner-sdk-reconcile into main
ci / bun-nix (push) Successful in 24s
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
ci / rust-arm64 (push) Successful in 2m28s
deb / build-publish-client-arm64 (push) Successful in 1m35s
deb / build-publish-host (push) Successful in 4m13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m59s
sdk-publish / publish (push) Successful in 1m52s
docker / builders-arm64cross (push) Successful in 15s
deb / build-publish (push) Successful in 7m16s
ci / rust (push) Successful in 6m52s
arch / build-publish (push) Successful in 7m29s
docker / deploy-docs (push) Failing after 3m54s
windows-host / package (push) Successful in 15m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
nix / flake (push) Successful in 14m35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m11s
Reviewed-on: #117
2026-08-08 12:41:58 +00:00
enricobuehler 8f32976349 Merge pull request 'Library source settings never opened — the drawer still called the old plugin origin' (#118) from worktree-library-settings-origin-split into main
ci / bun-nix (push) Successful in 31s
arch / build-publish (push) Canceled after 1m8s
ci / rust (push) Canceled after 52s
ci / docs-site (push) Canceled after 1m12s
ci / rust-arm64 (push) Canceled after 1m15s
ci / web (push) Canceled after 1m15s
deb / build-publish (push) Canceled after 55s
deb / build-publish-host (push) Canceled after 45s
deb / build-publish-client-arm64 (push) Canceled after 8s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 19s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 17s
docker / builders-arm64cross (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 26s
windows-host / package (push) Canceled after 1m54s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
Reviewed-on: #118
2026-08-08 12:40:44 +00:00
enricobuehler deef5e4382 fix(console): library source settings 404'd — the drawer still called the old plugin origin
ci / bun-nix (pull_request) Successful in 35s
ci / web (pull_request) Successful in 1m19s
ci / docs-site (pull_request) Successful in 1m18s
ci / rust-arm64 (pull_request) Successful in 3m22s
ci / rust (pull_request) Successful in 4m30s
Opening a library source's settings did nothing, for every library plugin. Confirmed on
`.21` against the running console:

    console origin :47992  /plugin-ui/lutris/__config -> 404
    plugin  origin :47993  /plugin-ui/lutris/__config -> 401

The drawer fetches a RELATIVE `/plugin-ui/<id>/__config`, so it resolves against the
console's own origin — where `middleware/auth.ts` answers 404 for `/plugin-ui/**`
unconditionally and by design. That refusal is the 2026-08-05 review's origin split
(H-3): plugin UIs moved to their own listener, and neither origin may serve the other's
paths. The drawer is the only consumer of `/plugin-ui` that is NOT an iframe — every
other caller builds an absolute URL from `pluginOriginFrom(uiConfig)` — so it was the
one thing the split broke, and nothing failed loudly enough to notice.

The fix is deliberately not to point the drawer at the plugin origin. That needs CORS
plus cross-site cookies, and it would put a plugin-controlled response inside a
credentialed cross-origin fetch — reopening exactly the hole the split closed. What
this drawer needs is DATA, not an embedded UI: `/api/plugin-config/<id>` reads the
plugin's `__config` server-side over loopback and returns the JSON same-origin, so no
plugin markup or script is ever served from the console origin and the per-boot secret
stays on the server, as with the `/plugin-ui` proxy.

`/api/**` is always session-gated (`isPublicPath`), so the new route inherits the gate
and answers 401 as JSON rather than redirecting to /login — which is what a `fetch`
needs and what the old path could never give it. It forwards only GET and PUT, reads
the body BEFORE the stale-credential retry (`readRawBody` drains the stream, so a
retried PUT would have saved `{}` over the operator's config), and passes the plugin's
own body through untouched so a 400's decode issue still reaches the operator.

Verified against the real built server: `/api/plugin-config/lutris` answers 401 — the
route resolves and is gated, and the BFF catch-all at `api/[...]` does not swallow it —
while `/plugin-ui/lutris/__config` still answers 404 on the console origin, i.e. the
split is intact. `/api/v1/status` still reaches the BFF. tsc clean, production build
clean, i18n 633 messages across en+de, biome clean on both touched files (the one
warning in SourceSettings.tsx pre-dates this change).
2026-08-08 14:17:58 +02:00
enricobuehler 32cc8dd529 fix(runner): an SDK fix could never reach an installed plugin
ci / bun-nix (pull_request) Successful in 21s
ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m29s
ci / rust-arm64 (pull_request) Successful in 3m5s
ci / rust (pull_request) Successful in 4m31s
nix / flake (pull_request) Failing after 11m38s
Publishing `@punktfunk/host@0.1.3` — the release that lets a library scanner register
`category`, so Lutris and Heroic stay out of the console nav — reached **no existing
install**. Measured on `.21`: the only thing that moved it was deleting `bun.lock` by
hand over ssh. A fix that needs an ssh session is not a fix.

**Why nothing reached it.** Every plugin resolves the SDK from the plugins tree, and
`bun.lock` pins it to an exact version with an integrity hash. Nothing in any
user-facing flow re-resolves that pin: installing a plugin, reinstalling it, and even
updating it to a newer release all leave the SDK alone, because the plugin's `^0.1.x`
range is already satisfied by what is locked. `bun update` does not help either — the
plugins are pinned exactly in the root manifest, so there is no direct dependency to
update through.

**Where the fix belongs.** The runner. It is bundled from this same `sdk/` at the
host's release commit (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the
punktfunk-scripting package), so `SDK_VERSION` is by construction the SDK matching the
host now on disk. A host upgrade is therefore the one moment that can carry an SDK fix
to already-installed plugins, and now it does — before any plugin loads, and with no
operator action at all.

**Why it re-resolves the whole lockfile** rather than pinning the SDK at the root: a
targeted `bun add @punktfunk/host@<v>` does NOT work while plugins declare the SDK in
their own `dependencies` (all six scanners do, though none import it). bun honours
their locked resolution and gives each a private nested copy that then SHADOWS the
root — measured, 5 nested copies, which is how I first "fixed" the box while leaving
every plugin still importing 0.1.2. A lockless resolve hoists one copy for everyone.
Once the plugins drop that spurious dependency this can become the targeted form.

Safety, because this runs unattended at boot on a tree the operator's plugins load
from: plugin versions are pinned exactly in the root manifest so a re-resolve cannot
move them (verified — lutris stays 0.1.0); the lockfile is backed up and restored if
the install fails or fails to deliver; and every failure is logged and swallowed, so a
dependency refresh can never stop working plugins from starting. The no-op path is the
one that runs on every healthy box, so it is tested first: same version, or no SDK at
all, touches nothing and logs nothing.

The SDK is bumped to 0.1.4 because its published content changed. Republishing 0.1.3
is impossible, and letting source drift from a published version is precisely the
defect that produced this whole chain — 0.1.2 was published before it forwarded
`category`, then the source changed underneath it without a bump. `version.test.ts`
fails if `SDK_VERSION` and `package.json` ever disagree.

Verified end to end on `.21` against a tree seeded from the operator's real pre-fix
backup: 0.1.2 → 0.1.3 automatically, one hoisted copy, no nested copies, plugin
versions preserved, and a second run is a silent no-op. SDK 79 tests pass (5 new),
typecheck clean.
2026-08-08 14:07:11 +02:00
8 changed files with 355 additions and 9 deletions
+12 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@punktfunk/host",
"version": "0.1.3",
"version": "0.1.4",
"description": "TypeScript SDK for the punktfunk streaming host: typed management-API client + lifecycle event stream, built on Effect.",
"type": "module",
"license": "MIT OR Apache-2.0",
@@ -13,7 +13,13 @@
"bugs": {
"url": "https://git.unom.io/unom/punktfunk/issues"
},
"keywords": ["punktfunk", "game-streaming", "automation", "sdk", "effect"],
"keywords": [
"punktfunk",
"game-streaming",
"automation",
"sdk",
"effect"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
@@ -29,7 +35,10 @@
"bin": {
"punktfunk-scripting": "./dist/runner-cli.js"
},
"files": ["dist", "README.md"],
"files": [
"dist",
"README.md"
],
"publishConfig": {
"registry": "https://git.unom.io/api/packages/unom/npm/"
},
+104
View File
@@ -6,6 +6,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { configDir } from "./config.js";
import { SDK_VERSION } from "./version.js";
/** The `@punktfunk` package registry (Gitea's npm registry for the `unom` org). */
export const REGISTRY = "https://git.unom.io/api/packages/unom/npm/";
@@ -187,6 +188,109 @@ const runBun = (action: "add" | "remove", pkgs: string[], opts: PkgOpts): void =
}
};
/** The SDK version installed in a plugins tree, or undefined if it isn't installed at all. */
export const installedSdkVersion = (
dir = pluginsDirDefault(),
): string | undefined => {
try {
const manifest = path.join(
dir,
"node_modules",
"@punktfunk",
"host",
"package.json",
);
const v = (
JSON.parse(fs.readFileSync(manifest, "utf8")) as { version?: string }
).version;
return typeof v === "string" ? v : undefined;
} catch {
return undefined;
}
};
/**
* Bring the plugins tree's `@punktfunk/host` up to the version THIS runner was built from.
*
* **Why this exists.** The SDK is the seam every plugin registers through, but each plugin resolves
* it from the plugins tree, and `bun.lock` pins it to an exact version with an integrity hash. No
* user-facing flow re-resolves that pin: installing a plugin, reinstalling it, even updating it to a
* newer release all leave the SDK where it is, because the plugin's `^0.1.x` range is already
* satisfied. Measured on 2026-08-08 — publishing `@punktfunk/host@0.1.3` (the release that lets a
* library scanner register `category`, so it stays out of the console nav) reached **no existing
* install**, and the only thing that moved it was deleting the lockfile by hand over ssh. Shipping a
* fix that needs an ssh session is not shipping a fix.
*
* The runner is the right owner: it is bundled from this same `sdk/` at the host's release commit
* (`packaging/arch/PKGBUILD` builds `src/runner-cli.ts` into the punktfunk-scripting package), so
* `SDK_VERSION` is by construction the SDK that matches the host now on disk. A host upgrade then
* carries the SDK with it and nobody touches a runner.
*
* **Why the whole lockfile.** A targeted `bun add @punktfunk/host@<v>` at the root does NOT work
* while plugins still declare the SDK in their own `dependencies` (they do, though none import it):
* bun honours their locked resolution and gives each plugin a private nested copy, which then
* SHADOWS the root — measured, 5 nested copies. A lockless resolve hoists one copy for everyone,
* also measured. Once the plugins drop that spurious dependency this can become the targeted form.
*
* Safety: the plugins' own versions are pinned exactly in the root `package.json`, so a re-resolve
* cannot move them; only shared transitive deps float within their declared ranges. The lockfile is
* backed up first and restored if the install fails, and any failure is logged and swallowed — a
* dependency refresh must never stop the plugins that are already working from loading.
*/
export const reconcileSharedSdk = (
dir = pluginsDirDefault(),
log: (line: string) => void = (l) => console.log(l),
): void => {
const have = installedSdkVersion(dir);
// Nothing installed = no plugins yet; the first `bun add` resolves the current SDK on its own.
if (have === undefined || have === SDK_VERSION) return;
const lock = path.join(dir, "bun.lock");
const backup = `${lock}.pf-bak`;
log(
`[plugins] @punktfunk/host ${have} installed, this host ships ${SDK_VERSION} — refreshing`,
);
let restore = false;
try {
if (fs.existsSync(lock)) {
fs.copyFileSync(lock, backup);
fs.rmSync(lock);
restore = true;
}
const res = Bun.spawnSync([process.execPath, "install", "--ignore-scripts"], {
cwd: dir,
stdio: ["inherit", "inherit", "inherit"],
});
if (!res.success) {
throw new Error(`bun install exited ${res.exitCode ?? "?"}`);
}
const now = installedSdkVersion(dir);
if (now !== SDK_VERSION) {
// The install "succeeded" and still did not deliver the version — better to sit on the
// known-good tree than to keep a half-resolved one.
throw new Error(`still ${now ?? "absent"} after install`);
}
restore = false;
if (fs.existsSync(backup)) fs.rmSync(backup);
log(`[plugins] @punktfunk/host is now ${SDK_VERSION}`);
} catch (e) {
log(
`[plugins] WARNING: could not refresh @punktfunk/host (${
e instanceof Error ? e.message : e
}) — plugins keep running against ${have}`,
);
if (restore && fs.existsSync(backup)) {
try {
fs.copyFileSync(backup, lock);
fs.rmSync(backup);
} catch {
// The backup is still on disk under its own name; say so rather than pretend.
log(`[plugins] the previous lockfile is at ${backup}`);
}
}
}
};
/** Install one or more plugins by friendly name or package. */
export const addPlugins = (names: string[], opts: PkgOpts = {}): void => {
const pkgs = names.map((n) => resolvePackage(n, opts));
+13 -1
View File
@@ -23,7 +23,12 @@
// package that may live on somebody else's registry — but they are ordinary CLI flags too.
import { Effect, Fiber } from "effect";
import { installLogShipper } from "./log-ship.js";
import { addPlugins, listInstalled, removePlugins } from "./plugins.js";
import {
addPlugins,
listInstalled,
reconcileSharedSdk,
removePlugins,
} from "./plugins.js";
import { discoverUnits, runner } from "./runner.js";
const arg = (flag: string): string | undefined => {
@@ -166,6 +171,13 @@ const keepAlive = setInterval(() => {}, 2 ** 31 - 1);
// a plugin failing to load are the first ones out.
const shipper = installLogShipper();
// Before any plugin loads: make the tree's shared SDK the one this runner was built from. A host
// upgrade is the only moment that can deliver an SDK fix to already-installed plugins, and this is
// that moment — see `reconcileSharedSdk`. Deliberately AFTER the log shipper so the operator can
// read what it did from the console's Logs page, and BEFORE `runner()` so plugins import the
// refreshed copy rather than the one they were started with.
reconcileSharedSdk(options.pluginsDir);
const fiber = Effect.runFork(runner(options));
let stopping = false;
const shutdown = (signal: string) => {
+11
View File
@@ -0,0 +1,11 @@
/**
* The version of this SDK, as a value the bundled runner can read about ITSELF.
*
* A constant rather than an import of `package.json`: `tsconfig.build.json` sets `rootDir: "src"`,
* so reaching one directory up breaks the npm build, and the runner ships as a single bundled
* `runner-cli.js` with no `package.json` beside it (`/usr/share/punktfunk-scripting/`), so there is
* nothing to read at runtime either. Inlining it at build time is the only form that survives both.
*
* `version.test.ts` fails if this and `package.json` disagree, so the duplication cannot rot.
*/
export const SDK_VERSION = "0.1.4";
+90
View File
@@ -0,0 +1,90 @@
// `reconcileSharedSdk` runs on EVERY runner start, so its no-op path is the safety-critical one:
// a false positive deletes a working lockfile and re-resolves the whole tree on a box that was
// fine. These tests pin the decision, not the install (which needs a registry).
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, test } from "bun:test";
import { installedSdkVersion, reconcileSharedSdk } from "../src/plugins.js";
import { SDK_VERSION } from "../src/version.js";
const dirs: string[] = [];
/** A plugins tree whose installed `@punktfunk/host` is `version` (omit for "not installed"). */
const tree = (version?: string): string => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pf-reconcile-"));
dirs.push(dir);
fs.writeFileSync(path.join(dir, "package.json"), '{"private":true}\n');
fs.writeFileSync(path.join(dir, "bun.lock"), "ORIGINAL-LOCK\n");
if (version !== undefined) {
const host = path.join(dir, "node_modules", "@punktfunk", "host");
fs.mkdirSync(host, { recursive: true });
fs.writeFileSync(
path.join(host, "package.json"),
JSON.stringify({ name: "@punktfunk/host", version }),
);
}
return dir;
};
afterEach(() => {
for (const d of dirs.splice(0)) fs.rmSync(d, { recursive: true, force: true });
});
describe("installedSdkVersion", () => {
test("reads the installed version, and is undefined when absent", () => {
expect(installedSdkVersion(tree("0.1.2"))).toBe("0.1.2");
expect(installedSdkVersion(tree())).toBeUndefined();
});
test("is undefined rather than throwing on a corrupt manifest", () => {
const dir = tree("0.1.2");
fs.writeFileSync(
path.join(dir, "node_modules", "@punktfunk", "host", "package.json"),
"{ not json",
);
expect(installedSdkVersion(dir)).toBeUndefined();
});
});
describe("reconcileSharedSdk", () => {
// The common case, every start, on every healthy box: touch nothing.
test("is a silent no-op when the installed SDK already matches", () => {
const dir = tree(SDK_VERSION);
const lines: string[] = [];
reconcileSharedSdk(dir, (l) => lines.push(l));
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines).toEqual([]);
});
// A tree with no SDK has no plugins yet — the first `bun add` resolves the current one, so
// there is nothing to refresh and nothing to log about.
test("is a silent no-op when no SDK is installed at all", () => {
const dir = tree();
const lines: string[] = [];
reconcileSharedSdk(dir, (l) => lines.push(l));
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines).toEqual([]);
});
// The failure path matters as much as the happy one: this runs unattended at boot, and the
// tree it just took the lockfile away from is the one the operator's plugins load from. The
// install cannot succeed here (the fake package.json resolves nothing), so this exercises the
// real rollback.
test("restores the lockfile and keeps going when the refresh fails", () => {
const dir = tree("0.0.1-not-a-real-version");
const lines: string[] = [];
expect(() => reconcileSharedSdk(dir, (l) => lines.push(l))).not.toThrow();
expect(fs.readFileSync(path.join(dir, "bun.lock"), "utf8")).toBe(
"ORIGINAL-LOCK\n",
);
expect(lines.join("\n")).toContain("WARNING");
// And it names both versions, so the log says what it was trying to do.
expect(lines.join("\n")).toContain("0.0.1-not-a-real-version");
expect(fs.existsSync(path.join(dir, "bun.lock.pf-bak"))).toBe(false);
});
});
+23
View File
@@ -0,0 +1,23 @@
// The one thing that keeps `SDK_VERSION` honest. The runner compares it against the SDK actually
// installed in the plugins tree and reinstalls on a mismatch, so a stale constant would either
// reinstall forever (constant behind) or never deliver a fix (constant ahead of a release).
import { readFileSync } from "node:fs";
import { describe, expect, test } from "bun:test";
import { SDK_VERSION } from "../src/version.js";
describe("SDK_VERSION", () => {
test("matches package.json — bump both or neither", () => {
// Read rather than import: `tsconfig.build.json` pins `rootDir: "src"`, so a JSON import of
// the manifest would not compile for the npm build even though bun would run it fine.
const pkg = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string };
expect(SDK_VERSION).toBe(pkg.version);
});
test("is a plain semver triple", () => {
// The runner compares it to an installed version string, so anything with a range operator
// (`^0.1.3`) would never compare equal and would reinstall on every start.
expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
});
});
@@ -0,0 +1,91 @@
// GET/PUT /api/plugin-config/<id> — a plugin's `__config`, readable from the CONSOLE origin.
//
// The Library section's "Game sources" settings drawer renders a form from a library plugin's
// `__config` (the kit's generic settings surface, so a scanner needs no SPA of its own). It fetched
// `/plugin-ui/<id>/__config` same-origin — and that stopped working the moment plugin UIs moved to
// their own origin (2026-08-05 review H-3): `middleware/auth.ts` answers 404 for `/plugin-ui/**` on
// the console origin, unconditionally and by design. The drawer is the only NON-IFRAME consumer of
// that path, so nothing else noticed, and settings silently failed to open for every library plugin.
//
// The fix is deliberately not "point the drawer at the plugin origin". That needs CORS plus
// cross-site cookies, and it would put a plugin-controlled response inside a credentialed
// cross-origin fetch — reopening the hole the split exists to close. What the drawer needs is DATA,
// not an embedded UI: this reads the JSON server-side over loopback and returns it same-origin, so
// no plugin HTML or JS is ever served from the console origin.
//
// Auth: `/api/**` is always session-gated (`isPublicPath`), so reaching here means a logged-in
// operator, and it answers 401 as JSON rather than redirecting — which is what a `fetch` needs. The
// plugin's per-boot secret stays server-side, exactly as in the `/plugin-ui` proxy.
import {
defineEventHandler,
getRouterParam,
readRawBody,
setResponseStatus,
} from "h3";
import {
bustCredential,
fetchUiCredential,
PLUGIN_ID_RE,
} from "../../../util/pluginProxy";
/** `GET` reads schema + current value; `PUT` validates and saves. Nothing else is forwarded. */
const ALLOWED = new Set(["GET", "PUT"]);
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
if (!id || !PLUGIN_ID_RE.test(id)) {
setResponseStatus(event, 404);
return { error: "not a valid plugin id" };
}
const method = event.method;
if (!ALLOWED.has(method)) {
setResponseStatus(event, 405);
return { error: "method not allowed" };
}
// Read the body BEFORE the retry below: `readRawBody` drains the stream, so a second attempt
// would forward an empty PUT and quietly save `{}` over the operator's config.
const body =
method === "PUT"
? ((await readRawBody(event, false)) as Uint8Array | undefined)
: undefined;
const attempt = async (bustCache: boolean): Promise<Response | null> => {
const cred = await fetchUiCredential(id, { bustCache });
if (!cred) return null;
try {
return await fetch(`http://127.0.0.1:${cred.port}/__config`, {
method,
headers: {
authorization: `Bearer ${cred.secret}`,
...(method === "PUT" ? { "content-type": "application/json" } : {}),
},
body: body as BodyInit | undefined,
});
} catch {
return null;
}
};
// A plugin's secret rotates when its process restarts, which happens well inside the credential
// cache's TTL — so a 401 here means "stale credential", not "denied". Same one-shot retry the
// `/plugin-ui` proxy does, for the same reason.
let res = await attempt(false);
if (res?.status === 401) {
bustCredential(id);
res = await attempt(true);
}
if (!res) {
setResponseStatus(event, 502);
return { error: `plugin ${id} is not reachable` };
}
setResponseStatus(event, res.status);
// Pass the plugin's own body through untouched: a 400 from `__config` carries the decode issue
// the drawer shows the operator, and rewriting it would throw away the only useful part.
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return { error: text || `plugin ${id} answered ${res.status}` };
}
});
+11 -5
View File
@@ -26,9 +26,15 @@ import { m } from "@/paraglide/messages";
* A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema.
*
* The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything
* goes through the existing session-gated `/plugin-ui/<id>/…` proxy, so there is **zero new host
* surface** — the browser never learns the plugin's port or secret.
* `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. The browser
* never learns the plugin's port or secret — the console reads it server-side over loopback.
*
* That read goes through `/api/plugin-config/<id>` on the CONSOLE origin, not the `/plugin-ui/…`
* proxy this used to call. Plugin UIs live on their own origin (2026-08-05 review H-3) and the
* console origin now answers 404 for `/plugin-ui/**` by design, which broke this drawer for every
* library plugin — it is the one consumer of that path that is not an iframe. What it needs is
* DATA, not an embedded UI, so it gets JSON same-origin and no plugin markup ever reaches the
* console origin.
*
* Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds
* the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still
@@ -51,7 +57,7 @@ export const SourceSettingsDialog: FC<{
let cancelled = false;
(async () => {
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
const res = await fetch(`/api/plugin-config/${pluginId}`, {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -77,7 +83,7 @@ export const SourceSettingsDialog: FC<{
const save = async (value: JsonObject) => {
setSaving(true);
try {
const res = await fetch(`/plugin-ui/${pluginId}/__config`, {
const res = await fetch(`/api/plugin-config/${pluginId}`, {
method: "PUT",
credentials: "same-origin",
headers: { "content-type": "application/json" },