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
+ TeamsSettings
`
: `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:
+ `
` : "";
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."}
- 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, /