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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,14 @@ updates:
patterns: ["*"]
commit-message:
prefix: "chore(deps)"

# Vue and its test tools remain confined to the private adapter example.
- package-ecosystem: npm
directory: /examples/vue
schedule:
interval: weekly
groups:
npm:
patterns: ["*"]
commit-message:
prefix: "chore(deps)"
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,24 @@ jobs:
if-no-files-found: error
retention-days: 7

vue:
name: Vue adapter example
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "22"
cache: npm
cache-dependency-path: examples/vue/package-lock.json
- run: npm ci --no-audit --no-fund
working-directory: examples/vue
- name: typecheck and test reactivity and subscription cleanup
run: npm test
working-directory: examples/vue

# The seam. Both suites above pass against bytes that never left their own process — which cannot
# prove that the server flushes, that frames survive arbitrary chunk boundaries, or that a terminal
# error really closes the connection. This job runs the REAL Go gateway over a REAL socket and reads
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ There are two halves, and they are usually two different people.

### The browser half

No bundler, no framework, no Kubernetes client. `EventSource` is native, and the store is plain ESM:
No bundler, no framework, no Kubernetes client. The managed connector uses fetch, and the store is plain ESM:

```ts
import { LiveResourceStore, connectWithEventSource, resourceStreamURL } from "@configbutler/krm-stream";
import { LiveResourceStore, connectManagedResourceStream, resourceStreamURL } from "@configbutler/krm-stream";

const store = new LiveResourceStore();

connectWithEventSource(
const connection = connectManagedResourceStream(
resourceStreamURL("/resource-stream/v1", {
target: "production",
version: "v1",
Expand All @@ -126,6 +126,9 @@ connectWithEventSource(
store.setValue(uid, ["spec", "replicas"], 3);
store.conflicts(uid); // paths where the server disagreed with an edit the user actually made
store.patch(uid); // an RFC 7386 merge patch of just their changes, or null

// In your host/view teardown callback:
connection.close(); // stop the stream and pending retries
```

If you have no bundler at all and vendor the library by copying it, import
Expand Down Expand Up @@ -164,7 +167,7 @@ patch through its own save endpoint, which is the one place a write can happen.
| Package | Purpose |
|---|---|
| `github.com/ConfigButler/krm-stream/gateway` | Dependency-free Go stream gateway and SSE handler. |
| `github.com/ConfigButler/krm-stream/gateway/kube` | Optional `client-go` backend and SSAR authorizer. |
| `github.com/ConfigButler/krm-stream/gateway/kube` | Optional `client-go` backend and SubjectAccessReview authorizer. |
| `@configbutler/krm-stream` | Official dependency-free ESM client store and transports. |
| `krm-stream@0.1.0` | Deprecated, frozen compatibility name claim. Use the scoped package instead. |
| [`spec/v1.md`](spec/v1.md) | Normative protocol contract. |
Expand Down
20 changes: 19 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ tasks:
# -------------------------------------------------------------------- test --
test:
desc: "Run both suites against the shared conformance fixtures."
deps: [test-gateway, test-kube, test-client]
deps: [test-gateway, test-kube, test-client, test-vue]

test-gateway:
desc: "Go: the gateway + its half of the conformance suite."
Expand Down Expand Up @@ -119,6 +119,24 @@ tasks:
cmds:
- node --test

test-vue:
desc: "Typecheck and test the optional Vue example."
deps: [_vue-deps]
dir: examples/vue
cmds:
- npm test

_vue-deps:
internal: true
dir: examples/vue
sources: ["package.json", "package-lock.json"]
generates: ["node_modules/.package-lock.json"]
status:
- test -x node_modules/.bin/tsc
- test -f node_modules/vue/package.json
cmds:
- npm ci --no-audit --no-fund

# --------------------------------------------------------------------- e2e --
replay:
desc: "Serve the conformance corpus over real SSE — a cluster you can point a browser at."
Expand Down
21 changes: 12 additions & 9 deletions docs/adopting.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,16 @@ func mount(mux *http.ServeMux, dynamicClientFor func(*User) dynamic.Interface) {
}
```

The browser uses `connectWithEventSource` for this route. Its same-origin session cookie is the only
credential EventSource can carry.
The browser uses `connectManagedResourceStream` for this route. Fetch sends its same-origin session
cookie and the managed connection provides bounded recovery after network failures and sequence gaps.

## 3. Browser client

```ts
import {
defaultPolicy,
LiveResourceStore,
connectWithEventSource,
connectManagedResourceStream,
resourceStreamURL,
withOpenAPIKeyedLists,
} from "@configbutler/krm-stream";
Expand All @@ -114,15 +114,18 @@ const url = resourceStreamURL("/resource-stream/v1", {
projection: "krm-full/v1",
});

connectWithEventSource(url, store, {
onGap: () => location.reload(), // reconnect for a new snapshot in an SPA-specific way
const connection = connectManagedResourceStream(url, store, {
onStateChange: state => renderConnection(state.status), // gaps recover with a fresh snapshot
});
store.subscribe(() => render(store));
```

For a bearer-token client, use `connectResourceStream(url, store, { headers: { Authorization: ... } })`.
For a bearer-token client, use `connectManagedResourceStream(url, store, { headers: { Authorization: ... } })`.
Comment thread
sunib marked this conversation as resolved.
That is useful for a non-browser client or an intentionally token-bearing browser application; the
same-origin cookie route is the safer browser default.
same-origin cookie route is the safer browser default. The host must enforce HTTPS for bearer-token
requests, including the resolved destination of relative URLs and any redirects. Validate the
trusted endpoint before supplying credentials and enforce the same policy in a custom fetch wrapper.
The stream connectors delegate transport to fetch; they do not enforce a credential transport policy.

For Deployment or CRD editing, a host may opt into OpenAPI-declared associative-list merging without
exposing schemas to the browser:
Expand All @@ -138,15 +141,15 @@ stays safely atomic.
## 4. Share watches only with Kubernetes-backed authorization

`SharedBackend` saves upstream watches but runs as one service identity. Pair it with
`kube.SSARAuthorizer` so Kubernetes still decides whether each caller may list and watch the scope.
`kube.SubjectAccessReviewAuthorizer` so Kubernetes still decides whether each caller may list and watch the scope.

```go
shared := gateway.NewSharedBackendWithOptions(serviceAccountBackend, gateway.SharedOptions{
QueueDepth: 512,
Observer: metrics,
})

options.Authorizer = kube.SSARAuthorizer(clientset, subjectFromUser)
options.Authorizer = kube.SubjectAccessReviewAuthorizer(clientset, subjectFromUser)
options.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil }
```

Expand Down
30 changes: 23 additions & 7 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,25 @@ too:
- **`ClientFor` is your refresh point.** It is called again each cycle, so you can hand back a client
bearing a *fresh* token.

**The gap:** a perfectly quiet stream may not cycle for a long time, so revocation is
noticed at the *next cycle*, not instantly. The credential half of that is solved properly on your
side of the seam — give the client a **refreshing token source** (Dex issues a refresh token; the
standard `oauth2.TokenSource` wraps it), and it never hands us a dead token in the first place.
Set `ReauthorizationInterval` to bound how long a quiet stream runs without checking entitlement:

```go
options.ReauthorizationInterval = 30 * time.Second
options.ReauthorizationTimeout = 5 * time.Second
```

Timed checks are per subscriber and recheck both `Authorizer` and the projection policy. During a
check that subscriber's object delivery pauses. Denial, timeout or policy failure terminates only
that stream; other subscribers and the shared upstream continue. A changed projection terminates
the old stream so it cannot keep disclosing its previous view. Zero interval preserves cycle-only
checks; zero timeout uses 10 seconds. The bound assumes host callbacks honor context cancellation
and sinks do not block indefinitely. The check uses the principal captured at stream open: resolve
current session/account validity inside the host authorizer if those can change independently of RBAC.

For 200 subscribers, a 30-second interval adds roughly 13 SubjectAccessReviews/second (list and watch
per subscriber), plus opening/cycle checks. Choose an interval and timeout for your revocation budget
and API-server capacity; checks are not cached across identities. `ClientFor` still runs per snapshot
cycle so the host can return a client backed by refreshing credentials.

## Two things that are easy to confuse

Expand All @@ -120,11 +135,11 @@ scope, so it opens it **once**, so it opens it as **one identity** — your serv
moment your `Authorizer` stops being defence in depth and becomes *the only thing* between a caller
and the objects. That is why it is opt-in, and why it is not the default.

If you turn it on, use **`kube.SSARAuthorizer`**, and Kubernetes is the boundary again:
If you turn it on, use **`kube.SubjectAccessReviewAuthorizer`**, and Kubernetes is the boundary again:

```go
shared := gateway.NewSharedBackend(serviceAccountBackend) // one watch, one identity…
opts.Authorizer = kube.SSARAuthorizer(clientset, subjectOf) // …but RBAC still decides
opts.Authorizer = kube.SubjectAccessReviewAuthorizer(clientset, subjectOf) // …but RBAC still decides
opts.Clients = func(context.Context, string, gateway.Principal) (gateway.Backend, error) { return shared, nil }
```

Expand All @@ -145,7 +160,8 @@ Three things it does that are easy to get accidentally permissive, all tested:
It needs your server's service account to hold `create` on `subjectaccessreviews` (the standard
`system:auth-delegator` role). It does **not** need impersonate rights: it asks a question *about* a
user, it does not act *as* one. And because the gateway re-authorizes every snapshot cycle, this is
also how a revocation reaches a stream that is already open.
also how a revocation reaches a stream that is already open. Timed checks bound quiet-stream revocation.
The old `SSARAuthorizer` name remains a deprecated alias; it never created SelfSubjectAccessReview.

## What this library never does

Expand Down
9 changes: 5 additions & 4 deletions docs/proposals/0004-views-and-bytes.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,17 @@ actually emitted, including `reset`, `synced`, errors, and deletes. It does not
cycle. A new HTTP connection begins at one.

The TypeScript transport checks every sequence number. A missing, repeated, malformed, or out-of-order
number closes the transport and reports a gap; the host reconnects for a fresh snapshot. `seq` is not
number closes the transport and reports a gap; the managed connector retries for a fresh snapshot. `seq` is not
an SSE `id` and does not provide replay or resume semantics.

## Consequences

- `krm-spec/v1` makes a status-blind editor cheap under controller churn without weakening the
complete-object invariant for the fields it receives.
- A suppressed update may leave the consumer's `metadata.resourceVersion` stale. Consumers must not
use that opaque value as a save precondition. Saves remain narrow merge patches built from local
edits, while the client-side three-way merge reports visible conflicts live.
- A suppressed update may leave the consumer's `metadata.resourceVersion` stale. It remains a safe
save precondition: the API server rejects a stale write with 409. Capture it with the patch,
reconcile before retrying, and never substitute a new version onto an old patch. Narrow patches
limit write scope; they do not prevent lost updates. See [saving](../saving.md).
- Shared upstream watches remain safe: projection, redaction revision, suppression digest, and sequence
state are per consumer stream, after upstream fan-out.

Expand Down
Loading