diff --git a/apps/pwa/README.md b/apps/pwa/README.md index dad83ba9..78e68e0c 100644 --- a/apps/pwa/README.md +++ b/apps/pwa/README.md @@ -63,6 +63,36 @@ export MOSHCODE_API=https://app.moshcode.sh export MOSHCODE_API_KEY=mck_... # sent as Authorization: Bearer ``` +## Organizations, teams, and shared sessions + +Open **Teams** in the app to create an organization, then a team. Add people by +the email on their existing moshcode account. Membership defaults to `read`; +`writer` can send terminal input, `admin` can manage membership within its +organization or team, and `owner` can also change ownership. At least one owner +must remain. Organization admins and owners inherit access to their teams; +other organization members only access teams they have joined. + +A session stays private until its owner opens **Share this session with a team** +on the session page. Teammates then see it in **Sessions** and on the team page. +They watch the same output and writers use the same command queue as the owner. +Only the owner's CLI key can publish output, claim commands, or end the session. +Removing access closes the shared stream on its next event or heartbeat, and +queued input from a removed or downgraded writer is cancelled before claim. + +The same operations accept CLI API keys as Bearer tokens: + +| Endpoint | Action | +| --- | --- | +| `GET/POST /api/organizations` | List or create your organizations | +| `GET /api/organizations/:id` | Organization and accessible teams | +| `POST /api/organizations/:id/teams` | Create a team (`name`) | +| `GET /api/teams/:id` | Team members and shared sessions | +| `POST /api/teams/:id/members` | Add an existing account (`email`, optional `role`) | +| `POST /api/teams/:id/members/:userId` | Change `role`, or set `remove: true` | +| `POST /api/organizations/:id/members/:userId` | Change organization role or remove a member | +| `GET /api/sessions` | Your sessions and sessions shared with your teams | +| `POST /api/sessions/:id/teams` | Share with `teamId`, or set `remove: true` to stop | + ## Status / TODO Scaffold is functional end-to-end (register → API key → `ask()` ingest → approve diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index fba3037e..5e9728e1 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -106,6 +106,7 @@ export function appBar(user, balance, csrf = "") {
The pit ${user ? `${balance.toLocaleString()} cr + Teams Settings
` : `Sign in`} diff --git a/apps/pwa/src/lib/organizations.mjs b/apps/pwa/src/lib/organizations.mjs new file mode 100644 index 00000000..e4e9bcaf --- /dev/null +++ b/apps/pwa/src/lib/organizations.mjs @@ -0,0 +1,166 @@ +import { db, get, all } from "../db.mjs"; +import { id } from "./crypto.mjs"; + +export const ROLES = ["read", "writer", "admin", "owner"]; +export const permission = (role) => ROLES.indexOf(role) + 1; +export const roleFor = (level) => ROLES[Number(level) - 1] || "read"; + +export class MembershipError extends Error { + constructor(message, status = 400) { super(message); this.status = status; } +} +const fail = (message, status) => { throw new MembershipError(message, status); }; +const nameOf = (value) => { + const name = String(value || "").trim(); + if (!name || name.length > 100) fail("Use a name between 1 and 100 characters."); + return name; +}; +const roleOf = (value) => { + const role = value == null || value === "" ? "read" : value; + if (!ROLES.includes(role)) fail("Choose read, writer, admin, or owner."); + return role; +}; +const first = async (tx, sql, args) => (await tx.execute({ sql, args })).rows[0] || null; +let writes = Promise.resolve(); +function transaction(work) { + // libSQL's local client opens another connection for each transaction. + // Queue local membership writes rather than racing BEGIN IMMEDIATE on it; + // the database transaction still protects against other app processes. + const operation = writes.then(async () => { + await db.execute("PRAGMA foreign_keys = ON"); + const tx = await db.transaction("write"); + try { const result = await work(tx); await tx.commit(); return result; } + catch (error) { await tx.rollback(); throw error; } + finally { tx.close(); } + }); + writes = operation.catch(() => {}); + return operation; +} +async function orgManager(tx, organizationId, actorId) { + const member = await first(tx, "SELECT role FROM organization_members WHERE organization_id=? AND user_id=?", [organizationId, actorId]); + if (!member || permission(member.role) < 3) fail("Organization admin access is required.", 403); + return member.role; +} +async function teamManager(tx, teamId, actorId) { + const member = await first(tx, "SELECT permission FROM team_access WHERE team_id=? AND user_id=?", [teamId, actorId]); + if (!member || Number(member.permission) < 3) fail("Team admin access is required.", 403); + return roleFor(member.permission); +} +function mayChange(actorRole, previousRole, nextRole) { + if (actorRole !== "owner" && (previousRole === "owner" || nextRole === "owner")) { + fail("Only an owner can change owner membership.", 403); + } +} + +export async function createOrganization(actorId, name) { + const org = { id: id(), name: nameOf(name), created_at: Date.now() }; + return transaction(async (tx) => { + await tx.execute({ sql: "INSERT INTO organizations (id,name,created_at) VALUES (?,?,?)", args: [org.id, org.name, org.created_at] }); + await tx.execute({ sql: "INSERT INTO organization_members (organization_id,user_id,role,created_at) VALUES (?,?,'owner',?)", args: [org.id, actorId, org.created_at] }); + return org; + }); +} +export const organizationsFor = (userId) => all(`SELECT o.*, m.role FROM organizations o + JOIN organization_members m ON m.organization_id=o.id WHERE m.user_id=? ORDER BY o.name`, [userId]); +export const teamsFor = (userId) => all(`SELECT t.*, o.name AS organization_name, a.permission + FROM teams t JOIN organizations o ON o.id=t.organization_id JOIN team_access a ON a.team_id=t.id + WHERE a.user_id=? ORDER BY o.name,t.name`, [userId]); + +export async function organizationFor(organizationId, userId) { + const org = await get(`SELECT o.*,m.role FROM organizations o JOIN organization_members m ON m.organization_id=o.id + WHERE o.id=? AND m.user_id=?`, [organizationId, userId]); + if (!org) fail("No such organization.", 404); + const teams = (await teamsFor(userId)).filter((t) => t.organization_id === organizationId); + const members = permission(org.role) >= 3 ? await all(`SELECT u.id,u.email,u.display_name,m.role FROM organization_members m + JOIN users u ON u.id=m.user_id WHERE m.organization_id=? ORDER BY u.email`, [organizationId]) : []; + return { ...org, teams, members }; +} +export async function teamFor(teamId, userId) { + const team = (await teamsFor(userId)).find((t) => t.id === teamId); + if (!team) fail("No such team.", 404); + const members = await all(`SELECT u.id,u.email,u.display_name,m.role FROM team_members m + JOIN users u ON u.id=m.user_id WHERE m.team_id=? ORDER BY u.email`, [teamId]); + return { ...team, role: roleFor(team.permission), members }; +} +export async function createTeam(actorId, organizationId, name) { + const team = { id: id(), organization_id: organizationId, name: nameOf(name), created_at: Date.now() }; + return transaction(async (tx) => { + await orgManager(tx, organizationId, actorId); + if (await first(tx, "SELECT id FROM teams WHERE organization_id=? AND name=?", [organizationId, team.name])) fail("That team already exists.", 409); + await tx.execute({ sql: "INSERT INTO teams (id,organization_id,name,created_at) VALUES (?,?,?,?)", args: [team.id, organizationId, team.name, team.created_at] }); + await tx.execute({ sql: "INSERT INTO team_members (team_id,organization_id,user_id,role,created_at) VALUES (?,?,?,'owner',?)", args: [team.id, organizationId, actorId, team.created_at] }); + return team; + }); +} + +// Membership uses an existing account. It never creates credentials for someone +// else or grants access to a later registration just because an email matches. +export async function setTeamMember(actorId, teamId, { email, userId, role, remove = false }) { + const nextRole = remove ? null : roleOf(role); + return transaction(async (tx) => { + const actorRole = await teamManager(tx, teamId, actorId); + const team = await first(tx, "SELECT * FROM teams WHERE id=?", [teamId]); + const user = userId + ? await first(tx, "SELECT id,email,display_name FROM users WHERE id=?", [userId]) + : await first(tx, "SELECT id,email,display_name FROM users WHERE lower(email)=?", [String(email || "").trim().toLowerCase()]); + if (!user) fail("That person needs to create a moshcode account first.", 404); + const previous = await first(tx, "SELECT role FROM team_members WHERE team_id=? AND user_id=?", [teamId, user.id]); + mayChange(actorRole, previous?.role, nextRole); + if (previous?.role === "owner" && nextRole !== "owner") { + const count = await first(tx, "SELECT COUNT(*) AS n FROM team_members WHERE team_id=? AND role='owner'", [teamId]); + if (Number(count.n) <= 1) fail("Keep at least one team owner.", 409); + } + if (remove) { + await tx.execute({ sql: "DELETE FROM team_members WHERE team_id=? AND user_id=?", args: [teamId, user.id] }); + } else { + await tx.execute({ sql: "INSERT OR IGNORE INTO organization_members (organization_id,user_id,role,created_at) VALUES (?,?,'read',?)", args: [team.organization_id, user.id, Date.now()] }); + await tx.execute({ sql: `INSERT INTO team_members (team_id,organization_id,user_id,role,created_at) VALUES (?,?,?,?,?) + ON CONFLICT(team_id,user_id) DO UPDATE SET role=excluded.role`, args: [teamId, team.organization_id, user.id, nextRole, Date.now()] }); + } + return { ...user, role: nextRole }; + }); +} +export async function setOrganizationMember(actorId, organizationId, { userId, role, remove = false }) { + const nextRole = remove ? null : roleOf(role); + return transaction(async (tx) => { + const actorRole = await orgManager(tx, organizationId, actorId); + const previous = await first(tx, "SELECT role FROM organization_members WHERE organization_id=? AND user_id=?", [organizationId, userId]); + if (!previous) fail("No such organization member.", 404); + mayChange(actorRole, previous.role, nextRole); + if (previous.role === "owner" && nextRole !== "owner") { + const count = await first(tx, "SELECT COUNT(*) AS n FROM organization_members WHERE organization_id=? AND role='owner'", [organizationId]); + if (Number(count.n) <= 1) fail("Keep at least one organization owner.", 409); + } + if (remove) { + const last = await first(tx, `SELECT t.team_id FROM team_members t WHERE t.organization_id=? AND t.user_id=? AND t.role='owner' + AND NOT EXISTS (SELECT 1 FROM team_members other WHERE other.team_id=t.team_id AND other.role='owner' AND other.user_id<>?)`, [organizationId, userId, userId]); + if (last) fail("Assign another owner to this member's teams first.", 409); + await tx.execute({ sql: "DELETE FROM organization_members WHERE organization_id=? AND user_id=?", args: [organizationId, userId] }); + } else { + await tx.execute({ sql: "UPDATE organization_members SET role=? WHERE organization_id=? AND user_id=?", args: [nextRole, organizationId, userId] }); + } + return { userId, role: nextRole }; + }); +} + +export const accessibleSession = (sessionId, userId) => get(`SELECT s.*, + CASE WHEN s.user_id=? THEN 4 ELSE a.permission END AS permission + FROM cli_sessions s LEFT JOIN shared_session_access a ON a.session_id=s.id AND a.user_id=? + WHERE s.id=? AND (s.user_id=? OR a.permission>=1)`, [userId, userId, sessionId, userId]); +export const sessionsFor = (userId) => all(`SELECT s.*, + CASE WHEN s.user_id=? THEN 4 ELSE a.permission END AS permission + FROM cli_sessions s LEFT JOIN shared_session_access a ON a.session_id=s.id AND a.user_id=? + WHERE s.user_id=? OR a.permission>=1 ORDER BY s.last_seen_at DESC LIMIT 50`, [userId, userId, userId]); +export const sharesFor = (sessionId) => all(`SELECT t.id,t.name,o.name AS organization_name FROM session_team_shares s + JOIN teams t ON t.id=s.team_id JOIN organizations o ON o.id=t.organization_id WHERE s.session_id=? ORDER BY o.name,t.name`, [sessionId]); +export async function shareSession(actorId, sessionId, teamId, remove = false) { + return transaction(async (tx) => { + if (!await first(tx, "SELECT id FROM cli_sessions WHERE id=? AND user_id=?", [sessionId, actorId])) fail("Only the session owner can share it.", 403); + // Removing a stale share must still work after the owner leaves its team. + if (remove) { + await tx.execute({ sql: "DELETE FROM session_team_shares WHERE session_id=? AND team_id=?", args: [sessionId, teamId] }); + return; + } + if (!await first(tx, "SELECT team_id FROM team_access WHERE team_id=? AND user_id=?", [teamId, actorId])) fail("Join the team before sharing a session with it.", 403); + await tx.execute({ sql: "INSERT OR IGNORE INTO session_team_shares (session_id,team_id,shared_by,created_at) VALUES (?,?,?,?)", args: [sessionId, teamId, actorId, Date.now()] }); + }); +} diff --git a/apps/pwa/src/migrations/023_organizations_teams.sql b/apps/pwa/src/migrations/023_organizations_teams.sql new file mode 100644 index 00000000..f8499f49 --- /dev/null +++ b/apps/pwa/src/migrations/023_organizations_teams.sql @@ -0,0 +1,66 @@ +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE organization_members ( + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'read' CHECK (role IN ('read','writer','admin','owner')), + created_at INTEGER NOT NULL, + PRIMARY KEY (organization_id, user_id) +); +CREATE INDEX idx_organization_members_user ON organization_members(user_id); + +CREATE TABLE teams ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (id, organization_id), + UNIQUE (organization_id, name) +); + +CREATE TABLE team_members ( + team_id TEXT NOT NULL, + organization_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'read' CHECK (role IN ('read','writer','admin','owner')), + created_at INTEGER NOT NULL, + PRIMARY KEY (team_id, user_id), + FOREIGN KEY (team_id, organization_id) REFERENCES teams(id, organization_id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, user_id) REFERENCES organization_members(organization_id, user_id) ON DELETE CASCADE +); +CREATE INDEX idx_team_members_user ON team_members(user_id); + +CREATE TABLE session_team_shares ( + session_id TEXT NOT NULL REFERENCES cli_sessions(id) ON DELETE CASCADE, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + shared_by TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (session_id, team_id) +); +CREATE INDEX idx_session_team_shares_team ON session_team_shares(team_id); + +-- Organization administrators manage every team. Other organization members +-- only enter teams they belong to; organization membership alone shares no terminal. +CREATE VIEW team_access AS + SELECT team_id, user_id, MAX(permission) AS permission FROM ( + SELECT team_id, user_id, + CASE role WHEN 'owner' THEN 4 WHEN 'admin' THEN 3 WHEN 'writer' THEN 2 ELSE 1 END AS permission + FROM team_members + UNION ALL + SELECT t.id, m.user_id, CASE m.role WHEN 'owner' THEN 4 ELSE 3 END + FROM teams t JOIN organization_members m ON m.organization_id=t.organization_id + WHERE m.role IN ('owner','admin') + ) GROUP BY team_id, user_id; + +CREATE VIEW shared_session_access AS + SELECT s.session_id, a.user_id, MAX(a.permission) AS permission + FROM session_team_shares s JOIN team_access a ON a.team_id=s.team_id + GROUP BY s.session_id, a.user_id; + +-- Kept on queued commands so revoking a member also stops their pending input. +-- No FK: deleting an actor must not turn their command into an owner command. +ALTER TABLE session_commands ADD COLUMN actor_user_id TEXT; diff --git a/apps/pwa/src/routes/organizations.mjs b/apps/pwa/src/routes/organizations.mjs new file mode 100644 index 00000000..902d8e0d --- /dev/null +++ b/apps/pwa/src/routes/organizations.mjs @@ -0,0 +1,100 @@ +import { Router } from "express"; +import { all } from "../db.mjs"; +import { bearer, userForApiKey } from "../lib/apikey.mjs"; +import { requireAuth, csrfInput } from "../lib/session.mjs"; +import { page, footer, appBar, esc } from "../lib/html.mjs"; +import { balance } from "../lib/credits.mjs"; +import { + ROLES, permission, roleFor, MembershipError, organizationsFor, organizationFor, + createOrganization, createTeam, teamFor, setTeamMember, setOrganizationMember, + shareSession, sessionsFor, +} from "../lib/organizations.mjs"; + +export const organizationsRouter = Router(); +const json = (req) => req.path.startsWith("/api/") || (req.get("accept") || "").includes("application/json"); +const wrap = (handler) => async (req, res, next) => { + try { await handler(req, res); } + catch (error) { + if (!(error instanceof MembershipError)) return next(error); + if (json(req)) return res.status(error.status).json({ error: error.message }); + res.status(error.status).type("html").send(page({ title: "moshcode ▸ teams", body: + `

Could not save

${esc(error.message)}

Back to organizations
${footer}` })); + } +}; +const apiAuth = async (req, res, next) => { + try { + req.user = await userForApiKey(bearer(req)); + if (!req.user) return res.status(401).json({ error: "invalid or missing API key" }); + next(); + } catch (error) { next(error); } +}; +for (const prefix of ["/api/organizations", "/api/teams"]) organizationsRouter.use(prefix, apiAuth); +const selectRole = (role = "read") => ``; +const rolesNote = `

Read: watch shared sessions. Writer: watch and send input. Admin: manage members and teams within their scope. Owner: manage ownership too.

`; +const card = (body) => `
${body}
`; +async function show(req, res, title, body) { + res.type("html").send(page({ title: `moshcode ▸ ${title}`, body: + `${appBar(req.user, await balance(req.user.id), req.csrfToken)}

${esc(title)}

${body}
${footer}` })); +} +const memberForms = (req, members, path, editable) => members.map((member) => `
+ ${esc(member.display_name || member.email || "Member")} ${esc(member.email || "")} · ${esc(member.role)} + ${editable ? `
+ ${csrfInput(req)}${selectRole(member.role)} +
` : ""}
`).join(""); + +organizationsRouter.get(["/organizations", "/api/organizations"], requireAuth, wrap(async (req, res) => { + const organizations = await organizationsFor(req.user.id); + if (json(req)) return res.json({ organizations }); + const items = organizations.map((org) => card(`${esc(org.name)} ${esc(org.role)}`)).join(""); + await show(req, res, "Organizations", `

Build teams and share a live moshcode session with the people you work with.

${items || card("You have not joined an organization yet.")} + ${card(`

Create an organization

${csrfInput(req)}
`)}`); +})); +organizationsRouter.post(["/organizations", "/api/organizations"], requireAuth, wrap(async (req, res) => { + const organization = await createOrganization(req.user.id, req.body?.name); + if (json(req)) return res.status(201).json({ organization }); + res.redirect(`/organizations/${organization.id}`); +})); +organizationsRouter.get(["/organizations/:id", "/api/organizations/:id"], requireAuth, wrap(async (req, res) => { + const org = await organizationFor(req.params.id, req.user.id); + if (json(req)) return res.json({ organization: org }); + const manager = permission(org.role) >= 3; + await show(req, res, org.name, `

← Organizations ${esc(org.role)}

+ ${rolesNote}

Teams

${org.teams.map((team) => card(`${esc(team.name)} ${roleFor(team.permission)}`)).join("") || card("No teams to show yet.")} + ${manager ? card(`

Create a team

${csrfInput(req)}
`) + + card(`

Organization members

Add people through a team below. Organization admins and owners can manage all teams.

${memberForms(req, org.members, `/organizations/${esc(org.id)}/members`, true)}`) : ""}`); +})); +organizationsRouter.post(["/organizations/:id/teams", "/api/organizations/:id/teams"], requireAuth, wrap(async (req, res) => { + const team = await createTeam(req.user.id, req.params.id, req.body?.name); + if (json(req)) return res.status(201).json({ team }); + res.redirect(`/teams/${team.id}`); +})); +organizationsRouter.post(["/organizations/:id/members/:userId", "/api/organizations/:id/members/:userId"], requireAuth, wrap(async (req, res) => { + const member = await setOrganizationMember(req.user.id, req.params.id, { userId: req.params.userId, role: req.body?.role, remove: req.body?.remove === "1" || req.body?.remove === true }); + if (json(req)) return res.json({ member }); + res.redirect(`/organizations/${req.params.id}`); +})); +organizationsRouter.get(["/teams/:id", "/api/teams/:id"], requireAuth, wrap(async (req, res) => { + const team = await teamFor(req.params.id, req.user.id); + const sessions = await all(`SELECT s.id,s.name,s.status FROM cli_sessions s JOIN session_team_shares sh ON sh.session_id=s.id WHERE sh.team_id=? ORDER BY s.last_seen_at DESC LIMIT 50`, [team.id]); + if (json(req)) return res.json({ team, sessions }); + const manager = permission(team.role) >= 3; + await show(req, res, team.name, `

${esc(team.organization_name)} → ${esc(team.name)} ${esc(team.role)}

+ ${rolesNote}${card(`

Shared sessions

The session owner shares a terminal from its session page. Everyone joins that same live terminal.

${sessions.map((s) => `

${esc(s.name)} → ${esc(s.status)}

`).join("") || `

No sessions shared yet. Open your sessions →

`}`)} + ${card(`

Team members

${memberForms(req, team.members, `/teams/${esc(team.id)}/members`, manager)}`)} + ${manager ? card(`

Add a member

Use the email on their moshcode account. Permissions default to read.

${csrfInput(req)}
`) : ""}`); +})); +organizationsRouter.post(["/teams/:id/members", "/api/teams/:id/members", "/teams/:id/members/:userId", "/api/teams/:id/members/:userId"], requireAuth, wrap(async (req, res) => { + const member = await setTeamMember(req.user.id, req.params.id, { email: req.body?.email, userId: req.params.userId, role: req.body?.role, remove: req.body?.remove === "1" || req.body?.remove === true }); + if (json(req)) return res.json({ member }); + res.redirect(`/teams/${req.params.id}`); +})); + +organizationsRouter.get("/api/sessions", apiAuth, wrap(async (req, res) => res.json({ sessions: await sessionsFor(req.user.id) }))); +organizationsRouter.post("/api/sessions/:id/teams", apiAuth, wrap(async (req, res) => { + await shareSession(req.user.id, req.params.id, req.body?.teamId, req.body?.remove === true); + res.json({ ok: true }); +})); +organizationsRouter.post("/sessions/:id/teams", requireAuth, wrap(async (req, res) => { + await shareSession(req.user.id, req.params.id, req.body?.teamId, req.body?.remove === "1"); + res.redirect(`/sessions/${req.params.id}`); +})); diff --git a/apps/pwa/src/routes/sessions.mjs b/apps/pwa/src/routes/sessions.mjs index 1270d280..ad9992d1 100644 --- a/apps/pwa/src/routes/sessions.mjs +++ b/apps/pwa/src/routes/sessions.mjs @@ -18,6 +18,7 @@ import { balance } from "../lib/credits.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { BASE_KEY_NAMES, EXTENDED_KEYS_FEATURE, KEY_NAMES as ALL_KEY_NAMES } from "../lib/session-keys.mjs"; +import { accessibleSession, sessionsFor, sharesFor, teamsFor, roleFor } from "../lib/organizations.mjs"; export const sessionsRouter = Router(); @@ -250,6 +251,10 @@ sessionsRouter.get("/api/sessions/:id/commands", cliAuth, async (req, res) => { await run(`UPDATE cli_sessions SET last_seen_at = ? WHERE id = ?`, [Date.now(), session.id]); const claim = async () => { + await run(`UPDATE session_commands SET status='cancelled' WHERE session_id=? AND status='queued' AND NOT ( + actor_user_id IS NULL OR actor_user_id=(SELECT user_id FROM cli_sessions WHERE id=session_commands.session_id) + OR EXISTS (SELECT 1 FROM shared_session_access a WHERE a.session_id=session_commands.session_id + AND a.user_id=session_commands.actor_user_id AND a.permission>=2))`, [session.id]); await run(`UPDATE session_commands SET status='cancelled' WHERE session_id=? AND status='queued' AND ( (mcp_share_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM mcp_shares sh WHERE sh.id=session_commands.mcp_share_id @@ -266,6 +271,10 @@ sessionsRouter.get("/api/sessions/:id/commands", cliAuth, async (req, res) => { // The UPDATE is the lock — only the poll that flips 'queued' runs it. const claimed = await run( `UPDATE session_commands SET status='claimed', claimed_at=? WHERE id=? AND status='queued' + AND (actor_user_id IS NULL + OR actor_user_id=(SELECT user_id FROM cli_sessions WHERE id=session_commands.session_id) + OR EXISTS (SELECT 1 FROM shared_session_access a WHERE a.session_id=session_commands.session_id + AND a.user_id=session_commands.actor_user_id AND a.permission>=2)) AND (mcp_share_id IS NULL OR EXISTS (SELECT 1 FROM mcp_shares sh WHERE sh.id=session_commands.mcp_share_id AND sh.session_id=session_commands.session_id AND sh.user_id=? AND sh.status='active' AND sh.expires_at>?)) @@ -321,10 +330,7 @@ sessionsRouter.post("/api/sessions/:id/commands/:cid", cliAuth, async (req, res) // ---- human side (cookie session) ---- sessionsRouter.get("/sessions", requireAuth, async (req, res) => { - const rows = await all( - `SELECT * FROM cli_sessions WHERE user_id = ? ORDER BY last_seen_at DESC LIMIT 50`, - [req.user.id] - ); + const rows = await sessionsFor(req.user.id); const items = rows.length ? rows.map((s) => { const live = isLive(s); return ` @@ -332,6 +338,7 @@ sessionsRouter.get("/sessions", requireAuth, async (req, res) => {
${esc(s.name)} + ${s.user_id === req.user.id ? "yours" : `shared · ${roleFor(s.permission)}`} ${esc(s.version ? "v" + s.version : "")}
${live ? (s.engine ? `▸ ${esc(s.engine)}` : "idle") : "offline"} · ${ago(s.last_seen_at)}${dim(s.cols) && dim(s.rows) ? ` · ${dim(s.cols)}×${dim(s.rows)}` : ""}${s.cwd ? ` · ${esc(s.cwd)}` : ""}
@@ -347,21 +354,33 @@ sessionsRouter.get("/sessions", requireAuth, async (req, res) => { body: `${appBar(req.user, await balance(req.user.id), req.csrfToken)}

Sessions

-

Live mirrors of your running mosh instances.

+

Your running mosh instances and sessions shared with your teams. Manage teams →

${items}
${footer}`, })); }); sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { - const s = await ownedSession(req.params.id, req.user.id); + const s = await accessibleSession(req.params.id, req.user.id); if (!s) return res.status(404).type("html").send(page({ body: `

No such session

` })); const live = isLive(s); + const canWrite = Number(s.permission) >= 2; + const writable = live && canWrite; + const owner = s.user_id === req.user.id; + const shares = owner ? await sharesFor(s.id) : []; + const teams = owner ? await teamsFor(req.user.id) : []; + const sharing = owner ? `
Share this session with a team
+

Read members watch. Writers, admins, and owners can send input to this same terminal. Only teams you choose get access.

+ ${shares.map((team) => `
${csrfInput(req)} + ${esc(team.organization_name)} → ${esc(team.name)}
`).join("")} + ${teams.length ? `
${csrfInput(req)}
` : `Create or join a team →`} +
` : ""; const geo = dim(s.cols) && dim(s.rows) ? `${dim(s.cols)}×${dim(s.rows)}` : ""; const keys = supportsKeys(s); - const padOn = live && keys; + const padOn = writable && keys; const extendedOn = padOn && supportsExtendedKeys(s); - const padNote = !live + const padNote = !canWrite ? "Read-only · watch and copy output" + : !live ? "offline" : extendedOn ? "Ctrl / Shift apply to the next key · choose a letter or tap a key" @@ -387,6 +406,8 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { ${esc(s.version ? "v" + s.version : "")}${s.cwd ? " · " + esc(s.cwd) : ""} ← all sessions
+

${owner ? "Your session" : `Shared session · ${roleFor(s.permission)}`}${canWrite ? " · Work together in this terminal." : " · You can watch; a team admin can grant writer access."}

+ ${sharing}
@@ -406,7 +427,7 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { ${extraKey("shift+enter", "⇧ ⏎", "Shift+Enter")} ${extraKey("ctrl+c", "Ctrl+C", "Ctrl+C — interrupt")} - +
@@ -419,19 +440,20 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => {
${csrfInput(req)} - + - - + +
${esc(geo)}

- Type anywhere on the terminal to reach the prompt. Commands run in the live mosh prompt. + ${canWrite ? `Type anywhere on the terminal to reach the prompt. Commands run in the live mosh prompt. Keyboard controls act on the remote terminal. Copy uses selected text; Paste inserts into - the command box so you can review it before pressing run. Click ❯ to open the multiline editor. + the command box so you can review it before pressing run. Click ❯ to open the multiline editor.` + : `You are watching the live terminal. Select output and use Copy to keep it. Ask a team admin for writer access when you want to participate.`}

@@ -443,7 +465,7 @@ sessionsRouter.get("/sessions/:id", requireAuth, async (req, res) => { }); sessionsRouter.get("/sessions/:id/stream", requireAuth, async (req, res) => { - const s = await ownedSession(req.params.id, req.user.id); + const s = await accessibleSession(req.params.id, req.user.id); if (!s) return res.status(404).end(); res.writeHead(200, { @@ -483,7 +505,28 @@ sessionsRouter.get("/sessions/:id/stream", requireAuth, async (req, res) => { [s.id, since] ); - const live = sseWatcher(res); + const wire = sseWatcher(res); + let pending = Promise.resolve(); + let currentPermission = Number(s.permission); + // Recheck before every delivery, including scrollback. Serialize deliveries + // so slow permission checks cannot reorder terminal output. + const live = s.user_id === req.user.id ? wire : { send(event) { + pending = pending.then(async () => { + if (res.destroyed || res.writableEnded) return; + const access = await accessibleSession(s.id, req.user.id); + if (!access) { + wire.send({ type: "access-revoked" }); + res.end(); + return; + } + if (Number(access.permission) !== currentPermission) { + currentPermission = Number(access.permission); + wire.send({ type: "access-changed" }); + } + if (event.type === "ping") res.write(": ping\n\n"); + else wire.send(event); + }).catch(() => res.end()); + } }; let last = since; for (const row of back) { last = Number(row.seq); @@ -503,12 +546,15 @@ sessionsRouter.get("/sessions/:id/stream", requireAuth, async (req, res) => { } // Proxies drop an idle stream; a comment every 25s is cheaper than a reconnect. - ping = setInterval(() => { try { res.write(": ping\n\n"); } catch { /* gone */ } }, 25000); + ping = setInterval(() => { + if (s.user_id !== req.user.id) live.send({ type: "ping" }); + else try { res.write(": ping\n\n"); } catch { /* gone */ } + }, 25000); }); // Queue one key. Always answers JSON: keys come from the pad, which is script, // never from a plain form post the way a typed line can be. -async function queueKey(res, s, name) { +async function queueKey(res, s, name, actor) { if (!KEY_NAMES.has(name)) return res.status(400).json({ error: "unknown key" }); if (!isLive(s)) return res.status(409).json({ error: "session offline" }); if (!supportsKeys(s)) return res.status(409).json({ error: "this mosh is too old for keys — update it" }); @@ -517,20 +563,21 @@ async function queueKey(res, s, name) { } const cid = id(); const body = keyCommand(name); - await run(`INSERT INTO session_commands (id,session_id,body,status,created_at) VALUES (?,?,?,'queued',?)`, - [cid, s.id, body, Date.now()]); + await run(`INSERT INTO session_commands (id,session_id,body,status,created_at,actor_user_id) VALUES (?,?,?,'queued',?,?)`, + [cid, s.id, body, Date.now(), actor.id]); // `key` rides the event so the page can report "▸ ↑" instead of the sentinel. - publish(s.id, { type: "queued", id: cid, body, key: name }); + publish(s.id, { type: "queued", id: cid, body, key: name, actor: actor.display_name || actor.email || "Teammate" }); wake(s.id); return res.json({ ok: true, id: cid, key: name }); } sessionsRouter.post("/sessions/:id/commands", requireAuth, async (req, res) => { - const s = await ownedSession(req.params.id, req.user.id); + const s = await accessibleSession(req.params.id, req.user.id); if (!s) return res.status(404).json({ error: "no such session" }); + if (Number(s.permission) < 2) return res.status(403).json({ error: "This session is read-only. Writer access is required to send input." }); // A key is one keypress rather than text, so it takes its own path: the // splitting below is for lines, and a key has no line to split. - if (req.body?.key) return queueKey(res, s, String(req.body.key).toLowerCase()); + if (req.body?.key) return queueKey(res, s, String(req.body.key).toLowerCase(), req.user); // A pasted block is queued a line at a time. The CLI hands exactly one line // to the prompt per turn — readline resolves on the first line it sees and // would swallow the rest — so splitting here is what makes paste work, and it @@ -556,10 +603,10 @@ sessionsRouter.post("/sessions/:id/commands", requireAuth, async (req, res) => { const queued = []; for (const [i, body] of lines.entries()) { const cid = id(); - await run(`INSERT INTO session_commands (id,session_id,body,status,created_at) VALUES (?,?,?,'queued',?)`, - [cid, s.id, body, at + i]); + await run(`INSERT INTO session_commands (id,session_id,body,status,created_at,actor_user_id) VALUES (?,?,?,'queued',?,?)`, + [cid, s.id, body, at + i, req.user.id]); queued.push({ id: cid, body }); - publish(s.id, { type: "queued", id: cid, body }); + publish(s.id, { type: "queued", id: cid, body, actor: req.user.display_name || req.user.email || "Teammate" }); } wake(s.id); // release the CLI's long-poll immediately return wantsJson(req) @@ -876,7 +923,9 @@ function mirror(opts) { // Queued commands are reported beside the terminal, never written into // it: the pit echoes the command itself when it runs, and injecting our // own text would shift whatever the CLI is redrawing out of place. - else if (d.type === "queued") { flash(d.key ? "▸ " + (GLYPH[d.key] || d.key) : "▸ queued: " + d.body); } + else if (d.type === "queued") { flash((d.actor ? d.actor + ": " : "") + (d.key ? "▸ " + (GLYPH[d.key] || d.key) : "▸ queued: " + d.body)); } + else if (d.type === "access-revoked") { offline(); es.close(); flash("Team access was removed."); } + else if (d.type === "access-changed") { window.location.reload(); } else if (d.type === "command-done") { flash(""); } else if (d.type === "end" || d.type === "offline") { offline(); } }; diff --git a/apps/pwa/src/server.mjs b/apps/pwa/src/server.mjs index c25c8896..f928b8a4 100644 --- a/apps/pwa/src/server.mjs +++ b/apps/pwa/src/server.mjs @@ -12,6 +12,7 @@ import { approvalsRouter } from "./routes/approvals.mjs"; import { creditsRouter } from "./routes/credits.mjs"; import { cliRouter } from "./routes/cli.mjs"; import { sessionsRouter } from "./routes/sessions.mjs"; +import { organizationsRouter } from "./routes/organizations.mjs"; import { mcpRouter } from "./routes/mcp.mjs"; import { mcpOAuthBrowserRouter, mcpOAuthMachineRouter } from "./routes/mcp-oauth.mjs"; import { pagesRouter } from "./routes/pages.mjs"; @@ -81,6 +82,7 @@ app.use(approvalsRouter); app.use(creditsRouter); app.use(cliRouter); // /cli/authorize, /cli/token, /api/me app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions +app.use(organizationsRouter); // organizations, teams, membership, and session sharing app.use(mcpOAuthBrowserRouter); // /oauth/authorize — logged-in consent + CSRF app.use(pagesRouter); // /app, /settings app.use(settingsSyncRouter); // /api/settings (+ /settings/sync) — the pit's /save and /load diff --git a/apps/pwa/test/organizations.test.mjs b/apps/pwa/test/organizations.test.mjs new file mode 100644 index 00000000..5288bb10 --- /dev/null +++ b/apps/pwa/test/organizations.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +let deps; +try { deps = { express: require("express"), cookieParser: require("cookie-parser") }; } catch { /* optional PWA dependencies */ } +const options = { skip: !deps && "apps/pwa deps not installed" }; +const dir = mkdtempSync(path.join(tmpdir(), "moshcode-organizations-")); +process.env.DATABASE_URL = `file:${path.join(dir, "test.db")}`; +process.env.SESSION_SECRET = "organization-tests-only"; +process.env.SESSION_POLL_MS = "30"; +let state; +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const sql = await import("../src/db.mjs"); + const org = await import("../src/lib/organizations.mjs"); + const { createApiKey } = await import("../src/lib/apikey.mjs"); + const { sessionMiddleware, csrfGuard } = await import("../src/lib/session.mjs"); + const { organizationsRouter } = await import("../src/routes/organizations.mjs"); + const { sessionsRouter } = await import("../src/routes/sessions.mjs"); + const app = deps.express(); + app.use(deps.express.json(), deps.express.urlencoded({ extended: false }), deps.cookieParser(), sessionMiddleware, csrfGuard, sessionsRouter, organizationsRouter); + app.use((err, _req, res, _next) => { console.error(err); res.status(500).json({ error: err.message }); }); + const server = await new Promise((resolve) => { const s = app.listen(0, "127.0.0.1", () => resolve(s)); }); + const base = `http://127.0.0.1:${server.address().port}`; + const tokens = {}; + for (const user of ["owner", "reader", "writer", "admin", "outsider", "second"]) { + await sql.run("INSERT INTO users (id,email,display_name,created_at) VALUES (?,?,?,?)", [user, `${user}@example.com`, user, Date.now()]); + await sql.run("INSERT INTO sessions (token,user_id,created_at,expires_at) VALUES (?,?,?,?)", [`cookie-${user}`, user, Date.now(), Date.now() + 600000]); + tokens[user] = (await createApiKey(user, "test")).plaintext; + } + const api = (user, pathname, body) => fetch(base + pathname, { method: body === undefined ? "GET" : "POST", + headers: { authorization: `Bearer ${tokens[user]}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); + const browser = (user, pathname, body, extra = {}) => fetch(base + pathname, { method: body === undefined ? "GET" : "POST", redirect: "manual", + headers: { cookie: `mc_sess=cookie-${user}; mc_csrf=csrf-${user}`, "content-type": "application/json", accept: "application/json" }, + body: body === undefined ? undefined : JSON.stringify({ ...body, _csrf: `csrf-${user}` }), ...extra }); + return { ...sql, org, base, api, browser, server }; +} +const app = () => state ||= boot(); +test.after(async () => { + if (state) { const { server, db } = await state; server.closeAllConnections(); await new Promise((done) => server.close(done)); db.close(); } + rmSync(dir, { recursive: true, force: true }); +}); +let sequence = 0; +async function teamFixture() { + const a = await app(); + const organization = await a.org.createOrganization("owner", `Organization ${++sequence}`); + const team = await a.org.createTeam("owner", organization.id, "Contractors"); + return { ...a, organization, team }; +} + +test("organizations and teams belong to their creator, with read access by default", options, async () => { + const { org, organization, team, api, browser } = await teamFixture(); + assert.equal((await org.organizationFor(organization.id, "owner")).role, "owner"); + assert.equal((await org.teamFor(team.id, "owner")).role, "owner"); + await org.setTeamMember("owner", team.id, { email: " READER@EXAMPLE.COM " }); + assert.equal((await org.teamFor(team.id, "reader")).role, "read"); + assert.equal((await org.organizationFor(organization.id, "reader")).role, "read"); + assert.equal((await api("outsider", `/api/teams/${team.id}`)).status, 404); + assert.equal((await api("outsider", `/api/organizations/${organization.id}`)).status, 404); + assert.equal((await api("reader", `/api/organizations/${organization.id}/teams`, { name: "Intrusion" })).status, 403); + assert.equal((await api("reader", `/api/teams/${team.id}/members`, { email: "outsider@example.com", role: "admin" })).status, 403); + assert.equal((await api("owner", `/api/teams/${team.id}/members`, { email: "reader@example.com", role: "root" })).status, 400); + const noCsrf = await browser("owner", `/teams/${team.id}/members`, {}, { body: JSON.stringify({ email: "reader@example.com" }) }); + assert.equal(noCsrf.status, 403); + const cookieApi = await browser("owner", "/api/organizations", { name: "No bearer" }); + assert.equal(cookieApi.status, 401, "API cannot authenticate with a CSRF-exempt cookie"); +}); + +test("team admins cannot grant ownership or promote their organization role", options, async () => { + const { org, organization, team } = await teamFixture(); + await org.setTeamMember("owner", team.id, { email: "admin@example.com", role: "admin" }); + await org.setTeamMember("admin", team.id, { email: "writer@example.com", role: "writer" }); + await assert.rejects(() => org.setTeamMember("admin", team.id, { email: "reader@example.com", role: "owner" }), /Only an owner/); + await assert.rejects(() => org.setTeamMember("admin", team.id, { userId: "owner", role: "read" }), /Only an owner/); + await assert.rejects(() => org.setOrganizationMember("admin", organization.id, { userId: "admin", role: "owner" }), /Organization admin/); + await assert.rejects(() => org.setTeamMember("owner", team.id, { userId: "owner", remove: true }), /at least one team owner/); + await assert.rejects(() => org.setOrganizationMember("owner", organization.id, { userId: "owner", role: "admin" }), /at least one organization owner/); + await org.setTeamMember("owner", team.id, { email: "second@example.com", role: "owner" }); + const raced = await Promise.allSettled([ + org.setTeamMember("owner", team.id, { userId: "owner", remove: true }), + org.setTeamMember("second", team.id, { userId: "second", remove: true }), + ]); + assert.equal(raced.filter((r) => r.status === "fulfilled").length, 1, "simultaneous removals preserve an owner"); +}); + +test("organization admin access is inherited but ordinary membership shares no other teams", options, async () => { + const { org, organization, team } = await teamFixture(); + await org.setTeamMember("owner", team.id, { email: "admin@example.com", role: "read" }); + await org.setTeamMember("owner", team.id, { email: "reader@example.com", role: "read" }); + const privateTeam = await org.createTeam("owner", organization.id, "Private"); + await assert.rejects(() => org.teamFor(privateTeam.id, "reader"), /No such team/); + await org.setOrganizationMember("owner", organization.id, { userId: "admin", role: "admin" }); + assert.equal((await org.teamFor(privateTeam.id, "admin")).role, "admin"); + await org.setOrganizationMember("owner", organization.id, { userId: "admin", role: "read" }); + await assert.rejects(() => org.teamFor(privateTeam.id, "admin"), /No such team/); + await org.setOrganizationMember("owner", organization.id, { userId: "reader", remove: true }); + await assert.rejects(() => org.teamFor(team.id, "reader"), /No such team/); +}); + +test("readers watch the same terminal, writers send input, and machine endpoints stay owner-only", options, async () => { + const { org, team, api, browser, get } = await teamFixture(); + for (const role of ["read", "writer"]) await org.setTeamMember("owner", team.id, { email: `${role === "read" ? "reader" : "writer"}@example.com`, role }); + const registered = await (await api("owner", "/api/sessions", { name: "Shared terminal", features: ["keys", "signals"] })).json(); + const sid = registered.id; + assert.equal((await browser("reader", `/sessions/${sid}`)).status, 404, "private until explicitly shared"); + await org.shareSession("owner", sid, team.id); + const read = await browser("reader", `/sessions/${sid}`); + assert.equal(read.status, 200); + const html = await read.text(); + assert.match(html, /Shared session · read/); + assert.match(html, /]*disabled/); + const writeHtml = await (await browser("writer", `/sessions/${sid}`)).text(); + assert.doesNotMatch(writeHtml, /]*disabled/); + assert.equal((await browser("reader", `/sessions/${sid}/commands`, { body: "do work" })).status, 403); + assert.equal((await browser("reader", `/sessions/${sid}/commands`, { key: "enter" })).status, 403); + assert.equal((await api("writer", `/api/sessions/${sid}/output`, { chunk: "forged" })).status, 404); + assert.equal((await api("writer", `/api/sessions/${sid}/commands`)).status, 404); + assert.equal((await api("writer", `/api/sessions/${sid}/end`, {})).status, 404); + assert.equal((await api("writer", `/api/sessions/${sid}/teams`, { teamId: team.id, remove: true })).status, 403); + const sent = await (await browser("writer", `/sessions/${sid}/commands`, { body: "hello from teammate" })).json(); + assert.equal((await get("SELECT actor_user_id FROM session_commands WHERE id=?", [sent.id])).actor_user_id, "writer"); + const claimed = await (await api("owner", `/api/sessions/${sid}/commands`)).json(); + assert.deepEqual(claimed.commands, [{ id: sent.id, body: "hello from teammate" }]); + assert.deepEqual((await (await api("owner", `/api/sessions/${sid}/commands`)).json()).commands, []); + const list = await (await api("reader", "/api/sessions")).json(); + assert.ok(list.sessions.some((s) => s.id === sid)); +}); + +test("revoking or downgrading a writer cancels queued input before the CLI can claim it", options, async () => { + const { org, team, api, browser, get } = await teamFixture(); + const { id: sid } = await (await api("owner", "/api/sessions", { name: "Revocation", features: ["keys"] })).json(); + await org.shareSession("owner", sid, team.id); + for (const change of ["downgrade", "remove", "unshare"]) { + await org.setTeamMember("owner", team.id, { email: "writer@example.com", role: "writer" }); + const sent = await (await browser("writer", `/sessions/${sid}/commands`, { key: "enter" })).json(); + if (change === "unshare") await org.shareSession("owner", sid, team.id, true); + else await org.setTeamMember("owner", team.id, { userId: "writer", role: "read", remove: change === "remove" }); + assert.deepEqual((await (await api("owner", `/api/sessions/${sid}/commands`)).json()).commands, []); + assert.equal((await get("SELECT status FROM session_commands WHERE id=?", [sent.id])).status, "cancelled"); + } +}); + +test("an open stream stops disclosing output as soon as team access is removed", options, async () => { + const { org, team, api, browser } = await teamFixture(); + await org.setTeamMember("owner", team.id, { email: "reader@example.com" }); + const { id: sid } = await (await api("owner", "/api/sessions", { name: "Revoked view" })).json(); + await org.shareSession("owner", sid, team.id); + await api("owner", `/api/sessions/${sid}/output`, { chunk: "visible before removal" }); + const stop = new AbortController(); + const response = await browser("reader", `/sessions/${sid}/stream`, undefined, { signal: stop.signal }); + const reader = response.body.getReader(); + const decode = new TextDecoder(); + let text = ""; + try { + while (!text.includes("visible before removal")) text += decode.decode((await reader.read()).value); + await org.setTeamMember("owner", team.id, { userId: "reader", remove: true }); + await api("owner", `/api/sessions/${sid}/output`, { chunk: "private after removal" }); + while (true) { const chunk = await reader.read(); if (chunk.done) break; text += decode.decode(chunk.value); } + assert.match(text, /access-revoked/); + assert.doesNotMatch(text, /private after removal/); + assert.equal((await browser("reader", `/sessions/${sid}/stream`)).status, 404); + } finally { stop.abort(); } +});