diff --git a/.lane/memory/apps/host/src/access.ts/01M5AZBD6AFYG6MAW4TY0PPDVD-only-people-with-direct-acce.md b/.lane/memory/apps/host/src/access.ts/01M5AZBD6AFYG6MAW4TY0PPDVD-only-people-with-direct-acce.md new file mode 100644 index 00000000..273c5153 --- /dev/null +++ b/.lane/memory/apps/host/src/access.ts/01M5AZBD6AFYG6MAW4TY0PPDVD-only-people-with-direct-acce.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZBD6AFYG6MAW4TY0PPDVD +anchor: function share +created: 2026-10-19T20:58:48Z +norm: '1' +sig: d7ac3266cb2699b9 +body_hash: 35c245d2cd091b03 +raw_hash: c5688f0256f1256a +lines: 108-115 +--- + +only people with direct access may change shares, so access from a share is never passed on diff --git a/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md b/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md index 13d32e52..973fc286 100644 --- a/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md +++ b/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md @@ -4,9 +4,9 @@ anchor: function configure created: 2026-10-15T04:08:53Z norm: '1' sig: f3e1bf286b59976d -body_hash: a544ad18b6a1f513 -raw_hash: a96cb077845f8617 -vouched: 2026-10-15T04:30:44Z +body_hash: b0b93d13e4ac000b +raw_hash: 46e2e589b2f3a6ff +vouched: 2026-10-19T20:58:11Z lines: 46-54 --- diff --git a/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md b/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md index 39627ebb..e15e9f48 100644 --- a/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md +++ b/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md @@ -4,9 +4,9 @@ anchor: function watch created: 2026-10-15T04:19:48Z norm: '1' sig: 01895e7ff9a47d37 -body_hash: 548d136b8cbdc7b1 -raw_hash: ed4ddc518aa6008f -vouched: 2026-10-16T20:34:05Z +body_hash: d05a924e14300999 +raw_hash: 37b2264443de0c14 +vouched: 2026-10-19T20:58:11Z lines: 89-149 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md b/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md index 51a2bfa3..221d0b2a 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md @@ -4,9 +4,9 @@ anchor: function serve created: 2026-10-15T03:39:45Z norm: '1' sig: 9102a6912d6b112c -body_hash: efe18941bbea238a -raw_hash: 68e7635dd1eeb140 -vouched: 2026-10-15T10:22:50Z +body_hash: 7c58dffb06b79595 +raw_hash: 6bf75b65468de74e +vouched: 2026-10-19T20:58:11Z lines: 668-751 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md index 463f084c..2c6bfff7 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YVZ6KGNQ7MFS1RP81MQCE0-a-hosted-channel-s-location.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T04:08:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 67b82b247b0adc3f -raw_hash: f2b48d8004d3b7cb -vouched: 2026-10-19T20:55:05Z +body_hash: 4b03543044edfeb1 +raw_hash: 3f6580dfd95cf9e6 +vouched: 2026-10-19T21:08:59Z lines: 266-519 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md index 30353740..1b222e34 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4Z2BCKRNHVMYEMC0KH3Q6BN-a-hosted-channel-must-be-rea.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-15T06:00:20Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 67b82b247b0adc3f -raw_hash: f2b48d8004d3b7cb -vouched: 2026-10-19T20:55:06Z +body_hash: 4b03543044edfeb1 +raw_hash: 3f6580dfd95cf9e6 +vouched: 2026-10-19T21:08:59Z lines: 281-561 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md b/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md index ff607bc1..504101fc 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md @@ -4,9 +4,9 @@ anchor: function serve created: 2026-10-15T10:17:10Z norm: '1' sig: 9102a6912d6b112c -body_hash: efe18941bbea238a -raw_hash: 68e7635dd1eeb140 -vouched: 2026-10-15T10:22:50Z +body_hash: 7c58dffb06b79595 +raw_hash: 6bf75b65468de74e +vouched: 2026-10-19T20:58:11Z lines: 830-948 supersedes: 01M4Z2R1HEVDKEA8VEJ7XRJQ8F --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md b/.lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md index a3ad3764..23488a48 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5APK9YCQH467YNMKAC8BT5B-a-move-to-a-cloud-workspace.md @@ -4,9 +4,9 @@ anchor: function handle created: 2026-10-19T18:25:49Z norm: '1' sig: e597b5fbc8e664ce -body_hash: 67b82b247b0adc3f -raw_hash: f2b48d8004d3b7cb -vouched: 2026-10-19T20:55:06Z +body_hash: 4b03543044edfeb1 +raw_hash: 3f6580dfd95cf9e6 +vouched: 2026-10-19T21:08:59Z lines: 379-692 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZBD5NYPKMB8PXA0BXEEDZ-every-peer-request-that-reac.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZBD5NYPKMB8PXA0BXEEDZ-every-peer-request-that-reac.md new file mode 100644 index 00000000..d2531171 --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZBD5NYPKMB8PXA0BXEEDZ-every-peer-request-that-reac.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZBD5NYPKMB8PXA0BXEEDZ +anchor: function mayOpen +created: 2026-10-19T20:58:48Z +norm: '1' +sig: 63457d044139a469 +body_hash: c50f5a2c20454f5f +raw_hash: 32764dfd8fd9642a +lines: 185-188 +--- + +every peer request that reaches a channel this host runs must pass the project access check; only the host's owner skips it diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5AZBD77VCB1R8N4W5DD6YB1-a-peer-s-github-login-decide.md b/.lane/memory/apps/host/src/gateway.ts/01M5AZBD77VCB1R8N4W5DD6YB1-a-peer-s-github-login-decide.md new file mode 100644 index 00000000..b513e808 --- /dev/null +++ b/.lane/memory/apps/host/src/gateway.ts/01M5AZBD77VCB1R8N4W5DD6YB1-a-peer-s-github-login-decide.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZBD77VCB1R8N4W5DD6YB1 +anchor: function peopleChanged +created: 2026-10-19T20:58:48Z +norm: '1' +sig: e666deb60c81d66b +body_hash: 7fdbdb95491b41b2 +raw_hash: 6184037910c5e4ac +lines: 285-292 +--- + +a peer's GitHub login decides what it sees, so learning one late must resend peers' listings diff --git a/.lane/memory/services/directory/src/index.ts/01M4Z3ZHB0N4SMGC71CRBVSFPS-hosts-publish-every-heartbea.md b/.lane/memory/services/directory/src/index.ts/01M4Z3ZHB0N4SMGC71CRBVSFPS-hosts-publish-every-heartbea.md index a90f5d8e..12b4975f 100644 --- a/.lane/memory/services/directory/src/index.ts/01M4Z3ZHB0N4SMGC71CRBVSFPS-hosts-publish-every-heartbea.md +++ b/.lane/memory/services/directory/src/index.ts/01M4Z3ZHB0N4SMGC71CRBVSFPS-hosts-publish-every-heartbea.md @@ -4,8 +4,9 @@ anchor: class Directory created: 2026-10-15T06:28:49Z norm: '1' sig: 6dd0ab7d7a4fcaa0 -body_hash: 722cfd3311eb2c59 -raw_hash: 68c61286e3d1e7e3 +body_hash: 35f5333e78ef4ab2 +raw_hash: 48cbc89e6e566ec8 +vouched: 2026-10-19T20:58:11Z lines: 27-77 --- diff --git a/.lane/memory/services/directory/src/index.ts/01M5AZBD82R08JCJ7T71MVDQHW-the-first-migration-predates.md b/.lane/memory/services/directory/src/index.ts/01M5AZBD82R08JCJ7T71MVDQHW-the-first-migration-predates.md new file mode 100644 index 00000000..515f4e8c --- /dev/null +++ b/.lane/memory/services/directory/src/index.ts/01M5AZBD82R08JCJ7T71MVDQHW-the-first-migration-predates.md @@ -0,0 +1,12 @@ +--- +id: 01M5AZBD82R08JCJ7T71MVDQHW +anchor: const MIGRATIONS +created: 2026-10-19T20:58:48Z +norm: '1' +sig: 106ab0bf742bac08 +body_hash: 98873eb47b43286b +raw_hash: 827d4fad15fce1ce +lines: 43-58 +--- + +the first migration predates the version key and must stay idempotent for directories created before it diff --git a/apps/host/src/access.ts b/apps/host/src/access.ts new file mode 100644 index 00000000..c553ceb5 --- /dev/null +++ b/apps/host/src/access.ts @@ -0,0 +1,115 @@ +/** + * Project access (docs/terms.md): direct for a GitHub repository a person can read or a folder on + * their own host, otherwise through a share in the team's directory. It is cooperative: peers + * report their owners' GitHub logins unchecked (docs/architecture.md, The team). + */ +import { hostname } from "node:os"; + +import * as catalog from "./catalog"; +import * as directory from "./directory"; +import * as github from "./github"; +import { failure, log } from "./log"; +import * as peers from "./peers"; +import { type Person, type ProjectAccess, TEAM } from "./protocol"; + +/** Listings check access on every frame, so GitHub's answers are kept for a while. */ +const FRESH = 5 * 60_000; +/** A check GitHub couldn't answer refuses access until it is asked again. */ +const RETRY = 60_000; + +let host = hostname(); + +/** This host's tailnet name, which its own folders' project IDs carry. */ +export function identify(name: string): void { + host = name; +} + +const checks = new Map }>(); + +function readable(repo: string, login?: string): Promise { + const key = `${repo} ${login?.toLowerCase()}`; + const cached = checks.get(key); + if (cached && Date.now() - cached.at < cached.ttl) return cached.value; + const entry = { + at: Date.now(), + ttl: FRESH, + value: github.readable(repo, login).catch((error) => { + entry.ttl = RETRY; + log("warn", "access.github", { repo, login, ...failure(error) }); + return false; + }), + }; + checks.set(key, entry); + return entry.value; +} + +/** A person's GitHub login: this host's own from gh, others' as their hosts last reported. */ +function githubOf(person: string): string | undefined { + if (person === catalog.user) return github.known(); + return peers.people()[person] + || directory.read().hosts.find((value) => value.login === person && value.github)?.github; +} + +function ownerOf(name: string): string | undefined { + if (name === host) return catalog.user; + return peers.machine(name)?.login + || directory.read().hosts.find((value) => value.name === name)?.login; +} + +/** Direct access is what lets a person share or unshare a project. */ +async function via(person: string, project: string): Promise { + if (project.startsWith("github:")) { + const isReadable = await readable(project.slice("github:".length), githubOf(person)); + return isReadable ? "github" : undefined; + } + const [folder] = JSON.parse(project) as [string, string]; + return ownerOf(folder) === person ? "folder" : undefined; +} + +export async function isDirect(person: string, project: string): Promise { + return !!await via(person, project); +} + +function shared(person: string, project: string): Person["via"] | undefined { + let found: Person["via"] | undefined; + for (const share of directory.read().shares) { + if (share.project !== project) continue; + if (share.person === TEAM) found = "team"; + else if (share.person === person) return "share"; + } + return found; +} + +export async function canAccess(person: string, project: string): Promise { + return !!shared(person, project) || isDirect(person, project); +} + +/** The team's people this host knows, by Tailscale login. */ +function team(): Set { + const people = new Set([catalog.user, ...Object.keys(peers.people())]); + for (const value of directory.read().hosts) people.add(value.login); + return people; +} + +/** Who on the team can see a project and why, for its owner's Share dialog. */ +export async function describe(project: string): Promise { + const people = await Promise.all([...team()].map(async (person): Promise => { + const reason = await via(person, project) || shared(person, project); + return reason && { person, github: githubOf(person), via: reason }; + })); + return { + direct: await isDirect(catalog.user, project), + shares: directory.read().shares.filter((share) => share.project === project), + people: people.filter((value) => !!value), + }; +} + +/** Only people with direct access share, so access from a share is never passed on. */ +export async function share(project: string, person: string, isShared: boolean): Promise { + if (!await isDirect(catalog.user, project)) { + throw new Error( + "Only people who can read the project on GitHub, or whose folder it is, can share it", + ); + } + await directory.share({ project, person, by: catalog.user }, isShared); +} diff --git a/apps/host/src/directory.ts b/apps/host/src/directory.ts index e94a0f8b..e8a49bca 100644 --- a/apps/host/src/directory.ts +++ b/apps/host/src/directory.ts @@ -5,7 +5,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { Lifecycle } from "@ace/directory/protocol"; +import type { Lifecycle, Share } from "@ace/directory/protocol"; import * as catalog from "./catalog"; import { hostedAuth, rejection, serviceAuth } from "./client"; @@ -31,18 +31,19 @@ export function url(): string | undefined { type Known = { hosts: Host[]; channels: Listing[]; + shares: Share[]; app?: string; hosted?: string; compute?: string; }; -let known: Known = { hosts: [], channels: [] }; +let known: Known = { hosts: [], channels: [], shares: [] }; let synced: number | undefined; /** The last sync's failure, kept until a sync succeeds so Settings can say what is wrong and since when. */ let error: string | undefined; let failed: number | undefined; /** The running host's watch, so a change made through it takes effect without waiting for a heartbeat. */ -let active: { sync(): Promise; changed(): void } | undefined; +let active: { sync(): Promise; again(): Promise; changed(): void } | undefined; /** A directory base URL without a trailing slash. */ export function canonical(value: string): string { @@ -62,7 +63,7 @@ export function canonical(value: string): string { export function configure(value: string | undefined): Promise | undefined { if (value) writeFileSync(config, JSON.stringify({ url: canonical(value) })); else rmSync(config, { force: true }); - known = { hosts: [], channels: [] }; + known = { hosts: [], channels: [], shares: [] }; synced = undefined; error = undefined; failed = undefined; @@ -103,6 +104,11 @@ export function sync(): Promise { return active?.sync() ?? Promise.resolve(); } +/** Read the directory again even if a sync is under way, which may have read it before a change. */ +export function refresh(): Promise { + return active?.again() ?? Promise.resolve(); +} + /** The directory's last answer; empty until it first responds or without a directory. */ export function read() { return known; @@ -122,6 +128,17 @@ export function apply(event: Lifecycle) { known = { ...known, channels: patch(known.channels, event) }; } +/** Shares are the directory's to keep, so a change waits for it and the next sync reads it back. */ +export async function share(share: Omit, shared: boolean): Promise { + const base = url(); + if (!base) throw new Error("Join a team directory to share projects."); + await call(base, "/shares", AbortSignal.timeout(10_000), { + method: "POST", + body: JSON.stringify({ ...share, shared }), + }); + await refresh(); +} + async function call( base: string, path: string, @@ -183,6 +200,8 @@ export function watch({ self, channels, changed, published, covered }: Options) // The directory changed while this sync was under way; its answer belongs to the old one. if (url() !== base) return; for (const event of reading) answer.channels = patch(answer.channels, event); + // Directories deployed before project access keep no shares. + answer.shares ||= []; known = answer; synced = Date.now(); error = undefined; @@ -221,7 +240,7 @@ export function watch({ self, channels, changed, published, covered }: Options) if (!running || running.base !== url()) return join(); return running.again ??= running.done.then(join); }; - active = { sync: join, changed }; + active = { sync: join, again: sync, changed }; void join(); const interval = setInterval(() => { // A failed publish keeps retrying every heartbeat; lobbies only carry what was published. diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index 2dece82a..c6ffd85f 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import type { Server, ServerWebSocket } from "bun"; +import * as access from "./access"; import * as auth from "./auth"; import * as catalog from "./catalog"; import { Connection } from "./client"; @@ -162,9 +163,55 @@ function everyone(own: Listing[]): Listing[] { return [...reachable, ...listed(new Set(reachable.map((value) => value.id)))]; } +/** A peer's person sees this host's channels in projects they can access; the owner sees all. */ +async function allowed(user: string, own: Listing[]): Promise { + if (user === catalog.user) return own; + const checks = await Promise.all( + own.map((value) => access.canAccess(user, channelProject(value))), + ); + return own.filter((_, index) => checks[index]); +} + async function listings(client: Client, own?: Listing[]): Promise { own ||= await local(); - return client.peer ? own : everyone(own); + return client.peer ? allowed(client.user, own) : everyone(own); +} + +async function projectOfChannel(channel: string): Promise { + const { root, repo } = await projects.checkout(catalog.read(channel).project); + return projectId(name, root, repo); +} + +async function mayOpen(client: Client, channel: string): Promise { + return !client.peer || client.user === catalog.user + || access.canAccess(client.user, await projectOfChannel(channel)); +} + +let shares = ""; + +/** + * After a directory read: when shares changed, peers' listings follow and watches on projects their + * person can no longer access end, as a closed channel's do. + */ +function sharesChanged(): Promise { + const current = JSON.stringify(directory.read().shares); + if (current === shares) return ownersChanged(); + shares = current; + return queue(async () => { + await revoke(); + await deliver(await local()); + }); +} + +async function revoke(): Promise { + for (const socket of sockets) { + for (const [channel, open] of socket.data.channels) { + if (!catalog.owns(channel) || await mayOpen(socket.data, channel)) continue; + socket.data.channels.delete(channel); + log("info", "gateway.access.revoked", { user: socket.data.user, channel }); + void open.then((connection) => connection.close(), () => {}); + } + } } const sockets = new Set>(); @@ -185,12 +232,25 @@ function queue(send: () => Promise): Promise { * two hosts relaying each other's frames would loop forever, starving both event loops. */ function broadcast(): Promise { - return queue(async () => { - const own = await local(); - const frame = JSON.stringify({ channels: own } satisfies HostFrame); - for (const socket of sockets) if (socket.data.peer) socket.send(frame); - await owners(own); - }); + return queue(async () => deliver(await local())); +} + +/** Each peer's person gets the channels they can access, worked out once per person. */ +async function deliver(own: Listing[]): Promise { + const frames = new Map>(); + for (const socket of sockets) { + if (!socket.data.peer) continue; + const { user } = socket.data; + let frame = frames.get(user); + if (!frame) { + frame = allowed(user, own).then((channels) => + JSON.stringify({ channels } satisfies HostFrame) + ); + frames.set(user, frame); + } + socket.send(await frame); + } + await owners(own); } function ownersChanged(): Promise { @@ -227,6 +287,8 @@ function peopleChanged(): void { if (frame === sent) return; sent = frame; for (const socket of sockets) if (!socket.data.peer) socket.send(frame); + // Peers' GitHub logins decide which of this host's channels they see, and can arrive late. + void broadcast(); } function loginChanged(): void { @@ -350,6 +412,8 @@ const moveOps = new Set(["move-targets", "move"]); const keychainOps = new Set(["key-store", "key-forget", "directory-set"]); const localOps = new Set([ "projects", + "project-access", + "project-share", "project-open", "project-init", "project-clone", @@ -468,6 +532,14 @@ async function handle( return; case "hello": return { user: client.user, host: name, github: github.login(loginChanged) }; + case "project-access": + return access.describe(request.project); + case "project-share": + await access.share(request.project, request.person, request.shared); + peers.sharesChanged(); + return sharesChanged(); + case "shares-changed": + return directory.refresh(); case "channels": return listings(client); case "projects": @@ -506,7 +578,12 @@ async function handle( .some(({ root }) => root === request.project) : (await projects.list()).some((project) => project.path === request.project); if (!known) throw new Error("Open this project in Ace first"); - return github.project(request.project); + const repo = await github.project(request.project); + const id = projectId(name, request.project, repo || undefined); + if (client.peer && client.user !== catalog.user && !await access.canAccess(client.user, id)) { + throw new Error("You don't have access to this project"); + } + return repo; } case "github-list": return github.list(request); @@ -655,6 +732,9 @@ async function handle( } : undefined; if (catalog.owns(request.channel)) { + if (!await mayOpen(client, request.channel)) { + throw new Error("You don't have access to this channel's project"); + } if (request.request.op === "kill") { if (client.user !== catalog.user) throw new Error("Only the channel's owner can kill it"); terminals.closeChannel(request.channel); @@ -962,12 +1042,15 @@ export async function serve(port: number, loopback = false): Promise { } const machine = loopback ? undefined : await self(); if (closing) return new Promise(() => {}); - if (machine) name = machine.name; + if (machine) { + name = machine.name; + access.identify(name); + } const address = machine ? { address: machine.address } : {}; const publish = loopback ? undefined : directory.watch({ self: () => ({ name, login: catalog.user, github: github.login(loginChanged), ...address }), channels: local, - changed: () => void ownersChanged(), + changed: () => void sharesChanged(), published: (before, after) => before && lobby.published(before, after), covered: lobby.isCovered, }); diff --git a/apps/host/src/github.ts b/apps/host/src/github.ts index d352aa31..577f82f0 100644 --- a/apps/host/src/github.ts +++ b/apps/host/src/github.ts @@ -74,6 +74,37 @@ export function repository(value: string): string { return value; } +/** GitHub hides what this host's account can't read, and collaborator permissions from readers. */ +const MISSING = /HTTP 404|Not Found/; +const PUSH = /HTTP 403/; + +/** + * Whether `login` can read `repo`, as this host's gh account sees it: a public repository is + * readable by everyone, even without a known login, this host's own account by reading it, and anyone else through their + * collaborator permission, which GitHub shows only to accounts that can push; without push, others + * can't be confirmed and get false. Throws when GitHub can't answer, so callers decide how long to + * trust that. + */ +export async function readable(repo: string, login?: string): Promise { + const path = `repos/${repository(repo)}`; + const found = await gh<{ private: boolean }>(["api", path]).catch((error: Error) => { + if (MISSING.test(error.message)) return undefined; + throw error; + }); + if (!found) return false; + if (!found.private) return true; + if (!login || !/^[A-Za-z0-9-]{1,39}$/.test(login)) return false; + if (login.toLowerCase() === owner?.toLowerCase()) return true; + const { permission } = await gh<{ permission: string }>([ + "api", + `${path}/collaborators/${login}/permission`, + ]).catch((error: Error) => { + if (MISSING.test(error.message) || PUSH.test(error.message)) return { permission: "none" }; + throw error; + }); + return permission !== "none"; +} + /** A GitHub repository URL, SSH remote, or owner/name, as a valid owner/name. */ export function parse(value: string): string { const repo = githubRepo(value); @@ -173,6 +204,11 @@ export async function clone(repo: string, path: string): Promise { let owner: string | undefined; let checked = 0; +/** The owner's GitHub login if gh already told it, without asking again. */ +export function known(): string | undefined { + return owner; +} + /** * The owner's GitHub login from gh, for avatars. Unknown until resolved; a missing or signed-out * gh is checked again at most every five minutes, so signing in later takes effect. diff --git a/apps/host/src/peers.ts b/apps/host/src/peers.ts index 4f0702c2..fff546fa 100644 --- a/apps/host/src/peers.ts +++ b/apps/host/src/peers.ts @@ -142,3 +142,13 @@ export function find(channel: string): Machine | undefined { export function machine(name: string): Machine | undefined { return hosts.get(name)?.machine; } + +/** + * Peers read shares from the directory, which a lobby can't announce to someone just given + * access, so the host that changed them tells every reachable peer to read it again. + */ +export function sharesChanged(): void { + for (const { client } of hosts.values()) { + if (client.status === "open") client.request({ op: "shares-changed" }).catch(() => {}); + } +} diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index a9741544..4ccad57a 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -8,7 +8,12 @@ import type { Request, RunEnd, } from "@ace/channel/protocol"; -import type { Presence } from "@ace/directory/protocol"; +import type { Presence, Share } from "@ace/directory/protocol"; + +export type { Share }; + +/** A share's `person` when it gives the whole team access. */ +export const TEAM = "*"; export const HOST_PROTOCOL = 10; @@ -174,6 +179,20 @@ export type TabRename = { }; export type TabResult = { ok: true; value: WindowState } | { ok: false; error: string }; +/** + * Someone on the team who can see a project: through GitHub read access or their own folder, which + * lets them share it, or through a share with the team or with them. + */ +export type Person = { + /** Tailscale login. */ + person: string; + github?: string; + via: "github" | "folder" | "team" | "share"; +}; + +/** `direct` is whether this host's owner can share and unshare the project. */ +export type ProjectAccess = { direct: boolean; shares: Share[]; people: Person[] }; + /** A folder opened on this host, independent of its channels and provider setup. */ export type Project = { /** The repository's main checkout, or the folder itself outside Git. */ @@ -233,6 +252,15 @@ export type HostRequest = * expands `~`. */ | { op: "project-clone"; repo: string; path: string } + /** Who can see a project by its ID, and whether this host's owner can share it. */ + | { op: "project-access"; project: string } + /** + * Share a project with a person by Tailscale login, or with the team as `TEAM`, or unshare it. + * Only someone with direct access can, and peers hear at once so their listings follow. + */ + | { op: "project-share"; project: string; person: string; shared: boolean } + /** From a peer whose owner changed shares: read the directory again. */ + | { op: "shares-changed" } /** Resolve a known project's GitHub remote on the machine that holds its checkout. */ | { op: "project-repo"; project: string; host?: string } /** GitHub reads use the local owner's GitHub CLI credentials, never a peer's account. */ diff --git a/docs/architecture.md b/docs/architecture.md index d6fdbcd7..04072267 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -666,6 +666,19 @@ channels, and only people with direct access change them. Existing projects beco upgrade. The host that runs a channel names each caller with `tailscale whois` and refuses callers without access, and each host shows its owner only the projects the owner can access. +`apps/host/src/access.ts` decides access. A host's owner always reaches its own channels, from any +of their machines. GitHub access is checked with the deciding host's `gh` account and kept for five +minutes: a public repository is open to everyone, its own account reads the repository, and +anyone else needs a collaborator permission other than `none`. GitHub shows those only to +accounts that can push, so a host whose owner can only read a private repository cannot confirm +teammates' access and they need a share. A check GitHub could not answer refuses access for a +minute. The directory keeps shares in its `shares` table; `GET /` returns them and +`POST /shares` adds or removes one. Hosts check direct access before writing, then tell every +reachable peer (`shares-changed`) to read the directory again, since a lobby can't reach someone +who just got access. After each directory read, a host whose shares changed resends each peer +the channels its person can access and ends their watches on channels they no longer can. Each +peer's listing is filtered for its person, and is resent when a peer's GitHub login arrives late. + **Known limitation: project access is cooperative.** It holds between hosts running unmodified Ace, not against a teammate who tampers with theirs. The team services accept one credential derived from `ACE_SECRET`, which every host holds, so anyone with that secret can read every diff --git a/services/directory/src/index.ts b/services/directory/src/index.ts index 5d2cf39b..b3ad4379 100644 --- a/services/directory/src/index.ts +++ b/services/directory/src/index.ts @@ -1,6 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import type { Lobby } from "./lobby"; +import type { Share } from "./protocol"; import { authorized, refused } from "./scope"; export { Lobby } from "./lobby"; @@ -34,6 +35,28 @@ export type Publication = { export type Host = Omit & { name: string; seen: number }; +/** + * Applied in order and counted in the `version` key, since Durable Object SQLite refuses + * `PRAGMA user_version`. Never edit one that has shipped. The first predates the key, so it must + * stay idempotent for directories created before it. + */ +const MIGRATIONS = [ + `CREATE TABLE IF NOT EXISTS hosts (name TEXT PRIMARY KEY, host TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + host TEXT NOT NULL, + listing TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS channels_host ON channels (host);`, + `CREATE TABLE shares ( + project TEXT NOT NULL, + person TEXT NOT NULL, + shared_by TEXT NOT NULL, + at INTEGER NOT NULL, + PRIMARY KEY (project, person) + );`, +]; + /** * The team's list of hosts and where each channel lives. It answers lookups; channel traffic never * passes through it. Each publication replaces the host's whole channel set, so a removed channel @@ -42,15 +65,12 @@ export type Host = Omit & { name: string; seen: number export class Directory extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); - ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS hosts (name TEXT PRIMARY KEY, host TEXT NOT NULL); - CREATE TABLE IF NOT EXISTS channels ( - id TEXT PRIMARY KEY, - host TEXT NOT NULL, - listing TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS channels_host ON channels (host); - `); + const version = ctx.storage.kv.get("version") || 0; + if (version >= MIGRATIONS.length) return; + ctx.storage.transactionSync(() => { + for (const migration of MIGRATIONS.slice(version)) ctx.storage.sql.exec(migration); + ctx.storage.kv.put("version", MIGRATIONS.length); + }); } publish(name: string, publication: Publication): void { @@ -78,7 +98,7 @@ export class Directory extends DurableObject { }); } - read(): { hosts: Host[]; channels: unknown[] } { + read(): { hosts: Host[]; channels: unknown[]; shares: Share[] } { const sql = this.ctx.storage.sql; return { hosts: sql.exec<{ host: string }>("SELECT host FROM hosts").toArray().map((row) => @@ -87,8 +107,25 @@ export class Directory extends DurableObject { channels: sql.exec<{ listing: string }>("SELECT listing FROM channels").toArray().map(( row, ) => JSON.parse(row.listing)), + shares: sql.exec('SELECT project, person, shared_by AS "by", at FROM shares') + .toArray(), }; } + + share(share: Share, shared: boolean): void { + const sql = this.ctx.storage.sql; + if (shared) { + sql.exec( + "INSERT OR REPLACE INTO shares VALUES (?, ?, ?, ?)", + share.project, + share.person, + share.by, + share.at, + ); + } else { + sql.exec("DELETE FROM shares WHERE project = ? AND person = ?", share.project, share.person); + } + } } function decoded(component: string): string | undefined { @@ -114,10 +151,11 @@ export default { } const directory = env.DIRECTORY.get(env.DIRECTORY.idFromName("team")); if (pathname === "/" && request.method === "GET") { - const { hosts, channels } = await directory.read(); + const { hosts, channels, shares } = await directory.read(); return Response.json({ hosts, channels, + shares, app: env.APP, hosted: env.HOSTED, compute: env.COMPUTE, @@ -135,6 +173,17 @@ export default { await directory.publish(host[1], publication); return new Response(null, { status: 204 }); } + if (pathname === "/shares" && request.method === "POST") { + const { shared, ...share } = await request.json(); + if ( + typeof share.project !== "string" || typeof share.person !== "string" + || typeof share.by !== "string" || typeof shared !== "boolean" + ) { + return new Response("Invalid share", { status: 400 }); + } + await directory.share({ ...share, at: Date.now() }, shared); + return new Response(null, { status: 204 }); + } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; diff --git a/services/directory/src/protocol.ts b/services/directory/src/protocol.ts index af9c21a6..41a26510 100644 --- a/services/directory/src/protocol.ts +++ b/services/directory/src/protocol.ts @@ -65,3 +65,15 @@ export type LobbyDown = /** Sent as text; the lobby answers `pong` without waking. */ export const PING = "ping"; export const PONG = "pong"; + +/** + * Access to a whole project, every host's channels in it, for one person by Tailscale login or + * for the team (`*`). The directory stores what hosts send; hosts decide who may share. + */ +export type Share = { + project: string; + person: string; + /** Who shared it, by Tailscale login. */ + by: string; + at: number; +};