diff --git a/cloud/preview-passwords.ts b/cloud/preview-passwords.ts index a0550aaf..e2ee0d2d 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 052ad2cb..5e884a1e 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 3dac4042..16f7e7bc 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 2d5b0e15..55d19fc8 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 841b0d4e..154c990f 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]) } : {}),