diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9bceb6a..c54f24d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -17,9 +17,9 @@ Run `task fixtures-check`, `task test`, and `task lint` before opening a pull re
Start with watch streams and authoritative resource views; add editing as an optional layer.
The [README](README.md) explains current capabilities and how to choose a native or gateway source.
-Current API examples must match this branch. Native viewing is read-only LIST/WATCH on the shared
-connection lifecycle; native editing, native resume, editor cleanup and larger comparisons are
-separate. Save-progress evaluation remains a proposal until implemented.
+Current API examples must match this branch. Native viewing is LIST/WATCH on the shared
+connection lifecycle, and native editing writes conditional merge patches back through the same
+proxy; native resume, editor cleanup and larger comparisons are separate. Save-progress evaluation remains a proposal 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.
@@ -64,7 +64,7 @@ the rule it protects.
| `task e2e-wire` | Real Go SSE bytes consumed by the TypeScript client over HTTP. |
| `task e2e-browser` | Native `EventSource`, unbundled ESM and the native viewer example in Chromium. |
| `task cluster-facts` | Record observed Kubernetes behavior for the supported cluster version. |
-| `task test-real-api` | Compose the save path and native viewing against a real API server: gateway or host proxy, host endpoint and browser store. |
+| `task test-real-api` | Compose the save path and native viewing and editing against a real API server: gateway or host proxy, host endpoint and browser store. |
| `task test-cluster` | Exercise the Kubernetes backend against a real API server. |
The cluster tasks need Docker and take longer; the fixture suites are the per-pull-request baseline.
diff --git a/README.md b/README.md
index cc59785..87e3f10 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Start with the resource state and guarantees the page needs; framing is an imple
| Source | What the page receives | Connector |
|---|---|---|
-| Native through a host proxy | Original authorized resources, with the shared client lifecycle and store | `connectNativeWatch`, read-only: LIST, then WATCH, and a fresh LIST on every reconnect |
+| Native through a host proxy | Original authorized resources, with the shared client lifecycle and store | `connectNativeWatch`: LIST, then WATCH, and a fresh LIST on every reconnect; edits are written back through the same proxy |
| Gateway | Projected, redacted or suppressed views and optional upstream watch sharing | `connectResourceStream`, delivered over SSE |
Native is the straightforward starting point for hosts that already proxy Kubernetes. It delivers
@@ -94,7 +94,7 @@ The host mounts the API server's paths under a base such as `/k8s` and owns cred
which collections a user may read. The connector lists the complete collection, delivers it as a
snapshot and becomes `live` once the WATCH from the collection's resourceVersion is accepted. Every
reconnect lists again, and the store prunes only when that snapshot completes. HTTP or in-stream 410
-recovers within the bounded retry budget; 401, 403 and other 4xx are terminal. Slice 1 does not
+recovers within the bounded retry budget; 401, 403 and other 4xx are terminal. The connector does not
paginate: a LIST that returns a continuation token is refused. The
[native viewer example](examples/native-viewer/README.md) runs this against `kubectl proxy`.
@@ -150,27 +150,31 @@ const intent = store.captureSave(uid); // detached { uid, resourceVersion, patch
```
The default policy allows `spec`, labels, annotations, `data` and `stringData`; status, immutable
-metadata and redacted paths remain read-only. A host can narrow the policy for its form.
+metadata and redacted paths remain read-only. Under every policy, `metadata.managedFields` and the
+last-applied annotation stay read-only too. A host can narrow the policy for its form.
The intended Save flow is explicit: capture the patch, UID and resource version together; have the
host authorize and validate it; apply a conditional merge PATCH; then observe the projected result
through the stream or a guarded projected read. Preserve typing made after Save. A version rejection
can occur without any field conflict, because suppressed updates still advance Kubernetes versions.
-The current recovery is a guarded read, review and another deliberate Save.
+The current recovery is a guarded read, review and another deliberate Save. A native store follows
+the same flow through its host proxy: the browser sends the merge PATCH with the captured UID and
+version inside it, and the proxy validates it before forwarding.
**A dirty draft, an accepted write and application progress are separate states.** A successful PATCH
can precede its watch observation, and neither proves a workload has finished rolling out.
Use [the editor state model](docs/client-state-model.md) for reconciliation, conflict resolution and
arrays, and [saving edits safely](docs/saving.md) for the complete host-owned write contract. The
-[conditional-save example](examples/conditional-save/README.md) executes that contract.
+[conditional-save example](examples/conditional-save/README.md) executes that contract, and the
+[native editor example](examples/native-editor/README.md) its native counterpart.
## How it fits today
```mermaid
flowchart LR
api["Kubernetes API"]
- proxy["Your native proxy
Credentials and routing"]
+ proxy["Your native proxy
Credentials, routing and write checks"]
gateway["Go gateway
Scopes, views and optional sharing"]
connector["Fetch connector
State events and recovery"]
store["Resource store
Live state and optional drafts"]
@@ -185,6 +189,8 @@ flowchart LR
ui -->|"Local edits"| store
ui -->|"Captured save intent"| save
save --> api
+ ui -->|"Native conditional PATCH"| proxy
+ proxy --> api
```
A page uses one source per store: the native proxy or the gateway. The gateway runs inside your
@@ -218,7 +224,7 @@ such as `spec`, `status` or ConfigMap `data`. Custom resources follow the same c
- [Editor state model](docs/client-state-model.md): drafts, conflicts, redactions and arrays.
- [Saving](docs/saving.md): conditional writes, recovery and user-facing outcomes.
- [Authorization](docs/auth.md) and [operations](docs/operations.md): identity, revocation and runtime limits.
-- [Examples](examples/README.md): native viewer, gateway browser demo, conditional editor, recovery recipes and Vue integration.
+- [Examples](examples/README.md): native viewer and editor, gateway browser demo, conditional editor, recovery recipes and Vue integration.
- [Delivery plan](docs/proposals/0006-stream-and-save-implementation-plan.md): completed work, open work, ordering and dependencies.
- [Upgrading from 0.7](docs/migrating.md) and [releasing](docs/releasing.md).
diff --git a/Taskfile.yml b/Taskfile.yml
index f1a89bc..59b41de 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -440,7 +440,7 @@ tasks:
# The save path, composed against a real API server: the real gateway, a real host endpoint and the
# real browser store (run by node).
test-real-api:
- desc: "Real-API composition: save path (status churn, overlapping recovery, UID race) and native viewing through a host proxy."
+ desc: "Real-API composition: save path (status churn, overlapping recovery, UID race) and native viewing and editing through a host proxy."
deps: [cluster-up]
dir: gateway/kube
env:
diff --git a/docs/adopting.md b/docs/adopting.md
index 0234d44..c1e0a36 100644
--- a/docs/adopting.md
+++ b/docs/adopting.md
@@ -11,8 +11,9 @@ delivered over SSE. Both connectors share one fetch-based frontend lifecycle.
For native access, a host that already proxies Kubernetes needs no gateway: point
`connectNativeWatch` at a collection behind that proxy, as in the [README](../README.md#watch-native-resources-through-a-host-proxy)
-and the [native viewer example](../examples/native-viewer/README.md). It is read-only and re-lists on
-every reconnect; native editing is separate. The steps below wire the gateway source.
+and the [native viewer example](../examples/native-viewer/README.md). It re-lists on every
+reconnect; to edit through the same proxy, see [native editing](saving.md#native-editing-through-a-host-proxy).
+The steps below wire the gateway source.
## 1. Mount the stream endpoint
diff --git a/docs/alternatives.md b/docs/alternatives.md
index 917501e..b40d2f2 100644
--- a/docs/alternatives.md
+++ b/docs/alternatives.md
@@ -6,7 +6,7 @@ and optional draft reconciliation. Choose tools according to the part your appli
| Need | Relevant approach | Where krm-stream fits |
|---|---|---|
-| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | `connectNativeWatch` lists and watches through the proxy with the shared lifecycle and store, read-only; the gateway adds projected views over SSE. |
+| Native watch through an existing browser proxy | Host proxy plus a native fetch/watch consumer | `connectNativeWatch` lists and watches through the proxy with the shared lifecycle and store, and edits write conditional merge patches back through it; the gateway adds projected views over SSE. |
| 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. |
diff --git a/docs/auth.md b/docs/auth.md
index 13f684a..f68a629 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -15,9 +15,11 @@ the service identity's access. Full/spec Secret redaction also does not replace
These settings describe the current gateway. The native connector reads through a host proxy behind
the same session boundary; the proxy, not the browser, holds Kubernetes credentials and authorizes
each collection. 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.
+projected views. Neither source may bypass refusal or extend session lifetime. The native connector
+must stop on terminal authentication failure, with a new connection after access is restored. Native
+writes go through the same proxy as the signed-in person; the proxy validates each PATCH with
+`ValidateNativeMergePatch` and keeps its own write authorization, and Kubernetes RBAC still applies.
+See [native editing](saving.md#native-editing-through-a-host-proxy).
## Browser sessions
diff --git a/docs/client-state-model.md b/docs/client-state-model.md
index db5b381..2a2af47 100644
--- a/docs/client-state-model.md
+++ b/docs/client-state-model.md
@@ -19,13 +19,14 @@ The connector consumes and delivers state events independently of the editor.
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. The native connector,
-`connectNativeWatch`, uses the store read-only; this guide's editing and save examples use gateway
-views. Native editing requires a source-bound policy and host read/write contract in a later slice.
+must not silently reuse drafts, redactions or snapshot state. The same store edits objects from
+`connectNativeWatch`: native writes and recovery reads go through the proxy the watch reads, as
+[native editing](saving.md#native-editing-through-a-host-proxy) describes. Never mix native and
+projected objects, responses or editors in one store.
-The native viewer uses the existing standalone `applyStreamEvent(store, event)`. 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.
+Both connectors feed the store through the existing standalone `applyStreamEvent(store, event)`. 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
@@ -63,6 +64,12 @@ 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.
+Under every policy, the store also keeps `metadata.managedFields` and the
+`kubectl.kubernetes.io/last-applied-configuration` annotation read-only. These are the machinery
+that gateway projections remove and native objects carry. A map that holds a protected path, such
+as `metadata.annotations` beside the last-applied annotation or a Secret's `data` beside withheld
+values, cannot be replaced or removed whole. It is merged and edited key by key instead.
+
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`
diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md
index 4dfa3ee..822f752 100644
--- a/docs/field-reports/third-our-identity.md
+++ b/docs/field-reports/third-our-identity.md
@@ -1,7 +1,7 @@
# Watch streams, optional editing and native access
-**Request of 2026-10-05. Slice 1, the native viewer, is implemented; the follow-ups below remain
-open.** It builds on the connector separation, Go API cleanup and real-API save tests. Use
+**Request of 2026-10-05. Slice 1, the native viewer, and slice 2, native editing, are implemented;
+the other follow-ups below remain open.** It builds on the connector separation, Go API cleanup and real-API save tests. Use
[proposal 0006](../proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order)
for completed work, delivery order and independent tracks.
@@ -14,7 +14,7 @@ conflict review and captured save intent; the host owns every write.
| Source | Delivered content | Added value |
|---|---|---|
-| Native through a host proxy — read-only viewing implemented | Original Kubernetes resources the host authorizes, including Secret values and machinery fields | Reuse connection lifecycle and live state without adopting the gateway |
+| Native through a host proxy — viewing and editing implemented | Original Kubernetes resources the host authorizes, including Secret values and machinery fields | Reuse connection lifecycle and live state without adopting the gateway |
| Gateway — supported | Named projected views delivered over SSE | Selected disclosure, fewer downstream events and optional shared upstream watches |
Native is the straightforward entry point for a host that already proxies Kubernetes. Gateway SSE
@@ -91,21 +91,66 @@ claims and demonstrates cleanup. Show how to target an existing host proxy. If a
harness can exercise LIST/WATCH cheaply, add one focused integration case and report its actual run;
a new cluster campaign is not a prerequisite. Existing tests still apply to any changed lifecycle.
+### Slice 2: native editing
+
+**Implemented** as store-level machinery protection, `nativeObjectURL`,
+`gateway.ValidateNativeMergePatch` and the [native editor example](../../examples/native-editor/README.md),
+with the contract in [saving](../saving.md#native-editing-through-a-host-proxy). The requirements
+below are what it was built and tested against.
+
+- **Edit policy.** No new policy and no new store. `metadata.managedFields` and the
+ `kubectl.kubernetes.io/last-applied-configuration` annotation are read-only under every policy, as
+ redacted paths are: the same two paths `ValidateMergePatch` refuses for every projection. A map
+ holding a protected path cannot be replaced or removed whole; where the policy makes it editable it
+ is merged and edited key by key. This also makes a new Secret key beside redacted values reach the
+ patch, which it previously did not.
+- **Source binding.** Writes and recovery reads address the object through the same proxy and
+ collection the watch reads (`nativeObjectURL`), under the object's own namespace and name. A
+ recovery read must be a Kubernetes object with the editor's UID; a projected envelope or another
+ UID is never reconciled. A native response never reaches a projected editor.
+- **Write contract.** The browser sends the captured intent as `application/merge-patch+json` with
+ `metadata.uid` and `metadata.resourceVersion` from the capture, never from a newer read. The host
+ proxy accepts only that content type, bounds the body and runs `ValidateNativeMergePatch`, which
+ refuses a patch missing either precondition or touching machinery. The host keeps authentication,
+ write authorization and audit; Kubernetes RBAC still applies.
+- **Save guards.** Unchanged: atomic capture, serialized saves, writes only while live, no adoption
+ of the write response, 409 recovery through `captureReconciliation`, a refused read owing another
+ read, and later typing preserved. A 422 on `metadata.uid` is treated as a replaced object.
+- **Unknown outcomes and confirmation.** After a network failure or a 5xx, or an accepted write
+ whose echo has not arrived, the next Save is a guarded read, never a second PATCH; `confirm()`
+ performs that read explicitly when no echo arrives. A 4xx is a definite refusal and owes no read.
+
+### Acceptance for slice 2
+
+**Met.** `packages/krm-stream/test/invariants.test.ts` covers machinery under the default policy and
+under a policy that makes all of `metadata` editable, and a Secret key added beside redacted values.
+`packages/krm-stream/test/native-editing.test.ts` covers the request shape and URL, machinery
+rewritten by the server during editing, 409 recovery to `version-stale` and to `draft-conflict`,
+replacement by 409 and by a `metadata.uid` 422, deletion, refused writes keeping their Status, reads
+overtaken by the watch, the live check and a projected envelope, an unknown write outcome (network
+failure and 502) settled by a read, a definite 422 needing none, and confirmation by echo, by the
+next Save's read and by `confirm()` after a write a webhook reverted. `gateway/patch_test.go` covers
+`ValidateNativeMergePatch`.
+
+`TestRealAPINativeEditThroughHostProxy` (`task test-real-api`) runs the real connector and editor
+through a credential-holding `/k8s` proxy that validates every PATCH, against a real API server: a
+save and its watch echo with the last-applied annotation untouched, a competing write landed between
+capture and PATCH (409, guarded read, a second deliberate save, neither write lost), the proxy
+refusing an unconditional patch and a machinery patch before they reach the API server, and an
+object replaced under the same name reported `unavailable` with the replacement untouched.
+
+There is no browser page for native editing yet; the comparative frontend (order 6) is where one
+belongs.
+
## Follow-ups after the viewer
-Each is separate from slice 1 and from the others:
+Each is separate from the slices above and from the others:
-- Native editing with machinery/last-applied protections and a source-bound write contract.
- Native resume, streaming lists and pagination.
- Comparative view and sharing measurements.
- Independent gateway continuation and save-progress evaluation.
- The independently proposed editor API cleanup in proposal 0009.
-Native editing is a separate slice: define the editable policy, exclude machinery and last-applied
-annotation changes, and bind host reads/writes to the same source and UID. Preserve later typing,
-submitted intent and reconciliation guards. A native response must not recover a projected editor.
-Keep [the editor model](../client-state-model.md) and [saving guide](../saving.md) as the detailed contracts.
-
Resumable native watches, streaming-list initialization and pagination are later improvements with
their own recovery tests. Slice 1 improves adoption and lifecycle reuse; re-listing on every reconnect
does not claim resume efficiency. Gateway upstream continuation has a different checkpoint owner and
@@ -115,7 +160,7 @@ can proceed independently. Neither implies browser replay for gateway SSE v1.
After the viewer works, compare native/full/spec and shared/unshared gateway runs using the same
objects and churn. Demonstrate Secret disclosure, downstream bytes/events, notifications/renders,
-snapshots, authorization work and upstream watches. Add editing once its native contract exists.
+snapshots, authorization work and upstream watches. Include editing on both sources.
Keep this comparison and the larger benchmark outside slice 1; begin with the small viewer.
## Save progress under suppressed churn
diff --git a/docs/migrating.md b/docs/migrating.md
index 15725ba..1ab1d77 100644
--- a/docs/migrating.md
+++ b/docs/migrating.md
@@ -4,8 +4,10 @@ The release after 0.7 has one browser connector and one name for each gateway se
removed rather than kept as aliases, so the compiler or a failed import finds every call site. The
editor store's methods (`setValue`, `captureSave`, `captureReconciliation`, `adoptSaved` and so on)
are unchanged on this branch. Proposed editor removals in proposal 0009 are not migration requirements
-yet. The read-only native connector, `connectNativeWatch`, is a new addition rather than a migration
-step, and uses today's standalone state-input helper. Gateway pages keep `connectResourceStream`.
+yet, and the one edit-policy change is [below](#editor-store). The native connector,
+`connectNativeWatch`, and `nativeObjectURL` for native editing are new additions rather than
+migration steps, and use today's standalone state-input helper. Gateway pages keep
+`connectResourceStream`.
For the overall direction, see [watching resources](why-a-gateway.md).
## Browser client
@@ -65,6 +67,19 @@ Three behaviors to account for:
that feeds a store from its own transport passes the same shapes: `reset`, `added` or `modified`
with `object`, `deleted` with `identity`, and `synced`.
+### Editor store
+
+`metadata.managedFields` and the `kubectl.kubernetes.io/last-applied-configuration` annotation are
+read-only under every policy, as they already were on the gateway's side through
+`ValidateMergePatch`. A map that holds one of them, or a redacted value, is now edited key by key:
+
+| 0.7 | Now |
+|---|---|
+| `setValue`/`removeKey` on `["metadata", "annotations"]` as a whole | refused as read-only; set or remove each annotation key instead |
+| `addKey`/`setValue` for the last-applied annotation, or anything under `managedFields` with a policy that made it editable | refused as read-only |
+| `isEditable(uid, ["metadata", "annotations"])` | `false`; `isDirty` on it still reports edits underneath |
+| A new key in a Secret's `data` beside redacted values was accepted but never reached `changes()` or `patch()` | it is an ordinary edit in the patch and survives later events |
+
## Go gateway
`Options` and `Gateway` embed one `StreamConfig`, so the settings they share are written inside it:
diff --git a/docs/proposals/0006-stream-and-save-implementation-plan.md b/docs/proposals/0006-stream-and-save-implementation-plan.md
index 3a625cd..770c099 100644
--- a/docs/proposals/0006-stream-and-save-implementation-plan.md
+++ b/docs/proposals/0006-stream-and-save-implementation-plan.md
@@ -2,13 +2,13 @@
**Status: active delivery plan, updated 2026-10-05.** This is the single inventory of completed work,
open work and ordering. The implementation includes connector separation, Go API cleanup,
-real-API save-composition tests and the read-only native viewer. Detailed contracts stay in the linked proposals and guides
-rather than becoming another roadmap.
+real-API save-composition tests, the native viewer and native editing. Detailed contracts stay in the
+linked proposals and guides rather than becoming another roadmap.
Start with [watching resources](../why-a-gateway.md); add the [editor](../client-state-model.md) only
where a page needs drafts. Hosts with a Kubernetes proxy can read native collections with the
-read-only native connector; the gateway remains the projected source with redaction, suppression and
-sharing, delivered over SSE.
+native connector and write edits back through the same proxy; the gateway remains the projected
+source with redaction, suppression and sharing, delivered over SSE.
Follow the [design rules](../../CONTRIBUTING.md#design-rules) and [release policy](../releasing.md).
[Proposal 0005](0005-kubernetes-stream-and-save-semantics.md) owns the stream/save tradeoffs.
@@ -30,6 +30,7 @@ remain separate delivery steps.
| Editor integration and recovery recipes | Tested deletion recovery, keep-local resolution and Vue subscription ownership | [Recipes](../../examples/editor-recipes/README.md), [Vue](../../examples/vue/README.md) |
| Real-API save composition | Cases for suppressed churn, guarded-read overlap, structured errors and same-name UID replacement | [Completed baseline](#completed-baseline) |
| Native viewer (slice 1) | `connectNativeWatch` and `nativeCollectionURL`: unpaginated LIST, WATCH from the collection RV, fresh LIST on reconnect, native 410 recovery and terminal refusals on a lifecycle shared with the gateway connector; fake-fetch, Chromium and real-API tests; minimal example | [Request](../field-reports/third-our-identity.md#slice-1-a-native-viewer), [example](../../examples/native-viewer/README.md), [client](../../packages/krm-stream/README.md#native-connections) |
+| Native editing (slice 2) | Machinery (`managedFields`, last-applied annotation) read-only in the store under every policy; maps holding a protected path merged key by key; `nativeObjectURL`; `gateway.ValidateNativeMergePatch` for host proxies; a native conditional editor with guarded native recovery reads; fake-fetch and real-API tests | [Request](../field-reports/third-our-identity.md#slice-2-native-editing), [example](../../examples/native-editor/README.md), [saving](../saving.md#native-editing-through-a-host-proxy) |
| Watch-first documentation | README, watch/edit/save guides, native-fetch request and compact decision records; duplicate/superseded guides removed | [README](../../README.md), [current request](../field-reports/third-our-identity.md) |
## Open work and delivery order
@@ -40,23 +41,23 @@ before starting a later one. Deliver separately reviewable changes and use the p
| Order | Open work | Dependencies and completion |
|---|---|---|
| 1 | Native viewer — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-1). |
-| 2 | Native editing | After the viewer; define source-bound edit policy and host validation, protect machinery fields and preserve save guards. Coordinate any public editor changes with proposal 0009. |
+| 2 | Native editing — **completed** | See [completed work](#completed-work); acceptance evidence is recorded in the [request](../field-reports/third-our-identity.md#acceptance-for-slice-2). No editor method changed, so proposal 0009's cleanup is unaffected. |
| 3 | Native reconnect efficiency | After the viewer; evaluate resumable watches, streaming-list initialization and pagination with explicit membership/checkpoint tests. Do not claim these efficiencies for slice 1. |
| 4 | Gateway upstream continuation | Independent track with a design below; review reopen/credential bounds, record a baseline, implement and measure. No dependency on native transport. |
| 5 | Save progress during suppressed churn | Independent evaluation using existing real-API fixtures; bounded submitted-intent recovery first, optional version delivery second. Implementation depends on measured benefit and reviewed scope. |
-| 6 | Comparative frontend and measurements | Follow working native viewing; add editing after order 2. Compare native/full/spec and shared/unshared workloads; the larger project is outside slice 1. |
+| 6 | Comparative frontend and measurements | Follow working native viewing and editing. Compare native/full/spec and shared/unshared workloads; the larger project is outside slice 1. |
| Separate | Editor API cleanup in proposal 0009 | Independently proposed. Keep current standalone `applyStreamEvent` in slice 1; neither bound methods nor a new read-only store are required for native transport. |
| Per change | Validation, release and host adoption | Release ready increments under the existing lockstep policy; do not wait for every track. Check each change's final-commit CI, artifact/API notes and host acceptance. |
### Next implementation
-The native viewer is in place: native frame parsing stays separate from SSE, and both connectors share
-the lifecycle and HTTP helpers. It re-lists on every reconnect and refuses paginated responses; it
-claims no resume efficiency. Choose the next change from orders 2 and 3, or from the independent
-tracks; none of them waits for another.
+The native viewer and native editing are in place: native frame parsing stays separate from SSE,
+both connectors share the lifecycle and HTTP helpers, and native writes go back through the host
+proxy as conditional merge patches. The connector re-lists on every reconnect and refuses paginated
+responses; it claims no resume efficiency. Choose the next change from order 3 or from the
+independent tracks; none of them waits for another.
-Native editing (order 2) builds on the viewer without waiting for resume or streaming lists. Native
-reconnect efficiency (order 3) needs its own membership and checkpoint tests. Keep the current
+Native reconnect efficiency (order 3) needs its own membership and checkpoint tests. Keep the current
standalone state-input helper; proposed editor removals remain in
[proposal 0009](0009-stream-and-editor-separation.md#proposed-editor-cleanup), with brief API change notes
when implemented. Gateway continuation and save measurements proceed independently. Retain the existing
diff --git a/docs/saving.md b/docs/saving.md
index 697236a..1e9f90d 100644
--- a/docs/saving.md
+++ b/docs/saving.md
@@ -18,9 +18,9 @@ accepted write and application progress are separate states; neither a clean for
proves a rollout or Git workflow completed. A definite 409 rejection differs from a lost response
whose write outcome is unknown; do not blindly replay an ambiguous write.
-This guide describes projected editing through the current gateway. The native connector is
-read-only. Native editing is a later slice requiring an explicit read/write and
-edit-policy contract before sharing these recipes. A raw response must never be fed into a
+Most of this guide describes projected editing through the gateway. Editing native objects through
+a host's Kubernetes proxy follows the same sequence with a different write contract; see
+[native editing](#native-editing-through-a-host-proxy). A native response must never be fed into a
projected editor as recovery or save confirmation.
The [conditional-save example](../examples/conditional-save/README.md) implements all of it: a
@@ -172,6 +172,51 @@ host-defined lifetime; it is for recovery, not a second draft to reconcile. The
[recovery-copy recipe](../examples/editor-recipes/README.md#recover-work-after-a-deletion) does
this. Resolve conflicts through the store's APIs rather than a second application conflict registry.
+## Native editing through a host proxy
+
+A page reading native objects with `connectNativeWatch` can edit them through the same proxy. The
+store, its default edit policy and the four steps above are unchanged; what differs is who adds the
+preconditions and what a recovery read returns.
+
+| | Projected editing (gateway) | Native editing (host proxy) |
+|---|---|---|
+| Write | The host endpoint adds `uid` and `resourceVersion` from the intent to its PATCH | The browser sends the intent as a JSON merge PATCH with both inside `metadata`, to the object's URL through the proxy |
+| Host check | `gateway.ValidateMergePatch` with the stream's projection and the current object | `gateway.ValidateNativeMergePatch`: both preconditions present, no machinery |
+| Recovery read | A projected GET with `redactedPaths` | A native GET of the same object; nothing is withheld, so there is no redaction metadata |
+| Protected | Redacted values, removed machinery, `status` under `krm-spec/v1` | `metadata.managedFields` and the last-applied annotation; `status` stays outside the default editable regions |
+
+```ts
+const intent = store.captureSave(uid); // before any await
+if (intent) {
+ const metadata = { ...intent.patch.metadata, uid: intent.uid, resourceVersion: intent.resourceVersion };
+ await fetch(nativeObjectURL("/k8s", { ...scope, namespace, name }), {
+ method: "PATCH",
+ headers: { "Content-Type": "application/merge-patch+json" },
+ body: JSON.stringify({ ...intent.patch, metadata }),
+ });
+}
+```
+
+The [native editor example](../examples/native-editor/README.md) implements the whole sequence: 409
+recovery through a guarded native read, replaced and deleted objects, and refused writes. Its
+outcomes match the [table above](#what-the-person-editing-sees). It never writes twice without
+knowing what the first write did: after a network failure or a 5xx, or an accepted write whose echo
+has not arrived, the next Save is a guarded read, and `confirm()` performs that read when the host
+stops waiting for an echo.
+
+Machinery is protected on both sides. The store makes `metadata.managedFields` and the
+`kubectl.kubernetes.io/last-applied-configuration` annotation read-only under every policy, so no
+patch can carry them. The host proxy refuses them again with `ValidateNativeMergePatch`, which also
+refuses a patch without both preconditions. The proxy accepts only
+`application/merge-patch+json`, forwards as the signed-in person and keeps its own write
+authorization and audit; Kubernetes RBAC still applies. The browser never holds a credential.
+
+A native write's response is the complete written object, Secret values included. Do not adopt it:
+the watch delivers the same object as an ordinary event. Native objects have no projection, so the
+[quiet-stream case](#why-a-quiet-stream-can-still-reject-a-save) does not arise: every change
+to the object, status included, reaches the store with its version. A 409 still means another write
+landed between capture and PATCH, or that the object was replaced under the same name.
+
## Creating and deleting whole objects
A create and a delete are host writes exactly as a save is: the client stages the *intent*, your
diff --git a/docs/why-a-gateway.md b/docs/why-a-gateway.md
index 27e38f9..1bd3e92 100644
--- a/docs/why-a-gateway.md
+++ b/docs/why-a-gateway.md
@@ -95,12 +95,12 @@ Both sources use fetch; native Kubernetes JSON and gateway SSE are different fra
both can carry errors. Both connectors handle refusal, expiry, cancellation and bounded retries
through one shared lifecycle; each classifies its own framing and HTTP responses. Browser `EventSource` is a different client mechanism with header/retry limitations.
-The native connector is a read-only viewer source: an ordinary LIST, then a WATCH from the
-collection's resourceVersion, with a fresh LIST on every reconnect. It uses the shared lifecycle and
-`LiveResourceStore(readOnlyPolicy)`, and is `live` only once the snapshot is applied and the WATCH is
-accepted. HTTP/in-stream 410 recovers within the bounded policy; terminal auth refusal never selects
-another source. A paginated LIST response is refused rather than marked complete. Native editing,
-resume, streaming lists and pagination follow separately.
+The native connector reads with an ordinary LIST, then a WATCH from the collection's
+resourceVersion, with a fresh LIST on every reconnect. It uses the shared lifecycle and
+`LiveResourceStore`, and is `live` only once the snapshot is applied and the WATCH is accepted. HTTP/in-stream 410 recovers within the bounded policy; terminal auth refusal never selects
+another source. A paginated LIST response is refused rather than marked complete.
+[Native editing](saving.md#native-editing-through-a-host-proxy) writes conditional merge patches back
+through the same proxy. Resume, streaming lists and pagination follow separately.
See the [native usage snippet](../README.md#watch-native-resources-through-a-host-proxy), the
[client reference](../packages/krm-stream/README.md#native-connections) and the
diff --git a/examples/README.md b/examples/README.md
index 5542a45..b7e6f7e 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -6,7 +6,9 @@ optional editor.
[Native viewer](native-viewer/README.md) is a read-only page that lists and watches native resources
through an existing host proxy, such as `kubectl proxy`, with the shared lifecycle and read-only store.
The checked browser example in [`vanilla-browser/`](vanilla-browser/) runs against the gateway source
-over SSE. Native editing and the larger view/sharing comparison follow separately.
+over SSE. [Native editor](native-editor/README.md) adds conditional saves through the same proxy,
+with machinery protection and guarded native recovery reads. The larger view/sharing comparison
+follows separately.
For gateway host integration patterns, use these small recipes:
- [Same-origin cookie application](../docs/adopting.md#1-mount-the-stream-endpoint): fetch
diff --git a/examples/native-editor/README.md b/examples/native-editor/README.md
new file mode 100644
index 0000000..481a190
--- /dev/null
+++ b/examples/native-editor/README.md
@@ -0,0 +1,103 @@
+# Native conditional editor
+
+Add this controller when a page watching native resources through a host proxy also needs to edit
+them. It composes `connectNativeWatch`, `LiveResourceStore` under the default edit policy and
+`nativeObjectURL`. Writes go through the same proxy and collection the watch reads, under the
+[editor model](../../docs/client-state-model.md) and the
+[native write contract](../../docs/saving.md#native-editing-through-a-host-proxy). It performs no
+automatic write retry.
+
+- [editor.ts](editor.ts) captures a save intent synchronously and sends it as a JSON merge PATCH
+ with the captured `metadata.uid` and `metadata.resourceVersion` inside it. On 409 it reconciles a
+ guarded native GET of the same object. After a write whose outcome is unknown, or an accepted
+ write whose echo has not arrived, the next Save reads instead of writing again; `confirm()` does
+ that read on the host's schedule. It keeps in-flight edits and never adopts a write response.
+- [Native editing tests](../../packages/krm-stream/test/native-editing.test.ts) cover the request
+ shape, machinery protection, 409 recovery and conflicts, replaced and deleted objects, refused
+ writes, unknown write outcomes, confirmation with and without an echo, reads overtaken by the
+ watch, and the live-state check.
+- `TestRealAPINativeEditThroughHostProxy` (`task test-real-api`) runs this editor and the real
+ connector through a credential-holding `/k8s` proxy that validates each PATCH with
+ `gateway.ValidateNativeMergePatch`. Against a real API server it checks a save and its echo, a
+ competing write between capture and PATCH, the proxy refusing unconditional and machinery
+ patches, and an object replaced under the same name.
+
+```ts
+const proxy = "/k8s";
+const scope = { version: "v1", resource: "configmaps", namespace: "app", name: "settings" };
+const store = new LiveResourceStore(); // defaultPolicy: spec, labels, annotations, data, stringData
+const connection = connectNativeWatch(nativeCollectionURL(proxy, scope), event => applyStreamEvent(store, event));
+const stopConnection = connection.subscribe(state => renderConnection(state));
+connection.closed.catch(reportApplicationError);
+// Once the object is in the store:
+const editor = nativeEditor(store, uid, { proxy, scope }, hostFetch, () => connection.state.status === "live");
+const unsubscribe = store.subscribe(renderEditor);
+// Enable Save only while connection.state.status === "live" and !editor.saving.
+// On Save: await editor.save(), then render errors/conflicts and the current draft.
+// After "saved" with no echo within the host's patience: await editor.confirm().
+// On unmount:
+unsubscribe();
+stopConnection();
+connection.close();
+```
+
+Pass the editor the same `proxy` and `scope` that built the watch URL. It addresses the object under
+its own namespace and name, so a collection watched across namespaces works too. Keep one store per
+source, scope and login identity. Never point this editor at a projected store, and never give a
+projected editor a native response.
+
+## What it protects
+
+- **Machinery.** `metadata.managedFields` and the `kubectl.kubernetes.io/last-applied-configuration`
+ annotation are read-only in the store under every policy. They follow the server and never enter
+ a patch. Other annotations stay editable one key at a time; the annotation map as a whole cannot be
+ replaced or removed, because that would rewrite the last-applied annotation inside it.
+- **Concurrency.** The captured `resourceVersion` makes Kubernetes refuse a write based on a version
+ the person never saw. The captured `uid` keeps a draft off an object deleted and recreated under the
+ same name: Kubernetes checks the version first, so that also answers 409, and the guarded read then
+ finds a different UID and reports `unavailable`.
+- **Source.** Recovery reads the native object from the same proxy and refuses anything that is not a
+ Kubernetes object with this UID, such as a projected endpoint's `{ object, redactedPaths }`.
+
+Native objects carry Secret values. A page that edits a Secret natively shows and sends base64 `data`
+values; use a gateway projection when a page must not disclose them.
+
+## Outcomes
+
+`saved`, `unchanged`, `busy`, `draft-conflict`, `version-stale`, `recovering` and `unavailable` mean
+what they mean for the [projected conditional editor](../conditional-save/README.md), and the
+[saving guide's table](../../docs/saving.md#what-the-person-editing-sees) applies unchanged. A refused
+write or read throws `NativeRequestError` with the HTTP status and the Kubernetes `Status`, so a 422
+keeps its field causes. A 422 whose cause is `metadata.uid` is an identity precondition, and the
+editor recovers from it as from a 409.
+
+`confirmed` is the one outcome the projected editor does not have: a guarded read, not a write,
+established the server's current state. Fields still dirty were not written, by this Save or by an
+earlier one; the next deliberate Save writes them.
+
+**Never two writes without knowing what the first did.** A network failure or a 5xx (a proxy's 502
+included) throws, but the write may have landed, so the next Save performs a guarded read and
+returns `confirmed` or another recovery outcome instead of sending the patch again. A 4xx is a
+definite refusal and owes no read: correct the draft and save again.
+
+A successful write's response is the written object. The editor does not read it: the watch delivers
+the same object as an ordinary event, and adopting the response could overtake a newer one. Until
+that echo arrives (the store holds any version after the one the write was based on), the next Save
+reads instead of writing. A write that leaves the object unchanged, such as one an admission webhook
+reverts, produces no event at all; call `editor.confirm()` when the host stops waiting for an echo.
+It reads without writing, and the reverted fields stay dirty.
+
+## The host proxy
+
+The browser writes through the host's proxy, which forwards as the signed-in person; Kubernetes RBAC
+still decides what that person may write. Before forwarding a `PATCH`, the proxy should:
+
+- accept only `Content-Type: application/merge-patch+json`. A JSON Patch or an apply patch has other
+ semantics;
+- bound the body size and run `gateway.ValidateNativeMergePatch` on it. That refuses a patch without
+ both preconditions or one touching managed fields or the last-applied annotation;
+- decide which resources and fields each user may write, and audit writes, exactly as it decides
+ what they may read.
+
+The test proxy in [native_e2e_test.go](../../gateway/kube/native_e2e_test.go) does these checks
+before an `httputil.ReverseProxy` forwards the request with the host's credentials.
diff --git a/examples/native-editor/editor.ts b/examples/native-editor/editor.ts
new file mode 100644
index 0000000..62f76bf
--- /dev/null
+++ b/examples/native-editor/editor.ts
@@ -0,0 +1,177 @@
+import {
+ type KRMObject,
+ type LiveResourceStore,
+ type NativeScope,
+ nativeObjectURL,
+} from "../../packages/krm-stream/src/index.ts";
+
+export type SaveOutcome =
+ | "unchanged"
+ | "busy"
+ | "saved"
+ | "confirmed"
+ | "draft-conflict"
+ | "version-stale"
+ | "recovering"
+ | "unavailable";
+
+/** A write or read the proxy refused, with the Kubernetes Status it answered: a 422 keeps its field
+ * causes for the form, a 403 its reason. */
+export class NativeRequestError extends Error {
+ readonly httpStatus: number;
+ readonly status: unknown;
+
+ constructor(httpStatus: number, status: unknown) {
+ const message = isRecord(status) && typeof status.message === "string" ? status.message : `HTTP ${httpStatus}`;
+ super(`native request refused: ${message}`);
+ this.httpStatus = httpStatus;
+ this.status = status;
+ }
+}
+
+/** Copy into the host. Edits one object of a native watch through the host proxy that watch reads.
+ *
+ * Pass the same `proxy` and `scope` given to nativeCollectionURL for the store's connectNativeWatch:
+ * the editor reads and writes the object at that collection under its own namespace and name, so
+ * its writes and recovery reads come from the source its draft was built from. request owns
+ * session/CSRF policy; isLive reads that connection's state. */
+export function nativeEditor(
+ store: LiveResourceStore,
+ uid: string,
+ source: { proxy: string; scope: NativeScope },
+ request: typeof fetch,
+ isLive: () => boolean,
+) {
+ const { namespace, name } = store.server(uid).metadata;
+ const url = nativeObjectURL(source.proxy, { ...source.scope, namespace, name });
+ let saving = false;
+ // The guarded read owed before the next write: "stale" after a version rejection, "confirm" after
+ // a write whose outcome is unknown.
+ let owed: "stale" | "confirm" | undefined;
+ // The version an accepted write was based on, until its echo or a confirming read moves past it.
+ let written: string | undefined;
+
+ /** The read owed before the next write, if any. An accepted write is confirmed once the store
+ * holds any later version: the write was conditional on `written`, so the next version is the
+ * write itself and every one after it follows it. */
+ function due(): "stale" | "confirm" | undefined {
+ if (written !== undefined && store.server(uid).metadata.resourceVersion !== written) written = undefined;
+ return owed ?? (written === undefined ? undefined : "confirm");
+ }
+
+ async function read(reason: "stale" | "confirm"): Promise {
+ owed = reason;
+ const reconcile = store.captureReconciliation(uid);
+ const latest = await request(url, { cache: "no-store", headers: { Accept: "application/json" } });
+ if (latest.status === 404) return "unavailable";
+ if (!latest.ok) throw new NativeRequestError(latest.status, await statusOf(latest));
+ const object: unknown = await latest.json();
+ // A native read answers with the object itself. Anything else is not this source.
+ if (!isRecord(object) || !isRecord(object.metadata)) throw new Error("native read: not a Kubernetes object");
+ if (!store.ids().includes(uid) || object.metadata.uid !== uid) return "unavailable";
+ // No redaction metadata: a native object withholds nothing.
+ const accepted = reconcile(object as KRMObject);
+ // false has several causes. Do not infer readiness from it or force a refused response.
+ // A later Save attempts only another guarded read until one is accepted while live.
+ if (!isLive()) return "recovering";
+ if (accepted) {
+ // The base now holds the server's current state: whatever the write did, or did not do, is
+ // visible as the remaining dirty fields.
+ owed = undefined;
+ written = undefined;
+ }
+ if (store.conflicts(uid).length) return "draft-conflict";
+ if (!accepted) return "recovering";
+ return reason === "stale" ? "version-stale" : "confirmed";
+ }
+
+ return {
+ get saving() {
+ return saving;
+ },
+ async save(): Promise {
+ if (saving) return "busy";
+ if (!store.ids().includes(uid)) return "unavailable";
+ if (!isLive()) return "recovering";
+ if (store.conflicts(uid).length) return "draft-conflict";
+ saving = true;
+ try {
+ const owedRead = due();
+ if (owedRead) return await read(owedRead);
+ const intent = store.captureSave(uid); // Patch, UID and base captured before any await.
+ if (!intent) return "unchanged";
+ // The preconditions travel inside the merge patch, where Kubernetes checks them atomically
+ // with the write. The store never puts identity or version in a patch itself.
+ const metadata = {
+ ...(intent.patch.metadata as object),
+ uid: intent.uid,
+ resourceVersion: intent.resourceVersion,
+ };
+ // Until an answer says otherwise, the write may have landed: establish that by a read
+ // before writing again, never by another write.
+ owed = "confirm";
+ const result = await request(url, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/merge-patch+json", Accept: "application/json" },
+ body: JSON.stringify({ ...intent.patch, metadata }),
+ });
+ if (result.ok) {
+ owed = undefined;
+ written = intent.resourceVersion;
+ // The response is the written object. The watch delivers it; adopting it here could
+ // overtake a newer event, so it is not read at all. Its echo, or confirm(), settles it.
+ await result.body?.cancel().catch(() => {});
+ return "saved";
+ }
+ // A 4xx is Kubernetes or the host refusing the write: it did not happen. A 5xx — the
+ // proxy's 502 included — leaves the outcome unknown, and the read stays owed.
+ if (result.status < 500) owed = undefined;
+ // Deleted since its last event: the watch will remove it too.
+ if (result.status === 404) return "unavailable";
+ const status = await statusOf(result);
+ // Kubernetes checks the version before the UID, so a stale version and an object replaced
+ // under the same name both answer 409. A 422 on metadata.uid is the identity precondition.
+ if (result.status === 409 || (result.status === 422 && namesUID(status))) {
+ if (!store.ids().includes(uid)) return "unavailable";
+ return await read("stale");
+ }
+ throw new NativeRequestError(result.status, status);
+ } finally {
+ saving = false;
+ }
+ },
+ /** Read the object back through a guarded read, without writing: after a saved write whose
+ * echo has not arrived within the host's patience, or to settle an unknown outcome. `confirmed`
+ * means the draft's base is the server's current state; fields still dirty were not written. */
+ async confirm(): Promise {
+ if (saving) return "busy";
+ if (!store.ids().includes(uid)) return "unavailable";
+ if (!isLive()) return "recovering";
+ saving = true;
+ try {
+ return await read(owed ?? "confirm");
+ } finally {
+ saving = false;
+ }
+ },
+ };
+}
+
+/** The Kubernetes Status a refusal carries, or undefined. */
+async function statusOf(response: Response): Promise {
+ try {
+ return await response.json();
+ } catch {
+ return undefined;
+ }
+}
+
+function namesUID(status: unknown): boolean {
+ const details = isRecord(status) ? status.details : undefined;
+ const causes = isRecord(details) ? details.causes : undefined;
+ return Array.isArray(causes) && causes.some((cause) => isRecord(cause) && cause.field === "metadata.uid");
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/examples/native-viewer/README.md b/examples/native-viewer/README.md
index ee91263..066fe74 100644
--- a/examples/native-viewer/README.md
+++ b/examples/native-viewer/README.md
@@ -35,8 +35,9 @@ browser never holds a Kubernetes credential.
- Objects are exactly what the proxy returns, Secret values and machinery fields included. Native
access provides no projection, redaction, suppression or shared watches; use the
[gateway](../../docs/why-a-gateway.md) for those.
-- It is a viewer. Native editing, resumable watches and paginated lists are later work; see the
- [delivery plan](../../docs/proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order).
+- It is a viewer. To edit through the same proxy, add the
+ [native editor](../native-editor/README.md). Resumable watches and paginated lists are later work;
+ see the [delivery plan](../../docs/proposals/0006-stream-and-save-implementation-plan.md#open-work-and-delivery-order).
`task e2e-browser` loads this page in Chromium on both entry points, with Playwright standing in for
the host proxy: [native-viewer.spec.ts](../vanilla-browser/tests/native-viewer.spec.ts).
diff --git a/gateway/README.md b/gateway/README.md
index 2c899a2..5a6ebdf 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -66,7 +66,9 @@ automatically redacted. Suppression compares projected content excluding RV plus
a hidden Secret rotation still emits an update. It reduces downstream work, not incoming watch changes.
The gateway never writes. `ValidateMergePatch` checks a browser's patch against the projection before
-the host sends it; see [saving](../docs/saving.md).
+the host sends it; see [saving](../docs/saving.md). A host proxy forwarding native writes runs
+`ValidateNativeMergePatch` instead: it requires the captured UID and resourceVersion preconditions
+and refuses machinery; see [native editing](../docs/saving.md#native-editing-through-a-host-proxy).
## Shared backends
diff --git a/gateway/kube/native_e2e_test.go b/gateway/kube/native_e2e_test.go
index 5561ad9..d4e7134 100644
--- a/gateway/kube/native_e2e_test.go
+++ b/gateway/kube/native_e2e_test.go
@@ -1,16 +1,18 @@
//go:build e2e
-// Native viewing against a real API server: the browser's connectNativeWatch, run by node
-// (packages/krm-stream/e2e/native-driver.ts), through a host-style /k8s proxy that holds the
-// credentials. No gateway is involved; this checks that a real LIST and WATCH — typed collections
-// whose items carry no apiVersion or kind, selector exits, deletes and same-name recreation — reach
-// the read-only store as the connector promises.
+// Native viewing and editing against a real API server: the browser's connectNativeWatch and the
+// native editor example, run by node (packages/krm-stream/e2e/native-driver.ts), through a host-style
+// /k8s proxy that holds the credentials. No gateway is involved; this checks that a real LIST and
+// WATCH — typed collections whose items carry no apiVersion or kind, selector exits, deletes and
+// same-name recreation — reach the store as the connector promises, and that a conditional native
+// write through the same proxy saves, recovers from a stale version and never lands on a replacement.
//
// task test-real-api
package kube_test
import (
"bufio"
+ "bytes"
"context"
"encoding/json"
"io"
@@ -23,17 +25,41 @@ import (
"path/filepath"
"slices"
"strings"
+ "sync"
"testing"
"time"
+ "github.com/ConfigButler/krm-stream/gateway"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
)
-// nativeProxy is the host: it serves the API server's paths under /k8s with its own credentials, for
-// GET only. The browser never holds a Kubernetes credential.
-func nativeProxy(t *testing.T) *httptest.Server {
+// nativeHost is the host: it serves the API server's paths under /k8s with its own credentials. The
+// browser never holds a Kubernetes credential. It forwards GET, and — once writable — a PATCH that is
+// a conditional JSON merge patch gateway.ValidateNativeMergePatch accepts.
+type nativeHost struct {
+ *httptest.Server
+ mu sync.Mutex
+ // beforePatch runs before the next accepted PATCH is forwarded, once: the place a test lands a
+ // competing write between the browser capturing its save and Kubernetes checking it.
+ beforePatch func()
+ patches []int // the API server's answer to each forwarded PATCH
+}
+
+func (h *nativeHost) nextPatch(hook func()) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.beforePatch = hook
+}
+
+func (h *nativeHost) answered() []int {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return slices.Clone(h.patches)
+}
+
+func nativeProxy(t *testing.T, writable bool) *nativeHost {
t.Helper()
cfg := restConfig(t)
transport, err := rest.TransportFor(cfg)
@@ -44,6 +70,7 @@ func nativeProxy(t *testing.T) *httptest.Server {
if err != nil {
t.Fatalf("API server address: %v", err)
}
+ host := &nativeHost{}
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.Out.URL.Scheme = target.Scheme
@@ -55,35 +82,89 @@ func nativeProxy(t *testing.T) *httptest.Server {
Transport: transport,
// A watch is a long response; every frame must reach the browser as it is written.
FlushInterval: -1,
+ ModifyResponse: func(res *http.Response) error {
+ if res.Request.Method == http.MethodPatch {
+ host.mu.Lock()
+ host.patches = append(host.patches, res.StatusCode)
+ host.mu.Unlock()
+ }
+ return nil
+ },
}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/k8s/") {
+ host.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasPrefix(r.URL.Path, "/k8s/") {
http.Error(w, "not proxied", http.StatusNotFound)
return
}
+ switch {
+ case r.Method == http.MethodGet:
+ case r.Method == http.MethodPatch && writable:
+ // Only a JSON merge patch: a JSON Patch or an apply patch has semantics the check below
+ // does not cover.
+ if r.Header.Get("Content-Type") != "application/merge-patch+json" {
+ refuse(w, http.StatusUnsupportedMediaType, "only application/merge-patch+json is forwarded")
+ return
+ }
+ patch, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
+ if err != nil {
+ refuse(w, http.StatusRequestEntityTooLarge, "patch too large")
+ return
+ }
+ if err := gateway.ValidateNativeMergePatch(patch); err != nil {
+ refuse(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ r.Body = io.NopCloser(bytes.NewReader(patch))
+ r.ContentLength = int64(len(patch))
+ host.mu.Lock()
+ hook := host.beforePatch
+ host.beforePatch = nil
+ host.mu.Unlock()
+ if hook != nil {
+ hook()
+ }
+ default:
+ refuse(w, http.StatusMethodNotAllowed, "not proxied")
+ return
+ }
proxy.ServeHTTP(w, r)
}))
t.Cleanup(func() {
- server.CloseClientConnections()
- server.Close()
+ host.CloseClientConnections()
+ host.Close()
+ })
+ return host
+}
+
+// refuse answers as the API server would, with a Status the editor can show.
+func refuse(w http.ResponseWriter, code int, message string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(metav1.Status{
+ TypeMeta: metav1.TypeMeta{Kind: "Status", APIVersion: "v1"},
+ Status: metav1.StatusFailure,
+ Code: int32(code), //nolint:gosec // an HTTP status code
+ Message: message,
})
- return server
}
type nativeMember struct {
- UID string `json:"uid"`
- Name string `json:"name"`
- Kind string `json:"kind"`
- Data map[string]string `json:"data"`
+ UID string `json:"uid"`
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ ResourceVersion string `json:"resourceVersion"`
+ Data map[string]string `json:"data"`
}
// nativeLine is one line the driver prints: a connection state, a state event with what the store
-// then holds, or an error.
+// then holds, a save's outcome, or an error.
type nativeLine struct {
- State string `json:"state"`
- Event string `json:"event"`
- Members []nativeMember `json:"members"`
- Error *struct {
+ State string `json:"state"`
+ Event string `json:"event"`
+ Members []nativeMember `json:"members"`
+ Saved string `json:"saved"`
+ CommandError string `json:"commandError"`
+ Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Terminal bool `json:"terminal"`
@@ -97,13 +178,18 @@ type nativeDriver struct {
members []nativeMember
}
-func newNativeDriver(t *testing.T, collectionURL string) *nativeDriver {
+// newNativeDriver starts the browser side on the collection that scope names behind proxy.
+func newNativeDriver(t *testing.T, proxy string, scope map[string]string) *nativeDriver {
t.Helper()
+ scopeJSON, err := json.Marshal(scope)
+ if err != nil {
+ t.Fatal(err)
+ }
node, err := exec.LookPath("node")
if err != nil {
t.Fatalf("node runs the browser connector in this test: %v", err)
}
- cmd := exec.Command(node, filepath.Join("..", "..", "packages", "krm-stream", "e2e", "native-driver.ts"), collectionURL) //nolint:gosec // a fixed path in this repository
+ cmd := exec.Command(node, filepath.Join("..", "..", "packages", "krm-stream", "e2e", "native-driver.ts"), proxy, string(scopeJSON)) //nolint:gosec // a fixed path in this repository
cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
@@ -148,6 +234,9 @@ func (d *nativeDriver) await(what string, pred func(nativeLine) bool) nativeLine
if line.Error != nil {
d.t.Fatalf("%s: connector error %s: %s (terminal %v)", what, line.Error.Code, line.Error.Message, line.Error.Terminal)
}
+ if line.CommandError != "" {
+ d.t.Fatalf("%s: command failed: %s", what, line.CommandError)
+ }
if line.Event != "" {
d.members = line.Members
}
@@ -228,8 +317,10 @@ func TestRealAPINativeWatchThroughHostProxy(t *testing.T) {
create("b", "web", "1")
create("c", "db", "1")
- proxy := nativeProxy(t)
- d := newNativeDriver(t, proxy.URL+"/k8s/api/v1/namespaces/"+url.PathEscape(ns)+"/configmaps?labelSelector=tier%3Dweb")
+ proxy := nativeProxy(t, false)
+ d := newNativeDriver(t, proxy.URL+"/k8s", map[string]string{
+ "version": "v1", "resource": "configmaps", "namespace": ns, "labelSelector": "tier=web",
+ })
d.await("live", func(line nativeLine) bool { return line.State == "live" })
if got := d.names(); !slices.Equal(got, []string{"a", "b"}) {
@@ -267,3 +358,189 @@ func TestRealAPINativeWatchThroughHostProxy(t *testing.T) {
}
d.await("closed", func(line nativeLine) bool { return line.State == "closed" })
}
+
+func (d *nativeDriver) send(command map[string]any) {
+ d.t.Helper()
+ line, err := json.Marshal(command)
+ if err != nil {
+ d.t.Fatal(err)
+ }
+ if _, err := d.stdin.Write(append(line, '\n')); err != nil {
+ d.t.Fatal(err)
+ }
+}
+
+func (d *nativeDriver) set(name string, path []any, value any) {
+ d.t.Helper()
+ d.send(map[string]any{"op": "set", "name": name, "path": path, "value": value})
+}
+
+// holds waits until the store's members satisfy pred, checking what it already reported first: a
+// watch echo can arrive before the save that caused it reports its outcome.
+func (d *nativeDriver) holds(what string, pred func([]nativeMember) bool) {
+ d.t.Helper()
+ if pred(d.members) {
+ return
+ }
+ d.await(what, func(line nativeLine) bool { return line.Event != "" && pred(line.Members) })
+}
+
+// save asks the browser's native editor to save and returns its outcome.
+func (d *nativeDriver) save(name string) string {
+ d.t.Helper()
+ d.send(map[string]any{"op": "save", "name": name})
+ return d.await("save "+name, func(line nativeLine) bool { return line.Saved != "" }).Saved
+}
+
+func TestRealAPINativeEditThroughHostProxy(t *testing.T) {
+ cs, _ := clients(t)
+ ns := scratchNamespace(t, cs)
+ ctx := context.Background()
+ configMaps := cs.CoreV1().ConfigMaps(ns)
+ const lastApplied = "kubectl.kubernetes.io/last-applied-configuration"
+ const applied = `{"apiVersion":"v1","kind":"ConfigMap","data":{"mode":"a"}}`
+ // Safe to call from the proxy's goroutine: it reports with Errorf and returns nil.
+ create := func(mode string) *corev1.ConfigMap {
+ created, err := configMaps.Create(ctx, &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "settings",
+ Annotations: map[string]string{lastApplied: applied, "owner": "team-a"},
+ },
+ Data: map[string]string{"mode": mode, "other": "1"},
+ }, metav1.CreateOptions{})
+ if err != nil {
+ t.Errorf("create settings: %v", err)
+ }
+ return created
+ }
+ get := func() *corev1.ConfigMap {
+ t.Helper()
+ current, err := configMaps.Get(ctx, "settings", metav1.GetOptions{})
+ if err != nil {
+ t.Fatalf("get settings: %v", err)
+ }
+ return current
+ }
+ echoed := func(mode string) func([]nativeMember) bool {
+ return func(members []nativeMember) bool { return len(members) == 1 && members[0].Data["mode"] == mode }
+ }
+ original := create("a")
+ if original == nil {
+ t.FailNow()
+ }
+
+ proxy := nativeProxy(t, true)
+ // One object watched by name, as an editor page for it would: it can be deleted and recreated
+ // like any collection member.
+ d := newNativeDriver(t, proxy.URL+"/k8s", map[string]string{
+ "version": "v1", "resource": "configmaps", "namespace": ns, "name": "settings",
+ })
+ d.await("live", func(line nativeLine) bool { return line.State == "live" })
+
+ // A save goes through the proxy as a conditional merge patch, and the watch echoes it. The
+ // last-applied annotation the store holds is never part of it.
+ d.set("settings", []any{"data", "mode"}, "b")
+ d.set("settings", []any{"metadata", "annotations", "owner"}, "team-b")
+ if got := d.save("settings"); got != "saved" {
+ t.Fatalf("first save: want saved, got %s", got)
+ }
+ d.holds("first save echoed", echoed("b"))
+ if cm := get(); cm.Data["mode"] != "b" || cm.Data["other"] != "1" || cm.Annotations["owner"] != "team-b" ||
+ cm.Annotations[lastApplied] != applied {
+ t.Fatalf("first save: unexpected object %+v %+v", cm.Data, cm.Annotations)
+ }
+
+ // Another writer lands between the browser capturing its save and Kubernetes checking it. The
+ // stale version is refused, a guarded native read recovers the draft, and the next deliberate
+ // save lands: neither write is lost.
+ d.set("settings", []any{"data", "mode"}, "c")
+ proxy.nextPatch(func() {
+ current, err := configMaps.Get(ctx, "settings", metav1.GetOptions{})
+ if err == nil {
+ current.Data["other"] = "2"
+ _, err = configMaps.Update(ctx, current, metav1.UpdateOptions{})
+ }
+ if err != nil {
+ t.Errorf("competing update: %v", err)
+ }
+ })
+ outcomes := []string{d.save("settings")}
+ // The watch can apply the competing write before or after the guarded read's capture, so the
+ // first recovery either establishes the new base (version-stale) or owes another read.
+ for outcomes[len(outcomes)-1] != "saved" {
+ if last := outcomes[len(outcomes)-1]; (last != "version-stale" && last != "recovering") || len(outcomes) > 4 {
+ t.Fatalf("recovery from a stale version: outcomes %v", outcomes)
+ }
+ outcomes = append(outcomes, d.save("settings"))
+ }
+ t.Logf("recovery from a stale version: outcomes %v", outcomes)
+ if outcomes[0] == "saved" {
+ t.Fatal("the competing write did not make the captured save stale; the case proves nothing")
+ }
+ d.holds("second save echoed", echoed("c"))
+ if cm := get(); cm.Data["mode"] != "c" || cm.Data["other"] != "2" || cm.Annotations[lastApplied] != applied {
+ t.Fatalf("after recovery: want mode c and the competing other=2, got %+v %+v", cm.Data, cm.Annotations)
+ }
+
+ // The host forwards only conditional patches that leave machinery alone.
+ objectURL := proxy.URL + "/k8s/api/v1/namespaces/" + url.PathEscape(ns) + "/configmaps/settings"
+ rv := get().ResourceVersion
+ for _, patch := range []string{
+ `{"data":{"mode":"blind"}}`,
+ `{"metadata":{"uid":"` + string(original.UID) + `","resourceVersion":"` + rv + `","annotations":{"` + lastApplied + `":"{}"}}}`,
+ } {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch, objectURL, strings.NewReader(patch))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/merge-patch+json")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = res.Body.Close()
+ if res.StatusCode != http.StatusBadRequest {
+ t.Fatalf("patch %s: want the host to refuse it with 400, got %d", patch, res.StatusCode)
+ }
+ }
+ if cm := get(); cm.ResourceVersion != rv {
+ t.Fatalf("a refused patch reached the API server: resourceVersion %s → %s", rv, cm.ResourceVersion)
+ }
+
+ // The object is replaced under the same name between capture and write. The old draft never
+ // lands on the replacement.
+ d.set("settings", []any{"data", "mode"}, "meant for the original")
+ replaced := make(chan *corev1.ConfigMap, 1)
+ proxy.nextPatch(func() {
+ if err := configMaps.Delete(ctx, "settings", metav1.DeleteOptions{}); err != nil {
+ t.Errorf("delete settings: %v", err)
+ }
+ replaced <- create("replacement")
+ })
+ if got := d.save("settings"); got != "unavailable" {
+ t.Fatalf("save to a replaced object: want unavailable, got %s", got)
+ }
+ var replacement *corev1.ConfigMap
+ select {
+ case replacement = <-replaced:
+ default:
+ }
+ if replacement == nil || replacement.UID == original.UID {
+ t.Fatal("the object was not replaced; the case proves nothing")
+ }
+ if cm := get(); cm.UID != replacement.UID || cm.Data["mode"] != "replacement" {
+ t.Fatalf("the replacement was written: %+v", cm.Data)
+ }
+ d.holds("replacement observed", func(members []nativeMember) bool {
+ return len(members) == 1 && members[0].UID == string(replacement.UID)
+ })
+
+ if got, want := proxy.answered(), []int{http.StatusOK, http.StatusConflict, http.StatusOK, http.StatusConflict}; !slices.Equal(got, want) {
+ t.Fatalf("forwarded PATCH answers: want %v, got %v", want, got)
+ }
+
+ if _, err := io.WriteString(d.stdin, "close\n"); err != nil {
+ t.Fatal(err)
+ }
+ d.await("closed", func(line nativeLine) bool { return line.State == "closed" })
+}
diff --git a/gateway/patch.go b/gateway/patch.go
index 2952a77..5bc152a 100644
--- a/gateway/patch.go
+++ b/gateway/patch.go
@@ -51,14 +51,57 @@ func projectionProtectedPaths(projection Projection, object KRMObject) map[strin
// These paths are projection policy, not a property of this particular object. Rejecting an
// attempt to add one back is as important as rejecting an attempt to modify one that existed.
- paths["/metadata/managedFields"] = "removed by the projection"
- paths["/metadata/annotations/"+escapePointer(lastAppliedAnnotation)] = "removed by the projection"
+ for _, path := range machineryPaths {
+ paths[path] = "removed by the projection"
+ }
if projection == ProjectionSpec {
paths["/status"] = "ignored by the projection"
}
return paths
}
+// machineryPaths are written by the API server and by `kubectl apply`, never by a person editing an
+// object. Every projection removes them, and the browser store keeps them read-only for every source.
+var machineryPaths = []string{
+ "/metadata/managedFields",
+ "/metadata/annotations/" + escapePointer(lastAppliedAnnotation),
+}
+
+// ValidateNativeMergePatch checks an RFC 7386 JSON merge patch a browser sends to a host proxy that
+// forwards native Kubernetes requests, before the host forwards it.
+//
+// A native editor writes through the proxy its watch reads from, so the patch arrives with its
+// preconditions already in place: metadata.uid and metadata.resourceVersion, captured with the edit
+// and checked by Kubernetes atomically with the write. This refuses a patch without both, so the
+// proxy never forwards a write that could overwrite a change its author never saw or land on a
+// replacement object of the same name. It also refuses a patch touching metadata.managedFields or the
+// last-applied-configuration annotation, including through a parent such as `annotations: null`.
+//
+// The host still owns everything else: authenticating the caller and forwarding as that caller,
+// accepting PATCH only with Content-Type application/merge-patch+json (a JSON Patch or an apply
+// patch has other semantics this does not check), which resources and fields it lets a user write,
+// size limits and audit. The function sends nothing and does not mutate patch.
+func ValidateNativeMergePatch(patch []byte) error {
+ var parsed map[string]any
+ if err := json.Unmarshal(patch, &parsed); err != nil {
+ return fmt.Errorf("krm-stream: invalid JSON merge patch: %w", err)
+ }
+ if parsed == nil {
+ return fmt.Errorf("krm-stream: JSON merge patch must be an object")
+ }
+ metadata, _ := parsed["metadata"].(map[string]any)
+ for _, field := range []string{"uid", "resourceVersion"} {
+ if value, _ := metadata[field].(string); value == "" {
+ return fmt.Errorf("krm-stream: a native write must be conditional: the patch has no metadata.%s", field)
+ }
+ }
+ protected := make(map[string]string, len(machineryPaths))
+ for _, path := range machineryPaths {
+ protected[path] = "written by the API server or kubectl apply"
+ }
+ return validatePatchObject(parsed, nil, protected)
+}
+
func validatePatchObject(patch map[string]any, path []string, protected map[string]string) error {
for key, value := range patch {
next := append(append([]string(nil), path...), key)
diff --git a/gateway/patch_test.go b/gateway/patch_test.go
index 8290a92..5cd6471 100644
--- a/gateway/patch_test.go
+++ b/gateway/patch_test.go
@@ -54,6 +54,45 @@ func TestValidateMergePatchHonorsProjectionAndPatchShape(t *testing.T) {
}
}
+func TestValidateNativeMergePatchRequiresPreconditionsAndProtectsMachinery(t *testing.T) {
+ const conditional = `"uid":"u1","resourceVersion":"7"`
+ for _, patch := range []string{
+ `{"metadata":{` + conditional + `},"data":{"mode":"b"}}`,
+ `{"metadata":{` + conditional + `,"annotations":{"owner":"team-b","gone":null}},"spec":{"replicas":2}}`,
+ `{"metadata":{` + conditional + `,"labels":null}}`,
+ } {
+ if err := ValidateNativeMergePatch([]byte(patch)); err != nil {
+ t.Errorf("patch %s rejected: %v", patch, err)
+ }
+ }
+
+ for _, patch := range []string{
+ `{"data":{"mode":"b"}}`,
+ `{"metadata":{"uid":"u1"},"data":{"mode":"b"}}`,
+ `{"metadata":{"resourceVersion":"7"},"data":{"mode":"b"}}`,
+ `{"metadata":{"uid":"","resourceVersion":"7"}}`,
+ `{"metadata":{"uid":"u1","resourceVersion":7}}`,
+ `{"metadata":null}`,
+ `null`, `[]`, `{`,
+ } {
+ if err := ValidateNativeMergePatch([]byte(patch)); err == nil {
+ t.Errorf("unconditional or malformed patch %s was accepted", patch)
+ }
+ }
+
+ for _, patch := range []string{
+ `{"metadata":{` + conditional + `,"managedFields":[]}}`,
+ `{"metadata":{` + conditional + `,"managedFields":null}}`,
+ `{"metadata":{` + conditional + `,"annotations":{"kubectl.kubernetes.io/last-applied-configuration":"{}"}}}`,
+ `{"metadata":{` + conditional + `,"annotations":null}}`,
+ } {
+ var violation *PatchViolation
+ if err := ValidateNativeMergePatch([]byte(patch)); !errors.As(err, &violation) {
+ t.Errorf("patch %s: want PatchViolation, got %v", patch, err)
+ }
+ }
+}
+
// Project is exported because the SAVE path needs it, and until it was, a host answering a save with
// the written object had no supported way to make that object safe. The obvious thing — hand back
// what the Kubernetes client returned — leaks exactly what the stream withholds, through the one
diff --git a/packages/krm-stream/README.md b/packages/krm-stream/README.md
index 7b61cca..cf3d41d 100644
--- a/packages/krm-stream/README.md
+++ b/packages/krm-stream/README.md
@@ -4,7 +4,8 @@
state in a browser or JavaScript application, with optional editing. It provides:
- `connectNativeWatch`, which reads a native Kubernetes collection through a host proxy with LIST
- and WATCH, and `nativeCollectionURL` to address one.
+ and WATCH, `nativeCollectionURL` to address one and `nativeObjectURL` to address a member for
+ reads and writes.
- `connectResourceStream`, which reads a gateway's projected views over SSE, and
`resourceStreamURL` for the v1 scope query format.
- `LiveResourceStore` for server state, local drafts, conflicts, redactions, and merge patches, and
@@ -14,7 +15,7 @@ Both connectors use fetch, share one connection lifecycle and hand each resource
callback, with bounded recovery. Native access delivers original authorized resources; the gateway
adds projected views, redaction, suppression and optional upstream watch sharing. Start with a
watch-backed list or viewer; add draft reconciliation only when the page needs editing. The package
-is headless and does not choose a UI framework. Native viewing is read-only in this release.
+is headless and does not choose a UI framework.
```ts
import { LiveResourceStore, readOnlyPolicy, applyStreamEvent, connectResourceStream, resourceStreamURL } from "@configbutler/krm-stream";
@@ -124,7 +125,7 @@ errors itself. Gateway framing still supports browser `EventSource` with session
```ts
const connection = connectNativeWatch(
nativeCollectionURL("/k8s", { version: "v1", resource: "configmaps", namespace: "app", name: "settings" }),
- event => applyStreamEvent(store, event), // a LiveResourceStore(readOnlyPolicy) for this source only
+ event => applyStreamEvent(store, event), // a LiveResourceStore for this source only
{ onError: (code, message, terminal) => showStreamError(code, message, terminal) },
);
```
@@ -156,6 +157,13 @@ remove the others. Native objects carry whatever the proxy returns, including Se
machinery fields; there is no projection, redaction or `seq`. Never fall back to native access after a
gateway refuses a view.
+A store fed by `connectNativeWatch` can be edited like any other. `nativeObjectURL(proxy, { ...scope,
+namespace, name })` addresses one member under the same proxy and collection, for a conditional JSON
+merge PATCH and a guarded recovery read. The store keeps `metadata.managedFields` and the
+last-applied annotation read-only under every policy, so no patch carries them; the host proxy checks
+again with `gateway.ValidateNativeMergePatch`. See [native editing](../../docs/saving.md#native-editing-through-a-host-proxy)
+and the [native editor example](../../examples/native-editor/README.md).
+
## Optional editing
Use `new LiveResourceStore()` for an editor, or provide a policy that narrows the form's editable
@@ -171,9 +179,10 @@ A quiet stream can still hold an older write version. See the
[normative contract](../../spec/v1.md#6-ordering-delivery--the-state-guarantee).
`store.captureSave(uid)` captures a detached patch, UID and base resourceVersion together.
-`store.captureReconciliation(uid)` guards a projected asynchronous response against newer watch state.
-See the [complete conditional-save example](../../examples/conditional-save/README.md) and
-[save guide](../../docs/saving.md) for host preconditions, real Kubernetes 409s, and draft preservation.
+`store.captureReconciliation(uid)` guards an asynchronous response from the same source against newer
+watch state. See the [complete conditional-save example](../../examples/conditional-save/README.md),
+the [native editor example](../../examples/native-editor/README.md) and the
+[save guide](../../docs/saving.md) for preconditions, real Kubernetes 409s, and draft preservation.
For Vue 3, use the [copyable composable](../../examples/vue/README.md) for reactive resource and connection state
with automatic subscription cleanup. Vue stays in the host application.
diff --git a/packages/krm-stream/e2e/native-driver.ts b/packages/krm-stream/e2e/native-driver.ts
index b8c885f..ca2336a 100644
--- a/packages/krm-stream/e2e/native-driver.ts
+++ b/packages/krm-stream/e2e/native-driver.ts
@@ -1,43 +1,94 @@
-// A native viewer on stdout, for the Go real-API suite (gateway/kube/native_e2e_test.go).
+// A native viewer and editor on stdin/stdout, for the Go real-API suite
+// (gateway/kube/native_e2e_test.go).
//
// The Go test owns the cluster and a host-style /k8s proxy that holds the credentials. This process is
-// the browser: the REAL connectNativeWatch feeding the REAL read-only store, with no gateway in
-// between. After every state event and connection state it prints one JSON line describing what the
-// store holds, so the Go side can wait for the cluster's changes to arrive. A line `close` on stdin
-// closes the connection; the driver exits once `closed` settles.
+// the browser: the REAL connectNativeWatch feeding the REAL store under the default edit policy, and
+// the REAL native editor from examples/native-editor writing back through the same proxy, with no
+// gateway in between. After every state event and connection state it prints one JSON line
+// describing what the store holds, so the Go side can wait for the cluster's changes to arrive.
//
-// node e2e/native-driver.ts
+// Commands, one per line on stdin:
+//
+// close close the connection; the driver exits once `closed` settles
+// {"op":"set","name":…,"path":[…],"value":…} edit the named member's draft
+// {"op":"save","name":…} save it; prints {"saved":}
+//
+// A command that fails prints {"commandError":…}.
+//
+// node e2e/native-driver.ts
import { createInterface } from "node:readline";
-import { applyStreamEvent, connectNativeWatch, LiveResourceStore, readOnlyPolicy } from "../src/index.ts";
+import { nativeEditor } from "../../../examples/native-editor/editor.ts";
+import {
+ applyStreamEvent,
+ connectNativeWatch,
+ LiveResourceStore,
+ type NativeScope,
+ nativeCollectionURL,
+ type Path,
+} from "../src/index.ts";
-const url = process.argv[2];
-if (!url) throw new Error("usage: native-driver.ts ");
+const [proxy, scopeJSON] = process.argv.slice(2);
+if (proxy === undefined || scopeJSON === undefined)
+ throw new Error("usage: native-driver.ts ");
+const scope = JSON.parse(scopeJSON) as NativeScope;
-const store = new LiveResourceStore(readOnlyPolicy);
+const store = new LiveResourceStore();
const report = (line: object) => process.stdout.write(`${JSON.stringify(line)}\n`);
const members = () =>
store
.ids()
.map((uid) => {
const object = store.server(uid);
- return { uid, name: object.metadata.name, kind: object.kind, data: object.data };
+ return {
+ uid,
+ name: object.metadata.name,
+ kind: object.kind,
+ resourceVersion: object.metadata.resourceVersion,
+ data: object.data,
+ };
})
.sort((a, b) => a.name.localeCompare(b.name) || a.uid.localeCompare(b.uid));
const handle = connectNativeWatch(
- url,
+ nativeCollectionURL(proxy, scope),
(event) => {
applyStreamEvent(store, event);
report({ event: event.type, members: members() });
},
{ retryDelayMs: 100, onError: (code, message, terminal) => report({ error: { code, message, terminal } }) },
);
-handle.subscribe((state) => report({ state: state.status }));
+let live = false;
+handle.subscribe((state) => {
+ live = state.status === "live";
+ report({ state: state.status });
+});
+
+// One editor per UID, so a guarded read it still owes survives between saves.
+const editors = new Map>();
+const uidOf = (name: string) => {
+ const uid = store.ids().find((id) => store.server(id).metadata.name === name);
+ if (uid === undefined) throw new Error(`${name} is not in the store`);
+ return uid;
+};
+
+type Command = { op: "set"; name: string; path: Path; value: unknown } | { op: "save"; name: string };
+
+async function run(command: Command) {
+ const uid = uidOf(command.name);
+ if (command.op === "set") return store.setValue(uid, command.path, command.value);
+ let editor = editors.get(uid);
+ if (!editor) {
+ editor = nativeEditor(store, uid, { proxy: proxy!, scope }, fetch, () => live);
+ editors.set(uid, editor);
+ }
+ report({ saved: await editor.save() });
+}
const input = createInterface({ input: process.stdin });
input.on("line", (line) => {
- if (line.trim() === "close") handle.close();
+ if (line.trim() === "close") return handle.close();
+ run(JSON.parse(line) as Command).catch((error: unknown) => report({ commandError: String(error) }));
});
input.on("close", () => handle.close());
await handle.closed.finally(() => input.close());
diff --git a/packages/krm-stream/src/index.ts b/packages/krm-stream/src/index.ts
index da4e9f9..c369d14 100644
--- a/packages/krm-stream/src/index.ts
+++ b/packages/krm-stream/src/index.ts
@@ -10,6 +10,7 @@
// derives dirtiness; tracks conflicts; builds the merge patch.
// resourceStreamURL builds the stream URL from a scope — the encoding the gateway parses back.
// nativeCollectionURL builds a native collection URL behind a host proxy.
+// nativeObjectURL builds the URL of one native object behind it, for reads and writes.
//
// The store is built test-first against conformance/ — the same fixtures the Go gateway runs. See
// ../../../docs/client-state-model.md for the algorithm, ../../../spec/v1.md for the wire, and
@@ -52,5 +53,5 @@ export type {
StreamEvent,
} from "./types.ts";
export type { NativeScope, ScopeQuery } from "./url.ts";
-export { nativeCollectionURL, resourceStreamURL } from "./url.ts";
+export { nativeCollectionURL, nativeObjectURL, resourceStreamURL } from "./url.ts";
export { PROTOCOL_VERSION, VERSION } from "./version.ts";
diff --git a/packages/krm-stream/src/policy.ts b/packages/krm-stream/src/policy.ts
index 335fa9c..f3342e2 100644
--- a/packages/krm-stream/src/policy.ts
+++ b/packages/krm-stream/src/policy.ts
@@ -21,7 +21,8 @@ export const DEFAULT_EDITABLE_REGIONS: Path[] = [
];
/** A policy from a list of editable region roots. Everything under a root is editable; everything
- * else follows the server. */
+ * else follows the server. Whatever the roots, the store keeps the machinery — `managedFields` and
+ * the last-applied annotation — read-only, as it does redacted paths. */
export function regionPolicy(roots: Path[]): EditabilityPolicy {
return {
isEditable: (_obj: KRMObject, path: Path) => roots.some((root) => isPrefix(root, path)),
@@ -35,7 +36,8 @@ export function regionPolicy(roots: Path[]): EditabilityPolicy {
}
/** `spec`, `metadata.labels`/`annotations`, `data`, `stringData` editable; `status`, the rest of
- * `metadata`, `apiVersion`, `kind`, `binaryData` read-only. */
+ * `metadata`, `apiVersion`, `kind`, `binaryData` read-only. The same policy edits a native object:
+ * the last-applied annotation inside `annotations` stays read-only through the store. */
export const defaultPolicy: EditabilityPolicy = regionPolicy(DEFAULT_EDITABLE_REGIONS);
/** Everything read-only: the status-watch use case. Same engine, same stream, no draft — a viewer
diff --git a/packages/krm-stream/src/store.ts b/packages/krm-stream/src/store.ts
index 1422813..7a385f6 100644
--- a/packages/krm-stream/src/store.ts
+++ b/packages/krm-stream/src/store.ts
@@ -107,6 +107,15 @@ interface Resource {
conflicts: Map;
}
+/** Machinery no person edits, under every policy and from every source: the field ownership the
+ * API server records, and the annotation `kubectl apply` diffs against. A gateway projection removes
+ * both and its write check, ValidateMergePatch, refuses them; a native object carries both, so here
+ * they are read-only exactly as a redacted value is. */
+const MACHINERY: Path[] = [
+ ["metadata", "managedFields"],
+ ["metadata", "annotations", "kubectl.kubernetes.io/last-applied-configuration"],
+];
+
export class LiveResourceStore {
readonly #policy: EditabilityPolicy;
#revision = 0;
@@ -340,7 +349,11 @@ export class LiveResourceStore {
const res = this.#must(id);
if (this.isEditable(id, path)) return !deepEqual(get(res.server, path), get(res.draft, path));
// A container (`[]`, `["metadata"]`) is dirty iff something editable underneath it is.
- if (!this.#policy.containsEditable(res.server, path)) return false;
+ const regions = this.#regionsFor(
+ res.server,
+ res.redacted.map((r) => r.path),
+ );
+ if (!regions.container(path)) return false;
return this.changes(id).some((c) => isPrefix(path, c.path));
}
@@ -457,12 +470,19 @@ export class LiveResourceStore {
return res;
}
+ /** The policy, minus the protected paths: this object's redactions and the machinery. A path
+ * inside one is read-only. A path holding one cannot be replaced or removed whole, which would
+ * rewrite what it holds, so where the policy makes it editable it is merged key by key instead: a
+ * new Secret key beside withheld values, or one annotation beside the last-applied one. */
#regionsFor(object: KRMObject, redacted: Path[]): Regions {
- const insideRedacted = (path: Path) => redacted.some((r) => isPrefix(r, path));
- const containsRedacted = (path: Path) => redacted.some((r) => isPrefix(path, r));
+ const protectedPaths = [...MACHINERY, ...redacted];
+ const inside = (path: Path) => protectedPaths.some((p) => isPrefix(p, path));
+ const holds = (path: Path) => protectedPaths.some((p) => isPrefix(path, p));
return {
- editable: (path) => !insideRedacted(path) && !containsRedacted(path) && this.#policy.isEditable(object, path),
- container: (path) => !insideRedacted(path) && this.#policy.containsEditable(object, path),
+ editable: (path) => !inside(path) && !holds(path) && this.#policy.isEditable(object, path),
+ container: (path) =>
+ !inside(path) &&
+ (this.#policy.containsEditable(object, path) || (holds(path) && this.#policy.isEditable(object, path))),
listMapKeys: (path) => this.#policy.listMapKeys?.(object, path),
};
}
diff --git a/packages/krm-stream/src/url.ts b/packages/krm-stream/src/url.ts
index b215a5b..0238984 100644
--- a/packages/krm-stream/src/url.ts
+++ b/packages/krm-stream/src/url.ts
@@ -72,24 +72,46 @@ export type NativeScope = Omit;
* any other collection member. The host owns credentials, routing and which collections it allows.
*/
export function nativeCollectionURL(proxyBase: string, scope: NativeScope): string {
- const segment = (what: string, value: string | undefined) => {
- if (!value || value === "." || value === ".." || value.includes("/")) {
- throw new Error(`krm-stream: invalid ${what} ${JSON.stringify(value ?? "")}`);
- }
- return encodeURIComponent(value);
- };
+ const q = new URLSearchParams();
+ if (scope.labelSelector) q.append("labelSelector", scope.labelSelector);
+ if (scope.name) q.append("fieldSelector", `metadata.name=${scope.name}`);
+ const query = q.toString();
+ return collectionPath(proxyBase, scope) + (query ? `?${query}` : "");
+}
+
+/**
+ * Build the URL of one native Kubernetes object behind a host proxy: where a native editor reads it
+ * back and sends its conditional PATCH, through the same proxy and collection it watches.
+ *
+ * ```ts
+ * const { namespace, name } = store.server(uid).metadata;
+ * nativeObjectURL("/k8s", { version: "v1", resource: "configmaps", namespace, name });
+ * // → /k8s/api/v1/namespaces/app/configmaps/settings
+ * ```
+ *
+ * Take `namespace` and `name` from the object itself, so a collection watched across namespaces
+ * still addresses each member. A label selector does not address an object and is ignored.
+ */
+export function nativeObjectURL(proxyBase: string, scope: NativeScope & { name: string }): string {
+ return `${collectionPath(proxyBase, scope)}/${segment("name", scope.name)}`;
+}
+
+function collectionPath(proxyBase: string, scope: NativeScope): string {
const path = [withoutTrailingSlashes(proxyBase)];
if (scope.group) path.push("apis", segment("group", scope.group));
else path.push("api");
path.push(segment("version", scope.version));
if (scope.namespace) path.push("namespaces", segment("namespace", scope.namespace));
path.push(segment("resource", scope.resource));
+ return path.join("/");
+}
- const q = new URLSearchParams();
- if (scope.labelSelector) q.append("labelSelector", scope.labelSelector);
- if (scope.name) q.append("fieldSelector", `metadata.name=${scope.name}`);
- const query = q.toString();
- return path.join("/") + (query ? `?${query}` : "");
+/** One path segment, refused when it would leave or change the path it is placed in. */
+function segment(what: string, value: string | undefined): string {
+ if (!value || value === "." || value === ".." || value.includes("/")) {
+ throw new Error(`krm-stream: invalid ${what} ${JSON.stringify(value ?? "")}`);
+ }
+ return encodeURIComponent(value);
}
/** The base without its trailing slashes. A loop, not `/\/+$/`: that pattern retries from every
diff --git a/packages/krm-stream/test/invariants.test.ts b/packages/krm-stream/test/invariants.test.ts
index 08c0dc5..d45ea84 100644
--- a/packages/krm-stream/test/invariants.test.ts
+++ b/packages/krm-stream/test/invariants.test.ts
@@ -11,7 +11,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
-import { defaultPolicy, LiveResourceStore, readOnlyPolicy, withOpenAPIKeyedLists } from "../src/index.ts";
+import { defaultPolicy, LiveResourceStore, readOnlyPolicy, regionPolicy, withOpenAPIKeyedLists } from "../src/index.ts";
import type { KRMObject, Path } from "../src/types.ts";
import { body } from "./conformance.ts";
@@ -336,6 +336,83 @@ test("I-REDACT — a redacted value is ABSENT, is read-only, and cannot reach a
assert.deepEqual(store.patch(id), { metadata: { labels: { "app.kubernetes.io/name": "checkout" } } });
});
+test("I-REDACT — a new key beside withheld values is an ordinary edit", () => {
+ const [store, id] = seeded("secret-token.v1-wire", [
+ { path: "/data/token", rev: 1 },
+ { path: "/data/username", rev: 1 },
+ ]);
+ store.setValue(id, ["data", "extra"], "ZXh0cmE=");
+ assert.deepEqual(store.patch(id), { data: { extra: "ZXh0cmE=" } });
+ assert.ok(store.isDirty(id, ["data"]));
+
+ // The map holding withheld values is merged key by key, so the next event keeps the new key.
+ const next = body("secret-token.v1-wire");
+ next.metadata.resourceVersion = "999";
+ store.applyServerEvent(next, { redacted: store.redactions(id) });
+ assert.deepEqual(store.draft(id).data, { extra: "ZXh0cmE=" });
+ assert.deepEqual(store.patch(id), { data: { extra: "ZXh0cmE=" } });
+});
+
+const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration";
+
+/** A ConfigMap as the API server itself returns it: with managedFields and the annotation
+ * `kubectl apply` records. A native source delivers both; a gateway projection removes them. */
+function nativeConfigMap(rv: string, applied: string): KRMObject {
+ return {
+ apiVersion: "v1",
+ kind: "ConfigMap",
+ metadata: {
+ uid: "u-native",
+ name: "settings",
+ namespace: "app",
+ resourceVersion: rv,
+ annotations: { [LAST_APPLIED]: applied, note: "base" },
+ managedFields: [{ manager: "kubectl", operation: "Apply", fieldsV1: { "f:data": {} } }],
+ },
+ data: { mode: "a" },
+ };
+}
+
+test("I-MACHINERY — managedFields and the last-applied annotation are read-only under every policy", () => {
+ const annotations = ["metadata", "annotations"];
+ const lastApplied = [...annotations, LAST_APPLIED];
+ // The default policy, and a host policy broad enough to make all of metadata editable.
+ for (const policy of [defaultPolicy, regionPolicy([["metadata"], ["data"]])]) {
+ const store = new LiveResourceStore(policy);
+ store.applyServerEvent(nativeConfigMap("1", '{"data":{"mode":"a"}}'));
+ const id = "u-native";
+
+ for (const p of [lastApplied, ["metadata", "managedFields"], ["metadata", "managedFields", 0, "manager"]]) {
+ assert.equal(store.isEditable(id, p), false, `${JSON.stringify(p)} must not be editable`);
+ assert.throws(() => store.setValue(id, p, "x"), /read-only/, `setValue(${JSON.stringify(p)})`);
+ assert.throws(() => store.removeKey(id, p), /read-only/, `removeKey(${JSON.stringify(p)})`);
+ }
+ assert.throws(() => store.addKey(id, annotations, LAST_APPLIED, "{}"), /read-only/);
+ // Replacing or removing the whole map would rewrite the protected annotation inside it.
+ assert.equal(store.isEditable(id, annotations), false);
+ assert.throws(() => store.setValue(id, annotations, { note: "x" }), /read-only/);
+ assert.throws(() => store.removeKey(id, annotations), /read-only/);
+ assert.throws(() => store.renameKey(id, annotations, LAST_APPLIED, "copy"), /read-only/);
+
+ // Every other annotation is still editable, one key at a time.
+ store.setValue(id, [...annotations, "note"], "mine");
+ assert.ok(store.isDirty(id, annotations));
+ assert.deepEqual(store.patch(id), { metadata: { annotations: { note: "mine" } } });
+
+ // The server rewrites both on its next write; they follow it, and the edit beside them stays.
+ const r = store.applyServerEvent(nativeConfigMap("2", '{"data":{"mode":"b"}}'));
+ assert.ok(r.flashed.some((p) => JSON.stringify(p) === JSON.stringify(lastApplied)));
+ assert.equal(store.draft(id).metadata.annotations?.[LAST_APPLIED], '{"data":{"mode":"b"}}');
+ assert.equal(store.draft(id).metadata.annotations?.note, "mine");
+ assert.deepEqual(store.patch(id), { metadata: { annotations: { note: "mine" } } });
+ assert.deepEqual(store.captureSave(id), {
+ uid: id,
+ resourceVersion: "2",
+ patch: { metadata: { annotations: { note: "mine" } } },
+ });
+ }
+});
+
test("unknown resource — every query says so rather than inventing an empty object", () => {
const store = new LiveResourceStore();
assert.deepEqual(store.ids(), []);
diff --git a/packages/krm-stream/test/native-editing.test.ts b/packages/krm-stream/test/native-editing.test.ts
new file mode 100644
index 0000000..c018163
--- /dev/null
+++ b/packages/krm-stream/test/native-editing.test.ts
@@ -0,0 +1,360 @@
+// Native editing: the store under the default policy, fed native objects, and the native conditional
+// editor in examples/native-editor writing through a scripted host proxy. Each test answers the
+// editor's PATCH and GET itself, so it decides what the API server said and when the watch moved.
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { type KRMObject, LiveResourceStore } from "../src/index.ts";
+
+const { nativeEditor, NativeRequestError } = await import("../../../examples/native-editor/editor.ts");
+
+const LAST_APPLIED = "kubectl.kubernetes.io/last-applied-configuration";
+const URL_ = "/k8s/api/v1/namespaces/app/configmaps/settings";
+const source = { proxy: "/k8s", scope: { version: "v1", resource: "configmaps", namespace: "app" } };
+
+/** A ConfigMap exactly as the API server returns it, machinery included. */
+const native = (rv: string, value = "base", uid = "u"): KRMObject => ({
+ apiVersion: "v1",
+ kind: "ConfigMap",
+ metadata: {
+ uid,
+ name: "settings",
+ namespace: "app",
+ resourceVersion: rv,
+ annotations: { [LAST_APPLIED]: `{"data":{"value":"${value}"}}`, owner: "team-a" },
+ managedFields: [{ manager: "kubectl", operation: "Apply" }],
+ },
+ data: { value },
+});
+const status = (code: number, reason: string, extra: Record = {}) =>
+ Response.json(
+ { kind: "Status", apiVersion: "v1", status: "Failure", code, reason, message: reason, ...extra },
+ {
+ status: code,
+ },
+ );
+
+interface Call {
+ method: string;
+ url: string;
+ headers: Record;
+ body: unknown;
+}
+
+/** A host proxy answering each request with the next scripted response. */
+function proxy(...answers: ((call: Call) => Response | Promise)[]) {
+ const calls: Call[] = [];
+ const request: typeof fetch = async (input, init) => {
+ const call: Call = {
+ method: init?.method ?? "GET",
+ url: String(input),
+ headers: (init?.headers ?? {}) as Record,
+ body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined,
+ };
+ calls.push(call);
+ const answer = answers.shift();
+ assert.ok(answer, `unexpected ${call.method} ${call.url}`);
+ return answer(call);
+ };
+ return { calls, request };
+}
+
+function seeded(rv = "1"): LiveResourceStore {
+ const store = new LiveResourceStore();
+ store.applyServerEvent(native(rv));
+ return store;
+}
+
+test("a native save is a conditional merge patch to the object, through the watched proxy", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "mine");
+ store.setValue("u", ["metadata", "annotations", "owner"], "team-b");
+ const { calls, request } = proxy(() => {
+ store.setValue("u", ["data", "value"], "typed while saving");
+ // The proxy answers with the written object, which the editor must not adopt.
+ return Response.json(native("2", "mine"));
+ });
+ const editor = nativeEditor(store, "u", source, request, () => true);
+
+ assert.equal(await editor.save(), "saved");
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0]!.method, "PATCH");
+ assert.equal(calls[0]!.url, URL_);
+ assert.equal(calls[0]!.headers["Content-Type"], "application/merge-patch+json");
+ assert.deepEqual(calls[0]!.body, {
+ metadata: { annotations: { owner: "team-b" }, uid: "u", resourceVersion: "1" },
+ data: { value: "mine" },
+ });
+ // The response was not adopted: the server object still waits for the watch.
+ assert.equal(store.server("u").metadata.resourceVersion, "1");
+ assert.deepEqual(store.draft("u").data, { value: "typed while saving" });
+
+ // The echo arrives: the saved value settles, the later typing stays.
+ store.applyServerEvent({
+ ...native("2", "mine"),
+ metadata: { ...native("2", "mine").metadata, annotations: { [LAST_APPLIED]: "{}", owner: "team-b" } },
+ });
+ assert.deepEqual(store.patch("u"), { data: { value: "typed while saving" } });
+ assert.equal(editor.saving, false);
+});
+
+test("a native save never carries machinery, even when the server rewrote it during editing", async () => {
+ const store = seeded();
+ store.setValue("u", ["metadata", "annotations", "owner"], "team-b");
+ assert.throws(() => store.removeKey("u", ["metadata", "annotations"]), /read-only/);
+ store.applyServerEvent(native("2", "applied again"));
+ const { calls, request } = proxy(() => new Response(null, { status: 200 }));
+ assert.equal(await nativeEditor(store, "u", source, request, () => true).save(), "saved");
+ assert.deepEqual(calls[0]!.body, {
+ metadata: { annotations: { owner: "team-b" }, uid: "u", resourceVersion: "2" },
+ });
+});
+
+test("a native 409 reconciles a guarded native GET and keeps the draft for review", async () => {
+ const store = seeded();
+ store.setValue("u", ["metadata", "annotations", "owner"], "team-b");
+ const { calls, request } = proxy(
+ () => status(409, "Conflict"),
+ (call) => {
+ assert.equal(call.url, URL_);
+ store.setValue("u", ["metadata", "annotations", "owner"], "team-c");
+ return Response.json(native("3", "moved on the server"));
+ },
+ () => new Response(null, { status: 200 }),
+ );
+ const editor = nativeEditor(store, "u", source, request, () => true);
+
+ assert.equal(await editor.save(), "version-stale");
+ assert.deepEqual(
+ calls.map((c) => c.method),
+ ["PATCH", "GET"],
+ );
+ assert.equal(store.server("u").metadata.resourceVersion, "3");
+ assert.equal(store.draft("u").metadata.annotations?.owner, "team-c");
+ assert.deepEqual(store.draft("u").data, { value: "moved on the server" });
+
+ // The next deliberate Save captures a new intent at the version the read established.
+ assert.equal(await editor.save(), "saved");
+ assert.deepEqual(calls[2]!.body, {
+ metadata: { annotations: { owner: "team-c" }, uid: "u", resourceVersion: "3" },
+ });
+});
+
+test("a native 409 with a concurrent change to the edited field is a draft conflict", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "mine");
+ const { request } = proxy(
+ () => status(409, "Conflict"),
+ () => Response.json(native("3", "theirs")),
+ );
+ const editor = nativeEditor(store, "u", source, request, () => true);
+ assert.equal(await editor.save(), "draft-conflict");
+ assert.deepEqual(store.conflicts("u"), [{ path: ["data", "value"], theirs: "theirs" }]);
+ assert.equal(await editor.save(), "draft-conflict", "no write until the conflict is resolved");
+});
+
+test("an object replaced under the same name is unavailable, and its replacement is never applied", async () => {
+ for (const refusal of [
+ () => status(409, "Conflict"),
+ () => status(422, "Invalid", { details: { causes: [{ field: "metadata.uid", message: "Precondition failed" }] } }),
+ ]) {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "mine");
+ const { calls, request } = proxy(refusal, () => Response.json(native("4", "replacement", "u-new")));
+ assert.equal(await nativeEditor(store, "u", source, request, () => true).save(), "unavailable");
+ assert.deepEqual(
+ calls.map((c) => c.method),
+ ["PATCH", "GET"],
+ );
+ assert.equal(store.server("u").metadata.resourceVersion, "1");
+ assert.deepEqual(store.draft("u").data, { value: "mine" });
+ }
+});
+
+test("a deleted object is unavailable on save and on the recovery read", async () => {
+ const deleted = seeded();
+ deleted.setValue("u", ["data", "value"], "mine");
+ const patch = proxy(() => status(404, "NotFound"));
+ assert.equal(await nativeEditor(deleted, "u", source, patch.request, () => true).save(), "unavailable");
+
+ const gone = seeded();
+ gone.setValue("u", ["data", "value"], "mine");
+ const read = proxy(
+ () => status(409, "Conflict"),
+ () => status(404, "NotFound"),
+ );
+ assert.equal(await nativeEditor(gone, "u", source, read.request, () => true).save(), "unavailable");
+});
+
+test("a refused native write keeps the Kubernetes Status for the form and the draft intact", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "x".repeat(10));
+ const invalid = { causes: [{ field: "data.value", message: "too long" }] };
+ const { request } = proxy(() => status(422, "Invalid", { details: invalid }));
+ const error = await nativeEditor(store, "u", source, request, () => true)
+ .save()
+ .then(
+ () => undefined,
+ (e: unknown) => e,
+ );
+ assert.ok(error instanceof NativeRequestError);
+ assert.equal(error.httpStatus, 422);
+ assert.deepEqual((error.status as { details: unknown }).details, invalid);
+ assert.deepEqual(store.patch("u"), { data: { value: "xxxxxxxxxx" } });
+});
+
+test("a recovery read overtaken by the watch is not applied, and the next Save reads again", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "mine");
+ const { calls, request } = proxy(
+ () => status(409, "Conflict"),
+ () => {
+ store.applyServerEvent(native("5", "base"));
+ return Response.json(native("4", "older"));
+ },
+ () => Response.json(native("5", "base")),
+ );
+ const editor = nativeEditor(store, "u", source, request, () => true);
+ assert.equal(await editor.save(), "recovering");
+ assert.equal(store.server("u").metadata.resourceVersion, "5");
+ assert.equal(await editor.save(), "version-stale", "only a read, never the old patch");
+ assert.deepEqual(
+ calls.map((c) => c.method),
+ ["PATCH", "GET", "GET"],
+ );
+});
+
+test("the native editor refuses to write while the connection is not live, and rejects a projected envelope", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "mine");
+ const idle = proxy();
+ assert.equal(await nativeEditor(store, "u", source, idle.request, () => false).save(), "recovering");
+ assert.equal(idle.calls.length, 0);
+
+ // A projected endpoint's { object, redactedPaths } is another source; it never reconciles here.
+ const projected = proxy(
+ () => status(409, "Conflict"),
+ () => Response.json({ object: native("2", "theirs"), redactedPaths: [] }),
+ );
+ await assert.rejects(
+ nativeEditor(store, "u", source, projected.request, () => true).save(),
+ /not a Kubernetes object/,
+ );
+ assert.equal(store.server("u").metadata.resourceVersion, "1");
+});
+
+test("a write whose outcome is unknown is followed by a guarded read, never a second write", async () => {
+ // The network failed after sending, and the write landed.
+ const lost = seeded();
+ lost.setValue("u", ["data", "value"], "mine");
+ const network = proxy(
+ () => {
+ throw new TypeError("network error");
+ },
+ () => Response.json(native("2", "mine")),
+ );
+ const lostEditor = nativeEditor(lost, "u", source, network.request, () => true);
+ await assert.rejects(lostEditor.save(), /network error/);
+ assert.equal(await lostEditor.save(), "confirmed");
+ assert.equal(lost.patch("u"), null, "the read found the write had landed");
+ assert.equal(await lostEditor.save(), "unchanged");
+ assert.deepEqual(
+ network.calls.map((c) => c.method),
+ ["PATCH", "GET"],
+ );
+
+ // The proxy answered 502, and the write did not land: the read establishes that, and only the
+ // next deliberate Save writes again.
+ const failed = seeded();
+ failed.setValue("u", ["data", "value"], "mine");
+ const gateway = proxy(
+ () => status(502, "BadGateway"),
+ () => Response.json(native("1")),
+ () => new Response(null, { status: 200 }),
+ );
+ const failedEditor = nativeEditor(failed, "u", source, gateway.request, () => true);
+ await assert.rejects(failedEditor.save(), NativeRequestError);
+ assert.equal(await failedEditor.save(), "confirmed");
+ assert.deepEqual(failed.patch("u"), { data: { value: "mine" } });
+ assert.equal(await failedEditor.save(), "saved");
+ assert.deepEqual(
+ gateway.calls.map((c) => c.method),
+ ["PATCH", "GET", "PATCH"],
+ );
+});
+
+test("a definite refusal needs no read: the corrected draft is written next", async () => {
+ const store = seeded();
+ store.setValue("u", ["data", "value"], "invalid");
+ const { calls, request } = proxy(
+ () => status(422, "Invalid", { details: { causes: [{ field: "data.value" }] } }),
+ () => new Response(null, { status: 200 }),
+ );
+ const editor = nativeEditor(store, "u", source, request, () => true);
+ await assert.rejects(editor.save(), NativeRequestError);
+ store.setValue("u", ["data", "value"], "valid");
+ assert.equal(await editor.save(), "saved");
+ assert.deepEqual(
+ calls.map((c) => c.method),
+ ["PATCH", "PATCH"],
+ );
+});
+
+test("an accepted write is confirmed by its echo, or by a guarded read when no echo arrives", async () => {
+ // No echo: the next Save reads instead of writing again, and never adopts the write response.
+ const quiet = seeded();
+ quiet.setValue("u", ["data", "value"], "mine");
+ const read = proxy(
+ () => Response.json(native("2", "mine")),
+ () => Response.json(native("2", "mine")),
+ );
+ const quietEditor = nativeEditor(quiet, "u", source, read.request, () => true);
+ assert.equal(await quietEditor.save(), "saved");
+ assert.equal(quiet.server("u").metadata.resourceVersion, "1", "the write response is not adopted");
+ assert.equal(await quietEditor.save(), "confirmed");
+ assert.equal(quiet.patch("u"), null);
+ assert.equal(await quietEditor.save(), "unchanged");
+ assert.deepEqual(
+ read.calls.map((c) => c.method),
+ ["PATCH", "GET"],
+ );
+
+ // The host confirms explicitly, as after a timeout; an admission webhook restored the value, so
+ // the edit is still dirty and the next Save writes it again deliberately.
+ const restored = seeded();
+ restored.setValue("u", ["data", "value"], "mine");
+ const confirm = proxy(
+ () => new Response(null, { status: 200 }),
+ () => Response.json(native("1")),
+ () => new Response(null, { status: 200 }),
+ );
+ const restoredEditor = nativeEditor(restored, "u", source, confirm.request, () => true);
+ assert.equal(await restoredEditor.save(), "saved");
+ assert.equal(await restoredEditor.confirm(), "confirmed");
+ assert.deepEqual(restored.patch("u"), { data: { value: "mine" } });
+ assert.equal(await restoredEditor.save(), "saved");
+ assert.deepEqual(
+ confirm.calls.map((c) => c.method),
+ ["PATCH", "GET", "PATCH"],
+ );
+
+ // The echo arrives: nothing is owed, and the next edit is written at the echoed version.
+ const echoed = seeded();
+ echoed.setValue("u", ["data", "value"], "mine");
+ const write = proxy(
+ () => new Response(null, { status: 200 }),
+ () => new Response(null, { status: 200 }),
+ );
+ const echoedEditor = nativeEditor(echoed, "u", source, write.request, () => true);
+ assert.equal(await echoedEditor.save(), "saved");
+ echoed.applyServerEvent(native("2", "mine"));
+ echoed.setValue("u", ["data", "value"], "again");
+ assert.equal(await echoedEditor.save(), "saved");
+ assert.deepEqual(
+ write.calls.map((c) => [c.method, (c.body as { metadata: { resourceVersion: string } }).metadata.resourceVersion]),
+ [
+ ["PATCH", "1"],
+ ["PATCH", "2"],
+ ],
+ );
+});
diff --git a/packages/krm-stream/test/native.test.ts b/packages/krm-stream/test/native.test.ts
index 8fb959c..580b291 100644
--- a/packages/krm-stream/test/native.test.ts
+++ b/packages/krm-stream/test/native.test.ts
@@ -13,6 +13,7 @@ import {
connectNativeWatch,
LiveResourceStore,
nativeCollectionURL,
+ nativeObjectURL,
type ResourceStateEvent,
type ResourceStreamHandle,
readOnlyPolicy,
@@ -926,6 +927,35 @@ test("nativeCollectionURL refuses segments that would change the path", () => {
);
});
+test("nativeObjectURL addresses one object in the collection a native watch reads", () => {
+ assert.equal(
+ nativeObjectURL("/k8s", { version: "v1", resource: "configmaps", namespace: "app", name: "settings" }),
+ "/k8s/api/v1/namespaces/app/configmaps/settings",
+ );
+ assert.equal(
+ nativeObjectURL("/k8s/", {
+ group: "apps",
+ version: "v1",
+ resource: "deployments",
+ name: "web",
+ labelSelector: "a=b",
+ }),
+ "/k8s/apis/apps/v1/deployments/web",
+ "a selector does not address an object",
+ );
+ assert.equal(
+ nativeObjectURL("", { version: "v1", resource: "namespaces", name: "team a" }),
+ "/api/v1/namespaces/team%20a",
+ );
+ for (const name of ["", ".", "..", "a/b"]) {
+ assert.throws(
+ () => nativeObjectURL("/k8s", { version: "v1", resource: "configmaps", namespace: "app", name }),
+ /krm-stream: invalid name/,
+ JSON.stringify(name),
+ );
+ }
+});
+
test("nativeCollectionURL trims any run of trailing slashes from the proxy base in linear time", () => {
assert.equal(nativeCollectionURL("/k8s///", { version: "v1", resource: "pods" }), "/k8s/api/v1/pods");
assert.equal(nativeCollectionURL("///", { version: "v1", resource: "pods" }), "/api/v1/pods");
diff --git a/packages/krm-stream/tsconfig.test.json b/packages/krm-stream/tsconfig.test.json
index d46bcea..7dc7ed6 100644
--- a/packages/krm-stream/tsconfig.test.json
+++ b/packages/krm-stream/tsconfig.test.json
@@ -17,6 +17,7 @@
"test/**/*.ts",
"e2e/**/*.ts",
"../../examples/conditional-save/**/*.ts",
- "../../examples/editor-recipes/**/*.ts"
+ "../../examples/editor-recipes/**/*.ts",
+ "../../examples/native-editor/**/*.ts"
]
}