diff --git a/CHANGELOG.md b/CHANGELOG.md index f9be456..ae3108c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac ## [Unreleased] +### Added + +- **App logins** - a username and password bound to a stable app id, in the local vault and in the project vault. The app id is not a host or a port, so `localhost:3000` can change without losing the account. `primora logins set|get|list|rm` (`--remote` for the project). The dashboard Vault page and the desktop vault window manage both. +- **Agent reads** - `primora_vault_get`, `primora_secrets_get`, `primora_logins_get`, `primora_logins_set`, and `primora_logins_list`. An agent fetches one named value when it needs to sign in. Lists still omit passwords. Project reveals stay audit-logged. Do not paste the value into chat or a `.env` file. + ## [0.8.5] - 2026-09-28 ### Fixed diff --git a/README.md b/README.md index f703f90..487c435 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Supabase, with no hosted tier - your data stays on your hardware. - **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend. - **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries. - **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`). -- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app. +- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app. App logins (username, password, stable app id) live in both, and an agent can read one named value when it needs to sign in. - **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery. - **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions. - **API keys** - `prm__` credentials, shown once. diff --git a/ROADMAP.md b/ROADMAP.md index 9585b5d..c891180 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -420,6 +420,21 @@ Ordered loosely by leverage. None committed; each gets scoped when picked. broadcasts on join/leave (stream-only), `GET …/realtime/presence` reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`. +## App logins (shipped, unreleased) + +Username and password for a website or for an app the user is building. +Stored in both the local vault and the project vault. The app id is a +stable name (`my-app`, the project slug), not a host or a port, because +`localhost:3000` can serve something else later. `origin` is only the +current URL. + +Agents read one named secret or login when they need it +(`primora_vault_get`, `primora_secrets_get`, `primora_logins_get`). +That is the point of the vault: encrypted at rest, fetched at use, not +pasted into chat and not written to `.env`. Lists stay metadata. +Project reveals are audit-logged. A `write` API key can reveal; a `read` +key cannot. + --- ## What is deliberately out of scope diff --git a/apps/backend/db/migrations/00017_project_logins.sql b/apps/backend/db/migrations/00017_project_logins.sql new file mode 100644 index 0000000..66e5a47 --- /dev/null +++ b/apps/backend/db/migrations/00017_project_logins.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- App logins: username + password bound to a stable app id, not a host:port. +-- The password is AES-256-GCM ciphertext. List returns metadata (including +-- the username and origin); reveal decrypts and is audit-logged. +CREATE TABLE core.project_logins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE, + app TEXT NOT NULL, + username TEXT NOT NULL, + ciphertext BYTEA NOT NULL, + origin TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, app) +); + +-- +goose Down +DROP TABLE IF EXISTS core.project_logins; diff --git a/apps/backend/db/queries/project_logins.sql b/apps/backend/db/queries/project_logins.sql new file mode 100644 index 0000000..639f964 --- /dev/null +++ b/apps/backend/db/queries/project_logins.sql @@ -0,0 +1,33 @@ +-- name: ListProjectLogins :many +SELECT id, project_id, app, username, origin, notes, created_at, updated_at +FROM core.project_logins +WHERE project_id = $1 +ORDER BY app ASC; + +-- name: GetProjectLogin :one +SELECT * FROM core.project_logins +WHERE project_id = $1 + AND app = $2; + +-- name: UpsertProjectLogin :one +INSERT INTO core.project_logins ( + project_id, + app, + username, + ciphertext, + origin, + notes +) VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (project_id, app) DO UPDATE SET + username = EXCLUDED.username, + ciphertext = EXCLUDED.ciphertext, + origin = EXCLUDED.origin, + notes = EXCLUDED.notes, + updated_at = NOW() +RETURNING *; + +-- name: DeleteProjectLogin :one +DELETE FROM core.project_logins +WHERE project_id = $1 + AND app = $2 +RETURNING id; diff --git a/apps/backend/internal/database/db/models.go b/apps/backend/internal/database/db/models.go index d353de4..ff771fd 100644 --- a/apps/backend/internal/database/db/models.go +++ b/apps/backend/internal/database/db/models.go @@ -361,6 +361,18 @@ type CoreProjectInvitation struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type CoreProjectLogin struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + App string `json:"app"` + Username string `json:"username"` + Ciphertext []byte `json:"ciphertext"` + Origin string `json:"origin"` + Notes string `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type CoreProjectMember struct { ID uuid.UUID `json:"id"` ProjectID uuid.UUID `json:"project_id"` diff --git a/apps/backend/internal/database/db/project_logins.sql.go b/apps/backend/internal/database/db/project_logins.sql.go new file mode 100644 index 0000000..3cc3b15 --- /dev/null +++ b/apps/backend/internal/database/db/project_logins.sql.go @@ -0,0 +1,158 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: project_logins.sql + +package db + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteProjectLogin = `-- name: DeleteProjectLogin :one +DELETE FROM core.project_logins +WHERE project_id = $1 + AND app = $2 +RETURNING id +` + +type DeleteProjectLoginParams struct { + ProjectID uuid.UUID `json:"project_id"` + App string `json:"app"` +} + +func (q *Queries) DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, deleteProjectLogin, arg.ProjectID, arg.App) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + +const getProjectLogin = `-- name: GetProjectLogin :one +SELECT id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at FROM core.project_logins +WHERE project_id = $1 + AND app = $2 +` + +type GetProjectLoginParams struct { + ProjectID uuid.UUID `json:"project_id"` + App string `json:"app"` +} + +func (q *Queries) GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) { + row := q.db.QueryRow(ctx, getProjectLogin, arg.ProjectID, arg.App) + var i CoreProjectLogin + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.App, + &i.Username, + &i.Ciphertext, + &i.Origin, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listProjectLogins = `-- name: ListProjectLogins :many +SELECT id, project_id, app, username, origin, notes, created_at, updated_at +FROM core.project_logins +WHERE project_id = $1 +ORDER BY app ASC +` + +type ListProjectLoginsRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + App string `json:"app"` + Username string `json:"username"` + Origin string `json:"origin"` + Notes string `json:"notes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) { + rows, err := q.db.Query(ctx, listProjectLogins, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProjectLoginsRow + for rows.Next() { + var i ListProjectLoginsRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.App, + &i.Username, + &i.Origin, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertProjectLogin = `-- name: UpsertProjectLogin :one +INSERT INTO core.project_logins ( + project_id, + app, + username, + ciphertext, + origin, + notes +) VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (project_id, app) DO UPDATE SET + username = EXCLUDED.username, + ciphertext = EXCLUDED.ciphertext, + origin = EXCLUDED.origin, + notes = EXCLUDED.notes, + updated_at = NOW() +RETURNING id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at +` + +type UpsertProjectLoginParams struct { + ProjectID uuid.UUID `json:"project_id"` + App string `json:"app"` + Username string `json:"username"` + Ciphertext []byte `json:"ciphertext"` + Origin string `json:"origin"` + Notes string `json:"notes"` +} + +func (q *Queries) UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) { + row := q.db.QueryRow(ctx, upsertProjectLogin, + arg.ProjectID, + arg.App, + arg.Username, + arg.Ciphertext, + arg.Origin, + arg.Notes, + ) + var i CoreProjectLogin + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.App, + &i.Username, + &i.Ciphertext, + &i.Origin, + &i.Notes, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/apps/backend/internal/database/db/querier.go b/apps/backend/internal/database/db/querier.go index c5e4f1a..c2a8acc 100644 --- a/apps/backend/internal/database/db/querier.go +++ b/apps/backend/internal/database/db/querier.go @@ -51,6 +51,7 @@ type Querier interface { DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) + DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error) DeleteSetting(ctx context.Context, key string) error @@ -79,6 +80,7 @@ type Querier interface { GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error) + GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) @@ -120,6 +122,7 @@ type Querier interface { ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) + ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) @@ -168,6 +171,7 @@ type Querier interface { UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error) + UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) diff --git a/apps/backend/internal/handlers/http.go b/apps/backend/internal/handlers/http.go index 48132e9..67af017 100644 --- a/apps/backend/internal/handlers/http.go +++ b/apps/backend/internal/handlers/http.go @@ -137,6 +137,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) { api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret) api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret) api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret) + api.GET("/projects/:projectID/logins", h.listProjectLogins) + api.PUT("/projects/:projectID/logins/:app", h.setProjectLogin) + api.DELETE("/projects/:projectID/logins/:app", h.deleteProjectLogin) + api.POST("/projects/:projectID/logins/:app/reveal", h.revealProjectLogin) api.GET("/projects/:projectID/alerts", h.listAlertRules) api.POST("/projects/:projectID/alerts", h.createAlertRule) diff --git a/apps/backend/internal/handlers/logins.go b/apps/backend/internal/handlers/logins.go new file mode 100644 index 0000000..6a1a062 --- /dev/null +++ b/apps/backend/internal/handlers/logins.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/tdvorak/primora/apps/backend/internal/middleware" + apperrors "github.com/tdvorak/primora/apps/backend/internal/response" + "github.com/tdvorak/primora/apps/backend/internal/services" +) + +func (h *HTTPHandler) loginError(c *gin.Context, err error) { + msg := err.Error() + if strings.Contains(msg, "invalid app id") || strings.Contains(msg, "username is required") { + apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg) + return + } + h.secretError(c, err) +} + +func (h *HTTPHandler) listProjectLogins(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.ListProjectLogins(c.Request.Context(), actor, projectID) + if err != nil { + h.loginError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": result}) +} + +func (h *HTTPHandler) setProjectLogin(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + var body services.SetProjectLoginInput + if !h.bindAndValidate(c, &body) { + return + } + body.App = c.Param("app") + result, err := h.Platform.SetProjectLogin(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c)) + if err != nil { + h.loginError(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *HTTPHandler) deleteProjectLogin(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + if err := h.Platform.DeleteProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)); err != nil { + h.loginError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (h *HTTPHandler) revealProjectLogin(c *gin.Context) { + actor, projectID, ok := h.actorAndProject(c) + if !ok { + return + } + result, err := h.Platform.RevealProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)) + if err != nil { + h.loginError(c, err) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/apps/backend/internal/services/project_logins.go b/apps/backend/internal/services/project_logins.go new file mode 100644 index 0000000..3dded76 --- /dev/null +++ b/apps/backend/internal/services/project_logins.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + db "github.com/tdvorak/primora/apps/backend/internal/database/db" + "github.com/tdvorak/primora/apps/backend/internal/models" +) + +// Project logins are website and app accounts: a stable app id, a username, +// and a password. The app id survives port changes (localhost:3000 is not an +// identity). Passwords are AES-256-GCM at rest; reveal is audit-logged. + +var loginAppRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +type ProjectLoginSummary struct { + ID uuid.UUID `json:"id"` + App string `json:"app"` + Username string `json:"username"` + Origin string `json:"origin"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SetProjectLoginInput struct { + App string `json:"app"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` + Origin *string `json:"origin"` + Notes *string `json:"notes"` +} + +type RevealProjectLoginResponse struct { + App string `json:"app"` + Username string `json:"username"` + Password string `json:"password"` + Origin string `json:"origin"` +} + +func validLoginApp(app string) bool { + return loginAppRe.MatchString(app) +} + +func toProjectLoginSummary(row db.ListProjectLoginsRow) ProjectLoginSummary { + return ProjectLoginSummary{ + ID: row.ID, + App: row.App, + Username: row.Username, + Origin: row.Origin, + Notes: row.Notes, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func (s *PlatformService) ListProjectLogins(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectLoginSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil { + return nil, err + } + rows, err := s.repo.Queries().ListProjectLogins(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]ProjectLoginSummary, 0, len(rows)) + for _, row := range rows { + out = append(out, toProjectLoginSummary(row)) + } + return out, nil +} + +func (s *PlatformService) SetProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectLoginInput, requestID string) (ProjectLoginSummary, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return ProjectLoginSummary{}, err + } + app := strings.TrimSpace(input.App) + if !validLoginApp(app) { + return ProjectLoginSummary{}, fmt.Errorf("invalid app id — use [A-Za-z0-9][A-Za-z0-9._-]{0,63}, not a URL or port") + } + username := strings.TrimSpace(input.Username) + if username == "" { + return ProjectLoginSummary{}, fmt.Errorf("username is required") + } + if s.enc == nil { + return ProjectLoginSummary{}, fmt.Errorf("encryption is not configured on this deployment") + } + ciphertext, err := s.enc.Encrypt([]byte(input.Password)) + if err != nil { + return ProjectLoginSummary{}, err + } + var origin, notes string + if existing, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app}); err == nil { + origin, notes = existing.Origin, existing.Notes + } else if err != pgx.ErrNoRows { + return ProjectLoginSummary{}, err + } + if input.Origin != nil { + origin = strings.TrimSpace(*input.Origin) + } + if input.Notes != nil { + notes = strings.TrimSpace(*input.Notes) + } + row, err := s.repo.Queries().UpsertProjectLogin(ctx, db.UpsertProjectLoginParams{ + ProjectID: projectID, + App: app, + Username: username, + Ciphertext: ciphertext, + Origin: origin, + Notes: notes, + }) + if err != nil { + return ProjectLoginSummary{}, err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.set", "project_login", row.ID.String(), map[string]any{ + "app": row.App, + })) + } + return ProjectLoginSummary{ + ID: row.ID, + App: row.App, + Username: row.Username, + Origin: row.Origin, + Notes: row.Notes, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + }, nil +} + +func (s *PlatformService) DeleteProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) error { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return err + } + row, err := s.repo.Queries().DeleteProjectLogin(ctx, db.DeleteProjectLoginParams{ProjectID: projectID, App: app}) + if err != nil { + return err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.deleted", "project_login", row.String(), map[string]any{ + "app": app, + })) + } + return nil +} + +func (s *PlatformService) RevealProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) (RevealProjectLoginResponse, error) { + if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil { + return RevealProjectLoginResponse{}, err + } + if s.enc == nil { + return RevealProjectLoginResponse{}, fmt.Errorf("encryption is not configured on this deployment") + } + row, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app}) + if err != nil { + return RevealProjectLoginResponse{}, err + } + plain, err := s.enc.Decrypt(row.Ciphertext) + if err != nil { + return RevealProjectLoginResponse{}, err + } + if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil { + _, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.revealed", "project_login", row.ID.String(), map[string]any{ + "app": row.App, + })) + } + return RevealProjectLoginResponse{App: row.App, Username: row.Username, Password: string(plain), Origin: row.Origin}, nil +} diff --git a/apps/backend/internal/services/project_logins_test.go b/apps/backend/internal/services/project_logins_test.go new file mode 100644 index 0000000..28312da --- /dev/null +++ b/apps/backend/internal/services/project_logins_test.go @@ -0,0 +1,27 @@ +package services + +import ( + "strings" + "testing" +) + +func TestValidLoginApp(t *testing.T) { + ok := []string{"primora", "MyApp", "app.local", "stripe-dashboard", "a"} + bad := []string{"", "has space", "http://localhost:3000", "/etc/passwd", "..", "-leading", "a/b"} + for _, app := range ok { + if !validLoginApp(app) { + t.Errorf("validLoginApp(%q) = false", app) + } + } + for _, app := range bad { + if validLoginApp(app) { + t.Errorf("validLoginApp(%q) = true", app) + } + } + if !validLoginApp(strings.Repeat("a", 64)) { + t.Error("64-char app id rejected") + } + if validLoginApp(strings.Repeat("a", 65)) { + t.Error("65-char app id accepted") + } +} diff --git a/apps/backend/openapi/openapi.yaml b/apps/backend/openapi/openapi.yaml index 8fb69f3..d150c38 100644 --- a/apps/backend/openapi/openapi.yaml +++ b/apps/backend/openapi/openapi.yaml @@ -2041,6 +2041,91 @@ paths: $ref: "#/components/responses/ErrorResponse" "404": $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/logins: + get: + tags: [Secrets] + operationId: listProjectLogins + summary: Lists app logins — username and origin, never the password. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + responses: + "200": + description: Project logins (password omitted) + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectLoginListResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/logins/{app}: + put: + tags: [Secrets] + operationId: setProjectLogin + summary: Creates or replaces an app login. The password is AES-256-GCM at rest. Omitted origin/notes keep existing values. The app id is stable — it is not a host or port. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/LoginApp" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SetProjectLoginRequest" + responses: + "200": + description: Login stored + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectLogin" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + delete: + tags: [Secrets] + operationId: deleteProjectLogin + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/LoginApp" + responses: + "204": + description: Login deleted + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" + /projects/{projectID}/logins/{app}/reveal: + post: + tags: [Secrets] + operationId: revealProjectLogin + summary: Returns the username, password, and origin for one app. Every call is audit-logged. Read one login when you need it. Do not copy it into chat or a .env file. + security: + - bearerAuth: [] + - apiKeyAuth: [] + parameters: + - $ref: "#/components/parameters/ProjectID" + - $ref: "#/components/parameters/LoginApp" + responses: + "200": + description: Decrypted login + content: + application/json: + schema: + $ref: "#/components/schemas/RevealProjectLoginResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + "404": + $ref: "#/components/responses/ErrorResponse" /projects/{projectID}/alerts: get: tags: [Alerts] @@ -2475,6 +2560,14 @@ components: schema: type: string pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + LoginApp: + name: app + in: path + required: true + description: Stable app id. Not a URL and not a port. + schema: + type: string + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" BucketID: name: bucketID in: path @@ -4408,6 +4501,62 @@ components: value: type: string required: [name, value] + ProjectLogin: + type: object + properties: + id: + type: string + format: uuid + app: + type: string + description: Stable app id. The current origin is separate and may change. + username: + type: string + origin: + type: string + description: Current URL of the app, if known. Not the identity. + notes: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: [id, app, username, origin, notes, created_at, updated_at] + ProjectLoginListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ProjectLogin" + required: [items] + SetProjectLoginRequest: + type: object + properties: + username: + type: string + password: + type: string + description: Plaintext — stored AES-256-GCM encrypted, never returned by list. + origin: + type: string + notes: + type: string + required: [username, password] + RevealProjectLoginResponse: + type: object + properties: + app: + type: string + username: + type: string + password: + type: string + origin: + type: string + required: [app, username, password, origin] AlertRuleConfig: type: object properties: diff --git a/apps/cli/README.md b/apps/cli/README.md index 4137f08..1762c0f 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -53,8 +53,14 @@ primora secrets import .env # import KEY=value pairs primora secrets list # names + urls + timestamps, never values primora secrets get STRIPE_KEY # prints the value (scriptable) primora secrets rm STRIPE_KEY --yes +primora logins set my-app --username ada --origin http://localhost:3000 +primora logins get my-app --json +primora logins list ``` +`logins` is a username and password for a stable app id (`my-app`, a project +slug). The id is not a host or a port. `origin` is only the current URL. + Format: Argon2id (64 MiB / 3 / 4) → XChaCha20-Poly1305 over a single JSON payload; the parameter header is the AEAD's AAD, atomic tmp+rename writes. Clean-room implementation - compatible with nothing but itself. @@ -76,6 +82,8 @@ primora secrets list --remote # metadata only — names, urls, notes primora secrets get STRIPE_KEY --remote # explicit reveal, audit-logged primora secrets rm STRIPE_KEY --remote --yes primora secrets import .env --remote --overwrite +primora logins set my-app --username ada --remote +primora logins get my-app --remote ``` Job payloads reference secrets as `secret://NAME`; the scheduler resolves them @@ -146,15 +154,18 @@ storage volume, `scripts/backup.sh` remains the tool. ## For AI agents -Two safe paths, no secret ever in the agent's context: +Fetch one named value when you need it. Do not paste it into chat and do +not write it to a `.env` file. Lists omit passwords. Project reveals are +audit-logged. The local vault must be unlocked (`primora vault unlock`). -- **Wrapped** — launch the agent under `primora agent … -- claude`. It holds - dummy tokens; the broker attaches real ones on the wire. -- **MCP** — `primora_vault_status`, `primora_vault_list` (metadata only), - `primora_vault_exec` (runs `primora` commands with a secret injected into - the process env; vault/secrets/inject/agent/login and `keys:create` are - denied, so output can never carry a credential). Requires an unlocked - vault session on the host. -- **MCP project vault** — `primora_secrets_list` (metadata only) and - `primora_secrets_set` (write-only). No reveal, no delete: agents reference - `secret://NAME` in job payloads without ever holding the value. +- **Read** — `primora_vault_get` / `primora secrets get` for a local secret, + `primora_secrets_get` for a project secret, `primora_logins_get` (or + `primora logins get`) for a username and password. `remote: true` / + `--remote` selects the project vault. +- **Store a login** — `primora_logins_set` or `primora logins set `. + The app id is stable. Use it again when the port changes. +- **Wrapped** — `primora agent … -- claude` still attaches API tokens on + the wire for providers that honor a base-URL override. Form logins need + the read path above, because the broker cannot type into a website. +- **Inject** — `primora_vault_exec` still runs an allow-listed command with + a secret in its environment, for tools that read env and must not print it. diff --git a/apps/cli/package.json b/apps/cli/package.json index 507a1b0..0fa3921 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -12,7 +12,8 @@ "scripts": { "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"", "dev": "npm run build && node dist/cli.js", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --experimental-strip-types --test src/login-id.test.ts" }, "publishConfig": { "access": "public" diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 6ab7a45..45ed0d6 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -18,6 +18,12 @@ import { } from "./commands/objects.js"; import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js"; import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js"; +import { + cmdLoginsGet, + cmdLoginsList, + cmdLoginsRemove, + cmdLoginsSet, +} from "./commands/logins.js"; import { cmdSecretsGet, cmdSecretsImport, @@ -260,6 +266,36 @@ cli .option("--overwrite", "Replace existing secrets") .action(cmdSecretsImport); +cli + .command("logins:set ", "Store a username and password for an app") + .option("--username ", "Account username") + .option("--password ", "Account password (otherwise prompted or read from stdin)") + .option("--origin ", "Current URL of the app — not its identity") + .option("--notes ", "Free-text notes") + .option("--remote", "Target the project vault on the server instead of the local vault") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "Read the vault password from a file") + .action(cmdLoginsSet); +cli + .command("logins:get [app]", "Print one login (username, password, origin)") + .option("--remote", "Reveal from the project vault on the server (audit-logged)") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "Read the vault password from a file") + .action(cmdLoginsGet); +cli + .command("logins:list", "List logins (app, username, origin — never the password)") + .option("--remote", "List project logins on the server") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "Read the vault password from a file") + .action(cmdLoginsList); +cli + .command("logins:rm [app]", "Delete a login") + .option("--remote", "Delete from the project vault on the server") + .option("--project ", "Project override (with --remote)") + .option("--password-file ", "Read the vault password from a file") + .option("--yes", "Skip the confirmation prompt") + .action(cmdLoginsRemove); + cli .command("inject", "Run a command with vault secrets in its environment") .option("--all", "Inject every vault secret (default when no --env-file)") @@ -317,6 +353,7 @@ cli.example("primora login --url https://primora.example.com"); cli.example("primora vault init"); cli.example("primora secrets import .env"); cli.example("primora secrets list --remote"); +cli.example("primora logins set my-app --username ada --remote"); cli.example("primora inject --env-file .env -- npm run dev"); cli.example("primora agent --anthropic CLAUDE_KEY -- claude"); @@ -334,6 +371,7 @@ const GROUPS = new Set([ "audit", "vault", "secrets", + "logins", "stack", ]); const argv = process.argv.slice(2); @@ -343,6 +381,8 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) { argv[0] = "vault:status"; } else if (argv[0] === "secrets" && !argv[1]) { argv[0] = "secrets:list"; +} else if (argv[0] === "logins" && !argv[1]) { + argv[0] = "logins:list"; } // mri coerces numeric-looking option values ("007" → 7, "" → 0), which diff --git a/apps/cli/src/commands/logins.ts b/apps/cli/src/commands/logins.ts new file mode 100644 index 0000000..8cc6d87 --- /dev/null +++ b/apps/cli/src/commands/logins.ts @@ -0,0 +1,270 @@ +import { readFileSync } from "node:fs"; + +import * as p from "@clack/prompts"; +import { SecretsService, type ProjectLogin } from "@primora/api-client"; + +import { loadConfig } from "../config.js"; +import { CliError, configureClient, requireAuth, requireProject } from "../http.js"; +import { confirmOrAbort, dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js"; +import { saveVault, type LoginEntry } from "../vault.js"; +import { APP_RE } from "../login-id.js"; +import { unlockVault, type VaultPasswordOptions } from "./vault.js"; +import { type RemoteOptions } from "./secrets.js"; + +export { APP_RE }; + +export function checkApp(app: string): void { + if (!APP_RE.test(app)) { + throw new CliError( + `Invalid app id "${app}".`, + "Use a stable name like my-app or stripe.dashboard - not a URL or a port.", + ); + } +} + +function remoteProject(options: RemoteOptions): string { + const cfg = loadConfig(); + requireAuth(cfg); + configureClient(cfg); + return requireProject(cfg, options.project); +} + +function loginsOf(vault: { data: { logins?: Record } }): Record { + vault.data.logins ??= {}; + return vault.data.logins; +} + +export interface LoginSetOptions extends VaultPasswordOptions, RemoteOptions { + username?: string; + password?: string; + origin?: string; + notes?: string; +} + +export async function cmdLoginsSet(app: string, options: LoginSetOptions): Promise { + checkApp(app); + let username = options.username?.trim(); + let password = options.password; + if (password === undefined && !process.stdin.isTTY) { + password = readFileSync(0, "utf8").replace(/\r?\n$/, ""); + } + if (!username || password === undefined) { + if (!process.stdin.isTTY) { + throw new CliError("Usage: primora logins set --username --password "); + } + } + if (!username) { + const entered = await p.text({ message: "Username" }); + if (p.isCancel(entered)) { + p.cancel("Cancelled."); + process.exit(0); + } + username = entered.trim(); + } + if (!username) throw new CliError("Username is required."); + if (password === undefined) { + const entered = await p.password({ message: `Password for ${username}` }); + if (p.isCancel(entered)) { + p.cancel("Cancelled."); + process.exit(0); + } + password = entered; + } + if (!password) throw new CliError("Password is required."); + + if (options.remote) { + const projectId = remoteProject(options); + await SecretsService.setProjectLogin({ + projectId, + app, + requestBody: { + username, + password, + origin: options.origin, + notes: options.notes, + }, + }); + success(`Stored login ${app} in the project vault`); + return; + } + + const vault = await unlockVault(options); + const now = new Date().toISOString(); + const logins = loginsOf(vault); + const existing = logins[app]; + logins[app] = { + username, + password, + origin: options.origin ?? existing?.origin, + notes: options.notes ?? existing?.notes, + created_at: existing?.created_at ?? now, + updated_at: now, + }; + await withSpinner("Encrypting vault…", async () => saveVault(vault)); + success(`${existing ? "Updated" : "Stored"} login ${app}`); +} + +async function pickRemote(projectId: string, message: string): Promise { + const { items } = await SecretsService.listProjectLogins({ projectId }); + if (!items.length) { + dim("No project logins — `primora logins set --remote`."); + process.exit(0); + } + const picked = await p.select({ + message, + options: items.map((s) => ({ + value: s.app, + label: s.app, + hint: s.username + (s.origin ? ` · ${s.origin}` : ""), + })), + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + process.exit(0); + } + return picked; +} + +function localPublic(app: string, entry: LoginEntry) { + return { + app, + username: entry.username, + origin: entry.origin ?? "", + notes: entry.notes ?? "", + created_at: entry.created_at, + updated_at: entry.updated_at, + }; +} + +export async function cmdLoginsGet( + app: string | undefined, + options: VaultPasswordOptions & RemoteOptions & { json?: boolean }, +): Promise { + if (options.remote) { + const projectId = remoteProject(options); + app ??= await pickRemote(projectId, "Login to reveal"); + checkApp(app); + const res = await SecretsService.revealProjectLogin({ projectId, app }); + emitLogin(res, isJson(options)); + return; + } + const vault = await unlockVault(options); + const logins = loginsOf(vault); + if (!app) { + if (!process.stdin.isTTY) throw new CliError("Usage: primora logins get "); + const names = Object.keys(logins).sort(); + if (!names.length) { + dim("No logins — `primora logins set `."); + return; + } + const picked = await p.select({ + message: "Login to reveal", + options: names.map((n) => ({ value: n, label: n, hint: logins[n].username })), + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + process.exit(0); + } + app = picked; + } + checkApp(app); + const entry = logins[app]; + if (!entry) throw new CliError(`Login "${app}" not found.`); + emitLogin( + { + app, + username: entry.username, + password: entry.password, + origin: entry.origin ?? "", + }, + isJson(options), + ); +} + +function emitLogin( + row: { app: string; username: string; password: string; origin: string }, + json: boolean, +): void { + if (json) { + printJson(row); + return; + } + process.stdout.write(`app: ${row.app}\nusername: ${row.username}\norigin: ${row.origin || "—"}\n${row.password}\n`); +} + +export async function cmdLoginsList( + options: VaultPasswordOptions & RemoteOptions & { json?: boolean }, +): Promise { + if (options.remote) { + const projectId = remoteProject(options); + const { items } = await SecretsService.listProjectLogins({ projectId }); + if (isJson(options)) { + printJson(items); + return; + } + if (!items.length) { + dim("No project logins — `primora logins set --remote`."); + return; + } + printTable(["APP", "USERNAME", "ORIGIN", "UPDATED"], items.map(remoteRow)); + return; + } + const vault = await unlockVault(options); + const rows = Object.entries(loginsOf(vault)).sort(([a], [b]) => a.localeCompare(b)); + if (isJson(options)) { + printJson(rows.map(([app, e]) => localPublic(app, e))); + return; + } + if (!rows.length) { + dim("No logins — `primora logins set `."); + return; + } + printTable( + ["APP", "USERNAME", "ORIGIN", "UPDATED"], + rows.map(([app, e]) => [app, e.username, e.origin ?? "—", formatDate(e.updated_at)]), + ); +} + +function remoteRow(s: ProjectLogin): string[] { + return [s.app, s.username, s.origin || "—", formatDate(s.updated_at)]; +} + +export async function cmdLoginsRemove( + app: string | undefined, + options: VaultPasswordOptions & RemoteOptions & { yes?: boolean }, +): Promise { + if (options.remote) { + const projectId = remoteProject(options); + app ??= await pickRemote(projectId, "Login to delete"); + checkApp(app); + await confirmOrAbort(`Delete project login ${app}?`, options.yes); + await SecretsService.deleteProjectLogin({ projectId, app }); + success(`Deleted login ${app}`); + return; + } + const vault = await unlockVault(options); + const logins = loginsOf(vault); + if (!app) { + if (!process.stdin.isTTY) throw new CliError("Usage: primora logins rm "); + const names = Object.keys(logins).sort(); + if (!names.length) { + dim("No logins."); + return; + } + const picked = await p.select({ + message: "Login to delete", + options: names.map((n) => ({ value: n, label: n })), + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + process.exit(0); + } + app = picked; + } + checkApp(app); + if (!logins[app]) throw new CliError(`Login "${app}" not found.`); + await confirmOrAbort(`Delete login ${app}?`, options.yes); + delete logins[app]; + await withSpinner("Encrypting vault…", async () => saveVault(vault)); + success(`Deleted login ${app}`); +} diff --git a/apps/cli/src/login-id.test.ts b/apps/cli/src/login-id.test.ts new file mode 100644 index 0000000..d8f4668 --- /dev/null +++ b/apps/cli/src/login-id.test.ts @@ -0,0 +1,13 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { APP_RE } from "./login-id.ts"; + +test("app id is a stable name, not a url or a port", () => { + for (const app of ["primora", "MyApp", "stripe.dashboard", "a", "a".repeat(64)]) { + assert.equal(APP_RE.test(app), true, app); + } + for (const app of ["", "http://localhost:3000", "localhost:3000", "has space", "-x", "a".repeat(65)]) { + assert.equal(APP_RE.test(app), false, app); + } +}); diff --git a/apps/cli/src/login-id.ts b/apps/cli/src/login-id.ts new file mode 100644 index 0000000..a2483ae --- /dev/null +++ b/apps/cli/src/login-id.ts @@ -0,0 +1,3 @@ +// Stable app id. A port is not an identity — localhost:3000 can serve +// something else tomorrow. Dots and hyphens are allowed; URLs are not. +export const APP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; diff --git a/apps/cli/src/vault.ts b/apps/cli/src/vault.ts index 81be9c0..958d45d 100644 --- a/apps/cli/src/vault.ts +++ b/apps/cli/src/vault.ts @@ -34,9 +34,21 @@ export interface SecretEntry { updated_at: string; } +/** A website or app account. `app` is the stable id; `origin` is the current URL. */ +export interface LoginEntry { + username: string; + password: string; + origin?: string; + notes?: string; + created_at: string; + updated_at: string; +} + export interface VaultData { v: 1; secrets: Record; + /** Absent in vaults written before logins existed. */ + logins?: Record; } const MAGIC = "PRMVLT01"; @@ -189,7 +201,10 @@ function decrypt(f: ParsedFile, key: Uint8Array): VaultData { try { const plain = xchacha20poly1305(key, f.nonce, f.header).decrypt(f.buf.subarray(HEADER_LEN)); const data = JSON.parse(dec.decode(plain)) as VaultData; - if (data.v !== 1 || typeof data.secrets !== "object") throw new Error("shape"); + if (data.v !== 1 || typeof data.secrets !== "object" || data.secrets === null) throw new Error("shape"); + // Old vaults have no logins map. Treat that as empty so the first save + // keeps every secret and just adds the field. + if (typeof data.logins !== "object" || data.logins === null) data.logins = {}; return data; } catch { throw new CliError("Wrong password or corrupt vault."); @@ -243,5 +258,5 @@ export function createVault(password: Uint8Array): void { } const salt = crypto.getRandomValues(new Uint8Array(16)); const key = argon2id(password, salt, { ...KDF, dkLen: 32 }); - saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF }); + saveVault({ data: { v: 1, secrets: {}, logins: {} }, key, salt, params: KDF }); } diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 02105fc..7d6d016 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -6,5 +6,6 @@ "noEmit": true, "types": ["node"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b760d09..3b50135 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -185,6 +185,42 @@ fn vault_rm(name: String) -> Result<(), String> { run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ()) } +#[tauri::command] +fn vault_logins() -> Result { + let out = run_cli(&["logins", "list", "--json"], None, None)?; + serde_json::from_str(&out).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn vault_login_reveal(app: String) -> Result { + let out = run_cli(&["logins", "get", &app, "--json"], None, None)?; + serde_json::from_str(&out).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn vault_login_set( + app: String, + username: String, + password: String, + origin: Option, + notes: Option, +) -> Result<(), String> { + // Password on stdin, not argv — the CLI reads stdin when --password is omitted. + let mut args = vec!["logins", "set", app.as_str(), "--username", username.as_str()]; + if let Some(o) = origin.as_deref().filter(|s| !s.is_empty()) { + args.extend(["--origin", o]); + } + if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) { + args.extend(["--notes", n]); + } + run_cli(&args, Some(&password), None).map(|_| ()) +} + +#[tauri::command] +fn vault_login_rm(app: String) -> Result<(), String> { + run_cli(&["logins", "rm", &app, "--yes"], None, None).map(|_| ()) +} + #[tauri::command] fn vault_import(path: String, overwrite: bool) -> Result { let mut args = vec!["secrets", "import", path.as_str()]; @@ -215,6 +251,10 @@ pub fn run() { vault_set, vault_rm, vault_import, + vault_logins, + vault_login_reveal, + vault_login_set, + vault_login_rm, ]) .setup(|app| { let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?; diff --git a/apps/desktop/ui/vault.js b/apps/desktop/ui/vault.js index f0ce22b..57e1962 100644 --- a/apps/desktop/ui/vault.js +++ b/apps/desktop/ui/vault.js @@ -203,6 +203,41 @@ async function renderUnlocked(st) { }, }, "Import .env"); + const logins = await tauri.core.invoke("vault_logins").catch(() => []); + const loginTable = logins.length + ? el("table", { class: "vtable" }, + el("thead", {}, el("tr", {}, + el("th", {}, "app"), el("th", {}, "username"), el("th", {}, "origin"), el("th", {}, ""))), + el("tbody", {}, ...logins.map(loginRow))) + : dimNote("No logins yet."); + + const app = el("input", { placeholder: "app id", class: "mono", spellcheck: "false" }); + const user = el("input", { placeholder: "username", autocomplete: "off" }); + const pass = el("input", { type: "password", placeholder: "password", autocomplete: "off" }); + const origin = el("input", { placeholder: "origin (optional)", inputmode: "url" }); + const loginErr = el("p", { class: "err", hidden: true }); + const addLogin = el("button", { + onclick: async () => { + loginErr.hidden = true; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(app.value)) { + loginErr.textContent = "App id is a stable name like my-app — not a URL or a port."; + loginErr.hidden = false; return; + } + if (!user.value.trim() || !pass.value) { + loginErr.textContent = "Username and password are required."; + loginErr.hidden = false; return; + } + busy(addLogin, "Encrypting…"); + try { + await tauri.core.invoke("vault_login_set", { + app: app.value, username: user.value.trim(), password: pass.value, + origin: origin.value || null, notes: null, + }); + } catch (e) { loginErr.textContent = String(e); loginErr.hidden = false; unbusy(addLogin); return; } + refresh(); + }, + }, "Add login"); + view.replaceChildren( el("section", { class: "vcard" }, el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`), @@ -210,12 +245,47 @@ async function renderUnlocked(st) { el("div", { class: "vform" }, name, value, url, notes, addBtn), el("div", { class: "vform" }, importPath, importBtn), err), + el("section", { class: "vcard" }, + el("h2", {}, `${logins.length} login${logins.length === 1 ? "" : "s"}`), + el("p", { class: "sub" }, "Username and password for an app. The app id stays put when the port changes."), + loginTable, + el("div", { class: "vform" }, app, user, pass, origin, addLogin), + loginErr), el("p", { class: "hint" }, "Values are never shown here — copy goes straight to the clipboard. " + - "Injection and the credential broker stay in the CLI: primora inject / primora agent.") + "Read one secret or login from an agent with primora_vault_get or primora_logins_get. " + + "Do not paste it into chat or a .env file.") ); } +function loginRow(s) { + const copy = el("button", { class: "ghost sm", title: "Copy password" }, "copy"); + copy.addEventListener("click", async () => { + try { + const row = await tauri.core.invoke("vault_login_reveal", { app: s.app }); + await navigator.clipboard.writeText(row.password); + copy.textContent = "copied"; + setTimeout(() => (copy.textContent = "copy"), 1200); + } catch { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); } + }); + const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete"); + del.addEventListener("click", async () => { + if (del.dataset.armed) { + try { await tauri.core.invoke("vault_login_rm", { app: s.app }); } catch {} + refresh(); + return; + } + del.dataset.armed = "1"; + del.textContent = "sure?"; + setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500); + }); + return el("tr", {}, + el("td", { class: "mono" }, s.app), + el("td", {}, s.username), + el("td", { class: "dimcell" }, s.origin || "—"), + el("td", { class: "acts" }, copy, del)); +} + /* ---------- boot ---------- */ async function refresh() { diff --git a/apps/frontend/src/lib/__tests__/agentPrompt.test.ts b/apps/frontend/src/lib/__tests__/agentPrompt.test.ts index 072d3c6..8289f6d 100644 --- a/apps/frontend/src/lib/__tests__/agentPrompt.test.ts +++ b/apps/frontend/src/lib/__tests__/agentPrompt.test.ts @@ -28,6 +28,8 @@ describe("buildAgentPrompt", () => { expect(prompt).toContain("/db-connections"); expect(prompt).toContain("deploy-markers"); expect(prompt).toContain("PRIMORA_PROJECT"); + expect(prompt).toContain(`/logins/${base.projectSlug}`); + expect(prompt).toContain("do not write it into `.env`"); for (const t of ["postgres", "mysql", "mongodb", "redis"]) { expect(prompt).toContain(t); } diff --git a/apps/frontend/src/lib/agent-prompt.ts b/apps/frontend/src/lib/agent-prompt.ts index 3614e61..1c81065 100644 --- a/apps/frontend/src/lib/agent-prompt.ts +++ b/apps/frontend/src/lib/agent-prompt.ts @@ -113,7 +113,28 @@ ${mcpSection} Optional CLI path: \`primora login --api-key $PRIMORA_API_KEY\`, then \`primora use\`. For wrapping third-party agents with brokered credentials (the agent never holds real keys), use \`primora agent -- -- \`. -## 4. Prove it works +## 4. Logins — accounts you create or sign in with + +When you create a test account for this app, or you need a username and password for any site, store it. Do not paste the password into the chat and do not write it into \`.env\`. + +The app id is stable. Use \`${input.projectSlug}\` for this project. A port is not an identity — \`localhost:3000\` can serve a different app later. For any other site, use a short slug (\`github\`, \`stripe-dashboard\`). \`origin\` is only the current URL. + +\`\`\` +PUT ${api}/projects/${p}/logins/${input.projectSlug} +X-Primora-Key: $PRIMORA_API_KEY + +{"username":"…","password":"…","origin":"http://localhost:3000"} +\`\`\` + +Read it back only when you are about to sign in. The call is audit-logged: + +\`\`\` +POST ${api}/projects/${p}/logins/${input.projectSlug}/reveal +\`\`\` + +The same tools cover the local vault on this machine once \`primora vault unlock\` has been run: \`primora logins set ${input.projectSlug} --username …\` (add \`--remote\` for the project vault). MCP: \`primora_logins_set\`, \`primora_logins_get\`, \`primora_secrets_get\`, \`primora_vault_get\`. Lists never include the password. + +## 5. Prove it works 1. \`curl -sS -X POST ${api}/ingest -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"events":[{"type":"event","component":"setup","message":"primora.setup.done"}]}'\` — expect \`{"accepted":1}\`. 2. Record a deploy marker: \`curl -sS -X POST ${api}/projects/${p}/deploy-markers -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"ref":"","environment":"dev","note":"agent setup"}'\`. diff --git a/apps/frontend/src/pages/VaultPage.tsx b/apps/frontend/src/pages/VaultPage.tsx index fd9c817..5f113fe 100644 --- a/apps/frontend/src/pages/VaultPage.tsx +++ b/apps/frontend/src/pages/VaultPage.tsx @@ -1,6 +1,7 @@ import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"; import { SecretsService, + type ProjectLogin, type ProjectSecret, } from "@primora/api-client"; import { Modal } from "../components/Modal"; @@ -22,6 +23,12 @@ const DEMO_SECRETS: ProjectSecret[] = [ { id: "d2", name: "SLACK_WEBHOOK", url: "https://hooks.slack.com", notes: "alerts channel", created_at: "", updated_at: "2026-09-10T08:00:00Z" }, ]; +const DEMO_LOGINS: ProjectLogin[] = [ + { id: "l1", app: "primora", username: "ada", origin: "http://localhost:3000", notes: "test account", created_at: "", updated_at: "2026-09-18T10:00:00Z" }, +]; + +const APP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + // Inside the Tauri shell, __TAURI__ is injected into every webview — the same // IPC commands the dedicated vault window uses. Absent in a plain browser. const tauri = () => @@ -35,6 +42,13 @@ interface LocalSecret { notes: string | null; updated_at: string; } +interface LocalLogin { + app: string; + username: string; + origin: string; + notes: string; + updated_at: string; +} interface LocalStatus { exists: boolean; unlocked: boolean; @@ -45,16 +59,20 @@ interface LocalStatus { export function VaultPage(props: VaultPageProps) { const [secrets, setSecrets] = createSignal([]); + const [logins, setLogins] = createSignal([]); const [message, setMessage] = createSignal(""); const [error, setError] = createSignal(""); const [busy, setBusy] = createSignal(); const [showCreate, setShowCreate] = createSignal(false); + const [showLogin, setShowLogin] = createSignal(false); const [showImport, setShowImport] = createSignal(false); const [form, setForm] = createSignal({ name: "", value: "", url: "", notes: "" }); + const [loginForm, setLoginForm] = createSignal({ app: "", username: "", password: "", origin: "", notes: "" }); const [importText, setImportText] = createSignal(""); const [localStatus, setLocalStatus] = createSignal(); const [localSecrets, setLocalSecrets] = createSignal([]); + const [localLogins, setLocalLogins] = createSignal([]); const [localErr, setLocalErr] = createSignal(""); const [localPw, setLocalPw] = createSignal(""); const [localTtl, setLocalTtl] = createSignal("900"); @@ -62,15 +80,30 @@ export function VaultPage(props: VaultPageProps) { const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000); onCleanup(() => clearInterval(timer)); + // Demo data is in-memory. Seed once per project so a later refresh keeps + // rows the user added or deleted, including an empty list. + let demoProject = ""; + const refresh = async () => { if (!props.projectID) return; if (props.demoMode) { - setSecrets(DEMO_SECRETS); + if (demoProject !== props.projectID) { + const switched = demoProject !== ""; + demoProject = props.projectID; + setSecrets(DEMO_SECRETS); + setLogins(DEMO_LOGINS); + if (switched) { + setMessage(""); + setError(""); + } + } return; } try { const res = await SecretsService.listProjectSecrets({ projectId: props.projectID }); setSecrets(res.items); + const log = await SecretsService.listProjectLogins({ projectId: props.projectID }); + setLogins(log.items); } catch (e) { setError(err(e)); } @@ -83,6 +116,7 @@ export function VaultPage(props: VaultPageProps) { const st = (await t.core.invoke("vault_status")) as LocalStatus; setLocalStatus(st); setLocalSecrets(st.unlocked ? ((await t.core.invoke("vault_secrets")) as LocalSecret[]) : []); + setLocalLogins(st.unlocked ? ((await t.core.invoke("vault_logins")) as LocalLogin[]) : []); } catch (e) { setLocalErr(err(e)); } @@ -163,6 +197,53 @@ export function VaultPage(props: VaultPageProps) { }, `Secret ${f.name} stored`); }; + const revealLogin = (app: string) => + run(`reveal-login-${app}`, async () => { + if (props.demoMode) { + await copy("demo-password", app); + return; + } + const res = await SecretsService.revealProjectLogin({ projectId: props.projectID!, app }); + await copy(res.password, app); + }); + + const createLogin = (e: Event) => { + e.preventDefault(); + const f = loginForm(); + const app = f.app.trim(); + if (!APP_RE.test(app)) { + setError("App id must be a stable name like my-app - not a URL or a port."); + return; + } + void run("create-login", async () => { + if (props.demoMode) { + setLogins([...logins(), { id: `l${Date.now()}`, app, username: f.username.trim(), origin: f.origin.trim(), notes: f.notes.trim(), created_at: "", updated_at: new Date().toISOString() }]); + } else { + await SecretsService.setProjectLogin({ + projectId: props.projectID!, + app, + requestBody: { + username: f.username.trim(), + password: f.password, + origin: f.origin.trim() || undefined, + notes: f.notes.trim() || undefined, + }, + }); + } + setShowLogin(false); + setLoginForm({ app: "", username: "", password: "", origin: "", notes: "" }); + }, `Login ${app} stored`); + }; + + const removeLogin = (app: string) => + run(`del-login-${app}`, async () => { + if (props.demoMode) { + setLogins(logins().filter((s) => s.app !== app)); + return; + } + await SecretsService.deleteProjectLogin({ projectId: props.projectID!, app }); + }, `Login ${app} deleted`); + const removeSecret = (name: string) => run(`del-${name}`, async () => { if (props.demoMode) { @@ -207,13 +288,16 @@ export function VaultPage(props: VaultPageProps) {

Vault

- Encrypted project secrets — reference them in job payloads as secret://NAME, resolved at - delivery. Values are never included in lists. + Encrypted project secrets and app logins. Secrets are referenced in job payloads as secret://NAME. + A login is a username and password for a stable app id - the URL can change. Lists never include passwords.

+ @@ -280,6 +364,61 @@ export function VaultPage(props: VaultPageProps) {
+
+
+
+
App logins
+
+ Username and password for an app. The app id stays put when the port changes. Copy is audit-logged. +
+
+
+ 0} + fallback={

No logins yet.

} + > +
+ + + + + + + + + + + + + + {(s) => ( + + + + + + + + + )} + + +
AppUsernameOriginNotesUpdatedActions
{s.app}{s.username}{s.origin ? {s.origin.replace(/^https?:\/\//, "")} : "—"}{s.notes || "—"}{fmtTime(s.updated_at)} +
+ + + + +
+
+
+
+
+
@@ -366,6 +505,40 @@ export function VaultPage(props: VaultPageProps) {
+ 0}> +

Logins on this machine

+
+ + + + + + + {(s) => ( + + + + + + + )} + + +
AppUsernameOriginActions
{s.app}{s.username}{s.origin || "—"} +
+ + +
+
+
+

Injection and the credential broker stay in the CLI: primora inject / primora agent. @@ -390,6 +563,20 @@ export function VaultPage(props: VaultPageProps) { + setShowLogin(false)} title="New login" size="md"> +

+ setLoginForm({ ...loginForm(), app: e.currentTarget.value })} required /> + setLoginForm({ ...loginForm(), username: e.currentTarget.value })} required /> + setLoginForm({ ...loginForm(), password: e.currentTarget.value })} required /> + setLoginForm({ ...loginForm(), origin: e.currentTarget.value })} /> + setLoginForm({ ...loginForm(), notes: e.currentTarget.value })} /> +
+ + +
+
+ + setShowImport(false)} title="Import .env" size="md">