diff --git a/cli/cmd/testutil_test.go b/cli/cmd/testutil_test.go
index d157353..a2427cc 100644
--- a/cli/cmd/testutil_test.go
+++ b/cli/cmd/testutil_test.go
@@ -2,20 +2,20 @@ package cmd
import (
"bytes"
- "crypto/sha1"
"encoding/json"
- "fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
+
+ "github.com/anthropics/code-index/cli/internal/client"
)
-// projectHash returns the same SHA1 prefix that the client uses for URL routing.
+// projectHash returns the same project URL hash the client uses for routing —
+// delegated to the real implementation so the per-machine namespacing matches.
func projectHash(path string) string {
- h := sha1.Sum([]byte(path))
- return fmt.Sprintf("%x", h)[:16]
+ return client.EncodeProjectPath(path)
}
// mockServer starts a test HTTP server and registers cleanup.
diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go
index 6723f12..e59039c 100644
--- a/cli/internal/client/client.go
+++ b/cli/internal/client/client.go
@@ -2,11 +2,17 @@ package client
import (
"bytes"
+ "crypto/rand"
"crypto/sha1"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
"time"
)
@@ -84,13 +90,64 @@ func (c *Client) do(method, path string, body interface{}) (*http.Response, erro
return resp, nil
}
-// encodeProjectPath returns SHA1 hash (first 16 hex chars) of the project path.
-// This avoids all URL encoding issues with slashes in paths.
+// encodeProjectPath returns the project's URL hash (first 16 hex chars of
+// SHA1 of the identity key). Local projects are namespaced per machine —
+// "local:{machine_id}:{path}" — so the same filesystem path on different
+// machines/users maps to different projects. MUST stay byte-identical to the
+// server's projects.LocalProjectKey + hashPath (server/internal/projects).
func encodeProjectPath(path string) string {
- h := sha1.Sum([]byte(path))
+ key := "local:" + machineID() + ":" + path
+ h := sha1.Sum([]byte(key))
return fmt.Sprintf("%x", h)[:16]
}
+// EncodeProjectPath is the exported project URL hash, for tests and tooling
+// that need to mirror the client's addressing exactly.
+func EncodeProjectPath(path string) string { return encodeProjectPath(path) }
+
+var (
+ machineIDOnce sync.Once
+ machineIDVal string
+)
+
+// machineID returns a stable per-machine (per-home) identifier, persisted at
+// ~/.cix/machine_id. Generated on first use. Used to namespace local project
+// identity so different developers' machines never collide on the same path.
+func machineID() string {
+ machineIDOnce.Do(func() { machineIDVal = loadOrCreateMachineID() })
+ return machineIDVal
+}
+
+func loadOrCreateMachineID() string {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "unknown-machine"
+ }
+ path := filepath.Join(home, ".cix", "machine_id")
+ if b, rerr := os.ReadFile(path); rerr == nil {
+ if id := strings.TrimSpace(string(b)); id != "" {
+ return id
+ }
+ }
+ buf := make([]byte, 16)
+ if _, gerr := rand.Read(buf); gerr != nil {
+ return "unknown-machine"
+ }
+ id := hex.EncodeToString(buf)
+ _ = os.MkdirAll(filepath.Dir(path), 0o755)
+ _ = os.WriteFile(path, []byte(id+"\n"), 0o600)
+ return id
+}
+
+// machineLabel returns the OS hostname for display purposes (sent to the
+// server as machine_label). Best-effort; empty when unavailable.
+func machineLabel() string {
+ if h, err := os.Hostname(); err == nil {
+ return h
+ }
+ return ""
+}
+
// parseResponse reads and unmarshals JSON response
func parseResponse(resp *http.Response, v interface{}) error {
defer resp.Body.Close()
diff --git a/cli/internal/client/projects.go b/cli/internal/client/projects.go
index 1278236..f447748 100644
--- a/cli/internal/client/projects.go
+++ b/cli/internal/client/projects.go
@@ -73,8 +73,14 @@ func (c *Client) GetProject(path string) (*Project, error) {
// CreateProject creates a new project
func (c *Client) CreateProject(path string) (*Project, error) {
+ // machine_id namespaces the project's identity so the same filesystem path
+ // on a different machine/user is a distinct project; machine_label is the
+ // hostname for display. The server derives path_hash from
+ // local:{machine_id}:{host_path} — matching encodeProjectPath.
body := map[string]string{
- "host_path": path,
+ "host_path": path,
+ "machine_id": machineID(),
+ "machine_label": machineLabel(),
}
resp, err := c.do("POST", "/api/v1/projects", body)
diff --git a/cli/internal/indexer/indexer_test.go b/cli/internal/indexer/indexer_test.go
index fb490d6..56f1ca3 100644
--- a/cli/internal/indexer/indexer_test.go
+++ b/cli/internal/indexer/indexer_test.go
@@ -2,11 +2,9 @@ package indexer
import (
"context"
- "crypto/sha1"
"crypto/sha256"
"encoding/hex"
"encoding/json"
- "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -18,10 +16,10 @@ import (
"github.com/anthropics/code-index/cli/internal/client"
)
-// projectHash mirrors the client's URL-encoding logic (SHA1, first 16 hex chars).
+// projectHash mirrors the client's project URL hash — delegated to the real
+// implementation so per-machine namespacing matches.
func projectHash(path string) string {
- h := sha1.Sum([]byte(path))
- return fmt.Sprintf("%x", h)[:16]
+ return client.EncodeProjectPath(path)
}
// sha256hex computes the hex-encoded SHA-256 of b, matching discovery.hashFile.
diff --git a/cli/internal/watcher/watcher_test.go b/cli/internal/watcher/watcher_test.go
index d5705e8..b4375fe 100644
--- a/cli/internal/watcher/watcher_test.go
+++ b/cli/internal/watcher/watcher_test.go
@@ -1,9 +1,7 @@
package watcher
import (
- "crypto/sha1"
"encoding/json"
- "fmt"
"io"
"log"
"net/http"
@@ -19,10 +17,10 @@ import (
"github.com/rjeczalik/notify"
)
-// projectHash mirrors client.encodeProjectPath.
+// projectHash mirrors the client's project URL hash — delegated to the real
+// implementation so per-machine namespacing matches.
func projectHash(path string) string {
- h := sha1.Sum([]byte(path))
- return fmt.Sprintf("%x", h)[:16]
+ return client.EncodeProjectPath(path)
}
// mockEventInfo implements notify.EventInfo for testing.
diff --git a/doc/openapi.yaml b/doc/openapi.yaml
index 02a19dc..ddad11c 100644
--- a/doc/openapi.yaml
+++ b/doc/openapi.yaml
@@ -87,6 +87,12 @@ tags:
modify any workspace. PR1 ships CRUD only; repository attachment,
webhooks, and the two-stage search endpoint land in subsequent
releases of the workspaces feature branch.
+ - name: groups
+ description: |
+ View-groups: admin-managed sets of users. External projects and
+ workspaces are shared to a group, granting its members read/search
+ access. Group CRUD and membership are admin-only; GET /groups is
+ member-scoped for regular users so the share picker can populate.
- name: github-tokens
description: |
GitHub Personal Access Tokens used by the workspaces feature for
@@ -1258,6 +1264,389 @@ paths:
"503":
$ref: "#/components/responses/WorkspacesDisabled"
+ /api/v1/groups:
+ get:
+ operationId: listGroups
+ tags: [groups]
+ summary: List view-groups
+ description: |
+ Admins see every group; a regular user sees only the groups they
+ belong to (so the dashboard's "share to group" picker can populate).
+ responses:
+ "200":
+ description: Group list
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GroupListResponse"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ post:
+ operationId: createGroup
+ tags: [groups]
+ summary: Create a view-group (admin only)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateGroupRequest"
+ responses:
+ "201":
+ description: Group created
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Group"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "409":
+ $ref: "#/components/responses/Conflict"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+
+ /api/v1/groups/{id}:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: View-group ID.
+ get:
+ operationId: getGroup
+ tags: [groups]
+ summary: Get a view-group (admin only)
+ responses:
+ "200":
+ description: Group
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Group"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ patch:
+ operationId: updateGroup
+ tags: [groups]
+ summary: Update a view-group (admin only)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateGroupRequest"
+ responses:
+ "200":
+ description: Updated group
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Group"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "409":
+ $ref: "#/components/responses/Conflict"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+ delete:
+ operationId: deleteGroup
+ tags: [groups]
+ summary: Delete a view-group (admin only)
+ description: |
+ Cascades to memberships and project/workspace shares referencing
+ the group.
+ responses:
+ "204":
+ description: Deleted
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+
+ /api/v1/groups/{id}/members:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: View-group ID.
+ get:
+ operationId: listGroupMembers
+ tags: [groups]
+ summary: List members of a view-group (admin only)
+ responses:
+ "200":
+ description: Member list
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GroupMemberListResponse"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ post:
+ operationId: addGroupMember
+ tags: [groups]
+ summary: Add a user to a view-group (admin only)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AddGroupMemberRequest"
+ responses:
+ "204":
+ description: Member added (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+
+ /api/v1/groups/{id}/members/{userId}:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: View-group ID.
+ - name: userId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: User ID to remove.
+ delete:
+ operationId: removeGroupMember
+ tags: [groups]
+ summary: Remove a user from a view-group (admin only)
+ responses:
+ "204":
+ description: Member removed (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+
+ /api/v1/projects/{hash}/shares:
+ parameters:
+ - name: hash
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Project path hash.
+ get:
+ operationId: listProjectShares
+ tags: [groups]
+ summary: List the view-groups an external project is shared to (admin only)
+ responses:
+ "200":
+ description: Group ids the project is shared to
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GroupIdListResponse"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ post:
+ operationId: shareProjectToGroup
+ tags: [groups]
+ summary: Share an external project to a view-group (admin only)
+ description: |
+ Only EXTERNAL projects (with a git_repos peer) may be shared — sharing
+ a personal/local project returns 422.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareToGroupRequest"
+ responses:
+ "204":
+ description: Shared (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+
+ /api/v1/projects/{hash}/shares/{groupId}:
+ parameters:
+ - name: hash
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Project path hash.
+ - name: groupId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: View-group ID.
+ delete:
+ operationId: unshareProjectFromGroup
+ tags: [groups]
+ summary: Revoke a project↔group share (admin only)
+ responses:
+ "204":
+ description: Unshared (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+
+ /api/v1/projects/{hash}/owner:
+ parameters:
+ - name: hash
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Project path hash.
+ put:
+ operationId: reassignProjectOwner
+ tags: [projects]
+ summary: Reassign the owner of a local project (admin only)
+ description: |
+ Only LOCAL projects (no git_repos peer) can be reassigned — external
+ projects are ownerless by design and return 422.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ReassignOwnerRequest"
+ responses:
+ "200":
+ description: Updated project
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Project"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+
+ /api/v1/workspaces/{id}/shares:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Workspace ID.
+ get:
+ operationId: listWorkspaceShares
+ tags: [workspaces]
+ summary: List the view-groups a workspace is shared to
+ responses:
+ "200":
+ description: Group ids the workspace is shared to
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GroupIdListResponse"
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "503":
+ $ref: "#/components/responses/WorkspacesDisabled"
+ post:
+ operationId: shareWorkspaceToGroup
+ tags: [workspaces]
+ summary: Share a workspace to a view-group
+ description: |
+ The workspace owner may share to a group they belong to; an admin may
+ share to any group. Visibility only — each project inside the workspace
+ is still access-checked per viewer.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ShareToGroupRequest"
+ responses:
+ "204":
+ description: Shared (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "422":
+ $ref: "#/components/responses/Unprocessable"
+ "503":
+ $ref: "#/components/responses/WorkspacesDisabled"
+
+ /api/v1/workspaces/{id}/shares/{groupId}:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Workspace ID.
+ - name: groupId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: View-group ID.
+ delete:
+ operationId: unshareWorkspaceFromGroup
+ tags: [workspaces]
+ summary: Revoke a workspace↔group share (owner or admin)
+ responses:
+ "204":
+ description: Unshared (idempotent)
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "503":
+ $ref: "#/components/responses/WorkspacesDisabled"
+
/api/v1/git-repos:
post:
operationId: addGitRepo
@@ -2396,7 +2785,7 @@ components:
format: email
role:
type: string
- enum: [admin, viewer]
+ enum: [admin, user]
must_change_password:
type: boolean
created_at:
@@ -2468,7 +2857,7 @@ components:
MeResponse:
type: object
- required: [user, auth_method]
+ required: [user, auth_method, groups]
properties:
user:
$ref: "#/components/schemas/User"
@@ -2478,6 +2867,15 @@ components:
description: |
Tells the dashboard whether to surface "logout" (session) or
hide it (api_key access — there's nothing to log out of).
+ groups:
+ type: array
+ description: |
+ View-groups the caller belongs to. Lets the dashboard scope the
+ "share to group" picker without a second round-trip. Empty for a
+ user in no groups; admins still only list their own memberships
+ here (the full group list comes from GET /groups).
+ items:
+ $ref: "#/components/schemas/Group"
ChangePasswordRequest:
type: object
@@ -2506,17 +2904,17 @@ components:
The admin shares this out-of-band.
role:
type: string
- enum: [admin, viewer]
+ enum: [admin, user]
UpdateUserRequest:
type: object
properties:
role:
type: string
- enum: [admin, viewer]
+ enum: [admin, user]
description: |
New role for the user. Refused for the last enabled admin
- when set to `viewer`.
+ when set to `user`.
disabled:
type: boolean
description: |
@@ -2953,6 +3351,28 @@ components:
Embedding model identifier active when this project was last
(re)indexed. NULL on rows that pre-date drift tracking — the
dashboard treats NULL as "Unknown" rather than as drift.
+ owner_user_id:
+ type: string
+ nullable: true
+ description: |
+ User who owns this personal (locally indexed) project. NULL means
+ ownerless — the canonical state for EXTERNAL projects (those with a
+ git_repos peer), which are admin-administered and reachable only via
+ a view-group share.
+ display_path:
+ type: string
+ description: |
+ Human-readable path. The real filesystem path for local projects,
+ the github path for external ones. host_path is the identity key
+ (namespaced per machine for locals) — clients should show this.
+ machine_id:
+ type: string
+ nullable: true
+ description: Per-machine UUID a local project was indexed on. NULL for external/legacy.
+ machine_label:
+ type: string
+ nullable: true
+ description: os.Hostname() of the indexing machine — display only.
sqlite_path:
type: string
nullable: true
@@ -2990,6 +3410,17 @@ components:
properties:
host_path:
type: string
+ description: The real filesystem path being registered (becomes display_path).
+ machine_id:
+ type: string
+ description: |
+ Per-machine UUID supplied by the CLI (from ~/.cix/machine_id). When
+ present the project is LOCAL and its identity key is namespaced
+ local:{machine_id}:{host_path} so the same path on different
+ machines/users does not collide. Omit for external repos.
+ machine_label:
+ type: string
+ description: os.Hostname() of the indexing machine — display only.
UpdateProjectRequest:
type: object
@@ -3537,6 +3968,13 @@ components:
updated_at:
type: string
format: date-time
+ owner_user_id:
+ type: string
+ nullable: true
+ description: |
+ User who created the workspace. NULL only when orphaned by a user
+ deletion. Visible to the owner, members of any view-group it is
+ shared to, and admins.
WorkspaceListResponse:
type: object
@@ -3573,6 +4011,112 @@ components:
description:
type: string
+ Group:
+ type: object
+ required: [id, name, description, created_at, updated_at]
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ description:
+ type: string
+ description: Free-form description. Empty string when absent.
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+
+ GroupMember:
+ type: object
+ required: [user_id, email, role, added_at]
+ properties:
+ user_id:
+ type: string
+ email:
+ type: string
+ format: email
+ role:
+ type: string
+ enum: [admin, user]
+ added_at:
+ type: string
+ format: date-time
+
+ GroupListResponse:
+ type: object
+ required: [groups, total]
+ properties:
+ groups:
+ type: array
+ items:
+ $ref: "#/components/schemas/Group"
+ total:
+ type: integer
+
+ GroupMemberListResponse:
+ type: object
+ required: [members, total]
+ properties:
+ members:
+ type: array
+ items:
+ $ref: "#/components/schemas/GroupMember"
+ total:
+ type: integer
+
+ CreateGroupRequest:
+ type: object
+ required: [name]
+ properties:
+ name:
+ type: string
+ minLength: 1
+ description:
+ type: string
+
+ UpdateGroupRequest:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ description:
+ type: string
+
+ AddGroupMemberRequest:
+ type: object
+ required: [user_id]
+ properties:
+ user_id:
+ type: string
+
+ ShareToGroupRequest:
+ type: object
+ required: [group_id]
+ properties:
+ group_id:
+ type: string
+
+ GroupIdListResponse:
+ type: object
+ required: [group_ids]
+ properties:
+ group_ids:
+ type: array
+ items:
+ type: string
+
+ ReassignOwnerRequest:
+ type: object
+ required: [owner_user_id]
+ properties:
+ owner_user_id:
+ type: string
+ description: New owner; must be an existing, enabled user.
+
GithubToken:
type: object
required: [id, name, scopes, created_at]
diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go
index 987d24b..c0b9697 100644
--- a/server/cmd/cix-server/main.go
+++ b/server/cmd/cix-server/main.go
@@ -23,6 +23,7 @@ import (
"github.com/dvcdsys/code-index/server/internal/embeddings"
"github.com/dvcdsys/code-index/server/internal/githubapi"
"github.com/dvcdsys/code-index/server/internal/githubtokens"
+ "github.com/dvcdsys/code-index/server/internal/groups"
"github.com/dvcdsys/code-index/server/internal/gitrepos"
"github.com/dvcdsys/code-index/server/internal/httpapi"
"github.com/dvcdsys/code-index/server/internal/indexer"
@@ -220,6 +221,7 @@ func run() error {
usrSvc := users.New(database)
sessSvc := sessions.New(database)
akSvc := apikeys.New(database)
+ grpSvc := groups.New(database)
if !cfg.AuthDisabled {
if err := bootstrapAuth(context.Background(), cfg, logger, usrSvc, akSvc); err != nil {
@@ -399,6 +401,7 @@ func run() error {
Logger: logger,
AuthDisabled: cfg.AuthDisabled,
Users: usrSvc,
+ Groups: grpSvc,
Sessions: sessSvc,
APIKeys: akSvc,
EmbeddingSvc: embedSvc,
diff --git a/server/dashboard/src/api/types.ts b/server/dashboard/src/api/types.ts
index 7f19573..d9b4b6a 100644
--- a/server/dashboard/src/api/types.ts
+++ b/server/dashboard/src/api/types.ts
@@ -7,9 +7,17 @@
import type { components } from './generated';
-export type Role = 'admin' | 'viewer';
+export type Role = 'admin' | 'user';
export type User = components['schemas']['User'];
+
+export type Group = components['schemas']['Group'];
+export type GroupMember = components['schemas']['GroupMember'];
+export type GroupListResponse = components['schemas']['GroupListResponse'];
+export type GroupMemberListResponse = components['schemas']['GroupMemberListResponse'];
+export type CreateGroupRequest = components['schemas']['CreateGroupRequest'];
+export type UpdateGroupRequest = components['schemas']['UpdateGroupRequest'];
+export type GroupIdListResponse = components['schemas']['GroupIdListResponse'];
export type UserWithStats = components['schemas']['UserWithStats'];
export type Session = components['schemas']['Session'];
export type ApiKey = components['schemas']['ApiKey'];
diff --git a/server/dashboard/src/app/App.tsx b/server/dashboard/src/app/App.tsx
index 260549b..b01bd9c 100644
--- a/server/dashboard/src/app/App.tsx
+++ b/server/dashboard/src/app/App.tsx
@@ -53,7 +53,7 @@ export default function App() {
// mounted, so a deep link to it 404s back to /.
const visible = MODULES.filter((m) => {
if (!m.requiredRole) return true;
- if (m.requiredRole === 'viewer') return true;
+ if (m.requiredRole === 'user') return true;
return user.role === 'admin';
});
diff --git a/server/dashboard/src/app/Sidebar.tsx b/server/dashboard/src/app/Sidebar.tsx
index 6a1241e..b8fa374 100644
--- a/server/dashboard/src/app/Sidebar.tsx
+++ b/server/dashboard/src/app/Sidebar.tsx
@@ -13,11 +13,11 @@ import { MODULES } from '@/modules/registry';
// edits to this component are needed when a module is added.
export function Sidebar() {
const { user, logout } = useAuth();
- const role = user?.role ?? 'viewer';
+ const role = user?.role ?? 'user';
const visible = MODULES.filter((m) => {
if (!m.requiredRole) return true;
- if (m.requiredRole === 'viewer') return true;
+ if (m.requiredRole === 'user') return true;
return role === 'admin';
});
diff --git a/server/dashboard/src/auth/AuthProvider.tsx b/server/dashboard/src/auth/AuthProvider.tsx
index e793dfa..5532209 100644
--- a/server/dashboard/src/auth/AuthProvider.tsx
+++ b/server/dashboard/src/auth/AuthProvider.tsx
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, useCallback, useMemo, type ReactNode } from 'react';
import { ApiError, api } from '@/api/client';
-import type { BootstrapStatusResponse, LoginRequest, LoginResponse, MeResponse, User } from '@/api/types';
+import type { BootstrapStatusResponse, Group, LoginRequest, LoginResponse, MeResponse, User } from '@/api/types';
// Shape exposed to components — kept narrow on purpose. Use `useAuth` to
// consume; `AuthProvider` is the only place that touches the underlying
@@ -13,6 +13,8 @@ export interface AuthContextValue {
needsBootstrap: boolean;
/** Currently authenticated user, or null when logged out. */
user: User | null;
+ /** View-groups the current user belongs to (drives the share picker). */
+ groups: Group[];
/** When true, the user must change their password before reaching the app. */
mustChangePassword: boolean;
/** Performs the login flow + warms /me. Throws ApiError on failure. */
@@ -98,6 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loading: bootstrap.isLoading || (bootstrap.data?.needs_bootstrap === false && me.isLoading),
needsBootstrap: bootstrap.data?.needs_bootstrap ?? false,
user: me.data?.user ?? null,
+ groups: me.data?.groups ?? [],
mustChangePassword: me.data?.user?.must_change_password ?? false,
login,
logout,
diff --git a/server/dashboard/src/modules/groups/GroupsPage.tsx b/server/dashboard/src/modules/groups/GroupsPage.tsx
new file mode 100644
index 0000000..1152b92
--- /dev/null
+++ b/server/dashboard/src/modules/groups/GroupsPage.tsx
@@ -0,0 +1,100 @@
+import { AlertCircle, Trash2, UsersRound } from 'lucide-react';
+import { toast } from 'sonner';
+import { ApiError } from '@/api/client';
+import { Alert, AlertDescription, AlertTitle } from '@/ui/alert';
+import { Button } from '@/ui/button';
+import { Skeleton } from '@/ui/skeleton';
+import { CreateGroupDialog } from './components/CreateGroupDialog';
+import { GroupMembersDialog } from './components/GroupMembersDialog';
+import { useDeleteGroup, useGroups } from './hooks';
+
+export default function GroupsPage() {
+ const { data, error, isLoading } = useGroups();
+ const del = useDeleteGroup();
+
+ async function onDelete(id: string, name: string) {
+ if (!window.confirm(`Delete group "${name}"? This removes all its memberships and shares.`)) {
+ return;
+ }
+ try {
+ await del.mutateAsync(id);
+ toast.success('Group deleted');
+ } catch (err) {
+ toast.error('Failed to delete group', {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+ }
+
+ return (
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : error ? (
+
+
+ Failed to load groups
+
+ {error instanceof ApiError ? error.detail : String(error)}
+
+
+ ) : !data || data.groups.length === 0 ? (
+
+ ) : (
+
+ {data.groups.map((g) => (
+
+
+
{g.name}
+ {g.description ? (
+
{g.description}
+ ) : null}
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+
+
No view-groups yet
+
+ Create a group, add users to it, then share external projects and
+ workspaces to the group so its members can search them.
+
+
+ );
+}
diff --git a/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx b/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx
new file mode 100644
index 0000000..276f1ec
--- /dev/null
+++ b/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx
@@ -0,0 +1,103 @@
+import { useState } from 'react';
+import { Loader2, Plus } from 'lucide-react';
+import { toast } from 'sonner';
+import { ApiError } from '@/api/client';
+import { Button } from '@/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/dialog';
+import { Input } from '@/ui/input';
+import { Label } from '@/ui/label';
+import { useCreateGroup } from '../hooks';
+
+// Admin-only: create a view-group. External projects and workspaces are later
+// shared TO a group, granting its members read/search access.
+export function CreateGroupDialog() {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const create = useCreateGroup();
+
+ function reset() {
+ setName('');
+ setDescription('');
+ create.reset();
+ }
+
+ async function onSubmit() {
+ const trimmed = name.trim();
+ if (!trimmed) return;
+ try {
+ await create.mutateAsync({ name: trimmed, description: description.trim() });
+ toast.success('Group created');
+ setOpen(false);
+ reset();
+ } catch (err) {
+ toast.error('Failed to create group', {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx b/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx
new file mode 100644
index 0000000..6646ec3
--- /dev/null
+++ b/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx
@@ -0,0 +1,128 @@
+import { useState } from 'react';
+import { Loader2, Trash2, UserPlus } from 'lucide-react';
+import { toast } from 'sonner';
+import { ApiError } from '@/api/client';
+import { Button } from '@/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/ui/select';
+import type { Group } from '@/api/types';
+import { useUsers } from '@/modules/users/hooks';
+import { useAddGroupMember, useGroupMembers, useRemoveGroupMember } from '../hooks';
+
+// Admin-only: manage which users belong to a view-group. Members get
+// read/search access to whatever is shared to the group.
+export function GroupMembersDialog({ group }: { group: Group }) {
+ const [open, setOpen] = useState(false);
+ const [selected, setSelected] = useState('');
+ const members = useGroupMembers(group.id, open);
+ const users = useUsers();
+ const addMember = useAddGroupMember();
+ const removeMember = useRemoveGroupMember();
+
+ const memberIds = new Set((members.data?.members ?? []).map((m) => m.user_id));
+ const candidates = (users.data?.users ?? []).filter((u) => !memberIds.has(u.id));
+
+ async function add() {
+ if (!selected) return;
+ try {
+ await addMember.mutateAsync({ id: group.id, userId: selected });
+ setSelected('');
+ } catch (err) {
+ toast.error('Failed to add member', {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+ }
+
+ async function remove(userId: string) {
+ try {
+ await removeMember.mutateAsync({ id: group.id, userId });
+ } catch (err) {
+ toast.error('Failed to remove member', {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx b/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx
new file mode 100644
index 0000000..1f83d32
--- /dev/null
+++ b/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx
@@ -0,0 +1,67 @@
+import type { Group } from '@/api/types';
+import { Button } from '@/ui/button';
+import { Card, CardContent } from '@/ui/card';
+
+interface Props {
+ heading: string;
+ description: string;
+ /** Groups the caller may share to. */
+ groups: Group[];
+ /** IDs currently shared. */
+ sharedIds: string[];
+ onShare: (groupId: string) => void;
+ onUnshare: (groupId: string) => void;
+ busy?: boolean;
+ emptyText?: string;
+}
+
+// Reused by the project detail (admin, external projects) and workspace detail
+// (owner shares to their own groups; admin to any). Pure presentational —
+// callers wire the mutations + supply the candidate group list.
+export function ShareToGroupCard({
+ heading,
+ description,
+ groups,
+ sharedIds,
+ onShare,
+ onUnshare,
+ busy,
+ emptyText = 'No view-groups available.',
+}: Props) {
+ const shared = new Set(sharedIds);
+ return (
+
+ {heading}
+ {description}
+ {groups.length === 0 ? (
+ {emptyText}
+ ) : (
+
+
+ {groups.map((g) => {
+ const isShared = shared.has(g.id);
+ return (
+
+
+
{g.name}
+ {g.description ? (
+
{g.description}
+ ) : null}
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/server/dashboard/src/modules/groups/hooks.ts b/server/dashboard/src/modules/groups/hooks.ts
new file mode 100644
index 0000000..6170567
--- /dev/null
+++ b/server/dashboard/src/modules/groups/hooks.ts
@@ -0,0 +1,78 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { api } from '@/api/client';
+import type {
+ CreateGroupRequest,
+ Group,
+ GroupListResponse,
+ GroupMemberListResponse,
+ UpdateGroupRequest,
+} from '@/api/types';
+
+export const groupKeys = {
+ all: ['groups'] as const,
+ members: (id: string) => ['groups', id, 'members'] as const,
+};
+
+export function useGroups() {
+ return useQuery({
+ queryKey: groupKeys.all,
+ queryFn: ({ signal }) => api.get('/groups', { signal }),
+ });
+}
+
+export function useCreateGroup() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CreateGroupRequest) => api.post('/groups', body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }),
+ });
+}
+
+export function useUpdateGroup() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, body }: { id: string; body: UpdateGroupRequest }) =>
+ api.patch(`/groups/${id}`, body),
+ onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }),
+ });
+}
+
+export function useDeleteGroup() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (id: string) => api.delete(`/groups/${id}`),
+ onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }),
+ });
+}
+
+export function useGroupMembers(id: string, enabled = true) {
+ return useQuery({
+ queryKey: groupKeys.members(id),
+ queryFn: ({ signal }) => api.get(`/groups/${id}/members`, { signal }),
+ enabled,
+ });
+}
+
+export function useAddGroupMember() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, userId }: { id: string; userId: string }) =>
+ api.post(`/groups/${id}/members`, { user_id: userId }),
+ onSuccess: (_data, { id }) => {
+ void qc.invalidateQueries({ queryKey: groupKeys.members(id) });
+ void qc.invalidateQueries({ queryKey: groupKeys.all });
+ },
+ });
+}
+
+export function useRemoveGroupMember() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, userId }: { id: string; userId: string }) =>
+ api.delete(`/groups/${id}/members/${userId}`),
+ onSuccess: (_data, { id }) => {
+ void qc.invalidateQueries({ queryKey: groupKeys.members(id) });
+ void qc.invalidateQueries({ queryKey: groupKeys.all });
+ },
+ });
+}
diff --git a/server/dashboard/src/modules/groups/index.ts b/server/dashboard/src/modules/groups/index.ts
new file mode 100644
index 0000000..405bdec
--- /dev/null
+++ b/server/dashboard/src/modules/groups/index.ts
@@ -0,0 +1,13 @@
+import { UsersRound } from 'lucide-react';
+import type { Module } from '../types';
+import GroupsPage from './GroupsPage';
+
+export const GroupsModule: Module = {
+ id: 'groups',
+ label: 'View Groups',
+ icon: UsersRound,
+ path: '/groups',
+ element: GroupsPage,
+ requiredRole: 'admin',
+ weight: 45,
+};
diff --git a/server/dashboard/src/modules/groups/shareHooks.ts b/server/dashboard/src/modules/groups/shareHooks.ts
new file mode 100644
index 0000000..6ca4b8f
--- /dev/null
+++ b/server/dashboard/src/modules/groups/shareHooks.ts
@@ -0,0 +1,67 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { api } from '@/api/client';
+import type { GroupIdListResponse } from '@/api/types';
+
+// Project ↔ group shares (admin only; external projects).
+export function useProjectShares(hash: string, enabled = true) {
+ return useQuery({
+ queryKey: ['project-shares', hash],
+ queryFn: ({ signal }) => api.get(`/projects/${hash}/shares`, { signal }),
+ enabled,
+ });
+}
+
+export function useShareProject() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ hash, groupId }: { hash: string; groupId: string }) =>
+ api.post(`/projects/${hash}/shares`, { group_id: groupId }),
+ onSuccess: (_d, { hash }) => qc.invalidateQueries({ queryKey: ['project-shares', hash] }),
+ });
+}
+
+export function useUnshareProject() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ hash, groupId }: { hash: string; groupId: string }) =>
+ api.delete(`/projects/${hash}/shares/${groupId}`),
+ onSuccess: (_d, { hash }) => qc.invalidateQueries({ queryKey: ['project-shares', hash] }),
+ });
+}
+
+// Workspace ↔ group shares (owner-in-group or admin).
+export function useWorkspaceShares(id: string, enabled = true) {
+ return useQuery({
+ queryKey: ['workspace-shares', id],
+ queryFn: ({ signal }) => api.get(`/workspaces/${id}/shares`, { signal }),
+ enabled,
+ });
+}
+
+export function useShareWorkspace() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, groupId }: { id: string; groupId: string }) =>
+ api.post(`/workspaces/${id}/shares`, { group_id: groupId }),
+ onSuccess: (_d, { id }) => qc.invalidateQueries({ queryKey: ['workspace-shares', id] }),
+ });
+}
+
+export function useUnshareWorkspace() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, groupId }: { id: string; groupId: string }) =>
+ api.delete(`/workspaces/${id}/shares/${groupId}`),
+ onSuccess: (_d, { id }) => qc.invalidateQueries({ queryKey: ['workspace-shares', id] }),
+ });
+}
+
+// Reassign a local project's owner (admin only).
+export function useReassignProjectOwner() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ hash, ownerUserId }: { hash: string; ownerUserId: string }) =>
+ api.put(`/projects/${hash}/owner`, { owner_user_id: ownerUserId }),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }),
+ });
+}
diff --git a/server/dashboard/src/modules/home/HomePage.tsx b/server/dashboard/src/modules/home/HomePage.tsx
index e8511dd..b119798 100644
--- a/server/dashboard/src/modules/home/HomePage.tsx
+++ b/server/dashboard/src/modules/home/HomePage.tsx
@@ -22,12 +22,12 @@ const DESCRIPTIONS: Record = {
export default function HomePage() {
const { user } = useAuth();
- const role = user?.role ?? 'viewer';
+ const role = user?.role ?? 'user';
const { data: status } = useServerStatus();
const cards = MODULES.filter((m) => m.id !== 'home').filter((m) => {
if (!m.requiredRole) return true;
- if (m.requiredRole === 'viewer') return true;
+ if (m.requiredRole === 'user') return true;
return role === 'admin';
});
diff --git a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx
index 71800f2..fb14bf6 100644
--- a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx
+++ b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx
@@ -14,6 +14,8 @@ import { DeleteProjectDialog } from './components/DeleteProjectDialog';
import { ForceStopButton } from './components/ForceStopButton';
import { IndexingProgressCard } from './components/IndexingProgressCard';
import { ProjectInfoCard } from './components/ProjectInfoCard';
+import { ProjectShareCard } from './components/ProjectShareCard';
+import { ReassignOwnerDialog } from './components/ReassignOwnerDialog';
import { ReindexProjectButton } from './components/ReindexProjectButton';
import { SyncProjectButton } from './components/SyncProjectButton';
import { SyncSettingsCard } from './components/SyncSettingsCard';
@@ -60,6 +62,7 @@ export function ProjectDetailPage() {
const s = summary.data;
const drift = !!p.indexed_with_model && !!currentModel && p.indexed_with_model !== currentModel;
const isExternal = p.host_path.startsWith('github.com/');
+ const displayPath = p.display_path ?? p.host_path;
return (
@@ -84,8 +87,11 @@ export function ProjectDetailPage() {
))}
- {p.host_path}
+ {displayPath}
+ {p.machine_label ? (
+ on {p.machine_label}
+ ) : null}
Hash: {p.path_hash}
Created {formatRelative(p.created_at)}
@@ -104,15 +110,18 @@ export function ProjectDetailPage() {
{isExternal ? (
<>
-
-
+
+
{p.status === 'indexing' ? (
-
+
) : null}
>
) : null}
+ {isAdmin && !isExternal ? (
+
+ ) : null}
{isAdmin ? (
-
+
) : null}
@@ -146,7 +155,7 @@ export function ProjectDetailPage() {
Reindex from your terminal:{' '}
- cix reindex {p.host_path}
+ cix reindex {displayPath}
@@ -163,6 +172,8 @@ export function ProjectDetailPage() {
{isExternal ? : null}
+ {isExternal && isAdmin ? : null}
+
Workspaces
{workspaces.isLoading ? (
diff --git a/server/dashboard/src/modules/projects/components/ProjectCard.tsx b/server/dashboard/src/modules/projects/components/ProjectCard.tsx
index 2ffd02c..54be724 100644
--- a/server/dashboard/src/modules/projects/components/ProjectCard.tsx
+++ b/server/dashboard/src/modules/projects/components/ProjectCard.tsx
@@ -45,10 +45,13 @@ export function ProjectCard({ project }: { project: Project }) {
- {basename(project.host_path)}
+ {basename(project.display_path ?? project.host_path)}
-
- {project.host_path}
+
+ {project.display_path ?? project.host_path}
@@ -100,7 +103,7 @@ export function ProjectCard({ project }: { project: Project }) {
>
+ toast.error(`Failed to ${action}`, {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+
+ return (
+
+ share.mutate({ hash, groupId }, { onError: onError('share project') })
+ }
+ onUnshare={(groupId) =>
+ unshare.mutate({ hash, groupId }, { onError: onError('unshare project') })
+ }
+ />
+ );
+}
diff --git a/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx b/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx
new file mode 100644
index 0000000..5ef7ff3
--- /dev/null
+++ b/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { Loader2, UserCog } from 'lucide-react';
+import { toast } from 'sonner';
+import { ApiError } from '@/api/client';
+import { Button } from '@/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/ui/select';
+import { useUsers } from '@/modules/users/hooks';
+import { useReassignProjectOwner } from '@/modules/groups/shareHooks';
+
+// Admin-only: reassign the owner of a LOCAL project. External projects are
+// ownerless (the server rejects them with 422).
+export function ReassignOwnerDialog({ hash, currentOwnerId }: { hash: string; currentOwnerId?: string | null }) {
+ const [open, setOpen] = useState(false);
+ const [selected, setSelected] = useState('');
+ const users = useUsers();
+ const reassign = useReassignProjectOwner();
+
+ async function onSubmit() {
+ if (!selected) return;
+ try {
+ await reassign.mutateAsync({ hash, ownerUserId: selected });
+ toast.success('Owner reassigned');
+ setOpen(false);
+ setSelected('');
+ } catch (err) {
+ toast.error('Failed to reassign owner', {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/server/dashboard/src/modules/registry.ts b/server/dashboard/src/modules/registry.ts
index ee26748..90bce6e 100644
--- a/server/dashboard/src/modules/registry.ts
+++ b/server/dashboard/src/modules/registry.ts
@@ -1,5 +1,6 @@
import { ApiKeysModule } from './api-keys';
import { GithubIntegrationModule } from './github-integration';
+import { GroupsModule } from './groups';
import { HomeModule } from './home';
import { ManagedTunnelsModule } from './managed-tunnels';
import { ProjectsModule } from './projects';
@@ -27,6 +28,7 @@ export const MODULES: Module[] = [
GithubIntegrationModule,
ManagedTunnelsModule,
UsersModule,
+ GroupsModule,
SettingsModule,
ServerModule,
].sort((a, b) => (a.weight ?? 100) - (b.weight ?? 100));
diff --git a/server/dashboard/src/modules/search/components/Filters.tsx b/server/dashboard/src/modules/search/components/Filters.tsx
index 45182fe..854c700 100644
--- a/server/dashboard/src/modules/search/components/Filters.tsx
+++ b/server/dashboard/src/modules/search/components/Filters.tsx
@@ -42,7 +42,7 @@ export function ProjectPicker({
) : (
projects.map((p) => (
- {p.host_path}
+ {p.display_path ?? p.host_path}
))
)}
diff --git a/server/dashboard/src/modules/types.ts b/server/dashboard/src/modules/types.ts
index 03c3143..f56cb03 100644
--- a/server/dashboard/src/modules/types.ts
+++ b/server/dashboard/src/modules/types.ts
@@ -18,7 +18,7 @@ export interface Module {
path: string;
/** Top-level page rendered for this module. Owns its own internal routes. */
element: ComponentType;
- /** Minimum role required to *see* this module in the sidebar. Default: viewer. */
+ /** Minimum role required to *see* this module in the sidebar. Default: user. */
requiredRole?: Role;
/** Sort order in the sidebar — lower comes first. Default: 100. */
weight?: number;
diff --git a/server/dashboard/src/modules/users/components/InviteUserDialog.tsx b/server/dashboard/src/modules/users/components/InviteUserDialog.tsx
index 458df94..fc4cd7e 100644
--- a/server/dashboard/src/modules/users/components/InviteUserDialog.tsx
+++ b/server/dashboard/src/modules/users/components/InviteUserDialog.tsx
@@ -31,13 +31,13 @@ import { useCreateUser } from '../hooks';
export function InviteUserDialog() {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState('');
- const [role, setRole] = useState('viewer');
+ const [role, setRole] = useState('user');
const [pw, setPw] = useState('');
const create = useCreateUser();
function reset() {
setEmail('');
- setRole('viewer');
+ setRole('user');
setPw('');
create.reset();
}
@@ -105,7 +105,7 @@ export function InviteUserDialog() {
- Viewer (read-only)
+ User
Admin (full access)
diff --git a/server/dashboard/src/modules/users/components/UserRoleSelect.tsx b/server/dashboard/src/modules/users/components/UserRoleSelect.tsx
index 18de764..f49a1ec 100644
--- a/server/dashboard/src/modules/users/components/UserRoleSelect.tsx
+++ b/server/dashboard/src/modules/users/components/UserRoleSelect.tsx
@@ -44,7 +44,7 @@ export function UserRoleSelect({
- Viewer
+ User
Admin
diff --git a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx
index 99c3bf4..852cd7b 100644
--- a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx
+++ b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx
@@ -2,12 +2,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { AlertCircle, ChevronLeft, Trash2 } from 'lucide-react';
import { ApiError, api } from '@/api/client';
+import { useAuth } from '@/auth/useAuth';
import { Alert, AlertDescription, AlertTitle } from '@/ui/alert';
import { Button } from '@/ui/button';
import { Skeleton } from '@/ui/skeleton';
import { AddExistingProjectDialog } from './components/AddExistingProjectDialog';
import { AddRepoDialog } from './components/AddRepoDialog';
import { WorkspaceProjectRow } from './components/WorkspaceProjectRow';
+import { WorkspaceShareCard } from './components/WorkspaceShareCard';
import { WorkspaceSearchDialog } from './components/WorkspaceSearchDialog';
import { isInFlight } from './types';
import type {
@@ -22,6 +24,7 @@ const POLL_MS = 3000;
export function WorkspaceDetailPage() {
const { id = '' } = useParams<{ id: string }>();
const navigate = useNavigate();
+ const { user } = useAuth();
const [workspace, setWorkspace] = useState(null);
const [projects, setProjects] = useState(null);
const [error, setError] = useState(null);
@@ -213,6 +216,10 @@ export function WorkspaceDetailPage() {
)}
+
+ {user && (user.role === 'admin' || workspace.owner_user_id === user.id) ? (
+
+ ) : null}
);
}
diff --git a/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx b/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx
new file mode 100644
index 0000000..52c1f45
--- /dev/null
+++ b/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx
@@ -0,0 +1,43 @@
+import { toast } from 'sonner';
+import { ApiError } from '@/api/client';
+import { useGroups } from '@/modules/groups/hooks';
+import { ShareToGroupCard } from '@/modules/groups/components/ShareToGroupCard';
+import {
+ useShareWorkspace,
+ useUnshareWorkspace,
+ useWorkspaceShares,
+} from '@/modules/groups/shareHooks';
+
+// Shown to the workspace owner / admins. GET /groups is member-scoped on the
+// server, so the candidate list is exactly the groups the caller may share to
+// (their own for a user, all for an admin).
+export function WorkspaceShareCard({ workspaceId }: { workspaceId: string }) {
+ const groups = useGroups();
+ const shares = useWorkspaceShares(workspaceId);
+ const share = useShareWorkspace();
+ const unshare = useUnshareWorkspace();
+
+ function onError(action: string) {
+ return (err: unknown) =>
+ toast.error(`Failed to ${action}`, {
+ description: err instanceof ApiError ? err.detail : String(err),
+ });
+ }
+
+ return (
+
+ share.mutate({ id: workspaceId, groupId }, { onError: onError('share workspace') })
+ }
+ onUnshare={(groupId) =>
+ unshare.mutate({ id: workspaceId, groupId }, { onError: onError('unshare workspace') })
+ }
+ />
+ );
+}
diff --git a/server/dashboard/src/modules/workspaces/types.ts b/server/dashboard/src/modules/workspaces/types.ts
index 7b72222..eb243f8 100644
--- a/server/dashboard/src/modules/workspaces/types.ts
+++ b/server/dashboard/src/modules/workspaces/types.ts
@@ -9,6 +9,7 @@ export type Workspace = {
description: string;
created_at: string;
updated_at: string;
+ owner_user_id?: string | null;
};
export type WorkspaceListResponse = {
diff --git a/server/internal/access/access.go b/server/internal/access/access.go
new file mode 100644
index 0000000..dae8881
--- /dev/null
+++ b/server/internal/access/access.go
@@ -0,0 +1,177 @@
+// Package access resolves the auth model's resource visibility: who can see a
+// project or workspace, and the share junctions (project_group_shares,
+// workspace_group_shares) that grant view-group members read access.
+//
+// These helpers express the rules for a REGULAR user. Admins bypass them
+// entirely (they see everything) — the HTTP layer checks role first and only
+// calls in here for non-admins.
+//
+// Access rule for a project P / user U (admin already handled):
+// - P.owner_user_id == U → full access (personal project)
+// - P shared to a view-group U belongs to → read/search (external project)
+// - else → hidden
+//
+// Workspace W / user U: visible if owner or shared to a group U belongs to.
+// Per-project access is always re-checked when listing/searching a workspace,
+// so a hidden project is simply dropped from the result.
+package access
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// --- project share CRUD ---
+
+// ShareProjectToGroup grants a view-group read access to an external project.
+// Idempotent (INSERT OR IGNORE).
+func ShareProjectToGroup(ctx context.Context, db *sql.DB, projectPath, groupID string) error {
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO project_group_shares (project_path, group_id, created_at)
+ VALUES (?, ?, ?)`, projectPath, groupID, now)
+ if err != nil {
+ return fmt.Errorf("share project to group: %w", err)
+ }
+ return nil
+}
+
+// UnshareProjectFromGroup revokes a project↔group share. Idempotent.
+func UnshareProjectFromGroup(ctx context.Context, db *sql.DB, projectPath, groupID string) error {
+ _, err := db.ExecContext(ctx,
+ `DELETE FROM project_group_shares WHERE project_path = ? AND group_id = ?`,
+ projectPath, groupID)
+ if err != nil {
+ return fmt.Errorf("unshare project from group: %w", err)
+ }
+ return nil
+}
+
+// ListProjectShareGroupIDs returns the group ids a project is shared to.
+func ListProjectShareGroupIDs(ctx context.Context, db *sql.DB, projectPath string) ([]string, error) {
+ return queryIDs(ctx, db,
+ `SELECT group_id FROM project_group_shares WHERE project_path = ? ORDER BY created_at ASC`,
+ projectPath)
+}
+
+// --- workspace share CRUD ---
+
+// ShareWorkspaceToGroup shares a workspace with a view-group. Idempotent.
+func ShareWorkspaceToGroup(ctx context.Context, db *sql.DB, workspaceID, groupID string) error {
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := db.ExecContext(ctx,
+ `INSERT OR IGNORE INTO workspace_group_shares (workspace_id, group_id, created_at)
+ VALUES (?, ?, ?)`, workspaceID, groupID, now)
+ if err != nil {
+ return fmt.Errorf("share workspace to group: %w", err)
+ }
+ return nil
+}
+
+// UnshareWorkspaceFromGroup revokes a workspace↔group share. Idempotent.
+func UnshareWorkspaceFromGroup(ctx context.Context, db *sql.DB, workspaceID, groupID string) error {
+ _, err := db.ExecContext(ctx,
+ `DELETE FROM workspace_group_shares WHERE workspace_id = ? AND group_id = ?`,
+ workspaceID, groupID)
+ if err != nil {
+ return fmt.Errorf("unshare workspace from group: %w", err)
+ }
+ return nil
+}
+
+// ListWorkspaceShareGroupIDs returns the group ids a workspace is shared to.
+func ListWorkspaceShareGroupIDs(ctx context.Context, db *sql.DB, workspaceID string) ([]string, error) {
+ return queryIDs(ctx, db,
+ `SELECT group_id FROM workspace_group_shares WHERE workspace_id = ? ORDER BY created_at ASC`,
+ workspaceID)
+}
+
+// --- resolution ---
+
+// ProjectSharedToUser reports whether the project is shared to any view-group
+// the user belongs to.
+func ProjectSharedToUser(ctx context.Context, db *sql.DB, projectPath, userID string) (bool, error) {
+ return exists(ctx, db, `
+ SELECT 1
+ FROM project_group_shares s
+ JOIN view_group_members m ON m.group_id = s.group_id
+ WHERE s.project_path = ? AND m.user_id = ?
+ LIMIT 1`, projectPath, userID)
+}
+
+// WorkspaceSharedToUser reports whether the workspace is shared to any
+// view-group the user belongs to.
+func WorkspaceSharedToUser(ctx context.Context, db *sql.DB, workspaceID, userID string) (bool, error) {
+ return exists(ctx, db, `
+ SELECT 1
+ FROM workspace_group_shares s
+ JOIN view_group_members m ON m.group_id = s.group_id
+ WHERE s.workspace_id = ? AND m.user_id = ?
+ LIMIT 1`, workspaceID, userID)
+}
+
+// IsProjectExternal reports whether a project has a git_repos peer (i.e. is an
+// external, server-cloned repo rather than a personal local project).
+func IsProjectExternal(ctx context.Context, db *sql.DB, projectPath string) (bool, error) {
+ return exists(ctx, db,
+ `SELECT 1 FROM git_repos WHERE project_path = ? LIMIT 1`, projectPath)
+}
+
+// AccessibleProjectHostPaths returns the host_paths a regular user may see:
+// the projects they own UNION the external projects shared to a group they
+// belong to. Used to filter the project list.
+func AccessibleProjectHostPaths(ctx context.Context, db *sql.DB, userID string) ([]string, error) {
+ return queryIDs(ctx, db, `
+ SELECT host_path FROM projects WHERE owner_user_id = ?
+ UNION
+ SELECT s.project_path
+ FROM project_group_shares s
+ JOIN view_group_members m ON m.group_id = s.group_id
+ WHERE m.user_id = ?`, userID, userID)
+}
+
+// VisibleWorkspaceIDs returns the workspace ids a regular user may see: the
+// workspaces they own UNION the workspaces shared to a group they belong to.
+func VisibleWorkspaceIDs(ctx context.Context, db *sql.DB, userID string) ([]string, error) {
+ return queryIDs(ctx, db, `
+ SELECT id FROM workspaces WHERE owner_user_id = ?
+ UNION
+ SELECT s.workspace_id
+ FROM workspace_group_shares s
+ JOIN view_group_members m ON m.group_id = s.group_id
+ WHERE m.user_id = ?`, userID, userID)
+}
+
+// --- helpers ---
+
+func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) {
+ var one int
+ err := db.QueryRowContext(ctx, query, args...).Scan(&one)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("exists check: %w", err)
+ }
+ return true, nil
+}
+
+func queryIDs(ctx context.Context, db *sql.DB, query string, args ...any) ([]string, error) {
+ rows, err := db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("query ids: %w", err)
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
diff --git a/server/internal/access/access_test.go b/server/internal/access/access_test.go
new file mode 100644
index 0000000..26a2a9e
--- /dev/null
+++ b/server/internal/access/access_test.go
@@ -0,0 +1,115 @@
+package access
+
+import (
+ "context"
+ "slices"
+ "testing"
+
+ "github.com/dvcdsys/code-index/server/internal/db"
+ "github.com/dvcdsys/code-index/server/internal/groups"
+ "github.com/dvcdsys/code-index/server/internal/projects"
+ "github.com/dvcdsys/code-index/server/internal/users"
+ "github.com/dvcdsys/code-index/server/internal/workspaces"
+)
+
+func TestProjectAndWorkspaceSharing(t *testing.T) {
+ ctx := context.Background()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer database.Close()
+
+ us := users.New(database)
+ gs := groups.New(database)
+ ws := workspaces.New(database)
+
+ alice, _ := us.Create(ctx, "alice@x.com", "password1234", users.RoleUser, false)
+ bob, _ := us.Create(ctx, "bob@x.com", "password1234", users.RoleUser, false)
+
+ // Personal project owned by alice.
+ if _, err := projects.Create(ctx, database, projects.CreateRequest{
+ HostPath: "/alice/local", OwnerUserID: alice.ID,
+ }); err != nil {
+ t.Fatalf("create local project: %v", err)
+ }
+
+ // External project (ownerless) + its git_repos peer.
+ if _, err := projects.Create(ctx, database, projects.CreateRequest{
+ HostPath: "github.com/x/y@main",
+ }); err != nil {
+ t.Fatalf("create external project: %v", err)
+ }
+ if _, err := database.ExecContext(ctx,
+ `INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at)
+ VALUES ('github.com/x/y@main', 'https://github.com/x/y', 'main', 's', '2024-01-01', '2024-01-01')`,
+ ); err != nil {
+ t.Fatalf("insert git_repos: %v", err)
+ }
+
+ // Group with bob as member.
+ g, _ := gs.Create(ctx, "Product", "")
+ if err := gs.AddMember(ctx, g.ID, bob.ID); err != nil {
+ t.Fatalf("add member: %v", err)
+ }
+
+ // IsProjectExternal.
+ if ext, _ := IsProjectExternal(ctx, database, "github.com/x/y@main"); !ext {
+ t.Error("github.com/x/y@main should be external")
+ }
+ if ext, _ := IsProjectExternal(ctx, database, "/alice/local"); ext {
+ t.Error("/alice/local should not be external")
+ }
+
+ // Before sharing: bob cannot access the external project.
+ if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); shared {
+ t.Error("bob should not have access before share")
+ }
+
+ // Share external project to the group.
+ if err := ShareProjectToGroup(ctx, database, "github.com/x/y@main", g.ID); err != nil {
+ t.Fatalf("share project: %v", err)
+ }
+ if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); !shared {
+ t.Error("bob should have access after share")
+ }
+ // alice (not in group) still has no group-share access to it.
+ if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", alice.ID); shared {
+ t.Error("alice (non-member) should not have group-share access")
+ }
+
+ // AccessibleProjectHostPaths: alice sees her own local; bob sees the shared external.
+ aliceHosts, _ := AccessibleProjectHostPaths(ctx, database, alice.ID)
+ if !contains(aliceHosts, "/alice/local") || contains(aliceHosts, "github.com/x/y@main") {
+ t.Errorf("alice accessible = %v", aliceHosts)
+ }
+ bobHosts, _ := AccessibleProjectHostPaths(ctx, database, bob.ID)
+ if !contains(bobHosts, "github.com/x/y@main") || contains(bobHosts, "/alice/local") {
+ t.Errorf("bob accessible = %v", bobHosts)
+ }
+
+ // Workspace owned by alice, shared to the group.
+ w, _ := ws.Create(ctx, "team", "")
+ if err := ShareWorkspaceToGroup(ctx, database, w.ID, g.ID); err != nil {
+ t.Fatalf("share workspace: %v", err)
+ }
+ if seen, _ := WorkspaceSharedToUser(ctx, database, w.ID, bob.ID); !seen {
+ t.Error("bob should see shared workspace")
+ }
+ visForBob, _ := VisibleWorkspaceIDs(ctx, database, bob.ID)
+ if !contains(visForBob, w.ID) {
+ t.Errorf("bob visible workspaces = %v", visForBob)
+ }
+
+ // Unshare removes access.
+ if err := UnshareProjectFromGroup(ctx, database, "github.com/x/y@main", g.ID); err != nil {
+ t.Fatalf("unshare: %v", err)
+ }
+ if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); shared {
+ t.Error("bob should lose access after unshare")
+ }
+}
+
+func contains(xs []string, want string) bool {
+ return slices.Contains(xs, want)
+}
diff --git a/server/internal/db/db.go b/server/internal/db/db.go
index 47133f3..3e2bd8a 100644
--- a/server/internal/db/db.go
+++ b/server/internal/db/db.go
@@ -62,6 +62,8 @@ var registeredMigrations = []migration{
{7, "git_repos_indexed_sha", func(db *sql.DB, _ OpenOptions) error { return migrateGitReposIndexedSHA(db) }},
{8, "tunnel_config", func(db *sql.DB, _ OpenOptions) error { return migrateTunnelConfig(db) }},
{9, "git_repos_polling", func(db *sql.DB, _ OpenOptions) error { return migrateGitReposPolling(db) }},
+ {10, "auth_groups_ownership", func(db *sql.DB, _ OpenOptions) error { return migrateAuthGroupsOwnership(db) }},
+ {11, "project_machine_identity", func(db *sql.DB, _ OpenOptions) error { return migrateProjectMachineIdentity(db) }},
}
// DriverName is the registered database/sql driver name for modernc.org/sqlite.
@@ -314,6 +316,165 @@ func migrateGitReposPolling(db *sql.DB) error {
return nil
}
+// columnExists reports whether table has a column named col. Returns false
+// (no error) when the table itself does not exist.
+func columnExists(db *sql.DB, table, col string) (bool, error) {
+ rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
+ if err != nil {
+ return false, fmt.Errorf("table_info %s: %w", table, err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var (
+ cid int
+ name, typ string
+ notnull, pk int
+ dflt sql.NullString
+ )
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
+ return false, err
+ }
+ if name == col {
+ return true, nil
+ }
+ }
+ return false, rows.Err()
+}
+
+// migrateProjectMachineIdentity adds display_path / machine_id / machine_label
+// to projects so local-project identity can be namespaced per machine
+// (host_path becomes "local:{machine_id}:{path}" for projects created after
+// this change; the CLI computes the matching path_hash). Existing rows keep
+// their host_path as-is and get display_path backfilled from it so the
+// dashboard has something to show — they remain reachable until re-init.
+// Idempotent via columnExists.
+func migrateProjectMachineIdentity(db *sql.DB) error {
+ adds := []struct{ col, ddl string }{
+ {"display_path", `ALTER TABLE projects ADD COLUMN display_path TEXT`},
+ {"machine_id", `ALTER TABLE projects ADD COLUMN machine_id TEXT`},
+ {"machine_label", `ALTER TABLE projects ADD COLUMN machine_label TEXT`},
+ }
+ for _, a := range adds {
+ have, err := columnExists(db, "projects", a.col)
+ if err != nil {
+ return err
+ }
+ if have {
+ continue
+ }
+ if _, err := db.Exec(a.ddl); err != nil {
+ return fmt.Errorf("add projects.%s: %w", a.col, err)
+ }
+ }
+ // Backfill display_path = host_path for any row missing it (existing
+ // projects predate the column; their host_path is still the real path).
+ if _, err := db.Exec(
+ `UPDATE projects SET display_path = host_path WHERE display_path IS NULL`,
+ ); err != nil {
+ return fmt.Errorf("backfill display_path: %w", err)
+ }
+ return nil
+}
+
+// migrateAuthGroupsOwnership upgrades pre-auth-model databases to the
+// owner + view-group sharing model:
+//
+// - adds projects.owner_user_id and workspaces.owner_user_id (NULL on
+// existing rows);
+// - creates view_groups / view_group_members / project_group_shares /
+// workspace_group_shares (matching schema.go; CREATE TABLE IF NOT EXISTS
+// so a fresh DB that already has them is a no-op);
+// - one-time data backfill: every existing user becomes admin (pre-migration
+// everyone had full access, so this preserves it — the operator demotes
+// afterwards); existing LOCAL projects (no git_repos row) and ALL existing
+// workspaces are assigned to the first active admin; existing EXTERNAL
+// projects intentionally stay ownerless (NULL).
+//
+// Idempotent: column adds are guarded by columnExists, table creates use
+// IF NOT EXISTS. The data backfill is naturally a no-op on a fresh DB (no
+// users/projects yet — bootstrap runs after Open) and runs exactly once on an
+// upgraded DB because schema_migrations records version 10.
+func migrateAuthGroupsOwnership(db *sql.DB) error {
+ // 1. owner columns (ALTER ADD COLUMN with a NULL-default REFERENCES clause
+ // is permitted by SQLite).
+ if have, err := columnExists(db, "projects", "owner_user_id"); err != nil {
+ return err
+ } else if !have {
+ if _, err := db.Exec(
+ `ALTER TABLE projects ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL`,
+ ); err != nil {
+ return fmt.Errorf("add projects.owner_user_id: %w", err)
+ }
+ }
+ if have, err := columnExists(db, "workspaces", "owner_user_id"); err != nil {
+ return err
+ } else if !have {
+ if _, err := db.Exec(
+ `ALTER TABLE workspaces ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL`,
+ ); err != nil {
+ return fmt.Errorf("add workspaces.owner_user_id: %w", err)
+ }
+ }
+
+ // 2. view-group + sharing tables (mirror schema.go).
+ for _, ddl := range []string{
+ `CREATE TABLE IF NOT EXISTS view_groups (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS view_group_members (
+ group_id TEXT NOT NULL, user_id TEXT NOT NULL, added_at TEXT NOT NULL,
+ PRIMARY KEY (group_id, user_id),
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`,
+ `CREATE INDEX IF NOT EXISTS idx_view_group_members_user ON view_group_members(user_id)`,
+ `CREATE TABLE IF NOT EXISTS project_group_shares (
+ project_path TEXT NOT NULL, group_id TEXT NOT NULL, created_at TEXT NOT NULL,
+ PRIMARY KEY (project_path, group_id),
+ FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE,
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE)`,
+ `CREATE INDEX IF NOT EXISTS idx_project_shares_group ON project_group_shares(group_id)`,
+ `CREATE TABLE IF NOT EXISTS workspace_group_shares (
+ workspace_id TEXT NOT NULL, group_id TEXT NOT NULL, created_at TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, group_id),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE)`,
+ `CREATE INDEX IF NOT EXISTS idx_workspace_shares_group ON workspace_group_shares(group_id)`,
+ } {
+ if _, err := db.Exec(ddl); err != nil {
+ return fmt.Errorf("create auth-model table/index: %w", err)
+ }
+ }
+
+ // 3. one-time data backfill.
+ if _, err := db.Exec(`UPDATE users SET role = 'admin'`); err != nil {
+ return fmt.Errorf("promote existing users to admin: %w", err)
+ }
+ var firstAdmin sql.NullString
+ if err := db.QueryRow(
+ `SELECT id FROM users WHERE role = 'admin' AND disabled_at IS NULL ORDER BY created_at ASC LIMIT 1`,
+ ).Scan(&firstAdmin); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return fmt.Errorf("select first active admin: %w", err)
+ }
+ if firstAdmin.Valid && firstAdmin.String != "" {
+ // Local projects (no git_repos peer) → first admin; external stay NULL.
+ if _, err := db.Exec(
+ `UPDATE projects SET owner_user_id = ?
+ WHERE owner_user_id IS NULL
+ AND host_path NOT IN (SELECT project_path FROM git_repos)`,
+ firstAdmin.String,
+ ); err != nil {
+ return fmt.Errorf("backfill local project owners: %w", err)
+ }
+ if _, err := db.Exec(
+ `UPDATE workspaces SET owner_user_id = ? WHERE owner_user_id IS NULL`,
+ firstAdmin.String,
+ ); err != nil {
+ return fmt.Errorf("backfill workspace owners: %w", err)
+ }
+ }
+ return nil
+}
+
// migrateDropCommunities removes the PR5–PR12 communities +
// community_members tables. The PR14 fan-out search doesn't need
// them; leaving them around would just confuse anyone reading the
diff --git a/server/internal/db/db_test.go b/server/internal/db/db_test.go
index 5e3e9ce..4b484a1 100644
--- a/server/internal/db/db_test.go
+++ b/server/internal/db/db_test.go
@@ -308,6 +308,129 @@ func TestOpenMigratesPreM9DB(t *testing.T) {
again.Close()
}
+// TestOpenMigratesPreAuthModelDB simulates a pre-m10 database (no owner_user_id
+// columns, users with the old 'viewer' role, one local + one external project,
+// one workspace) and verifies the auth-model backfill: every user becomes
+// admin, local projects + workspaces are assigned to the first active admin,
+// and external projects (git_repos peer) stay ownerless.
+func TestOpenMigratesPreAuthModelDB(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "pre-m10.db")
+ seed, err := sql.Open(DriverName, "file:"+tmp)
+ if err != nil {
+ t.Fatalf("seed Open: %v", err)
+ }
+ mustExec := func(q string, args ...any) {
+ t.Helper()
+ if _, err := seed.Exec(q, args...); err != nil {
+ t.Fatalf("seed exec %q: %v", q, err)
+ }
+ }
+ // Pre-m10 shapes: no owner_user_id anywhere; role defaults to 'viewer'.
+ mustExec(`CREATE TABLE users (
+ id TEXT PRIMARY KEY, email TEXT NOT NULL, password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'viewer', must_change_password INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL, disabled_at TEXT)`)
+ mustExec(`CREATE TABLE projects (
+ host_path TEXT PRIMARY KEY, container_path TEXT NOT NULL,
+ languages TEXT DEFAULT '[]', settings TEXT DEFAULT '{}', stats TEXT DEFAULT '{}',
+ status TEXT DEFAULT 'created', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ last_indexed_at TEXT, path_hash TEXT)`)
+ mustExec(`CREATE TABLE workspaces (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`)
+ mustExec(`CREATE TABLE git_repos (
+ project_path TEXT PRIMARY KEY, github_url TEXT NOT NULL, branch TEXT NOT NULL,
+ token_id TEXT, webhook_secret TEXT NOT NULL, webhook_id INTEGER,
+ webhook_mode TEXT NOT NULL DEFAULT 'manual', auto_webhook INTEGER NOT NULL DEFAULT 0,
+ last_sha TEXT, indexed_sha TEXT, last_error TEXT,
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`)
+
+ // alice is older than bob → alice is the first active admin after backfill.
+ mustExec(`INSERT INTO users (id, email, password_hash, role, created_at, updated_at)
+ VALUES ('u_alice', 'alice@x.com', 'h', 'viewer', '2024-01-01', '2024-01-01')`)
+ mustExec(`INSERT INTO users (id, email, password_hash, role, created_at, updated_at)
+ VALUES ('u_bob', 'bob@x.com', 'h', 'viewer', '2024-06-01', '2024-06-01')`)
+ mustExec(`INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash)
+ VALUES ('/local/proj', '/local/proj', '2024-02-01', '2024-02-01', 'aaaa')`)
+ mustExec(`INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash)
+ VALUES ('github.com/x/y@main', 'github.com/x/y@main', '2024-02-01', '2024-02-01', 'bbbb')`)
+ mustExec(`INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at)
+ VALUES ('github.com/x/y@main', 'https://github.com/x/y', 'main', 'sekret', '2024-02-01', '2024-02-01')`)
+ mustExec(`INSERT INTO workspaces (id, name, created_at, updated_at)
+ VALUES ('ws_1', 'team', '2024-03-01', '2024-03-01')`)
+ seed.Close()
+
+ database, err := Open(tmp)
+ if err != nil {
+ t.Fatalf("Open migrates pre-m10 DB: %v", err)
+ }
+ defer database.Close()
+ defer os.Remove(tmp)
+
+ // 1. every user is now admin.
+ var nonAdmins int
+ if err := database.QueryRow(`SELECT COUNT(*) FROM users WHERE role <> 'admin'`).Scan(&nonAdmins); err != nil {
+ t.Fatalf("count non-admins: %v", err)
+ }
+ if nonAdmins != 0 {
+ t.Errorf("non-admin users after migration = %d, want 0", nonAdmins)
+ }
+
+ // 2. local project owned by the first active admin (alice).
+ var localOwner sql.NullString
+ if err := database.QueryRow(
+ `SELECT owner_user_id FROM projects WHERE host_path = '/local/proj'`,
+ ).Scan(&localOwner); err != nil {
+ t.Fatalf("select local owner: %v", err)
+ }
+ if !localOwner.Valid || localOwner.String != "u_alice" {
+ t.Errorf("local project owner = %v, want u_alice", localOwner)
+ }
+
+ // 3. external project stays ownerless.
+ var extOwner sql.NullString
+ if err := database.QueryRow(
+ `SELECT owner_user_id FROM projects WHERE host_path = 'github.com/x/y@main'`,
+ ).Scan(&extOwner); err != nil {
+ t.Fatalf("select external owner: %v", err)
+ }
+ if extOwner.Valid {
+ t.Errorf("external project owner = %q, want NULL", extOwner.String)
+ }
+
+ // 4. workspace owned by the first active admin.
+ var wsOwner sql.NullString
+ if err := database.QueryRow(
+ `SELECT owner_user_id FROM workspaces WHERE id = 'ws_1'`,
+ ).Scan(&wsOwner); err != nil {
+ t.Fatalf("select workspace owner: %v", err)
+ }
+ if !wsOwner.Valid || wsOwner.String != "u_alice" {
+ t.Errorf("workspace owner = %v, want u_alice", wsOwner)
+ }
+
+ // 5. new tables exist.
+ for _, tbl := range []string{"view_groups", "view_group_members", "project_group_shares", "workspace_group_shares"} {
+ var n int
+ if err := database.QueryRow(
+ `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, tbl,
+ ).Scan(&n); err != nil {
+ t.Fatalf("check table %s: %v", tbl, err)
+ }
+ if n != 1 {
+ t.Errorf("table %s missing after migration", tbl)
+ }
+ }
+
+ // 6. second Open is idempotent.
+ database.Close()
+ again, err := Open(tmp)
+ if err != nil {
+ t.Fatalf("second Open (idempotent): %v", err)
+ }
+ again.Close()
+}
+
func TestSymbolsIndexExists(t *testing.T) {
database, err := Open(":memory:")
if err != nil {
@@ -1098,3 +1221,49 @@ func readMigrationLedger(t *testing.T, d *sql.DB) []migrationLedgerRow {
}
return out
}
+
+// TestOpenMigratesPreM11DB verifies the machine-identity migration adds
+// display_path/machine_id/machine_label and backfills display_path = host_path
+// for pre-existing rows.
+func TestOpenMigratesPreM11DB(t *testing.T) {
+ tmp := filepath.Join(t.TempDir(), "pre-m11.db")
+ seed, err := sql.Open(DriverName, "file:"+tmp)
+ if err != nil {
+ t.Fatalf("seed Open: %v", err)
+ }
+ if _, err := seed.Exec(`CREATE TABLE projects (
+ host_path TEXT PRIMARY KEY, container_path TEXT NOT NULL,
+ languages TEXT DEFAULT '[]', settings TEXT DEFAULT '{}', stats TEXT DEFAULT '{}',
+ status TEXT DEFAULT 'created', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ last_indexed_at TEXT, path_hash TEXT)`); err != nil {
+ t.Fatalf("seed projects: %v", err)
+ }
+ if _, err := seed.Exec(
+ `INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash)
+ VALUES ('/Users/dev/legacy', '/Users/dev/legacy', '2024-01-01', '2024-01-01', 'cafef00dcafef00d')`,
+ ); err != nil {
+ t.Fatalf("seed row: %v", err)
+ }
+ seed.Close()
+
+ database, err := Open(tmp)
+ if err != nil {
+ t.Fatalf("Open migrates pre-m11 DB: %v", err)
+ }
+ defer database.Close()
+ defer os.Remove(tmp)
+
+ var displayPath sql.NullString
+ var machineID sql.NullString
+ if err := database.QueryRow(
+ `SELECT display_path, machine_id FROM projects WHERE host_path = '/Users/dev/legacy'`,
+ ).Scan(&displayPath, &machineID); err != nil {
+ t.Fatalf("select new columns: %v", err)
+ }
+ if !displayPath.Valid || displayPath.String != "/Users/dev/legacy" {
+ t.Errorf("display_path = %v, want /Users/dev/legacy", displayPath)
+ }
+ if machineID.Valid {
+ t.Errorf("legacy machine_id = %q, want NULL", machineID.String)
+ }
+}
diff --git a/server/internal/db/schema.go b/server/internal/db/schema.go
index 60304ae..cbe6646 100644
--- a/server/internal/db/schema.go
+++ b/server/internal/db/schema.go
@@ -23,7 +23,27 @@ CREATE TABLE IF NOT EXISTS projects (
-- project was last indexed. NULL on legacy rows (pre-PR-E) until next
-- reindex. Compared against the live runtime model to surface a "stale
-- model" badge on the dashboard project list.
- indexed_with_model TEXT
+ indexed_with_model TEXT,
+ -- owner_user_id is the user who owns this (personal, locally indexed)
+ -- project. NULL means ownerless: that is the canonical state for EXTERNAL
+ -- projects (those with a git_repos row), which are admin-administered and
+ -- reachable only via a view-group share. Personal projects are private to
+ -- their owner; admins see everything. Set on CreateProject; NULL on
+ -- AddGitRepo. FK SET NULL so deleting a user orphans (does not delete) the
+ -- project for an admin to reassign.
+ owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
+ -- host_path (PK) is the project IDENTITY, not necessarily the literal
+ -- filesystem path. For LOCAL projects it is namespaced as
+ -- "local:{machine_id}:{display_path}" so the same path on different
+ -- machines (different users) never collides — the CLI computes path_hash
+ -- from the same key. For EXTERNAL projects host_path == display_path ==
+ -- the github.com/owner/repo@branch string. display_path is the
+ -- human-readable path shown in the dashboard; machine_id is the per-home
+ -- UUID the CLI sends (NULL for external); machine_label is os.Hostname()
+ -- for display only.
+ display_path TEXT,
+ machine_id TEXT,
+ machine_label TEXT
);
-- NOTE: CREATE INDEX on path_hash is intentionally NOT here. Pre-m7 databases
@@ -97,7 +117,7 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
- role TEXT NOT NULL DEFAULT 'viewer',
+ role TEXT NOT NULL DEFAULT 'user',
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -153,14 +173,20 @@ CREATE TABLE IF NOT EXISTS runtime_settings (
-- Workspaces group indexed projects (rows in the projects table,
-- optionally with their git_repos peer) for cross-project semantic
-- search. Membership lives in workspace_projects; clone + webhook
--- metadata lives in git_repos. Server-wide shared: every authenticated
--- user can see and modify any workspace (per the chosen visibility model).
+-- metadata lives in git_repos.
+--
+-- owner_user_id is the user who created the workspace; visible to the owner,
+-- to members of any view-group it is shared to (workspace_group_shares), and
+-- to admins. A workspace is decoupled from its projects: access is always
+-- evaluated per project, so a project a viewer cannot see is simply hidden
+-- from the workspace listing / search.
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL
+ updated_at TEXT NOT NULL,
+ owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL
);
-- github_tokens stores GitHub Personal Access Tokens encrypted at rest with
@@ -248,6 +274,52 @@ CREATE TABLE IF NOT EXISTS workspace_projects (
CREATE INDEX IF NOT EXISTS idx_workspace_projects_project
ON workspace_projects(project_path);
+-- view_groups + sharing (auth model). A view-group is an admin-managed set of
+-- users; external projects and workspaces are shared TO a group, granting its
+-- members read/search access. Group CRUD and membership are admin-only;
+-- workspace owners may share their own workspaces to groups they belong to.
+CREATE TABLE IF NOT EXISTS view_groups (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ description TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS view_group_members (
+ group_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ added_at TEXT NOT NULL,
+ PRIMARY KEY (group_id, user_id),
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_view_group_members_user ON view_group_members(user_id);
+
+-- project_group_shares: an EXTERNAL project (git_repos peer, owner_user_id NULL)
+-- shared to a view-group. Members of group_id get read/search on project_path.
+CREATE TABLE IF NOT EXISTS project_group_shares (
+ project_path TEXT NOT NULL,
+ group_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (project_path, group_id),
+ FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE,
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_project_shares_group ON project_group_shares(group_id);
+
+-- workspace_group_shares: a workspace shared to a view-group. Visibility only;
+-- the per-project access check still gates which projects a member actually sees.
+CREATE TABLE IF NOT EXISTS workspace_group_shares (
+ workspace_id TEXT NOT NULL,
+ group_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, group_id),
+ FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
+ FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_workspace_shares_group ON workspace_group_shares(group_id);
+
-- jobs is the persistent worker queue. Survives process restarts; one
-- worker pool drains it. dedupe_key is the partial-unique mechanism that
-- collapses webhook bursts (e.g. 50 push deliveries for the same repo
@@ -384,6 +456,10 @@ var ExpectedTables = []string{
"github_tokens",
"git_repos",
"workspace_projects",
+ "view_groups",
+ "view_group_members",
+ "project_group_shares",
+ "workspace_group_shares",
"jobs",
"call_edges",
"chunks_meta",
diff --git a/server/internal/groups/groups.go b/server/internal/groups/groups.go
new file mode 100644
index 0000000..baec699
--- /dev/null
+++ b/server/internal/groups/groups.go
@@ -0,0 +1,294 @@
+// Package groups is the service layer for view-groups: admin-managed sets of
+// users used to share external projects and workspaces. A user who belongs to
+// a group gets read/search access to whatever is shared to that group.
+//
+// Scope: group CRUD + membership. The share junctions (project_group_shares,
+// workspace_group_shares) and the access resolution that consumes them live
+// with the projects/workspaces access layer, not here.
+package groups
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+var (
+ ErrNotFound = errors.New("group not found")
+ ErrNameTaken = errors.New("group name already in use")
+ ErrNameEmpty = errors.New("group name is required")
+ // ErrUserNotFound is returned by AddMember when the target user does not
+ // exist (the FK insert fails).
+ ErrUserNotFound = errors.New("user not found")
+)
+
+// Group is the metadata view of a view-group.
+type Group struct {
+ ID string
+ Name string
+ Description string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Member is one row of a group's membership, joined with the user for display.
+type Member struct {
+ UserID string
+ Email string
+ Role string
+ AddedAt time.Time
+}
+
+// Service wraps the view_groups + view_group_members tables.
+type Service struct {
+ DB *sql.DB
+}
+
+// New returns a Service.
+func New(db *sql.DB) *Service { return &Service{DB: db} }
+
+// Create inserts a new group. Name must be non-empty and unique.
+func (s *Service) Create(ctx context.Context, name, description string) (Group, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return Group{}, ErrNameEmpty
+ }
+ description = strings.TrimSpace(description)
+
+ id := uuid.NewString()
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := s.DB.ExecContext(ctx,
+ `INSERT INTO view_groups (id, name, description, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ id, name, nullableString(description), now, now,
+ )
+ if err != nil {
+ if isUniqueConstraintViolation(err) {
+ return Group{}, ErrNameTaken
+ }
+ return Group{}, fmt.Errorf("insert group: %w", err)
+ }
+ return s.GetByID(ctx, id)
+}
+
+// GetByID returns one group. ErrNotFound when absent.
+func (s *Service) GetByID(ctx context.Context, id string) (Group, error) {
+ row := s.DB.QueryRowContext(ctx,
+ `SELECT id, name, description, created_at, updated_at
+ FROM view_groups WHERE id = ?`, id)
+ return scanRow(row)
+}
+
+// List returns every group, newest first.
+func (s *Service) List(ctx context.Context) ([]Group, error) {
+ rows, err := s.DB.QueryContext(ctx,
+ `SELECT id, name, description, created_at, updated_at
+ FROM view_groups ORDER BY created_at DESC`)
+ if err != nil {
+ return nil, fmt.Errorf("list groups: %w", err)
+ }
+ defer rows.Close()
+ return scanRows(rows)
+}
+
+// ListForUser returns the groups the given user is a member of, newest first.
+// Backs the non-admin GET /groups (so the workspace-share picker can list the
+// caller's groups) and the access checks.
+func (s *Service) ListForUser(ctx context.Context, userID string) ([]Group, error) {
+ rows, err := s.DB.QueryContext(ctx,
+ `SELECT g.id, g.name, g.description, g.created_at, g.updated_at
+ FROM view_groups g
+ JOIN view_group_members m ON m.group_id = g.id
+ WHERE m.user_id = ?
+ ORDER BY g.created_at DESC`, userID)
+ if err != nil {
+ return nil, fmt.Errorf("list groups for user: %w", err)
+ }
+ defer rows.Close()
+ return scanRows(rows)
+}
+
+// Update changes name/description. Pointer-nil = leave alone.
+func (s *Service) Update(ctx context.Context, id string, name *string, description *string) (Group, error) {
+ current, err := s.GetByID(ctx, id)
+ if err != nil {
+ return Group{}, err
+ }
+ newName := current.Name
+ if name != nil {
+ trimmed := strings.TrimSpace(*name)
+ if trimmed == "" {
+ return Group{}, ErrNameEmpty
+ }
+ newName = trimmed
+ }
+ newDesc := current.Description
+ if description != nil {
+ newDesc = strings.TrimSpace(*description)
+ }
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err = s.DB.ExecContext(ctx,
+ `UPDATE view_groups SET name = ?, description = ?, updated_at = ? WHERE id = ?`,
+ newName, nullableString(newDesc), now, id)
+ if err != nil {
+ if isUniqueConstraintViolation(err) {
+ return Group{}, ErrNameTaken
+ }
+ return Group{}, fmt.Errorf("update group: %w", err)
+ }
+ return s.GetByID(ctx, id)
+}
+
+// Delete removes a group (cascades to memberships and shares). ErrNotFound
+// when absent so the handler can choose 404 vs 204.
+func (s *Service) Delete(ctx context.Context, id string) error {
+ res, err := s.DB.ExecContext(ctx, `DELETE FROM view_groups WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("delete group: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("rows affected: %w", err)
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// AddMember adds a user to a group. Idempotent (INSERT OR IGNORE). Returns
+// ErrNotFound if the group is absent, ErrUserNotFound if the user is absent.
+func (s *Service) AddMember(ctx context.Context, groupID, userID string) error {
+ if _, err := s.GetByID(ctx, groupID); err != nil {
+ return err
+ }
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := s.DB.ExecContext(ctx,
+ `INSERT OR IGNORE INTO view_group_members (group_id, user_id, added_at)
+ VALUES (?, ?, ?)`, groupID, userID, now)
+ if err != nil {
+ if isForeignKeyViolation(err) {
+ return ErrUserNotFound
+ }
+ return fmt.Errorf("add group member: %w", err)
+ }
+ return nil
+}
+
+// RemoveMember removes a user from a group. Idempotent — removing an absent
+// membership is a no-op (no error).
+func (s *Service) RemoveMember(ctx context.Context, groupID, userID string) error {
+ _, err := s.DB.ExecContext(ctx,
+ `DELETE FROM view_group_members WHERE group_id = ? AND user_id = ?`,
+ groupID, userID)
+ if err != nil {
+ return fmt.Errorf("remove group member: %w", err)
+ }
+ return nil
+}
+
+// ListMembers returns the members of a group joined with user email/role.
+func (s *Service) ListMembers(ctx context.Context, groupID string) ([]Member, error) {
+ rows, err := s.DB.QueryContext(ctx,
+ `SELECT u.id, u.email, u.role, m.added_at
+ FROM view_group_members m
+ JOIN users u ON u.id = m.user_id
+ WHERE m.group_id = ?
+ ORDER BY m.added_at ASC`, groupID)
+ if err != nil {
+ return nil, fmt.Errorf("list group members: %w", err)
+ }
+ defer rows.Close()
+ out := []Member{}
+ for rows.Next() {
+ var (
+ m Member
+ addedAt string
+ )
+ if err := rows.Scan(&m.UserID, &m.Email, &m.Role, &addedAt); err != nil {
+ return nil, fmt.Errorf("scan member: %w", err)
+ }
+ m.AddedAt, _ = time.Parse(time.RFC3339Nano, addedAt)
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+// IsMember reports whether a user belongs to a group.
+func (s *Service) IsMember(ctx context.Context, userID, groupID string) (bool, error) {
+ var one int
+ err := s.DB.QueryRowContext(ctx,
+ `SELECT 1 FROM view_group_members WHERE group_id = ? AND user_id = ?`,
+ groupID, userID).Scan(&one)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("is member: %w", err)
+ }
+ return true, nil
+}
+
+// --- helpers ---
+
+func scanRow(r interface{ Scan(dest ...any) error }) (Group, error) {
+ var (
+ g Group
+ description sql.NullString
+ createdAt string
+ updatedAt string
+ )
+ err := r.Scan(&g.ID, &g.Name, &description, &createdAt, &updatedAt)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return Group{}, ErrNotFound
+ }
+ return Group{}, fmt.Errorf("scan group: %w", err)
+ }
+ g.Description = description.String
+ g.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
+ g.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
+ return g, nil
+}
+
+func scanRows(rows *sql.Rows) ([]Group, error) {
+ out := []Group{}
+ for rows.Next() {
+ g, err := scanRow(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, g)
+ }
+ return out, rows.Err()
+}
+
+func nullableString(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+func isUniqueConstraintViolation(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "UNIQUE constraint failed") ||
+ strings.Contains(msg, "constraint failed: UNIQUE")
+}
+
+func isForeignKeyViolation(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "FOREIGN KEY constraint failed") ||
+ strings.Contains(msg, "constraint failed: FOREIGN KEY")
+}
diff --git a/server/internal/groups/groups_test.go b/server/internal/groups/groups_test.go
new file mode 100644
index 0000000..29078cd
--- /dev/null
+++ b/server/internal/groups/groups_test.go
@@ -0,0 +1,103 @@
+package groups
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/dvcdsys/code-index/server/internal/db"
+ "github.com/dvcdsys/code-index/server/internal/users"
+)
+
+func newTestService(t *testing.T) (*Service, *users.Service) {
+ t.Helper()
+ database, err := db.Open(":memory:")
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ return New(database), users.New(database)
+}
+
+func TestGroupCRUDAndMembership(t *testing.T) {
+ ctx := context.Background()
+ gs, us := newTestService(t)
+
+ alice, err := us.Create(ctx, "alice@x.com", "password1234", users.RoleUser, false)
+ if err != nil {
+ t.Fatalf("create alice: %v", err)
+ }
+ bob, err := us.Create(ctx, "bob@x.com", "password1234", users.RoleUser, false)
+ if err != nil {
+ t.Fatalf("create bob: %v", err)
+ }
+
+ g, err := gs.Create(ctx, "Product", "PM agents")
+ if err != nil {
+ t.Fatalf("create group: %v", err)
+ }
+ if g.Name != "Product" {
+ t.Errorf("name = %q", g.Name)
+ }
+
+ // Duplicate name → ErrNameTaken.
+ if _, err := gs.Create(ctx, "Product", ""); !errors.Is(err, ErrNameTaken) {
+ t.Errorf("duplicate name err = %v, want ErrNameTaken", err)
+ }
+
+ // Add alice; bob stays out.
+ if err := gs.AddMember(ctx, g.ID, alice.ID); err != nil {
+ t.Fatalf("add member: %v", err)
+ }
+ // Idempotent re-add.
+ if err := gs.AddMember(ctx, g.ID, alice.ID); err != nil {
+ t.Fatalf("re-add member: %v", err)
+ }
+
+ if ok, _ := gs.IsMember(ctx, alice.ID, g.ID); !ok {
+ t.Error("alice should be a member")
+ }
+ if ok, _ := gs.IsMember(ctx, bob.ID, g.ID); ok {
+ t.Error("bob should NOT be a member")
+ }
+
+ // AddMember for a non-existent user → ErrUserNotFound.
+ if err := gs.AddMember(ctx, g.ID, "nope"); !errors.Is(err, ErrUserNotFound) {
+ t.Errorf("add bad user err = %v, want ErrUserNotFound", err)
+ }
+
+ members, err := gs.ListMembers(ctx, g.ID)
+ if err != nil {
+ t.Fatalf("list members: %v", err)
+ }
+ if len(members) != 1 || members[0].Email != "alice@x.com" {
+ t.Errorf("members = %+v", members)
+ }
+
+ forAlice, err := gs.ListForUser(ctx, alice.ID)
+ if err != nil {
+ t.Fatalf("list for user: %v", err)
+ }
+ if len(forAlice) != 1 || forAlice[0].ID != g.ID {
+ t.Errorf("groups for alice = %+v", forAlice)
+ }
+ if forBob, _ := gs.ListForUser(ctx, bob.ID); len(forBob) != 0 {
+ t.Errorf("groups for bob = %+v, want none", forBob)
+ }
+
+ // Remove alice.
+ if err := gs.RemoveMember(ctx, g.ID, alice.ID); err != nil {
+ t.Fatalf("remove member: %v", err)
+ }
+ if ok, _ := gs.IsMember(ctx, alice.ID, g.ID); ok {
+ t.Error("alice should be removed")
+ }
+
+ // Delete group, then GetByID → ErrNotFound.
+ if err := gs.Delete(ctx, g.ID); err != nil {
+ t.Fatalf("delete group: %v", err)
+ }
+ if _, err := gs.GetByID(ctx, g.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("get deleted err = %v, want ErrNotFound", err)
+ }
+}
diff --git a/server/internal/httpapi/access.go b/server/internal/httpapi/access.go
new file mode 100644
index 0000000..b2b8158
--- /dev/null
+++ b/server/internal/httpapi/access.go
@@ -0,0 +1,162 @@
+package httpapi
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/dvcdsys/code-index/server/internal/access"
+ "github.com/dvcdsys/code-index/server/internal/projects"
+ "github.com/dvcdsys/code-index/server/internal/users"
+ "github.com/dvcdsys/code-index/server/internal/workspaces"
+)
+
+// callerIdentity returns the authenticated user id and whether the caller is
+// an admin. When auth is disabled (CIX_AUTH_DISABLED), every request is
+// treated as admin — matching mustBeAdmin's dev-mode short-circuit.
+func (s *Server) callerIdentity(r *http.Request) (userID string, isAdmin bool) {
+ if s.Deps.AuthDisabled {
+ return "", true
+ }
+ ac, ok := authFromCtx(r.Context())
+ if !ok {
+ return "", false
+ }
+ return ac.User.ID, ac.User.Role == users.RoleAdmin
+}
+
+// canAccessProject reports whether the caller has READ access to an
+// already-loaded project: admin, owner, or a member of a view-group it is
+// shared to. Used where the project is resolved by the handler (not the
+// {path} URL param) — e.g. linking a project into a workspace.
+func (s *Server) canAccessProject(r *http.Request, p *projects.Project) bool {
+ userID, isAdmin := s.callerIdentity(r)
+ if isAdmin {
+ return true
+ }
+ if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID {
+ return true
+ }
+ if userID != "" {
+ shared, _ := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID)
+ return shared
+ }
+ return false
+}
+
+// requireProjectAccess resolves the project named by the {path} hash and
+// enforces READ access: admin, owner, or a member of a view-group the project
+// is shared to. On any failure it writes the response (404 to hide existence
+// from callers without access) and returns nil.
+func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request) *projects.Project {
+ p := resolveProjectFromHash(w, r, s.Deps)
+ if p == nil {
+ return nil
+ }
+ userID, isAdmin := s.callerIdentity(r)
+ if isAdmin {
+ return p
+ }
+ if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID {
+ return p
+ }
+ if userID != "" {
+ shared, err := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "access check failed")
+ return nil
+ }
+ if shared {
+ return p
+ }
+ }
+ writeError(w, http.StatusNotFound, "project not found")
+ return nil
+}
+
+// requireProjectOwnership resolves the project and enforces WRITE access:
+// admin or the project's owner. External projects (ownerless) are admin-only.
+// Returns 403 when the caller can read the project but does not own it, 404
+// when they cannot see it at all.
+func (s *Server) requireProjectOwnership(w http.ResponseWriter, r *http.Request) *projects.Project {
+ p := resolveProjectFromHash(w, r, s.Deps)
+ if p == nil {
+ return nil
+ }
+ userID, isAdmin := s.callerIdentity(r)
+ if isAdmin {
+ return p
+ }
+ if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID {
+ return p
+ }
+ if userID != "" {
+ if shared, _ := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID); shared {
+ writeError(w, http.StatusForbidden, "this action requires the project owner or an admin")
+ return nil
+ }
+ }
+ writeError(w, http.StatusNotFound, "project not found")
+ return nil
+}
+
+// loadWorkspace fetches the workspace and writes 404/500 on error, returning
+// ok=false. Shared by the visibility/ownership guards below.
+func (s *Server) loadWorkspace(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) {
+ ws, err := s.Deps.Workspaces.GetByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, workspaces.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "workspace not found")
+ } else {
+ writeError(w, http.StatusInternalServerError, "could not load workspace")
+ }
+ return workspaces.Workspace{}, false
+ }
+ return ws, true
+}
+
+// requireWorkspaceVisible enforces READ visibility: admin, owner, or a member
+// of a view-group the workspace is shared to. 404 hides non-visible workspaces.
+func (s *Server) requireWorkspaceVisible(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) {
+ ws, ok := s.loadWorkspace(w, r, id)
+ if !ok {
+ return ws, false
+ }
+ userID, isAdmin := s.callerIdentity(r)
+ if isAdmin {
+ return ws, true
+ }
+ if userID != "" && ws.OwnerUserID != nil && *ws.OwnerUserID == userID {
+ return ws, true
+ }
+ if userID != "" {
+ if shared, _ := access.WorkspaceSharedToUser(r.Context(), s.Deps.DB, id, userID); shared {
+ return ws, true
+ }
+ }
+ writeError(w, http.StatusNotFound, "workspace not found")
+ return workspaces.Workspace{}, false
+}
+
+// requireWorkspaceOwnership enforces WRITE access: admin or the owner. 403 when
+// the caller can see the workspace but does not own it, 404 when hidden.
+func (s *Server) requireWorkspaceOwnership(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) {
+ ws, ok := s.loadWorkspace(w, r, id)
+ if !ok {
+ return ws, false
+ }
+ userID, isAdmin := s.callerIdentity(r)
+ if isAdmin {
+ return ws, true
+ }
+ if userID != "" && ws.OwnerUserID != nil && *ws.OwnerUserID == userID {
+ return ws, true
+ }
+ if userID != "" {
+ if shared, _ := access.WorkspaceSharedToUser(r.Context(), s.Deps.DB, id, userID); shared {
+ writeError(w, http.StatusForbidden, "this action requires the workspace owner or an admin")
+ return workspaces.Workspace{}, false
+ }
+ }
+ writeError(w, http.StatusNotFound, "workspace not found")
+ return workspaces.Workspace{}, false
+}
diff --git a/server/internal/httpapi/admin_server_test.go b/server/internal/httpapi/admin_server_test.go
index b99c093..6269180 100644
--- a/server/internal/httpapi/admin_server_test.go
+++ b/server/internal/httpapi/admin_server_test.go
@@ -38,7 +38,7 @@ func newAdminFixture(t *testing.T) *adminFixture {
if err != nil {
t.Fatalf("seed admin: %v", err)
}
- viewer, err := usrSvc.Create(context.Background(), "viewer@example.com", "secret-password", users.RoleViewer, false)
+ viewer, err := usrSvc.Create(context.Background(), "viewer@example.com", "secret-password", users.RoleUser, false)
if err != nil {
t.Fatalf("seed viewer: %v", err)
}
diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go
index 8ee8314..61c3e43 100644
--- a/server/internal/httpapi/auth.go
+++ b/server/internal/httpapi/auth.go
@@ -223,9 +223,18 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "Authentication required")
return
}
+ groupPayloads := []groupPayload{}
+ if s.Deps.Groups != nil {
+ if gs, err := s.Deps.Groups.ListForUser(r.Context(), ac.User.ID); err == nil {
+ for _, g := range gs {
+ groupPayloads = append(groupPayloads, groupToPayload(g))
+ }
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{
"user": userToPayload(ac.User),
"auth_method": ac.Method,
+ "groups": groupPayloads,
})
}
diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go
index 5162e95..9bc1564 100644
--- a/server/internal/httpapi/auth_test.go
+++ b/server/internal/httpapi/auth_test.go
@@ -294,7 +294,7 @@ func TestCreateUser_AdminOnly(t *testing.T) {
cookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
body, _ := json.Marshal(map[string]string{
- "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer",
+ "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "user",
})
req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), cookie)
req.Header.Set("Content-Type", "application/json")
@@ -307,7 +307,7 @@ func TestCreateUser_AdminOnly(t *testing.T) {
// Now try the same request as the viewer — expect 403.
viewerCookie := sessionCookie(loginRR(t, f.Router, "viewer@example.com", "viewerpass1"))
body, _ = json.Marshal(map[string]string{
- "email": "another@example.com", "initial_password": "anotherpass1", "role": "viewer",
+ "email": "another@example.com", "initial_password": "anotherpass1", "role": "user",
})
req = withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), viewerCookie)
req.Header.Set("Content-Type", "application/json")
@@ -371,7 +371,7 @@ func TestApiKey_CreateListRevokeFlow(t *testing.T) {
func TestApiKey_ListForOwnerHidesOthers(t *testing.T) {
f := newAuthFixture(t)
// Seed a viewer + their own key directly via the underlying services.
- v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleViewer, false)
+ v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleUser, false)
if err != nil {
t.Fatalf("seed viewer: %v", err)
}
@@ -401,81 +401,134 @@ func TestApiKey_ListForOwnerHidesOthers(t *testing.T) {
// project are gated behind the admin role. POST /index/cancel is
// intentionally NOT gated — see comment on IndexCancel for why — and is
// covered separately by TestIndexCancel_AnyAuthenticatedUser.
-func TestProjectMutations_AdminOnly(t *testing.T) {
+// TestProjectMutations_OwnerOrAdmin pins the auth model for PATCH/DELETE on a
+// project: the owner or an admin may mutate it; a different regular user cannot
+// even see it (404, hiding existence). CreateProject sets owner = caller.
+func TestProjectMutations_OwnerOrAdmin(t *testing.T) {
f := newAuthFixture(t)
adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
- // Admin creates a project to act on. CreateProject is intentionally
- // not admin-only — viewers can register their own projects.
- createBody, _ := json.Marshal(map[string]string{"host_path": "/tmp/test-proj"})
- req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)), adminCookie)
+ // Seed a regular user (alice) and log in.
+ aliceBody, _ := json.Marshal(map[string]string{
+ "email": "alice@example.com", "initial_password": "alicepass1", "role": "user",
+ })
+ req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(aliceBody)), adminCookie)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
f.Router.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
- t.Fatalf("admin create project status = %d (body=%s)", rr.Code, rr.Body.String())
+ t.Fatalf("seed alice status = %d (body=%s)", rr.Code, rr.Body.String())
+ }
+ aliceCookie := sessionCookie(loginRR(t, f.Router, "alice@example.com", "alicepass1"))
+
+ createProject := func(cookie, hostPath string) string {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"host_path": hostPath})
+ req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body)), cookie)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ f.Router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("create project %s status = %d (body=%s)", hostPath, rr.Code, rr.Body.String())
+ }
+ var created struct {
+ PathHash string `json:"path_hash"`
+ }
+ _ = json.Unmarshal(rr.Body.Bytes(), &created)
+ if created.PathHash == "" {
+ t.Fatalf("created project missing path_hash: %s", rr.Body.String())
+ }
+ return created.PathHash
}
- var created struct{ PathHash string `json:"path_hash"` }
- _ = json.Unmarshal(rr.Body.Bytes(), &created)
- if created.PathHash == "" {
- t.Fatalf("created project payload missing path_hash: %s", rr.Body.String())
+
+ do := func(cookie, method, path string, body []byte) int {
+ t.Helper()
+ req := withCookie(httptest.NewRequest(method, path, bytes.NewReader(body)), cookie)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ f.Router.ServeHTTP(rr, req)
+ return rr.Code
}
- // Seed a viewer + log in as them.
- viewerBody, _ := json.Marshal(map[string]string{
- "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer",
+ patchBody := mustJSON(t, map[string]any{
+ "settings": map[string]any{"exclude_patterns": []string{"vendor"}},
})
- req = withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(viewerBody)), adminCookie)
+
+ adminProj := createProject(adminCookie, "/tmp/admin-proj")
+ aliceProj := createProject(aliceCookie, "/tmp/alice-proj")
+
+ t.Run("non-owner cannot patch (404 hides existence)", func(t *testing.T) {
+ if code := do(aliceCookie, http.MethodPatch, "/api/v1/projects/"+adminProj, patchBody); code != http.StatusNotFound {
+ t.Errorf("alice PATCH admin's project = %d, want 404", code)
+ }
+ })
+ t.Run("non-owner cannot delete (404 hides existence)", func(t *testing.T) {
+ if code := do(aliceCookie, http.MethodDelete, "/api/v1/projects/"+adminProj, nil); code != http.StatusNotFound {
+ t.Errorf("alice DELETE admin's project = %d, want 404", code)
+ }
+ })
+ t.Run("owner can patch own project", func(t *testing.T) {
+ if code := do(aliceCookie, http.MethodPatch, "/api/v1/projects/"+aliceProj, patchBody); code != http.StatusOK {
+ t.Errorf("alice PATCH own project = %d, want 200", code)
+ }
+ })
+ t.Run("admin can patch another user's project", func(t *testing.T) {
+ if code := do(adminCookie, http.MethodPatch, "/api/v1/projects/"+aliceProj, patchBody); code != http.StatusOK {
+ t.Errorf("admin PATCH alice's project = %d, want 200", code)
+ }
+ })
+ t.Run("owner can delete own project", func(t *testing.T) {
+ ownDel := createProject(aliceCookie, "/tmp/alice-proj-2")
+ if code := do(aliceCookie, http.MethodDelete, "/api/v1/projects/"+ownDel, nil); code != http.StatusNoContent {
+ t.Errorf("alice DELETE own project = %d, want 204", code)
+ }
+ })
+ t.Run("admin can delete another user's project", func(t *testing.T) {
+ if code := do(adminCookie, http.MethodDelete, "/api/v1/projects/"+aliceProj, nil); code != http.StatusNoContent {
+ t.Errorf("admin DELETE alice's project = %d, want 204", code)
+ }
+ })
+}
+
+// TestExternalAndTokenEndpoints_AdminOnly locks the review's top findings:
+// external-project administration and GitHub-token management are admin-only,
+// so a regular user is rejected with 403 (the admin gate runs before any
+// service-availability check, so this holds regardless of wiring).
+func TestExternalAndTokenEndpoints_AdminOnly(t *testing.T) {
+ f := newAuthFixture(t)
+ adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
+
+ userBody, _ := json.Marshal(map[string]string{
+ "email": "bob@example.com", "initial_password": "bobpass1234", "role": "user",
+ })
+ req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(userBody)), adminCookie)
req.Header.Set("Content-Type", "application/json")
- rr = httptest.NewRecorder()
+ rr := httptest.NewRecorder()
f.Router.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
- t.Fatalf("seed viewer status = %d (body=%s)", rr.Code, rr.Body.String())
+ t.Fatalf("seed user status = %d (body=%s)", rr.Code, rr.Body.String())
}
- viewerCookie := sessionCookie(loginRR(t, f.Router, "viewer@example.com", "viewerpass1"))
+ userCookie := sessionCookie(loginRR(t, f.Router, "bob@example.com", "bobpass1234"))
- // Each gated endpoint must 403 for the viewer, then succeed for the
- // admin. PATCH first (mutates settings), DELETE last (destructive).
cases := []struct {
- name string
- method string
- path string
- body []byte
- adminStatus int
+ name, method, path string
}{
- {
- name: "patch settings",
- method: http.MethodPatch,
- path: "/api/v1/projects/" + created.PathHash,
- body: mustJSON(t, map[string]any{
- "settings": map[string]any{"exclude_patterns": []string{"vendor"}},
- }),
- adminStatus: http.StatusOK,
- },
- {
- name: "delete project",
- method: http.MethodDelete,
- path: "/api/v1/projects/" + created.PathHash,
- adminStatus: http.StatusNoContent,
- },
+ {"add git-repo", http.MethodPost, "/api/v1/git-repos"},
+ {"list github-tokens", http.MethodGet, "/api/v1/github-tokens"},
+ {"create github-token", http.MethodPost, "/api/v1/github-tokens"},
+ {"get project git-repo", http.MethodGet, "/api/v1/projects/deadbeefdeadbeef/git-repo"},
+ {"reindex project", http.MethodPost, "/api/v1/projects/deadbeefdeadbeef/reindex"},
+ {"force-stop project", http.MethodPost, "/api/v1/projects/deadbeefdeadbeef/force-stop"},
+ {"webhook-info", http.MethodGet, "/api/v1/projects/deadbeefdeadbeef/webhook-info"},
}
for _, c := range cases {
- t.Run(c.name+"/viewer-forbidden", func(t *testing.T) {
- req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader(c.body)), viewerCookie)
+ t.Run(c.name, func(t *testing.T) {
+ req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader([]byte("{}"))), userCookie)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
f.Router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
- t.Errorf("viewer %s %s status = %d, want 403", c.method, c.path, rr.Code)
- }
- })
- t.Run(c.name+"/admin-allowed", func(t *testing.T) {
- req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader(c.body)), adminCookie)
- req.Header.Set("Content-Type", "application/json")
- rr := httptest.NewRecorder()
- f.Router.ServeHTTP(rr, req)
- if rr.Code != c.adminStatus {
- t.Errorf("admin %s %s status = %d, want %d (body=%s)", c.method, c.path, rr.Code, c.adminStatus, rr.Body.String())
+ t.Errorf("user %s %s = %d, want 403", c.method, c.path, rr.Code)
}
})
}
@@ -500,7 +553,7 @@ func TestIndexCancel_AnyAuthenticatedUser(t *testing.T) {
// Seed a viewer + a project they can cancel against.
viewerBody, _ := json.Marshal(map[string]string{
- "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer",
+ "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "user",
})
req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(viewerBody)), adminCookie)
req.Header.Set("Content-Type", "application/json")
@@ -539,7 +592,7 @@ func TestListUsers_IncludesStats(t *testing.T) {
cookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
// Seed a viewer + give them an api-key so the row is non-trivial.
- v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleViewer, false)
+ v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleUser, false)
if err != nil {
t.Fatalf("seed viewer: %v", err)
}
diff --git a/server/internal/httpapi/githubtokens.go b/server/internal/httpapi/githubtokens.go
index 6e86514..1da96a6 100644
--- a/server/internal/httpapi/githubtokens.go
+++ b/server/internal/httpapi/githubtokens.go
@@ -62,6 +62,9 @@ func (s *Server) githubTokensUnavailable(w http.ResponseWriter) bool {
// ListGithubTokens — GET /api/v1/github-tokens.
func (s *Server) ListGithubTokens(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.githubTokensUnavailable(w) {
return
}
@@ -92,6 +95,9 @@ func (s *Server) ListGithubTokens(w http.ResponseWriter, r *http.Request) {
// actually advertises. The Scopes field on the request stays for
// backwards compatibility with older clients but is ignored.
func (s *Server) CreateGithubToken(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.githubTokensUnavailable(w) {
return
}
@@ -142,6 +148,9 @@ func (s *Server) CreateGithubToken(w http.ResponseWriter, r *http.Request) {
// repository — useful when /user/repos misses SAML-protected org
// repos that only surface under /orgs/{login}/repos.
func (s *Server) ListTokenAccounts(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.githubTokensUnavailable(w) {
return
}
@@ -200,6 +209,9 @@ func (s *Server) ListTokenRepos(
id string,
params openapi.ListTokenReposParams,
) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.githubTokensUnavailable(w) {
return
}
@@ -292,6 +304,9 @@ func ptrStr(s string) *string {
// DeleteGithubToken — DELETE /api/v1/github-tokens/{id}.
func (s *Server) DeleteGithubToken(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.githubTokensUnavailable(w) {
return
}
diff --git a/server/internal/httpapi/gitrepos.go b/server/internal/httpapi/gitrepos.go
index b169e47..4000451 100644
--- a/server/internal/httpapi/gitrepos.go
+++ b/server/internal/httpapi/gitrepos.go
@@ -96,6 +96,11 @@ func gitRepoToPayload(g gitrepos.GitRepo) gitRepoPayload {
// and enqueues a clone_repo job. The resulting project belongs to no
// workspace — the caller can attach it via POST /workspaces/{id}/projects.
func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) {
+ // External projects are admin-administered and ownerless; only admins may
+ // add one (it clones a repo, may register a webhook, and uses a stored PAT).
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.gitReposUnavailable(w) {
return
}
@@ -252,6 +257,10 @@ func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) {
// GetProjectGitRepo — GET /api/v1/projects/{hash}/git-repo.
func (s *Server) GetProjectGitRepo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) {
+ // Exposes sync config for an external project — admin-only, like the PATCH.
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.gitReposUnavailable(w) {
return
}
@@ -418,6 +427,10 @@ func (s *Server) deregisterWebhookIfAny(ctx context.Context, g gitrepos.GitRepo)
// suspected index drift — manual rebuild without losing tokens or
// workspace memberships.
func (s *Server) ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params openapi.ReindexProjectParams) {
+ // Reindex of an external repo is an admin operation (clone + index).
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.gitReposUnavailable(w) {
return
}
@@ -500,6 +513,10 @@ func (s *Server) ReindexProject(w http.ResponseWriter, r *http.Request, hash str
// and a force-stop landing in the brief clone/fetch window may not catch
// an index job the clone goroutine enqueues a moment later — re-run if so.
func (s *Server) ForceStopIndex(w http.ResponseWriter, r *http.Request, hash string) {
+ // Force-stopping an external repo's index run is an admin operation.
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.gitReposUnavailable(w) {
return
}
diff --git a/server/internal/httpapi/groups.go b/server/internal/httpapi/groups.go
new file mode 100644
index 0000000..7f2f848
--- /dev/null
+++ b/server/internal/httpapi/groups.go
@@ -0,0 +1,504 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/dvcdsys/code-index/server/internal/access"
+ "github.com/dvcdsys/code-index/server/internal/groups"
+ "github.com/dvcdsys/code-index/server/internal/projects"
+)
+
+// ---------------------------------------------------------------------------
+// Payloads (hand-built so date formatting stays RFC3339Nano UTC, matching the
+// rest of the API).
+// ---------------------------------------------------------------------------
+
+type groupPayload struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+func groupToPayload(g groups.Group) groupPayload {
+ return groupPayload{
+ ID: g.ID,
+ Name: g.Name,
+ Description: g.Description,
+ CreatedAt: g.CreatedAt.UTC().Format(time.RFC3339Nano),
+ UpdatedAt: g.UpdatedAt.UTC().Format(time.RFC3339Nano),
+ }
+}
+
+type groupMemberPayload struct {
+ UserID string `json:"user_id"`
+ Email string `json:"email"`
+ Role string `json:"role"`
+ AddedAt string `json:"added_at"`
+}
+
+// groupsUnavailable returns 503 when the service is not wired.
+func (s *Server) groupsUnavailable(w http.ResponseWriter) bool {
+ if s.Deps.Groups == nil {
+ writeError(w, http.StatusServiceUnavailable, "groups service is not configured on this server")
+ return true
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// Group CRUD (admin-only except ListGroups, which is member-scoped for users)
+// ---------------------------------------------------------------------------
+
+// ListGroups — GET /api/v1/groups. Admins see all; users see their own groups.
+func (s *Server) ListGroups(w http.ResponseWriter, r *http.Request) {
+ if s.groupsUnavailable(w) {
+ return
+ }
+ userID, isAdmin := s.callerIdentity(r)
+ var (
+ list []groups.Group
+ err error
+ )
+ if isAdmin {
+ list, err = s.Deps.Groups.List(r.Context())
+ } else {
+ list, err = s.Deps.Groups.ListForUser(r.Context(), userID)
+ }
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list groups")
+ return
+ }
+ out := make([]groupPayload, 0, len(list))
+ for _, g := range list {
+ out = append(out, groupToPayload(g))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"groups": out, "total": len(out)})
+}
+
+// CreateGroup — POST /api/v1/groups (admin only).
+func (s *Server) CreateGroup(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
+ return
+ }
+ g, err := s.Deps.Groups.Create(r.Context(), body.Name, body.Description)
+ if err != nil {
+ switch {
+ case errors.Is(err, groups.ErrNameEmpty):
+ writeError(w, http.StatusUnprocessableEntity, "name is required")
+ case errors.Is(err, groups.ErrNameTaken):
+ writeError(w, http.StatusConflict, "group name already exists")
+ default:
+ writeError(w, http.StatusInternalServerError, "could not create group")
+ }
+ return
+ }
+ writeJSON(w, http.StatusCreated, groupToPayload(g))
+}
+
+// GetGroup — GET /api/v1/groups/{id} (admin only).
+func (s *Server) GetGroup(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ g, err := s.Deps.Groups.GetByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, groups.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "group not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not load group")
+ return
+ }
+ writeJSON(w, http.StatusOK, groupToPayload(g))
+}
+
+// UpdateGroup — PATCH /api/v1/groups/{id} (admin only).
+func (s *Server) UpdateGroup(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ var body struct {
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
+ return
+ }
+ g, err := s.Deps.Groups.Update(r.Context(), id, body.Name, body.Description)
+ if err != nil {
+ switch {
+ case errors.Is(err, groups.ErrNotFound):
+ writeError(w, http.StatusNotFound, "group not found")
+ case errors.Is(err, groups.ErrNameEmpty):
+ writeError(w, http.StatusUnprocessableEntity, "name is required")
+ case errors.Is(err, groups.ErrNameTaken):
+ writeError(w, http.StatusConflict, "group name already exists")
+ default:
+ writeError(w, http.StatusInternalServerError, "could not update group")
+ }
+ return
+ }
+ writeJSON(w, http.StatusOK, groupToPayload(g))
+}
+
+// DeleteGroup — DELETE /api/v1/groups/{id} (admin only).
+func (s *Server) DeleteGroup(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ if err := s.Deps.Groups.Delete(r.Context(), id); err != nil {
+ if errors.Is(err, groups.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "group not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not delete group")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// ---------------------------------------------------------------------------
+// Membership (admin only)
+// ---------------------------------------------------------------------------
+
+// ListGroupMembers — GET /api/v1/groups/{id}/members (admin only).
+func (s *Server) ListGroupMembers(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ if _, err := s.Deps.Groups.GetByID(r.Context(), id); err != nil {
+ if errors.Is(err, groups.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "group not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not load group")
+ return
+ }
+ members, err := s.Deps.Groups.ListMembers(r.Context(), id)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list members")
+ return
+ }
+ out := make([]groupMemberPayload, 0, len(members))
+ for _, m := range members {
+ out = append(out, groupMemberPayload{
+ UserID: m.UserID,
+ Email: m.Email,
+ Role: m.Role,
+ AddedAt: m.AddedAt.UTC().Format(time.RFC3339Nano),
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"members": out, "total": len(out)})
+}
+
+// AddGroupMember — POST /api/v1/groups/{id}/members (admin only).
+func (s *Server) AddGroupMember(w http.ResponseWriter, r *http.Request, id string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ var body struct {
+ UserID string `json:"user_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
+ return
+ }
+ if body.UserID == "" {
+ writeError(w, http.StatusUnprocessableEntity, "user_id is required")
+ return
+ }
+ if err := s.Deps.Groups.AddMember(r.Context(), id, body.UserID); err != nil {
+ switch {
+ case errors.Is(err, groups.ErrNotFound):
+ writeError(w, http.StatusNotFound, "group not found")
+ case errors.Is(err, groups.ErrUserNotFound):
+ writeError(w, http.StatusUnprocessableEntity, "user not found")
+ default:
+ writeError(w, http.StatusInternalServerError, "could not add member")
+ }
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// RemoveGroupMember — DELETE /api/v1/groups/{id}/members/{userId} (admin only).
+func (s *Server) RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ if err := s.Deps.Groups.RemoveMember(r.Context(), id, userId); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not remove member")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// ---------------------------------------------------------------------------
+// Project shares (admin only; external projects only)
+// ---------------------------------------------------------------------------
+
+func (s *Server) loadProjectByHash(w http.ResponseWriter, r *http.Request, hash string) *projects.Project {
+ p, err := projects.GetByHash(r.Context(), s.Deps.DB, hash)
+ if err != nil {
+ if errors.Is(err, projects.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "project not found")
+ return nil
+ }
+ writeError(w, http.StatusInternalServerError, "could not load project")
+ return nil
+ }
+ return p
+}
+
+// ListProjectShares — GET /api/v1/projects/{hash}/shares (admin only).
+func (s *Server) ListProjectShares(w http.ResponseWriter, r *http.Request, hash string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ p := s.loadProjectByHash(w, r, hash)
+ if p == nil {
+ return
+ }
+ ids, err := access.ListProjectShareGroupIDs(r.Context(), s.Deps.DB, p.HostPath)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list shares")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"group_ids": ids})
+}
+
+// ShareProjectToGroup — POST /api/v1/projects/{hash}/shares (admin only).
+func (s *Server) ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ if s.groupsUnavailable(w) {
+ return
+ }
+ p := s.loadProjectByHash(w, r, hash)
+ if p == nil {
+ return
+ }
+ external, err := access.IsProjectExternal(r.Context(), s.Deps.DB, p.HostPath)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not check project")
+ return
+ }
+ if !external {
+ writeError(w, http.StatusUnprocessableEntity, "only external projects can be shared to a group")
+ return
+ }
+ groupID, ok := s.decodeGroupID(w, r)
+ if !ok {
+ return
+ }
+ if !s.groupExists(w, r, groupID) {
+ return
+ }
+ if err := access.ShareProjectToGroup(r.Context(), s.Deps.DB, p.HostPath, groupID); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not share project")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// UnshareProjectFromGroup — DELETE /api/v1/projects/{hash}/shares/{groupId} (admin only).
+func (s *Server) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ p := s.loadProjectByHash(w, r, hash)
+ if p == nil {
+ return
+ }
+ if err := access.UnshareProjectFromGroup(r.Context(), s.Deps.DB, p.HostPath, groupId); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not unshare project")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// ReassignProjectOwner — PUT /api/v1/projects/{hash}/owner (admin only).
+func (s *Server) ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
+ p := s.loadProjectByHash(w, r, hash)
+ if p == nil {
+ return
+ }
+ external, err := access.IsProjectExternal(r.Context(), s.Deps.DB, p.HostPath)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not check project")
+ return
+ }
+ if external {
+ writeError(w, http.StatusUnprocessableEntity, "external projects are ownerless and cannot be reassigned")
+ return
+ }
+ var body struct {
+ OwnerUserID string `json:"owner_user_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
+ return
+ }
+ if body.OwnerUserID == "" {
+ writeError(w, http.StatusUnprocessableEntity, "owner_user_id is required")
+ return
+ }
+ target, err := s.Deps.Users.GetByID(r.Context(), body.OwnerUserID)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "owner_user_id does not refer to an existing user")
+ return
+ }
+ if target.DisabledAt != nil {
+ writeError(w, http.StatusUnprocessableEntity, "cannot assign ownership to a disabled user")
+ return
+ }
+ if err := projects.SetOwner(r.Context(), s.Deps.DB, p.HostPath, body.OwnerUserID); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not reassign owner")
+ return
+ }
+ updated, err := projects.Get(r.Context(), s.Deps.DB, p.HostPath)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not load updated project")
+ return
+ }
+ writeJSON(w, http.StatusOK, projectToOpenAPI(updated))
+}
+
+// ---------------------------------------------------------------------------
+// Workspace shares (owner-in-group or admin)
+// ---------------------------------------------------------------------------
+
+// ListWorkspaceShares — GET /api/v1/workspaces/{id}/shares.
+func (s *Server) ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string) {
+ if s.workspacesUnavailable(w) {
+ return
+ }
+ if _, ok := s.requireWorkspaceVisible(w, r, id); !ok {
+ return
+ }
+ ids, err := access.ListWorkspaceShareGroupIDs(r.Context(), s.Deps.DB, id)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list shares")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"group_ids": ids})
+}
+
+// ShareWorkspaceToGroup — POST /api/v1/workspaces/{id}/shares. The owner may
+// share to a group they belong to; an admin may share to any group.
+func (s *Server) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string) {
+ if s.workspacesUnavailable(w) || s.groupsUnavailable(w) {
+ return
+ }
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
+ return
+ }
+ groupID, ok := s.decodeGroupID(w, r)
+ if !ok {
+ return
+ }
+ if !s.groupExists(w, r, groupID) {
+ return
+ }
+ // A non-admin owner may only share to groups they are a member of.
+ userID, isAdmin := s.callerIdentity(r)
+ if !isAdmin {
+ member, err := s.Deps.Groups.IsMember(r.Context(), userID, groupID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not check membership")
+ return
+ }
+ if !member {
+ writeError(w, http.StatusForbidden, "you can only share to groups you belong to")
+ return
+ }
+ }
+ if err := access.ShareWorkspaceToGroup(r.Context(), s.Deps.DB, id, groupID); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not share workspace")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// UnshareWorkspaceFromGroup — DELETE /api/v1/workspaces/{id}/shares/{groupId}.
+func (s *Server) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string) {
+ if s.workspacesUnavailable(w) {
+ return
+ }
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
+ return
+ }
+ if err := access.UnshareWorkspaceFromGroup(r.Context(), s.Deps.DB, id, groupId); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not unshare workspace")
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// --- small shared helpers ---
+
+func (s *Server) decodeGroupID(w http.ResponseWriter, r *http.Request) (string, bool) {
+ var body struct {
+ GroupID string `json:"group_id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
+ return "", false
+ }
+ if body.GroupID == "" {
+ writeError(w, http.StatusUnprocessableEntity, "group_id is required")
+ return "", false
+ }
+ return body.GroupID, true
+}
+
+func (s *Server) groupExists(w http.ResponseWriter, r *http.Request, groupID string) bool {
+ if _, err := s.Deps.Groups.GetByID(r.Context(), groupID); err != nil {
+ if errors.Is(err, groups.ErrNotFound) {
+ writeError(w, http.StatusUnprocessableEntity, "group_id does not refer to an existing group")
+ return false
+ }
+ writeError(w, http.StatusInternalServerError, "could not load group")
+ return false
+ }
+ return true
+}
diff --git a/server/internal/httpapi/groups_test.go b/server/internal/httpapi/groups_test.go
new file mode 100644
index 0000000..49ae3fe
--- /dev/null
+++ b/server/internal/httpapi/groups_test.go
@@ -0,0 +1,275 @@
+package httpapi
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/dvcdsys/code-index/server/internal/projects"
+)
+
+// seedUser creates a regular user via the admin API and returns a login cookie.
+func seedUser(t *testing.T, f *authTestFixture, adminCookie, email, password string) string {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{
+ "email": email, "initial_password": password, "role": "user",
+ })
+ req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), adminCookie)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ f.Router.ServeHTTP(rr, req)
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("seed user %s status = %d (body=%s)", email, rr.Code, rr.Body.String())
+ }
+ return sessionCookie(loginRR(t, f.Router, email, password))
+}
+
+func doReq(t *testing.T, f *authTestFixture, cookie, method, path string, body any) (*httptest.ResponseRecorder, []byte) {
+ t.Helper()
+ var rdr *bytes.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ rdr = bytes.NewReader(b)
+ } else {
+ rdr = bytes.NewReader(nil)
+ }
+ req := withCookie(httptest.NewRequest(method, path, rdr), cookie)
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ f.Router.ServeHTTP(rr, req)
+ return rr, rr.Body.Bytes()
+}
+
+func TestGroupCRUD_AdminOnly(t *testing.T) {
+ f := newAuthFixture(t)
+ adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
+ userCookie := seedUser(t, f, adminCookie, "bob@example.com", "bobpass1234")
+
+ // User is forbidden from every mutating group endpoint.
+ for _, c := range []struct{ method, path string }{
+ {http.MethodPost, "/api/v1/groups"},
+ {http.MethodGet, "/api/v1/groups/x"},
+ {http.MethodPatch, "/api/v1/groups/x"},
+ {http.MethodDelete, "/api/v1/groups/x"},
+ {http.MethodGet, "/api/v1/groups/x/members"},
+ {http.MethodPost, "/api/v1/groups/x/members"},
+ {http.MethodDelete, "/api/v1/groups/x/members/y"},
+ } {
+ rr, _ := doReq(t, f, userCookie, c.method, c.path, map[string]any{"name": "n", "user_id": "u"})
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("user %s %s = %d, want 403", c.method, c.path, rr.Code)
+ }
+ }
+
+ // Admin lifecycle.
+ rr, body := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "Product"})
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("create group = %d (%s)", rr.Code, body)
+ }
+ var g struct{ ID string `json:"id"` }
+ _ = json.Unmarshal(body, &g)
+ if g.ID == "" {
+ t.Fatal("group id empty")
+ }
+
+ if rr, _ := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/groups/"+g.ID, nil); rr.Code != http.StatusOK {
+ t.Errorf("get group = %d", rr.Code)
+ }
+ if rr, _ := doReq(t, f, adminCookie, http.MethodPatch, "/api/v1/groups/"+g.ID, map[string]string{"description": "PM agents"}); rr.Code != http.StatusOK {
+ t.Errorf("patch group = %d", rr.Code)
+ }
+
+ // Add bob (need his id — look him up via admin users list).
+ rr, body = doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil)
+ var ul struct {
+ Users []struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ } `json:"users"`
+ }
+ _ = json.Unmarshal(body, &ul)
+ var bobID string
+ for _, u := range ul.Users {
+ if u.Email == "bob@example.com" {
+ bobID = u.ID
+ }
+ }
+ if bobID == "" {
+ t.Fatal("bob id not found")
+ }
+ if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": bobID}); rr.Code != http.StatusNoContent {
+ t.Errorf("add member = %d", rr.Code)
+ }
+ // Adding a non-existent user → 422.
+ if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": "nope"}); rr.Code != http.StatusUnprocessableEntity {
+ t.Errorf("add bad member = %d, want 422", rr.Code)
+ }
+
+ rr, body = doReq(t, f, adminCookie, http.MethodGet, "/api/v1/groups/"+g.ID+"/members", nil)
+ var ml struct{ Total int `json:"total"` }
+ _ = json.Unmarshal(body, &ml)
+ if rr.Code != http.StatusOK || ml.Total != 1 {
+ t.Errorf("list members = %d total=%d", rr.Code, ml.Total)
+ }
+
+ // bob now sees the group via /auth/me and GET /groups (member-scoped).
+ rr, body = doReq(t, f, userCookie, http.MethodGet, "/api/v1/auth/me", nil)
+ var me struct {
+ Groups []struct{ ID string `json:"id"` } `json:"groups"`
+ }
+ _ = json.Unmarshal(body, &me)
+ if rr.Code != http.StatusOK || len(me.Groups) != 1 || me.Groups[0].ID != g.ID {
+ t.Errorf("/auth/me groups = %+v (status %d)", me.Groups, rr.Code)
+ }
+
+ // Remove + delete.
+ if rr, _ := doReq(t, f, adminCookie, http.MethodDelete, "/api/v1/groups/"+g.ID+"/members/"+bobID, nil); rr.Code != http.StatusNoContent {
+ t.Errorf("remove member = %d", rr.Code)
+ }
+ if rr, _ := doReq(t, f, adminCookie, http.MethodDelete, "/api/v1/groups/"+g.ID, nil); rr.Code != http.StatusNoContent {
+ t.Errorf("delete group = %d", rr.Code)
+ }
+}
+
+func TestProjectShareAndOwnerReassign(t *testing.T) {
+ f := newAuthFixture(t)
+ adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
+ aliceCookie := seedUser(t, f, adminCookie, "alice@example.com", "alicepass1")
+
+ // Find alice's id.
+ _, body := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil)
+ var ul struct {
+ Users []struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ } `json:"users"`
+ }
+ _ = json.Unmarshal(body, &ul)
+ var aliceID string
+ for _, u := range ul.Users {
+ if u.Email == "alice@example.com" {
+ aliceID = u.ID
+ }
+ }
+
+ // Insert an external project (project row + git_repos peer) directly.
+ extPath := "github.com/x/y@main"
+ if _, err := projects.Create(t.Context(), f.Deps.DB, projects.CreateRequest{HostPath: extPath}); err != nil {
+ t.Fatalf("create external project: %v", err)
+ }
+ if _, err := f.Deps.DB.ExecContext(t.Context(),
+ `INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at)
+ VALUES (?, 'https://github.com/x/y', 'main', 's', '2024-01-01', '2024-01-01')`, extPath); err != nil {
+ t.Fatalf("insert git_repos: %v", err)
+ }
+ extHash := projects.HashPath(extPath)
+
+ // Group with alice as member.
+ _, gbody := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "Product"})
+ var g struct{ ID string `json:"id"` }
+ _ = json.Unmarshal(gbody, &g)
+ doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": aliceID})
+
+ // Before share: alice does not see the external project.
+ if !aliceSeesProject(t, f, aliceCookie, extHash) {
+ // expected: not visible
+ } else {
+ t.Error("alice should NOT see the external project before share")
+ }
+
+ // Share to the group (admin). Then alice sees it.
+ if rr, b := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/projects/"+extHash+"/shares", map[string]string{"group_id": g.ID}); rr.Code != http.StatusNoContent {
+ t.Fatalf("share project = %d (%s)", rr.Code, b)
+ }
+ if !aliceSeesProject(t, f, aliceCookie, extHash) {
+ t.Error("alice should see the external project after share")
+ }
+
+ // Reassign owner of an external project → 422.
+ if rr, _ := doReq(t, f, adminCookie, http.MethodPut, "/api/v1/projects/"+extHash+"/owner", map[string]string{"owner_user_id": aliceID}); rr.Code != http.StatusUnprocessableEntity {
+ t.Errorf("reassign external owner = %d, want 422", rr.Code)
+ }
+
+ // Local project owned by admin → reassign to alice → alice now sees it.
+ localPath := "/tmp/admin-local"
+ if _, err := projects.Create(t.Context(), f.Deps.DB, projects.CreateRequest{HostPath: localPath, OwnerUserID: f.UserID}); err != nil {
+ t.Fatalf("create local project: %v", err)
+ }
+ localHash := projects.HashPath(localPath)
+ if aliceSeesProject(t, f, aliceCookie, localHash) {
+ t.Error("alice should not see admin's local project before reassign")
+ }
+ if rr, b := doReq(t, f, adminCookie, http.MethodPut, "/api/v1/projects/"+localHash+"/owner", map[string]string{"owner_user_id": aliceID}); rr.Code != http.StatusOK {
+ t.Fatalf("reassign local owner = %d (%s)", rr.Code, b)
+ }
+ if !aliceSeesProject(t, f, aliceCookie, localHash) {
+ t.Error("alice should see the local project after reassign")
+ }
+}
+
+func aliceSeesProject(t *testing.T, f *authTestFixture, cookie, hash string) bool {
+ t.Helper()
+ _, body := doReq(t, f, cookie, http.MethodGet, "/api/v1/projects", nil)
+ var pl struct {
+ Projects []struct{ PathHash string `json:"path_hash"` } `json:"projects"`
+ }
+ _ = json.Unmarshal(body, &pl)
+ for _, p := range pl.Projects {
+ if p.PathHash == hash {
+ return true
+ }
+ }
+ return false
+}
+
+func TestWorkspaceShare_OwnerInGroupOnly(t *testing.T) {
+ f := newAuthFixture(t)
+ adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password"))
+ aliceCookie := seedUser(t, f, adminCookie, "alice@example.com", "alicepass1")
+
+ _, body := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil)
+ var ul struct {
+ Users []struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ } `json:"users"`
+ }
+ _ = json.Unmarshal(body, &ul)
+ var aliceID string
+ for _, u := range ul.Users {
+ if u.Email == "alice@example.com" {
+ aliceID = u.ID
+ }
+ }
+
+ // Two groups: gIn (alice is a member), gOut (she is not).
+ _, b1 := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "in"})
+ _, b2 := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "out"})
+ var gIn, gOut struct{ ID string `json:"id"` }
+ _ = json.Unmarshal(b1, &gIn)
+ _ = json.Unmarshal(b2, &gOut)
+ doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+gIn.ID+"/members", map[string]string{"user_id": aliceID})
+
+ // Alice creates a workspace (she owns it).
+ rr, wbody := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces", map[string]string{"name": "alice-ws"})
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("create workspace = %d (%s)", rr.Code, wbody)
+ }
+ var ws struct{ ID string `json:"id"` }
+ _ = json.Unmarshal(wbody, &ws)
+
+ // Share to a group she belongs to → 204.
+ if rr, b := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gIn.ID}); rr.Code != http.StatusNoContent {
+ t.Errorf("share to own group = %d (%s)", rr.Code, b)
+ }
+ // Share to a group she does NOT belong to → 403.
+ if rr, _ := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gOut.ID}); rr.Code != http.StatusForbidden {
+ t.Errorf("share to foreign group = %d, want 403", rr.Code)
+ }
+ // Admin may share to any group.
+ if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gOut.ID}); rr.Code != http.StatusNoContent {
+ t.Errorf("admin share to any group = %d", rr.Code)
+ }
+}
diff --git a/server/internal/httpapi/middleware_test.go b/server/internal/httpapi/middleware_test.go
index 6b18866..c7e1f40 100644
--- a/server/internal/httpapi/middleware_test.go
+++ b/server/internal/httpapi/middleware_test.go
@@ -9,8 +9,10 @@ import (
"github.com/dvcdsys/code-index/server/internal/apikeys"
apidb "github.com/dvcdsys/code-index/server/internal/db"
+ "github.com/dvcdsys/code-index/server/internal/groups"
"github.com/dvcdsys/code-index/server/internal/sessions"
"github.com/dvcdsys/code-index/server/internal/users"
+ "github.com/dvcdsys/code-index/server/internal/workspaces"
)
// authTestFixture bundles a router plus the seeded admin user + a fresh
@@ -57,6 +59,8 @@ func newAuthFixture(t *testing.T) *authTestFixture {
Users: usrSvc,
Sessions: sessSvc,
APIKeys: akSvc,
+ Groups: groups.New(database),
+ Workspaces: workspaces.New(database),
}
return &authTestFixture{Router: NewRouter(deps), Deps: deps, UserID: u.ID, FullKey: full}
}
diff --git a/server/internal/httpapi/openapi/openapi.gen.go b/server/internal/httpapi/openapi/openapi.gen.go
index d894964..105a051 100644
--- a/server/internal/httpapi/openapi/openapi.gen.go
+++ b/server/internal/httpapi/openapi/openapi.gen.go
@@ -49,8 +49,8 @@ func (e AddGitRepoRequestWebhookMode) Valid() bool {
// Defines values for CreateUserRequestRole.
const (
- CreateUserRequestRoleAdmin CreateUserRequestRole = "admin"
- CreateUserRequestRoleViewer CreateUserRequestRole = "viewer"
+ CreateUserRequestRoleAdmin CreateUserRequestRole = "admin"
+ CreateUserRequestRoleUser CreateUserRequestRole = "user"
)
// Valid indicates whether the value is a known member of the CreateUserRequestRole enum.
@@ -58,7 +58,7 @@ func (e CreateUserRequestRole) Valid() bool {
switch e {
case CreateUserRequestRoleAdmin:
return true
- case CreateUserRequestRoleViewer:
+ case CreateUserRequestRoleUser:
return true
default:
return false
@@ -104,6 +104,24 @@ func (e GithubAccountType) Valid() bool {
}
}
+// Defines values for GroupMemberRole.
+const (
+ GroupMemberRoleAdmin GroupMemberRole = "admin"
+ GroupMemberRoleUser GroupMemberRole = "user"
+)
+
+// Valid indicates whether the value is a known member of the GroupMemberRole enum.
+func (e GroupMemberRole) Valid() bool {
+ switch e {
+ case GroupMemberRoleAdmin:
+ return true
+ case GroupMemberRoleUser:
+ return true
+ default:
+ return false
+ }
+}
+
// Defines values for HealthResponseStatus.
const (
HealthResponseStatusOk HealthResponseStatus = "ok"
@@ -556,8 +574,8 @@ func (e UpdateGitRepoSyncRequestSyncMethod) Valid() bool {
// Defines values for UpdateUserRequestRole.
const (
- UpdateUserRequestRoleAdmin UpdateUserRequestRole = "admin"
- UpdateUserRequestRoleViewer UpdateUserRequestRole = "viewer"
+ UpdateUserRequestRoleAdmin UpdateUserRequestRole = "admin"
+ UpdateUserRequestRoleUser UpdateUserRequestRole = "user"
)
// Valid indicates whether the value is a known member of the UpdateUserRequestRole enum.
@@ -565,7 +583,7 @@ func (e UpdateUserRequestRole) Valid() bool {
switch e {
case UpdateUserRequestRoleAdmin:
return true
- case UpdateUserRequestRoleViewer:
+ case UpdateUserRequestRoleUser:
return true
default:
return false
@@ -574,8 +592,8 @@ func (e UpdateUserRequestRole) Valid() bool {
// Defines values for UserRole.
const (
- UserRoleAdmin UserRole = "admin"
- UserRoleViewer UserRole = "viewer"
+ UserRoleAdmin UserRole = "admin"
+ UserRoleUser UserRole = "user"
)
// Valid indicates whether the value is a known member of the UserRole enum.
@@ -583,7 +601,7 @@ func (e UserRole) Valid() bool {
switch e {
case UserRoleAdmin:
return true
- case UserRoleViewer:
+ case UserRoleUser:
return true
default:
return false
@@ -592,8 +610,8 @@ func (e UserRole) Valid() bool {
// Defines values for UserWithStatsRole.
const (
- UserWithStatsRoleAdmin UserWithStatsRole = "admin"
- UserWithStatsRoleViewer UserWithStatsRole = "viewer"
+ UserWithStatsRoleAdmin UserWithStatsRole = "admin"
+ UserWithStatsRoleUser UserWithStatsRole = "user"
)
// Valid indicates whether the value is a known member of the UserWithStatsRole enum.
@@ -601,7 +619,7 @@ func (e UserWithStatsRole) Valid() bool {
switch e {
case UserWithStatsRoleAdmin:
return true
- case UserWithStatsRoleViewer:
+ case UserWithStatsRoleUser:
return true
default:
return false
@@ -855,6 +873,11 @@ type AddGitRepoRequest struct {
// AddGitRepoRequestWebhookMode defines model for AddGitRepoRequest.WebhookMode.
type AddGitRepoRequestWebhookMode string
+// AddGroupMemberRequest defines model for AddGroupMemberRequest.
+type AddGroupMemberRequest struct {
+ UserId string `json:"user_id"`
+}
+
// ApiKey defines model for ApiKey.
type ApiKey struct {
CreatedAt time.Time `json:"created_at"`
@@ -928,9 +951,25 @@ type CreateGithubTokenRequest struct {
Token string `json:"token"`
}
+// CreateGroupRequest defines model for CreateGroupRequest.
+type CreateGroupRequest struct {
+ Description *string `json:"description,omitempty"`
+ Name string `json:"name"`
+}
+
// CreateProjectRequest defines model for CreateProjectRequest.
type CreateProjectRequest struct {
+ // HostPath The real filesystem path being registered (becomes display_path).
HostPath string `json:"host_path"`
+
+ // MachineId Per-machine UUID supplied by the CLI (from ~/.cix/machine_id). When
+ // present the project is LOCAL and its identity key is namespaced
+ // local:{machine_id}:{host_path} so the same path on different
+ // machines/users does not collide. Omit for external repos.
+ MachineId *string `json:"machine_id,omitempty"`
+
+ // MachineLabel os.Hostname() of the indexing machine — display only.
+ MachineLabel *string `json:"machine_label,omitempty"`
}
// CreateUserRequest defines model for CreateUserRequest.
@@ -1170,6 +1209,45 @@ type GithubTokenListResponse struct {
Total int `json:"total"`
}
+// Group defines model for Group.
+type Group struct {
+ CreatedAt time.Time `json:"created_at"`
+
+ // Description Free-form description. Empty string when absent.
+ Description string `json:"description"`
+ Id string `json:"id"`
+ Name string `json:"name"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// GroupIdListResponse defines model for GroupIdListResponse.
+type GroupIdListResponse struct {
+ GroupIds []string `json:"group_ids"`
+}
+
+// GroupListResponse defines model for GroupListResponse.
+type GroupListResponse struct {
+ Groups []Group `json:"groups"`
+ Total int `json:"total"`
+}
+
+// GroupMember defines model for GroupMember.
+type GroupMember struct {
+ AddedAt time.Time `json:"added_at"`
+ Email openapi_types.Email `json:"email"`
+ Role GroupMemberRole `json:"role"`
+ UserId string `json:"user_id"`
+}
+
+// GroupMemberRole defines model for GroupMember.Role.
+type GroupMemberRole string
+
+// GroupMemberListResponse defines model for GroupMemberListResponse.
+type GroupMemberListResponse struct {
+ Members []GroupMember `json:"members"`
+ Total int `json:"total"`
+}
+
// HealthResponse defines model for HealthResponse.
type HealthResponse struct {
// Reason Set only when `status` is `unhealthy`.
@@ -1343,7 +1421,13 @@ type MeResponse struct {
// AuthMethod Tells the dashboard whether to surface "logout" (session) or
// hide it (api_key access — there's nothing to log out of).
AuthMethod MeResponseAuthMethod `json:"auth_method"`
- User User `json:"user"`
+
+ // Groups View-groups the caller belongs to. Lets the dashboard scope the
+ // "share to group" picker without a second round-trip. Empty for a
+ // user in no groups; admins still only list their own memberships
+ // here (the full group list comes from GET /groups).
+ Groups []Group `json:"groups"`
+ User User `json:"user"`
}
// MeResponseAuthMethod Tells the dashboard whether to surface "logout" (session) or
@@ -1386,6 +1470,11 @@ type Project struct {
ContainerPath string `json:"container_path"`
CreatedAt time.Time `json:"created_at"`
+ // DisplayPath Human-readable path. The real filesystem path for local projects,
+ // the github path for external ones. host_path is the identity key
+ // (namespaced per machine for locals) — clients should show this.
+ DisplayPath *string `json:"display_path,omitempty"`
+
// HostPath Absolute filesystem path on the operator's machine.
HostPath string `json:"host_path"`
@@ -1396,6 +1485,18 @@ type Project struct {
Languages []string `json:"languages"`
LastIndexedAt *time.Time `json:"last_indexed_at"`
+ // MachineId Per-machine UUID a local project was indexed on. NULL for external/legacy.
+ MachineId *string `json:"machine_id,omitempty"`
+
+ // MachineLabel os.Hostname() of the indexing machine — display only.
+ MachineLabel *string `json:"machine_label,omitempty"`
+
+ // OwnerUserId User who owns this personal (locally indexed) project. NULL means
+ // ownerless — the canonical state for EXTERNAL projects (those with a
+ // git_repos peer), which are admin-administered and reachable only via
+ // a view-group share.
+ OwnerUserId *string `json:"owner_user_id,omitempty"`
+
// PathHash First 16 hex chars of SHA1(host_path) — stable URL identifier.
PathHash string `json:"path_hash"`
Settings ProjectSettings `json:"settings"`
@@ -1458,6 +1559,12 @@ type ProjectWorkspaceList struct {
Workspaces []ProjectWorkspaceEntry `json:"workspaces"`
}
+// ReassignOwnerRequest defines model for ReassignOwnerRequest.
+type ReassignOwnerRequest struct {
+ // OwnerUserId New owner; must be an existing, enabled user.
+ OwnerUserId string `json:"owner_user_id"`
+}
+
// ReferenceItem defines model for ReferenceItem.
type ReferenceItem struct {
ChunkType ReferenceItemChunkType `json:"chunk_type"`
@@ -1619,6 +1726,11 @@ type SessionListResponse struct {
Total int `json:"total"`
}
+// ShareToGroupRequest defines model for ShareToGroupRequest.
+type ShareToGroupRequest struct {
+ GroupId string `json:"group_id"`
+}
+
// SidecarStatus defines model for SidecarStatus.
type SidecarStatus struct {
// InFlight Embedding queue depth at the moment of sampling.
@@ -1837,6 +1949,12 @@ type UpdateGitRepoSyncResult struct {
Note *string `json:"note,omitempty"`
}
+// UpdateGroupRequest defines model for UpdateGroupRequest.
+type UpdateGroupRequest struct {
+ Description *string `json:"description,omitempty"`
+ Name *string `json:"name,omitempty"`
+}
+
// UpdateProjectRequest defines model for UpdateProjectRequest.
type UpdateProjectRequest struct {
Settings *ProjectSettings `json:"settings,omitempty"`
@@ -1849,12 +1967,12 @@ type UpdateUserRequest struct {
Disabled *bool `json:"disabled,omitempty"`
// Role New role for the user. Refused for the last enabled admin
- // when set to `viewer`.
+ // when set to `user`.
Role *UpdateUserRequestRole `json:"role,omitempty"`
}
// UpdateUserRequestRole New role for the user. Refused for the last enabled admin
-// when set to `viewer`.
+// when set to `user`.
type UpdateUserRequestRole string
// UpdateWorkspaceRequest Both fields are optional — omitting a field leaves the existing
@@ -2003,8 +2121,13 @@ type Workspace struct {
Id string `json:"id"`
// Name Unique workspace name.
- Name string `json:"name"`
- UpdatedAt time.Time `json:"updated_at"`
+ Name string `json:"name"`
+
+ // OwnerUserId User who created the workspace. NULL only when orphaned by a user
+ // deletion. Visible to the owner, members of any view-group it is
+ // shared to, and admins.
+ OwnerUserId *string `json:"owner_user_id,omitempty"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// WorkspaceListResponse defines model for WorkspaceListResponse.
@@ -2296,12 +2419,27 @@ type AddGitRepoJSONRequestBody = AddGitRepoRequest
// CreateGithubTokenJSONRequestBody defines body for CreateGithubToken for application/json ContentType.
type CreateGithubTokenJSONRequestBody = CreateGithubTokenRequest
+// CreateGroupJSONRequestBody defines body for CreateGroup for application/json ContentType.
+type CreateGroupJSONRequestBody = CreateGroupRequest
+
+// UpdateGroupJSONRequestBody defines body for UpdateGroup for application/json ContentType.
+type UpdateGroupJSONRequestBody = UpdateGroupRequest
+
+// AddGroupMemberJSONRequestBody defines body for AddGroupMember for application/json ContentType.
+type AddGroupMemberJSONRequestBody = AddGroupMemberRequest
+
// CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType.
type CreateProjectJSONRequestBody = CreateProjectRequest
// UpdateProjectGitRepoSyncJSONRequestBody defines body for UpdateProjectGitRepoSync for application/json ContentType.
type UpdateProjectGitRepoSyncJSONRequestBody = UpdateGitRepoSyncRequest
+// ReassignProjectOwnerJSONRequestBody defines body for ReassignProjectOwner for application/json ContentType.
+type ReassignProjectOwnerJSONRequestBody = ReassignOwnerRequest
+
+// ShareProjectToGroupJSONRequestBody defines body for ShareProjectToGroup for application/json ContentType.
+type ShareProjectToGroupJSONRequestBody = ShareToGroupRequest
+
// UpdateProjectJSONRequestBody defines body for UpdateProject for application/json ContentType.
type UpdateProjectJSONRequestBody = UpdateProjectRequest
@@ -2344,6 +2482,9 @@ type UpdateWorkspaceJSONRequestBody = UpdateWorkspaceRequest
// LinkProjectToWorkspaceJSONRequestBody defines body for LinkProjectToWorkspace for application/json ContentType.
type LinkProjectToWorkspaceJSONRequestBody = LinkProjectRequest
+// ShareWorkspaceToGroupJSONRequestBody defines body for ShareWorkspaceToGroup for application/json ContentType.
+type ShareWorkspaceToGroupJSONRequestBody = ShareToGroupRequest
+
// ServerInterface represents all server handlers.
type ServerInterface interface {
// List GGUF model files cached on disk (admin only)
@@ -2427,6 +2568,30 @@ type ServerInterface interface {
// Re-register all webhook_mode=auto repos against the current public URL
// (POST /api/v1/github/webhooks/reconcile)
ReconcileWebhooks(w http.ResponseWriter, r *http.Request)
+ // List view-groups
+ // (GET /api/v1/groups)
+ ListGroups(w http.ResponseWriter, r *http.Request)
+ // Create a view-group (admin only)
+ // (POST /api/v1/groups)
+ CreateGroup(w http.ResponseWriter, r *http.Request)
+ // Delete a view-group (admin only)
+ // (DELETE /api/v1/groups/{id})
+ DeleteGroup(w http.ResponseWriter, r *http.Request, id string)
+ // Get a view-group (admin only)
+ // (GET /api/v1/groups/{id})
+ GetGroup(w http.ResponseWriter, r *http.Request, id string)
+ // Update a view-group (admin only)
+ // (PATCH /api/v1/groups/{id})
+ UpdateGroup(w http.ResponseWriter, r *http.Request, id string)
+ // List members of a view-group (admin only)
+ // (GET /api/v1/groups/{id}/members)
+ ListGroupMembers(w http.ResponseWriter, r *http.Request, id string)
+ // Add a user to a view-group (admin only)
+ // (POST /api/v1/groups/{id}/members)
+ AddGroupMember(w http.ResponseWriter, r *http.Request, id string)
+ // Remove a user from a view-group (admin only)
+ // (DELETE /api/v1/groups/{id}/members/{userId})
+ RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string)
// List background jobs (status / type filter)
// (GET /api/v1/jobs)
ListJobs(w http.ResponseWriter, r *http.Request, params ListJobsParams)
@@ -2445,9 +2610,21 @@ type ServerInterface interface {
// Reconfigure how an external project is kept in sync
// (PATCH /api/v1/projects/{hash}/git-repo)
UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request, hash string)
+ // Reassign the owner of a local project (admin only)
+ // (PUT /api/v1/projects/{hash}/owner)
+ ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string)
// Manually re-trigger the clone + index pipeline
// (POST /api/v1/projects/{hash}/reindex)
ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params ReindexProjectParams)
+ // List the view-groups an external project is shared to (admin only)
+ // (GET /api/v1/projects/{hash}/shares)
+ ListProjectShares(w http.ResponseWriter, r *http.Request, hash string)
+ // Share an external project to a view-group (admin only)
+ // (POST /api/v1/projects/{hash}/shares)
+ ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string)
+ // Revoke a project↔group share (admin only)
+ // (DELETE /api/v1/projects/{hash}/shares/{groupId})
+ UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string)
// Webhook URL + secret for manual GitHub setup
// (GET /api/v1/projects/{hash}/webhook-info)
GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string)
@@ -2556,6 +2733,15 @@ type ServerInterface interface {
// Hybrid BM25+dense search across all repos in a workspace
// (GET /api/v1/workspaces/{id}/search)
WorkspaceSearch(w http.ResponseWriter, r *http.Request, id string, params WorkspaceSearchParams)
+ // List the view-groups a workspace is shared to
+ // (GET /api/v1/workspaces/{id}/shares)
+ ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string)
+ // Share a workspace to a view-group
+ // (POST /api/v1/workspaces/{id}/shares)
+ ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string)
+ // Revoke a workspace↔group share (owner or admin)
+ // (DELETE /api/v1/workspaces/{id}/shares/{groupId})
+ UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string)
// Liveness probe (public)
// (GET /health)
GetHealth(w http.ResponseWriter, r *http.Request)
@@ -2727,6 +2913,54 @@ func (_ Unimplemented) ReconcileWebhooks(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNotImplemented)
}
+// List view-groups
+// (GET /api/v1/groups)
+func (_ Unimplemented) ListGroups(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Create a view-group (admin only)
+// (POST /api/v1/groups)
+func (_ Unimplemented) CreateGroup(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Delete a view-group (admin only)
+// (DELETE /api/v1/groups/{id})
+func (_ Unimplemented) DeleteGroup(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Get a view-group (admin only)
+// (GET /api/v1/groups/{id})
+func (_ Unimplemented) GetGroup(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Update a view-group (admin only)
+// (PATCH /api/v1/groups/{id})
+func (_ Unimplemented) UpdateGroup(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// List members of a view-group (admin only)
+// (GET /api/v1/groups/{id}/members)
+func (_ Unimplemented) ListGroupMembers(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Add a user to a view-group (admin only)
+// (POST /api/v1/groups/{id}/members)
+func (_ Unimplemented) AddGroupMember(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Remove a user from a view-group (admin only)
+// (DELETE /api/v1/groups/{id}/members/{userId})
+func (_ Unimplemented) RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
// List background jobs (status / type filter)
// (GET /api/v1/jobs)
func (_ Unimplemented) ListJobs(w http.ResponseWriter, r *http.Request, params ListJobsParams) {
@@ -2763,12 +2997,36 @@ func (_ Unimplemented) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.R
w.WriteHeader(http.StatusNotImplemented)
}
+// Reassign the owner of a local project (admin only)
+// (PUT /api/v1/projects/{hash}/owner)
+func (_ Unimplemented) ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
// Manually re-trigger the clone + index pipeline
// (POST /api/v1/projects/{hash}/reindex)
func (_ Unimplemented) ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params ReindexProjectParams) {
w.WriteHeader(http.StatusNotImplemented)
}
+// List the view-groups an external project is shared to (admin only)
+// (GET /api/v1/projects/{hash}/shares)
+func (_ Unimplemented) ListProjectShares(w http.ResponseWriter, r *http.Request, hash string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Share an external project to a view-group (admin only)
+// (POST /api/v1/projects/{hash}/shares)
+func (_ Unimplemented) ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Revoke a project↔group share (admin only)
+// (DELETE /api/v1/projects/{hash}/shares/{groupId})
+func (_ Unimplemented) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
// Webhook URL + secret for manual GitHub setup
// (GET /api/v1/projects/{hash}/webhook-info)
func (_ Unimplemented) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string) {
@@ -2985,6 +3243,24 @@ func (_ Unimplemented) WorkspaceSearch(w http.ResponseWriter, r *http.Request, i
w.WriteHeader(http.StatusNotImplemented)
}
+// List the view-groups a workspace is shared to
+// (GET /api/v1/workspaces/{id}/shares)
+func (_ Unimplemented) ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Share a workspace to a view-group
+// (POST /api/v1/workspaces/{id}/shares)
+func (_ Unimplemented) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
+// Revoke a workspace↔group share (owner or admin)
+// (DELETE /api/v1/workspaces/{id}/shares/{groupId})
+func (_ Unimplemented) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string) {
+ w.WriteHeader(http.StatusNotImplemented)
+}
+
// Liveness probe (public)
// (GET /health)
func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) {
@@ -3673,11 +3949,8 @@ func (siw *ServerInterfaceWrapper) ReconcileWebhooks(w http.ResponseWriter, r *h
handler.ServeHTTP(w, r)
}
-// ListJobs operation middleware
-func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Request) {
-
- var err error
- _ = err
+// ListGroups operation middleware
+func (siw *ServerInterfaceWrapper) ListGroups(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -3685,50 +3958,8 @@ func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Reque
r = r.WithContext(ctx)
- // Parameter object where we will unmarshal all parameters from the context
- var params ListJobsParams
-
- // ------------- Optional query parameter "status" -------------
-
- err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
- if err != nil {
- var requiredError *runtime.RequiredParameterError
- if errors.As(err, &requiredError) {
- siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"})
- } else {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err})
- }
- return
- }
-
- // ------------- Optional query parameter "type" -------------
-
- err = runtime.BindQueryParameterWithOptions("form", true, false, "type", r.URL.Query(), ¶ms.Type, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
- if err != nil {
- var requiredError *runtime.RequiredParameterError
- if errors.As(err, &requiredError) {
- siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "type"})
- } else {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err})
- }
- return
- }
-
- // ------------- Optional query parameter "limit" -------------
-
- err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
- if err != nil {
- var requiredError *runtime.RequiredParameterError
- if errors.As(err, &requiredError) {
- siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"})
- } else {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
- }
- return
- }
-
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.ListJobs(w, r, params)
+ siw.Handler.ListGroups(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3738,8 +3969,8 @@ func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Reque
handler.ServeHTTP(w, r)
}
-// ListProjects operation middleware
-func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) {
+// CreateGroup operation middleware
+func (siw *ServerInterfaceWrapper) CreateGroup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -3748,7 +3979,7 @@ func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.R
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.ListProjects(w, r)
+ siw.Handler.CreateGroup(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3758,8 +3989,20 @@ func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.R
handler.ServeHTTP(w, r)
}
-// CreateProject operation middleware
-func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) {
+// DeleteGroup operation middleware
+func (siw *ServerInterfaceWrapper) DeleteGroup(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "id" -------------
+ var id string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
+ return
+ }
ctx := r.Context()
@@ -3768,7 +4011,7 @@ func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.CreateProject(w, r)
+ siw.Handler.DeleteGroup(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3778,18 +4021,18 @@ func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r)
}
-// ForceStopIndex operation middleware
-func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http.Request) {
+// GetGroup operation middleware
+func (siw *ServerInterfaceWrapper) GetGroup(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "hash" -------------
- var hash string
+ // ------------- Path parameter "id" -------------
+ var id string
- err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
@@ -3800,7 +4043,7 @@ func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.ForceStopIndex(w, r, hash)
+ siw.Handler.GetGroup(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3810,18 +4053,18 @@ func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r)
}
-// GetProjectGitRepo operation middleware
-func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *http.Request) {
+// UpdateGroup operation middleware
+func (siw *ServerInterfaceWrapper) UpdateGroup(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "hash" -------------
- var hash string
+ // ------------- Path parameter "id" -------------
+ var id string
- err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
@@ -3832,7 +4075,7 @@ func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *h
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.GetProjectGitRepo(w, r, hash)
+ siw.Handler.UpdateGroup(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3842,18 +4085,18 @@ func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *h
handler.ServeHTTP(w, r)
}
-// UpdateProjectGitRepoSync operation middleware
-func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request) {
+// ListGroupMembers operation middleware
+func (siw *ServerInterfaceWrapper) ListGroupMembers(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "hash" -------------
- var hash string
+ // ------------- Path parameter "id" -------------
+ var id string
- err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
@@ -3864,7 +4107,7 @@ func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWrite
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.UpdateProjectGitRepoSync(w, r, hash)
+ siw.Handler.ListGroupMembers(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3874,18 +4117,18 @@ func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWrite
handler.ServeHTTP(w, r)
}
-// ReindexProject operation middleware
-func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http.Request) {
+// AddGroupMember operation middleware
+func (siw *ServerInterfaceWrapper) AddGroupMember(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "hash" -------------
- var hash string
+ // ------------- Path parameter "id" -------------
+ var id string
- err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
@@ -3895,24 +4138,8 @@ func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http
r = r.WithContext(ctx)
- // Parameter object where we will unmarshal all parameters from the context
- var params ReindexProjectParams
-
- // ------------- Optional query parameter "full" -------------
-
- err = runtime.BindQueryParameterWithOptions("form", true, false, "full", r.URL.Query(), ¶ms.Full, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""})
- if err != nil {
- var requiredError *runtime.RequiredParameterError
- if errors.As(err, &requiredError) {
- siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "full"})
- } else {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "full", Err: err})
- }
- return
- }
-
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.ReindexProject(w, r, hash, params)
+ siw.Handler.AddGroupMember(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3922,18 +4149,27 @@ func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http
handler.ServeHTTP(w, r)
}
-// GetProjectWebhookInfo operation middleware
-func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request) {
+// RemoveGroupMember operation middleware
+func (siw *ServerInterfaceWrapper) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "hash" -------------
- var hash string
+ // ------------- Path parameter "id" -------------
+ var id string
- err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
+ return
+ }
+
+ // ------------- Path parameter "userId" -------------
+ var userId string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "userId", chi.URLParam(r, "userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err})
return
}
@@ -3944,7 +4180,7 @@ func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter,
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.GetProjectWebhookInfo(w, r, hash)
+ siw.Handler.RemoveGroupMember(w, r, id, userId)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -3954,29 +4190,447 @@ func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter,
handler.ServeHTTP(w, r)
}
-// DeleteProject operation middleware
-func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) {
+// ListJobs operation middleware
+func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
- // ------------- Path parameter "path" -------------
- var path ProjectHash
-
- err = runtime.BindStyledParameterWithOptions("simple", "path", chi.URLParam(r, "path"), &path, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
- if err != nil {
- siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
- return
- }
-
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
- handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- siw.Handler.DeleteProject(w, r, path)
+ // Parameter object where we will unmarshal all parameters from the context
+ var params ListJobsParams
+
+ // ------------- Optional query parameter "status" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err})
+ }
+ return
+ }
+
+ // ------------- Optional query parameter "type" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "type", r.URL.Query(), ¶ms.Type, runtime.BindQueryParameterOptions{Type: "string", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "type"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err})
+ }
+ return
+ }
+
+ // ------------- Optional query parameter "limit" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err})
+ }
+ return
+ }
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ListJobs(w, r, params)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ListProjects operation middleware
+func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) {
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ListProjects(w, r)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// CreateProject operation middleware
+func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) {
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.CreateProject(w, r)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ForceStopIndex operation middleware
+func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ForceStopIndex(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// GetProjectGitRepo operation middleware
+func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.GetProjectGitRepo(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// UpdateProjectGitRepoSync operation middleware
+func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.UpdateProjectGitRepoSync(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ReassignProjectOwner operation middleware
+func (siw *ServerInterfaceWrapper) ReassignProjectOwner(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ReassignProjectOwner(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ReindexProject operation middleware
+func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params ReindexProjectParams
+
+ // ------------- Optional query parameter "full" -------------
+
+ err = runtime.BindQueryParameterWithOptions("form", true, false, "full", r.URL.Query(), ¶ms.Full, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""})
+ if err != nil {
+ var requiredError *runtime.RequiredParameterError
+ if errors.As(err, &requiredError) {
+ siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "full"})
+ } else {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "full", Err: err})
+ }
+ return
+ }
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ReindexProject(w, r, hash, params)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ListProjectShares operation middleware
+func (siw *ServerInterfaceWrapper) ListProjectShares(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ListProjectShares(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ShareProjectToGroup operation middleware
+func (siw *ServerInterfaceWrapper) ShareProjectToGroup(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ShareProjectToGroup(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// UnshareProjectFromGroup operation middleware
+func (siw *ServerInterfaceWrapper) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ // ------------- Path parameter "groupId" -------------
+ var groupId string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "groupId", chi.URLParam(r, "groupId"), &groupId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "groupId", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.UnshareProjectFromGroup(w, r, hash, groupId)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// GetProjectWebhookInfo operation middleware
+func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "hash" -------------
+ var hash string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.GetProjectWebhookInfo(w, r, hash)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// DeleteProject operation middleware
+func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "path" -------------
+ var path ProjectHash
+
+ err = runtime.BindStyledParameterWithOptions("simple", "path", chi.URLParam(r, "path"), &path, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.DeleteProject(w, r, path)
}))
for _, middleware := range siw.HandlerMiddlewares {
@@ -5079,6 +5733,111 @@ func (siw *ServerInterfaceWrapper) WorkspaceSearch(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r)
}
+// ListWorkspaceShares operation middleware
+func (siw *ServerInterfaceWrapper) ListWorkspaceShares(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "id" -------------
+ var id string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ListWorkspaceShares(w, r, id)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// ShareWorkspaceToGroup operation middleware
+func (siw *ServerInterfaceWrapper) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "id" -------------
+ var id string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.ShareWorkspaceToGroup(w, r, id)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
+// UnshareWorkspaceFromGroup operation middleware
+func (siw *ServerInterfaceWrapper) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request) {
+
+ var err error
+ _ = err
+
+ // ------------- Path parameter "id" -------------
+ var id string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
+ return
+ }
+
+ // ------------- Path parameter "groupId" -------------
+ var groupId string
+
+ err = runtime.BindStyledParameterWithOptions("simple", "groupId", chi.URLParam(r, "groupId"), &groupId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
+ if err != nil {
+ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "groupId", Err: err})
+ return
+ }
+
+ ctx := r.Context()
+
+ ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
+
+ r = r.WithContext(ctx)
+
+ handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ siw.Handler.UnshareWorkspaceFromGroup(w, r, id, groupId)
+ }))
+
+ for _, middleware := range siw.HandlerMiddlewares {
+ handler = middleware(handler)
+ }
+
+ handler.ServeHTTP(w, r)
+}
+
// GetHealth operation middleware
func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Request) {
@@ -5287,6 +6046,30 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/github/webhooks/reconcile", wrapper.ReconcileWebhooks)
})
+ r.Group(func(r chi.Router) {
+ r.Get(options.BaseURL+"/api/v1/groups", wrapper.ListGroups)
+ })
+ r.Group(func(r chi.Router) {
+ r.Post(options.BaseURL+"/api/v1/groups", wrapper.CreateGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Delete(options.BaseURL+"/api/v1/groups/{id}", wrapper.DeleteGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Get(options.BaseURL+"/api/v1/groups/{id}", wrapper.GetGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Patch(options.BaseURL+"/api/v1/groups/{id}", wrapper.UpdateGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Get(options.BaseURL+"/api/v1/groups/{id}/members", wrapper.ListGroupMembers)
+ })
+ r.Group(func(r chi.Router) {
+ r.Post(options.BaseURL+"/api/v1/groups/{id}/members", wrapper.AddGroupMember)
+ })
+ r.Group(func(r chi.Router) {
+ r.Delete(options.BaseURL+"/api/v1/groups/{id}/members/{userId}", wrapper.RemoveGroupMember)
+ })
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/jobs", wrapper.ListJobs)
})
@@ -5305,9 +6088,21 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Patch(options.BaseURL+"/api/v1/projects/{hash}/git-repo", wrapper.UpdateProjectGitRepoSync)
})
+ r.Group(func(r chi.Router) {
+ r.Put(options.BaseURL+"/api/v1/projects/{hash}/owner", wrapper.ReassignProjectOwner)
+ })
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/projects/{hash}/reindex", wrapper.ReindexProject)
})
+ r.Group(func(r chi.Router) {
+ r.Get(options.BaseURL+"/api/v1/projects/{hash}/shares", wrapper.ListProjectShares)
+ })
+ r.Group(func(r chi.Router) {
+ r.Post(options.BaseURL+"/api/v1/projects/{hash}/shares", wrapper.ShareProjectToGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Delete(options.BaseURL+"/api/v1/projects/{hash}/shares/{groupId}", wrapper.UnshareProjectFromGroup)
+ })
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/projects/{hash}/webhook-info", wrapper.GetProjectWebhookInfo)
})
@@ -5416,6 +6211,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/workspaces/{id}/search", wrapper.WorkspaceSearch)
})
+ r.Group(func(r chi.Router) {
+ r.Get(options.BaseURL+"/api/v1/workspaces/{id}/shares", wrapper.ListWorkspaceShares)
+ })
+ r.Group(func(r chi.Router) {
+ r.Post(options.BaseURL+"/api/v1/workspaces/{id}/shares", wrapper.ShareWorkspaceToGroup)
+ })
+ r.Group(func(r chi.Router) {
+ r.Delete(options.BaseURL+"/api/v1/workspaces/{id}/shares/{groupId}", wrapper.UnshareWorkspaceFromGroup)
+ })
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/health", wrapper.GetHealth)
})
@@ -5428,424 +6232,455 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
// const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{
- "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",
+ "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",
}
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go
index 52f4ac1..2be7bf5 100644
--- a/server/internal/httpapi/router.go
+++ b/server/internal/httpapi/router.go
@@ -15,6 +15,7 @@ import (
"github.com/dvcdsys/code-index/server/internal/embeddings"
"github.com/dvcdsys/code-index/server/internal/gitrepos"
"github.com/dvcdsys/code-index/server/internal/githubtokens"
+ "github.com/dvcdsys/code-index/server/internal/groups"
"github.com/dvcdsys/code-index/server/internal/httpapi/openapi"
"github.com/dvcdsys/code-index/server/internal/indexer"
"github.com/dvcdsys/code-index/server/internal/jobs"
@@ -63,6 +64,10 @@ type Deps struct {
Users *users.Service
Sessions *sessions.Service
APIKeys *apikeys.Service
+ // Groups backs the view-group auth model (admin-managed sets of users
+ // that external projects + workspaces are shared to). Required in
+ // production; nil + AuthDisabled is tolerated by tests.
+ Groups *groups.Service
// EmbeddingSvc is the in-process embeddings service. May be nil when the
// server is started with CIX_EMBEDDINGS_ENABLED=false (e.g. in router
// tests). Phase 5 uses it for semantic search.
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index eaae8a1..fb67367 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -12,6 +12,7 @@ import (
"strings"
"time"
+ "github.com/dvcdsys/code-index/server/internal/access"
"github.com/dvcdsys/code-index/server/internal/embeddings"
"github.com/dvcdsys/code-index/server/internal/httpapi/openapi"
"github.com/dvcdsys/code-index/server/internal/indexer"
@@ -145,7 +146,23 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnprocessableEntity, "host_path is required")
return
}
- p, err := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{HostPath: body.HostPath})
+ // A project created here is a personal/local project owned by the caller.
+ // External projects are created ownerless via AddGitRepo instead.
+ ownerID, _ := s.callerIdentity(r)
+ machineID := ""
+ if body.MachineId != nil {
+ machineID = *body.MachineId
+ }
+ machineLabel := ""
+ if body.MachineLabel != nil {
+ machineLabel = *body.MachineLabel
+ }
+ p, err := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{
+ HostPath: body.HostPath,
+ OwnerUserID: ownerID,
+ MachineID: machineID,
+ MachineLabel: machineLabel,
+ })
if err != nil {
if errors.Is(err, projects.ErrConflict) || errors.Is(err, projects.ErrOverlap) {
writeError(w, http.StatusConflict, err.Error())
@@ -157,13 +174,34 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, projectToOpenAPI(p))
}
-// ListProjects — GET /api/v1/projects.
+// ListProjects — GET /api/v1/projects. Filtered by access: admins see every
+// project; a regular user sees the projects they own plus external projects
+// shared to a view-group they belong to.
func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
list, err := projects.List(r.Context(), s.Deps.DB)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
+ userID, isAdmin := s.callerIdentity(r)
+ if !isAdmin {
+ allowed, err := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "access check failed")
+ return
+ }
+ allowedSet := make(map[string]struct{}, len(allowed))
+ for _, hp := range allowed {
+ allowedSet[hp] = struct{}{}
+ }
+ filtered := list[:0]
+ for _, p := range list {
+ if _, ok := allowedSet[p.HostPath]; ok {
+ filtered = append(filtered, p)
+ }
+ }
+ list = filtered
+ }
out := make([]openapi.Project, 0, len(list))
for i := range list {
out = append(out, projectToOpenAPI(&list[i]))
@@ -176,7 +214,7 @@ func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
// GetProject — GET /api/v1/projects/{path}.
func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -246,10 +284,7 @@ func dirSizeBytes(dir string) (int64, bool) {
// changes can shrink the indexing surface (exclude_patterns, max_file_size)
// and viewers should not be able to silently de-index a project.
func (s *Server) UpdateProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- if _, ok := s.mustBeAdmin(w, r); !ok {
- return
- }
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -278,10 +313,7 @@ func (s *Server) UpdateProject(w http.ResponseWriter, r *http.Request, path open
// project also wipes its symbols/refs/embeddings and is destructive enough
// that it must not be reachable from a viewer-scoped session or API key.
func (s *Server) DeleteProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- if _, ok := s.mustBeAdmin(w, r); !ok {
- return
- }
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -298,7 +330,7 @@ func (s *Server) DeleteProject(w http.ResponseWriter, r *http.Request, path open
// SearchSymbols — POST /api/v1/projects/{path}/search/symbols.
func (s *Server) SearchSymbols(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -340,7 +372,7 @@ func (s *Server) SearchSymbols(w http.ResponseWriter, r *http.Request, path open
// SearchDefinitions — POST /api/v1/projects/{path}/search/definitions.
func (s *Server) SearchDefinitions(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -383,7 +415,7 @@ func (s *Server) SearchDefinitions(w http.ResponseWriter, r *http.Request, path
// SearchReferences — POST /api/v1/projects/{path}/search/references.
func (s *Server) SearchReferences(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -424,7 +456,7 @@ func (s *Server) SearchReferences(w http.ResponseWriter, r *http.Request, path o
// SearchFiles — POST /api/v1/projects/{path}/search/files.
func (s *Server) SearchFiles(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -483,7 +515,7 @@ func (s *Server) SearchFiles(w http.ResponseWriter, r *http.Request, path openap
// GetProjectSummary — GET /api/v1/projects/{path}/summary.
func (s *Server) GetProjectSummary(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -583,7 +615,7 @@ func (s *Server) GetProjectSummary(w http.ResponseWriter, r *http.Request, path
// SemanticSearch — POST /api/v1/projects/{path}/search.
func (s *Server) SemanticSearch(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectAccess(w, r)
if p == nil {
return
}
@@ -742,7 +774,7 @@ func fileGroupsToOpenAPI(in []fileGroupResult) []openapi.FileGroupResult {
// IndexBegin — POST /api/v1/projects/{path}/index/begin.
func (s *Server) IndexBegin(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -783,7 +815,7 @@ func (s *Server) IndexBegin(w http.ResponseWriter, r *http.Request, path openapi
// Honours `Accept: application/x-ndjson` to switch into the streaming
// variant; otherwise returns the legacy single-JSON summary.
func (s *Server) IndexFiles(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash, params openapi.IndexFilesParams) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -848,7 +880,7 @@ func (s *Server) IndexFiles(w http.ResponseWriter, r *http.Request, path openapi
// IndexFinish — POST /api/v1/projects/{path}/index/finish.
func (s *Server) IndexFinish(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -893,7 +925,7 @@ func (s *Server) IndexFinish(w http.ResponseWriter, r *http.Request, path openap
// theoretical DoS we'd be preventing. If you need owner-scoped semantics
// later, key off projects.indexing_run.started_by_user_id.
func (s *Server) IndexCancel(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -911,7 +943,7 @@ func (s *Server) IndexCancel(w http.ResponseWriter, r *http.Request, path openap
// IndexStatus — GET /api/v1/projects/{path}/index/status.
func (s *Server) IndexStatus(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) {
- p := s.lookupProject(w, r, path)
+ p := s.requireProjectOwnership(w, r)
if p == nil {
return
}
@@ -963,17 +995,9 @@ func (s *Server) IndexStatus(w http.ResponseWriter, r *http.Request, path openap
}
// ---------------------------------------------------------------------------
-// Internal helpers — type conversion + legacy lookup wrapper
+// Internal helpers — type conversion
// ---------------------------------------------------------------------------
-// lookupProject resolves the {path} URL parameter. Wraps resolveProjectFromHash
-// so generated method signatures stay clean.
-func (s *Server) lookupProject(w http.ResponseWriter, r *http.Request, _ openapi.ProjectHash) *projects.Project {
- // Use the helper that already exists in search.go — it pulls the
- // {path} chi URL param from r and writes a 404 on miss.
- return resolveProjectFromHash(w, r, s.Deps)
-}
-
// projectToOpenAPI converts the internal projects.Project (string dates,
// flat Settings/Stats) into the generated openapi.Project (time.Time dates,
// embedded openapi.ProjectSettings/Stats).
@@ -1020,6 +1044,16 @@ func projectToOpenAPI(p *projects.Project) openapi.Project {
v := *p.IndexedWithModel
out.IndexedWithModel = &v
}
+ dp := p.DisplayPath
+ out.DisplayPath = &dp
+ if p.MachineID != nil {
+ v := *p.MachineID
+ out.MachineId = &v
+ }
+ if p.MachineLabel != nil {
+ v := *p.MachineLabel
+ out.MachineLabel = &v
+ }
return out
}
diff --git a/server/internal/httpapi/webhooks.go b/server/internal/httpapi/webhooks.go
index 5c9fb62..3c53bf1 100644
--- a/server/internal/httpapi/webhooks.go
+++ b/server/internal/httpapi/webhooks.go
@@ -20,11 +20,14 @@ import (
// GetProjectWebhookInfo — GET /api/v1/projects/{hash}/webhook-info.
//
-// Authenticated. Returns the publicly-reachable webhook URL + the HMAC
-// secret. Operators copy these into GitHub's webhook config when
-// webhook_mode is not 'auto'. 404 when the project is local (no
-// git_repos row).
+// Admin-only. Returns the publicly-reachable webhook URL + the HMAC
+// secret (a credential) for an external project. Operators copy these into
+// GitHub's webhook config when webhook_mode is not 'auto'. 404 when the
+// project is local (no git_repos row).
func (s *Server) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) {
+ if _, ok := s.mustBeAdmin(w, r); !ok {
+ return
+ }
if s.gitReposUnavailable(w) {
return
}
diff --git a/server/internal/httpapi/workspaceprojects.go b/server/internal/httpapi/workspaceprojects.go
index 1836340..68edcb5 100644
--- a/server/internal/httpapi/workspaceprojects.go
+++ b/server/internal/httpapi/workspaceprojects.go
@@ -5,10 +5,10 @@ import (
"errors"
"net/http"
+ "github.com/dvcdsys/code-index/server/internal/access"
"github.com/dvcdsys/code-index/server/internal/httpapi/openapi"
"github.com/dvcdsys/code-index/server/internal/projects"
"github.com/dvcdsys/code-index/server/internal/workspaceprojects"
- "github.com/dvcdsys/code-index/server/internal/workspaces"
)
// workspaceProjectsUnavailable returns 503 when any required service
@@ -22,26 +22,15 @@ func (s *Server) workspaceProjectsUnavailable(w http.ResponseWriter) bool {
return false
}
-// requireWorkspace loads the parent workspace and returns 404 if missing.
-func (s *Server) requireWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
- _, err := s.Deps.Workspaces.GetByID(r.Context(), workspaceID)
- if err != nil {
- if errors.Is(err, workspaces.ErrNotFound) {
- writeError(w, http.StatusNotFound, "workspace not found")
- } else {
- writeError(w, http.StatusInternalServerError, "could not load workspace")
- }
- return false
- }
- return true
-}
-
-// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects.
+// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects. Visible to
+// owner/group-members/admin; per the decoupled model, projects the caller
+// cannot access are dropped from the listing rather than blocking the whole
+// workspace.
func (s *Server) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, id string) {
if s.workspaceProjectsUnavailable(w) {
return
}
- if !s.requireWorkspace(w, r, id) {
+ if _, ok := s.requireWorkspaceVisible(w, r, id); !ok {
return
}
memberships, err := s.Deps.WorkspaceProjects.ListByWorkspace(r.Context(), id)
@@ -49,8 +38,26 @@ func (s *Server) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, i
writeError(w, http.StatusInternalServerError, "could not list workspace projects: "+err.Error())
return
}
+ userID, isAdmin := s.callerIdentity(r)
+ var allowed map[string]struct{}
+ if !isAdmin {
+ hosts, aerr := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID)
+ if aerr != nil {
+ writeError(w, http.StatusInternalServerError, "access check failed")
+ return
+ }
+ allowed = make(map[string]struct{}, len(hosts))
+ for _, hp := range hosts {
+ allowed[hp] = struct{}{}
+ }
+ }
out := make([]map[string]any, 0, len(memberships))
for _, m := range memberships {
+ if !isAdmin {
+ if _, ok := allowed[m.ProjectPath]; !ok {
+ continue // hidden from this caller
+ }
+ }
proj, perr := projects.Get(r.Context(), s.Deps.DB, m.ProjectPath)
if perr != nil {
// The FK should prevent dangling rows, but if the project
@@ -79,7 +86,7 @@ func (s *Server) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request,
if s.workspaceProjectsUnavailable(w) {
return
}
- if !s.requireWorkspace(w, r, id) {
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
return
}
var body openapi.LinkProjectRequest
@@ -96,6 +103,11 @@ func (s *Server) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request,
writeError(w, http.StatusInternalServerError, "could not load project: "+perr.Error())
return
}
+ // The caller may only link projects they can access (hide others as 404).
+ if !s.canAccessProject(r, proj) {
+ writeError(w, http.StatusNotFound, "project not found")
+ return
+ }
m, err := s.Deps.WorkspaceProjects.Link(r.Context(), id, proj.HostPath)
if err != nil {
switch {
@@ -128,7 +140,7 @@ func (s *Server) UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Reque
if s.workspaceProjectsUnavailable(w) {
return
}
- if !s.requireWorkspace(w, r, id) {
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
return
}
proj, perr := projects.GetByHash(r.Context(), s.Deps.DB, string(hash))
diff --git a/server/internal/httpapi/workspaces.go b/server/internal/httpapi/workspaces.go
index 9974046..3fc03f7 100644
--- a/server/internal/httpapi/workspaces.go
+++ b/server/internal/httpapi/workspaces.go
@@ -6,6 +6,7 @@ import (
"net/http"
"time"
+ "github.com/dvcdsys/code-index/server/internal/access"
"github.com/dvcdsys/code-index/server/internal/httpapi/openapi"
"github.com/dvcdsys/code-index/server/internal/workspaces"
)
@@ -52,6 +53,25 @@ func (s *Server) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "could not list workspaces")
return
}
+ userID, isAdmin := s.callerIdentity(r)
+ if !isAdmin {
+ visible, err := access.VisibleWorkspaceIDs(r.Context(), s.Deps.DB, userID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "access check failed")
+ return
+ }
+ visibleSet := make(map[string]struct{}, len(visible))
+ for _, id := range visible {
+ visibleSet[id] = struct{}{}
+ }
+ filtered := list[:0]
+ for _, ws := range list {
+ if _, ok := visibleSet[ws.ID]; ok {
+ filtered = append(filtered, ws)
+ }
+ }
+ list = filtered
+ }
out := make([]workspacePayload, 0, len(list))
for _, ws := range list {
out = append(out, workspaceToPayload(ws))
@@ -76,7 +96,8 @@ func (s *Server) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
if body.Description != nil {
description = *body.Description
}
- ws, err := s.Deps.Workspaces.Create(r.Context(), body.Name, description)
+ ownerID, _ := s.callerIdentity(r)
+ ws, err := s.Deps.Workspaces.CreateOwned(r.Context(), body.Name, description, ownerID)
if err != nil {
switch {
case errors.Is(err, workspaces.ErrNameEmpty):
@@ -96,13 +117,8 @@ func (s *Server) GetWorkspace(w http.ResponseWriter, r *http.Request, id string)
if s.workspacesUnavailable(w) {
return
}
- ws, err := s.Deps.Workspaces.GetByID(r.Context(), id)
- if err != nil {
- if errors.Is(err, workspaces.ErrNotFound) {
- writeError(w, http.StatusNotFound, "workspace not found")
- return
- }
- writeError(w, http.StatusInternalServerError, "could not load workspace")
+ ws, ok := s.requireWorkspaceVisible(w, r, id)
+ if !ok {
return
}
writeJSON(w, http.StatusOK, workspaceToPayload(ws))
@@ -113,6 +129,9 @@ func (s *Server) UpdateWorkspace(w http.ResponseWriter, r *http.Request, id stri
if s.workspacesUnavailable(w) {
return
}
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
+ return
+ }
var body openapi.UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusUnprocessableEntity, "invalid JSON body")
@@ -140,6 +159,9 @@ func (s *Server) DeleteWorkspace(w http.ResponseWriter, r *http.Request, id stri
if s.workspacesUnavailable(w) {
return
}
+ if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok {
+ return
+ }
if err := s.Deps.Workspaces.Delete(r.Context(), id); err != nil {
if errors.Is(err, workspaces.ErrNotFound) {
writeError(w, http.StatusNotFound, "workspace not found")
diff --git a/server/internal/httpapi/workspacesearch.go b/server/internal/httpapi/workspacesearch.go
index b5175c5..af83589 100644
--- a/server/internal/httpapi/workspacesearch.go
+++ b/server/internal/httpapi/workspacesearch.go
@@ -2,7 +2,6 @@ package httpapi
import (
"context"
- "errors"
"math"
"net/http"
"runtime"
@@ -12,9 +11,9 @@ import (
"golang.org/x/sync/errgroup"
+ "github.com/dvcdsys/code-index/server/internal/access"
"github.com/dvcdsys/code-index/server/internal/chunksfts"
"github.com/dvcdsys/code-index/server/internal/httpapi/openapi"
- "github.com/dvcdsys/code-index/server/internal/workspaces"
)
// Tuning constants for the hybrid workspace search.
@@ -144,12 +143,7 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri
"embeddings or vectorstore not configured — workspace search requires both")
return
}
- if _, err := s.Deps.Workspaces.GetByID(r.Context(), id); err != nil {
- if errors.Is(err, workspaces.ErrNotFound) {
- writeError(w, http.StatusNotFound, "workspace not found")
- return
- }
- writeError(w, http.StatusInternalServerError, "could not load workspace")
+ if _, ok := s.requireWorkspaceVisible(w, r, id); !ok {
return
}
@@ -208,6 +202,29 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri
writeError(w, http.StatusInternalServerError, "iterate workspace projects: "+err.Error())
return
}
+
+ // Per-project access filter: a regular user only searches the projects in
+ // this workspace they can actually see (their own + shared external).
+ // Admins search everything. Decoupled model — hidden projects just drop out.
+ if userID, isAdmin := s.callerIdentity(r); !isAdmin {
+ hosts, aerr := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID)
+ if aerr != nil {
+ writeError(w, http.StatusInternalServerError, "access check failed")
+ return
+ }
+ allowed := make(map[string]struct{}, len(hosts))
+ for _, hp := range hosts {
+ allowed[hp] = struct{}{}
+ }
+ filtered := members[:0]
+ for _, m := range members {
+ if _, ok := allowed[m.ProjectPath]; ok {
+ filtered = append(filtered, m)
+ }
+ }
+ members = filtered
+ }
+
if len(members) == 0 {
writeJSON(w, http.StatusOK, workspaceSearchResponse(
"empty",
diff --git a/server/internal/projects/projects.go b/server/internal/projects/projects.go
index e46bd14..3871804 100644
--- a/server/internal/projects/projects.go
+++ b/server/internal/projects/projects.go
@@ -69,11 +69,42 @@ type Project struct {
// indexed under PR-E (or never indexed at all). The dashboard renders
// nil as a neutral "Unknown" badge, NOT as drift.
IndexedWithModel *string
+ // OwnerUserID is the user who owns this personal (locally indexed)
+ // project. nil = ownerless, the canonical state for EXTERNAL projects
+ // (those with a git_repos row). See the auth model in db/schema.go.
+ OwnerUserID *string
+ // DisplayPath is the human-readable path (the real filesystem path for
+ // local projects, the github path for external). HostPath is the identity
+ // key and may be namespaced; clients should display DisplayPath.
+ DisplayPath string
+ // MachineID / MachineLabel identify the machine a local project was
+ // indexed on. nil for external (and legacy) projects.
+ MachineID *string
+ MachineLabel *string
}
// CreateRequest mirrors Python ProjectCreate.
type CreateRequest struct {
+ // HostPath is the REAL filesystem path the caller is registering (for
+ // external repos, the github path). It becomes display_path; the stored
+ // identity key is derived from it (namespaced with MachineID for locals).
HostPath string
+ // OwnerUserID, when non-empty, is stored as the project's owner (personal
+ // project). Empty → NULL (ownerless), used by the external-repo path.
+ OwnerUserID string
+ // MachineID, when non-empty, marks this as a LOCAL project and namespaces
+ // the identity key as local:{MachineID}:{HostPath} so the same path on
+ // different machines/users does not collide. Empty → external (no
+ // namespacing). MachineLabel is os.Hostname() for display only.
+ MachineID string
+ MachineLabel string
+}
+
+// LocalProjectKey returns the namespaced identity key for a local project.
+// MUST stay byte-identical to the CLI's key computation (cli client) so the
+// path_hash both sides derive matches. Format: "local:{machineID}:{path}".
+func LocalProjectKey(machineID, path string) string {
+ return "local:" + machineID + ":" + path
}
// UpdateRequest mirrors Python ProjectUpdate.
@@ -114,10 +145,19 @@ func hashPath(path string) string {
// slashes) risks 404s on subsequent GET/PATCH calls that carry the original
// path through their SHA1 hash.
func Create(ctx context.Context, db *sql.DB, req CreateRequest) (*Project, error) {
- hostPath := req.HostPath
+ displayPath := req.HostPath
now := time.Now().UTC().Format(time.RFC3339Nano)
- if conflict, err := findOverlap(ctx, db, hostPath); err != nil {
+ // Identity key: namespaced per machine for local projects so the same
+ // filesystem path on different machines/users does not collide; the real
+ // path is kept as display_path. External projects (no MachineID) use the
+ // path as-is — it is already globally unique (github.com/owner/repo@branch).
+ key := displayPath
+ if req.MachineID != "" {
+ key = LocalProjectKey(req.MachineID, displayPath)
+ }
+
+ if conflict, err := findOverlap(ctx, db, key); err != nil {
return nil, fmt.Errorf("check overlap: %w", err)
} else if conflict != "" {
return nil, fmt.Errorf("%w: %s already registered", ErrOverlap, conflict)
@@ -134,18 +174,29 @@ func Create(ctx context.Context, db *sql.DB, req CreateRequest) (*Project, error
return nil, fmt.Errorf("marshal stats: %w", err)
}
+ var owner any
+ if req.OwnerUserID != "" {
+ owner = req.OwnerUserID
+ }
+ var machineID, machineLabel any
+ if req.MachineID != "" {
+ machineID = req.MachineID
+ }
+ if req.MachineLabel != "" {
+ machineLabel = req.MachineLabel
+ }
_, err = db.ExecContext(ctx,
- `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- hostPath, hostPath, "[]", string(settingsJSON), string(statsJSON), "created", now, now, hashPath(hostPath),
+ `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash, owner_user_id, display_path, machine_id, machine_label)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ key, displayPath, "[]", string(settingsJSON), string(statsJSON), "created", now, now, hashPath(key), owner, displayPath, machineID, machineLabel,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
- return nil, fmt.Errorf("%w: %s", ErrConflict, hostPath)
+ return nil, fmt.Errorf("%w: %s", ErrConflict, displayPath)
}
return nil, fmt.Errorf("insert project: %w", err)
}
- return Get(ctx, db, hostPath)
+ return Get(ctx, db, key)
}
// findOverlap returns the host_path of the first existing project that is a
@@ -191,7 +242,7 @@ func findOverlap(ctx context.Context, db *sql.DB, candidate string) (string, err
// Get retrieves a project by its host_path. Returns ErrNotFound if absent.
func Get(ctx context.Context, db *sql.DB, hostPath string) (*Project, error) {
row := db.QueryRowContext(ctx,
- `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model
+ `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model, owner_user_id, display_path, machine_id, machine_label
FROM projects WHERE host_path = ?`, hostPath,
)
return scanProject(hostPath, row)
@@ -219,7 +270,7 @@ func GetByHash(ctx context.Context, db *sql.DB, pathHash string) (*Project, erro
// List returns all projects ordered by created_at descending.
func List(ctx context.Context, db *sql.DB) ([]Project, error) {
rows, err := db.QueryContext(ctx,
- `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model
+ `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model, owner_user_id, display_path, machine_id, machine_label
FROM projects ORDER BY created_at DESC`,
)
if err != nil {
@@ -313,11 +364,16 @@ func scanProject(hostPath string, row *sql.Row) (*Project, error) {
createdAt, updatedAt string
lastIndexedAt *string
indexedWithModel *string
+ ownerUserID *string
+ displayPath *string
+ machineID *string
+ machineLabel *string
)
err := row.Scan(
&hp, &containerPath,
&langsJSON, &settingsJSON, &statsJSON,
- &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel,
+ &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, &ownerUserID,
+ &displayPath, &machineID, &machineLabel,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrNotFound, hostPath)
@@ -325,7 +381,7 @@ func scanProject(hostPath string, row *sql.Row) (*Project, error) {
if err != nil {
return nil, fmt.Errorf("scan project row: %w", err)
}
- return buildProject(hp, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel)
+ return buildProject(hp, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel)
}
func scanProjectRow(rows *sql.Rows) (*Project, error) {
@@ -336,18 +392,23 @@ func scanProjectRow(rows *sql.Rows) (*Project, error) {
createdAt, updatedAt string
lastIndexedAt *string
indexedWithModel *string
+ ownerUserID *string
+ displayPath *string
+ machineID *string
+ machineLabel *string
)
if err := rows.Scan(
&hostPath, &containerPath,
&langsJSON, &settingsJSON, &statsJSON,
- &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel,
+ &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, &ownerUserID,
+ &displayPath, &machineID, &machineLabel,
); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
- return buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel)
+ return buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel)
}
-func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt string, lastIndexedAt, indexedWithModel *string) (*Project, error) {
+func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt string, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel *string) (*Project, error) {
var langs []string
if err := json.Unmarshal([]byte(langsJSON), &langs); err != nil {
langs = nil
@@ -363,6 +424,10 @@ func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, s
stats = Stats{}
}
+ dp := hostPath
+ if displayPath != nil && *displayPath != "" {
+ dp = *displayPath
+ }
return &Project{
HostPath: hostPath,
ContainerPath: containerPath,
@@ -374,5 +439,33 @@ func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, s
UpdatedAt: updatedAt,
LastIndexedAt: lastIndexedAt,
IndexedWithModel: indexedWithModel,
+ OwnerUserID: ownerUserID,
+ DisplayPath: dp,
+ MachineID: machineID,
+ MachineLabel: machineLabel,
}, nil
}
+
+// SetOwner reassigns (or clears, when ownerUserID == "") a project's owner.
+// Used by the admin reassign-owner endpoint. Returns ErrNotFound if absent.
+func SetOwner(ctx context.Context, db *sql.DB, hostPath, ownerUserID string) error {
+ var owner any
+ if ownerUserID != "" {
+ owner = ownerUserID
+ }
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ res, err := db.ExecContext(ctx,
+ `UPDATE projects SET owner_user_id = ?, updated_at = ? WHERE host_path = ?`,
+ owner, now, hostPath)
+ if err != nil {
+ return fmt.Errorf("set project owner: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("rows affected: %w", err)
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/server/internal/projects/projects_test.go b/server/internal/projects/projects_test.go
index 9691136..5d9a18e 100644
--- a/server/internal/projects/projects_test.go
+++ b/server/internal/projects/projects_test.go
@@ -257,3 +257,49 @@ func TestHashPath_MatchesPython(t *testing.T) {
}
}
+
+// TestCreate_MachineNamespacingAvoidsCollision verifies that the same
+// filesystem path indexed from two different machines becomes two distinct
+// projects (different identity key + hash), while the same machine+path
+// collides — and that display_path holds the real path either way.
+func TestCreate_MachineNamespacingAvoidsCollision(t *testing.T) {
+ ctx := context.Background()
+ d := openTestDB(t)
+
+ const realPath = "/Users/dev/myapp"
+
+ p1, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineA", MachineLabel: "laptop-a"})
+ if err != nil {
+ t.Fatalf("create on machineA: %v", err)
+ }
+ p2, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineB", MachineLabel: "laptop-b"})
+ if err != nil {
+ t.Fatalf("create same path on machineB: %v", err)
+ }
+
+ if p1.HostPath == p2.HostPath {
+ t.Errorf("identity keys collided: %q", p1.HostPath)
+ }
+ if HashPath(p1.HostPath) == HashPath(p2.HostPath) {
+ t.Error("path_hashes collided across machines")
+ }
+ if p1.DisplayPath != realPath || p2.DisplayPath != realPath {
+ t.Errorf("display_path = %q / %q, want %q", p1.DisplayPath, p2.DisplayPath, realPath)
+ }
+ if p1.MachineID == nil || *p1.MachineID != "machineA" {
+ t.Errorf("p1 machine_id = %v, want machineA", p1.MachineID)
+ }
+ if p1.MachineLabel == nil || *p1.MachineLabel != "laptop-a" {
+ t.Errorf("p1 machine_label = %v, want laptop-a", p1.MachineLabel)
+ }
+
+ // Same machine + same path → conflict.
+ if _, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineA"}); !errors.Is(err, ErrConflict) {
+ t.Errorf("same machine+path err = %v, want ErrConflict", err)
+ }
+
+ // The namespaced key must equal what the CLI computes.
+ if got, want := p1.HostPath, LocalProjectKey("machineA", realPath); got != want {
+ t.Errorf("identity key = %q, want %q", got, want)
+ }
+}
diff --git a/server/internal/sessions/sessions_test.go b/server/internal/sessions/sessions_test.go
index ee79c37..870ab4c 100644
--- a/server/internal/sessions/sessions_test.go
+++ b/server/internal/sessions/sessions_test.go
@@ -24,7 +24,7 @@ func newFixture(t *testing.T) fixture {
}
t.Cleanup(func() { _ = d.Close() })
usrSvc := users.New(d)
- u, err := usrSvc.Create(context.Background(), "a@b.com", "password1234", users.RoleViewer, false)
+ u, err := usrSvc.Create(context.Background(), "a@b.com", "password1234", users.RoleUser, false)
if err != nil {
t.Fatalf("create user: %v", err)
}
diff --git a/server/internal/users/users.go b/server/internal/users/users.go
index 1cf55d6..d0151f7 100644
--- a/server/internal/users/users.go
+++ b/server/internal/users/users.go
@@ -19,8 +19,8 @@ import (
// Roles. Kept open-coded (string constants) rather than a typed enum so
// SQL queries and HTTP handlers can compare without import gymnastics.
const (
- RoleAdmin = "admin"
- RoleViewer = "viewer"
+ RoleAdmin = "admin"
+ RoleUser = "user"
)
// BcryptCost is the work factor for password hashing. 12 is the current
@@ -400,7 +400,7 @@ func scanUserRow(r rowScanner) (User, error) {
func normalizeEmail(s string) string { return strings.TrimSpace(strings.ToLower(s)) }
-func validRole(r string) bool { return r == RoleAdmin || r == RoleViewer }
+func validRole(r string) bool { return r == RoleAdmin || r == RoleUser }
// isUniqueViolation matches modernc.org/sqlite's UNIQUE-constraint error
// without taking a hard import dependency on its error type. The driver
diff --git a/server/internal/users/users_test.go b/server/internal/users/users_test.go
index d7cc97c..7768cf3 100644
--- a/server/internal/users/users_test.go
+++ b/server/internal/users/users_test.go
@@ -45,7 +45,7 @@ func TestCreateAndAuthenticate(t *testing.T) {
func TestAuthenticate_Wrong(t *testing.T) {
s := newTestService(t)
- _, _ = s.Create(context.Background(), "a@b.com", "rightpassword", RoleViewer, false)
+ _, _ = s.Create(context.Background(), "a@b.com", "rightpassword", RoleUser, false)
_, err := s.Authenticate(context.Background(), "a@b.com", "wrong")
if !errors.Is(err, ErrInvalidLogin) {
t.Errorf("err = %v, want ErrInvalidLogin", err)
@@ -58,11 +58,11 @@ func TestAuthenticate_Wrong(t *testing.T) {
func TestEmailUniqueness(t *testing.T) {
s := newTestService(t)
- _, err := s.Create(context.Background(), "a@b.com", "password1", RoleViewer, false)
+ _, err := s.Create(context.Background(), "a@b.com", "password1", RoleUser, false)
if err != nil {
t.Fatalf("first Create: %v", err)
}
- _, err = s.Create(context.Background(), "A@B.com", "password2", RoleViewer, false)
+ _, err = s.Create(context.Background(), "A@B.com", "password2", RoleUser, false)
if !errors.Is(err, ErrEmailTaken) {
t.Errorf("err = %v, want ErrEmailTaken (case-insensitive uniqueness)", err)
}
@@ -70,7 +70,7 @@ func TestEmailUniqueness(t *testing.T) {
func TestUpdatePassword_ClearsMustChange(t *testing.T) {
s := newTestService(t)
- u, _ := s.Create(context.Background(), "a@b.com", "initial-password", RoleViewer, true)
+ u, _ := s.Create(context.Background(), "a@b.com", "initial-password", RoleUser, true)
if err := s.UpdatePassword(context.Background(), u.ID, "newpassword123"); err != nil {
t.Fatalf("UpdatePassword: %v", err)
}
@@ -92,12 +92,12 @@ func TestUpdatePassword_ClearsMustChange(t *testing.T) {
func TestSetRole_LastAdminBlock(t *testing.T) {
s := newTestService(t)
a, _ := s.Create(context.Background(), "a@b.com", "password1", RoleAdmin, false)
- if err := s.SetRole(context.Background(), a.ID, RoleViewer); !errors.Is(err, ErrLastAdminBlock) {
+ if err := s.SetRole(context.Background(), a.ID, RoleUser); !errors.Is(err, ErrLastAdminBlock) {
t.Errorf("demoting last admin err = %v, want ErrLastAdminBlock", err)
}
// Add a second admin — now demotion of the first must succeed.
_, _ = s.Create(context.Background(), "b@b.com", "password2", RoleAdmin, false)
- if err := s.SetRole(context.Background(), a.ID, RoleViewer); err != nil {
+ if err := s.SetRole(context.Background(), a.ID, RoleUser); err != nil {
t.Errorf("demoting with another admin around: %v", err)
}
}
@@ -129,7 +129,7 @@ func TestInvalidRole(t *testing.T) {
func TestList(t *testing.T) {
s := newTestService(t)
for _, em := range []string{"a@b.com", "b@b.com", "c@b.com"} {
- _, _ = s.Create(context.Background(), em, "password1234", RoleViewer, false)
+ _, _ = s.Create(context.Background(), em, "password1234", RoleUser, false)
}
list, err := s.List(context.Background())
if err != nil {
@@ -165,11 +165,11 @@ func TestListWithStats(t *testing.T) {
if err != nil {
t.Fatalf("create alice: %v", err)
}
- bob, err := s.Create(ctx, "bob@b.com", "password1234", RoleViewer, false)
+ bob, err := s.Create(ctx, "bob@b.com", "password1234", RoleUser, false)
if err != nil {
t.Fatalf("create bob: %v", err)
}
- carol, err := s.Create(ctx, "carol@b.com", "password1234", RoleViewer, false)
+ carol, err := s.Create(ctx, "carol@b.com", "password1234", RoleUser, false)
if err != nil {
t.Fatalf("create carol: %v", err)
}
diff --git a/server/internal/workspaces/workspaces.go b/server/internal/workspaces/workspaces.go
index 38f5a8a..aca0040 100644
--- a/server/internal/workspaces/workspaces.go
+++ b/server/internal/workspaces/workspaces.go
@@ -40,6 +40,10 @@ type Workspace struct {
Description string
CreatedAt time.Time
UpdatedAt time.Time
+ // OwnerUserID is the creator. nil only for workspaces orphaned by a user
+ // deletion (FK SET NULL). Visible to the owner, members of any view-group
+ // it is shared to, and admins.
+ OwnerUserID *string
}
// Service wraps the workspaces table.
@@ -50,8 +54,15 @@ type Service struct {
// New returns a Service.
func New(db *sql.DB) *Service { return &Service{DB: db} }
-// Create inserts a new workspace. Name must be non-empty and unique.
+// Create inserts a new ownerless workspace. Equivalent to CreateOwned with an
+// empty owner — retained for callers (and tests) that don't set ownership.
func (s *Service) Create(ctx context.Context, name, description string) (Workspace, error) {
+ return s.CreateOwned(ctx, name, description, "")
+}
+
+// CreateOwned inserts a new workspace owned by ownerUserID (empty → NULL).
+// Name must be non-empty and unique.
+func (s *Service) CreateOwned(ctx context.Context, name, description, ownerUserID string) (Workspace, error) {
name = strings.TrimSpace(name)
if name == "" {
return Workspace{}, ErrNameEmpty
@@ -62,9 +73,9 @@ func (s *Service) Create(ctx context.Context, name, description string) (Workspa
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.DB.ExecContext(ctx,
- `INSERT INTO workspaces (id, name, description, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?)`,
- id, name, nullableString(description), now, now,
+ `INSERT INTO workspaces (id, name, description, created_at, updated_at, owner_user_id)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ id, name, nullableString(description), now, now, nullableString(ownerUserID),
)
if err != nil {
if isUniqueConstraintViolation(err) {
@@ -78,7 +89,7 @@ func (s *Service) Create(ctx context.Context, name, description string) (Workspa
// GetByID returns one workspace. ErrNotFound when absent.
func (s *Service) GetByID(ctx context.Context, id string) (Workspace, error) {
row := s.DB.QueryRowContext(ctx,
- `SELECT id, name, description, created_at, updated_at
+ `SELECT id, name, description, created_at, updated_at, owner_user_id
FROM workspaces WHERE id = ?`, id)
return scanRow(row)
}
@@ -86,7 +97,7 @@ func (s *Service) GetByID(ctx context.Context, id string) (Workspace, error) {
// List returns every workspace, newest first.
func (s *Service) List(ctx context.Context) ([]Workspace, error) {
rows, err := s.DB.QueryContext(ctx,
- `SELECT id, name, description, created_at, updated_at
+ `SELECT id, name, description, created_at, updated_at, owner_user_id
FROM workspaces ORDER BY created_at DESC`)
if err != nil {
return nil, fmt.Errorf("list workspaces: %w", err)
@@ -149,12 +160,13 @@ func (s *Service) Delete(ctx context.Context, id string) error {
func scanRow(r interface{ Scan(dest ...any) error }) (Workspace, error) {
var (
- w Workspace
- description sql.NullString
- createdAt string
- updatedAt string
+ w Workspace
+ description sql.NullString
+ createdAt string
+ updatedAt string
+ ownerUserID sql.NullString
)
- err := r.Scan(&w.ID, &w.Name, &description, &createdAt, &updatedAt)
+ err := r.Scan(&w.ID, &w.Name, &description, &createdAt, &updatedAt, &ownerUserID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Workspace{}, ErrNotFound
@@ -164,6 +176,9 @@ func scanRow(r interface{ Scan(dest ...any) error }) (Workspace, error) {
w.Description = description.String
w.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
w.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt)
+ if ownerUserID.Valid {
+ w.OwnerUserID = &ownerUserID.String
+ }
return w, nil
}