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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/readm3",
"version": "0.27.0",
"version": "0.27.1",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down
17 changes: 7 additions & 10 deletions server/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ export function operate(
),
};
case "organizations_list":
// Only the caller's own memberships, admins included: a site admin must not
// see (or share into) other people's workspaces.
return store.all(
user.admin
? "SELECT o.*,'owner' AS role FROM organizations o ORDER BY o.createdAt DESC"
: "SELECT o.*,m.role FROM organizations o JOIN members m ON m.orgId=o.id WHERE m.userId=? ORDER BY o.createdAt DESC",
...(user.admin ? [] : [user.id]),
"SELECT o.*,m.role FROM organizations o JOIN members m ON m.orgId=o.id WHERE m.userId=? ORDER BY o.createdAt DESC",
user.id,
);
case "organizations_create": {
const org = {
Expand Down Expand Up @@ -328,12 +328,9 @@ export function operate(
return { ok: true };
}
case "documents_list": {
const accessible = user.admin
? "1=1"
: `(d.ownerId=? OR EXISTS (SELECT 1 FROM permissions p WHERE p.documentId=d.id AND p.userId=?) OR (d.access!='private' AND EXISTS (SELECT 1 FROM members m WHERE m.orgId=d.orgId AND m.userId=?) AND (d.teamId IS NULL OR EXISTS (SELECT 1 FROM team_members tm WHERE tm.teamId=d.teamId AND tm.userId=?))))`;
const values: string[] = user.admin
? []
: [user.id, user.id, user.id, user.id];
// Same rule for everyone: owned, granted, or visible through membership.
const accessible = `(d.ownerId=? OR EXISTS (SELECT 1 FROM permissions p WHERE p.documentId=d.id AND p.userId=?) OR (d.access!='private' AND EXISTS (SELECT 1 FROM members m WHERE m.orgId=d.orgId AND m.userId=?) AND (d.teamId IS NULL OR EXISTS (SELECT 1 FROM team_members tm WHERE tm.teamId=d.teamId AND tm.userId=?))))`;
const values: string[] = [user.id, user.id, user.id, user.id];
if (args.orgId) values.push(orgId());
if (args.search) values.push(text(args.search, "Search"));
const docs = store.all<Doc>(
Expand Down
13 changes: 6 additions & 7 deletions server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,12 @@ export class Store {
);
return { id: sessionId, token, expiresAt, label };
}
/**
* The caller's role in an organization: membership only. Site admins get no
* standing in other people's workspaces; admin powers live on the admin_*
* operations, which check user.admin themselves.
*/
orgRole(user: User, orgId: string) {
if (
user.admin &&
this.get("SELECT id FROM organizations WHERE id=?", orgId)
)
return "owner";
return this.get<{ role: string }>(
"SELECT role FROM members WHERE orgId=? AND userId=?",
orgId,
Expand All @@ -252,7 +252,7 @@ export class Store {
doc: Doc,
shareToken?: string,
): "owner" | "edit" | "view" | null {
if (user && (user.admin || user.id === doc.ownerId)) return "owner";
if (user && user.id === doc.ownerId) return "owner";
let role: "edit" | "view" | null = null;
if (user) {
role =
Expand Down Expand Up @@ -332,7 +332,6 @@ export class Store {
if (!this.get("SELECT id FROM teams WHERE id=? AND orgId=?", teamId, orgId))
throw new HttpError(400, "Team must belong to this organization.");
if (
!user.admin &&
this.orgRole(user, orgId) !== "owner" &&
!this.get(
"SELECT userId FROM team_members WHERE teamId=? AND userId=?",
Expand Down
29 changes: 23 additions & 6 deletions server/test/sharing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ test("pinned versions stay immutable, restore adds history, and deletion invalid
}
});

test("the one-time super-admin claim is secret gated and grants access across all owners", async () => {
test("the one-time super-admin claim is secret gated and gives no access to other people's workspaces", async () => {
const before = process.env.READM3_ADMIN_BOOTSTRAP_SECRET;
process.env.READM3_ADMIN_BOOTSTRAP_SECRET = "test-admin-" + id();
const f = fixture();
Expand Down Expand Up @@ -470,10 +470,14 @@ test("the one-time super-admin claim is secret gated and grants access across al
).status,
).toBe(409);
expect((await admin.action("admin_users")).data).toHaveLength(2);
// Admin is a site role, not a key to everyone's workspace.
const orgs = (await admin.action("organizations_list")).data;
expect(orgs.map((o: { id: string }) => o.id)).not.toContain(first.org.id);
const docs = (await admin.action("documents_list", {})).data;
expect(docs.map((d: { id: string }) => d.id)).not.toContain(doc.id);
expect(
(await admin.action("documents_get", { documentId: doc.id })).data
.canManage,
).toBe(true);
(await admin.action("documents_get", { documentId: doc.id })).status,
).not.toBe(200);
expect(
(
await admin.action("documents_update", {
Expand All @@ -482,10 +486,23 @@ test("the one-time super-admin claim is secret gated and grants access across al
source: "# Moderated",
})
).status,
).toBe(200);
).not.toBe(200);
expect(
(await admin.action("documents_delete", { documentId: doc.id })).status,
).toBe(200);
).not.toBe(200);
expect(
(
await admin.action("documents_create", {
orgId: first.org.id,
title: "Pushed.md",
source: "# Into someone else's workspace",
})
).status,
).toBe(403);
expect(
(await first.action("documents_get", { documentId: doc.id })).data.version
.source,
).toBe("# Private");
} finally {
f.store.close();
if (before === undefined) delete process.env.READM3_ADMIN_BOOTSTRAP_SECRET;
Expand Down
17 changes: 7 additions & 10 deletions site/test/sharing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ test("revoked links stop working and cloud documents never enter the offline wor
}
});

test("super-admin setup grants the account administration of another user's private file", async ({
test("super-admin setup gives no access to another user's private file", async ({
page,
context,
browser,
Expand All @@ -244,18 +244,15 @@ test("super-admin setup grants the account administration of another user's priv
await register(context, "superadmin_" + suffix());
await page.goto("/admin#claim=browser-test-admin-secret");
await expect(page.locator("#account-label")).toContainText("Super admin");
// Admin is a site role, not a key to other people's workspaces.
await expect(
page.getByRole("link", { name: "Project plan.md" }).first(),
).toBeVisible();
page.getByRole("link", { name: "Project plan.md" }),
).toHaveCount(0);
await page.goto(`/viewer?doc=${doc.id}`);
await expect(page.locator("#edit-mode")).toBeEnabled();
await expect(page.locator("#share")).toBeVisible();
await page.locator("#edit-mode").click();
await editorInput(page).fill("# Super-admin correction");
await page.locator("#cloud-save").click();
await expect(page.locator("#save-status")).toHaveText(
"Version saved online",
await expect(page.locator("body")).toContainText(
"if this file is private",
);
await expect(page.locator("#cloud-save")).toBeHidden();
} finally {
await other.close();
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts";
import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts";
import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts";

export const VERSION = "0.27.0";
export const VERSION = "0.27.1";

/** The help text, exported so a test can hold it to the flags it documents. */
export const USAGE = `readm3 — a plain text editor for Markdown and code
Expand Down
Loading