From 11857f934b2959afa17950a6c0f14dae86478a40 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Thu, 15 Oct 2026 20:58:36 +0000 Subject: [PATCH] Config and provisioning, mesh-sdk's part (feature 57) The SDK side of design 57 (ADR 0348, ADR 0349): seal (issue 650); the provider helper that answers a provision's consumers from the plan and the runtime's state, with the consumers' file, names on the plan entry and StateBus (issues 678, 679, 776, 788, 792, 804); and the runtime's mesh/provision messages (issue 685). Squashed from the feature's local branch; each issue's commit is named on its issue. Co-Authored-By: Claude --- README.md | 14 +- RELEASE-57.md | 6 + go/README.md | 4 +- go/provider/consumers.go | 72 ++ go/provider/consumers_test.go | 69 ++ go/provider/provider.go | 635 ++++++++++++++++++ go/provider/provider_test.go | 590 ++++++++++++++++ go/provider/statebus_test.go | 152 +++++ go/provision.go | 90 +++ go/provision_test.go | 176 +++++ go/seal/seal.go | 203 ++++++ go/seal/seal_test.go | 156 +++++ .../testdata/sealed-by-licence-manager.json | 121 ++++ go/seal/testdata/sealed-by-typescript.json | 12 + go/stdio.go | 27 +- package.json | 6 +- src/index.ts | 7 +- src/{provisioner => provider-loop}/index.ts | 33 +- src/seal/box.ts | 90 +++ src/seal/index.ts | 29 + test/retire.test.ts | 12 +- test/sdk.test.ts | 39 +- test/seal.test.ts | 71 ++ test/standing.test.ts | 6 +- 24 files changed, 2580 insertions(+), 40 deletions(-) create mode 100644 RELEASE-57.md create mode 100644 go/provider/consumers.go create mode 100644 go/provider/consumers_test.go create mode 100644 go/provider/provider.go create mode 100644 go/provider/provider_test.go create mode 100644 go/provider/statebus_test.go create mode 100644 go/provision.go create mode 100644 go/provision_test.go create mode 100644 go/seal/seal.go create mode 100644 go/seal/seal_test.go create mode 100644 go/seal/testdata/sealed-by-licence-manager.json create mode 100644 go/seal/testdata/sealed-by-typescript.json rename src/{provisioner => provider-loop}/index.ts (96%) create mode 100644 src/seal/box.ts create mode 100644 src/seal/index.ts create mode 100644 test/seal.test.ts diff --git a/README.md b/README.md index 81b4e03..1788b82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,13 @@ Not the manifest schema (the control plane owns and parses that, in Go). The sha contains — the contract both a provider and a consumer conform to), and the tool and event types. These change rarely and deliberately; when one does, a rebuild of everything is *correct*. -### `provisioner` — the reconcile harness every provider shares +### `provider-loop` — the reconcile harness every provider on the grant files shares +Called `provisioner` until design 57 (novox/hq issue 678); that word is mesh-provisioner's now. The old path and +names (`/provisioner`, `runProvisioner`, `provisionerTools`) stay aliases until the providers importing them move to +the Go helper that answers consumers over the bus (`go/provider`, design 57 B2 and B3) and this loop retires with the +grant files. Its events and tools keep their names (`provisioner.failing`, `provisioner_retirement`, …): the +controller and the operator read them. + The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create @@ -146,12 +152,12 @@ import { UmamiClient } from "../client.js"; // the client lives in the module, n registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL))); ``` -A provider's provisioner: +A provider's loop: ```ts -import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner"; +import { runProviderLoop, Grant, Credential } from "@novox/mesh-sdk/provider-loop"; -runProvisioner("analytics", { +runProviderLoop("analytics", { async create(grant: Grant): Promise { /* create a umami site, return the grant */ }, async remove(grant: Grant): Promise { /* delete the site */ }, }); diff --git a/RELEASE-57.md b/RELEASE-57.md new file mode 100644 index 0000000..140ccff --- /dev/null +++ b/RELEASE-57.md @@ -0,0 +1,6 @@ +# Release 57: config and provisioning + +This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package +merges here through its own reviewed pull request; the branch reaches main as one pull request, in one +delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog +and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request. diff --git a/go/README.md b/go/README.md index 76ffd0f..6a591b2 100644 --- a/go/README.md +++ b/go/README.md @@ -3,7 +3,7 @@ `stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `.` is the seat's implementation. -`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only. +`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). `stdio.Config(key)` and `stdio.Bound(provision, key)` read, and `stdio.WatchConfig` and `stdio.WatchBound` watch, the module's own config values and the non-secret values of what it is bound to, for what its manifest declares: the runtime asks its machine's provisioner client as the module it launched, never a module the code names. `stdio.Secret(name)` is refused while the module's code runs in the tool runner; its secrets reach it through its files (novox/hq ADR 0349 §14, ADR 0370). `provider` is a providing module's half of a binding (novox/hq ADR 0349 §12, issue 678): it sees each consumer that binds the provider in the plans of the assignments mirror, writes one entry per consumer to `_bound` (`..[.]`: served values composed with the consumer's identity, the login, and the name of the vault's credential, never a secret), hands the provider's adapter the vault's value (`provision//` shared, `pair////[/]` per consumer), refuses a derived value to a consumer bound under a local name, naming both ends, and removes the entry of one that unbinds; the bus and the vault are interfaces the provider wires, the vault as `provider.ConsumersFile`, the file its machine's provisioner client fills from `${consumers:}` with each bound consumer's credential (novox/hq ADR 0373), since a provider in the tool runner never asks the vault itself. Standard library only. ## Installing @@ -16,4 +16,4 @@ at the same version as the TypeScript package. What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol `mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one -answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. +answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. diff --git a/go/provider/consumers.go b/go/provider/consumers.go new file mode 100644 index 0000000..326d0a9 --- /dev/null +++ b/go/provider/consumers.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" +) + +// The provider's consumers' credentials, from the file its machine's provisioner client fills (novox/hq ADR 0373, +// issue 679). +// +// **The provider never asks the vault.** A provider in the tool runner has no account of its own, and the vault +// hands a secret only to a node's provisioner client or to a module under an account of its own (ADR 0349 clause +// 11, ADR 0370 clause 2). So the provider's template names `${consumers:}`, and its machine's client +// fills that file with one entry per consumer bound to it for that provision, each credential asked of the vault in +// the client's own name; the file is the provider's alone (mode 0600, owned). ConsumersFile is that file as the +// helper's Vault: it reads the file on each ask, so a consumer that binds or a credential the vault remakes reaches +// the provider at its next pass. + +// ConsumerCredential is one entry of the file: the consumer, the vault's name of its credential, and its value. +type ConsumerCredential struct { + // Consumer and Module are the consumer's machine and module, Local its local name for the provision, if any. + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // Credential is the vault's name of the consumer's credential, PairName's. + Credential string `json:"credential"` + // Value is the credential itself. + Value string `json:"value"` +} + +// ConsumersFile answers a credential from the files the provider machine's client fills: one per provision whose +// template names `${consumers:}`, each a JSON list of ConsumerCredential. +type ConsumersFile struct { + Paths []string +} + +// Value is the credential named name, from the first file carrying it. A file not there yet is passed over; one not +// in the client's form is an error, never read as no consumers. +func (f ConsumersFile) Value(_ context.Context, name string) ([]byte, error) { + var missing []string + for _, at := range f.Paths { + raw, err := os.ReadFile(at) + if errors.Is(err, fs.ErrNotExist) { + missing = append(missing, at) + continue + } + if err != nil { + return nil, fmt.Errorf("the consumers' file %s: %w", at, err) + } + var entries []ConsumerCredential + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("the consumers' file %s is not the provisioner client's list of consumers", at) + } + for _, e := range entries { + if e.Credential == name { + if e.Value == "" { + return nil, fmt.Errorf("the consumers' file %s carries %s with no value", at, name) + } + return []byte(e.Value), nil + } + } + } + if len(missing) == len(f.Paths) { + return nil, fmt.Errorf("%s: no consumers' file is filled yet (%v)", name, f.Paths) + } + return nil, fmt.Errorf("%s is in no consumers' file: the vault holds none for it yet, or its machine's client "+ + "has not filled it", name) +} diff --git a/go/provider/consumers_test.go b/go/provider/consumers_test.go new file mode 100644 index 0000000..731fc31 --- /dev/null +++ b/go/provider/consumers_test.go @@ -0,0 +1,69 @@ +package provider + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// The provider reads its consumers' credentials only from the file its machine's provisioner client fills (novox/hq +// ADR 0373 clause 3, issue 679): one entry per consumer, naming the consumer and the vault's name for its credential, +// and carrying the value. The helper is wired to it as its Vault and asks nothing of the vault itself. +func TestTheConsumersFileAnswersEachConsumersCredential(t *testing.T) { + dir := t.TempDir() + at := filepath.Join(dir, "consumers.json") + file := `[{"consumer":"ace","module":"letta","credential":"pair/novox/postgres-database/ace/letta","value":"s3cret"}, + {"consumer":"g14","module":"gitea","local":"cache","credential":"pair/novox/postgres-database/g14/gitea/cache","value":"other"}]` + if err := os.WriteFile(at, []byte(file), 0o600); err != nil { + t.Fatal(err) + } + v := ConsumersFile{Paths: []string{filepath.Join(dir, "absent.json"), at}} + got, err := v.Value(context.Background(), PairName("novox", "postgres-database", "ace", "letta", "")) + if err != nil || string(got) != "s3cret" { + t.Fatalf("ace's letta: %q, %v; want s3cret", got, err) + } + got, err = v.Value(context.Background(), PairName("novox", "postgres-database", "g14", "gitea", "cache")) + if err != nil || string(got) != "other" { + t.Fatalf("g14's gitea under cache: %q, %v; want other", got, err) + } + // A consumer the file does not carry is waited for, and never told of a login its provider has not made. + _, err = v.Value(context.Background(), PairName("novox", "postgres-database", "shanks", "photos", "")) + if err == nil || !strings.Contains(err.Error(), "pair/novox/postgres-database/shanks/photos") { + t.Fatalf("a consumer the file does not carry: %v", err) + } + if strings.Contains(err.Error(), "s3cret") || strings.Contains(err.Error(), "other") { + t.Fatalf("an error names a credential: %v", err) + } + + // Wired as the helper's Vault, the file is what makes each consumer's login. + bus := newStandIn() + bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte(`{"node":"ace","module":"letta", + "provisioning":{"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres"}]}}`)) + var made []string + p := New(Options{Node: "novox", Module: "postgres", Bus: bus, Vault: v, + Offers: map[string]Offer{"postgres-database": {Serves: map[string]any{"port": 5432}}}, + Make: func(_ context.Context, c Consumer) error { + made = append(made, c.Entry.User+"="+string(c.Credential)) + return nil + }, Logf: func(string, ...any) {}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + if len(made) != 1 || made[0] != "mesh_ace_letta=s3cret" { + t.Fatalf("made %v, want ace's letta under the file's credential", made) + } +} + +// A file that is not the client's form is said, never read as no consumers. +func TestAConsumersFileNotInItsFormIsRefused(t *testing.T) { + at := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(at, []byte(`{"not": "a list"}`), 0o600); err != nil { + t.Fatal(err) + } + _, err := ConsumersFile{Paths: []string{at}}.Value(context.Background(), "pair/novox/p/ace/m") + if err == nil || !strings.Contains(err.Error(), at) { + t.Fatalf("a file not in the client's form: %v", err) + } +} diff --git a/go/provider/provider.go b/go/provider/provider.go new file mode 100644 index 0000000..95bfd19 --- /dev/null +++ b/go/provider/provider.go @@ -0,0 +1,635 @@ +// Package provider is a providing module's half of a binding: it answers the provider's consumers itself, over the +// bus (novox/hq ADR 0349 clause 12, design 57 B1, issue 678). +// +// **Who binds the provider.** Each node's provisioner client records the needs of the modules handed to it from +// their plans, and the controller names in each plan, per required provision and local name, the provider that +// resolution chose: `provisioning.provisions` in `.` of the assignments mirror (issue 760). A recorded +// need names no provider (issue 677's note 7), so the plan's entry is where a bind is seen, as the vault sees it +// (mesh-vault pair.go). A consumer binds this provider when an entry names this machine and this module, and +// unbinds when the entry, or the assignment, goes. A consumer still filled the old way has no plan and is not seen +// here; the controller answers it as before (ADR 0349 clause 17). +// +// **One entry per consumer, in the provider's own bucket.** For each bind the helper composes the consumer's +// served values from the provider's offer (its config already composed into it, ADR 0202 §7) and the consumer's +// identity (`${consumer:as}`, `${consumer:as:dns}`, ADR 0202 §1), and writes one entry, `.. +// [.]` of `_bound`: the one statement both ends read. It never carries a +// secret. It names the credential the vault keeps for the pair, which the consumer's client asks the vault for. +// +// **The credential is the vault's** (ADR 0349 clause 8). The helper mints nothing and reads nothing of the +// provider's own secrets: it reads the value from the vault and hands it to the provider's adapter, which makes the +// login its software keeps. An offer whose software holds one credential (ADR 0158) is the vault's one value per +// provider and provision, `provision//`, with the provider's one user name; an offer with +// a login per consumer (ADR 0048, ADR 0158 §11, the default) is each consumer's own value, +// `pair////[/]`, with the consumer's identity as its login (issue +// 677's notes 12 and 13). A credential the vault does not hold yet is waited for: nothing is made and no entry is +// written, so a consumer is never told of a login its provider has not made. A provider in the tool runner never asks +// the vault itself: its Vault is ConsumersFile, the file its machine's provisioner client fills (ADR 0373). +// +// **A multi-holder consumer gets no derived value** (ADR 0202 §10 as ADR 0349 clause 12 changes it): a consumer +// bound under a local name keeps several holders of the provision, each with its own login, while what the +// provider derives names one; its entry refuses it, naming both ends, and its client reports it. +// +// The helper speaks to the bus and the vault through the two interfaces below, so a provider wires it to its +// runtime and a test to a stand-in. Standard library only. +package provider + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// AssignmentsBucket is the controller's assignments mirror, `.`, whose value carries the plan of a +// module handed to the provisioner. +const AssignmentsBucket = "mesh-controller_assignments" + +// BucketOf is a providing module's own bucket of entries, one per consumer: `_bound`, the module's state +// `bound`. +func BucketOf(module string) string { return module + "_bound" } + +// Bus is what the helper needs of the bus: a key-value watch and the provider's writes to its own bucket. +type Bus interface { + // Watch hands handle every current key and value of bucket, then each change as it comes, a removed key with a + // nil value; it returns once the current values are handed, and the watch lasts until ctx ends. + Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error + Put(ctx context.Context, bucket, key string, value []byte) error + Delete(ctx context.Context, bucket, key string) error +} + +// Vault is how the provider reads a credential the vault keeps, by its name, opened. +type Vault interface { + Value(ctx context.Context, name string) ([]byte, error) +} + +// CredentialForm is which credential an offer's consumers present. +type CredentialForm int + +const ( + // PerConsumer is a login of each consumer's own (ADR 0048, ADR 0158 §11): the default. + PerConsumer CredentialForm = iota + // Shared is the one credential the provider's software holds, shared with every consumer (ADR 0158). + Shared + // NoCredential is an offer whose consumers present none. + NoCredential +) + +// Offer is what the provider serves one provision with. +type Offer struct { + // Serves are the values served to each consumer, the provider's config already composed into them; a string + // may name the consumer's identity, `${consumer:as}` or `${consumer:as:dns}`. + Serves map[string]any + // Credential is the form of the credential its consumers present. + Credential CredentialForm + // User is the provider's one user name, for a Shared credential. + User string +} + +// Entry is one consumer's entry in the provider's bucket: the one statement both ends read. Never a secret. +type Entry struct { + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Provision string `json:"provision"` + Consumer string `json:"consumer"` + Module string `json:"module"` + Local string `json:"local,omitempty"` + // As is the consumer's identity, the mesh's (ADR 0202 §1). + As string `json:"as,omitempty"` + // User is the login the consumer presents: its identity, or the provider's one user name for a shared credential. + User string `json:"user,omitempty"` + // Values are the served values, composed for this consumer: what `${bound::}` reads. + Values map[string]any `json:"values,omitempty"` + // Credential is the name of the credential in the vault, which the consumer's client asks the vault for. + Credential string `json:"credential,omitempty"` + // Refused, when set, is why the consumer is not served, naming both ends. + Refused string `json:"refused,omitempty"` + // Contributes is what the consumer's module tells its provider about this provision (a database's name, its + // extensions), from its plan's entry (issue 776). Handed to Make and Gone; never written to the bucket. + Contributes map[string]any `json:"-"` + // At and From are where the consumer's plan says the provider is and which machine it is (issue 788): the values + // today's binding file gives that consumer. Written into Values as `at` and `from`; never alone to the bucket. + At, From string `json:"-"` + // Names are the names the controller composed for this consumer on this provision (issue 792): its own name, its + // internal name, and one of each per local name it contributes under, keyed as its binding file names them. + // Written into Values over anything the provider serves under the key; never alone to the bucket. + Names map[string]string `json:"-"` +} + +// Key is the entry's key in the provider's bucket: `..`, and `.` for a bind under a +// local name. +func (e Entry) Key() string { + k := e.Consumer + "." + e.Module + "." + e.Provision + if e.Local != "" { + k += "." + e.Local + } + return k +} + +// Consumer is what the provider's adapter makes: the consumer's entry and the credential the vault keeps for it. +type Consumer struct { + Entry Entry + Credential []byte +} + +// Options are the provider's. +type Options struct { + // Node and Module are this provider: the machine it runs on and the module serving. + Node, Module string + // Offers are the provisions it serves, by name. + Offers map[string]Offer + Bus Bus + Vault Vault + // Make brings a consumer into being in the provider's software under the vault's credential. Asked on every + // pass for every consumer answered, so it must leave one that is already as asked alone; a rotated credential + // reaches the software this way. + Make func(ctx context.Context, c Consumer) error + // Gone, when set, hears that a consumer no longer binds the provider, before its entry is removed; an error + // keeps the entry, and it is heard again on the next pass. Retiring what was kept for it is the provider's + // (ADR 0230). + Gone func(ctx context.Context, e Entry) error + // Identity is the consumer's identity for an entry whose plan names none; ConsumerIdentity of the node and the + // module when not set. + Identity func(node, module string) string + // Every is how often a pass runs without a change watched; a minute when not set. + Every time.Duration + // Logf says what a pass did that differs from the last; the standard log when not set. + Logf func(format string, args ...any) +} + +// Provider answers its consumers. +type Provider struct { + opts Options + + mu sync.Mutex + assignments map[string][]byte + entries map[string][]byte + said map[string]string + poke chan struct{} +} + +// New is a provider answering its consumers as opts say. +func New(opts Options) *Provider { + if opts.Identity == nil { + opts.Identity = ConsumerIdentity + } + if opts.Every <= 0 { + opts.Every = time.Minute + } + if opts.Logf == nil { + opts.Logf = func(f string, a ...any) { fmt.Printf("[provider] "+f+"\n", a...) } + } + return &Provider{opts: opts, assignments: map[string][]byte{}, entries: map[string][]byte{}, + said: map[string]string{}, poke: make(chan struct{}, 1)} +} + +// What a pass did for one bind. +const ( + Answered = "answered" + Refused = "refused" + Waiting = "waiting" + Failed = "failed" + Removed = "removed" +) + +// Outcome is what a pass did for one consumer's entry. +type Outcome struct { + Key string `json:"key"` + Status string `json:"status"` + Why string `json:"why,omitempty"` +} + +// Run watches the binds and the provider's own entries, and passes once at start, on every change and every +// Every, until ctx ends. +func (p *Provider) Run(ctx context.Context) error { + if err := p.watch(ctx, BucketOf(p.opts.Module), p.entries); err != nil { + return fmt.Errorf("watching %s: %w", BucketOf(p.opts.Module), err) + } + if err := p.watch(ctx, AssignmentsBucket, p.assignments); err != nil { + return fmt.Errorf("watching %s: %w", AssignmentsBucket, err) + } + tick := time.NewTicker(p.opts.Every) + defer tick.Stop() + for { + p.mu.Lock() + assignments, entries := clone(p.assignments), clone(p.entries) + p.mu.Unlock() + if _, err := p.pass(ctx, assignments, entries); err != nil { + p.say("pass", err.Error()) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.poke: + case <-tick.C: + } + } +} + +func (p *Provider) watch(ctx context.Context, bucket string, into map[string][]byte) error { + return p.opts.Bus.Watch(ctx, bucket, func(key string, value []byte) { + p.mu.Lock() + if value == nil { + delete(into, key) + } else { + into[key] = append([]byte(nil), value...) + } + p.mu.Unlock() + if bucket == AssignmentsBucket { + select { + case p.poke <- struct{}{}: + default: + } + } + }) +} + +// Pass reads the binds and the provider's entries from the bus and answers once: each bind of this provider gets +// its entry, and each entry of this provider no bind asks for is removed. An assignment that cannot be read stops +// the pass before anything is removed. Run passes by itself; Pass is for a provider that passes when it chooses. +func (p *Provider) Pass(ctx context.Context) ([]Outcome, error) { + watchCtx, cancel := context.WithCancel(ctx) + defer cancel() + var mu sync.Mutex + read := func(bucket string) (map[string][]byte, error) { + got := map[string][]byte{} + err := p.opts.Bus.Watch(watchCtx, bucket, func(key string, value []byte) { + mu.Lock() + defer mu.Unlock() + if value == nil { + delete(got, key) + } else { + got[key] = append([]byte(nil), value...) + } + }) + mu.Lock() + defer mu.Unlock() + return clone(got), err + } + entries, err := read(BucketOf(p.opts.Module)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", BucketOf(p.opts.Module), err) + } + assignments, err := read(AssignmentsBucket) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", AssignmentsBucket, err) + } + return p.pass(ctx, assignments, entries) +} + +// planned is as much of an assignment as the helper reads: lenient, since the plan is the client's to act on. +type planned struct { + Node string `json:"node"` + Module string `json:"module"` + Provisioning *struct { + Provisions []struct { + Provision string `json:"provision"` + Local string `json:"local"` + Provider string `json:"provider"` + ProviderModule string `json:"provider-module"` + Contributes map[string]any `json:"contributes"` + At string `json:"at"` + From string `json:"from"` + As string `json:"as"` + Names map[string]string `json:"names"` + } `json:"provisions"` + } `json:"provisioning"` +} + +func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]byte) ([]Outcome, error) { + var binds []Entry + for _, key := range sortedKeys(assignments) { + var a planned + if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key { + return nil, fmt.Errorf("the assignment %s is not the controller's, so who binds %s on %s is not known; "+ + "nothing is answered or removed", key, p.opts.Module, p.opts.Node) + } + if a.Provisioning == nil { + continue + } + for _, b := range a.Provisioning.Provisions { + if b.Provider != p.opts.Node || b.ProviderModule != p.opts.Module { + continue + } + binds = append(binds, Entry{Provider: p.opts.Node, ProviderModule: p.opts.Module, + Provision: b.Provision, Consumer: a.Node, Module: a.Module, Local: b.Local, + Contributes: b.Contributes, At: b.At, From: b.From, As: b.As, Names: b.Names}) + } + } + + var out []Outcome + want := map[string]bool{} + for _, b := range binds { + key := b.Key() + want[key] = true + o := p.answer(ctx, b, entries[key]) + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + } + for _, key := range sortedKeys(entries) { + if want[key] { + continue + } + var e Entry + if err := json.Unmarshal(entries[key], &e); err != nil || e.Provider != p.opts.Node || + e.ProviderModule != p.opts.Module { + continue // another machine's instance of this provider wrote it, or nothing of this helper's + } + if p.opts.Gone != nil { + if err := p.opts.Gone(ctx, e); err != nil { + o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and the provider could not let it go: " + + err.Error()} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + } + if err := p.opts.Bus.Delete(ctx, BucketOf(p.opts.Module), key); err != nil { + o := Outcome{Key: key, Status: Failed, Why: "no longer bound, and its entry could not be removed: " + + err.Error()} + out = append(out, o) + p.say(key, o.Status+" "+o.Why) + continue + } + out = append(out, Outcome{Key: key, Status: Removed}) + p.say(key, Removed) + p.mu.Lock() + delete(p.said, key) + p.mu.Unlock() + } + return out, nil +} + +// answer makes one bind's entry and writes it when it differs from what the bucket holds. +func (p *Provider) answer(ctx context.Context, e Entry, have []byte) Outcome { + key := e.Key() + offer, offered := p.opts.Offers[e.Provision] + switch { + case !offered: + e.Refused = fmt.Sprintf("%s on %s is bound to %s on %s for %s, which %s does not serve", e.Module, + e.Consumer, e.ProviderModule, e.Provider, e.Provision, e.ProviderModule) + case e.Local != "" && derives(offer.Serves): + e.Refused = fmt.Sprintf("%s on %s keeps several holders of %s (this one is %q), and %s on %s derives %s for "+ + "each consumer from its login: each holder has a login of its own and a consumer is told one value per "+ + "requirement, so the two ends would name different things (novox/hq ADR 0202 §10, ADR 0349 clause 12)", + e.Module, e.Consumer, e.Provision, e.Local, e.ProviderModule, e.Provider, + strings.Join(derivedKeys(offer.Serves), ", ")) + } + if e.Refused != "" { + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + + // The plan's identity is the controller's, slug first; Identity only answers for an entry without one. + if e.As == "" { + e.As = p.opts.Identity(e.Consumer, e.Module) + } + values, err := ServedTo(offer.Serves, e.As) + if err != nil { + e.Refused = fmt.Sprintf("%s on %s serving %s to %s on %s: %v", e.ProviderModule, e.Provider, e.Provision, + e.Module, e.Consumer, err) + e.As = "" + return p.write(ctx, e, have, Outcome{Key: key, Status: Refused, Why: e.Refused}) + } + if e.At != "" || e.From != "" { + if values == nil { + values = map[string]any{} + } + if e.At != "" { + values["at"] = e.At + } + if e.From != "" { + values["from"] = e.From + } + } + if len(e.Names) > 0 { + if values == nil { + values = map[string]any{} + } + for key, name := range e.Names { + values[key] = name + } + } + e.Values = values + var credential []byte + switch offer.Credential { + case Shared: + e.User = offer.User + e.Credential = SharedName(e.Provider, e.Provision) + case PerConsumer: + e.User = e.As + e.Credential = PairName(e.Provider, e.Provision, e.Consumer, e.Module, e.Local) + } + if e.Credential != "" { + credential, err = p.opts.Vault.Value(ctx, e.Credential) + if err != nil { + return Outcome{Key: key, Status: Waiting, Why: fmt.Sprintf("the vault's %s: %v", e.Credential, err)} + } + } + if p.opts.Make != nil { + if err := p.opts.Make(ctx, Consumer{Entry: e, Credential: credential}); err != nil { + why := err.Error() + if len(credential) > 0 { + why = strings.ReplaceAll(why, string(credential), "") + } + return Outcome{Key: key, Status: Failed, Why: "the provider could not make it: " + why} + } + } + return p.write(ctx, e, have, Outcome{Key: key, Status: Answered}) +} + +func (p *Provider) write(ctx context.Context, e Entry, have []byte, o Outcome) Outcome { + body, err := json.Marshal(e) + if err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: err.Error()} + } + if sameJSON(have, body) { + return o + } + if err := p.opts.Bus.Put(ctx, BucketOf(p.opts.Module), o.Key, body); err != nil { + return Outcome{Key: o.Key, Status: Failed, Why: "its entry could not be written: " + err.Error()} + } + return o +} + +// say logs what a key's pass said when it differs from the last. +func (p *Provider) say(key, what string) { + p.mu.Lock() + same := p.said[key] == what + p.said[key] = what + p.mu.Unlock() + if !same { + p.opts.Logf("%s: %s", key, strings.TrimSpace(what)) + } +} + +// SharedName is the vault's one value per provider and provision (ADR 0158, issue 677). +func SharedName(provider, provision string) string { return "provision/" + provider + "/" + provision } + +// PairName is the vault's value of one consumer's own login (ADR 0048, ADR 0158 §11, issue 677's note 12). +func PairName(provider, provision, node, module, local string) string { + n := "pair/" + provider + "/" + provision + "/" + node + "/" + module + if local != "" { + n += "/" + local + } + return n +} + +// identityUnusable is every character a consumer's identity does not hold (mesh-controller internal/catalogue +// identity.go, the one derivation). +var identityUnusable = regexp.MustCompile(`[^a-z0-9_]+`) + +// ConsumerIdentity is what one module on one machine is called wherever it authenticates: `mesh__`, +// lower case, anything but letters, digits and `_` written `_` (mesh-controller ConsumerIdentity). module is the +// identity source: the module's slug when it declares one, otherwise its name (ADR 0049). +func ConsumerIdentity(node, module string) string { + clean := func(s string) string { + return strings.Trim(identityUnusable.ReplaceAllString(strings.ToLower(s), "_"), "_") + } + return "mesh_" + clean(node) + "_" + clean(module) +} + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// ServedTo fills an offer's served values for one consumer whose identity is as (ADR 0202 §1): `${consumer:as}` as +// it is, `${consumer:as:dns}` with `-` for `_`. Only strings carry placeholders; any other fact or alphabet is +// refused, never left standing. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if serves == nil { + return nil, nil + } + out := make(map[string]any, len(serves)) + for _, key := range sortedKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + out[key] = serves[key] + continue + } + var failed error + out[key] = consumerFact.ReplaceAllStringFunc(text, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + switch { + case parts[1] == "as" && parts[2] == "": + return as + case parts[1] == "as" && parts[2] == "dns": + return strings.ReplaceAll(as, "_", "-") + } + if failed == nil { + failed = fmt.Errorf("the value served as %q says %s, and the mesh states only ${consumer:as} and "+ + "${consumer:as:dns}", key, match) + } + return match + }) + if failed != nil { + return nil, failed + } + } + return out, nil +} + +func derives(serves map[string]any) bool { return len(derivedKeys(serves)) > 0 } + +func derivedKeys(serves map[string]any) []string { + var out []string + for _, k := range sortedKeys(serves) { + if text, ok := serves[k].(string); ok && strings.Contains(text, "${consumer:") { + out = append(out, k) + } + } + return out +} + +func clone(m map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func sameJSON(a, b []byte) bool { + if a == nil || b == nil { + return false + } + var x, y any + if json.Unmarshal(a, &x) != nil || json.Unmarshal(b, &y) != nil { + return false + } + ja, _ := json.Marshal(x) + jb, _ := json.Marshal(y) + return string(ja) == string(jb) +} + +func sortedKeys[V any](m map[string]V) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// StateBus is the Bus of a provider that runs as a module bundle over the runtime's stdio: the provider's own bucket, +// BucketOf(module), is the module's state `bound`, and AssignmentsBucket is the state it reads, +// `mesh-controller.assignments` (both declared in the module's manifest). Any other bucket is refused. +// +// A stdio state watch lasts as long as the process and cannot be ended (stdio.KeptState.Watch), so Watch honours the +// Bus contract by silencing instead: once ctx has ended the handler is never called again, though the runtime keeps +// sending changes that are dropped. A Pass, which watches twice per call, is therefore correct, only the dropped +// changes of its two spent watches remain. +func StateBus(module string) Bus { return stateBus{module: module} } + +type stateBus struct{ module string } + +func (b stateBus) state(bucket string) (stdio.KeptState, error) { + switch bucket { + case BucketOf(b.module): + return stdio.State("bound"), nil + case AssignmentsBucket: + return stdio.State("mesh-controller.assignments"), nil + } + return stdio.KeptState{}, fmt.Errorf("%s is not a bucket this provider keeps or reads", bucket) +} + +func (b stateBus) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Watch("", func(ch stdio.StateChange) error { + if ctx.Err() != nil { + return nil + } + if ch.Op == "delete" { + handle(ch.Key, nil) + } else { + handle(ch.Key, ch.Value) + } + return nil + }) +} + +func (b stateBus) Put(_ context.Context, bucket, key string, value []byte) error { + s, err := b.state(bucket) + if err != nil { + return err + } + _, err = s.Put(key, json.RawMessage(value)) + return err +} + +func (b stateBus) Delete(_ context.Context, bucket, key string) error { + s, err := b.state(bucket) + if err != nil { + return err + } + return s.Delete(key) +} diff --git a/go/provider/provider_test.go b/go/provider/provider_test.go new file mode 100644 index 0000000..7e00b63 --- /dev/null +++ b/go/provider/provider_test.go @@ -0,0 +1,590 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" +) + +// standIn is a stand-in bus: buckets of keys and values, and watches that hand the current values first and then +// every change, as the bus's key-value watch does. +type standIn struct { + mu sync.Mutex + buckets map[string]map[string][]byte + watchers map[string][]func(string, []byte) +} + +func newStandIn() *standIn { + return &standIn{buckets: map[string]map[string][]byte{}, watchers: map[string][]func(string, []byte){}} +} + +func (s *standIn) Watch(ctx context.Context, bucket string, handle func(key string, value []byte)) error { + s.mu.Lock() + current := map[string][]byte{} + for k, v := range s.buckets[bucket] { + current[k] = v + } + s.watchers[bucket] = append(s.watchers[bucket], handle) + s.mu.Unlock() + for _, k := range sortedKeys(current) { + handle(k, current[k]) + } + return nil +} + +func (s *standIn) Put(_ context.Context, bucket, key string, value []byte) error { + s.mu.Lock() + if s.buckets[bucket] == nil { + s.buckets[bucket] = map[string][]byte{} + } + s.buckets[bucket][key] = append([]byte(nil), value...) + ws := slices.Clone(s.watchers[bucket]) + s.mu.Unlock() + for _, w := range ws { + w(key, value) + } + return nil +} + +func (s *standIn) Delete(_ context.Context, bucket, key string) error { + s.mu.Lock() + delete(s.buckets[bucket], key) + ws := slices.Clone(s.watchers[bucket]) + s.mu.Unlock() + for _, w := range ws { + w(key, nil) + } + return nil +} + +func (s *standIn) get(bucket, key string) []byte { + s.mu.Lock() + defer s.mu.Unlock() + return s.buckets[bucket][key] +} + +func (s *standIn) keys(bucket string) []string { + s.mu.Lock() + defer s.mu.Unlock() + return sortedKeys(s.buckets[bucket]) +} + +// everything is every value the bus holds, for a test that no secret is in a bucket. +func (s *standIn) everything() []byte { + s.mu.Lock() + defer s.mu.Unlock() + var all []byte + for _, b := range s.buckets { + for k, v := range b { + all = append(all, k...) + all = append(all, v...) + } + } + return all +} + +// vault is a stand-in vault: a value per name, or nothing yet. +type vault struct { + mu sync.Mutex + values map[string]string + asked []string +} + +func (v *vault) Value(_ context.Context, name string) ([]byte, error) { + v.mu.Lock() + defer v.mu.Unlock() + v.asked = append(v.asked, name) + val, ok := v.values[name] + if !ok { + return nil, fmt.Errorf("the vault holds no %s yet", name) + } + return []byte(val), nil +} + +// maker records what the provider's adapter was asked to make, with the credential it was handed. +type maker struct { + mu sync.Mutex + made map[string]string + // contributed is what each consumer's entry carried of its plan's contribution. + contributed map[string]map[string]any + // as is the identity each consumer's entry was made under. + as map[string]string + gone []string + fail error + calls int +} + +func (m *maker) Make(_ context.Context, c Consumer) error { + m.mu.Lock() + defer m.mu.Unlock() + m.calls++ + if m.fail != nil { + return m.fail + } + if m.made == nil { + m.made = map[string]string{} + } + m.made[c.Entry.Key()] = string(c.Credential) + if m.contributed == nil { + m.contributed = map[string]map[string]any{} + } + m.contributed[c.Entry.Key()] = c.Entry.Contributes + if m.as == nil { + m.as = map[string]string{} + } + m.as[c.Entry.Key()] = c.Entry.As + return nil +} + +func (m *maker) Gone(_ context.Context, e Entry) error { + m.mu.Lock() + defer m.mu.Unlock() + m.gone = append(m.gone, e.Key()) + return nil +} + +// assign puts a consumer's assignment with its plan's provisions into the stand-in mirror. +func assign(t *testing.T, bus *standIn, node, module string, provisions ...map[string]string) { + t.Helper() + ps := []map[string]string{} + ps = append(ps, provisions...) + raw, err := json.Marshal(map[string]any{"node": node, "module": module, "assigned": "2026-10-15T00:00:00Z", + "provisioning": map[string]any{"templates": map[string]string{"source": "x", "digest": "y"}, + "provisions": ps}}) + if err != nil { + t.Fatal(err) + } + if err := bus.Put(context.Background(), AssignmentsBucket, node+"."+module, raw); err != nil { + t.Fatal(err) + } +} + +func bind(provision, local, provider, providerModule string) map[string]string { + b := map[string]string{"provision": provision, "provider": provider, "provider-module": providerModule} + if local != "" { + b["local"] = local + } + return b +} + +func entryOf(t *testing.T, bus *standIn, key string) Entry { + t.Helper() + raw := bus.get(BucketOf("postgres"), key) + if raw == nil { + t.Fatalf("no entry %s in %s; it holds %v", key, BucketOf("postgres"), bus.keys(BucketOf("postgres"))) + } + var e Entry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatalf("entry %s: %v", key, err) + } + return e +} + +// postgres is a provider of postgres-database on novox with a login per consumer (ADR 0048, ADR 0158 §11), its +// served values derived from each consumer's identity (ADR 0202 §1). +func postgres(bus *standIn, v *vault, m *maker) *Provider { + return New(Options{ + Node: "novox", Module: "postgres", Bus: bus, Vault: v, Make: m.Make, Gone: m.Gone, + Offers: map[string]Offer{ + "postgres-database": {Credential: PerConsumer, Serves: map[string]any{ + "host": "postgres.novox.mesh", "port": 5432, "database": "${consumer:as}"}}, + }, + Logf: func(string, ...any) {}, + }) +} + +// A provider answers each consumer that binds it with an entry of its own in its own bucket: composed from its +// served values and the consumer's identity, naming the credential the vault keeps for that consumer, and never +// the credential itself; its adapter is handed each consumer's own value read from the vault. +func TestEachConsumerGetsAnEntryOfItsOwnAndTheVaultsValue(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/letta": "letta-secret-1", + "pair/novox/postgres-database/g14/umami": "umami-secret-2", + }} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database", + "g14.umami.postgres-database"}) { + t.Fatalf("entries %v", got) + } + e := entryOf(t, bus, "ace.letta.postgres-database") + if e.Provider != "novox" || e.ProviderModule != "postgres" || e.Consumer != "ace" || e.Module != "letta" || + e.As != "mesh_ace_letta" || e.User != "mesh_ace_letta" || + e.Credential != "pair/novox/postgres-database/ace/letta" || e.Refused != "" { + t.Fatalf("entry %+v", e) + } + if e.Values["database"] != "mesh_ace_letta" || e.Values["host"] != "postgres.novox.mesh" || + e.Values["port"] != float64(5432) { + t.Fatalf("values %v", e.Values) + } + if m.made["ace.letta.postgres-database"] != "letta-secret-1" || + m.made["g14.umami.postgres-database"] != "umami-secret-2" { + t.Fatalf("made %v", m.made) + } + if bytes.Contains(bus.everything(), []byte("secret-")) { + t.Fatal("a credential is in a bucket") + } + for _, o := range out { + if o.Status != Answered { + t.Fatalf("outcome %+v", o) + } + } +} + +// A provider whose offer shares one credential (ADR 0158) names the vault's one value for the provision, and its +// one user name, to every consumer. +func TestASharedCredentialIsTheVaultsOneValueAndTheProvidersOneUser(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"provision/novox/sonarr-api": "one-value"}} + assign(t, bus, "ace", "overseerr", bind("sonarr-api", "", "novox", "sonarr")) + assign(t, bus, "g14", "homepage", bind("sonarr-api", "", "novox", "sonarr")) + p := New(Options{Node: "novox", Module: "sonarr", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"sonarr-api": {Credential: Shared, User: "sonarr", + Serves: map[string]any{"url": "http://sonarr.novox.mesh:8989"}}}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + for _, key := range []string{"ace.overseerr.sonarr-api", "g14.homepage.sonarr-api"} { + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("sonarr"), key), &e); err != nil { + t.Fatalf("%s: %v", key, err) + } + if e.Credential != "provision/novox/sonarr-api" || e.User != "sonarr" || e.Values["url"] == nil { + t.Fatalf("%s: %+v", key, e) + } + if m.made[key] != "one-value" { + t.Fatalf("%s made with %q", key, m.made[key]) + } + } + if bytes.Contains(bus.everything(), []byte("one-value")) { + t.Fatal("the shared credential is in a bucket") + } +} + +// A consumer that keeps several holders of one provision is served no derived value: its entry refuses it, naming +// both ends, so its client can report it (ADR 0349 clause 12, ADR 0202 §10); nothing is made for it. +func TestAMultiHolderConsumerIsRefusedADerivedValueNamingBothEnds(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta/archive": "x-secret"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "archive", "novox", "postgres")) + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + e := entryOf(t, bus, "ace.letta.postgres-database.archive") + for _, says := range []string{"letta on ace", "postgres on novox", "postgres-database", "archive"} { + if !strings.Contains(e.Refused, says) { + t.Fatalf("the refusal does not name %q: %s", says, e.Refused) + } + } + if e.Values != nil || e.Credential != "" { + t.Fatalf("a refused entry carries values: %+v", e) + } + if m.calls != 0 { + t.Fatal("the adapter made a refused consumer") + } + if len(out) != 1 || out[0].Status != Refused { + t.Fatalf("outcomes %+v", out) + } +} + +// A consumer bound under a local name is served when the offer derives nothing from it: its credential is its own +// pair row under that local name. +func TestALocalNameIsServedWhenNothingIsDerived(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/redis-cache/ace/letta/queue": "q-secret"}} + assign(t, bus, "ace", "letta", bind("redis-cache", "queue", "novox", "redis")) + p := New(Options{Node: "novox", Module: "redis", Bus: bus, Vault: v, Make: m.Make, Logf: func(string, ...any) {}, + Offers: map[string]Offer{"redis-cache": {Credential: PerConsumer, Serves: map[string]any{"port": 6379}}}}) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("redis"), "ace.letta.redis-cache.queue"), &e); err != nil { + t.Fatal(err) + } + if e.Refused != "" || e.Credential != "pair/novox/redis-cache/ace/letta/queue" || e.Local != "queue" { + t.Fatalf("entry %+v", e) + } + if m.made["ace.letta.redis-cache.queue"] != "q-secret" { + t.Fatalf("made %v", m.made) + } +} + +// A consumer that unbinds loses its entry and the adapter hears it is gone; an entry another machine's instance of +// the same provider wrote is not this one's to remove. +func TestAnUnbindRemovesTheEntryAndLeavesAnotherProvidersAlone(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + other, _ := json.Marshal(Entry{Provider: "shanks", ProviderModule: "postgres", Provision: "postgres-database", + Consumer: "g14", Module: "gitea"}) + _ = bus.Put(context.Background(), BucketOf("postgres"), "g14.gitea.postgres-database", other) + p := postgres(bus, v, m) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + _ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta") + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"g14.gitea.postgres-database"}) { + t.Fatalf("entries left %v", got) + } + if !slices.Equal(m.gone, []string{"ace.letta.postgres-database"}) { + t.Fatalf("gone %v", m.gone) + } + if len(out) != 1 || out[0].Status != Removed { + t.Fatalf("outcomes %+v", out) + } +} + +// A bind whose credential the vault does not hold yet is waited for: nothing is made and no entry is written, so a +// consumer is never told of a login its provider has not made; once the vault holds it, it is answered. +func TestACredentialTheVaultDoesNotHoldYetIsWaitedFor(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != Waiting || !strings.Contains(out[0].Why, "pair/novox/postgres-database/ace/letta") { + t.Fatalf("outcomes %+v", out) + } + if bus.keys(BucketOf("postgres")) != nil || m.calls != 0 { + t.Fatal("a consumer was answered before its credential was there") + } + v.values["pair/novox/postgres-database/ace/letta"] = "late" + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + if entryOf(t, bus, "ace.letta.postgres-database").Refused != "" || m.made["ace.letta.postgres-database"] != "late" { + t.Fatal("not answered once the vault held the value") + } +} + +// Only the binds naming this provider, on this machine, are its own; a bind naming it for a provision it does not +// offer is refused, naming both ends. +func TestOnlyBindsNamingThisProviderAreAnswered(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "shanks", "postgres"), + bind("mongodb-database", "", "novox", "postgres")) + assign(t, bus, "ace", "other", bind("postgres-database", "", "novox", "mssql")) + out, err := postgres(bus, v, m).Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.mongodb-database"}) { + t.Fatalf("entries %v", got) + } + e := entryOf(t, bus, "ace.letta.mongodb-database") + if !strings.Contains(e.Refused, "postgres on novox") || !strings.Contains(e.Refused, "letta on ace") { + t.Fatalf("refusal %q", e.Refused) + } + if len(out) != 1 || m.calls != 0 { + t.Fatalf("outcomes %+v, %d made", out, m.calls) + } +} + +// An assignment that cannot be read stops the pass: a bind missed would read as an unbind and remove an entry its +// consumer still reads. +func TestAnUnreadableAssignmentRemovesNothing(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + p := postgres(bus, v, m) + if _, err := p.Pass(context.Background()); err != nil { + t.Fatal(err) + } + _ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", []byte("{not json")) + if _, err := p.Pass(context.Background()); err == nil { + t.Fatal("a pass over an unreadable assignment went on") + } + if bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil || len(m.gone) != 0 { + t.Fatal("an entry was removed on an unreadable assignment") + } +} + +// An adapter that fails is said with the credential taken out of its words, and the entry is not written. +func TestAFailedMakeIsSaidWithoutTheCredential(t *testing.T) { + bus, m := newStandIn(), &maker{fail: errors.New(`password "hunter2-secret" rejected`)} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "hunter2-secret"}} + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + var logged []string + p := postgres(bus, v, m) + p.opts.Logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) } + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != Failed || strings.Contains(out[0].Why, "hunter2") { + t.Fatalf("outcomes %+v", out) + } + if strings.Contains(strings.Join(logged, "\n"), "hunter2") || len(logged) == 0 { + t.Fatalf("logged %q", logged) + } + if bus.keys(BucketOf("postgres")) != nil { + t.Fatal("an entry was written for a consumer the adapter could not make") + } +} + +// Run watches the binds on the stand-in bus: a consumer that binds is answered, and one that unbinds is removed, +// without a pass asked by hand. +func TestRunAnswersBindsAndUnbindsAsTheyAreWatched(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s"}} + p := postgres(bus, v, m) + p.opts.Every = time.Hour + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- p.Run(ctx) }() + assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres")) + waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") != nil }) + _ = bus.Delete(context.Background(), AssignmentsBucket, "ace.letta") + waitFor(t, func() bool { return bus.get(BucketOf("postgres"), "ace.letta.postgres-database") == nil }) + cancel() + if err := <-done; err != nil && !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func waitFor(t *testing.T, ok func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !ok() { + if time.Now().After(deadline) { + t.Fatal("not within 3s") + } + time.Sleep(5 * time.Millisecond) + } +} + +// The consumer's identity is the mesh's (ADR 0202 §1): mesh__, lower case, `_` for anything else, +// and its DNS form writes `-` for `_`. +func TestTheIdentityIsTheMeshsAndItsDNSForm(t *testing.T) { + if got := ConsumerIdentity("Home.Server", "my-app"); got != "mesh_home_server_my_app" { + t.Fatal(got) + } + got, err := ServedTo(map[string]any{"bucket": "b-${consumer:as:dns}", "n": 3}, "mesh_ace_letta") + if err != nil || got["bucket"] != "b-mesh-ace-letta" || got["n"] != 3 { + t.Fatalf("%v %v", got, err) + } + if _, err := ServedTo(map[string]any{"x": "${consumer:node}"}, "mesh_ace_letta"); err == nil { + t.Fatal("a fact the mesh does not state was filled") + } +} + +// A consumer's plan entry may carry what its module contributes to the provider (issue 776): the helper hands it to +// the provider's adapter with the consumer, and does not write it to the bucket. +func TestAConsumersContributionIsHandedToTheProviderPerConsumer(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/nextcloud": "a", "pair/novox/postgres-database/g14/umami": "b"}} + raw := `{"node":"ace","module":"nextcloud","provisioning":{"templates":{"source":"x","digest":"y"},` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` + + `"contributes":{"name":"nextcloud","extensions":["pg_trgm"]}}]}}` + if err := bus.Put(context.Background(), AssignmentsBucket, "ace.nextcloud", []byte(raw)); err != nil { + t.Fatal(err) + } + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + want := map[string]any{"name": "nextcloud", "extensions": []any{"pg_trgm"}} + if got := m.contributed["ace.nextcloud.postgres-database"]; !reflect.DeepEqual(got, want) { + t.Fatalf("the adapter was handed the contribution %v, want %v", got, want) + } + if got := m.contributed["g14.umami.postgres-database"]; got != nil { + t.Fatalf("a consumer contributing nothing was handed %v", got) + } + if bytes.Contains(bus.get(BucketOf("postgres"), "ace.nextcloud.postgres-database"), []byte("pg_trgm")) { + t.Fatal("a contribution is in the provider's bucket") + } +} + +// A consumer's plan entry carries where its provider is, which machine, and who the consumer is (issue 788): the +// entry's values carry `at` and `from` as the binding file gives them, and its `as` is the plan's, so a module with a +// slug keeps the login it has today. An entry of a plan without them is as it was. +func TestAConsumersEntryCarriesAtFromAndTheAsOfItsPlan(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/claude-code": "a", "pair/novox/postgres-database/g14/umami": "b"}} + raw := `{"node":"ace","module":"claude-code","provisioning":{"templates":{"source":"x","digest":"y"},` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` + + `"at":"10.7.0.1","from":"novox","as":"mesh_ace_agent"}]}}` + if err := bus.Put(context.Background(), AssignmentsBucket, "ace.claude-code", []byte(raw)); err != nil { + t.Fatal(err) + } + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.claude-code.postgres-database"), &e); err != nil { + t.Fatal(err) + } + if e.Values["at"] != "10.7.0.1" || e.Values["from"] != "novox" || e.Values["port"] == nil { + t.Fatalf("the entry's values are %v", e.Values) + } + if e.As != "mesh_ace_agent" || e.User != "mesh_ace_agent" || m.as["ace.claude-code.postgres-database"] != "mesh_ace_agent" { + t.Fatalf("the entry is %+v, made as %q", e, m.as["ace.claude-code.postgres-database"]) + } + var plain Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { + t.Fatal(err) + } + if _, has := plain.Values["at"]; has || plain.As != "mesh_g14_umami" { + t.Fatalf("an entry of a plan without them changed: %+v", plain) + } +} + +// A consumer's plan entry may carry the names the controller composed for it (issue 792): each is copied into the +// entry's values under its key, over anything the provider serves under it. An entry without them is as it was. +func TestAConsumersEntryCarriesTheNamesOfItsPlan(t *testing.T) { + bus, m := newStandIn(), &maker{} + v := &vault{values: map[string]string{ + "pair/novox/postgres-database/ace/gitea": "a", "pair/novox/postgres-database/g14/umami": "b"}} + raw := `{"node":"ace","module":"gitea","provisioning":{"templates":{"source":"x","digest":"y"},` + + `"provisions":[{"provision":"postgres-database","provider":"novox","provider-module":"postgres",` + + `"names":{"name":"git.example.org","internal-name":"git.mesh","name-web":"web.example.org"}}]}}` + if err := bus.Put(context.Background(), AssignmentsBucket, "ace.gitea", []byte(raw)); err != nil { + t.Fatal(err) + } + assign(t, bus, "g14", "umami", bind("postgres-database", "", "novox", "postgres")) + if _, err := postgres(bus, v, m).Pass(context.Background()); err != nil { + t.Fatal(err) + } + var e Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "ace.gitea.postgres-database"), &e); err != nil { + t.Fatal(err) + } + if e.Values["name"] != "git.example.org" || e.Values["internal-name"] != "git.mesh" || + e.Values["name-web"] != "web.example.org" || e.Values["port"] == nil { + t.Fatalf("the entry's values are %v", e.Values) + } + var plain Entry + if err := json.Unmarshal(bus.get(BucketOf("postgres"), "g14.umami.postgres-database"), &plain); err != nil { + t.Fatal(err) + } + if _, has := plain.Values["name"]; has { + t.Fatalf("an entry of a plan without names changed: %+v", plain) + } +} diff --git a/go/provider/statebus_test.go b/go/provider/statebus_test.go new file mode 100644 index 0000000..efe29de --- /dev/null +++ b/go/provider/statebus_test.go @@ -0,0 +1,152 @@ +package provider + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// runtime drives a bundle over pipes as the runtime does: it reads what the bundle says and sends what the runtime would. +type runtime struct { + t *testing.T + lines *bufio.Scanner + in io.Writer +} + +func (r *runtime) next() map[string]any { + done := make(chan map[string]any, 1) + go func() { + if r.lines.Scan() { + var m map[string]any + _ = json.Unmarshal(r.lines.Bytes(), &m) + done <- m + } + }() + select { + case m := <-done: + return m + case <-time.After(3 * time.Second): + r.t.Fatal("the bundle said nothing") + return nil + } +} + +func (r *runtime) send(m map[string]any) { + b, _ := json.Marshal(m) + _, _ = r.in.Write(append(b, '\n')) +} + +func (r *runtime) call(id int, tool string, args map[string]any) { + r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": tool, "arguments": args}}) +} + +func serve(t *testing.T, tools []stdio.Tool) *runtime { + t.Setenv(stdio.ServedAs, "keeper") + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { _ = stdio.ServeOn("", tools, inR, outW) }() + t.Cleanup(func() { _ = inW.Close() }) + return &runtime{t: t, lines: bufio.NewScanner(outR), in: inW} +} + +// The bus of a moved provider maps its own bucket to the state `bound` and the assignments to the state +// `mesh-controller.assignments`, writes only the own bucket's state, and refuses any other bucket. +func TestStateBusMapsTheProvidersBucketsToTheRuntimesStates(t *testing.T) { + bus := StateBus("postgres") + rt := serve(t, []stdio.Tool{ + {Name: "watch", Run: func(a map[string]any) (any, error) { + return nil, bus.Watch(context.Background(), a["bucket"].(string), func(string, []byte) {}) + }}, + {Name: "put", Run: func(map[string]any) (any, error) { + return "put", bus.Put(context.Background(), BucketOf("postgres"), "k", []byte(`{"a":1}`)) + }}, + {Name: "delete", Run: func(map[string]any) (any, error) { + return "deleted", bus.Delete(context.Background(), BucketOf("postgres"), "k") + }}, + }) + for i, c := range []struct{ bucket, state string }{ + {BucketOf("postgres"), "bound"}, + {AssignmentsBucket, "mesh-controller.assignments"}, + } { + rt.call(i+1, "watch", map[string]any{"bucket": c.bucket}) + w := rt.next() + if w["method"] != "mesh/state.watch" || w["params"].(map[string]any)["state"] != c.state { + t.Fatalf("bucket %s asked %v, want the state %s", c.bucket, w, c.state) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) + rt.next() + } + rt.call(3, "put", nil) + p := rt.next() + if p["method"] != "mesh/state.put" || p["params"].(map[string]any)["state"] != "bound" || p["params"].(map[string]any)["key"] != "k" { + t.Fatalf("put asked %v", p) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": p["id"], "result": map[string]any{"revision": 1}}) + rt.next() + rt.call(4, "delete", nil) + d := rt.next() + if d["method"] != "mesh/state.delete" || d["params"].(map[string]any)["state"] != "bound" { + t.Fatalf("delete asked %v", d) + } + rt.send(map[string]any{"jsonrpc": "2.0", "id": d["id"], "result": map[string]any{}}) + rt.next() + + for _, bucket := range []string{"other_bound", "elsewhere"} { + if err := bus.Watch(context.Background(), bucket, func(string, []byte) {}); err == nil || !strings.Contains(err.Error(), "not a bucket") { + t.Fatalf("a watch of %s gave %v", bucket, err) + } + if err := bus.Put(context.Background(), bucket, "k", nil); err == nil { + t.Fatalf("a put to %s was allowed", bucket) + } + if err := bus.Delete(context.Background(), bucket, "k"); err == nil { + t.Fatalf("a delete in %s was allowed", bucket) + } + } +} + +// A watch whose ctx has ended calls its handler no more, though the runtime keeps sending changes: the Bus contract +// says a watch lasts until ctx ends, and a stdio state watch lasts as long as the process. +func TestStateBusWatchCallsItsHandlerNoMoreOnceItsContextEnded(t *testing.T) { + bus := StateBus("postgres") + ctx, cancel := context.WithCancel(context.Background()) + handled := make(chan string, 8) + rt := serve(t, []stdio.Tool{ + {Name: "start", Run: func(map[string]any) (any, error) { + return "started", bus.Watch(ctx, AssignmentsBucket, func(key string, value []byte) { + handled <- key + "=" + string(value) + }) + }}, + {Name: "stop", Run: func(map[string]any) (any, error) { cancel(); return "stopped", nil }}, + }) + change := func(id int, watch any, key, op string) { + rt.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "mesh/state", "params": map[string]any{"watch": watch, + "change": map[string]any{"state": "mesh-controller.assignments", "key": key, "op": op, "value": "v"}}}) + if r := rt.next(); r["id"] != float64(id) || r["error"] != nil { + t.Fatalf("a change was answered %v", r) + } + } + rt.call(1, "start", nil) + w := rt.next() + rt.send(map[string]any{"jsonrpc": "2.0", "id": w["id"], "result": map[string]any{}}) + rt.next() + + change(50, w["id"], "before", "put") + if got := <-handled; got != `before="v"` { + t.Fatalf("handled %q", got) + } + rt.call(2, "stop", nil) + rt.next() + change(51, w["id"], "after", "put") + change(52, w["id"], "after", "delete") + select { + case got := <-handled: + t.Fatalf("the handler was called after its context ended: %q", got) + case <-time.After(100 * time.Millisecond): + } +} diff --git a/go/provision.go b/go/provision.go new file mode 100644 index 0000000..03a7adc --- /dev/null +++ b/go/provision.go @@ -0,0 +1,90 @@ +package stdio + +import ( + "encoding/json" + "errors" + "time" +) + +// A module's code reads its values at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1): its config +// values and its bindings' values that are not secret, for what its manifest declares, and watches them. The +// runtime that launched this bundle asks its machine's provisioner client **as this bundle's module** — the module it +// launched, never a name this code passes — and the client answers only what that module declares, only while the +// mesh places it on that machine. A secret is refused while the module's code runs in the tool runner: the vault +// hands a secret only to a caller the bus proves, and the bus proves only the tool runner there, so a module's +// secrets reach it through its files until it runs under a bus account of its own (novox/hq issue 569). + +// ProvisionChange is what a watch hands over: the value now, or — once it is no longer answered, the module taken +// off the machine or the key no longer declared — Refused, why. +type ProvisionChange struct { + Kind string `json:"kind"` + Key string `json:"key"` + Value json.RawMessage `json:"value,omitempty"` + Refused string `json:"refused,omitempty"` +} + +const provisionTimeout = 30 * time.Second + +func provisionGet(kind, key string) (json.RawMessage, error) { + c, err := live() + if err != nil { + return nil, err + } + return c.ask("mesh/provision.get", map[string]any{"kind": kind, "key": key}, provisionTimeout) +} + +func provisionWatch(kind, key string, handle func(ProvisionChange) error) error { + if handle == nil { + return errors.New("a watch needs a handler") + } + c, err := live() + if err != nil { + return err + } + id := c.newID() + c.mu.Lock() + c.provisioned[id] = handle + c.mu.Unlock() + if _, err := c.askAs(id, "mesh/provision.watch", map[string]any{"kind": kind, "key": key}, provisionTimeout); err != nil { + c.mu.Lock() + delete(c.provisioned, id) + c.mu.Unlock() + return err + } + return nil +} + +// Config is one of this module's config values as mesh-config resolved it for this machine, as JSON: the key as +// its manifest declares it under `config`. A key it does not declare is refused, with the reason. +func Config(key string) (json.RawMessage, error) { return provisionGet("config", key) } + +// WatchConfig hands handle the config value now and then every change, in order, and returns once the value now is +// handled. The watch lasts as long as the process. +func WatchConfig(key string, handle func(ProvisionChange) error) error { + return provisionWatch("config", key, handle) +} + +// Bound is one value, not a secret, of what this module is bound to: the provision as its manifest requires it, +// and the key its provider answers (`${bound::}`). +func Bound(provision, key string) (json.RawMessage, error) { + return provisionGet("bound", provision+":"+key) +} + +// WatchBound is WatchConfig for a bound value. +func WatchBound(provision, key string, handle func(ProvisionChange) error) error { + return provisionWatch("bound", provision+":"+key, handle) +} + +// Secret is one of this module's secrets. Refused, with the reason, while the module's code runs in the tool +// runner (novox/hq ADR 0349 §11, ADR 0370 §2): its secrets reach it through its files. +func Secret(name string) ([]byte, error) { + raw, err := provisionGet("secret", name) + if err != nil { + return nil, err + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New("the runtime answered a secret that is not a string") + } + return []byte(s), nil +} diff --git a/go/provision_test.go b/go/provision_test.go new file mode 100644 index 0000000..fec0a2c --- /dev/null +++ b/go/provision_test.go @@ -0,0 +1,176 @@ +package stdio + +import ( + "bufio" + "encoding/json" + "io" + "strings" + "testing" + "time" +) + +// A Go bundle reads its values at runtime through the runtime (novox/hq ADR 0349 §14, ADR 0370, issue 685): Config, +// Bound and their watches ask the runtime, which asks as the module; a watch's value now is handled before it +// returns and each change after is named by the watch's id; what the module does not declare and every secret come +// back refused, in the runtime's words; a change for no watch of this bundle is refused. +func TestABundleReadsAndWatchesItsValuesThroughTheRuntime(t *testing.T) { + t.Setenv(ServedAs, "claude-code") + inR, inW := io.Pipe() + outR, outW := io.Pipe() + seen := make(chan string, 16) + watched := make(chan error, 1) + answers := make(chan string, 4) + go func() { + _ = ServeOn("", []Tool{ + {Name: "start", Run: func(map[string]any) (any, error) { + go func() { + watched <- WatchConfig("managed_settings", func(c ProvisionChange) error { + seen <- c.Kind + " " + c.Key + " " + string(c.Value) + c.Refused + return nil + }) + }() + return "started", nil + }}, + {Name: "read", Run: func(a map[string]any) (any, error) { + var v json.RawMessage + var err error + switch a["what"] { + case "config": + v, err = Config(a["key"].(string)) + case "bound": + v, err = Bound("mcp-endpoint", "url") + case "secret": + var s []byte + s, err = Secret("gitea-read") + v = json.RawMessage(s) + } + if err != nil { + answers <- "refused: " + err.Error() + } else { + answers <- string(v) + } + return "read", nil + }}, + }, inR, outW) + }() + lines := bufio.NewScanner(outR) + next := func() map[string]any { + done := make(chan map[string]any, 1) + go func() { + if lines.Scan() { + var m map[string]any + _ = json.Unmarshal(lines.Bytes(), &m) + done <- m + } + }() + select { + case m := <-done: + return m + case <-time.After(3 * time.Second): + t.Fatal("the bundle said nothing") + return nil + } + } + send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) } + // asked is the bundle's next ask of the runtime, beside its answers to this tool call and those before. + asked := func(callID int) map[string]any { + t.Helper() + var ask map[string]any + for ask == nil { + m := next() + if m["method"] != nil { + ask = m + } else if id, ok := m["id"].(float64); !ok || id > float64(callID) || m["error"] != nil { + t.Fatalf("said %v", m) + } + } + return ask + } + + // The watch: asked of the runtime with the kind and the key and no module, the module being the runtime's word. + send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "start"}}) + watch := asked(1) + if watch["method"] != "mesh/provision.watch" { + t.Fatalf("asked %v", watch) + } + if p := watch["params"].(map[string]any); p["kind"] != "config" || p["key"] != "managed_settings" || len(p) != 2 { + t.Fatalf("asked to watch %v", p) + } + send(map[string]any{"jsonrpc": "2.0", "id": 50, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"], + "change": map[string]any{"kind": "config", "key": "managed_settings", "value": map[string]any{"spinnerTipsEnabled": false}}}}) + for { + if r := next(); r["id"] == float64(50) { + if r["error"] != nil { + t.Fatalf("the value now was answered %v", r) + } + break + } + } + if s := <-seen; s != `config managed_settings {"spinnerTipsEnabled":false}` { + t.Fatalf("handled %q", s) + } + select { + case err := <-watched: + t.Fatalf("the watch returned before it was answered: %v", err) + default: + } + send(map[string]any{"jsonrpc": "2.0", "id": watch["id"], "result": map[string]any{}}) + if err := <-watched; err != nil { + t.Fatal(err) + } + // A change, and then the value no longer answered. + send(map[string]any{"jsonrpc": "2.0", "id": 51, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"], + "change": map[string]any{"kind": "config", "key": "managed_settings", "value": map[string]any{"spinnerTipsEnabled": true}}}}) + if r := next(); r["id"] != float64(51) || r["error"] != nil { + t.Fatalf("a change was answered %v", r) + } + if s := <-seen; s != `config managed_settings {"spinnerTipsEnabled":true}` { + t.Fatalf("handled %q", s) + } + send(map[string]any{"jsonrpc": "2.0", "id": 52, "method": "mesh/provision", "params": map[string]any{"watch": watch["id"], + "change": map[string]any{"kind": "config", "key": "managed_settings", "refused": "claude-code is not placed on g14"}}}) + if r := next(); r["id"] != float64(52) || r["error"] != nil { + t.Fatalf("a refusal was answered %v", r) + } + if s := <-seen; s != `config managed_settings claude-code is not placed on g14` { + t.Fatalf("handled %q", s) + } + // A change for a watch this bundle never asked for is refused. + send(map[string]any{"jsonrpc": "2.0", "id": 53, "method": "mesh/provision", "params": map[string]any{"watch": "bundle-999", + "change": map[string]any{"kind": "config", "key": "x"}}}) + if r := next(); r["id"] != float64(53) || !strings.Contains(r["error"].(map[string]any)["message"].(string), "names no watch") { + t.Fatalf("a change for no watch was answered %v", r) + } + + // Each reader asks the runtime, and hands back its answer or its refusal. + for i, c := range []struct { + what, key, kind, askedKey string + result any + refusal string + want string + }{ + {what: "config", key: "role", kind: "config", askedKey: "role", result: "the build machine", want: `"the build machine"`}, + {what: "config", key: "nothing", kind: "config", askedKey: "nothing", + refusal: "claude-code declares no config value nothing", want: "refused: claude-code declares no config value nothing"}, + {what: "bound", kind: "bound", askedKey: "mcp-endpoint:url", result: "http://127.0.0.1:9000", want: `"http://127.0.0.1:9000"`}, + {what: "secret", kind: "secret", askedKey: "gitea-read", + refusal: "the vault hands a secret only to a caller the bus proves", want: "refused: the vault hands a secret only to a caller the bus proves"}, + } { + id := 10 + i + send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": map[string]any{"name": "read", + "arguments": map[string]any{"what": c.what, "key": c.key}}}) + get := asked(id) + if p := get["params"].(map[string]any); get["method"] != "mesh/provision.get" || p["kind"] != c.kind || p["key"] != c.askedKey || len(p) != 2 { + t.Fatalf("%s: asked %v", c.what, get) + } + if c.refusal != "" { + send(map[string]any{"jsonrpc": "2.0", "id": get["id"], "error": map[string]any{"code": -32000, "message": c.refusal}}) + } else { + send(map[string]any{"jsonrpc": "2.0", "id": get["id"], "result": c.result}) + } + if got := <-answers; got != c.want { + t.Errorf("%s %s: %q, want %q", c.what, c.key, got, c.want) + } + } + _ = inW.Close() +} diff --git a/go/seal/seal.go b/go/seal/seal.go new file mode 100644 index 0000000..0aeb8ca --- /dev/null +++ b/go/seal/seal.go @@ -0,0 +1,203 @@ +// Package seal is the mesh's sealed box (novox/hq ADR 0183, ADR 0206): a value sealed to one recipient's +// public key, which only that recipient's private key opens. X25519 for the agreement, HKDF-SHA256 for the +// key, AES-256-GCM for the box — the box the licence manager made and opened before it lived here, byte +// for byte, and the one the SDK's TypeScript `seal` makes and opens. +// +// A box is `{ v: 1, eph, iv, tag, ct }`, every field standard base64; `eph` is the one-time public key as +// SPKI DER. The key is derived with the salt eph-DER ‖ the recipient's raw public key and the info +// "novox-mesh sealed box v1", so a box cannot be re-addressed to another key. +// +// Keys travel as PEM: the public key as SPKI ("PUBLIC KEY"), the private key as PKCS #8 ("PRIVATE KEY"). +// Standard library only. +package seal + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +// Box is a value sealed to one recipient, as it travels in JSON. +type Box struct { + V int `json:"v"` + Eph string `json:"eph"` + IV string `json:"iv"` + Tag string `json:"tag"` + Ct string `json:"ct"` +} + +// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as. +type KeyPair struct { + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` +} + +// Version is the only box version Open opens. +const Version = 1 + +const info = "novox-mesh sealed box v1" + +// GenerateKeyPair makes a fresh X25519 keypair, PEM-encoded. +func GenerateKeyPair() (KeyPair, error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, err + } + pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey()) + if err != nil { + return KeyPair{}, err + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return KeyPair{}, err + } + return KeyPair{ + PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})), + PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})), + }, nil +} + +// Seal seals plaintext to the recipient's PEM public key, with a fresh one-time key and iv. +func Seal(plaintext, recipientPEM string) (Box, error) { + eph, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return Box{}, err + } + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + return Box{}, err + } + return sealWith(plaintext, recipientPEM, eph, iv) +} + +// sealWith is Seal with its two sources of randomness given, for the byte-for-byte tests. +func sealWith(plaintext, recipientPEM string, eph *ecdh.PrivateKey, iv []byte) (Box, error) { + recipient, err := publicFromPEM(recipientPEM) + if err != nil { + return Box{}, err + } + secret, err := eph.ECDH(recipient) + if err != nil { + return Box{}, err + } + ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey()) + if err != nil { + return Box{}, err + } + gcm, err := newGCM(boxKey(secret, ephDER, recipient.Bytes())) + if err != nil { + return Box{}, err + } + out := gcm.Seal(nil, iv, []byte(plaintext), nil) + ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():] + b64 := base64.StdEncoding.EncodeToString + return Box{V: Version, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil +} + +// Open opens a box with the recipient's PEM private key. It fails for a box sealed to another key, one +// changed in any field, or one of another version. +func Open(box Box, privatePEM string) (string, error) { + if box.V != Version { + return "", fmt.Errorf("a sealed box of version %d, not %d", box.V, Version) + } + priv, err := privateFromPEM(privatePEM) + if err != nil { + return "", err + } + d := base64.StdEncoding.DecodeString + ephDER, err := d(box.Eph) + if err != nil { + return "", fmt.Errorf("the box's eph: %w", err) + } + ephKey, err := x509.ParsePKIXPublicKey(ephDER) + if err != nil { + return "", fmt.Errorf("the box's eph: %w", err) + } + eph, ok := ephKey.(*ecdh.PublicKey) + if !ok || eph.Curve() != ecdh.X25519() { + return "", errors.New("the box's eph is not an X25519 key") + } + secret, err := priv.ECDH(eph) + if err != nil { + return "", err + } + iv, err1 := d(box.IV) + tag, err2 := d(box.Tag) + ct, err3 := d(box.Ct) + if err := errors.Join(err1, err2, err3); err != nil { + return "", fmt.Errorf("the box's fields: %w", err) + } + gcm, err := newGCM(boxKey(secret, ephDER, priv.PublicKey().Bytes())) + if err != nil { + return "", err + } + if len(iv) != gcm.NonceSize() || len(tag) != gcm.Overhead() { + return "", errors.New("the box does not open with this key") + } + plain, err := gcm.Open(nil, iv, append(append([]byte{}, ct...), tag...), nil) + if err != nil { + return "", errors.New("the box does not open with this key") + } + return string(plain), nil +} + +func publicFromPEM(p string) (*ecdh.PublicKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM public key") + } + k, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + pub, ok := k.(*ecdh.PublicKey) + if !ok || pub.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 public key") + } + return pub, nil +} + +func privateFromPEM(p string) (*ecdh.PrivateKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM private key") + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + priv, ok := k.(*ecdh.PrivateKey) + if !ok || priv.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 private key") + } + return priv, nil +} + +// boxKey is HKDF-SHA256 (RFC 5869) for one 32-byte block: written out because crypto/hkdf needs Go 1.24 +// and this module promises 1.22; the vectors prove it equals the licence manager's hkdf.Key. +func boxKey(secret, ephDER, recipientRaw []byte) []byte { + salt := append(append([]byte{}, ephDER...), recipientRaw...) + extract := hmac.New(sha256.New, salt) + extract.Write(secret) + prk := extract.Sum(nil) + expand := hmac.New(sha256.New, prk) + expand.Write([]byte(info)) + expand.Write([]byte{1}) + return expand.Sum(nil) +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/go/seal/seal_test.go b/go/seal/seal_test.go new file mode 100644 index 0000000..2a2786e --- /dev/null +++ b/go/seal/seal_test.go @@ -0,0 +1,156 @@ +package seal + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "os" + "testing" +) + +type fixture struct { + Recipient KeyPair `json:"recipient"` + Sealed []struct { + Plaintext string `json:"plaintext"` + Box Box `json:"box"` + } `json:"sealed"` + Vectors []struct { + Plaintext string `json:"plaintext"` + EphPrivate string `json:"ephPrivate"` + IV string `json:"iv"` + Box Box `json:"box"` + } `json:"vectors"` +} + +func readFixture(t *testing.T) fixture { + t.Helper() + raw, err := os.ReadFile("testdata/sealed-by-licence-manager.json") + if err != nil { + t.Fatal(err) + } + var f fixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if len(f.Sealed) == 0 || len(f.Vectors) == 0 { + t.Fatal("the fixture holds no boxes") + } + return f +} + +// Every box the licence manager's own Seal made opens here to its plaintext. +func TestABoxTheLicenceManagerSealedOpens(t *testing.T) { + f := readFixture(t) + for _, s := range f.Sealed { + got, err := Open(s.Box, f.Recipient.PrivateKey) + if err != nil || got != s.Plaintext { + t.Fatalf("opened %q, %v; want %q", got, err, s.Plaintext) + } + } +} + +// Given the same one-time key and iv, this package seals byte for byte what the licence manager sealed: +// the reverse direction, so whatever opens the licence manager's boxes opens these. +func TestSealIsByteForByteTheLicenceManagersBox(t *testing.T) { + f := readFixture(t) + for _, v := range f.Vectors { + d := base64.StdEncoding.DecodeString + ephRaw, err1 := d(v.EphPrivate) + iv, err2 := d(v.IV) + if err1 != nil || err2 != nil { + t.Fatal(err1, err2) + } + eph, err := ecdh.X25519().NewPrivateKey(ephRaw) + if err != nil { + t.Fatal(err) + } + got, err := sealWith(v.Plaintext, f.Recipient.PublicKey, eph, iv) + if err != nil { + t.Fatal(err) + } + if got != v.Box { + t.Fatalf("sealed %+v\nthe licence manager sealed %+v", got, v.Box) + } + if plain, err := Open(got, f.Recipient.PrivateKey); err != nil || plain != v.Plaintext { + t.Fatalf("reopened %q, %v", plain, err) + } + } +} + +// A box the agent module's TypeScript sealed (the fixture the licence manager keeps) opens here too. +func TestABoxSealedInTypeScriptOpens(t *testing.T) { + raw, err := os.ReadFile("testdata/sealed-by-typescript.json") + if err != nil { + t.Fatal(err) + } + var f struct { + PrivateKey string `json:"privateKey"` + Box Box `json:"box"` + Plaintext string `json:"plaintext"` + } + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if got, err := Open(f.Box, f.PrivateKey); err != nil || got != f.Plaintext { + t.Fatalf("opened %q, %v", got, err) + } +} + +// What Seal makes opens with its recipient's key and no other, and a fresh box differs every time. +func TestABoxOpensOnlyForItsRecipient(t *testing.T) { + a, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b, _ := GenerateKeyPair() + box, err := Seal("a token", a.PublicKey) + if err != nil { + t.Fatal(err) + } + if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" { + t.Fatalf("opened %q, %v", got, err) + } + if _, err := Open(box, b.PrivateKey); err == nil { + t.Fatal("a box opened for another key") + } + again, _ := Seal("a token", a.PublicKey) + if again.Eph == box.Eph || again.IV == box.IV { + t.Fatal("two boxes share a one-time key or iv") + } +} + +// A box changed in any field, or of another version, does not open. +func TestATamperedBoxDoesNotOpen(t *testing.T) { + f := readFixture(t) + good := f.Sealed[1].Box + flip := func(s string) string { + b, _ := base64.StdEncoding.DecodeString(s) + b[len(b)-1] ^= 1 + return base64.StdEncoding.EncodeToString(b) + } + for name, box := range map[string]Box{ + "ct": {V: 1, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: flip(good.Ct)}, + "tag": {V: 1, Eph: good.Eph, IV: good.IV, Tag: flip(good.Tag), Ct: good.Ct}, + "iv": {V: 1, Eph: good.Eph, IV: flip(good.IV), Tag: good.Tag, Ct: good.Ct}, + "eph": {V: 1, Eph: flip(good.Eph), IV: good.IV, Tag: good.Tag, Ct: good.Ct}, + "version": {V: 2, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: good.Ct}, + } { + if _, err := Open(box, f.Recipient.PrivateKey); err == nil { + t.Fatalf("a box with a changed %s opened", name) + } + } +} + +// A key that is not X25519 PEM is refused, not misread. +func TestAKeyOfAnotherKindIsRefused(t *testing.T) { + if _, err := Seal("x", "not a key"); err == nil { + t.Fatal("sealed to text") + } + f := readFixture(t) + if _, err := Open(f.Sealed[0].Box, f.Recipient.PublicKey); err == nil { + t.Fatal("opened with a public key") + } + if _, err := Seal("x", f.Recipient.PrivateKey); err == nil { + t.Fatal("sealed to a private key") + } +} diff --git a/go/seal/testdata/sealed-by-licence-manager.json b/go/seal/testdata/sealed-by-licence-manager.json new file mode 100644 index 0000000..b85ea5a --- /dev/null +++ b/go/seal/testdata/sealed-by-licence-manager.json @@ -0,0 +1,121 @@ +{ + "about": "Made by the claude-licence-manager's seal.go (mesh-catalog feat/57-config-and-provisioning at 8aa37e8), unchanged but for its two sources of randomness, which the vectors fix. The recipient is a test key derived from a fixed label; it guards nothing.", + "recipient": { + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAkZXnghyfA1BR5rWtvBLmjdZMCbC99xxF3WKHe1E+3XM=\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIHjV954xXPa72FyGUw5PsWCbsHrliVp/7xoAgG9fGs7q\n-----END PRIVATE KEY-----\n" + }, + "sealed": [ + { + "plaintext": "", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAwN2gt32hv+9VyFh0oQ8oetPBHsZ9dt80DzwPwcB0bHg=", + "iv": "0xHCegxP6o48EGLq", + "tag": "VYQZm/lBZB0YMPUqYVPKaQ==", + "ct": "" + } + }, + { + "plaintext": "a grant sealed by the licence manager", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEA54v96LEb8SwT2uJZCa90/2jcLvEbK/BZpbMS6TxAAW0=", + "iv": "xm6Jg4oOnRpRpDGK", + "tag": "cmbOzIV63Nv0lPfpTgP2kg==", + "ct": "InWe8PZ8hkd7F9QaHujjhdiBECKzLAK22d2aIEXwBhtxIowc/Q==" + } + }, + { + "plaintext": "sk-ant-api03-test-0123456789abcdefghij\n", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAIXdum2FYLzvLDj/Of2253hH8Lu0szU4tLxJzfXTahDs=", + "iv": "KMPNa60C/Qjhtp0Z", + "tag": "PPa82ZdFyWz9kbLSSGkl3g==", + "ct": "Phu3ZsxIWGlBWbYOGzPopncEbc0VhTAloxzeHs4KDBMFZU9Pet2V" + } + }, + { + "plaintext": "ünïcødé ✓ and a NUL \u0000 inside", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAo1WaRyrAJFM6If/cpFgdpZjsC6XebuJtVSugbI+m8RU=", + "iv": "H9+WvM6ZYbCYJj6D", + "tag": "K5p986nSpeItYK1m4bcVPQ==", + "ct": "eyZs3uVeZguNbqSmrMc1rWgxhW3nP0GABH6Ek06/8UuBqw==" + } + }, + { + "plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAzjZdTMu08I0Z+FWXSr9f++EADfqg3qr2FVt+3Q/hxm0=", + "iv": "lTG2VwPA5SwHbZE/", + "tag": "Il6ICxoZgZr9dOgeIZOPrg==", + "ct": "ZWQ8m1nxrqq0oIGRoeNlUu9/JpTMrxlncTCL5cDqsJrx9NjS0nZX292JJ5p0+BYSVLUN+yGUuBOnH860ywvKK7XhPOc0Yo2nq7X2O3OYn9Za4y+HK6dVDM8zhsS8P3Di/MJCwS+AnremVCaj2k3xHL3oc331RBZZ5w2JfgSX3ouwl2+VbIPyJVSDsxnLLoHsiIR7nB0GNCe0fynhVkoIEwC8IXLnL6m3rjjN3YCQDmVP2UoftniLAypd5ioc9GCtpvUL6ktjs82JkvEtFbHtc+YzIjP6ZnjTj/bvbRHkIqN4MDiu+fvGOr0jtGWO24ecGC+6KtrKGOaEYHDqOLaK3c+UcrDsFYC+cuKCdCNhs5UNhYYylVXE648KFa488eFk" + } + } + ], + "vectors": [ + { + "plaintext": "", + "ephPrivate": "W8iT0SrRDnazANHYsXHC72sM1wzx+/Y8vowREeGGz/A=", + "iv": "DD7WV6Y8hLGvaG0b", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAdCCqlFhxbKBpv1JClXceH43AqEvcaM6SGfyXx05UJUs=", + "iv": "DD7WV6Y8hLGvaG0b", + "tag": "ctb+ZQjx8FyynvT88rfwkQ==", + "ct": "" + } + }, + { + "plaintext": "a grant sealed by the licence manager", + "ephPrivate": "lzIbkYmIbkKTYDL4dZiAFUFv2h+GWBiqlQfnsVD2An8=", + "iv": "xz2FIBXNM3scOAJb", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAS3L49p4KrGMo/NmHZq8osoPbk2ZCQWLoNIKnpB4T5hg=", + "iv": "xz2FIBXNM3scOAJb", + "tag": "7dC8bYqzsJ/17EC7y6lhCA==", + "ct": "FEMfh4HMvMwqx4QE4YMMR3d3g5rNuxa/+u6u+i/aRLed3q8kBw==" + } + }, + { + "plaintext": "sk-ant-api03-test-0123456789abcdefghij\n", + "ephPrivate": "tAjRQTQHk6nNJLblzdslDbWmyYm8cE6/G4ipXHR0zlE=", + "iv": "BPF1JDQ5mDNhKKr3", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEA1GianODxSXUHYMdUmZLcQztp5ry2/xtwUSeHZWrGFAo=", + "iv": "BPF1JDQ5mDNhKKr3", + "tag": "uiIIFD7LgPmLV3Y6uleSzg==", + "ct": "+maa0GDDOkcyzD0chFQ4a/0NvBrvl23NVqcGv1BEKgmMUenf9HHd" + } + }, + { + "plaintext": "ünïcødé ✓ and a NUL \u0000 inside", + "ephPrivate": "QBYUxuk4jwiWKOPEWeE7v6WxY8BDmZZCDwlY1WJ1PSA=", + "iv": "u76JO8baqokfnjcK", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAePakOoCmmXLtnN3EykbpqfdbfXpkg04F2djxnJWH220=", + "iv": "u76JO8baqokfnjcK", + "tag": "sfBPRnwhfP/p7vlsUx+e9w==", + "ct": "B9yc0FbdKYDtDeX1jmKa+DBP8fPN1O9SbeYBDn9b3+Bq2Q==" + } + }, + { + "plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ", + "ephPrivate": "2VYOyzqLrb+zWUhVpHA9aV3vMAyRWlNPEq2ASf60F9M=", + "iv": "0Gv6ejq07LwbM8cY", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEARSOBczrJ6X1MkxT7Mrd1ryDLmXAMBgYIgpfwBjHz1GA=", + "iv": "0Gv6ejq07LwbM8cY", + "tag": "Vm+E0myXgdMwA6knJDg/+w==", + "ct": "tJwZpUXeQYOu2PG1g9AwIV8RhKKDorUHWQSygEus3xHOpuTwh3+D4j2htIerMB/3AKToQYABeZMyEAOzhL2x+Cg06WlUKrggZjvdX5W7X813KQBgzg0YnnCZTKTYlviDWe1E9TZ3aAauhlcgpBaQXUh/akkDiqeq2Ua5ruY6IGH5HgqWWPYJo3b4zkbArhvdP2HJX9Pn1mQa0BtGMinyutyD0am5vZuemdKGYS1Ao00M81sMXYc2CBy9K48Ofw8GapcmYNHsE0s6NSgNZumnVaR8TQRy/oqEdL3Q6z31hAZMzTdS0ckXg1NCgcgXVuzwNov/D4320E3DxFS3v8Z5p74ZkqAmo/1JcCNJwwQyRCWk/4iJFjP1OKEavOGo+bcp" + } + } + ] +} diff --git a/go/seal/testdata/sealed-by-typescript.json b/go/seal/testdata/sealed-by-typescript.json new file mode 100644 index 0000000..ba811d7 --- /dev/null +++ b/go/seal/testdata/sealed-by-typescript.json @@ -0,0 +1,12 @@ +{ + "privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=", + "iv": "ABqT/hMukn6+xpjh", + "tag": "POzmsWTPHSn9xLMMJJ9Akg==", + "ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC" + }, + "plaintext": "a grant sealed by the TypeScript agent module" +} \ No newline at end of file diff --git a/go/stdio.go b/go/stdio.go index 4ce6003..03db9bd 100644 --- a/go/stdio.go +++ b/go/stdio.go @@ -66,6 +66,8 @@ type channel struct { subscribed int // watches are this bundle's state watches by the id each was asked with (ADR 0201). watches map[string]func(StateChange) error + // provisioned are this bundle's watches of its values, by the id each was asked with (provision.go). + provisioned map[string]func(ProvisionChange) error // takers and provers are this bundle's seat work and proof handlers, by worker and subject (ADR 0259). takers map[string]func(Work) error provers map[string]func(subject string, body json.RawMessage) (any, error) @@ -169,7 +171,8 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { byName[t.Name] = t } c := &channel{enc: json.NewEncoder(out), waiting: map[string]chan message{}, watches: map[string]func(StateChange) error{}, - takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}} + provisioned: map[string]func(ProvisionChange) error{}, + takers: map[string]func(Work) error{}, provers: map[string]func(string, json.RawMessage) (any, error){}} currentMu.Lock() current = c currentMu.Unlock() @@ -297,6 +300,28 @@ func ServeOn(name string, tools []Tool, in io.Reader, out io.Writer) error { } c.say(map[string]any{"id": id, "result": map[string]any{}}) }(id, *p.Change) + case "mesh/provision": + // One change to a value of this module's that this bundle watches (provision.go), named by the + // watch's id, answered once its handler has taken it. Beside the loop, like a tool call. + var p struct { + Watch string `json:"watch"` + Change *ProvisionChange `json:"change"` + } + _ = json.Unmarshal(m.Params, &p) + c.mu.Lock() + handle := c.provisioned[p.Watch] + c.mu.Unlock() + if handle == nil || p.Change == nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32602, "message": "mesh/provision names no watch of this bundle"}}) + continue + } + go func(id any, change ProvisionChange) { + if err := handle(change); err != nil { + c.say(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}}) + return + } + c.say(map[string]any{"id": id, "result": map[string]any{}}) + }(id, *p.Change) case "mesh/work": // One piece of a seat's work this bundle takes (novox/hq ADR 0259): taken by an answer that is // not an error, offered again after one. Beside the loop, like a tool call. diff --git a/package.json b/package.json index cd796d0..5c158a5 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,15 @@ "exports": { ".": "./dist/index.js", "./contracts": "./dist/contracts/index.js", - "./provisioner": "./dist/provisioner/index.js", + "./provider-loop": "./dist/provider-loop/index.js", + "./provisioner": "./dist/provider-loop/index.js", "./tools": "./dist/tools/index.js", "./messaging": "./dist/messaging/index.js", "./events": "./dist/events/index.js", "./primitives": "./dist/primitives/index.js", "./stdio": "./dist/stdio/index.js", - "./state": "./dist/state/index.js" + "./state": "./dist/state/index.js", + "./seal": "./dist/seal/index.js" }, "files": [ "dist" diff --git a/src/index.ts b/src/index.ts index 39a0633..57175b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,16 @@ // @novox/mesh-sdk — the stable spine a module's own code builds against (novox/hq ADR 0039). // -// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provisioner`, …) or the whole +// Import the area you need directly (`@novox/mesh-sdk/tools`, `/provider-loop`, …) or the whole // surface from here. Everything specific to one module — its API client, its tool implementations, // its create-a-resource adapter — lives in the module, never here. export * from "./contracts/index.js"; export * as tools from "./tools/index.js"; -export * as provisioner from "./provisioner/index.js"; +export * as providerLoop from "./provider-loop/index.js"; +/** @deprecated The provider loop's name before design 57 (novox/hq issue 678): `providerLoop`. */ +export * as provisioner from "./provider-loop/index.js"; export * as messaging from "./messaging/index.js"; export * as events from "./events/index.js"; export * as primitives from "./primitives/index.js"; export * as state from "./state/index.js"; +export * as seal from "./seal/index.js"; diff --git a/src/provisioner/index.ts b/src/provider-loop/index.ts similarity index 96% rename from src/provisioner/index.ts rename to src/provider-loop/index.ts index 8a8d77f..14344b8 100644 --- a/src/provisioner/index.ts +++ b/src/provider-loop/index.ts @@ -1,3 +1,13 @@ +// The provider loop: the reconcile harness every provider on the controller's grant files shares. +// Called `provisioner` until design 57 (novox/hq issue 678): that word is mesh-provisioner's, which +// places every value a module needs (ADR 0349), so the loop is named for what it is. The old path +// `@novox/mesh-sdk/provisioner` and the names `runProvisioner`, `provisionerTools`, `Provisioner` and +// `ProvisionerOptions` stay as aliases of these until the providers that import them move to the Go +// helper that answers consumers over the bus (mesh-sdk go/provider, design 57 B2 and B3) and this loop +// retires with the grant files. The events (`provisioner.failing`, `.recovered`, `.retirement`) and the +// tool names (`provisioner_retirement`, …) are what the controller and the operator read on the bus, +// and keep their names. +// // The reconcile harness every provider shares. The loop below is identical for postgres, redis, // minio and umami: read the contributions the mesh delivered, bring each consumer's resource into // being through the provider's adapter under the login and password the mesh minted, and retire @@ -140,9 +150,9 @@ export interface Act { readonly via?: string; } -/** The running provisioner: a stop function, as before, that also answers and takes the person's +/** The running provider loop: a stop function, as before, that also answers and takes the person's * acts on retirement (novox/hq ADR 0230). */ -export type Provisioner = (() => void) & { +export type ProviderLoop = (() => void) & { retirement(): Promise; approve(consumers: readonly string[], act: Act): Promise<{ retired: string[] }>; reject(consumers: readonly string[], act: Act): Promise<{ kept: string[] }>; @@ -167,7 +177,7 @@ export const CLASS_REFUSED = "refused"; /** How long a consumer fails without one success before it is announced failing (ADR 0224). */ export const FAILING_AFTER_MS = 5 * 60_000; -export interface ProvisionerOptions { +export interface ProviderLoopOptions { /** The contributions file the mesh writes for this resource (the module's `receives` path). * Defaults to $MESH_RECEIVES. */ receives?: string; @@ -250,7 +260,7 @@ interface Contribution { * `retireFraction` of those held where more than one is held, is not retired without a person: the * provider waits for approve or reject. RETIRED → DELETED only through `provisioner_delete`. */ -export function runProvisioner(resource: string, adapter: Adapter, opts: ProvisionerOptions = {}): Provisioner { +export function runProviderLoop(resource: string, adapter: Adapter, opts: ProviderLoopOptions = {}): ProviderLoop { const receives = opts.receives ?? envOrThrow("MESH_RECEIVES"); const everyMs = opts.everyMs ?? 5000; const verifyEveryMs = opts.verifyEveryMs ?? 60_000; @@ -757,8 +767,8 @@ export function runProvisioner(resource: string, adapter: Adapter, opts: Provisi const stop = () => { stopped = true; }; - const handle: Provisioner = Object.assign(stop, { retirement, approve, reject, deleteRetired }); - if (opts.module) registerModuleTools(opts.module, () => provisionerTools(handle)); + const handle: ProviderLoop = Object.assign(stop, { retirement, approve, reject, deleteRetired }); + if (opts.module) registerModuleTools(opts.module, () => providerLoopTools(handle)); return handle; } @@ -767,7 +777,7 @@ const text = (v: unknown): string => (typeof v === "string" ? v : ""); /** The four tools every provider serves for retirement (novox/hq ADR 0230), the controller's `retire` * and `cleanup` verbs asking them per machine. */ -export function provisionerTools(p: Provisioner): ToolDefinition[] { +export function providerLoopTools(p: ProviderLoop): ToolDefinition[] { const act = { type: "string" }; return [ { @@ -900,3 +910,12 @@ function envOrThrow(name: string): string { if (!v) throw new Error(`${name} is not set — the provisioner cannot run without it`); return v; } + +/** @deprecated The provider loop's name before design 57: {@link runProviderLoop}. */ +export const runProvisioner = runProviderLoop; +/** @deprecated {@link providerLoopTools}. */ +export const provisionerTools = providerLoopTools; +/** @deprecated {@link ProviderLoop}. */ +export type Provisioner = ProviderLoop; +/** @deprecated {@link ProviderLoopOptions}. */ +export type ProvisionerOptions = ProviderLoopOptions; diff --git a/src/seal/box.ts b/src/seal/box.ts new file mode 100644 index 0000000..30ea274 --- /dev/null +++ b/src/seal/box.ts @@ -0,0 +1,90 @@ +// The sealed box itself (novox/hq ADR 0183, ADR 0206), with its two sources of randomness as arguments so +// the tests can prove it byte for byte against the Go package's vectors. Modules import `./index.js`. + +import { + createCipheriv, + createDecipheriv, + createPrivateKey, + createPublicKey, + diffieHellman, + hkdfSync, + type KeyObject, +} from "node:crypto"; + +/** A value sealed to one recipient, as it travels in JSON; every field but `v` is standard base64. */ +export interface SealedBox { + readonly v: number; + /** The one-time public key, SPKI DER. */ + readonly eph: string; + readonly iv: string; + readonly tag: string; + readonly ct: string; +} + +/** The only box version `open` opens. */ +export const VERSION = 1; + +const INFO = "novox-mesh sealed box v1"; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +function x25519(key: KeyObject, what: string): KeyObject { + if (key.asymmetricKeyType !== "x25519") throw new Error(`not an X25519 ${what} key`); + return key; +} + +export function publicFromPem(pem: string): KeyObject { + return x25519(createPublicKey({ key: pem, format: "pem" }), "public"); +} + +export function privateFromPem(pem: string): KeyObject { + return x25519(createPrivateKey({ key: pem, format: "pem" }), "private"); +} + +/** The recipient's raw 32-byte public key: the last 32 bytes of its SPKI DER. */ +function raw(pub: KeyObject): Buffer { + const der = pub.export({ format: "der", type: "spki" }); + return der.subarray(der.length - 32); +} + +function boxKey(secret: Buffer, ephDer: Buffer, recipientRaw: Buffer): Buffer { + return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([ephDer, recipientRaw]), INFO, 32)); +} + +/** `seal` with the one-time private key and the iv given. */ +export function sealWith(plaintext: string, recipientPem: string, eph: KeyObject, iv: Buffer): SealedBox { + const recipient = publicFromPem(recipientPem); + x25519(eph, "one-time"); + if (iv.length !== IV_BYTES) throw new Error(`an iv of ${iv.length} bytes, not ${IV_BYTES}`); + const secret = diffieHellman({ privateKey: eph, publicKey: recipient }); + const ephDer = createPublicKey(eph).export({ format: "der", type: "spki" }); + const cipher = createCipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(recipient)), iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + v: VERSION, + eph: ephDer.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Open a box with the recipient's PEM private key; throws for a box to another key, changed, or of another version. */ +export function open(box: SealedBox, privatePem: string): string { + if (box.v !== VERSION) throw new Error(`a sealed box of version ${box.v}, not ${VERSION}`); + const priv = privateFromPem(privatePem); + const ephDer = Buffer.from(box.eph, "base64"); + const eph = x25519(createPublicKey({ key: ephDer, format: "der", type: "spki" }), "one-time"); + const iv = Buffer.from(box.iv, "base64"); + const tag = Buffer.from(box.tag, "base64"); + // node accepts a shorter tag than 16 bytes; the box never has one, so a short one is refused here. + if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error("the box does not open with this key"); + const secret = diffieHellman({ privateKey: priv, publicKey: eph }); + const decipher = createDecipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(createPublicKey(priv))), iv); + decipher.setAuthTag(tag); + try { + return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); + } catch { + throw new Error("the box does not open with this key"); + } +} diff --git a/src/seal/index.ts b/src/seal/index.ts new file mode 100644 index 0000000..d3b1751 --- /dev/null +++ b/src/seal/index.ts @@ -0,0 +1,29 @@ +// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): X25519 for the agreement, HKDF-SHA256 for the +// key, AES-256-GCM for the box. The TypeScript twin of the Go package `go/seal`, byte for byte: the same +// fixtures prove both. A box is `{ v: 1, eph, iv, tag, ct }`; keys travel as PEM (SPKI public, PKCS #8 +// private). + +import { generateKeyPairSync, randomBytes } from "node:crypto"; +import { sealWith, open, VERSION, type SealedBox } from "./box.js"; + +export { open, VERSION, type SealedBox }; + +/** A recipient's keypair as the two PEM strings it is kept and sent as. */ +export interface KeyPair { + readonly publicKey: string; + readonly privateKey: string; +} + +/** A fresh X25519 keypair, PEM-encoded. */ +export function generateKeyPair(): KeyPair { + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + return { + publicKey: publicKey.export({ format: "pem", type: "spki" }) as string, + privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string, + }; +} + +/** Seal plaintext to the recipient's PEM public key, with a fresh one-time key and iv. */ +export function seal(plaintext: string, recipientPem: string): SealedBox { + return sealWith(plaintext, recipientPem, generateKeyPairSync("x25519").privateKey, randomBytes(12)); +} diff --git a/test/retire.test.ts b/test/retire.test.ts index 9d660a4..a000760 100644 --- a/test/retire.test.ts +++ b/test/retire.test.ts @@ -8,7 +8,7 @@ import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { runProvisioner, provisionerTools, RETIREMENT_EVENT, type Provisioner } from "../dist/provisioner/index.js"; +import { runProviderLoop, providerLoopTools, RETIREMENT_EVENT, type ProviderLoop } from "../dist/provider-loop/index.js"; import { resetTools, collectTools } from "../dist/tools/index.js"; const EVERY = 10; @@ -32,7 +32,7 @@ interface World { reenabled: string[]; events: Record[]; said: string[]; - p: Provisioner; + p: ProviderLoop; done(): void; } @@ -62,7 +62,7 @@ async function world( ...(opts.retire === false ? {} : { async retire(p: { as: string; why: string }) { w.retired.push({ as: p.as, why: p.why }); } }), ...(opts.held ? { async held() { return opts.held!; } } : {}), }; - w.p = runProvisioner("postgres-database", adapter, { + w.p = runProviderLoop("postgres-database", adapter, { receives, everyMs: EVERY, node: "anchor", stableForMs: opts.stableForMs ?? 0, // Only retirement is these tests'; the standing (provisioner.failing, .recovered) is standing.test.ts's. announce: (event, body) => { if (event === RETIREMENT_EVENT) w.events.push(body); }, @@ -168,7 +168,7 @@ test("five passes are not enough: the same set must also hold for stableForMs", }); test("the default hold is ten minutes", async () => { - const { STABLE_FOR_MS } = await import("../dist/provisioner/index.js"); + const { STABLE_FOR_MS } = await import("../dist/provider-loop/index.js"); assert.equal(STABLE_FOR_MS, 10 * 60_000); }); @@ -327,7 +327,7 @@ test("the four tools are registered for the module and answer", async () => { const dir = await mkdtemp(join(tmpdir(), "retire-tools-")); const receives = join(dir, "r.json"); await writeFile(receives, JSON.stringify({ requirement: "r", given: [] })); - const p = runProvisioner("r", { async create() {}, async remove() {} }, { receives, everyMs: EVERY, module: "postgres" }); + const p = runProviderLoop("r", { async create() {}, async remove() {} }, { receives, everyMs: EVERY, module: "postgres" }); try { const mine = collectTools({}).find((c) => c.module === "postgres"); assert.deepEqual(mine?.tools.map((t) => t.name), [ @@ -336,7 +336,7 @@ test("the four tools are registered for the module and answer", async () => { const answer = (await mine!.tools[0].run({})) as { resource: string; waiting: unknown }; assert.equal(answer.resource, "r"); assert.equal(answer.waiting, null); - assert.equal(provisionerTools(p).length, 4); + assert.equal(providerLoopTools(p).length, 4); } finally { p(); resetTools(); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index c23339c..5091183 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { compareVersions } from "../dist/primitives/index.js"; import { registerModuleTools, collectTools, collectToolsEach, resetTools, serveTools, listTools, invokeTool } from "../dist/tools/index.js"; -import { runProvisioner } from "../dist/provisioner/index.js"; +import { runProviderLoop } from "../dist/provider-loop/index.js"; import { emit, on, type Event } from "../dist/events/index.js"; import { useBroker } from "../dist/messaging/index.js"; @@ -47,7 +47,7 @@ test("provisioner creates each consumer with the mesh's login and password; remo doc([{ from: "webapp", node: "anchor", as: "webapp-anchor", secret: join(dir, "webapp.secret"), values: { name: "webapp" } }]), ); - const stop = runProvisioner( + const stop = runProviderLoop( "analytics", { async create(p) { @@ -101,7 +101,7 @@ test("provisioner hands the adapter what the mesh derived, and still has it at w }); await writeFile(receives, doc([entry("mesh-anchor-ncloud")])); - const stop = runProvisioner( + const stop = runProviderLoop( "s3-bucket", { async create(p) { @@ -153,7 +153,7 @@ test("provisioner stops calling an unreadable secret a race, and says what to lo console.error = (line: unknown) => { said.push(String(line)); }; let stop: (() => void) | undefined; try { - stop = runProvisioner("db", { async create() {}, async remove() {} }, { receives, everyMs: 5 }); + stop = runProviderLoop("db", { async create() {}, async remove() {} }, { receives, everyMs: 5 }); await waitFor(() => said.some((l) => l.includes("CANNOT READ")), 3000); } finally { stop?.(); @@ -185,7 +185,7 @@ test("provisioner removes nothing while its contributions file cannot be used (n ]); await writeFile(receives, both); - const stop = runProvisioner( + const stop = runProviderLoop( "postgres-database", { async create(p) { @@ -239,7 +239,7 @@ test("provisioner applies again what the backend no longer holds, and trusts mem const backend = new Set(); let creates = 0; let asked = 0; - const stop = runProvisioner( + const stop = runProviderLoop( "cache", { async create(p) { @@ -271,7 +271,7 @@ test("provisioner applies again what the backend no longer holds, and trusts mem // An adapter without holds is trusted from memory, as before: a forgotten backend stays forgotten. const forgetful = new Set(); let plainCreates = 0; - const stopPlain = runProvisioner( + const stopPlain = runProviderLoop( "cache", { async create(p) { @@ -299,7 +299,7 @@ test("provisioner keeps a consumer applied when the backend cannot be asked", as ); let creates = 0; let asked = 0; - const stop = runProvisioner( + const stop = runProviderLoop( "cache", { async create() { @@ -330,7 +330,7 @@ test("provisioner backs off when create does not satisfy holds, and says so", as const original = console.error; console.error = (...a: unknown[]) => logged.push(a.join(" ")); try { - const stop = runProvisioner( + const stop = runProviderLoop( "cache", { async create() { @@ -371,7 +371,7 @@ test("provisioner treats a failed or hung holds as could-not-ask, and never logs const original = console.error; console.error = (...a: unknown[]) => logged.push(a.join(" ")); try { - const stop = runProvisioner( + const stop = runProviderLoop( "cache", { async create() { @@ -404,7 +404,7 @@ test("provisioner treats a failed or hung holds as could-not-ask, and never logs const hungLogged: string[] = []; console.error = (...a: unknown[]) => hungLogged.push(a.join(" ")); try { - const stopHung = runProvisioner( + const stopHung = runProviderLoop( "cache", { async create() { @@ -438,7 +438,7 @@ test("provisioner stops a verify pass at a timeout, and retries a failed re-appl try { // A timeout on "a" means "b" is not asked in that pass. const askedFor: string[] = []; - const stopHung = runProvisioner( + const stopHung = runProviderLoop( "cache", { async create() {}, @@ -460,7 +460,7 @@ test("provisioner stops a verify pass at a timeout, and retries a failed re-appl let createCalls = 0; const logged: string[] = []; console.error = (...a: unknown[]) => logged.push(a.join(" ")); - const stop = runProvisioner( + const stop = runProviderLoop( "cache", { async create(p) { @@ -649,3 +649,16 @@ test("each registration is given the environment asked for it, and sees no other assert.deepEqual(seen.beta, { BETA_URL: "http://127.0.0.1:3000" }); resetTools(); }); + +test("the provider loop is named for what it is, and its old name stays an alias until its providers move (issue 678)", async () => { + const loop = await import("../dist/provider-loop/index.js"); + const old = await import("../dist/index.js"); + assert.equal(old.provisioner.runProvisioner, loop.runProviderLoop); + assert.equal(old.provisioner.provisionerTools, loop.providerLoopTools); + assert.equal(old.providerLoop.runProviderLoop, loop.runProviderLoop); + const pkg = JSON.parse(await (await import("node:fs/promises")).readFile(new URL("../package.json", import.meta.url), "utf8")); + assert.equal(pkg.exports["./provider-loop"], "./dist/provider-loop/index.js"); + assert.equal(pkg.exports["./provisioner"], pkg.exports["./provider-loop"]); + // The events and tools are what the controller and the operator read on the bus: their names stay. + assert.equal(loop.FAILING_EVENT, "provisioner.failing"); +}); diff --git a/test/seal.test.ts b/test/seal.test.ts new file mode 100644 index 0000000..9eb840b --- /dev/null +++ b/test/seal.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createPrivateKey } from "node:crypto"; + +import { seal, open, generateKeyPair, type SealedBox } from "../dist/seal/index.js"; +import { sealWith } from "../dist/seal/box.js"; + +// The fixtures the Go package proves itself against: boxes the licence manager's own seal.go made, and +// the one the agent module's TypeScript made. One format, three implementations. +const fixtures = new URL("../go/seal/testdata/", import.meta.url); +const lm = JSON.parse(readFileSync(new URL("sealed-by-licence-manager.json", fixtures), "utf8")) as { + recipient: { publicKey: string; privateKey: string }; + sealed: { plaintext: string; box: SealedBox }[]; + vectors: { plaintext: string; ephPrivate: string; iv: string; box: SealedBox }[]; +}; + +// PKCS #8 for an X25519 private key is this prefix and the 32 raw bytes. +const pkcs8 = (raw: Buffer) => + createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]), format: "der", type: "pkcs8" }); + +test("seal: every box the licence manager sealed opens to its plaintext", () => { + assert.ok(lm.sealed.length > 0); + for (const s of lm.sealed) assert.equal(open(s.box, lm.recipient.privateKey), s.plaintext); +}); + +test("seal: given the same one-time key and iv, the box is byte for byte the licence manager's", () => { + assert.ok(lm.vectors.length > 0); + for (const v of lm.vectors) { + const box = sealWith(v.plaintext, lm.recipient.publicKey, pkcs8(Buffer.from(v.ephPrivate, "base64")), Buffer.from(v.iv, "base64")); + assert.deepEqual(box, v.box); + assert.equal(open(box, lm.recipient.privateKey), v.plaintext); + } +}); + +test("seal: the agent module's TypeScript box opens", () => { + const f = JSON.parse(readFileSync(new URL("sealed-by-typescript.json", fixtures), "utf8")); + assert.equal(open(f.box, f.privateKey), f.plaintext); +}); + +test("seal: a box opens for its recipient only, and two boxes share no one-time key or iv", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + const box = seal("a token", a.publicKey); + assert.equal(open(box, a.privateKey), "a token"); + assert.throws(() => open(box, b.privateKey)); + const again = seal("a token", a.publicKey); + assert.notEqual(again.eph, box.eph); + assert.notEqual(again.iv, box.iv); +}); + +test("seal: a box changed in any field, or of another version, does not open", () => { + const good = lm.sealed[1].box; + const flip = (s: string) => { + const b = Buffer.from(s, "base64"); + b[b.length - 1] ^= 1; + return b.toString("base64"); + }; + for (const box of [ + { ...good, ct: flip(good.ct) }, + { ...good, tag: flip(good.tag) }, + { ...good, iv: flip(good.iv) }, + { ...good, eph: flip(good.eph) }, + { ...good, v: 2 }, + ]) assert.throws(() => open(box as SealedBox, lm.recipient.privateKey)); +}); + +test("seal: a key that is not X25519 PEM is refused", () => { + assert.throws(() => seal("x", "not a key")); + assert.throws(() => open(lm.sealed[0].box, lm.recipient.publicKey)); +}); diff --git a/test/standing.test.ts b/test/standing.test.ts index 538ae86..107cb49 100644 --- a/test/standing.test.ts +++ b/test/standing.test.ts @@ -9,9 +9,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { - runProvisioner, classOf, FAILING_EVENT, RECOVERED_EVENT, RETIREMENT_EVENT, + runProviderLoop, classOf, FAILING_EVENT, RECOVERED_EVENT, RETIREMENT_EVENT, CLASS_CREDENTIALS, CLASS_UNREACHABLE, CLASS_SECRET, CLASS_REFUSED, type Adapter, -} from "../dist/provisioner/index.js"; +} from "../dist/provider-loop/index.js"; import { useBroker } from "../dist/messaging/index.js"; const EVERY = 10; @@ -68,7 +68,7 @@ async function world(opts: { classify?: boolean; announce?: false; stableForMs?: }; const was = console.error; console.error = () => {}; - const stop = runProvisioner("postgres-database", adapter, { + const stop = runProviderLoop("postgres-database", adapter, { receives, everyMs: EVERY, verifyEveryMs: 0, node: "home-server", failingAfterMs: FAILING_AFTER, sayAgainEveryMs: SAY_AGAIN, stableForMs: opts.stableForMs ?? 0, ...(opts.announce === false ? {} : {