diff --git a/apps/e2e/src/tests/demo-role.test.ts b/apps/e2e/src/tests/demo-role.test.ts
index e7cf1ff92..871fbe345 100644
--- a/apps/e2e/src/tests/demo-role.test.ts
+++ b/apps/e2e/src/tests/demo-role.test.ts
@@ -132,7 +132,7 @@ describe.skipIf(!hasSqlite3())('a published demo', () => {
];
for (const r of refused) {
expect(r.status, JSON.stringify(r.json)).toBe(403);
- expect(r.json.error).toMatch(/read-only demo/);
+ expect(r.json.error).toMatch(/read-only/);
}
// A write SQL statement is refused by the permissions the Demo role lacks.
const conn = (await api('GET', '/connections', undefined, demoCookie)).json.connections.find(
diff --git a/apps/web/src/frontend/app/shell/DemoBanner.tsx b/apps/web/src/frontend/app/shell/DemoBanner.tsx
index e0a892c8f..c58d72750 100644
--- a/apps/web/src/frontend/app/shell/DemoBanner.tsx
+++ b/apps/web/src/frontend/app/shell/DemoBanner.tsx
@@ -8,12 +8,12 @@
*/
import React from 'react';
import { Eye } from 'lucide-react';
-import { isDemoRole } from '@foxschema/shared';
import { useAuthStore } from '@/platform/authStore';
export const DemoBanner: React.FC = () => {
const demo = useAuthStore((s) => s.demo);
- const role = useAuthStore((s) => s.user?.role);
+ /** Read-only: the account cannot save (the Demo role, or any role without app.write). */
+ const readOnly = useAuthStore((s) => s.user != null && !s.can('app.write'));
if (!demo?.on) return null;
return (
{
Demo
- {isDemoRole(role)
+ {readOnly
? 'Explore the sample databases: browse, compare, and run queries. Nothing here can be saved or changed.'
: 'Demo mode is on: visitors see this banner, and the sign-in page shows the demo login.'}
diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx
index 6cc55d790..4ab4f8625 100644
--- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx
+++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx
@@ -1,5 +1,4 @@
import React, { lazy, useEffect, useRef, useState } from 'react';
-import { isDemoRole } from '@foxschema/shared';
import { createPortal } from 'react-dom';
import { LogOut, Palette, ArrowUpCircle, Globe, Shield, MonitorX, MessageSquarePlus, UserPlus } from 'lucide-react';
import { apiSignOutOthers } from '@/shared/api/authApi';
@@ -23,6 +22,8 @@ export function ProfileMenu(): React.ReactElement | null {
const { user, logout, launch } = useAuthStore();
const setActiveView = useUiStore((s) => s.setActiveView);
const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles'));
+ /** A shared login (the Demo role) does not run its own account; the server refuses it too. */
+ const managesOwnAccount = useAuthStore((s) => s.can('account.self'));
const setRegisterDialogOpen = useAuthStore((s) => s.setRegisterDialogOpen);
const [open, setOpen] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
@@ -159,8 +160,8 @@ export function ProfileMenu(): React.ReactElement | null {
foxschema.com
- {/* A demo login is every visitor at once; the server refuses this for it. */}
- {!isDemoRole(user?.role) && (
+ {/* A shared login is every visitor at once; the server refuses this for it. */}
+ {managesOwnAccount && (
| null = null;
+let routeTable: Array<{ method: string; path: string; access: string; reads: boolean }> | null = null;
function mountedRoutes() {
if (!routeTable) {
const ctx = createFeatureContext();
routeTable = FEATURES.flatMap((f) => f.mounts).flatMap((m) =>
- m.routes(ctx).flatten(m.prefix).map((r) => ({ method: r.method, path: r.path, access: m.access }))
+ m.routes(ctx).flatten(m.prefix).map((r) => ({ method: r.method, path: r.path, access: m.access, reads: r.reads === true }))
);
}
return routeTable;
}
+/**
+ * The writes declared to only read (`reads` beside their handler). A read-only
+ * account (the Demo role) may call exactly these: adding one here is a review
+ * of whether it really changes nothing a visitor would share.
+ */
+const REVIEWED_READS = [
+ 'POST /api/compare',
+ 'POST /api/schema/db-access',
+ 'POST /api/schema/list',
+ 'POST /api/schema/load',
+ 'POST /api/schema/table-insight',
+ 'POST /api/sql/code-cell/transpile',
+ 'POST /api/sql/execute',
+ 'POST /api/workspaces/:id/select',
+ 'PUT /api/user/preferences',
+];
+
const DEMO_EMAIL = 'demo-visitor@example.com';
const DEMO_PASSWORD = 'shared-demo-login-9';
@@ -126,7 +143,7 @@ describe('a demo account', () => {
it('is refused every write behind sign-in except the few that read — before the route runs', async () => {
const guarded = mountedRoutes().filter((r) => (r.access === 'user' || r.access === 'registered') && r.method !== 'GET');
expect(guarded.length).toBeGreaterThan(50);
- const allowed = new Set(DEMO_ALLOWED_WRITES.map((r) => `${r.method} ${r.path}`));
+ const allowed = new Set(guarded.filter((r) => r.reads).map((r) => `${r.method} ${r.path}`));
const wrong: string[] = [];
for (const route of guarded) {
@@ -139,9 +156,12 @@ describe('a demo account', () => {
expect(wrong, 'allow-listed routes must pass the guard; every other write must be refused').toEqual([]);
}, 120_000);
- it('lists only routes that exist — a renamed route fails here instead of closing quietly', () => {
- const served = new Set(mountedRoutes().map((r) => `${r.method} ${r.path}`));
- expect(DEMO_ALLOWED_WRITES.map((r) => `${r.method} ${r.path}`).filter((k) => !served.has(k))).toEqual([]);
+ it('declares reads on exactly the reviewed writes — a new one is a deliberate change here', () => {
+ const declared = mountedRoutes()
+ .filter((r) => r.reads && r.method !== 'GET')
+ .map((r) => `${r.method} ${r.path}`)
+ .sort();
+ expect(declared).toEqual(REVIEWED_READS);
});
it('runs the reads it is allowed: the guard lets them through to their own checks', async () => {
diff --git a/packages/server/src/features/access/access.routes.ts b/packages/server/src/features/access/access.routes.ts
index 7080fb1a8..7228ebd0f 100644
--- a/packages/server/src/features/access/access.routes.ts
+++ b/packages/server/src/features/access/access.routes.ts
@@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { WorkspaceScope } from '../../platform/http/scope';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import type { ConnectionModule } from '@foxschema/db';
import { requireAnyPermission, requirePermissions } from '../../platform/authorization/rbac.guard';
import { rateLimit } from '../../platform/guards/rate-limit';
@@ -195,6 +195,7 @@ export function createAccessRoutes(deps: AccessRouteDeps): Router {
router.post(
'/schema/db-access',
+ reads,
dbAccessLimiter,
// Access workspace tabs use access.*; Utilities → Database Access uses
// utility.access. Either family may load the catalog.
diff --git a/packages/server/src/features/auth/auth.routes.test.ts b/packages/server/src/features/auth/auth.routes.test.ts
index f5caf166b..b59c6225e 100644
--- a/packages/server/src/features/auth/auth.routes.test.ts
+++ b/packages/server/src/features/auth/auth.routes.test.ts
@@ -17,7 +17,7 @@ import type { AuthMailer } from '../../platform/identity/auth-mail';
import type { SignupModule } from '../users';
import { createAuthRoutes } from './auth.routes';
-const USER = { id: 'u-1', email: 'u-1@example.test', role: 'editor' };
+const USER = { id: 'u-1', email: 'u-1@example.test', role: 'editor', permissions: ['app.write', 'account.self'] };
const WORKSPACE = { id: 'personal-u-1', name: 'Personal' };
function routes() {
diff --git a/packages/server/src/features/auth/auth.routes.ts b/packages/server/src/features/auth/auth.routes.ts
index 0d5f19114..6c77968fd 100644
--- a/packages/server/src/features/auth/auth.routes.ts
+++ b/packages/server/src/features/auth/auth.routes.ts
@@ -13,8 +13,7 @@ import { DemoMode } from '../../platform/identity/demo-mode.service';
import { SignupModule } from '../users';
import { AppSettingsStore } from '../../platform/settings/app-settings.service';
import { sendError } from '../../platform/http/respond';
-import { DEMO_READ_ONLY } from '../../platform/authorization/demo-access';
-import { isDemoRole } from '@foxschema/shared';
+import { READ_ONLY_ACCOUNT } from '../../platform/authorization/read-only';
import { ServiceError } from '../../platform/contracts/actor';
import { rateLimit } from '../../platform/guards/rate-limit';
import { getLogger } from '../../platform/logger/logger';
@@ -146,9 +145,10 @@ export function createAuthRoutes(
sendError(res, 'unauthenticated', 'Sign in to verify your email.');
return;
}
- // Any visitor could mail the demo address over and over: its profile is locked.
- if (isDemoRole(session.user.role)) {
- sendError(res, 'forbidden', DEMO_READ_ONLY);
+ // A shared login (the Demo role) does not manage its own account: any
+ // visitor could mail its address over and over.
+ if (!session.user.permissions.includes('account.self')) {
+ sendError(res, 'forbidden', READ_ONLY_ACCOUNT);
return;
}
if ((await auth.emailVerification(session.user.id)).verified) {
@@ -253,9 +253,9 @@ export function createAuthRoutes(
sendError(res, 'unauthenticated', 'Authentication required');
return;
}
- // A demo login is every visitor at once: this would sign all the others out.
- if (isDemoRole(user.role)) {
- sendError(res, 'forbidden', DEMO_READ_ONLY);
+ // A shared login is every visitor at once: this would sign all the others out.
+ if (!user.permissions.includes('account.self')) {
+ sendError(res, 'forbidden', READ_ONLY_ACCOUNT);
return;
}
res.send({ signedOut: await auth.signOutOtherSessions(user.id, token) });
diff --git a/packages/server/src/features/compare/compare.routes.ts b/packages/server/src/features/compare/compare.routes.ts
index 0c890a240..c57375d94 100644
--- a/packages/server/src/features/compare/compare.routes.ts
+++ b/packages/server/src/features/compare/compare.routes.ts
@@ -5,7 +5,7 @@
*
* Declaration only: path, method, guards. No logic.
*/
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import { requirePermissions } from '../../platform/authorization/rbac.guard';
import { makeCompareController } from './compare.controller';
import { makeCompareHandlers } from './compare.handler';
@@ -15,7 +15,7 @@ export function createCompareRoutes(deps: { compareService: CompareService }): R
const router = Router();
const handlers = makeCompareHandlers(makeCompareController(deps));
- router.post('/compare', requirePermissions('schema.compare'), handlers.compare);
+ router.post('/compare', reads, requirePermissions('schema.compare'), handlers.compare);
return router;
}
diff --git a/packages/server/src/features/schema/schema.routes.ts b/packages/server/src/features/schema/schema.routes.ts
index 236b849c5..11f58b5ea 100644
--- a/packages/server/src/features/schema/schema.routes.ts
+++ b/packages/server/src/features/schema/schema.routes.ts
@@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { ConnectionResolver } from '../../platform/connections/resolve';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import { requirePermissions } from '../../platform/authorization/rbac.guard';
import type { AuthedRequest } from '../../platform/http/types';
import type { ConnectionRef } from '../../platform/connections/resolve';
@@ -26,7 +26,7 @@ export interface SchemaRouteDeps {
export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
const router = Router();
- router.post('/schema/list', requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
+ router.post('/schema/list', reads, requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
try {
const { dialect, option } = await deps.resolveRef(scopeOf(req as AuthedRequest), req.body as ConnectionRef);
const provider = deps.connectionModule.getProvider(dialect);
@@ -40,7 +40,7 @@ export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
}
});
- router.post('/schema/load', requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
+ router.post('/schema/load', reads, requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] };
try {
const { dialect, option, schema } = await deps.resolveRef(scopeOf(req as AuthedRequest), ref);
@@ -59,6 +59,7 @@ export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
const tableInsightLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 });
router.post(
'/schema/table-insight',
+ reads,
tableInsightLimiter,
// This powers Data Peek's Insight tab in the SQL Editor. Requiring
// utility.access locked the whole tab out for the built-in viewer role,
diff --git a/packages/server/src/features/sql-editor/editor.routes.ts b/packages/server/src/features/sql-editor/editor.routes.ts
index 4624e6a5d..41eb8a26f 100644
--- a/packages/server/src/features/sql-editor/editor.routes.ts
+++ b/packages/server/src/features/sql-editor/editor.routes.ts
@@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { ConnectionResolver } from '../../platform/connections/resolve';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import { denyUnless } from '../../platform/authorization/rbac.guard';
import { rateLimit } from '../../platform/guards/rate-limit';
import { idempotency } from '../../platform/guards/idempotency';
@@ -80,7 +80,7 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router {
// made Express call it with (req, res, next); it returned a handler and never
// called next(), so the request hung with no error and no response.
const writeIdempotency = idempotency();
- router.post('/sql/execute', sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => {
+ router.post('/sql/execute', reads, sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => {
const { statements, maxRows, offset, params, datagridAction, seek, orderBy, stopOnError, transaction, ...ref } = req.body as ConnectionRef & {
statements?: unknown;
maxRows?: unknown;
@@ -229,7 +229,7 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router {
// The server already has it for Node cells, so the browser sends the source
// here. It is compiled, never run: the same permission as opening the editor.
const transpileLimiter = rateLimit({ windowMs: 60 * 1000, max: 120 });
- router.post('/sql/code-cell/transpile', transpileLimiter, async (req: AppRequest, res: FastifyReply) => {
+ router.post('/sql/code-cell/transpile', reads, transpileLimiter, async (req: AppRequest, res: FastifyReply) => {
if (denyUnless(req as AuthedRequest, res, 'editor.access')) return;
const source = (req.body as { source?: unknown } | undefined)?.source;
if (typeof source !== 'string' || !source.trim()) {
diff --git a/packages/server/src/features/users/user.routes.ts b/packages/server/src/features/users/user.routes.ts
index f8f7786cc..f73900609 100644
--- a/packages/server/src/features/users/user.routes.ts
+++ b/packages/server/src/features/users/user.routes.ts
@@ -1,6 +1,6 @@
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import { UserModule, UserPreferences } from './user.service';
import type { AuthedRequest } from '../../platform/http/types';
@@ -12,7 +12,7 @@ export function createUserRoutes(user: UserModule): Router {
res.send({ preferences: await user.getPreferences(req.userId!) });
});
- router.put('/preferences', async (req: AuthedRequest, res: FastifyReply) => {
+ router.put('/preferences', reads, async (req: AuthedRequest, res: FastifyReply) => {
const { role, primaryDatabase, primaryGoal, theme, onboardingCompleted } = req.body as Partial;
res.send({
preferences: await user.updatePreferences(req.userId!, {
diff --git a/packages/server/src/features/workspaces/workspaces.routes.ts b/packages/server/src/features/workspaces/workspaces.routes.ts
index 82b58d0a1..7c60119af 100644
--- a/packages/server/src/features/workspaces/workspaces.routes.ts
+++ b/packages/server/src/features/workspaces/workspaces.routes.ts
@@ -7,7 +7,7 @@
* (name, archive, members). Mounted behind `authGuard`.
*/
import type { FastifyReply } from 'fastify';
-import { Router } from '../../platform/http/router';
+import { Router, reads } from '../../platform/http/router';
import type { AuthedRequest } from '../../platform/http/types';
import { sendError } from '../../platform/http/respond';
import { ServiceError } from '../../platform/contracts/actor';
@@ -132,7 +132,7 @@ export function createWorkspaceRoutes(
});
/** Act in this workspace from now on (every tab and the CLI's next run follow). */
- router.post('/:id/select', async (req: AuthedRequest, res: FastifyReply) => {
+ router.post('/:id/select', reads, async (req: AuthedRequest, res: FastifyReply) => {
try {
await directory.select(req.userId!, id(req));
res.send({ ok: true });
diff --git a/packages/server/src/platform/authorization/demo-access.ts b/packages/server/src/platform/authorization/demo-access.ts
deleted file mode 100644
index 57da3dd7d..000000000
--- a/packages/server/src/platform/authorization/demo-access.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Fox Schema (foxschema)
- * Copyright 2024-2026 Huy Phan
- * SPDX-License-Identifier: Apache-2.0
- *
- * What a demo account may call.
- *
- * A published demo has one account whose login anyone can read, so its
- * permissions are not the whole story: a write any permission allowed would be
- * a write every visitor shares. So a demo account may read whatever its role
- * allows, and may write only through the routes below. Each of them reads,
- * whatever its verb: a SELECT goes over POST because it carries a body, not
- * because it changes anything.
- *
- * This is an allow-list on purpose. A route added later is closed to the demo
- * until someone decides it reads, rather than open until someone notices it
- * writes. `api/demo-account.test.ts` checks every entry against the real route
- * table, so a renamed route fails the build instead of quietly closing.
- */
-import { isDemoRole } from '@foxschema/shared';
-
-export interface DemoRoute {
- method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
- /** The route as registered, with `:params`. */
- path: string;
- /** Why it only reads, for whoever reviews the list next. */
- why: string;
-}
-
-export const DEMO_ALLOWED_WRITES: readonly DemoRoute[] = [
- {
- method: 'POST',
- path: '/api/sql/execute',
- why: 'Runs SQL, and the demo role has no editor.dml, editor.ddl or editor.grant: only reads pass the gate.',
- },
- { method: 'POST', path: '/api/sql/code-cell/transpile', why: 'Compiles a TypeScript cell for the browser; never runs it.' },
- { method: 'POST', path: '/api/schema/list', why: 'Lists schemas.' },
- { method: 'POST', path: '/api/schema/load', why: 'Reads a schema.' },
- { method: 'POST', path: '/api/schema/table-insight', why: 'Reads catalog statistics for one table.' },
- { method: 'POST', path: '/api/schema/db-access', why: 'Reads database users and grants.' },
- { method: 'POST', path: '/api/compare', why: 'Compares two schemas; deploying the result is schema.migrate.' },
- {
- method: 'PUT',
- path: '/api/user/preferences',
- why: 'Theme and onboarding state. Shared by every visitor, and harmless to share.',
- },
- {
- method: 'POST',
- path: '/api/workspaces/:id/select',
- why: 'Switches the workspace the account acts in (one of its own): a preference, like the theme.',
- },
-];
-
-/** The answer a demo account gets for anything else. */
-export const DEMO_READ_ONLY = 'This is a read-only demo: browsing, comparing and running queries work; saving and changing do not.';
-
-const READS = new Set(['GET', 'HEAD', 'OPTIONS']);
-
-/**
- * Whether `role` may call `method path`. Only a demo account is limited;
- * everyone else answers to their permissions alone.
- */
-export function demoMayCall(role: unknown, method: string, path: string): boolean {
- if (!isDemoRole(role)) return true;
- const verb = method.toUpperCase();
- if (READS.has(verb)) return true;
- return DEMO_ALLOWED_WRITES.some((r) => r.method === verb && r.path === path);
-}
diff --git a/packages/server/src/platform/authorization/rbac.service.test.ts b/packages/server/src/platform/authorization/rbac.service.test.ts
index b487f82dd..1b3c96778 100644
--- a/packages/server/src/platform/authorization/rbac.service.test.ts
+++ b/packages/server/src/platform/authorization/rbac.service.test.ts
@@ -277,6 +277,30 @@ describe('RbacModule', () => {
}
});
+ it('gives a role saved before read-only existed app.write and account.self, once, and never the Demo role', async () => {
+ const store = await getStore();
+ try {
+ await rbac.setRolePermissions(
+ 'editor',
+ DEFAULT_ROLE_PERMISSIONS.editor.filter((p) => p !== 'app.write' && p !== 'account.self')
+ );
+ await rbac.setRolePermissions('demo', DEFAULT_ROLE_PERMISSIONS.demo);
+ await store.run('DELETE FROM app_settings WHERE "key" = ?', ['rbac.grant.app.write']);
+ await backfillDatagridRolePermissions(store);
+ const editor = new Set(await rbac.permissionsForRole('editor'));
+ expect(editor.has('app.write')).toBe(true);
+ expect(editor.has('account.self')).toBe(true);
+ expect(await rbac.permissionsForRole('demo')).not.toContain('app.write');
+ // An admin who then makes editors read-only keeps them that way.
+ await rbac.setRolePermissions('editor', [...editor].filter((p) => p !== 'app.write') as never);
+ await backfillDatagridRolePermissions(store);
+ expect(await rbac.permissionsForRole('editor')).not.toContain('app.write');
+ } finally {
+ await rbac.setRolePermissions('editor', DEFAULT_ROLE_PERMISSIONS.editor);
+ await rbac.setRolePermissions('demo', DEFAULT_ROLE_PERMISSIONS.demo);
+ }
+ });
+
it('does not backfill Grant privileges onto editor', async () => {
const store = await getStore();
try {
diff --git a/packages/server/src/platform/authorization/rbac.service.ts b/packages/server/src/platform/authorization/rbac.service.ts
index 296660c50..080f2f2a9 100644
--- a/packages/server/src/platform/authorization/rbac.service.ts
+++ b/packages/server/src/platform/authorization/rbac.service.ts
@@ -275,6 +275,13 @@ const ONE_TIME_GRANTS: Array<{
key: 'rbac.grant.workspace.manage',
perms: ['workspace.members', 'workspace.settings'],
when: (p) => p.has('schema.migrate'),
+ }, {
+ // Read-only as a permission: every role that could save and change things
+ // before keeps doing so. The Demo role's defaults lack both, so it is
+ // skipped and stays read-only; a role emptied on purpose stays empty.
+ key: 'rbac.grant.app.write',
+ perms: ['app.write', 'account.self'],
+ when: () => true,
},
];
diff --git a/packages/server/src/platform/authorization/read-only.test.ts b/packages/server/src/platform/authorization/read-only.test.ts
new file mode 100644
index 000000000..902400672
--- /dev/null
+++ b/packages/server/src/platform/authorization/read-only.test.ts
@@ -0,0 +1,30 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Read-only is a permission, not a role name: any account without app.write
+ * reads, whatever role it has.
+ */
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_ROLE_PERMISSIONS } from '@foxschema/shared';
+import { requestAllowed } from './read-only';
+
+describe('requestAllowed', () => {
+ it('lets an account without app.write read, and write only where the route declares it reads', () => {
+ const readOnly = DEFAULT_ROLE_PERMISSIONS.demo;
+ expect(readOnly).not.toContain('app.write');
+ expect(requestAllowed(readOnly, 'GET', false)).toBe(true);
+ expect(requestAllowed(readOnly, 'POST', true)).toBe(true);
+ expect(requestAllowed(readOnly, 'POST', false)).toBe(false);
+ expect(requestAllowed(readOnly, 'delete', false)).toBe(false);
+ });
+
+ it('lets every other built-in role write, and any role an admin strips of app.write read only', () => {
+ for (const role of ['viewer', 'editor', 'owner', 'admin'] as const) {
+ expect(requestAllowed(DEFAULT_ROLE_PERMISSIONS[role], 'PUT', false), role).toBe(true);
+ }
+ const stripped = new Set(DEFAULT_ROLE_PERMISSIONS.editor.filter((p) => p !== 'app.write'));
+ expect(requestAllowed(stripped, 'POST', false)).toBe(false);
+ });
+});
diff --git a/packages/server/src/platform/authorization/read-only.ts b/packages/server/src/platform/authorization/read-only.ts
new file mode 100644
index 000000000..552303479
--- /dev/null
+++ b/packages/server/src/platform/authorization/read-only.ts
@@ -0,0 +1,33 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Which requests an account without `app.write` may make: every GET, and the
+ * writes whose route declares that it only reads (`reads` in router.ts, beside
+ * the handler: a SELECT goes over POST because it carries a body, not because
+ * it changes anything). Everything else refuses it before the handler runs.
+ *
+ * The Demo role is the role without `app.write`: a published demo's shared
+ * login can browse, compare and query, and save nothing every visitor would
+ * share. A new route is closed to such an account until it is declared to read,
+ * rather than open until someone notices it writes; `api/demo-account.test.ts`
+ * calls every guarded write as one and keeps the list of `reads` routes reviewed.
+ */
+import type { Permission } from '@foxschema/shared';
+
+/** The answer a read-only account gets for anything it may not do. */
+export const READ_ONLY_ACCOUNT =
+ 'This account is read-only: browsing, comparing and running queries work; saving and changing do not.';
+
+const READS = new Set(['GET', 'HEAD', 'OPTIONS']);
+
+/** Whether an account with `permissions` may call a `method` route that does (or does not) declare `reads`. */
+export function requestAllowed(
+ permissions: readonly Permission[] | ReadonlySet,
+ method: string,
+ routeReads: boolean
+): boolean {
+ if (READS.has(method.toUpperCase()) || routeReads) return true;
+ return 'has' in permissions ? permissions.has('app.write') : permissions.includes('app.write');
+}
diff --git a/packages/server/src/platform/http/fastify-bind.ts b/packages/server/src/platform/http/fastify-bind.ts
index c28552671..81d4e9ec4 100644
--- a/packages/server/src/platform/http/fastify-bind.ts
+++ b/packages/server/src/platform/http/fastify-bind.ts
@@ -47,6 +47,8 @@ export function bindRoutes(app: FastifyInstance, routes: RouteDefinition[]): voi
app.route({
method: route.method,
url: route.path,
+ // Read by the session guard (`req.routeOptions.config.reads`).
+ config: { reads: route.reads === true },
handler: async (request, reply) => {
const proceed = await runChain(route.middlewares, request as AppRequest, reply);
if (!proceed) return reply;
diff --git a/packages/server/src/platform/http/router.ts b/packages/server/src/platform/http/router.ts
index ce91fe6ec..2694ab7e9 100644
--- a/packages/server/src/platform/http/router.ts
+++ b/packages/server/src/platform/http/router.ts
@@ -15,6 +15,19 @@
*/
import type { HttpMethod, Middleware, RouteHandler } from './types';
+/**
+ * Declares that a route only reads, whatever its verb: `router.post('/sql/execute',
+ * reads, handler)`. A POST that carries a SELECT in its body still reads. An
+ * account without `app.write` (the Demo role) may call a route only when it is
+ * a GET or declared this way; every other write refuses it before the handler.
+ *
+ * A route says so beside its own handler, so a new route is closed to a
+ * read-only account until someone decides it reads.
+ */
+export const reads = Object.freeze({ reads: true as const });
+type ReadsMarker = typeof reads;
+type ChainItem = Middleware | RouteHandler | ReadsMarker;
+
export interface RouteDefinition {
method: HttpMethod;
/** As declared, relative to wherever this router is mounted. */
@@ -22,6 +35,8 @@ export interface RouteDefinition {
/** Guards for this route only, in declaration order. */
middlewares: Middleware[];
handler: RouteHandler;
+ /** Declared with `reads`: only reads, whatever its verb. */
+ reads?: true;
}
interface MountedRouter {
@@ -34,25 +49,27 @@ export class RouteCollector {
private readonly routes: RouteDefinition[] = [];
private readonly mounted: MountedRouter[] = [];
- private add(method: HttpMethod, path: string, chain: (Middleware | RouteHandler)[]): void {
- const handler = chain[chain.length - 1] as RouteHandler;
- const middlewares = chain.slice(0, -1) as Middleware[];
- this.routes.push({ method, path, middlewares, handler });
+ private add(method: HttpMethod, path: string, chain: ChainItem[]): void {
+ const isRead = chain.includes(reads);
+ const rest = chain.filter((c) => c !== reads) as (Middleware | RouteHandler)[];
+ const handler = rest[rest.length - 1] as RouteHandler;
+ const middlewares = rest.slice(0, -1) as Middleware[];
+ this.routes.push({ method, path, middlewares, handler, ...(isRead ? { reads: true as const } : {}) });
}
- get(path: string, ...chain: (Middleware | RouteHandler)[]): void {
+ get(path: string, ...chain: ChainItem[]): void {
this.add('GET', path, chain);
}
- post(path: string, ...chain: (Middleware | RouteHandler)[]): void {
+ post(path: string, ...chain: ChainItem[]): void {
this.add('POST', path, chain);
}
- put(path: string, ...chain: (Middleware | RouteHandler)[]): void {
+ put(path: string, ...chain: ChainItem[]): void {
this.add('PUT', path, chain);
}
- delete(path: string, ...chain: (Middleware | RouteHandler)[]): void {
+ delete(path: string, ...chain: ChainItem[]): void {
this.add('DELETE', path, chain);
}
- patch(path: string, ...chain: (Middleware | RouteHandler)[]): void {
+ patch(path: string, ...chain: ChainItem[]): void {
this.add('PATCH', path, chain);
}
@@ -98,6 +115,7 @@ export class RouteCollector {
path: joinPath(prefix, r.path),
middlewares: [...base, ...r.middlewares],
handler: r.handler,
+ ...(r.reads ? { reads: r.reads } : {}),
});
}
for (const m of this.mounted) {
diff --git a/packages/server/src/platform/identity/auth.guard.ts b/packages/server/src/platform/identity/auth.guard.ts
index b0cf41045..56783004d 100644
--- a/packages/server/src/platform/identity/auth.guard.ts
+++ b/packages/server/src/platform/identity/auth.guard.ts
@@ -14,7 +14,7 @@ import type { Permission, WorkspaceRole } from '@foxschema/shared';
import { type AuthModule, SESSION_COOKIE, SESSION_MAX_AGE_MS, type AuthUser } from './auth.service';
import { sendError } from '../http/respond';
import { ServiceError } from '../contracts/actor';
-import { DEMO_READ_ONLY, demoMayCall } from '../authorization/demo-access';
+import { READ_ONLY_ACCOUNT, requestAllowed } from '../authorization/read-only';
/** Put the resolved session user's identity + grants on the request. */
export function attachAuthUser(
@@ -85,11 +85,12 @@ export function authGuard(auth: AuthModule) {
sendError(res, 'unauthenticated', 'Authentication required');
return;
}
- // A demo account reads; it writes only through routes that read (demo-access.ts).
- // Matched on the registered route, `:params` and all, never the raw URL.
- // Before the workspace reads below: a refused write needs none of them.
- if (!demoMayCall(session.user.role, req.method, req.routeOptions?.url ?? '')) {
- sendError(res, 'forbidden', DEMO_READ_ONLY);
+ // An account without app.write reads: a write it may make declares it on
+ // its own route (`reads`, read-only.ts). Before the workspace reads
+ // below: a refused write needs none of them.
+ const routeReads = (req.routeOptions?.config as { reads?: boolean } | undefined)?.reads === true;
+ if (!requestAllowed(session.user.permissions, req.method, routeReads)) {
+ sendError(res, 'forbidden', READ_ONLY_ACCOUNT);
return;
}
if (session.launch && (await auth.registrationState()).required) {
diff --git a/packages/server/src/platform/identity/auth.service.ts b/packages/server/src/platform/identity/auth.service.ts
index b27531f35..d8e445a3b 100644
--- a/packages/server/src/platform/identity/auth.service.ts
+++ b/packages/server/src/platform/identity/auth.service.ts
@@ -275,10 +275,11 @@ export class AuthModule {
[normalized]
);
if (!row || (row.active !== null && row.active !== undefined && Number(row.active) === 0)) return null;
- // A demo login is shared and its password is public: a visitor asking for
- // a reset would change it for everyone. Only an admin resets it. Answered
- // exactly like an unknown email, so the reply says nothing either way.
- if (isDemoRole(row.app_role)) return null;
+ // A shared login (no account.self, the Demo role) has a public password: a
+ // visitor asking for a reset would change it for everyone. Only an admin
+ // resets it. Answered exactly like an unknown email, so the reply says
+ // nothing either way.
+ if (!(await this.rbac.permissionsForRole(toAppRole(row.app_role))).includes('account.self')) return null;
return this.issueCode(row.id, 'reset');
}
diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts
index 915e11f32..2fe2196a8 100644
--- a/packages/shared/src/permissions.ts
+++ b/packages/shared/src/permissions.ts
@@ -93,6 +93,9 @@ export const PERMISSIONS = [
'admin.users',
'admin.roles',
'admin.drivers',
+ // Account
+ 'app.write',
+ 'account.self',
] as const;
export type Permission = (typeof PERMISSIONS)[number];
@@ -156,6 +159,8 @@ export const PERMISSION_META: PermissionMeta[] = [
{ id: 'workspace.create', group: 'Workspace', label: 'Create workspaces', description: 'Create workspaces besides your own. Install-wide: comes from the account role, not a workspace role.' },
{ id: 'admin.users', group: 'Admin', label: 'Manage users', description: 'List FoxSchema logins, assign app roles, and activate or deactivate accounts. Not the same as database users on a connected server.' },
{ id: 'admin.roles', group: 'Admin', label: 'Configure roles', description: 'Edit which FoxSchema permissions each app role receives, including Grant privileges for database GRANT/REVOKE.' },
+ { id: 'app.write', group: 'Account', label: 'Save and change', description: 'Save connections, secrets, scripts and settings, and run anything that writes. Without it an account only reads: it browses, compares and runs queries, and every other route refuses it. The Demo role is the role without it. Install-wide: comes from the account role, not a workspace role.' },
+ { id: 'account.self', group: 'Account', label: 'Manage own account', description: 'Change its own profile and password, ask for a reset or a verification email, and sign its other sessions out. A shared login (the Demo role) has it withheld, so no visitor changes the account for everyone. Install-wide: comes from the account role.' },
{ id: 'admin.drivers', group: 'Admin', label: 'Install drivers', description: 'Install a database driver package on the server (npm install, and a native build for Db2). It downloads and runs code on the machine Fox Schema runs on, for every workspace. Install-wide: comes from the account role, not a workspace role.' },
];
@@ -163,11 +168,11 @@ const ALL = [...PERMISSIONS];
/**
* A viewer's reading, without workflows or Git: browse and compare schemas,
- * read history, inspect access, run SELECTs. Every permission that writes is
- * absent. Whatever an admin adds here, the server lets a demo account reach
- * only the routes that read (`DEMO_ALLOWED_WRITES` on the server): saving a
- * connection, a secret, a workspace or a profile stays closed. What SQL it may
- * run follows these permissions, so adding `editor.dml` makes a sandbox.
+ * read history, inspect access, run SELECTs. It lacks `app.write`, so the
+ * server refuses every route that is not declared to read (saving a
+ * connection, a secret, a workspace), and `account.self`, so no visitor
+ * changes the shared login. What SQL it may run follows these permissions, so
+ * adding `editor.dml` makes a sandbox.
*/
const DEMO: Permission[] = [
'schema.browse',
@@ -185,8 +190,11 @@ const DEMO: Permission[] = [
'access.report',
];
-/** The demo's reading, plus workflows and Git to look at. Each role above builds on the one below. */
-const VIEWER: Permission[] = [...DEMO, 'workflow.access', 'git.view'];
+/**
+ * The demo's reading, plus workflows and Git to look at, and the right to save
+ * its own work and run its own account. Each role above builds on the one below.
+ */
+const VIEWER: Permission[] = [...DEMO, 'workflow.access', 'git.view', 'app.write', 'account.self'];
const EDITOR: Permission[] = [
...VIEWER,
@@ -334,6 +342,8 @@ export const INSTALL_PERMISSIONS: ReadonlySet = new Set(
'admin.roles',
'admin.drivers',
'workspace.create',
+ 'app.write',
+ 'account.self',
]);
/**