From 2b1fc1b8e39571c4afe13a3f4bee43abf4d8fc46 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:56:48 +0000 Subject: [PATCH 1/6] feat(client)!: keep machinery read-only under every edit policy The store now treats metadata.managedFields and the last-applied-configuration annotation like redacted paths, whatever the policy: the same two paths ValidateMergePatch refuses for every projection. A native object carries both, so this is what lets the default policy edit it. A map that holds a protected path is merged and edited key by key rather than replaced whole. That also fixes a new Secret data key beside redacted values, which was accepted but never reached changes() or patch(). BREAKING CHANGE: setValue and removeKey on metadata.annotations as a whole are refused; edit each annotation key instead. isEditable on that map is now false. Co-Authored-By: Claude Opus 5.5 --- docs/migrating.md | 19 ++++- packages/krm-stream/src/policy.ts | 6 +- packages/krm-stream/src/store.ts | 30 ++++++-- packages/krm-stream/test/invariants.test.ts | 79 ++++++++++++++++++++- 4 files changed, 124 insertions(+), 10 deletions(-) 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/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/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(), []); From 32b67d72ba9d8ebcc99a2c9856b18776dcf80bec Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:56:49 +0000 Subject: [PATCH 2/6] feat(client): address one native object with nativeObjectURL Builds the object URL under the same proxy and collection a native watch reads, for a native editor's conditional PATCH and guarded recovery read. Shares segment validation with nativeCollectionURL. Co-Authored-By: Claude Opus 5.5 --- packages/krm-stream/src/index.ts | 3 +- packages/krm-stream/src/url.ts | 44 ++++++++++++++++++------- packages/krm-stream/test/native.test.ts | 30 +++++++++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) 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/url.ts b/packages/krm-stream/src/url.ts index 8765aff..4573220 100644 --- a/packages/krm-stream/src/url.ts +++ b/packages/krm-stream/src/url.ts @@ -72,22 +72,44 @@ 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 = [proxyBase.replace(/\/+$/, "")]; 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); } diff --git a/packages/krm-stream/test/native.test.ts b/packages/krm-stream/test/native.test.ts index d30b513..1d09c5d 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, @@ -925,3 +926,32 @@ test("nativeCollectionURL refuses segments that would change the path", () => { "/k8s/api/v1/namespaces/a%20b/pods", ); }); + +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), + ); + } +}); From e6a9fcac0d8d92febdc174950bc5cc1293b95ef2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:56:49 +0000 Subject: [PATCH 3/6] feat(gateway): validate native merge patches a host proxy forwards ValidateNativeMergePatch refuses a patch without metadata.uid and metadata.resourceVersion preconditions, or one touching managedFields or the last-applied annotation, including through a parent. A host proxy runs it before forwarding a native write. Co-Authored-By: Claude Opus 5.5 --- gateway/README.md | 4 +++- gateway/patch.go | 47 +++++++++++++++++++++++++++++++++++++++++-- gateway/patch_test.go | 39 +++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) 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/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 From fd57292f9f9495a247938d2c07c9c07557897c99 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:56:49 +0000 Subject: [PATCH 4/6] test(examples): add a native conditional editor with fake-fetch and real-API checks examples/native-editor/editor.ts sends the captured intent as a JSON merge PATCH with its UID and resourceVersion through the watched proxy, and recovers a 409 through a guarded native GET. The real-API case runs it and the connector through a /k8s proxy that validates each PATCH: a save and its echo, a competing write between capture and PATCH, refused unconditional and machinery patches, and a same-name replacement. Co-Authored-By: Claude Opus 5.5 --- Taskfile.yml | 2 +- examples/native-editor/README.md | 88 +++++ examples/native-editor/editor.ts | 135 +++++++ gateway/kube/native_e2e_test.go | 329 ++++++++++++++++-- packages/krm-stream/e2e/native-driver.ts | 79 ++++- .../krm-stream/test/native-editing.test.ts | 244 +++++++++++++ packages/krm-stream/tsconfig.test.json | 3 +- 7 files changed, 838 insertions(+), 42 deletions(-) create mode 100644 examples/native-editor/README.md create mode 100644 examples/native-editor/editor.ts create mode 100644 packages/krm-stream/test/native-editing.test.ts 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/examples/native-editor/README.md b/examples/native-editor/README.md new file mode 100644 index 0000000..a3d33bf --- /dev/null +++ b/examples/native-editor/README.md @@ -0,0 +1,88 @@ +# 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. 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, 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. +// 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. + +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. A write +that leaves the object unchanged produces no event; if no echo arrives, a guarded read confirms the +state. + +## 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..d36c8c3 --- /dev/null +++ b/examples/native-editor/editor.ts @@ -0,0 +1,135 @@ +import { + type KRMObject, + type LiveResourceStore, + type NativeScope, + nativeObjectURL, +} from "../../packages/krm-stream/src/index.ts"; + +export type SaveOutcome = + | "unchanged" + | "busy" + | "saved" + | "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; + let needsRead = false; + + async function refresh(): Promise { + needsRead = true; + 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"; + needsRead = !accepted; + if (store.conflicts(uid).length) return "draft-conflict"; + return accepted ? "version-stale" : "recovering"; + } + + 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 { + if (needsRead) return await refresh(); + 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, + }; + 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) { + // 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. + await result.body?.cancel().catch(() => {}); + return "saved"; + } + // 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 refresh(); + } + throw new NativeRequestError(result.status, status); + } 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/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/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/test/native-editing.test.ts b/packages/krm-stream/test/native-editing.test.ts new file mode 100644 index 0000000..e71dd15 --- /dev/null +++ b/packages/krm-stream/test/native-editing.test.ts @@ -0,0 +1,244 @@ +// 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"); +}); 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" ] } From 01b303e0aa3fe49082ab614ed874a67080678c94 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 11:56:49 +0000 Subject: [PATCH 5/6] docs: document native editing and mark slice 2 implemented Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 8 +-- README.md | 20 +++--- docs/adopting.md | 5 +- docs/alternatives.md | 2 +- docs/auth.md | 8 ++- docs/client-state-model.md | 19 ++++-- docs/field-reports/third-our-identity.md | 62 +++++++++++++++---- ...006-stream-and-save-implementation-plan.md | 25 ++++---- docs/saving.md | 48 +++++++++++++- docs/why-a-gateway.md | 12 ++-- examples/README.md | 4 +- examples/native-viewer/README.md | 5 +- packages/krm-stream/README.md | 21 +++++-- 13 files changed, 175 insertions(+), 64 deletions(-) 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/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..0aea9ce 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,61 @@ 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. + +### 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. `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 +155,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/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..4f3e8de 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,48 @@ 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). + +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-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/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. From a0d6652f9188a958010540f8a531d93ef3a3f92a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 5 Oct 2026 12:16:20 +0000 Subject: [PATCH 6/6] fix(examples): settle unknown and unechoed native writes with a guarded read A network failure or 5xx left the native editor free to send the same PATCH again, and an accepted write whose echo never arrived let a second Save write again too. The editor now owes a guarded read in both cases: the next Save reads instead of writing, and confirm() performs that read when the host stops waiting for an echo. A 4xx is a definite refusal and owes none. The write response is still never adopted. Co-Authored-By: Claude Opus 5.5 --- docs/field-reports/third-our-identity.md | 7 +- docs/saving.md | 5 +- examples/native-editor/README.md | 25 +++- examples/native-editor/editor.ts | 58 +++++++-- .../krm-stream/test/native-editing.test.ts | 116 ++++++++++++++++++ 5 files changed, 196 insertions(+), 15 deletions(-) diff --git a/docs/field-reports/third-our-identity.md b/docs/field-reports/third-our-identity.md index 0aea9ce..822f752 100644 --- a/docs/field-reports/third-our-identity.md +++ b/docs/field-reports/third-our-identity.md @@ -116,6 +116,9 @@ below are what it was built and tested against. - **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 @@ -124,7 +127,9 @@ under a policy that makes all of `metadata` editable, and a Secret key added bes `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. `gateway/patch_test.go` covers +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 diff --git a/docs/saving.md b/docs/saving.md index 4f3e8de..1e9f90d 100644 --- a/docs/saving.md +++ b/docs/saving.md @@ -199,7 +199,10 @@ if (intent) { 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). +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 diff --git a/examples/native-editor/README.md b/examples/native-editor/README.md index a3d33bf..481a190 100644 --- a/examples/native-editor/README.md +++ b/examples/native-editor/README.md @@ -9,10 +9,13 @@ 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. It keeps in-flight edits and never adopts a write response. + 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, reads overtaken by the watch, and the live-state check. + 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 @@ -31,6 +34,7 @@ const editor = nativeEditor(store, uid, { proxy, scope }, hostFetch, () => conne 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(); @@ -67,10 +71,21 @@ write or read throws `NativeRequestError` with the HTTP status and the Kubernete 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. A write -that leaves the object unchanged produces no event; if no echo arrives, a guarded read confirms the -state. +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 diff --git a/examples/native-editor/editor.ts b/examples/native-editor/editor.ts index d36c8c3..62f76bf 100644 --- a/examples/native-editor/editor.ts +++ b/examples/native-editor/editor.ts @@ -9,6 +9,7 @@ export type SaveOutcome = | "unchanged" | "busy" | "saved" + | "confirmed" | "draft-conflict" | "version-stale" | "recovering" @@ -44,10 +45,22 @@ export function nativeEditor( const { namespace, name } = store.server(uid).metadata; const url = nativeObjectURL(source.proxy, { ...source.scope, namespace, name }); let saving = false; - let needsRead = 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; - async function refresh(): Promise { - needsRead = true; + /** 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"; @@ -61,9 +74,15 @@ export function nativeEditor( // 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"; - needsRead = !accepted; + 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"; - return accepted ? "version-stale" : "recovering"; + if (!accepted) return "recovering"; + return reason === "stale" ? "version-stale" : "confirmed"; } return { @@ -77,7 +96,8 @@ export function nativeEditor( if (store.conflicts(uid).length) return "draft-conflict"; saving = true; try { - if (needsRead) return await refresh(); + 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 @@ -87,17 +107,25 @@ export function nativeEditor( 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. + // 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); @@ -105,13 +133,27 @@ export function nativeEditor( // 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 refresh(); + 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; + } + }, }; } diff --git a/packages/krm-stream/test/native-editing.test.ts b/packages/krm-stream/test/native-editing.test.ts index e71dd15..c018163 100644 --- a/packages/krm-stream/test/native-editing.test.ts +++ b/packages/krm-stream/test/native-editing.test.ts @@ -242,3 +242,119 @@ test("the native editor refuses to write while the connection is not live, and r ); 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"], + ], + ); +});