From 4ef6f4589abd4135b9a43e270fe39df6e689299c Mon Sep 17 00:00:00 2001 From: vthwang Date: Wed, 12 Aug 2026 11:42:22 -0700 Subject: [PATCH] feat: advertise TSP alongside DIDComm across all four components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The images are built with TSP; the recipes were not. Turn it on in the four places that have to agree, and fix one that was already wrong. `services.tsp = true` in both VTA recipes publishes a `#tsp` `TSPTransport` entry from DID log v1, pointing at the same mediator as the DIDComm entry. `[messaging]` deliberately gains no `protocols` key — omitted, setup derives the minted mediator's transports from `[services]`, which is the only value that keeps the two in step. The VTC's `messaging.transports` is a repair, not an addition: it is required, so `vtc setup --from` had no chance of parsing this recipe on any vtc image from #929 onward, TSP or no TSP. `identity.transport` in the webvh recipe was likewise not neutral by absence — the daemon reads absent as `both`, so a TSP-carrying build has been advertising `TSPTransport` against a mediator whose recipe said `["didcomm"]`. The mediator's own `protocols` has no runtime effect on a prebuilt image; it records which image the session expects. Two things to know before this ships: - The VTA image must be built `--features tsp`. Setup refuses the flag otherwise, so an image predating TSP no longer builds a session. - `vta_only` points at the platform stack's mediator via `kind = "existing"`, and nothing upstream verifies that mediator carries TSP. Rebuild the platform stack on a TSP-enabled mediator image first: `#tsp` is first in the preference order, so peers pick it and fail rather than fall back to DIDComm. Signed-off-by: vthwang --- docs/full-stack-setup-design.md | 11 +++- docs/vta-setup-design.md | 27 ++++++++ internal/setup/templates.go | 6 ++ internal/setup/templates_fullstack.go | 17 ++++- internal/setup/templates_tsp_test.go | 94 +++++++++++++++++++++++++++ internal/setup/templates_vtc.go | 5 ++ 6 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 internal/setup/templates_tsp_test.go diff --git a/docs/full-stack-setup-design.md b/docs/full-stack-setup-design.md index fc16fd3..14d017a 100644 --- a/docs/full-stack-setup-design.md +++ b/docs/full-stack-setup-design.md @@ -861,6 +861,7 @@ public_url = "{{ .VtaPublicURL }}" # https://vta-{vta_name}.{domain} [services] rest = true didcomm = true +tsp = true # requires a VTA image built --features tsp; setup refuses the flag otherwise [server] host = "0.0.0.0" @@ -881,6 +882,8 @@ k8s_role = "{{ .Vault.K8sRole }}" skip_verify = {{ .Vault.SkipVerify }} [messaging] # ← full_stack: CREATE the mediator (vta_only uses kind="existing") +# No `protocols` key on purpose: omitted, setup derives the minted mediator's +# transports from [services] — the only value that keeps the two in step. kind = "create_mediator" context = "mediator" url = "{{ .MediatorURL }}" # https://mediator-{vta_name}.{domain}/mediator/v1 @@ -900,7 +903,9 @@ pre_rotation_count = {{ .PreRotationCount }} ```toml [deployment] type = "server" -protocols = ["didcomm"] +# No runtime effect on a prebuilt image (the mediator's TSP is compile-time). +# Stated because it records which image this session expects. +protocols = ["didcomm", "tsp"] use_vta = true vta_mode = "sealed-export" @@ -956,6 +961,7 @@ data_dir = "data/daemon" [identity] public_url = "{{ .PublicURL }}" # https://dids-{vta_name}.{domain} mediator_did = "{{ .MediatorDid }}" # 1b +transport = "both" # NOT the default-if-absent it looks like — omitted reads as "both" already [vta] request_path = "bootstrap-request.json" # phase 1 only @@ -1014,6 +1020,8 @@ path = "{{ .VtcName }}-vtc" # DID path — did:webvh::` as the exists-tolerant regrant | `vta-service/src/main.rs::ImportDid` (`--context Vec`) | diff --git a/docs/vta-setup-design.md b/docs/vta-setup-design.md index 3ba96ab..746e220 100644 --- a/docs/vta-setup-design.md +++ b/docs/vta-setup-design.md @@ -351,6 +351,7 @@ public_url = "{{ .PublicURL }}" # https://{subdomain}.{CLUSTER_DOMAIN [services] rest = true didcomm = true +tsp = true # see "TSP is on, and the platform stack has to hold it up" below [server] host = "0.0.0.0" @@ -386,6 +387,32 @@ pre_rotation_count = {{ .PreRotationCount }} > `kind = "create_mediator"` and points `[vta_did].url` at the in-cluster dids host — see > [`full-stack-setup-design.md`](full-stack-setup-design.md). +### TSP is on, and the platform stack has to hold it up + +Every session this API creates advertises both transports. `services.tsp = true` +publishes a `#tsp` `TSPTransport` entry in the VTA's DID document from log v1, +pointing at the **same** mediator as its `DIDCommMessaging` entry — TSP is a +second protocol over one mediator, never a second mediator. + +Two consequences, both of which bite in `vta_only` specifically: + +- **The VTA image must be built `--features tsp`.** `vta setup --from` refuses + the flag otherwise, rather than advertise a transport the binary cannot + answer, so an image predating TSP no longer builds a session. +- **Nobody upstream checks the mediator.** `vta_only` uses + `[messaging] kind = "existing"` against the platform stack's mediator, and + `ServiceCapabilities::from_did_document` is only consulted where setup mints + the mediator itself. Sequencing is ours to keep: rebuild the platform stack on + a TSP-enabled mediator image *before* any `vta_only` session points at it. + +Getting that order wrong does not degrade gracefully — `#tsp` is *first* in the +preference order, so the peers that read a DID document most carefully are the +ones that pick it and fail there instead of falling back to DIDComm. + +Turning TSP off on a minted session is not a config change here; the DID +document is authoritative once written. It is `pnm services tsp disable` against +the running VTA, plus re-publishing the document. + ### Full Stack recipes — see the Full Stack design `mediator-recipe.toml`, `webvh-recipe.toml` (phases 1 and 3), and `vtc-setup.toml` are diff --git a/internal/setup/templates.go b/internal/setup/templates.go index c4c64be..c72dfa8 100644 --- a/internal/setup/templates.go +++ b/internal/setup/templates.go @@ -7,6 +7,11 @@ import ( "github.com/ic3software/vtafarm-api/internal/model" ) +// `services.tsp = true` needs a VTA image built `--features tsp` — setup +// refuses the flag otherwise. The mediator here is the platform stack's +// (`kind = "existing"`) and nothing upstream checks it routes TSP, so that +// stack must be TSP-enabled first: `#tsp` is first in the preference order, so +// peers pick it and fail rather than fall back to DIDComm. var vtaSetupTmpl = template.Must(template.New("vta-setup").Parse(`config_path = "config.toml" data_dir = "data/vta" vta_name = "{{ .VtaName }}" @@ -15,6 +20,7 @@ public_url = "{{ .PublicURL }}" [services] rest = true didcomm = true +tsp = true [server] host = "0.0.0.0" diff --git a/internal/setup/templates_fullstack.go b/internal/setup/templates_fullstack.go index f56c065..5c4a2f2 100644 --- a/internal/setup/templates_fullstack.go +++ b/internal/setup/templates_fullstack.go @@ -18,6 +18,7 @@ public_url = "{{ .VtaPublicURL }}" [services] rest = true didcomm = true +tsp = true [server] host = "0.0.0.0" @@ -69,6 +70,10 @@ type fullStackVtaSetupData struct { // The webvh URL paths become the DIDs' path components // (did:webvh:::-vta / -mediator) — derived // from the session's name, same convention as the VTC's -vtc. +// +// `[messaging]` carries no `protocols` key on purpose: omitted, setup derives +// the minted mediator's transports from `[services]`, which is the only value +// that keeps the VTA's `#tsp` and the mediator it names in step. func RenderFullStackVtaSetupTOML(s *model.SetupSession, vault VaultSecrets) (string, error) { var buf bytes.Buffer err := fullStackVtaSetupTmpl.Execute(&buf, fullStackVtaSetupData{ @@ -86,9 +91,14 @@ func RenderFullStackVtaSetupTOML(s *model.SetupSession, vault VaultSecrets) (str // ── mediator-recipe.toml ───────────────────────────────────────────────────── +// `protocols` has no runtime effect on a prebuilt image — the mediator's TSP +// is compile-time and mediator-setup writes no TSP key into +// conf/mediator.toml. Stated because it records which image this session +// expects: one built without `--features tsp` accepts this recipe and then +// never answers the `#tsp` entry the VTA published for it. var mediatorRecipeTmpl = template.Must(template.New("fs-mediator-recipe").Parse(`[deployment] type = "server" -protocols = ["didcomm"] +protocols = ["didcomm", "tsp"] use_vta = true vta_mode = "sealed-export" @@ -148,6 +158,10 @@ const ( WebvhPhaseComplete = "offline-complete" // phase 3 — step_dids_p2 ) +// `identity.transport` is written out because omitting it does not mean +// "DIDComm" — the daemon reads absent as `both`, so a TSP-carrying build has +// been advertising `TSPTransport` here all along. `both` matches the rest of +// the stack; the point is that it is now stated rather than inherited. var webvhRecipeTmpl = template.Must(template.New("fs-webvh-recipe").Parse(`[deployment] service = "daemon" vta_mode = "{{ .Phase }}" @@ -165,6 +179,7 @@ data_dir = "data/daemon" [identity] public_url = "{{ .PublicURL }}" mediator_did = "{{ .MediatorDid }}" +transport = "both" [vta] {{- if eq .Phase "offline-prepare" }} diff --git a/internal/setup/templates_tsp_test.go b/internal/setup/templates_tsp_test.go new file mode 100644 index 0000000..b34e9f0 --- /dev/null +++ b/internal/setup/templates_tsp_test.go @@ -0,0 +1,94 @@ +package setup + +import ( + "strings" + "testing" + + "github.com/ic3software/vtafarm-api/internal/model" +) + +// Four recipes advertise TSP in four different vocabularies and only work if +// all four agree. Two of them default to something else when the key goes +// missing (`identity.transport` reads absent as `both`, `deployment.protocols` +// as `["didcomm"]`), so a dropped line renders fine and ships a stack that +// advertises a transport nobody serves — or serves one nobody was told about. + +func tspTestSession() *model.SetupSession { + return &model.SetupSession{ + VtaName: "alice", + VtcName: "alice", + Subdomain: "vta-alice", + MediatorSubdomain: "mediator-alice", + DidsSubdomain: "dids-alice", + VtcSubdomain: "vtc-alice", + Domain: "firstperson.dev", + MediatorDid: "did:webvh:scid:dids-alice.firstperson.dev:alice-mediator", + VtaDid: "did:webvh:scid:dids-alice.firstperson.dev:alice-vta", + VtaDidUrl: "https://dids.firstperson.dev/alice-vta", + Portable: true, + PreRotationCount: 1, + } +} + +func TestVtaOnlySetupTOMLEnablesTSP(t *testing.T) { + out, err := RenderVtaSetupTOML(tspTestSession(), VaultSecrets{}) + if err != nil { + t.Fatalf("render: %v", err) + } + // TSP requires DIDComm — setup refuses the pair split apart. + for _, want := range []string{"didcomm = true", "tsp = true"} { + if !strings.Contains(out, want) { + t.Errorf("vta_only setup TOML missing %q:\n%s", want, out) + } + } +} + +func TestFullStackVtaSetupTOMLEnablesTSP(t *testing.T) { + out, err := RenderFullStackVtaSetupTOML(tspTestSession(), VaultSecrets{}) + if err != nil { + t.Fatalf("render: %v", err) + } + for _, want := range []string{"didcomm = true", "tsp = true"} { + if !strings.Contains(out, want) { + t.Errorf("full_stack VTA setup TOML missing %q:\n%s", want, out) + } + } + // Deliberately absent — derived from `[services]`, not a second source of truth. + if strings.Contains(out, "protocols") { + t.Errorf("full_stack VTA setup TOML names messaging.protocols; it must stay derived:\n%s", out) + } +} + +func TestMediatorRecipeCarriesTSP(t *testing.T) { + out, err := RenderMediatorRecipeTOML(tspTestSession(), MediatorVaultSecrets{}) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(out, `protocols = ["didcomm", "tsp"]`) { + t.Errorf("mediator recipe does not carry TSP:\n%s", out) + } +} + +func TestWebvhRecipeStatesTransportExplicitly(t *testing.T) { + // Both phases render the same [identity] block; only [vta] is phase-dependent. + for _, phase := range []string{WebvhPhasePrepare, WebvhPhaseComplete} { + out, err := RenderWebvhRecipeTOML(tspTestSession(), phase, "sha256:deadbeef", WebvhVaultSecrets{}) + if err != nil { + t.Fatalf("render %s: %v", phase, err) + } + if !strings.Contains(out, `transport = "both"`) { + t.Errorf("webvh recipe (%s) leaves identity.transport implicit:\n%s", phase, out) + } + } +} + +func TestVtcSetupTOMLAdvertisesBothTransports(t *testing.T) { + out, err := RenderVtcSetupTOML(tspTestSession(), VtcVaultSecrets{}) + if err != nil { + t.Fatalf("render: %v", err) + } + // Required by the schema, and TSP leads — array order encodes preference. + if !strings.Contains(out, `transports = ["tsp", "didcomm"]`) { + t.Errorf("VTC setup TOML missing messaging.transports:\n%s", out) + } +} diff --git a/internal/setup/templates_vtc.go b/internal/setup/templates_vtc.go index 0baa9d2..fa50fc8 100644 --- a/internal/setup/templates_vtc.go +++ b/internal/setup/templates_vtc.go @@ -26,6 +26,10 @@ import ( // relative, resolved against the Job's /app/vtc workingDir where // step_vtc_setup_key wrote it. vault_secret_key = "bundle" stores the // serialized VtcKeyBundle — vti_secrets' seed store is byte-agnostic. +// +// `messaging.transports` is required — a `[messaging]` table without it does +// not deserialize. Consumed once at mint, never persisted; array order is +// preference, and both entries bind the same `mediator_did`. var vtcSetupTmpl = template.Must(template.New("fs-vtc-setup").Parse(`config_path = "config.toml" base_url = "{{ .VtcPublicURL }}" vta_did = "{{ .VtaDid }}" @@ -39,6 +43,7 @@ path = "{{ .VtcDidPath }}" [messaging] mediator_did = "{{ .MediatorDid }}" mediator_url = "{{ .MediatorURL }}" +transports = ["tsp", "didcomm"] [secrets] backend = "vault"