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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/full-stack-setup-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1014,6 +1020,8 @@ path = "{{ .VtcName }}-vtc" # DID path — did:webvh:<scid>:<d
[messaging]
mediator_did = "{{ .MediatorDid }}" # 1b — the same shared mediator
mediator_url = "{{ .MediatorURL }}" # informational only; endpoint is resolved from the DID doc
transports = ["tsp", "didcomm"] # required — a [messaging] table without it does not deserialize.
# Consumed once at mint, never persisted; array order is preference.

[secrets]
backend = "vault"
Expand Down Expand Up @@ -1641,6 +1649,7 @@ daemon), rather than taken from docs:
| `[secrets]` field names + defaults + `deny_unknown_fields` | `vtc-service/src/config.rs::SecretsConfig` (mirrors `vti_secrets`; kv_mount `secret`, secret_key `seed`, auth `kubernetes`) |
| `vault-secrets` is a non-default vtc feature | `vtc-service/Cargo.toml` + `docs/03-vtc/feature-flags.md` |
| `[messaging]` fields (`mediator_did` required, `mediator_url` optional) | `vti-common/src/config.rs::MessagingConfig` |
| `[messaging].transports` required, `["tsp","didcomm"]`, not persisted | `vtc-service/src/setup/from_toml.rs::MessagingSetup` + `wizard.rs::Transport` |
| `[webvh].server_id` → `WEBVH_SERVER` template var | `vtc-service/src/setup/wizard.rs` (`WebvhTarget`, `build_template_vars`) |
| `vta contexts create` offline, `--admin-did`/`--admin-expires N[s\|m\|h\|d\|w]`, atomic ACL; Conflict on existing id | `vta-service/src/main.rs::ContextCommands::Create`; `operations/contexts.rs` (`Conflict: context already exists`) |
| `vta import-did --role admin --context <ctx>` as the exists-tolerant regrant | `vta-service/src/main.rs::ImportDid` (`--context Vec<String>`) |
Expand Down
27 changes: 27 additions & 0 deletions docs/vta-setup-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/setup/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -15,6 +20,7 @@ public_url = "{{ .PublicURL }}"
[services]
rest = true
didcomm = true
tsp = true

[server]
host = "0.0.0.0"
Expand Down
17 changes: 16 additions & 1 deletion internal/setup/templates_fullstack.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public_url = "{{ .VtaPublicURL }}"
[services]
rest = true
didcomm = true
tsp = true

[server]
host = "0.0.0.0"
Expand Down Expand Up @@ -69,6 +70,10 @@ type fullStackVtaSetupData struct {
// The webvh URL paths become the DIDs' path components
// (did:webvh:<scid>:<host>:<vta_name>-vta / <vta_name>-mediator) — derived
// from the session's name, same convention as the VTC's <vtc_name>-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{
Expand All @@ -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"

Expand Down Expand Up @@ -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 }}"
Expand All @@ -165,6 +179,7 @@ data_dir = "data/daemon"
[identity]
public_url = "{{ .PublicURL }}"
mediator_did = "{{ .MediatorDid }}"
transport = "both"

[vta]
{{- if eq .Phase "offline-prepare" }}
Expand Down
94 changes: 94 additions & 0 deletions internal/setup/templates_tsp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions internal/setup/templates_vtc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -39,6 +43,7 @@ path = "{{ .VtcDidPath }}"
[messaging]
mediator_did = "{{ .MediatorDid }}"
mediator_url = "{{ .MediatorURL }}"
transports = ["tsp", "didcomm"]

[secrets]
backend = "vault"
Expand Down