Feature 57 (config and provisioning): release mesh-sdk, the reviewed release branch on top of main
mesh/merge-gate head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ac…
mesh/repo-check head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning delivered: every member is delivered

This commit is contained in:
2026-10-16 08:24:11 +00:00
parent a2d2af00dc
commit 1bc5719b8b
24 changed files with 2580 additions and 40 deletions
+10 -4
View File
@@ -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<Credential> { /* create a umami site, return the grant */ },
async remove(grant: Grant): Promise<void> { /* delete the site */ },
});
+6
View File
@@ -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.
+2 -2
View File
@@ -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 `<seat>.<verb>` 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 `<module>_bound` (`<node>.<module>.<provision>[.<local>]`: 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/<provider>/<provision>` shared, `pair/<provider>/<provision>/<node>/<module>[/<local>]` 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:<provision>}` 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.
+72
View File
@@ -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:<provision>}`, 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:<provision>}`, 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)
}
+69
View File
@@ -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)
}
}
+635
View File
@@ -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 `<node>.<module>` 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, `<node>.<module>.
// <provision>[.<local>]` of `<provider module>_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/<provider node>/<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/<provider node>/<provision>/<node>/<module>[/<local>]`, 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, `<node>.<module>`, 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: `<module>_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:<provision>:<key>}` 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: `<node>.<module>.<provision>`, and `.<local>` 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), "<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_<node>_<module>`,
// 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:<fact>}` or `${consumer:<fact>:<alphabet>}`.
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)
}
+590
View File
@@ -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_<node>_<module>, 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)
}
}
+152
View File
@@ -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):
}
}
+90
View File
@@ -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:<provision>:<key>}`).
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
}
+176
View File
@@ -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()
}
+203
View File
@@ -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)
}
+156
View File
@@ -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")
}
}
+121
View File
@@ -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"
}
}
]
}
+12
View File
@@ -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"
}
+26 -1
View File
@@ -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.
+4 -2
View File
@@ -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"
+5 -2
View File
@@ -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";
@@ -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<Retirement>;
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;
+90
View File
@@ -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");
}
}
+29
View File
@@ -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));
}
+6 -6
View File
@@ -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<string, unknown>[];
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();
+26 -13
View File
@@ -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<string>();
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<string>();
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");
});
+71
View File
@@ -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));
});
+3 -3
View File
@@ -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 ? {} : {