diff --git a/src/auth.ts b/src/auth.ts index 25a022c..14b591c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -310,13 +310,39 @@ export function clientMetadata(): Response { }); } +/** + * What a signed-in user is allowed to be. + * + * `owner` runs the instance: curation, the admin views, every visible model. + * `guest` gets a chat and nothing that decides what anyone else sees. + * + * Two cases deliberately resolve to owner: auth turned off (a local instance + * has no one to be a guest to), and an empty `auth.owners` (a deployment that + * never named owners hasn't opted into having guests — every instance behaved + * this way before roles existed, and an upgrade must not silently demote its + * only user). + */ +export type Role = "owner" | "guest"; + +export function roleFor(sub: string | undefined): Role { + const cfg = getConfig().auth; + if (!cfg.enabled || cfg.owners.length === 0) return "owner"; + return sub && cfg.owners.includes(sub) ? "owner" : "guest"; +} + +/** The role of whoever made this request. */ +export function requestRole(req: Request, store: Store): Role { + return roleFor(getSession(req, store)?.sub); +} + /** The public shape of the signed-in user, for `/api/me`. */ export function sessionUser(session: Session): { sub: string; + role: Role; name?: string; picture?: string; url?: string; email?: string; } { - return { sub: session.sub, ...session.profile }; + return { sub: session.sub, role: roleFor(session.sub), ...session.profile }; } diff --git a/src/client/app.css b/src/client/app.css index da23988..26f7bce 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -4177,6 +4177,24 @@ body.lb-open { .modelrow.off { opacity: 0.5; } +/* Guest access sits with the row it belongs to, quiet until you look for it. */ +.modelrow .guesttoggle { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + font-size: 0.78rem; + color: var(--text-dim); + cursor: pointer; + user-select: none; +} +.modelrow .guesttoggle[hidden] { + display: none; +} +.modelrow .guesttoggle input { + margin: 0; + accent-color: var(--accent); +} /* While dragging: the grabbed row stays in flow (visibility-hidden, so the browser doesn't abort the drag) and becomes the single dashed drop slot; the rest of the selection leaves the flow entirely. The drag image (a stacked diff --git a/src/client/views/settings.js b/src/client/views/settings.js index 10cf876..639ab78 100644 --- a/src/client/views/settings.js +++ b/src/client/views/settings.js @@ -196,6 +196,29 @@ export function mount(root, _params, ctx) { saved.className = "saved"; saved.textContent = "saved"; + // Guest access, only where it can mean anything: a model nobody can see is + // not a model guests can be given. + var guest = document.createElement("label"); + guest.className = "guesttoggle"; + guest.title = "Also offer this model to guests"; + var guestBox = document.createElement("input"); + guestBox.type = "checkbox"; + guestBox.checked = !!m.guestVisible; + guestBox.setAttribute("aria-label", "Offer to guests"); + guest.appendChild(guestBox); + guest.appendChild(document.createTextNode("guests")); + if (!m.visible) guest.hidden = true; + guestBox.addEventListener("change", async function () { + var v = guestBox.checked; + if (await patchRaw(m.ref, "guestVisible", v)) { + m.guestVisible = v; + flash(saved, true); + } else { + guestBox.checked = !v; + flash(saved, false); + } + }); + // Enabling/disabling moves the model between sections, so re-render on success. toggle.addEventListener("change", async function () { var v = toggle.checked; @@ -216,6 +239,7 @@ export function mount(root, _params, ctx) { row.appendChild(toggle); row.appendChild(main); row.appendChild(rename); + row.appendChild(guest); row.appendChild(saved); return row; } diff --git a/src/http.ts b/src/http.ts index 067ce5f..5d09471 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,7 +1,15 @@ import { randomUUID } from "node:crypto"; import { brotliCompressSync, gzipSync, constants as zlibConstants } from "node:zlib"; import { ConversationActor, type Subscriber, type WireEvent } from "./actor"; -import { authEnabled, gateApi, getSession, sessionUser } from "./auth"; +import { + authEnabled, + gateApi, + getSession, + type Role, + requestRole, + roleFor, + sessionUser, +} from "./auth"; import type { BlobStore } from "./blobs"; import { ACTOR_IDLE_TTL_MS, SSE_RETRY_MS, SUBSCRIBER_HEARTBEAT_MS } from "./config"; import { Event, type EventData, type EventName, parseEventId } from "./events"; @@ -259,6 +267,27 @@ function requireKnownModel(ref: string): Response | null { return Response.json({ error: `unknown model "${ref}"` }, { status: 422 }); } +/** + * The same gate, narrowed by role: a guest may only run what curation offers + * guests. The picker already hides the rest, but the picker is a suggestion — + * this is the boundary, and it has to sit on every path that names a model, + * because a run costs someone real credits. + */ +function requireUsableModel(ref: string, store: Store, role: Role): Response | null { + const unknown = requireKnownModel(ref); + if (unknown) return unknown; + if (role === "owner") return null; + const s = store.getModelSetting(ref); + if (s?.visible && s.guestVisible) return null; + return Response.json({ error: `model "${ref}" is not available to you` }, { status: 403 }); +} + +/** 403 unless the caller runs this instance. */ +function requireOwner(req: Request, store: Store): Response | null { + if (requestRole(req, store) === "owner") return null; + return Response.json({ error: "owners only" }, { status: 403 }); +} + /** * Every available model joined to its curation state, for the settings UI. * Models with no curation row default to hidden with their catalog name. @@ -272,6 +301,7 @@ function adminModels(store: Store) { return { ...m, visible: s?.visible ?? false, + guestVisible: s?.guestVisible ?? false, displayName: s?.displayName ?? null, sortOrder: s?.sortOrder ?? 0, }; @@ -282,13 +312,14 @@ function adminModels(store: Store) { * The curated subset shown in the chat picker: opt-in (visible only), with * displayName applied and ordered by sortOrder then name. */ -function chatModels(store: Store) { +function chatModels(store: Store, role: Role) { const settings = new Map(store.listModelSettings().map((s) => [s.ref, s])); return getRegistry() .listModels() .flatMap((m) => { const s = settings.get(m.ref); if (!s?.visible) return []; + if (role === "guest" && !s.guestVisible) return []; return [ { ref: m.ref, @@ -387,9 +418,10 @@ function startRun( conversationId: string, data: PromptBody, store: Store, + role: Role, owner?: string, ): Response { - const rejected = requireKnownModel(data.model); + const rejected = requireUsableModel(data.model, store, role); if (rejected) return rejected; const badBlob = requireKnownBlobs(data.attachments, store); if (badBlob) return badBlob; @@ -430,8 +462,8 @@ function startRun( * the current run finishes, the drive loop promotes the WHOLE pending queue * and runs it as a single batched generation. */ -function startSteer(conversationId: string, data: SteerBody, store: Store): Response { - const rejected = requireKnownModel(data.model); +function startSteer(conversationId: string, data: SteerBody, store: Store, role: Role): Response { + const rejected = requireUsableModel(data.model, store, role); if (rejected) return rejected; const badBlob = requireKnownBlobs(data.attachments, store); if (badBlob) return badBlob; @@ -592,6 +624,7 @@ function patchModel(data: ModelPatchBody, store: Store): Response { const merged = { ref: data.ref, visible: data.visible ?? prev?.visible ?? false, + guestVisible: data.guestVisible ?? prev?.guestVisible ?? false, displayName: data.displayName !== undefined ? data.displayName : (prev?.displayName ?? null), sortOrder: data.sortOrder ?? prev?.sortOrder ?? 0, }; @@ -652,7 +685,11 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v "/api/me": { GET: (req: Bun.BunRequest<"/api/me">) => { const s = getSession(req, store); - return s ? Response.json(sessionUser(s)) : Response.json({ authenticated: false }); + // The role rides along so the UI can hide what it may not touch; the + // API gates on its own reading of the session either way. + return s + ? Response.json(sessionUser(s)) + : Response.json({ authenticated: false, role: roleFor(undefined) }); }, }, @@ -816,7 +853,9 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v // choices made by clicking, and each one should not cost an endpoint. "/api/prefs": { GET: () => Response.json(prefsPayload(store)), - PATCH: withBody(PrefsPatchBody, (data) => { + PATCH: withBody(PrefsPatchBody, (data, req: Bun.BunRequest<"/api/prefs">) => { + const denied = requireOwner(req, store); + if (denied) return denied; for (const [key, value] of Object.entries(data)) { // Only keys we know: an open key/value endpoint is an invitation to // store whatever, and this table is read by the run loop. @@ -862,14 +901,21 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v }, // Settings/admin view: every available model + its curation state. + // Curation is the instance's shape, so it is the owner's to see and change. "/api/models": { - GET: () => Response.json({ models: adminModels(store) }), - PATCH: withBody(ModelPatchBody, (data) => patchModel(data, store)), + GET: (req: Bun.BunRequest<"/api/models">) => + requireOwner(req, store) ?? Response.json({ models: adminModels(store) }), + PATCH: withBody( + ModelPatchBody, + (data, req: Bun.BunRequest<"/api/models">) => + requireOwner(req, store) ?? patchModel(data, store), + ), }, // Chat view: the curated, opt-in subset, ordered for the picker. "/api/models/chat": { - GET: () => Response.json({ models: chatModels(store) }), + GET: (req: Bun.BunRequest<"/api/models/chat">) => + Response.json({ models: chatModels(store, requestRole(req, store)) }), }, "/api/conversations/:id/stream": { @@ -880,7 +926,13 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v POST: withBody(PromptBody, (data, req: Bun.BunRequest<"/api/conversations/:id/prompt">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; - const res = startRun(req.params.id, data, store, getSession(req, store)?.sub); + const res = startRun( + req.params.id, + data, + store, + requestRole(req, store), + getSession(req, store)?.sub, + ); kick(); return res; }), @@ -897,7 +949,7 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v POST: withBody(SteerBody, (data, req: Bun.BunRequest<"/api/conversations/:id/steer">) => { const denied = guardConv(req, req.params.id); if (denied) return denied; - const res = startSteer(req.params.id, data, store); + const res = startSteer(req.params.id, data, store, requestRole(req, store)); kick(); return res; }), diff --git a/src/schemas.ts b/src/schemas.ts index c15e5b3..89cd6d9 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -79,6 +79,7 @@ export type ProjectAssignBody = v.InferOutput; export const ModelPatchBody = v.object({ ref: v.pipe(v.string(), v.minLength(1)), visible: v.optional(v.boolean()), + guestVisible: v.optional(v.boolean()), displayName: v.optional(v.nullable(v.string())), sortOrder: v.optional(v.number()), }); diff --git a/src/settings.ts b/src/settings.ts index 4da09eb..ca37318 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -361,6 +361,16 @@ const AuthSchema = v.object({ clientSecret: v.optional(v.string(), ""), /** Allowed subject URLs (indiko `me`/`sub`); empty = any authenticated user. */ allowedSubs: v.optional(v.array(v.string()), []), + /** + * Subject URLs with full access: curation, every visible model, the admin + * views. Everyone else who gets through `allowedSubs` is a guest, who sees + * only the models marked guest-visible and cannot change what anyone sees. + * + * Empty (the default) means the deployment has no guests and every + * authenticated user is an owner — which is what a single-user instance is, + * and what every instance was before roles existed. + */ + owners: v.optional(v.array(v.string()), []), sessionTtlDays: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 30), appName: v.optional(v.string(), "kloe"), logoUri: v.optional(v.string(), ""), diff --git a/src/store.ts b/src/store.ts index c9c4cfd..6492c30 100644 --- a/src/store.ts +++ b/src/store.ts @@ -260,6 +260,12 @@ function snippetAround(text: string, query: string): string { export interface ModelSetting { ref: string; visible: boolean; + /** + * Also offered to guests. Narrows `visible` rather than standing beside it: + * a model guests may use is necessarily one the instance offers, so this is + * only consulted for models that are already visible. + */ + guestVisible: boolean; displayName: string | null; sortOrder: number; } @@ -267,6 +273,7 @@ export interface ModelSetting { interface ModelSettingRow { model_ref: string; visible: number; + guest_visible: number; display_name: string | null; sort_order: number; } @@ -275,6 +282,7 @@ function rowToSetting(r: ModelSettingRow): ModelSetting { return { ref: r.model_ref, visible: r.visible === 1, + guestVisible: r.guest_visible === 1, displayName: r.display_name, sortOrder: r.sort_order, }; @@ -328,6 +336,7 @@ CREATE TABLE IF NOT EXISTS cancel_requests ( CREATE TABLE IF NOT EXISTS model_settings ( model_ref TEXT PRIMARY KEY, visible INTEGER NOT NULL DEFAULT 0, + guest_visible INTEGER NOT NULL DEFAULT 0, display_name TEXT, sort_order INTEGER NOT NULL DEFAULT 0 ); @@ -560,6 +569,16 @@ export class Store { } catch { // column already exists } + // Migration: curation gained a guest dimension. Existing rows default to + // 0 — a deployment that adds guests decides what they may reach, rather + // than inheriting the owner's whole list the moment roles arrive. + try { + this.db.exec( + "ALTER TABLE model_settings ADD COLUMN guest_visible INTEGER NOT NULL DEFAULT 0", + ); + } catch { + // column already exists + } // Migration: which project a conversation belongs to (NULL = unfiled). try { this.db.exec("ALTER TABLE conversations ADD COLUMN project_id TEXT"); @@ -677,17 +696,18 @@ export class Store { ); this.listSettingsStmt = this.db.prepare( - `SELECT model_ref, visible, display_name, sort_order FROM model_settings`, + `SELECT model_ref, visible, guest_visible, display_name, sort_order FROM model_settings`, ); this.getSettingStmt = this.db.prepare( - `SELECT model_ref, visible, display_name, sort_order + `SELECT model_ref, visible, guest_visible, display_name, sort_order FROM model_settings WHERE model_ref = ?`, ); this.upsertSettingStmt = this.db.prepare( - `INSERT INTO model_settings (model_ref, visible, display_name, sort_order) - VALUES (?, ?, ?, ?) + `INSERT INTO model_settings (model_ref, visible, guest_visible, display_name, sort_order) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(model_ref) DO UPDATE SET visible = excluded.visible, + guest_visible = excluded.guest_visible, display_name = excluded.display_name, sort_order = excluded.sort_order`, ); @@ -1217,7 +1237,13 @@ export class Store { } setModelSetting(s: ModelSetting): void { - this.upsertSettingStmt.run(s.ref, s.visible ? 1 : 0, s.displayName, s.sortOrder); + this.upsertSettingStmt.run( + s.ref, + s.visible ? 1 : 0, + s.guestVisible ? 1 : 0, + s.displayName, + s.sortOrder, + ); } /** diff --git a/tests/curation.test.ts b/tests/curation.test.ts index a13ec65..ce47b27 100644 --- a/tests/curation.test.ts +++ b/tests/curation.test.ts @@ -78,11 +78,17 @@ test("PATCH /api/models makes a model visible and renames it; chat reflects it", const { base } = freshApp(); const patched = await json( - await patchModels(base, { ref: "acme/acme-1", visible: true, displayName: "Acme (fast)" }), + await patchModels(base, { + ref: "acme/acme-1", + visible: true, + guestVisible: false, + displayName: "Acme (fast)", + }), ); expect(patched).toMatchObject({ ref: "acme/acme-1", visible: true, + guestVisible: false, displayName: "Acme (fast)", }); @@ -97,14 +103,24 @@ test("PATCH /api/models makes a model visible and renames it; chat reflects it", test("PATCH is partial: a second patch keeps prior fields", async () => { const { base } = freshApp(); - await patchModels(base, { ref: "acme/acme-1", visible: true, displayName: "Kept" }); + await patchModels(base, { + ref: "acme/acme-1", + visible: true, + guestVisible: false, + displayName: "Kept", + }); const result = await json(await patchModels(base, { ref: "acme/acme-1", sortOrder: 5 })); expect(result).toMatchObject({ visible: true, displayName: "Kept", sortOrder: 5 }); }); test("PATCH displayName:null clears the override", async () => { const { base } = freshApp(); - await patchModels(base, { ref: "acme/acme-1", visible: true, displayName: "Temp" }); + await patchModels(base, { + ref: "acme/acme-1", + visible: true, + guestVisible: false, + displayName: "Temp", + }); const cleared = await json(await patchModels(base, { ref: "acme/acme-1", displayName: null })); expect(cleared.displayName).toBeNull(); @@ -114,8 +130,13 @@ test("PATCH displayName:null clears the override", async () => { test("chat models are ordered by sortOrder then name", async () => { const { base } = freshApp(); - await patchModels(base, { ref: "acme/acme-1", visible: true, sortOrder: 10 }); - await patchModels(base, { ref: "acme/acme-2", visible: true, sortOrder: 1 }); + await patchModels(base, { + ref: "acme/acme-1", + visible: true, + guestVisible: false, + sortOrder: 10, + }); + await patchModels(base, { ref: "acme/acme-2", visible: true, guestVisible: false, sortOrder: 1 }); const chat = await json(await fetch(`${base}/api/models/chat`)); expect(chat.models.map((m: any) => m.ref)).toEqual(["acme/acme-2", "acme/acme-1"]); }); @@ -134,6 +155,7 @@ test("curation persists across Store re-open", async () => { store1.setModelSetting({ ref: "acme/acme-1", visible: true, + guestVisible: false, displayName: "Persisted", sortOrder: 3, }); @@ -144,6 +166,7 @@ test("curation persists across Store re-open", async () => { expect(got).toEqual({ ref: "acme/acme-1", visible: true, + guestVisible: false, displayName: "Persisted", sortOrder: 3, }); diff --git a/tests/roles.test.ts b/tests/roles.test.ts new file mode 100644 index 0000000..c04afb9 --- /dev/null +++ b/tests/roles.test.ts @@ -0,0 +1,193 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resetAuthCache, roleFor } from "../src/auth"; +import { FsBlobStore } from "../src/blobs"; +import { Catalog } from "../src/catalog"; +import { apiRoutes } from "../src/http"; +import { setRegistry } from "../src/inference"; +import { ProviderRegistry } from "../src/providers"; +import { loadConfig, setConfig } from "../src/settings"; +import { Store } from "../src/store"; + +/** + * Roles: an owner runs the instance, a guest gets a chat. The tests that matter + * are the ones about the boundary rather than the picker — the picker is a + * suggestion, and a guest who names a model directly is the case worth being + * sure about. + */ + +const OWNER = "https://dunkirk.sh/"; +const GUEST = "https://someone.else/"; + +function fixtureRegistry(): ProviderRegistry { + const catalog = Catalog.fromRaw([ + { + id: "acme", + name: "Acme", + type: "openai-compat", + api_endpoint: "https://acme.test/v1", + models: [ + { id: "cheap", name: "Cheap", context_window: 8000 }, + { id: "spendy", name: "Spendy", context_window: 200000 }, + ], + }, + ]); + return new ProviderRegistry(catalog, { + config: { providers: [{ id: "acme", apiKey: "$ACME_KEY" }] }, + }); +} + +function configure(owners: string[]): void { + const base = loadConfig({ path: "does-not-exist.json", env: {} }); + setConfig({ ...base, auth: { ...base.auth, enabled: true, owners } }); + resetAuthCache(); +} + +const servers: Array> = []; +const tmpDirs: string[] = []; +function freshApp() { + const tmp = mkdtempSync(join(tmpdir(), "kloe-roles-")); + tmpDirs.push(tmp); + const store = new Store(join(tmp, "test.db")); + const blobs = new FsBlobStore(join(tmp, "blobs")); + const server = Bun.serve({ port: 0, routes: apiRoutes({ store, blobs }) }); + servers.push(server); + return { base: server.url.origin, store }; +} +afterAll(() => { + for (const s of servers) s.stop(true); + for (const d of tmpDirs) rmSync(d, { recursive: true, force: true }); +}); + +/** A session cookie for `sub`, so a request arrives as that person. */ +let sid = 0; +function as(store: Store, sub: string): { cookie: string } { + const id = `sid-${sub}-${sid++}`; + store.createSession(id, sub, {}, Date.now() + 600_000); + return { cookie: `kloe_session=${encodeURIComponent(id)}` }; +} + +beforeEach(() => { + setRegistry(fixtureRegistry()); +}); +afterEach(() => { + setConfig(null); + resetAuthCache(); +}); + +test("an instance that names no owners has no guests", () => { + configure([]); + // Every instance behaved this way before roles existed; an upgrade must not + // demote the only user of a single-user deployment. + expect(roleFor(OWNER)).toBe("owner"); + expect(roleFor(GUEST)).toBe("owner"); + expect(roleFor(undefined)).toBe("owner"); +}); + +test("auth off means there is nobody to be a guest to", () => { + const base = loadConfig({ path: "does-not-exist.json", env: {} }); + setConfig({ ...base, auth: { ...base.auth, enabled: false, owners: [OWNER] } }); + resetAuthCache(); + expect(roleFor(undefined)).toBe("owner"); +}); + +test("with owners named, everyone else is a guest", () => { + configure([OWNER]); + expect(roleFor(OWNER)).toBe("owner"); + expect(roleFor(GUEST)).toBe("guest"); +}); + +test("the chat picker shows a guest only the models marked for guests", async () => { + configure([OWNER]); + const { base, store } = freshApp(); + store.setModelSetting({ + ref: "acme/cheap", + visible: true, + guestVisible: true, + displayName: null, + sortOrder: 0, + }); + store.setModelSetting({ + ref: "acme/spendy", + visible: true, + guestVisible: false, + displayName: null, + sortOrder: 1, + }); + + const forOwner = await fetch(`${base}/api/models/chat`, { headers: as(store, OWNER) }); + expect(((await forOwner.json()) as any).models.map((m: any) => m.ref)).toEqual([ + "acme/cheap", + "acme/spendy", + ]); + + const forGuest = await fetch(`${base}/api/models/chat`, { headers: as(store, GUEST) }); + expect(((await forGuest.json()) as any).models.map((m: any) => m.ref)).toEqual(["acme/cheap"]); +}); + +test("a guest naming a model the picker never offered is refused", async () => { + configure([OWNER]); + const { base, store } = freshApp(); + store.setModelSetting({ + ref: "acme/spendy", + visible: true, + guestVisible: false, + displayName: null, + sortOrder: 0, + }); + + const prompt = (sub: string) => + fetch(`${base}/api/conversations/c1/prompt`, { + method: "POST", + headers: { "content-type": "application/json", ...as(store, sub) }, + body: JSON.stringify({ content: "hi", model: "acme/spendy" }), + }); + + expect((await prompt(GUEST)).status).toBe(403); + // …and the owner, on the same model, is untouched by the check. + expect((await prompt(OWNER)).status).toBe(202); +}); + +test("a guest cannot read or change curation", async () => { + configure([OWNER]); + const { base, store } = freshApp(); + + expect((await fetch(`${base}/api/models`, { headers: as(store, GUEST) })).status).toBe(403); + + const patch = (sub: string) => + fetch(`${base}/api/models`, { + method: "PATCH", + headers: { "content-type": "application/json", ...as(store, sub) }, + body: JSON.stringify({ ref: "acme/cheap", visible: true, guestVisible: true }), + }); + expect((await patch(GUEST)).status).toBe(403); + expect(store.getModelSetting("acme/cheap")).toBeUndefined(); + + expect((await patch(OWNER)).status).toBe(200); + expect(store.getModelSetting("acme/cheap")?.guestVisible).toBe(true); +}); + +test("guest visibility survives a round trip through the patch endpoint", async () => { + configure([OWNER]); + const { base, store } = freshApp(); + await fetch(`${base}/api/models`, { + method: "PATCH", + headers: { "content-type": "application/json", ...as(store, OWNER) }, + body: JSON.stringify({ ref: "acme/cheap", visible: true, guestVisible: true }), + }); + // A later patch that says nothing about guests leaves them alone. + await fetch(`${base}/api/models`, { + method: "PATCH", + headers: { "content-type": "application/json", ...as(store, OWNER) }, + body: JSON.stringify({ ref: "acme/cheap", displayName: "Cheap One" }), + }); + expect(store.getModelSetting("acme/cheap")).toEqual({ + ref: "acme/cheap", + visible: true, + guestVisible: true, + displayName: "Cheap One", + sortOrder: 0, + }); +}); diff --git a/tests/smallmodel.test.ts b/tests/smallmodel.test.ts index 32b8a93..6f7885e 100644 --- a/tests/smallmodel.test.ts +++ b/tests/smallmodel.test.ts @@ -72,7 +72,13 @@ function registry(): ProviderRegistry { function storeWith(...visible: string[]): Store { const store = new Store(":memory:"); for (const ref of visible) - store.setModelSetting({ ref, visible: true, sortOrder: 0, displayName: null }); + store.setModelSetting({ + ref, + visible: true, + guestVisible: false, + sortOrder: 0, + displayName: null, + }); return store; }