From 2e7da596ef034254e4244893044b1416dd6a8138 Mon Sep 17 00:00:00 2001 From: Tiger Wang <73211705+TigerYWang@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:42:37 +0000 Subject: [PATCH] Admins see a new app's Preview link and password in Your Apps with the owner's Mac off A bot that makes an app on its computer while the owner's Mac is off already keeps the app's record and its Preview's label copy in Bops Cloud, and shares the Preview password with its exact link. Your Apps still showed that app by name only, because members' sites came from the owner's Mac's read alone. Now Bops Cloud fills in the link where the Mac's read has none (cloud/ws-slice.ts sitesFilled): - Admins and members with Your Apps at Write see the link and its password. Read sees the link and that it asks for a password, never the password. - Only the link the app's record names: its label copy (a runner keeps only one sealed for that app), under orgo.dev, with no port, path, query or sign in. Nothing for an app that lost its computer, or a Preview whose password the owner turned off. - A link the Mac read stands as it is; a site the Mac read before its Preview was made gets the link. - Members see up to 100 apps (was 30), so the newest apps a bot made aren't the ones cut. Ships with Bops Cloud only. --- cloud/preview-passwords.ts | 4 +- cloud/protocol.ts | 2 + cloud/test/core-members.test.ts | 174 ++++++++++++++++++++++ cloud/test/core-preview-passwords.test.ts | 58 +++++++- cloud/ws-slice.ts | 97 ++++++++++-- 5 files changed, 318 insertions(+), 17 deletions(-) diff --git a/cloud/preview-passwords.ts b/cloud/preview-passwords.ts index a0550aaf4..e2ee0d2de 100644 --- a/cloud/preview-passwords.ts +++ b/cloud/preview-passwords.ts @@ -17,7 +17,9 @@ import { announce, checkUser, lockedState, lockUser, toJson } from "./state.ts"; * this is how what a runner set reaches anyone: nothing waits on the owner's Mac being on. Bops Cloud keeps the newest * on the owner's state, by "/", with the link it's for and when it took it, by its own clock: posts land in * the order they reach the owner's row lock, never by a sender's clock. ws-slice.ts appsFor shows it beside that link, - * only to viewers at Write (an Admin always), and only while the link is the one the owner's Mac read for members. + * only to viewers at Write (an Admin always), and only while the link is the one the owner's Mac read for members, or, + * where the owner's Mac read none (a new app a bot made on its computer while the Mac was off), the one the app's record + * names by its label copy (ws-slice.ts sitesFilled): that link, and that it asks for a password, at Read too. * A share can miss (Bops Cloud away for longer than its sender keeps trying, an older Bops that never shares): the * owner's Mac, as it reads Orgo for members, tells it what Orgo has (lib/server/apps-for-members.ts), with the entry it * saw (`seen`), so that never lands over a share that came after it looked. diff --git a/cloud/protocol.ts b/cloud/protocol.ts index 1430c8a4a..53dfbd2d4 100644 --- a/cloud/protocol.ts +++ b/cloud/protocol.ts @@ -878,6 +878,8 @@ export type SharedApp = { /** * `url`: Published's address (none: no domain yet). Its Preview, where members deploy, from an owner's Bops that sends * it: its link, newest deploy and what it serves, whether its link asks for a password, and the password at Write only. + * A Preview the owner's Mac hasn't read yet has its link and password from Bops Cloud alone (ws-slice.ts sitesFilled): + * no deploy or what it serves (`serving` null, no `live`). */ sites?: { name: string; diff --git a/cloud/test/core-members.test.ts b/cloud/test/core-members.test.ts index 3dac40421..16f7e7bcb 100644 --- a/cloud/test/core-members.test.ts +++ b/cloud/test/core-members.test.ts @@ -2053,6 +2053,180 @@ test("Your Apps is in the member's fingerprint: what the owner's Mac reads again assert.notEqual(sharedHash(sharedSlice(later, "ws_apps", TOM_VIEW)), a); }); +/* A new app a bot made on its computer while the owner's Mac was off: its record, and the password its computer shared for its Preview's link. */ +const NEW_LABEL = "q4m8x2kz7wpt"; +const NEW_LINK = `https://${NEW_LABEL}.orgo.dev`; +const NEW_PASS = "Tz6kW3p"; +const SITE_LABEL = "r5n9y3va8xqu"; +const SITE_LINK = `https://${SITE_LABEL}.web.orgo.dev`; +const SITE_PASS = "Hb4nQ7s"; +const ADA_VIEW: SliceViewer = { role: "admin", email: "ada@example.com", id: "u-ada", ownerEmail: STAFF_OWNER }; +const SAM_WRITE: SliceViewer = { role: "member", email: "sam@example.com", id: "u-sam", ownerEmail: STAFF_OWNER }; +type NewApp = { host?: Record; entries?: Record; mac?: Record }; +/** + * appsBlob with "client-x" in it as the runner keeps it (its bot and computer, its label copy, when its password was set: + * computer-scope.ts HOSTING_NEW) and its password as the runner shared it (Workspace.previewPasswords); nothing of it the + * owner's Mac read (`mac`: what it did). + */ +function newAppBlob(apps: Record | undefined, o: NewApp = {}): Record { + const blob = appsBlob(apps); + const ws = (blob.workspaces as Record>[])[0]; + (ws.hostedApps as Record)["client-x"] = { botId: "max", computerId: "comp-3", preview: NEW_LABEL, passwordSet: { at: 20, live: null }, ...o.host }; + ws.previewPasswords = o.entries ?? { "client-x/client-x": { url: NEW_LINK, password: NEW_PASS, at: 21 } }; + if (o.mac) ((ws.appsForMembers as Record).apps as Record)["client-x"] = o.mac; + return blob; +} +const newApp = (blob: Record, viewer: SliceViewer) => sharedSlice(blob, "ws_apps", viewer).apps?.list.find((a) => a.name === "client-x"); + +test("Your Apps with the owner's Mac off: a new app's Preview link and password from its computer's share, at Write (an Admin always); at Read its link and that it asks for one, never the password; nothing for None", () => { + for (const viewer of [ADA_VIEW, SAM_WRITE, TOM_VIEW]) { + const write = viewer !== TOM_VIEW; + const s = sharedSlice(newAppBlob({ apps: write ? "write" : "read" }), "ws_apps", viewer); + const app = s.apps!.list.find((a) => a.name === "client-x")!; + assert.deepEqual(Object.keys(app).sort(), ["botId", "name", "sites"], viewer.email); + assert.deepEqual( + app.sites, + [{ name: "client-x", url: null, preview: NEW_LINK, previewProtected: true, ...(write ? { previewPassword: NEW_PASS, previewPasswordAt: 21 } : {}), serving: null }], + viewer.email, + ); + assert.equal(s.apps!.at, 10, "as of the owner's Mac's last read, as before"); + assert.equal(JSON.stringify(s).includes(NEW_PASS), write, `${viewer.email}: the password at Write only`); + for (const hidden of ["comp-", "proj-", "passwordSet", "previewPasswords", NEW_LABEL + '"']) assert.ok(!JSON.stringify(s).includes(hidden), `${viewer.email}: "${hidden}" isn't shown`); + // The apps the owner's Mac read are as they were. + assert.deepEqual(s.apps!.list.find((a) => a.name === "shop"), sharedSlice(appsBlob({ apps: write ? "write" : "read" }), "ws_apps", viewer).apps!.list.find((a) => a.name === "shop")); + } + // Orgo's team in a workspace whose owner isn't: read only, so the link without the password. + const capped = newApp(newAppBlob({ apps: "write" }), { ...TOM_VIEW, ownerEmail: "owner@example.com" }); + assert.equal(capped?.sites?.[0].preview, NEW_LINK); + assert.equal(capped?.sites?.[0].previewPassword, undefined); + for (const [why, blob, viewer] of [ + ["None", newAppBlob({ apps: "none" }), TOM_VIEW], + ["someone the owner gave nothing", newAppBlob({ apps: "write" }), { role: "member", email: "kim@example.com", id: "u-kim", ownerEmail: STAFF_OWNER }], + ["a workspace whose owner isn't Orgo's team", newAppBlob({ apps: "write" }), { ...SAM_WRITE, ownerEmail: "owner@example.com" }], + ["the owner (their own page reads Orgo)", newAppBlob({ apps: "write" }), OWNER_VIEW], + ] as const) { + const text = JSON.stringify(sharedSlice(blob, "ws_apps", viewer as SliceViewer)); + assert.ok(!text.includes(NEW_PASS) && !text.includes(NEW_LINK), why); + } +}); + +test("Your Apps with the owner's Mac off: no link but the one the app's record names, under orgo.dev and nothing else; none for an app that lost its computer or a Preview whose password the owner turned off", () => { + const entry = (url: unknown) => ({ "client-x/client-x": { url, password: NEW_PASS, at: 21 } }); + for (const [why, o] of [ + ["an app that lost its computer", { host: { lost: 30 } }], + ["a record with no label copy", { host: { preview: undefined } }], + ["a label that isn't a Preview's", { host: { preview: "short" }, entries: entry("https://short.orgo.dev") }], + ["its password turned off by the owner", { host: { passwordOff: true } }], + ["a link for another label", { entries: entry("https://z9y8x7w6v5u4.orgo.dev") }], + ["another app's entry", { entries: { "shop/shop": { url: NEW_LINK, password: NEW_PASS, at: 21 } } }], + ["a site the record hasn't", { entries: { "client-x/admin": { url: NEW_LINK, password: NEW_PASS, at: 21 } } }], + ["a host outside orgo.dev", { entries: entry(`https://${NEW_LABEL}.orgo.dev.example.com`) }], + ["a name that only ends like it", { entries: entry(`https://${NEW_LABEL}.notorgo.dev`) }], + ["another domain", { entries: entry(`https://${NEW_LABEL}.example.com`) }], + ["http", { entries: entry(`http://${NEW_LABEL}.orgo.dev`) }], + ["a port", { entries: entry(`${NEW_LINK}:8443`) }], + ["https's own port written out", { entries: entry(`${NEW_LINK}:443`) }], + ["a path", { entries: entry(`${NEW_LINK}/admin`) }], + ["a query", { entries: entry(`${NEW_LINK}/?next=1`) }], + ["a hash", { entries: entry(`${NEW_LINK}/#x`) }], + ["a sign in", { entries: entry(`https://u:p@${NEW_LABEL}.orgo.dev`) }], + ["capitals", { entries: entry(`https://${NEW_LABEL.toUpperCase()}.orgo.dev`) }], + ["a trailing dot", { entries: entry(`${NEW_LINK}.`) }], + ["the label further down", { entries: entry(`https://x.${NEW_LABEL}.orgo.dev`) }], + ["a url that isn't text", { entries: entry(7) }], + ["an entry that isn't one", { entries: { "client-x/client-x": "x" } }], + ["no entry", { entries: {} }], + ] as const) { + for (const viewer of [ADA_VIEW, TOM_VIEW]) { + const blob = newAppBlob({ apps: viewer === TOM_VIEW ? "read" : "write" }, o as NewApp); + const app = newApp(blob, viewer); + assert.ok(app, `${why}: still shown by name`); + assert.equal(app.sites, undefined, `${why}: ${viewer.email}`); + assert.ok(!JSON.stringify(sharedSlice(blob, "ws_apps", viewer)).includes(NEW_PASS), `${why}: ${viewer.email}`); + } + } + // Shown: a slash at its end, and another of Orgo's names in orgo.dev (orgo-web makes some under web.orgo.dev). + for (const url of [`${NEW_LINK}/`, `https://${NEW_LABEL}.web.orgo.dev`, `https://${NEW_LABEL}.staging.orgo.dev`]) + assert.equal(newApp(newAppBlob({ apps: "write" }, { entries: entry(url) }), ADA_VIEW)?.sites?.[0].previewPassword, NEW_PASS, url); + // The owner's Mac's state can say lost in other ways too: never filled. + assert.equal(newApp(newAppBlob({ apps: "write" }, { host: { lost: null } }), ADA_VIEW)?.sites, undefined); +}); + +test("Your Apps with the owner's Mac off: a site the Mac read before its Preview was made gets the link; one it read with a link keeps it; a site it never read comes after, the app's own first", () => { + const macSite = (name: string, more: Record = {}) => ({ name, url: null, preview: null, serving: null, live: null, ...more }); + const mac = (sites: unknown[]) => ({ head: { id: "cs-9", message: "First", at: 19 }, sites, commits: [], deploys: [] }); + // Read with no Preview yet: filled, the rest of the row as the Mac read it. + const early = newApp(newAppBlob({ apps: "write" }, { mac: mac([macSite("client-x", { serving: "cs-9" })]) }), ADA_VIEW)!; + assert.deepEqual(early.sites, [{ name: "client-x", url: null, preview: NEW_LINK, previewProtected: true, previewPassword: NEW_PASS, previewPasswordAt: 21, serving: "cs-9" }]); + assert.deepEqual(early.head, { id: "cs-9", message: "First", at: 19 }); + // A Mac of v0.0.34 (no preview field at all): filled too. + const noField: Record = macSite("client-x"); + delete noField.preview; + assert.equal(newApp(newAppBlob({ apps: "write" }, { mac: mac([noField]) }), ADA_VIEW)!.sites![0].preview, NEW_LINK); + // Read with a link: that one stands, with the Mac's password (Bops Cloud's is for another link); Read never sees either. + const OLD_LINK = "https://k7p2qxm4wr3d.orgo.dev"; + const read = mac([macSite("client-x", { preview: OLD_LINK, previewProtected: true, previewPassword: "Mac4pas" })]); + assert.deepEqual(newApp(newAppBlob({ apps: "write" }, { mac: read }), ADA_VIEW)!.sites![0], { name: "client-x", url: null, preview: OLD_LINK, previewProtected: true, previewPassword: "Mac4pas", serving: null }); + assert.equal(newApp(newAppBlob({ apps: "read" }, { mac: read }), TOM_VIEW)!.sites![0].preview, OLD_LINK); + // Its other sites (AppHosting.sites): each with its own label copy and share. + const two: NewApp = { + host: { sites: [{ name: "client-x-admin", preview: SITE_LABEL }] }, + entries: { "client-x/client-x": { url: NEW_LINK, password: NEW_PASS, at: 21 }, "client-x/client-x-admin": { url: SITE_LINK, password: SITE_PASS, at: 22 } }, + }; + const both = newApp(newAppBlob({ apps: "write" }, two), ADA_VIEW)!; + assert.deepEqual(both.siteNames, ["client-x-admin"]); + assert.deepEqual(both.sites!.map((s) => [s.name, s.preview, s.previewPassword]), [["client-x", NEW_LINK, NEW_PASS], ["client-x-admin", SITE_LINK, SITE_PASS]]); + // The Mac read the app's own site only: the other comes after it. + const later = newApp(newAppBlob({ apps: "write" }, { ...two, mac: mac([macSite("client-x", { preview: NEW_LINK, previewProtected: true })]) }), ADA_VIEW)!; + assert.deepEqual(later.sites!.map((s) => [s.name, s.preview, s.previewPassword]), [["client-x", NEW_LINK, NEW_PASS], ["client-x-admin", SITE_LINK, SITE_PASS]]); + // Only the other site shared yet, nothing read: the app's own still first (what Your Apps shows up top), with no link. + const second = newApp(newAppBlob({ apps: "write" }, { ...two, entries: { "client-x/client-x-admin": { url: SITE_LINK, password: SITE_PASS, at: 22 } } }), ADA_VIEW)!; + assert.deepEqual(second.sites, [ + { name: "client-x", url: null, preview: null, serving: null }, + { name: "client-x-admin", url: null, preview: SITE_LINK, previewProtected: true, previewPassword: SITE_PASS, previewPasswordAt: 22, serving: null }, + ]); + // Only a member's act kept on it (no sites read): every site of the record, its acts as they were. + const acted = newApp(newAppBlob({ apps: "write" }, { mac: { head: null, sites: [], commits: [], deploys: [], acts: [{ key: "k9", userId: "u-sam", name: "Sam", act: "deploy", ok: false, at: 23 }] } }), ADA_VIEW)!; + assert.deepEqual(acted.sites!.map((s) => s.preview), [NEW_LINK]); + assert.equal(acted.acts?.length, 1); + // Never more than an app's sites (lib/hosted-apps.ts MAX_SITES): the record's first five. + const many = Array.from({ length: 7 }, (_, i) => ({ name: `part-${i}`, preview: `${"p".repeat(11)}${i}` })); + const manyEntries = Object.fromEntries([["client-x/client-x", { url: NEW_LINK, password: NEW_PASS, at: 21 }], ...many.map((s) => [`client-x/${s.name}`, { url: `https://${s.preview}.orgo.dev`, password: NEW_PASS, at: 21 }])]); + assert.deepEqual(newApp(newAppBlob({ apps: "write" }, { host: { sites: many }, entries: manyEntries }), ADA_VIEW)!.sites!.map((s) => s.name), ["client-x", "part-0", "part-1", "part-2", "part-3"]); +}); + +test("Your Apps with the owner's Mac off is in the fingerprint: a link shared gives everyone shown it a new one; a new password, a Write viewer only (by its time, never the password)", () => { + const hash = (blob: Record, viewer: SliceViewer) => sharedHash(sharedSlice(blob, "ws_apps", viewer)); + const entryOf = (blob: Record) => ((blob.workspaces as Record>[])[0].previewPasswords as Record>)["client-x/client-x"]; + const none = newAppBlob({ apps: "read" }, { entries: {} }); + const shared = newAppBlob({ apps: "read" }); + assert.notEqual(hash(shared, TOM_VIEW), hash(none, TOM_VIEW), "Read: the link came"); + assert.notEqual(hash(shared, ADA_VIEW), hash(none, ADA_VIEW), "Write: the link and password came"); + // A new password for the same link (its deploy went live): Read's view is the same; Write's changes by its time alone. + const rotated = newAppBlob({ apps: "read" }); + Object.assign(entryOf(rotated), { password: "Wd5mK8r" }); + assert.equal(hash(rotated, TOM_VIEW), hash(shared, TOM_VIEW)); + assert.equal(hash(rotated, ADA_VIEW), hash(shared, ADA_VIEW), "no fingerprint is worked out from a password"); + entryOf(rotated).at = 25; + assert.equal(hash(rotated, TOM_VIEW), hash(shared, TOM_VIEW), "Read: nothing it's shown changed"); + assert.notEqual(hash(rotated, ADA_VIEW), hash(shared, ADA_VIEW), "Write: its time is in it"); +}); + +test("Your Apps shows up to 100 apps, the newest last: one a bot made past the owner's Mac's 30 still has its link", () => { + const blob = newAppBlob({ apps: "write" }); + const ws = (blob.workspaces as Record>[])[0]; + const apps = ws.hostedApps as Record; + const all = { ...apps }; + for (const k of Object.keys(apps)) delete apps[k]; + for (let i = 0; i < 40; i++) apps[`client-${i}`] = { botId: "max", computerId: "comp-3" }; + Object.assign(apps, all); + const list = sharedSlice(blob, "ws_apps", ADA_VIEW).apps!.list; + assert.equal(list.length, 43); + assert.deepEqual(list.at(-1)?.sites?.[0].previewPassword, NEW_PASS, "the newest, 43rd"); + for (let i = 40; i < 120; i++) apps[`client-${i}`] = { botId: "max", computerId: "comp-3" }; + assert.equal(sharedSlice(blob, "ws_apps", ADA_VIEW).apps!.list.length, 100); +}); + test("over HTTP: in a workspace of Orgo's team, Your Apps at Write deploys, goes back, changes settings and uses a domain, whatever the member's email; Read, a missing app, a domain it doesn't own, a deploy that can't go back or a workspace whose owner isn't Orgo's team are refused", async () => { const blob = appsBlob({ apps: "write" }); queued.length = 0; diff --git a/cloud/test/core-preview-passwords.test.ts b/cloud/test/core-preview-passwords.test.ts index 2d5b0e151..55d19fc89 100644 --- a/cloud/test/core-preview-passwords.test.ts +++ b/cloud/test/core-preview-passwords.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { after, before, test } from "node:test"; import WebSocket from "ws"; @@ -108,11 +109,13 @@ function stateOf() { }; } -/** A user whose Mac wrote their state (version 1). */ -async function seeded(what: string) { +/** A user whose Mac wrote their state (version 1), with `edit` made to it first. */ +async function seeded(what: string, edit?: (s: ReturnType) => void) { const u = newUserId(what); users.push(u); - const r = await call(cloud.url, "PUT", "/v1/state", { key: `key-${u}`, headers: asNew(u), json: { base: 0, state: stateOf() } }); + const state = stateOf(); + edit?.(state); + const r = await call(cloud.url, "PUT", "/v1/state", { key: `key-${u}`, headers: asNew(u), json: { base: 0, state } }); assert.equal(r.status, 200, r.text); return u; } @@ -325,6 +328,55 @@ test("who is shown it: a Write member and an Admin see the new one beside its li for (const v of [SAM, ADA, RAE]) assert.ok(!JSON.stringify(sharedSlice((await row(u)).state as unknown as Record, "ws_main", v)).includes("previewPasswords")); }); +/** A Preview label sealed for an app's site, as lib/server/app-labels.ts newLabel makes one from `head` (8 letters and digits): what computer-scope.ts sealedFor lets a computer keep. */ +function sealedLabel(repo: string, site: string, head: string): string { + const h = createHash("sha256").update(`bops:preview:${repo.toLowerCase()}:${site}:${head}`).digest(); + return head + Array.from({ length: 4 }, (_, i) => "abcdefghijklmnopqrstuvwxyz0123456789"[h[i] % 36]).join(""); +} + +test("with the owner's Mac off, a new app a bot made on its computer shows its Preview's link to everyone with Your Apps and its password at Write, from the runner's own writes alone; another computer's share never does", T, async () => { + clearAuthCache(); + const u = await seeded("new-app", (s) => void (s.workspaces[0].orgoProjectId = "proj-acme")); + const label = sealedLabel("proj-acme/client-x", "client-x", "q4m8x2kz"); + const link = `https://${label}.orgo.dev`; + const app = (blob: unknown, v: SliceViewer) => sharedSlice(blob as Record, "ws_main", v).apps?.list.find((a) => a.name === "client-x"); + // The runner on D1 gives it its first home there and keeps its label copy (lib/server/hosted-apps.ts appWork, ensurePreview). + const read = (await call(cloud.url, "GET", "/v1/state", { key: vm(u), headers: asNew(u) })).json; + const next = structuredClone(read.state) as ReturnType; + (next.workspaces[0].hostedApps as Record)["client-x"] = { botId: "main", computerId: D1, preview: label, passwordSet: { at: 5, live: null } }; + const put = await call(cloud.url, "PUT", "/v1/state", { key: vm(u), headers: asNew(u), json: { base: read.version, state: next } }); + assert.equal(put.status, 200, put.text); + const before = (await row(u)).state; + assert.equal(app(before, ADA)?.sites, undefined, "by name only, before its password is shared"); + const hashes = (blob: unknown) => [SAM, ADA, RAE].map((v) => sharedHash(sharedSlice(blob as Record, "ws_main", v))); + const was = hashes(before); + // Another computer (D2, Iris's) telling a password for it: refused, nothing kept. + assert.equal((await post(vm(u, D2), body({ app: "client-x", site: "client-x", url: link, password: "Pt5hR3c" }))).status, 403); + assert.equal(app((await row(u)).state, ADA)?.sites, undefined); + // Its own computer sets its password on Orgo and shares it, with its link. + assert.equal((await post(vm(u), body({ app: "client-x", site: "client-x", url: link, password: "Nw5pR8t" }))).status, 204); + const now = (await row(u)).state; + for (const v of [SAM, ADA]) { + const site = app(now, v)?.sites?.[0]; + assert.deepEqual({ ...site, previewPasswordAt: 0 }, { name: "client-x", url: null, preview: link, previewProtected: true, previewPassword: "Nw5pR8t", previewPasswordAt: 0, serving: null }, v.email); + } + assert.deepEqual(app(now, RAE)?.sites, [{ name: "client-x", url: null, preview: link, previewProtected: true, serving: null }], "Read: its link, and that it asks for a password"); + assert.ok(!JSON.stringify(sharedSlice(now as unknown as Record, "ws_main", RAE)).includes("Nw5pR8t"), "Read never sees it"); + // Everyone shown it is told: each one's fingerprint is another. + hashes(now).forEach((h, i) => assert.notEqual(h, was[i], [SAM, ADA, RAE][i].email)); + // The runner's own read never has it. + const again = await call(cloud.url, "GET", "/v1/state", { key: vm(u), headers: asNew(u) }); + answers.push(again.text); + assert.ok(!again.text.includes("Nw5pR8t")); + // The owner's Mac back on reads it from Orgo: its row stands, the newest password beside it. + const mac = structuredClone(now); + ((mac.workspaces[0].appsForMembers as { apps: Record }).apps)["client-x"] = { head: { id: "cs-1", message: "First", at: 6 }, sites: [{ name: "client-x", url: null, preview: link, previewLive: { id: "dep-1", state: "succeeded", when: 6 }, previewServing: "cs-1", previewProtected: true, serving: null, live: null }], commits: [], deploys: [] }; + const back = await call(cloud.url, "PUT", "/v1/state", { key: `key-${u}`, headers: asNew(u), json: { base: (await row(u)).version, state: mac } }); + assert.equal(back.status, 200, back.text); + const read2 = app((await row(u)).state, ADA)!; + assert.deepEqual(read2.sites?.map((s) => [s.preview, s.previewLive?.state, s.previewPassword]), [[link, "succeeded", "Nw5pR8t"]]); +}); + test("a computer never reads it, can't write one (even the right one), and keeps it when it writes back what it read", T, async () => { clearAuthCache(); const u = await seeded("runner"); diff --git a/cloud/ws-slice.ts b/cloud/ws-slice.ts index 841b0d4ea..154c990f7 100644 --- a/cloud/ws-slice.ts +++ b/cloud/ws-slice.ts @@ -344,10 +344,11 @@ const SITE_SEEN_WRITE: Spec = { ...SITE_SEEN, previewPassword: true }; /** * A Write viewer's sites (as SITE_SEEN_WRITE kept them) with each Preview's password as Bops Cloud keeps it from whoever * set it last (Workspace.previewPasswords, cloud/preview-passwords.ts: "/"), for a site whose Preview link is - * the one it was set for: that one over the copy the owner's Mac read (appsForMembers), which stays for a site Bops - * Cloud has none for. One for a link the site no longer has is never shown. One a share never reached is put right by - * the owner's Mac as it reads Orgo for members (lib/server/apps-for-members.ts fixCloudCopy), and each sender keeps - * trying its newest for about 20 minutes (lib/server/preview-password-share.ts). + * the one it was set for (the owner's Mac's read, or the link Bops Cloud filled in from that entry: sitesFilled): that one + * over the copy the owner's Mac read (appsForMembers), which stays for a site Bops Cloud has none for. One for a link the + * site no longer has is never shown. One a share never reached is put right by the owner's Mac as it reads Orgo for + * members (lib/server/apps-for-members.ts fixCloudCopy), and each sender keeps trying its newest for about 20 minutes + * (lib/server/preview-password-share.ts). */ function withCloudPasswords(app: string, sites: unknown, kept: Record): unknown { if (!Array.isArray(sites)) return sites; @@ -359,6 +360,67 @@ function withCloudPasswords(app: string, sites: unknown, kept: Record.orgo.dev, or under another of Orgo's names in orgo.dev, as + * web.orgo.dev or staging.orgo.dev: which one is orgo-web's to say, so a label alone can't give the link), and nothing + * else: no port, path, query or sign in, exactly as written. + */ +const PREVIEW_LINK = /^https:\/\/([a-z0-9]{12})\.(?:[a-z0-9-]{1,63}\.){0,4}orgo\.dev\/?$/; + +/** + * A site's Preview link as Bops Cloud knows it with the owner's Mac off: the link its newest password was shared for + * (`entry`: Workspace.previewPasswords' url, from the app's own computer or the owner's Bops), only when that's a + * Preview's link (PREVIEW_LINK) whose label is the site's own label copy on the app's record (`label`). A computer keeps + * as that copy only a label sealed for the app and site (computer-scope.ts sealedFor), and shares only its own apps' + * passwords (appHomeHere), so no runner puts a link of its choosing in Your Apps; an entry for a link the record no + * longer names (an app made again, a site given a new link) is never one. Never reads the password. + */ +function previewLinkOf(label: unknown, entry: unknown): string | undefined { + if (typeof label !== "string" || !isObject(entry) || typeof entry.url !== "string" || entry.url.length > 256) return undefined; + const m = PREVIEW_LINK.exec(entry.url); + return m && m[1] === label ? entry.url : undefined; +} + +/** An app's sites as its record has them (lib/hosted-apps.ts AppHosting: its own first, named as the app), each with its Preview label copy and whether the owner turned its password off. */ +function recordSites(name: string, host: Record): { site: string; label: unknown; off: boolean }[] { + const out = [{ site: name, label: host.preview, off: host.passwordOff === true }]; + for (const s of Array.isArray(host.sites) ? host.sites : []) + if (isObject(s) && typeof s.name === "string" && /^[a-z0-9-]{1,64}$/.test(s.name) && !out.some((x) => x.site === s.name)) out.push({ site: s.name, label: s.preview, off: s.passwordOff === true }); + return out.slice(0, MAX_SITES); +} + +/** + * An app's sites as a member is shown them, before the allow list: the owner's Mac's read (`mac`, appsForMembers), with + * each Preview link Bops Cloud knows (previewLinkOf) where that read has none, so a new app's Preview (a bot made it on + * its computer, the owner's Mac off) is there at once: on a site read before its Preview was made, and as a row of its + * own for a site never read. With no read at all, one row for each site of the record, the app's own first (what Your + * Apps shows up top). A link Bops Cloud fills in asks for a password (a password was shared for it); the password itself + * is withCloudPasswords', at Write. A link the owner's Mac read stands as it is. Nothing for an app that lost its + * computer (its Preview went with it), nor a site whose password the owner turned off (no share says its link: the + * owner's Mac's read does). Undefined: no sites to show. + */ +function sitesFilled(name: string, host: Record, mac: unknown[] | undefined, links: Record): unknown[] | undefined { + if (host.lost !== undefined) return mac; + const sites = recordSites(name, host); + const linkOf = new Map(); + for (const s of sites) { + const key = `${name}/${s.site}`; + const link = s.off ? undefined : previewLinkOf(s.label, Object.hasOwn(links, key) ? links[key] : undefined); + if (link) linkOf.set(s.site, link); + } + if (!linkOf.size) return mac; + const row = (site: string) => ({ name: site, url: null, preview: linkOf.get(site) ?? null, ...(linkOf.has(site) ? { previewProtected: true } : {}), serving: null, live: null }); + // (None read, or only a member's act kept on it: lib/server/apps-for-members.ts noteMemberAct.) + if (!mac?.length) return sites.map((s) => row(s.site)); + const read = new Set(mac.flatMap((r) => (isObject(r) && typeof r.name === "string" ? [r.name] : []))); + const filled = mac.map((r) => (isObject(r) && typeof r.name === "string" && typeof r.preview !== "string" && linkOf.has(r.name) ? { ...r, preview: linkOf.get(r.name), previewProtected: true } : r)); + const more = sites.filter((s) => linkOf.has(s.site) && !read.has(s.site)).map((s) => row(s.site)); + return [...filled, ...more.slice(0, Math.max(0, MAX_SITES - filled.length))]; +} + const COMMIT_SEEN: Spec = { id: true, message: true, author: true, at: true, changes: { added: true, modified: true, deleted: true } }; /** `env`: where it went, its Preview or Published (none: from a Bops before, Published's). */ const DEPLOY_SEEN: Spec = { id: true, state: true, at: true, rollback: true, when: true, live: true, canRollback: true, site: true, env: true }; @@ -367,8 +429,12 @@ const SETTINGS_SEEN: Spec = { port: true, install: true, build: true, start: tru /** An app's GitHub production as shown (repo and branch, as the owner's Bops checked them when set), or none when malformed. */ const githubOf = (g: unknown): { repo: string; branch: string } | undefined => isObject(g) && typeof g.repo === "string" && typeof g.branch === "string" && g.repo.length <= 201 && g.branch.length <= 200 ? { repo: g.repo, branch: g.branch } : undefined; -/** The most apps a member is shown. */ -const MAX_SHARED_APPS = 30; +/** + * The most apps a member is shown: as many as a workspace's computers may make (computer-scope.ts MAX_APPS_FROM_COMPUTER), + * as new ones come last. The owner's Mac reads the first 30 for members (lib/hosted-apps.ts MEMBER_APPS); each one after + * those is shown by name, with its Preview's link and password once its computer shared them (sitesFilled). + */ +const MAX_SHARED_APPS = 100; /** * Whether a workspace's apps are shown to `viewer` (apps-team.ts): "write" (as far as their level goes) when orgo-web @@ -387,10 +453,12 @@ export const ownerAppsOpen = (viewer: SliceViewer): boolean => appsOpenTo(viewer * Admin always has Write: viewerAccess). A workspace with no apps yet gives an empty list. Allow lists only: of each app's record its name, its * bot (only one they see), address, whether it lost its computer and its sites' names; of what the owner's Mac read for * members (Workspace.appsForMembers) its head, sites (each with its Preview's link and state), history, deploys and - * members' acts; each site's settings, its Preview's password and the workspace's own domains only at Write. The - * password is the one Bops Cloud keeps from whoever set it last (Workspace.previewPasswords: withCloudPasswords), else - * the owner's Mac's copy; it changes their slice, so a Write viewer watching gets a new fingerprint and reads it. Never - * its variables, tokens, keys, logs, error text, computer or project ids. + * members' acts; each site's settings, its Preview's password and the workspace's own domains only at Write. A site's + * Preview link the owner's Mac hasn't read (a new app a bot made on its computer while the Mac was off) is the one its + * password was shared for, when the app's record names it (sitesFilled), at Read too: that it asks for a password, never + * the password. The password is the one Bops Cloud keeps from whoever set it last (Workspace.previewPasswords: + * withCloudPasswords), else the owner's Mac's copy; it changes their slice, so a Write viewer watching gets a new + * fingerprint and reads it. Never its variables, tokens, keys, logs, error text, computer or project ids. */ export function appsFor(workspace: Record | null, viewer: SliceViewer, access: MemberAccess, botsSeen: ReadonlySet): SharedApps | undefined { const open = appsOpenFor(viewer); @@ -404,8 +472,10 @@ export function appsFor(workspace: Record | null, viewer: Slice if (!isObject(workspace.hostedApps)) return { level: write ? "write" : "read", at: 0, list: [] }; const seen = isObject(workspace.appsForMembers) ? workspace.appsForMembers : {}; const seenApps = isObject(seen.apps) ? seen.apps : {}; - // Each Preview's password as whoever set it last told Bops Cloud: Write only, over the owner's Mac's copy. - const passwords = write && isObject(workspace.previewPasswords) ? workspace.previewPasswords : {}; + // Each Preview's password as whoever set it last told Bops Cloud, with the link it's for: that link at every level, where + // the owner's Mac read none (sitesFilled reads its url alone); the password at Write only, over the owner's Mac's copy. + const links = isObject(workspace.previewPasswords) ? workspace.previewPasswords : {}; + const passwords = write ? links : {}; const list: SharedApp[] = Object.entries(workspace.hostedApps) .filter(([name, h]) => /^[a-z0-9-]{1,64}$/.test(name) && isObject(h)) .slice(0, MAX_SHARED_APPS) @@ -414,6 +484,7 @@ export function appsFor(workspace: Record | null, viewer: Slice const got = isObject(seenApps[name]) ? (seenApps[name] as Record) : {}; const settings = write && isObject(got.settings) ? Object.fromEntries(Object.entries(got.settings).filter(([site, v]) => /^[a-z0-9-]{1,64}$/.test(site) && isObject(v)).map(([site, v]) => [site, keep(v, SETTINGS_SEEN)])) : undefined; const sites = Array.isArray(host.sites) ? host.sites.flatMap((x) => (isObject(x) && typeof x.name === "string" ? [x.name] : [])) : []; + const rows = sitesFilled(name, host, Array.isArray(got.sites) ? got.sites : undefined, links); const app: Record = { name, ...(typeof host.botId === "string" && botsSeen.has(host.botId) ? { botId: host.botId } : {}), @@ -423,7 +494,7 @@ export function appsFor(workspace: Record | null, viewer: Slice ...(githubOf(host.github) ? { github: githubOf(host.github) } : {}), ...(sites.length ? { siteNames: sites } : {}), ...(got.head === null ? { head: null } : isObject(got.head) ? { head: keep(got.head, { id: true, message: true, at: true }) } : {}), - ...(Array.isArray(got.sites) ? { sites: write ? withCloudPasswords(name, keep(got.sites, [SITE_SEEN_WRITE]), passwords) : keep(got.sites, [SITE_SEEN]) } : {}), + ...(rows ? { sites: write ? withCloudPasswords(name, keep(rows, [SITE_SEEN_WRITE]), passwords) : keep(rows, [SITE_SEEN]) } : {}), ...(Array.isArray(got.commits) ? { commits: keep(got.commits, [COMMIT_SEEN]) } : {}), ...(Array.isArray(got.deploys) ? { deploys: keep(got.deploys, [DEPLOY_SEEN]) } : {}), ...(Array.isArray(got.acts) ? { acts: keep(got.acts, [ACT_SEEN]) } : {}),