Skip to content

Commit 7dbf4b8

Browse files
adds send shape to workspace feature and lock workspace feature
1 parent 9767c0f commit 7dbf4b8

19 files changed

Lines changed: 1031 additions & 63 deletions

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ yarn-debug.log*
66
yarn-error.log*
77
lerna-debug.log*
88
PLAN.md
9-
9+
.DS_Store
1010
# Diagnostic reports (https://nodejs.org/api/report.html)
1111
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
1212

@@ -147,3 +147,6 @@ backend/data/*.db
147147

148148
# Backend build output
149149
backend/dist/
150+
151+
# Locked workspaces config (local only)
152+
locked-workspaces.json

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,26 @@ docker run -d --name dctap -p 3000:3000 -v dctap-data:/app/data dctap-dancer
192192

193193
The `-v dctap-data:/app/data` flag persists the SQLite databases between container restarts.
194194

195+
## Locked Workspaces
196+
197+
Workspaces can be marked as read-only ("locked") to prevent modifications. Locked workspaces can still be viewed and duplicated, but cannot be edited or deleted. This is useful for public deployments where certain workspaces should remain stable as reference templates.
198+
199+
Locked workspaces are configured via a local `locked-workspaces.json` file (not tracked in git). Use the CLI tool to manage them:
200+
201+
```bash
202+
# List all workspaces with lock status
203+
npm run lock-workspace --workspace=backend list
204+
205+
# Lock a workspace by name or ID
206+
npm run lock-workspace --workspace=backend lock "My Workspace"
207+
208+
# Unlock a workspace
209+
npm run lock-workspace --workspace=backend unlock "My Workspace"
210+
211+
# Show current locked-workspaces.json config
212+
npm run lock-workspace --workspace=backend show
213+
```
214+
195215
## Testing
196216

197217
```bash

backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"start": "node dist/index.js",
1111
"test": "vitest run",
1212
"test:watch": "vitest",
13-
"test:coverage": "vitest run --coverage"
13+
"test:coverage": "vitest run --coverage",
14+
"lock-workspace": "tsx src/cli/lock-workspace.ts"
1415
},
1516
"dependencies": {
1617
"cors": "^2.8.5",

backend/src/cli/lock-workspace.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
#!/usr/bin/env node
2+
/**
3+
* CLI tool to manage locked workspaces
4+
*
5+
* Usage:
6+
* npx ts-node src/cli/lock-workspace.ts list - List all workspaces and their lock status
7+
* npx ts-node src/cli/lock-workspace.ts lock <id|name> - Lock a workspace by ID or name
8+
* npx ts-node src/cli/lock-workspace.ts unlock <id|name> - Unlock a workspace by ID or name
9+
* npx ts-node src/cli/lock-workspace.ts show - Show current locked workspaces config
10+
*/
11+
12+
import { existsSync, readFileSync, writeFileSync } from 'fs';
13+
import { join } from 'path';
14+
import initSqlJs from 'sql.js';
15+
16+
const CONFIG_FILE = join(process.cwd(), 'locked-workspaces.json');
17+
const DATA_DIR = join(process.cwd(), 'data');
18+
const MASTER_DB_PATH = join(DATA_DIR, '_master.db');
19+
20+
interface LockedWorkspacesConfig {
21+
lockedWorkspaceIds?: string[];
22+
lockedWorkspaceNames?: string[];
23+
}
24+
25+
interface Workspace {
26+
id: string;
27+
name: string;
28+
}
29+
30+
function loadConfig(): LockedWorkspacesConfig {
31+
if (!existsSync(CONFIG_FILE)) {
32+
return { lockedWorkspaceIds: [], lockedWorkspaceNames: [] };
33+
}
34+
try {
35+
const content = readFileSync(CONFIG_FILE, 'utf-8');
36+
const parsed = JSON.parse(content) as LockedWorkspacesConfig;
37+
return {
38+
lockedWorkspaceIds: parsed.lockedWorkspaceIds || [],
39+
lockedWorkspaceNames: parsed.lockedWorkspaceNames || []
40+
};
41+
} catch {
42+
return { lockedWorkspaceIds: [], lockedWorkspaceNames: [] };
43+
}
44+
}
45+
46+
function saveConfig(config: LockedWorkspacesConfig): void {
47+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
48+
}
49+
50+
async function getWorkspaces(): Promise<Workspace[]> {
51+
if (!existsSync(MASTER_DB_PATH)) {
52+
console.error('Error: No database found. Run the server first to create workspaces.');
53+
process.exit(1);
54+
}
55+
56+
const SQL = await initSqlJs();
57+
const buffer = readFileSync(MASTER_DB_PATH);
58+
const db = new SQL.Database(buffer);
59+
60+
const stmt = db.prepare('SELECT id, name FROM workspaces ORDER BY name ASC');
61+
const workspaces: Workspace[] = [];
62+
while (stmt.step()) {
63+
const row = stmt.getAsObject() as { id: string; name: string };
64+
workspaces.push({ id: row.id, name: row.name });
65+
}
66+
stmt.free();
67+
db.close();
68+
69+
return workspaces;
70+
}
71+
72+
function isLocked(config: LockedWorkspacesConfig, workspace: Workspace): boolean {
73+
return (
74+
(config.lockedWorkspaceIds?.includes(workspace.id) || false) ||
75+
(config.lockedWorkspaceNames?.includes(workspace.name) || false)
76+
);
77+
}
78+
79+
async function listWorkspaces(): Promise<void> {
80+
const workspaces = await getWorkspaces();
81+
const config = loadConfig();
82+
83+
if (workspaces.length === 0) {
84+
console.log('No workspaces found.');
85+
return;
86+
}
87+
88+
console.log('\nWorkspaces:\n');
89+
console.log(' Status ID Name');
90+
console.log(' ------ -- ----');
91+
92+
for (const ws of workspaces) {
93+
const locked = isLocked(config, ws);
94+
const status = locked ? '🔒 LOCKED' : ' open ';
95+
console.log(` ${status} ${ws.id} ${ws.name}`);
96+
}
97+
console.log('');
98+
}
99+
100+
async function lockWorkspace(identifier: string): Promise<void> {
101+
const workspaces = await getWorkspaces();
102+
const config = loadConfig();
103+
104+
// Find workspace by ID or name
105+
const workspace = workspaces.find(ws => ws.id === identifier || ws.name === identifier);
106+
107+
if (!workspace) {
108+
console.error(`Error: Workspace not found: "${identifier}"`);
109+
console.log('\nAvailable workspaces:');
110+
for (const ws of workspaces) {
111+
console.log(` - ${ws.name} (${ws.id})`);
112+
}
113+
process.exit(1);
114+
}
115+
116+
if (isLocked(config, workspace)) {
117+
console.log(`Workspace "${workspace.name}" is already locked.`);
118+
return;
119+
}
120+
121+
// Add to locked IDs (prefer ID over name for stability)
122+
if (!config.lockedWorkspaceIds) {
123+
config.lockedWorkspaceIds = [];
124+
}
125+
config.lockedWorkspaceIds.push(workspace.id);
126+
127+
saveConfig(config);
128+
console.log(`✓ Locked workspace: "${workspace.name}" (${workspace.id})`);
129+
}
130+
131+
async function unlockWorkspace(identifier: string): Promise<void> {
132+
const workspaces = await getWorkspaces();
133+
const config = loadConfig();
134+
135+
// Find workspace by ID or name
136+
const workspace = workspaces.find(ws => ws.id === identifier || ws.name === identifier);
137+
138+
if (!workspace) {
139+
console.error(`Error: Workspace not found: "${identifier}"`);
140+
process.exit(1);
141+
}
142+
143+
if (!isLocked(config, workspace)) {
144+
console.log(`Workspace "${workspace.name}" is not locked.`);
145+
return;
146+
}
147+
148+
// Remove from both ID and name lists
149+
if (config.lockedWorkspaceIds) {
150+
config.lockedWorkspaceIds = config.lockedWorkspaceIds.filter(id => id !== workspace.id);
151+
}
152+
if (config.lockedWorkspaceNames) {
153+
config.lockedWorkspaceNames = config.lockedWorkspaceNames.filter(name => name !== workspace.name);
154+
}
155+
156+
saveConfig(config);
157+
console.log(`✓ Unlocked workspace: "${workspace.name}" (${workspace.id})`);
158+
}
159+
160+
function showConfig(): void {
161+
const config = loadConfig();
162+
console.log('\nCurrent locked workspaces config:\n');
163+
console.log(JSON.stringify(config, null, 2));
164+
console.log('');
165+
}
166+
167+
function showHelp(): void {
168+
console.log(`
169+
Lock Workspace CLI Tool
170+
171+
Usage:
172+
npm run lock-workspace list List all workspaces and their lock status
173+
npm run lock-workspace lock <id|name> Lock a workspace by ID or name
174+
npm run lock-workspace unlock <id|name> Unlock a workspace by ID or name
175+
npm run lock-workspace show Show current locked workspaces config
176+
177+
Examples:
178+
npm run lock-workspace list
179+
npm run lock-workspace lock "Public Template"
180+
npm run lock-workspace unlock abc123-def456-...
181+
`);
182+
}
183+
184+
async function main(): Promise<void> {
185+
const args = process.argv.slice(2);
186+
const command = args[0];
187+
188+
switch (command) {
189+
case 'list':
190+
await listWorkspaces();
191+
break;
192+
case 'lock':
193+
if (!args[1]) {
194+
console.error('Error: Please provide a workspace ID or name to lock.');
195+
process.exit(1);
196+
}
197+
await lockWorkspace(args[1]);
198+
break;
199+
case 'unlock':
200+
if (!args[1]) {
201+
console.error('Error: Please provide a workspace ID or name to unlock.');
202+
process.exit(1);
203+
}
204+
await unlockWorkspace(args[1]);
205+
break;
206+
case 'show':
207+
showConfig();
208+
break;
209+
case 'help':
210+
case '--help':
211+
case '-h':
212+
showHelp();
213+
break;
214+
default:
215+
showHelp();
216+
break;
217+
}
218+
}
219+
220+
main().catch(err => {
221+
console.error('Error:', err.message);
222+
process.exit(1);
223+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import { workspaceService } from '../services/database.js';
3+
import { lockedWorkspacesService } from '../services/locked-workspaces.js';
4+
import { AppError } from './error-handler.js';
5+
6+
/**
7+
* Middleware to check if a workspace is locked before allowing modifications.
8+
* Used on PUT, POST, DELETE routes for workspace content (shapes, rows, etc.)
9+
*/
10+
export function checkWorkspaceLocked(req: Request, _res: Response, next: NextFunction) {
11+
const workspaceId = req.params.workspaceId || req.params.id;
12+
13+
if (!workspaceId) {
14+
return next();
15+
}
16+
17+
// Get workspace to check by name as well
18+
const workspace = workspaceService.get(workspaceId);
19+
if (!workspace) {
20+
// Let the route handler deal with non-existent workspaces
21+
return next();
22+
}
23+
24+
if (lockedWorkspacesService.isLocked(workspaceId, workspace.name)) {
25+
return next(new AppError(
26+
403,
27+
'This workspace is locked and cannot be modified. You can duplicate it to create an editable copy.',
28+
'WORKSPACE_LOCKED'
29+
));
30+
}
31+
32+
next();
33+
}
34+
35+
/**
36+
* Middleware for routes that use :workspaceId param (like starting-point routes)
37+
*/
38+
export function checkWorkspaceLockedById(req: Request, _res: Response, next: NextFunction) {
39+
const workspaceId = req.params.workspaceId;
40+
41+
if (!workspaceId) {
42+
return next();
43+
}
44+
45+
const workspace = workspaceService.get(workspaceId);
46+
if (!workspace) {
47+
return next();
48+
}
49+
50+
if (lockedWorkspacesService.isLocked(workspaceId, workspace.name)) {
51+
return next(new AppError(
52+
403,
53+
'This workspace is locked and cannot be modified. You can duplicate it to create an editable copy.',
54+
'WORKSPACE_LOCKED'
55+
));
56+
}
57+
58+
next();
59+
}
60+
61+
/**
62+
* Middleware specifically for workspace deletion - prevents deleting locked workspaces
63+
*/
64+
export function checkWorkspaceDeleteLocked(req: Request, _res: Response, next: NextFunction) {
65+
const workspaceId = req.params.id;
66+
67+
if (!workspaceId) {
68+
return next();
69+
}
70+
71+
const workspace = workspaceService.get(workspaceId);
72+
if (!workspace) {
73+
return next();
74+
}
75+
76+
if (lockedWorkspacesService.isLocked(workspaceId, workspace.name)) {
77+
return next(new AppError(
78+
403,
79+
'This workspace is locked and cannot be deleted.',
80+
'WORKSPACE_LOCKED'
81+
));
82+
}
83+
84+
next();
85+
}

0 commit comments

Comments
 (0)