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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions apps/pwa/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/pwa/src/lib/html.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function appBar(user, balance, csrf = "") {
<div class="bar-right">
<a class="btn" href="/pit">The pit</a>
${user ? `<span class="bal-chip">◆ <b>${balance.toLocaleString()}</b> cr</span>
<a class="btn" href="/organizations">Teams</a>
<a class="btn" href="/settings">Settings</a>
<form method="post" action="/auth/logout" style="margin:0"><input type="hidden" name="_csrf" value="${esc(csrf)}"><button class="btn">Sign out</button></form>`
: `<a class="btn acid" href="/">Sign in</a>`}
Expand Down
166 changes: 166 additions & 0 deletions apps/pwa/src/lib/organizations.mjs
Original file line number Diff line number Diff line change
@@ -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()] });
});
}
66 changes: 66 additions & 0 deletions apps/pwa/src/migrations/023_organizations_teams.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading