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:
Tomas DvorakandDevin committed 2026-09-24 18:46:37 +02:00
1 parent 31bc23386b
commit 585bb04ab2
52 files changed
+3929 -142

No files matched your search

+16
View File
@@ -2,6 +2,22 @@
All notable changes to Primora. Format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/). 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 ## [0.7.1] - 2026-09-23
### Fixed ### Fixed
+1 -1
View File
@@ -37,7 +37,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
| `DRAGONFLY_URL` | backend → cache DSN | yes | | `DRAGONFLY_URL` | backend → cache DSN | yes |
| `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes | | `JWT_SECRET` | signs API JWTs (`JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_TTL_SECONDS` tune tokens) | yes |
| `BETTER_AUTH_SECRET` | auth session/signing secret | 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_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | 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 | | `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
+1
View File
@@ -61,6 +61,7 @@ The important ones:
| `POSTGRES_*` / `DATABASE_URL` | Postgres connection | | `POSTGRES_*` / `DATABASE_URL` | Postgres connection |
| `DRAGONFLY_URL` | Cache/rate-limit store | | `DRAGONFLY_URL` | Cache/rate-limit store |
| `SMTP_*` / `MAIL_FROM` | Transactional email (Mailpit in dev, or Resend via `RESEND_API_KEY`) | | `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 | | `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 | | `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 | | `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | Throttling defaults — overridable in-app |
+2 -2
View File
@@ -91,9 +91,9 @@ and [QUICK_START.md](QUICK_START.md#configuration) for the important ones.
## Ecosystem ## 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. - **[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. - **PWA** - the dashboard is installable on mobile and desktop browsers.
## Documentation ## Documentation
+18
View File
@@ -263,6 +263,24 @@ dashboard's live feed off the same stream.
`auth-client` now resolves `/auth` against `window.location.origin`, `auth-client` now resolves `/auth` against `window.location.origin`,
preserving same-origin defaults without breaking split-origin dev. 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 ## Future phases — candidates
Ordered loosely by leverage. None committed; each gets scoped when picked. 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"` 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 { type CoreScheduledJob struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_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) DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error) DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, 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) DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error DeleteSetting(ctx context.Context, key string) error
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, 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) GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error) GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, 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) GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetSetting(ctx context.Context, key string) (GetSettingRow, error) GetSetting(ctx context.Context, key string) (GetSettingRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, 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) ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error) ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, 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) ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error) ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, 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) UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error) UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, 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) UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error) UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
} }
+4
View File
@@ -116,6 +116,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob) api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns) api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob) 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("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/buckets/:bucketID/objects", h.listObjects) api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject) api.POST("/buckets/:bucketID/objects", h.uploadObject)
+87
View File
@@ -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)
}
+6 -1
View File
@@ -393,6 +393,7 @@ type jobQueries interface {
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error) FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error) MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
} }
type JobScheduler struct { 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. // 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) { 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{ payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun, "event": WebhookEventJobRun,
"project_id": job.ProjectID.String(), "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(), "run_id": run.ID.String(),
"triggered_by": run.TriggeredBy, "triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339), "occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload), "data": data,
}) })
secret := "" secret := ""
if len(job.Secret) > 0 && j.enc != nil { 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")
}
})
}
+139
View File
@@ -1838,6 +1838,91 @@ paths:
description: text/event-stream of {type, occurred_at, data} messages description: text/event-stream of {type, occurred_at, data} messages
"401": "401":
$ref: "#/components/responses/ErrorResponse" $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: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
@@ -1863,6 +1948,13 @@ components:
schema: schema:
type: string type: string
format: uuid format: uuid
SecretName:
name: name
in: path
required: true
schema:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
BucketID: BucketID:
name: bucketID name: bucketID
in: path in: path
@@ -3664,3 +3756,50 @@ components:
items: items:
$ref: "#/components/schemas/ScheduledJobRun" $ref: "#/components/schemas/ScheduledJobRun"
required: [items] 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
View File
@@ -1,6 +1,7 @@
# @primora/cli # @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 ## Build
@@ -20,4 +21,123 @@ project interactively.
## Commands ## Commands
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`, `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.
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@primora/cli", "name": "@primora/cli",
"version": "0.5.0", "version": "0.6.0",
"description": "Primora command-line client", "description": "Primora command-line client",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -19,6 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"cac": "^7.0.0", "cac": "^7.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
+151 -2
View File
@@ -4,6 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js"; import { cmdAuditList } from "./commands/audit.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js"; import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.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 { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js"; import { cmdKeysCreate, cmdKeysList, cmdKeysRevoke } from "./commands/keys.js";
import { import {
@@ -14,6 +15,28 @@ import {
} from "./commands/objects.js"; } from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js"; import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.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"; import { fail } from "./out.js";
const cli = cac("primora"); const cli = cac("primora");
@@ -144,16 +167,142 @@ cli
.option("--interval <s>", "Poll interval in seconds with --follow (default 2)") .option("--interval <s>", "Poll interval in seconds with --follow (default 2)")
.action(cmdAuditList); .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.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 // cac only matches the first positional token, so "orgs list" cannot be a
// command name. Fold "group verb" pairs into "group:verb" before parsing — // command name. Fold "group verb" pairs into "group:verb" before parsing —
// `primora orgs list` and `primora orgs:list` both work. // `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); const argv = process.argv.slice(2);
if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) { if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv.splice(0, 2, `${argv[0]}:${argv[1]}`); 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() { async function main() {
+236
View File
@@ -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);
}
+61
View File
@@ -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]));
}
+317
View File
@@ -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.");
}
}
+142
View File
@@ -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.");
}
+166
View File
@@ -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.`);
}
+16
View File
@@ -1,3 +1,4 @@
import * as p from "@clack/prompts";
import pc from "picocolors"; import pc from "picocolors";
export function isJson(options: { json?: boolean }): boolean { export function isJson(options: { json?: boolean }): boolean {
@@ -28,6 +29,21 @@ export function fail(error: unknown): never {
process.exit(1); 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. */ /** Minimal column printer — left-aligned, two-space gutter. */
export function printTable(headers: string[], rows: string[][]): void { export function printTable(headers: string[], rows: string[][]): void {
const all = headers.length ? [headers, ...rows] : rows; const all = headers.length ? [headers, ...rows] : rows;
+247
View File
@@ -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 });
}
+15
View File
@@ -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. to be a Primora deployment, use the tray's Change Server.
- macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or - macOS blocks plain-HTTP loads to non-localhost hosts (ATS). Use HTTPS, or
`http://localhost` for a local deployment. `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. - Deep links / push notifications: deliberately omitted — nothing needs them yet.
@@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default capabilities for the main window", "description": "Default capabilities for the main window",
"windows": ["main"], "windows": ["main", "vault"],
"permissions": ["core:default"] "permissions": ["core:default"]
} }
+149 -3
View File
@@ -1,10 +1,13 @@
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{ use tauri::{
menu::{Menu, MenuItem}, menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, WindowEvent, AppHandle, Listener, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent,
}; };
/// Persisted shell state — one field, the deployment to load. /// 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main(app); 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| { .setup(|app| {
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?; 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 change = MenuItem::with_id(app, "change_server", "Change Server…", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Quit", 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() let mut tray = TrayIconBuilder::new()
.menu(&menu) .menu(&menu)
.tooltip("Primora") .tooltip("Primora")
.on_menu_event(|app, event| match event.id().as_ref() { .on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app), "show" => show_main(app),
"vault" => open_vault(app),
"change_server" => { "change_server" => {
// Clearing the URL and restarting lands back on the connect screen. // Clearing the URL and restarting lands back on the connect screen.
let _ = save_config(app, &DesktopConfig { server_url: None }); let _ = save_config(app, &DesktopConfig { server_url: None });
@@ -108,6 +250,10 @@ pub fn run() {
} }
let _tray = tray.build(app)?; 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. // Close hides to the tray instead of quitting — Quit is on the menu.
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let handle = window.clone(); let handle = window.clone();
+6
View File
@@ -27,6 +27,7 @@
</form> </form>
<p id="err" class="err" hidden></p> <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">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> </main>
<script type="module"> <script type="module">
@@ -51,6 +52,11 @@
if (saved) location.replace(saved); if (saved) location.replace(saved);
} }
document.getElementById("vault-link").addEventListener("click", (e) => {
e.preventDefault();
tauri?.event.emit("open-vault");
});
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
err.hidden = true; err.hidden = true;
+219
View File
@@ -99,3 +99,222 @@ button:disabled {
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.5; 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;
}
+30
View File
@@ -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>
+247
View File
@@ -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();
+10
View File
@@ -47,6 +47,7 @@ import {
DatabasesPage, DatabasesPage,
TelemetryPage, TelemetryPage,
IntegrationsPage, IntegrationsPage,
VaultPage,
LoginPage, LoginPage,
MembersPage, MembersPage,
ProjectsPage, 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-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-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-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-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-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") }, { 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>
<Show when={activeView() === "vault"}>
<VaultPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}> <Show when={activeView() === "storage"}>
<StoragePage <StoragePage
buckets={buckets()} buckets={buckets()}
@@ -11,6 +11,7 @@ import {
IconTelemetry, IconTelemetry,
IconIntegrations, IconIntegrations,
IconZap, IconZap,
IconKey,
IconAudit, IconAudit,
IconAuth, IconAuth,
IconSettings, IconSettings,
@@ -32,6 +33,7 @@ export type ViewType =
| "telemetry" | "telemetry"
| "integrations" | "integrations"
| "automation" | "automation"
| "vault"
| "auth" | "auth"
| "audit" | "audit"
| "settings"; | "settings";
@@ -49,6 +51,7 @@ const projectNav: NavEntry[] = [
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry }, { id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations }, { id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap }, { id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
{ id: "vault", label: "Vault", icon: IconKey },
{ id: "storage", label: "Storage", icon: IconStorage }, { id: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections }, { id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth }, { id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
@@ -96,6 +99,7 @@ const viewTitle: Record<ViewType, string> = {
telemetry: "Telemetry", telemetry: "Telemetry",
integrations: "Integrations", integrations: "Integrations",
automation: "Automation", automation: "Automation",
vault: "Vault",
auth: "Authentication", auth: "Authentication",
audit: "Audit log", audit: "Audit log",
settings: "Settings", settings: "Settings",
+404
View File
@@ -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>
);
}
+1
View File
@@ -7,6 +7,7 @@ export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage"; export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage"; export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage"; export { IntegrationsPage } from "./IntegrationsPage";
export { VaultPage } from "./VaultPage";
export { AutomationPage } from "./AutomationPage"; export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage"; export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage"; export { AuditPage } from "./AuditPage";
+11
View File
@@ -21,3 +21,14 @@ config file - `primora login` once and both tools work. Env vars override:
collections, API keys, integrations, webhooks, scheduled jobs, telemetry, collections, API keys, integrations, webhooks, scheduled jobs, telemetry,
and audit: `primora_whoami`, `primora_list_projects`, and audit: `primora_whoami`, `primora_list_projects`,
`primora_upload_object`, `primora_query_audit`, `primora_run_job`, … `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.
+147
View File
@@ -1,3 +1,5 @@
import { spawnSync } from "node:child_process";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
@@ -10,6 +12,7 @@ import {
OrganizationsService, OrganizationsService,
PlatformService, PlatformService,
ProjectsService, ProjectsService,
SecretsService,
StorageService, StorageService,
TelemetryService, TelemetryService,
WebhooksService, 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()); await server.connect(new StdioServerTransport());
+70 -129
View File
@@ -1,12 +1,12 @@
{ {
"name": "primora", "name": "primora",
"version": "0.5.0", "version": "0.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "primora", "name": "primora",
"version": "0.5.0", "version": "0.7.1",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -41,6 +41,8 @@
"version": "0.5.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"cac": "^7.0.0", "cac": "^7.0.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
}, },
@@ -67,7 +69,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@primora/desktop", "name": "@primora/desktop",
"version": "0.5.0", "version": "0.7.1",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4" "@tauri-apps/cli": "^2.11.4"
} }
@@ -173,7 +175,7 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/css-calc": "^2.1.3", "@csstools/css-calc": "^2.1.3",
@@ -187,7 +189,7 @@
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true, "devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1807,7 +1809,7 @@
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1827,7 +1829,7 @@
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1851,7 +1853,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1879,7 +1881,7 @@
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1902,7 +1904,7 @@
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true, "devOptional": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1956,7 +1958,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"aix" "aix"
@@ -1972,7 +1973,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -1988,7 +1988,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -2004,7 +2003,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -2020,7 +2018,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -2036,7 +2033,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -2052,7 +2048,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -2068,7 +2063,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -2084,7 +2078,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2100,7 +2093,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2116,7 +2108,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2132,7 +2123,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2148,7 +2138,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2164,7 +2153,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2180,7 +2168,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2196,7 +2183,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2212,7 +2198,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -2228,7 +2213,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -2244,7 +2228,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"netbsd" "netbsd"
@@ -2260,7 +2243,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -2276,7 +2258,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -2292,7 +2273,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -2308,7 +2288,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"sunos" "sunos"
@@ -2324,7 +2303,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2340,7 +2318,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2356,7 +2333,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2398,7 +2374,7 @@
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -2418,7 +2394,7 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true, "devOptional": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -2427,7 +2403,7 @@
"version": "0.3.11", "version": "0.3.11",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25" "@jridgewell/trace-mapping": "^0.3.25"
@@ -2443,7 +2419,7 @@
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
@@ -2511,9 +2487,10 @@
} }
}, },
"node_modules/@noble/ciphers": { "node_modules/@noble/ciphers": {
"version": "2.1.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
}, },
@@ -2522,9 +2499,10 @@
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "2.0.1", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 20.19.0"
}, },
@@ -3074,7 +3052,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -3087,7 +3064,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -3100,7 +3076,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -3113,7 +3088,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -3126,7 +3100,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -3139,7 +3112,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -3152,7 +3124,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3165,7 +3136,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3178,7 +3148,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3191,7 +3160,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3204,7 +3172,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3217,7 +3184,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3230,7 +3196,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3243,7 +3208,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3256,7 +3220,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3269,7 +3232,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3282,7 +3244,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3295,7 +3256,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3308,7 +3268,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -3321,7 +3280,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -3334,7 +3292,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -3347,7 +3304,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -3360,7 +3316,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -3373,7 +3328,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -3386,7 +3340,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -3802,7 +3755,7 @@
"version": "24.12.0", "version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -3937,7 +3890,7 @@
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "devOptional": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3949,7 +3902,7 @@
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
@@ -4523,7 +4476,7 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true "devOptional": true
}, },
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
@@ -4842,7 +4795,7 @@
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^3.2.0", "@asamuzakjp/css-color": "^3.2.0",
@@ -4861,7 +4814,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"whatwg-mimetype": "^4.0.0", "whatwg-mimetype": "^4.0.0",
@@ -4875,7 +4828,7 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"punycode": "^2.3.1" "punycode": "^2.3.1"
@@ -4888,7 +4841,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true, "devOptional": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -4898,7 +4851,7 @@
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tr46": "^5.1.0", "tr46": "^5.1.0",
@@ -4979,7 +4932,7 @@
"version": "10.6.0", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/deep-eql": { "node_modules/deep-eql": {
@@ -5061,7 +5014,7 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "devOptional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -5136,7 +5089,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true, "devOptional": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@@ -5714,7 +5667,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"optional": true, "optional": true,
"os": [ "os": [
@@ -5844,7 +5796,7 @@
"version": "4.13.7", "version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"resolve-pkg-maps": "^1.0.0" "resolve-pkg-maps": "^1.0.0"
}, },
@@ -6030,7 +5982,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"whatwg-encoding": "^3.1.1" "whatwg-encoding": "^3.1.1"
@@ -6068,7 +6020,7 @@
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.0", "agent-base": "^7.1.0",
@@ -6082,7 +6034,7 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"agent-base": "^7.1.2", "agent-base": "^7.1.2",
@@ -6096,7 +6048,7 @@
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0" "safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -6433,7 +6385,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-promise": { "node_modules/is-promise": {
@@ -6657,7 +6609,7 @@
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "devOptional": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -6692,7 +6644,7 @@
"version": "26.1.0", "version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cssstyle": "^4.2.1", "cssstyle": "^4.2.1",
@@ -6732,7 +6684,7 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"punycode": "^2.3.1" "punycode": "^2.3.1"
@@ -6745,7 +6697,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true, "devOptional": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -6755,7 +6707,7 @@
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tr46": "^5.1.0", "tr46": "^5.1.0",
@@ -6841,7 +6793,7 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
}, },
@@ -6873,7 +6825,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -6893,7 +6844,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -6913,7 +6863,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -6933,7 +6882,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -6953,7 +6901,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -6973,7 +6920,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -6993,7 +6939,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -7013,7 +6958,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -7033,7 +6977,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -7053,7 +6996,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -7073,7 +7015,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -7417,7 +7358,7 @@
"version": "2.2.23", "version": "2.2.23",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": { "node_modules/object-assign": {
@@ -7540,7 +7481,7 @@
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
}, },
@@ -7995,7 +7936,7 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -8281,7 +8222,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true, "devOptional": true,
"funding": { "funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
} }
@@ -8397,7 +8338,7 @@
"version": "0.8.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/run-parallel": { "node_modules/run-parallel": {
@@ -8485,7 +8426,7 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"xmlchars": "^2.2.0" "xmlchars": "^2.2.0"
@@ -8777,7 +8718,7 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true, "devOptional": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -8795,7 +8736,7 @@
"version": "0.5.21", "version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
"source-map": "^0.6.0" "source-map": "^0.6.0"
@@ -9045,7 +8986,7 @@
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
@@ -9116,7 +9057,7 @@
"version": "5.51.2", "version": "5.51.2",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
"integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -9134,7 +9075,7 @@
"version": "2.20.3", "version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true "devOptional": true
}, },
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
@@ -9245,7 +9186,7 @@
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tldts-core": "^6.1.86" "tldts-core": "^6.1.86"
@@ -9258,7 +9199,7 @@
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
@@ -9285,7 +9226,7 @@
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true, "devOptional": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"tldts": "^6.1.32" "tldts": "^6.1.32"
@@ -9316,7 +9257,7 @@
"version": "4.21.0", "version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -9494,7 +9435,7 @@
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true "devOptional": true
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
@@ -10118,7 +10059,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
@@ -10137,7 +10078,7 @@
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iconv-lite": "0.6.3" "iconv-lite": "0.6.3"
@@ -10150,7 +10091,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -10522,7 +10463,7 @@
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -10544,7 +10485,7 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -10554,7 +10495,7 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/xtend": { "node_modules/xtend": {
@@ -96,8 +96,11 @@ export type { Project } from './models/Project';
export type { ProjectID } from './models/ProjectID'; export type { ProjectID } from './models/ProjectID';
export { ProjectMember } from './models/ProjectMember'; export { ProjectMember } from './models/ProjectMember';
export type { ProjectOverview } from './models/ProjectOverview'; 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 { ProjectSummary } from './models/ProjectSummary';
export type { ReadinessStatus } from './models/ReadinessStatus'; export type { ReadinessStatus } from './models/ReadinessStatus';
export type { RevealProjectSecretResponse } from './models/RevealProjectSecretResponse';
export type { RybbitMetricItem } from './models/RybbitMetricItem'; export type { RybbitMetricItem } from './models/RybbitMetricItem';
export type { RybbitSite } from './models/RybbitSite'; export type { RybbitSite } from './models/RybbitSite';
export { ScheduledJob } from './models/ScheduledJob'; export { ScheduledJob } from './models/ScheduledJob';
@@ -106,6 +109,8 @@ export type { ScheduledJobListResponse } from './models/ScheduledJobListResponse
export { ScheduledJobRun } from './models/ScheduledJobRun'; export { ScheduledJobRun } from './models/ScheduledJobRun';
export type { ScheduledJobRunListResponse } from './models/ScheduledJobRunListResponse'; export type { ScheduledJobRunListResponse } from './models/ScheduledJobRunListResponse';
export type { SchemaQuery } from './models/SchemaQuery'; 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 { TableQuery } from './models/TableQuery';
export type { TelemetryComponent } from './models/TelemetryComponent'; export type { TelemetryComponent } from './models/TelemetryComponent';
export type { TelemetryComponentListResponse } from './models/TelemetryComponentListResponse'; export type { TelemetryComponentListResponse } from './models/TelemetryComponentListResponse';
@@ -223,8 +228,11 @@ export { $Project } from './schemas/$Project';
export { $ProjectID } from './schemas/$ProjectID'; export { $ProjectID } from './schemas/$ProjectID';
export { $ProjectMember } from './schemas/$ProjectMember'; export { $ProjectMember } from './schemas/$ProjectMember';
export { $ProjectOverview } from './schemas/$ProjectOverview'; export { $ProjectOverview } from './schemas/$ProjectOverview';
export { $ProjectSecret } from './schemas/$ProjectSecret';
export { $ProjectSecretListResponse } from './schemas/$ProjectSecretListResponse';
export { $ProjectSummary } from './schemas/$ProjectSummary'; export { $ProjectSummary } from './schemas/$ProjectSummary';
export { $ReadinessStatus } from './schemas/$ReadinessStatus'; export { $ReadinessStatus } from './schemas/$ReadinessStatus';
export { $RevealProjectSecretResponse } from './schemas/$RevealProjectSecretResponse';
export { $RybbitMetricItem } from './schemas/$RybbitMetricItem'; export { $RybbitMetricItem } from './schemas/$RybbitMetricItem';
export { $RybbitSite } from './schemas/$RybbitSite'; export { $RybbitSite } from './schemas/$RybbitSite';
export { $ScheduledJob } from './schemas/$ScheduledJob'; export { $ScheduledJob } from './schemas/$ScheduledJob';
@@ -233,6 +241,8 @@ export { $ScheduledJobListResponse } from './schemas/$ScheduledJobListResponse';
export { $ScheduledJobRun } from './schemas/$ScheduledJobRun'; export { $ScheduledJobRun } from './schemas/$ScheduledJobRun';
export { $ScheduledJobRunListResponse } from './schemas/$ScheduledJobRunListResponse'; export { $ScheduledJobRunListResponse } from './schemas/$ScheduledJobRunListResponse';
export { $SchemaQuery } from './schemas/$SchemaQuery'; export { $SchemaQuery } from './schemas/$SchemaQuery';
export { $SecretName } from './schemas/$SecretName';
export { $SetProjectSecretRequest } from './schemas/$SetProjectSecretRequest';
export { $TableQuery } from './schemas/$TableQuery'; export { $TableQuery } from './schemas/$TableQuery';
export { $TelemetryComponent } from './schemas/$TelemetryComponent'; export { $TelemetryComponent } from './schemas/$TelemetryComponent';
export { $TelemetryComponentListResponse } from './schemas/$TelemetryComponentListResponse'; export { $TelemetryComponentListResponse } from './schemas/$TelemetryComponentListResponse';
@@ -270,6 +280,7 @@ export { IntegrationsService } from './services/IntegrationsService';
export { OrganizationsService } from './services/OrganizationsService'; export { OrganizationsService } from './services/OrganizationsService';
export { PlatformService } from './services/PlatformService'; export { PlatformService } from './services/PlatformService';
export { ProjectsService } from './services/ProjectsService'; export { ProjectsService } from './services/ProjectsService';
export { SecretsService } from './services/SecretsService';
export { StorageService } from './services/StorageService'; export { StorageService } from './services/StorageService';
export { TelemetryService } from './services/TelemetryService'; export { TelemetryService } from './services/TelemetryService';
export { WebhooksService } from './services/WebhooksService'; 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`,
},
});
}
}