mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test); sync-versions also updates apps' workspace pins.
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
// Share links, sharing with people by email, the join page, guest authentication and live revocation (server).
|
|
import { randomBytes } from "node:crypto";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { domainToASCII } from "node:url";
|
|
import { arr, nul, obj, str, union } from "@earendil-works/pi-schema";
|
|
import {
|
|
type Ctx,
|
|
contribute,
|
|
defineFacet,
|
|
defineState,
|
|
defineTypedState,
|
|
deriveTyped,
|
|
forbidden,
|
|
type GuestPrincipal,
|
|
isGuest,
|
|
isOwner,
|
|
type P2Principal,
|
|
record,
|
|
} from "@p2/app/v1";
|
|
import {
|
|
Authenticators,
|
|
cookieValue,
|
|
fromOrigin,
|
|
hasLoopbackCookie,
|
|
page,
|
|
type RequestOrigin,
|
|
Routes,
|
|
Server,
|
|
type ServerInfo,
|
|
SessionAccess,
|
|
ShareUrls,
|
|
sendJson,
|
|
serverOf,
|
|
} from "@p2/app/v1/server";
|
|
import { Catalog } from "../sessions/contract.ts";
|
|
import { ChildStore, remoteEnvironments } from "../subagents/contract.ts";
|
|
import {
|
|
ConnectedSchema,
|
|
GuestPresence,
|
|
LinkSchema,
|
|
PersonSchema,
|
|
Presence,
|
|
type ShareLink,
|
|
type ShareMode,
|
|
Shares,
|
|
Sharing,
|
|
} from "./contract.ts";
|
|
import { type GuestRow, JoinError, ShareDb } from "./db.ts";
|
|
|
|
/** Registers the join routes, the guest cookie and Radius authenticators, and guest session access. */
|
|
|
|
const cookieName = (port: number): string => `p2_guest_${port}`;
|
|
|
|
/** The URL of a share link: on the first tunnel host, or on loopback when there is none. */
|
|
function shareUrl(token: string, { allowedHosts, port }: ServerInfo): string {
|
|
const origin = allowedHosts[0] ? `https://${allowedHosts[0]}` : `http://127.0.0.1:${port}`;
|
|
return `${origin}/join/${token}`;
|
|
}
|
|
|
|
const GMAIL_DOMAINS = new Set(["gmail.com", "googlemail.com"]);
|
|
|
|
/**
|
|
* An email as Radius normalizes it (`normalizeEmail` in radius/packages/control-plane/src/utils/mail.ts), so that the
|
|
* address the owner typed matches the verified one Radius names. Keep the two in step. Null when it is no address.
|
|
*/
|
|
export function normalizeEmail(email: string): string | null {
|
|
const parts = email.trim().split("@");
|
|
if (parts.length !== 2 || !parts[0]?.trim() || !parts[1]) return null;
|
|
const local = parts[0].trim().toLowerCase();
|
|
const domain = domainToASCII(parts[1]).toLowerCase();
|
|
if (!domain || !domain.includes(".")) return null;
|
|
return GMAIL_DOMAINS.has(domain) ? `${local.replaceAll(".", "")}@gmail.com` : `${local}@${domain}`;
|
|
}
|
|
|
|
/** Server-rendered pages run one inline script, allowed by a per-response nonce. */
|
|
function sendPage(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
status: number,
|
|
content: { title: string; heading: string; body: string; script?: string },
|
|
): void {
|
|
const nonce = randomBytes(18).toString("base64url");
|
|
response.writeHead(status, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
});
|
|
response.end(request.method === "HEAD" ? undefined : page({ ...content, nonce }));
|
|
}
|
|
|
|
const JOIN_SCRIPT =
|
|
'document.getElementById("join").onsubmit=async(e)=>{e.preventDefault();const status=document.getElementById("status");try{const r=await fetch("/join",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:location.pathname.split("/").pop(),name:document.getElementById("name").value})});const v=await r.json();if(!r.ok)throw new Error(v.error);location.replace("/s/"+v.session);}catch(e){status.className="error";status.textContent=e.message;}};';
|
|
|
|
async function readJson(request: IncomingMessage, limit: number): Promise<unknown> {
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
|
|
size += bytes.length;
|
|
if (size > limit) throw new JoinError("Too large");
|
|
chunks.push(bytes);
|
|
}
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
}
|
|
|
|
/** The database's links, emails and people as `Shares` shows them, and the archived sessions as last seen. */
|
|
const Shown = defineTypedState("p2.shares", {
|
|
schema: obj({
|
|
/** Null before the catalog was first seen. */
|
|
archived: union([arr(str()), nul()]),
|
|
links: arr(LinkSchema),
|
|
emails: arr(str()),
|
|
people: arr(PersonSchema),
|
|
}),
|
|
version: 1,
|
|
initial: () => ({ archived: null, links: [], emails: [], people: [] }),
|
|
});
|
|
|
|
/** The connected guests (in memory). */
|
|
const Connected = defineState("p2.presence", {
|
|
schema: obj({ guests: record(ConnectedSchema) }),
|
|
version: 1,
|
|
initial: () => ({ guests: {} }),
|
|
});
|
|
|
|
export default defineFacet((host) => {
|
|
host.serve(Shares, (_params, self) => {
|
|
const catalog = self.get(Catalog, {});
|
|
const children = self.get(ChildStore, {});
|
|
const server = self.get(Server, {});
|
|
const shown = self.state(Shown);
|
|
const unarchived = (session: string): boolean => !shown.value.archived?.includes(session);
|
|
const reload = (ctx: Ctx) =>
|
|
shown.change((draft) => {
|
|
draft.links = dbResource.value.links();
|
|
draft.emails = dbResource.value.emails(unarchived);
|
|
draft.people = dbResource.value.people();
|
|
}, ctx);
|
|
const dbResource = self.open(
|
|
async (ctx) => {
|
|
const db = new ShareDb(server.info.value.dataDir);
|
|
await shown.change((draft) => {
|
|
draft.links = db.links();
|
|
draft.emails = db.emails(unarchived);
|
|
draft.people = db.people();
|
|
}, ctx);
|
|
return db;
|
|
},
|
|
(db) => db.close(),
|
|
);
|
|
/** The session is known and not archived. */
|
|
const open = async (session: string, ctx: Ctx): Promise<boolean> => {
|
|
const found = await catalog.get({ id: session }, ctx).catch(() => undefined);
|
|
return found !== undefined && found.archivedAt === null;
|
|
};
|
|
/** A guest's principal: its grants on sessions that are open now; null when there are none. */
|
|
const principalOf = async (row: GuestRow | undefined, ctx: Ctx): Promise<GuestPrincipal | null> => {
|
|
if (row === undefined) return null;
|
|
const sessions: Record<string, ShareMode> = {};
|
|
let home: { session: string; at: number } | undefined;
|
|
for (const grant of dbResource.value.grants(row.id)) {
|
|
if (grant.removed || !(await open(grant.session, ctx))) continue;
|
|
sessions[grant.session] = grant.mode;
|
|
if (!home || grant.grantedAt >= home.at) home = { session: grant.session, at: grant.grantedAt };
|
|
}
|
|
if (!home) return null;
|
|
const { id, name, email, revision } = row;
|
|
return {
|
|
kind: "guest",
|
|
id,
|
|
name,
|
|
...(email === null ? {} : { email }),
|
|
home: home.session,
|
|
sessions,
|
|
revision,
|
|
};
|
|
};
|
|
/** Ends the objects of guests whose grants changed, so that they continue with their new principal. */
|
|
const revoke = async (guests: readonly string[], ctx: Ctx): Promise<void> => {
|
|
for (const id of guests) await server.revokeGuests({ id }, ctx);
|
|
};
|
|
/** Refuses read/write access to a session on a remote environment the owner did not confirm. */
|
|
const confirmRemote = async (session: string, mode: ShareMode, confirmed: boolean | undefined, ctx: Ctx) => {
|
|
if (!(await open(session, ctx))) throw new Error("Session not found or archived");
|
|
// A subagent's own location may be remote while the session is not: guests reach it as well.
|
|
const remote = remoteEnvironments(await catalog.get({ id: session }, ctx), children.children.value[session]);
|
|
if (mode === "full" && remote.length && !confirmed)
|
|
throw new Error("Confirm first: guests with send access can run commands on this remote host");
|
|
};
|
|
// Archive ends every guest's access to the session, restore gives it back: both change their grants.
|
|
self.on(catalog.sessions, async ({ value: sessions }, ctx) => {
|
|
const archived = sessions.filter((session) => session.archivedAt !== null).map((session) => session.id);
|
|
const previous = shown.value.archived;
|
|
await shown.change((draft) => {
|
|
draft.archived = archived;
|
|
}, ctx);
|
|
await shown.change((draft) => {
|
|
draft.emails = dbResource.value.emails(unarchived);
|
|
}, ctx);
|
|
if (previous === null) return;
|
|
const before = new Set(previous);
|
|
const after = new Set(archived);
|
|
const changed = [...archived.filter((id) => !before.has(id)), ...previous.filter((id) => !after.has(id))];
|
|
for (const session of changed) await revoke(dbResource.value.bump(session), ctx);
|
|
});
|
|
return {
|
|
links: deriveTyped(self, [shown], ([value]) => value.links),
|
|
emails: self.derive([shown], ([value]) => value.emails),
|
|
people: deriveTyped(self, [shown], ([value]) => value.people),
|
|
authenticate: async ({ cookie }, ctx) => {
|
|
const principal = await principalOf(dbResource.value.guestOfCookie(cookie), ctx);
|
|
if (principal) dbResource.value.seen(principal.id);
|
|
return principal;
|
|
},
|
|
authenticateEmail: async ({ email }, ctx) => {
|
|
const principal = await principalOf(dbResource.value.guestOfEmail(email), ctx);
|
|
if (principal) dbResource.value.seen(principal.id);
|
|
return principal;
|
|
},
|
|
grant: ({ id }, ctx) => principalOf(dbResource.value.guest(id), ctx),
|
|
active: async ({ token }, ctx) => {
|
|
const link = dbResource.value.linkByToken(token);
|
|
return link && (await open(link.session, ctx)) ? link : null;
|
|
},
|
|
link: async ({ session, mode, reset, remoteConfirmed }, ctx) => {
|
|
await confirmRemote(session, mode, remoteConfirmed, ctx);
|
|
const ended = dbResource.value.link(session, mode, reset);
|
|
await reload(ctx);
|
|
await revoke(ended, ctx);
|
|
return null;
|
|
},
|
|
share: async ({ session, email, mode, remoteConfirmed }, ctx) => {
|
|
const normalized = normalizeEmail(email);
|
|
if (!normalized) throw new Error("Enter an email address");
|
|
await confirmRemote(session, mode, remoteConfirmed, ctx);
|
|
const shared = dbResource.value.share(session, normalized, email.trim(), mode);
|
|
await reload(ctx);
|
|
if (shared.changed) await revoke([shared.guest], ctx);
|
|
return null;
|
|
},
|
|
remove: async ({ session, guestId }, ctx) => {
|
|
if (dbResource.value.remove(session, guestId)) await revoke([guestId], ctx);
|
|
await reload(ctx);
|
|
return null;
|
|
},
|
|
join: async ({ token, name, cookie }, ctx) => {
|
|
const db = dbResource.value;
|
|
const link = db.linkByToken(token);
|
|
if (!link || !(await open(link.session, ctx))) throw new JoinError("This share has ended");
|
|
// The same browser keeps its identity and gains the session; a removed one cannot join it again.
|
|
const existing = cookie === undefined ? undefined : db.guestOfCookie(cookie);
|
|
const joined = db.join(link, name, existing);
|
|
if (joined.changed) await revoke([joined.guest], ctx);
|
|
return { session: link.session, ...(joined.cookie ? { cookie: joined.cookie } : {}) };
|
|
},
|
|
};
|
|
});
|
|
|
|
host.serve(Presence, (_params, self) => {
|
|
const connected = self.state(Connected);
|
|
return {
|
|
guests: self.derive([connected], ([value]) => value.guests),
|
|
enter: async (guest, ctx) => {
|
|
await connected.change((draft) => {
|
|
draft.guests[guest.id] = guest;
|
|
}, ctx);
|
|
return null;
|
|
},
|
|
leave: async ({ id }, ctx) => {
|
|
await connected.change((draft) => {
|
|
delete draft.guests[id];
|
|
}, ctx);
|
|
return null;
|
|
},
|
|
};
|
|
});
|
|
|
|
host.serve(GuestPresence, (_params, principal, self) => {
|
|
if (isOwner(principal)) return { variant: "owner", members: {} };
|
|
if (!isGuest(principal)) throw forbidden("owners and guests only");
|
|
const presence = self.get(Presence, {});
|
|
const server = serverOf(self);
|
|
const { id, name, sessions } = principal;
|
|
// One object per guest revision: live while any of the guest's tabs holds it.
|
|
self.open(
|
|
(ctx) => presence.enter({ id, name, sessions }, ctx),
|
|
async () => {
|
|
await server().root((ctx) => presence.leave({ id }, ctx));
|
|
},
|
|
);
|
|
return { variant: "guest", members: {} };
|
|
});
|
|
|
|
host.serve(Sharing, ({ session }, principal, self) => {
|
|
if (!isOwner(principal)) throw forbidden("sharing is owner-only");
|
|
const shares = self.get(Shares, {});
|
|
const presence = self.get(Presence, {});
|
|
const server = self.get(Server, {});
|
|
const catalog = self.get(Catalog, {});
|
|
const children = self.get(ChildStore, {});
|
|
// Every remote environment a read/write guest can reach: the session's and its subagents' own.
|
|
const shareUrls = self.registry(ShareUrls);
|
|
const env = self.derive(
|
|
[catalog.sessions, children.children],
|
|
([sessions, stored]) =>
|
|
remoteEnvironments(
|
|
sessions.find((entry) => entry.id === session),
|
|
stored[session],
|
|
).join(", ") || null,
|
|
);
|
|
const linksView = self.derive([shares.links, server.info], ([links, info]) =>
|
|
(links as readonly ShareLink[])
|
|
.filter((link) => link.session === session)
|
|
.map(({ mode, token }) => ({ mode, token, url: shareUrl(token, info as ServerInfo) })),
|
|
);
|
|
const peopleView = self.derive([shares.people], ([people]) =>
|
|
people.filter((person) => person.session === session).map(({ id, name, mode }) => ({ id, name, mode })),
|
|
);
|
|
// Read when the people change; a brain learns its Radius page from the owner's first visit through Radius.
|
|
const peopleUrl = self.derive([shares.people, shareUrls], ([, urls]) => {
|
|
for (const url of urls.values()) {
|
|
const value = url(session);
|
|
if (value !== undefined) return value;
|
|
}
|
|
return null;
|
|
});
|
|
const guestsView = self.derive([presence.guests], ([guests]) =>
|
|
Object.values(guests)
|
|
.flatMap(({ id, name, sessions }) => {
|
|
const mode = sessions[session];
|
|
return mode === undefined ? [] : [{ id, name, mode }];
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
);
|
|
return {
|
|
variant: "owner",
|
|
members: {
|
|
env,
|
|
links: linksView,
|
|
guests: guestsView,
|
|
people: peopleView,
|
|
peopleUrl,
|
|
link: ({ mode, reset, remoteConfirmed }, ctx) =>
|
|
shares.link(
|
|
{ session, mode, reset: reset ?? false, ...(remoteConfirmed ? { remoteConfirmed } : {}) },
|
|
ctx,
|
|
),
|
|
share: ({ email, mode, remoteConfirmed }, ctx) =>
|
|
shares.share({ session, email, mode, ...(remoteConfirmed ? { remoteConfirmed } : {}) }, ctx),
|
|
remove: ({ guestId }, ctx) => shares.remove({ session, guestId }, ctx),
|
|
},
|
|
};
|
|
});
|
|
|
|
contribute(host, "p2.sharing-http", (self) => {
|
|
const shares = self.get(Shares, {});
|
|
const server = self.get(Server, {});
|
|
const catalog = self.get(Catalog, {});
|
|
const joinPage = async (
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
principal: P2Principal | undefined,
|
|
origin: RequestOrigin,
|
|
ctx: Ctx,
|
|
): Promise<void> => {
|
|
const token = new URL(request.url ?? "/", "http://localhost").pathname.split("/")[2] ?? "";
|
|
const link = await shares.active({ token }, ctx);
|
|
if (!link) {
|
|
sendPage(request, response, 410, {
|
|
title: "Share ended",
|
|
heading: "This share has ended",
|
|
body: "<p>The link was reset or the session was archived. Ask the owner for a new one.</p>",
|
|
});
|
|
return;
|
|
}
|
|
// The owner, or a guest who already joined through this link, goes straight to the session. As in Pi Mobile,
|
|
// an owner only by its cookie (or relay): a cookieless loopback browser (a fresh profile opening the link) is
|
|
// not the device owner here, but someone joining.
|
|
const owner =
|
|
isOwner(principal) &&
|
|
(principal.access !== "local" || hasLoopbackCookie(request, origin.port, server.info.value.dataDir));
|
|
if (owner || (isGuest(principal) && principal.sessions[link.session] === link.mode)) {
|
|
response.writeHead(303, { Location: `/s/${link.session}` });
|
|
response.end();
|
|
return;
|
|
}
|
|
const title = (await catalog.get({ id: link.session }, ctx)).title;
|
|
const access =
|
|
link.mode === "full"
|
|
? "You can read, send messages and uploads, and stop runs. This lets you make the agent run commands on the host."
|
|
: "You can read along, but not send messages.";
|
|
sendPage(request, response, 200, {
|
|
title: "Join shared session",
|
|
heading: title,
|
|
body: `<p>A session has been shared with you. ${access} Enter your name to join; names are not verified.</p><form id="join"><label for="name">Your name</label><input id="name" maxlength="40" required autocomplete="nickname"><button>Join session</button></form><p id="status" role="status"></p>`,
|
|
script: JOIN_SCRIPT,
|
|
});
|
|
};
|
|
const join = async (
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
origin: RequestOrigin,
|
|
ctx: Ctx,
|
|
): Promise<void> => {
|
|
// A state-changing request: exactly same-origin (a cross-site form cannot send this Origin).
|
|
if (!fromOrigin(request, origin)) {
|
|
request.resume();
|
|
sendJson(response, 403, { error: "Invalid origin" });
|
|
return;
|
|
}
|
|
try {
|
|
const input = await readJson(request, 2048);
|
|
if (
|
|
typeof input !== "object" ||
|
|
input === null ||
|
|
typeof (input as { token?: unknown }).token !== "string" ||
|
|
typeof (input as { name?: unknown }).name !== "string"
|
|
)
|
|
throw new JoinError("Invalid join");
|
|
const { token, name } = input as { token: string; name: string };
|
|
const cookie = cookieValue(request, cookieName(origin.port));
|
|
const joined = await shares.join({ token, name, ...(cookie ? { cookie } : {}) }, ctx);
|
|
sendJson(
|
|
response,
|
|
200,
|
|
{ session: joined.session },
|
|
joined.cookie
|
|
? {
|
|
"Set-Cookie": `${cookieName(origin.port)}=${joined.cookie}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000${origin.remote ? "; Secure" : ""}`,
|
|
}
|
|
: {},
|
|
);
|
|
} catch (error) {
|
|
request.resume();
|
|
sendJson(response, 400, {
|
|
error: error instanceof JoinError ? error.message : "Could not join this session.",
|
|
});
|
|
}
|
|
};
|
|
/** Through Radius, people are shared with by email and carry no cookie, so links do not apply there. */
|
|
const refuseRelayed = (request: IncomingMessage, response: ServerResponse): void => {
|
|
request.resume();
|
|
response.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
response.end("Share links do not work through Radius. Ask the owner to share the session with your email.\n");
|
|
};
|
|
self.contribute(Routes, {
|
|
"GET /join/*": (request, response, { principal, origin }, ctx) =>
|
|
origin.relayUser ? refuseRelayed(request, response) : joinPage(request, response, principal, origin, ctx),
|
|
"HEAD /join/*": (request, response, { principal, origin }, ctx) =>
|
|
origin.relayUser ? refuseRelayed(request, response) : joinPage(request, response, principal, origin, ctx),
|
|
"POST /join": (request, response, { origin }, ctx) =>
|
|
origin.relayUser ? refuseRelayed(request, response) : join(request, response, origin, ctx),
|
|
});
|
|
self.contribute(Authenticators, {
|
|
guest: async (request, origin, ctx) => {
|
|
if (origin.relayUser !== undefined)
|
|
return (await shares.authenticateEmail({ email: origin.relayUser.email }, ctx)) ?? undefined;
|
|
const cookie = cookieValue(request, cookieName(origin.port));
|
|
return cookie === undefined ? undefined : ((await shares.authenticate({ cookie }, ctx)) ?? undefined);
|
|
},
|
|
});
|
|
self.contribute(SessionAccess, {
|
|
// A guest reaches the sessions shared with it while its principal (revision included) is current.
|
|
guest: async (principal, session, ctx) => {
|
|
if (!isGuest(principal) || !Object.hasOwn(principal.sessions, session)) return false;
|
|
const current = await shares.grant({ id: principal.id }, ctx);
|
|
return current !== null && current.revision === principal.revision;
|
|
},
|
|
});
|
|
});
|
|
});
|