mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-29 16:14:53 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a49de2db5 | ||
|
|
61a45cc48f | ||
|
|
d44bbcceda | ||
|
|
56c63d43cd | ||
|
|
6d758c0089 | ||
|
|
361538aabc | ||
|
|
5eec80bbca | ||
|
|
40c189c431 | ||
|
|
bc8b165ad9 | ||
|
|
61f0974c61 | ||
|
|
769d04471f | ||
|
|
da294aaa2b | ||
|
|
1226d44872 | ||
|
|
ac4ca9a3ae | ||
|
|
597db3287e | ||
|
|
bbc57f9c0d | ||
|
|
9ab9b550d1 | ||
|
|
49ca9067a9 | ||
|
|
805a58e32f | ||
|
|
d09b0a1c2d | ||
|
|
92b1810c07 | ||
|
|
3ad37c190b | ||
|
|
cb56cedb1f | ||
|
|
20b4ea6a07 | ||
|
|
0511e5754b | ||
|
|
3800e736d0 | ||
|
|
7548ea75d4 | ||
|
|
14d0762245 | ||
|
|
a990989d34 | ||
|
|
3b9ea333ad |
No files matched your search
+5
-1
@@ -77,9 +77,13 @@ BACKEND_STORAGE_DRIVER=local
|
||||
|
||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||
# values win over these env vars; deleting the in-app value reverts here.
|
||||
# With no transport configured (no Resend key and no SMTP_HOST) accounts are
|
||||
# verified automatically and no verification mail is attempted.
|
||||
# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml)
|
||||
# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025.
|
||||
RESEND_API_KEY=
|
||||
MAIL_FROM=Primora <[email protected]>
|
||||
SMTP_HOST=mailpit
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
github: Dvorinka
|
||||
@@ -327,6 +327,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
# contents: write — the job uploads SBOM assets onto the release.
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
steps:
|
||||
@@ -339,6 +341,8 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# anchore/sbom-action does not create the output directory.
|
||||
- run: mkdir -p dist-release
|
||||
- uses: anchore/sbom-action@v0
|
||||
with:
|
||||
image: ghcr.io/${{ env.GHCR_OWNER }}/primora-backend:${{ needs.verify.outputs.version }}
|
||||
|
||||
@@ -39,3 +39,5 @@ infra/mailpit
|
||||
# Generated code-graph artifacts
|
||||
graphify-out/
|
||||
.playwright-mcp/
|
||||
# Local brag/launch-video output
|
||||
brag-output/
|
||||
@@ -4,6 +4,52 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **App logins** - a username and password bound to a stable app id, in the local vault and in the project vault. The app id is not a host or a port, so `localhost:3000` can change without losing the account. `primora logins set|get|list|rm` (`--remote` for the project). The dashboard Vault page and the desktop vault window manage both.
|
||||
- **Agent reads** - `primora_vault_get`, `primora_secrets_get`, `primora_logins_get`, `primora_logins_set`, and `primora_logins_list`. An agent fetches one named value when it needs to sign in. Lists still omit passwords. Project reveals stay audit-logged. Do not paste the value into chat or a `.env` file.
|
||||
|
||||
## [0.8.5] - 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Content column centers on wide screens** — `.page` was capped at 72rem but left-aligned, so above ~1200px the dashboard hugged the left with a growing dead zone on the right. It now centers (`margin-inline: auto`) while staying full-width below the cap.
|
||||
|
||||
## [0.8.4] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Slugs auto-derive from names** — typing an organization, project, bucket, or collection name now fills its slug (`TDvorak` → `tdvorak`); hand-editing the slug stops the derivation, and manual edits are sanitized as you type. Edit forms stay manual.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Installer URL prompt was invisible under `curl | bash`** — `exec 9</dev/tty 2>/dev/null` sent the whole shell's stderr to `/dev/null` permanently, so the `read` prompt rendered nowhere while the script blocked on input. The prompt now reads `/dev/tty` directly with a quiet probe; no-tty runs default silently.
|
||||
- **Installer fetches fail fast** — the compose-file and source-tarball `curl` calls now carry connect/max-time timeouts plus retries, so a stalled GitHub connection errors within seconds naming the file instead of hanging forever.
|
||||
|
||||
## [0.8.3] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Install script asks for the public URL** — `install.sh` now prompts for where you'll open the app and accepts a full URL: `https://primora.example.com` (TLS at your proxy, hints printed at the end), `primora.example.com`, or `192.168.1.50:8085` (the port becomes `NGINX_PORT`). The answer rewrites every public URL var in `.env` and `COOKIE_DOMAIN` for real DNS names. The prompt reads the controlling terminal so it appears under `curl | bash`; no tty (CI, cron) falls back to `localhost`. Non-interactive via `PUBLIC_URL=`; legacy `DOMAIN`/`NGINX_PORT` envs still work; reruns over an existing `.env` skip the question.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auth origins behind external TLS terminators** — trusted origins now accept both URL schemes on the serving host. Verified live over a Cloudflare quick tunnel: a real browser session cookie + `https://*.trycloudflare.com` origin was 403'd on v0.8.2 (nginx reports `X-Forwarded-Proto: http` inbound); the same request now signs in cleanly while cross-site origins still 403.
|
||||
|
||||
## [0.8.2] - 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
|
||||
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
|
||||
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
|
||||
|
||||
## [0.8.1] - 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist.
|
||||
|
||||
## [0.8.0] - 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
+7
-3
@@ -40,7 +40,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
| `PRIMORA_ENCRYPTION_KEY` | AES-256 key (64 hex chars) for project secrets (Vault page, `secret://` job refs), integration credentials + webhook secrets at rest | production |
|
||||
| `AUTH_BASE_URL` / `BETTER_AUTH_URL` | public auth URL (`https://host/auth`) | yes |
|
||||
| `AUTH_INTERNAL_BASE_URL` | backend → auth internal URL (`http://auth:3001`) | yes |
|
||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — **baked into the frontend image at build time** | yes |
|
||||
| `VITE_APP_URL` / `VITE_AUTH_BASE_URL` / `VITE_API_BASE_URL` | frontend public URLs — informational only; the shipped image resolves API/auth URLs same-origin at runtime, so no rebuild is needed when the host changes | yes |
|
||||
| `BACKEND_STORAGE_ROOT` | object storage directory inside the backend container (local driver) | yes |
|
||||
| `BACKEND_STORAGE_DRIVER` | `local` (default) or `s3` — S3-compatible object storage | no |
|
||||
| `S3_ENDPOINT` / `S3_REGION` / `S3_BUCKET` | S3 API endpoint (empty endpoint → AWS `s3.<region>.amazonaws.com`), region, bucket | with `s3` |
|
||||
@@ -81,8 +81,12 @@ labels:
|
||||
```
|
||||
|
||||
Once TLS is live, update `VITE_APP_URL`, `VITE_AUTH_BASE_URL`,
|
||||
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, and `COOKIE_DOMAIN` to the https origin
|
||||
and rebuild the frontend image (`docker compose build frontend`).
|
||||
`AUTH_BASE_URL`, `BETTER_AUTH_URL`, `BACKEND_PUBLIC_URL`, and
|
||||
`COOKIE_DOMAIN` to the https origin and restart the stack
|
||||
(`docker compose up -d`). The frontend resolves API/auth URLs
|
||||
same-origin, so no image rebuild is needed — HTTPS is still required for
|
||||
PWA install, service workers, and other secure-context features on
|
||||
non-localhost origins.
|
||||
|
||||
## SMTP / email
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Supabase, with no hosted tier - your data stays on your hardware.
|
||||
- **Storage** - S3-style buckets on local disk or any S3-compatible backend (AWS, MinIO, Garage, R2), public or private. With `s3` the API mints presigned upload/download URLs so large objects skip the backend.
|
||||
- **Collections** - schema-flexible JSON documents in Postgres JSONB, with filter/order queries.
|
||||
- **Functions** - project-scoped JS/TS run by bun/deno — on the host (`exec`) or in ephemeral locked-down containers (`docker` driver) — with per-run logs. Trigger them four ways: manual invoke, cron schedules (`function_id` on a job), inbound hooks (`mode: "function"`), or domain events (`event_pattern` like `document.*`).
|
||||
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app.
|
||||
- **Vault** - AES-256-GCM project secrets plus an Argon2id/XChaCha20 local vault in the CLI and desktop app. App logins (username, password, stable app id) live in both, and an agent can read one named value when it needs to sign in.
|
||||
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
|
||||
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
|
||||
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
|
||||
@@ -77,7 +77,9 @@ Prerequisites: Docker with the Compose plugin.
|
||||
curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
|
||||
```
|
||||
|
||||
Installs into `./primora`, generates secrets, and starts the stack on port 80.
|
||||
Installs into `./primora`, asks one question — the URL you'll open the app on
|
||||
(domain, IP, optional port) — generates secrets, and starts the stack.
|
||||
Non-interactive: `PUBLIC_URL=https://primora.example.com bash install.sh`.
|
||||
Or from a clone:
|
||||
|
||||
```bash
|
||||
|
||||
+15
@@ -420,6 +420,21 @@ Ordered loosely by leverage. None committed; each gets scoped when picked.
|
||||
broadcasts on join/leave (stream-only), `GET …/realtime/presence`
|
||||
reads the count. SDK: `publish()` + `presence()`; CLI: `events:send`.
|
||||
|
||||
## App logins (shipped, unreleased)
|
||||
|
||||
Username and password for a website or for an app the user is building.
|
||||
Stored in both the local vault and the project vault. The app id is a
|
||||
stable name (`my-app`, the project slug), not a host or a port, because
|
||||
`localhost:3000` can serve something else later. `origin` is only the
|
||||
current URL.
|
||||
|
||||
Agents read one named secret or login when they need it
|
||||
(`primora_vault_get`, `primora_secrets_get`, `primora_logins_get`).
|
||||
That is the point of the vault: encrypted at rest, fetched at use, not
|
||||
pasted into chat and not written to `.env`. Lists stay metadata.
|
||||
Project reveals are audit-logged. A `write` API key can reveal; a `read`
|
||||
key cannot.
|
||||
|
||||
---
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
@@ -5,7 +5,7 @@ import { requestId } from "hono/request-id";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { createClient } from "redis";
|
||||
|
||||
import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js";
|
||||
import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js";
|
||||
import { authPool } from "./lib/db.js";
|
||||
import { env } from "./lib/env.js";
|
||||
|
||||
@@ -20,6 +20,7 @@ try {
|
||||
}
|
||||
|
||||
await retry("auth_migrations", runAuthMigrations);
|
||||
await verifyUsersWithoutMailTransport();
|
||||
await promoteAdminEmails();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { admin, jwt } from "better-auth/plugins";
|
||||
import { getMigrations } from "better-auth/db/migration";
|
||||
|
||||
import { authPool } from "./db.js";
|
||||
import { sendTransactionalEmail } from "./mail.js";
|
||||
import { hasMailTransport, sendTransactionalEmail } from "./mail.js";
|
||||
import { getBoolSetting } from "./settings.js";
|
||||
import { env } from "./env.js";
|
||||
|
||||
@@ -53,7 +53,22 @@ export const auth = betterAuth({
|
||||
appName: "Primora",
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
|
||||
// Self-host: the browser reaches /auth through the same nginx origin as the
|
||||
// app, so whatever host actually served this request is trusted — static
|
||||
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
|
||||
// An attacker's cross-site Origin still won't match the server host.
|
||||
trustedOrigins: (request) => {
|
||||
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
|
||||
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
|
||||
if (host) {
|
||||
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
|
||||
origins.push(`${proto}://${host}`);
|
||||
// TLS often terminates at an external proxy — nginx then reports http.
|
||||
// Both schemes on the serving host are legitimately this instance.
|
||||
origins.push(proto === "https" ? `http://${host}` : `https://${host}`);
|
||||
}
|
||||
return origins;
|
||||
},
|
||||
database: authPool,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
@@ -72,13 +87,20 @@ export const auth = betterAuth({
|
||||
// Gates only the public sign-up endpoint. Users created through the
|
||||
// admin plugin or OAuth callbacks take different paths and pass.
|
||||
async before(user, ctx) {
|
||||
if (ctx?.path !== "/sign-up/email") return;
|
||||
const data = { ...user };
|
||||
// Without a mail transport the verification mail can never arrive —
|
||||
// mark every created account verified so nothing downstream nags
|
||||
// "unverified" on users nobody can verify.
|
||||
if (!(await hasMailTransport())) {
|
||||
data.emailVerified = true;
|
||||
}
|
||||
if (ctx?.path === "/sign-up/email") {
|
||||
const { rows } = await authPool.query<{ n: number }>(
|
||||
`select count(*)::int as n from "user"`,
|
||||
);
|
||||
if (rows[0].n === 0) {
|
||||
// Bootstrap: the first public signup becomes the instance admin.
|
||||
return { data: { ...user, role: "admin" } };
|
||||
return { data: { ...data, role: "admin" } };
|
||||
}
|
||||
const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED);
|
||||
if (!enabled) {
|
||||
@@ -86,6 +108,8 @@ export const auth = betterAuth({
|
||||
message: "Sign-up is disabled on this instance.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return data.emailVerified === user.emailVerified ? undefined : { data };
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,6 +118,9 @@ export const auth = betterAuth({
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
async sendVerificationEmail({ user, url }) {
|
||||
// No transport at all → the mail can never arrive; the user was already
|
||||
// marked verified at creation.
|
||||
if (!(await hasMailTransport())) return;
|
||||
// Verification is optional — a misconfigured transport must not fail
|
||||
// sign-up or sign-in.
|
||||
try {
|
||||
@@ -143,6 +170,17 @@ export async function runAuthMigrations() {
|
||||
await migrations.runMigrations();
|
||||
}
|
||||
|
||||
/** No transport → existing unverified users can never resolve that state.
|
||||
* Best-effort: a startup failure here must never take the service down. */
|
||||
export async function verifyUsersWithoutMailTransport() {
|
||||
try {
|
||||
if (await hasMailTransport()) return;
|
||||
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
||||
export async function promoteAdminEmails() {
|
||||
const emails = (env.AUTH_ADMIN_EMAILS ?? "")
|
||||
|
||||
@@ -30,6 +30,21 @@ async function resolveMailConfig() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
// True when a mail transport exists (Resend key or SMTP host). Verification
|
||||
// and reset mails only make sense when mail can actually leave the box.
|
||||
// Mail is optional — a failed settings read must degrade to "no transport",
|
||||
// never break sign-up.
|
||||
export async function hasMailTransport(): Promise<boolean> {
|
||||
try {
|
||||
const cfg = await resolveMailConfig();
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
|
||||
return "smtp" in cfg && Boolean(cfg.smtp);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(input: {
|
||||
to: string;
|
||||
subject: string;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- App logins: username + password bound to a stable app id, not a host:port.
|
||||
-- The password is AES-256-GCM ciphertext. List returns metadata (including
|
||||
-- the username and origin); reveal decrypts and is audit-logged.
|
||||
CREATE TABLE core.project_logins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||
app TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
ciphertext BYTEA NOT NULL,
|
||||
origin TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, app)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS core.project_logins;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- name: ListProjectLogins :many
|
||||
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||
FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
ORDER BY app ASC;
|
||||
|
||||
-- name: GetProjectLogin :one
|
||||
SELECT * FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
AND app = $2;
|
||||
|
||||
-- name: UpsertProjectLogin :one
|
||||
INSERT INTO core.project_logins (
|
||||
project_id,
|
||||
app,
|
||||
username,
|
||||
ciphertext,
|
||||
origin,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
origin = EXCLUDED.origin,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteProjectLogin :one
|
||||
DELETE FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
AND app = $2
|
||||
RETURNING id;
|
||||
@@ -361,6 +361,18 @@ type CoreProjectInvitation struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type CoreProjectLogin struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
App string `json:"app"`
|
||||
Username string `json:"username"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Origin string `json:"origin"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CoreProjectMember struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.29.0
|
||||
// source: project_logins.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteProjectLogin = `-- name: DeleteProjectLogin :one
|
||||
DELETE FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
AND app = $2
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type DeleteProjectLoginParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
App string `json:"app"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, deleteProjectLogin, arg.ProjectID, arg.App)
|
||||
var id uuid.UUID
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const getProjectLogin = `-- name: GetProjectLogin :one
|
||||
SELECT id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
AND app = $2
|
||||
`
|
||||
|
||||
type GetProjectLoginParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
App string `json:"app"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error) {
|
||||
row := q.db.QueryRow(ctx, getProjectLogin, arg.ProjectID, arg.App)
|
||||
var i CoreProjectLogin
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.App,
|
||||
&i.Username,
|
||||
&i.Ciphertext,
|
||||
&i.Origin,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listProjectLogins = `-- name: ListProjectLogins :many
|
||||
SELECT id, project_id, app, username, origin, notes, created_at, updated_at
|
||||
FROM core.project_logins
|
||||
WHERE project_id = $1
|
||||
ORDER BY app ASC
|
||||
`
|
||||
|
||||
type ListProjectLoginsRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
App string `json:"app"`
|
||||
Username string `json:"username"`
|
||||
Origin string `json:"origin"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listProjectLogins, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListProjectLoginsRow
|
||||
for rows.Next() {
|
||||
var i ListProjectLoginsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.App,
|
||||
&i.Username,
|
||||
&i.Origin,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertProjectLogin = `-- name: UpsertProjectLogin :one
|
||||
INSERT INTO core.project_logins (
|
||||
project_id,
|
||||
app,
|
||||
username,
|
||||
ciphertext,
|
||||
origin,
|
||||
notes
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (project_id, app) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
origin = EXCLUDED.origin,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()
|
||||
RETURNING id, project_id, app, username, ciphertext, origin, notes, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertProjectLoginParams struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
App string `json:"app"`
|
||||
Username string `json:"username"`
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Origin string `json:"origin"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error) {
|
||||
row := q.db.QueryRow(ctx, upsertProjectLogin,
|
||||
arg.ProjectID,
|
||||
arg.App,
|
||||
arg.Username,
|
||||
arg.Ciphertext,
|
||||
arg.Origin,
|
||||
arg.Notes,
|
||||
)
|
||||
var i CoreProjectLogin
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ProjectID,
|
||||
&i.App,
|
||||
&i.Username,
|
||||
&i.Ciphertext,
|
||||
&i.Origin,
|
||||
&i.Notes,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -51,6 +51,7 @@ type Querier interface {
|
||||
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
|
||||
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
DeleteProjectLogin(ctx context.Context, arg DeleteProjectLoginParams) (uuid.UUID, error)
|
||||
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
|
||||
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
|
||||
DeleteSetting(ctx context.Context, key string) error
|
||||
@@ -79,6 +80,7 @@ type Querier interface {
|
||||
GetOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
|
||||
GetOrganizationMembership(ctx context.Context, arg GetOrganizationMembershipParams) (GetOrganizationMembershipRow, error)
|
||||
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
|
||||
GetProjectLogin(ctx context.Context, arg GetProjectLoginParams) (CoreProjectLogin, error)
|
||||
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
|
||||
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
|
||||
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
|
||||
@@ -120,6 +122,7 @@ type Querier interface {
|
||||
ListOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]ListOrganizationMembersRow, error)
|
||||
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
|
||||
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
|
||||
ListProjectLogins(ctx context.Context, projectID uuid.UUID) ([]ListProjectLoginsRow, error)
|
||||
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
|
||||
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
|
||||
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
|
||||
@@ -168,6 +171,7 @@ type Querier interface {
|
||||
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
|
||||
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
|
||||
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
|
||||
UpsertProjectLogin(ctx context.Context, arg UpsertProjectLoginParams) (CoreProjectLogin, error)
|
||||
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
|
||||
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
|
||||
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
|
||||
|
||||
@@ -137,6 +137,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
|
||||
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
|
||||
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
|
||||
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
|
||||
api.GET("/projects/:projectID/logins", h.listProjectLogins)
|
||||
api.PUT("/projects/:projectID/logins/:app", h.setProjectLogin)
|
||||
api.DELETE("/projects/:projectID/logins/:app", h.deleteProjectLogin)
|
||||
api.POST("/projects/:projectID/logins/:app/reveal", h.revealProjectLogin)
|
||||
|
||||
api.GET("/projects/:projectID/alerts", h.listAlertRules)
|
||||
api.POST("/projects/:projectID/alerts", h.createAlertRule)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/middleware"
|
||||
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
)
|
||||
|
||||
func (h *HTTPHandler) loginError(c *gin.Context, err error) {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "invalid app id") || strings.Contains(msg, "username is required") {
|
||||
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
|
||||
return
|
||||
}
|
||||
h.secretError(c, err)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listProjectLogins(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.ListProjectLogins(c.Request.Context(), actor, projectID)
|
||||
if err != nil {
|
||||
h.loginError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": result})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) setProjectLogin(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body services.SetProjectLoginInput
|
||||
if !h.bindAndValidate(c, &body) {
|
||||
return
|
||||
}
|
||||
body.App = c.Param("app")
|
||||
result, err := h.Platform.SetProjectLogin(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.loginError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteProjectLogin(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Platform.DeleteProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c)); err != nil {
|
||||
h.loginError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) revealProjectLogin(c *gin.Context) {
|
||||
actor, projectID, ok := h.actorAndProject(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.Platform.RevealProjectLogin(c.Request.Context(), actor, projectID, c.Param("app"), middleware.RequestIDFromContext(c))
|
||||
if err != nil {
|
||||
h.loginError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/models"
|
||||
)
|
||||
|
||||
// Project logins are website and app accounts: a stable app id, a username,
|
||||
// and a password. The app id survives port changes (localhost:3000 is not an
|
||||
// identity). Passwords are AES-256-GCM at rest; reveal is audit-logged.
|
||||
|
||||
var loginAppRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
type ProjectLoginSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
App string `json:"app"`
|
||||
Username string `json:"username"`
|
||||
Origin string `json:"origin"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SetProjectLoginInput struct {
|
||||
App string `json:"app"`
|
||||
Username string `json:"username" validate:"required"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
Origin *string `json:"origin"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
type RevealProjectLoginResponse struct {
|
||||
App string `json:"app"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
||||
func validLoginApp(app string) bool {
|
||||
return loginAppRe.MatchString(app)
|
||||
}
|
||||
|
||||
func toProjectLoginSummary(row db.ListProjectLoginsRow) ProjectLoginSummary {
|
||||
return ProjectLoginSummary{
|
||||
ID: row.ID,
|
||||
App: row.App,
|
||||
Username: row.Username,
|
||||
Origin: row.Origin,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PlatformService) ListProjectLogins(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectLoginSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.repo.Queries().ListProjectLogins(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ProjectLoginSummary, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, toProjectLoginSummary(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) SetProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectLoginInput, requestID string) (ProjectLoginSummary, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return ProjectLoginSummary{}, err
|
||||
}
|
||||
app := strings.TrimSpace(input.App)
|
||||
if !validLoginApp(app) {
|
||||
return ProjectLoginSummary{}, fmt.Errorf("invalid app id — use [A-Za-z0-9][A-Za-z0-9._-]{0,63}, not a URL or port")
|
||||
}
|
||||
username := strings.TrimSpace(input.Username)
|
||||
if username == "" {
|
||||
return ProjectLoginSummary{}, fmt.Errorf("username is required")
|
||||
}
|
||||
if s.enc == nil {
|
||||
return ProjectLoginSummary{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||
}
|
||||
ciphertext, err := s.enc.Encrypt([]byte(input.Password))
|
||||
if err != nil {
|
||||
return ProjectLoginSummary{}, err
|
||||
}
|
||||
var origin, notes string
|
||||
if existing, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app}); err == nil {
|
||||
origin, notes = existing.Origin, existing.Notes
|
||||
} else if err != pgx.ErrNoRows {
|
||||
return ProjectLoginSummary{}, err
|
||||
}
|
||||
if input.Origin != nil {
|
||||
origin = strings.TrimSpace(*input.Origin)
|
||||
}
|
||||
if input.Notes != nil {
|
||||
notes = strings.TrimSpace(*input.Notes)
|
||||
}
|
||||
row, err := s.repo.Queries().UpsertProjectLogin(ctx, db.UpsertProjectLoginParams{
|
||||
ProjectID: projectID,
|
||||
App: app,
|
||||
Username: username,
|
||||
Ciphertext: ciphertext,
|
||||
Origin: origin,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
return ProjectLoginSummary{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.set", "project_login", row.ID.String(), map[string]any{
|
||||
"app": row.App,
|
||||
}))
|
||||
}
|
||||
return ProjectLoginSummary{
|
||||
ID: row.ID,
|
||||
App: row.App,
|
||||
Username: row.Username,
|
||||
Origin: row.Origin,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) DeleteProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) error {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return err
|
||||
}
|
||||
row, err := s.repo.Queries().DeleteProjectLogin(ctx, db.DeleteProjectLoginParams{ProjectID: projectID, App: app})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.deleted", "project_login", row.String(), map[string]any{
|
||||
"app": app,
|
||||
}))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PlatformService) RevealProjectLogin(ctx context.Context, actor *models.Actor, projectID uuid.UUID, app string, requestID string) (RevealProjectLoginResponse, error) {
|
||||
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
||||
return RevealProjectLoginResponse{}, err
|
||||
}
|
||||
if s.enc == nil {
|
||||
return RevealProjectLoginResponse{}, fmt.Errorf("encryption is not configured on this deployment")
|
||||
}
|
||||
row, err := s.repo.Queries().GetProjectLogin(ctx, db.GetProjectLoginParams{ProjectID: projectID, App: app})
|
||||
if err != nil {
|
||||
return RevealProjectLoginResponse{}, err
|
||||
}
|
||||
plain, err := s.enc.Decrypt(row.Ciphertext)
|
||||
if err != nil {
|
||||
return RevealProjectLoginResponse{}, err
|
||||
}
|
||||
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
|
||||
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "login.revealed", "project_login", row.ID.String(), map[string]any{
|
||||
"app": row.App,
|
||||
}))
|
||||
}
|
||||
return RevealProjectLoginResponse{App: row.App, Username: row.Username, Password: string(plain), Origin: row.Origin}, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidLoginApp(t *testing.T) {
|
||||
ok := []string{"primora", "MyApp", "app.local", "stripe-dashboard", "a"}
|
||||
bad := []string{"", "has space", "http://localhost:3000", "/etc/passwd", "..", "-leading", "a/b"}
|
||||
for _, app := range ok {
|
||||
if !validLoginApp(app) {
|
||||
t.Errorf("validLoginApp(%q) = false", app)
|
||||
}
|
||||
}
|
||||
for _, app := range bad {
|
||||
if validLoginApp(app) {
|
||||
t.Errorf("validLoginApp(%q) = true", app)
|
||||
}
|
||||
}
|
||||
if !validLoginApp(strings.Repeat("a", 64)) {
|
||||
t.Error("64-char app id rejected")
|
||||
}
|
||||
if validLoginApp(strings.Repeat("a", 65)) {
|
||||
t.Error("65-char app id accepted")
|
||||
}
|
||||
}
|
||||
@@ -2041,6 +2041,91 @@ paths:
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/logins:
|
||||
get:
|
||||
tags: [Secrets]
|
||||
operationId: listProjectLogins
|
||||
summary: Lists app logins — username and origin, never the password.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
responses:
|
||||
"200":
|
||||
description: Project logins (password omitted)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProjectLoginListResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/logins/{app}:
|
||||
put:
|
||||
tags: [Secrets]
|
||||
operationId: setProjectLogin
|
||||
summary: Creates or replaces an app login. The password is AES-256-GCM at rest. Omitted origin/notes keep existing values. The app id is stable — it is not a host or port.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/LoginApp"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SetProjectLoginRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Login stored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProjectLogin"
|
||||
"400":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
delete:
|
||||
tags: [Secrets]
|
||||
operationId: deleteProjectLogin
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/LoginApp"
|
||||
responses:
|
||||
"204":
|
||||
description: Login deleted
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/logins/{app}/reveal:
|
||||
post:
|
||||
tags: [Secrets]
|
||||
operationId: revealProjectLogin
|
||||
summary: Returns the username, password, and origin for one app. Every call is audit-logged. Read one login when you need it. Do not copy it into chat or a .env file.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- apiKeyAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/ProjectID"
|
||||
- $ref: "#/components/parameters/LoginApp"
|
||||
responses:
|
||||
"200":
|
||||
description: Decrypted login
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RevealProjectLoginResponse"
|
||||
"401":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
"404":
|
||||
$ref: "#/components/responses/ErrorResponse"
|
||||
/projects/{projectID}/alerts:
|
||||
get:
|
||||
tags: [Alerts]
|
||||
@@ -2475,6 +2560,14 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
|
||||
LoginApp:
|
||||
name: app
|
||||
in: path
|
||||
required: true
|
||||
description: Stable app id. Not a URL and not a port.
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"
|
||||
BucketID:
|
||||
name: bucketID
|
||||
in: path
|
||||
@@ -4408,6 +4501,62 @@ components:
|
||||
value:
|
||||
type: string
|
||||
required: [name, value]
|
||||
ProjectLogin:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
app:
|
||||
type: string
|
||||
description: Stable app id. The current origin is separate and may change.
|
||||
username:
|
||||
type: string
|
||||
origin:
|
||||
type: string
|
||||
description: Current URL of the app, if known. Not the identity.
|
||||
notes:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, app, username, origin, notes, created_at, updated_at]
|
||||
ProjectLoginListResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ProjectLogin"
|
||||
required: [items]
|
||||
SetProjectLoginRequest:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
description: Plaintext — stored AES-256-GCM encrypted, never returned by list.
|
||||
origin:
|
||||
type: string
|
||||
notes:
|
||||
type: string
|
||||
required: [username, password]
|
||||
RevealProjectLoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
app:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
origin:
|
||||
type: string
|
||||
required: [app, username, password, origin]
|
||||
AlertRuleConfig:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+24
-13
@@ -21,7 +21,7 @@ project interactively.
|
||||
## Commands
|
||||
|
||||
`orgs`, `projects`, `buckets`, `objects` (list/upload/download/rm), `keys`,
|
||||
`jobs`, `events:send`, `audit list --follow`. Every command accepts `--json`.
|
||||
`jobs`, `events:send`, `audit:list --follow`. Every command accepts `--json`.
|
||||
|
||||
```sh
|
||||
primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||
@@ -31,7 +31,7 @@ primora events:send custom.deploy.done --data '{"sha":"abc123"}'
|
||||
webhooks, and `event_pattern` functions all fire.
|
||||
|
||||
```sh
|
||||
primora documents list --collection users \
|
||||
primora documents:list --collection users \
|
||||
--filter "status.eq.active,age.gt.18" --order "age.desc"
|
||||
```
|
||||
|
||||
@@ -53,8 +53,14 @@ primora secrets import .env # import KEY=value pairs
|
||||
primora secrets list # names + urls + timestamps, never values
|
||||
primora secrets get STRIPE_KEY # prints the value (scriptable)
|
||||
primora secrets rm STRIPE_KEY --yes
|
||||
primora logins set my-app --username ada --origin http://localhost:3000
|
||||
primora logins get my-app --json
|
||||
primora logins list
|
||||
```
|
||||
|
||||
`logins` is a username and password for a stable app id (`my-app`, a project
|
||||
slug). The id is not a host or a port. `origin` is only the current URL.
|
||||
|
||||
Format: Argon2id (64 MiB / 3 / 4) → XChaCha20-Poly1305 over a single JSON
|
||||
payload; the parameter header is the AEAD's AAD, atomic tmp+rename writes.
|
||||
Clean-room implementation - compatible with nothing but itself.
|
||||
@@ -76,6 +82,8 @@ primora secrets list --remote # metadata only — names, urls, notes
|
||||
primora secrets get STRIPE_KEY --remote # explicit reveal, audit-logged
|
||||
primora secrets rm STRIPE_KEY --remote --yes
|
||||
primora secrets import .env --remote --overwrite
|
||||
primora logins set my-app --username ada --remote
|
||||
primora logins get my-app --remote
|
||||
```
|
||||
|
||||
Job payloads reference secrets as `secret://NAME`; the scheduler resolves them
|
||||
@@ -146,15 +154,18 @@ storage volume, `scripts/backup.sh` remains the tool.
|
||||
|
||||
## For AI agents
|
||||
|
||||
Two safe paths, no secret ever in the agent's context:
|
||||
Fetch one named value when you need it. Do not paste it into chat and do
|
||||
not write it to a `.env` file. Lists omit passwords. Project reveals are
|
||||
audit-logged. The local vault must be unlocked (`primora vault unlock`).
|
||||
|
||||
- **Wrapped** — launch the agent under `primora agent … -- claude`. It holds
|
||||
dummy tokens; the broker attaches real ones on the wire.
|
||||
- **MCP** — `primora_vault_status`, `primora_vault_list` (metadata only),
|
||||
`primora_vault_exec` (runs `primora` commands with a secret injected into
|
||||
the process env; vault/secrets/inject/agent/login and `keys:create` are
|
||||
denied, so output can never carry a credential). Requires an unlocked
|
||||
vault session on the host.
|
||||
- **MCP project vault** — `primora_secrets_list` (metadata only) and
|
||||
`primora_secrets_set` (write-only). No reveal, no delete: agents reference
|
||||
`secret://NAME` in job payloads without ever holding the value.
|
||||
- **Read** — `primora_vault_get` / `primora secrets get` for a local secret,
|
||||
`primora_secrets_get` for a project secret, `primora_logins_get` (or
|
||||
`primora logins get`) for a username and password. `remote: true` /
|
||||
`--remote` selects the project vault.
|
||||
- **Store a login** — `primora_logins_set` or `primora logins set <app>`.
|
||||
The app id is stable. Use it again when the port changes.
|
||||
- **Wrapped** — `primora agent … -- claude` still attaches API tokens on
|
||||
the wire for providers that honor a base-URL override. Form logins need
|
||||
the read path above, because the broker cannot type into a website.
|
||||
- **Inject** — `primora_vault_exec` still runs an allow-listed command with
|
||||
a secret in its environment, for tools that read env and must not print it.
|
||||
@@ -12,7 +12,8 @@
|
||||
"scripts": {
|
||||
"build": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\"",
|
||||
"dev": "npm run build && node dist/cli.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --experimental-strip-types --test src/login-id.test.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+41
-1
@@ -18,6 +18,12 @@ import {
|
||||
} from "./commands/objects.js";
|
||||
import { cmdOrgsCreate, cmdOrgsList, cmdOrgsRemove } from "./commands/orgs.js";
|
||||
import { cmdProjectsCreate, cmdProjectsList, cmdProjectsRemove } from "./commands/projects.js";
|
||||
import {
|
||||
cmdLoginsGet,
|
||||
cmdLoginsList,
|
||||
cmdLoginsRemove,
|
||||
cmdLoginsSet,
|
||||
} from "./commands/logins.js";
|
||||
import {
|
||||
cmdSecretsGet,
|
||||
cmdSecretsImport,
|
||||
@@ -51,7 +57,7 @@ cli
|
||||
.option("--url <url>", "Primora base URL (default http://localhost)")
|
||||
.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("--api-key <key>", "Store a prm_ API key instead of a session")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(cmdLogin);
|
||||
|
||||
@@ -260,6 +266,36 @@ cli
|
||||
.option("--overwrite", "Replace existing secrets")
|
||||
.action(cmdSecretsImport);
|
||||
|
||||
cli
|
||||
.command("logins:set <app>", "Store a username and password for an app")
|
||||
.option("--username <user>", "Account username")
|
||||
.option("--password <password>", "Account password (otherwise prompted or read from stdin)")
|
||||
.option("--origin <url>", "Current URL of the app — not its identity")
|
||||
.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(cmdLoginsSet);
|
||||
cli
|
||||
.command("logins:get [app]", "Print one login (username, password, origin)")
|
||||
.option("--remote", "Reveal from the project vault on the server (audit-logged)")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdLoginsGet);
|
||||
cli
|
||||
.command("logins:list", "List logins (app, username, origin — never the password)")
|
||||
.option("--remote", "List project logins on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.action(cmdLoginsList);
|
||||
cli
|
||||
.command("logins:rm [app]", "Delete a login")
|
||||
.option("--remote", "Delete from the project vault on the server")
|
||||
.option("--project <id>", "Project override (with --remote)")
|
||||
.option("--password-file <path>", "Read the vault password from a file")
|
||||
.option("--yes", "Skip the confirmation prompt")
|
||||
.action(cmdLoginsRemove);
|
||||
|
||||
cli
|
||||
.command("inject", "Run a command with vault secrets in its environment")
|
||||
.option("--all", "Inject every vault secret (default when no --env-file)")
|
||||
@@ -317,6 +353,7 @@ cli.example("primora login --url https://primora.example.com");
|
||||
cli.example("primora vault init");
|
||||
cli.example("primora secrets import .env");
|
||||
cli.example("primora secrets list --remote");
|
||||
cli.example("primora logins set my-app --username ada --remote");
|
||||
cli.example("primora inject --env-file .env -- npm run dev");
|
||||
cli.example("primora agent --anthropic CLAUDE_KEY -- claude");
|
||||
|
||||
@@ -334,6 +371,7 @@ const GROUPS = new Set([
|
||||
"audit",
|
||||
"vault",
|
||||
"secrets",
|
||||
"logins",
|
||||
"stack",
|
||||
]);
|
||||
const argv = process.argv.slice(2);
|
||||
@@ -343,6 +381,8 @@ if (GROUPS.has(argv[0]) && argv[1] && !argv[1].startsWith("-")) {
|
||||
argv[0] = "vault:status";
|
||||
} else if (argv[0] === "secrets" && !argv[1]) {
|
||||
argv[0] = "secrets:list";
|
||||
} else if (argv[0] === "logins" && !argv[1]) {
|
||||
argv[0] = "logins:list";
|
||||
}
|
||||
|
||||
// mri coerces numeric-looking option values ("007" → 7, "" → 0), which
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ScheduledJob, ScheduledJobRun } from "@primora/api-client";
|
||||
import { AutomationService } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { configureClient, requireAuth, requireProject, resolveJobId } from "../http.js";
|
||||
import { formatDate, isJson, printJson, printTable, success } from "../out.js";
|
||||
|
||||
interface JobOptions {
|
||||
@@ -88,12 +88,13 @@ export async function cmdJobsCreate(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?: boolean }): Promise<void> {
|
||||
export async function cmdJobsRemove(jobRef: string, options: JobOptions & { yes?: boolean }): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const jobId = await resolveJobId(projectId, jobRef);
|
||||
await AutomationService.deleteScheduledJob({ projectId, jobId });
|
||||
if (isJson(options)) {
|
||||
printJson({ deleted: jobId });
|
||||
@@ -102,12 +103,13 @@ export async function cmdJobsRemove(jobId: string, options: JobOptions & { yes?:
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdJobsRun(jobId: string, options: JobOptions): Promise<void> {
|
||||
export async function cmdJobsRun(jobRef: string, options: JobOptions): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const jobId = await resolveJobId(projectId, jobRef);
|
||||
const run = await AutomationService.runScheduledJob({ projectId, jobId });
|
||||
if (isJson(options)) {
|
||||
printJson(run);
|
||||
@@ -128,7 +130,7 @@ function runRow(run: ScheduledJobRun): string[] {
|
||||
}
|
||||
|
||||
export async function cmdJobsRuns(
|
||||
jobId: string,
|
||||
jobRef: string,
|
||||
options: JobOptions & { limit?: string },
|
||||
): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
@@ -136,6 +138,7 @@ export async function cmdJobsRuns(
|
||||
configureClient(cfg);
|
||||
|
||||
const projectId = requireProject(cfg, options.project);
|
||||
const jobId = await resolveJobId(projectId, jobRef);
|
||||
const page = await AutomationService.listScheduledJobRuns({
|
||||
projectId,
|
||||
jobId,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { SecretsService, type ProjectLogin } from "@primora/api-client";
|
||||
|
||||
import { loadConfig } from "../config.js";
|
||||
import { CliError, configureClient, requireAuth, requireProject } from "../http.js";
|
||||
import { confirmOrAbort, dim, formatDate, isJson, printJson, printTable, success, withSpinner } from "../out.js";
|
||||
import { saveVault, type LoginEntry } from "../vault.js";
|
||||
import { APP_RE } from "../login-id.js";
|
||||
import { unlockVault, type VaultPasswordOptions } from "./vault.js";
|
||||
import { type RemoteOptions } from "./secrets.js";
|
||||
|
||||
export { APP_RE };
|
||||
|
||||
export function checkApp(app: string): void {
|
||||
if (!APP_RE.test(app)) {
|
||||
throw new CliError(
|
||||
`Invalid app id "${app}".`,
|
||||
"Use a stable name like my-app or stripe.dashboard - not a URL or a port.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function remoteProject(options: RemoteOptions): string {
|
||||
const cfg = loadConfig();
|
||||
requireAuth(cfg);
|
||||
configureClient(cfg);
|
||||
return requireProject(cfg, options.project);
|
||||
}
|
||||
|
||||
function loginsOf(vault: { data: { logins?: Record<string, LoginEntry> } }): Record<string, LoginEntry> {
|
||||
vault.data.logins ??= {};
|
||||
return vault.data.logins;
|
||||
}
|
||||
|
||||
export interface LoginSetOptions extends VaultPasswordOptions, RemoteOptions {
|
||||
username?: string;
|
||||
password?: string;
|
||||
origin?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export async function cmdLoginsSet(app: string, options: LoginSetOptions): Promise<void> {
|
||||
checkApp(app);
|
||||
let username = options.username?.trim();
|
||||
let password = options.password;
|
||||
if (password === undefined && !process.stdin.isTTY) {
|
||||
password = readFileSync(0, "utf8").replace(/\r?\n$/, "");
|
||||
}
|
||||
if (!username || password === undefined) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new CliError("Usage: primora logins set <app> --username <user> --password <password>");
|
||||
}
|
||||
}
|
||||
if (!username) {
|
||||
const entered = await p.text({ message: "Username" });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
username = entered.trim();
|
||||
}
|
||||
if (!username) throw new CliError("Username is required.");
|
||||
if (password === undefined) {
|
||||
const entered = await p.password({ message: `Password for ${username}` });
|
||||
if (p.isCancel(entered)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
password = entered;
|
||||
}
|
||||
if (!password) throw new CliError("Password is required.");
|
||||
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
await SecretsService.setProjectLogin({
|
||||
projectId,
|
||||
app,
|
||||
requestBody: {
|
||||
username,
|
||||
password,
|
||||
origin: options.origin,
|
||||
notes: options.notes,
|
||||
},
|
||||
});
|
||||
success(`Stored login ${app} in the project vault`);
|
||||
return;
|
||||
}
|
||||
|
||||
const vault = await unlockVault(options);
|
||||
const now = new Date().toISOString();
|
||||
const logins = loginsOf(vault);
|
||||
const existing = logins[app];
|
||||
logins[app] = {
|
||||
username,
|
||||
password,
|
||||
origin: options.origin ?? existing?.origin,
|
||||
notes: options.notes ?? existing?.notes,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`${existing ? "Updated" : "Stored"} login ${app}`);
|
||||
}
|
||||
|
||||
async function pickRemote(projectId: string, message: string): Promise<string> {
|
||||
const { items } = await SecretsService.listProjectLogins({ projectId });
|
||||
if (!items.length) {
|
||||
dim("No project logins — `primora logins set <app> --remote`.");
|
||||
process.exit(0);
|
||||
}
|
||||
const picked = await p.select({
|
||||
message,
|
||||
options: items.map((s) => ({
|
||||
value: s.app,
|
||||
label: s.app,
|
||||
hint: s.username + (s.origin ? ` · ${s.origin}` : ""),
|
||||
})),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
function localPublic(app: string, entry: LoginEntry) {
|
||||
return {
|
||||
app,
|
||||
username: entry.username,
|
||||
origin: entry.origin ?? "",
|
||||
notes: entry.notes ?? "",
|
||||
created_at: entry.created_at,
|
||||
updated_at: entry.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cmdLoginsGet(
|
||||
app: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
app ??= await pickRemote(projectId, "Login to reveal");
|
||||
checkApp(app);
|
||||
const res = await SecretsService.revealProjectLogin({ projectId, app });
|
||||
emitLogin(res, isJson(options));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const logins = loginsOf(vault);
|
||||
if (!app) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora logins get <app>");
|
||||
const names = Object.keys(logins).sort();
|
||||
if (!names.length) {
|
||||
dim("No logins — `primora logins set <app>`.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Login to reveal",
|
||||
options: names.map((n) => ({ value: n, label: n, hint: logins[n].username })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
app = picked;
|
||||
}
|
||||
checkApp(app);
|
||||
const entry = logins[app];
|
||||
if (!entry) throw new CliError(`Login "${app}" not found.`);
|
||||
emitLogin(
|
||||
{
|
||||
app,
|
||||
username: entry.username,
|
||||
password: entry.password,
|
||||
origin: entry.origin ?? "",
|
||||
},
|
||||
isJson(options),
|
||||
);
|
||||
}
|
||||
|
||||
function emitLogin(
|
||||
row: { app: string; username: string; password: string; origin: string },
|
||||
json: boolean,
|
||||
): void {
|
||||
if (json) {
|
||||
printJson(row);
|
||||
return;
|
||||
}
|
||||
process.stdout.write(`app: ${row.app}\nusername: ${row.username}\norigin: ${row.origin || "—"}\n${row.password}\n`);
|
||||
}
|
||||
|
||||
export async function cmdLoginsList(
|
||||
options: VaultPasswordOptions & RemoteOptions & { json?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
const { items } = await SecretsService.listProjectLogins({ projectId });
|
||||
if (isJson(options)) {
|
||||
printJson(items);
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
dim("No project logins — `primora logins set <app> --remote`.");
|
||||
return;
|
||||
}
|
||||
printTable(["APP", "USERNAME", "ORIGIN", "UPDATED"], items.map(remoteRow));
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const rows = Object.entries(loginsOf(vault)).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (isJson(options)) {
|
||||
printJson(rows.map(([app, e]) => localPublic(app, e)));
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
dim("No logins — `primora logins set <app>`.");
|
||||
return;
|
||||
}
|
||||
printTable(
|
||||
["APP", "USERNAME", "ORIGIN", "UPDATED"],
|
||||
rows.map(([app, e]) => [app, e.username, e.origin ?? "—", formatDate(e.updated_at)]),
|
||||
);
|
||||
}
|
||||
|
||||
function remoteRow(s: ProjectLogin): string[] {
|
||||
return [s.app, s.username, s.origin || "—", formatDate(s.updated_at)];
|
||||
}
|
||||
|
||||
export async function cmdLoginsRemove(
|
||||
app: string | undefined,
|
||||
options: VaultPasswordOptions & RemoteOptions & { yes?: boolean },
|
||||
): Promise<void> {
|
||||
if (options.remote) {
|
||||
const projectId = remoteProject(options);
|
||||
app ??= await pickRemote(projectId, "Login to delete");
|
||||
checkApp(app);
|
||||
await confirmOrAbort(`Delete project login ${app}?`, options.yes);
|
||||
await SecretsService.deleteProjectLogin({ projectId, app });
|
||||
success(`Deleted login ${app}`);
|
||||
return;
|
||||
}
|
||||
const vault = await unlockVault(options);
|
||||
const logins = loginsOf(vault);
|
||||
if (!app) {
|
||||
if (!process.stdin.isTTY) throw new CliError("Usage: primora logins rm <app>");
|
||||
const names = Object.keys(logins).sort();
|
||||
if (!names.length) {
|
||||
dim("No logins.");
|
||||
return;
|
||||
}
|
||||
const picked = await p.select({
|
||||
message: "Login to delete",
|
||||
options: names.map((n) => ({ value: n, label: n })),
|
||||
});
|
||||
if (p.isCancel(picked)) {
|
||||
p.cancel("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
app = picked;
|
||||
}
|
||||
checkApp(app);
|
||||
if (!logins[app]) throw new CliError(`Login "${app}" not found.`);
|
||||
await confirmOrAbort(`Delete login ${app}?`, options.yes);
|
||||
delete logins[app];
|
||||
await withSpinner("Encrypting vault…", async () => saveVault(vault));
|
||||
success(`Deleted login ${app}`);
|
||||
}
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
import { OpenAPI, StorageService } from "@primora/api-client";
|
||||
import { AutomationService, OpenAPI, StorageService } from "@primora/api-client";
|
||||
|
||||
import type { CliConfig } from "./config.js";
|
||||
|
||||
@@ -110,3 +110,16 @@ export async function resolveBucketId(projectId: string, ref: string): Promise<s
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
/** Resolve a scheduled job reference that may be an id or a name. */
|
||||
export async function resolveJobId(projectId: string, ref: string): Promise<string> {
|
||||
const { items } = await AutomationService.listScheduledJobs({ projectId });
|
||||
const match = items.find((j) => j.id === ref || j.name === ref);
|
||||
if (!match) {
|
||||
throw new CliError(
|
||||
`Job "${ref}" not found.`,
|
||||
`Known jobs: ${items.map((j) => j.name).join(", ") || "(none)"}`,
|
||||
);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { APP_RE } from "./login-id.ts";
|
||||
|
||||
test("app id is a stable name, not a url or a port", () => {
|
||||
for (const app of ["primora", "MyApp", "stripe.dashboard", "a", "a".repeat(64)]) {
|
||||
assert.equal(APP_RE.test(app), true, app);
|
||||
}
|
||||
for (const app of ["", "http://localhost:3000", "localhost:3000", "has space", "-x", "a".repeat(65)]) {
|
||||
assert.equal(APP_RE.test(app), false, app);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stable app id. A port is not an identity — localhost:3000 can serve
|
||||
// something else tomorrow. Dots and hyphens are allowed; URLs are not.
|
||||
export const APP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
+17
-2
@@ -34,9 +34,21 @@ export interface SecretEntry {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** A website or app account. `app` is the stable id; `origin` is the current URL. */
|
||||
export interface LoginEntry {
|
||||
username: string;
|
||||
password: string;
|
||||
origin?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface VaultData {
|
||||
v: 1;
|
||||
secrets: Record<string, SecretEntry>;
|
||||
/** Absent in vaults written before logins existed. */
|
||||
logins?: Record<string, LoginEntry>;
|
||||
}
|
||||
|
||||
const MAGIC = "PRMVLT01";
|
||||
@@ -189,7 +201,10 @@ function decrypt(f: ParsedFile, key: Uint8Array): VaultData {
|
||||
try {
|
||||
const plain = xchacha20poly1305(key, f.nonce, f.header).decrypt(f.buf.subarray(HEADER_LEN));
|
||||
const data = JSON.parse(dec.decode(plain)) as VaultData;
|
||||
if (data.v !== 1 || typeof data.secrets !== "object") throw new Error("shape");
|
||||
if (data.v !== 1 || typeof data.secrets !== "object" || data.secrets === null) throw new Error("shape");
|
||||
// Old vaults have no logins map. Treat that as empty so the first save
|
||||
// keeps every secret and just adds the field.
|
||||
if (typeof data.logins !== "object" || data.logins === null) data.logins = {};
|
||||
return data;
|
||||
} catch {
|
||||
throw new CliError("Wrong password or corrupt vault.");
|
||||
@@ -243,5 +258,5 @@ export function createVault(password: Uint8Array): void {
|
||||
}
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = argon2id(password, salt, { ...KDF, dkLen: 32 });
|
||||
saveVault({ data: { v: 1, secrets: {} }, key, salt, params: KDF });
|
||||
saveVault({ data: { v: 1, secrets: {}, logins: {} }, key, salt, params: KDF });
|
||||
}
|
||||
@@ -6,5 +6,6 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@primora/desktop",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"description": "Primora desktop shell — thin Tauri client for a self-hosted deployment",
|
||||
"scripts": {
|
||||
|
||||
@@ -185,6 +185,42 @@ fn vault_rm(name: String) -> Result<(), String> {
|
||||
run_cli(&["secrets", "rm", &name, "--yes"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_logins() -> Result<Value, String> {
|
||||
let out = run_cli(&["logins", "list", "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_login_reveal(app: String) -> Result<Value, String> {
|
||||
let out = run_cli(&["logins", "get", &app, "--json"], None, None)?;
|
||||
serde_json::from_str(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_login_set(
|
||||
app: String,
|
||||
username: String,
|
||||
password: String,
|
||||
origin: Option<String>,
|
||||
notes: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// Password on stdin, not argv — the CLI reads stdin when --password is omitted.
|
||||
let mut args = vec!["logins", "set", app.as_str(), "--username", username.as_str()];
|
||||
if let Some(o) = origin.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--origin", o]);
|
||||
}
|
||||
if let Some(n) = notes.as_deref().filter(|s| !s.is_empty()) {
|
||||
args.extend(["--notes", n]);
|
||||
}
|
||||
run_cli(&args, Some(&password), None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_login_rm(app: String) -> Result<(), String> {
|
||||
run_cli(&["logins", "rm", &app, "--yes"], None, None).map(|_| ())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn vault_import(path: String, overwrite: bool) -> Result<String, String> {
|
||||
let mut args = vec!["secrets", "import", path.as_str()];
|
||||
@@ -215,6 +251,10 @@ pub fn run() {
|
||||
vault_set,
|
||||
vault_rm,
|
||||
vault_import,
|
||||
vault_logins,
|
||||
vault_login_reveal,
|
||||
vault_login_set,
|
||||
vault_login_rm,
|
||||
])
|
||||
.setup(|app| {
|
||||
let show = MenuItem::with_id(app, "show", "Show Primora", true, None::<&str>)?;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Primora",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.5",
|
||||
"identifier": "dev.tdvorak.primora",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
+23
-11
@@ -101,6 +101,18 @@
|
||||
tauri?.event.emit("open-vault");
|
||||
});
|
||||
|
||||
const probe = async (url) => {
|
||||
try {
|
||||
// Opaque (no-cors) — proves the host answers, not that it is a
|
||||
// Primora deployment. A wrong-but-alive URL is recoverable via the
|
||||
// tray menu's "Change server".
|
||||
await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(8000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
err.hidden = true;
|
||||
@@ -108,7 +120,17 @@
|
||||
button.textContent = "Checking…";
|
||||
|
||||
let url = input.value.trim();
|
||||
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
// Self-hosted deployments are often plain HTTP on a LAN — probe
|
||||
// https first, fall back to http, and only keep the one that answers.
|
||||
const https = `https://${url}`;
|
||||
url = (await probe(https)) ? https : `http://${url}`;
|
||||
}
|
||||
|
||||
if (!(await probe(url))) {
|
||||
fail("Server did not respond. Check the URL and that the deployment is up.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauri.core.invoke("set_server_url", { url });
|
||||
@@ -117,16 +139,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Reachability probe — opaque (no-cors), so it proves the host answers,
|
||||
// not that it is a Primora deployment. A wrong-but-alive URL is recoverable
|
||||
// via the tray menu's "Change server".
|
||||
try {
|
||||
await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(8000) });
|
||||
} catch {
|
||||
fail("Server did not respond. Check the URL and that the deployment is up.");
|
||||
return;
|
||||
}
|
||||
|
||||
location.replace(url);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -203,6 +203,41 @@ async function renderUnlocked(st) {
|
||||
},
|
||||
}, "Import .env");
|
||||
|
||||
const logins = await tauri.core.invoke("vault_logins").catch(() => []);
|
||||
const loginTable = logins.length
|
||||
? el("table", { class: "vtable" },
|
||||
el("thead", {}, el("tr", {},
|
||||
el("th", {}, "app"), el("th", {}, "username"), el("th", {}, "origin"), el("th", {}, ""))),
|
||||
el("tbody", {}, ...logins.map(loginRow)))
|
||||
: dimNote("No logins yet.");
|
||||
|
||||
const app = el("input", { placeholder: "app id", class: "mono", spellcheck: "false" });
|
||||
const user = el("input", { placeholder: "username", autocomplete: "off" });
|
||||
const pass = el("input", { type: "password", placeholder: "password", autocomplete: "off" });
|
||||
const origin = el("input", { placeholder: "origin (optional)", inputmode: "url" });
|
||||
const loginErr = el("p", { class: "err", hidden: true });
|
||||
const addLogin = el("button", {
|
||||
onclick: async () => {
|
||||
loginErr.hidden = true;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(app.value)) {
|
||||
loginErr.textContent = "App id is a stable name like my-app — not a URL or a port.";
|
||||
loginErr.hidden = false; return;
|
||||
}
|
||||
if (!user.value.trim() || !pass.value) {
|
||||
loginErr.textContent = "Username and password are required.";
|
||||
loginErr.hidden = false; return;
|
||||
}
|
||||
busy(addLogin, "Encrypting…");
|
||||
try {
|
||||
await tauri.core.invoke("vault_login_set", {
|
||||
app: app.value, username: user.value.trim(), password: pass.value,
|
||||
origin: origin.value || null, notes: null,
|
||||
});
|
||||
} catch (e) { loginErr.textContent = String(e); loginErr.hidden = false; unbusy(addLogin); return; }
|
||||
refresh();
|
||||
},
|
||||
}, "Add login");
|
||||
|
||||
view.replaceChildren(
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`),
|
||||
@@ -210,12 +245,47 @@ async function renderUnlocked(st) {
|
||||
el("div", { class: "vform" }, name, value, url, notes, addBtn),
|
||||
el("div", { class: "vform" }, importPath, importBtn),
|
||||
err),
|
||||
el("section", { class: "vcard" },
|
||||
el("h2", {}, `${logins.length} login${logins.length === 1 ? "" : "s"}`),
|
||||
el("p", { class: "sub" }, "Username and password for an app. The app id stays put when the port changes."),
|
||||
loginTable,
|
||||
el("div", { class: "vform" }, app, user, pass, origin, addLogin),
|
||||
loginErr),
|
||||
el("p", { class: "hint" },
|
||||
"Values are never shown here — copy goes straight to the clipboard. " +
|
||||
"Injection and the credential broker stay in the CLI: primora inject / primora agent.")
|
||||
"Read one secret or login from an agent with primora_vault_get or primora_logins_get. " +
|
||||
"Do not paste it into chat or a .env file.")
|
||||
);
|
||||
}
|
||||
|
||||
function loginRow(s) {
|
||||
const copy = el("button", { class: "ghost sm", title: "Copy password" }, "copy");
|
||||
copy.addEventListener("click", async () => {
|
||||
try {
|
||||
const row = await tauri.core.invoke("vault_login_reveal", { app: s.app });
|
||||
await navigator.clipboard.writeText(row.password);
|
||||
copy.textContent = "copied";
|
||||
setTimeout(() => (copy.textContent = "copy"), 1200);
|
||||
} catch { copy.textContent = "error"; setTimeout(() => (copy.textContent = "copy"), 1500); }
|
||||
});
|
||||
const del = el("button", { class: "ghost sm danger", title: "Delete" }, "delete");
|
||||
del.addEventListener("click", async () => {
|
||||
if (del.dataset.armed) {
|
||||
try { await tauri.core.invoke("vault_login_rm", { app: s.app }); } catch {}
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
del.dataset.armed = "1";
|
||||
del.textContent = "sure?";
|
||||
setTimeout(() => { delete del.dataset.armed; del.textContent = "delete"; }, 2500);
|
||||
});
|
||||
return el("tr", {},
|
||||
el("td", { class: "mono" }, s.app),
|
||||
el("td", {}, s.username),
|
||||
el("td", { class: "dimcell" }, s.origin || "—"),
|
||||
el("td", { class: "acts" }, copy, del));
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
|
||||
async function refresh() {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Show, createEffect, createMemo, createResource, createSignal, onCleanup
|
||||
import { authClient, fetchApiToken } from "./lib/auth-client";
|
||||
import { configureApiToken, errorMessage } from "./lib/api";
|
||||
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
|
||||
import { applySlugField } from "./lib/slug";
|
||||
import {
|
||||
AgentSetupModal,
|
||||
AppShell,
|
||||
@@ -735,11 +736,22 @@ export default function App() {
|
||||
await runPending("auth-submit", async () => {
|
||||
setAuthMessage("");
|
||||
try {
|
||||
// better-auth client resolves { data, error } instead of throwing on
|
||||
// HTTP failures — surface res.error or the UI claims success while
|
||||
// nothing was created.
|
||||
if (mode() === "sign-up") {
|
||||
await authClient.signUp.email({ email: email(), password: password(), name: name() });
|
||||
const res = await authClient.signUp.email({ email: email(), password: password(), name: name() });
|
||||
if (res.error) {
|
||||
setAuthMessage(res.error.message ?? "Sign-up failed");
|
||||
return;
|
||||
}
|
||||
setAuthMessage("Account created — signing you in.");
|
||||
} else {
|
||||
await authClient.signIn.email({ email: email(), password: password() });
|
||||
const res = await authClient.signIn.email({ email: email(), password: password() });
|
||||
if (res.error) {
|
||||
setAuthMessage(res.error.message ?? "Sign-in failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setAuthMessage(getErrorMessage(error, "Authentication failed"));
|
||||
@@ -1625,7 +1637,7 @@ export default function App() {
|
||||
<PwaInstallBanner />
|
||||
|
||||
<Show
|
||||
when={platform() || isDemo}
|
||||
when={platform()}
|
||||
fallback={
|
||||
<div class="page">
|
||||
<div class="skeleton" style="height:2rem;width:12rem;border-radius:8px" />
|
||||
@@ -1644,6 +1656,14 @@ export default function App() {
|
||||
when={activeProject()}
|
||||
fallback={
|
||||
<div class="page">
|
||||
<Show
|
||||
when={!organizationProjects.loading}
|
||||
fallback={
|
||||
<div class="card" style="min-height:20rem">
|
||||
<div class="skeleton" style="height:100%;min-height:20rem;border-radius:12px" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={(platform()?.organizations ?? []).length > 0}
|
||||
fallback={
|
||||
@@ -1659,14 +1679,14 @@ export default function App() {
|
||||
label="Organization name"
|
||||
placeholder="Acme Corporation"
|
||||
value={organizationInput().name}
|
||||
onInput={(e) => setOrganizationInput((c) => ({ ...c, name: e.currentTarget.value }))}
|
||||
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "name", e.currentTarget.value))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Slug"
|
||||
placeholder="acme"
|
||||
value={organizationInput().slug}
|
||||
onInput={(e) => setOrganizationInput((c) => ({ ...c, slug: e.currentTarget.value }))}
|
||||
onInput={(e) => setOrganizationInput((c) => applySlugField(c, "slug", e.currentTarget.value))}
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary w-full" disabled={workspacePending()}>
|
||||
@@ -1690,6 +1710,7 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1712,7 +1733,7 @@ export default function App() {
|
||||
projectMessage={projectMessage()}
|
||||
projectPending={projectPending()}
|
||||
canUpdateProject={canUpdateProject()}
|
||||
onProjectInputChange={(field, value) => setProjectInput((c) => ({ ...c, [field]: value }))}
|
||||
onProjectInputChange={(field, value) => setProjectInput((c) => applySlugField(c, field, value))}
|
||||
onProjectEditInputChange={(field, value) => setProjectEditInput((c) => ({ ...c, [field]: value }))}
|
||||
onCreateProject={createProject}
|
||||
onUpdateProject={updateActiveProject}
|
||||
@@ -1811,7 +1832,7 @@ export default function App() {
|
||||
previewLoading={objectPreview.loading}
|
||||
renameObjectKey={renameObjectKeyInput()}
|
||||
moveDestinationBucketID={moveDestinationBucketID()}
|
||||
onBucketInputChange={(field, value) => setBucketInput((c) => ({ ...c, [field]: value }))}
|
||||
onBucketInputChange={(field, value) => setBucketInput((c) => applySlugField(c, field, value))}
|
||||
onBucketEditInputChange={(field, value) => setBucketEditInput((c) => ({ ...c, [field]: value }))}
|
||||
onCreateBucket={createBucket}
|
||||
onUpdateBucket={updateSelectedBucket}
|
||||
@@ -1848,7 +1869,7 @@ export default function App() {
|
||||
collectionPending={collectionPending()}
|
||||
documentPending={documentPending()}
|
||||
canUpdate={canUpdateProject()}
|
||||
onCollectionInputChange={(field, value) => setCollectionInput((c) => ({ ...c, [field]: value }))}
|
||||
onCollectionInputChange={(field, value) => setCollectionInput((c) => applySlugField(c, field, value))}
|
||||
onCreateCollection={createCollection}
|
||||
onDeleteCollection={deleteCollection}
|
||||
onSelectCollection={setSelectedCollectionID}
|
||||
|
||||
@@ -1096,6 +1096,8 @@ code {
|
||||
/* ---------- page scaffold ---------- */
|
||||
.page {
|
||||
max-width: 72rem;
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -28,6 +28,8 @@ describe("buildAgentPrompt", () => {
|
||||
expect(prompt).toContain("/db-connections");
|
||||
expect(prompt).toContain("deploy-markers");
|
||||
expect(prompt).toContain("PRIMORA_PROJECT");
|
||||
expect(prompt).toContain(`/logins/${base.projectSlug}`);
|
||||
expect(prompt).toContain("do not write it into `.env`");
|
||||
for (const t of ["postgres", "mysql", "mongodb", "redis"]) {
|
||||
expect(prompt).toContain(t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applySlugField, slugify } from "../slug";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("derives lowercase hyphenated slugs", () => {
|
||||
expect(slugify("TDvorak")).toBe("tdvorak");
|
||||
expect(slugify("Acme Corporation")).toBe("acme-corporation");
|
||||
expect(slugify(" My_App!! v2 ")).toBe("my-app-v2");
|
||||
});
|
||||
|
||||
it("strips accents and non-ascii characters", () => {
|
||||
expect(slugify("Zürich — Héllo")).toBe("zurich-hello");
|
||||
expect(slugify("日本語")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySlugField", () => {
|
||||
const form = { name: "", slug: "", description: "" };
|
||||
|
||||
it("fills the slug while it is untouched", () => {
|
||||
const next = applySlugField(form, "name", "My Org");
|
||||
expect(next.slug).toBe("my-org");
|
||||
// keeps following name edits
|
||||
expect(applySlugField(next, "name", "Renamed Org").slug).toBe("renamed-org");
|
||||
});
|
||||
|
||||
it("stops deriving once the slug was hand-edited", () => {
|
||||
const custom = applySlugField(applySlugField(form, "name", "My Org"), "slug", "custom-slug");
|
||||
expect(applySlugField(custom, "name", "New Name").slug).toBe("custom-slug");
|
||||
});
|
||||
|
||||
it("resumes deriving if the slug is cleared", () => {
|
||||
const cleared = applySlugField(applySlugField(form, "name", "My Org"), "slug", "");
|
||||
expect(applySlugField(cleared, "name", "Other").slug).toBe("other");
|
||||
});
|
||||
|
||||
it("sanitizes manual slug edits", () => {
|
||||
expect(applySlugField(form, "slug", "Not A Slug!").slug).toBe("not-a-slug");
|
||||
});
|
||||
|
||||
it("passes through unrelated fields", () => {
|
||||
expect(applySlugField(form, "description", "hi").description).toBe("hi");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'solid-js/web';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { applySlugField } from '../slug';
|
||||
import { Input } from '../../components/Input';
|
||||
|
||||
describe('slug auto-derive in DOM', () => {
|
||||
it('typing name fills the slug input', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const [input, setInput] = createSignal({ name: '', slug: '' });
|
||||
const dispose = render(() => (
|
||||
<form>
|
||||
<Input label="Name" value={input().name}
|
||||
onInput={(e) => setInput((c) => applySlugField(c, 'name', e.currentTarget.value))} />
|
||||
<Input label="Slug" value={input().slug}
|
||||
onInput={(e) => setInput((c) => applySlugField(c, 'slug', e.currentTarget.value))} />
|
||||
</form>
|
||||
), host);
|
||||
const [name, slug] = Array.from(host.querySelectorAll('input'));
|
||||
name.value = 'Acme Corp';
|
||||
name.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(slug.value).toBe('acme-corp');
|
||||
dispose();
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,28 @@ ${mcpSection}
|
||||
|
||||
Optional CLI path: \`primora login --api-key $PRIMORA_API_KEY\`, then \`primora use\`. For wrapping third-party agents with brokered credentials (the agent never holds real keys), use \`primora agent --<provider> <vault-secret> -- <cmd>\`.
|
||||
|
||||
## 4. Prove it works
|
||||
## 4. Logins — accounts you create or sign in with
|
||||
|
||||
When you create a test account for this app, or you need a username and password for any site, store it. Do not paste the password into the chat and do not write it into \`.env\`.
|
||||
|
||||
The app id is stable. Use \`${input.projectSlug}\` for this project. A port is not an identity — \`localhost:3000\` can serve a different app later. For any other site, use a short slug (\`github\`, \`stripe-dashboard\`). \`origin\` is only the current URL.
|
||||
|
||||
\`\`\`
|
||||
PUT ${api}/projects/${p}/logins/${input.projectSlug}
|
||||
X-Primora-Key: $PRIMORA_API_KEY
|
||||
|
||||
{"username":"…","password":"…","origin":"http://localhost:3000"}
|
||||
\`\`\`
|
||||
|
||||
Read it back only when you are about to sign in. The call is audit-logged:
|
||||
|
||||
\`\`\`
|
||||
POST ${api}/projects/${p}/logins/${input.projectSlug}/reveal
|
||||
\`\`\`
|
||||
|
||||
The same tools cover the local vault on this machine once \`primora vault unlock\` has been run: \`primora logins set ${input.projectSlug} --username …\` (add \`--remote\` for the project vault). MCP: \`primora_logins_set\`, \`primora_logins_get\`, \`primora_secrets_get\`, \`primora_vault_get\`. Lists never include the password.
|
||||
|
||||
## 5. Prove it works
|
||||
|
||||
1. \`curl -sS -X POST ${api}/ingest -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"events":[{"type":"event","component":"setup","message":"primora.setup.done"}]}'\` — expect \`{"accepted":1}\`.
|
||||
2. Record a deploy marker: \`curl -sS -X POST ${api}/projects/${p}/deploy-markers -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"ref":"<git sha>","environment":"dev","note":"agent setup"}'\`.
|
||||
|
||||
@@ -216,7 +216,7 @@ let apiKeys: ApiKey[] = [
|
||||
id: "key-1",
|
||||
project_id: "proj-1",
|
||||
name: "Production key",
|
||||
prefix: "pk_live_7f2a",
|
||||
prefix: "prm_7f2a9c",
|
||||
last_used_at: ago(0.2),
|
||||
revoked_at: null,
|
||||
},
|
||||
@@ -224,7 +224,7 @@ let apiKeys: ApiKey[] = [
|
||||
id: "key-2",
|
||||
project_id: "proj-1",
|
||||
name: "CI deploy",
|
||||
prefix: "pk_test_9c41",
|
||||
prefix: "prm_9c41de",
|
||||
last_used_at: ago(2),
|
||||
revoked_at: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a field edit to a {name, slug, ...} form. While the slug is
|
||||
* untouched — empty or still equal to the derived value — renaming the
|
||||
* entity re-derives its slug. Explicit slug edits are sanitized in place.
|
||||
*/
|
||||
export function applySlugField<T extends { name: string; slug: string }>(
|
||||
current: T,
|
||||
field: string,
|
||||
value: string,
|
||||
): T {
|
||||
if (field === "slug") return { ...current, slug: slugify(value) };
|
||||
if (field === "name" && (current.slug === "" || current.slug === slugify(current.name))) {
|
||||
return { ...current, name: value, slug: slugify(value) };
|
||||
}
|
||||
return { ...current, [field]: value };
|
||||
}
|
||||
@@ -3,4 +3,17 @@ import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
// Reload once when an updated service worker takes control. Without this the
|
||||
// precached shell keeps serving the previous release until a manual refresh.
|
||||
// Skipped when the page has no controller yet (first SW install) so first-time
|
||||
// visitors don't get an extra reload.
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
|
||||
let refreshing = false;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
render(() => <App />, document.getElementById("root")!);
|
||||
@@ -253,7 +253,13 @@ export function CollectionsPage(props: CollectionsPageProps) {
|
||||
title="Create collection"
|
||||
description="Collections store JSON documents scoped to a project."
|
||||
>
|
||||
<form onSubmit={props.onCreateCollection} class="space-y-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
props.onCreateCollection(e);
|
||||
setCreateOpen(false);
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<Input
|
||||
label="Collection name"
|
||||
placeholder="Posts"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import {
|
||||
SecretsService,
|
||||
type ProjectLogin,
|
||||
type ProjectSecret,
|
||||
} from "@primora/api-client";
|
||||
import { Modal } from "../components/Modal";
|
||||
@@ -22,6 +23,12 @@ const DEMO_SECRETS: ProjectSecret[] = [
|
||||
{ id: "d2", name: "SLACK_WEBHOOK", url: "https://hooks.slack.com", notes: "alerts channel", created_at: "", updated_at: "2026-09-10T08:00:00Z" },
|
||||
];
|
||||
|
||||
const DEMO_LOGINS: ProjectLogin[] = [
|
||||
{ id: "l1", app: "primora", username: "ada", origin: "http://localhost:3000", notes: "test account", created_at: "", updated_at: "2026-09-18T10:00:00Z" },
|
||||
];
|
||||
|
||||
const APP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
|
||||
// Inside the Tauri shell, __TAURI__ is injected into every webview — the same
|
||||
// IPC commands the dedicated vault window uses. Absent in a plain browser.
|
||||
const tauri = () =>
|
||||
@@ -35,6 +42,13 @@ interface LocalSecret {
|
||||
notes: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
interface LocalLogin {
|
||||
app: string;
|
||||
username: string;
|
||||
origin: string;
|
||||
notes: string;
|
||||
updated_at: string;
|
||||
}
|
||||
interface LocalStatus {
|
||||
exists: boolean;
|
||||
unlocked: boolean;
|
||||
@@ -45,16 +59,20 @@ interface LocalStatus {
|
||||
|
||||
export function VaultPage(props: VaultPageProps) {
|
||||
const [secrets, setSecrets] = createSignal<ProjectSecret[]>([]);
|
||||
const [logins, setLogins] = createSignal<ProjectLogin[]>([]);
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [error, setError] = createSignal("");
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [showLogin, setShowLogin] = createSignal(false);
|
||||
const [showImport, setShowImport] = createSignal(false);
|
||||
const [form, setForm] = createSignal({ name: "", value: "", url: "", notes: "" });
|
||||
const [loginForm, setLoginForm] = createSignal({ app: "", username: "", password: "", origin: "", notes: "" });
|
||||
const [importText, setImportText] = createSignal("");
|
||||
|
||||
const [localStatus, setLocalStatus] = createSignal<LocalStatus>();
|
||||
const [localSecrets, setLocalSecrets] = createSignal<LocalSecret[]>([]);
|
||||
const [localLogins, setLocalLogins] = createSignal<LocalLogin[]>([]);
|
||||
const [localErr, setLocalErr] = createSignal("");
|
||||
const [localPw, setLocalPw] = createSignal("");
|
||||
const [localTtl, setLocalTtl] = createSignal("900");
|
||||
@@ -62,15 +80,30 @@ export function VaultPage(props: VaultPageProps) {
|
||||
const timer = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
|
||||
onCleanup(() => clearInterval(timer));
|
||||
|
||||
// Demo data is in-memory. Seed once per project so a later refresh keeps
|
||||
// rows the user added or deleted, including an empty list.
|
||||
let demoProject = "";
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
if (props.demoMode) {
|
||||
if (demoProject !== props.projectID) {
|
||||
const switched = demoProject !== "";
|
||||
demoProject = props.projectID;
|
||||
setSecrets(DEMO_SECRETS);
|
||||
setLogins(DEMO_LOGINS);
|
||||
if (switched) {
|
||||
setMessage("");
|
||||
setError("");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await SecretsService.listProjectSecrets({ projectId: props.projectID });
|
||||
setSecrets(res.items);
|
||||
const log = await SecretsService.listProjectLogins({ projectId: props.projectID });
|
||||
setLogins(log.items);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
@@ -83,6 +116,7 @@ export function VaultPage(props: VaultPageProps) {
|
||||
const st = (await t.core.invoke("vault_status")) as LocalStatus;
|
||||
setLocalStatus(st);
|
||||
setLocalSecrets(st.unlocked ? ((await t.core.invoke("vault_secrets")) as LocalSecret[]) : []);
|
||||
setLocalLogins(st.unlocked ? ((await t.core.invoke("vault_logins")) as LocalLogin[]) : []);
|
||||
} catch (e) {
|
||||
setLocalErr(err(e));
|
||||
}
|
||||
@@ -163,6 +197,53 @@ export function VaultPage(props: VaultPageProps) {
|
||||
}, `Secret ${f.name} stored`);
|
||||
};
|
||||
|
||||
const revealLogin = (app: string) =>
|
||||
run(`reveal-login-${app}`, async () => {
|
||||
if (props.demoMode) {
|
||||
await copy("demo-password", app);
|
||||
return;
|
||||
}
|
||||
const res = await SecretsService.revealProjectLogin({ projectId: props.projectID!, app });
|
||||
await copy(res.password, app);
|
||||
});
|
||||
|
||||
const createLogin = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const f = loginForm();
|
||||
const app = f.app.trim();
|
||||
if (!APP_RE.test(app)) {
|
||||
setError("App id must be a stable name like my-app - not a URL or a port.");
|
||||
return;
|
||||
}
|
||||
void run("create-login", async () => {
|
||||
if (props.demoMode) {
|
||||
setLogins([...logins(), { id: `l${Date.now()}`, app, username: f.username.trim(), origin: f.origin.trim(), notes: f.notes.trim(), created_at: "", updated_at: new Date().toISOString() }]);
|
||||
} else {
|
||||
await SecretsService.setProjectLogin({
|
||||
projectId: props.projectID!,
|
||||
app,
|
||||
requestBody: {
|
||||
username: f.username.trim(),
|
||||
password: f.password,
|
||||
origin: f.origin.trim() || undefined,
|
||||
notes: f.notes.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
setShowLogin(false);
|
||||
setLoginForm({ app: "", username: "", password: "", origin: "", notes: "" });
|
||||
}, `Login ${app} stored`);
|
||||
};
|
||||
|
||||
const removeLogin = (app: string) =>
|
||||
run(`del-login-${app}`, async () => {
|
||||
if (props.demoMode) {
|
||||
setLogins(logins().filter((s) => s.app !== app));
|
||||
return;
|
||||
}
|
||||
await SecretsService.deleteProjectLogin({ projectId: props.projectID!, app });
|
||||
}, `Login ${app} deleted`);
|
||||
|
||||
const removeSecret = (name: string) =>
|
||||
run(`del-${name}`, async () => {
|
||||
if (props.demoMode) {
|
||||
@@ -207,13 +288,16 @@ export function VaultPage(props: VaultPageProps) {
|
||||
<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.
|
||||
Encrypted project secrets and app logins. Secrets are referenced in job payloads as <code>secret://NAME</code>.
|
||||
A login is a username and password for a stable app id - the URL can change. Lists never include passwords.
|
||||
</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-ghost btn-sm" onClick={() => setShowLogin(true)}>
|
||||
<IconPlus class="w-4 h-4" /> New login
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
|
||||
<IconPlus class="w-4 h-4" /> New secret
|
||||
</button>
|
||||
@@ -280,6 +364,61 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="card card-flush">
|
||||
<div class="card-header">
|
||||
<div class="flex-1">
|
||||
<div class="card-header-title">App logins</div>
|
||||
<div class="card-header-description">
|
||||
Username and password for an app. The app id stays put when the port changes. Copy is audit-logged.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={logins().length > 0}
|
||||
fallback={<p class="text-text-2 text-sm p-4">No logins yet.</p>}
|
||||
>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th>Username</th>
|
||||
<th>Origin</th>
|
||||
<th>Notes</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={logins()}>
|
||||
{(s) => (
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{s.app}</td>
|
||||
<td class="text-sm">{s.username}</td>
|
||||
<td class="text-sm">{s.origin ? <a href={s.origin} target="_blank" rel="noreferrer">{s.origin.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 password (audit-logged)" onClick={() => void revealLogin(s.app)} disabled={busy() === `reveal-login-${s.app}`}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={props.canManage}>
|
||||
<button class="btn btn-ghost btn-sm" title="Delete" onClick={() => void removeLogin(s.app)} disabled={busy() === `del-login-${s.app}`}>
|
||||
<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">
|
||||
@@ -366,6 +505,40 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={localLogins().length > 0}>
|
||||
<p class="text-xs text-text-2 mt-4 mb-2">Logins on this machine</p>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>App</th><th>Username</th><th>Origin</th><th class="text-right">Actions</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={localLogins()}>
|
||||
{(s) => (
|
||||
<tr>
|
||||
<td class="font-mono text-sm">{s.app}</td>
|
||||
<td class="text-sm">{s.username}</td>
|
||||
<td class="text-sm">{s.origin || "—"}</td>
|
||||
<td>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button class="btn btn-ghost btn-sm" title="Copy password" onClick={() => void runLocal(async () => {
|
||||
const row = (await tauri()!.core.invoke("vault_login_reveal", { app: s.app })) as { password: string };
|
||||
await copy(row.password, s.app);
|
||||
})}>
|
||||
<IconCopy class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" title="Delete" onClick={() => void runLocal(() => tauri()!.core.invoke("vault_login_rm", { app: s.app }))}>
|
||||
<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>.
|
||||
@@ -390,6 +563,20 @@ export function VaultPage(props: VaultPageProps) {
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal error={error()} open={showLogin()} onClose={() => setShowLogin(false)} title="New login" size="md">
|
||||
<form onSubmit={createLogin} class="space-y-4">
|
||||
<Input label="App" placeholder="my-app" value={loginForm().app} onInput={(e) => setLoginForm({ ...loginForm(), app: e.currentTarget.value })} required />
|
||||
<Input label="Username" placeholder="ada" value={loginForm().username} onInput={(e) => setLoginForm({ ...loginForm(), username: e.currentTarget.value })} required />
|
||||
<Input label="Password" type="password" value={loginForm().password} onInput={(e) => setLoginForm({ ...loginForm(), password: e.currentTarget.value })} required />
|
||||
<Input label="Origin" placeholder="http://localhost:3000 (optional, not the identity)" value={loginForm().origin} onInput={(e) => setLoginForm({ ...loginForm(), origin: e.currentTarget.value })} />
|
||||
<Input label="Notes" placeholder="optional" value={loginForm().notes} onInput={(e) => setLoginForm({ ...loginForm(), notes: e.currentTarget.value })} />
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost" onClick={() => setShowLogin(false)}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create-login"}>Store login</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal error={error()} 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" />
|
||||
|
||||
+14
-13
@@ -22,17 +22,18 @@ collections, API keys, integrations, webhooks, scheduled jobs, telemetry,
|
||||
and audit: `primora_whoami`, `primora_list_projects`,
|
||||
`primora_upload_object`, `primora_query_audit`, `primora_run_job`, …
|
||||
|
||||
Secrets are agent-safe by design — values are never returned by any tool:
|
||||
Secrets and logins are encrypted at rest. An agent reads one named value
|
||||
when it needs it, and does not paste that value into chat or a `.env` file.
|
||||
Lists omit passwords. Project reveals are audit-logged.
|
||||
|
||||
- `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. A strict allow-list (read-mostly commands like
|
||||
`whoami`, `context`, `orgs:list`, `buckets:list`, `jobs:list`,
|
||||
`audit:list`, `events:send`) — nothing that reads, writes, or moves a
|
||||
credential can run; file/path flags are rejected and the injected env
|
||||
name must be a safe identifier (loader hooks like `NODE_OPTIONS` are
|
||||
blocked).
|
||||
- `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.
|
||||
- `primora_vault_status` / `primora_vault_list` / `primora_vault_get` —
|
||||
local vault. Get returns one secret. Requires `primora vault unlock`.
|
||||
- `primora_vault_exec` — runs an allow-listed `primora` command with a
|
||||
vault secret in the child env (the secret is not in the command output).
|
||||
File/path flags are rejected. Loader hooks like `NODE_OPTIONS` are blocked.
|
||||
- `primora_secrets_list` / `primora_secrets_set` / `primora_secrets_get` —
|
||||
project vault. Get reveals one secret and is audit-logged. Job payloads
|
||||
can still reference `secret://NAME`.
|
||||
- `primora_logins_list` / `primora_logins_set` / `primora_logins_get` —
|
||||
username and password for a stable app id, local or `remote: true` for
|
||||
the project. The app id is not a host or a port.
|
||||
+133
-9
@@ -949,13 +949,12 @@ server.registerTool(
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* local vault — metadata only, values never leave the CLI */
|
||||
/* local vault — list is metadata; get returns one named value */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
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. */
|
||||
/** Run the primora CLI; returns {code, stdout, stderr}. */
|
||||
function runCli(args: string[], env?: Record<string, string>) {
|
||||
const res = spawnSync(PRIMORA_CLI, args, {
|
||||
env: { ...process.env, ...env },
|
||||
@@ -986,11 +985,29 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_vault_get",
|
||||
{
|
||||
description:
|
||||
"Read one local-vault secret by name. Requires `primora vault unlock` on the host. Use the value here — do not paste it into chat or write it to a .env file.",
|
||||
inputSchema: { name: z.string().describe("Secret name") },
|
||||
},
|
||||
async ({ name }) => {
|
||||
try {
|
||||
const r = runCli(["secrets", "get", name, "--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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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.",
|
||||
"List local-vault secrets — names, urls, notes, timestamps only. Read one value with primora_vault_get.",
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
@@ -1088,14 +1105,32 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// 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.
|
||||
// Project vault — encrypted at rest. List is metadata. Get returns one value
|
||||
// and is audit-logged. Job payloads can still use secret://NAME.
|
||||
server.registerTool(
|
||||
"primora_secrets_get",
|
||||
{
|
||||
description:
|
||||
"Read one project-vault secret by name. The server audit-logs the reveal. Do not paste the value into chat or write it to a .env file.",
|
||||
inputSchema: {
|
||||
name: z.string().describe("Secret name"),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ name, projectId: p }) => {
|
||||
try {
|
||||
return ok(await SecretsService.revealProjectSecret({ projectId: await projectId(p), name }));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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.",
|
||||
"List project vault secrets — names, urls, notes, timestamps only. Read one value with primora_secrets_get. Job payloads can reference a secret as secret://NAME.",
|
||||
inputSchema: { projectId: z.string().optional() },
|
||||
},
|
||||
async ({ projectId: p }) => {
|
||||
@@ -1111,7 +1146,7 @@ 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.",
|
||||
"Store a secret in the project vault (AES-256-GCM at rest). Read it back with primora_secrets_get. 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"),
|
||||
@@ -1136,4 +1171,93 @@ server.registerTool(
|
||||
},
|
||||
);
|
||||
|
||||
// Logins: username + password bound to a stable app id (not a host:port).
|
||||
// The same record can live in the local vault or the project vault.
|
||||
server.registerTool(
|
||||
"primora_logins_list",
|
||||
{
|
||||
description:
|
||||
"List app logins (app id, username, origin). Passwords are omitted — read one with primora_logins_get. remote=true uses the project vault; otherwise the local vault (needs `primora vault unlock`).",
|
||||
inputSchema: {
|
||||
remote: z.boolean().optional().describe("true = project vault on the server"),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ remote, projectId: p }) => {
|
||||
try {
|
||||
if (remote) {
|
||||
return ok(await SecretsService.listProjectLogins({ projectId: await projectId(p) }));
|
||||
}
|
||||
const r = runCli(["logins", "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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_logins_get",
|
||||
{
|
||||
description:
|
||||
"Read one app login (username, password, origin). Use it to sign in. Do not paste it into chat or write it to a .env file. Project-vault reads are audit-logged. remote=true uses the project vault.",
|
||||
inputSchema: {
|
||||
app: z.string().describe("Stable app id, e.g. my-app or github"),
|
||||
remote: z.boolean().optional(),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ app, remote, projectId: p }) => {
|
||||
try {
|
||||
if (remote) {
|
||||
return ok(await SecretsService.revealProjectLogin({ projectId: await projectId(p), app }));
|
||||
}
|
||||
const r = runCli(["logins", "get", app, "--json"]);
|
||||
if (r.code !== 0) throw new Error(r.stderr.trim() || "vault locked or login missing");
|
||||
return ok(JSON.parse(r.stdout));
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"primora_logins_set",
|
||||
{
|
||||
description:
|
||||
"Store a username and password for an app. app is a stable id, not a port. origin is the current URL and may change later. remote=true stores it in the project vault.",
|
||||
inputSchema: {
|
||||
app: z.string(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
origin: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
remote: z.boolean().optional(),
|
||||
projectId: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ app, username, password, origin, notes, remote, projectId: p }) => {
|
||||
try {
|
||||
if (remote) {
|
||||
const res = await SecretsService.setProjectLogin({
|
||||
projectId: await projectId(p),
|
||||
app,
|
||||
requestBody: { username, password, origin, notes },
|
||||
});
|
||||
const { id: _, ...meta } = res;
|
||||
return ok(meta);
|
||||
}
|
||||
const args = ["logins", "set", app, "--username", username, "--password", password];
|
||||
if (origin !== undefined) args.push("--origin", origin);
|
||||
if (notes !== undefined) args.push("--notes", notes);
|
||||
const r = runCli(args);
|
||||
if (r.code !== 0) throw new Error(r.stderr.trim() || "failed to store login");
|
||||
return ok({ app, username, origin: origin ?? "", stored: "local" });
|
||||
} catch (e) {
|
||||
return fail(e);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
+77
-16
@@ -3,8 +3,14 @@
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dvorinka/Primora/master/install.sh | bash
|
||||
#
|
||||
# Installs into ./primora (override with PRIMORA_DIR). Non-interactive:
|
||||
# DOMAIN=example.com NGINX_PORT=8080 bash install.sh
|
||||
# Installs into ./primora (override with PRIMORA_DIR). On a fresh install,
|
||||
# interactive shells get one question — the URL the app will be opened on
|
||||
# (domain or IP, optional port). Non-interactive:
|
||||
# PUBLIC_URL=https://primora.example.com bash install.sh
|
||||
# DOMAIN=192.168.1.50 NGINX_PORT=8085 bash install.sh (legacy form)
|
||||
#
|
||||
# https URLs mean TLS terminates at your own proxy in front of Primora's
|
||||
# bundled nginx (which always speaks http on NGINX_PORT).
|
||||
#
|
||||
# Pulls prebuilt GHCR images when available; otherwise downloads the source
|
||||
# tarball and builds locally. Either way the result is the same compose stack.
|
||||
@@ -14,8 +20,8 @@ set -euo pipefail
|
||||
REPO="Dvorinka/Primora"
|
||||
REF="${PRIMORA_REF:-master}"
|
||||
DIR="${PRIMORA_DIR:-primora}"
|
||||
DOMAIN="${DOMAIN:-localhost}"
|
||||
NGINX_PORT="${NGINX_PORT:-80}"
|
||||
PUBLIC_URL="${PUBLIC_URL:-}"
|
||||
|
||||
info() { echo "==> $*"; }
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
@@ -32,12 +38,62 @@ mkdir -p "$DIR/infra/nginx"
|
||||
cd "$DIR"
|
||||
|
||||
# The compose stack needs exactly these three files from the repo.
|
||||
# Timeouts + retries: a stalled connection must fail loudly, not hang.
|
||||
info "Fetching compose files ($REF)"
|
||||
for f in docker-compose.yml .env.example infra/nginx/default.conf; do
|
||||
curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f"
|
||||
curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-all-errors \
|
||||
"https://raw.githubusercontent.com/$REPO/$REF/$f" -o "$f" \
|
||||
|| die "could not fetch $f — check connectivity to raw.githubusercontent.com"
|
||||
done
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
if [ -f .env ]; then
|
||||
info "Existing .env kept"
|
||||
# Display URL comes from the file — answers here would change nothing.
|
||||
PUBLIC_URL="$(grep -E '^VITE_APP_URL=' .env | tail -1 | cut -d= -f2- || true)"
|
||||
[ -z "$PUBLIC_URL" ] && PUBLIC_URL="http://localhost"
|
||||
else
|
||||
# Public URL — the single question. Env wins, then prompt, then default.
|
||||
if [ -z "$PUBLIC_URL" ] && [ -n "${DOMAIN:-}" ]; then
|
||||
PUBLIC_URL="http://${DOMAIN}"
|
||||
[ "$NGINX_PORT" != "80" ] && PUBLIC_URL="$PUBLIC_URL:$NGINX_PORT"
|
||||
fi
|
||||
if [ -z "$PUBLIC_URL" ]; then
|
||||
DEFAULT_URL="http://localhost"
|
||||
[ "$NGINX_PORT" != "80" ] && DEFAULT_URL="http://localhost:$NGINX_PORT"
|
||||
# Under `curl | bash` stdin is the script pipe — ask on the controlling
|
||||
# terminal instead. `true </dev/tty` probes the open quietly; read's own
|
||||
# redirect fails non-fatally (default applies) where there is no tty.
|
||||
# Never `exec ... 2>/dev/null` here — exec redirections persist and would
|
||||
# silence stderr, including the read prompt below, for the whole script.
|
||||
if true 2>/dev/null </dev/tty; then
|
||||
read -rp "URL where you'll open Primora (domain or IP, e.g. https://primora.example.com) [$DEFAULT_URL]: " PUBLIC_URL </dev/tty || true
|
||||
fi
|
||||
PUBLIC_URL="${PUBLIC_URL:-$DEFAULT_URL}"
|
||||
fi
|
||||
|
||||
# Normalize: default scheme http, strip trailing slash.
|
||||
case "$PUBLIC_URL" in
|
||||
http://*|https://*) ;;
|
||||
*) PUBLIC_URL="http://$PUBLIC_URL" ;;
|
||||
esac
|
||||
PUBLIC_URL="${PUBLIC_URL%/}"
|
||||
|
||||
# Split host[:port]; URL paths aren't supported — nginx mounts auth at /auth.
|
||||
REST="${PUBLIC_URL#*://}"
|
||||
HOSTPORT="${REST%%/*}"
|
||||
[ "$REST" != "$HOSTPORT" ] && die "URL paths are not supported — use a domain or port instead: $PUBLIC_URL"
|
||||
if [[ "$HOSTPORT" == *:* ]]; then
|
||||
HOST="${HOSTPORT%%:*}"
|
||||
PORT="${HOSTPORT##*:}"
|
||||
else
|
||||
HOST="$HOSTPORT"; PORT=""
|
||||
fi
|
||||
[ -z "$HOST" ] && die "could not parse a host from: $PUBLIC_URL"
|
||||
if [ -n "$PORT" ]; then
|
||||
[[ "$PORT" =~ ^[0-9]+$ ]] || die "invalid port in: $PUBLIC_URL"
|
||||
NGINX_PORT="$PORT"
|
||||
fi
|
||||
|
||||
info "Generating .env with random secrets"
|
||||
cp .env.example .env
|
||||
|
||||
@@ -53,12 +109,15 @@ if [ ! -f .env ]; then
|
||||
"${SED[@]}" "s|^PRIMORA_ENCRYPTION_KEY=.*|PRIMORA_ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env
|
||||
"${SED[@]}" "s|^NGINX_PORT=.*|NGINX_PORT=$NGINX_PORT|" .env
|
||||
|
||||
if [ "$DOMAIN" != "localhost" ]; then
|
||||
"${SED[@]}" "s|http://localhost|http://$DOMAIN|g" .env
|
||||
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$DOMAIN|" .env
|
||||
# Every public URL var (VITE_APP_URL, BETTER_AUTH_URL, AUTH_BASE_URL,
|
||||
# VITE_AUTH_BASE_URL, VITE_API_BASE_URL, BACKEND_PUBLIC_URL) is built on the
|
||||
# http://localhost prefix in .env.example — one rewrite covers them all.
|
||||
"${SED[@]}" "s|http://localhost|$PUBLIC_URL|g" .env
|
||||
|
||||
# Cookie domains only apply to real DNS names — never IPs or localhost.
|
||||
if [ "$HOST" != "localhost" ] && ! [[ "$HOST" =~ ^[0-9.]+$ ]]; then
|
||||
"${SED[@]}" "s|^COOKIE_DOMAIN=.*|COOKIE_DOMAIN=$HOST|" .env
|
||||
fi
|
||||
else
|
||||
info "Existing .env kept"
|
||||
fi
|
||||
|
||||
# Compose v5 skips `pull` for services that declare build:, so fetch the
|
||||
@@ -75,15 +134,17 @@ if docker pull "ghcr.io/$GHCR_OWNER/primora-backend:latest" \
|
||||
else
|
||||
# GHCR packages are private until first published release — build from source.
|
||||
info "Prebuilt images unavailable; building from source"
|
||||
curl -fsSL "https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
|
||||
curl -fsSL --connect-timeout 10 --max-time 120 --retry 3 --retry-delay 1 --retry-all-errors \
|
||||
"https://codeload.github.com/$REPO/tar.gz/refs/heads/$REF" | tar xz --strip-components=1
|
||||
docker compose up -d --build
|
||||
fi
|
||||
|
||||
DISPLAY_URL="http://$DOMAIN"
|
||||
[ "$NGINX_PORT" != "80" ] && DISPLAY_URL="$DISPLAY_URL:$NGINX_PORT"
|
||||
|
||||
info "Primora is starting"
|
||||
echo " Dashboard: $DISPLAY_URL"
|
||||
echo " Health: $DISPLAY_URL/api/v1/health/liveness"
|
||||
echo " Dashboard: $PUBLIC_URL"
|
||||
echo " Health: $PUBLIC_URL/api/v1/health/liveness"
|
||||
echo " Logs: cd $DIR && docker compose logs -f"
|
||||
echo " Stop: cd $DIR && docker compose down"
|
||||
if [[ "$PUBLIC_URL" == https://* ]]; then
|
||||
echo
|
||||
echo " Primora listens on http port $NGINX_PORT — point your TLS proxy there."
|
||||
fi
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "primora",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.5",
|
||||
"private": true,
|
||||
"packageManager": "[email protected]",
|
||||
"workspaces": [
|
||||
|
||||
@@ -98,6 +98,7 @@ export type { Invitation } from './models/Invitation';
|
||||
export type { InvitationID } from './models/InvitationID';
|
||||
export type { InvokeFunctionRequest } from './models/InvokeFunctionRequest';
|
||||
export type { JobID } from './models/JobID';
|
||||
export type { LoginApp } from './models/LoginApp';
|
||||
export type { MeResponse } from './models/MeResponse';
|
||||
export type { MetricPoint } from './models/MetricPoint';
|
||||
export type { MetricSeriesResponse } from './models/MetricSeriesResponse';
|
||||
@@ -114,6 +115,8 @@ export { PresignedURL } from './models/PresignedURL';
|
||||
export { PresignObjectRequest } from './models/PresignObjectRequest';
|
||||
export type { Project } from './models/Project';
|
||||
export type { ProjectID } from './models/ProjectID';
|
||||
export type { ProjectLogin } from './models/ProjectLogin';
|
||||
export type { ProjectLoginListResponse } from './models/ProjectLoginListResponse';
|
||||
export { ProjectMember } from './models/ProjectMember';
|
||||
export type { ProjectOverview } from './models/ProjectOverview';
|
||||
export type { ProjectSecret } from './models/ProjectSecret';
|
||||
@@ -121,6 +124,7 @@ export type { ProjectSecretListResponse } from './models/ProjectSecretListRespon
|
||||
export type { ProjectSummary } from './models/ProjectSummary';
|
||||
export type { PublishEventRequest } from './models/PublishEventRequest';
|
||||
export type { ReadinessStatus } from './models/ReadinessStatus';
|
||||
export type { RevealProjectLoginResponse } from './models/RevealProjectLoginResponse';
|
||||
export type { RevealProjectSecretResponse } from './models/RevealProjectSecretResponse';
|
||||
export type { RybbitMetricItem } from './models/RybbitMetricItem';
|
||||
export type { RybbitSite } from './models/RybbitSite';
|
||||
@@ -131,6 +135,7 @@ export { ScheduledJobRun } from './models/ScheduledJobRun';
|
||||
export type { ScheduledJobRunListResponse } from './models/ScheduledJobRunListResponse';
|
||||
export type { SchemaQuery } from './models/SchemaQuery';
|
||||
export type { SecretName } from './models/SecretName';
|
||||
export type { SetProjectLoginRequest } from './models/SetProjectLoginRequest';
|
||||
export type { SetProjectSecretRequest } from './models/SetProjectSecretRequest';
|
||||
export type { TableQuery } from './models/TableQuery';
|
||||
export type { TelemetryComponent } from './models/TelemetryComponent';
|
||||
@@ -253,6 +258,7 @@ export { $Invitation } from './schemas/$Invitation';
|
||||
export { $InvitationID } from './schemas/$InvitationID';
|
||||
export { $InvokeFunctionRequest } from './schemas/$InvokeFunctionRequest';
|
||||
export { $JobID } from './schemas/$JobID';
|
||||
export { $LoginApp } from './schemas/$LoginApp';
|
||||
export { $MeResponse } from './schemas/$MeResponse';
|
||||
export { $MetricPoint } from './schemas/$MetricPoint';
|
||||
export { $MetricSeriesResponse } from './schemas/$MetricSeriesResponse';
|
||||
@@ -269,6 +275,8 @@ export { $PresignedURL } from './schemas/$PresignedURL';
|
||||
export { $PresignObjectRequest } from './schemas/$PresignObjectRequest';
|
||||
export { $Project } from './schemas/$Project';
|
||||
export { $ProjectID } from './schemas/$ProjectID';
|
||||
export { $ProjectLogin } from './schemas/$ProjectLogin';
|
||||
export { $ProjectLoginListResponse } from './schemas/$ProjectLoginListResponse';
|
||||
export { $ProjectMember } from './schemas/$ProjectMember';
|
||||
export { $ProjectOverview } from './schemas/$ProjectOverview';
|
||||
export { $ProjectSecret } from './schemas/$ProjectSecret';
|
||||
@@ -276,6 +284,7 @@ export { $ProjectSecretListResponse } from './schemas/$ProjectSecretListResponse
|
||||
export { $ProjectSummary } from './schemas/$ProjectSummary';
|
||||
export { $PublishEventRequest } from './schemas/$PublishEventRequest';
|
||||
export { $ReadinessStatus } from './schemas/$ReadinessStatus';
|
||||
export { $RevealProjectLoginResponse } from './schemas/$RevealProjectLoginResponse';
|
||||
export { $RevealProjectSecretResponse } from './schemas/$RevealProjectSecretResponse';
|
||||
export { $RybbitMetricItem } from './schemas/$RybbitMetricItem';
|
||||
export { $RybbitSite } from './schemas/$RybbitSite';
|
||||
@@ -286,6 +295,7 @@ export { $ScheduledJobRun } from './schemas/$ScheduledJobRun';
|
||||
export { $ScheduledJobRunListResponse } from './schemas/$ScheduledJobRunListResponse';
|
||||
export { $SchemaQuery } from './schemas/$SchemaQuery';
|
||||
export { $SecretName } from './schemas/$SecretName';
|
||||
export { $SetProjectLoginRequest } from './schemas/$SetProjectLoginRequest';
|
||||
export { $SetProjectSecretRequest } from './schemas/$SetProjectSecretRequest';
|
||||
export { $TableQuery } from './schemas/$TableQuery';
|
||||
export { $TelemetryComponent } from './schemas/$TelemetryComponent';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Stable app id. Not a URL and not a port.
|
||||
*/
|
||||
export type LoginApp = string;
|
||||
@@ -0,0 +1,20 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type ProjectLogin = {
|
||||
id: string;
|
||||
/**
|
||||
* Stable app id. The current origin is separate and may change.
|
||||
*/
|
||||
app: string;
|
||||
username: string;
|
||||
/**
|
||||
* Current URL of the app, if known. Not the identity.
|
||||
*/
|
||||
origin: 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 { ProjectLogin } from './ProjectLogin';
|
||||
export type ProjectLoginListResponse = {
|
||||
items: Array<ProjectLogin>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type RevealProjectLoginResponse = {
|
||||
app: string;
|
||||
username: string;
|
||||
password: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type SetProjectLoginRequest = {
|
||||
username: string;
|
||||
/**
|
||||
* Plaintext — stored AES-256-GCM encrypted, never returned by list.
|
||||
*/
|
||||
password: string;
|
||||
origin?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $LoginApp = {
|
||||
type: 'string',
|
||||
description: `Stable app id. Not a URL and not a port.`,
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$',
|
||||
} as const;
|
||||
@@ -0,0 +1,41 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $ProjectLogin = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
format: 'uuid',
|
||||
},
|
||||
app: {
|
||||
type: 'string',
|
||||
description: `Stable app id. The current origin is separate and may change.`,
|
||||
isRequired: true,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
description: `Current URL of the app, if known. Not the identity.`,
|
||||
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 $ProjectLoginListResponse = {
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
contains: {
|
||||
type: 'ProjectLogin',
|
||||
},
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,24 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $RevealProjectLoginResponse = {
|
||||
properties: {
|
||||
app: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,23 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const $SetProjectLoginRequest = {
|
||||
properties: {
|
||||
username: {
|
||||
type: 'string',
|
||||
isRequired: true,
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
description: `Plaintext — stored AES-256-GCM encrypted, never returned by list.`,
|
||||
isRequired: true,
|
||||
},
|
||||
origin: {
|
||||
type: 'string',
|
||||
},
|
||||
notes: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -2,9 +2,13 @@
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { ProjectLogin } from '../models/ProjectLogin';
|
||||
import type { ProjectLoginListResponse } from '../models/ProjectLoginListResponse';
|
||||
import type { ProjectSecret } from '../models/ProjectSecret';
|
||||
import type { ProjectSecretListResponse } from '../models/ProjectSecretListResponse';
|
||||
import type { RevealProjectLoginResponse } from '../models/RevealProjectLoginResponse';
|
||||
import type { RevealProjectSecretResponse } from '../models/RevealProjectSecretResponse';
|
||||
import type { SetProjectLoginRequest } from '../models/SetProjectLoginRequest';
|
||||
import type { SetProjectSecretRequest } from '../models/SetProjectSecretRequest';
|
||||
import type { CancelablePromise } from '../core/CancelablePromise';
|
||||
import { OpenAPI } from '../core/OpenAPI';
|
||||
@@ -109,4 +113,112 @@ export class SecretsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Lists app logins — username and origin, never the password.
|
||||
* @returns ProjectLoginListResponse Project logins (password omitted)
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static listProjectLogins({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string,
|
||||
}): CancelablePromise<ProjectLoginListResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/projects/{projectID}/logins',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Creates or replaces an app login. The password is AES-256-GCM at rest. Omitted origin/notes keep existing values. The app id is stable — it is not a host or port.
|
||||
* @returns ProjectLogin Login stored
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static setProjectLogin({
|
||||
projectId,
|
||||
app,
|
||||
requestBody,
|
||||
}: {
|
||||
projectId: string,
|
||||
/**
|
||||
* Stable app id. Not a URL and not a port.
|
||||
*/
|
||||
app: string,
|
||||
requestBody: SetProjectLoginRequest,
|
||||
}): CancelablePromise<ProjectLogin> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/projects/{projectID}/logins/{app}',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'app': app,
|
||||
},
|
||||
body: requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
400: `Error response`,
|
||||
401: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @returns void
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static deleteProjectLogin({
|
||||
projectId,
|
||||
app,
|
||||
}: {
|
||||
projectId: string,
|
||||
/**
|
||||
* Stable app id. Not a URL and not a port.
|
||||
*/
|
||||
app: string,
|
||||
}): CancelablePromise<void> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/projects/{projectID}/logins/{app}',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'app': app,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Returns the username, password, and origin for one app. Every call is audit-logged. Read one login when you need it. Do not copy it into chat or a .env file.
|
||||
* @returns RevealProjectLoginResponse Decrypted login
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static revealProjectLogin({
|
||||
projectId,
|
||||
app,
|
||||
}: {
|
||||
projectId: string,
|
||||
/**
|
||||
* Stable app id. Not a URL and not a port.
|
||||
*/
|
||||
app: string,
|
||||
}): CancelablePromise<RevealProjectLoginResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/projects/{projectID}/logins/{app}/reveal',
|
||||
path: {
|
||||
'projectID': projectId,
|
||||
'app': app,
|
||||
},
|
||||
errors: {
|
||||
401: `Error response`,
|
||||
404: `Error response`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+24
-7
@@ -48,15 +48,32 @@ fi
|
||||
read -p "🌐 Enter your domain or IP (default: localhost): " DOMAIN
|
||||
DOMAIN=${DOMAIN:-localhost}
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s/COOKIE_DOMAIN=localhost/COOKIE_DOMAIN=$DOMAIN/g" .env
|
||||
sed -i '' "s/BETTER_AUTH_URL=http:\/\/localhost/BETTER_AUTH_URL=http:\/\/$DOMAIN/g" .env
|
||||
sed -i '' "s/VITE_APP_URL=http:\/\/localhost/VITE_APP_URL=http:\/\/$DOMAIN/g" .env
|
||||
# Plain HTTP for localhost/IPs; domains are presumed to sit behind a TLS
|
||||
# proxy (see DEPLOYMENT_GUIDE.md — Primora never terminates TLS itself).
|
||||
if [[ "$DOMAIN" == "localhost" || "$DOMAIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
SCHEME="http"
|
||||
else
|
||||
sed -i "s/COOKIE_DOMAIN=localhost/COOKIE_DOMAIN=$DOMAIN/g" .env
|
||||
sed -i "s/BETTER_AUTH_URL=http:\/\/localhost/BETTER_AUTH_URL=http:\/\/$DOMAIN/g" .env
|
||||
sed -i "s/VITE_APP_URL=http:\/\/localhost/VITE_APP_URL=http:\/\/$DOMAIN/g" .env
|
||||
SCHEME="https"
|
||||
fi
|
||||
BASE_URL="$SCHEME://$DOMAIN"
|
||||
|
||||
rewrite_env() {
|
||||
# $1 = var name, $2 = new value — anchor to the full line so prior values
|
||||
# (ports, stale paths) are replaced regardless of what .env.example held.
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|^$1=.*|$1=$2|" .env
|
||||
else
|
||||
sed -i "s|^$1=.*|$1=$2|" .env
|
||||
fi
|
||||
}
|
||||
|
||||
rewrite_env AUTH_BASE_URL "$BASE_URL/auth"
|
||||
rewrite_env BETTER_AUTH_URL "$BASE_URL/auth"
|
||||
rewrite_env VITE_APP_URL "$BASE_URL"
|
||||
rewrite_env VITE_AUTH_BASE_URL "$BASE_URL/auth"
|
||||
rewrite_env VITE_API_BASE_URL "$BASE_URL/api/v1"
|
||||
rewrite_env BACKEND_PUBLIC_URL "$BASE_URL/api/v1"
|
||||
rewrite_env COOKIE_DOMAIN "$DOMAIN"
|
||||
|
||||
# Start services — the dev overlay adds mailpit for local email capture.
|
||||
# Production deploys use the base file alone: docker compose up -d
|
||||
|
||||
Reference in new issue
Block a user