From 971856a28a7db4727163df12a6dd5326b878eec3 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:19:31 -0400 Subject: [PATCH 1/5] style(operations-api): apply prettier table alignment Pre-existing format:check violation on the kris/secrets-docs branch; the repo-wide prettier gate must be clean for CI. Co-Authored-By: Claude Opus 4.8 --- reference/operations-api/operations.md | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 3114d175..c0dabf01 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -612,11 +612,11 @@ Additional parameters: When a component is installed from a private npm registry, `registryAuth` supplies the credential. It is an array of entries, each naming a `registry` and providing the credential exactly one of two ways: -| Field | Description | -| ---------- | --------------------------------------------------------------------------------------------- | -| `registry` | The registry URL or host the credential applies to. **Required.** | -| `token` | A literal auth token, or | -| `secret` | The name of an [`hdb_secret`](../security/secrets.md) row to resolve the token from. | +| Field | Description | +| ---------- | ------------------------------------------------------------------------------------------------ | +| `registry` | The registry URL or host the credential applies to. **Required.** | +| `token` | A literal auth token, or | +| `secret` | The name of an [`hdb_secret`](../security/secrets.md) row to resolve the token from. | | `scope` | Optional npm `@scope` (e.g. `"@my-org"`) the entry applies to; omit to set the default registry. | A provided **`token`** is not treated as ephemeral: Harper ingests it into the encrypted [secrets store](../security/secrets.md) and references it everywhere, so package-reference deploys keep working through rollback, reboot, and new peers joining — without re-supplying the token. The token is encrypted at rest, stripped from the operation before replication and from the operations log, and only ever crosses the cluster as ciphertext. Using a **`secret`** reference names an existing store row directly. Ingesting a token requires custody on the deploying node; on OSS core without custody, a literal token falls back to a transient, this-node-only credential (not persisted or replicated). @@ -754,26 +754,26 @@ Operations for managing the encrypted [secrets store](../security/secrets.md) (` Detailed documentation: [Secrets](../security/secrets.md) | Operation | Description | Role Required | -| ------------------------ | ------------------------------------------------------------- | ------------- | -| `set_secret` | Creates or updates a secret and chooses its delivery tier | super_user | +| ------------------------ | -------------------------------------------------------------- | ------------- | +| `set_secret` | Creates or updates a secret and chooses its delivery tier | super_user | | `grant_secret` | Adds a component to a scoped secret's grants (idempotent) | super_user | | `revoke_secret` | Removes a component from a scoped secret's grants (idempotent) | super_user | -| `list_secrets` | Lists secret metadata — never envelopes or values | super_user | -| `delete_secret` | Deletes a secret row | super_user | -| `get_secrets_public_key` | Returns the cluster public key for client-side encryption | super_user | +| `list_secrets` | Lists secret metadata — never envelopes or values | super_user | +| `delete_secret` | Deletes a secret row | super_user | +| `get_secrets_public_key` | Returns the cluster public key for client-side encryption | super_user | ### `set_secret` Creates or updates a secret. Supply exactly one of `value` (plaintext, encrypted on ingest — requires custody on this node) or `envelope` (an `enc:v1:` ciphertext produced client-side against `get_secrets_public_key`). The delivery tier is `processEnv: true` **or** `grants` — the two are mutually exclusive. On update, tier and metadata default to the stored row, so a value rotation preserves the tier without re-specifying it. -| Parameter | Type | Description | -| ------------ | ---------- | -------------------------------------------------------------------------------- | -| `name` | string | Secret name (word characters, dots, dashes). **Required.** | -| `value` | string | Plaintext value; encrypted immediately, then discarded. Requires custody. | -| `envelope` | string | `enc:v1:` ciphertext (alternative to `value`). | -| `processEnv` | boolean | `true` delivers the secret via `process.env` (global tier). | -| `grants` | string[] | Components allowed to read the secret via the `secrets` accessor (scoped tier). | -| `metadata` | object | Optional free-form label object (not a payload store). | +| Parameter | Type | Description | +| ------------ | -------- | ------------------------------------------------------------------------------- | +| `name` | string | Secret name (word characters, dots, dashes). **Required.** | +| `value` | string | Plaintext value; encrypted immediately, then discarded. Requires custody. | +| `envelope` | string | `enc:v1:` ciphertext (alternative to `value`). | +| `processEnv` | boolean | `true` delivers the secret via `process.env` (global tier). | +| `grants` | string[] | Components allowed to read the secret via the `secrets` accessor (scoped tier). | +| `metadata` | object | Optional free-form label object (not a payload store). | ```json { From bdfecc052fea1b6b1560f36c27e5fbd3c706cd27 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:19:31 -0400 Subject: [PATCH 2/5] docs(security): document secrets change subscription and live scoped accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents harper#1787 (live secret change detection) on the new secrets reference page: - secrets.subscribe(name) — async iterable yielding the current value then every change; shown as read-now + fire-and-forget background subscribe so module load isn't blocked (the practical hot-swap-on-rotation pattern). - Live scoped-tier accessor: a fresh secrets.NAME read reflects the latest value; a destructure is now called out as a point-in-time copy. - Reworked 'Healing after changes' into a per-tier 'How each tier sees a change' table (scoped = live; global/process.env = reload-only) and corrected the 'frozen' accessor description to the read-only live view, with subscribe as a non-enumerable member. Ships in v5.2.0 alongside the rest of the secrets store, so no differential VersionBadge (the page is uniformly 5.2.0 surface). Co-Authored-By: Claude Opus 4.8 --- reference/security/secrets.md | 67 +++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/reference/security/secrets.md b/reference/security/secrets.md index a8a4ef9e..8d0d840a 100644 --- a/reference/security/secrets.md +++ b/reference/security/secrets.md @@ -28,10 +28,10 @@ A single cluster-shared keypair is distributed to every node the same way the JW Each secret is delivered exactly one of two ways, decided by the row itself. The two tiers are mutually exclusive — a `process.env` secret is already global, so scoping it would be meaningless, and `set_secret` rejects a row that is both. -| Tier | Row flag | Reaches components as | In `process.env`? | Inherited by child processes? | -| ---- | -------- | --------------------- | ----------------- | ----------------------------- | -| **Global** | `processEnv: true` | `process.env.NAME` | Yes | Yes | -| **Scoped** | `grants: [...]` | `secrets.NAME` (granted components only) | No | No | +| Tier | Row flag | Reaches components as | In `process.env`? | Inherited by child processes? | +| ---------- | ------------------ | ---------------------------------------- | ----------------- | ----------------------------- | +| **Global** | `processEnv: true` | `process.env.NAME` | Yes | Yes | +| **Scoped** | `grants: [...]` | `secrets.NAME` (granted components only) | No | No | **Global tier** secrets are materialized into the real `process.env` at startup, before components load — exactly like `.env` values, with the same semantics (a pre-existing real environment variable always wins over the store). There is no isolation between components on this tier. @@ -45,13 +45,13 @@ JS-level scoping is a slowdown layer, not a hard security boundary — component All secret operations are **`super_user` only** and are documented in the [Operations Reference](../operations-api/operations.md#secrets). The core operations: -| Operation | Purpose | -| --------- | ------- | -| `set_secret` | Create or update a secret (and choose its tier) | +| Operation | Purpose | +| -------------------------------- | ------------------------------------------------------- | +| `set_secret` | Create or update a secret (and choose its tier) | | `grant_secret` / `revoke_secret` | Add or remove a component from a scoped secret's grants | -| `list_secrets` | List secret metadata (never values) | -| `delete_secret` | Remove a secret row | -| `get_secrets_public_key` | Fetch the cluster public key for client-side encryption | +| `list_secrets` | List secret metadata (never values) | +| `delete_secret` | Remove a secret row | +| `get_secrets_public_key` | Fetch the cluster public key for client-side encryption | ### Create a global (process.env) secret @@ -132,15 +132,54 @@ import { secrets } from 'harper'; const { STRIPE_KEY, WEBHOOK_SECRET } = secrets; ``` -The `secrets` object is frozen and enumerable (`Object.keys(secrets)`, spread). Global-tier secrets are read from `process.env` as usual and do not need the accessor. +On the scoped tier the accessor is **live**: a fresh property read (`secrets.STRIPE_KEY`) always reflects the latest stored value, with no reload. A destructure like the one above is a convenient **point-in-time copy** — ideal when a secret is read once at load and never changes, but it will _not_ observe a later rotation. To react to rotations, read the property when you need it, or subscribe (see [React to rotations](#react-to-rotations-with-secretssubscribe)). + +The `secrets` object is a read-only, live view of the secrets granted to the component; its secret names are enumerable (`Object.keys(secrets)`, spread), while the `subscribe` method below is a non-enumerable member so it never leaks into a spread of values. Global-tier secrets are read from `process.env` as usual and do not need the accessor. :::note Under the VM/compartment component loaders the `harper` module is per-scope, so `import { secrets } from 'harper'` binds to the loading component exactly. Under the native loader the `harper` package is a process-wide singleton, so `secrets` binds to the current component via the component-load context; accessing it **outside** a component-load context fails loudly rather than guessing which component is asking. The module-top-level destructure above is exact in all modes. ::: -### Healing after changes +### React to rotations with `secrets.subscribe()` + +`secrets.subscribe(name)` returns an async iterable that yields the secret's **current value immediately**, then a new value on **every change** — a grant, a rotation (`set_secret`), a revoke, or a delete. It lets a component hot-swap a rotated secret without a restart. + +The iterator stays open for the life of the component, so a top-level `for await` would block module load forever. Instead, use the current value right away, then subscribe in a **background task** so module load is never blocked and the rest of the component keeps initializing: + +```js +import { secrets } from 'harper'; + +// Ready to serve immediately with the current value... +let stripe = new Stripe(secrets.STRIPE_KEY); + +// ...then hot-swap on every rotation, without blocking module load. +(async () => { + for await (const key of secrets.subscribe('STRIPE_KEY')) { + stripe = new Stripe(key); + } +})(); +``` + +Authority is re-evaluated on **every** event through the same rules as the read accessor: + +- A **revoke** or **delete** yields `undefined` (no plaintext is retained), while the stream stays **open**. +- A later **re-grant** or **re-add** resumes delivery on the same iterator — no restart, no re-subscribe. + +`subscribe` is a reserved name: it can never be a secret's name, and never resolves to a secret value. + +### How each tier sees a change + +The two tiers observe changes differently: + +| Read path | Scoped tier (`grants`) | Global tier (`processEnv`) | +| --------------------------- | ---------------------------------------------------- | ------------------------------------------------- | +| `secrets.NAME` accessor | **Live** — reflects the latest value on a fresh read | Current value at load; reload-only | +| `secrets.subscribe('NAME')` | **Live** — streams every change | Current value only; reload-only | +| `process.env.NAME` | n/a | Reload-only — never re-mutated under running code | + +**Scoped-tier** consumers see grants, rotations, revokes, and late-arriving custody take effect live, through the accessor or a subscription. -Materialization happens once per load cycle — there is no live re-materialization. A secret that was granted late, rotated, or whose custody came up after boot heals on the next **restart or component reload**, when the store is re-read. +**Global-tier** secrets are materialized into `process.env` once at startup, before components load, and are deliberately **not** re-materialized under running code: child processes inherit `process.env`, and the "a pre-existing real environment variable always wins" precedence must hold. A change to a global secret — or custody that only came up after boot — heals on the next **restart or component reload**, when the store is re-read. ## Client-side encryption (encrypt before it leaves the client) @@ -187,7 +226,7 @@ enc:v1: "k": "", "iv": "", "ct": "", - "tag": "" + "tag": "", } ``` From 475b8aced8f63f4dab0708606ec1d8ab503bc4b5 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:23:51 -0400 Subject: [PATCH 3/5] docs(security): dedupe the subscribe example's startup rebuild Address review on #584: secrets.subscribe() replays the current value first, so guard on key change to skip the redundant client rebuild on startup, and note that semantic inline. Co-Authored-By: Claude Opus 4.8 --- reference/security/secrets.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/reference/security/secrets.md b/reference/security/secrets.md index 8d0d840a..2a96a2cd 100644 --- a/reference/security/secrets.md +++ b/reference/security/secrets.md @@ -150,16 +150,21 @@ The iterator stays open for the life of the component, so a top-level `for await import { secrets } from 'harper'; // Ready to serve immediately with the current value... -let stripe = new Stripe(secrets.STRIPE_KEY); +let currentKey = secrets.STRIPE_KEY; +let stripe = new Stripe(currentKey); // ...then hot-swap on every rotation, without blocking module load. (async () => { for await (const key of secrets.subscribe('STRIPE_KEY')) { - stripe = new Stripe(key); + if (key === currentKey) continue; // the first yield is the value we already have + currentKey = key; + stripe = new Stripe(currentKey); } })(); ``` +The first value `subscribe` yields is the secret's **current** value — the same one read synchronously above — so the guard skips a redundant rebuild on startup and only rebuilds the client when the key actually rotates. + Authority is re-evaluated on **every** event through the same rules as the read accessor: - A **revoke** or **delete** yields `undefined` (no plaintext is retained), while the stream stays **open**. From 85cc8d68073eafc7f93f9a9f054571398c1c64c8 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 11:36:34 -0400 Subject: [PATCH 4/5] docs(security): guard the subscribe example against unhandled rejections Mirror the studio review fix (HarperFast/studio#1501): the fire-and-forget subscribe loop is copy-paste code, so wrap it in try/catch and console.error the failure so a stream error can't become an unhandled promise rejection. Co-Authored-By: Claude Opus 4.8 --- reference/security/secrets.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/reference/security/secrets.md b/reference/security/secrets.md index 2a96a2cd..3a59f9bc 100644 --- a/reference/security/secrets.md +++ b/reference/security/secrets.md @@ -155,10 +155,15 @@ let stripe = new Stripe(currentKey); // ...then hot-swap on every rotation, without blocking module load. (async () => { - for await (const key of secrets.subscribe('STRIPE_KEY')) { - if (key === currentKey) continue; // the first yield is the value we already have - currentKey = key; - stripe = new Stripe(currentKey); + try { + for await (const key of secrets.subscribe('STRIPE_KEY')) { + if (key === currentKey) continue; // the first yield is the value we already have + currentKey = key; + stripe = new Stripe(currentKey); + } + } catch (error) { + // Log and keep serving the last client; it stays valid until the next reload. + console.error('secret subscription ended for STRIPE_KEY:', error); } })(); ``` From 19bc0eba93b2590818f5319a4359335ce2b8b141 Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Tue, 14 Jul 2026 12:28:51 -0400 Subject: [PATCH 5/5] docs(security): lead with the live accessor; subscription is the exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per feedback: reading secrets.NAME (undestructured) returns the live value on every read, so most components just use the secret where they need it — e.g. per request inside a resource — and get the current value with no subscription. Only a destructure takes a snapshot. Reframe subscribe() as the case for long-lived objects built from a secret (client/pool/signer) that must be rebuilt on rotation, and make the loader note's per-loader boundary explicit (vm/compartment read live anywhere; native resolves only during load). Co-Authored-By: Claude Opus 4.8 --- reference/security/secrets.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/reference/security/secrets.md b/reference/security/secrets.md index 3a59f9bc..6eeeb701 100644 --- a/reference/security/secrets.md +++ b/reference/security/secrets.md @@ -124,7 +124,23 @@ import { secrets } from 'harper'; const stripe = new Stripe(secrets.STRIPE_KEY); ``` -The recommended idiom is a **module-top-level destructure**, which binds correctly in every component-loading mode: +**The accessor is live.** On the scoped tier a property read (`secrets.STRIPE_KEY`) always returns the latest stored value — a rotation is reflected on the next read, no reload required. So most components need nothing more than the accessor: read the secret **where you use it**, and every request sees the current value. Reading inside a resource handler is the common case: + +```js +import { Resource, secrets } from 'harper'; + +export class Report extends Resource { + async get() { + // Read at call time, so each request uses the current token — even after a rotation. + const response = await fetch('https://api.example.com/report', { + headers: { authorization: `Bearer ${secrets.API_TOKEN}` }, + }); + return response.json(); + } +} +``` + +The one thing that is **not** live is a **destructure**: `const { STRIPE_KEY } = secrets` — or any assignment that copies the value into your own variable — takes a **point-in-time snapshot** and won't observe a later rotation. That's fine for a value read once and used as-is, and it's the idiom to use when the read must happen at module load (see the loader note below): ```js import { secrets } from 'harper'; @@ -132,16 +148,16 @@ import { secrets } from 'harper'; const { STRIPE_KEY, WEBHOOK_SECRET } = secrets; ``` -On the scoped tier the accessor is **live**: a fresh property read (`secrets.STRIPE_KEY`) always reflects the latest stored value, with no reload. A destructure like the one above is a convenient **point-in-time copy** — ideal when a secret is read once at load and never changes, but it will _not_ observe a later rotation. To react to rotations, read the property when you need it, or subscribe (see [React to rotations](#react-to-rotations-with-secretssubscribe)). - The `secrets` object is a read-only, live view of the secrets granted to the component; its secret names are enumerable (`Object.keys(secrets)`, spread), while the `subscribe` method below is a non-enumerable member so it never leaks into a spread of values. Global-tier secrets are read from `process.env` as usual and do not need the accessor. :::note -Under the VM/compartment component loaders the `harper` module is per-scope, so `import { secrets } from 'harper'` binds to the loading component exactly. Under the native loader the `harper` package is a process-wide singleton, so `secrets` binds to the current component via the component-load context; accessing it **outside** a component-load context fails loudly rather than guessing which component is asking. The module-top-level destructure above is exact in all modes. +Under the VM/compartment component loaders (the default) the `harper` module is per-scope, so `import { secrets } from 'harper'` is a view bound to your component — you can read `secrets.NAME` live from anywhere in it, including inside request handlers. Under the native loader the `harper` package is a process-wide singleton that resolves the calling component from the component-load context, so a read **outside** component load fails loudly rather than guessing which component is asking; there, read at module top level during load (the destructure above — a snapshot) or use `secrets.subscribe()` to track changes. The module-top-level destructure is exact in all modes. ::: ### React to rotations with `secrets.subscribe()` +Reading `secrets.NAME` per use (above) already picks up rotations, so reach for a subscription only when you build something **from** a secret and keep it around — an API client, a connection pool, a cached signer — and need to rebuild that object when the secret changes. + `secrets.subscribe(name)` returns an async iterable that yields the secret's **current value immediately**, then a new value on **every change** — a grant, a rotation (`set_secret`), a revoke, or a delete. It lets a component hot-swap a rotated secret without a restart. The iterator stays open for the life of the component, so a top-level `for await` would block module load forever. Instead, use the current value right away, then subscribe in a **background task** so module load is never blocked and the rest of the component keeps initializing: