diff --git a/.cursor/Dockerfile b/.cursor/Dockerfile new file mode 100644 index 00000000..a68060bd --- /dev/null +++ b/.cursor/Dockerfile @@ -0,0 +1,52 @@ +FROM node:20-bullseye + +# Install system dependencies for LMDB and development tools +RUN apt-get update && apt-get install -y \ + build-essential \ + python3 \ + python3-pip \ + git \ + curl \ + wget \ + vim \ + nano \ + htop \ + tree \ + jq \ + liblmdb-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Install global Node.js development tools +RUN npm install -g \ + nodemon \ + pm2 \ + typescript \ + ts-node \ + eslint \ + prettier + +# Create a non-root user for development +RUN useradd -m -s /bin/bash ubuntu && \ + usermod -aG sudo ubuntu && \ + echo 'ubuntu ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + +# Set up the working directory +USER ubuntu +WORKDIR /home/ubuntu + +# Set environment variables +ENV NODE_ENV=development +ENV PATH="/home/ubuntu/.local/bin:${PATH}" + +# Create common directories +RUN mkdir -p /home/ubuntu/.npm-global && \ + mkdir -p /home/ubuntu/.cache && \ + mkdir -p /home/ubuntu/workspace + +# Configure npm to use the global directory +RUN npm config set prefix '/home/ubuntu/.npm-global' +ENV PATH="/home/ubuntu/.npm-global/bin:${PATH}" + +# Set the default shell to bash +SHELL ["/bin/bash", "-c"] diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..d354084d --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,6 @@ +{ + "build": { + "context": ".", + "dockerfile": "Dockerfile" + } +} diff --git a/.cursor/prompts/20250715-160300.md b/.cursor/prompts/20250715-160300.md new file mode 100644 index 00000000..df902ede --- /dev/null +++ b/.cursor/prompts/20250715-160300.md @@ -0,0 +1,100 @@ +# Resource address schema refactor + +We are implementing a common resource address schema for canvas: [user.name]@[remote-or-remote-alias]:[resource][optional path] +This schema is to be used on all canvas-server clients(cli, electron ui, whatever rust/go or python based canvas roles the user decides to run locally) + +Examples: + userfoo@canvas.local:workspace1 + userbar@canvas.remote:context-foo + userbaz@work.tld:workspace-baz/shell/bash # Dotfile reference when called with canvas dot + userbas@workmachine:workspace-baf/foo/bar/baf # When called with canvas context + +We'll use a common configuration store for all our client applications, by default in +~/.canvas/config + +Minimal acceptable protection: + - file permissions (chmod 600) and perhaps ~/.canvas/credentials.json separate from config for easier secrets handling but we are in MVP territory so not sure about spliting it off yet/as-of-now + - For future, we can support OS keyrings. + +Local configuration should use the following files/schemas: +``` + /remotes.json + { + "user@remote-shortname":{ + url: https://full-canvs-server-url:port/ + apiBase: /rest/v2 + description: optional description + auth: + "method": "token|password|oauth2|oidc", + "tokenType": "jwt", + "token": "..." + }, + lastSynced: timestamp-of-last-full-sync + } + } + /contexts.json + { + "user@remote-shortname:context_id": { + url + ...context object we get from remote canvas-server api when listing contexts/updating/creating individual contexts + lastSynced: timestamp-of-last-sync + } + } + + /workspaces.json + { + "user@remote-shortname:workspace_id": { + ...workspace object we get from remote canvas-server api when listing workspaces/updating/creating individual workspaces + lastSynced: timestamp-of-last-sync + } + } + /session-cli.json + { + boundRemote: user.name@remote-shortname + defaultWorkspace: user@remote-shortname:workspace_id, optional + boundContext: user@remote-shortname:context_id, optional + boundAt: timestamp + } +``` + +Every canvas-server instance holds a index of all its local users, contexts, workspaces but to +- a) not overengineer our MVP +- b) not overcomplicate the UX + +we'll treat canvas-servers / remotes as with git or ssh. + +Every Context and every Workspace can also be accessed using a dedicated api-token(you can share your workspace with other users by creating a api token(ro or rw) for said workspace or context - not sure where to store that information, probably in workspaces.json worksapce.auth.type? and if auth.type is token we use workspace token to access that workspace + +Whenever canvas cli does any changes related to the context(mostly changing its URL) or workspace(changing its parameters) - we get a full object back, we should therefore update our local index right away +`$ canvas context bind user@remote:context-id` +`$ canvas context set workspace-id://some/context/path` +Returns a context object which we can save + +The following commands should be implemented in our main bun/node based CLI: +``` +canvas remote add user@remote-shortname url --token +canvas remote list | ls +canvas remote remove user@remote-shortname +canvas remote sync user@remote-shortname +canvas remote ping user@remote-shortname +canvas remote bind user@remote-shortname # To select a remote as default, we should write ~/.canvas/session.json +canvas remote login user@remote-shortname # JWT +canvas remote logout user@remote-shortname # JWT +remote rename old new +remote show user@remote # Dump remote details +``` +On login, we should call +``` +GET apiBase/workspaces +GET apiBase/contexts +``` + +Then update our local indexes +This should also be triggered with the sync command above + +Indexes needs to be updated on a per-resource basis, not just by overwriting the whole file but by selectively updating the resource + +So +Guess lets devise a plan to update the canvas-server backend and canvas-cli and start working + +- Model: claude-4-sonnet MAX diff --git a/.cursorrules b/.cursorrules index c8b5aca9..70ee1551 100644 --- a/.cursorrules +++ b/.cursorrules @@ -40,8 +40,7 @@ Context layers filter different data based on where they are placed within the c - We use ES6 vanilla JavaScript and may use TypeScript in the future - We use bash and powershell for scripting - We use Node.js v20 LTS for the server runtime -- We use LMDB for the main server db backend `/server/db`) -- We use Canvas-SynapsD for user workspace databases and as the default JSON document store +- We use LMDB-based canvas-synapsd databases as the default JSON document store for user workspaces - User data paths: - `/users/john.doe@email.com/universe/` is the default workspace directory for user data - `/users/john.doe@email.com/sample-workspace` is the example workspace directory for user diff --git a/.gitmodules b/.gitmodules index 7e4e7c90..69cf3d21 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,8 +8,8 @@ [submodule "src/ui/shell"] path = src/ui/shell url = https://github.com/canvas-ai/canvas-shell.git -[submodule "src/ui/browser"] - path = src/ui/browser +[submodule "src/ui/browser-extension"] + path = src/ui/browser-extension url = https://github.com/canvas-ai/canvas-browser-extensions.git [submodule "src/services/synapsd"] path = src/services/synapsd diff --git a/README.md b/README.md index 403e245f..466f5a8f 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,17 @@ Server runtime for the Canvas project **! Use the dev branch for now** **On every iteration(refactor) of this project, I actually loose(as in - BREAK -) functionality!** -We already had tab management implemented(great showcase for the bitmap-based context tree index), with named sessions and working browser extensions. I decided to **slightly** refactor the context? or workspace manager? Don't even remember(git history would show) - 6 months later we still have no working runtime and using AI actually makes things worse! +We already had tab management implemented(great showcase for the bitmap-based context tree index), with named sessions and working browser extensions. I decided to **slightly** refactor the context? or workspace manager? don't even remember(git history would show) - 6 months later we still have no working runtime and using AI actually makes things worse! (as we are now in an attention-based economy(creds for coining the term _for me_ to @TechLead, I'll rant about it in some "coding-canvas" live stream session @idnc.streams soon)) Sooo New approach: **"Do The Simplest Thing That Could Possibly Work(tm)"** + +- No federation support +- No remote workspaces* +- Contexts bound to the canvas-server instance + Sorry Universe for the delay.. ## Installation @@ -22,8 +27,9 @@ Sorry Universe for the delay.. ```bash $ git clone https://github.com/canvas-ai/canvas-server /path/to/canvas-server $ cd /path/to/canvas-server -$ npm install # or yarn install -$ npm run start # or yarn start +$ npm install +$ npm run dev # or +$ npm run start ``` ## Docker @@ -55,8 +61,8 @@ CANVAS_ADMIN_RESET: ${CANVAS_ADMIN_RESET:-false} CANVAS_DISABLE_API: ${CANVAS_DISABLE_API:-false} CANVAS_API_PORT: ${CANVAS_API_PORT:-8001} CANVAS_API_HOST: ${CANVAS_API_HOST:-0.0.0.0} -JWT_SECRET: ${JWT_SECRET:-$(openssl rand -base64 16)} -TOKEN_EXPIRY: ${TOKEN_EXPIRY:-7d} +CANVAS_JWT_SECRET: ${CANVAS_JWT_SECRET:-$(openssl rand -base64 16)} +CANVAS_JWT_TOKEN_EXPIRY: ${CANVAS_JWT_TOKEN_EXPIRY:-7d} # Canvas USER dirs for single-user mode # See env.js for more info @@ -98,7 +104,7 @@ $ npm install $ npm start # or npm run pm2:start ``` -## Make Canavas Server WebUI available remotely +## Make Canvas Server WebUI available remotely ```bash # Copy the .env.example file @@ -111,79 +117,10 @@ $ npm run build ## Scripts -### build-portable-image.sh - -This script builds a Docker image for the Canvas Server with a portable configuration. - -#### Usage - -```bash -$ ./scripts/build-portable-image.sh [-n image_name] [-t image_tag] [-f dockerfile] [-c config_dir] -``` - -#### Options - -- `-n`: Image name (default: canvas-server) -- `-t`: Image tag (default: portable) -- `-f`: Dockerfile to use (default: Dockerfile) -- `-c`: Config directory to copy (default: ./config) - -### install-docker.sh - -This script installs Docker and Docker Compose on an Ubuntu system. It checks if Docker and Docker Compose are already installed, and if not, installs them. - -#### Usage - -```bash -$ ./scripts/install-docker.sh -``` - -### install-ubuntu.sh - -This script installs and sets up the Canvas Server on an Ubuntu system. It installs Node.js, clones the Canvas Server repository, and sets up the service. - -#### Usage - -```bash -$ ./scripts/install-ubuntu.sh [-r canvas_root] [-u canvas_user] [-g canvas_group] [-b canvas_repo_branch] [-n nodejs_version] [-e web_admin_email] [-f web_fqdn] -``` - -#### Options - -- `-r`: Canvas root directory (default: /opt/canvas-server) -- `-u`: Canvas user (default: canvas) -- `-g`: Canvas group (default: www-data) -- `-b`: Canvas repository branch (default: dev) -- `-n`: Node.js version (default: 20) -- `-e`: Web admin email (default: $(hostname)@cnvs.ai) -- `-f`: Web FQDN (default: my.cnvs.ai) - -### update-docker.sh - -This script updates Docker containers for the Canvas Server. - -#### Usage - -```bash -$ ./scripts/update-docker.sh [-r canvas_root] [-f docker_compose_file] [-b target_branch] [-l log_file] -``` - -#### Options - -- `-r`: Canvas root directory (default: /opt/canvas-server) -- `-f`: Docker Compose file (default: docker-compose.yml) -- `-b`: Target branch for git pull (default: main) -- `-l`: Log file (default: /var/log/canvas-docker-update.log) - -### update-git.sh - -This script updates the Canvas Server by pulling the latest changes from the git repository and restarting the service. - -#### Usage - -```bash -$ ./scripts/update-git.sh -``` +`build-portable-image.sh`: Builds a Docker image for the Canvas Server with a portable configuration. +`install-ubuntu.sh`: Installs and sets up the Canvas Server on an Ubuntu system. It installs Node.js, clones the Canvas Server repository, and sets up the service. +`update-git.sh`: Script updates the Canvas Server by pulling the latest changes from the git repository and restarting the service. +`update-submodules.sh`: Pushes git submodule changes to a remote git repository ## Authentication @@ -194,6 +131,10 @@ Canvas Server supports two types of authentication: ## References +### API Documentation + +- [Canvas Server API Reference](docs/API.md) - Complete REST API and WebSocket documentation + ### DB / Index - https://www.npmjs.com/package/lmdb diff --git a/docker-compose.yml b/docker-compose.yml index d0b6b099..c95d220f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,8 +23,8 @@ services: CANVAS_DISABLE_API: ${CANVAS_DISABLE_API:-false} CANVAS_API_PORT: ${CANVAS_API_PORT:-8001} CANVAS_API_HOST: ${CANVAS_API_HOST:-0.0.0.0} - JWT_SECRET: ${JWT_SECRET:-$(openssl rand -base64 16)} - TOKEN_EXPIRY: ${TOKEN_EXPIRY:-7d} + CANVAS_JWT_SECRET: ${CANVAS_JWT_SECRET:-$(openssl rand -base64 16)} + CANVAS_JWT_TOKEN_EXPIRY: ${CANVAS_JWT_TOKEN_EXPIRY:-7d} volumes: - ${CANVAS_SERVER_HOME:-./server}:/opt/canvas-server/server - ${CANVAS_USER_HOME:-./users}:/opt/canvas-server/users diff --git a/docs/API-ACCESS-DESIGN.md b/docs/API-ACCESS-DESIGN.md new file mode 100644 index 00000000..7e623c82 --- /dev/null +++ b/docs/API-ACCESS-DESIGN.md @@ -0,0 +1,261 @@ +# Canvas API Access Design + +## Overview + +This document defines the access patterns and authentication mechanisms for the Canvas Server REST API. The API supports different access methods for workspace and context resources, with clear separation between personal and shared resource access. + +## Authentication Methods + +### 1. JWT Tokens (Web UI) +- **Purpose**: Primary authentication for web interface users +- **Scope**: Access to user's own resources only +- **Lifetime**: Short-lived (default: 1 day) +- **Format**: Standard JWT bearer token +- **Usage**: Web application sessions + +```bash +# Example usage +curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ + http://localhost:8001/rest/v2/workspaces +``` + +### 2. API Tokens (Programmatic Access) +- **Purpose**: Long-lived programmatic access and workspace sharing +- **Scope**: User's own resources + shared resources via workspace ACLs +- **Lifetime**: Long-lived or permanent (configurable expiration) +- **Format**: `canvas-` prefixed tokens +- **Usage**: CLI tools, scripts, integrations + +```bash +# Example usage +curl -H "Authorization: Bearer canvas-ae651b2333fa2ee6be289f9f3d81831b1b040a472aba4c39" \ + http://localhost:8001/rest/v2/workspaces +``` + +## Resource Access Patterns + +### Personal Workspace Access + +#### List User's Own Workspaces +``` +GET /rest/v2/workspaces +``` +- **Authentication**: JWT or API token +- **Returns**: All workspaces owned by the authenticated user +- **Access Control**: User can only see their own workspaces + +#### Access Specific Workspace (by ID or Name) +``` +GET /rest/v2/workspaces/:id +``` +- **Authentication**: JWT or API token +- **Resource Identifier**: Can be workspace UUID or workspace name +- **Access Control**: + - JWT tokens: Owner access only + - API tokens: Owner access OR workspace ACL permissions + +#### Supported Workspace Identifiers +1. **Workspace UUID**: `7c84589b-9268-45e8-9b7c-85c29adc9bca` +2. **Workspace Name**: `universe`, `my-project`, etc. +3. **Resource Address**: `user.name/workspace.name` (address resolver middleware) + +### Shared Resource Access + +#### Public/Shared Context Access +``` +GET /rest/v2/pub/:targetUserId/contexts/:contextId +POST /rest/v2/pub/:ownerUserId/contexts/:contextId/shares +DELETE /rest/v2/pub/:ownerUserId/contexts/:contextId/shares/:sharedWithUserId +``` +- **Purpose**: Access contexts shared by other users +- **Authentication**: JWT or API token required +- **Access Control**: Based on context ACL permissions +- **Scope**: Context-level sharing only (no workspace sharing via pub routes) + +### API Token Workspace Sharing + +API tokens can access workspaces beyond their owner through workspace-level ACLs stored in `workspace.json`: + +```json +{ + "acl": { + "tokens": { + "sha256:abc123...": { + "permissions": ["read", "write"], + "description": "Jane's laptop", + "createdAt": "2024-01-01T00:00:00Z", + "expiresAt": null + } + } + } +} +``` + +#### Token Hash Generation +```javascript +const tokenHash = `sha256:${crypto.createHash('sha256').update(token).digest('hex')}`; +``` + +#### Permission Levels +- `read`: List documents, get workspace info +- `write`: Insert/update documents, modify workspace tree +- `admin`: Full workspace management, ACL modification + +## Resource Addressing + +### Simple Resource Addresses +Format: `user.name/resource.name` + +Examples: +- `admin/universe` → resolves to workspace owned by user "admin" named "universe" +- `john.doe/my-project` → resolves to workspace owned by user "john.doe" named "my-project" + +### Address Resolution Middleware +- **Location**: `src/api/middleware/address-resolver.js` +- **Purpose**: Converts user-friendly addresses to internal IDs +- **Scope**: Applied to routes with `:id` parameters +- **Behavior**: + - Detects addresses containing `/` + - Resolves `user.name/resource.name` to internal UUIDs + - Passes through regular IDs unchanged + - Stores original address for response inclusion + +### Remote References (CLI Only) +Format: `user@remote:workspace[/path]` + +Examples: +- `admin@canvas.local:universe` +- `john.doe@remote.server.com:shared-workspace/subfolder` + +**Note**: Remote references are handled by CLI clients and converted to simple addresses before sending to the API. + +## Access Control Matrix + +| Resource Type | JWT Token | API Token (Owner) | API Token (ACL) | Pub Routes | +|---------------|-----------|-------------------|-----------------|------------| +| List Own Workspaces | ✅ | ✅ | ✅ | ❌ | +| Access Own Workspace | ✅ | ✅ | ✅ | ❌ | +| Access Shared Workspace | ❌ | ❌ | ✅ (if ACL permits) | ❌ | +| List Own Contexts | ✅ | ✅ | ✅ | ❌ | +| Access Own Context | ✅ | ✅ | ✅ | ❌ | +| Access Shared Context | ❌ | ❌ | ❌ | ✅ (if shared) | +| Share Context | ✅ | ✅ | ❌ | ✅ (owner only) | + +## Error Responses + +### Common HTTP Status Codes + +#### 401 Unauthorized +```json +{ + "status": "error", + "statusCode": 401, + "message": "Valid authentication required" +} +``` + +#### 403 Forbidden +```json +{ + "status": "error", + "statusCode": 403, + "message": "Access denied to workspace abc123. Token lacks required permission: read" +} +``` + +#### 404 Not Found +```json +{ + "status": "error", + "statusCode": 404, + "message": "Workspace not found: admin/nonexistent" +} +``` + +## Implementation Notes + +### Workspace ACL Middleware +- **Location**: `src/api/middleware/workspace-acl.js` +- **Purpose**: Validates workspace access for both JWT and API tokens +- **Logic**: + 1. Try owner access first (fastest path) + 2. For API tokens: try token-based ACL access + 3. For JWT tokens: owner access only + +### Address Resolution Process +1. **Detection**: Check if `:id` parameter contains `/` +2. **Resolution**: Call manager's `resolveIdFromSimpleIdentifier()` method +3. **Replacement**: Replace `:id` with resolved internal UUID +4. **Storage**: Store original address for response inclusion + +### Response Enhancement +API responses include `resourceAddress` field when available: + +```json +{ + "status": "success", + "payload": { + "workspace": { /* workspace data */ }, + "access": { /* access info */ }, + "resourceAddress": "admin/universe" + } +} +``` + +## Configuration + +### Environment Variables +- `JWT_SECRET`: Secret for JWT token signing +- `AUTH_TOKEN_*`: Configuration for API token settings + +### ACL Configuration +Workspace ACLs are stored in individual `workspace.json` files: +``` +/server/users/user@domain.com/workspace.json +``` + +### Context Sharing +Context sharing is managed through the context manager and stored in context-specific ACL structures. + +## Security Considerations + +1. **Token Scope**: JWT tokens limited to owner access only +2. **ACL Validation**: Token hashes verified against workspace ACLs +3. **Expiration**: Both JWT and API tokens support expiration +4. **Permission Checks**: Granular permission checking (read/write/admin) +5. **Owner Validation**: Strict owner verification for sensitive operations + +## Testing Examples + +### Valid API Token Access +```bash +# List own workspaces +curl -H "Authorization: Bearer canvas-abc123..." \ + http://localhost:8001/rest/v2/workspaces + +# Access workspace by name +curl -H "Authorization: Bearer canvas-abc123..." \ + http://localhost:8001/rest/v2/workspaces/universe + +# Access workspace by resource address +curl -H "Authorization: Bearer canvas-abc123..." \ + http://localhost:8001/rest/v2/workspaces/admin/universe +``` + +### Valid JWT Access +```bash +# List own workspaces +curl -H "Authorization: Bearer eyJhbGciOi..." \ + http://localhost:8001/rest/v2/workspaces + +# Access own workspace +curl -H "Authorization: Bearer eyJhbGciOi..." \ + http://localhost:8001/rest/v2/workspaces/my-workspace +``` + +### Shared Context Access +```bash +# Access shared context via pub routes +curl -H "Authorization: Bearer canvas-abc123..." \ + http://localhost:8001/rest/v2/pub/other-user-id/contexts/shared-context-id +``` diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..cb4244b0 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,248 @@ +# REST API + +> **📖 See [API Access Design](./API-ACCESS-DESIGN.md) for comprehensive authentication patterns, resource addressing, and access control documentation.** + +## Main entry points + +- `/auth`: Authentication and user management routes +- `/pub`: Shared content and cross-user context access +- `/workspaces`: Workspace management and operations +- `/contexts`: Context management and operations +- `/schemas`: Data abstraction schemas +- `/ping`: Server status and health checks + +## Auth routes + +### Authentication Configuration +- `GET /rest/v2/auth/config` - Get authentication configuration (strategies, domains) + +### User Authentication +- `POST /rest/v2/auth/login` - User login (supports local, IMAP, and auto strategies) +- `POST /rest/v2/auth/logout` - User logout (client-side token invalidation) +- `POST /rest/v2/auth/register` - User registration +- `GET /rest/v2/auth/me` - Get current user profile + +### Password Management +- `PUT /rest/v2/auth/password` - Update user password (requires current password) +- `POST /rest/v2/auth/forgot-password` - Request password reset email +- `POST /rest/v2/auth/reset-password` - Reset password with token + +### Email Verification +- `POST /rest/v2/auth/verify-email` - Request email verification +- `GET /rest/v2/auth/verify-email/:token` - Verify email with token + +### API Token Management +- `GET /rest/v2/auth/tokens` - List user's API tokens +- `POST /rest/v2/auth/tokens` - Create new API token +- `PUT /rest/v2/auth/tokens/:tokenId` - Update API token (name/description) +- `DELETE /rest/v2/auth/tokens/:tokenId` - Delete API token + +### Token Verification +- `POST /rest/v2/auth/token/verify` - Verify JWT or API token validity + +## Pub routes + +### Shared Context Access +- `GET /rest/v2/pub/:targetUserId/contexts/:contextId` - Get shared context by owner and context ID +- `POST /rest/v2/pub/:ownerUserId/contexts/:contextId/shares` - Share context with another user +- `DELETE /rest/v2/pub/:ownerUserId/contexts/:contextId/shares/:sharedWithUserId` - Revoke context access + +### Shared Document Operations +- `GET /rest/v2/pub/:targetUserId/contexts/:contextId/documents` - List documents in shared context +- `POST /rest/v2/pub/:targetUserId/contexts/:contextId/documents` - Insert documents into shared context +- `PUT /rest/v2/pub/:targetUserId/contexts/:contextId/documents` - Update documents in shared context +- `DELETE /rest/v2/pub/:targetUserId/contexts/:contextId/documents/remove` - Remove documents from shared context +- `DELETE /rest/v2/pub/:targetUserId/contexts/:contextId/documents` - Delete documents from shared context (owner only) +- `GET /rest/v2/pub/:targetUserId/contexts/:contextId/documents/by-id/:docId` - Get specific document by ID from shared context + +## Workspaces + +### Workspace Lifecycle +- `GET /rest/v2/workspaces` - List user's workspaces +- `POST /rest/v2/workspaces` - Create new workspace +- `GET /rest/v2/workspaces/:id` - Get workspace by ID +- `PUT /rest/v2/workspaces/:id` - Update workspace +- `DELETE /rest/v2/workspaces/:id` - Delete workspace + +### Workspace Documents +- `GET /rest/v2/workspaces/:id/documents` - List documents in workspace +- `POST /rest/v2/workspaces/:id/documents` - Insert documents into workspace +- `PUT /rest/v2/workspaces/:id/documents` - Update documents in workspace +- `DELETE /rest/v2/workspaces/:id/documents` - Delete documents from workspace +- `DELETE /rest/v2/workspaces/:id/documents/remove` - Remove documents from workspace + +### Workspace Tree Operations +- `GET /rest/v2/workspaces/:id/tree` - Get workspace tree structure +- `POST /rest/v2/workspaces/:id/tree/paths` - Insert path into workspace tree +- `DELETE /rest/v2/workspaces/:id/tree/paths` - Remove path from workspace tree +- `POST /rest/v2/workspaces/:id/tree/paths/move` - Move path in workspace tree +- `POST /rest/v2/workspaces/:id/tree/paths/copy` - Copy path in workspace tree +- `POST /rest/v2/workspaces/:id/tree/paths/merge-up` - Merge layer bitmaps upwards +- `POST /rest/v2/workspaces/:id/tree/paths/merge-down` - Merge layer bitmaps downwards + +### Workspace Token Management +- `GET /rest/v2/workspaces/:id/tokens` - List workspace access tokens +- `POST /rest/v2/workspaces/:id/tokens` - Create workspace access token +- `PUT /rest/v2/workspaces/:id/tokens/:tokenId` - Update workspace access token +- `DELETE /rest/v2/workspaces/:id/tokens/:tokenId` - Delete workspace access token + +## Contexts + +### Context Lifecycle +- `GET /rest/v2/contexts` - List user's contexts +- `POST /rest/v2/contexts` - Create new context +- `GET /rest/v2/contexts/:id` - Get context by ID +- `PUT /rest/v2/contexts/:id` - Update context +- `DELETE /rest/v2/contexts/:id` - Delete context + +### Context URL Management +- `GET /rest/v2/contexts/:id/url` - Get context URL +- `POST /rest/v2/contexts/:id/url` - Set context URL +- `GET /rest/v2/contexts/:id/path` - Get context path +- `GET /rest/v2/contexts/:id/path-array` - Get context path array + +### Context Documents +- `GET /rest/v2/contexts/:id/documents` - List documents in context +- `POST /rest/v2/contexts/:id/documents` - Insert documents into context +- `PUT /rest/v2/contexts/:id/documents` - Update documents in context +- `DELETE /rest/v2/contexts/:id/documents` - Delete documents from context (direct DB deletion) +- `DELETE /rest/v2/contexts/:id/documents/remove` - Remove documents from context +- `GET /rest/v2/contexts/:id/documents/by-id/:docId` - Get document by ID +- `GET /rest/v2/contexts/:id/documents/:docId` - Get document by ID (direct route) +- `GET /rest/v2/contexts/:id/documents/by-abstraction/:abstraction` - Get documents by abstraction +- `GET /rest/v2/contexts/:id/documents/by-hash/:algo/:hash` - Get document by hash (owner-only) + +### Context Tree Operations +- `GET /rest/v2/contexts/:id/tree` - Get context tree structure +- `POST /rest/v2/contexts/:id/tree/paths` - Insert path into context tree +- `DELETE /rest/v2/contexts/:id/tree/paths` - Remove path from context tree +- `POST /rest/v2/contexts/:id/tree/paths/move` - Move path in context tree +- `POST /rest/v2/contexts/:id/tree/paths/copy` - Copy path in context tree +- `POST /rest/v2/contexts/:id/tree/paths/merge-up` - Merge layer bitmaps upwards +- `POST /rest/v2/contexts/:id/tree/paths/merge-down` - Merge layer bitmaps downwards + +## Schemas + +### Schema Management +- `GET /rest/v2/schemas` - List all data schemas +- `GET /rest/v2/schemas/data/abstraction/:abstraction` - List schemas by abstraction type +- `GET /rest/v2/schemas/data/abstraction/:abstraction.json` - Get JSON schema for specific abstraction + +## Ping + +### Server Status +- `GET /ping` - Simple ping endpoint +- `GET /debug` - Debug endpoint (requires authentication) +- `GET /rest/v2/ping` - Server status with system information + +# Websockets + +## Workspaces + +### Workspace Events +- `workspace.status.changed` - Workspace status changes +- `workspace.created` - New workspace created +- `workspace.updated` - Workspace updated +- `workspace.deleted` - Workspace deleted +- `workspace.documents.inserted` - Documents inserted into workspace +- `workspace.documents.updated` - Documents updated in workspace +- `workspace.documents.deleted` - Documents deleted from workspace +- `workspace.tree.path.inserted` - Path inserted into workspace tree +- `workspace.tree.path.removed` - Path removed from workspace tree +- `workspace.tree.path.moved` - Path moved in workspace tree +- `workspace.tree.path.copied` - Path copied in workspace tree + +## Contexts + +### Context Events +- `context.url.set` - Context URL changed +- `context.updated` - Context updated +- `context.locked` - Context locked +- `context.unlocked` - Context unlocked +- `context.acl.updated` - Context ACL updated +- `context.acl.revoked` - Context ACL revoked +- `context.documents.inserted` - Documents inserted into context +- `context.documents.updated` - Documents updated in context +- `context.documents.deleted` - Documents deleted from context +- `context.tree.path.inserted` - Path inserted into context tree +- `context.tree.path.removed` - Path removed from context tree +- `context.tree.path.moved` - Path moved in context tree +- `context.tree.path.copied` - Path copied in context tree + +# Common + +## ResponseObject + +The API uses a standardized `ResponseObject` class for all responses with the following structure: + +```javascript +{ + status: 'success' | 'error', + statusCode: number, + message: string, + payload: any, + count: number | null +} +``` + +### Response Methods +- `success(payload, message, statusCode, count)` - Generic success response +- `created(payload, message, statusCode, count)` - Resource creation success +- `found(payload, message, statusCode, count)` - Resource retrieval success +- `updated(payload, message, statusCode, count)` - Resource update success +- `deleted(message, statusCode, count)` - Resource deletion success +- `notFound(message, payload, statusCode)` - Resource not found +- `error(message, payload, statusCode)` - Generic error +- `badRequest(message, payload, statusCode)` - Invalid request +- `unauthorized(message, payload, statusCode)` - Authentication required +- `forbidden(message, payload, statusCode)` - Insufficient permissions +- `conflict(message, payload, statusCode)` - Request conflict +- `serverError(message, payload, statusCode)` - Internal server error + +## Authentication + +The API supports two authentication methods: + +### JWT Tokens +- Used primarily for web UI authentication +- Short-lived tokens (default: 1 day) +- Include user information in payload + +### API Tokens +- Long-lived tokens with `canvas-` prefix +- Used for programmatic access +- Support token-based workspace sharing via ACLs + +## Access Control + +### Workspace ACL +- Owner access: Full permissions (read, write, admin) +- Token-based access: Configurable permissions per token +- JWT tokens: Owner-only access + +### Context ACL +- Owner access: Full permissions +- Shared access: Document-level permissions (documentRead, documentWrite, documentReadWrite) +- Cross-user context access via `/pub` routes + +## Error Handling + +All endpoints return standardized error responses using the `ResponseObject` class. Common error scenarios: + +- **401 Unauthorized**: Invalid or missing authentication token +- **403 Forbidden**: Insufficient permissions for the requested operation +- **404 Not Found**: Requested resource doesn't exist +- **409 Conflict**: Resource conflict (e.g., duplicate names) +- **500 Internal Server Error**: Unexpected server error + +## Rate Limiting + +Currently no rate limiting is implemented. Consider implementing rate limiting for production deployments. + +## CORS + +CORS is enabled with the following configuration: +- Origin: All origins (configurable) +- Methods: GET, PUT, POST, DELETE, PATCH, OPTIONS +- Credentials: true +- Allowed headers: Content-Type, Authorization, Accept, X-App-Name, X-Selected-Session diff --git a/docs/auth-redirect-loop-fix.md b/docs/auth-redirect-loop-fix.md new file mode 100644 index 00000000..acb2b9e5 --- /dev/null +++ b/docs/auth-redirect-loop-fix.md @@ -0,0 +1,220 @@ +# Auth Redirect Loop Fix Documentation + +## Problem Description + +The Canvas server was experiencing a redirect loop issue where users would get stuck bouncing between the login page (`/login`) and the workspaces page (`/workspaces`). This occurred when: + +1. A user had a valid JWT token that was not expired +2. The JWT token contained a valid user ID (e.g., `oCmZ7iNP`) +3. The user was not found in the database index (`User not found in index: oCmZ7iNP`) + +## Root Cause + +The issue occurred due to a mismatch between the JWT token's user ID and what was stored in the user database index. This can happen when: + +- **User data deletion**: User was deleted from the database but the JWT token was still valid +- **Database corruption or reset**: The user index was corrupted or reset +- **User ID format changes**: User ID format changed between token issuance and validation + +## Symptoms + +``` +[01:04:30.010] INFO (1365515): [Auth/Me] JWT verification successful: oCmZ7iNP +[01:04:30.011] ERROR (1365515): [Auth/Me] Database error: User not found in index: oCmZ7iNP +``` + +The UI would show: +- User gets redirected from `/workspaces` to `/login` +- User's token is still valid, so they get redirected back to `/workspaces` +- Cycle repeats infinitely + +## Solution + +The fix involves three main components: + +### 1. Backend: Enhanced Error Handling in Auth Routes + +**File**: `src/api/routes/auth.js` + +```javascript +// Handle the specific case where user exists in token but not in database +if (dbError.message.includes('User not found in index')) { + fastify.log.warn(`[Auth/Me] User ${userId} has valid token but missing from database - clearing authentication`); + + // Return a specific error that the frontend can handle + const response = new ResponseObject().unauthorized( + 'Your session is invalid. Please log in again.', + { + code: 'USER_NOT_FOUND_IN_DATABASE', + userId: userId, + action: 'logout' + } + ); + return reply.code(response.statusCode).send(response.getResponse()); +} +``` + +### 2. Backend: Enhanced Error Handling in Auth Strategies + +**Files**: `src/api/auth/strategies.js` + +Both `verifyJWT` and `verifyApiToken` functions now handle the missing user scenario: + +```javascript +// Handle the specific case where user exists in token but not in database +if (userError.message.includes('User not found in index')) { + console.warn(`[Auth/JWT] User ${decoded.sub} has valid JWT token but missing from database`); + return response.unauthorized('Your session is invalid. Please log in again.'); +} +``` + +### 3. Frontend: Improved Error Handling + +**File**: `src/ui/web/src/services/auth.ts` + +```typescript +// Handle the specific case where user exists in token but not in database +if (error instanceof Error && + (error.message.includes('USER_NOT_FOUND_IN_DATABASE') || + error.message.includes('Your session is invalid'))) { + console.warn('User token is valid but user not found in database - clearing authentication'); + api.clearAuthToken(); + socketService.disconnect(); + return null; +} +``` + +## Testing + +A comprehensive test suite has been created to verify the fix: + +**File**: `tests/auth-redirect-loop-test.js` + +To run the test: + +```bash +cd /path/to/canvas-server +node tests/auth-redirect-loop-test.js +``` + +The test covers: +1. User creation and login +2. Valid token verification +3. Missing user scenario simulation +4. Invalid token handling +5. Expired token handling + +## Prevention Measures + +### 1. Database Backup and Recovery + +- **Regular backups**: Implement automated backups of the user index +- **Backup verification**: Regularly verify backup integrity +- **Recovery procedures**: Document and test recovery procedures + +### 2. Token Lifecycle Management + +- **Shorter token expiration**: Consider shorter JWT token expiration times +- **Token refresh mechanisms**: Implement token refresh to minimize impact +- **Token validation**: Add periodic token validation checks + +### 3. Monitoring and Alerting + +- **Error monitoring**: Set up alerts for "User not found in index" errors +- **Authentication metrics**: Monitor authentication success/failure rates +- **Database health checks**: Regular database integrity checks + +### 4. User Session Management + +- **Session invalidation**: Implement proper session invalidation on user deletion +- **Token blacklisting**: Consider token blacklisting for deleted users +- **Graceful degradation**: Ensure graceful handling of authentication failures + +## Implementation Notes + +### Error Response Format + +The error response follows the `ResponseObject` pattern: + +```javascript +{ + status: 'error', + statusCode: 401, + message: 'Your session is invalid. Please log in again.', + payload: { + code: 'USER_NOT_FOUND_IN_DATABASE', + userId: 'oCmZ7iNP', + action: 'logout' + } +} +``` + +### Frontend Error Handling + +The frontend now properly handles multiple error conditions: + +- `USER_NOT_FOUND_IN_DATABASE`: Specific code for missing user +- `Your session is invalid`: Generic session invalid message +- `user account no longer exists`: Account deletion message +- `Authentication required`: Generic auth failure + +### Logging Improvements + +Enhanced logging throughout the authentication flow: + +- JWT token verification status +- User lookup results +- Error conditions and handling +- Database error specifics + +## Future Improvements + +1. **User Reactivation**: Implement user reactivation mechanism +2. **Token Refresh**: Add automatic token refresh functionality +3. **Database Sync**: Implement database synchronization checks +4. **User Migration**: Add user migration tools for database changes +5. **Admin Dashboard**: Create admin interface for user management + +## Troubleshooting + +### Common Issues + +1. **Token still valid but user missing**: + - Check user database integrity + - Verify token expiration settings + - Review user deletion procedures + +2. **Persistent redirect loops**: + - Clear browser localStorage + - Check for frontend caching issues + - Verify API error response format + +3. **Database corruption**: + - Run database integrity checks + - Restore from backup if necessary + - Review recent changes or migrations + +### Debug Commands + +```bash +# Check user database contents +node -e "console.log(require('./src/utils/jim/index.js').createIndex('users').store)" + +# Verify JWT token contents +node -e "console.log(require('jsonwebtoken').decode('YOUR_TOKEN_HERE'))" + +# Test authentication endpoint +curl -H "Authorization: Bearer YOUR_TOKEN_HERE" http://localhost:8001/rest/v2/auth/me +``` + +## Related Files + +- `src/api/routes/auth.js`: Main authentication routes +- `src/api/auth/strategies.js`: JWT and API token strategies +- `src/ui/web/src/services/auth.ts`: Frontend authentication service +- `src/managers/user/index.js`: User management logic +- `tests/auth-redirect-loop-test.js`: Test suite + +## Conclusion + +This fix ensures that when a user has a valid JWT token but is missing from the database, the system gracefully handles the error and prompts the user to log in again, breaking the redirect loop and providing a better user experience. diff --git a/docs/pub-email-redirect.md b/docs/pub-email-redirect.md new file mode 100644 index 00000000..193824dc --- /dev/null +++ b/docs/pub-email-redirect.md @@ -0,0 +1,117 @@ +# Pub Routes Email-to-User-ID Redirection + +## Overview + +The pub routes now support automatic redirection from email-based URLs to user ID-based URLs. This allows users to access shared resources using email addresses instead of having to know the specific user ID. + +## How It Works + +When a request is made to a pub route with an email address as the `targetUserId` or `ownerUserId` parameter, the system: + +1. Validates that the parameter is a valid email address using the `validator` library +2. Attempts to find a user with that email address using `userManager.getUserByEmail()` +3. If a user is found, redirects (HTTP 301) to the same URL with the user ID instead of the email +4. If no user is found or the parameter is not an email, continues with the original logic + +## Supported Routes + +All pub routes that use `targetUserId` or `ownerUserId` parameters now support email redirection: + +- `GET /pub/:targetUserId/contexts/:contextId` - Get context +- `POST /pub/:ownerUserId/contexts/:contextId/shares` - Share context +- `DELETE /pub/:ownerUserId/contexts/:contextId/shares/:sharedWithUserId` - Revoke share +- `GET /pub/:targetUserId/contexts/:contextId/documents` - List documents +- `POST /pub/:targetUserId/contexts/:contextId/documents` - Insert documents +- `PUT /pub/:targetUserId/contexts/:contextId/documents` - Update documents +- `DELETE /pub/:targetUserId/contexts/:contextId/documents/remove` - Remove documents +- `DELETE /pub/:targetUserId/contexts/:contextId/documents` - Delete documents +- `GET /pub/:targetUserId/contexts/:contextId/documents/by-id/:docId` - Get document by ID + +## Examples + +### Before (User ID required) +``` +GET /pub/abc123/contexts/myproject +``` + +### After (Email supported) +``` +GET /pub/user@example.com/contexts/myproject +# Redirects to: GET /pub/abc123/contexts/myproject +``` + +### Before (User ID required) +``` +POST /pub/def456/contexts/work/shares +``` + +### After (Email supported) +``` +POST /pub/admin@company.com/contexts/work/shares +# Redirects to: POST /pub/def456/contexts/work/shares +``` + +## Implementation Details + +### Helper Function + +The redirection logic is implemented in the `resolveUserIdFromEmail` helper function: + +```javascript +const resolveUserIdFromEmail = async (request, reply, targetUserId) => { + // Check if targetUserId looks like an email + if (!validator.isEmail(targetUserId)) { + return null; // Not an email, return null to continue with original targetUserId + } + + try { + // Try to find user by email + const user = await fastify.userManager.getUserByEmail(targetUserId); + if (user && user.id) { + // Redirect to the user ID-based URL + const originalUrl = request.url; + const newUrl = originalUrl.replace(`/${targetUserId}/`, `/${user.id}/`); + + fastify.log.info(`Redirecting email-based URL to user ID: ${originalUrl} -> ${newUrl}`); + return reply.redirect(301, newUrl); + } + } catch (error) { + // User not found by email, continue with original targetUserId + fastify.log.debug(`User not found by email: ${targetUserId}`); + } + + return null; // No redirect needed +}; +``` + +### Integration + +The helper function is called at the beginning of each route handler: + +```javascript +// Check if targetUserId is an email and redirect if needed +const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); +if (redirectResult) { + return redirectResult; // Redirect has been sent +} +``` + +## Benefits + +1. **User-Friendly URLs**: Users can share links using email addresses instead of cryptic user IDs +2. **Backward Compatibility**: Existing user ID-based URLs continue to work unchanged +3. **SEO Friendly**: Email-based URLs are more descriptive and memorable +4. **Automatic Resolution**: No manual lookup required - the system handles the conversion automatically + +## Error Handling + +- If an email is provided but no user is found, the request continues with the email as-is (which will likely result in a 404) +- If the email format is invalid, the request continues with the original parameter +- All existing error handling for invalid user IDs remains unchanged + +## Testing + +The functionality is tested in `tests/pub-email-redirect-test.js` which verifies: +- Email validation logic +- URL redirection construction +- Edge cases and error conditions diff --git a/docs/workspace-naming-convention.md b/docs/workspace-naming-convention.md new file mode 100644 index 00000000..faff2ce0 --- /dev/null +++ b/docs/workspace-naming-convention.md @@ -0,0 +1,214 @@ +# Workspace Naming Convention + +## Overview + +Canvas uses a structured naming convention for workspaces that supports both local and remote workspace references. This convention enables workspace portability, sharing, and future federation capabilities. + +## Format + +``` +[user.id]@[host]:[workspace_slug][/optional_path...] +``` + +### Components + +- **user.id**: User identifier (must be user.id, NOT user.email to avoid parsing ambiguity) +- **host**: Host identifier (e.g., `canvas.local`, `remote.server.com`) +- **workspace_slug**: Workspace name/slug (filesystem-safe identifier) +- **optional_path**: Optional path within the workspace for sub-contexts + +### Examples + +``` +user123@canvas.local:my-project +user456@canvas.local:shared-workspace +user789@remote.server.com:collaboration-space/subfolder +``` + +### Important: User ID Requirements + +**✅ Allowed**: `user123@canvas.local:my-project` +**❌ Not Allowed**: `user@domain.com@canvas.local:my-project` + +User identifiers in workspace references must be user.id values, not user.email addresses. This prevents parsing ambiguity since email addresses contain `@` symbols that would conflict with the reference format. + +## Default Host + +- **Local workspaces**: `canvas.local` +- **Remote workspaces**: Custom host names (e.g., `company.canvas.com`) + +## Internal Storage + +### Index Keys + +Workspaces are stored internally using a double-colon separator to avoid conflicts with Conf's nested object interpretation: + +``` +user123::CsEHlodqNfMp +user@domain.com::2RKU9lEFg93n +``` + +### Index Structure + +```javascript +{ + "user123::CsEHlodqNfMp": { + "id": "CsEHlodqNfMp", + "name": "my-project", + "owner": "user123", + "host": "canvas.local", + "reference": "user123@canvas.local:my-project", + "rootPath": "/path/to/workspace", + "configPath": "/path/to/workspace/workspace.json", + "status": "available", + // ... other properties + } +} +``` + +## API Methods + +### Workspace Manager Methods + +#### `parseWorkspaceReference(workspaceRef)` +Parses a workspace reference string into components. + +```javascript +const parsed = workspaceManager.parseWorkspaceReference('user123@canvas.local:my-project'); +// Returns: { +// userId: 'user123', +// host: 'canvas.local', +// workspaceSlug: 'my-project', +// path: '', +// full: 'user123@canvas.local:my-project', +// isLocal: true, +// isRemote: false +// } +``` + +#### `constructWorkspaceReference(userId, workspaceSlug, host, path)` +Constructs a workspace reference string from components. + +```javascript +const ref = workspaceManager.constructWorkspaceReference('user123', 'my-project'); +// Returns: 'user123@canvas.local:my-project' +``` + +#### `resolveWorkspaceId(userId, workspaceName, host)` +Resolves a workspace ID from user ID and workspace name. + +```javascript +const workspaceId = workspaceManager.resolveWorkspaceId('user123', 'my-project'); +// Returns: 'CsEHlodqNfMp' +``` + +#### `resolveWorkspaceIdFromReference(workspaceRef)` +Resolves a workspace ID from a full workspace reference. + +```javascript +const workspaceId = workspaceManager.resolveWorkspaceIdFromReference('user123@canvas.local:my-project'); +// Returns: 'CsEHlodqNfMp' +``` + +### Opening Workspaces + +The `openWorkspace` method supports multiple identifier formats: + +```javascript +// By workspace ID +const workspace = await workspaceManager.openWorkspace('user123', 'CsEHlodqNfMp', 'user123'); + +// By workspace name +const workspace = await workspaceManager.openWorkspace('user123', 'my-project', 'user123'); + +// By full reference +const workspace = await workspaceManager.openWorkspace('user123', 'user123@canvas.local:my-project', 'user123'); +``` + +## Migration from Legacy Format + +### Legacy Format +``` +user123/my-project (using / separator) +user123|my-project (using | separator) +``` + +### New Format +``` +user123@canvas.local:my-project +``` + +### Migration Process + +1. **Backward Compatibility**: The system maintains backward compatibility during transition +2. **Index Rebuilding**: Existing workspaces are automatically migrated to the new format +3. **Reference Generation**: Legacy workspaces get reference properties added automatically + +## Benefits + +### 1. **Unambiguous Parsing** +Using user.id instead of user.email eliminates parsing ambiguity: +``` +user123@canvas.local:my-project ✅ Clear parsing +user@company.com@canvas.local:my-project ❌ Ambiguous parsing +``` + +### 2. **Remote Workspace Support** +Enables future federation with remote Canvas instances: +``` +user123@remote.canvas.com:shared-project +``` + +### 3. **Path Support** +Supports sub-contexts within workspaces: +``` +user123@canvas.local:my-project/reports/2024 +``` + +### 4. **Portability** +Workspaces can be referenced consistently across different Canvas instances. + +### 5. **Conflict Resolution** +The `::` separator in internal storage avoids conflicts with Conf's nested object interpretation. + +## Implementation Details + +### Parser Logic + +The parser handles email addresses by: +1. Finding the last `@` before the first `:` +2. Splitting userid from host at that point +3. Parsing workspace slug and optional path after `:` + +### Index Management + +- **Primary Index**: `userId::workspaceId` → workspace data +- **Name Index**: `userId@host:workspaceName` → workspaceId +- **Reference Index**: Full reference → workspaceId + +### Storage Backend + +Uses Conf with `accessPropertiesByDotNotation: false` to ensure flat key storage without nested object interpretation. + +## Testing + +Run the comprehensive test suite: + +```bash +node tests/workspace-naming-convention-test.js +``` + +The test covers: +- Workspace creation with new naming convention +- Reference parsing and construction +- Workspace resolution by different identifiers +- Opening workspaces with multiple formats +- Index key format validation +- Workspace removal and cleanup + +## Future Enhancements + +1. **Federation Support**: Enable workspace sharing across Canvas instances +2. **DNS-based Discovery**: Resolve Canvas hosts via DNS +3. **Workspace Synchronization**: Sync workspaces between local and remote instances +4. **Access Control**: Fine-grained permissions for remote workspace access diff --git a/package.json b/package.json index 2206f962..2a2975db 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "start": "cross-env DEBUG=canvas* node ./src/init.js", "dev": "cross-env DEBUG=* NODE_ENV=development node ./src/init.js", "update-submodules": "git submodule update --init --remote && git submodule foreach \"git add .\"", - "build": "npm run build -w src/ui/web && npm run build -w src/ui/browser", - "postinstall": "npm run build -w src/ui/web && npm run build -w src/ui/browser", + "build": "npm run build -w src/ui/web && npm run build -w src/ui/browser-extension", + "postinstall": "npm run build -w src/ui/web && npm run build -w src/ui/browser-extension", "lint": "eslint \"**/*.{js,ts}\"", "lint:fix": "eslint \"**/*.{js,ts}\" --fix" }, @@ -18,7 +18,7 @@ ".", "src/ui/cli", "src/ui/web", - "src/ui/browser", + "src/ui/browser-extension", "src/ui/shell", "src/services/synapsd", "src/services/neurald", @@ -46,6 +46,7 @@ "fastmcp": "^2.1.2", "imapflow": "^1.0.187", "jsonwebtoken": "^9.0.2", + "nanoid": "^5.1.5", "node-machine-id": "^1.1.12", "pino-pretty": "^10.3.1", "randomcolor": "^0.6.2", diff --git a/src/Server.js b/src/Server.js index edeb06e1..2fb785c2 100644 --- a/src/Server.js +++ b/src/Server.js @@ -131,9 +131,15 @@ class Server extends EventEmitter { } async #initializeCoreServices() { + this.#userManager = new UserManager({ + rootPath: env.user.home, + indexStore: jim.createIndex('users'), + }); + this.#workspaceManager = new WorkspaceManager({ defaultRootPath: env.user.home, indexStore: jim.createIndex('workspaces'), + userManager: this.#userManager, }); this.#contextManager = new ContextManager({ @@ -141,17 +147,12 @@ class Server extends EventEmitter { workspaceManager: this.#workspaceManager }); - this.#userManager = new UserManager({ - rootPath: env.user.home, - indexStore: jim.createIndex('users'), - workspaceManager: this.#workspaceManager, - contextManager: this.#contextManager - }); + this.#userManager.setWorkspaceManager(this.#workspaceManager); + this.#userManager.setContextManager(this.#contextManager); - // Initialize managers - this.#workspaceManager = await this.#workspaceManager.initialize(); - this.#contextManager = await this.#contextManager.initialize(); - this.#userManager = await this.#userManager.initialize(); + await this.#userManager.initialize(); + await this.#workspaceManager.initialize(); + await this.#contextManager.initialize(); } async #createAdminUser() { @@ -183,7 +184,7 @@ class Server extends EventEmitter { // Create new admin user debug(`Creating new admin user ${adminEmail}`); user = await this.#userManager.createUser({ - id: adminEmail, + name: this.#generateUsernameFromEmail(adminEmail), // Generate proper username email: adminEmail, userType: 'admin', status: 'active' @@ -265,6 +266,38 @@ class Server extends EventEmitter { await this.start(); return this; } + + /** + * Generate a GitHub-style username from an email address + * @param {string} email - Email address + * @returns {string} - Valid username + * @private + */ + #generateUsernameFromEmail(email) { + // Extract the local part (before @) + let username = email.split('@')[0].toLowerCase(); + + // Remove special characters, keep only letters, numbers, dots, underscores, hyphens + username = username.replace(/[^a-z0-9._-]/g, ''); + + // Replace dots and underscores with hyphens for consistency + username = username.replace(/[._]/g, '-'); + + // Remove consecutive hyphens + username = username.replace(/-+/g, '-'); + + // Remove leading and trailing hyphens + username = username.replace(/^-+|-+$/g, ''); + + // Ensure maximum length + if (username.length > 32) { + username = username.substring(0, 32); + // Remove trailing hyphens if we cut in the middle + username = username.replace(/-+$/, ''); + } + + return username; + } } // Create server instance diff --git a/src/api/README.md b/src/api/README.md deleted file mode 100644 index e297e76a..00000000 --- a/src/api/README.md +++ /dev/null @@ -1,8 +0,0 @@ - - -$ curl -s https://fqdn/api/v1/hosts -$ curl -s https://fqdn/api/v1/hosts/server.domain.tld -$ curl -s https://fqdn/api/v1/hosts/server.domain.tld/packages -$ curl -s https://fqdn/api/v1/packages/yelp-xsl -$ curl -s https://fqdn/api/v1/packages/yelp-xsl/ -$ curl -s https://fqdn/api/v1/packages/yelp-xsl/3.36.0-1 \ No newline at end of file diff --git a/src/api/ResponseObject.js b/src/api/ResponseObject.js index 5aa0d47c..55964c15 100644 --- a/src/api/ResponseObject.js +++ b/src/api/ResponseObject.js @@ -1,3 +1,8 @@ +'use strict'; + +import crypto from 'crypto'; + +// This is a new file I'm creating to standardize our API responses. export default class ResponseObject { constructor() { this.status = 'error'; // Default to error to ensure explicit success setting @@ -48,10 +53,11 @@ export default class ResponseObject { } // Delete: Successful deletion of a resource - deleted(message = 'Resource deleted successfully', statusCode = 200, count = null) { + deleted(payload, message = 'Resource deleted successfully', statusCode = 200, count = null) { this.status = 'success'; this.statusCode = statusCode; this.message = message; + this.payload = payload; this.count = count; return this; } diff --git a/src/api/auth/imap-strategy.js b/src/api/auth/imap-strategy.js index 2db918f0..ae613c5c 100644 --- a/src/api/auth/imap-strategy.js +++ b/src/api/auth/imap-strategy.js @@ -30,10 +30,10 @@ class ImapAuthStrategy { if (this.#initialized) return; try { - // Load auth configuration + // Load auth configuration (created by AuthService if needed) const configPath = path.join(process.cwd(), 'server/config/auth.json'); if (!fs.existsSync(configPath)) { - throw new ImapConfigError('Auth configuration file not found'); + throw new ImapConfigError('Auth configuration file not found - AuthService should have created it'); } const configData = fs.readFileSync(configPath, 'utf8'); @@ -201,13 +201,13 @@ class ImapAuthStrategy { // Create new user const userData = { id: authResult.email, + name: this.#generateUsernameFromEmail(authResult.email), // Generate proper username email: authResult.email, userType: imapSettings.defaultUserType || 'user', status: imapSettings.defaultStatus || 'active', authMethod: 'imap', // imapDomain: authResult.domain, // Not used yet // imapServer: authResult.imapServer, // Not used yet - //name: authResult.email.split('@')[0], // Use email prefix as default name created: new Date().toISOString(), updated: new Date().toISOString() }; @@ -241,6 +241,61 @@ class ImapAuthStrategy { requireAppPassword: config.requireAppPassword || false })); } + + /** + * Generate a GitHub-style username from an email address + * @param {string} email - Email address + * @returns {string} - Valid username + * @private + */ + #generateUsernameFromEmail(email) { + // Extract the local part (before @) + let username = email.split('@')[0].toLowerCase(); + + // Remove special characters, keep only letters, numbers, dots, underscores, hyphens + username = username.replace(/[^a-z0-9._-]/g, ''); + + // Replace dots and underscores with hyphens for consistency + username = username.replace(/[._]/g, '-'); + + // Remove consecutive hyphens (safe from ReDoS) + while (username.includes('--')) { + username = username.replace(/--/g, '-'); + } + + // Remove leading and trailing hyphens (safe from ReDoS) + while (username.startsWith('-')) { + username = username.slice(1); + } + while (username.endsWith('-')) { + username = username.slice(0, -1); + } + + // Ensure minimum length + if (username.length < 3) { + username = username + '123'; + } + + // Ensure maximum length + if (username.length > 39) { + username = username.substring(0, 39); + // Remove trailing hyphens if we cut in the middle + username = username.replace(/-+$/, ''); + } + + // Check for reserved names and append number if needed + const reservedNames = [ + 'admin', 'administrator', 'root', 'system', 'support', 'help', + 'api', 'www', 'mail', 'ftp', 'localhost', 'test', 'demo', + 'canvas', 'universe', 'workspace', 'context', 'user', 'users' + ]; + + if (reservedNames.includes(username)) { + username = username + '1'; + } + + return username; + } } // Export singleton instance diff --git a/src/api/auth/service.js b/src/api/auth/service.js index 773dafda..b74617bb 100644 --- a/src/api/auth/service.js +++ b/src/api/auth/service.js @@ -4,6 +4,8 @@ import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import fs from 'fs'; +import path from 'path'; import { env } from '../../env.js'; // Import jim from Server.js @@ -31,12 +33,63 @@ class AuthService { // Init happens in initialize() to ensure env is loaded } + /** + * Ensure auth configuration exists with default values + * @private + */ + #ensureAuthConfig() { + const configPath = path.join(process.cwd(), 'server/config/auth.json'); // TODO: Move to a common config module + + // Create default auth configuration if it doesn't exist + if (!fs.existsSync(configPath)) { + console.log('[AuthService] Auth configuration file not found, creating default configuration...'); + + // Ensure the config directory exists + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + // Create default configuration with all supported strategies + const defaultConfig = { + strategies: { + local: { + enabled: true + }, + imap: { + enabled: false, + domains: { + "acmedomain.tld": { + "host": "mail.acmedomain.tld", + "port": "465", + "secure": true + } + }, + defaultUserType: 'user', + defaultStatus: 'active' + } + // Future strategies like OAuth can be added here + // oauth: { + // enabled: false, + // providers: {} + // } + } + }; + + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf8'); + console.log('[AuthService] Default auth configuration created at:', configPath); + } + } + /** * Initialize the auth service */ async initialize() { if (this.#initialized) return; + // Ensure auth configuration exists with defaults + this.#ensureAuthConfig(); + // Initialize storage for tokens and passwords using jim this.#tokensStore = jim.createIndex('tokens'); this.#passwordsStore = jim.createIndex('passwords'); @@ -472,6 +525,7 @@ class AuthService { valid: true, user: { id: user.id, + name: user.name || user.email, email: user.email, userType: user.userType || 'user', status: user.status || 'active' @@ -513,6 +567,7 @@ class AuthService { valid: true, user: { id: user.id, + name: user.name || user.email, email: user.email, userType: user.userType || 'user', status: user.status || 'active' diff --git a/src/api/auth/strategies.js b/src/api/auth/strategies.js index 9463d7a4..d01dd098 100644 --- a/src/api/auth/strategies.js +++ b/src/api/auth/strategies.js @@ -22,7 +22,7 @@ const UserValidationError = createError('ERR_USER_VALIDATION', 'User validation * @returns {Object} - Validated user object * @throws {Error} - If validation fails */ -export function validateUser(user, requiredProps = ['id', 'email']) { +export function validateUser(user, requiredProps = ['id', 'name', 'email']) { // Check if user exists if (!user) { throw new UserValidationError('User not found'); @@ -84,110 +84,111 @@ export function validateUser(user, requiredProps = ['id', 'email']) { * @param {Object} request - Fastify request object * @param {Object} reply - Fastify reply object */ -export async function verifyJWT(request, reply, done) { - try { - const authHeader = request.headers.authorization; - console.log(`[Auth/JWT] Authorization header: ${authHeader ? 'present' : 'missing'}`); - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.log('[Auth/JWT] No Bearer token or invalid format'); - return done(new InvalidTokenError('Bearer token required')); - } +export async function verifyJWT(request, reply) { + // Check for token in Authorization header + if (!request.headers.authorization || !request.headers.authorization.startsWith('Bearer ')) { + console.log('[Auth/JWT] No Bearer token or invalid format'); + const error = new Error('Bearer token required'); + error.statusCode = 401; + throw error; + } - const token = authHeader.split(' ')[1]; - if (!token) { - console.log('[Auth/JWT] Empty token'); - return done(new InvalidTokenError('Token is empty')); - } + const token = request.headers.authorization.split(' ')[1]; + if (!token) { + console.log('[Auth/JWT] Empty token'); + const error = new Error('Token is empty'); + error.statusCode = 401; + throw error; + } - // Skip JWT verification if this is clearly an API token (starts with canvas-) - // Let the API token strategy handle it - if (token.startsWith('canvas-')) { - console.log('[Auth/JWT] Detected API token in Authorization header, skipping JWT verification'); - return done(new InvalidTokenError('Not a JWT token')); - } + // Only process JWT tokens, not API tokens + if (token.startsWith('canvas-')) { + console.log(`[Auth/JWT] Not a JWT token (has canvas- prefix): ${token.substring(0, 10)}...`); + const error = new Error('Not a JWT token'); + error.statusCode = 401; + throw error; + } - console.log('[Auth/JWT] Verifying JWT token...'); + console.log(`[Auth/JWT] Verifying JWT token: ${token.substring(0, 10)}...`); - // For debugging, let's try to decode without verification first - try { - const decoded = request.server.jwt.decode(token); - console.log(`[Auth/JWT] Token decoded (not verified): ${JSON.stringify(decoded)}`); - } catch (decodeErr) { - console.log(`[Auth/JWT] Token decode failed: ${decodeErr.message}`); - } + let decoded; + try { + decoded = await request.jwtVerify(); // This returns the payload + console.log(`[Auth/JWT] JWT verified, subject: ${decoded.sub}`); + } catch (jwtError) { + console.error(`[Auth/JWT] JWT verification failed: ${jwtError.message}`); + const error = new Error(`JWT verification failed: ${jwtError.message}`); + error.statusCode = 401; + throw error; + } - // Proceed with actual JWT verification - let decoded; - try { - decoded = await request.jwtVerify(); // This returns the payload - // if (request.user && request.user.sub && !request.user.id) { // Check if it looks like raw JWT payload - // console.log('[Auth/JWT] Deleting request.user potentially set by jwtVerify default'); - // delete request.user; - // } - console.log(`[Auth/JWT] JWT verified, subject: ${decoded.sub}`); - } catch (jwtError) { - console.error(`[Auth/JWT] JWT verification failed: ${jwtError.message}`); - return done(new InvalidTokenError(`JWT verification failed: ${jwtError.message}`)); - } + const userManager = request.server.userManager; + if (!userManager) { + console.error('[Auth/JWT] userManager not available on server'); + const error = new Error('User manager not initialized'); + error.statusCode = 500; + throw error; + } - const userManager = request.server.userManager; - if (!userManager) { - console.error('[Auth/JWT] userManager not available on server'); - return done(new Error('User manager not initialized')); - } + console.log(`[Auth/JWT] Getting user by ID: ${decoded.sub}`); + let user; + try { + user = await userManager.getUserById(decoded.sub); + console.log(`[Auth/JWT] User retrieved: ${!!user}, ID: ${user ? user.id : 'null'}`); + } catch (userError) { + console.error(`[Auth/JWT] Error retrieving user: ${userError.message}`); + + // Handle the specific case where user exists in token but not in database + if (userError.message.includes('User not found in index')) { + console.warn(`[Auth/JWT] User ${decoded.sub} has valid JWT token but missing from database`); + const error = new Error('Your session is invalid. Please log in again.'); + error.statusCode = 401; + throw error; + } + + const error = new Error(`Error retrieving user: ${userError.message}`); + error.statusCode = 401; + throw error; + } - console.log(`[Auth/JWT] Getting user by ID: ${decoded.sub}`); - let user; - try { - user = await userManager.getUserById(decoded.sub); - console.log(`[Auth/JWT] User retrieved: ${!!user}, ID: ${user ? user.id : 'null'}`); - } catch (userError) { - console.error(`[Auth/JWT] Error retrieving user: ${userError.message}`); - return done(new Error(`Error retrieving user: ${userError.message}`)); - } + if (!user) { + console.error(`[Auth/JWT] User not found: ${decoded.sub}`); + const error = new Error(`User not found: ${decoded.sub}`); + error.statusCode = 401; + throw error; + } - if (!user) { - console.error(`[Auth/JWT] User not found: ${decoded.sub}`); - return done(new UserValidationError(`User not found: ${decoded.sub}`)); - } + // Validate user status directly without modifying any properties + if (user.status !== 'active') { + console.log(`[Auth/JWT] User ${user.id} not active (${user.status})`); + const error = new Error('User account is not active'); + error.statusCode = 401; + throw error; + } - // Validate user status directly without modifying any properties - if (user.status !== 'active') { - console.log(`[Auth/JWT] User ${user.id} not active (${user.status})`); - return done(new InvalidTokenError('User account is not active')); + // Only check version if both token and user have versions + if (decoded.ver && (user.updatedAt || user.createdAt)) { + const userVersion = user.updatedAt || user.createdAt; + if (decoded.ver !== userVersion) { + console.log(`[Auth/JWT] Token version mismatch: ${decoded.ver} vs ${userVersion}`); + const error = new Error('Token is invalid - user data has changed'); + error.statusCode = 401; + throw error; } + } - // Only check version if both token and user have versions - if (decoded.ver && (user.updatedAt || user.createdAt)) { - const userVersion = user.updatedAt || user.createdAt; - if (decoded.ver !== userVersion) { - console.log(`[Auth/JWT] Token version mismatch: ${decoded.ver} vs ${userVersion}`); - return done(new InvalidTokenError('Token is invalid - user data has changed')); - } - } + // Create a simplified user object that contains only the essential properties + const essentialUserData = { + id: user.id, + name: user.name || user.email, + email: user.email ? user.email.toLowerCase() : null, + userType: user.userType || 'user', + status: user.status || 'active' + }; - // Create a simplified user object that contains only the essential properties - const essentialUserData = { - id: user.id, - email: user.email ? user.email.toLowerCase() : null, - userType: user.userType || 'user', - status: user.status || 'active' - }; - - console.log(`[Auth/JWT] Preparing to authenticate user: ${essentialUserData.id}`); - request.user = essentialUserData; // Set request.user directly - console.log(`[Auth/JWT] User ${essentialUserData.id} authenticated via JWT (set on request.user)`); - return done(); // Call done() on success - } catch (err) { - console.log(`[Auth/JWT] JWT verification failed: ${err.message}`, err); - // For auth errors, pass them along - if (err.code === 'ERR_INVALID_TOKEN' || err.code === 'ERR_USER_VALIDATION') { - return done(err); // Pass existing custom error - } - // For other errors (e.g., jwtVerify throwing its own specific errors, or unexpected ones) - return done(new InvalidTokenError(err.message || 'JWT authentication failed')); - } + console.log(`[Auth/JWT] Preparing to authenticate user: ${essentialUserData.id}`); + request.user = essentialUserData; // Set request.user directly + console.log(`[Auth/JWT] User ${essentialUserData.id} authenticated via JWT (set on request.user)`); } /** @@ -195,93 +196,121 @@ export async function verifyJWT(request, reply, done) { * @param {Object} request - Fastify request object * @param {Object} reply - Fastify reply object */ -export async function verifyApiToken(request, reply, done) { - try { - // Check for token in Authorization header - if (!request.headers.authorization || !request.headers.authorization.startsWith('Bearer ')) { - console.log('[Auth/API] No Bearer token or invalid format'); - return done(new InvalidTokenError('Bearer token required')); - } +export async function verifyApiToken(request, reply) { + // Check for token in Authorization header + if (!request.headers.authorization || !request.headers.authorization.startsWith('Bearer ')) { + console.log('[Auth/API] No Bearer token or invalid format'); + const error = new Error('Bearer token required'); + error.statusCode = 401; + throw error; + } - const token = request.headers.authorization.split(' ')[1]; - if (!token) { - console.log('[Auth/API] Empty token'); - return done(new InvalidTokenError('Token is empty')); - } + const token = request.headers.authorization.split(' ')[1]; + if (!token) { + console.log('[Auth/API] Empty token'); + const error = new Error('Token is empty'); + error.statusCode = 401; + throw error; + } - // Only process tokens with the "canvas-" prefix, which identifies it as an API token - if (!token.startsWith('canvas-')) { - console.log(`[Auth/API] Not an API token (missing canvas- prefix): ${token.substring(0, 10)}...`); - return done(new InvalidTokenError('Not a valid API token')); - } + // Only process tokens with the "canvas-" prefix, which identifies it as an API token + if (!token.startsWith('canvas-')) { + console.log(`[Auth/API] Not an API token (missing canvas- prefix): ${token.substring(0, 10)}...`); + const error = new Error('Not an API token'); + error.statusCode = 401; + throw error; + } - console.log(`[Auth/API] Verifying API token: ${token.substring(0, 10)}...`); + console.log(`[Auth/API] Verifying API token: ${token.substring(0, 10)}...`); + console.log(`[Auth/API] Full token: ${token}`); + console.log(`[Auth/API] Server has authService: ${!!request.server.authService}`); - let tokenResult; - try { - tokenResult = await request.server.authService.verifyApiToken(token); - console.log(`[Auth/API] Token verification result: ${JSON.stringify(tokenResult)}`); - } catch (tokenError) { - console.error(`[Auth/API] Token verification error: ${tokenError.message}`); - return done(new InvalidTokenError(`API token verification failed: ${tokenError.message}`)); - } + let tokenResult; + try { + tokenResult = await request.server.authService.verifyApiToken(token); + console.log(`[Auth/API] Token verification result: ${JSON.stringify(tokenResult)}`); + } catch (tokenError) { + console.error(`[Auth/API] Token verification error: ${tokenError.message}`); + console.error(`[Auth/API] Token verification error stack: ${tokenError.stack}`); + const error = new Error(`API token verification failed: ${tokenError.message}`); + error.statusCode = 401; + throw error; + } - if (!tokenResult) { - console.error('[Auth/API] Invalid API token - verification returned null'); - return done(new InvalidTokenError('Invalid API token')); - } + if (!tokenResult) { + console.error('[Auth/API] Invalid API token - verification returned null'); + const error = new Error('Invalid API token'); + error.statusCode = 401; + throw error; + } - // Load user from UserManager - const userManager = request.server.userManager; - if (!userManager) { - console.error('[Auth/API] userManager not available on server'); - return done(new Error('User manager not initialized')); - } + // Load user from UserManager + const userManager = request.server.userManager; + if (!userManager) { + console.error('[Auth/API] userManager not available on server'); + const error = new Error('User manager not initialized'); + error.statusCode = 500; + throw error; + } - console.log(`[Auth/API] Getting user with ID: ${tokenResult.userId}`); - let user; - try { - user = await userManager.getUserById(tokenResult.userId); - console.log(`[Auth/API] User found: ${!!user}, user ID: ${user ? user.id : 'null'}`); - } catch (userError) { - console.error(`[Auth/API] Error retrieving user: ${userError.message}`); - return done(new Error(`Error retrieving user: ${userError.message}`)); - } + console.log(`[Auth/API] Getting user with ID: ${tokenResult.userId}`); + let user; + try { + user = await userManager.getUserById(tokenResult.userId); + console.log(`[Auth/API] User found: ${!!user}, user ID: ${user ? user.id : 'null'}`); + } catch (userError) { + console.error(`[Auth/API] Error retrieving user: ${userError.message}`); + + // Handle the specific case where user exists in token but not in database + if (userError.message.includes('User not found in index')) { + console.warn(`[Auth/API] User ${tokenResult.userId} has valid API token but missing from database`); + const error = new Error('Your session is invalid. Please log in again.'); + error.statusCode = 401; + throw error; + } + + const error = new Error(`Error retrieving user: ${userError.message}`); + error.statusCode = 401; + throw error; + } - if (!user) { - console.error(`[Auth/API] User not found for token userId: ${tokenResult.userId}`); - return done(new UserValidationError('User not found for this API token')); - } + if (!user) { + console.error(`[Auth/API] User not found for token userId: ${tokenResult.userId}`); + const error = new Error('User not found for this API token'); + error.statusCode = 401; + throw error; + } - // Check required properties without modifying the object - if (!user.id || !user.email) { - console.error(`[Auth/API] User missing required properties`); - return done(new UserValidationError('User missing required properties: id or email')); - } + // Check required properties without modifying the object + if (!user.id || !user.email) { + console.error(`[Auth/API] User missing required properties`); + const error = new Error('User missing required properties: id or email'); + error.statusCode = 401; + throw error; + } - if (user.status !== 'active') { - console.error(`[Auth/API] User account not active: ${user.status}`); - return done(new InvalidTokenError('User account is not active')); - } + // Validate user status directly without modifying any properties + if (user.status !== 'active') { + console.error(`[Auth/API] User account not active: ${user.status}`); + const error = new Error('User account is not active'); + error.statusCode = 401; + throw error; + } - // Create a simplified user object with essential properties - const essentialUserData = { - id: user.id, - email: user.email ? user.email.toLowerCase() : null, - userType: user.userType || 'user', - status: user.status || 'active' - }; + // Create a simplified user object with essential properties + const essentialUserData = { + id: user.id, + name: user.name || user.email, + email: user.email ? user.email.toLowerCase() : null, + userType: user.userType || 'user', + status: user.status || 'active' + }; - console.log(`[Auth/API] Preparing to authenticate user: ${essentialUserData.id}`); - request.user = essentialUserData; // Set request.user directly - request.token = tokenResult.tokenId; // Keep this for API token specific logic if any + console.log(`[Auth/API] Preparing to authenticate user: ${essentialUserData.id}`); + request.user = essentialUserData; // Set request.user directly + request.token = tokenResult.tokenId; // Keep this for API token specific logic if any - console.log(`[Auth/API] Authentication successful for user ${essentialUserData.id}`); - return done(); // Call done() on success - } catch (err) { - console.error(`[Auth/API] API token verification failed: ${err.message}`, err); - return done(new InvalidTokenError(err.message)); - } + console.log(`[Auth/API] Authentication successful for user ${essentialUserData.id}`); } /** @@ -408,6 +437,7 @@ export async function login(email, password, userManager, strategy = 'auto') { export async function register(userData, userManager) { // Create user const user = await userManager.createUser({ + name: userData.name, email: userData.email, // firstName: userData.firstName, // lastName: userData.lastName, @@ -426,6 +456,7 @@ export async function register(userData, userManager) { data: { user: { id: user.id, + name: user.name, email: user.email }, token: verificationToken // This is the email verification token diff --git a/src/api/index.js b/src/api/index.js index 4887a58b..5f3e727c 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -23,9 +23,10 @@ import { import authRoutes from './routes/auth.js'; import workspaceRoutes from './routes/workspaces/index.js'; import contextRoutes from './routes/contexts/index.js'; -import userRoutes from './routes/users/index.js'; +import pubRoutes from './routes/pub/index.js'; import pingRoute from './routes/ping.js'; import schemaRoutes from './routes/schemas.js'; +import adminRoutes from './routes/admin/index.js'; import { mcpPlugin } from './mcp/index.js'; // WebSocket handlers @@ -67,7 +68,7 @@ export async function createServer(options = {}) { // Register fastify-jwt FIRST - needed for request.jwtVerify await server.register(fastifyJwt, { - secret: env.auth.jwtSecret || 'change-this-secret-in-production', + secret: env.auth.jwtSecret, sign: { expiresIn: '1d' }, @@ -75,8 +76,56 @@ export async function createServer(options = {}) { decorateRequest: false }); - // Remove fastify-auth registration if ONLY used for the main authenticate hook - // await server.register(fastifyAuth); + // Decorate server with our custom verification strategies + server.decorate('verifyJWT', verifyJWT); + server.decorate('verifyApiToken', verifyApiToken); + + // Register fastify-auth, which will allow us to chain strategies + await server.register(fastifyAuth); + + // Define the 'authenticate' decorator using the chained strategies. + // @fastify/auth will try them in order until one succeeds. + server.decorate('authenticate', server.auth([ + server.verifyJWT, + server.verifyApiToken + ], { relation: 'or' })); + + // Create a custom authentication decorator that handles errors properly + server.decorate('authenticateCustom', async (request, reply) => { + try { + // Try JWT first + await server.verifyJWT(request, reply); + return; // Success + } catch (jwtError) { + console.log(`[Auth/Custom] JWT failed: ${jwtError.message}`); + + try { + // Try API token if JWT fails + await server.verifyApiToken(request, reply); + return; // Success + } catch (apiError) { + console.log(`[Auth/Custom] API token failed: ${apiError.message}`); + + // Both failed - send error response and close connection + const statusCode = apiError.statusCode || 401; + reply.header('Connection', 'close'); + + const response = new ResponseObject(); + response.error(apiError.message || 'Authentication failed', [apiError], statusCode); + reply.code(statusCode).send(response.getResponse()); + + // Force close the connection after sending the response + setImmediate(() => { + console.log('Forcing connection close after authentication failure'); + if (reply.raw.socket && !reply.raw.socket.destroyed) { + reply.raw.socket.end(); + } + }); + + return; // Don't throw - we've handled the error + } + } + }); // Make managers available if (options.userManager) server.decorate('userManager', options.userManager); @@ -94,6 +143,33 @@ export async function createServer(options = {}) { maxAge: 86400 // 24 hours }); + // Add security headers including CSP for browser extension compatibility + server.addHook('onSend', async (request, reply, payload) => { + // Set CSP headers that are compatible with browser extensions and WebSocket connections + const cspDirectives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Allow inline scripts for socket.io + "style-src 'self' 'unsafe-inline'", // Allow inline styles + "connect-src 'self' ws: wss: http: https:", // Allow WebSocket and HTTP connections + "img-src 'self' data: blob:", // Allow images from various sources + "font-src 'self' data:", // Allow fonts + "frame-src 'self'", // Allow frames from same origin + "worker-src 'self' blob:", // Allow web workers + "object-src 'none'", // Disable object/embed elements + "base-uri 'self'" // Restrict base tag + ].join('; '); + + reply.header('Content-Security-Policy', cspDirectives); + + // Additional security headers + reply.header('X-Frame-Options', 'SAMEORIGIN'); + reply.header('X-Content-Type-Options', 'nosniff'); + reply.header('X-XSS-Protection', '1; mode=block'); + reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); + + return payload; + }); + await server.register(fastifyMultipart, { limits: { fileSize: options.maxFileSize || 10485760 // 10MB default @@ -154,152 +230,16 @@ export async function createServer(options = {}) { prefix: '/', }); - // Define MANUAL authentication decorator - server.decorate('authenticate', async function(request, reply) { - try { - console.log(`[Auth Manual] Processing auth for ${request.method} ${request.url}`); - const authHeader = request.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - console.log('[Auth Manual] No Bearer token or invalid format'); - throw new AuthError('Bearer token required'); - } - - const token = authHeader.split(' ')[1]; - if (!token) { - console.log('[Auth Manual] Empty token'); - throw new AuthError('Token is empty'); - } - - let user = null; - - // Check for API token first - if (token.startsWith('canvas-')) { - console.log(`[Auth Manual/API] Verifying API token: ${token.substring(0, 10)}...`); - const authService = request.server.authService; - const userManager = request.server.userManager; - if (!authService || !userManager) throw new Error('Auth service or user manager not initialized'); - - let tokenResult; - try { - tokenResult = await authService.verifyApiToken(token); - } catch (tokenError) { - console.error(`[Auth Manual/API] Token verification error: ${tokenError.message}`); - throw new AuthError(`API token verification failed: ${tokenError.message}`); - } - - if (!tokenResult) { - console.error('[Auth Manual/API] Invalid API token - verification returned null'); - throw new AuthError('Invalid API token'); - } - - let dbUser; - try { - dbUser = await userManager.getUserById(tokenResult.userId); - } catch (userError) { - console.error(`[Auth Manual/API] Error retrieving user: ${userError.message}`); - throw new Error(`Error retrieving user: ${userError.message}`); // Internal error potentially - } - - if (!dbUser) { - console.error(`[Auth Manual/API] User not found for token userId: ${tokenResult.userId}`); - throw new AuthError('User not found for this API token'); - } - if (dbUser.status !== 'active') { - console.error(`[Auth Manual/API] User account not active: ${dbUser.status}`); - throw new AuthError('User account is not active'); - } - - user = { - id: dbUser.id, - email: dbUser.email ? dbUser.email.toLowerCase() : null, - userType: dbUser.userType || 'user', - status: dbUser.status || 'active' - }; - request.isApiTokenAuth = true; // Keep flag if needed elsewhere - request.token = tokenResult.tokenId; - console.log(`[Auth Manual/API] Authentication successful for user: ${user.id}`); - - } else { - // Assume JWT - console.log('[Auth Manual/JWT] Verifying JWT token...'); - const userManager = request.server.userManager; - if (!userManager) throw new Error('User manager not initialized'); - - let decoded; - try { - // request.jwtVerify() is available from @fastify/jwt - decoded = await request.jwtVerify(); - } catch (jwtError) { - console.error(`[Auth Manual/JWT] JWT verification failed: ${jwtError.message}`); - throw new AuthError(`JWT verification failed: ${jwtError.message}`); - } - - let dbUser; - try { - dbUser = await userManager.getUserById(decoded.sub); - } catch (userError) { - console.error(`[Auth Manual/JWT] Error retrieving user: ${userError.message}`); - throw new Error(`Error retrieving user: ${userError.message}`); // Internal error potentially - } - - if (!dbUser) { - console.error(`[Auth Manual/JWT] User not found: ${decoded.sub}`); - throw new AuthError(`User not found: ${decoded.sub}`); - } - if (dbUser.status !== 'active') { - console.log(`[Auth Manual/JWT] User ${dbUser.id} not active (${dbUser.status})`); - throw new AuthError('User account is not active'); - } - // Optional: Check token version if needed (logic from verifyJWT) - if (decoded.ver && (dbUser.updated || dbUser.created)) { - const userVersion = dbUser.updated || dbUser.created; - if (decoded.ver !== userVersion) { - console.log(`[Auth Manual/JWT] Token version mismatch: ${decoded.ver} vs ${userVersion}`); - throw new AuthError('Token is invalid - user data has changed'); - } - } - - user = { - id: dbUser.id, - email: dbUser.email ? dbUser.email.toLowerCase() : null, - userType: dbUser.userType || 'user', - status: dbUser.status || 'active' - }; - request.isJwtAuth = true; // Keep flag if needed elsewhere - console.log(`[Auth Manual/JWT] Authentication successful for user: ${user.id}`); - } - - // If we successfully got a user object - if (user && user.id) { - request.user = user; // Set the user object on the request - } else { - // This case should ideally be caught by specific errors above - console.error('[Auth Manual] Auth succeeded but failed to produce valid user object.'); - throw new AuthError('Authentication succeeded but user data is invalid'); - } - - } catch (error) { - console.log(`[Auth Manual] Authentication failed: ${error.message}`); - if (!reply.sent) { - // Use status code from AuthError if available, otherwise default to 401 - const statusCode = error.statusCode || 401; - const response = new ResponseObject().error(error.message || 'Authentication required', null, statusCode); - reply.code(response.statusCode).send(response.getResponse()); - } - // Throw the error to stop the request lifecycle via Fastify's error handling - throw error; - } - }); - await authService.initialize(); + // Register routes server.register(pingRoute); server.register(authRoutes, { prefix: '/rest/v2/auth' }); server.register(workspaceRoutes, { prefix: '/rest/v2/workspaces' }); server.register(contextRoutes, { prefix: '/rest/v2/contexts' }); - server.register(userRoutes, { prefix: '/rest/v2/users' }); + server.register(pubRoutes, { prefix: '/rest/v2/pub' }); server.register(schemaRoutes, { prefix: '/rest/v2/schemas' }); + server.register(adminRoutes, { prefix: '/rest/v2/admin' }); server.register(mcpPlugin); // TODO: Draft/test only!!! // Global 404 handler @@ -315,20 +255,45 @@ export async function createServer(options = {}) { reply.sendFile('index.html'); }); - // Global error handler + // Global error handler server.setErrorHandler((error, request, reply) => { - server.log.error(error); + server.log.error('Global error handler called:', error); + console.log('Global error handler called:', error.message, 'statusCode:', error.statusCode); // Only send error response if a response hasn't been sent yet if (!reply.sent) { - // Send appropriate error response const statusCode = error.statusCode || 500; - const response = new ResponseObject().error( - error.message || 'Something went wrong', - null, - statusCode - ); - reply.code(response.statusCode).send(response.getResponse()); + + // For authentication errors (401), close the connection to prevent resource exhaustion + if (statusCode === 401) { + // Set Connection: close header to signal connection should be closed + reply.header('Connection', 'close'); + server.log.info('Authentication failed - closing connection'); + console.log('Authentication failed - closing connection'); + + // Create and send the error response + const response = new ResponseObject(); + response.error(error.message || 'Authentication failed', null, statusCode); + + // Send the response and close the connection immediately after + reply.code(statusCode).send(response.getResponse()); + + // Force close the connection after sending the response + setImmediate(() => { + server.log.info('Forcing connection close after authentication failure'); + console.log('Forcing connection close after authentication failure'); + if (reply.raw.socket && !reply.raw.socket.destroyed) { + reply.raw.socket.end(); + } + }); + } else { + // Use the generic error method from our ResponseObject for non-auth errors + const response = new ResponseObject(); + response.error(error.message || 'Something went wrong', [error], statusCode); + reply.code(response.statusCode).send(response.getResponse()); + } + } else { + console.log('Reply already sent - not handling error'); } }); diff --git a/src/api/middleware/address-resolver.js b/src/api/middleware/address-resolver.js new file mode 100644 index 00000000..f6d552f9 --- /dev/null +++ b/src/api/middleware/address-resolver.js @@ -0,0 +1,63 @@ +'use strict'; + +import ResponseObject from '../ResponseObject.js'; + +/** + * Create middleware to resolve resource addresses in URL parameters + * @param {string} resourceType - Type of resource ('workspace' or 'context') + * @returns {Function} Middleware function + */ +export function createAddressResolver(resourceType = 'workspace') { + return async function resolveAddress(request, reply) { + const addressParam = request.params.id; + + if (!addressParam) { + return; // No ID parameter to process + } + + // Check if it's a user/resource address (contains '/') + if (addressParam.includes('/')) { + let resolvedId = null; + + try { + if (resourceType === 'workspace') { + // Use WorkspaceManager to resolve simple identifier + resolvedId = await request.server.workspaceManager.resolveWorkspaceIdFromSimpleIdentifier(addressParam); + } else if (resourceType === 'context') { + // Use ContextManager to resolve simple identifier + resolvedId = await request.server.contextManager.resolveContextIdFromSimpleIdentifier(addressParam); + } + + if (resolvedId) { + // Store original address for potential use in response + request.originalAddress = addressParam; + // Replace the ID parameter with the resolved internal ID + request.params.id = resolvedId; + return; // Successfully resolved + } + + // Could not resolve the address + const response = new ResponseObject().notFound(`${resourceType} not found: ${addressParam}`); + return reply.code(response.statusCode).send(response.getResponse()); + + } catch (error) { + console.error(`Error resolving ${resourceType} address ${addressParam}:`, error.message); + const response = new ResponseObject().badRequest(`Invalid ${resourceType} address format: ${addressParam}`); + return reply.code(response.statusCode).send(response.getResponse()); + } + } + + // Not a user/resource address format, pass through as-is + // The existing route handlers will validate it as a direct ID + }; +} + +/** + * Middleware for resolving workspace addresses + */ +export const resolveWorkspaceAddress = createAddressResolver('workspace'); + +/** + * Middleware for resolving context addresses + */ +export const resolveContextAddress = createAddressResolver('context'); diff --git a/src/api/middleware/workspace-acl.js b/src/api/middleware/workspace-acl.js new file mode 100644 index 00000000..3ca8acb3 --- /dev/null +++ b/src/api/middleware/workspace-acl.js @@ -0,0 +1,328 @@ +'use strict'; + +import crypto from 'crypto'; +import { createDebug } from '../../utils/log/index.js'; +import ResponseObject from '../ResponseObject.js'; + +const debug = createDebug('canvas-server:middleware:workspace-acl'); + +/** + * Workspace ACL Validation Middleware + * + * This middleware validates workspace access supporting both JWT and API tokens: + * - JWT tokens (web UI): Only owner access allowed + * - Canvas API tokens: Owner access + token-based sharing access + * + * It works in conjunction with the existing Canvas authentication middleware. + * + * Token-based ACL format in workspace.json: + * { + * "acl": { + * "tokens": { + * "sha256:abc123...": { + * "permissions": ["read", "write"], + * "description": "Jane's laptop", + * "createdAt": "2024-01-01T00:00:00Z", + * "expiresAt": null + * } + * } + * } + * } + */ + +/** + * Create workspace access validation middleware + * @param {string} requiredPermission - Required permission ('read', 'write', 'admin') + * @returns {Function} Fastify middleware function + */ +export function createWorkspaceACLMiddleware(requiredPermission = 'read') { + return async function validateWorkspaceAccess(request, reply) { + try { + debug(`Validating workspace access for permission: ${requiredPermission}`); + + // 1. Extract workspace ID from route parameters + const workspaceId = request.params.id; + if (!workspaceId) { + throw new Error('Workspace ID required in route parameters'); + } + + // 2. Extract token from request (should already be validated by fastify.authenticate) + const authHeader = request.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + throw new Error('Bearer token required'); + } + + const token = authHeader.split(' ')[1]; + const isApiToken = token.startsWith('canvas-'); + const isJwtToken = !isApiToken; // Assume JWT if not API token + + // 3. Get user information from the request (set by authenticate middleware) + let userId; + + if (isJwtToken) { + // For JWT tokens (web UI), user info is in request.user + if (!request.user?.id) { + throw new Error('Invalid JWT token: no user information'); + } + userId = request.user.id; + debug(`Using JWT token for user: ${userId}`); + } else { + // For API tokens, verify through authService + let tokenResult; + try { + tokenResult = await request.server.authService.verifyApiToken(token); + if (!tokenResult) { + throw new Error('Invalid API token'); + } + userId = tokenResult.userId; + debug(`Using API token for user: ${userId}`); + } catch (error) { + debug(`API token verification failed: ${error.message}`); + throw new Error(`Token verification failed: ${error.message}`); + } + } + + // 4. Try owner access first (fastest path) + const workspace = await tryOwnerAccess( + request.server.workspaceManager, + userId, + workspaceId + ); + + if (workspace) { + debug(`Owner access granted for workspace ${workspaceId}`); + request.workspace = workspace; + request.workspaceAccess = { + permissions: ['read', 'write', 'admin'], + isOwner: true, + description: 'Workspace owner' + }; + return; // Continue to route handler + } + + // 5. Try token-based access (only for API tokens, not JWT tokens) + if (isApiToken) { + const tokenAccess = await tryTokenAccess( + request.server.workspaceManager, + workspaceId, + token, + requiredPermission + ); + + if (tokenAccess) { + debug(`Token access granted for workspace ${workspaceId}: ${tokenAccess.access.description}`); + request.workspace = tokenAccess.workspace; + request.workspaceAccess = { + ...tokenAccess.access, + isOwner: false + }; + return; // Continue to route handler + } + } + + // 6. Access denied + debug(`Access denied for workspace ${workspaceId}`); + if (isJwtToken) { + const response = new ResponseObject().forbidden( + `Access denied to workspace ${workspaceId}. You are not the owner of this workspace.` + ); + return reply.code(response.statusCode).send(response.getResponse()); + } else { + const response = new ResponseObject().forbidden( + `Access denied to workspace ${workspaceId}. Token lacks required permission: ${requiredPermission}` + ); + return reply.code(response.statusCode).send(response.getResponse()); + } + + } catch (error) { + debug(`Workspace ACL validation error: ${error.message}`); + const response = new ResponseObject().serverError(`Workspace access validation failed: ${error.message}`); + return reply.code(response.statusCode).send(response.getResponse()); + } + }; +} + +/** + * Try to access workspace as owner + * @param {WorkspaceManager} workspaceManager - Workspace manager instance + * @param {string} userId - User ID from token + * @param {string} workspaceIdentifier - Workspace ID or name + * @returns {Promise} Workspace instance if owner, null otherwise + */ +async function tryOwnerAccess(workspaceManager, userId, workspaceIdentifier) { + try { + // Check if identifier is a workspace ID (UUID format) or name + // Workspace IDs are UUIDs like 7c84589b-9268-45e8-9b7c-85c29adc9bca + const isWorkspaceId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(workspaceIdentifier); + + let workspace; + if (isWorkspaceId) { + // Try to get workspace by ID + workspace = await workspaceManager.getWorkspace(userId, workspaceIdentifier, userId); + } else { + // Try to get workspace by name + workspace = await workspaceManager.getWorkspaceByName(userId, workspaceIdentifier, userId); + } + + return workspace; + } catch (error) { + debug(`Owner access failed: ${error.message}`); + return null; + } +} + +/** + * Try to access workspace via token-based ACL + * @param {WorkspaceManager} workspaceManager - Workspace manager instance + * @param {string} workspaceIdentifier - Workspace ID or name + * @param {string} token - API token + * @param {string} requiredPermission - Required permission + * @returns {Promise} Access info if valid, null otherwise + */ +async function tryTokenAccess(workspaceManager, workspaceIdentifier, token, requiredPermission) { + try { + // Hash the token to match against ACL + const tokenHash = `sha256:${crypto.createHash('sha256').update(token).digest('hex')}`; + + // Find workspace with this token in ACL + const workspaceEntry = await findWorkspaceByTokenHash(workspaceManager, workspaceIdentifier, tokenHash); + if (!workspaceEntry) { + debug(`Token not found in any workspace ACL: ${tokenHash.substring(0, 16)}...`); + return null; + } + + // Validate token permissions and expiration + const tokenData = workspaceEntry.acl.tokens[tokenHash]; + + // Check expiration + if (tokenData.expiresAt && new Date() > new Date(tokenData.expiresAt)) { + debug(`Token has expired: ${tokenData.expiresAt}`); + return null; + } + + // Check permissions + if (!tokenData.permissions.includes(requiredPermission)) { + debug(`Token lacks required permission. Has: ${tokenData.permissions}, needs: ${requiredPermission}`); + return null; + } + + // Load the actual workspace instance for token access + const workspace = await loadWorkspaceForTokenAccess(workspaceManager, workspaceEntry); + if (!workspace) { + debug(`Failed to load workspace for token access: ${workspaceIdentifier}`); + return null; + } + + return { + workspace, + access: tokenData, + config: workspaceEntry + }; + + } catch (error) { + debug(`Token access validation error: ${error.message}`); + return null; + } +} + +/** + * Find workspace by searching for token hash in ACLs + * @param {WorkspaceManager} workspaceManager - Workspace manager instance + * @param {string} workspaceIdentifier - Workspace ID or name + * @param {string} tokenHash - Token hash to search for + * @returns {Promise} Workspace config if found, null otherwise + */ +async function findWorkspaceByTokenHash(workspaceManager, workspaceIdentifier, tokenHash) { + try { + // Check if identifier is a workspace ID (UUID format) or name + // Workspace IDs are UUIDs like 7c84589b-9268-45e8-9b7c-85c29adc9bca + const isWorkspaceId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(workspaceIdentifier); + + if (isWorkspaceId) { + // Direct lookup by workspace ID + const allWorkspaces = workspaceManager.getAllWorkspacesWithKeys(); + let workspaceEntry = null; + + // Search for workspace by ID across all users + for (const [indexKey, entry] of Object.entries(allWorkspaces)) { + const parsed = workspaceManager.constructor.prototype.constructor.parseWorkspaceIndexKey?.(indexKey) || + (() => { + const parts = indexKey.split('::'); + return parts.length === 2 ? { userId: parts[0], workspaceId: parts[1] } : null; + })(); + + if (parsed && parsed.workspaceId === workspaceIdentifier) { + workspaceEntry = entry; + break; + } + } + + if (workspaceEntry) { + // Check if token exists in this workspace's ACL + const tokens = workspaceEntry.acl?.tokens || {}; + if (tokens[tokenHash]) { + debug(`Found token in workspace ACL: ${workspaceIdentifier}`); + return workspaceEntry; + } + } + } else { + // Search through all workspaces for a matching name and token + const allWorkspaces = workspaceManager.getAllWorkspacesWithKeys(); + + for (const [indexKey, workspaceEntry] of Object.entries(allWorkspaces)) { + // Check if this workspace has the matching name and token + if (workspaceEntry.name === workspaceIdentifier) { + const tokens = workspaceEntry.acl?.tokens || {}; + if (tokens[tokenHash]) { + const parsed = workspaceManager.constructor.prototype.constructor.parseWorkspaceIndexKey?.(indexKey) || + (() => { + const parts = indexKey.split('::'); + return parts.length === 2 ? { userId: parts[0], workspaceId: parts[1] } : null; + })(); + debug(`Found token in workspace ACL: ${parsed?.workspaceId || 'unknown'} (name: ${workspaceIdentifier})`); + return workspaceEntry; + } + } + } + } + + debug(`Token not found in any workspace ACL: ${tokenHash.substring(0, 16)}...`); + return null; + + } catch (error) { + debug(`Error searching for token in workspace ACLs: ${error.message}`); + return null; + } +} + +/** + * Load workspace instance for token-based access + * @param {WorkspaceManager} workspaceManager - Workspace manager instance + * @param {Object} workspaceEntry - Workspace entry from index + * @returns {Promise} Workspace instance if successful, null otherwise + */ +async function loadWorkspaceForTokenAccess(workspaceManager, workspaceEntry) { + try { + // Load the workspace by ID (this bypasses owner check since we validated ACL) + const workspace = await workspaceManager.getWorkspaceById(workspaceEntry.id); + return workspace; + + } catch (error) { + debug(`Error loading workspace for token access: ${error.message}`); + return null; + } +} + +/** + * Convenience middleware factories for common permissions + */ +export const requireWorkspaceRead = () => createWorkspaceACLMiddleware('read'); +export const requireWorkspaceWrite = () => createWorkspaceACLMiddleware('write'); +export const requireWorkspaceAdmin = () => createWorkspaceACLMiddleware('admin'); + +export default { + createWorkspaceACLMiddleware, + requireWorkspaceRead, + requireWorkspaceWrite, + requireWorkspaceAdmin +}; diff --git a/src/api/routes/admin/index.js b/src/api/routes/admin/index.js new file mode 100644 index 00000000..503f3de7 --- /dev/null +++ b/src/api/routes/admin/index.js @@ -0,0 +1,333 @@ +'use strict'; + +import ResponseObject from '../../ResponseObject.js'; +import { validateUser } from '../../auth/strategies.js'; + +/** + * Admin routes handler for the API + * @param {FastifyInstance} fastify - Fastify instance + * @param {Object} options - Plugin options + */ +export default async function adminRoutes(fastify, options) { + + /** + * Middleware to check if user is admin + */ + const requireAdmin = async (request, reply) => { + if (!validateUser(request.user, ['id', 'email'])) { + const response = new ResponseObject().unauthorized('Valid authentication required'); + return reply.code(response.statusCode).send(response.getResponse()); + } + + try { + const user = await fastify.userManager.getUser(request.user.id); + if (!user.isAdmin()) { + const response = new ResponseObject().forbidden('Admin access required'); + return reply.code(response.statusCode).send(response.getResponse()); + } + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError('Failed to verify admin privileges'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }; + + // User Management Routes + + // List all users (admin only) + fastify.get('/users', { + onRequest: [fastify.authenticate, requireAdmin] + }, async (request, reply) => { + try { + const { status, userType } = request.query; + const users = await fastify.userManager.listUsers({ status, userType }); + + const response = new ResponseObject().found(users, 'Users retrieved successfully', 200, users.length); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError('Failed to list users'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Create user (admin only) + fastify.post('/users', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + body: { + type: 'object', + required: ['name', 'email'], + properties: { + name: { + type: 'string', + minLength: 3, + maxLength: 39, + pattern: '^[a-z0-9_-]+$', + description: 'Username (3-39 chars, lowercase letters, numbers, underscores, hyphens only)' + }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + userType: { type: 'string', enum: ['user', 'admin'], default: 'user' }, + status: { type: 'string', enum: ['active', 'inactive', 'pending', 'deleted'], default: 'active' } + } + } + } + }, async (request, reply) => { + try { + const { name, email, password, userType = 'user', status = 'active' } = request.body; + + // Create user + const user = await fastify.userManager.createUser({ + name, + email, + userType, + status + }); + + // Set password if provided + if (password) { + await fastify.authService.setPassword(user.id, password); + } + + const response = new ResponseObject().created({ + id: user.id, + name: user.name, + email: user.email, + userType: user.userType, + status: user.status, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }, 'User created successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError(error.message || 'Failed to create user'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Get user by ID (admin only) + fastify.get('/users/:userId', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + const user = await fastify.userManager.getUser(request.params.userId); + + const response = new ResponseObject().found({ + id: user.id, + name: user.name, + email: user.email, + userType: user.userType, + status: user.status, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }, 'User retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().notFound('User not found'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Update user (admin only) + fastify.put('/users/:userId', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 3, + maxLength: 39, + pattern: '^[a-z0-9_-]+$' + }, + email: { type: 'string', format: 'email' }, + userType: { type: 'string', enum: ['user', 'admin'] }, + status: { type: 'string', enum: ['active', 'inactive', 'pending', 'deleted'] } + } + } + } + }, async (request, reply) => { + try { + const user = await fastify.userManager.updateUser(request.params.userId, request.body); + + const response = new ResponseObject().success({ + id: user.id, + name: user.name, + email: user.email, + userType: user.userType, + status: user.status, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }, 'User updated successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError(error.message || 'Failed to update user'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Delete user (admin only) + fastify.delete('/users/:userId', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + params: { + type: 'object', + required: ['userId'], + properties: { + userId: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + // Prevent admin from deleting themselves + if (request.params.userId === request.user.id) { + const response = new ResponseObject().badRequest('Cannot delete your own account'); + return reply.code(response.statusCode).send(response.getResponse()); + } + + await fastify.userManager.deleteUser(request.params.userId); + + const response = new ResponseObject().success(true, 'User deleted successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError(error.message || 'Failed to delete user'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Workspace Management Routes + + // List all workspaces (admin only) + fastify.get('/workspaces', { + onRequest: [fastify.authenticate, requireAdmin] + }, async (request, reply) => { + try { + // Get all users first + const users = await fastify.userManager.listUsers(); + let allWorkspaces = []; + + // Get workspaces for each user + for (const user of users) { + try { + const userWorkspaces = await fastify.workspaceManager.listUserWorkspaces(user.id); + // Add owner info to each workspace + const workspacesWithOwner = userWorkspaces.map(ws => ({ + ...ws, + ownerName: user.name, + ownerEmail: user.email + })); + allWorkspaces = allWorkspaces.concat(workspacesWithOwner); + } catch (error) { + // Skip users that have workspace access issues + fastify.log.warn(`Failed to get workspaces for user ${user.id}: ${error.message}`); + } + } + + const response = new ResponseObject().found(allWorkspaces, 'All workspaces retrieved successfully', 200, allWorkspaces.length); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError('Failed to list all workspaces'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Create workspace for user (admin only) + fastify.post('/workspaces', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + body: { + type: 'object', + required: ['userId', 'name'], + properties: { + userId: { type: 'string' }, + name: { type: 'string' }, + label: { type: 'string' }, + description: { type: 'string' }, + color: { type: 'string', pattern: '^#[0-9A-Fa-f]{3,6}$' }, + type: { type: 'string', enum: ['workspace', 'universe'] }, + metadata: { type: 'object' } + } + } + } + }, async (request, reply) => { + try { + const { userId, name, label, description, color, type = 'workspace', metadata } = request.body; + + // Verify the user exists + await fastify.userManager.getUser(userId); + + const workspace = await fastify.workspaceManager.createWorkspace( + userId, + name, + { + owner: userId, + type, + label: label || name, + description: description || '', + color, + metadata + } + ); + + const response = new ResponseObject().created(workspace, 'Workspace created successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError(error.message || 'Failed to create workspace'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Delete workspace (admin only) + fastify.delete('/workspaces/:workspaceId', { + onRequest: [fastify.authenticate, requireAdmin], + schema: { + params: { + type: 'object', + required: ['workspaceId'], + properties: { + workspaceId: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + // Get workspace to verify it exists and get owner info + const workspace = await fastify.workspaceManager.getWorkspace(request.params.workspaceId); + + // Delete the workspace + await fastify.workspaceManager.deleteWorkspace(workspace.owner, request.params.workspaceId); + + const response = new ResponseObject().success(true, 'Workspace deleted successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + const response = new ResponseObject().serverError(error.message || 'Failed to delete workspace'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); +} diff --git a/src/api/routes/auth.js b/src/api/routes/auth.js index 6435fa17..4f4a7dcf 100644 --- a/src/api/routes/auth.js +++ b/src/api/routes/auth.js @@ -56,6 +56,21 @@ export default async function authRoutes(fastify, options) { } const { email, password, strategy = 'auto' } = request.body; + + // Ensure IMAP strategy is initialized before login attempt + if (strategy === 'imap' || strategy === 'auto') { + try { + await imapAuthStrategy.initialize(); + } catch (error) { + fastify.log.warn(`IMAP strategy initialization failed: ${error.message}. IMAP login may be unavailable.`); + // If strategy is explicitly 'imap', fail fast + if (strategy === 'imap') { + const response = new ResponseObject().serverError('IMAP authentication is not available.'); + return reply.code(response.statusCode).send(response.getResponse()); + } + } + } + const result = await login(email, password, fastify.userManager, strategy); // Generate JWT token @@ -65,8 +80,8 @@ export default async function authRoutes(fastify, options) { token, user: { id: result.user.id, - email: result.user.email, name: result.user.name || result.user.email, + email: result.user.email, authMethod: result.authMethod || 'local' } }, 'Login successful'); @@ -116,8 +131,15 @@ export default async function authRoutes(fastify, options) { schema: { body: { type: 'object', - required: ['email', 'password'], + required: ['name', 'email', 'password'], properties: { + name: { + type: 'string', + minLength: 3, + maxLength: 39, + pattern: '^[a-z0-9_-]+$', + description: 'Username (3-39 chars, lowercase letters, numbers, underscores, hyphens only)' + }, email: { type: 'string', format: 'email' }, password: { type: 'string', minLength: 8 } } @@ -135,13 +157,16 @@ export default async function authRoutes(fastify, options) { const response = new ResponseObject().created(result.data, 'Registration successful'); return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { - fastify.log.error(`[Register Route Error] ${error.message}`, error); - if (error.message && error.message.toLowerCase().includes('user already exists')) { - const responseObject = new ResponseObject().conflict(error.message); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + fastify.log.error('[Register Route Error]', error.message); + + // Provide specific error messages for username validation + if (error.message.includes('User name')) { + const response = new ResponseObject().badRequest(error.message); + return reply.code(response.statusCode).send(response.getResponse()); } - const responseObject = new ResponseObject().serverError(error.message || 'Registration failed due to an unexpected error'); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + const response = new ResponseObject().serverError('Registration failed'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -436,7 +461,7 @@ export default async function authRoutes(fastify, options) { // Get current user endpoint fastify.get('/me', { - onRequest: [fastify.authenticate] + onRequest: [fastify.authenticateCustom] }, async (request, reply) => { // Check if reply has already been sent by auth middleware or other mechanism @@ -514,13 +539,31 @@ export default async function authRoutes(fastify, options) { if (!userData) { fastify.log.error(`[Auth/Me] User not found in database: ${userId}`); - const response = new ResponseObject().notFound(`User not found: ${userId}`); + const response = new ResponseObject().unauthorized(`Authentication failed - user account no longer exists: ${userId}`); return reply.code(response.statusCode).send(response.getResponse()); } fastify.log.info(`[Auth/Me] Successfully retrieved user data: ${userData.id}`); } catch (dbError) { fastify.log.error(`[Auth/Me] Database error: ${dbError.message}`); + + // Handle the specific case where user exists in token but not in database + if (dbError.message.includes('User not found in index')) { + fastify.log.warn(`[Auth/Me] User ${userId} has valid token but missing from database - clearing authentication`); + + // Return a specific error that the frontend can handle + const response = new ResponseObject().unauthorized( + 'Your session is invalid. Please log in again.', + { + code: 'USER_NOT_FOUND_IN_DATABASE', + userId: userId, + action: 'logout' + } + ); + return reply.code(response.statusCode).send(response.getResponse()); + } + + // For other database errors, return a generic server error const response = new ResponseObject().serverError('Database error when retrieving user profile'); return reply.code(response.statusCode).send(response.getResponse()); } @@ -534,6 +577,7 @@ export default async function authRoutes(fastify, options) { // Return user profile const response = new ResponseObject().found({ id: userData.id, + name: userData.name || userData.email, email: userData.email, userType: userData.userType || 'user', status: userData.status || 'active' diff --git a/src/api/routes/contexts/documents.js b/src/api/routes/contexts/documents.js index 442a714f..96c436bc 100644 --- a/src/api/routes/contexts/documents.js +++ b/src/api/routes/contexts/documents.js @@ -2,25 +2,23 @@ import ResponseObject from '../../ResponseObject.js'; import { validateUser } from '../../auth/strategies.js'; +import { resolveContextAddress } from '../../middleware/address-resolver.js'; export default async function documentRoutes(fastify, options) { - // Helper functions (will be populated) - // const validateUser = (request) => { // Removed - // const user = request.user; - // if (!user || !user.id) { - // return false; - // } - // return true; - // }; - - const validateUserWithResponse = (request, reply) => { - if (!validateUser(request.user, ['id'])) { // Updated to use imported validateUser - const response = new ResponseObject().unauthorized('Valid authentication required'); - reply.code(response.statusCode).send(response.getResponse()); - return false; + + // Add a pre-handler hook to ensure user is authenticated and valid for all context document routes + fastify.addHook('preHandler', async (request, reply) => { + try { + // The `authenticate` hook should have already run and populated `request.user` + // We just need to validate it has the required fields for our operations. + validateUser(request.user, ['id']); // For context operations, we primarily need the user's ID. + } catch (err) { + // If validateUser throws, it means the user object is invalid. + const response = new ResponseObject().unauthorized(err.message); + return reply.code(response.statusCode).send(response.getResponse()); } - return true; - }; + }); + // Document routes will be added here @@ -47,10 +45,6 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } - // request.params.id will be available here due to the prefix const contextId = request.params.id; try { @@ -70,11 +64,11 @@ export default async function documentRoutes(fastify, options) { if (dbResult.error) { fastify.log.error(`SynapsD error in listDocuments: ${dbResult.error}`); - const response = new ResponseObject().serverError('Failed to list documents due to a database error.', dbResult.error); + const response = new ResponseObject().error('Failed to list documents due to a database error.', dbResult.error); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found(dbResult.data, 'Documents retrieved successfully', 200, dbResult.count); + const response = new ResponseObject().success(dbResult.data, 'Documents retrieved successfully', 200, dbResult.count); return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); @@ -82,7 +76,7 @@ export default async function documentRoutes(fastify, options) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to list documents'); + const response = new ResponseObject().error('Failed to list documents'); return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -113,9 +107,6 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } const contextId = request.params.id; try { @@ -131,7 +122,7 @@ export default async function documentRoutes(fastify, options) { const result = await context.insertDocumentArray(request.user.id, documentArray, featureArray); - const response = new ResponseObject().created(result, 'Documents inserted successfully', 201, result.length); + const response = new ResponseObject().created(result, 'Documents inserted successfully'); return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); @@ -139,7 +130,62 @@ export default async function documentRoutes(fastify, options) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to insert documents'); + const response = new ResponseObject().error('Failed to insert documents'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Batch insert documents into context + // Path: /batch (relative to /:id/documents) + fastify.post('/batch', { + onRequest: [fastify.authenticate], + schema: { + body: { + type: 'object', + required: ['documents'], + properties: { + featureArray: { + type: 'array', + items: { type: 'string' } + }, + documents: { + type: 'array', + items: { type: 'object' }, + minItems: 1 + } + } + } + } + }, async (request, reply) => { + const contextId = request.params.id; + + try { + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound('Context not found'); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const { featureArray = [], documents } = request.body; + + if (!Array.isArray(documents) || documents.length === 0) { + const response = new ResponseObject().badRequest('Documents must be a non-empty array'); + return reply.code(response.statusCode).send(response.getResponse()); + } + + console.log(`🔧 Batch insert: Processing ${documents.length} documents for context ${contextId}`); + + const result = await context.insertDocumentArray(request.user.id, documents, featureArray); + + const response = new ResponseObject().created(result, `${documents.length} documents inserted successfully`); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + if (error.message.startsWith('Access denied')) { + const response = new ResponseObject().forbidden(error.message); + return reply.code(response.statusCode).send(response.getResponse()); + } + const response = new ResponseObject().error('Failed to batch insert documents'); return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -170,9 +216,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { @@ -190,16 +234,16 @@ export default async function documentRoutes(fastify, options) { const result = await context.updateDocumentArray(request.user.id, documents, featureArray); - const response = new ResponseObject().success(result, 'Documents updated successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().updated(result, 'Documents updated successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to update documents'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to update documents'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -217,16 +261,13 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { const context = await fastify.contextManager.getContext(request.user.id, contextId); if (!context) { - const response = new ResponseObject().notFound(`Context with ID ${contextId} not found or user is not owner (required for direct DB deletion).`); - return reply.code(response.statusCode).send(response.getResponse()); + return response.notFound(`Context with ID ${contextId} not found or user is not owner (required for direct DB deletion).`); } // Validate that we have a body @@ -241,16 +282,16 @@ export default async function documentRoutes(fastify, options) { // Let the Context.deleteDocumentArrayFromDb method handle the ID validation and conversion const result = await context.deleteDocumentArrayFromDb(request.user.id, documentIdArray); - const response = new ResponseObject().success(result, 'Documents deleted from database successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().deleted(result, 'Documents deleted from database successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to delete documents from database'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to delete documents from database'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -268,9 +309,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { @@ -293,15 +332,15 @@ export default async function documentRoutes(fastify, options) { const result = await context.removeDocumentArray(request.user.id, documentIdArray); const response = new ResponseObject().success(result, 'Documents removed from context successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to remove documents from context'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to remove documents from context'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -320,9 +359,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; const docId = request.params.docId; @@ -340,16 +377,16 @@ export default async function documentRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found(document, 'Document retrieved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success(document, 'Document retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get document by ID'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get document by ID'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -380,9 +417,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; const abstraction = request.params.abstraction; @@ -405,20 +440,20 @@ export default async function documentRoutes(fastify, options) { if (dbResult.error) { fastify.log.error(`SynapsD error in listDocuments (by-abstraction): ${dbResult.error}`); - const response = new ResponseObject().serverError('Failed to list documents by abstraction due to a database error.', dbResult.error); + const response = new ResponseObject().error('Failed to list documents by abstraction due to a database error.', dbResult.error); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found(dbResult.data, 'Documents retrieved successfully by abstraction', 200, dbResult.count); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success({ documents: dbResult.data, count: dbResult.count }, 'Documents retrieved successfully by abstraction'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get documents by abstraction'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get documents by abstraction'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -436,9 +471,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; const docId = request.params.docId; @@ -456,7 +489,54 @@ export default async function documentRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found(document, 'Document retrieved successfully'); + const response = new ResponseObject().success(document, 'Document retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); + } catch (error) { + fastify.log.error(error); + if (error.message.startsWith('Access denied')) { + const response = new ResponseObject().forbidden(error.message); + return reply.code(response.statusCode).send(response.getResponse()); + } + const response = new ResponseObject().error('Failed to get document by ID'); + return reply.code(response.statusCode).send(response.getResponse()); + } + }); + + // Delete single document by ID + // Path: /:docId (relative to /:id/documents) + fastify.delete('/:docId', { + onRequest: [fastify.authenticate], + schema: { + params: { + type: 'object', + required: ['docId'], + properties: { + docId: { type: ['string', 'number'] } + } + } + } + }, async (request, reply) => { + const contextId = request.params.id; + const docId = request.params.docId; + + try { + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + // Convert docId to number if it's a string number + const documentId = parseInt(docId, 10); + if (isNaN(documentId)) { + const response = new ResponseObject().badRequest(`Invalid document ID: ${docId}. Must be a number.`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + // Delete the document from database (direct deletion) + const result = await context.deleteDocumentFromDb(request.user.id, documentId); + + const response = new ResponseObject().deleted(result, 'Document deleted from database successfully'); return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); @@ -464,7 +544,7 @@ export default async function documentRoutes(fastify, options) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get document by ID'); + const response = new ResponseObject().error('Failed to delete document from database'); return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -485,9 +565,7 @@ export default async function documentRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; const { algo, hash } = request.params; @@ -502,20 +580,18 @@ export default async function documentRoutes(fastify, options) { const document = await context.getDocumentByChecksumStringFromDb(request.user.id, checksumString); if (!document) { - const response = new ResponseObject().notFound(`Document with checksum '${checksumString}' not found via context '${contextId}' (owner access).`); - return reply.code(response.statusCode).send(response.getResponse()); + return response.notFound(`Document with checksum '${checksumString}' not found via context '${contextId}' (owner access).`); } - const response = new ResponseObject().found(document, 'Document retrieved successfully by hash (owner access)'); - return reply.code(response.statusCode).send(response.getResponse()); + return response.success(document, 'Document retrieved successfully by hash (owner access)'); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { const response = new ResponseObject().forbidden(error.message); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get document by hash'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get document by hash'); + return reply.code(response.statusCode).send(response.getResponse()); } }); } diff --git a/src/api/routes/contexts/index.js b/src/api/routes/contexts/index.js index 9e4722bc..419b47d6 100644 --- a/src/api/routes/contexts/index.js +++ b/src/api/routes/contexts/index.js @@ -3,6 +3,7 @@ import lifecycleRoutes from './lifecycle.js'; import documentRoutes from './documents.js'; import treeRoutes from './tree.js'; +import { resolveContextAddress } from '../../middleware/address-resolver.js'; /** * Context routes handler for the API @@ -11,8 +12,14 @@ import treeRoutes from './tree.js'; */ export default async function contextRoutes(fastify, options) { fastify.register(lifecycleRoutes, { prefix: '/' }); - fastify.register(documentRoutes, { prefix: '/:id/documents' }); - fastify.register(treeRoutes, { prefix: '/:id/tree' }); + fastify.register(documentRoutes, { + prefix: '/:id/documents', + onRequest: [resolveContextAddress] + }); + fastify.register(treeRoutes, { + prefix: '/:id/tree', + onRequest: [resolveContextAddress] + }); // Additional general routes for /contexts if any can go here // For example, the current GET / and POST / for listing/creating contexts diff --git a/src/api/routes/contexts/lifecycle.js b/src/api/routes/contexts/lifecycle.js index 16bb766f..c2783fe1 100644 --- a/src/api/routes/contexts/lifecycle.js +++ b/src/api/routes/contexts/lifecycle.js @@ -2,25 +2,21 @@ import ResponseObject from '../../ResponseObject.js'; import { validateUser } from '../../auth/strategies.js'; +import { resolveContextAddress } from '../../middleware/address-resolver.js'; export default async function lifecycleRoutes(fastify, options) { - // Helper functions (will be populated) - // const validateUser = (request) => { // Removed - // const user = request.user; - // if (!user || !user.id) { - // return false; - // } - // return true; - // }; - - const validateUserWithResponse = (request, reply) => { - if (!validateUser(request.user, ['id'])) { // Updated to use imported validateUser - const response = new ResponseObject().unauthorized('Valid authentication required'); - reply.code(response.statusCode).send(response.getResponse()); - return false; + // Add a pre-handler hook to ensure user is authenticated and valid for all context document routes + fastify.addHook('preHandler', async (request, reply) => { + try { + // The `authenticate` hook should have already run and populated `request.user` + // We just need to validate it has the required fields for our operations. + validateUser(request.user, ['id']); // For context operations, we primarily need the user's ID. + } catch (err) { + // If validateUser throws, it means the user object is invalid. + const response = new ResponseObject().unauthorized(err.message); + return reply.code(response.statusCode).send(response.getResponse()); } - return true; - }; + }); // Lifecycle routes will be added here @@ -28,18 +24,18 @@ export default async function lifecycleRoutes(fastify, options) { fastify.get('/', { onRequest: [fastify.authenticate], }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } try { const contexts = await fastify.contextManager.listUserContexts(request.user.id); - const response = new ResponseObject().found(contexts, 'Contexts retrieved successfully', 200, contexts.length); - return reply.code(response.statusCode).send(response.getResponse()); + + // Return consistent ResponseObject format + const response = new ResponseObject(); + return reply.code(200).send(response.found(contexts, 'Contexts retrieved successfully', 200, contexts.length).getResponse()); + } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to list contexts'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to list contexts'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -62,9 +58,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } try { const context = await fastify.contextManager.createContext( @@ -82,18 +75,18 @@ export default async function lifecycleRoutes(fastify, options) { ); const response = new ResponseObject().created({ context }, 'Context created successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to create context', error.message); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to create context', error.message); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Get context by ID // Note: The prefix for this group is '/', so this becomes GET /:id fastify.get('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -104,9 +97,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } try { const context = await fastify.contextManager.getContext(request.user.id, request.params.id); @@ -116,14 +106,25 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const responseObject = new ResponseObject().found( - { context: context.toJSON() }, - 'Context retrieved successfully' - ); - const finalResponse = responseObject.getResponse(); + const responsePayload = context.toJSON(); + + // Include resource address if it was resolved from user/resource format + if (request.originalAddress) { + responsePayload.resourceAddress = request.originalAddress; + } else { + // Try to construct resource address from context data + try { + const resourceAddress = await fastify.contextManager.constructResourceAddress(context); + if (resourceAddress) { + responsePayload.resourceAddress = resourceAddress; + } + } catch (error) { + // Ignore errors in address construction + } + } - fastify.log.info(finalResponse); - return reply.code(responseObject.statusCode).send(finalResponse); + const response = new ResponseObject().success(responsePayload, 'Context retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); if (error.message.startsWith('Access denied')) { @@ -134,15 +135,15 @@ export default async function lifecycleRoutes(fastify, options) { const response = new ResponseObject().badRequest(`Invalid context ID format for this endpoint. Use /users/{ownerId}/contexts/{contextId} for shared contexts. Context ID '${request.params.id}' is not valid here.`); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get context'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get context'); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Get context URL // Note: The prefix for this group is '/', so this becomes GET /:id/url fastify.get('/:id/url', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -153,9 +154,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const context = await fastify.contextManager.getContext(request.user.id, request.params.id); @@ -164,19 +162,19 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found({ url: context.url }, 'Context URL retrieved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success({ url: context.url }, 'Context URL retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to get context URL'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get context URL'); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Set context URL // Note: The prefix for this group is '/', so this becomes POST /:id/url fastify.post('/:id/url', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -194,9 +192,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const context = await fastify.contextManager.getContext(request.user.id, request.params.id); @@ -207,18 +202,18 @@ export default async function lifecycleRoutes(fastify, options) { await context.setUrl(request.body.url); const response = new ResponseObject().success({ url: context.url }, 'Context URL updated successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError(`Failed to set context URL: ${error.message}`); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error(`Failed to set context URL: ${error.message}`); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Get context path // Note: The prefix for this group is '/', so this becomes GET /:id/path fastify.get('/:id/path', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -229,9 +224,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const context = await fastify.contextManager.getContext(request.user.id, request.params.id); @@ -240,19 +232,19 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found({ path: context.path }, 'Context path retrieved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success({ path: context.path }, 'Context path retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to get context path'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get context path'); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Get context path array // Note: The prefix for this group is '/', so this becomes GET /:id/path-array fastify.get('/:id/path-array', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -263,9 +255,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const context = await fastify.contextManager.getContext(request.user.id, request.params.id); @@ -274,19 +263,19 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found({ pathArray: context.pathArray }, 'Context path array retrieved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success({ pathArray: context.pathArray }, 'Context path array retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to get context path array'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get context path array'); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Update context // Note: The prefix for this group is '/', so this becomes PUT /:id fastify.put('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -306,9 +295,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const context = await fastify.contextManager.updateContext( @@ -322,19 +308,19 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().success({ context }, 'Context updated successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().updated({ context }, 'Context updated successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to update context'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to update context'); + return reply.code(response.statusCode).send(response.getResponse()); } }); // Delete context // Note: The prefix for this group is '/', so this becomes DELETE /:id fastify.delete('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveContextAddress], schema: { params: { type: 'object', @@ -345,9 +331,6 @@ export default async function lifecycleRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return reply; - } try { const success = await fastify.contextManager.removeContext(request.user.id, request.params.id); @@ -356,12 +339,12 @@ export default async function lifecycleRoutes(fastify, options) { return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().success(null, 'Context deleted successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().deleted(null, 'Context deleted successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(error); - const response = new ResponseObject().serverError('Failed to delete context'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to delete context'); + return reply.code(response.statusCode).send(response.getResponse()); } }); } diff --git a/src/api/routes/contexts/tree.js b/src/api/routes/contexts/tree.js index 2c4a64e0..26088c7d 100644 --- a/src/api/routes/contexts/tree.js +++ b/src/api/routes/contexts/tree.js @@ -4,43 +4,15 @@ import ResponseObject from '../../ResponseObject.js'; import { validateUser } from '../../auth/strategies.js'; export default async function treeRoutes(fastify, options) { - // Helper functions (will be populated) - // const validateUser = (request) => { // Removed - // const user = request.user; - // if (!user || !user.id) { - // return false; - // } - // return true; - // }; - - const validateUserWithResponse = (request, reply) => { - if (!validateUser(request.user, ['id'])) { // Updated to use imported validateUser - const response = new ResponseObject().unauthorized('Valid authentication required'); - reply.code(response.statusCode).send(response.getResponse()); - return false; - } - return true; - }; - - // Helper to get workspace from context and handle errors - async function getWorkspaceFromContext(request, reply, contextId) { - const context = await fastify.contextManager.getContext(request.user.id, contextId); - if (!context) { - const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); - reply.code(response.statusCode).send(response.getResponse()); - return null; - } - - const workspace = context.workspace; - if (!workspace) { - fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); - const response = new ResponseObject().serverError(`Workspace for context ${contextId} is not available.`); - reply.code(response.statusCode).send(response.getResponse()); - return null; + // Add a pre-handler hook to ensure user is authenticated and valid + fastify.addHook('preHandler', async (request, reply) => { + try { + validateUser(request.user, ['id']); + } catch (err) { + const response = new ResponseObject().unauthorized(err.message); + return reply.code(response.statusCode).send(response.getResponse()); } - // Workspace methods have internal #ensureActiveForTreeOp, which will throw if not active. - return workspace; - } + }); // Get context tree // Path: / (relative to /:id/tree) @@ -50,45 +22,41 @@ export default async function treeRoutes(fastify, options) { // params.id is implicitly available } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) return; + const contextId = request.params.id; try { - // Use the helper to get the workspace instance - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; // Error already sent by helper if context or workspace is not found - - // Access the jsonTree getter from the workspace - const treeJsonString = workspace.jsonTree; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } - if (treeJsonString === undefined || treeJsonString === null) { - // This case implies an issue with workspace.jsonTree getter itself or uninitialized tree. - // Workspace.js jsonTree getter returns '{}' on error or if tree.jsonTree is falsy. - // So, a more direct check might be if it's just an empty object string after parsing. - fastify.log.warn(`Received null or undefined jsonTree for context ${contextId} from workspace ${workspace.id}`); - const response = new ResponseObject().serverError('Failed to retrieve valid tree data from workspace.'); + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); return reply.code(response.statusCode).send(response.getResponse()); } - let treeData; - try { - treeData = treeJsonString; - } catch (parseError) { - fastify.log.error(`Failed to parse tree JSON for context ${contextId}: ${parseError.message}. JSON: ${treeJsonString}`); - const response = new ResponseObject().serverError('Failed to parse tree data from workspace.'); + const treeData = workspace.jsonTree; + + if (treeData === undefined || treeData === null) { + fastify.log.warn(`Received null or undefined jsonTree for context ${contextId} from workspace ${workspace.id}`); + const response = new ResponseObject().error('Failed to retrieve valid tree data from workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().found(treeData, 'Context tree retrieved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().success(treeData, 'Context tree retrieved successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Get tree error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active or DB is not initialized') || error.message.includes('is not active. Cannot perform tree operation')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to get context tree'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to get context tree'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -108,40 +76,45 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } - // Workspace.insertPath(path, data = null, autoCreateLayers = true) - // It returns the result from tree.insertPath (e.g. true or layerId object) const result = await workspace.insertPath( request.body.path, - null, // data - not taken from this API endpoint's body + null, request.body.autoCreateLayers === undefined ? true : request.body.autoCreateLayers ); - // SynapsD tree.insertPath typically returns true or an object on success. const success = result === true || (typeof result === 'object' && result !== null); if (!success) { - const response = new ResponseObject().serverError('Failed to insert tree path in workspace.'); + const response = new ResponseObject().error('Failed to insert tree path in workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } const response = new ResponseObject().created(true, 'Tree path inserted successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Insert path error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to insert tree path'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to insert tree path'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -161,33 +134,41 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } - const success = await workspace.removePath( + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const success = await workspace.removePath( request.query.path, request.query.recursive || false ); if (!success) { - const response = new ResponseObject().serverError('Failed to remove tree path from workspace.'); + const response = new ResponseObject().error('Failed to remove tree path from workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().success({ success: true }, 'Tree path removed successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().deleted({ success: true }, 'Tree path removed successfully'); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Remove path error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to remove tree path'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to remove tree path'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -208,14 +189,22 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } const success = await workspace.movePath( request.body.from, @@ -223,19 +212,19 @@ export default async function treeRoutes(fastify, options) { request.body.recursive || false ); if (!success) { - const response = new ResponseObject().serverError('Failed to move tree path in workspace.'); + const response = new ResponseObject().error('Failed to move tree path in workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } const response = new ResponseObject().success({ success: true }, 'Tree path moved successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Move path error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to move tree path'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to move tree path'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -256,14 +245,22 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } const success = await workspace.copyPath( request.body.from, @@ -271,19 +268,19 @@ export default async function treeRoutes(fastify, options) { request.body.recursive || false ); if (!success) { - const response = new ResponseObject().serverError('Failed to copy tree path in workspace.'); + const response = new ResponseObject().error('Failed to copy tree path in workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } const response = new ResponseObject().success({ success: true }, 'Tree path copied successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Copy path error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to copy tree path'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to copy tree path'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -302,30 +299,38 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } const success = await workspace.mergeUp(request.body.path); if (!success) { - const response = new ResponseObject().serverError('Failed to merge layer bitmaps upwards in workspace.'); + const response = new ResponseObject().error('Failed to merge layer bitmaps upwards in workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } const response = new ResponseObject().success({ success: true }, 'Layer bitmaps merged upwards successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Merge up error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to merge layer bitmaps upwards'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to merge layer bitmaps upwards'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -344,30 +349,38 @@ export default async function treeRoutes(fastify, options) { } } }, async (request, reply) => { - if (!validateUserWithResponse(request, reply)) { - return; - } + const contextId = request.params.id; try { - const workspace = await getWorkspaceFromContext(request, reply, contextId); - if (!workspace) return; + const context = await fastify.contextManager.getContext(request.user.id, contextId); + if (!context) { + const response = new ResponseObject().notFound(`Context with ID ${contextId} not found`); + return reply.code(response.statusCode).send(response.getResponse()); + } + + const workspace = context.workspace; + if (!workspace) { + fastify.log.error(`Workspace not found or not loaded for context ${contextId}, user ${request.user.id}`); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not available.`); + return reply.code(response.statusCode).send(response.getResponse()); + } const success = await workspace.mergeDown(request.body.path); if (!success) { - const response = new ResponseObject().serverError('Failed to merge layer bitmaps downwards in workspace.'); + const response = new ResponseObject().error('Failed to merge layer bitmaps downwards in workspace.'); return reply.code(response.statusCode).send(response.getResponse()); } const response = new ResponseObject().success({ success: true }, 'Layer bitmaps merged downwards successfully'); - return reply.code(response.statusCode).send(response.getResponse()); + return reply.code(response.statusCode).send(response.getResponse()); } catch (error) { fastify.log.error(`Merge down error for context ${contextId}: ${error.message}`); if (error.message.includes('is not active')) { - const errResponse = new ResponseObject().serverError(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); - return reply.code(errResponse.statusCode).send(errResponse.getResponse()); + const response = new ResponseObject().error(`Workspace for context ${contextId} is not active. Cannot perform tree operation.`); + return reply.code(response.statusCode).send(response.getResponse()); } - const response = new ResponseObject().serverError('Failed to merge layer bitmaps downwards'); - return reply.code(response.statusCode).send(response.getResponse()); + const response = new ResponseObject().error('Failed to merge layer bitmaps downwards'); + return reply.code(response.statusCode).send(response.getResponse()); } }); } diff --git a/src/api/routes/users/index.js b/src/api/routes/pub/index.js similarity index 88% rename from src/api/routes/users/index.js rename to src/api/routes/pub/index.js index a5eedb1b..2bedba32 100644 --- a/src/api/routes/users/index.js +++ b/src/api/routes/pub/index.js @@ -1,13 +1,14 @@ 'use strict'; import ResponseObject from '../../ResponseObject.js'; // Assuming ResponseObject is in a relative path +import validator from 'validator'; /** - * User-specific routes, including shared context access. + * Public/shared resource routes for accessing contexts and documents shared by other users. * @param {FastifyInstance} fastify - Fastify instance * @param {Object} options - Plugin options */ -export default async function userRoutes(fastify, options) { +export default async function pubRoutes(fastify, options) { /** * Helper function to validate user is authenticated and has an id * @param {Object} request - Fastify request @@ -36,6 +37,38 @@ export default async function userRoutes(fastify, options) { return true; }; + /** + * Helper function to check if targetUserId is an email and redirect to user ID if needed + * @param {Object} request - Fastify request + * @param {Object} reply - Fastify reply + * @param {string} targetUserId - The targetUserId parameter from the route + * @returns {Promise} The resolved user ID if email was found, null if not an email or user not found + */ + const resolveUserIdFromEmail = async (request, reply, targetUserId) => { + // Check if targetUserId looks like an email + if (!validator.isEmail(targetUserId)) { + return null; // Not an email, return null to continue with original targetUserId + } + + try { + // Try to find user by email + const user = await fastify.userManager.getUserByEmail(targetUserId); + if (user && user.id) { + // Redirect to the user ID-based URL + const originalUrl = request.url; + const newUrl = originalUrl.replace(`/${targetUserId}/`, `/${user.id}/`); + + fastify.log.info(`Redirecting email-based URL to user ID: ${originalUrl} -> ${newUrl}`); + return reply.redirect(301, newUrl); + } + } catch (error) { + // User not found by email, continue with original targetUserId + fastify.log.debug(`User not found by email: ${targetUserId}`); + } + + return null; // No redirect needed + }; + // Get a specific context (owned by targetUserId, accessed by request.user.id) fastify.get('/:targetUserId/contexts/:contextId', { onRequest: [fastify.authenticate], @@ -54,6 +87,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -68,7 +107,7 @@ export default async function userRoutes(fastify, options) { } const responseObject = new ResponseObject().found( - { context: context.toJSON() }, + context.toJSON(), 'Context retrieved successfully' ); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); @@ -113,6 +152,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if ownerUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.ownerUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + const requestingUserId = request.user.id; const ownerUserIdFromParams = request.params.ownerUserId; const contextId = request.params.contextId; @@ -166,6 +211,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if ownerUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.ownerUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + const requestingUserId = request.user.id; const ownerUserIdFromParams = request.params.ownerUserId; const contextId = request.params.contextId; @@ -230,6 +281,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -296,6 +353,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -362,6 +425,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -424,6 +493,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -493,6 +568,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; @@ -572,6 +653,12 @@ export default async function userRoutes(fastify, options) { return; } + // Check if targetUserId is an email and redirect if needed + const redirectResult = await resolveUserIdFromEmail(request, reply, request.params.targetUserId); + if (redirectResult) { + return redirectResult; // Redirect has been sent + } + try { const accessingUserId = request.user.id; const ownerUserId = request.params.targetUserId; diff --git a/src/api/routes/workspaces/documents.js b/src/api/routes/workspaces/documents.js index 3ae35fa7..5cefbe4b 100644 --- a/src/api/routes/workspaces/documents.js +++ b/src/api/routes/workspaces/documents.js @@ -34,7 +34,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -95,7 +95,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -146,7 +146,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -204,7 +204,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -260,7 +260,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -306,7 +306,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -324,7 +324,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } - const responseObject = new ResponseObject().deleted('Documents deleted successfully'); + const responseObject = new ResponseObject().deleted(null, 'Documents deleted successfully'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } catch (error) { fastify.log.error(error); @@ -354,7 +354,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -372,7 +372,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } - const responseObject = new ResponseObject().deleted('Documents removed successfully'); + const responseObject = new ResponseObject().deleted(null, 'Documents removed successfully'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } catch (error) { fastify.log.error(error); @@ -398,7 +398,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { }, async (request, reply) => { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -445,7 +445,7 @@ export default async function workspaceDocumentRoutes(fastify, options) { try { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); diff --git a/src/api/routes/workspaces/index.js b/src/api/routes/workspaces/index.js index 4186162a..ed09b1c6 100644 --- a/src/api/routes/workspaces/index.js +++ b/src/api/routes/workspaces/index.js @@ -1,6 +1,9 @@ 'use strict'; import ResponseObject from '../../ResponseObject.js'; +import { requireWorkspaceRead, requireWorkspaceWrite, requireWorkspaceAdmin } from '../../middleware/workspace-acl.js'; +import { validateUser } from '../../auth/strategies.js'; +import { resolveWorkspaceAddress } from '../../middleware/address-resolver.js'; /** * Main workspace routes handler for the API @@ -13,47 +16,55 @@ export default async function workspaceRoutes(fastify, options) { * @param {Object} request - Fastify request * @returns {boolean} true if valid, false if not */ - const validateUser = (request) => { - const user = request.user; - if (!user || !user.email || !user.id) { - return false; - } - return true; - }; - const validateUserWithResponse = (request, reply) => { - if (!validateUser(request)) { - const response = new ResponseObject().unauthorized('Valid authentication required'); - reply.code(response.statusCode).send(response.getResponse()); - return false; + if (!validateUser(request.user, ['id', 'email'])) { + const response = new ResponseObject().unauthorized('Valid authentication required'); + reply.code(response.statusCode).send(response.getResponse()); + return false; } return true; }; // Register sub-routes - fastify.register(import('./documents.js'), { prefix: '/:id/documents' }); - fastify.register(import('./tree.js'), { prefix: '/:id/tree' }); - fastify.register(import('./lifecycle.js'), { prefix: '/:id' }); + fastify.register(import('./documents.js'), { + prefix: '/:id/documents', + onRequest: [resolveWorkspaceAddress] + }); + fastify.register(import('./tree.js'), { + prefix: '/:id/tree', + onRequest: [resolveWorkspaceAddress] + }); + fastify.register(import('./lifecycle.js'), { + prefix: '/:id', + onRequest: [resolveWorkspaceAddress] + }); + fastify.register(import('./tokens.js'), { + prefix: '/:id/tokens', + onRequest: [resolveWorkspaceAddress] + }); // List all workspaces fastify.get('/', { onRequest: [fastify.authenticate] }, async (request, reply) => { + try { // Validate user is authenticated properly - if (!request.user || !request.user.id) { - fastify.log.error('User data missing in request after authentication'); - const responseObject = new ResponseObject().unauthorized('User authentication data is incomplete'); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + if (!validateUser(request.user, ['id', 'email'])) { + const response = new ResponseObject().unauthorized('Valid authentication required'); + return reply.code(response.statusCode).send(response.getResponse()); } const workspaces = await fastify.workspaceManager.listUserWorkspaces(request.user.id); - const responseObject = new ResponseObject().found(workspaces, 'Workspaces retrieved successfully', 200, workspaces.length); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + // Return consistent ResponseObject format + const response = new ResponseObject(); + return reply.code(200).send(response.found(workspaces, 'Workspaces retrieved successfully', 200, workspaces.length).getResponse()); + } catch (error) { fastify.log.error(error); - const responseObject = new ResponseObject().serverError('Failed to list workspaces'); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + const response = new ResponseObject().serverError('Failed to list workspaces'); + return reply.code(response.statusCode).send(response.getResponse()); } }); @@ -77,9 +88,12 @@ export default async function workspaceRoutes(fastify, options) { } } }, async (request, reply) => { + if (!validateUserWithResponse(request, reply)) { + return; + } try { const workspace = await fastify.workspaceManager.createWorkspace( - request.user.email, + request.user.id, request.body.name, { owner: request.user.id, @@ -97,14 +111,15 @@ export default async function workspaceRoutes(fastify, options) { return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } catch (error) { fastify.log.error(error); - const responseObject = new ResponseObject().serverError('Failed to create workspace'); + // Return the actual error message instead of a generic one + const responseObject = new ResponseObject().serverError(error.message || 'Failed to create workspace'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } }); // Get workspace details fastify.get('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveWorkspaceAddress, requireWorkspaceRead()], schema: { params: { type: 'object', @@ -116,21 +131,35 @@ export default async function workspaceRoutes(fastify, options) { } }, async (request, reply) => { try { - const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, - request.params.id, - request.user.id - ); + // Workspace and access info already validated by middleware + const workspace = request.workspace; + const access = request.workspaceAccess; - if (!workspace) { - const responseObject = new ResponseObject().notFound(`Workspace with ID ${request.params.id} not found`); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + const response = { + workspace: workspace.toJSON(), + access: { + permissions: access.permissions, + isOwner: access.isOwner, + description: access.description + } + }; + + // Include resource address if it was resolved from user/resource format + if (request.originalAddress) { + response.resourceAddress = request.originalAddress; + } else { + // Try to construct resource address from workspace data + try { + const resourceAddress = await fastify.workspaceManager.constructResourceAddress(workspace); + if (resourceAddress) { + response.resourceAddress = resourceAddress; + } + } catch (error) { + // Ignore errors in address construction + } } - const responseObject = new ResponseObject().found( - { workspace: workspace.toJSON() }, - 'Workspace retrieved successfully' - ); + const responseObject = new ResponseObject().found(response, 'Workspace retrieved successfully'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } catch (error) { fastify.log.error(error); @@ -141,7 +170,7 @@ export default async function workspaceRoutes(fastify, options) { // Update workspace fastify.patch('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveWorkspaceAddress, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -164,16 +193,25 @@ export default async function workspaceRoutes(fastify, options) { } } }, async (request, reply) => { + if (!validateUserWithResponse(request, reply)) { + return; + } try { + // Access already validated by middleware + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can modify workspace configuration'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + const success = await fastify.workspaceManager.updateWorkspaceConfig( - request.user.email, + request.user.id, request.params.id, request.user.id, request.body ); if (!success) { - const responseObject = new ResponseObject().notFound(`Workspace with ID ${request.params.id} not found`); + const responseObject = new ResponseObject().serverError('Failed to update workspace configuration'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } @@ -188,7 +226,7 @@ export default async function workspaceRoutes(fastify, options) { // Delete workspace fastify.delete('/:id', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, resolveWorkspaceAddress, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -199,6 +237,9 @@ export default async function workspaceRoutes(fastify, options) { } } }, async (request, reply) => { + if (!validateUserWithResponse(request, reply)) { + return; + } try { // Prevent deletion of universe workspace if (request.params.id === 'universe') { @@ -206,19 +247,25 @@ export default async function workspaceRoutes(fastify, options) { return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } + // Only owners can delete workspaces + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can delete workspaces'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + const success = await fastify.workspaceManager.removeWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id, true // destroyData = true to actually delete the workspace files ); if (!success) { - const responseObject = new ResponseObject().notFound(`Workspace with ID ${request.params.id} not found`); + const responseObject = new ResponseObject().serverError('Failed to delete workspace'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } - const responseObject = new ResponseObject().deleted('Workspace deleted successfully'); + const responseObject = new ResponseObject().deleted(null, 'Workspace deleted successfully'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); } catch (error) { fastify.log.error(error); diff --git a/src/api/routes/workspaces/lifecycle.js b/src/api/routes/workspaces/lifecycle.js index ab24fdfd..b3797564 100644 --- a/src/api/routes/workspaces/lifecycle.js +++ b/src/api/routes/workspaces/lifecycle.js @@ -1,6 +1,7 @@ 'use strict'; import ResponseObject from '../../ResponseObject.js'; +import { requireWorkspaceRead, requireWorkspaceWrite, requireWorkspaceAdmin } from '../../middleware/workspace-acl.js'; /** * Workspace lifecycle routes handler for the API @@ -10,7 +11,7 @@ import ResponseObject from '../../ResponseObject.js'; export default async function workspaceLifecycleRoutes(fastify, options) { // Get workspace status fastify.get('/status', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, requireWorkspaceRead()], schema: { params: { type: 'object', @@ -22,16 +23,9 @@ export default async function workspaceLifecycleRoutes(fastify, options) { } }, async (request, reply) => { try { - const status = await fastify.workspaceManager.getWorkspaceStatus( - request.user.email, - request.params.id, - request.user.id - ); - - if (!status) { - const responseObject = new ResponseObject().notFound(`Workspace with ID ${request.params.id} not found`); - return reply.code(responseObject.statusCode).send(responseObject.getResponse()); - } + // Workspace access already validated by middleware + const workspace = request.workspace; + const status = workspace.status; const responseObject = new ResponseObject().found({ status }, 'Workspace status retrieved successfully'); return reply.code(responseObject.statusCode).send(responseObject.getResponse()); @@ -44,7 +38,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { // Open workspace fastify.post('/open', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -63,7 +57,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { try { const workspace = await fastify.workspaceManager.openWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -100,7 +94,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { // Close workspace fastify.post('/close', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -114,7 +108,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { try { // First get the workspace object before closing it const workspace = await fastify.workspaceManager.openWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -126,7 +120,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { // Now close the workspace const success = await fastify.workspaceManager.closeWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -159,7 +153,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { // Start workspace fastify.post('/start', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -173,7 +167,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { let workspace; try { workspace = await fastify.workspaceManager.startWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); @@ -200,7 +194,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { // Stop workspace fastify.post('/stop', { - onRequest: [fastify.authenticate], + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], schema: { params: { type: 'object', @@ -214,7 +208,7 @@ export default async function workspaceLifecycleRoutes(fastify, options) { let success; try { success = await fastify.workspaceManager.stopWorkspace( - request.user.email, + request.user.id, request.params.id, request.user.id ); diff --git a/src/api/routes/workspaces/tokens.js b/src/api/routes/workspaces/tokens.js new file mode 100644 index 00000000..3bb74919 --- /dev/null +++ b/src/api/routes/workspaces/tokens.js @@ -0,0 +1,346 @@ +'use strict'; + +import crypto from 'crypto'; +import ResponseObject from '../../ResponseObject.js'; +import { requireWorkspaceAdmin } from '../../middleware/workspace-acl.js'; + +/** + * Workspace token sharing routes for the API + * @param {FastifyInstance} fastify - Fastify instance + * @param {Object} options - Plugin options + */ +export default async function workspaceTokenRoutes(fastify, options) { + + // Create a sharing token for the workspace + fastify.post('/', { + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], + schema: { + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' } + } + }, + body: { + type: 'object', + required: ['permissions'], + properties: { + permissions: { + type: 'array', + items: { type: 'string', enum: ['read', 'write', 'admin'] }, + minItems: 1 + }, + description: { type: 'string' }, + expiresAt: { + oneOf: [ + { type: 'string', format: 'date-time' }, + { type: 'null' } + ] + } + } + } + } + }, async (request, reply) => { + try { + // Only owners can create sharing tokens + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can create sharing tokens'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const { permissions, description, expiresAt } = request.body; + const workspace = request.workspace; + + // Generate new API token + const tokenValue = `canvas-${crypto.randomBytes(24).toString('hex')}`; + const tokenHash = `sha256:${crypto.createHash('sha256').update(tokenValue).digest('hex')}`; + + // Create token data + const tokenData = { + permissions, + description: description || 'Workspace sharing token', + createdAt: new Date().toISOString(), + expiresAt: expiresAt || null + }; + + // Update workspace ACL + const currentACL = workspace.acl || { tokens: {} }; + currentACL.tokens = currentACL.tokens || {}; + currentACL.tokens[tokenHash] = tokenData; + + // Save the updated ACL + const success = await fastify.workspaceManager.updateWorkspaceConfig( + request.user.id, + workspace.id, + request.user.id, + { acl: currentACL } + ); + + if (!success) { + const responseObject = new ResponseObject().serverError('Failed to save workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + // Return token info (including the actual token value) + const responseData = { + tokenHash, + token: tokenValue, // Only returned on creation + ...tokenData + }; + + const responseObject = new ResponseObject().created(responseData, 'Workspace sharing token created successfully'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + } catch (error) { + fastify.log.error(error); + const responseObject = new ResponseObject().serverError('Failed to create workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + }); + + // List all tokens for the workspace + fastify.get('/', { + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], + schema: { + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + // Only owners can list sharing tokens + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can list sharing tokens'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const workspace = request.workspace; + const tokens = workspace.acl?.tokens || {}; + + // Convert to array and hide sensitive data + const tokenList = Object.entries(tokens).map(([tokenHash, tokenData]) => ({ + tokenHash, + permissions: tokenData.permissions, + description: tokenData.description, + createdAt: tokenData.createdAt, + expiresAt: tokenData.expiresAt, + isExpired: tokenData.expiresAt ? new Date() > new Date(tokenData.expiresAt) : false + })); + + const responseObject = new ResponseObject().found(tokenList, 'Workspace sharing tokens retrieved successfully', 200, tokenList.length); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + } catch (error) { + fastify.log.error(error); + const responseObject = new ResponseObject().serverError('Failed to list workspace sharing tokens'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + }); + + // Update a specific token + fastify.patch('/:tokenHash', { + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], + schema: { + params: { + type: 'object', + required: ['id', 'tokenHash'], + properties: { + id: { type: 'string' }, + tokenHash: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + permissions: { + type: 'array', + items: { type: 'string', enum: ['read', 'write', 'admin'] }, + minItems: 1 + }, + description: { type: 'string' }, + expiresAt: { + oneOf: [ + { type: 'string', format: 'date-time' }, + { type: 'null' } + ] + } + } + } + } + }, async (request, reply) => { + try { + // Only owners can update sharing tokens + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can update sharing tokens'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const { permissions, description, expiresAt } = request.body; + const workspace = request.workspace; + const tokenHash = request.params.tokenHash; + + const currentACL = workspace.acl || { tokens: {} }; + const tokens = currentACL.tokens || {}; + + // Check if token exists + if (!tokens[tokenHash]) { + const responseObject = new ResponseObject().notFound('Sharing token not found'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + // Update token data + const tokenData = tokens[tokenHash]; + if (permissions !== undefined) tokenData.permissions = permissions; + if (description !== undefined) tokenData.description = description; + if (expiresAt !== undefined) tokenData.expiresAt = expiresAt; + tokenData.updatedAt = new Date().toISOString(); + + // Save the updated ACL + const success = await fastify.workspaceManager.updateWorkspaceConfig( + request.user.id, + workspace.id, + request.user.id, + { acl: currentACL } + ); + + if (!success) { + const responseObject = new ResponseObject().serverError('Failed to update workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + // Return updated token info (without the actual token value) + const responseData = { + tokenHash, + permissions: tokenData.permissions, + description: tokenData.description, + createdAt: tokenData.createdAt, + updatedAt: tokenData.updatedAt, + expiresAt: tokenData.expiresAt + }; + + const responseObject = new ResponseObject().success(responseData, 'Workspace sharing token updated successfully'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + } catch (error) { + fastify.log.error(error); + const responseObject = new ResponseObject().serverError('Failed to update workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + }); + + // Revoke (delete) a specific token + fastify.delete('/:tokenHash', { + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], + schema: { + params: { + type: 'object', + required: ['id', 'tokenHash'], + properties: { + id: { type: 'string' }, + tokenHash: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + // Only owners can revoke sharing tokens + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can revoke sharing tokens'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const workspace = request.workspace; + const tokenHash = request.params.tokenHash; + + const currentACL = workspace.acl || { tokens: {} }; + const tokens = currentACL.tokens || {}; + + // Check if token exists + if (!tokens[tokenHash]) { + const responseObject = new ResponseObject().notFound('Sharing token not found'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + // Remove the token + delete tokens[tokenHash]; + + // Save the updated ACL + const success = await fastify.workspaceManager.updateWorkspaceConfig( + request.user.id, + workspace.id, + request.user.id, + { acl: currentACL } + ); + + if (!success) { + const responseObject = new ResponseObject().serverError('Failed to revoke workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const responseObject = new ResponseObject().deleted(null, 'Workspace sharing token revoked successfully'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + } catch (error) { + fastify.log.error(error); + const responseObject = new ResponseObject().serverError('Failed to revoke workspace sharing token'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + }); + + // Get details of a specific token + fastify.get('/:tokenHash', { + onRequest: [fastify.authenticate, requireWorkspaceAdmin()], + schema: { + params: { + type: 'object', + required: ['id', 'tokenHash'], + properties: { + id: { type: 'string' }, + tokenHash: { type: 'string' } + } + } + } + }, async (request, reply) => { + try { + // Only owners can view sharing token details + if (!request.workspaceAccess.isOwner) { + const responseObject = new ResponseObject().forbidden('Only workspace owners can view sharing token details'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const tokenHash = request.params.tokenHash; + const workspace = request.workspace; + const tokens = workspace.acl?.tokens || {}; + + // Check if token exists + if (!tokens[tokenHash]) { + const responseObject = new ResponseObject().notFound('Sharing token not found'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + + const tokenData = tokens[tokenHash]; + const responseData = { + tokenHash, + permissions: tokenData.permissions, + description: tokenData.description, + createdAt: tokenData.createdAt, + updatedAt: tokenData.updatedAt, + expiresAt: tokenData.expiresAt, + isExpired: tokenData.expiresAt ? new Date() > new Date(tokenData.expiresAt) : false + }; + + const responseObject = new ResponseObject().found(responseData, 'Workspace sharing token details retrieved successfully'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + + } catch (error) { + fastify.log.error(error); + const responseObject = new ResponseObject().serverError('Failed to get workspace sharing token details'); + return reply.code(responseObject.statusCode).send(responseObject.getResponse()); + } + }); +} diff --git a/src/api/routes/workspaces/tree.js b/src/api/routes/workspaces/tree.js index 1daa1f60..7c9245d8 100644 --- a/src/api/routes/workspaces/tree.js +++ b/src/api/routes/workspaces/tree.js @@ -11,7 +11,7 @@ export default async function workspaceTreeRoutes(fastify, options) { // Helper to get workspace and handle common errors async function getWorkspaceInstance(request, reply) { const workspace = await fastify.workspaceManager.getWorkspace( - request.user.email, // Assuming user email is still needed for manager access + request.user.id, // Use user.id for consistency with new indexing system request.params.id, request.user.id ); diff --git a/src/api/websocket/channels/context.js b/src/api/websocket/channels/context.js index f8544190..c61bc6dd 100644 --- a/src/api/websocket/channels/context.js +++ b/src/api/websocket/channels/context.js @@ -1,6 +1,6 @@ import { createDebug } from '../../../utils/log/index.js'; -const debug = createDebug('canvas-server:websocket:context'); +const debug = createDebug('websocket:context'); /** * Push-only WebSocket bridge for context events. @@ -18,30 +18,51 @@ export default function registerContextWebSocket(fastify, socket) { const listeners = new Map(); const wildcardListener = async function (payload) { - debug('🎯 DEBUG: Wildcard listener received payload:', payload); + debug('🎯 DEBUG: Wildcard listener received event:', this.event); + debug('🎯 DEBUG: Event payload:', JSON.stringify(payload, null, 2)); + try { const eventName = this.event; const contextId = payload?.contextId || payload?.id; const userId = socket.user?.id; + debug(`🎯 DEBUG: Processing event "${eventName}" for contextId="${contextId}", userId="${userId}"`); + + if (!userId) { + debug('❌ User ID not found on socket - skipping event'); + return; + } + if (contextId) { - const allowed = await contextManager.hasContext(userId, contextId); - if (!allowed) { - debug(`User ${userId} lacks access to context ${contextId} – skip ${eventName}`); + try { + // Use getContext instead of hasContext to properly check permissions + const context = await contextManager.getContext(userId, contextId); + if (!context) { + debug(`❌ User ${userId} lacks access to context ${contextId} – skip ${eventName}`); + return; + } + debug(`✅ User ${userId} has access to context ${contextId} – forwarding ${eventName}`); + } catch (error) { + debug(`❌ Access check failed for user ${userId} to context ${contextId}: ${error.message} – skip ${eventName}`); return; } + } else { + debug(`🎯 Event "${eventName}" has no contextId, forwarding to all users`); } socket.emit(eventName, payload); debug(`➡️ sent ${eventName} to ${userId}`); } catch (err) { - debug(`Error forwarding context event: ${err.message}`); + debug(`❌ Error forwarding context event: ${err.message}`); + debug(`❌ Error stack:`, err.stack); } }; contextManager.on('**', wildcardListener); listeners.set('contextWildcard', wildcardListener); + debug(`✅ Context WebSocket bridge registered for socket ${socket.id} (user: ${socket.user?.id})`); + socket.on('disconnect', () => { listeners.forEach((listener) => contextManager.off('**', listener)); listeners.clear(); diff --git a/src/api/websocket/channels/workspace.js b/src/api/websocket/channels/workspace.js index e0939328..6cf56d2e 100644 --- a/src/api/websocket/channels/workspace.js +++ b/src/api/websocket/channels/workspace.js @@ -1,6 +1,7 @@ import { createDebug } from '../../../utils/log/index.js'; +import crypto from 'crypto'; -const debug = createDebug('canvas-server:websocket:workspace'); +const debug = createDebug('websocket:workspace'); /** * Register workspace-update websocket forwarding for a specific socket. @@ -41,8 +42,8 @@ export default function registerWorkspaceWebSocket(fastify, socket) { return; } - // Verify access – only owners / ACL'd users should receive. - const hasAccess = await workspaceManager.hasWorkspace(userId, workspaceId); + // Verify access using token-based ACL validation + const hasAccess = await validateWorkspaceAccess(socket, workspaceId); if (!hasAccess) { debug(`Access denied for user ${userId} to workspace ${workspaceId} – not forwarding ${eventName}`); return; @@ -66,3 +67,125 @@ export default function registerWorkspaceWebSocket(fastify, socket) { debug(`Cleaned workspace WS listeners for socket ${socket.id}`); }); } + +/** + * Validate workspace access using token-based ACLs + * @param {Socket} socket - Authenticated socket with user info + * @param {string} workspaceIdentifier - Workspace ID or name to validate access for + * @returns {Promise} True if access is granted, false otherwise + */ +async function validateWorkspaceAccess(socket, workspaceIdentifier) { + try { + const userId = socket.user?.id; + if (!userId) { + debug(`No user ID found on socket for workspace access validation`); + return false; + } + + // Get the token from the socket handshake + const token = socket.handshake?.auth?.token; + if (!token || !token.startsWith('canvas-')) { + debug(`Invalid or missing Canvas token for workspace access`); + return false; + } + + // Try owner access first (fastest path) + const workspaceManager = socket.server?.workspaceManager; + if (!workspaceManager) { + debug(`WorkspaceManager not available for access validation`); + return false; + } + + try { + // Check if identifier is a workspace ID (12 chars) or name + const isWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + + let workspace; + if (isWorkspaceId) { + workspace = await workspaceManager.getWorkspaceById(workspaceIdentifier, userId); + } else { + workspace = await workspaceManager.getWorkspaceByName(userId, workspaceIdentifier, userId); + } + + if (workspace) { + debug(`Owner access granted for workspace ${workspaceIdentifier}`); + return true; + } + } catch (error) { + debug(`Owner access check failed: ${error.message}`); + } + + // Try token-based access + const tokenHash = `sha256:${crypto.createHash('sha256').update(token).digest('hex')}`; + const allWorkspaces = workspaceManager.getAllWorkspacesWithKeys(); + + // Check if identifier is a workspace ID (12 chars) or name + const isWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + + if (isWorkspaceId) { + // Direct lookup by workspace ID + let workspaceEntry = null; + + // Search for workspace by ID across all users + for (const [indexKey, entry] of Object.entries(allWorkspaces)) { + const parsed = (() => { + const parts = indexKey.split('/'); + return parts.length === 2 ? { userId: parts[0], workspaceId: parts[1] } : null; + })(); + + if (parsed && parsed.workspaceId === workspaceIdentifier) { + workspaceEntry = entry; + break; + } + } + + if (workspaceEntry) { + const tokens = workspaceEntry.acl?.tokens || {}; + const tokenData = tokens[tokenHash]; + + if (tokenData) { + // Check expiration + if (tokenData.expiresAt && new Date() > new Date(tokenData.expiresAt)) { + debug(`Token has expired for workspace ${workspaceIdentifier}`); + return false; + } + + // WebSocket access requires at least read permission + if (tokenData.permissions.includes('read')) { + debug(`Token access granted for workspace ${workspaceIdentifier}`); + return true; + } + } + } + } else { + // Search through all workspaces for a matching name and token + for (const [indexKey, workspaceEntry] of Object.entries(allWorkspaces)) { + if (workspaceEntry.name === workspaceIdentifier) { + const tokens = workspaceEntry.acl?.tokens || {}; + const tokenData = tokens[tokenHash]; + + if (tokenData) { + // Check expiration + if (tokenData.expiresAt && new Date() > new Date(tokenData.expiresAt)) { + debug(`Token has expired for workspace ${workspaceIdentifier}`); + continue; + } + + // WebSocket access requires at least read permission + if (tokenData.permissions.includes('read')) { + debug(`Token access granted for workspace ${workspaceIdentifier}`); + return true; + } + } + } + } + } + + debug(`No valid access found for workspace ${workspaceIdentifier}`); + return false; + + } catch (error) { + debug(`Error validating workspace access: ${error.message}`); + return false; + } +} diff --git a/src/api/websocket/index.js b/src/api/websocket/index.js index edc70da8..a8d8ca40 100644 --- a/src/api/websocket/index.js +++ b/src/api/websocket/index.js @@ -1,8 +1,11 @@ 'use strict'; +import { createDebug } from '../../utils/log/index.js'; import registerContextWebSocket from './channels/context.js'; import registerWorkspaceWebSocket from './channels/workspace.js'; +const debug = createDebug('canvas-server:websocket:main'); + /** * WebSocket bootstrap – push-only design. * • Authenticates sockets (JWT or API token handled by fastify.authService). @@ -17,18 +20,27 @@ export default function setupWebSocketHandlers(fastify) { const connections = new Map(); // socket.id → { socket, user, lastActivity } const connectionAttempts = new Map(); // ip → { count, timestamp } + debug('🚀 Setting up WebSocket handlers...'); + /* ---------------- Authentication middleware ---------------- */ io.use(async (socket, next) => { try { const clientIp = socket.handshake.address; const connectionId = socket.handshake.headers['x-connection-id'] || generateConnectionId(); + debug(`🔐 Authenticating WebSocket connection from ${clientIp}`); + // rudimentary rate-limit on handshake attempts per IP const attempt = connectionAttempts.get(clientIp) || { count: 0, timestamp: Date.now() }; attempt.count += 1; connectionAttempts.set(clientIp, attempt); if (attempt.count > 10 && (Date.now() - attempt.timestamp) < 60_000) { - return next(new Error('Too many connection attempts')); + const error = new Error('Too many connection attempts'); + debug(`❌ Rate limit exceeded for ${clientIp}`); + next(error); + // Force disconnect to close TCP connection + socket.disconnect(true); + return; } if ((Date.now() - attempt.timestamp) > 60_000) { attempt.count = 1; @@ -37,62 +49,115 @@ export default function setupWebSocketHandlers(fastify) { // extract bearer / ws auth token const token = socket.handshake.auth.token || socket.handshake.headers.authorization?.replace('Bearer ', ''); - if (!token) return next(new Error('Authentication token required')); + if (!token) { + const error = new Error('Authentication token required'); + debug(`❌ No token provided for ${clientIp}`); + next(error); + // Force disconnect to close TCP connection + socket.disconnect(true); + return; + } let user; if (token.startsWith('canvas-')) { + debug(`🎫 Verifying Canvas API token for ${clientIp}`); const apiRes = await fastify.authService.verifyApiToken(token); - if (!apiRes) return next(new Error('Invalid API token')); + if (!apiRes) { + const error = new Error('Invalid API token'); + debug(`❌ Invalid Canvas API token for ${clientIp}`); + next(error); + // Force disconnect to close TCP connection + socket.disconnect(true); + return; + } user = await fastify.userManager.getUserById(apiRes.userId); } else { + debug(`🎫 Verifying JWT token for ${clientIp}`); const decoded = fastify.jwt.verify(token); user = await fastify.userManager.getUserById(decoded.sub); } - if (!user || user.status !== 'active') return next(new Error('Invalid user')); + if (!user || user.status !== 'active') { + const error = new Error('Invalid user'); + debug(`❌ Invalid or inactive user for ${clientIp}`); + next(error); + // Force disconnect to close TCP connection + socket.disconnect(true); + return; + } socket.user = { id: user.id, email: user.email.toLowerCase() }; socket.connectionId = connectionId; + debug(`✅ WebSocket authenticated: ${user.email} (${user.id}) from ${clientIp}`); next(); } catch (err) { - next(new Error(`Auth error: ${err.message}`)); + const error = new Error(`Auth error: ${err.message}`); + debug(`❌ Authentication error: ${err.message}`); + next(error); + // Force disconnect to close TCP connection + socket.disconnect(true); } }); /* ---------------- Broadcast helpers ---------------- */ fastify.decorate('broadcastToUser', (userId, event, payload) => { + debug(`📡 Broadcasting event "${event}" to user ${userId}`); let sent = 0; connections.forEach((conn) => { if (conn.user.id === userId) { - try { conn.socket.emit(event, payload); sent++; } catch { /* ignore */ } + try { + debug(` ➡️ Sending to socket ${conn.socket.id}`); + conn.socket.emit(event, payload); + sent++; + } catch (error) { + debug(` ❌ Failed to send to socket ${conn.socket.id}:`, error.message); + } } }); + debug(`📡 Broadcast complete: sent to ${sent} connections for user ${userId}`); return sent; }); fastify.decorate('broadcastToWorkspace', (workspaceId, event, payload) => { + debug(`📡 Broadcasting event "${event}" to workspace ${workspaceId}`); let sent = 0; connections.forEach((conn) => { if (conn.socket.subscriptions?.has?.(`workspace:${workspaceId}`)) { - try { conn.socket.emit(event, payload); sent++; } catch {} + try { + debug(` ➡️ Sending to socket ${conn.socket.id}`); + conn.socket.emit(event, payload); + sent++; + } catch (error) { + debug(` ❌ Failed to send to socket ${conn.socket.id}:`, error.message); + } } }); + debug(`📡 Broadcast complete: sent to ${sent} connections for workspace ${workspaceId}`); return sent; }); fastify.decorate('broadcastToContext', (contextId, event, payload) => { + debug(`📡 Broadcasting event "${event}" to context ${contextId}`); let sent = 0; connections.forEach((conn) => { if (conn.socket.subscriptions?.has?.(`context:${contextId}`)) { - try { conn.socket.emit(event, payload); sent++; } catch {} + try { + debug(` ➡️ Sending to socket ${conn.socket.id}`); + conn.socket.emit(event, payload); + sent++; + } catch (error) { + debug(` ❌ Failed to send to socket ${conn.socket.id}:`, error.message); + } } }); + debug(`📡 Broadcast complete: sent to ${sent} connections for context ${contextId}`); return sent; }); fastify.decorate('getUserConnectionCount', (userId) => { let c = 0; connections.forEach((conn) => { if (conn.user.id === userId) c++; }); + debug(`👥 User ${userId} has ${c} active connections`); return c; }); @@ -101,16 +166,26 @@ export default function setupWebSocketHandlers(fastify) { const { user } = socket; connections.set(socket.id, { socket, user, lastActivity: Date.now() }); + debug(`🔌 New WebSocket connection: ${socket.id} for user ${user.email} (${user.id})`); + debug(`👥 Total connections: ${connections.size}`); + // Register push modules + debug(`📋 Registering context WebSocket for socket ${socket.id}`); registerContextWebSocket(fastify, socket); + debug(`📋 Registering workspace WebSocket for socket ${socket.id}`); registerWorkspaceWebSocket(fastify, socket); socket.emit('authenticated', { userId: user.id, email: user.email }); + debug(`✅ Sent authentication confirmation to ${socket.id}`); // heartbeat - socket.on('ping', () => socket.emit('pong', { time: Date.now() })); + socket.on('ping', () => { + debug(`💗 Heartbeat from ${socket.id}`); + socket.emit('pong', { time: Date.now() }); + }); socket.on('disconnect', () => { + debug(`🔌 WebSocket disconnected: ${socket.id} for user ${user.email}`); connections.delete(socket.id); fastify.broadcastToUser(user.id, 'connection.change', { event: 'disconnect', diff --git a/src/env.js b/src/env.js index 51499b0f..02a655fa 100644 --- a/src/env.js +++ b/src/env.js @@ -19,10 +19,11 @@ const USER_HOME = process.env.CANVAS_USER_HOME || getUserHome(); export const env = { server: { - mode: SERVER_MODE, + mode: process.env.CANVAS_SERVER_MODE || SERVER_MODE, root: SERVER_ROOT, home: SERVER_HOME, logLevel: process.env.LOG_LEVEL || 'info', + host: process.env.CANVAS_SERVER_HOST || 'canvas.local', api: { enabled: process.env.CANVAS_DISABLE_API || true, protocol: process.env.CANVAS_API_PROTOCOL || 'http', @@ -34,7 +35,7 @@ export const env = { protocol: process.env.CANVAS_WEB_PROTOCOL || 'http', port: process.env.CANVAS_WEB_PORT || 8001, host: process.env.CANVAS_WEB_HOST || '0.0.0.0' - } + }, }, user: { home: USER_HOME @@ -45,7 +46,7 @@ export const env = { tokenExpiry: process.env.CANVAS_JWT_TOKEN_EXPIRY || '7d' }, admin: { - email: process.env.CANVAS_ADMIN_EMAIL || 'admin@universe.local', + email: process.env.CANVAS_ADMIN_EMAIL || 'admin@canvas.local', password: process.env.CANVAS_ADMIN_PASSWORD || null, // null will trigger auto-generation forceReset: process.env.CANVAS_ADMIN_RESET === 'true' || false } @@ -80,9 +81,3 @@ function getUserHome() { return path.join(SERVER_HOME, 'users'); } - -function generateJwtSecret() { - const randomSecret = crypto.randomBytes(64).toString('hex'); - console.log('[ENV] Generated random JWT secret (sessions will not persist across server restarts)'); - return randomSecret; -} diff --git a/src/managers/context/index.js b/src/managers/context/index.js index 48d90081..c0d9d709 100644 --- a/src/managers/context/index.js +++ b/src/managers/context/index.js @@ -6,7 +6,7 @@ import EventEmitter from 'eventemitter2'; // Logging import logger, { createDebug } from '../../utils/log/index.js'; -const debug = createDebug('context-manager'); +const debug = createDebug('context-manager:index'); // Includes import Context from './lib/Context.js'; @@ -184,13 +184,13 @@ class ContextManager extends EventEmitter { let contextInstance = null; // Check in-memory cache first if (this.#contexts.has(contextKey)) { - debug(`Returning cached Context instance for ${contextKey}`); + debug(`📋 ContextManager: Returning cached Context instance for ${contextKey}`); contextInstance = this.#contexts.get(contextKey); } else { // Try to load from store const storedContextData = this.#indexStore.get(contextKey); if (storedContextData) { - debug(`Context with key "${contextKey}" found in store, loading into memory.`); + debug(`📋 ContextManager: Context with key "${contextKey}" found in store, loading into memory.`); if (storedContextData.userId !== ownerUserId) { // This should ideally not happen if contextKey is correct, but good for sanity. throw new Error(`Mismatch in owner user ID. Expected ${ownerUserId}, found ${storedContextData.userId} in stored data for key ${contextKey}`); @@ -230,6 +230,8 @@ class ContextManager extends EventEmitter { const wildcardForwarder = function (payload = {}) { const eventName = this.event; const enriched = { ...payload, contextId: loadedContext.id }; + debug(`📋 ContextManager: 🎯 Forwarding event "${eventName}" from loaded context ${loadedContext.id}`); + debug(`📋 ContextManager: 🎯 Event payload:`, JSON.stringify(enriched, null, 2)); manager.emit(eventName, enriched); debug(`📋 ContextManager: ➡️ forwarded ${eventName} for loaded context ${loadedContext.id}`); }; @@ -302,7 +304,19 @@ class ContextManager extends EventEmitter { const ownedPrefix = `${accessingUserId}/`; for (const [key, contextInstance] of this.#contexts) { if (key.startsWith(ownedPrefix)) { - userContextsArray.push(contextInstance.toJSON()); + // Resolve owner ID to user email + try { + const ownerUser = await this.#workspaceManager.userManager.getUser(contextInstance.userId); + const contextWithOwnerEmail = { + ...contextInstance.toJSON(), + ownerEmail: ownerUser.email + }; + userContextsArray.push(contextWithOwnerEmail); + } catch (error) { + debug(`Failed to resolve owner email for in-memory context ${contextInstance.id}: ${error.message}`); + // Fallback to original entry if user resolution fails + userContextsArray.push(contextInstance.toJSON()); + } processedKeys.add(key); } } @@ -323,7 +337,19 @@ class ContextManager extends EventEmitter { // Check if it's an owned context (not already in memory) if (key.startsWith(ownedPrefix)) { - userContextsArray.push(storedContextData); + // Resolve owner ID to user email + try { + const ownerUser = await this.#workspaceManager.userManager.getUser(storedContextData.userId); + const contextWithOwnerEmail = { + ...storedContextData, + ownerEmail: ownerUser.email + }; + userContextsArray.push(contextWithOwnerEmail); + } catch (error) { + debug(`Failed to resolve owner email for context ${storedContextData.id}: ${error.message}`); + // Fallback to original entry if user resolution fails + userContextsArray.push(storedContextData); + } processedKeys.add(key); } else { // 3. Check if it's a context shared with the accessingUserId @@ -333,11 +359,24 @@ class ContextManager extends EventEmitter { // The accessingUserId has some level of access to this context. // We can add a flag or modify the data slightly if needed to indicate it's a shared context. // For now, just add the raw data. - userContextsArray.push({ - ...storedContextData, - isShared: true, // Indicate that this context is accessed via a share - sharedVia: storedContextData.acl[accessingUserId] // Optionally show the permission level - }); + try { + const ownerUser = await this.#workspaceManager.userManager.getUser(storedContextData.userId); + const contextWithOwnerEmail = { + ...storedContextData, + ownerEmail: ownerUser.email, + isShared: true, // Indicate that this context is accessed via a share + sharedVia: storedContextData.acl[accessingUserId] // Optionally show the permission level + }; + userContextsArray.push(contextWithOwnerEmail); + } catch (error) { + debug(`Failed to resolve owner email for shared context ${storedContextData.id}: ${error.message}`); + // Fallback to original entry if user resolution fails + userContextsArray.push({ + ...storedContextData, + isShared: true, // Indicate that this context is accessed via a share + sharedVia: storedContextData.acl[accessingUserId] // Optionally show the permission level + }); + } processedKeys.add(key); // Mark as processed to avoid duplicates if logic changes } } @@ -376,25 +415,34 @@ class ContextManager extends EventEmitter { try { const contextKey = this.#constructContextKey(userId, contextId); + let contextWasRemoved = false; if (this.#contexts.has(contextKey)) { const context = this.#contexts.get(contextKey); await context.destroy(); this.#contexts.delete(contextKey); + contextWasRemoved = true; } // Remove from index store if exists (which should be the case) if (this.#indexStore.has(contextKey)) { this.#indexStore.delete(contextKey); + contextWasRemoved = true; } - this.emit('context.deleted', { - contextKey: contextKey, - userId: userId, - contextId: contextId.toString() - }); - debug(`Context ${contextKey} removed.`); - return true; + // Only emit event and log if something was actually removed + if (contextWasRemoved) { + this.emit('context.deleted', { + contextKey: contextKey, + userId: userId, + contextId: contextId.toString() + }); + debug(`Context ${contextKey} removed.`); + return true; + } else { + debug(`Context ${contextKey} not found, nothing to remove.`); + return false; + } } catch (error) { debug(`Error removing context for user ${userId}: ${error.message}`); return false; @@ -428,6 +476,8 @@ class ContextManager extends EventEmitter { const wildcardForwarder = function (payload = {}) { const eventName = this.event; // EventEmitter2 provides the emitted event name const enriched = { ...payload, contextId: context.id }; + debug(`📋 ContextManager: 🎯 Forwarding event "${eventName}" from context ${context.id}`); + debug(`📋 ContextManager: 🎯 Event payload:`, JSON.stringify(enriched, null, 2)); manager.emit(eventName, enriched); debug(`📋 ContextManager: ➡️ forwarded ${eventName} for context ${context.id}`); }; @@ -466,16 +516,71 @@ class ContextManager extends EventEmitter { if (idStr.includes('/')) { const parts = idStr.split('/'); if (parts.length === 2 && parts[0] && parts[1]) { - // Potentially validate email format for parts[0] if needed + // Simple user/resource format: user.name/context.name or user.id/context.id return { ownerUserId: parts[0], contextId: this.#sanitizeContextId(parts[1]) }; } else { - throw new Error(`Invalid shared context identifier format: ${idStr}. Expected 'user@email.com/contextId'.`); + throw new Error(`Invalid context identifier format: ${idStr}. Expected 'user.name/context.name' or simple 'contextId'.`); } } // If no '/', it's a simple contextId, owner is the defaultUserId (usually the accessing user) return { ownerUserId: defaultUserId, contextId: this.#sanitizeContextId(idStr) }; } + /** + * Resolves a context ID from a simple context identifier + * @param {string} contextIdentifier - Simple identifier in format user.name/context.name + * @returns {Promise} The context ID if found, null otherwise + */ + async resolveContextIdFromSimpleIdentifier(contextIdentifier) { + try { + const { ownerUserId, contextId } = this.#parseContextIdentifier(contextIdentifier, null); + + // If no ownerUserId was parsed (simple contextId), return null as this method is for user/context format + if (!ownerUserId) { + return null; + } + + // Resolve the user identifier to a user ID if needed + const resolvedUserId = await this.#workspaceManager.userManager.resolveToUserId(ownerUserId); + if (!resolvedUserId) { + return null; + } + + // Check if context exists + const contextKey = this.#constructContextKey(resolvedUserId, contextId); + if (this.#contexts.has(contextKey) || this.#indexStore.has(contextKey)) { + return contextId; + } + + return null; + } catch (error) { + return null; + } + } + + /** + * Construct a simple resource address from context data + * @param {Object} context - Context object with userId and id + * @returns {Promise} Resource address in format user.name/context.id + */ + async constructResourceAddress(context) { + if (!context || !context.userId || !context.id) { + return null; + } + + try { + // Get user info to construct the address + const user = await this.#workspaceManager.userManager.getUser(context.userId); + if (!user || !user.name) { + return null; + } + + return `${user.name}/${context.id}`; + } catch (error) { + return null; + } + } + async grantContextAccess(requestingUserId, targetContextIdentifier, sharedWithUserId, accessLevel) { if (!this.#initialized) throw new Error('ContextManager not initialized'); if (!requestingUserId) throw new Error('Requesting User ID is required.'); diff --git a/src/managers/context/lib/Context.js b/src/managers/context/lib/Context.js index 433fd3aa..9dd84bb9 100644 --- a/src/managers/context/lib/Context.js +++ b/src/managers/context/lib/Context.js @@ -59,7 +59,14 @@ class Context extends EventEmitter { #pendingUrl; constructor(url = DEFAULT_BASE_URL, options = {}) { - super(); + // Enable wildcard events for EventEmitter2 so ContextManager can listen with ** + super({ + wildcard: true, + delimiter: '.', + newListener: false, + maxListeners: 50, + ...(options.eventEmitterOptions || {}) + }); // Context properties this.#id = options.id || uuidv4(); // TODO: Use human-typeable 6-char ULID @@ -124,19 +131,19 @@ class Context extends EventEmitter { this.#path = baseUrl.path; this.#pathArray = baseUrl.pathArray; } else { - // If no workspaceId in URL, use current workspace + // If no workspaceId in URL, use current workspace name if (!parsedUrl.workspaceId) { - this.#url = `${this.#workspace.id}://${parsedUrl.path.replace(/^\//, '')}`; + this.#url = `${this.#workspace.name}://${parsedUrl.path.replace(/^\//, '')}`; this.#path = parsedUrl.path; this.#pathArray = parsedUrl.pathArray; - } else if (parsedUrl.workspaceId === this.#workspace.id) { + } else if (parsedUrl.workspaceId === this.#workspace.name) { // Same workspace, use as-is this.#url = parsedUrl.url; this.#path = parsedUrl.path; this.#pathArray = parsedUrl.pathArray; } else { // Different workspace, store as pending for later switching - this.#url = `${this.#workspace.id}://${parsedUrl.path.replace(/^\//, '')}`; + this.#url = `${this.#workspace.name}://${parsedUrl.path.replace(/^\//, '')}`; this.#path = parsedUrl.path; this.#pathArray = parsedUrl.pathArray; this.#pendingUrl = url; @@ -182,6 +189,7 @@ class Context extends EventEmitter { get pathArray() { return this.#pathArray; } get workspace() { return this.#workspace; } get workspaceId() { return this.#workspace.id; } + get workspaceName() { return this.#workspace.name; } get tree() { return this.#tree.toJSON(); } get color() { return this.#color; } get pendingUrl() { return this.#pendingUrl; } @@ -378,12 +386,12 @@ class Context extends EventEmitter { } } - // Determine target workspace ID - const targetWorkspaceId = parsed.workspaceId || this.#workspace.id; + // Determine target workspace name + const targetWorkspaceName = parsed.workspaceId || this.#workspace.name; - // If the workspace ID is different, switch to the new workspace - if (targetWorkspaceId !== this.#workspace.id) { - await this.#switchWorkspace(targetWorkspaceId); + // If the workspace name is different, switch to the new workspace + if (targetWorkspaceName !== this.#workspace.name) { + await this.#switchWorkspace(targetWorkspaceName); } // Create the URL path in the current workspace @@ -392,7 +400,7 @@ class Context extends EventEmitter { debug(`ContextPath: ${parsed.path}, contextLayer IDs: ${JSON.stringify(contextLayers)}`); // Update the internal URL state - this.#url = `${this.#workspace.id}://${parsed.path.replace(/^\//, '')}`; + this.#url = `${this.#workspace.name}://${parsed.path.replace(/^\//, '')}`; this.#path = parsed.path; this.#pathArray = parsed.pathArray; @@ -400,6 +408,7 @@ class Context extends EventEmitter { this.#updatedAt = new Date().toISOString(); // Emit the change event + debug(`📋 Context: Emitting context.url.set event for context ${this.#id}, new URL: ${this.#url}`); this.emit('context.url.set', { id: this.#id, url: this.#url }); // Save changes to index @@ -421,7 +430,7 @@ class Context extends EventEmitter { throw new Error(`Invalid base URL format: ${newBaseUrl}`); } // Ensure the new base URL is within the same workspace - if (parsedNewBase.workspaceId && parsedNewBase.workspaceId !== this.#workspace.id) { + if (parsedNewBase.workspaceId && parsedNewBase.workspaceId !== this.#workspace.name) { throw new Error(`Cannot set base URL to a different workspace: ${newBaseUrl}`); } @@ -429,7 +438,7 @@ class Context extends EventEmitter { if (this.#url) { const currentParsed = new Url(this.#url); // Only check path if the current URL is actually in the same workspace - if ((!currentParsed.workspaceId || currentParsed.workspaceId === this.#workspace.id) && + if ((!currentParsed.workspaceId || currentParsed.workspaceId === this.#workspace.name) && !currentParsed.path.startsWith(parsedNewBase.path)) { throw new Error( `Current URL "${this.#url}" is outside the proposed new base URL "${newBaseUrl}". Please navigate within the new base URL before setting it.`, @@ -494,21 +503,21 @@ class Context extends EventEmitter { return Promise.resolve(this); } - async #switchWorkspace(workspaceId) { + async #switchWorkspace(workspaceName) { if (this.#isLocked) { throw new Error('Context is locked'); } - const hasWs = await this.#workspaceManager.hasWorkspace(this.#userId, workspaceId, this.#userId); + const hasWs = await this.#workspaceManager.hasWorkspace(this.#userId, workspaceName, this.#userId); if (!hasWs) { - throw new Error(`Workspace "${workspaceId}" not found`); + throw new Error(`Workspace "${workspaceName}" not found`); } try { // Clean up event forwarding from the old workspace this.#cleanupWorkspaceEventForwarding(); - const newWorkspaceInstance = await this.#workspaceManager.getWorkspace(this.#userId, workspaceId, this.#userId); + const newWorkspaceInstance = await this.#workspaceManager.getWorkspace(this.#userId, workspaceName, this.#userId); this.#workspace = newWorkspaceInstance; this.#db = this.#workspace.db; this.#tree = this.#workspace.tree; @@ -517,7 +526,7 @@ class Context extends EventEmitter { // Set up event forwarding for the new workspace this.#setupWorkspaceEventForwarding(); - debug(`Context "${this.#id}" successfully switched to workspace "${workspaceId}"`); + debug(`Context "${this.#id}" successfully switched to workspace "${workspaceName}"`); } catch (error) { throw new Error(`Failed to switch workspace: ${error.message}`); } @@ -600,6 +609,8 @@ class Context extends EventEmitter { timestamp: new Date().toISOString() }; + debug(`📋 Context: Emitting document.inserted event for context ${this.#id}, documentId: ${documentId}`); + debug(`📋 Context: Event payload:`, JSON.stringify(documentEventPayload, null, 2)); this.emit('document.inserted', documentEventPayload); this.emit('context.updated', { id: this.#id, @@ -684,7 +695,8 @@ class Context extends EventEmitter { timestamp: new Date().toISOString() }; - debug('#insertDocumentArray: Emitting document.inserted event with payload:', JSON.stringify(documentEventPayload, null, 2)); + debug(`📋 Context: Emitting document.inserted event for context ${this.#id}, documentIds: ${JSON.stringify(documentIds)}`); + debug(`📋 Context: Event payload:`, JSON.stringify(documentEventPayload, null, 2)); this.emit('document.inserted', documentEventPayload); return result; } @@ -1173,6 +1185,7 @@ class Context extends EventEmitter { path: this.#path, pathArray: this.#pathArray, workspaceId: this.#workspace?.id, + workspaceName: this.#workspace?.name, color: this.#color, acl: this.#acl, createdAt: this.#createdAt, diff --git a/src/managers/context/lib/Url.js b/src/managers/context/lib/Url.js index 7b570da4..88c6ba25 100644 --- a/src/managers/context/lib/Url.js +++ b/src/managers/context/lib/Url.js @@ -18,7 +18,7 @@ const DEFAULT_PATH = '/'; class Url { #raw; #url; - #workspaceId; + #workspaceName; #path; #pathArray; #valid = false; @@ -34,8 +34,12 @@ class Url { get url() { return this.#url; } // Full URL string + get workspaceName() { + return this.#workspaceName; + } get workspaceId() { - return this.#workspaceId; + // Keep backward compatibility - return workspaceName + return this.#workspaceName; } get path() { return this.#path; @@ -58,8 +62,8 @@ class Url { // Clean and normalize the URL const cleanedUrl = this.cleanUrl(url); - // Set the workspace ID - this.#workspaceId = this.parseWorkspace(cleanedUrl); + // Set the workspace name + this.#workspaceName = this.parseWorkspace(cleanedUrl); // Set the URL path this.#path = this.parsePath(cleanedUrl); @@ -74,7 +78,7 @@ class Url { } catch (error) { this.#valid = false; this.#url = null; - this.#workspaceId = null; + this.#workspaceName = null; this.#path = DEFAULT_PATH; this.#pathArray = []; @@ -115,10 +119,10 @@ class Url { let workspacePart = cleaned.substring(0, colonSlashIndex); let pathPart = cleaned.substring(colonSlashIndex + 3); - // Clean workspace part - only allow alphanumeric and underscores + // Clean workspace part - only allow alphanumeric and underscores workspacePart = workspacePart.replace(/[^a-zA-Z0-9_]/g, ''); - // If workspace becomes empty after cleaning, return just the path + // If workspace name becomes empty after cleaning, return just the path if (!workspacePart) { return this.cleanPath(pathPart) || '/'; } @@ -166,8 +170,8 @@ class Url { // Format the URL based on the parsed components formatUrl() { - if (this.#workspaceId) { - return `${this.#workspaceId}://${this.#path.replace(/^\//, '')}`; + if (this.#workspaceName) { + return `${this.#workspaceName}://${this.#path.replace(/^\//, '')}`; } else { return this.#path; } @@ -189,7 +193,7 @@ class Url { // Parse the path portion of the url parsePath(url) { - // Handle workspace format: workspace://path + // Handle workspace format: workspace-name://path const workspaceRegex = /^([a-zA-Z0-9_]+):\/\/(.*)$/; const workspaceMatch = url.match(workspaceRegex); diff --git a/src/managers/dotfile/README.md b/src/managers/dotfile/README.md new file mode 100644 index 00000000..2f61d9f6 --- /dev/null +++ b/src/managers/dotfile/README.md @@ -0,0 +1,49 @@ +# Dotfile manager + +## CLI + +[user_or_team]@[canvas-host]/[workspace_id]/[resource...] + +```bash +# Bind to local or remote context +$ context switch user_or_team@canvas-host/context_id +``` + + +```bash +$ canvas dotfile list | canvas dotfiles | dot list +# Returns: +# user_email/workspace_name/dotfile +# user_email/work/ssh +# user_email/work/vscode +# user_email/work/shell +# team-workspace/shell +# user_email/home/shell +# user_email/home/audio/audacious +# user_email/home/email/evolution + +$ ws workspace-name dotfile list | ws workspace-name dotfiles +# Returns for workspace workspace-name +# ssh +# vscode +# shell +# shell/bash + +$ ws workspace-name dotfile activate # activates all dotfiles within the current active workspace +$ ws workspace-name dotfile activate ssh # activates the ssh dotfile only +$ ws workspace-name dotfile deactivate ssh +$ ws workspace-name dotfile update optional-dotfile # update / git fetch updates for all dotfiles of the given workspace +$ ws workspace-name dotfile status optional-dotfile + +$ canvas dotfile status user_email/workspace_name/dotfile + + +$ context dotfiles # Dotfiles of the workspace of the current context +$ context dotfile activate ssh # Lets say context url is home://travel/bratislava, workspace is "home", dotfile activate ssh would activate dotfiles from the home workspace +# This functionality is meant just as a shortcut for now, no dynamic dotfile update kung-fu + + +# dot command +$ dot list +$ dot activate user@host:work/ssh +$ diff --git a/src/managers/user/index.js b/src/managers/user/index.js index 3f4fc6cb..cdfb0168 100644 --- a/src/managers/user/index.js +++ b/src/managers/user/index.js @@ -5,7 +5,7 @@ import path from 'path'; import { existsSync } from 'fs'; import EventEmitter from 'eventemitter2'; import validator from 'validator'; -import { generateULID } from '../../utils/id.js'; +import { generateNanoid } from '../../utils/id.js'; // Logging import logger, { createDebug } from '../../utils/log/index.js'; @@ -53,14 +53,10 @@ class UserManager extends EventEmitter { throw new Error('Index store is required for UserManager'); } - if (!options.workspaceManager) { - throw new Error('Workspace manager is required for UserManager'); - } - this.#rootPath = options.rootPath; this.#indexStore = options.indexStore; - this.#workspaceManager = options.workspaceManager; - this.#contextManager = options.contextManager; + this.#workspaceManager = options.workspaceManager; // Can be initially undefined + this.#contextManager = options.contextManager; // Can be initially undefined debug(`Initializing UserManager with user home directory rootPath: ${this.#rootPath}`); } @@ -85,14 +81,58 @@ class UserManager extends EventEmitter { get users() { return Array.from(this.#users.values()); } get workspaceManager() { return this.#workspaceManager; } + /** + * Setters for late dependency injection to solve circular dependencies. + */ + setWorkspaceManager(manager) { + if (!this.#workspaceManager) { + this.#workspaceManager = manager; + } + } + + setContextManager(manager) { + if (!this.#contextManager) { + this.#contextManager = manager; + } + } + /** * User Manager API */ + /** + * Resolve a user identifier (ID, email, or name) to a user ID. + * @param {string} identifier - The user ID, email, or name. + * @returns {Promise} The user ID if found, otherwise null. + */ + async resolveToUserId(identifier) { + if (!this.#initialized) throw new Error('UserManager not initialized'); + if (!identifier) return null; + + // Check if it's an ID + if (await this.hasUser(identifier)) { + return identifier; + } + + // Check if it's an email + if (validator.isEmail(identifier)) { + const userIdByEmail = this.#findUserIdByEmail(identifier); + if (userIdByEmail) return userIdByEmail; + } + + // Check if it's a name + const userIdByName = this.#findUserIdByName(identifier); + if (userIdByName) return userIdByName; + + return null; + } + /** * Create a new user with a Universe workspace * @param {Object} userData - User data + * @param {string} userData.name - User nickname/display name (required) * @param {string} userData.email - User email (required) + * @param {string} [userData.id] - User ID (if not provided, generates 8-char lowercase nanoid) * @param {string} [userData.userType='user'] - User type: 'user' or 'admin' * @param {string} [userData.status='active'] - User status * @returns {Promise} Created user @@ -101,20 +141,37 @@ class UserManager extends EventEmitter { if (!this.#initialized) throw new Error('UserManager not initialized'); debug(`createUser: Creating user with data: ${JSON.stringify(userData)}`); + const id = userData.id || generateNanoid(8); + try { this.#validateUserSettings(userData); const email = userData.email.toLowerCase(); - const id = userData.id || email; + const name = userData.name; const userHomePath = userData.homePath || path.join(this.#rootPath, email); if (await this.hasUser(id)) throw new Error(`User already exists with ID: ${id}`); if (await this.hasUserByEmail(email)) throw new Error(`User already exists with email: ${email} (ID: ${id})`); + // Pre-register user in index so workspace creation can resolve the ID + const preliminaryUserData = { + id, + name, + email, + homePath: userHomePath, + userType: userData.userType || 'user', + status: 'pending', // Mark as pending until fully created + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + this.#indexStore.set(id, preliminaryUserData); + debug(`Pre-registered user in index: ${id}`); + await this.#createHomeDirectory(userHomePath, id, email); const user = await this.#initializeUser({ id, + name, email, homePath: userHomePath, userType: userData.userType || 'user', @@ -127,10 +184,18 @@ class UserManager extends EventEmitter { }); this.#setupUserEventListeners(user); - this.emit('user.created', { id, email }); - debug(`User created: ${user.email} (ID: ${user.id})`); + this.emit('user.created', { id, name, email }); + debug(`User created: ${user.name} (${user.email}) (ID: ${user.id})`); return user; } catch (error) { + // Rollback pre-registration if creation fails + if (this.#indexStore.has(id)) { + const storedData = this.#indexStore.get(id); + if (storedData?.status === 'pending') { + this.#indexStore.delete(id); + debug(`Rolled back pre-registration for user: ${id}`); + } + } debug(`Error creating user: ${error.message}`); throw error; } @@ -184,6 +249,22 @@ class UserManager extends EventEmitter { return this.getUser(id); } + /** + * Get a user by name + * @param {string} name - User name + * @returns {Promise} User instance + */ + async getUserByName(name) { + if (!this.#initialized) { + throw new Error('UserManager not initialized'); + } + const id = this.#findUserIdByName(name); + if (!id) { + throw new Error(`User not found by name: ${name}`); + } + return this.getUser(id); + } + /** * Check if user exists by ID * @param {string} id - User ID @@ -256,6 +337,7 @@ class UserManager extends EventEmitter { const updateDataForValidation = { ...userData, homePath: currentUserDataFromIndex.homePath, + originalName: currentUserDataFromIndex.name, }; try { this.#validateUserSettings(updateDataForValidation, true); @@ -266,7 +348,7 @@ class UserManager extends EventEmitter { const updatedUserDataToStore = { ...currentUserDataFromIndex, ...userData, - updated: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; this.#indexStore.set(id, updatedUserDataToStore); @@ -324,14 +406,14 @@ class UserManager extends EventEmitter { throw new Error(`User not found: ${userId}`); } - const universeWorkspace = await this.#workspaceManager.getWorkspace(user.email, 'universe'); + const universeWorkspace = await this.#workspaceManager.getWorkspace(user.id, 'universe', user.id); if (!universeWorkspace) { throw new Error(`Universe workspace not found for user: ${user.email}`); } // This is handled by our stupid to-be-refactored/renamed getWorkspace method if (universeWorkspace.status !== 'running') { - await this.#workspaceManager.startWorkspace(user.email, 'universe'); + await this.#workspaceManager.startWorkspace(user.id, 'universe', user.id); } return true; @@ -383,7 +465,7 @@ class UserManager extends EventEmitter { } try { - await this.#workspaceManager.createWorkspace(userEmail, 'universe', { + await this.#workspaceManager.createWorkspace(userId, 'universe', { workspacePath: userHomePath, type: 'universe', owner: userId, @@ -398,14 +480,15 @@ class UserManager extends EventEmitter { } async #initializeUser(userData) { - debug(`Initializing user: ${userData.email} (ID: ${userData.id})`); + debug(`Initializing user: ${userData.name} (${userData.email}) (ID: ${userData.id})`); if (!userData.id) { throw new Error('User ID is required for #initializeUser'); } + if (!userData.name) { throw new Error('Name is required for #initializeUser'); } if (!userData.email) { throw new Error('Email is required for #initializeUser'); } if (!userData.homePath) { throw new Error('Home path is required for #initializeUser'); } const userOptions = { - ...userData, // This has id, email, homePath, userType, status + ...userData, // This has id, name, email, homePath, userType, status eventEmitterOptions: this.eventEmitterOptions }; @@ -416,14 +499,14 @@ class UserManager extends EventEmitter { this.#saveEntry(user.id, user); // Start the universe workspace - this is critical for user functionality - debug(`Starting universe workspace for user ${user.email}`); + debug(`Starting universe workspace for user ${user.name} (${user.email})`); - const workspace = await this.#workspaceManager.startWorkspace(user.email, 'universe', user.id); + const workspace = await this.#workspaceManager.startWorkspace(user.id, 'universe', user.id); if (!workspace) { - throw new Error(`Failed to start universe workspace for user ${user.email}`); + throw new Error(`Failed to start universe workspace for user ${user.name} (${user.email})`); } - debug(`Universe workspace started successfully for user ${user.email}`); + debug(`Universe workspace started successfully for user ${user.name} (${user.email})`); // Setup event listeners after workspace is started this.#setupUserEventListeners(user); @@ -443,11 +526,65 @@ class UserManager extends EventEmitter { throw new Error('User settings are required'); } - // Email is required for new users, and must be valid if provided + // Name and email are required for new users, and must be valid if provided + if (!isUpdate && !userSettings.name) { + throw new Error('User name is required'); + } + if (!isUpdate && !userSettings.email) { throw new Error('User email is required'); } + if (userSettings.name) { + if (typeof userSettings.name !== 'string') { + throw new Error('User name must be a string'); + } + + if (userSettings.name.trim().length === 0) { + throw new Error('User name cannot be empty'); + } + + // GitHub-style username validation + const username = userSettings.name.toLowerCase().trim(); + const usernameRegex = /^[a-z0-9_-]+$/; + + if (!usernameRegex.test(username)) { + throw new Error('User name can only contain lowercase letters, numbers, underscores, and hyphens'); + } + + if (username.length < 3) { + throw new Error('User name must be at least 3 characters long'); + } + + if (username.length > 39) { + throw new Error('User name cannot be longer than 39 characters'); + } + + // Check for reserved names (GitHub-style) + const reservedNames = [ + 'admin', 'administrator', 'root', 'system', 'support', 'help', + 'api', 'www', 'mail', 'ftp', 'localhost', 'test', 'demo', + 'canvas', 'universe', 'workspace', 'context', 'user', 'users' + ]; + + if (reservedNames.includes(username)) { + // Allow 'admin' username only for admin user type + if (username === 'admin' && userSettings.userType === 'admin') { + // Allow admin username for admin user type + } else { + throw new Error(`User name '${username}' is reserved and cannot be used`); + } + } + + // Check for uniqueness (only for new users or name changes) + if (!isUpdate || (isUpdate && userSettings.name !== userSettings.originalName)) { + this.#validateUsernameUniqueness(username, userSettings.id); + } + + // Update the name to the validated format + userSettings.name = username; + } + if (userSettings.email && !validator.isEmail(userSettings.email)) { throw new Error('Invalid user email'); } @@ -463,6 +600,35 @@ class UserManager extends EventEmitter { } } + /** + * Validate that a username is unique across all users + * @param {string} username - Username to validate + * @param {string} [excludeUserId] - User ID to exclude from uniqueness check (for updates) + * @throws {Error} If username is not unique + * @private + */ + #validateUsernameUniqueness(username, excludeUserId = null) { + // Check in-memory users first + for (const user of this.#users.values()) { + if (excludeUserId && user.id === excludeUserId) { + continue; // Skip the user being updated + } + if (user.name === username) { + throw new Error(`User name '${username}' is already taken`); + } + } + + // Check in the index store + for (const [id, userData] of Object.entries(this.#indexStore.store || {})) { + if (excludeUserId && id === excludeUserId) { + continue; // Skip the user being updated + } + if (userData?.name === username) { + throw new Error(`User name '${username}' is already taken`); + } + } + } + /** * Find a user ID by email in memory or store * @param {string} email - User email @@ -480,6 +646,23 @@ class UserManager extends EventEmitter { return null; } + /** + * Find a user ID by name in memory or store + * @param {string} name - User name + * @returns {string|null} User ID if found, otherwise null + * @private + */ + #findUserIdByName(name) { + const lower = name.toLowerCase(); + for (const user of this.#users.values()) { + if (user.name.toLowerCase() === lower) return user.id; + } + for (const [id, data] of Object.entries(this.#indexStore.store || {})) { + if (data?.name?.toLowerCase() === lower) return id; + } + return null; + } + #setupUserEventListeners(user) { user.on('create', (data) => { debug(`User created: ${data.email} (ID: ${data.id})`, data); diff --git a/src/managers/user/lib/User.js b/src/managers/user/lib/User.js index 944af440..c9ae7dcb 100644 --- a/src/managers/user/lib/User.js +++ b/src/managers/user/lib/User.js @@ -3,7 +3,6 @@ // Utils import path from 'path'; import EventEmitter from 'eventemitter2'; -import { generateULID } from '../../../utils/id.js'; // Logging import logger, { createDebug } from '../../../utils/log/index.js'; @@ -19,6 +18,7 @@ import { USER_STATUS_CODES } from '../index.js'; class User extends EventEmitter { #id; + #name; #email; #userType; #authMethod; @@ -32,8 +32,9 @@ class User extends EventEmitter { /** * Create a new User instance * @param {Object} options - User options - * @param {string} options.id - User ID - * @param {string} options.email - User email + * @param {string} [options.id] - User ID (if not provided, generates 8-char lowercase nanoid) + * @param {string} options.name - User nickname/display name (required) + * @param {string} options.email - User email (required) * @param {string} options.authMethod - User auth method (imap, local, etc.) * @param {string} options.homePath - User home path (Universe workspace) * @param {string} [options.userType='user'] - User type ('user' or 'admin') @@ -44,6 +45,7 @@ class User extends EventEmitter { // Validate required options if (!options.id) { throw new Error('ID is required'); } + if (!options.name) { throw new Error('Name is required'); } if (!options.email) { throw new Error('Email is required'); } if (!options.homePath) { throw new Error('Home path is required'); } if (!options.avatar) { options.avatar = '/images/avatars/default.png'; } @@ -52,14 +54,15 @@ class User extends EventEmitter { * User properties */ - this.#id = options.id || generateULID(12, 'lower'); + this.#id = options.id; + this.#name = options.name; this.#email = options.email; this.#authMethod = options.authMethod || 'local'; this.#avatar = options.avatar; this.#homePath = path.resolve(options.homePath); // Ensure absolute path this.#userType = options.userType || 'user'; this.#status = options.status || 'inactive'; - debug(`User instance created: ${this.#id} (${this.#email}) with home path: ${this.#homePath}`); + debug(`User instance created: ${this.#id} (${this.#name} - ${this.#email}) with home path: ${this.#homePath}`); } /** @@ -67,6 +70,7 @@ class User extends EventEmitter { */ get id() { return this.#id; } + get name() { return this.#name; } get email() { return this.#email; } get userType() { return this.#userType; } get authMethod() { return this.#authMethod; } @@ -86,6 +90,7 @@ class User extends EventEmitter { this.#status = status; this.emit('update', { id: this.#id, + name: this.#name, email: this.#email, status: this.#status }); @@ -114,6 +119,7 @@ class User extends EventEmitter { toJSON() { return { id: this.#id, + name: this.#name, email: this.#email, userType: this.#userType, authMethod: this.#authMethod, diff --git a/src/managers/workspace/index.js b/src/managers/workspace/index.js index 680501d9..6e85eff7 100644 --- a/src/managers/workspace/index.js +++ b/src/managers/workspace/index.js @@ -7,6 +7,7 @@ import * as fsPromises from 'fs/promises'; import { existsSync } from 'fs'; import EventEmitter from 'eventemitter2'; import Conf from 'conf'; +import { generateUUID } from '../../utils/id.js'; // import AdmZip from 'adm-zip'; // Logging @@ -20,10 +21,14 @@ import Workspace from './lib/Workspace.js'; * Constants */ -// Workspace ID format: user.id/workspace.id -// Example: user123/my-project -// Example: user@domain.com/my-project -// We need to cleanup the whole implementation! +// Default host for local workspaces +const DEFAULT_HOST = 'canvas.local'; + +// Workspace reference format: [user_identifier]@[host]:[workspace_slug][/optional_path...] +// Examples: +// - user.id@canvas.local:my-project +// - user.name@canvas.local:my-project +// - user.email@remote.server.com:shared-workspace/subfolder const WORKSPACE_CONFIG_FILENAME = 'workspace.json'; const WORKSPACE_DIRECTORIES = { @@ -50,30 +55,156 @@ const WORKSPACE_STATUS_CODES = { }; // Default configuration template for a new workspace's workspace.json -// Keeping things minimal for now +// Using token-based ACLs for portable workspace sharing const DEFAULT_WORKSPACE_CONFIG = { - id: null, // Set to user.id/workspace.id + id: null, // Set to 12-char nanoid (opaque identifier) + name: null, // User-defined slug-like name owner: null, // User ID (email) type: 'workspace', // "workspace" or "universe" (user home directory) label: 'Workspace', color: null, description: '', - acl: {}, + acl: { + tokens: {} // Token-based ACL: { "sha256:hash": { permissions: [], description: "", createdAt: "", expiresAt: null } } + }, created: null, updated: null, }; /** - * Workspace Manager (Simplified) + * Workspace Reference Utilities + */ + +/** + * Parse simple workspace identifier in format user.name/workspace.name + * @param {string} workspaceIdentifier - Simple workspace identifier + * @returns {Object|null} Parsed identifier or null if invalid + */ +function parseSimpleWorkspaceIdentifier(workspaceIdentifier) { + if (!workspaceIdentifier || typeof workspaceIdentifier !== 'string') { + return null; + } + + if (workspaceIdentifier.includes('/')) { + const parts = workspaceIdentifier.split('/'); + if (parts.length === 2 && parts[0] && parts[1]) { + return { + userIdentifier: parts[0].trim(), + workspaceIdentifier: parts[1].trim(), + full: workspaceIdentifier + }; + } + } + + return null; // Not a user/workspace format +} + +/** + * Parse workspace reference in format [user_identifier]@[host]:[workspace_slug][/optional_path...] + * @param {string} workspaceRef - Workspace reference string + * @returns {Object|null} Parsed reference or null if invalid + */ +function parseWorkspaceReference(workspaceRef) { + if (!workspaceRef || typeof workspaceRef !== 'string') { + return null; + } + + const colonIndex = workspaceRef.indexOf(':'); + if (colonIndex === -1 || colonIndex === workspaceRef.length - 1) { + return null; // No colon or empty resource part + } + + const userHostPart = workspaceRef.substring(0, colonIndex); + const resourcePart = workspaceRef.substring(colonIndex + 1); + + const atIndex = userHostPart.lastIndexOf('@'); + if (atIndex === -1 || atIndex === 0 || atIndex === userHostPart.length - 1) { + return null; // No '@' or it's at the start/end + } + + const userIdentifier = userHostPart.substring(0, atIndex).trim(); + const host = userHostPart.substring(atIndex + 1).trim(); + + if (!userIdentifier || !host) { + return null; + } + + const [workspaceSlug, ...optionalPathParts] = resourcePart.split('/'); + const optionalPath = optionalPathParts.length > 0 ? '/' + optionalPathParts.join('/') : ''; + + + return { + userIdentifier, + host, + workspaceSlug: workspaceSlug.trim(), + path: optionalPath || '', + full: workspaceRef, + isLocal: host === DEFAULT_HOST, + isRemote: host !== DEFAULT_HOST + }; +} + +/** + * Construct workspace reference string + * @param {string} userIdentifier - User ID, name, or email + * @param {string} workspaceSlug - Workspace slug/name + * @param {string} [host=DEFAULT_HOST] - Host (defaults to canvas.local) + * @param {string} [path=''] - Optional path within workspace + * @returns {string} Workspace reference string + */ +function constructWorkspaceReference(userIdentifier, workspaceSlug, host = DEFAULT_HOST, path = '') { + if (!userIdentifier || !workspaceSlug) { + throw new Error('userIdentifier and workspaceSlug are required to construct a workspace reference.'); + } + return `${userIdentifier}@${host}:${workspaceSlug}${path}`; +} + +/** + * Construct workspace index key from user ID and workspace ID + * @param {string} userId - User ID + * @param {string} workspaceId - Workspace ID (12-char nanoid) + * @returns {string} Index key for internal storage + */ +function constructWorkspaceIndexKey(userId, workspaceId) { + // Use forward slash separator to match context format: user.id/workspace.id + return `${userId}/${workspaceId}`; +} + +/** + * Parse workspace index key to extract user ID and workspace ID + * @param {string} indexKey - Index key from storage + * @returns {Object|null} Parsed {userId, workspaceId} or null if invalid + */ +function parseWorkspaceIndexKey(indexKey) { + if (!indexKey || typeof indexKey !== 'string') { + return null; + } + + const parts = indexKey.split('/'); + if (parts.length !== 2) { + return null; + } + + return { + userId: parts[0], + workspaceId: parts[1] + }; +} + +/** + * Workspace Manager (Enhanced with new naming convention) */ class WorkspaceManager extends EventEmitter { #defaultRootPath; // Default Root path for all user workspaces managed by this instance (e.g., /users) - #indexStore; // Persistent index of all workspaces + #indexStore; // Persistent index of all workspaces (key: userId/workspace.id -> workspace data) + #nameIndex; // Secondary index for name lookups (key: userId@host:workspaceName -> workspace.id) + #referenceIndex; // Tertiary index for full reference lookups (key: userIdentifier@host:workspaceName -> workspace.id) + #userManager; // UserManager instance for resolving user identifiers // Runtime - #workspaces = new Map(); // Cache for loaded Workspace instances (key: userId/workspaceId -> Workspace) + #workspaces = new Map(); // Cache for loaded Workspace instances (key: workspace.id -> Workspace) #initialized = false; // Add initialized flag /** @@ -81,6 +212,7 @@ class WorkspaceManager extends EventEmitter { * @param {Object} options - Configuration options * @param {string} options.defaultRootPath - Root path where user workspace directories are stored * @param {Object} options.indexStore - Initialized Conf instance for the workspace index + * @param {Object} options.userManager - Initialized UserManager instance * @param {Object} [options.eventEmitterOptions] - Options for EventEmitter2 */ constructor(options = {}) { @@ -94,18 +226,85 @@ class WorkspaceManager extends EventEmitter { throw new Error('Index store is required for WorkspaceManager'); } + if (!options.userManager) { + throw new Error('UserManager is required for WorkspaceManager'); + } + this.#defaultRootPath = path.resolve(options.defaultRootPath); // Ensure absolute path this.#indexStore = options.indexStore; + this.#userManager = options.userManager; // Store userManager instance + this.#nameIndex = new Map(); // In-memory secondary index for name lookups + this.#referenceIndex = new Map(); // In-memory tertiary index for reference lookups debug(`Initializing WorkspaceManager with default rootPath: ${this.#defaultRootPath}`); } + /** + * Getters + */ + + get userManager() { return this.#userManager; } + + /** + * Private helper to construct workspace index key + * @param {string} userId - User ID + * @param {string} workspaceId - Workspace ID + * @returns {string} Index key in format userId/workspaceId + * @private + */ + #constructWorkspaceIndexKey(userId, workspaceId) { + return constructWorkspaceIndexKey(userId, workspaceId); + } + + /** + * Private helper to parse workspace index key + * @param {string} indexKey - Index key in format userId/workspaceId + * @returns {Object|null} Parsed {userId, workspaceId} or null if invalid + * @private + */ + #parseWorkspaceIndexKey(indexKey) { + return parseWorkspaceIndexKey(indexKey); + } + + /** + * Parse workspace reference string + * @param {string} workspaceRef - Workspace reference in format userIdentifier@host:workspaceSlug[/path] + * @returns {Object|null} Parsed reference or null if invalid + */ + parseWorkspaceReference(workspaceRef) { + return parseWorkspaceReference(workspaceRef); + } + + /** + * Parse simple workspace identifier in format user.name/workspace.name + * @param {string} workspaceIdentifier - Simple workspace identifier + * @returns {Object|null} Parsed identifier or null if invalid + */ + parseSimpleWorkspaceIdentifier(workspaceIdentifier) { + return parseSimpleWorkspaceIdentifier(workspaceIdentifier); + } + + /** + * Construct workspace reference string + * @param {string} userIdentifier - User ID, name, or email + * @param {string} workspaceSlug - Workspace slug/name + * @param {string} [host=DEFAULT_HOST] - Host (defaults to canvas.local) + * @param {string} [path=''] - Optional path within workspace + * @returns {string} Workspace reference string + */ + constructWorkspaceReference(userIdentifier, workspaceSlug, host = DEFAULT_HOST, path = '') { + return constructWorkspaceReference(userIdentifier, workspaceSlug, host, path); + } + /** * Initialization */ async initialize() { if (this.#initialized) { return true; } + // Rebuild name and reference indexes from existing workspaces + await this.#rebuildIndexes(); + // Scan the index for all workspaces await this.#scanIndexedWorkspaces(); @@ -123,40 +322,47 @@ class WorkspaceManager extends EventEmitter { /** * Creates a new workspace directory, config file, and adds it to the index. - * @param {string} userId - The identifier used for key prefix - * @param {string} workspaceId - The desired workspaceId for the workspace. + * @param {string} userId - The user ID for key prefix (must be user.id, not user.email) + * @param {string} workspaceName - The desired workspace name (slug-like identifier). * @param {Object} options - Additional options for workspace config. * @param {string} options.owner - The ULID of the user who owns this workspace. * @param {string} [options.rootPath] - Custom root for this workspace path. * @param {string} [options.workspacePath] - Absolute path for out-of-tree workspace. * @param {string} [options.type='workspace'] - Type of workspace. + * @param {string} [options.host=DEFAULT_HOST] - Host for the workspace reference. * @returns {Promise} The index entry of the newly created workspace. */ - async createWorkspace(userId, workspaceId, options = {}) { + async createWorkspace(userId, workspaceName, options = {}) { if (!this.#initialized) throw new Error('WorkspaceManager not initialized'); if (!userId) throw new Error('userId required to create a workspace.'); - if (!workspaceId) throw new Error('Workspace ID required to create a workspace.'); + if (!workspaceName) throw new Error('Workspace name required to create a workspace.'); - // Sanitize the workspace ID - workspaceId = this.#sanitizeWorkspaceId(workspaceId); + // The provided userId is now treated as an identifier that needs resolution + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + throw new Error(`Could not resolve user identifier: "${userId}"`); + } - // Construct the workspace key - const workspaceKey = this.#constructWorkspaceKey(userId, workspaceId); + // Sanitize the workspace name + workspaceName = this.#sanitizeWorkspaceName(workspaceName); + const host = options.host || DEFAULT_HOST; - if (this.#indexStore.has(workspaceKey)) { - throw new Error(`Workspace with key "${workspaceKey}" already exists for user ${userId}.`); + // Generate unique workspace ID + const workspaceId = options.id || generateUUID(); + + // Check if workspace name already exists for this user on this host + const referenceKey = this.constructWorkspaceReference(userId, workspaceName, host); + if (this.#referenceIndex.has(referenceKey)) { + throw new Error(`Workspace with name "${workspaceName}" already exists for user ${userId} on host ${host}.`); } - // Determine workspace directory path + // Determine workspace directory path (using name for filesystem) const workspaceDir = options.workspacePath || - (options.rootPath ? path.join(options.rootPath, workspaceId) : - path.join(this.#defaultRootPath, userId, WORKSPACE_DIRECTORIES.workspaces, workspaceId)); - debug(`Using workspace path: ${workspaceDir}`); + (options.rootPath ? path.join(options.rootPath, workspaceName) : + path.join(this.#defaultRootPath, ownerId, WORKSPACE_DIRECTORIES.workspaces, workspaceName)); + debug(`Using workspace path: ${workspaceDir} for workspace ${workspaceId}`); // Validate and create workspace - if (this.#indexStore.has(workspaceKey)) { - throw new Error(`Workspace with key "${workspaceKey}" already exists.`); - } if (existsSync(workspaceDir)) { console.warn(`Workspace directory "${workspaceDir}" already exists.`); } @@ -173,66 +379,128 @@ class WorkspaceManager extends EventEmitter { const workspaceConfigPath = path.join(workspaceDir, WORKSPACE_CONFIG_FILENAME); const isUniverse = options.type === 'universe'; const configData = { - ...DEFAULT_WORKSPACE_CONFIG, - id: (isUniverse ? 'universe' : workspaceId), - label: isUniverse ? 'Universe' : (options.label || workspaceId), - description: isUniverse ? 'And then there was geometry..' : options.description || '', - owner: userId, - color: isUniverse ? '#ffffff' : options.color || WorkspaceManager.getRandomColor(), - type: options.type || 'workspace', - acl: options.acl || { "rw": [userId], "ro": [] }, - created: new Date().toISOString(), - updated: new Date().toISOString(), + id: workspaceId, + name: isUniverse ? 'universe' : workspaceName, + label: isUniverse ? 'Universe' : (options.label || workspaceName), + description: options.description || '', + owner: ownerId, + color: isUniverse ? '#ffffff' : (options.color || this.getRandomColor()), + type: isUniverse ? 'universe' : 'workspace', + status: 'inactive', + host: host, // Add host field to workspace data + rootPath: workspaceDir, + configPath: workspaceConfigPath, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + metadata: options.metadata || {}, + tokens: {} // Token-based ACL: { "sha256:hash": { permissions: [], description: "", createdAt: "", expiresAt: null } } }; - new Conf({ configName: path.basename(workspaceConfigPath, '.json'), cwd: workspaceDir }).store = configData; + new Conf({ + configName: path.basename(workspaceConfigPath, '.json'), + cwd: workspaceDir, + accessPropertiesByDotNotation: false + }).store = configData; + debug(`Created workspace config file: ${workspaceConfigPath}`); - // Create index entry - const indexEntry = { ...configData, rootPath: workspaceDir, configPath: workspaceConfigPath, status: WORKSPACE_STATUS_CODES.AVAILABLE, lastAccessed: null }; - this.#indexStore.set(workspaceKey, indexEntry); - this.emit('workspace.created', { userId, workspaceId, workspaceKey, workspace: indexEntry }); - debug(`Workspace created: ${workspaceKey}`); + // Create index entry with user.id prefix + const indexEntry = { + ...configData, + status: WORKSPACE_STATUS_CODES.AVAILABLE, + lastAccessed: null + }; + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + + // Use userId/workspaceId as primary key + this.#indexStore.set(indexKey, indexEntry); + + // Add to reference index for lookups + this.#referenceIndex.set(referenceKey, workspaceId); + + // Add to name index for backward compatibility and various user identifier lookups + const nameKey = `${userId}@${host}:${workspaceName}`; + this.#nameIndex.set(nameKey, workspaceId); + + // Add reference field to the index entry + indexEntry.reference = referenceKey; + + this.emit('workspace.created', { userId: ownerId, workspaceId, workspaceName, workspace: indexEntry }); + debug(`Workspace created: ${workspaceId} (name: ${workspaceName}) for user ${ownerId} on host ${host}`); return indexEntry; } /** * Opens a workspace, loading it into memory if not already loaded. - * @param {string} userId - The owner identifier - * @param {string} workspaceId - The short ID/name of the workspace. + * @param {string} userId - The owner identifier (can be ID, name, or email). + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request (for ownership check). * @returns {Promise} The loaded Workspace instance. */ - async openWorkspace(userId, workspaceId, requestingUserId) { + async openWorkspace(userId, workspaceIdentifier, requestingUserId) { if (!this.#initialized) { throw new Error('WorkspaceManager not initialized. Cannot open workspace.'); } - if (!userId || !workspaceId) { - throw new Error(`userId and workspaceId are required to open a workspace, got userId: ${userId}, workspaceId: ${workspaceId}`); + if (!userId || !workspaceIdentifier) { + throw new Error(`userId and workspaceIdentifier are required to open a workspace, got userId: ${userId}, workspaceIdentifier: ${workspaceIdentifier}`); + } + + // Resolve the provided userId (which can be an identifier) to an actual user ID. + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`openWorkspace failed: Could not resolve user identifier "${userId}"`); + return null; } - // This is not the brightest idea but convenient for now if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; + } + + // Try to parse as workspace reference first + const parsedRef = this.parseWorkspaceReference(workspaceIdentifier); + let workspaceId; + + if (parsedRef) { + // Full reference format: userIdentifier@host:workspaceSlug[/path] + workspaceId = this.#referenceIndex.get(parsedRef.full.split('/')[0]); // Remove path for lookup + if (!workspaceId) { + debug(`openWorkspace failed: No workspace found with reference "${workspaceIdentifier}"`); + return null; + } + } else { + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); + + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + // Try to resolve as workspace name for the given user (using original identifier) + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`openWorkspace failed: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return null; + } + } } - const workspaceKey = `${userId}/${workspaceId}`; - debug(`Opening workspace: ${workspaceKey} for requestingUser: ${requestingUserId}`); + debug(`Opening workspace: ${workspaceId} (identifier: ${workspaceIdentifier}) for requestingUser: ${requestingUserId}`); // Return from cache if available and owner matches - if (this.#workspaces.has(workspaceKey)) { - debug(`Returning cached Workspace instance for ${workspaceKey}`); - const cachedWs = this.#workspaces.get(workspaceKey); + if (this.#workspaces.has(workspaceId)) { + debug(`Returning cached Workspace instance for ${workspaceId}`); + const cachedWs = this.#workspaces.get(workspaceId); if (cachedWs.owner !== requestingUserId) { - console.error(`Ownership mismatch for cached workspace ${workspaceKey}. Owner: ${cachedWs.owner}, Requester: ${requestingUserId}`); + console.error(`Ownership mismatch for cached workspace ${workspaceId}. Owner: ${cachedWs.owner}, Requester: ${requestingUserId}`); return null; } return cachedWs; } - // Load from index - const entry = this.#indexStore.get(workspaceKey); - if (!this.#validateWorkspaceEntryForOpen(entry, workspaceKey, requestingUserId)) { + // Load from index using the new index key format + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + const entry = this.#indexStore.get(indexKey); + if (!this.#validateWorkspaceEntryForOpen(entry, workspaceId, requestingUserId)) { return null; // Validation failed, details logged in helper } @@ -245,196 +513,314 @@ class WorkspaceManager extends EventEmitter { const workspace = new Workspace({ rootPath: entry.rootPath, configStore: conf, + eventEmitterOptions: { + wildcard: true, + delimiter: '.', + newListener: false, + maxListeners: 50 + } }); - this.#workspaces.set(workspaceKey, workspace); - debug(`Loaded and cached Workspace instance for ${workspaceKey}`); - this.#updateWorkspaceIndexEntry(workspaceKey, { lastAccessed: new Date().toISOString() }); + this.#workspaces.set(workspaceId, workspace); + debug(`Loaded and cached Workspace instance for ${workspaceId}`); + this.#updateWorkspaceIndexEntry(indexKey, { lastAccessed: new Date().toISOString() }); return workspace; } catch (err) { - console.error(`openWorkspace failed: Could not load config or instantiate Workspace for ${workspaceKey}: ${err.message}`); + console.error(`openWorkspace failed: Could not load config or instantiate Workspace for ${workspaceId}: ${err.message}`); return null; } } /** * Closes a workspace, removing it from the memory cache after stopping it. - * @param {string} userId - The owner identifier. - * @param {string} workspaceId - The workspace ID. + * @param {string} userId - The owner identifier (can be ID, name, or email). + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request. * @returns {Promise} True if closed or not loaded, false on failure to stop. */ - async closeWorkspace(userId, workspaceId, requestingUserId) { + async closeWorkspace(userId, workspaceIdentifier, requestingUserId) { if (!this.#initialized) throw new Error('WorkspaceManager not initialized'); - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'closeWorkspace'); + this.#ensureRequiredParams({ userId, workspaceIdentifier: workspaceIdentifier, requestingUserId }, 'closeWorkspace'); - const workspaceKey = `${userId}/${workspaceId}`; + // Resolve the provided userId (which can be an identifier) to an actual user ID. + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`closeWorkspace failed: Could not resolve user identifier "${userId}"`); + return false; + } + + // Resolve workspace identifier to ID + let workspaceId; + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); - if (!this.#workspaces.has(workspaceKey)) { - debug(`closeWorkspace: Workspace ${workspaceKey} is not loaded in memory.`); + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`closeWorkspace: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return false; + } + } + + if (!this.#workspaces.has(workspaceId)) { + debug(`closeWorkspace: Workspace ${workspaceId} is not loaded in memory.`); return true; } - const stopped = await this.stopWorkspace(userId, workspaceId, requestingUserId); + const stopped = await this.stopWorkspace(ownerId, workspaceIdentifier, requestingUserId); if (!stopped) { // stopWorkspace logs details, but we might want to indicate failure here too - console.warn(`closeWorkspace: Failed to stop workspace ${workspaceKey} before closing. It might still be in memory.`); + console.warn(`closeWorkspace: Failed to stop workspace ${workspaceId} before closing. It might still be in memory.`); // Depending on desired behavior, we might not delete from cache if stop failed. // For now, we proceed with deletion from cache. } - const deleted = this.#workspaces.delete(workspaceKey); + const deleted = this.#workspaces.delete(workspaceId); if (deleted) { - debug(`closeWorkspace: Removed workspace ${workspaceKey} from memory cache.`); - this.emit('workspace.closed', { workspaceKey, userId, workspaceId }); + debug(`closeWorkspace: Removed workspace ${workspaceId} from memory cache.`); + this.emit('workspace.closed', { workspaceId, userId, workspaceIdentifier }); } return deleted; // Or perhaps return `stopped && deleted` } /** * Starts an opened workspace. - * @param {string} userId - The owner identifier. - * @param {string} workspaceId - The workspace ID. + * @param {string} userId - The owner identifier (can be ID, name, or email). + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request. * @returns {Promise} The started Workspace instance or null on failure. */ - async startWorkspace(userId, workspaceId, requestingUserId) { + async startWorkspace(userId, workspaceIdentifier, requestingUserId) { if (!this.#initialized) throw new Error('WorkspaceManager not initialized'); if (!requestingUserId) { requestingUserId = userId; } + this.#ensureRequiredParams({ userId, workspaceIdentifier: workspaceIdentifier, requestingUserId }, 'startWorkspace'); + + // Resolve the provided userId (which can be an identifier) to an actual user ID. + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`startWorkspace failed: Could not resolve user identifier "${userId}"`); + return null; + } + if (requestingUserId === userId) { // If they were the same, update requestingUserId to resolved ID + requestingUserId = ownerId; + } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'startWorkspace'); - const workspaceKey = `${userId}/${workspaceId}`; - debug(`Starting workspace ${workspaceKey} for requestingUserId: ${requestingUserId}`); - let workspace = this.#workspaces.get(workspaceKey); + // Resolve workspace identifier to ID + let workspaceId; + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); + + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`startWorkspace: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return null; + } + } + + debug(`Starting workspace ${workspaceId} (identifier: ${workspaceIdentifier}) for requestingUserId: ${requestingUserId}`); + let workspace = this.#workspaces.get(workspaceId); if (!workspace) { - debug(`startWorkspace: Workspace ${workspaceKey} not found in memory, attempting to open...`); - workspace = await this.openWorkspace(userId, workspaceId, requestingUserId); + debug(`startWorkspace: Workspace ${workspaceId} not found in memory, attempting to open...`); + workspace = await this.openWorkspace(ownerId, workspaceIdentifier, requestingUserId); if (!workspace) { - debug(`startWorkspace: Could not open workspace ${userId}/${workspaceId}.`); + debug(`startWorkspace: Could not open workspace ${workspaceIdentifier} for user ${userId}.`); return null; } } if (workspace.status === WORKSPACE_STATUS_CODES.ACTIVE) { - debug(`Workspace ${workspaceKey} is already active.`); + debug(`Workspace ${workspaceId} is already active.`); return workspace; } - debug(`Starting workspace ${workspaceKey}...`); + debug(`Starting workspace ${workspaceId}...`); try { await workspace.start(); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.ACTIVE, lastAccessed: new Date().toISOString() }); - debug(`Workspace ${workspaceKey} started successfully.`); - this.emit('workspace.started', { workspaceKey, workspace: workspace.toJSON() }); + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.ACTIVE, lastAccessed: new Date().toISOString() }); + debug(`Workspace ${workspaceId} started successfully.`); + this.emit('workspace.started', { workspaceId, workspace: workspace.toJSON() }); return workspace; } catch (err) { - console.error(`Failed to start workspace ${workspaceKey}: ${err.message}`); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.ERROR }); - this.emit('workspace.startFailed', { workspaceKey, error: err.message }); + console.error(`Failed to start workspace ${workspaceId}: ${err.message}`); + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.ERROR }); + this.emit('workspace.startFailed', { workspaceId, error: err.message }); return null; } } /** * Stops a loaded and active workspace. - * @param {string} userId - The owner identifier. - * @param {string} workspaceId - The workspace ID. + * @param {string} userId - The owner identifier (can be ID, name, or email). + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request. * @returns {Promise} True if stopped or already inactive/not loaded, false on failure. */ - async stopWorkspace(userId, workspaceId, requestingUserId) { + async stopWorkspace(userId, workspaceIdentifier, requestingUserId) { if (!this.#initialized) throw Error('WorkspaceManager not initialized'); if (!requestingUserId) { requestingUserId = userId; } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'stopWorkspace'); + this.#ensureRequiredParams({ userId, workspaceIdentifier: workspaceIdentifier, requestingUserId }, 'stopWorkspace'); - const workspaceKey = `${userId}/${workspaceId}`; - const workspace = this.#workspaces.get(workspaceKey); + // Resolve the provided userId (which can be an identifier) to an actual user ID. + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`stopWorkspace failed: Could not resolve user identifier "${userId}"`); + return false; + } + if (requestingUserId === userId) { // If they were the same, update requestingUserId to resolved ID + requestingUserId = ownerId; + } + + // Resolve workspace identifier to ID + let workspaceId; + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); + + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`stopWorkspace: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return false; + } + } + + const workspace = this.#workspaces.get(workspaceId); if (!workspace) { - debug(`Workspace ${workspaceKey} is not loaded in memory, considered stopped.`); + debug(`Workspace ${workspaceId} is not loaded in memory, considered stopped.`); // Potentially update index if it was marked ACTIVE but not in memory (e.g. after a crash) - const entry = this.#indexStore.get(workspaceKey); + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + const entry = this.#indexStore.get(indexKey); if (entry && entry.owner === requestingUserId && entry.status === WORKSPACE_STATUS_CODES.ACTIVE) { - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.INACTIVE }); - debug(`Marked workspace ${workspaceKey} (not in memory) as INACTIVE in index.`); + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.INACTIVE }); + debug(`Marked workspace ${workspaceId} (not in memory) as INACTIVE in index.`); } return true; } if (workspace.owner !== requestingUserId) { - console.error(`stopWorkspace: User ${requestingUserId} not owner of ${workspaceKey}. Workspace owner: ${workspace.owner}`); + console.error(`stopWorkspace: User ${requestingUserId} not owner of ${workspaceId}. Workspace owner: ${workspace.owner}`); return false; } if ([WORKSPACE_STATUS_CODES.INACTIVE, WORKSPACE_STATUS_CODES.AVAILABLE].includes(workspace.status)) { - debug(`Workspace ${workspaceKey} is already stopped (status: ${workspace.status}).`); + debug(`Workspace ${workspaceId} is already stopped (status: ${workspace.status}).`); return true; } - debug(`Stopping workspace ${workspaceKey}...`); + debug(`Stopping workspace ${workspaceId}...`); try { await workspace.stop(); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.INACTIVE }, requestingUserId); - debug(`Workspace ${workspaceKey} stopped successfully.`); - this.emit('workspace.stopped', { workspaceKey }); + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.INACTIVE }, requestingUserId); + debug(`Workspace ${workspaceId} stopped successfully.`); + this.emit('workspace.stopped', { workspaceId }); return true; } catch (err) { - console.error(`Failed to stop workspace ${workspaceKey}: ${err.message}`); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.ERROR }, requestingUserId); - this.emit('workspace.stopFailed', { workspaceKey, error: err.message }); + console.error(`Failed to stop workspace ${workspaceId}: ${err.message}`); + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.ERROR }, requestingUserId); + this.emit('workspace.stopFailed', { workspaceId, error: err.message }); return false; } } /** * Removes a workspace from the index and optionally deletes its data. - * @param {string} userId - The owner identifier. - * @param {string} workspaceId - The workspace ID. + * @param {string} userId - The owner identifier (can be ID, name, or email). + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request. * @param {boolean} [destroyData=false] - Whether to delete the workspace directory. * @returns {Promise} True if successful, false otherwise. */ - async removeWorkspace(userId, workspaceId, requestingUserId, destroyData = false) { + async removeWorkspace(userId, workspaceIdentifier, requestingUserId, destroyData = false) { if (!this.#initialized) throw new Error('WorkspaceManager not initialized'); + + // Resolve the provided userId (which can be an identifier) to an actual user ID. + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`removeWorkspace failed: Could not resolve user identifier "${userId}"`); + return false; + } + if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; + } + if (requestingUserId === userId) { + requestingUserId = ownerId; } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'removeWorkspace'); - const workspaceKey = `${userId}/${workspaceId}`; - debug(`Removing workspace: ${workspaceKey}, destroyData: ${destroyData}, requested by ${requestingUserId}`); + this.#ensureRequiredParams({ userId, workspaceIdentifier: workspaceIdentifier, requestingUserId }, 'removeWorkspace'); + + // Resolve workspace identifier to ID + let workspaceId; + const parsedRef = this.parseWorkspaceReference(workspaceIdentifier); + + if (parsedRef) { + // Full reference format + workspaceId = this.resolveWorkspaceIdFromReference(workspaceIdentifier); + } else { + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) or name + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); + + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`removeWorkspace: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return false; + } + } + } + + debug(`Removing workspace: ${workspaceId} (identifier: ${workspaceIdentifier}), destroyData: ${destroyData}, requested by ${requestingUserId}`); // Prevent removal of universe workspace - if (this.#isUniverseWorkspace(workspaceKey)) { + if (this.#isUniverseWorkspace(workspaceId)) { throw new Error('Cannot remove the universe workspace'); } - const entry = this.#indexStore.get(workspaceKey); + // Find workspace entry using the new index key format + const indexKey = this.#constructWorkspaceIndexKey(ownerId, workspaceId); + const entry = this.#indexStore.get(indexKey); if (!entry) { - console.warn(`removeWorkspace failed: Workspace ${workspaceKey} not found in index.`); + console.warn(`removeWorkspace failed: Workspace ${workspaceId} not found in index.`); return false; } if (entry.owner !== requestingUserId) { - console.error(`removeWorkspace failed: User ${requestingUserId} is not the owner of ${workspaceKey}. Owner: ${entry.owner}`); + console.error(`removeWorkspace failed: User ${requestingUserId} is not the owner of ${workspaceId}. Owner: ${entry.owner}`); return false; } // Attempt to stop and remove from cache first - if (this.#workspaces.has(workspaceKey)) { - const stopped = await this.stopWorkspace(userId, workspaceId, requestingUserId); + if (this.#workspaces.has(workspaceId)) { + const stopped = await this.stopWorkspace(userId, workspaceIdentifier, requestingUserId); if (!stopped) { - console.error(`removeWorkspace: Could not stop workspace ${workspaceKey} before removal. Proceeding with index removal.`); + console.error(`removeWorkspace: Could not stop workspace ${workspaceId} before removal. Proceeding with index removal.`); // Decide if we should abort or continue. For now, continue. } - this.#workspaces.delete(workspaceKey); - debug(`Removed workspace ${workspaceKey} from memory cache during removal process.`); + this.#workspaces.delete(workspaceId); + debug(`Removed workspace ${workspaceId} from memory cache during removal process.`); } let deletionError = null; @@ -454,17 +840,27 @@ class WorkspaceManager extends EventEmitter { } } - this.#indexStore.delete(workspaceKey); + // Remove from reference index + if (entry.reference) { + this.#referenceIndex.delete(entry.reference); + } + + // Remove from name index + const nameKey = `${userId}@${entry.host || DEFAULT_HOST}:${entry.name}`; + this.#nameIndex.delete(nameKey); + + // Remove from index store using the correct key + this.#indexStore.delete(indexKey); this.emit('workspace.removed', { - userId, + userId: ownerId, workspaceId, - workspaceKey, + workspaceIdentifier, requestingUserId, destroyData, success: !deletionError, // Success is true if data destruction didn't fail (or wasn't attempted) error: deletionError ? deletionError.message : null }); - debug(`Workspace ${workspaceKey} removed from index for owner ${userId}.`); + debug(`Workspace ${workspaceId} removed from index for owner ${ownerId}.`); return !deletionError; // Return based on data destruction outcome if attempted } @@ -472,6 +868,211 @@ class WorkspaceManager extends EventEmitter { * Public API - Workspace Information & Configuration */ + /** + * Gets all workspace entries from the index (for internal use) + * @returns {Object} All workspace entries from the index store, with workspaceId as keys + */ + getAllWorkspaces() { + const allEntries = this.#indexStore?.store || {}; + const result = {}; + + for (const [indexKey, entry] of Object.entries(allEntries)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (parsed) { + // Use workspaceId as key for backward compatibility + result[parsed.workspaceId] = entry; + } + } + + return result; + } + + /** + * Gets all workspace entries from the index with full index keys (for internal use) + * @returns {Object} All workspace entries from the index store, with userId/workspaceId as keys + */ + getAllWorkspacesWithKeys() { + return this.#indexStore?.store || {}; + } + + /** + * Resolves a workspace ID from a workspace name and user identifier + * @param {string} userIdentifier - The user ID, name, or email + * @param {string} workspaceName - The workspace name + * @param {string} [host=DEFAULT_HOST] - Host (defaults to canvas.local) + * @returns {string|null} The workspace ID if found, null otherwise + */ + resolveWorkspaceId(userIdentifier, workspaceName, host = DEFAULT_HOST) { + const nameKey = `${userIdentifier}@${host}:${workspaceName}`; + return this.#nameIndex.get(nameKey) || null; + } + + /** + * Resolves a workspace ID from a workspace reference + * @param {string} workspaceRef - Workspace reference in format userIdentifier@host:workspaceSlug[/path] + * @returns {string|null} The workspace ID if found, null otherwise + */ + resolveWorkspaceIdFromReference(workspaceRef) { + const parsedRef = this.parseWorkspaceReference(workspaceRef); + if (!parsedRef) { + return null; + } + + const baseRef = this.constructWorkspaceReference(parsedRef.userIdentifier, parsedRef.workspaceSlug, parsedRef.host); + return this.#referenceIndex.get(baseRef) || null; + } + + /** + * Resolves a workspace ID from a simple workspace identifier + * @param {string} workspaceIdentifier - Simple identifier in format user.name/workspace.name + * @returns {Promise} The workspace ID if found, null otherwise + */ + async resolveWorkspaceIdFromSimpleIdentifier(workspaceIdentifier) { + const parsed = this.parseSimpleWorkspaceIdentifier(workspaceIdentifier); + if (!parsed) { + return null; + } + + // First resolve the user identifier to a user ID + const userId = await this.#userManager.resolveToUserId(parsed.userIdentifier); + if (!userId) { + return null; + } + + // Then try to resolve the workspace by name + return this.resolveWorkspaceId(userId, parsed.workspaceIdentifier); + } + + /** + * Construct a simple resource address from workspace data + * @param {Object} workspace - Workspace object with owner and name + * @returns {Promise} Resource address in format user.name/workspace.name + */ + async constructResourceAddress(workspace) { + if (!workspace || !workspace.owner || !workspace.name) { + return null; + } + + try { + // Get user info to construct the address + const user = await this.#userManager.getUser(workspace.owner); + if (!user || !user.name) { + return null; + } + + return `${user.name}/${workspace.name}`; + } catch (error) { + return null; + } + } + + /** + * Gets a workspace by ID directly + * @param {string} workspaceId - The workspace ID + * @param {string} requestingUserId - The ULID of the user making the request + * @returns {Promise} The loaded Workspace instance + */ + async getWorkspaceById(workspaceId, requestingUserId) { + if (!this.#initialized) { + throw new Error('WorkspaceManager not initialized. Cannot get workspace by ID.'); + } + if (!workspaceId) { + throw new Error('workspaceId is required to get workspace by ID'); + } + + // Search for workspace in index by workspaceId across all users + const allEntries = this.#indexStore.store; + let entry = null; + let foundIndexKey = null; + + for (const [indexKey, workspaceEntry] of Object.entries(allEntries)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (parsed && parsed.workspaceId === workspaceId) { + entry = workspaceEntry; + foundIndexKey = indexKey; + break; + } + } + + if (!entry) { + debug(`getWorkspaceById: Workspace ${workspaceId} not found in index`); + return null; + } + + // Check ownership if requesting user is provided + if (requestingUserId && entry.owner !== requestingUserId) { + debug(`getWorkspaceById: User ${requestingUserId} is not the owner of workspace ${workspaceId}`); + return null; + } + + // Return from cache if available + if (this.#workspaces.has(workspaceId)) { + debug(`Returning cached Workspace instance for ${workspaceId}`); + return this.#workspaces.get(workspaceId); + } + + // Load workspace + try { + const conf = new Conf({ + configName: path.basename(entry.configPath, '.json'), + cwd: path.dirname(entry.configPath) + }); + + const workspace = new Workspace({ + rootPath: entry.rootPath, + configStore: conf, + eventEmitterOptions: { + wildcard: true, + delimiter: '.', + newListener: false, + maxListeners: 50 + } + }); + + this.#workspaces.set(workspaceId, workspace); + debug(`Loaded and cached Workspace instance for ${workspaceId}`); + this.#updateWorkspaceIndexEntry(foundIndexKey, { lastAccessed: new Date().toISOString() }); + return workspace; + } catch (err) { + console.error(`getWorkspaceById failed: Could not load config or instantiate Workspace for ${workspaceId}: ${err.message}`); + return null; + } + } + + /** + * Gets a workspace by name (resolves to ID first) + * @param {string} userId - The user ID, name, or email + * @param {string} workspaceName - The workspace name + * @param {string} requestingUserId - The ULID of the user making the request + * @returns {Promise} The loaded Workspace instance + */ + async getWorkspaceByName(userId, workspaceName, requestingUserId) { + if (!this.#initialized) { + throw new Error('WorkspaceManager not initialized. Cannot get workspace by name.'); + } + if (!userId || !workspaceName) { + throw new Error('userId and workspaceName are required to get workspace by name'); + } + + // Resolve workspace ID from name + const workspaceId = this.resolveWorkspaceId(userId, workspaceName); + if (!workspaceId) { + debug(`getWorkspaceByName: No workspace found with name "${workspaceName}" for user ${userId}`); + return null; + } + + // Resolve user identifier to ID for getWorkspaceById call + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) { + debug(`getWorkspaceByName: Could not resolve user identifier "${userId}"`); + return null; + } + + + // Get workspace by ID + return this.getWorkspaceById(workspaceId, requestingUserId || ownerId); + } + /** * Checks if a workspace instance is currently loaded in memory. * @param {string} ownerId - The owner identifier (e.g., userId) used for key construction. @@ -481,10 +1082,12 @@ class WorkspaceManager extends EventEmitter { */ async isOpen(ownerId, workspaceId, requestingUserId) { if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; } this.#ensureRequiredParams({ ownerId, workspaceId, requestingUserId }, 'isOpen'); - const workspaceKey = `${ownerId}/${workspaceId}`; + const resolvedOwnerId = await this.#userManager.resolveToUserId(ownerId); + if (!resolvedOwnerId) return false; + const workspaceKey = `${resolvedOwnerId}/${workspaceId}`; const ws = this.#workspaces.get(workspaceKey); return !!ws && ws.owner === requestingUserId; // Check against the stored ULID owner } @@ -498,48 +1101,65 @@ class WorkspaceManager extends EventEmitter { */ async isActive(ownerId, workspaceId, requestingUserId) { if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; } this.#ensureRequiredParams({ ownerId, workspaceId, requestingUserId }, 'isActive'); - const workspaceKey = `${ownerId}/${workspaceId}`; + const resolvedOwnerId = await this.#userManager.resolveToUserId(ownerId); + if (!resolvedOwnerId) return false; + const workspaceKey = `${resolvedOwnerId}/${workspaceId}`; const workspace = this.#workspaces.get(workspaceKey); return !!workspace && workspace.owner === requestingUserId && workspace.status === WORKSPACE_STATUS_CODES.ACTIVE; } /** - * Lists all workspaces for a given userId (e.g., userId). - * @param {string} userId - The owner identifier (e.g., userId) used for key prefixing. + * Lists all workspaces for a given userId + * @param {string} userId - The user ID + * @param {string} [host=DEFAULT_HOST] - Host to filter by (defaults to canvas.local) * @returns {Promise>} An array of workspace index entry objects. */ - async listUserWorkspaces(userId) { + async listUserWorkspaces(userId, host = DEFAULT_HOST) { if (!this.#initialized) throw new Error('WorkspaceManager not initialized'); if (!userId) return []; - const prefix = `${userId}/`; - debug(`Listing workspaces for userId ${userId}`); + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) return []; - const allWorkspaces = this.#indexStore.store; + const prefix = `${ownerId}/`; + debug(`Listing workspaces for userId ${ownerId} on host ${host}`); + + const allWorkspaces = this.#indexStore.store; const userWorkspaceEntries = []; - // Assuming accessPropertiesByDotNotation is false, keys are literal. - // If it were true, iterating `allWorkspaces` and checking prefix would be fine. - // With it false, this direct check is also fine. + for (const key in allWorkspaces) { if (key.startsWith(prefix)) { - // We also need to ensure the value is a valid workspace entry, not some other data - // if the indexStore is shared or has a flat structure with non-workspace items. - // For now, assume all keys starting with prefix are workspace entries. - if (allWorkspaces[key] && typeof allWorkspaces[key] === 'object' && allWorkspaces[key].id) { - userWorkspaceEntries.push(allWorkspaces[key]); + const workspaceEntry = allWorkspaces[key]; + if (workspaceEntry && typeof workspaceEntry === 'object' && workspaceEntry.id) { + // More flexible host filtering - if workspace has no host field, assume it's the default host + const workspaceHost = workspaceEntry.host || DEFAULT_HOST; + if (!host || workspaceHost === host) { + // Resolve owner ID to user email + try { + const ownerUser = await this.#userManager.getUser(workspaceEntry.owner); + const workspaceWithOwnerEmail = { + ...workspaceEntry, + ownerEmail: ownerUser.email + }; + userWorkspaceEntries.push(workspaceWithOwnerEmail); + } catch (error) { + debug(`Failed to resolve owner email for workspace ${workspaceEntry.id}: ${error.message}`); + // Fallback to original entry if user resolution fails + userWorkspaceEntries.push(workspaceEntry); + } + } } } } - debug(`Found ${userWorkspaceEntries.length} workspaces for userId ${userId}`); + debug(`Found ${userWorkspaceEntries.length} workspaces for userId ${ownerId} on host ${host}`); return userWorkspaceEntries; } /** * Gets a loaded Workspace instance from memory. Alias for openWorkspace. - * TODO: This method has to be renamed * @param {string} userId - The owner identifier. * @param {string} workspaceId - The workspace ID. * @param {string} requestingUserId - The ULID of the user making the request. @@ -562,19 +1182,45 @@ class WorkspaceManager extends EventEmitter { /** * Checks if a workspace exists in the index for the given owner and user. * @param {string} userId - The owner identifier. - * @param {string} workspaceId - The workspace ID. + * @param {string} workspaceIdentifier - The workspace ID or name. * @param {string} requestingUserId - The ULID of the user making the request. * @returns {Promise} True if the workspace exists and is owned by the user. */ - async hasWorkspace(userId, workspaceId, requestingUserId) { + async hasWorkspace(userId, workspaceIdentifier, requestingUserId) { if (!this.#initialized) { throw new Error('WorkspaceManager not initialized'); } + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) return false; + if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; + } else { + const resolvedRequesterId = await this.#userManager.resolveToUserId(requestingUserId); + if (!resolvedRequesterId) return false; + requestingUserId = resolvedRequesterId; } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'hasWorkspace', false); // Allow missing requestingUserId for a general check if needed, but enforce for ownership check + + this.#ensureRequiredParams({ userId: ownerId, workspaceIdentifier, requestingUserId }, 'hasWorkspace', false); // Allow missing requestingUserId for a general check if needed, but enforce for ownership check try { - const workspaceKey = `${userId}/${workspaceId}`; + // Resolve workspace identifier to ID (handle both IDs and names) + let workspaceId; + + // Check if it's a workspace ID (either new 12-char format or legacy UUID format) + const isNewWorkspaceId = workspaceIdentifier.length === 12 && /^[a-zA-Z0-9]+$/.test(workspaceIdentifier); + const isLegacyWorkspaceId = workspaceIdentifier.length === 36 && /^[a-f0-9-]+$/.test(workspaceIdentifier); + + if (isNewWorkspaceId || isLegacyWorkspaceId) { + workspaceId = workspaceIdentifier; + } else { + // Try to resolve as workspace name + workspaceId = this.resolveWorkspaceId(userId, workspaceIdentifier); + if (!workspaceId) { + debug(`hasWorkspace: No workspace found with name "${workspaceIdentifier}" for user ${userId}`); + return false; + } + } + + const workspaceKey = `${ownerId}/${workspaceId}`; debug(`Checking if workspace exists: ${workspaceKey} for user ${requestingUserId}`); const entry = this.#indexStore.get(workspaceKey); return !!entry && (!requestingUserId || entry.owner === requestingUserId); // If requestingUserId is provided, check ownership @@ -593,12 +1239,20 @@ class WorkspaceManager extends EventEmitter { */ async getWorkspaceConfig(userId, workspaceId, requestingUserId) { if (!this.#initialized) { throw new Error('WorkspaceManager not initialized'); } + + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) return null; + if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; + } else { + const resolvedRequesterId = await this.#userManager.resolveToUserId(requestingUserId); + if (!resolvedRequesterId) return null; + requestingUserId = resolvedRequesterId; } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId }, 'getWorkspaceConfig'); + this.#ensureRequiredParams({ userId: ownerId, workspaceId, requestingUserId }, 'getWorkspaceConfig'); - const workspaceKey = `${userId}/${workspaceId}`; + const workspaceKey = `${ownerId}/${workspaceId}`; const entry = this.#indexStore.get(workspaceKey); if (!entry) { @@ -635,12 +1289,19 @@ class WorkspaceManager extends EventEmitter { */ async updateWorkspaceConfig(userId, workspaceId, requestingUserId, updates) { if (!this.#initialized) { throw new Error('WorkspaceManager not initialized'); } + const ownerId = await this.#userManager.resolveToUserId(userId); + if (!ownerId) return false; + if (!requestingUserId) { - requestingUserId = userId; + requestingUserId = ownerId; + } else { + const resolvedRequesterId = await this.#userManager.resolveToUserId(requestingUserId); + if (!resolvedRequesterId) return false; + requestingUserId = resolvedRequesterId; } - this.#ensureRequiredParams({ userId, workspaceId, requestingUserId, updates }, 'updateWorkspaceConfig'); + this.#ensureRequiredParams({ userId: ownerId, workspaceId, requestingUserId, updates }, 'updateWorkspaceConfig'); - const workspaceKey = `${userId}/${workspaceId}`; + const workspaceKey = `${ownerId}/${workspaceId}`; const entry = this.#indexStore.get(workspaceKey); if (!entry) { @@ -686,6 +1347,21 @@ class WorkspaceManager extends EventEmitter { console.warn(`Invalid color "${validUpdates[key]}" for workspace ${workspaceKey} during update. Ignoring color update.`); continue; // Skip this update } + if (key === 'name' && validUpdates[key]) { + // If name is changing, we need to update the indexes + const oldNameKey = `${entry.owner}@${entry.host || DEFAULT_HOST}:${entry.name}`; + const newNameKey = `${entry.owner}@${entry.host || DEFAULT_HOST}:${validUpdates[key]}`; + const oldRefKey = entry.reference; + const newRefKey = this.constructWorkspaceReference(entry.owner, validUpdates[key], entry.host); + + this.#nameIndex.delete(oldNameKey); + this.#nameIndex.set(newNameKey, workspaceId); + + this.#referenceIndex.delete(oldRefKey); + this.#referenceIndex.set(newRefKey, workspaceId); + + validUpdates.reference = newRefKey; // Update reference in config + } if (conf.get(key) !== validUpdates[key]) { conf.set(key, validUpdates[key]); changed = true; @@ -693,7 +1369,7 @@ class WorkspaceManager extends EventEmitter { } if (changed) { - conf.set('updated', new Date().toISOString()); + conf.set('updatedAt', new Date().toISOString()); // Update relevant fields in the index entry const indexUpdates = { lastAccessed: new Date().toISOString() }; @@ -717,6 +1393,80 @@ class WorkspaceManager extends EventEmitter { * Static Utility Methods */ + /** + * Parse remote workspace reference format: user.email@host:workspace.name + * @param {string} remoteRef - Remote workspace reference + * @returns {Object|null} Parsed remote workspace info or null if invalid + * @static + */ + static parseRemoteWorkspaceRef(remoteRef) { + if (!remoteRef || typeof remoteRef !== 'string') { + return null; + } + + // Format: user.email@host:workspace.name + const match = remoteRef.match(/^([^@]+)@([^:]+):(.+)$/); + if (!match) { + return null; + } + + const [, userEmail, host, workspaceName] = match; + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(userEmail)) { + return null; + } + + return { + userEmail, + host, + workspaceName, + original: remoteRef + }; + } + + /** + * Resolve remote workspace reference to internal format + * @param {string} remoteRef - Remote workspace reference + * @param {Function} userResolver - Function to resolve user.email to user.id + * @param {Function} workspaceResolver - Function to resolve workspace.name to workspace.id + * @returns {Promise} Resolved remote workspace info or null if invalid + * @static + */ + static async resolveRemoteWorkspaceRef(remoteRef, userResolver, workspaceResolver) { + const parsed = WorkspaceManager.parseRemoteWorkspaceRef(remoteRef); + if (!parsed) { + return null; + } + + try { + // Resolve user email to user ID + const userId = await userResolver(parsed.userEmail); + if (!userId) { + debug(`Failed to resolve user email to ID: ${parsed.userEmail}`); + return null; + } + + // Resolve workspace name to workspace ID + const workspaceId = await workspaceResolver(userId, parsed.workspaceName); + if (!workspaceId) { + debug(`Failed to resolve workspace name to ID: ${parsed.workspaceName} for user ${userId}`); + return null; + } + + return { + ...parsed, + userId, + workspaceId, + resolved: `${userId}@${parsed.host}:${workspaceId}` + }; + } catch (error) { + debug(`Error resolving remote workspace reference: ${error.message}`); + return null; + } + } + /** * Get a random color for workspace * @returns {string} Random color @@ -753,9 +1503,42 @@ class WorkspaceManager extends EventEmitter { * Private Methods */ - #sanitizeWorkspaceId(workspaceId) { - if (!workspaceId) return 'untitled'; - let sanitized = workspaceId.toString().toLowerCase().trim(); + /** + * Rebuilds the name and reference indexes from existing workspaces in the index store + * @private + */ + async #rebuildIndexes() { + this.#nameIndex.clear(); + this.#referenceIndex.clear(); + const allWorkspaces = this.#indexStore.store; + + for (const [indexKey, workspaceEntry] of Object.entries(allWorkspaces)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (workspaceEntry && workspaceEntry.name && parsed) { + const host = workspaceEntry.host || DEFAULT_HOST; + const ownerId = parsed.userId; // This is the actual user ID + + const nameKey = `${ownerId}@${host}:${workspaceEntry.name}`; + this.#nameIndex.set(nameKey, parsed.workspaceId); + + // Add reference index entry + if (workspaceEntry.reference) { + this.#referenceIndex.set(workspaceEntry.reference, parsed.workspaceId); + } else { + // Create reference for legacy workspaces + const reference = constructWorkspaceReference(ownerId, workspaceEntry.name, host); + this.#referenceIndex.set(reference, parsed.workspaceId); + // TODO: Should we add this back to the config file? + } + } + } + + debug(`Rebuilt name and reference indexes with ${this.#nameIndex.size} workspace name mappings`); + } + + #sanitizeWorkspaceName(workspaceName) { + if (!workspaceName) return 'untitled'; + let sanitized = workspaceName.toString().toLowerCase().trim(); // Remove all special characters except "_", "-" sanitized = sanitized.replace(/[^a-z0-9-_]/g, ''); @@ -763,14 +1546,10 @@ class WorkspaceManager extends EventEmitter { // Replace spaces with hyphens sanitized = sanitized.replace(/\s+/g, '-'); - // Return the sanitized workspaceId + // Return the sanitized workspaceName return sanitized; } - #constructWorkspaceKey(userId, workspaceId) { - return `${userId}/${workspaceId}`; - } - /** * Pre-creates all subdirectories defined in WORKSPACE_DIRECTORIES. * @param {string} workspaceDir - The workspace directory path. @@ -805,29 +1584,32 @@ class WorkspaceManager extends EventEmitter { const allWorkspaces = this.#indexStore.store; let requiresSave = false; // To track if any changes were made to the index - for (const workspaceKey in allWorkspaces) { - const workspaceEntry = allWorkspaces[workspaceKey]; + for (const indexKey in allWorkspaces) { + const workspaceEntry = allWorkspaces[indexKey]; + const parsed = this.#parseWorkspaceIndexKey(indexKey); + // Basic validation of the entry structure - if (!workspaceEntry || typeof workspaceEntry !== 'object' || !workspaceEntry.id) { - debug(`Skipping invalid or incomplete workspace entry for key: ${workspaceKey}`); + if (!workspaceEntry || typeof workspaceEntry !== 'object' || !workspaceEntry.id || !parsed) { + debug(`Skipping invalid or incomplete workspace entry for key: ${indexKey}`); continue; } - debug(`Scanning workspace ${workspaceKey} (ID: ${workspaceEntry.id}, Owner: ${workspaceEntry.owner})`); + const workspaceId = parsed.workspaceId; + debug(`Scanning workspace ${workspaceId} (Name: ${workspaceEntry.name}, Owner: ${workspaceEntry.owner})`); let currentStatus = workspaceEntry.status; let newStatus = currentStatus; // Skip already processed states unless we need to re-validate if ([WORKSPACE_STATUS_CODES.REMOVED, WORKSPACE_STATUS_CODES.DESTROYED].includes(currentStatus)) { - debug(`Workspace ${workspaceKey} is in status ${currentStatus}, skipping.`); + debug(`Workspace ${workspaceId} is in status ${currentStatus}, skipping.`); continue; } if (!workspaceEntry.rootPath || !existsSync(workspaceEntry.rootPath)) { - debug(`Workspace path not found for ${workspaceKey} at path ${workspaceEntry.rootPath}, marking as NOT_FOUND`); + debug(`Workspace path not found for ${workspaceId} at path ${workspaceEntry.rootPath}, marking as NOT_FOUND`); newStatus = WORKSPACE_STATUS_CODES.NOT_FOUND; } else if (!workspaceEntry.configPath || !existsSync(workspaceEntry.configPath)) { - debug(`Workspace config not found for ${workspaceKey} at path ${workspaceEntry.configPath}, marking as ERROR`); + debug(`Workspace config not found for ${workspaceId} at path ${workspaceEntry.configPath}, marking as ERROR`); newStatus = WORKSPACE_STATUS_CODES.ERROR; } else if (![WORKSPACE_STATUS_CODES.ACTIVE, WORKSPACE_STATUS_CODES.INACTIVE, WORKSPACE_STATUS_CODES.ERROR, WORKSPACE_STATUS_CODES.NOT_FOUND].includes(currentStatus)) { // If it's not in a definitive error/active/inactive state, mark as available (implies it passed path checks) @@ -839,11 +1621,10 @@ class WorkspaceManager extends EventEmitter { } } - if (newStatus !== currentStatus) { - this.#updateWorkspaceIndexEntry(workspaceKey, { status: newStatus }); + this.#updateWorkspaceIndexEntry(indexKey, { status: newStatus }); requiresSave = true; // Conf usually saves on set, but this flag is for conceptual grouping. - debug(`Updated status for ${workspaceKey} from ${currentStatus} to ${newStatus}`); + debug(`Updated status for ${workspaceId} from ${currentStatus} to ${newStatus}`); } } @@ -857,28 +1638,44 @@ class WorkspaceManager extends EventEmitter { /** * Validates a workspace index entry for opening. * @param {Object} entry - The workspace index entry. - * @param {string} workspaceKey - The key for the workspace. + * @param {string} workspaceId - The ID of the workspace. * @param {string} requestingUserId - The ULID of the user making the request. * @returns {boolean} True if valid, false otherwise. * @private */ - #validateWorkspaceEntryForOpen(entry, workspaceKey, requestingUserId) { + #validateWorkspaceEntryForOpen(entry, workspaceId, requestingUserId) { if (!entry) { - debug(`openWorkspace failed: Workspace ${workspaceKey} not found in index.`); + debug(`openWorkspace failed: Workspace ${workspaceId} not found in index.`); return false; } if (entry.owner !== requestingUserId) { - console.warn(`openWorkspace failed: User ${requestingUserId} is not the owner of workspace ${workspaceKey}. Stored owner: ${entry.owner}`); + console.warn(`openWorkspace failed: User ${requestingUserId} is not the owner of workspace ${workspaceId}. Stored owner: ${entry.owner}`); return false; } if (!entry.rootPath || !existsSync(entry.rootPath)) { - console.warn(`openWorkspace failed: Workspace ${workspaceKey} rootPath is missing or does not exist: ${entry.rootPath}`); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.NOT_FOUND }); + console.warn(`openWorkspace failed: Workspace ${workspaceId} rootPath is missing or does not exist: ${entry.rootPath}`); + // Find the correct index key for this workspace + const allEntries = this.#indexStore.store; + for (const [indexKey, workspaceEntry] of Object.entries(allEntries)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (parsed && parsed.workspaceId === workspaceId) { + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.NOT_FOUND }); + break; + } + } return false; } if (!entry.configPath || !existsSync(entry.configPath)) { - console.warn(`openWorkspace failed: Workspace ${workspaceKey} configPath is missing or does not exist: ${entry.configPath}`); - this.#updateWorkspaceIndexEntry(workspaceKey, { status: WORKSPACE_STATUS_CODES.ERROR }); + console.warn(`openWorkspace failed: Workspace ${workspaceId} configPath is missing or does not exist: ${entry.configPath}`); + // Find the correct index key for this workspace + const allEntries = this.#indexStore.store; + for (const [indexKey, workspaceEntry] of Object.entries(allEntries)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (parsed && parsed.workspaceId === workspaceId) { + this.#updateWorkspaceIndexEntry(indexKey, { status: WORKSPACE_STATUS_CODES.ERROR }); + break; + } + } return false; } const validOpenStatuses = [ @@ -888,7 +1685,7 @@ class WorkspaceManager extends EventEmitter { // WORKSPACE_STATUS_CODES.ERROR, // Should we allow opening an errored workspace? Perhaps if paths are now valid. ]; if (!validOpenStatuses.includes(entry.status)) { - console.warn(`openWorkspace failed: Workspace ${workspaceKey} status is invalid (${entry.status}). Must be one of: ${validOpenStatuses.join(', ')}.`); + console.warn(`openWorkspace failed: Workspace ${workspaceId} status is invalid (${entry.status}). Must be one of: ${validOpenStatuses.join(', ')}.`); // Don't change status here, as it might be a temporary issue or a state we don't want to override. return false; } @@ -898,28 +1695,28 @@ class WorkspaceManager extends EventEmitter { /** * Helper to update a workspace's entry in the index store. * Ensures owner check if requestingUserId is provided for sensitive updates. - * @param {string} workspaceKey - The key of the workspace in the index. + * @param {string} indexKey - The index key (userId/workspaceId) of the workspace in the index. * @param {Object} updates - Key-value pairs to update in the index entry. * @param {string} [requestingUserId] - Optional. If provided, validates ownership before certain updates. * @private */ - #updateWorkspaceIndexEntry(workspaceKey, updates, requestingUserId = null) { - const currentEntry = this.#indexStore.get(workspaceKey); + #updateWorkspaceIndexEntry(indexKey, updates, requestingUserId = null) { + const currentEntry = this.#indexStore.get(indexKey); if (!currentEntry) { - debug(`Cannot update index for ${workspaceKey}: entry not found.`); + debug(`Cannot update index for ${indexKey}: entry not found.`); return; } // If requestingUserId is provided (typically for status changes like stop/start), // ensure the action is performed by the owner. if (requestingUserId && currentEntry.owner !== requestingUserId) { - console.error(`Index update for ${workspaceKey} denied: User ${requestingUserId} is not the owner. Owner: ${currentEntry.owner}`); + console.error(`Index update for ${indexKey} denied: User ${requestingUserId} is not the owner. Owner: ${currentEntry.owner}`); return; } - const updatedEntry = { ...currentEntry, ...updates, updated: new Date().toISOString() }; - this.#indexStore.set(workspaceKey, updatedEntry); - debug(`Updated index entry for ${workspaceKey} with: ${JSON.stringify(updates)}`); + const updatedEntry = { ...currentEntry, ...updates, updatedAt: new Date().toISOString() }; + this.#indexStore.set(indexKey, updatedEntry); + debug(`Updated index entry for ${indexKey} with: ${JSON.stringify(updates)}`); } /** @@ -944,13 +1741,20 @@ class WorkspaceManager extends EventEmitter { /** * Checks if a workspace is the universe workspace - * @param {string} workspaceKey - The workspace key to check + * @param {string} workspaceId - The workspace ID to check * @returns {boolean} True if the workspace is the universe workspace * @private */ - #isUniverseWorkspace(workspaceKey) { - const entry = this.#indexStore.get(workspaceKey); - return entry && entry.type === 'universe'; + #isUniverseWorkspace(workspaceId) { + // Search for workspace in index by workspaceId across all users + const allEntries = this.#indexStore.store; + for (const [indexKey, workspaceEntry] of Object.entries(allEntries)) { + const parsed = this.#parseWorkspaceIndexKey(indexKey); + if (parsed && parsed.workspaceId === workspaceId) { + return workspaceEntry.type === 'universe'; + } + } + return false; } } diff --git a/src/managers/workspace/lib/Workspace.js b/src/managers/workspace/lib/Workspace.js index 48840004..ee946617 100644 --- a/src/managers/workspace/lib/Workspace.js +++ b/src/managers/workspace/lib/Workspace.js @@ -19,6 +19,33 @@ import { /** * Canvas Workspace + * + * PROPOSED: Token-Based ACL Schema + * Instead of storing user emails, store token hashes with permissions: + * + * "acl": { + * "tokens": { + * "sha256:abc123...": { + * "permissions": ["read", "write"], + * "description": "John's home laptop", + * "createdAt": "2024-01-01T00:00:00Z", + * "expiresAt": null + * }, + * "sha256:def456...": { + * "permissions": ["read"], + * "description": "Family member token", + * "createdAt": "2024-01-01T00:00:00Z", + * "expiresAt": "2025-01-01T00:00:00Z" + * } + * } + * } + * + * Benefits: + * - Workspace is truly portable (no server user dependency) + * - Tokens travel with clients, not workspace + * - Fine-grained permissions per token + * - Can revoke/rotate individual tokens + * - Self-contained ACL within workspace.json */ class Workspace extends EventEmitter { @@ -73,9 +100,10 @@ class Workspace extends EventEmitter { // Persisted Configuration Getters (from configStore) get id() { return this.#configStore?.get('id'); } - get label() { return this.#configStore?.get('label', this.id); } + get name() { return this.#configStore?.get('name'); } + get label() { return this.#configStore?.get('label', this.name || this.id); } get icon() { return this.#configStore?.get('icon'); } - get description() { return this.#configStore?.get('description', `Canvas Workspace for ${this.id}`); } + get description() { return this.#configStore?.get('description', `Canvas Workspace for ${this.name || this.id}`); } get color() { return this.#configStore?.get('color'); } get type() { return this.#configStore?.get('type', 'workspace'); } get owner() { return this.#configStore?.get('owner'); } // User ULID @@ -152,6 +180,19 @@ class Workspace extends EventEmitter { return this.#updateConfig('label', String(label)); } + async setName(name) { + if (!name) { + console.warn(`Invalid name for workspace ${this.id}. Must be a non-empty string.`); + return false; + } + // Name should be slug-like (lowercase, alphanumeric, dashes, underscores) + const sanitizedName = name.toLowerCase().replace(/[^a-z0-9-_]/g, ''); + if (sanitizedName !== name) { + console.warn(`Name "${name}" was sanitized to "${sanitizedName}" for workspace ${this.id}`); + } + return this.#updateConfig('name', sanitizedName); + } + async setMetadata(metadata) { if (typeof metadata !== 'object' || metadata === null) { console.warn(`Invalid metadata for workspace ${this.id}. Must be an object.`); @@ -719,6 +760,7 @@ class Workspace extends EventEmitter { configPath: this.configPath, status: this.status, isActive: this.isActive, + name: this.name, }; } @@ -746,7 +788,7 @@ class Workspace extends EventEmitter { // return true; // No change, but operation is successful in intent } this.#configStore.set(key, value); - this.#configStore.set('updated', new Date().toISOString()); + this.#configStore.set('updatedAt', new Date().toISOString()); // It might be good to emit an event specific to this workspace instance this.emit(`${key}.changed`, { id: this.id, [key]: value }); debug(`Workspace "${this.id}" config updated: { ${key}: ${value} }. Old value: ${oldValue}`); diff --git a/src/services/synapsd b/src/services/synapsd index 4b7722aa..2e9ab670 160000 --- a/src/services/synapsd +++ b/src/services/synapsd @@ -1 +1 @@ -Subproject commit 4b7722aae4f45748b1fea6043442b1adbaa1a62d +Subproject commit 2e9ab670bd2cd2692f1968c05d5a8996628a4795 diff --git a/src/ui/browser b/src/ui/browser deleted file mode 160000 index 2e9bb6ac..00000000 --- a/src/ui/browser +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2e9bb6ac8b7810eaa280c8be55014586e6808bec diff --git a/src/ui/browser-extension b/src/ui/browser-extension new file mode 160000 index 00000000..0e860b1c --- /dev/null +++ b/src/ui/browser-extension @@ -0,0 +1 @@ +Subproject commit 0e860b1cd7ca1e8f4f0a3a6da59eebb118847897 diff --git a/src/ui/cli b/src/ui/cli index ae5821dc..85ca76c2 160000 --- a/src/ui/cli +++ b/src/ui/cli @@ -1 +1 @@ -Subproject commit ae5821dccedf6a39661c2b9d4001b5dea2a0ad97 +Subproject commit 85ca76c299791ebb624c6e33bcb0aeb3011b720f diff --git a/src/ui/web b/src/ui/web index a8632fea..1a6bea28 160000 --- a/src/ui/web +++ b/src/ui/web @@ -1 +1 @@ -Subproject commit a8632feabf18f36fd89bc8b9f16c5db89041cdb5 +Subproject commit 1a6bea2859129b2db28cd6ce345cb3e44a0e8378 diff --git a/src/utils/id.js b/src/utils/id.js index b6695a32..7a969412 100644 --- a/src/utils/id.js +++ b/src/utils/id.js @@ -1,28 +1,44 @@ import { ulid } from 'ulid'; import { v4 as uuidv4 } from 'uuid'; +import { customAlphabet } from 'nanoid'; /** * Generate a UUID - * @param {string} [prefix] - Prefix for the UUID * @param {number} [length] - Length of the UUID + * @param {string} [prefix] - Prefix for the UUID + * @param {string} [delimiter='-'] - Delimiter between prefix and UUID * @returns {string} UUID */ -function generateUUID(prefix = '', length = 12, delimiter = '-') { - const id = uuidv4().replace(/-/g, '').slice(0, length); +function generateUUID(length, prefix, delimiter = '-') { + const id = (length) ? uuidv4().replace(/-/g, '').slice(0, length) : uuidv4(); return (prefix ? `${prefix}${delimiter}${id}` : id); } /** * Generate a ULID + * @param {number} [length=12] - Length of the ULID * @param {string} [prefix] - Prefix for the ULID - * @param {number} [length] - Length of the ULID + * @param {string} [delimiter='-'] - Delimiter between prefix and ULID * @returns {string} ULID */ -function generateULID(prefix = '', length = 12, delimiter = '-') { +function generateULID(length = 12, prefix, delimiter = '-') { const id = ulid().replace(/-/g, '').slice(0, length).toLowerCase(); return (prefix ? `${prefix}${delimiter}${id}` : id); } +/** + * Generate a nanoid + * @param {number} [length=12] - Length of the nanoid + * @param {string} [prefix] - Prefix for the nanoid + * @param {string} [delimiter='-'] - Delimiter between prefix and nanoid + * @returns {string} nanoid + */ +function generateNanoid(length = 12, prefix, delimiter = '-') { + const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', length); + const id = nanoid(); + return (prefix ? `${prefix}${delimiter}${id}` : id); +} + /** * Generate an index key * @param {string} module - Module name @@ -37,5 +53,6 @@ function generateIndexKey(module, key, delimiter = '/') { export { generateULID, generateUUID, + generateNanoid, generateIndexKey }; diff --git a/tests/auth-redirect-loop-test.js b/tests/auth-redirect-loop-test.js new file mode 100644 index 00000000..0ee47970 --- /dev/null +++ b/tests/auth-redirect-loop-test.js @@ -0,0 +1,320 @@ +#!/usr/bin/env node + +/** + * Test script to verify the auth redirect loop fix + * This test simulates the scenario where a user has a valid JWT token + * but is not found in the database (causing the redirect loop) + */ + +import { spawn } from 'child_process'; +import { readFileSync } from 'fs'; +import path from 'path'; + +const API_BASE_URL = 'http://127.0.0.1:8001'; + +// Test configuration +const testConfig = { + apiUrl: API_BASE_URL, + testUser: { + email: 'test.redirect@example.com', + password: 'testpassword123', + name: 'Test User' + } +}; + +console.log('🧪 Auth Redirect Loop Fix Test'); +console.log('================================'); + +/** + * Make HTTP request with proper headers + */ +async function makeRequest(url, options = {}) { + const { default: fetch } = await import('node-fetch'); + + const defaultOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-App-Name': 'test-client', + ...options.headers + } + }; + + const response = await fetch(url, { ...defaultOptions, ...options }); + + let data; + try { + data = await response.json(); + } catch (error) { + data = { error: 'Could not parse response as JSON' }; + } + + return { + status: response.status, + ok: response.ok, + data + }; +} + +/** + * Test 1: Create a user and get JWT token + */ +async function testCreateUserAndLogin() { + console.log('\n📝 Test 1: Create user and login'); + + try { + // Register user + const registerResponse = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/register`, { + method: 'POST', + body: JSON.stringify({ + email: testConfig.testUser.email, + password: testConfig.testUser.password, + name: testConfig.testUser.name + }) + }); + + if (registerResponse.status === 409) { + console.log(' ℹ️ User already exists, proceeding with login'); + } else if (!registerResponse.ok) { + console.error(' ❌ Registration failed:', registerResponse.data); + return null; + } else { + console.log(' ✅ User registered successfully'); + } + + // Login user + const loginResponse = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/login`, { + method: 'POST', + body: JSON.stringify({ + email: testConfig.testUser.email, + password: testConfig.testUser.password + }) + }); + + if (!loginResponse.ok) { + console.error(' ❌ Login failed:', loginResponse.data); + return null; + } + + const token = loginResponse.data.payload?.token || loginResponse.data.token; + if (!token) { + console.error(' ❌ No token received in login response'); + return null; + } + + console.log(' ✅ Login successful, token received'); + return token; + + } catch (error) { + console.error(' ❌ Test 1 failed:', error.message); + return null; + } +} + +/** + * Test 2: Verify /auth/me works with valid token + */ +async function testAuthMeWithValidToken(token) { + console.log('\n🔍 Test 2: Verify /auth/me works with valid token'); + + try { + const response = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) { + console.error(' ❌ /auth/me failed with valid token:', response.data); + return false; + } + + const user = response.data.payload; + if (!user || !user.id) { + console.error(' ❌ Invalid user data received:', response.data); + return false; + } + + console.log(' ✅ /auth/me successful, user data:', { + id: user.id, + email: user.email, + name: user.name + }); + + return { userId: user.id, email: user.email }; + + } catch (error) { + console.error(' ❌ Test 2 failed:', error.message); + return false; + } +} + +/** + * Test 3: Simulate user deletion from database (this would normally be done by manually deleting the user) + */ +async function testUserDeletionScenario(token) { + console.log('\n🗑️ Test 3: Simulate user missing from database scenario'); + + try { + // Note: We can't actually delete the user from the database in this test + // This test is more about verifying the error handling when a user is missing + + console.log(' ⚠️ Note: This test simulates the scenario where a user has a valid JWT token'); + console.log(' but is not found in the database. The actual deletion would be done manually.'); + console.log(' The fix should handle this gracefully by returning a proper error message.'); + + // Test with the same token - if user was deleted, this should fail gracefully + const response = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + console.log(' ✅ User still exists in database (expected for this test)'); + return true; + } else { + // Check if the error is handled gracefully + if (response.data.message && response.data.message.includes('Your session is invalid')) { + console.log(' ✅ Error handled gracefully:', response.data.message); + return true; + } else { + console.error(' ❌ Error not handled properly:', response.data); + return false; + } + } + + } catch (error) { + console.error(' ❌ Test 3 failed:', error.message); + return false; + } +} + +/** + * Test 4: Test with invalid token format + */ +async function testInvalidTokenFormat() { + console.log('\n🔒 Test 4: Test with invalid token format'); + + try { + const invalidToken = 'invalid-token-format'; + const response = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/me`, { + headers: { + 'Authorization': `Bearer ${invalidToken}` + } + }); + + if (response.status === 401) { + console.log(' ✅ Invalid token rejected properly'); + return true; + } else { + console.error(' ❌ Invalid token not rejected properly:', response.data); + return false; + } + + } catch (error) { + console.error(' ❌ Test 4 failed:', error.message); + return false; + } +} + +/** + * Test 5: Test with expired token (simulate by creating a token with past expiration) + */ +async function testExpiredToken() { + console.log('\n⏰ Test 5: Test with expired token'); + + try { + // Create a mock expired JWT token (this is just for testing error handling) + const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjE2MDk0NTkyMDB9.invalid'; + + const response = await makeRequest(`${testConfig.apiUrl}/rest/v2/auth/me`, { + headers: { + 'Authorization': `Bearer ${expiredToken}` + } + }); + + if (response.status === 401) { + console.log(' ✅ Expired token rejected properly'); + return true; + } else { + console.error(' ❌ Expired token not rejected properly:', response.data); + return false; + } + + } catch (error) { + console.error(' ❌ Test 5 failed:', error.message); + return false; + } +} + +/** + * Run all tests + */ +async function runTests() { + console.log(`🚀 Starting tests against ${testConfig.apiUrl}`); + + let passed = 0; + let failed = 0; + + // Test 1: Create user and login + const token = await testCreateUserAndLogin(); + if (token) { + passed++; + } else { + failed++; + console.log('\n❌ Cannot continue without valid token'); + return; + } + + // Test 2: Verify /auth/me works with valid token + const userResult = await testAuthMeWithValidToken(token); + if (userResult) { + passed++; + } else { + failed++; + } + + // Test 3: Simulate user deletion scenario + const deletionResult = await testUserDeletionScenario(token); + if (deletionResult) { + passed++; + } else { + failed++; + } + + // Test 4: Test with invalid token format + const invalidTokenResult = await testInvalidTokenFormat(); + if (invalidTokenResult) { + passed++; + } else { + failed++; + } + + // Test 5: Test with expired token + const expiredTokenResult = await testExpiredToken(); + if (expiredTokenResult) { + passed++; + } else { + failed++; + } + + // Summary + console.log('\n📊 Test Results'); + console.log('================'); + console.log(`✅ Passed: ${passed}`); + console.log(`❌ Failed: ${failed}`); + console.log(`📈 Success Rate: ${Math.round((passed / (passed + failed)) * 100)}%`); + + if (failed === 0) { + console.log('\n🎉 All tests passed! The auth redirect loop fix is working correctly.'); + } else { + console.log('\n⚠️ Some tests failed. Please review the error messages above.'); + } +} + +// Run the tests +runTests().catch(error => { + console.error('❌ Test suite failed:', error); + process.exit(1); +}); diff --git a/tests/test-api-client-fix.js b/tests/test-api-client-fix.js new file mode 100644 index 00000000..f254605e --- /dev/null +++ b/tests/test-api-client-fix.js @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/** + * Test script to verify the API client fix for empty JSON body error + * + * This test verifies that the API client correctly handles: + * 1. Only setting Content-Type header when there's actual data + * 2. Sending empty objects {} for POST requests without data + */ + +import { CanvasApiClient } from '../src/ui/cli/src/utils/api-client.js'; + +// Mock config for testing +const mockConfig = { + data: { + 'server.url': 'http://127.0.0.1:8001/rest/v2', + 'server.auth.token': 'canvas-a0ca1d5ce7da0235808d6b2d3e0a3530103c433e02b3d0f3' + }, + get(key) { + return this.data[key]; + }, + set(key, value) { + this.data[key] = value; + } +}; + +async function testApiClient() { + console.log('🧪 Testing API Client Fix...\n'); + + const client = new CanvasApiClient(mockConfig); + + try { + // Test 1: Ping endpoint (GET request - no body, no content-type) + console.log('1. Testing ping endpoint...'); + const pingResponse = await client.ping(); + console.log('✓ Ping successful:', pingResponse.message || 'OK'); + + // Test 2: Get profile (GET request - no body, no content-type) + console.log('2. Testing get profile...'); + const profile = await client.getProfile(); + console.log('✓ Profile retrieved:', profile.name || profile.email || 'OK'); + + // Test 3: Get workspaces (GET request - no body, no content-type) + console.log('3. Testing get workspaces...'); + const workspaces = await client.getWorkspaces(); + const workspaceCount = Array.isArray(workspaces) ? workspaces.length : 'unknown'; + console.log(`✓ Workspaces retrieved: ${workspaceCount} workspaces`); + + // Test 4: Workspace lifecycle (POST request with empty body {}) + if (Array.isArray(workspaces) && workspaces.length > 0) { + const workspaceId = workspaces[0].id; + console.log(`4. Testing workspace lifecycle with ID: ${workspaceId}...`); + + try { + const statusResponse = await client.getWorkspaceStatus(workspaceId); + console.log('✓ Workspace status retrieved:', statusResponse.status || 'OK'); + + // Test actual POST request (workspace start/stop) + console.log('5. Testing workspace start (POST with empty body)...'); + await client.startWorkspace(workspaceId); + console.log('✓ Workspace start request sent (this tests the content-type fix)'); + + } catch (error) { + if (error.message.includes('Unsupported Media Type: application/x-www-form-urlencoded')) { + console.error('❌ Content-type fix failed - still getting form-encoded error'); + } else { + console.log('✓ Workspace lifecycle test completed (error expected for non-existent workspace)'); + } + } + } + + console.log('\n🎉 All tests passed! API client fix is working correctly.'); + + } catch (error) { + console.error('❌ Test failed:', error.message); + + // Check if it's the original error we were trying to fix + if (error.message.includes('Body cannot be empty when content-type is set to')) { + console.error('💀 The original error still exists - fix didn\'t work!'); + } else { + console.log('ℹ️ Different error encountered - this may be expected depending on server state'); + } + } +} + +// Run the test +testApiClient().catch(console.error); diff --git a/tests/test-cli-fixes.js b/tests/test-cli-fixes.js new file mode 100644 index 00000000..b1501e74 --- /dev/null +++ b/tests/test-cli-fixes.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node + +/** + * Test script to verify CLI fixes work correctly + */ + +import { spawn } from 'child_process'; +import { promisify } from 'util'; + +const exec = promisify(spawn); + +const SERVER_URL = 'http://127.0.0.1:8001/rest/v2'; +const EMAIL = 'admin@canvas.local'; +const PASSWORD = 'cHbOcruNZKux'; + +async function runCommand(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: 'pipe' }); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + resolve({ code, stdout, stderr }); + }); + + child.on('error', (error) => { + reject(error); + }); + }); +} + +async function testCLIFixes() { + console.log('Testing CLI fixes...\n'); + + try { + // Test 1: Login first + console.log('1. Testing login...'); + const loginResult = await runCommand('node', [ + 'src/ui/cli/bin/canvas.js', + 'auth', 'login', + '--server', SERVER_URL, + '--email', EMAIL, + '--password', PASSWORD + ]); + + if (loginResult.code !== 0) { + console.error('Login failed:', loginResult.stderr); + return; + } + + console.log('✓ Login successful\n'); + + // Test 2: List workspaces (should NOT show workspace ID) + console.log('2. Testing workspace list (should not show workspace ID)...'); + const wsListResult = await runCommand('node', [ + 'src/ui/cli/bin/canvas.js', + 'ws', 'list', + '--server', SERVER_URL + ]); + + console.log('Workspace list output:'); + console.log(wsListResult.stdout); + + // Check if output contains workspace ID column + if (wsListResult.stdout.includes('ID')) { + console.log('❌ Workspace list still shows ID column'); + } else { + console.log('✓ Workspace list no longer shows ID column'); + } + + // Check if output contains owner email + if (wsListResult.stdout.includes('@')) { + console.log('✓ Workspace list shows owner email'); + } else { + console.log('⚠️ Workspace list may not show owner email'); + } + + console.log(''); + + // Test 3: List contexts (should NOT show context ID, should show owner email) + console.log('3. Testing context list (should not show context ID, should show owner email)...'); + const contextListResult = await runCommand('node', [ + 'src/ui/cli/bin/canvas.js', + 'context', 'list', + '--server', SERVER_URL + ]); + + console.log('Context list output:'); + console.log(contextListResult.stdout); + + // Check if output contains context ID column + if (contextListResult.stdout.includes('ID')) { + console.log('❌ Context list still shows ID column'); + } else { + console.log('✓ Context list no longer shows ID column'); + } + + // Check if output contains owner email + if (contextListResult.stdout.includes('@')) { + console.log('✓ Context list shows owner email'); + } else { + console.log('⚠️ Context list may not show owner email'); + } + + console.log('\n✓ All CLI fixes tested successfully!'); + + } catch (error) { + console.error('Error running CLI tests:', error); + } +} + +testCLIFixes(); diff --git a/tests/test-cli-integration.js b/tests/test-cli-integration.js new file mode 100644 index 00000000..2cebf6f2 --- /dev/null +++ b/tests/test-cli-integration.js @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +/** + * Test script to verify CLI integration with the new REST API + * + * Usage: node test-cli-integration.js + * + * This script tests the updated CLI against the new REST API structure + * using the provided test credentials. + */ + +import { execSync } from 'child_process'; +import chalk from 'chalk'; + +// Test configuration +const CONFIG = { + server: 'http://127.0.0.1:8001/rest/v2', + email: 'admin@canvas.local', + password: 'cHbOcruNZKux', + token: 'canvas-a0ca1d5ce7da0235808d6b2d3e0a3530103c433e02b3d0f3' +}; + +// Helper function to run CLI commands +function runCLI(command, expectSuccess = true) { + console.log(chalk.cyan(`\n► Running: canvas ${command}`)); + + try { + const result = execSync(`node src/ui/cli/src/index.js ${command}`, { + encoding: 'utf8', + cwd: process.cwd(), + stdio: 'pipe' + }); + + if (expectSuccess) { + console.log(chalk.green('✓ Success')); + console.log(result); + } + + return { success: true, output: result }; + } catch (error) { + if (expectSuccess) { + console.log(chalk.red('✗ Failed')); + console.log(error.stdout || error.message); + } + + return { success: false, output: error.stdout || error.message }; + } +} + +async function main() { + console.log(chalk.bold.blue('🧪 Testing CLI Integration with New REST API\n')); + + // Step 1: Configure CLI + console.log(chalk.yellow('Step 1: Configuring CLI')); + runCLI(`config set server.url ${CONFIG.server}`); + + // Step 2: Test ping + console.log(chalk.yellow('\nStep 2: Testing server connectivity')); + runCLI('config set server.auth.token ""'); // Clear token first + + // Step 3: Test authentication + console.log(chalk.yellow('\nStep 3: Testing authentication')); + + // Test login + runCLI(`auth login ${CONFIG.email} --password ${CONFIG.password}`); + + // Test profile + runCLI('auth profile'); + + // Test token creation + runCLI('auth create-token "Test Token" --description "Integration test token"'); + + // Test manual token setting + runCLI(`auth set-token ${CONFIG.token}`); + + // Test auth status + runCLI('auth status'); + + // Step 4: Test workspace operations + console.log(chalk.yellow('\nStep 4: Testing workspace operations')); + + // List workspaces + runCLI('workspace list'); + + // Show universe workspace + runCLI('workspace show universe'); + + // Get workspace status + runCLI('workspace status universe'); + + // Start workspace + runCLI('workspace start universe'); + + // List documents in workspace + runCLI('workspace documents universe'); + + // Get workspace tree + runCLI('workspace tree universe'); + + // Step 5: Test context operations + console.log(chalk.yellow('\nStep 5: Testing context operations')); + + // List contexts + runCLI('context list'); + + // Create a test context + runCLI('context create test-context --description "Test context for integration"'); + + // Show context + runCLI('context show test-context'); + + // Switch to context + runCLI('context switch test-context'); + + // Show current context + runCLI('context current'); + + // Get context URL + runCLI('context url test-context'); + + // Get context path + runCLI('context path test-context'); + + // List documents in context + runCLI('context documents'); + + // Add a note + runCLI('context note add "Test note for integration testing" --title "Integration Test Note"'); + + // List notes + runCLI('context notes'); + + // Add a tab + runCLI('context tab add https://example.com --title "Example Site"'); + + // List tabs + runCLI('context tabs'); + + // Get context tree + runCLI('context tree test-context'); + + // Step 6: Test error handling + console.log(chalk.yellow('\nStep 6: Testing error handling')); + + // Test non-existent context + runCLI('context show non-existent-context', false); + + // Test invalid token + runCLI('auth set-token invalid-token', false); + + // Step 7: Cleanup + console.log(chalk.yellow('\nStep 7: Cleanup')); + + // Delete test context + runCLI('context destroy test-context --force'); + + console.log(chalk.bold.green('\n✅ CLI Integration Test Complete!')); + console.log(chalk.gray('Check the output above for any failures or issues.')); +} + +// Run the test +main().catch(error => { + console.error(chalk.red('Test failed:'), error); + process.exit(1); +}); diff --git a/tests/test-rest-api-auth-connection-close.js b/tests/test-rest-api-auth-connection-close.js new file mode 100755 index 00000000..5c114e7c --- /dev/null +++ b/tests/test-rest-api-auth-connection-close.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node +'use strict'; + +import { spawn } from 'child_process'; +import { setTimeout } from 'timers/promises'; +import http from 'http'; +import https from 'https'; +import { URL } from 'url'; + +/** + * Test REST API authentication connection closure + * Verifies that TCP connections are properly closed when invalid API tokens are provided + */ + +const SERVER_URL = 'http://localhost:8001'; +const INVALID_TOKEN = 'canvas-a0ca1d5ce7da0235808d6b2d3e0a3530103c433e02b3d0f3'; + +async function testInvalidTokenWithNodeFetch() { + console.log('\n=== Test 1: Invalid token with Node.js HTTP request ==='); + + return new Promise((resolve, reject) => { + const url = new URL('/rest/v2/auth/me', SERVER_URL); + const startTime = Date.now(); + + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + 'Authorization': `Bearer ${INVALID_TOKEN}`, + 'Content-Type': 'application/json' + } + }; + + const req = http.request(options, (res) => { + const duration = Date.now() - startTime; + console.log(`Response received after ${duration}ms`); + console.log(`Status: ${res.statusCode}`); + console.log(`Headers:`, res.headers); + + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + console.log(`Response body: ${body}`); + + if (res.statusCode === 401) { + console.log('✅ Expected: 401 Unauthorized received'); + + // Check if Connection: close header is present + if (res.headers.connection && res.headers.connection.toLowerCase() === 'close') { + console.log('✅ Connection: close header is present'); + resolve({ success: true, message: `Connection properly closed after ${duration}ms` }); + } else { + console.log('❌ Connection: close header is missing'); + resolve({ success: false, message: 'Connection: close header not set' }); + } + } else { + console.log(`❌ Unexpected status code: ${res.statusCode}`); + resolve({ success: false, message: `Expected 401, got ${res.statusCode}` }); + } + }); + }); + + req.on('error', (error) => { + console.log(`Request error: ${error.message}`); + resolve({ success: false, message: `Request error: ${error.message}` }); + }); + + req.on('close', () => { + console.log('✅ Request connection closed'); + }); + + req.setTimeout(10000, () => { + console.log('❌ Request timeout'); + req.destroy(); + resolve({ success: false, message: 'Request timeout' }); + }); + + req.end(); + }); +} + +async function testInvalidTokenWithCurl() { + console.log('\n=== Test 2: Invalid token with curl (simulating CLI behavior) ==='); + + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + // Use curl to test REST API endpoint with invalid token + const curlProcess = spawn('curl', [ + '-v', + '-X', 'GET', + '-H', 'Content-Type: application/json', + '-H', `Authorization: Bearer ${INVALID_TOKEN}`, + '--max-time', '10', + '--connect-timeout', '5', + `${SERVER_URL}/rest/v2/auth/me` + ]); + + let output = ''; + let error = ''; + + curlProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + curlProcess.stderr.on('data', (data) => { + error += data.toString(); + }); + + curlProcess.on('close', (code) => { + const duration = Date.now() - startTime; + console.log(`curl process exited with code ${code} after ${duration}ms`); + + // Check if we got a 401 response + if (error.includes('401') || output.includes('401')) { + console.log('✅ Expected: 401 Unauthorized received'); + + // Check if Connection: close header is present in response + if (error.includes('Connection: close') || error.includes('connection: close')) { + console.log('✅ Connection: close header detected'); + resolve({ success: true, message: `Connection properly closed in ${duration}ms` }); + } else { + console.log('❌ Connection: close header not found'); + resolve({ success: false, message: 'Connection: close header not found in response' }); + } + } else { + console.log('❌ Expected 401 response not received'); + console.log('STDERR:', error); + console.log('STDOUT:', output); + resolve({ success: false, message: 'Expected 401 response not received' }); + } + }); + + curlProcess.on('error', (err) => { + console.log('curl error:', err.message); + resolve({ success: false, message: `curl error: ${err.message}` }); + }); + }); +} + +async function testConnectionReuse() { + console.log('\n=== Test 3: Connection reuse behavior with invalid token ==='); + + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + // Make multiple requests to see if connections are reused + const curlProcess = spawn('curl', [ + '-v', + '--http1.1', + '--keepalive-time', '2', + '-X', 'GET', + '-H', 'Content-Type: application/json', + '-H', `Authorization: Bearer ${INVALID_TOKEN}`, + '--max-time', '10', + `${SERVER_URL}/rest/v2/auth/me`, + `${SERVER_URL}/rest/v2/auth/me` + ]); + + let output = ''; + let error = ''; + + curlProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + curlProcess.stderr.on('data', (data) => { + error += data.toString(); + }); + + curlProcess.on('close', (code) => { + const duration = Date.now() - startTime; + console.log(`curl process exited with code ${code} after ${duration}ms`); + + // Check if connection was reused (should NOT be reused if properly closed) + if (error.includes('Re-using existing connection') || error.includes('Connection #0 to host')) { + console.log('❌ Connection was reused - this suggests connection was not properly closed'); + resolve({ success: false, message: 'Connection was reused after auth failure' }); + } else { + console.log('✅ Connection was not reused - connections properly closed'); + resolve({ success: true, message: `Connections properly closed in ${duration}ms` }); + } + }); + + curlProcess.on('error', (err) => { + console.log('curl error:', err.message); + resolve({ success: false, message: `curl error: ${err.message}` }); + }); + }); +} + +async function runTests() { + console.log('Testing REST API authentication connection closure...'); + console.log('Make sure the Canvas server is running on localhost:3000'); + + try { + // Test 1: Node.js HTTP request behavior + const test1Result = await testInvalidTokenWithNodeFetch(); + console.log(`Test 1 result: ${test1Result.success ? 'PASS' : 'FAIL'} - ${test1Result.message}`); + + // Wait a bit between tests + await setTimeout(1000); + + // Test 2: curl behavior (simulating CLI) + const test2Result = await testInvalidTokenWithCurl(); + console.log(`Test 2 result: ${test2Result.success ? 'PASS' : 'FAIL'} - ${test2Result.message}`); + + // Wait a bit between tests + await setTimeout(1000); + + // Test 3: Connection reuse behavior + const test3Result = await testConnectionReuse(); + console.log(`Test 3 result: ${test3Result.success ? 'PASS' : 'FAIL'} - ${test3Result.message}`); + + // Overall result + console.log('\n=== Test Summary ==='); + console.log(`Test 1 (Node.js HTTP): ${test1Result.success ? 'PASS' : 'FAIL'}`); + console.log(`Test 2 (curl): ${test2Result.success ? 'PASS' : 'FAIL'}`); + console.log(`Test 3 (connection reuse): ${test3Result.success ? 'PASS' : 'FAIL'}`); + + if (test1Result.success && test2Result.success && test3Result.success) { + console.log('✅ All tests passed - REST API auth properly closes connections'); + process.exit(0); + } else { + console.log('❌ Some tests failed - REST API auth may not be closing connections properly'); + process.exit(1); + } + + } catch (error) { + console.error('Test error:', error); + process.exit(1); + } +} + +// Run tests if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + runTests(); +} diff --git a/tests/test-websocket-events.js b/tests/test-websocket-events.js new file mode 100644 index 00000000..b0585279 --- /dev/null +++ b/tests/test-websocket-events.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +/** + * Test WebSocket Event Forwarding + * + * This test verifies that events emitted from Context instances + * are properly forwarded through ContextManager to WebSocket clients. + * + * Run with: node tests/test-websocket-events.js + */ + +import { createDebug } from '../src/utils/log/index.js'; +import ContextManager from '../src/managers/context/index.js'; +import WorkspaceManager from '../src/managers/workspace/index.js'; +import UserManager from '../src/managers/user/index.js'; +import jim from '../src/utils/jim/index.js'; +import io from 'socket.io-client'; + +const debug = createDebug('test:websocket-events'); + +async function testWebSocketEvents() { + debug('🧪 Starting WebSocket event forwarding test...'); + + try { + // Initialize managers + const userManager = new UserManager({ + rootPath: '/tmp/test-users', + indexStore: jim.createIndex('test-users'), + }); + + const workspaceManager = new WorkspaceManager({ + defaultRootPath: '/tmp/test-workspaces', + indexStore: jim.createIndex('test-workspaces'), + userManager: userManager, + }); + + const contextManager = new ContextManager({ + indexStore: jim.createIndex('test-contexts'), + workspaceManager: workspaceManager + }); + + userManager.setWorkspaceManager(workspaceManager); + userManager.setContextManager(contextManager); + + await userManager.initialize(); + await workspaceManager.initialize(); + await contextManager.initialize(); + + debug('✅ Managers initialized'); + + // Create a test user + const testUser = await userManager.createUser({ + email: 'test@example.com', + name: 'Test User' + }); + + debug('✅ Test user created:', testUser.id); + + // Get default workspace + const workspace = await workspaceManager.getWorkspace(testUser.id, 'universe', testUser.id); + debug('✅ Got workspace:', workspace.name); + + // Create a context + const context = await contextManager.createContext(testUser.id, 'universe://test', { + id: 'test-context' + }); + + debug('✅ Created context:', context.id); + + // Set up event listeners on ContextManager + let managerEventReceived = false; + let contextEventReceived = false; + + contextManager.on('document.inserted', (payload) => { + debug('🎯 ContextManager received document.inserted event:', payload); + managerEventReceived = true; + }); + + context.on('document.inserted', (payload) => { + debug('🎯 Context received document.inserted event:', payload); + contextEventReceived = true; + }); + + debug('✅ Event listeners set up'); + + // Insert a test document to trigger events + const testDocument = { + schema: 'data/abstraction/tab', + schemaVersion: '2.0', + data: { + url: 'https://test.example.com', + title: 'Test Tab', + timestamp: new Date().toISOString() + } + }; + + debug('🚀 Inserting test document...'); + const result = await context.insertDocumentArray(testUser.id, [testDocument], ['test-feature']); + + debug('✅ Document inserted, result:', result); + + // Wait a bit for events to propagate + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Check results + if (contextEventReceived) { + debug('✅ Context event was emitted correctly'); + } else { + debug('❌ Context event was NOT emitted'); + } + + if (managerEventReceived) { + debug('✅ ContextManager received forwarded event correctly'); + } else { + debug('❌ ContextManager did NOT receive forwarded event'); + } + + // Test context URL change + debug('🚀 Testing context URL change...'); + let urlChangeReceived = false; + + contextManager.on('context.url.set', (payload) => { + debug('🎯 ContextManager received context.url.set event:', payload); + urlChangeReceived = true; + }); + + await context.setUrl('universe://test/changed'); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (urlChangeReceived) { + debug('✅ Context URL change event was forwarded correctly'); + } else { + debug('❌ Context URL change event was NOT forwarded'); + } + + // Summary + debug('🧪 Test Summary:'); + debug(` Context Event Emission: ${contextEventReceived ? '✅' : '❌'}`); + debug(` Manager Event Forwarding: ${managerEventReceived ? '✅' : '❌'}`); + debug(` URL Change Forwarding: ${urlChangeReceived ? '✅' : '❌'}`); + + if (contextEventReceived && managerEventReceived && urlChangeReceived) { + debug('🎉 All tests PASSED! Event forwarding is working correctly.'); + process.exit(0); + } else { + debug('💥 Some tests FAILED! Check the logs above.'); + process.exit(1); + } + + } catch (error) { + debug('💥 Test failed with error:', error.message); + debug('Stack:', error.stack); + process.exit(1); + } +} + +// Run the test +testWebSocketEvents(); diff --git a/tests/workspace-naming-convention-test.js b/tests/workspace-naming-convention-test.js new file mode 100644 index 00000000..7b8f0a1b --- /dev/null +++ b/tests/workspace-naming-convention-test.js @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +/** + * Test script for the new workspace naming convention + * Format: [userid]@[host]:[workspace_slug][/optional_path...] + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync } from 'fs'; +import * as fsPromises from 'fs/promises'; + +// Get the directory of the current module +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Add src to path for imports +const srcPath = path.join(__dirname, '..', 'src'); +process.env.NODE_PATH = srcPath; + +// Imports +import WorkspaceManager from '../src/managers/workspace/index.js'; +import Conf from 'conf'; + +const DEFAULT_HOST = 'canvas.local'; + +async function testWorkspaceNamingConvention() { + console.log('🧪 Testing new workspace naming convention...\n'); + + // Setup test environment + const testDir = path.join(__dirname, 'temp-workspace-naming-test'); + const indexStorePath = path.join(testDir, 'workspace-index.json'); + + // Clean up any existing test directory + if (existsSync(testDir)) { + await fsPromises.rm(testDir, { recursive: true, force: true }); + } + await fsPromises.mkdir(testDir, { recursive: true }); + + try { + // Create workspace manager with test configuration + const indexStore = new Conf({ + configName: 'workspace-index', + cwd: testDir, + accessPropertiesByDotNotation: false + }); + + const workspaceManager = new WorkspaceManager({ + defaultRootPath: testDir, + indexStore: indexStore + }); + + await workspaceManager.initialize(); + + // Test data + const testUser1 = 'user123'; + const testUser2 = 'user456'; + const testWorkspace1 = 'my-project'; + const testWorkspace2 = 'shared-workspace'; + + console.log('📝 Test 1: Creating workspaces with new naming convention'); + + // Create workspace for user123@canvas.local:my-project + const workspace1 = await workspaceManager.createWorkspace(testUser1, testWorkspace1, { + owner: testUser1, + label: 'My Project', + description: 'Test workspace for user123' + }); + + // Create workspace for user456@canvas.local:shared-workspace + const workspace2 = await workspaceManager.createWorkspace(testUser2, testWorkspace2, { + owner: testUser2, + label: 'Shared Workspace', + description: 'Test workspace for user456' + }); + + console.log(`✅ Created workspace1: ${workspace1.id} (${workspace1.name})`); + console.log(`✅ Created workspace2: ${workspace2.id} (${workspace2.name})`); + + console.log('\n📝 Test 2: Testing user.email validation (should fail)'); + + // Test that user.email is rejected + try { + await workspaceManager.createWorkspace('user@domain.com', 'invalid-workspace', { + owner: 'user@domain.com', + label: 'Invalid Workspace' + }); + console.log('❌ Should have failed for user.email'); + } catch (error) { + console.log(`✅ Correctly rejected user.email: ${error.message}`); + } + + // Test that workspace reference parsing rejects user.email + const invalidRef = 'user@domain.com@canvas.local:invalid-workspace'; + const parsedInvalid = workspaceManager.parseWorkspaceReference(invalidRef); + console.log(`✅ Invalid reference parsing result: ${JSON.stringify(parsedInvalid)} (should be null)`); + + console.log('\n📝 Test 3: Testing workspace reference parsing'); + + // Test workspace reference parsing + const ref1 = workspaceManager.constructWorkspaceReference(testUser1, testWorkspace1); + const ref2 = workspaceManager.constructWorkspaceReference(testUser2, testWorkspace2); + const ref3 = workspaceManager.constructWorkspaceReference(testUser1, testWorkspace1, 'remote.server.com', '/subfolder'); + + console.log(`✅ Reference 1: ${ref1}`); + console.log(`✅ Reference 2: ${ref2}`); + console.log(`✅ Reference 3: ${ref3}`); + + // Test parsing references + const parsed1 = workspaceManager.parseWorkspaceReference(ref1); + const parsed2 = workspaceManager.parseWorkspaceReference(ref2); + const parsed3 = workspaceManager.parseWorkspaceReference(ref3); + + console.log(`✅ Parsed 1:`, parsed1); + console.log(`✅ Parsed 2:`, parsed2); + console.log(`✅ Parsed 3:`, parsed3); + + console.log('\n📝 Test 4: Testing workspace resolution'); + + // Test workspace ID resolution + const resolvedId1 = workspaceManager.resolveWorkspaceId(testUser1, testWorkspace1); + const resolvedId2 = workspaceManager.resolveWorkspaceId(testUser2, testWorkspace2); + const resolvedId3 = workspaceManager.resolveWorkspaceIdFromReference(ref1); + + console.log(`✅ Resolved ID 1: ${resolvedId1} (should be ${workspace1.id})`); + console.log(`✅ Resolved ID 2: ${resolvedId2} (should be ${workspace2.id})`); + console.log(`✅ Resolved ID 3: ${resolvedId3} (should be ${workspace1.id})`); + + console.log('\n📝 Test 5: Testing workspace opening with different identifiers'); + + // Test opening workspace by ID + const openedById1 = await workspaceManager.openWorkspace(testUser1, workspace1.id, testUser1); + console.log(`✅ Opened by ID: ${openedById1?.id}`); + + // Test opening workspace by name + const openedByName1 = await workspaceManager.openWorkspace(testUser1, testWorkspace1, testUser1); + console.log(`✅ Opened by name: ${openedByName1?.id}`); + + // Test opening workspace by reference + const openedByRef1 = await workspaceManager.openWorkspace(testUser1, ref1, testUser1); + console.log(`✅ Opened by reference: ${openedByRef1?.id}`); + + console.log('\n📝 Test 6: Testing workspace listing'); + + // Test listing workspaces + const user1Workspaces = await workspaceManager.listUserWorkspaces(testUser1); + const user2Workspaces = await workspaceManager.listUserWorkspaces(testUser2); + + console.log(`✅ User1 workspaces: ${user1Workspaces.length} (should be 1)`); + console.log(`✅ User2 workspaces: ${user2Workspaces.length} (should be 1)`); + + console.log('\n📝 Test 7: Testing index key format'); + + // Test index key format + const allWorkspaces = workspaceManager.getAllWorkspacesWithKeys(); + console.log('✅ Index keys:'); + for (const key of Object.keys(allWorkspaces)) { + console.log(` ${key}`); + } + + console.log('\n📝 Test 8: Testing workspace removal'); + + // Test removing workspace + const removed = await workspaceManager.removeWorkspace(testUser1, testWorkspace1, testUser1, false); + console.log(`✅ Removed workspace: ${removed}`); + + // Verify removal + const remainingWorkspaces = await workspaceManager.listUserWorkspaces(testUser1); + console.log(`✅ Remaining workspaces for user1: ${remainingWorkspaces.length} (should be 0)`); + + console.log('\n🎉 All tests passed! New workspace naming convention is working correctly.'); + + // Test assertions + console.log('\n📊 Assertions:'); + + // Assert reference format + console.assert(ref1 === `${testUser1}@${DEFAULT_HOST}:${testWorkspace1}`, 'Reference 1 format incorrect'); + console.assert(ref2 === `${testUser2}@${DEFAULT_HOST}:${testWorkspace2}`, 'Reference 2 format incorrect'); + console.assert(ref3 === `${testUser1}@remote.server.com:${testWorkspace1}/subfolder`, 'Reference 3 format incorrect'); + + // Assert parsing + console.assert(parsed1.userId === testUser1, 'Parsed userId incorrect'); + console.assert(parsed1.workspaceSlug === testWorkspace1, 'Parsed workspaceSlug incorrect'); + console.assert(parsed1.host === DEFAULT_HOST, 'Parsed host incorrect'); + console.assert(parsed1.isLocal === true, 'Parsed isLocal incorrect'); + + // Assert user.email validation + console.assert(parsedInvalid === null, 'Invalid reference should be null'); + + // Assert resolution + console.assert(resolvedId1 === workspace1.id, 'Resolved ID 1 incorrect'); + console.assert(resolvedId2 === workspace2.id, 'Resolved ID 2 incorrect'); + console.assert(resolvedId3 === workspace1.id, 'Resolved ID 3 incorrect'); + + // Assert opening + console.assert(openedById1?.id === workspace1.id, 'Opened by ID incorrect'); + console.assert(openedByName1?.id === workspace1.id, 'Opened by name incorrect'); + console.assert(openedByRef1?.id === workspace1.id, 'Opened by reference incorrect'); + + // Assert listing + console.assert(user1Workspaces.length === 1, 'User1 workspace count incorrect'); + console.assert(user2Workspaces.length === 1, 'User2 workspace count incorrect'); + + // Assert index key format (should use :: separator) + const indexKeys = Object.keys(allWorkspaces); + console.assert(indexKeys.some(key => key.includes('::')), 'Index keys should use :: separator'); + console.assert(!indexKeys.some(key => key.includes('|')), 'Index keys should not use | separator'); + console.assert(!indexKeys.some(key => key.includes('@')), 'Index keys should not contain @ symbol'); + + // Assert removal + console.assert(removed === true, 'Workspace removal failed'); + console.assert(remainingWorkspaces.length === 0, 'Workspace not properly removed'); + + console.log('✅ All assertions passed!'); + + } catch (error) { + console.error('❌ Test failed:', error); + process.exit(1); + } finally { + // Clean up test directory + if (existsSync(testDir)) { + await fsPromises.rm(testDir, { recursive: true, force: true }); + } + } +} + +// Run the test +testWorkspaceNamingConvention().catch(console.error); diff --git a/Canvas.TODO b/todo/Canvas.TODO similarity index 100% rename from Canvas.TODO rename to todo/Canvas.TODO diff --git a/TODO.md b/todo/Generic.TODO similarity index 82% rename from TODO.md rename to todo/Generic.TODO index fc391cf8..446b3c16 100644 --- a/TODO.md +++ b/todo/Generic.TODO @@ -3,7 +3,7 @@ ## Architectural changes - Integrate stored into synapsd -- Use https://unstorage.unjs.io/ instead of a custom lmdb wrapper, in worst cases we can write a small lmdb driver for unstorage if we'll feel its justified +- Use https://unstorage.unjs.io/ instead of a custom lmdb wrapper?, in worst cases we can write a small lmdb driver for unstorage if we'll feel its justified ## Utils/config @@ -12,7 +12,8 @@ ## Context handling -- We need to ensure +- Support contexts on top of remote workspaces! +- We need to ensure - Context layers will get locked when open in a context - Layers will need to have a in-memory map of userid/contextid locks @@ -32,8 +33,7 @@ Workspace config paths - streamline the IDs used across managers (user-prefix is currently a mix of user.email and user.id) - !!! SIMPLIFY -- !!! SIMPLIFY -- !!! SIMPLIFY +- !!! SIMPLIFY- !!! SIMPLIFY ## Workspace Manager