Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
115 changes: 115 additions & 0 deletions apps/host/src/access.ts
Original file line number Diff line number Diff line change
@@ -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<string, { at: number; ttl: number; value: Promise<boolean> }>();

function readable(repo: string, login?: string): Promise<boolean> {
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<Person["via"] | undefined> {
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<boolean> {
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<boolean> {
return !!shared(person, project) || isDirect(person, project);
}

/** The team's people this host knows, by Tailscale login. */
function team(): Set<string> {
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<ProjectAccess> {
const people = await Promise.all([...team()].map(async (person): Promise<Person | undefined> => {
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<void> {
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);
}
29 changes: 24 additions & 5 deletions apps/host/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void>; changed(): void } | undefined;
let active: { sync(): Promise<void>; again(): Promise<void>; changed(): void } | undefined;

/** A directory base URL without a trailing slash. */
export function canonical(value: string): string {
Expand All @@ -62,7 +63,7 @@ export function canonical(value: string): string {
export function configure(value: string | undefined): Promise<void> | 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;
Expand Down Expand Up @@ -103,6 +104,11 @@ export function sync(): Promise<void> {
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<void> {
return active?.again() ?? Promise.resolve();
}

/** The directory's last answer; empty until it first responds or without a directory. */
export function read() {
return known;
Expand All @@ -122,6 +128,17 @@ export function apply(event: Lifecycle<Listing>) {
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<Share, "at">, shared: boolean): Promise<void> {
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading