Overview
┌────────────┐ Managed OAuth + PKCE ┌──────────────────────────────┐│ prk (Rust)│────────────────────────▶│ Cloudflare Access ││ │ or service-token hdrs │ (authenticates at the edge) │└────────────┘ └──────────────┬───────────────┘┌────────────┐ Access SSO │ signed JWT│ Browser │───────────────────────────────────────▶▼│ SvelteKit │◀── static assets ──┌──────────────────────────────────┐└────────────┘ │ Worker │ │ hooks.server.ts splits: │ │ /api/* → Hono │ │ else → SvelteKit SSR │ │ both call core/* in-process │ └──────────────┬───────────────────┘ ▼ ┌──────────────────┐ │ D1 (values │ │ encrypted+AAD) │ └──────────────────┘The decision everything hangs off
packages/app/src/lib/server/core/* takes (db, actor, input), returns data or
throws a PrickError, and knows nothing about HTTP. The Hono routes and the
SvelteKit load functions are both thin transports over it.
That is not tidiness. It has two concrete consequences.
No internal HTTP hop. A SvelteKit server load calls listProjects()
in-process. It cannot usefully call its own /api instead: event.fetch does
not forward arbitrary headers, so Cf-Access-Jwt-Assertion could not be passed
through, and the CF_Authorization cookie is documented as not guaranteed to be
passed either. An internal hop would have to re-solve authentication, badly.
Authorization is written once. The bug class where one handler checks scope and the handler next to it forgets is not something discipline prevents here — it is unreachable, because both transports enter through the same function.
Repository layout
crates/ prk/ the binary: argument parsing, commands, rendering prick-core/ PURE: no I/O, no async, no unsafe prick-api/ HTTP client, typed models, error classification prick-auth/ OAuth + PKCE, service tokens, token storage prick-exec/ process launch, signals, job objectspackages/ app/ THE deployed Worker: Hono API + SvelteKit UI shared/ zod schemas shared by the Worker and the UI docs/ Astro + Starlight renderer for the root docs/ Markdown mcp/ the MCP server, published alongside the CLI npm/prick/ the published @yashau/prick launcheraction/ the composite GitHub Actionscripts/ Node ESM helpers (version stamping, npm assembly, release)e2e/ Playwrightxtask/ shell completions and man page generationThe documentation site is a separate Worker from the application: public, no secrets, no access control. That is why a typo in a Markdown file cannot trigger a production deploy of the secrets manager, and a schema migration cannot be blocked behind a docs build.
Cargo members are crates/*; pnpm packages are packages/*. Neither glob
crosses.
Data model
Eleven tables: projects, environments, secrets, secret_versions,
identities, grants, groups, group_members, group_grants, audit_log,
keyring_state.
Conventions that hold throughout packages/app/src/lib/server/db/schema.ts:
- IDs are UUIDv7 text. Not
crypto.randomUUID(), which is v4. v7 embeds a millisecond timestamp in its high bits and therefore sorts lexicographically in creation order, which is what makesWHERE id > :cursor ORDER BY ida correct, index-only keyset paginator for the audit log. With v4 ids the same query returns rows in arbitrary order and the cursor means nothing. - Timestamps are integer epoch milliseconds. Never ISO-8601 text: that compares correctly only by accident of format, costs bytes, and forces a parse on every read.
- Foreign keys are real. D1 enforces them by default, so
ON DELETE CASCADEactually fires and there is no hand-rolled cascade to get wrong.
The constraints that are load-bearing
UNIQUE(environment_id, key, version) on secret_versions is the concurrency
primitive. There is no lock table anywhere in the schema because of it. Two
writers racing on the same key both read version N and both compute N+1; both
batches attempt the same insert. One commits, and the other trips the constraint
— which aborts its entire batch, because D1 rolls back on error. The loser
writes nothing at all: not a partial update, not an out-of-order version. It
retries once against the new state, and returns 409 if it loses again.
grants uses partial unique indexes, one per scope type, not one composite
index over (identity_id, scope_type, project_id, environment_id). SQLite
follows the standard in treating NULLs as distinct for uniqueness, so in a
composite index two global grants — both (id, 'global', NULL, NULL) — would not
collide. The constraint would look correct, pass every casual test, and silently
permit unlimited duplicate global admin grants. group_grants carries the same
shape, keyed on the group rather than the identity.
There is no foreign key on secret_versions.key. History is keyed by
(environment_id, key) rather than by secrets.id, so deleting a key and
recreating it continues the version sequence instead of restarting at 1. A
version number therefore never refers to two different values in one environment
— which matters, because the version is inside the data each ciphertext is bound
to.
audit_log has no foreign key to identities. An audit row must outlive the
identity it names, and a cascade would delete exactly the history you need after
revoking someone’s access.
secret_versions has nine columns, and the count is load-bearing. D1 allows
100 bound parameters per query, so a multi-row insert fits 11 rows. Adding a
tenth column drops that to 10 and must be a deliberate decision.
Atomic writes
A bulk write is:
- One read for existing keys and versions. It serves the authenticated-data versions, the delete set, the audit diff and the revision check at once.
- Compute in JavaScript, and encrypt each value against its next version.
- One
batch(): revision bump, multi-row insert intosecret_versions, multi-row upsert intosecrets, tombstones, deletes, and the audit insert last.
D1’s batch() is a real transaction: if a statement fails, the whole sequence
rolls back. A 100-secret write is roughly 23 statements in one batch instead of
101 sequential round-trips.
The audit insert being inside the batch is what makes an un-audited mutation unrepresentable rather than merely discouraged. If the audit write fails, the data write fails with it.
Why the environment cap exists
A full-environment replace must fit in one batch, because splitting it would
forfeit atomicity, and batch() has a documented 30-second ceiling. So there is
a hard cap — ENV_MAX_SECRETS, default 500 — and anything larger is a 413.
That number is derived from an undocumented per-batch statement limit and needs
load-testing; if it does not hold, the fix is a lower cap, never a split batch.
The optimistic-concurrency guard
UPDATE … WHERE rev = ? does not work as a guard. D1 rolls back on an
error, not on zero rows changed, so a non-matching update succeeds as a no-op
and the rest of the batch commits anyway.
The construct that works is a deliberate primary-key collision:
INSERT INTO environments SELECT * FROM environments WHERE id = ?1 AND rev != ?2Zero rows — a harmless no-op — when the revision matches; one row, and therefore
a primary key violation that aborts the whole batch, when it does not. Mapped to
412.
The UI
Screens with no secret values in them — projects, environments, access, audit — use server-side rendering and server loads.
The secrets subtree only sets ssr = false
(src/routes/(app)/p/[project]/[env]/+layout.ts). It is client-rendered, and
values are fetched from /api/v1 in the browser. No server render means no
serialised page payload, which means there is nothing there to leak. Form actions
are used for projects, environments and grants only, never for anything that
returns a value, because SvelteKit serialises an action’s return into page data.
That rule is enforced, not just held
.github/workflows/ci.yml greps every +*.server.ts under src/routes for
calls to revealSecret, revealSecrets, exportSecrets or
decryptSecretValue, on every push, and fails the build on a hit.
It matches call syntax rather than the bare names deliberately. The first version matched bare identifiers and failed on its own documentation — the comments that describe this very check name those functions — so naming one in prose, as this page does, is fine.
Status
| Layer | State |
|---|---|
Crypto (crypto/) |
Implemented |
Access verification and authorization, incl. groups (auth/) |
Implemented |
Projects, environments, secrets, identities, groups, audit (core/) |
Implemented |
Key ring status and rekey (core/keyring.ts) |
Implemented; no cron, a rotation is driven by calls |
| Hono routes | Fully mounted |
| SvelteKit UI | Complete screens, reading the domain layer |
| CLI | Every command wired |