Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re

## Design rules

Start with watch streams and authoritative resource views; add editing as an optional layer.
The [README](README.md) explains current capabilities and the native-fetch direction. Current API
examples must match this branch. The next native slice is read-only LIST/WATCH using the existing
lifecycle and state-input helper; editor cleanup, native editing and larger comparisons are separate.
Native transport and save-progress evaluation remain proposals until implemented.

- Keep the core gateway free of `client-go`; Kubernetes integration belongs in `gateway/kube`.
- Keep the browser client framework-free and free of runtime dependencies.
- Keep credentials, application identity, authorization policy, and writes in the host application.
Expand All @@ -34,6 +40,16 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re
Protocol compatibility is explicit. Additive optional fields are allowed when consumers can ignore them;
incompatible event semantics require a new protocol version rather than a silent reinterpretation.

## Documentation changes

Keep viewing and transport guidance in [watching resources](docs/why-a-gateway.md), editor behavior in
[the editor model](docs/client-state-model.md) and writes in [saving](docs/saving.md). Examples own
their framework-specific guidance. Link to these contracts rather than repeating them in proposals.
Retain decisions and useful evidence when shortening completed plans; remove superseded request
inventories and duplicate guides. Preserve dated observations and generated release history.
Documentation-only changes need local link/anchor checks, snippet/API review and diagram review;
cluster and runtime suites are required when behavior changes, not for prose alone.

## Fixtures

Fixtures use source YAML in `conformance/bodies/` and `conformance/fixtures/`. Run `task fixtures` to
Expand Down
312 changes: 140 additions & 172 deletions README.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ almost all *disclosure* failures. The things we would treat as security bugs:
- **A projected or redacted value reaching the browser.** A `Secret` value, `managedFields`, or any
path the effective projection withheld appearing in a stream event, an error message, or a save
response. The projection is the boundary; a leak through it is the highest-severity bug this
codebase can have.
codebase can have. This applies to the effective projection: explicitly authorized raw/native
access can disclose Secret values, and built-in redaction does not classify arbitrary CRD fields.
- **A caller receiving an object outside their authorized scope.** Particularly through
[`SharedBackend`](gateway/shared.go), where one upstream watch is fanned out to many subscribers and
the host's `Authorizer` is the only thing standing between a caller and the cache. A bug there is
Expand All @@ -52,4 +53,5 @@ The boundaries this library claims to hold, and where they are enforced:

- [docs/auth.md](docs/auth.md): identity, RBAC, and the `SharedBackend` trade.
- [docs/saving.md](docs/saving.md): the write path, and why a save answers 204.
- [docs/why-a-gateway.md](docs/why-a-gateway.md): why the browser never holds a cluster credential.
- [docs/why-a-gateway.md](docs/why-a-gateway.md): watch views, Secret disclosure, host-owned credentials
and explicit native/SSE source selection.
4 changes: 4 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# conformance — the shared contract, executable

These fixtures verify the current gateway SSE contract and browser state application. Editing cases
add local drafts to that live-state contract; viewing does not require edit controls. Requested native
watch support will need recovery tests of its own, not an assumption that these SSE fixtures prove it.

**One YAML file describes one scenario end to end**: the Kubernetes watch input, the gateway's wire
output, and the client's resulting state after applying events and local edits. The Go and TypeScript
suites load the same files, so a contract change is checked on both sides in the same commit.
Expand Down
34 changes: 26 additions & 8 deletions docs/adopting.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
# Adopting krm-stream

This is the recommended route from an existing Go application to a live KRM view in the browser. The
library owns the read path. Your application owns identity, authorization policy, Kubernetes
credentials and writes.
Start with a live resource view, then add editing only where the page needs it. This guide wires the
currently supported gateway stream into an existing Go application. The library owns stream recovery
and browser reconciliation; your application owns identity, authorization policy, credentials and writes.

Choose scope, projection and sharing separately using [watching resources](why-a-gateway.md).
Choose the source for the guarantees the page needs: native access retains original resources through
an existing host proxy; the gateway adds projected views, redaction, suppression and optional sharing,
delivered over SSE. Both should reuse a fetch-based frontend lifecycle.

The [first native slice](field-reports/third-our-identity.md#slice-1-a-native-viewer) is a small read-only
LIST/WATCH connector with re-list recovery and a viewer. Native editing is separate and does not block
that slice. Today the supported connector uses the gateway wiring below.

## 1. Mount the stream endpoint

Expand Down Expand Up @@ -101,12 +110,13 @@ Use the `Authorizer` to pin a user to a namespace or target before any watch ope
```ts
import {
LiveResourceStore,
readOnlyPolicy,
applyStreamEvent,
connectResourceStream,
resourceStreamURL,
} from "@configbutler/krm-stream";

const store = new LiveResourceStore();
const store = new LiveResourceStore(readOnlyPolicy); // list or viewer
const url = resourceStreamURL("/resource-stream/v1", {
target: "production",
version: "v1",
Expand All @@ -115,17 +125,25 @@ const url = resourceStreamURL("/resource-stream/v1", {
projection: "krm-full/v1",
});

const stopRendering = store.subscribe(() => render(store));
const connection = connectResourceStream(url, event => applyStreamEvent(store, event));
renderConnection(connection.state.status);
connection.subscribe(state => renderConnection(state.status)); // gaps recover with a fresh snapshot
connection.closed.catch(reportApplicationError); // the callback threw, and the stream stopped
store.subscribe(() => render(store));
const stopConnection = connection.subscribe(state => renderConnection(state.status));
connection.closed.catch(reportApplicationError); // host callback failures stop the stream

// On view disposal:
stopRendering();
stopConnection();
connection.close();
```

Fetch sends the same-origin session cookie, and the connection recovers on its own after network
failures and sequence gaps.

Saving is the host's: see [saving](saving.md). A quiet stream can still hold an older write version;
For an editor, choose `new LiveResourceStore()` with the default policy or a form-specific policy.
Render the draft, edit through the store methods and resolve conflicts explicitly; see the
[editor state model](client-state-model.md). Saving is the host's: see [saving](saving.md).
A quiet stream can still hold an older write version;
see [why a quiet stream can reject a save](saving.md#why-a-quiet-stream-can-still-reject-a-save).

## 4. Optional: recheck quiet streams on a timer
Expand Down
8 changes: 5 additions & 3 deletions docs/alternatives.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Alternatives

krm-stream combines a scoped KRM read stream with a browser store for live state, local drafts and
conflicts. Choose tools according to which part of that problem your application needs.
krm-stream starts with efficient live Kubernetes views for browser applications. Its gateway adds
projection, redaction, suppression and optional watch sharing; its client adds lifecycle recovery
and optional draft reconciliation. Choose tools according to the part your application needs.

| Need | Relevant approach | Where krm-stream fits |
|---|---|---|
| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | A read-only native connector with shared lifecycle is next work; gateway SSE delivers the supported projected source today. |
| Kubernetes API access from a server | Kubernetes client libraries and raw watches | The Go adapter uses `client-go`; the gateway adds projections and browser snapshot framing. |
| A complete Kubernetes UI | Dashboard applications and their plugin APIs | krm-stream supplies state and transport; the host builds the UI. |
| Review and deliver configuration packages | KRM package and GitOps systems | The host can record accepted writes in its delivery workflow. |
Expand All @@ -24,5 +26,5 @@ conflicts. Choose tools according to which part of that problem your application
- [gitops-reverser](https://reversegitops.dev): a complementary write-and-record workflow. The host
connects accepted Kubernetes writes to Git; krm-stream supplies the read and edit side.

The [architecture overview](../README.md#how-it-fits) shows the library/host split. The
The [architecture overview](../README.md#how-it-fits-today) shows the library/host split. The
[saving guide](saving.md) describes the conditional-write boundary and its limitations.
9 changes: 8 additions & 1 deletion docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ host's scope and projection decisions. How Kubernetes checks a caller depends on
| Shared | One service identity | Authorize every subscriber before serving cached objects; use `kube.SubjectAccessReviewAuthorizer` for Kubernetes RBAC decisions. |

Prefer a per-user backend: Kubernetes RBAC is then the boundary by construction. The wiring for both
is in [adopting](adopting.md).
is in [adopting](adopting.md). Watch sharing is an efficiency choice; it never grants a subscriber
the service identity's access. Full/spec Secret redaction also does not replace scope authorization.

These settings describe the current gateway. The requested native fetch connector uses a host proxy
and the same session boundary. Native retains original authorized resources; gateway SSE delivers
projected views. Neither source may bypass refusal or extend session lifetime. The first native
slice is read-only and must stop on terminal authentication failure, with a new connection after
access is restored; native editing is separate.

## Browser sessions

Expand Down
91 changes: 62 additions & 29 deletions docs/client-state-model.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
# Client state model

`LiveResourceStore` is a headless TypeScript store for a KRM resource stream. It separates the
authoritative object received from Kubernetes from the user's local draft, so a live update never
silently overwrites an edit.
# Editor state model

Editing is an optional layer on a live resource view. `LiveResourceStore` keeps the last delivered
server object separate from the person's local draft, incorporates incoming changes and exposes
conflicts for review. The host owns the form and every write; see [saving edits safely](saving.md).
For a list or viewer today, use `LiveResourceStore(readOnlyPolicy)` and render `server(uid)`.
The connector consumes and delivers state events independently of the editor.

## Intended use

1. Choose one authorized source, scope and view for the store. Choose the form's editable fields.
2. Apply authoritative state events synchronously with `applyStreamEvent(store, event)`.
3. Render detached store reads and make edits through `setValue`, `removeKey` and related methods.
4. Present incoming changes and resolve conflicts explicitly while preserving the draft.
5. On a deliberate Save, capture the patch, UID and resource version together. Let the host authorize,
validate and conditionally write that intent; preserve later typing during the request.
6. Observe the result through the watch or a guarded read under the same view. Track write acceptance
and workload progress separately from draft dirtiness.

Do not mutate a `draft()` return value directly: reads are detached copies, and that bypasses edit
policy and notifications. Independent sources/scopes need separate stores. Switching identity or view
must not silently reuse drafts, redactions or snapshot state. Native watch integration is
[requested](field-reports/third-our-identity.md#native-watch-connector). The first native slice uses the
store read-only; this guide's editing and save examples currently use gateway views. Native editing
requires a source-bound policy and host read/write contract in a later slice.

Keep the existing standalone `applyStreamEvent(store, event)` for the native viewer. A bound store
method is proposed editor cleanup in proposal 0009, not a current API or a prerequisite for native
transport. No new store is needed.

## State per resource

| Value | Meaning |
|---|---|
| `server(id)` | The latest delivered complete projected object. Every upsert replaces it. |
| `server(id)` | The latest complete object delivered by the chosen source. Current gateway objects are projected; native objects retain upstream fields. Every upsert replaces it. |
| `draft(id)` | The object rendered and edited by the UI. Editable regions are reconciled with server changes. |
| `conflicts(id)` | Server values that changed concurrently with a different local edit. |
| `redactions(id)` | Paths known to exist upstream but intentionally withheld by the selected projection. |
Expand Down Expand Up @@ -40,7 +64,14 @@ objects it has not yet reloaded.
The default editable regions are `spec`, `metadata.labels`, `metadata.annotations`, `data`, and
`stringData`. `status`, immutable metadata, and redacted paths are read-only.

When a new server object arrives, the store compares three values at each editable path:
Suppose a Deployment starts with image `v1` and replicas `3`. The person types image `v2`, then an
autoscaler changes replicas to `5`. The draft becomes image `v2`, replicas `5`: untouched fields follow
the server while local work stays. If another person changes the image to `v3`, the local image `v2`
stays and a conflict records `v3` for review.

When a new server object arrives, the store compares three values at each editable path: **base** is
the previous server value, **ours** is the draft, and **theirs** is the incoming server value. The
incoming object then becomes the new server base.

| Draft differs from base | Incoming server differs from base | Result |
|---|---|---|
Expand All @@ -50,7 +81,9 @@ When a new server object arrives, the store compares three values at each editab
| yes | yes, differing from the draft | keep the draft and record a conflict |

`isDirty` and `changes` are derived from `draft` versus `server`; neither is a cache that can drift
after a stream update. `revert` or `takeTheirs` restores the current server value.
after a stream update. `revert` restores the current server value. To keep a local value explicitly,
use the [tested keep-local recipe](../examples/editor-recipes/README.md#keep-the-local-value-in-a-conflict).
The UI owns the choice; the store does not silently choose a winner for differing concurrent edits.

## Patches and redactions

Expand Down Expand Up @@ -88,27 +121,22 @@ const pendingDeletes = new Set(); // uids marked for removal

### Reflecting the result

The recommended shape is still 204 and let the watch echo it (see [saving edits safely](saving.md)): a
create arrives as an `added` event, a delete as a `deleted` event, and the store converges on its own.
Two primitives exist for a host that cannot wait for the echo, and both are idempotent with it
(`I-IDEMPOTENT`):

- `adoptSaved(object)` — insert the created object once the save returns it. The echo that follows is
a no-op, not a second card.
- `removeResource(uid)` — drop a deleted object before its `deleted` event arrives.
Prefer 204 or a receipt and let the stream reflect creates and deletes. Keep accepted writes separate
from pending confirmation, so a delayed watch echo cannot cause a duplicate submission. A create
arrives with its server-assigned UID; a delete removes only the original UID. A completed snapshot
can confirm missed observations. Account for an echo arriving before the HTTP response as well as
for an echo arriving afterwards.

Either way, clear the page-local entry — the `pendingCreates` draft or the `pendingDeletes` uid — when
its write succeeds. Those primitives update the store, not your pending lists; a completed mutation
left staged reappears in the review list and can be submitted twice.
Current `adoptSaved` and `removeResource` APIs remain available, but the recommended flow uses the
stream. `adoptSaved` is unguarded and must not receive an asynchronous response that can race newer
watch state; a reconciliation guard for an existing UID does not insert a newly created resource.
Optimistic removal after a failed delete cannot recover until an authoritative event or snapshot
restores the object. Do not invent UIDs or fabricate authoritative stream events to represent intent.

Two caveats keep this honest:

- **A create reflects only _after_ the server responds.** `adoptSaved` needs the server-assigned uid
and a **projected** object (never a raw Kubernetes object — see [saving edits safely](saving.md)). Do
not fabricate a uid for a pending draft; keep it page-local until the create returns the object.
- **An optimistic delete is not self-healing.** A delete that _fails_ server-side produces no watch
event, so a `removeResource`d object does not reappear until the next snapshot. Re-add it on failure,
or skip the optimism and let the `deleted` echo do it.
Deletion and snapshot pruning discard the resource's draft. If recovery matters, capture a detached
copy as edits change, before removal, using the [recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion).
Retain it under the original identity and UID for explicit copy-out; never transfer it automatically
to a same-name replacement.

## Arrays and associative lists

Expand All @@ -131,8 +159,8 @@ retain the atomic behavior. Merge patches still send the final array as one RFC

## UI integration

The store has no rendering dependency. Subscribe once, then query `draft`, `status`, `changes`,
`conflicts`, and `redactions` during rendering:
The store has no rendering dependency. Subscribe once, then query `draft`, `server`, `changes`,
`conflicts` and `redactions` during rendering. Connection state belongs to the connector:

```ts
const unsubscribe = store.subscribe(() => render(store));
Expand All @@ -146,3 +174,8 @@ if (intent) await hostSave(intent);
Use the [conditional editor](../examples/conditional-save/README.md) for conflict checks, serialized
saves and guarded asynchronous responses. `adoptSaved` is for synchronous adoption or newly created
objects; a delayed response must use a reconciliation guard. See [saving](saving.md).

A clean draft means it matches the delivered editable values. It does not prove that a captured write
was accepted or that a controller completed its work. A suppressed update can leave the displayed
view unchanged but its save version stale; see [the save-version explanation](saving.md#why-a-quiet-stream-can-still-reject-a-save).
For reactive integration and subscription ownership, see the [Vue example](../examples/vue/README.md).
Loading
Loading