security: harden agent surface — vault_exec allow-list, SSRF, error hygiene

Stress-test remediation across MCP, CLI, and backend:

- primora_vault_exec rewritten as an allow-list (was deny-list):
  colon-form single-token commands bypassed it, leaking every vault
  secret; verbatim env-name injection allowed NODE_OPTIONS=--require
  host RCE. Now fixed read-mostly command set, arg/path deny regex,
  strict env-name grammar, loader-hook name blocklist. All prior
  bypasses verified denied; allowed commands still inject.
- SSRF: webhook/job URL validation now blocks cloud metadata IPs
  (169.254.169.254, fd00:ec2::254) inside the link-local allowance
  and non-canonical IP encodings (decimal/hex bare hosts) smuggled
  as "docker service names".
- Path params encodeURIComponent in all client consumers — encodeURI
  left ?#& intact, smuggling query segments through {name} params.
- primora_upload_object strict-validates contentBase64 — Buffer.from
  silently dropped invalid chars.
- MCP no-creds startup degrades instead of crashing mid-handshake.
- GET /api/v1/context — API-key self-discovery; fixes the CLI dead
  end (login --api-key validates the key + seeds org/project,
  use/context/whoami work under key auth).
- CLI: unknown commands / bare group exit 1 with hints; mri numeric
  coercion reversed via raw-argv restore (007 stays "007");
  login --json; orgs:rm / projects:rm / buckets:rm behind --yes;
  fetch-failed errors name the cause.
- Error hygiene: pgx.ErrNoRows -> 404, pgconn codes -> clean
  400/409 without SQLSTATE leaks, validator failures formatted,
  services.InputError maps input faults to 400 (webhook URL/event
  errors were 500s), presign on local driver -> 400.
- Webhook events accept wildcard patterns (*, ns.*, custom.*)
  matched via SQL LIKE — client-published events can be webhooked.

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 21:56:11 +02:00
1 parent 3b90dae767
commit 2d6135121e
28 files changed
+704 -31

No files matched your search

+21 -1
View File
@@ -11,7 +11,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
- **`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.
- **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. (Hardened to a strict allow-list — see Security.)
- **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.
@@ -31,6 +31,26 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
- **Client events + presence** — `POST /projects/:id/events` publishes client-authored events (`custom.*` namespace enforced — system types can't be spoofed) through the same fan-out as system events: realtime subscribers, matching webhooks, and `event_pattern` functions all fire. Realtime now tracks presence: `presence.update` {online} broadcasts on every subscribe/unsubscribe (stream-only — never reaches webhooks or functions), `GET …/realtime/presence` reads the count. Client SDK gains `publish()` and `presence()`; CLI gains `events:send`.
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
- **`GET /api/v1/context`** — actor self-discovery: for API keys returns scopes, key prefix, and the resolved organization + project (id/slug/name). Fixes the API-key onboarding dead end: `primora login --api-key` now validates the key against the instance before saving and seeds org/project context automatically; `primora use`, `context`, and `whoami` work under API-key auth via this endpoint.
- **CLI `orgs:rm` / `projects:rm` / `buckets:rm`** — destructive deletes gated behind `--yes` in non-interactive mode, interactive confirm otherwise. `buckets:rm` resolves id/slug/name.
- **Webhook wildcard subscriptions** — `events` accepts `*`, `<namespace>.*`, and `custom.*` patterns, matched at dispatch time via SQL `LIKE` — the same semantics function `event_pattern` already used. Client-published `custom.*` events can now be webhooked.
### Security
- **`primora_vault_exec` rewritten as an allow-list** — the deny-list was bypassed by colon-form commands (`["secrets:get","X"]` as a single token evaded the pair check), exposing every vault secret; `--remote` variants revealed project secrets; and the `secret` param was used verbatim as an env name, allowing `NODE_OPTIONS=--require <file>` host code execution. Now only a fixed read-mostly command set runs (`whoami`, `context`, `orgs:list`, `projects:list`, `buckets:list`, `objects:list`, `documents:list`, `jobs:list`, `jobs:runs`, `audit:list`, `keys:list`, `events:send`), path/file flags and `..` args are rejected, injected env names must match `^[A-Z_][A-Z0-9_]{0,63}$`, and loader-hook names (`NODE_*`, `LD_*`, `DYLD_*`, `PATH`, `BASH*`, `PYTHON*`, `GIT_*`, `SSL_CERT*`, `XDG_*`, …) are blocked. Verified: every prior bypass denied, allowed commands still inject.
- **SSRF hardening on webhook/job URLs** — cloud metadata endpoints (`169.254.169.254`, `fd00:ec2::254`) are excluded from the link-local/private allowance, and pure decimal/hex "bare hostnames" (`2130706433`, `0x7f000001` — non-canonical IP encodings of loopback) are no longer treated as Docker service names.
- **Path params now `encodeURIComponent`** in all generated-client consumers (CLI, MCP, dashboard) — `encodeURI` left `?#&` intact, smuggling query segments through `{name}` params (e.g. `secrets/a?reveal=1`).
- **Strict base64 in `primora_upload_object`** — `Buffer.from(b64)` silently drops invalid characters; `contentBase64` is now validated (whitespace-tolerant) so corrupt input can't silently store garbage bytes.
- **MCP no-creds startup** — a missing config/API key previously threw inside `configureClient`, killing the process mid-handshake (protocol hang, stack on stderr). Startup now degrades: `initialize`/`tools/list` answer normally and every tool call returns the actionable error.
### Fixed
- **Silent CLI dispatch** — unknown commands, a bare `primora`, and group names without a verb exited 0 with no output. Now: unknown input errors with exit 1 (bare groups list their verbs), bare `primora` prints help.
- **Arg-parser numeric coercion** — `cac`/`mri` coerced numeric-looking flag values (`--value "007"` → `7`, `--value ""` → `0`), corrupting secrets and keys. Parsed options are now re-read as raw strings from argv for every non-boolean flag; `--value 007` round-trips intact.
- **`pgx.ErrNoRows` → 404** in the shared error mapper — missing resources returned raw "no rows in result set" 500s across all handlers.
- **Structured error hygiene** — `pgconn.PgError` codes map to clean statuses (23505 → 409 "resource already exists", 23503 → 409, 22001/54000 → 400 "value too large", 22021 → 400) instead of leaking SQLSTATE/constraint names; validator failures report `field is required`/`field failed <tag> validation`; a new `services.InputError` type maps service-layer input failures to 400 (webhook URL/event-type errors were 500s). Presign on the local driver returns `presign_unsupported` 400.
- **`fetch failed` polish** — connection failures report the cause code and suggest `primora context` instead of bare undici errors.
- **`login --json`** — the flag now emits JSON on both the session and API-key paths.
## [0.7.1] - 2026-09-23
@@ -147,3 +147,7 @@ WHERE p.organization_id = $1;
DELETE FROM core.organizations
WHERE id = $1
RETURNING *;
-- name: GetOrganizationByID :one
SELECT * FROM core.organizations
WHERE id = $1;
+4 -1
View File
@@ -11,7 +11,10 @@ WHERE id = $1;
SELECT * FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events));
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
));
-- name: CreateWebhook :one
INSERT INTO core.webhooks (
@@ -241,6 +241,23 @@ func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash string
return i, err
}
const getOrganizationByID = `-- name: GetOrganizationByID :one
SELECT id, slug, name, created_at FROM core.organizations
WHERE id = $1
`
func (q *Queries) GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error) {
row := q.db.QueryRow(ctx, getOrganizationByID, id)
var i CoreOrganization
err := row.Scan(
&i.ID,
&i.Slug,
&i.Name,
&i.CreatedAt,
)
return i, err
}
const getOrganizationMembership = `-- name: GetOrganizationMembership :one
SELECT
om.id, om.organization_id, om.user_id, om.role, om.created_at,
@@ -76,6 +76,7 @@ type Querier interface {
GetIntegrationByID(ctx context.Context, id uuid.UUID) (CoreIntegration, error)
GetInvitationByIDForOrganization(ctx context.Context, arg GetInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
GetInvitationByTokenHash(ctx context.Context, tokenHash string) (CoreProjectInvitation, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
@@ -291,7 +291,10 @@ const listWebhooksForEvent = `-- name: ListWebhooksForEvent :many
SELECT id, project_id, url, secret, events, enabled, created_by_user_id, created_at, updated_at FROM core.webhooks
WHERE project_id = $1
AND enabled
AND (cardinality(events) = 0 OR $2::text = ANY(events))
AND (cardinality(events) = 0 OR EXISTS (
SELECT 1 FROM unnest(events) AS e
WHERE e = '*' OR $2::text LIKE REPLACE(e, '*', '%')
))
`
type ListWebhooksForEventParams struct {
+62 -1
View File
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
@@ -11,6 +12,7 @@ import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
@@ -35,6 +37,7 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api := router.Group("/api/v1")
api.GET("/me", h.me)
api.GET("/context", h.actorContext)
api.GET("/instance/public", h.instancePublic)
api.GET("/instance/settings", h.listInstanceSettings)
api.PUT("/instance/settings/:key", h.updateInstanceSetting)
@@ -181,6 +184,19 @@ func (h *HTTPHandler) me(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) actorContext(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.ActorContext(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) bootstrap(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
@@ -921,6 +937,10 @@ func (h *HTTPHandler) presignObject(c *gin.Context) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "bucket or object not found")
return
}
if errors.Is(err, services.ErrPresignUnsupported) {
apperrors.Abort(c, http.StatusBadRequest, "presign_unsupported", err.Error())
return
}
h.handleError(c, err)
return
}
@@ -1260,17 +1280,58 @@ func (h *HTTPHandler) bindAndValidate(c *gin.Context, target any) bool {
return false
}
if err := h.Validate.Struct(target); err != nil {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", err.Error())
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", formatFieldError(valErrs[0]))
} else {
apperrors.Abort(c, http.StatusBadRequest, "validation_failed", "invalid request body")
}
return false
}
return true
}
func formatFieldError(f validator.FieldError) string {
if f.Tag() == "required" {
return fmt.Sprintf("%s is required", strings.ToLower(f.Field()))
}
return fmt.Sprintf("%s failed %s validation", strings.ToLower(f.Field()), f.Tag())
}
func (h *HTTPHandler) handleError(c *gin.Context, err error) {
switch {
case errors.Is(err, io.EOF):
apperrors.Abort(c, http.StatusBadRequest, "invalid_body", "request body is empty")
case errors.Is(err, pgx.ErrNoRows):
apperrors.Abort(c, http.StatusNotFound, "not_found", "resource not found")
default:
var inputErr *services.InputError
if errors.As(err, &inputErr) {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", inputErr.Error())
return
}
var valErrs validator.ValidationErrors
if errors.As(err, &valErrs) && len(valErrs) > 0 {
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", formatFieldError(valErrs[0]))
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// Structured codes — never leak raw SQLSTATE text or constraint names.
switch pgErr.Code {
case "23505":
apperrors.Abort(c, http.StatusConflict, "conflict", "resource already exists")
case "23503":
apperrors.Abort(c, http.StatusConflict, "conflict", "referenced resource does not exist")
case "22001", "54000":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "value too large")
case "22021":
apperrors.Abort(c, http.StatusBadRequest, "invalid_request", "input is not valid UTF-8")
default:
apperrors.Abort(c, http.StatusInternalServerError, "request_failed", "database error")
}
return
}
status := http.StatusInternalServerError
message := strings.ToLower(err.Error())
if strings.Contains(message, "insufficient") || strings.Contains(message, "access denied") {
@@ -104,4 +104,39 @@ func TestValidateWebhookEvents(t *testing.T) {
if _, err := validateWebhookEvents([]string{"bogus.event"}); err == nil {
t.Error("expected unknown event rejected")
}
for _, ok := range [][]string{
{"*"},
{"document.*"},
{"custom.deploy.done"},
{"custom.*"},
{"custom.deploy.*"},
} {
if _, err := validateWebhookEvents(ok); err != nil {
t.Errorf("expected %v accepted, got %v", ok, err)
}
}
for _, bad := range [][]string{
{"banana.*"}, {"custom..*"}, {"custom.UPPER"}, {"document.exploded"},
} {
if _, err := validateWebhookEvents(bad); err == nil {
t.Errorf("expected %v rejected", bad)
}
}
}
func TestPrivateWebhookHostHardening(t *testing.T) {
private := []string{"localhost", "minio", "echo-svc", "host.docker.internal",
"10.0.0.4", "172.16.5.5", "192.168.1.10", "fe80::1", "svc.local", "db.internal"}
public := []string{"169.254.169.254", "fd00:ec2::254", "2130706433", "0x7f000001",
"8.8.8.8", "example.com", "999999999999"}
for _, h := range private {
if !isPrivateWebhookHost(h) {
t.Errorf("expected %q private", h)
}
}
for _, h := range public {
if isPrivateWebhookHost(h) {
t.Errorf("expected %q public/blocked", h)
}
}
}
@@ -405,6 +405,51 @@ func (s *PlatformService) PublishProjectEvent(ctx context.Context, actor *models
return nil
}
// ActorContext describes the caller's own scope — the self-discovery
// endpoint for API keys, which cannot call /me. User actors get just the
// type marker; their full view is PlatformSummary.
type ActorContext struct {
Actor string `json:"actor"`
Scopes []string `json:"scopes,omitempty"`
KeyPrefix string `json:"key_prefix,omitempty"`
Organization *ContextOrgInfo `json:"organization,omitempty"`
Project *ContextRef `json:"project,omitempty"`
}
type ContextOrgInfo struct {
ContextRef
Role string `json:"role,omitempty"`
}
type ContextRef struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
func (s *PlatformService) ActorContext(ctx context.Context, actor *models.Actor) (ActorContext, error) {
out := ActorContext{Actor: string(actor.Type)}
if actor.Type != models.ActorTypeAPIKey {
return out, nil
}
out.Scopes = actor.Scopes
out.KeyPrefix = actor.APIKeyPrefix
if actor.ProjectID == nil {
return out, nil
}
project, err := s.repo.Queries().GetProjectByID(ctx, *actor.ProjectID)
if err != nil {
return out, err
}
out.Project = &ContextRef{ID: project.ID, Slug: project.Slug, Name: project.Name}
org, err := s.repo.Queries().GetOrganizationByID(ctx, project.OrganizationID)
if err != nil {
return out, err
}
out.Organization = &ContextOrgInfo{ContextRef: ContextRef{ID: org.ID, Slug: org.Slug, Name: org.Name}}
return out, nil
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
if actor.UserID == nil {
return PlatformSummary{}, errors.New("user actor required")
@@ -1479,6 +1524,20 @@ func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bu
return object, file, nil
}
// ErrPresignUnsupported — the configured storage driver can't mint signed
// URLs (only the S3 driver can). Handlers map it to 400, not 500.
var ErrPresignUnsupported = errors.New("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
// InputError marks a service-layer failure caused by caller input — the
// HTTP layer maps it to 400 instead of 500.
type InputError struct{ msg string }
func (e *InputError) Error() string { return e.msg }
func inputErrorf(format string, args ...any) error {
return &InputError{msg: fmt.Sprintf(format, args...)}
}
// PresignObject mints a short-lived direct-to-storage URL for a single
// object. Requires the s3 storage driver — the local store has no external
// URL surface to sign against.
@@ -1507,7 +1566,7 @@ func (s *PlatformService) PresignObject(ctx context.Context, actor *models.Actor
}
p, ok := s.store.(storage.Presigner)
if !ok {
return nil, fmt.Errorf("presigned URLs require BACKEND_STORAGE_DRIVER=s3")
return nil, ErrPresignUnsupported
}
ttl := time.Duration(ttlSeconds) * time.Second
return p.Presign(ctx, bucketID.String(), objectKey, method, ttl)
+48 -5
View File
@@ -14,6 +14,7 @@ import (
"net"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
@@ -165,7 +166,7 @@ func normalizeWebhookURL(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return "", errors.New("webhook url must be an https URL")
return "", inputErrorf("webhook url must be an https URL")
}
if u.Scheme == "https" {
return trimmed, nil
@@ -173,17 +174,35 @@ func normalizeWebhookURL(raw string) (string, error) {
if u.Scheme == "http" && isPrivateWebhookHost(u.Hostname()) {
return trimmed, nil
}
return "", errors.New("webhook url must be https (http allowed only for private/self-hosted targets)")
return "", inputErrorf("webhook url must be https (http allowed only for private/self-hosted targets)")
}
var decimalOrHexIPRe = regexp.MustCompile(`^(0x[0-9a-f]+|[0-9]+)$`)
// metadataAddrs are link-local addresses that serve cloud instance
// metadata — "private" by range but a privileged SSRF target.
var metadataAddrs = map[string]bool{
"169.254.169.254": true, // AWS/GCP/Azure
"fd00:ec2::254": true, // AWS IPv6 metadata
}
func isPrivateWebhookHost(host string) bool {
h := strings.ToLower(strings.Trim(host, "[]"))
if metadataAddrs[h] {
return false
}
if h == "localhost" || h == "host.docker.internal" ||
strings.HasSuffix(h, ".local") || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".lan") {
return true
}
if !strings.Contains(h, ".") && !strings.Contains(h, ":") {
return true // bare hostname — docker service name, unqualified LAN host
// Bare hostname — docker service name, unqualified LAN host. A
// pure decimal/hex integer is never a service name: it's a
// non-canonical IP encoding (2130706433 = 127.0.0.1, 0x7f000001).
if decimalOrHexIPRe.MatchString(h) {
return false
}
return true
}
ip := net.ParseIP(h)
if ip == nil {
@@ -192,16 +211,40 @@ func isPrivateWebhookHost(host string) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
}
// webhookEventNamespaces are the valid `<ns>.*` subscription prefixes —
// derived from the known system event types.
func webhookEventNamespaces() map[string]bool {
ns := map[string]bool{}
for t := range webhookEventTypes {
if i := strings.Index(t, "."); i > 0 {
ns[t[:i]] = true
}
}
return ns
}
// validateWebhookEvents accepts known system types, `*`, `<ns>.*` namespace
// patterns, and `custom.*` wildcard patterns — the same grammar functions
// use for event_pattern. Matching happens via SQL LIKE at dispatch.
func validateWebhookEvents(events []string) ([]string, error) {
out := make([]string, 0, len(events))
seen := map[string]bool{}
namespaces := webhookEventNamespaces()
for _, e := range events {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if !webhookEventTypes[e] {
return nil, fmt.Errorf("unknown event type %q", e)
valid := webhookEventTypes[e] || e == "*"
if !valid && strings.HasSuffix(e, ".*") {
valid = namespaces[strings.TrimSuffix(e, ".*")]
}
if !valid && strings.HasPrefix(e, "custom.") {
// `*` isn't in the type grammar — probe it as a literal.
valid = customEventTypeRe.MatchString(strings.ReplaceAll(e, "*", "x"))
}
if !valid {
return nil, inputErrorf("invalid event type %q — use a known type, <namespace>.*, custom.<pattern>, or *", e)
}
if !seen[e] {
seen[e] = true
+44
View File
@@ -68,6 +68,22 @@ paths:
$ref: "#/components/schemas/MeResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/context:
get:
tags: [Platform]
operationId: getActorContext
summary: Caller scope — self-discovery for API keys, which cannot call /me
security:
- bearerAuth: []
responses:
"200":
description: Actor type, scopes, and resolved org/project for API keys
content:
application/json:
schema:
$ref: "#/components/schemas/ActorContext"
"401":
$ref: "#/components/responses/ErrorResponse"
/instance/public:
get:
tags: [Instance]
@@ -2776,6 +2792,34 @@ components:
items:
$ref: "#/components/schemas/OrganizationSummary"
required: [user, organizations]
ActorContext:
type: object
properties:
actor:
type: string
enum: [user, api_key]
scopes:
type: array
items:
type: string
key_prefix:
type: string
organization:
$ref: "#/components/schemas/ContextRef"
project:
$ref: "#/components/schemas/ContextRef"
required: [actor]
ContextRef:
type: object
properties:
id:
type: string
format: uuid
slug:
type: string
name:
type: string
required: [id, slug, name]
OrganizationMembership:
type: object
properties:
+65 -3
View File
@@ -4,7 +4,7 @@ import { cmdLogin, cmdLogout, cmdWhoami } from "./commands/auth.js";
import { cmdAuditList } from "./commands/audit.js";
import { cmdDocumentsList } from "./commands/documents.js";
import { cmdEventsSend } from "./commands/events.js";
import { cmdBucketsCreate, cmdBucketsList } from "./commands/buckets.js";
import { cmdBucketsCreate, cmdBucketsList, cmdBucketsRemove } from "./commands/buckets.js";
import { cmdContext, cmdUse } from "./commands/context.js";
import { cmdInject } from "./commands/inject.js";
import { cmdJobsCreate, cmdJobsList, cmdJobsRemove, cmdJobsRun, cmdJobsRuns } from "./commands/jobs.js";
@@ -16,8 +16,8 @@ import {
cmdObjectsPresign,
cmdObjectsUpload,
} from "./commands/objects.js";
import { cmdOrgsCreate, cmdOrgsList } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList } from "./commands/projects.js";
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
import {
cmdSecretsGet,
cmdSecretsImport,
@@ -52,6 +52,7 @@ cli
.option("--email <email>", "Account email")
.option("--password <password>", "Account password")
.option("--api-key <key>", "Store a pk_live_/pk_test_ API key instead of a session")
.option("--json", "Machine-readable output")
.action(cmdLogin);
cli.command("logout", "Remove stored credentials").action(cmdLogout);
@@ -70,6 +71,10 @@ cli
.command("orgs:create <name>", "Create an organization")
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.action(cmdOrgsCreate);
cli
.command("orgs:rm <org>", "Delete an organization (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdOrgsRemove);
cli
.command("projects:list", "List projects in the current org")
@@ -82,6 +87,10 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--description <text>", "Description")
.action(cmdProjectsCreate);
cli
.command("projects:rm <project>", "Delete a project (id, slug, or name)")
.option("--yes", "Skip the confirmation prompt")
.action(cmdProjectsRemove);
cli
.command("buckets:list", "List buckets in the current project")
@@ -94,6 +103,11 @@ cli
.option("--slug <slug>", "URL-safe slug (derived from name if omitted)")
.option("--public", "Public visibility (default private)")
.action(cmdBucketsCreate);
cli
.command("buckets:rm <bucket>", "Delete a bucket and its objects (id or slug)")
.option("--project <id>", "Project override")
.option("--yes", "Skip the confirmation prompt")
.action(cmdBucketsRemove);
cli
.command("objects:list <bucket>", "List objects in a bucket (id or slug)")
@@ -331,9 +345,57 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
argv[0] = "secrets:list";
}
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
// corrupts secrets, keys, and passwords. Re-extract raw strings for every
// non-boolean flag and patch parsed options before the action runs.
function restoreRawOptionStrings(): void {
const cmd = (cli as unknown as { matchedCommand?: { options: { isBoolean: boolean; name: string; names: string[] }[] } })
.matchedCommand;
if (!cmd) return;
const state = cli as unknown as { options: Record<string, unknown>; globalCommand: { options: { isBoolean: boolean; name: string; names: string[] }[] } };
const specs = [...state.globalCommand.options, ...cmd.options].filter((o) => !o.isBoolean);
const raw = process.argv.slice(2);
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
for (const spec of specs) {
if (typeof state.options[spec.name] !== "number") continue;
let value: string | undefined;
for (let i = 0; i < raw.length; i++) {
for (const n of spec.names) {
const flags = n.length === 1 ? [`-${n}`] : [`--${n}`, `--${kebab(n)}`];
for (const f of flags) {
if (raw[i] === f) value = raw[i + 1];
else if (raw[i].startsWith(f + "=")) value = raw[i].slice(f.length + 1);
}
}
}
if (value !== undefined) state.options[spec.name] = value;
}
}
// Unknown command or a bare group — cac only fires this on non-empty args.
cli.addEventListener("command:*", () => {
const state = cli as unknown as { args: string[]; commands: { name: string }[] };
const arg = String(state.args[0] ?? "");
const group = arg.split(":")[0];
const verbs = GROUPS.has(group)
? state.commands.map((c) => c.name).filter((n) => n.startsWith(group + ":")).map((n) => n.slice(group.length + 1))
: [];
process.stderr.write(`error: unknown command "${arg.replace(":", " ")}"\n`);
if (verbs.length) process.stderr.write(` ${group} verbs: ${verbs.join(", ")}\n`);
process.stderr.write(" run `primora --help` for usage\n");
process.exit(1);
});
async function main() {
try {
cli.parse([process.argv[0], process.argv[1], ...argv], { run: false });
restoreRawOptionStrings();
const state = cli as unknown as { matchedCommand?: unknown; options: Record<string, unknown> };
if (!state.matchedCommand && !state.options.help && !state.options.version) {
cli.outputHelp();
if (argv.length) process.exit(1);
return;
}
await cli.runMatchedCommand();
} catch (error) {
fail(error);
+37 -2
View File
@@ -10,6 +10,7 @@ interface LoginOptions {
email?: string;
password?: string;
apiKey?: string;
json?: boolean;
}
function cancel(): never {
@@ -39,9 +40,22 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
throw new CliError("Malformed API key.", "Expected the full key, e.g. prm_xxxx_…");
}
cfg.auth = { type: "apiKey", key: options.apiKey };
// Validate the key against the instance before saving — a stored-but-
// dead key is a worse failure than a rejected login.
configureClient(cfg);
const ctx = await PlatformService.getActorContext();
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Saved API key credentials to ${configFilePath()}`);
dim("API keys are project-scoped — set context with `primora use --org <id> --project <id>`.");
if (isJson(options)) {
printJson(ctx);
return;
}
success(`API key verified — ${ctx.key_prefix}… (scopes: ${ctx.scopes?.join(", ") || "none"})`);
if (ctx.organization && ctx.project) {
success(`Context → ${ctx.organization.slug} / ${ctx.project.slug}`);
}
dim(`Config written to ${configFilePath()}`);
return;
}
@@ -94,6 +108,10 @@ export async function cmdLogin(options: LoginOptions): Promise<void> {
configureClient(cfg);
const me = await PlatformService.getMe();
if (isJson(options)) {
printJson(me);
return;
}
success(`Signed in as ${me.user.email}`);
dim(`Config written to ${configFilePath()}`);
}
@@ -108,6 +126,23 @@ export async function cmdLogout(): Promise<void> {
export async function cmdWhoami(options: { json?: boolean }): Promise<void> {
const cfg = loadConfig();
configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson(ctx);
return;
}
printTable(["KEY", "SCOPES", "ORGANIZATION", "PROJECT"], [
[
`${ctx.key_prefix}…`,
ctx.scopes?.join(", ") || "—",
ctx.organization?.slug ?? "—",
ctx.project?.slug ?? "—",
],
]);
dim(`\n${cfg.baseUrl} · auth: api_key`);
return;
}
const me = await PlatformService.getMe();
if (isJson(options)) {
+21 -2
View File
@@ -1,8 +1,8 @@
import { CreateBucketRequest, StorageService } from "@primora/api-client";
import { loadConfig } from "../config.js";
import { configureClient, requireAuth, requireProject } from "../http.js";
import { isJson, printJson, printTable, success } from "../out.js";
import { configureClient, requireAuth, requireProject, resolveBucketId } from "../http.js";
import { confirmOrAbort, isJson, printJson, printTable, success } from "../out.js";
export async function cmdBucketsList(options: {
project?: string;
@@ -54,3 +54,22 @@ export async function cmdBucketsCreate(
}
success(`Bucket "${bucket.name}" created (${bucket.slug}, ${bucket.visibility})`);
}
export async function cmdBucketsRemove(
bucketRef: string,
options: { project?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
const projectId = requireProject(cfg, options.project);
const bucketId = await resolveBucketId(projectId, bucketRef);
await confirmOrAbort(`Delete bucket "${bucketRef}" and all its objects?`, options.yes);
await StorageService.deleteBucket({ bucketId });
if (isJson(options)) {
printJson({ deleted: bucketId });
} else {
success(`deleted bucket ${bucketRef}`);
}
}
+29
View File
@@ -23,6 +23,22 @@ export async function cmdUse(options: UseOptions): Promise<void> {
requireAuth(cfg);
configureClient(cfg);
// API keys are bound to one org+project — resolve the scope directly.
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (options.org && ctx.organization && options.org !== ctx.organization.id && options.org !== ctx.organization.slug) {
throw new Error(`API key is scoped to ${ctx.organization.slug} — cannot switch to "${options.org}".`);
}
if (options.project && ctx.project && options.project !== ctx.project.id && options.project !== ctx.project.slug) {
throw new Error(`API key is scoped to ${ctx.project.slug} — cannot switch to "${options.project}".`);
}
if (ctx.organization) cfg.organizationId = ctx.organization.id;
if (ctx.project) cfg.projectId = ctx.project.id;
saveConfig(cfg);
success(`Context → ${ctx.organization?.slug ?? "—"} / ${ctx.project?.slug ?? "—"}`);
return;
}
const me = await PlatformService.getMe();
const orgs = me.organizations;
if (orgs.length === 0) {
@@ -77,6 +93,19 @@ export async function cmdContext(options: { json?: boolean }): Promise<void> {
requireAuth(cfg);
configureClient(cfg);
if (cfg.auth?.type === "apiKey") {
const ctx = await PlatformService.getActorContext();
if (isJson(options)) {
printJson({ baseUrl: cfg.baseUrl, auth: "apiKey", ...ctx });
return;
}
dim(`url ${cfg.baseUrl}`);
dim(`auth api_key (${ctx.key_prefix}…, scopes: ${ctx.scopes?.join(", ") || "none"})`);
process.stdout.write(`org ${ctx.organization ? `${ctx.organization.name} (${ctx.organization.slug})` : "—"}\n`);
process.stdout.write(`project ${ctx.project ? `${ctx.project.name} (${ctx.project.slug})` : "—"}\n`);
return;
}
const me = await PlatformService.getMe();
const org = me.organizations.find((o) => o.id === cfg.organizationId);
const project = org?.projects.find((pr) => pr.id === cfg.projectId);
+28
View File
@@ -38,3 +38,31 @@ export async function cmdOrgsCreate(
}
success(`Organization "${org.name}" created (${org.slug})`);
}
export async function cmdOrgsRemove(
orgRef: string,
options: { yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let organizationId = orgRef;
if (!isUuid(orgRef)) {
const me = await PlatformService.getMe();
const found = me.organizations.find((o) => o.id === orgRef || o.slug === orgRef || o.name === orgRef);
if (!found) throw new Error(`Organization "${orgRef}" not found — pass its id directly.`);
organizationId = found.id;
}
await confirmOrAbort(`Delete organization "${orgRef}"? Every project inside goes with it.`, options.yes);
await OrganizationsService.deleteOrganization({ organizationId });
if (isJson(options)) {
printJson({ deleted: organizationId });
} else {
success(`deleted organization ${orgRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+31
View File
@@ -50,3 +50,34 @@ export async function cmdProjectsCreate(
}
success(`Project "${project.name}" created (${project.slug})`);
}
export async function cmdProjectsRemove(
projectRef: string,
options: { org?: string; yes?: boolean; json?: boolean },
): Promise<void> {
const cfg = loadConfig();
requireAuth(cfg);
configureClient(cfg);
let projectId = projectRef;
if (!isUuid(projectRef)) {
const me = await PlatformService.getMe();
const orgs = cfg.auth?.type === "apiKey" ? [] : me.organizations;
const found = orgs
.flatMap((o) => o.projects.map((pr) => ({ ...pr, orgId: o.id })))
.find((pr) => pr.id === projectRef || pr.slug === projectRef || pr.name === projectRef);
if (!found) throw new Error(`Project "${projectRef}" not found — pass its id directly.`);
projectId = found.id;
}
await confirmOrAbort(`Delete project "${projectRef}"? Buckets, secrets, and functions go with it.`, options.yes);
await ProjectsService.deleteProject({ projectId });
if (isJson(options)) {
printJson({ deleted: projectId });
} else {
success(`deleted project ${projectRef}`);
}
}
function isUuid(v: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}
+2
View File
@@ -60,6 +60,8 @@ export function configureClient(cfg: CliConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
OpenAPI.HEADERS = undefined;
OpenAPI.TOKEN = undefined;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
+19 -1
View File
@@ -17,9 +17,27 @@ export function dim(message: string): void {
process.stdout.write(pc.dim(message) + "\n");
}
/** Destructive-action gate — requires --yes in scripts, prompts on a TTY. */
export async function confirmOrAbort(message: string, yes?: boolean): Promise<void> {
if (yes) return;
if (!process.stdin.isTTY) {
throw new Error(`${message} — pass --yes to confirm in non-interactive mode`);
}
const answer = await p.confirm({ message });
if (p.isCancel(answer) || !answer) {
p.cancel("Aborted.");
process.exit(1);
}
}
export function fail(error: unknown): never {
const err = error as { message?: string; hint?: string; status?: number; body?: unknown };
const err = error as { message?: string; hint?: string; status?: number; body?: unknown; cause?: { code?: string } };
let message = err?.message ?? "unknown error";
// undici TypeError "fetch failed" — the useful part is the cause code.
if (message === "fetch failed" || message.startsWith("fetch failed")) {
const code = err?.cause?.code;
message = `could not reach the instance${code ? ` (${code})` : ""} — check \`primora context\` and that it is running`;
}
// generated client throws ApiError with a parsed body
const body = err?.body as { error?: { message?: string } } | undefined;
if (body?.error?.message) message = body.error.message;
+2
View File
@@ -3,6 +3,8 @@ import { OpenAPI } from "@primora/api-client";
import { fetchApiToken } from "./auth-client";
OpenAPI.BASE = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
OpenAPI.CREDENTIALS = "include";
OpenAPI.WITH_CREDENTIALS = true;
OpenAPI.TOKEN = async () => (await fetchApiToken()) ?? "";
+2
View File
@@ -49,6 +49,8 @@ async function mintSessionToken(cfg: McpConfig): Promise<string> {
export function configureClient(cfg: McpConfig): void {
OpenAPI.BASE = `${cfg.baseUrl}/api/v1`;
// encodeURI leaves ?#& intact in path params — smuggles query segments.
OpenAPI.ENCODE_PATH = encodeURIComponent;
if (cfg.auth?.type === "apiKey") {
OpenAPI.HEADERS = { "X-API-Key": cfg.auth.key };
return;
+72 -13
View File
@@ -22,7 +22,16 @@ import {
import { configureClient, loadConfig, type McpConfig } from "./config.js";
const cfg = loadConfig();
try {
configureClient(cfg);
} catch (e) {
// Missing credentials must not kill the server — initialize/tools/list
// still work, and every call surfaces the actionable error through the
// normal tool error path instead of an uncaught startup exception.
OpenAPI.TOKEN = async () => {
throw e;
};
}
const TEXT_TYPES = /^(text\/|application\/(json|xml|yaml|javascript|x-ndjson))/;
@@ -234,7 +243,13 @@ server.registerTool(
const bucketId = await bucketIdFor(projectId(p), bucket);
let blob: Blob;
if (contentBase64 !== undefined) {
blob = new Blob([Buffer.from(contentBase64, "base64")], {
// Buffer.from(..., "base64") silently drops invalid chars — strict-
// validate so corrupt input can't silently store garbage bytes.
const clean = contentBase64.replace(/\s/g, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean) || clean.length % 4 !== 0) {
throw new Error("contentBase64 is not valid base64");
}
blob = new Blob([Buffer.from(clean, "base64")], {
type: contentType ?? "application/octet-stream",
});
} else if (content !== undefined) {
@@ -939,20 +954,65 @@ server.registerTool(
},
);
// 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"]);
// vault_exec allow-list — read/non-destructive commands an agent may run
// under an injected secret. Deny-lists lose (a colon-form `secrets:get`
// already bypassed a pair-match once); only these verbs execute.
// Commands touching host files (upload/download --out, secrets import) and
// anything that moves a secret into output are intentionally absent.
const EXEC_ALLOW = new Set([
"whoami",
"context",
"orgs:list",
"projects:list",
"buckets:list",
"objects:list",
"documents:list",
"jobs:list",
"jobs:runs",
"audit:list",
"keys:list",
"events:send",
]);
// Flags that could redirect output into host files — none of the allowed
// commands need them, so reject the whole set defensively.
const EXEC_ARG_DENY = /^--?(out|file|path|dir|env-file|output|write)\b/;
// Env names that must never carry an injected secret — preload/runtime
// hooks and interpreter variables turn an env injection into code exec
// (NODE_OPTIONS, LD_*, BASH_ENV, PYTHONPATH, …). PRIMORA_* stays allowed:
// the injected VALUE comes from the vault, and those names are its purpose.
const ENV_NAME_DENY =
/^(NODE_|LD_|DYLD_|BASH|ENV$|IFS$|PATH$|HOME$|SHELL$|CDPATH$|SHELLOPTS$|BASHOPTS$|PYTHON|PERL|RUBY|GIT_|SSL_CERT|XDG_)/;
function execAllowed(raw: string[]): { args: string[]; verb: string } {
// Normalize colon-form: ["objects:list","b"] ≡ ["objects","list","b"].
const args = [...raw];
if (args[0].includes(":") && !args[0].startsWith("-")) {
args.splice(0, 1, ...args[0].split(":"));
}
const verb = args.length > 1 && !args[1].startsWith("-") ? `${args[0]}:${args[1]}` : args[0];
if (!EXEC_ALLOW.has(verb)) {
throw new Error(`denied: "${verb}" is not on the vault_exec allow-list (read-only commands only)`);
}
for (const a of args) {
if (a.includes("\0")) throw new Error("denied: NUL byte in argument");
if (a.includes("..") || EXEC_ARG_DENY.test(a)) {
throw new Error(`denied: argument "${a}" could redirect to host files`);
}
}
return { args, verb };
}
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.",
"Run a read-only primora CLI command with a vault secret injected into its environment (default secret: PRIMORA_API_KEY). Allow-listed commands only; the secret never appears in arguments or the response.",
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'),
.describe('primora arguments, e.g. ["objects","list","mybucket"] — allow-listed read commands only'),
secret: z
.string()
.optional()
@@ -962,17 +1022,16 @@ server.registerTool(
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 { args } = execAllowed(command);
const secretName = secret ?? "PRIMORA_API_KEY";
if (!/^[A-Z_][A-Z0-9_]{0,63}$/.test(secretName) || ENV_NAME_DENY.test(secretName)) {
throw new Error(`denied: "${secretName}" is not a safe env name to inject`);
}
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() });
const r = runCli(args, { [secretName]: got.stdout.trimEnd() });
return ok({ exitCode: r.code, stdout: r.stdout, stderr: r.stderr });
} catch (e) {
return fail(e);
@@ -8,6 +8,7 @@ export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';
export type { AcceptInvitationRequest } from './models/AcceptInvitationRequest';
export { ActorContext } from './models/ActorContext';
export { AlertRule } from './models/AlertRule';
export type { AlertRuleConfig } from './models/AlertRuleConfig';
export type { AlertRuleListResponse } from './models/AlertRuleListResponse';
@@ -29,6 +30,7 @@ export type { CollectionListResponse } from './models/CollectionListResponse';
export type { ComponentHealth } from './models/ComponentHealth';
export type { ComponentID } from './models/ComponentID';
export type { ConnectionID } from './models/ConnectionID';
export type { ContextRef } from './models/ContextRef';
export type { CopyBucketObjectRequest } from './models/CopyBucketObjectRequest';
export type { CreateApiKeyRequest } from './models/CreateApiKeyRequest';
export { CreateBucketRequest } from './models/CreateBucketRequest';
@@ -161,6 +163,7 @@ export type { WebhookID } from './models/WebhookID';
export type { WebhookListResponse } from './models/WebhookListResponse';
export { $AcceptInvitationRequest } from './schemas/$AcceptInvitationRequest';
export { $ActorContext } from './schemas/$ActorContext';
export { $AlertRule } from './schemas/$AlertRule';
export { $AlertRuleConfig } from './schemas/$AlertRuleConfig';
export { $AlertRuleListResponse } from './schemas/$AlertRuleListResponse';
@@ -182,6 +185,7 @@ export { $CollectionListResponse } from './schemas/$CollectionListResponse';
export { $ComponentHealth } from './schemas/$ComponentHealth';
export { $ComponentID } from './schemas/$ComponentID';
export { $ConnectionID } from './schemas/$ConnectionID';
export { $ContextRef } from './schemas/$ContextRef';
export { $CopyBucketObjectRequest } from './schemas/$CopyBucketObjectRequest';
export { $CreateApiKeyRequest } from './schemas/$CreateApiKeyRequest';
export { $CreateBucketRequest } from './schemas/$CreateBucketRequest';
@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ContextRef } from './ContextRef';
export type ActorContext = {
actor: ActorContext.actor;
scopes?: Array<string>;
key_prefix?: string;
organization?: ContextRef;
project?: ContextRef;
};
export namespace ActorContext {
export enum actor {
USER = 'user',
API_KEY = 'api_key',
}
}
@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ContextRef = {
id: string;
slug: string;
name: string;
};
@@ -0,0 +1,27 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ActorContext = {
properties: {
actor: {
type: 'Enum',
isRequired: true,
},
scopes: {
type: 'array',
contains: {
type: 'string',
},
},
key_prefix: {
type: 'string',
},
organization: {
type: 'ContextRef',
},
project: {
type: 'ContextRef',
},
},
} as const;
@@ -0,0 +1,21 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ContextRef = {
properties: {
id: {
type: 'string',
isRequired: true,
format: 'uuid',
},
slug: {
type: 'string',
isRequired: true,
},
name: {
type: 'string',
isRequired: true,
},
},
} as const;
@@ -2,6 +2,7 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ActorContext } from '../models/ActorContext';
import type { BootstrapRequest } from '../models/BootstrapRequest';
import type { BootstrapResponse } from '../models/BootstrapResponse';
import type { MeResponse } from '../models/MeResponse';
@@ -22,6 +23,20 @@ export class PlatformService {
},
});
}
/**
* Caller scope — self-discovery for API keys, which cannot call /me
* @returns ActorContext Actor type, scopes, and resolved org/project for API keys
* @throws ApiError
*/
public static getActorContext(): CancelablePromise<ActorContext> {
return __request(OpenAPI, {
method: 'GET',
url: '/context',
errors: {
401: `Error response`,
},
});
}
/**
* @returns BootstrapResponse Platform bootstrapped
* @throws ApiError