Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
7fa48b2
docs: spec for workspaces foundation (sub-project A)
itsmeakhil Jun 26, 2026
ec93c15
docs: implementation plan for workspaces foundation (sub-project A)
itsmeakhil Jun 26, 2026
eddcd73
feat(workspaces): mongo schemas + repo + system-org seed
itsmeakhil Jun 26, 2026
10a924e
fix(workspaces): add get_system_org_id + race-safe upserts
itsmeakhil Jun 26, 2026
d313c4f
feat(workspaces): WorkspaceContext + get_workspace_ctx middleware
itsmeakhil Jun 26, 2026
3d338a6
feat(workspaces): first-login hook ensures Personal workspace
itsmeakhil Jun 26, 2026
5edb861
fix(workspaces): revert unauthorized Python 3.9 compat rewrites
itsmeakhil Jun 26, 2026
7a41c02
feat(workspaces): per-user lazy backfill background task
itsmeakhil Jun 26, 2026
cb7cd83
feat(workspaces): GET orgs/workspaces + POST active route
itsmeakhil Jun 26, 2026
6d652bb
refactor(passwords): scope queries by workspace context
itsmeakhil Jun 26, 2026
9f40a66
refactor(bookmarks): scope queries by workspace context
itsmeakhil Jun 26, 2026
427d391
fix(workspaces): apply_legacy_or_filter preserves caller $or
itsmeakhil Jun 26, 2026
7c1c388
refactor(tasks): scope queries by workspace context
itsmeakhil Jun 26, 2026
0f2400b
refactor(notes): scope queries by workspace context
itsmeakhil Jun 26, 2026
b855bbd
fix(workspaces): backfill spec uses created_by for all 17 collections
itsmeakhil Jun 26, 2026
1f5bcc2
refactor(environment-manager): scope queries by workspace context
itsmeakhil Jun 26, 2026
1fc8d79
refactor(api-key-vault): scope queries by workspace context
itsmeakhil Jun 26, 2026
e5171e2
refactor(code-snippets): scope queries by workspace context
itsmeakhil Jun 26, 2026
4b5ca44
refactor(nosql): scope queries by workspace context
itsmeakhil Jun 26, 2026
0ba0d31
fix(nosql): drop stale "upsert" wording from save_connection route su…
itsmeakhil Jun 26, 2026
dd300b9
refactor(api-client): scope queries by workspace context
itsmeakhil Jun 26, 2026
732ddf3
refactor(sql-client): scope queries by workspace context
itsmeakhil Jun 26, 2026
1a0a61e
refactor(s3-drive): scope queries by workspace context
itsmeakhil Jun 26, 2026
b86a876
refactor(redis-commander): scope queries by workspace context
itsmeakhil Jun 26, 2026
8f1886c
refactor(url-shortener): scope queries by workspace context
itsmeakhil Jun 26, 2026
3734879
refactor(json-formatter): scope queries by workspace context
itsmeakhil Jun 26, 2026
a4de0f4
chore(dns-lookup): document stateless-tool exemption from workspace s…
itsmeakhil Jun 26, 2026
7198c97
feat(workspaces): web API client + Zustand store
itsmeakhil Jun 26, 2026
9c4d750
feat(workspaces): display-only switcher pill in navbar
itsmeakhil Jun 26, 2026
5c6473d
feat(workspaces): re-key pinned tools by workspace
itsmeakhil Jun 26, 2026
d00c8e0
feat(workspaces): sync pinned tools as keyed map
itsmeakhil Jun 26, 2026
57fc606
feat(workspaces): boot wiring + first-login migration banner
itsmeakhil Jun 26, 2026
c60acf7
fix(workspaces): final review wave — pinned-tools field names, auth_u…
itsmeakhil Jun 26, 2026
e27e4db
fix(workspaces): stable empty-array ref for pinned-tools selector
itsmeakhil Jun 26, 2026
6be2ad0
docs: spec for workspaces collaboration + E2EE (sub-projects B + C)
itsmeakhil Jun 26, 2026
cd58dd4
docs: implementation plan for workspaces collaboration + E2EE (B + C)
itsmeakhil Jun 26, 2026
e737de8
docs: renumber B/C tasks to 1..33 for SDD task-brief script
itsmeakhil Jun 26, 2026
c771d94
feat(workspaces): invitations collection + soft-delete repo helpers
itsmeakhil Jun 26, 2026
7114798
feat(workspaces): Resend email wrapper with dev-mode logger
itsmeakhil Jun 27, 2026
2124e72
feat(workspaces): hardcoded RBAC matrix + require_permission dep
itsmeakhil Jun 27, 2026
e9408fc
feat(workspaces): role cascade — org owner/admin implicit ws admin
itsmeakhil Jun 27, 2026
99c18f1
feat(workspaces): org create/rename/soft-delete + cascade
itsmeakhil Jun 27, 2026
47650b1
feat(workspaces): shared workspace create/rename/soft-delete
itsmeakhil Jun 27, 2026
b19a74c
feat(workspaces): member CRUD with role cascade + sole-owner guard
itsmeakhil Jun 27, 2026
cd7af25
feat(workspaces): invitation create / accept / revoke flows
itsmeakhil Jun 27, 2026
60b6973
feat(workspaces): wrap all 14 scoped routes with require_permission
itsmeakhil Jun 27, 2026
70769e0
feat(workspaces): 30-day soft-delete sweeper background task
itsmeakhil Jun 27, 2026
cdcdffb
feat(workspaces): frontend RBAC matrix + useToolPermission hook
itsmeakhil Jun 27, 2026
a5bfe73
fix(workspaces): mirror encrypted-tool keys in frontend RBAC matrix +…
itsmeakhil Jun 27, 2026
c44ca6c
feat(workspaces): sidebar role-gates tools + filters pinned by permis…
itsmeakhil Jun 27, 2026
be92c26
feat(workspaces): switcher dropdown with org groups and create CTAs
itsmeakhil Jun 27, 2026
eb5624b
feat(workspaces): create-org / create-workspace / invite-member dialogs
itsmeakhil Jun 27, 2026
b707979
feat(workspaces): /settings/workspaces management page
itsmeakhil Jun 27, 2026
a1e88d6
feat(workspaces): pending invitations badge + token-auto-accept on login
itsmeakhil Jun 27, 2026
87a532e
fix(workspaces): expose invitation token on response so in-app accept…
itsmeakhil Jun 27, 2026
6b15a52
feat(workspaces): encrypted-tool placeholder in shared workspaces
itsmeakhil Jun 27, 2026
37fb625
feat(workspaces): BroadcastChannel cross-tab sync for active workspace
itsmeakhil Jun 27, 2026
657277d
feat(workspaces): client-side crypto helpers (X25519/P-256 + AES-GCM)
itsmeakhil Jun 27, 2026
aaf3a64
feat(workspaces): users.encryption schema + keypair endpoints
itsmeakhil Jun 27, 2026
6fe5b4d
feat(workspaces): per-member DEK wrap + rotate + pending-wraps routes
itsmeakhil Jun 27, 2026
d0d6683
fix(workspaces): pending-wraps requires admin; absent-field match; pr…
itsmeakhil Jun 27, 2026
5e7defe
feat(workspaces): user keypair store + unwrap-on-master-unlock
itsmeakhil Jun 27, 2026
f4f2b91
fix(workspaces): clear keypair on sidebar signout + getKeypair handle…
itsmeakhil Jun 27, 2026
371979a
feat(workspaces): workspace DEK store with lazy hydrate
itsmeakhil Jun 27, 2026
aca52ff
feat(workspaces): password manager uses workspace DEK in shared works…
itsmeakhil Jun 27, 2026
bfbd383
fix(workspaces): hoist password-manager hooks above conditional place…
itsmeakhil Jun 27, 2026
d78906e
feat(workspaces): environment manager uses workspace DEK in shared wo…
itsmeakhil Jun 27, 2026
0b0ffc5
feat(workspaces): api key vault uses workspace DEK in shared workspaces
itsmeakhil Jun 27, 2026
a1ab321
feat(workspaces): enable encrypted tools CTA + initial DEK wrap
itsmeakhil Jun 27, 2026
78a4045
fix(workspaces): use master-vault salt when publishing user keypair
itsmeakhil Jun 27, 2026
ab887d2
feat(workspaces): pending wraps prompt + cross-member completion
itsmeakhil Jun 27, 2026
728f20f
feat(workspaces): rotate-key UI + trigger
itsmeakhil Jun 27, 2026
65152a7
feat(workspaces): client-side DEK rotation + re-encrypt loop
itsmeakhil Jun 27, 2026
f72af12
feat(workspaces): Hoppscotch-style top-right cluster — switcher + + +…
itsmeakhil Jun 27, 2026
5161948
UI
itsmeakhil Jun 29, 2026
371b01e
ui
itsmeakhil Jun 29, 2026
849fb76
fix: TaskContainer event.key guard + RBAC flips encrypted-tool row wh…
itsmeakhil Jun 29, 2026
60bbf32
UI
itsmeakhil Jun 29, 2026
a0ee8ec
fix(workspaces): TypeScript strictness — Uint8Array<ArrayBufferLike> …
itsmeakhil Jun 29, 2026
db16a94
UI
itsmeakhil Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/app/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from app.api.routes.url_shortener.api import router as url_shortener_router
from app.api.routes.dns_lookup.api import router as dns_lookup_router
from app.api.routes.audit_log.api import router as audit_log_router
from app.api.routes.workspaces.api import router as workspaces_router

api_router = APIRouter()
api_router.include_router(health_router)
Expand All @@ -50,3 +51,4 @@
api_router.include_router(url_shortener_router)
api_router.include_router(dns_lookup_router)
api_router.include_router(audit_log_router)
api_router.include_router(workspaces_router)
70 changes: 37 additions & 33 deletions apps/backend/app/api/routes/api_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,24 @@
ApiClientWorkspaceUpdate,
)
from app.api.routes.auth.services import get_current_uid
from app.api.routes.workspaces.middleware import WorkspaceContext
from app.api.routes.workspaces.rbac import require_permission

router = APIRouter(prefix="/api-client", tags=["api-client"])
router.include_router(collections_delta.router)


@router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections")
async def list_collections(uid: str = Depends(get_current_uid)) -> list[ApiClientCollectionOut]:
return await api_client_svc.list_collections(uid=uid)
async def list_collections(ctx: WorkspaceContext = Depends(require_permission("api-client", "read"))) -> list[ApiClientCollectionOut]:
return await api_client_svc.list_collections(ctx=ctx)


@router.post("/collections", response_model=ApiClientCollectionOut, summary="Create API client collection")
async def create_collection(
body: ApiClientCollectionCreate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientCollectionOut:
return await api_client_svc.create_collection(uid, body)
return await api_client_svc.create_collection(ctx, body)


@router.patch(
Expand All @@ -45,27 +47,27 @@ async def create_collection(
async def patch_collection(
collection_id: str,
body: ApiClientCollectionUpdate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientCollectionOut:
return await api_client_svc.patch_collection(uid, collection_id, body)
return await api_client_svc.patch_collection(ctx, collection_id, body)


@router.delete("/collections/{collection_id}", status_code=204, summary="Delete API client collection")
async def delete_collection(collection_id: str, uid: str = Depends(get_current_uid)) -> None:
await api_client_svc.delete_collection(uid, collection_id)
async def delete_collection(collection_id: str, ctx: WorkspaceContext = Depends(require_permission("api-client", "delete"))) -> None:
await api_client_svc.delete_collection(ctx, collection_id)


@router.get("/environments", response_model=list[ApiClientEnvironmentOut], summary="List API client environments")
async def list_environments(uid: str = Depends(get_current_uid)) -> list[ApiClientEnvironmentOut]:
return await api_client_svc.list_environments(uid=uid)
async def list_environments(ctx: WorkspaceContext = Depends(require_permission("api-client", "read"))) -> list[ApiClientEnvironmentOut]:
return await api_client_svc.list_environments(ctx=ctx)


@router.post("/environments", response_model=ApiClientEnvironmentOut, summary="Create API client environment")
async def create_environment(
body: ApiClientEnvironmentCreate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientEnvironmentOut:
return await api_client_svc.create_environment(uid, body)
return await api_client_svc.create_environment(ctx, body)


@router.patch(
Expand All @@ -76,46 +78,48 @@ async def create_environment(
async def patch_environment(
environment_id: str,
body: ApiClientEnvironmentUpdate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientEnvironmentOut:
return await api_client_svc.patch_environment(uid, environment_id, body)
return await api_client_svc.patch_environment(ctx, environment_id, body)


@router.delete("/environments/{environment_id}", status_code=204, summary="Delete API client environment")
async def delete_environment(environment_id: str, uid: str = Depends(get_current_uid)) -> None:
await api_client_svc.delete_environment(uid, environment_id)
async def delete_environment(environment_id: str, ctx: WorkspaceContext = Depends(require_permission("api-client", "delete"))) -> None:
await api_client_svc.delete_environment(ctx, environment_id)


@router.get("/history", response_model=list[ApiClientHistoryOut], summary="List API client request history")
async def list_history(
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "read")),
limit: int = Query(default=HISTORY_MAX_ITEMS, ge=1, le=HISTORY_MAX_ITEMS),
) -> list[ApiClientHistoryOut]:
return await api_client_svc.list_history(uid=uid, limit=limit)
return await api_client_svc.list_history(ctx=ctx, limit=limit)


@router.post("/history", response_model=ApiClientHistoryOut, summary="Append API client history entry")
async def create_history(
body: ApiClientHistoryCreate,
background_tasks: BackgroundTasks,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientHistoryOut:
entry = await api_client_svc.create_history(uid, body)
background_tasks.add_task(api_client_svc.trim_history, uid)
entry = await api_client_svc.create_history(ctx, body)
background_tasks.add_task(api_client_svc.trim_history, ctx)
return entry


@router.delete("/history/clear", status_code=204, summary="Clear all API client history")
async def clear_history(uid: str = Depends(get_current_uid)) -> None:
await api_client_svc.clear_history(uid)
async def clear_history(ctx: WorkspaceContext = Depends(require_permission("api-client", "admin"))) -> None:
await api_client_svc.clear_history(ctx)


@router.delete("/history/{entry_id}", status_code=204, summary="Delete one history entry")
async def delete_history_entry(entry_id: str, uid: str = Depends(get_current_uid)) -> None:
await api_client_svc.delete_history_entry(uid, entry_id)
async def delete_history_entry(entry_id: str, ctx: WorkspaceContext = Depends(require_permission("api-client", "delete"))) -> None:
await api_client_svc.delete_history_entry(ctx, entry_id)


# ── Public mocks ─────────────────────────────────────────────────────────────
# Public mocks retain uid-based ownership management (not workspace-scoped).
# See services.py for rationale.


@router.get(
Expand Down Expand Up @@ -170,8 +174,8 @@ async def get_public_mock_anonymous(mock_id: str) -> ApiClientPublicMockOut:
response_model=list[ApiClientWorkspaceOut],
summary="List the caller's workspaces",
)
async def list_workspaces(uid: str = Depends(get_current_uid)) -> list[ApiClientWorkspaceOut]:
return await api_client_svc.list_workspaces(uid=uid)
async def list_workspaces(ctx: WorkspaceContext = Depends(require_permission("api-client", "read"))) -> list[ApiClientWorkspaceOut]:
return await api_client_svc.list_workspaces(ctx=ctx)


@router.post(
Expand All @@ -181,9 +185,9 @@ async def list_workspaces(uid: str = Depends(get_current_uid)) -> list[ApiClient
)
async def create_workspace(
body: ApiClientWorkspaceCreate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientWorkspaceOut:
return await api_client_svc.create_workspace(uid, body)
return await api_client_svc.create_workspace(ctx, body)


@router.patch(
Expand All @@ -194,15 +198,15 @@ async def create_workspace(
async def patch_workspace(
workspace_id: str,
body: ApiClientWorkspaceUpdate,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApiClientWorkspaceOut:
return await api_client_svc.patch_workspace(uid, workspace_id, body)
return await api_client_svc.patch_workspace(ctx, workspace_id, body)


@router.delete(
"/workspaces/{workspace_id}",
status_code=204,
summary="Delete a workspace (collections reset to default)",
)
async def delete_workspace(workspace_id: str, uid: str = Depends(get_current_uid)) -> None:
await api_client_svc.delete_workspace(uid, workspace_id)
async def delete_workspace(workspace_id: str, ctx: WorkspaceContext = Depends(require_permission("api-client", "delete"))) -> None:
await api_client_svc.delete_workspace(ctx, workspace_id)
18 changes: 9 additions & 9 deletions apps/backend/app/api/routes/api_client/collections_delta.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
Op,
UpdateItemOp,
)
from app.api.routes.auth.services import get_current_uid
from app.core.cache import bump_version
from app.api.routes.workspaces.middleware import WorkspaceContext, apply_workspace_filter
from app.api.routes.workspaces.rbac import require_permission
from app.database import db_manager
from app.utils.collection_name import API_CLIENT_COLLECTIONS

Expand Down Expand Up @@ -199,14 +199,15 @@ def _apply_move(
# ── Service ───────────────────────────────────────────────────────────────────

async def apply_collection_delta(
uid: str,
ctx: WorkspaceContext,
collection_id: str,
ops: list[Op],
) -> ApiClientCollectionOut:
oid = _parse_oid(collection_id, kind="collection")

# Fetch + ownership check BEFORE any mutation
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid})
flt = apply_workspace_filter(ctx, {"_id": oid, "created_by": ctx.uid})
doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, flt)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")

Expand Down Expand Up @@ -245,7 +246,7 @@ async def apply_collection_delta(
try:
updated_doc = await db_manager.find_one_and_update(
API_CLIENT_COLLECTIONS,
{"_id": oid, "created_by": uid},
flt,
{"$set": {"items": items}},
return_document=ReturnDocument.AFTER,
)
Expand All @@ -258,8 +259,7 @@ async def apply_collection_delta(
if not updated_doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.")

# Invalidate cache (same namespace as patch_collection / delete_collection)
await bump_version(ns="api_client", uid=uid)
# ponytail: cache removed during workspace refactor; re-add with (workspace_id, uid) key if hot

return _collection_to_out(updated_doc)

Expand All @@ -274,7 +274,7 @@ async def apply_collection_delta(
async def apply_delta(
collection_id: str,
body: ApplyDeltaRequest,
uid: str = Depends(get_current_uid),
ctx: WorkspaceContext = Depends(require_permission("api-client", "write")),
) -> ApplyDeltaResponse:
collection = await apply_collection_delta(uid, collection_id, body.ops)
collection = await apply_collection_delta(ctx, collection_id, body.ops)
return ApplyDeltaResponse(collection=collection)
Loading