mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: encrypted secrets vault across CLI, desktop, dashboard, and backend
Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id → XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:* / inject (primora:// refs) / agent credential broker (dummy tokens in the child, real secret on the wire) / TTL sessions / stack:* operator group. Project vault (backend): core.project_secrets, AES-256-GCM via the platform encryptor. Metadata-only list, audit-logged reveal, and secret://NAME refs resolved at job delivery — refs stored, values never. Surfaces: dashboard Vault page (+ local-vault card under the Tauri shell), desktop vault window via IPC to the primora binary, secrets:* --remote CLI mode, metadata/write-only MCP tools so agents consume secrets via secret:// refs without holding plaintext. Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
31bc23386b
commit
585bb04ab2
52 files changed
+3929
-142
No files matched your search
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
|
||||
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
|
||||
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
|
||||
- **`primora inject`** — runs a child process with vault secrets in its environment (`--all`, or `--env-file` templates containing `primora://NAME` refs). Nothing on disk, nothing in argv.
|
||||
- **`primora stack:*` operator group** — `up`, `down`, `status`, `logs [service]`, `pull`, `backup` wrap `docker compose` in `--dir` (default cwd). `stack up --vault` materializes `.env` from the vault for the duration of the run (refuses to clobber an existing `.env` without `--force`, which swaps and restores it). `stack backup` writes a `pg_dump -Fc` of the platform database to `./backups`.
|
||||
- **Vault MCP tools** (`@primora/mcp`) — `primora_vault_status`, `primora_vault_list` (metadata only — values are never returned), and `primora_vault_exec` which runs `primora` commands with a secret injected into the child environment; `vault`/`secrets`/`inject`/`agent`/`login`/`logout`/`use` and `keys:create` are denied so no response can carry a credential.
|
||||
- **Secrets Vault window in the desktop app** (`@primora/desktop`) — tray "Secrets Vault…" opens a dedicated window (`ui/vault.html`) covering init, timed unlock/lock with live countdown, secret list with copy-to-clipboard (values never displayed), add/delete, and `.env` import. IPC shells out to the `primora` binary (`PRIMORA_CLI` overrides), so desktop and terminal share one vault file and one crypto implementation; a missing CLI shows install guidance. Also reachable from the connect screen.
|
||||
- **Project vault — server-side secrets** (`core.project_secrets`, migration 00011) — per-project secrets encrypted AES-256-GCM at rest via the existing encryptor. API: `GET/PUT/DELETE /projects/:projectID/secrets[/:name]` plus `POST …/reveal`. Lists return metadata only (name, url, notes, timestamps); reveal decrypts explicitly and is audit-logged (`secret.set`/`secret.revealed`/`secret.deleted`). Scheduled-job payloads reference secrets as `secret://NAME`, resolved to plaintext at delivery — refs are stored, values are not; unresolved or unconfigured refs fail the run loudly.
|
||||
- **Vault page in the dashboard** — project secrets table with create/update, `.env` import, delete, and copy-to-clipboard reveal; plus a "This machine" card when running inside the desktop shell that drives the local vault through the same Tauri IPC commands as the vault window.
|
||||
- **CLI remote mode** — `primora secrets set|get|list|rm|import --remote` operates on the project vault via the existing API auth (`--project` overrides). `get --remote` is the explicit reveal path; `list --remote` never returns values.
|
||||
- **Project-vault MCP tools** — `primora_secrets_list` (metadata only) and `primora_secrets_set` (write-only). No reveal or delete tools — agents consume secrets through `secret://NAME` references in job payloads without holding plaintext.
|
||||
|
||||
## [0.7.1] - 2026-09-23
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
| `DRAGONFLY_URL` | backend → cache DSN | yes |
|
||||
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
|
||||
| `BETTER_AUTH_SECRET` | auth session/signing secret | yes |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for integration credentials + webhook secrets at rest | production |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production |
|
||||
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||
|
||||
@@ -61,6 +61,7 @@ The important ones:
|
||||
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
|
||||
| `DRAGONFLY_URL` | Cache/rate-limit store |
|
||||
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) |
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars, `openssl rand -hex 32`) — encrypts the project vault, integration credentials and webhook secrets at rest. Without it the Vault page and `secret://` job refs are unavailable |
|
||||
| `SIGNUP_ENABLED` | Open public sign-up after bootstrap — default `false`; admins override in-app |
|
||||
| `PRIMORA_MANAGED` | Managed tier flag — enables OAuth providers when `true`; self-hosted is email/password only |
|
||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
|
||||
|
||||
@@ -91,9 +91,9 @@ and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, and audit from the terminal.
|
||||
- **[CLI](apps/cli)** - `primora` for orgs, projects, buckets, objects, keys, jobs, and audit — plus an encrypted local secrets vault (`vault:*`, `secrets:*`, `inject`), the shared project vault (`secrets:* --remote`, `secret://NAME` refs in job payloads), an agent credential broker (`agent`), and compose-stack operator commands (`stack:*`).
|
||||
- **[MCP server](apps/mcp)** - exposes `primora_*` tools to MCP clients.
|
||||
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence.
|
||||
- **[Desktop](apps/desktop)** - Tauri 2 shell connecting to your deployment, with tray presence and a local Secrets Vault window (drives the CLI's vault).
|
||||
- **PWA** - the dashboard is installable on mobile and desktop browsers.
|
||||
|
||||
## Documentation
|
||||
|
||||
+18
@@ -263,6 +263,24 @@ dashboard's live feed off the same stream.
|
||||
`auth-client` now resolves `/auth` against `window.location.origin`,
|
||||
preserving same-origin defaults without breaking split-origin dev.
|
||||
|
||||
## Secrets vault (unreleased)
|
||||
|
||||
Arca-inspired secrets management, clean-room — every surface covered.
|
||||
|
||||
- **Local vault** (`apps/cli`) — `PRMVLT01` format: Argon2id (64 MiB/3/4) →
|
||||
XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes. `vault:*`,
|
||||
`secrets:*`, `inject` (env injection + `primora://` refs), TTL sessions,
|
||||
and the `agent` credential broker (dummy tokens in the child, real secret
|
||||
attached on the wire per grant).
|
||||
- **Project vault** (`core.project_secrets`, migration 00011) — AES-256-GCM
|
||||
via the platform encryptor; metadata-only list, audit-logged reveal;
|
||||
`secret://NAME` refs in job payloads resolve at delivery and fail loudly
|
||||
when unresolved.
|
||||
- **Surfaces** — dashboard Vault page (+ local-vault card under the desktop
|
||||
shell), desktop vault window via Tauri IPC → `primora` binary,
|
||||
`secrets:* --remote` CLI mode, and MCP tools that are metadata/write-only
|
||||
so agents never hold plaintext.
|
||||
|
||||
## Future phases — candidates
|
||||
|
||||
Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's
|
||||
-- Encryptor. Values are write-once — list/read expose metadata only; a
|
||||
-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME
|
||||
-- references in job payloads resolve at delivery time.
|
||||
CREATE TABLE core.project_secrets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
ciphertext BYTEA NOT NULL,
|
||||
url 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, name)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.project_secrets;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- name: ListProjectSecrets :many
|
||||
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
ORDER BY name ASC;
|
||||
|
||||
-- name: GetProjectSecret :one
|
||||
SELECT * FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2;
|
||||
|
||||
-- name: UpsertProjectSecret :one
|
||||
INSERT INTO core.project_secrets (
|
||||
project_id,
|
||||
name,
|
||||
ciphertext,
|
||||
url,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
url = EXCLUDED.url,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteProjectSecret :one
|
||||
DELETE FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
RETURNING id;
|
||||
|
||||
-- name: ListProjectSecretValues :many
|
||||
SELECT name, ciphertext
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1;
|
||||
@@ -307,6 +307,17 @@ type CoreProjectMember struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreProjectSecret struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreScheduledJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: project_secrets.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
|
||||
DELETE FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type DeleteProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getProjectSecret = `-- name: GetProjectSecret :one
|
||||
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
AND name = $2
|
||||
`
|
||||
|
||||
type GetProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
|
||||
var i CoreProjectSecret
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Ciphertext,
|
||||
&i.Url,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
|
||||
SELECT name, ciphertext
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
`
|
||||
|
||||
type ListProjectSecretValuesRow struct {
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListProjectSecretValuesRow
|
||||
for rows.Next() {
|
||||
var i ListProjectSecretValuesRow
|
||||
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listProjectSecrets = `-- name: ListProjectSecrets :many
|
||||
SELECT id, project_id, name, url, notes, created_at, updated_at
|
||||
FROM core.project_secrets
|
||||
WHERE project_id = $1
|
||||
ORDER BY name ASC
|
||||
`
|
||||
|
||||
type ListProjectSecretsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListProjectSecretsRow
|
||||
for rows.Next() {
|
||||
var i ListProjectSecretsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&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 upsertProjectSecret = `-- name: UpsertProjectSecret :one
|
||||
INSERT INTO core.project_secrets (
|
||||
project_id,
|
||||
name,
|
||||
ciphertext,
|
||||
url,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (project_id, name) DO UPDATE SET
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
url = EXCLUDED.url,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertProjectSecretParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Url string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
|
||||
row := q.db.QueryRow(ctx, upsertProjectSecret,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.Ciphertext,
|
||||
arg.Url,
|
||||
arg.Notes,
|
||||
)
|
||||
var i CoreProjectSecret
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.Name,
|
||||
&i.Ciphertext,
|
||||
&i.Url,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -45,6 +45,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)
|
||||
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
|
||||
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
|
||||
@@ -68,6 +69,7 @@ type Querier interface {
|
||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, 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)
|
||||
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
|
||||
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
|
||||
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
|
||||
@@ -96,6 +98,8 @@ type Querier interface {
|
||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, 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)
|
||||
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
|
||||
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
|
||||
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
|
||||
@@ -135,6 +139,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)
|
||||
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
|
||||
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
|
||||
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
|
||||
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
|
||||
api.GET("/projects/:projectID/secrets", h.listProjectSecrets)
|
||||
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/realtime/stream", h.realtimeStream)
|
||||
api.GET("/buckets/:bucketID/objects", h.listObjects)
|
||||
api.POST("/buckets/:bucketID/objects", h.uploadObject)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"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) secretError(c *gin.Context, err error) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
|
||||
return
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "invalid secret name") {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
return
|
||||
}
|
||||
if strings.Contains(msg, "encryption is not configured") {
|
||||
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
|
||||
return
|
||||
}
|
||||
h.handleError(c, err)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.SetProjectSecretInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
body.Name = c.Param("name")
|
||||
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.secretError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -393,6 +393,7 @@ type jobQueries interface {
|
||||
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
|
||||
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
|
||||
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
|
||||
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
|
||||
}
|
||||
|
||||
type JobScheduler struct {
|
||||
@@ -552,6 +553,10 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
|
||||
|
||||
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
|
||||
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
|
||||
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": WebhookEventJobRun,
|
||||
"project_id": job.ProjectID.String(),
|
||||
@@ -560,7 +565,7 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
|
||||
"run_id": run.ID.String(),
|
||||
"triggered_by": run.TriggeredBy,
|
||||
"occurred_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"data": json.RawMessage(job.Payload),
|
||||
"data": data,
|
||||
})
|
||||
secret := ""
|
||||
if len(job.Secret) > 0 && j.enc != nil {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
// Project secrets are the server-side counterpart of the CLI's local vault:
|
||||
// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads
|
||||
// reference them as secret://NAME — resolved at delivery, never stored in
|
||||
// plaintext config.
|
||||
|
||||
var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
type ProjectSecretSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SetProjectSecretInput struct {
|
||||
Name string `json:"name"` // from the path param, not the body
|
||||
Value string `json:"value" validate:"required"`
|
||||
URL *string `json:"url"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type RevealProjectSecretResponse struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary {
|
||||
return ProjectSecretSummary{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProjectSecretSummary, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, toProjectSecretSummary(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if !projectSecretNameRe.MatchString(name) {
|
||||
return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
if s.enc == nil {
|
||||
return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||
}
|
||||
ciphertext, err := s.enc.Encrypt([]byte(input.Value))
|
||||
if err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
// Merge url/notes over the existing row when the caller omits them —
|
||||
// `secrets set NAME` should not wipe metadata it never saw.
|
||||
var url, notes string
|
||||
if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil {
|
||||
url, notes = existing.Url, existing.Notes
|
||||
} else if err != pgx.ErrNoRows {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
if input.URL != nil {
|
||||
url = strings.TrimSpace(*input.URL)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
notes = strings.TrimSpace(*input.Notes)
|
||||
}
|
||||
row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{
|
||||
ProjectID: projectID,
|
||||
Name: name,
|
||||
Ciphertext: ciphertext,
|
||||
Url: url,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return ProjectSecretSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return ProjectSecretSummary{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name})
|
||||
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, "secret.deleted", "project_secret", row.String(), map[string]any{
|
||||
"name": name,
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reveal decrypts a single secret. Deliberately loud: audit-logged every time.
|
||||
func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name})
|
||||
if err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
plain, err := s.enc.Decrypt(row.Ciphertext)
|
||||
if err != nil {
|
||||
return RevealProjectSecretResponse{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{
|
||||
"name": row.Name,
|
||||
}))
|
||||
}
|
||||
return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil
|
||||
}
|
||||
|
||||
// resolveSecretRefs replaces "secret://NAME" string values in a payload with
|
||||
// decrypted values. Names that do not resolve fail loudly — a literal
|
||||
// secret:// string reaching the endpoint is worse than a failed run.
|
||||
// Package-level so the JobScheduler can reuse it through its narrow repo iface.
|
||||
func resolveSecretRefs(
|
||||
ctx context.Context,
|
||||
list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error),
|
||||
enc *secrets.Encryptor,
|
||||
projectID uuid.UUID,
|
||||
payload json.RawMessage,
|
||||
) (json.RawMessage, error) {
|
||||
if len(payload) == 0 || !strings.Contains(string(payload), "secret://") {
|
||||
return payload, nil
|
||||
}
|
||||
if enc == nil {
|
||||
return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment")
|
||||
}
|
||||
rows, err := list(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
plain, err := enc.Decrypt(row.Ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name)
|
||||
}
|
||||
values[row.Name] = string(plain)
|
||||
}
|
||||
var doc any
|
||||
if err := json.Unmarshal(payload, &doc); err != nil {
|
||||
return nil, fmt.Errorf("payload is not valid JSON: %w", err)
|
||||
}
|
||||
var missing []string
|
||||
resolved := walkSecretRefs(doc, values, &missing)
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
out, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func walkSecretRefs(node any, values map[string]string, missing *[]string) any {
|
||||
switch v := node.(type) {
|
||||
case string:
|
||||
if strings.HasPrefix(v, "secret://") {
|
||||
name := strings.TrimPrefix(v, "secret://")
|
||||
if resolved, ok := values[name]; ok {
|
||||
return resolved
|
||||
}
|
||||
*missing = append(*missing, name)
|
||||
return v
|
||||
}
|
||||
return v
|
||||
case map[string]any:
|
||||
for k, item := range v {
|
||||
v[k] = walkSecretRefs(item, values, missing)
|
||||
}
|
||||
return v
|
||||
case []any:
|
||||
for i, item := range v {
|
||||
v[i] = walkSecretRefs(item, values, missing)
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return node
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/secrets"
|
||||
)
|
||||
|
||||
func testEncryptor(t *testing.T) *secrets.Encryptor {
|
||||
t.Helper()
|
||||
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||
enc, err := secrets.NewEncryptor(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor: %v", err)
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func secretLister(t *testing.T, enc *secrets.Encryptor, kv map[string]string) func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
|
||||
t.Helper()
|
||||
return func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
|
||||
rows := make([]db.ListProjectSecretValuesRow, 0, len(kv))
|
||||
for name, value := range kv {
|
||||
ct, err := enc.Encrypt([]byte(value))
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt %s: %v", name, err)
|
||||
}
|
||||
rows = append(rows, db.ListProjectSecretValuesRow{Name: name, Ciphertext: ct})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSecretRefs(t *testing.T) {
|
||||
enc := testEncryptor(t)
|
||||
list := secretLister(t, enc, map[string]string{
|
||||
"API_KEY": "sk_live_99",
|
||||
"RECURSIVE": "secret://API_KEY", // must not resolve twice
|
||||
})
|
||||
pid := uuid.New()
|
||||
|
||||
t.Run("replaces nested and array refs", func(t *testing.T) {
|
||||
payload := json.RawMessage(`{
|
||||
"token": "secret://API_KEY",
|
||||
"nested": {"deep": ["a", "secret://API_KEY", {"x": "secret://API_KEY"}]},
|
||||
"plain": "not a secret",
|
||||
"num": 42
|
||||
}`)
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out, &doc); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if doc["token"] != "sk_live_99" {
|
||||
t.Errorf("token = %v", doc["token"])
|
||||
}
|
||||
deep := doc["nested"].(map[string]any)["deep"].([]any)
|
||||
if deep[1] != "sk_live_99" || deep[2].(map[string]any)["x"] != "sk_live_99" {
|
||||
t.Errorf("nested refs not resolved: %v", deep)
|
||||
}
|
||||
if doc["plain"] != "not a secret" || doc["num"] != float64(42) {
|
||||
t.Errorf("non-ref values changed: %v", doc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ref fails the job", func(t *testing.T) {
|
||||
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://NOPE"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "NOPE") {
|
||||
t.Fatalf("expected unresolved error naming NOPE, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("embedded ref in a larger string is not resolved", func(t *testing.T) {
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"prefix secret://API_KEY suffix"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "secret://API_KEY") {
|
||||
t.Fatalf("embedded ref must pass through unchanged: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resolved value is not re-scanned", func(t *testing.T) {
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://RECURSIVE"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
var doc map[string]any
|
||||
_ = json.Unmarshal(out, &doc)
|
||||
if doc["a"] != "secret://API_KEY" {
|
||||
t.Fatalf("single-pass resolution expected, got %v", doc["a"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid JSON fails loudly when a ref is present", func(t *testing.T) {
|
||||
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{secret://API_KEY`))
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("payloads without refs pass through untouched", func(t *testing.T) {
|
||||
payload := json.RawMessage(`{"a":1}`)
|
||||
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
|
||||
if err != nil || string(out) != string(payload) {
|
||||
t.Fatalf("passthrough failed: %v %s", err, out)
|
||||
}
|
||||
// Nil encryptor must fail loudly — a literal secret:// reaching the
|
||||
// endpoint is worse than a failed run.
|
||||
_, err = resolveSecretRefs(context.Background(), list, nil, pid, json.RawMessage(`{"a":"secret://API_KEY"}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when encryption is not configured")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1838,6 +1838,91 @@ paths:
|
||||
description: text/event-stream of {type, occurred_at, data} messages
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/secrets:
|
||||
get:
|
||||
tags: [Secrets]
|
||||
operationId: listProjectSecrets
|
||||
summary: Lists project secrets — metadata only, values are never included.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Project secrets (metadata only)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProjectSecretListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/secrets/{name}:
|
||||
put:
|
||||
tags: [Secrets]
|
||||
operationId: setProjectSecret
|
||||
summary: Creates or replaces a project secret (AES-256-GCM at rest). Omitted url/notes keep existing values. Job payloads can reference it as secret://NAME, resolved at delivery.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/SecretName"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SetProjectSecretRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Secret stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProjectSecret"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Secrets]
|
||||
operationId: deleteProjectSecret
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/SecretName"
|
||||
responses:
|
||||
"204":
|
||||
description: Secret deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/secrets/{name}/reveal:
|
||||
post:
|
||||
tags: [Secrets]
|
||||
operationId: revealProjectSecret
|
||||
summary: Returns the decrypted value. Every call is audit-logged.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/SecretName"
|
||||
responses:
|
||||
"200":
|
||||
description: Decrypted value
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RevealProjectSecretResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -1863,6 +1948,13 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
SecretName:
|
||||
name: name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
|
||||
BucketID:
|
||||
name: bucketID
|
||||
in: path
|
||||
@@ -3664,3 +3756,50 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/ScheduledJobRun"
|
||||
required: [items]
|
||||
ProjectSecret:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
description: Env-var-safe name; referenced as secret://NAME in job payloads.
|
||||
url:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, name, url, notes, created_at, updated_at]
|
||||
ProjectSecretListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ProjectSecret"
|
||||
required: [items]
|
||||
SetProjectSecretRequest:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
description: Plaintext — stored AES-256-GCM encrypted, never returned by list.
|
||||
url:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
required: [value]
|
||||
RevealProjectSecretResponse:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required: [name, value]
|
||||
+122
-2
@@ -1,6 +1,7 @@
|
||||
# @primora/cli
|
||||
|
||||
`primora` - command-line client for a Primora deployment.
|
||||
`primora` - command-line client for a Primora deployment, plus a local
|
||||
encrypted secrets vault and compose-stack operator commands.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -20,4 +21,123 @@ project interactively.
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`audit list --follow`. Every command accepts `--json`.
|
||||
`jobs`, `audit list --follow`. Every command accepts `--json`.
|
||||
|
||||
## Vault
|
||||
|
||||
A local encrypted vault for secrets the CLI holds - API keys, deployment `.env`
|
||||
values, anything you would otherwise leave in a dotfile or shell history.
|
||||
|
||||
```bash
|
||||
primora vault init # create ~/.config/primora/vault.bin
|
||||
primora secrets set STRIPE_KEY # value prompted, or --value / stdin
|
||||
primora secrets set OPENAI --value sk-… --url https://platform.openai.com --notes "prod"
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Password source order: live session → `PRIMORA_VAULT_PASSWORD` →
|
||||
`--password-file` → hidden prompt. `PRIMORA_VAULT` overrides the file
|
||||
location.
|
||||
|
||||
## Project vault (server)
|
||||
|
||||
`--remote` targets the project's server-side vault instead of the local file —
|
||||
AES-256-GCM at rest in Postgres, shared with the team and the web dashboard's
|
||||
Vault page. No vault password; it uses your `primora login` session or
|
||||
`PRIMORA_API_KEY`.
|
||||
|
||||
```bash
|
||||
primora secrets set STRIPE_KEY --value sk-… --remote
|
||||
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
|
||||
```
|
||||
|
||||
Job payloads reference secrets as `secret://NAME`; the scheduler resolves them
|
||||
at delivery, so payloads on disk never contain plaintext:
|
||||
|
||||
```json
|
||||
{"auth": "secret://STRIPE_KEY"}
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
`primora vault unlock --ttl 900` proves the password once, then writes a
|
||||
TTL'd session key (`vault.session`, 0600) so agents and scripts use the
|
||||
vault without ever seeing the password. `primora vault lock` revokes
|
||||
immediately; expiry does it on its own. The session file is same-user
|
||||
readable for its lifetime — that is the trade, bounded by the TTL.
|
||||
|
||||
## Agent broker
|
||||
|
||||
`primora agent` wraps one process: the child gets dummy tokens and a
|
||||
loopback base URL; the broker attaches the real secret on the wire.
|
||||
|
||||
```bash
|
||||
primora agent --anthropic CLAUDE_KEY --openai OAI_KEY --ttl 900 -- claude
|
||||
primora agent --upstream stripe=https://api.stripe.com -- npm test
|
||||
```
|
||||
|
||||
Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`,
|
||||
`github`, `npm`. Each flag takes a vault secret name; `--upstream
|
||||
name=https://…` adds any Bearer API (vault key `<NAME>_KEY`).
|
||||
|
||||
The child environment contains `*_API_KEY=primora-brokered` and a
|
||||
`*_BASE_URL` pointing at the broker — so the tool must honor a base-URL
|
||||
override. `gh` hardcodes api.github.com and cannot be brokered this way.
|
||||
This is a base-URL broker, not a TLS MITM; no CA trust needed.
|
||||
|
||||
## Inject
|
||||
|
||||
Run one process with vault secrets in its environment - nothing on disk,
|
||||
nothing in argv, parent shell unchanged:
|
||||
|
||||
```bash
|
||||
primora inject -- npm run dev # all vault secrets
|
||||
primora inject --env-file .env -- npm start # resolve primora:// refs
|
||||
```
|
||||
|
||||
An env-file can mix literals and refs:
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=postgres://localhost/app
|
||||
STRIPE_KEY=primora://STRIPE_KEY
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
Operator surface for a compose deployment (`--dir`, defaults to cwd):
|
||||
|
||||
```bash
|
||||
primora stack up # docker compose up -d
|
||||
primora stack up --vault # materialize .env from the vault, remove after
|
||||
primora stack status | logs [svc] | down | pull
|
||||
primora stack backup # pg_dump → ./backups (storage: scripts/backup.sh)
|
||||
```
|
||||
|
||||
`stack up --vault` refuses to overwrite an existing `.env` without `--force`
|
||||
(the original is restored after the run). For a full backup including the
|
||||
storage volume, `scripts/backup.sh` remains the tool.
|
||||
|
||||
## For AI agents
|
||||
|
||||
Two safe paths, no secret ever in the agent's context:
|
||||
|
||||
- **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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/cli",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Primora command-line client",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -19,6 +19,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"cac": "^7.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
|
||||
+151
-2
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
|
||||
import { cmdAuditList } from "./commands/audit.js";
|
||||
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
|
||||
import { cmdContext, cmdUse } from "./commands/context.js";
|
||||
import { cmdInject } from "./commands/inject.js";
|
||||
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
|
||||
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
|
||||
import {
|
||||
@@ -14,6 +15,28 @@ import {
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
cmdSecretsList,
|
||||
cmdSecretsRemove,
|
||||
cmdSecretsSet,
|
||||
} from "./commands/secrets.js";
|
||||
import { cmdAgent, PRESET_NAMES } from "./commands/agent.js";
|
||||
import {
|
||||
cmdStackBackup,
|
||||
cmdStackDown,
|
||||
cmdStackLogs,
|
||||
cmdStackPull,
|
||||
cmdStackStatus,
|
||||
cmdStackUp,
|
||||
} from "./commands/stack.js";
|
||||
import {
|
||||
cmdVaultInit,
|
||||
cmdVaultLock,
|
||||
cmdVaultStatus,
|
||||
cmdVaultUnlock,
|
||||
} from "./commands/vault.js";
|
||||
import { fail } from "./out.js";
|
||||
|
||||
const cli = cac("primora");
|
||||
@@ -144,16 +167,142 @@ cli
|
||||
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
|
||||
.action(cmdAuditList);
|
||||
|
||||
// Local vault — secrets on this machine, not on the server. Password source:
|
||||
// live session → PRIMORA_VAULT_PASSWORD → --password-file → hidden prompt.
|
||||
cli
|
||||
.command("vault:init", "Create the local secrets vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultInit);
|
||||
cli
|
||||
.command("vault:status", "Show vault path, KDF parameters and lock state")
|
||||
.action(cmdVaultStatus);
|
||||
cli
|
||||
.command("vault:unlock", "Open a TTL'd session — agents use the vault without the password")
|
||||
.option("--ttl <s>", "Session lifetime in seconds (default 900, cap 86400)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdVaultUnlock);
|
||||
cli
|
||||
.command("vault:lock", "Revoke the session immediately")
|
||||
.action(cmdVaultLock);
|
||||
|
||||
cli
|
||||
.command("secrets:set <name>", "Store a secret in the vault")
|
||||
.option("--value <value>", "Secret value (otherwise prompted or read from stdin)")
|
||||
.option("--url <url>", "Associated URL (dashboard, docs, endpoint)")
|
||||
.option("--notes <text>", "Free-text notes")
|
||||
.option("--remote", "Target the project vault on the server instead of the local vault")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsSet);
|
||||
cli
|
||||
.command("secrets:get [name]", "Print a secret's value (interactive picker if omitted)")
|
||||
.option("--remote", "Reveal from the project vault on the server (audit-logged)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsGet);
|
||||
cli
|
||||
.command("secrets:list", "List secret names (never values)")
|
||||
.option("--remote", "List project vault secrets on the server (metadata only)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdSecretsList);
|
||||
cli
|
||||
.command("secrets:rm [name]", "Delete a secret (interactive picker if omitted)")
|
||||
.option("--remote", "Delete from the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdSecretsRemove);
|
||||
cli
|
||||
.command("secrets:import <file>", "Import a .env file into the vault")
|
||||
.option("--remote", "Import into the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--overwrite", "Replace existing secrets")
|
||||
.action(cmdSecretsImport);
|
||||
|
||||
cli
|
||||
.command("inject", "Run a command with vault secrets in its environment")
|
||||
.option("--all", "Inject every vault secret (default when no --env-file)")
|
||||
.option("--env-file <path>", "env template; primora://NAME values resolve from the vault")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdInject);
|
||||
|
||||
// Agent broker — the child gets dummy tokens; the loopback proxy attaches the
|
||||
// real secret per grant. One flag per preset takes a vault secret name.
|
||||
const agentCmd = cli
|
||||
.command("agent", "Wrap a command with the credential broker (dummy env, real secret on the wire)")
|
||||
.option("--ttl <s>", "Grant lifetime in seconds (default 900, cap 3600)")
|
||||
.option("--upstream <name=url>", "Custom Bearer upstream, repeatable (vault key: <NAME>_KEY)")
|
||||
.option("--password-file <path>", "Read the vault password from a file");
|
||||
for (const preset of PRESET_NAMES) {
|
||||
agentCmd.option(`--${preset} <name>`, `Grant ${preset} API access from vault secret <name>`);
|
||||
}
|
||||
agentCmd.action(cmdAgent);
|
||||
|
||||
// Stack — operator surface for the compose deployment in --dir (default cwd).
|
||||
cli
|
||||
.command("stack:up", "docker compose up -d")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--vault", "Materialize .env from the vault for this run only")
|
||||
.option("--force", "Allow --vault to temporarily swap an existing .env")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdStackUp);
|
||||
cli
|
||||
.command("stack:down", "docker compose down")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackDown);
|
||||
cli
|
||||
.command("stack:status", "docker compose ps")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackStatus);
|
||||
cli
|
||||
.command("stack:logs [service]", "docker compose logs")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--follow", "Follow log output")
|
||||
.option("--tail <n>", "Lines per service (default 200)")
|
||||
.action(cmdStackLogs);
|
||||
cli
|
||||
.command("stack:pull", "Pull newer images")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.action(cmdStackPull);
|
||||
cli
|
||||
.command("stack:backup", "pg_dump the platform database into ./backups")
|
||||
.option("--dir <path>", "Deployment directory (default: cwd)")
|
||||
.option("--out <path>", "Backup directory (default: <dir>/backups)")
|
||||
.action(cmdStackBackup);
|
||||
|
||||
cli.help();
|
||||
cli.version("0.4.0");
|
||||
cli.version("0.6.0");
|
||||
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 inject --env-file .env -- npm run dev");
|
||||
cli.example("primora agent --anthropic CLAUDE_KEY -- claude");
|
||||
|
||||
// cac only matches the first positional token, so "orgs list" cannot be a
|
||||
// command name. Fold "group verb" pairs into "group:verb" before parsing —
|
||||
// `primora orgs list` and `primora orgs:list` both work.
|
||||
const GROUPS = new Set(["orgs", "projects", "buckets", "objects", "keys", "jobs", "audit"]);
|
||||
const GROUPS = new Set([
|
||||
"orgs",
|
||||
"projects",
|
||||
"buckets",
|
||||
"objects",
|
||||
"keys",
|
||||
"jobs",
|
||||
"audit",
|
||||
"vault",
|
||||
"secrets",
|
||||
"stack",
|
||||
]);
|
||||
const argv = process.argv.slice(2);
|
||||
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv.splice(0, 2, `${argv[0]}:${argv[1]}`);
|
||||
} else if (argv[0] === "vault" && !argv[1]) {
|
||||
argv[0] = "vault:status";
|
||||
} else if (argv[0] === "secrets" && !argv[1]) {
|
||||
argv[0] = "secrets:list";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
/**
|
||||
* `primora agent` — loopback credential broker. Wraps one child process with
|
||||
* dummy tokens; the proxy attaches the real secret on the wire per grant.
|
||||
* The child (an AI agent, a script) never holds a real key.
|
||||
*
|
||||
* Transport note: this is a base-URL broker, not a TLS MITM — tools must honor
|
||||
* their *_BASE_URL / registry / api-url override (OpenAI, Anthropic, Groq,
|
||||
* DeepSeek, xAI, OpenRouter, npm do; `gh` hardcodes api.github.com). That is
|
||||
* the documented ceiling — a CA-trusted MITM is a much larger build.
|
||||
* ponytail: no standalone daemon (`agent start`) — wrap mode only.
|
||||
*/
|
||||
|
||||
interface Grant {
|
||||
upstream: string; // e.g. https://api.anthropic.com
|
||||
header: (secret: string) => [string, string];
|
||||
}
|
||||
|
||||
interface Preset extends Grant {
|
||||
env: (base: string) => Record<string, string>; // child env; base = http://127.0.0.1:port/<name>
|
||||
}
|
||||
|
||||
const DUMMY = "primora-brokered";
|
||||
const bearer = (s: string): [string, string] => ["authorization", `Bearer ${s}`];
|
||||
|
||||
const PRESETS: Record<string, Preset> = {
|
||||
anthropic: {
|
||||
upstream: "https://api.anthropic.com",
|
||||
header: (s) => ["x-api-key", s],
|
||||
env: (b) => ({ ANTHROPIC_BASE_URL: b, ANTHROPIC_API_KEY: DUMMY }),
|
||||
},
|
||||
openai: {
|
||||
upstream: "https://api.openai.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENAI_BASE_URL: `${b}/v1`, OPENAI_API_KEY: DUMMY }),
|
||||
},
|
||||
openrouter: {
|
||||
upstream: "https://openrouter.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ OPENROUTER_BASE_URL: `${b}/api`, OPENROUTER_API_KEY: DUMMY }),
|
||||
},
|
||||
groq: {
|
||||
upstream: "https://api.groq.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GROQ_BASE_URL: `${b}/openai`, GROQ_API_KEY: DUMMY }),
|
||||
},
|
||||
deepseek: {
|
||||
upstream: "https://api.deepseek.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ DEEPSEEK_BASE_URL: b, DEEPSEEK_API_KEY: DUMMY }),
|
||||
},
|
||||
xai: {
|
||||
upstream: "https://api.x.ai",
|
||||
header: bearer,
|
||||
env: (b) => ({ XAI_BASE_URL: `${b}/v1`, XAI_API_KEY: DUMMY }),
|
||||
},
|
||||
github: {
|
||||
upstream: "https://api.github.com",
|
||||
header: bearer,
|
||||
env: (b) => ({ GITHUB_API_URL: b, GH_TOKEN: DUMMY, GITHUB_TOKEN: DUMMY }),
|
||||
},
|
||||
npm: {
|
||||
upstream: "https://registry.npmjs.org",
|
||||
header: bearer,
|
||||
env: (b) => ({ NPM_CONFIG_REGISTRY: b, NPM_TOKEN: DUMMY }),
|
||||
},
|
||||
};
|
||||
|
||||
export const PRESET_NAMES = Object.keys(PRESETS);
|
||||
|
||||
export interface AgentOptions extends VaultPasswordOptions {
|
||||
ttl?: string;
|
||||
upstream?: string | string[];
|
||||
[preset: string]: unknown; // cac preset flags land here
|
||||
}
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
async function proxy(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
grants: Map<string, { grant: Grant; secret: string }>,
|
||||
): Promise<void> {
|
||||
const match = /^\/([a-z0-9_-]+)(\/.*)?$/i.exec(req.url ?? "");
|
||||
const entry = match ? grants.get(match[1].toLowerCase()) : undefined;
|
||||
if (!match || !entry) {
|
||||
res.writeHead(404, { "content-type": "text/plain" }).end("primora agent: no grant for this route");
|
||||
return;
|
||||
}
|
||||
const suffix = match[2] ?? "/";
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
const lk = k.toLowerCase();
|
||||
if (HOP_BY_HOP.has(lk) || Array.isArray(v) || v === undefined) continue;
|
||||
headers[lk] = v;
|
||||
}
|
||||
const [hk, hv] = entry.grant.header(entry.secret);
|
||||
headers[hk] = hv; // real secret attached here — the child sent a dummy
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
||||
|
||||
try {
|
||||
const up = await fetch(entry.grant.upstream + suffix, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
const out: Record<string, string> = {};
|
||||
up.headers.forEach((v, k) => {
|
||||
if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
|
||||
});
|
||||
res.writeHead(up.status, out);
|
||||
if (up.body) Readable.fromWeb(up.body as never).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
res.writeHead(502).end(`primora agent: upstream error — ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdAgent(options: AgentOptions): Promise<void> {
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError(
|
||||
"No command given.",
|
||||
"Usage: primora agent --anthropic VAULT_KEY [--ttl 900] -- <cmd>",
|
||||
);
|
||||
}
|
||||
|
||||
// Collect grants: --<preset> <vault-name> plus custom --upstream name=url.
|
||||
const wanted: { name: string; grant: Grant; env?: Preset["env"]; secretName: string }[] = [];
|
||||
for (const name of PRESET_NAMES) {
|
||||
const secretName = options[name];
|
||||
if (typeof secretName === "string" && secretName) {
|
||||
wanted.push({ name, grant: PRESETS[name], env: PRESETS[name].env, secretName });
|
||||
}
|
||||
}
|
||||
const customs = ([] as string[]).concat(options.upstream ?? []);
|
||||
for (const spec of customs) {
|
||||
const eq = spec.indexOf("=");
|
||||
const url = spec.slice(eq + 1);
|
||||
// HTTPS for real upstreams; HTTP only for loopback dev servers.
|
||||
const urlOk =
|
||||
/^https:\/\//.test(url) || /^http:\/\/(127\.|localhost|\[::1\])/.test(url);
|
||||
if (eq < 1 || !urlOk) {
|
||||
throw new CliError(
|
||||
`Bad --upstream "${spec}".`,
|
||||
"Format: name=https://api.host (http allowed for 127.0.0.1/localhost only) — Authorization: Bearer is attached.",
|
||||
);
|
||||
}
|
||||
const name = spec.slice(0, eq).toLowerCase();
|
||||
const upstream = spec.slice(eq + 1).replace(/\/+$/, "");
|
||||
wanted.push({
|
||||
name,
|
||||
grant: { upstream, header: bearer },
|
||||
env: (b) => ({
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_BASE_URL`]: b,
|
||||
[`${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]: DUMMY,
|
||||
}),
|
||||
secretName: name.toUpperCase().replace(/-/g, "_") + "_KEY",
|
||||
});
|
||||
}
|
||||
if (!wanted.length) {
|
||||
throw new CliError(
|
||||
"No grants.",
|
||||
`Pass at least one: ${PRESET_NAMES.map((n) => `--${n} <vault-key>`).join(" ")} or --upstream name=https://…`,
|
||||
);
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const grants = new Map<string, { grant: Grant; secret: string }>();
|
||||
for (const w of wanted) {
|
||||
const entry = vault.data.secrets[w.secretName];
|
||||
if (!entry) {
|
||||
throw new CliError(
|
||||
`Secret "${w.secretName}" not in vault for grant "${w.name}".`,
|
||||
`Run \`primora secrets set ${w.secretName}\` first.`,
|
||||
);
|
||||
}
|
||||
grants.set(w.name, { grant: w.grant, secret: entry.value });
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => void proxy(req, res, grants));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
const ttl = Math.min(Math.max(60, Number(options.ttl ?? 900)), 3600);
|
||||
const timer = setTimeout(() => {
|
||||
server.close();
|
||||
process.stderr.write("primora agent: grant TTL expired — broker stopped\n");
|
||||
process.exit(0);
|
||||
}, ttl * 1000);
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const w of wanted) Object.assign(env, w.env?.(`${base}/${w.name}`));
|
||||
dim(`broker on ${base} — grants: ${wanted.map((w) => w.name).join(", ")} (ttl ${ttl}s)`);
|
||||
|
||||
// Async spawn — spawnSync would block the event loop and starve the proxy.
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
const proc = spawn(child[0], child.slice(1), { env, stdio: "inherit" });
|
||||
proc.on("error", (e) => {
|
||||
server.close();
|
||||
reject(new CliError(`Failed to start ${child[0]}: ${e.message}`));
|
||||
});
|
||||
proc.on("exit", (c) => resolve(c ?? 1));
|
||||
});
|
||||
clearTimeout(timer);
|
||||
server.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim } from "../out.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
const REF_RE = /^primora:\/\/(.+)$/;
|
||||
|
||||
/**
|
||||
* `primora inject [--all] [--env-file PATH] -- <cmd>` — run one child process
|
||||
* with vault secrets in its environment. Nothing written to disk, nothing in
|
||||
* argv; the parent shell is unchanged. `primora://NAME` values inside the
|
||||
* env-file resolve against the vault; literals stay literals.
|
||||
*/
|
||||
export async function cmdInject(
|
||||
options: VaultPasswordOptions & { all?: boolean; envFile?: string },
|
||||
): Promise<void> {
|
||||
// cac does not model `--` passthrough — slice it out of argv directly.
|
||||
const sep = process.argv.indexOf("--");
|
||||
const child = sep >= 0 ? process.argv.slice(sep + 1) : [];
|
||||
if (!child.length) {
|
||||
throw new CliError("No command given.", "Usage: primora inject [--all] [--env-file .env] -- <cmd>");
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Default: whole vault. --all exists for readability when --env-file is also used.
|
||||
if (options.all || !options.envFile) {
|
||||
for (const [k, e] of Object.entries(vault.data.secrets)) env[k] = e.value;
|
||||
}
|
||||
if (options.envFile) {
|
||||
for (const [key, raw] of Object.entries(parseEnvFile(options.envFile))) {
|
||||
const ref = REF_RE.exec(raw);
|
||||
if (!ref) {
|
||||
env[key] = raw;
|
||||
continue;
|
||||
}
|
||||
const entry = vault.data.secrets[ref[1]];
|
||||
if (!entry) throw new CliError(`Unresolved ref: primora://${ref[1]} in ${options.envFile}`);
|
||||
env[key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
dim(`injecting ${Object.keys(env).length} var${Object.keys(env).length === 1 ? "" : "s"} → ${child[0]}`);
|
||||
const res = spawnSync(child[0], child.slice(1), {
|
||||
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (res.error) throw new CliError(`Failed to start ${child[0]}: ${res.error.message}`);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
/** Used by `stack up --vault`: render all vault entries as .env lines. */
|
||||
export async function vaultAsEnvFile(
|
||||
options: VaultPasswordOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const vault = await unlockVault(options);
|
||||
return Object.fromEntries(Object.entries(vault.data.secrets).map(([k, e]) => [k, e.value]));
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { SecretsService, type ProjectSecret } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||
import { saveVault } from "../vault.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
// Env-safe names only: `inject` and `stack --vault` place entries into a child
|
||||
// environment, so a name that is not a valid env var would fail there anyway.
|
||||
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
function checkName(name: string): void {
|
||||
if (!NAME_RE.test(name)) {
|
||||
throw new CliError(
|
||||
`Invalid secret name "${name}".`,
|
||||
"Use env-var-safe names: [A-Za-z_][A-Za-z0-9_]*",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote secrets live in the project's server-side store (AES-256-GCM at rest).
|
||||
// `--remote` switches every secrets:* verb to it; no vault password is needed.
|
||||
export interface RemoteOptions {
|
||||
remote?: boolean;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
function remoteProject(options: RemoteOptions): string {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
return requireProject(cfg, options.project);
|
||||
}
|
||||
|
||||
function remoteRow(s: ProjectSecret): string[] {
|
||||
return [s.name, s.url || "—", s.notes || "—", formatDate(s.updated_at)];
|
||||
}
|
||||
|
||||
export interface SetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||
value?: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function cmdSecretsSet(name: string, options: SetOptions): Promise<void> {
|
||||
checkName(name);
|
||||
let value = options.value;
|
||||
if (value === undefined && !process.stdin.isTTY) {
|
||||
value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||
}
|
||||
if (value === undefined) {
|
||||
const entered = await p.password({ message: `Value for ${name}` });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
value = entered;
|
||||
}
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
await SecretsService.setProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
requestBody: { value, url: options.url, notes: options.notes },
|
||||
});
|
||||
success(`Set ${name} in the project vault`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
const existing = vault.data.secrets[name];
|
||||
vault.data.secrets[name] = {
|
||||
value,
|
||||
url: options.url ?? existing?.url,
|
||||
notes: options.notes ?? existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`${existing ? "Updated" : "Set"} ${name}`);
|
||||
}
|
||||
|
||||
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
process.exit(0);
|
||||
}
|
||||
const picked = await p.select({
|
||||
message,
|
||||
options: items.map((s) => ({ value: s.name, label: s.name, hint: s.url || s.notes || undefined })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export async function cmdSecretsGet(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to reveal");
|
||||
const res = await SecretsService.revealProjectSecret({ projectId, name });
|
||||
if (isJson(options)) {
|
||||
printJson({ name, value: res.value });
|
||||
return;
|
||||
}
|
||||
process.stdout.write(res.value + "\n");
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets get <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME`.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to print",
|
||||
options: names.map((n) => ({
|
||||
value: n,
|
||||
label: n,
|
||||
hint: vault.data.secrets[n].url ?? vault.data.secrets[n].notes ?? undefined,
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
const entry = vault.data.secrets[name];
|
||||
if (!entry) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
name,
|
||||
value: entry.value,
|
||||
url: entry.url ?? null,
|
||||
notes: entry.notes ?? null,
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
});
|
||||
return;
|
||||
}
|
||||
process.stdout.write(entry.value + "\n");
|
||||
}
|
||||
|
||||
export async function cmdSecretsList(
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson(items);
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
dim("Project vault is empty — `primora secrets set NAME --remote`.");
|
||||
return;
|
||||
}
|
||||
printTable(["NAME", "URL", "NOTES", "UPDATED"], items.map(remoteRow));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const rows = Object.entries(vault.data.secrets).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (isJson(options)) {
|
||||
printJson(
|
||||
rows.map(([name, e]) => ({
|
||||
name,
|
||||
url: e.url ?? null,
|
||||
notes: e.notes ?? null,
|
||||
created_at: e.created_at,
|
||||
updated_at: e.updated_at,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
dim("Vault is empty — `primora secrets set NAME` or `primora secrets import .env`.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["NAME", "URL", "UPDATED"],
|
||||
rows.map(([name, e]) => [name, e.url ?? "—", formatDate(e.updated_at)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdSecretsRemove(
|
||||
name: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
name ??= await pickRemote(projectId, "Secret to delete");
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete project secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
await SecretsService.deleteProjectSecret({ projectId, name });
|
||||
success(`Deleted ${name}`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
if (!name) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora secrets rm <name>");
|
||||
const names = Object.keys(vault.data.secrets).sort();
|
||||
if (!names.length) {
|
||||
dim("Vault is empty.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Secret to delete",
|
||||
options: names.map((n) => ({ value: n, label: n })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
name = picked;
|
||||
}
|
||||
if (!vault.data.secrets[name]) throw new CliError(`Secret "${name}" not found.`);
|
||||
if (!options.yes && process.stdin.isTTY) {
|
||||
const confirmed = await p.confirm({ message: `Delete secret ${name}?` });
|
||||
if (p.isCancel(confirmed) || !confirmed) return;
|
||||
}
|
||||
delete vault.data.secrets[name];
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Deleted ${name}`);
|
||||
}
|
||||
|
||||
/** Parse a .env file: KEY=value lines, optional `export`, quotes stripped. */
|
||||
export function parseEnvFile(path: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const raw of readFileSync(path, "utf8").split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const key = line.slice(0, eq).trim().replace(/^export\s+/, "");
|
||||
let val = line.slice(eq + 1).trim();
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function cmdSecretsImport(
|
||||
file: string,
|
||||
options: VaultPasswordOptions & RemoteOptions & { overwrite?: boolean },
|
||||
): Promise<void> {
|
||||
const parsed = parseEnvFile(file);
|
||||
const entries = Object.entries(parsed).filter(([k]) => NAME_RE.test(k));
|
||||
if (!entries.length) throw new CliError(`No valid KEY=value pairs in ${file}.`);
|
||||
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectSecrets({ projectId });
|
||||
const existing = new Set(items.map((s) => s.name));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
if (existing.has(key) && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await SecretsService.setProjectSecret({ projectId, name: key, requestBody: { value } });
|
||||
added++;
|
||||
}
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} into the project vault`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const [key, value] of entries) {
|
||||
const existing = vault.data.secrets[key];
|
||||
if (existing && !options.overwrite) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
vault.data.secrets[key] = {
|
||||
value,
|
||||
url: existing?.url,
|
||||
notes: existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
added++;
|
||||
}
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Imported ${added} secret${added === 1 ? "" : "s"} from ${file}`);
|
||||
if (skipped) dim(`Skipped ${skipped} existing (pass --overwrite to replace).`);
|
||||
if (entries.length < Object.keys(parsed).length) {
|
||||
dim("Skipped keys that are not env-var-safe.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, success } from "../out.js";
|
||||
import { vaultAsEnvFile } from "./inject.js";
|
||||
import { parseEnvFile } from "./secrets.js";
|
||||
import type { VaultPasswordOptions } from "./vault.js";
|
||||
|
||||
interface StackOptions extends VaultPasswordOptions {
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
function projectDir(options: StackOptions): string {
|
||||
const dir = options.dir ?? process.cwd();
|
||||
for (const name of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
||||
if (existsSync(join(dir, name))) return dir;
|
||||
}
|
||||
throw new CliError(
|
||||
`No compose file in ${dir}.`,
|
||||
"Run from a Primora deployment directory or pass --dir.",
|
||||
);
|
||||
}
|
||||
|
||||
function compose(dir: string, args: string[], env?: NodeJS.ProcessEnv): number {
|
||||
const res = spawnSync("docker", ["compose", ...args], {
|
||||
cwd: dir,
|
||||
stdio: "inherit",
|
||||
env: env ?? process.env,
|
||||
});
|
||||
if (res.error) {
|
||||
throw new CliError(
|
||||
`Failed to run docker compose: ${res.error.message}`,
|
||||
"Docker with the Compose plugin is required.",
|
||||
);
|
||||
}
|
||||
return res.status ?? 1;
|
||||
}
|
||||
|
||||
export async function cmdStackUp(
|
||||
options: StackOptions & { vault?: boolean; force?: boolean; detach?: boolean },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envPath = join(dir, ".env");
|
||||
|
||||
if (!options.vault) {
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault mode: materialize .env for the duration of compose, then remove it.
|
||||
// `env_file:` directives make compose require the file on disk — there is no
|
||||
// flag to feed it via the process environment alone.
|
||||
const secrets = await vaultAsEnvFile(options);
|
||||
const hadEnv = existsSync(envPath);
|
||||
if (hadEnv && !options.force) {
|
||||
throw new CliError(
|
||||
`${envPath} already exists.`,
|
||||
"Import it (`primora secrets import .env --overwrite`), remove it, or pass --force to swap it for the vault during this run.",
|
||||
);
|
||||
}
|
||||
const prior = hadEnv ? readFileSync(envPath) : undefined;
|
||||
// compose env_file parsing is literal — write raw KEY=value lines.
|
||||
const lines = Object.entries(secrets).map(([k, v]) => {
|
||||
if (v.includes("\n")) throw new CliError(`Secret ${k} contains a newline — cannot be an env var.`);
|
||||
return `${k}=${v}`;
|
||||
});
|
||||
try {
|
||||
writeFileSync(envPath, lines.join("\n") + "\n", { mode: 0o600 });
|
||||
const code = compose(dir, ["up", "-d"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack is up — .env restored/removed; secrets live in the vault only.");
|
||||
} finally {
|
||||
if (prior) writeFileSync(envPath, prior, { mode: 0o600 });
|
||||
else if (existsSync(envPath)) unlinkSync(envPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdStackDown(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["down"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Stack stopped.");
|
||||
}
|
||||
|
||||
export async function cmdStackStatus(options: StackOptions): Promise<void> {
|
||||
const code = compose(projectDir(options), ["ps"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackLogs(
|
||||
service: string | undefined,
|
||||
options: StackOptions & { follow?: boolean; tail?: string },
|
||||
): Promise<void> {
|
||||
const args = ["logs", "--tail", options.tail ?? "200"];
|
||||
if (options.follow) args.push("--follow");
|
||||
if (service) args.push(service);
|
||||
const code = compose(projectDir(options), args);
|
||||
if (code !== 0) process.exit(code);
|
||||
}
|
||||
|
||||
export async function cmdStackPull(options: StackOptions): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const code = compose(dir, ["pull"]);
|
||||
if (code !== 0) process.exit(code);
|
||||
success("Images updated — run `primora stack up` to apply.");
|
||||
}
|
||||
|
||||
/** Postgres dump via `compose exec`; for a full backup (DB + storage volume)
|
||||
* use scripts/backup.sh — it covers both. ponytail: no storage tar here. */
|
||||
export async function cmdStackBackup(
|
||||
options: StackOptions & { out?: string },
|
||||
): Promise<void> {
|
||||
const dir = projectDir(options);
|
||||
const envFile = join(dir, ".env");
|
||||
const env = existsSync(envFile) ? parseEnvFile(envFile) : {};
|
||||
const user = env.POSTGRES_USER ?? "primora";
|
||||
const db = env.POSTGRES_DB ?? "primora";
|
||||
|
||||
const outDir = options.out ?? join(dir, "backups");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const outPath = join(outDir, `primora-${stamp}.dump`);
|
||||
|
||||
const res = spawnSync(
|
||||
"docker",
|
||||
["compose", "exec", "-T", "postgres", "pg_dump", "-U", user, "-Fc", db],
|
||||
{ cwd: dir, encoding: "buffer", maxBuffer: 1024 * 1024 * 1024 },
|
||||
);
|
||||
if (res.error || res.status !== 0) {
|
||||
throw new CliError(
|
||||
`pg_dump failed: ${res.error?.message ?? res.stderr?.toString().slice(0, 300) ?? `exit ${res.status}`}`,
|
||||
"Is the stack running? `primora stack status`",
|
||||
);
|
||||
}
|
||||
writeFileSync(outPath, res.stdout as Buffer, { mode: 0o600 });
|
||||
success(`Backup written to ${outPath}`);
|
||||
dim("Postgres only — scripts/backup.sh also archives the storage volume.");
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as p from "@clack/prompts";
|
||||
|
||||
import pc from "picocolors";
|
||||
|
||||
import { CliError } from "../http.js";
|
||||
import { dim, isJson, printJson, success, withSpinner } from "../out.js";
|
||||
import {
|
||||
clearSession,
|
||||
createVault,
|
||||
loadSessionKey,
|
||||
openVault,
|
||||
openVaultWithKey,
|
||||
saveSessionKey,
|
||||
sessionExpiry,
|
||||
vaultExists,
|
||||
vaultHeader,
|
||||
vaultPath,
|
||||
type OpenVault,
|
||||
} from "../vault.js";
|
||||
|
||||
export interface VaultPasswordOptions {
|
||||
passwordFile?: string;
|
||||
}
|
||||
|
||||
async function promptPassword(confirm: boolean): Promise<Uint8Array> {
|
||||
const entered = await p.password({ message: "Vault password" });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (confirm) {
|
||||
const again = await p.password({ message: "Confirm vault password" });
|
||||
if (p.isCancel(again)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
if (again !== entered) throw new CliError("Passwords do not match.");
|
||||
}
|
||||
return new TextEncoder().encode(entered);
|
||||
}
|
||||
|
||||
async function readPassword(options: VaultPasswordOptions): Promise<Uint8Array | undefined> {
|
||||
const env = process.env.PRIMORA_VAULT_PASSWORD;
|
||||
if (env) return new TextEncoder().encode(env);
|
||||
if (options.passwordFile) {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const pw = readFileSync(options.passwordFile, "utf8").replace(/\r?\n$/, "");
|
||||
return new TextEncoder().encode(pw);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the vault: live session key → PRIMORA_VAULT_PASSWORD → --password-file →
|
||||
* hidden prompt. The session is how agents and scripts use the vault without
|
||||
* ever seeing the password; everything else still proves the password.
|
||||
*/
|
||||
export async function unlockVault(options: VaultPasswordOptions): Promise<OpenVault> {
|
||||
const session = loadSessionKey();
|
||||
if (session) return openVaultWithKey(session);
|
||||
|
||||
const password = await readPassword(options);
|
||||
if (password) return withSpinner("Unlocking vault…", async () => openVault(password));
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault is locked.",
|
||||
"Run `primora vault unlock`, or set PRIMORA_VAULT_PASSWORD / pass --password-file.",
|
||||
);
|
||||
}
|
||||
const prompted = await promptPassword(false);
|
||||
return withSpinner("Unlocking vault…", async () => openVault(prompted));
|
||||
}
|
||||
|
||||
export async function cmdVaultInit(options: VaultPasswordOptions): Promise<void> {
|
||||
let password = await readPassword(options);
|
||||
if (!password) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file in non-interactive mode.",
|
||||
);
|
||||
}
|
||||
password = await promptPassword(true);
|
||||
}
|
||||
await withSpinner("Creating vault…", async () => createVault(password));
|
||||
success(`Vault created at ${vaultPath()}`);
|
||||
dim("Secrets are encrypted with Argon2id + XChaCha20-Poly1305. The password is not recoverable.");
|
||||
}
|
||||
|
||||
export async function cmdVaultUnlock(
|
||||
options: VaultPasswordOptions & { ttl?: string },
|
||||
): Promise<void> {
|
||||
const session = loadSessionKey();
|
||||
if (session) {
|
||||
const exp = sessionExpiry();
|
||||
dim(`Already unlocked — expires ${exp ? new Date(exp * 1000).toISOString() : "soon"}.`);
|
||||
return;
|
||||
}
|
||||
const password =
|
||||
(await readPassword(options)) ??
|
||||
(process.stdin.isTTY
|
||||
? await promptPassword(false)
|
||||
: (() => {
|
||||
throw new CliError(
|
||||
"Vault password required.",
|
||||
"Set PRIMORA_VAULT_PASSWORD or pass --password-file.",
|
||||
);
|
||||
})());
|
||||
const vault = await withSpinner("Unlocking vault…", async () => openVault(password)); // proves the password before stashing the key
|
||||
const ttl = saveSessionKey(vault.key, Number(options.ttl ?? 900));
|
||||
success(`Vault unlocked for ${ttl}s — secrets usable without the password until expiry.`);
|
||||
dim("`primora vault lock` revokes immediately.");
|
||||
}
|
||||
|
||||
export async function cmdVaultLock(): Promise<void> {
|
||||
if (clearSession()) success("Vault locked — session revoked.");
|
||||
else dim("No active session.");
|
||||
}
|
||||
|
||||
export async function cmdVaultStatus(options: { json?: boolean }): Promise<void> {
|
||||
const exists = vaultExists();
|
||||
const header = exists ? vaultHeader() : undefined;
|
||||
const exp = sessionExpiry();
|
||||
const unlocked = exp !== undefined;
|
||||
let count: number | undefined;
|
||||
if (unlocked) {
|
||||
try {
|
||||
count = Object.keys(openVaultWithKey(loadSessionKey()!).data.secrets).length;
|
||||
} catch {
|
||||
// session key no longer matches (vault re-created) — report locked
|
||||
}
|
||||
}
|
||||
if (isJson(options)) {
|
||||
printJson({
|
||||
path: vaultPath(),
|
||||
exists,
|
||||
unlocked,
|
||||
expires_at: exp ?? null,
|
||||
secrets: count ?? null,
|
||||
kdf: header ?? null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!exists) {
|
||||
dim(`No vault at ${vaultPath()} — run \`primora vault init\`.`);
|
||||
return;
|
||||
}
|
||||
const remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0;
|
||||
const mins = Math.floor(remaining / 60);
|
||||
const state = unlocked
|
||||
? pc.green("● unlocked") +
|
||||
pc.dim(` — ${mins ? `${mins}m ` : ""}${remaining % 60}s remaining`)
|
||||
: pc.dim("● locked");
|
||||
process.stdout.write(`vault ${state}\n\n`);
|
||||
const rows: [string, string][] = [
|
||||
["path", vaultPath()],
|
||||
["kdf", `argon2id · ${header!.m} KiB · ${header!.t} passes · ${header!.p} lanes`],
|
||||
];
|
||||
if (unlocked) rows.push(["secrets", String(count ?? "?")]);
|
||||
const width = Math.max(...rows.map(([k]) => k.length));
|
||||
for (const [k, v] of rows) {
|
||||
process.stdout.write(`${pc.dim(k.padEnd(width))} ${v}\n`);
|
||||
}
|
||||
if (!unlocked) dim(`\n\`primora vault unlock\` opens a session for agents and scripts.`);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
|
||||
export function isJson(options: { json?: boolean }): boolean {
|
||||
@@ -28,6 +29,21 @@ export function fail(error: unknown): never {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Spinner for multi-hundred-ms work (Argon2 derives). Silent when piped. */
|
||||
export async function withSpinner<T>(message: string, work: () => Promise<T>): Promise<T> {
|
||||
if (!process.stdout.isTTY) return work();
|
||||
const s = p.spinner();
|
||||
s.start(message);
|
||||
try {
|
||||
const result = await work();
|
||||
s.stop(message);
|
||||
return result;
|
||||
} catch (error) {
|
||||
s.stop(pc.red("failed"));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal column printer — left-aligned, two-space gutter. */
|
||||
export function printTable(headers: string[], rows: string[][]): void {
|
||||
const all = headers.length ? [headers, ...rows] : rows;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// Local encrypted vault for CLI-held secrets (API keys, deployment .env values).
|
||||
// Clean-room format — inspired by arca-core's design goals, not its code (AGPL).
|
||||
//
|
||||
// File layout (all multi-byte ints little-endian):
|
||||
// 8B magic "PRMVLT01"
|
||||
// 1B kdf id (0x01 = argon2id)
|
||||
// 4B m (KiB) 4B t 4B p
|
||||
// 1B aead id (0x01 = xchacha20poly1305)
|
||||
// 16B salt 24B nonce
|
||||
// … ciphertext+tag of JSON payload; the full header is the AEAD AAD,
|
||||
// so no parameter can be altered without failing decryption.
|
||||
//
|
||||
// Session: `vault unlock` writes vault.session (0600, TTL'd) holding the
|
||||
// derived key, so agents/scripts use the vault without the password.
|
||||
// `vault lock` or expiry removes it. The session file is the same-user risk
|
||||
// equivalent of a plaintext .env for its lifetime — bounded by the TTL.
|
||||
//
|
||||
// ponytail: single credential slot, no duress/decoy, no Shamir — those are
|
||||
// coercion-resistance features, not a CLI secrets problem.
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
import { argon2id } from "@noble/hashes/argon2.js";
|
||||
|
||||
import { CliError } from "./http.js";
|
||||
|
||||
export interface SecretEntry {
|
||||
value: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VaultData {
|
||||
v: 1;
|
||||
secrets: Record<string, SecretEntry>;
|
||||
}
|
||||
|
||||
const MAGIC = "PRMVLT01";
|
||||
const KDF_ARGON2ID = 0x01;
|
||||
const AEAD_XCHACHA = 0x01;
|
||||
const HEADER_LEN = 8 + 1 + 4 + 4 + 4 + 1 + 16 + 24; // 62
|
||||
|
||||
// Floors, not targets — matching argon2id's current recommended memory cost.
|
||||
const KDF = { m: 65536, t: 3, p: 4 } as const;
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
|
||||
export function configDir(): string {
|
||||
return (
|
||||
process.env.PRIMORA_CONFIG_DIR ??
|
||||
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "primora")
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultPath(): string {
|
||||
return process.env.PRIMORA_VAULT ?? join(configDir(), "vault.bin");
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return existsSync(vaultPath());
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* session */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const SESSION_MAX_TTL = 86400; // 24 h ceiling
|
||||
|
||||
interface SessionFile {
|
||||
key: string; // base64 data key
|
||||
exp: number; // epoch seconds
|
||||
}
|
||||
|
||||
function sessionPath(): string {
|
||||
return process.env.PRIMORA_VAULT_SESSION ?? join(configDir(), "vault.session");
|
||||
}
|
||||
|
||||
/** Session key if one exists and is unexpired; expired files are removed. */
|
||||
export function loadSessionKey(): Uint8Array | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
if (typeof s.key !== "string" || typeof s.exp !== "number") throw new Error("shape");
|
||||
if (s.exp <= Date.now() / 1000) {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
const key = Buffer.from(s.key, "base64");
|
||||
if (key.length !== 32) throw new Error("bad key length");
|
||||
return new Uint8Array(key);
|
||||
} catch {
|
||||
unlinkSync(path);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionKey(key: Uint8Array, ttlSeconds: number): number {
|
||||
const ttl = Math.min(Math.max(60, ttlSeconds), SESSION_MAX_TTL);
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
mkdirSync(dirname(sessionPath()), { recursive: true });
|
||||
writeFileSync(sessionPath(), JSON.stringify({ key: Buffer.from(key).toString("base64"), exp }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
return ttl;
|
||||
}
|
||||
|
||||
export function clearSession(): boolean {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return false;
|
||||
unlinkSync(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function sessionExpiry(): number | undefined {
|
||||
const path = sessionPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(path, "utf8")) as SessionFile;
|
||||
return s.exp > Date.now() / 1000 ? s.exp : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* format */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Header fields readable without the password (for `vault status`). */
|
||||
export function vaultHeader(): { m: number; t: number; p: number } | undefined {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) return undefined;
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
return { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
}
|
||||
|
||||
interface ParsedFile {
|
||||
buf: Buffer;
|
||||
header: Buffer;
|
||||
salt: Buffer;
|
||||
nonce: Buffer;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
function parseFile(): ParsedFile {
|
||||
const path = vaultPath();
|
||||
if (!existsSync(path)) {
|
||||
throw new CliError("No vault found.", "Run `primora vault init` first.");
|
||||
}
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length < HEADER_LEN + 16 || buf.subarray(0, 8).toString("latin1") !== MAGIC) {
|
||||
throw new CliError(`Not a primora vault: ${path}`);
|
||||
}
|
||||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
if (dv.getUint8(8) !== KDF_ARGON2ID || dv.getUint8(21) !== AEAD_XCHACHA) {
|
||||
throw new CliError("Unsupported vault parameters — newer format version?");
|
||||
}
|
||||
const params = { m: dv.getUint32(9, true), t: dv.getUint32(13, true), p: dv.getUint32(17, true) };
|
||||
if (params.m < 8192 || params.t < 1 || params.p < 1) {
|
||||
throw new CliError("Vault KDF parameters below floor — file is suspect.");
|
||||
}
|
||||
return {
|
||||
buf,
|
||||
header: buf.subarray(0, HEADER_LEN),
|
||||
salt: buf.subarray(22, 38),
|
||||
nonce: buf.subarray(38, 62),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenVault {
|
||||
data: VaultData;
|
||||
key: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
params: { m: number; t: number; p: number };
|
||||
}
|
||||
|
||||
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");
|
||||
return data;
|
||||
} catch {
|
||||
throw new CliError("Wrong password or corrupt vault.");
|
||||
}
|
||||
}
|
||||
|
||||
export function openVault(password: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
const key = argon2id(password, f.salt, { ...f.params, dkLen: 32 });
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Open with a session key — skips Argon2 entirely. */
|
||||
export function openVaultWithKey(key: Uint8Array): OpenVault {
|
||||
const f = parseFile();
|
||||
return { data: decrypt(f, key), key, salt: f.salt, params: f.params };
|
||||
}
|
||||
|
||||
/** Re-encrypt with the same salt/params and a fresh nonce. Atomic via tmp+rename. */
|
||||
export function saveVault(open: OpenVault): void {
|
||||
const path = vaultPath();
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(24));
|
||||
|
||||
const header = new Uint8Array(HEADER_LEN);
|
||||
header.set(enc.encode(MAGIC), 0);
|
||||
const dv = new DataView(header.buffer);
|
||||
dv.setUint8(8, KDF_ARGON2ID);
|
||||
dv.setUint32(9, open.params.m, true);
|
||||
dv.setUint32(13, open.params.t, true);
|
||||
dv.setUint32(17, open.params.p, true);
|
||||
dv.setUint8(21, AEAD_XCHACHA);
|
||||
header.set(open.salt, 22);
|
||||
header.set(nonce, 38);
|
||||
|
||||
const plain = enc.encode(JSON.stringify(open.data));
|
||||
const ct = xchacha20poly1305(open.key, nonce, header).encrypt(plain);
|
||||
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(tmp, Buffer.concat([header, Buffer.from(ct)]), { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
export function createVault(password: Uint8Array): void {
|
||||
const path = vaultPath();
|
||||
if (existsSync(path)) {
|
||||
throw new CliError(
|
||||
`Vault already exists: ${path}`,
|
||||
"Use `primora secrets:*` — or remove the file to start over.",
|
||||
);
|
||||
}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = argon2id(password, salt, { ...KDF, dkLen: 32 });
|
||||
saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF });
|
||||
}
|
||||
@@ -28,4 +28,19 @@ npm run bundle --workspace @primora/desktop # release bundles → src-tauri/ta
|
||||
to be a Primora deployment, use the tray's Change Server.
|
||||
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
|
||||
`http://localhost` for a local deployment.
|
||||
|
||||
## Secrets Vault
|
||||
|
||||
The tray menu's **Secrets Vault…** opens a local window (`ui/vault.html`) for
|
||||
the CLI's encrypted vault — init, timed unlock/lock, list with copy-to-clipboard,
|
||||
add/delete, `.env` import. Values are never displayed; copy goes straight to the
|
||||
clipboard.
|
||||
|
||||
The desktop app does not reimplement the vault: IPC commands shell out to the
|
||||
`primora` binary on PATH (`PRIMORA_CLI` overrides the binary path). One vault
|
||||
file, one crypto implementation — terminal and desktop always agree. Requires
|
||||
`npm i -g @primora/cli`; without it the window shows install instructions.
|
||||
|
||||
Injection and the credential broker stay CLI-only (`primora inject`,
|
||||
`primora agent`).
|
||||
- Deep links / push notifications: deliberately omitted — nothing needs them yet.
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "vault"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, WindowEvent,
|
||||
AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
||||
};
|
||||
|
||||
/// Persisted shell state — one field, the deployment to load.
|
||||
@@ -67,24 +70,163 @@ fn show_main(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_vault(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("vault") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
return;
|
||||
}
|
||||
let _ = WebviewWindowBuilder::new(app, "vault", WebviewUrl::App("vault.html".into()))
|
||||
.title("Primora — Secrets Vault")
|
||||
.inner_size(780.0, 620.0)
|
||||
.min_inner_size(560.0, 420.0)
|
||||
.center()
|
||||
.build();
|
||||
}
|
||||
|
||||
/// The vault lives in the CLI — the desktop proxies the `primora` binary so
|
||||
/// both surfaces operate on the same vault file, one crypto implementation.
|
||||
/// Passwords travel via env, secret values via stdin; never via argv.
|
||||
fn run_cli(args: &[&str], stdin_data: Option<&str>, password: Option<&str>) -> Result<String, String> {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
let mut cmd = Command::new(&bin);
|
||||
cmd.args(args)
|
||||
.stdin(if stdin_data.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(pw) = password {
|
||||
cmd.env("PRIMORA_VAULT_PASSWORD", pw);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|_| {
|
||||
"primora CLI not found on PATH — install it: npm i -g @primora/cli".to_string()
|
||||
})?;
|
||||
if let Some(data) = stdin_data {
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or("failed to open child stdin")?
|
||||
.write_all(data.as_bytes())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let out = child.wait_with_output().map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_probe() -> bool {
|
||||
let bin = std::env::var("PRIMORA_CLI").unwrap_or_else(|_| "primora".into());
|
||||
Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_status() -> Result<Value, String> {
|
||||
let out = run_cli(&["vault", "status", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_secrets() -> Result<Value, String> {
|
||||
let out = run_cli(&["secrets", "list", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_reveal(name: String) -> Result<String, String> {
|
||||
run_cli(&["secrets", "get", &name], None, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_init(password: String) -> Result<(), String> {
|
||||
run_cli(&["vault", "init"], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_unlock(password: String, ttl: u32) -> Result<(), String> {
|
||||
let ttl = ttl.clamp(60, 86400).to_string();
|
||||
run_cli(&["vault", "unlock", "--ttl", &ttl], None, Some(&password)).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_lock() -> Result<(), String> {
|
||||
run_cli(&["vault", "lock"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_set(
|
||||
name: String,
|
||||
value: String,
|
||||
url: Option<String>,
|
||||
notes: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut args = vec!["secrets", "set", name.as_str()];
|
||||
if let Some(u) = url.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--url", u]);
|
||||
}
|
||||
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--notes", n]);
|
||||
}
|
||||
run_cli(&args, Some(&value), None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_rm(name: String) -> Result<(), String> {
|
||||
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
|
||||
let mut args = vec!["secrets", "import", path.as_str()];
|
||||
if overwrite {
|
||||
args.push("--overwrite");
|
||||
}
|
||||
run_cli(&args, None, None)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
show_main(app);
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![get_server_url, set_server_url])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_server_url,
|
||||
set_server_url,
|
||||
vault_probe,
|
||||
vault_status,
|
||||
vault_secrets,
|
||||
vault_reveal,
|
||||
vault_init,
|
||||
vault_unlock,
|
||||
vault_lock,
|
||||
vault_set,
|
||||
vault_rm,
|
||||
vault_import,
|
||||
])
|
||||
.setup(|app| {
|
||||
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
|
||||
let vault_item = MenuItem::with_id(app, "vault", "Secrets Vault…", true, None::<&str>)?;
|
||||
let change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show, &change, &quit])?;
|
||||
let menu = Menu::with_items(app, &[&show, &vault_item, &change, &quit])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("Primora")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => show_main(app),
|
||||
"vault" => open_vault(app),
|
||||
"change_server" => {
|
||||
// Clearing the URL and restarting lands back on the connect screen.
|
||||
let _ = save_config(app, &DesktopConfig { server_url: None });
|
||||
@@ -108,6 +250,10 @@ pub fn run() {
|
||||
}
|
||||
let _tray = tray.build(app)?;
|
||||
|
||||
// The connect screen links here — "open the local Secrets Vault".
|
||||
let handle = app.handle().clone();
|
||||
app.listen("open-vault", move |_| open_vault(&handle));
|
||||
|
||||
// Close hides to the tray instead of quitting — Quit is on the menu.
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let handle = window.clone();
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</form>
|
||||
<p id="err" class="err" hidden></p>
|
||||
<p class="hint">The desktop app renders the console served by your own server. Nothing is hosted by us.</p>
|
||||
<p class="hint"><a href="#" id="vault-link" class="vlink">Open the local Secrets Vault</a></p>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
@@ -51,6 +52,11 @@
|
||||
if (saved) location.replace(saved);
|
||||
}
|
||||
|
||||
document.getElementById("vault-link").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
tauri?.event.emit("open-vault");
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
err.hidden = true;
|
||||
|
||||
@@ -99,3 +99,222 @@ button:disabled {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- secrets vault window ---------- */
|
||||
|
||||
body.vault {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
background: #101216;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-sub {
|
||||
font-size: 0.72rem;
|
||||
color: #6d6d76;
|
||||
font-family: ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #6d6d76;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
color: #34d399;
|
||||
border-color: #1e3a2f;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
color: #9a9aa3;
|
||||
}
|
||||
|
||||
.view {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vcard {
|
||||
background: #14161b;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.vcard h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.vcard select {
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: #0b0d11;
|
||||
border: 1px solid #2e3239;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.vtable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.vtable th {
|
||||
text-align: left;
|
||||
color: #6d6d76;
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-bottom: 1px solid #26292f;
|
||||
}
|
||||
|
||||
.vtable td {
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-bottom: 1px solid #1e2025;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vtable tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dimcell {
|
||||
color: #9a9aa3;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.acts {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid #2e3239;
|
||||
color: #c9c9cf;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ghost:hover {
|
||||
border-color: #f4693a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost.sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
border-color: #ef6b6b;
|
||||
color: #ef6b6b;
|
||||
}
|
||||
|
||||
.vform {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vform input {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vform input.grow {
|
||||
flex: 3 1 14rem;
|
||||
}
|
||||
|
||||
.vform button {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #26292f;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: #e7e7ea;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vlink {
|
||||
color: #f4693a;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.vlink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #6d6d76;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Primora — Secrets Vault</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon.svg" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="vault">
|
||||
<header class="bar">
|
||||
<div class="brand">
|
||||
<img src="icon.svg" alt="" />
|
||||
<div>
|
||||
<h1>Secrets Vault</h1>
|
||||
<p id="path" class="bar-sub"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bar-right">
|
||||
<span id="pill" class="pill"></span>
|
||||
<button id="lock-btn" class="ghost" hidden>Lock</button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="view" class="view">
|
||||
<p class="dim">Loading…</p>
|
||||
</main>
|
||||
<script type="module" src="vault.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
const tauri = window.__TAURI__;
|
||||
const view = document.getElementById("view");
|
||||
const pill = document.getElementById("pill");
|
||||
const lockBtn = document.getElementById("lock-btn");
|
||||
const pathEl = document.getElementById("path");
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
const el = (tag, attrs = {}, ...children) => {
|
||||
const node = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === "class") node.className = v;
|
||||
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
|
||||
else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) node.append(c);
|
||||
return node;
|
||||
};
|
||||
|
||||
const errBox = (msg) => el("p", { class: "err" }, msg);
|
||||
const dimNote = (msg) => el("p", { class: "hint" }, msg);
|
||||
|
||||
function setPill(state, expiresAt) {
|
||||
clearInterval(countdownTimer);
|
||||
pill.className = "pill";
|
||||
lockBtn.hidden = true;
|
||||
if (state === "unlocked" && expiresAt) {
|
||||
const tick = () => {
|
||||
const left = Math.max(0, expiresAt - Math.floor(Date.now() / 1000));
|
||||
const m = Math.floor(left / 60);
|
||||
const s = left % 60;
|
||||
pill.textContent = `● unlocked — ${m}m ${String(s).padStart(2, "0")}s`;
|
||||
if (!left) refresh();
|
||||
};
|
||||
pill.classList.add("ok");
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
lockBtn.hidden = false;
|
||||
} else {
|
||||
pill.textContent = state === "none" ? "no vault" : "● locked";
|
||||
if (state !== "none") pill.classList.add("warn");
|
||||
}
|
||||
}
|
||||
|
||||
function card(title, sub, ...children) {
|
||||
return el("section", { class: "vcard" },
|
||||
el("h2", {}, title),
|
||||
sub ? el("p", { class: "sub" }, sub) : "",
|
||||
...children);
|
||||
}
|
||||
|
||||
function busy(btn, label) {
|
||||
btn.disabled = true;
|
||||
btn.dataset.label = btn.textContent;
|
||||
btn.textContent = label;
|
||||
}
|
||||
const unbusy = (btn) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label || btn.textContent;
|
||||
};
|
||||
|
||||
/* ---------- views ---------- */
|
||||
|
||||
function renderMissing() {
|
||||
setPill("none");
|
||||
pathEl.textContent = "";
|
||||
view.replaceChildren(card(
|
||||
"CLI required",
|
||||
"The vault lives in the primora CLI — the desktop app drives the same vault file, so terminal and desktop always agree.",
|
||||
el("pre", { class: "codeblock" }, "npm i -g @primora/cli\nprimora vault init"),
|
||||
dimNote("Reopen this window after installing.")
|
||||
));
|
||||
}
|
||||
|
||||
function renderInit() {
|
||||
setPill("none");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "new-password" });
|
||||
const pw2 = el("input", { type: "password", placeholder: "Confirm password", autocomplete: "new-password" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (pw.value.length < 8) { err.textContent = "Use at least 8 characters."; err.hidden = false; return; }
|
||||
if (pw.value !== pw2.value) { err.textContent = "Passwords do not match."; err.hidden = false; return; }
|
||||
busy(btn, "Creating…");
|
||||
try { await tauri.core.invoke("vault_init", { password: pw.value }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Create vault");
|
||||
view.replaceChildren(card(
|
||||
"Create your vault",
|
||||
"Local, encrypted, Argon2id + XChaCha20-Poly1305. The password is not recoverable — lose it and the contents are gone.",
|
||||
pw, pw2, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function renderLocked() {
|
||||
setPill("locked");
|
||||
const pw = el("input", { type: "password", placeholder: "Vault password", autocomplete: "current-password", autofocus: true });
|
||||
const ttl = el("select", {},
|
||||
el("option", { value: "900" }, "Unlock for 15 minutes"),
|
||||
el("option", { value: "3600" }, "Unlock for 1 hour"),
|
||||
el("option", { value: "28800" }, "Unlock for 8 hours"),
|
||||
el("option", { value: "86400" }, "Unlock for 24 hours"));
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const btn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
busy(btn, "Unlocking…");
|
||||
try { await tauri.core.invoke("vault_unlock", { password: pw.value, ttl: Number(ttl.value) }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(btn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Unlock");
|
||||
pw.addEventListener("keydown", (e) => { if (e.key === "Enter") btn.click(); });
|
||||
view.replaceChildren(card(
|
||||
"Vault is locked",
|
||||
"Unlocks a timed session — agents and scripts can use secrets without the password until it expires.",
|
||||
pw, ttl, btn, err
|
||||
));
|
||||
}
|
||||
|
||||
function secretRow(s) {
|
||||
const copy = el("button", { class: "ghost sm", title: "Copy value" }, "copy");
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
const value = await tauri.core.invoke("vault_reveal", { name: s.name });
|
||||
await navigator.clipboard.writeText(value.trimEnd());
|
||||
copy.textContent = "copied";
|
||||
setTimeout(() => (copy.textContent = "copy"), 1200);
|
||||
} catch (e) { 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_rm", { name: s.name }); } 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.name),
|
||||
el("td", {}, s.url ? el("span", {
|
||||
class: "vlink",
|
||||
title: `${s.url} — click to copy`,
|
||||
onclick: async () => { await navigator.clipboard.writeText(s.url).catch(() => {}); },
|
||||
}, s.url.replace(/^https?:\/\//, "").slice(0, 34)) : "—"),
|
||||
el("td", { class: "dimcell", title: s.notes ?? "" }, (s.notes ?? "—").slice(0, 34)),
|
||||
el("td", { class: "dimcell" }, (s.updated_at ?? "").slice(0, 10)),
|
||||
el("td", { class: "acts" }, copy, del));
|
||||
}
|
||||
|
||||
async function renderUnlocked(st) {
|
||||
setPill("unlocked", st.expires_at);
|
||||
const secrets = await tauri.core.invoke("vault_secrets").catch(() => []);
|
||||
|
||||
const table = secrets.length
|
||||
? el("table", { class: "vtable" },
|
||||
el("thead", {}, el("tr", {},
|
||||
el("th", {}, "name"), el("th", {}, "url"), el("th", {}, "notes"),
|
||||
el("th", {}, "updated"), el("th", {}, ""))),
|
||||
el("tbody", {}, ...secrets.map(secretRow)))
|
||||
: dimNote("Empty — add a secret below or import a .env file.");
|
||||
|
||||
// inline add form
|
||||
const name = el("input", { placeholder: "NAME", class: "mono", spellcheck: "false" });
|
||||
const value = el("input", { type: "password", placeholder: "value", autocomplete: "off" });
|
||||
const url = el("input", { placeholder: "url (optional)", inputmode: "url" });
|
||||
const notes = el("input", { placeholder: "notes (optional)" });
|
||||
const err = el("p", { class: "err", hidden: true });
|
||||
const addBtn = el("button", {
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name.value)) {
|
||||
err.textContent = "Name must be env-safe: [A-Za-z_][A-Za-z0-9_]*";
|
||||
err.hidden = false; return;
|
||||
}
|
||||
if (!value.value) { err.textContent = "Value required."; err.hidden = false; return; }
|
||||
busy(addBtn, "Encrypting…");
|
||||
try {
|
||||
await tauri.core.invoke("vault_set", {
|
||||
name: name.value, value: value.value, url: url.value || null, notes: notes.value || null,
|
||||
});
|
||||
} catch (e) { err.textContent = String(e); err.hidden = false; unbusy(addBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Add secret");
|
||||
|
||||
const importPath = el("input", { placeholder: "/path/to/.env", class: "mono grow" });
|
||||
const importBtn = el("button", {
|
||||
class: "ghost",
|
||||
onclick: async () => {
|
||||
err.hidden = true;
|
||||
if (!importPath.value.trim()) return;
|
||||
busy(importBtn, "Importing…");
|
||||
try { await tauri.core.invoke("vault_import", { path: importPath.value.trim(), overwrite: false }); }
|
||||
catch (e) { err.textContent = String(e); err.hidden = false; unbusy(importBtn); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Import .env");
|
||||
|
||||
view.replaceChildren(
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
|
||||
table,
|
||||
el("div", { class: "vform" }, name, value, url, notes, addBtn),
|
||||
el("div", { class: "vform" }, importPath, importBtn),
|
||||
err),
|
||||
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.")
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
async function refresh() {
|
||||
if (!tauri) {
|
||||
view.replaceChildren(errBox("This page is meant to run inside the Primora desktop app."));
|
||||
return;
|
||||
}
|
||||
const ok = await tauri.core.invoke("vault_probe").catch(() => false);
|
||||
if (!ok) return renderMissing();
|
||||
const st = await tauri.core.invoke("vault_status").catch((e) => ({ error: String(e) }));
|
||||
if (st.error) { view.replaceChildren(errBox(st.error)); return; }
|
||||
pathEl.textContent = st.path ?? "";
|
||||
if (!st.exists) return renderInit();
|
||||
if (!st.unlocked) return renderLocked();
|
||||
renderUnlocked(st);
|
||||
}
|
||||
|
||||
lockBtn.addEventListener("click", async () => {
|
||||
await tauri.core.invoke("vault_lock").catch(() => {});
|
||||
refresh();
|
||||
});
|
||||
|
||||
// Keep the pill/table honest if the session expires while the window sits open.
|
||||
setInterval(async () => {
|
||||
const st = await tauri?.core.invoke("vault_status").catch(() => null);
|
||||
if (st && !st.unlocked && !lockBtn.hidden) refresh();
|
||||
}, 30000);
|
||||
|
||||
refresh();
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
DatabasesPage,
|
||||
TelemetryPage,
|
||||
IntegrationsPage,
|
||||
VaultPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -1531,6 +1532,7 @@ export default function App() {
|
||||
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
|
||||
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
||||
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||
@@ -1772,6 +1774,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "vault"}>
|
||||
<VaultPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconTelemetry,
|
||||
IconIntegrations,
|
||||
IconZap,
|
||||
IconKey,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
IconSettings,
|
||||
@@ -32,6 +33,7 @@ export type ViewType =
|
||||
| "telemetry"
|
||||
| "integrations"
|
||||
| "automation"
|
||||
| "vault"
|
||||
| "auth"
|
||||
| "audit"
|
||||
| "settings";
|
||||
@@ -49,6 +51,7 @@ const projectNav: NavEntry[] = [
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||
{ id: "vault", label: "Vault", icon: IconKey },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
|
||||
@@ -96,6 +99,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
telemetry: "Telemetry",
|
||||
integrations: "Integrations",
|
||||
automation: "Automation",
|
||||
vault: "Vault",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
settings: "Settings",
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import {
|
||||
SecretsService,
|
||||
type ProjectSecret,
|
||||
} from "@primora/api-client";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { IconPlus, IconTrash, IconCopy, IconRefresh, IconKey } from "../components/Icons";
|
||||
|
||||
interface VaultPageProps {
|
||||
projectID?: string;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
|
||||
const DEMO_SECRETS: ProjectSecret[] = [
|
||||
{ id: "d1", name: "STRIPE_SECRET", url: "https://dashboard.stripe.com", notes: "prod", created_at: "", updated_at: "2026-09-18T10:00:00Z" },
|
||||
{ id: "d2", name: "SLACK_WEBHOOK", url: "https://hooks.slack.com", notes: "alerts channel", created_at: "", updated_at: "2026-09-10T08:00:00Z" },
|
||||
];
|
||||
|
||||
// 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 = () =>
|
||||
(window as unknown as {
|
||||
__TAURI__?: { core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } };
|
||||
}).__TAURI__;
|
||||
|
||||
interface LocalSecret {
|
||||
name: string;
|
||||
url: string | null;
|
||||
notes: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface LocalStatus {
|
||||
exists: boolean;
|
||||
unlocked: boolean;
|
||||
expires_at: number | null;
|
||||
secrets: number | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function VaultPage(props: VaultPageProps) {
|
||||
const [secrets, setSecrets] = createSignal<ProjectSecret[]>([]);
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [error, setError] = createSignal("");
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [showImport, setShowImport] = createSignal(false);
|
||||
const [form, setForm] = createSignal({ name: "", value: "", url: "", notes: "" });
|
||||
const [importText, setImportText] = createSignal("");
|
||||
|
||||
const [localStatus, setLocalStatus] = createSignal<LocalStatus>();
|
||||
const [localSecrets, setLocalSecrets] = createSignal<LocalSecret[]>([]);
|
||||
const [localErr, setLocalErr] = createSignal("");
|
||||
const [localPw, setLocalPw] = createSignal("");
|
||||
const [localTtl, setLocalTtl] = createSignal("900");
|
||||
const [now, setNow] = createSignal(Math.floor(Date.now() / 1000));
|
||||
const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
|
||||
onCleanup(() => clearInterval(timer));
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
if (props.demoMode) {
|
||||
setSecrets(DEMO_SECRETS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await SecretsService.listProjectSecrets({ projectId: props.projectID });
|
||||
setSecrets(res.items);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLocal = async () => {
|
||||
const t = tauri();
|
||||
if (!t) return;
|
||||
try {
|
||||
const st = (await t.core.invoke("vault_status")) as LocalStatus;
|
||||
setLocalStatus(st);
|
||||
setLocalSecrets(st.unlocked ? ((await t.core.invoke("vault_secrets")) as LocalSecret[]) : []);
|
||||
} catch (e) {
|
||||
setLocalErr(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
void refreshLocal();
|
||||
});
|
||||
createEffect(() => void refresh());
|
||||
|
||||
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
|
||||
setBusy(key);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await task();
|
||||
if (done) setMessage(done);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const runLocal = async (task: () => Promise<unknown>) => {
|
||||
setLocalErr("");
|
||||
try {
|
||||
await task();
|
||||
} catch (e) {
|
||||
setLocalErr(err(e));
|
||||
} finally {
|
||||
await refreshLocal();
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setMessage(`${label} copied to clipboard`);
|
||||
} catch {
|
||||
setError("Clipboard unavailable — check browser permissions.");
|
||||
}
|
||||
};
|
||||
|
||||
const reveal = (name: string) =>
|
||||
run(`reveal-${name}`, async () => {
|
||||
if (props.demoMode) {
|
||||
await copy(`demo-${name.toLowerCase()}`, name);
|
||||
return;
|
||||
}
|
||||
const res = await SecretsService.revealProjectSecret({ projectId: props.projectID!, name });
|
||||
await copy(res.value, name);
|
||||
});
|
||||
|
||||
const revealLocal = (name: string) =>
|
||||
runLocal(async () => {
|
||||
const value = (await tauri()!.core.invoke("vault_reveal", { name })) as string;
|
||||
await copy(value.trimEnd(), name);
|
||||
});
|
||||
|
||||
const createSecret = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const f = form();
|
||||
void run("create", async () => {
|
||||
if (props.demoMode) {
|
||||
setSecrets([...secrets(), { id: `d${Date.now()}`, name: f.name, url: f.url, notes: f.notes, created_at: "", updated_at: new Date().toISOString() }]);
|
||||
return;
|
||||
}
|
||||
await SecretsService.setProjectSecret({
|
||||
projectId: props.projectID!,
|
||||
name: f.name.trim(),
|
||||
requestBody: { value: f.value, url: f.url.trim() || undefined, notes: f.notes.trim() || undefined },
|
||||
});
|
||||
setShowCreate(false);
|
||||
setForm({ name: "", value: "", url: "", notes: "" });
|
||||
}, `Secret ${f.name} stored`);
|
||||
};
|
||||
|
||||
const removeSecret = (name: string) =>
|
||||
run(`del-${name}`, async () => {
|
||||
if (props.demoMode) {
|
||||
setSecrets(secrets().filter((s) => s.name !== name));
|
||||
return;
|
||||
}
|
||||
await SecretsService.deleteProjectSecret({ projectId: props.projectID!, name });
|
||||
}, `Secret ${name} deleted`);
|
||||
|
||||
const importEnv = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const pairs = importText()
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("#") && l.includes("="))
|
||||
.map((l) => {
|
||||
const i = l.indexOf("=");
|
||||
return [l.slice(0, i).trim().replace(/^export\s+/, ""), l.slice(i + 1).trim().replace(/^["']|["']$/g, "")] as const;
|
||||
})
|
||||
.filter(([k]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k));
|
||||
if (!pairs.length) {
|
||||
setError("No valid KEY=value lines found.");
|
||||
return;
|
||||
}
|
||||
void run("import", async () => {
|
||||
for (const [name, value] of pairs) {
|
||||
await SecretsService.setProjectSecret({ projectId: props.projectID!, name, requestBody: { value } });
|
||||
}
|
||||
setShowImport(false);
|
||||
setImportText("");
|
||||
}, `Imported ${pairs.length} secret${pairs.length === 1 ? "" : "s"}`);
|
||||
};
|
||||
|
||||
const localRemaining = () => {
|
||||
const st = localStatus();
|
||||
return st?.expires_at ? Math.max(0, st.expires_at - now()) : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="page-title">Vault</h1>
|
||||
<p class="page-description">
|
||||
Encrypted project secrets — reference them in job payloads as <code>secret://NAME</code>, resolved at
|
||||
delivery. Values are never included in lists.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => setShowImport(true)}>Import .env</button>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
|
||||
<IconPlus class="w-4 h-4" /> New secret
|
||||
</button>
|
||||
</Show>
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => void refresh()} title="Refresh">
|
||||
<IconRefresh class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={message()}><div class="message message-success mb-4">{message()}</div></Show>
|
||||
<Show when={error()}><div class="message message-error mb-4">{error()}</div></Show>
|
||||
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">Project secrets</div>
|
||||
<div class="card-header-description">
|
||||
Stored AES-256-GCM encrypted in this deployment. Copy goes to your clipboard and is audit-logged.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={secrets().length > 0}
|
||||
fallback={<p class="text-text-2 text-sm p-4">No secrets yet — create one or import a .env file.</p>}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th>Notes</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={secrets()}>
|
||||
{(s) => (
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{s.name}</td>
|
||||
<td class="text-sm">{s.url ? <a href={s.url} target="_blank" rel="noreferrer">{s.url.replace(/^https?:\/\//, "")}</a> : "—"}</td>
|
||||
<td class="text-sm text-text-2" title={s.notes}>{s.notes || "—"}</td>
|
||||
<td class="text-sm text-text-2">{fmtTime(s.updated_at)}</td>
|
||||
<td>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button class="btn btn-ghost btn-sm" title="Copy value (audit-logged)" onClick={() => void reveal(s.name)} disabled={busy() === `reveal-${s.name}`}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-ghost btn-sm" title="Delete" onClick={() => void removeSecret(s.name)} disabled={busy() === `del-${s.name}`}>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={tauri()}>
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title flex items-center gap-2"><IconKey class="w-4 h-4" /> This machine</div>
|
||||
<div class="card-header-description">
|
||||
Local vault on this device — the same one the CLI manages. These secrets never touch the server.
|
||||
</div>
|
||||
</div>
|
||||
<Show when={localStatus()?.unlocked}>
|
||||
<span class="badge badge-success font-mono">
|
||||
unlocked — {Math.floor(localRemaining() / 60)}m {String(localRemaining() % 60).padStart(2, "0")}s
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<Show when={localErr()}><div class="message message-error mb-3">{localErr()}</div></Show>
|
||||
|
||||
<Show
|
||||
when={localStatus()?.unlocked}
|
||||
fallback={
|
||||
<Show
|
||||
when={localStatus()?.exists}
|
||||
fallback={
|
||||
<p class="text-sm text-text-2">
|
||||
No vault on this device — create one with <code>primora vault init</code> or from the tray's
|
||||
Secrets Vault window.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<form
|
||||
class="flex gap-2 items-end flex-wrap"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void runLocal(() => tauri()!.core.invoke("vault_unlock", { password: localPw(), ttl: Number(localTtl()) }));
|
||||
setLocalPw("");
|
||||
}}
|
||||
>
|
||||
<div class="flex-1 min-w-[10rem]">
|
||||
<Input type="password" placeholder="Vault password" value={localPw()} onInput={(e) => setLocalPw(e.currentTarget.value)} />
|
||||
</div>
|
||||
<Select value={localTtl()} onChange={(e) => setLocalTtl(e.currentTarget.value)} class="w-auto">
|
||||
<option value="900">15 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="28800">8 hours</option>
|
||||
<option value="86400">24 hours</option>
|
||||
</Select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Unlock</button>
|
||||
</form>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={localSecrets().length > 0}
|
||||
fallback={<p class="text-sm text-text-2">Local vault is empty — manage it from the tray's Secrets Vault window or the CLI.</p>}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>URL</th><th>Notes</th><th class="text-right">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={localSecrets()}>
|
||||
{(s) => (
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{s.name}</td>
|
||||
<td class="text-sm">{s.url ?? "—"}</td>
|
||||
<td class="text-sm text-text-2" title={s.notes ?? ""}>{s.notes ?? "—"}</td>
|
||||
<td>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button class="btn btn-ghost btn-sm" title="Copy value" onClick={() => void revealLocal(s.name)}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Delete" onClick={() => void runLocal(() => tauri()!.core.invoke("vault_rm", { name: s.name }))}>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<p class="text-xs text-text-2">
|
||||
Injection and the credential broker stay in the CLI: <code>primora inject</code> / <code>primora agent</code>.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" onClick={() => void runLocal(() => tauri()!.core.invoke("vault_lock"))}>Lock</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New secret" size="md">
|
||||
<form onSubmit={createSecret} class="space-y-4">
|
||||
<Input label="Name" placeholder="STRIPE_SECRET" value={form().name} onInput={(e) => setForm({ ...form(), name: e.currentTarget.value })} required />
|
||||
<Input label="Value" type="password" placeholder="sk_live_…" value={form().value} onInput={(e) => setForm({ ...form(), value: e.currentTarget.value })} required />
|
||||
<Input label="URL" placeholder="https://dashboard.stripe.com (optional)" value={form().url} onInput={(e) => setForm({ ...form(), url: e.currentTarget.value })} />
|
||||
<Input label="Notes" placeholder="optional" value={form().notes} onInput={(e) => setForm({ ...form(), notes: e.currentTarget.value })} />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create"}>Store secret</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={showImport()} onClose={() => setShowImport(false)} title="Import .env" size="md">
|
||||
<form onSubmit={importEnv} class="space-y-4">
|
||||
<Textarea label="Paste .env contents" rows={8} placeholder={"KEY=value\nOTHER=…"} value={importText()} onInput={(e) => setImportText(e.currentTarget.value)} class="font-mono text-sm" />
|
||||
<p class="text-xs text-text-2">Existing names are overwritten. Keys that aren't env-var-safe are skipped.</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowImport(false)}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "import"}>Import</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export { CollectionsPage } from "./CollectionsPage";
|
||||
export { DatabasesPage } from "./DatabasesPage";
|
||||
export { TelemetryPage } from "./TelemetryPage";
|
||||
export { IntegrationsPage } from "./IntegrationsPage";
|
||||
export { VaultPage } from "./VaultPage";
|
||||
export { AutomationPage } from "./AutomationPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
|
||||
@@ -21,3 +21,14 @@ config file - `primora login` once and both tools work. Env vars override:
|
||||
collections, API keys, integrations, webhooks, scheduled jobs, telemetry,
|
||||
and audit: `primora_whoami`, `primora_list_projects`,
|
||||
`primora_upload_object`, `primora_query_audit`, `primora_run_job`, …
|
||||
|
||||
Secrets are agent-safe by design — values are never returned by any tool:
|
||||
|
||||
- `primora_vault_status` / `primora_vault_list` — local-vault state and
|
||||
metadata (names/urls/notes only); requires `primora vault unlock` on the host.
|
||||
- `primora_vault_exec` — runs `primora` commands with a vault secret injected
|
||||
into the child's env; `vault`/`secrets`/`inject`/`agent`/`login`/`use` and
|
||||
`keys:create` are denied so no response can carry a credential.
|
||||
- `primora_secrets_list` / `primora_secrets_set` — project-vault metadata and
|
||||
write-only storage. No reveal or delete: agents consume secrets through
|
||||
`secret://NAME` references in job payloads, resolved server-side at delivery.
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
OrganizationsService,
|
||||
PlatformService,
|
||||
ProjectsService,
|
||||
SecretsService,
|
||||
StorageService,
|
||||
TelemetryService,
|
||||
WebhooksService,
|
||||
@@ -880,4 +883,148 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* local vault — metadata only, values never leave the CLI */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
const PRIMORA_CLI = process.env.PRIMORA_CLI ?? "primora";
|
||||
|
||||
/** Run the primora CLI; returns {code, stdout, stderr}. Values fetched via
|
||||
* `secrets get` stay inside this process — they are never returned. */
|
||||
function runCli(args: string[], env?: Record<string, string>) {
|
||||
const res = spawnSync(PRIMORA_CLI, args, {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
return {
|
||||
code: res.status ?? 1,
|
||||
stdout: (res.stdout ?? "").toString(),
|
||||
stderr: ((res.stderr ?? "") + (res.error ? String(res.error.message) : "")).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
server.registerTool(
|
||||
"primora_vault_status",
|
||||
{
|
||||
description:
|
||||
"Local vault state — exists, locked/unlocked, KDF parameters, secret count. Unlock with `primora vault unlock` on the host first.",
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const r = runCli(["vault", "status", "--json"]);
|
||||
if (r.code !== 0) throw new Error(r.stderr.trim() || "vault status failed");
|
||||
return ok(JSON.parse(r.stdout));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_vault_list",
|
||||
{
|
||||
description:
|
||||
"List vault secrets — names, urls, notes, timestamps only. Secret values are never returned by any tool; they are injected at exec time.",
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const r = runCli(["secrets", "list", "--json"]);
|
||||
if (r.code !== 0) throw new Error(r.stderr.trim() || "vault locked — run `primora vault unlock`");
|
||||
return ok(JSON.parse(r.stdout));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Commands that would move a secret into the response are denied outright —
|
||||
// the CLI is the broker; the model sees results, not credentials.
|
||||
const EXEC_DENY_FIRST = new Set(["vault", "secrets", "inject", "agent", "login", "logout", "use"]);
|
||||
const EXEC_DENY_PAIR = new Set(["keys:create"]);
|
||||
|
||||
server.registerTool(
|
||||
"primora_vault_exec",
|
||||
{
|
||||
description:
|
||||
"Run a primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). The secret never appears in arguments or the response — only the command output is returned.",
|
||||
inputSchema: {
|
||||
command: z
|
||||
.array(z.string())
|
||||
.describe('primora arguments, e.g. ["objects","list","mybucket"] — vault/secrets/inject/agent/login/logout/use and keys:create are denied'),
|
||||
secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Vault secret name to inject as itself (default PRIMORA_API_KEY)"),
|
||||
},
|
||||
},
|
||||
async ({ command, secret }) => {
|
||||
try {
|
||||
if (!command.length) throw new Error("empty command");
|
||||
const first = command[0];
|
||||
const pair = `${first}:${command[1] ?? ""}`;
|
||||
if (EXEC_DENY_FIRST.has(first) || EXEC_DENY_PAIR.has(pair) || EXEC_DENY_PAIR.has(first)) {
|
||||
throw new Error(`denied: "${first}" would expose credentials or config`);
|
||||
}
|
||||
const secretName = secret ?? "PRIMORA_API_KEY";
|
||||
const got = runCli(["secrets", "get", secretName]);
|
||||
if (got.code !== 0) {
|
||||
throw new Error(got.stderr.trim() || `vault locked or "${secretName}" missing`);
|
||||
}
|
||||
const r = runCli(command, { [secretName]: got.stdout.trimEnd() });
|
||||
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Project vault — server-side store. Agents get metadata + write; there is no
|
||||
// reveal or delete tool. To use a secret, reference it as secret://NAME in a
|
||||
// scheduled-job payload and the server resolves it at delivery.
|
||||
server.registerTool(
|
||||
"primora_secrets_list",
|
||||
{
|
||||
description:
|
||||
"List project vault secrets — names, urls, notes, timestamps only. Values are never returned; reference them as secret://NAME in job payloads.",
|
||||
inputSchema: { projectId: z.string().optional() },
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
try {
|
||||
return ok(await SecretsService.listProjectSecrets({ projectId: projectId(p) }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_secrets_set",
|
||||
{
|
||||
description:
|
||||
"Store a secret in the project vault (encrypted at rest, write-only). The value is sent once and can never be read back through MCP. Omitted url/notes keep existing values.",
|
||||
inputSchema: {
|
||||
name: z.string().describe("Env-var-safe name, e.g. STRIPE_SECRET — referenced as secret://NAME"),
|
||||
value: z.string().describe("Secret value — sent once, never returned"),
|
||||
url: z.string().optional().describe("Associated dashboard/console URL"),
|
||||
notes: z.string().optional().describe("Free-text notes"),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, value, url, notes, projectId: p }) => {
|
||||
try {
|
||||
const res = await SecretsService.setProjectSecret({
|
||||
projectId: projectId(p),
|
||||
name,
|
||||
requestBody: { value, url, notes },
|
||||
});
|
||||
// Return metadata only — echoing the value back would put it in context.
|
||||
const { id: _, ...meta } = res;
|
||||
return ok(meta);
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
Generated
+70
-129
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "primora",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "primora",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.1",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -41,6 +41,8 @@
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"cac": "^7.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -67,7 +69,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@primora/desktop",
|
||||
"version": "0.5.0",
|
||||
"version": "0.7.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4"
|
||||
}
|
||||
@@ -173,7 +175,7 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
@@ -187,7 +189,7 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1807,7 +1809,7 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1827,7 +1829,7 @@
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1851,7 +1853,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1879,7 +1881,7 @@
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1902,7 +1904,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1956,7 +1958,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
@@ -1972,7 +1973,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -1988,7 +1988,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -2004,7 +2003,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -2020,7 +2018,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -2036,7 +2033,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -2052,7 +2048,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -2068,7 +2063,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -2084,7 +2078,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2100,7 +2093,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2116,7 +2108,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2132,7 +2123,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2148,7 +2138,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2164,7 +2153,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2180,7 +2168,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2196,7 +2183,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2212,7 +2198,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -2228,7 +2213,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -2244,7 +2228,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@@ -2260,7 +2243,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -2276,7 +2258,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -2292,7 +2273,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -2308,7 +2288,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@@ -2324,7 +2303,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2340,7 +2318,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2356,7 +2333,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2398,7 +2374,7 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -2418,7 +2394,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -2427,7 +2403,7 @@
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
@@ -2443,7 +2419,7 @@
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
@@ -2511,9 +2487,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
|
||||
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
@@ -2522,9 +2499,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
@@ -3074,7 +3052,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -3087,7 +3064,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -3100,7 +3076,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -3113,7 +3088,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -3126,7 +3100,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -3139,7 +3112,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -3152,7 +3124,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3165,7 +3136,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3178,7 +3148,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3191,7 +3160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3204,7 +3172,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3217,7 +3184,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3230,7 +3196,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3243,7 +3208,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3256,7 +3220,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3269,7 +3232,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3282,7 +3244,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3295,7 +3256,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3308,7 +3268,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -3321,7 +3280,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -3334,7 +3292,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -3347,7 +3304,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3360,7 +3316,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3373,7 +3328,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3386,7 +3340,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -3802,7 +3755,7 @@
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -3937,7 +3890,7 @@
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3949,7 +3902,7 @@
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
@@ -4523,7 +4476,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
@@ -4842,7 +4795,7 @@
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
@@ -4861,7 +4814,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
@@ -4875,7 +4828,7 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
@@ -4888,7 +4841,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -4898,7 +4851,7 @@
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
@@ -4979,7 +4932,7 @@
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
@@ -5061,7 +5014,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -5136,7 +5089,7 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
@@ -5714,7 +5667,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5844,7 +5796,7 @@
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
@@ -6030,7 +5982,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
@@ -6068,7 +6020,7 @@
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
@@ -6082,7 +6034,7 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
@@ -6096,7 +6048,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -6433,7 +6385,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
@@ -6657,7 +6609,7 @@
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -6692,7 +6644,7 @@
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
@@ -6732,7 +6684,7 @@
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
@@ -6745,7 +6697,7 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -6755,7 +6707,7 @@
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
@@ -6841,7 +6793,7 @@
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -6873,7 +6825,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -6893,7 +6844,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -6913,7 +6863,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -6933,7 +6882,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -6953,7 +6901,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6973,7 +6920,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -6993,7 +6939,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -7013,7 +6958,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -7033,7 +6977,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -7053,7 +6996,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -7073,7 +7015,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -7417,7 +7358,7 @@
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
@@ -7540,7 +7481,7 @@
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
@@ -7995,7 +7936,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -8281,7 +8222,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
@@ -8397,7 +8338,7 @@
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
@@ -8485,7 +8426,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
@@ -8777,7 +8718,7 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -8795,7 +8736,7 @@
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
@@ -9045,7 +8986,7 @@
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
@@ -9116,7 +9057,7 @@
|
||||
"version": "5.51.2",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
|
||||
"integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -9134,7 +9075,7 @@
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
@@ -9245,7 +9186,7 @@
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
@@ -9258,7 +9199,7 @@
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
@@ -9285,7 +9226,7 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
@@ -9316,7 +9257,7 @@
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -9494,7 +9435,7 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
"version": "2.0.1",
|
||||
@@ -10118,7 +10059,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
@@ -10137,7 +10078,7 @@
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
@@ -10150,7 +10091,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10522,7 +10463,7 @@
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -10544,7 +10485,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10554,7 +10495,7 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
|
||||
@@ -96,8 +96,11 @@ export type { Project } from './models/Project';
|
||||
export type { ProjectID } from './models/ProjectID';
|
||||
export { ProjectMember } from './models/ProjectMember';
|
||||
export type { ProjectOverview } from './models/ProjectOverview';
|
||||
export type { ProjectSecret } from './models/ProjectSecret';
|
||||
export type { ProjectSecretListResponse } from './models/ProjectSecretListResponse';
|
||||
export type { ProjectSummary } from './models/ProjectSummary';
|
||||
export type { ReadinessStatus } from './models/ReadinessStatus';
|
||||
export type { RevealProjectSecretResponse } from './models/RevealProjectSecretResponse';
|
||||
export type { RybbitMetricItem } from './models/RybbitMetricItem';
|
||||
export type { RybbitSite } from './models/RybbitSite';
|
||||
export { ScheduledJob } from './models/ScheduledJob';
|
||||
@@ -106,6 +109,8 @@ export type { ScheduledJobListResponse } from './models/ScheduledJobListResponse
|
||||
export { ScheduledJobRun } from './models/ScheduledJobRun';
|
||||
export type { ScheduledJobRunListResponse } from './models/ScheduledJobRunListResponse';
|
||||
export type { SchemaQuery } from './models/SchemaQuery';
|
||||
export type { SecretName } from './models/SecretName';
|
||||
export type { SetProjectSecretRequest } from './models/SetProjectSecretRequest';
|
||||
export type { TableQuery } from './models/TableQuery';
|
||||
export type { TelemetryComponent } from './models/TelemetryComponent';
|
||||
export type { TelemetryComponentListResponse } from './models/TelemetryComponentListResponse';
|
||||
@@ -223,8 +228,11 @@ export { $Project } from './schemas/$Project';
|
||||
export { $ProjectID } from './schemas/$ProjectID';
|
||||
export { $ProjectMember } from './schemas/$ProjectMember';
|
||||
export { $ProjectOverview } from './schemas/$ProjectOverview';
|
||||
export { $ProjectSecret } from './schemas/$ProjectSecret';
|
||||
export { $ProjectSecretListResponse } from './schemas/$ProjectSecretListResponse';
|
||||
export { $ProjectSummary } from './schemas/$ProjectSummary';
|
||||
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
||||
export { $RevealProjectSecretResponse } from './schemas/$RevealProjectSecretResponse';
|
||||
export { $RybbitMetricItem } from './schemas/$RybbitMetricItem';
|
||||
export { $RybbitSite } from './schemas/$RybbitSite';
|
||||
export { $ScheduledJob } from './schemas/$ScheduledJob';
|
||||
@@ -233,6 +241,8 @@ export { $ScheduledJobListResponse } from './schemas/$ScheduledJobListResponse';
|
||||
export { $ScheduledJobRun } from './schemas/$ScheduledJobRun';
|
||||
export { $ScheduledJobRunListResponse } from './schemas/$ScheduledJobRunListResponse';
|
||||
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
||||
export { $SecretName } from './schemas/$SecretName';
|
||||
export { $SetProjectSecretRequest } from './schemas/$SetProjectSecretRequest';
|
||||
export { $TableQuery } from './schemas/$TableQuery';
|
||||
export { $TelemetryComponent } from './schemas/$TelemetryComponent';
|
||||
export { $TelemetryComponentListResponse } from './schemas/$TelemetryComponentListResponse';
|
||||
@@ -270,6 +280,7 @@ export { IntegrationsService } from './services/IntegrationsService';
|
||||
export { OrganizationsService } from './services/OrganizationsService';
|
||||
export { PlatformService } from './services/PlatformService';
|
||||
export { ProjectsService } from './services/ProjectsService';
|
||||
export { SecretsService } from './services/SecretsService';
|
||||
export { StorageService } from './services/StorageService';
|
||||
export { TelemetryService } from './services/TelemetryService';
|
||||
export { WebhooksService } from './services/WebhooksService';
|
||||
@@ -0,0 +1,16 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ProjectSecret = {
|
||||
id: string;
|
||||
/**
|
||||
* Env-var-safe name; referenced as secret://NAME in job payloads.
|
||||
*/
|
||||
name: string;
|
||||
url: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ProjectSecret } from './ProjectSecret';
|
||||
export type ProjectSecretListResponse = {
|
||||
items: Array<ProjectSecret>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type RevealProjectSecretResponse = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type SecretName = string;
|
||||
@@ -0,0 +1,13 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type SetProjectSecretRequest = {
|
||||
/**
|
||||
* Plaintext — stored AES-256-GCM encrypted, never returned by list.
|
||||
*/
|
||||
value: string;
|
||||
url?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $ProjectSecret = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'uuid',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: `Env-var-safe name; referenced as secret://NAME in job payloads.`,
|
||||
isRequired: true,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
notes: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
updated_at: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $ProjectSecretListResponse = {
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'ProjectSecret',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $RevealProjectSecretResponse = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $SecretName = {
|
||||
type: 'string',
|
||||
pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
|
||||
} as const;
|
||||
@@ -0,0 +1,19 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $SetProjectSecretRequest = {
|
||||
properties: {
|
||||
value: {
|
||||
type: 'string',
|
||||
description: `Plaintext — stored AES-256-GCM encrypted, never returned by list.`,
|
||||
isRequired: true,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
},
|
||||
notes: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ProjectSecret } from '../models/ProjectSecret';
|
||||
import type { ProjectSecretListResponse } from '../models/ProjectSecretListResponse';
|
||||
import type { RevealProjectSecretResponse } from '../models/RevealProjectSecretResponse';
|
||||
import type { SetProjectSecretRequest } from '../models/SetProjectSecretRequest';
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
import { request as __request } from '../core/request';
|
||||
export class SecretsService {
|
||||
/**
|
||||
* Lists project secrets — metadata only, values are never included.
|
||||
* @returns ProjectSecretListResponse Project secrets (metadata only)
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listProjectSecrets({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string,
|
||||
}): CancelablePromise<ProjectSecretListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/secrets',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Creates or replaces a project secret (AES-256-GCM at rest). Omitted url/notes keep existing values. Job payloads can reference it as secret://NAME, resolved at delivery.
|
||||
* @returns ProjectSecret Secret stored
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static setProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
requestBody,
|
||||
}: {
|
||||
projectId: string,
|
||||
name: string,
|
||||
requestBody: SetProjectSecretRequest,
|
||||
}): CancelablePromise<ProjectSecret> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/projects/{projectID}/secrets/{name}',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'name': name,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
400: `Error response`,
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns void
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
}: {
|
||||
projectId: string,
|
||||
name: string,
|
||||
}): CancelablePromise<void> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/projects/{projectID}/secrets/{name}',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'name': name,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Returns the decrypted value. Every call is audit-logged.
|
||||
* @returns RevealProjectSecretResponse Decrypted value
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static revealProjectSecret({
|
||||
projectId,
|
||||
name,
|
||||
}: {
|
||||
projectId: string,
|
||||
name: string,
|
||||
}): CancelablePromise<RevealProjectSecretResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/secrets/{name}/reveal',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'name': name,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user