diff --git a/docs/provisioning.md b/docs/provisioning.md index 34149c8..ac430e8 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -118,12 +118,15 @@ Reconciliation is **converging, never destructive**: Nothing sensitive enters the spec or the resolved state. Both are committed. -| Secret | Path | Rule | +Paths are computed as `/fluid-telegram//telegram/`, so two +campaigns on one interface cannot read each other's credentials. + +| Secret | Key | Rule | |---|---|---| -| MTProto session string | `bao:.../telegram/operator-session` | Provisioning only. Never given to the adapter. | -| `api_id` / `api_hash` | `bao:.../telegram/operator-app` | Provisioning only. | -| Bot token | `bao:.../telegram/bot-token` | Written by `apply` the moment BotFather issues it, read by the adapter at start. Never logged, never echoed, not even in error text. | -| Redaction salt | `bao:.../telegram/redaction-salt` | **Create-if-absent, never overwrite.** | +| `api_id` / `api_hash` | `operator-app` | The only one written by hand. Provisioning only. | +| MTProto session string | `operator-session` | Written by `session bootstrap`. A full-account credential: provisioning only, never given to the adapter, never on disk. | +| Bot token | `bot-token` | Written by `apply` the moment BotFather issues it, read by the adapter at start. Never logged, never echoed, not even in error text. | +| Redaction salt | `redaction-salt` | **Create-if-absent, never overwrite.** | The salt rule is a hard one. `docs/observation.md` explains why: rotating it silently invalidates every longitudinal comparison the interface has ever made, diff --git a/docs/seeding-runbook.md b/docs/seeding-runbook.md index 36738ff..4123b14 100644 --- a/docs/seeding-runbook.md +++ b/docs/seeding-runbook.md @@ -41,12 +41,35 @@ becomes irreproducible. - [ ] A phone number that can receive SMS, **not** a personal one - [ ] A device with Telegram installed, to complete the first login -- [ ] Access to OpenBao (`bao.coulomb.social`) with write on the interface's path +- [ ] Access to OpenBao (`bao.coulomb.social`) with write on the campaign's subtree - [ ] The campaign's presence spec merged (`pr-hall-of-helix/presence/telegram.yaml`) - [ ] The avatar asset committed and referenced by the spec Roughly 20 minutes, once. +## Where the secrets go + +The provisioner computes these paths; they are not a convention you may vary. +For campaign `hall-of-helix` on the default `secret` mount: + +``` +secret/fluid-telegram/hall-of-helix/telegram/operator-app api_id, api_hash +secret/fluid-telegram/hall-of-helix/telegram/operator-session written by the tool +secret/fluid-telegram/hall-of-helix/telegram/bot-token written by the tool +secret/fluid-telegram/hall-of-helix/telegram/redaction-salt written by the tool, once +``` + +Set these before any command below. `BAO_MOUNT` defaults to `secret`. + +```bash +export BAO_ADDR=https://bao.coulomb.social +export BAO_TOKEN= # not committed, not echoed +``` + +Only the first path is written by hand. The rest are the tool's, and writing +one yourself will be overwritten or -- for the salt -- silently honoured +forever. + --- ## Step 1 — Register the operator account @@ -57,14 +80,16 @@ is to prove a person is present. 1. Install Telegram on the device and register with the operator phone number. 2. Set a display name that reads as an operator account, not a person. -3. Enable two-factor authentication and put the password in OpenBao at - `/telegram/operator-2fa` immediately. +3. Enable two-factor authentication and store the password somewhere you can + retrieve it. The tool prompts for it during Step 3 and does not read it from + OpenBao, so this is for you, not for the provisioner. **Do not use a personal account.** If it is rate-limited or restricted — which automation can cause — the loss should be a throwaway account, not someone's messages. -**Record:** the phone number, in OpenBao at `/telegram/operator-phone`. +**Record:** the phone number where your team can find it. The tool prompts for +it rather than reading it, so it is not one of the paths above. --- @@ -80,7 +105,19 @@ exposes it, Step 2 disappears. - Platform: Other 3. Copy `api_id` and `api_hash`. -**Record:** both, in OpenBao at `/telegram/operator-app`. +**Record:** both, in OpenBao. This is the one secret you write by hand, and +everything downstream reads it: + +```bash +bao kv put secret/fluid-telegram/hall-of-helix/telegram/operator-app \ + api_id= api_hash= +``` + +Verify the tool can see it before going on: + +```bash +bao kv get secret/fluid-telegram/hall-of-helix/telegram/operator-app +``` They are shown once per application. Losing them means creating another. @@ -92,12 +129,12 @@ They are shown once per application. Losing them means creating another. command is interactive by necessity — but *only this command*, and only once. ```bash -provision session bootstrap --campaign hall-of-helix +go run ./cmd/provision session bootstrap --campaign hall-of-helix ``` It prompts for the phone number, the login code, and the 2FA password, then writes the session string to OpenBao at -`/telegram/operator-session`. +`secret/fluid-telegram/hall-of-helix/telegram/operator-session`. The session string is a **full-account credential** — it can do anything the account can. It never leaves OpenBao, is never given to the adapter, and is used @@ -106,7 +143,7 @@ only by the provisioner. Treat losing it as losing the account. **Verify:** ```bash -provision session check --campaign hall-of-helix # prints the account, no secrets +go run ./cmd/provision session check --campaign hall-of-helix # prints the account, no secrets ``` --- @@ -116,15 +153,20 @@ provision session check --campaign hall-of-helix # prints the account, no sec From here nothing is manual. The remaining work is `FT-WP-0002` T03–T06: ```bash -provision plan --spec ../pr-hall-of-helix/presence/telegram.yaml # read the diff -provision apply --spec ../pr-hall-of-helix/presence/telegram.yaml # execute it +go run ./cmd/provision plan --spec ../pr-hall-of-helix/presence/telegram.yaml # read the diff +go run ./cmd/provision apply --spec ../pr-hall-of-helix/presence/telegram.yaml # execute it ``` -`apply` registers the bot through BotFather, sets its name, about text, -description and avatar, creates both channels, adds the bot as administrator with +`apply` registers the bot through BotFather, sets its name, about text and +description, creates the test channel, adds the bot as administrator with `post_messages` only, generates the redaction salt if it is absent, and writes `presence/resolved/hall-of-helix.yaml`. +Two things it will *not* do on this first run, both by design and both reported +in the plan. The **public channel is held** until a rendering has been checked in +the test channel. The **avatar is deferred**: `/setuserpic` needs a photo upload +that is not implemented, so set it by hand in @BotFather, or wait. + **Read the plan before approving it.** That is the human judgement this design keeps — not clicking through BotFather, but deciding whether the diff is what was intended. diff --git a/internal/plan/plan.go b/internal/plan/plan.go index e72aad2..62dc3a3 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -149,7 +149,14 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { sp.Bot.Name, len(sp.Bot.UsernamePreference), strings.Join(sp.Bot.UsernamePreference, ", "))) p.add(Create, "bot.profile", "set name, about text and description") if sp.Bot.Avatar != "" { - p.add(Create, "bot.avatar", sp.Bot.Avatar) + // apply does not set the avatar yet: /setuserpic needs a photo + // upload, which is FT-WP-0002 T04's remaining piece. Say so, rather + // than promising an action that would be silently skipped -- a plan + // nobody can trust line by line is not worth reading. + p.addWhy(Defer, "bot.avatar", sp.Bot.Avatar, + "Not applied yet: setting a bot's picture needs a photo upload, which "+ + "is not implemented. Set it by hand in @BotFather with /setuserpic, "+ + "or leave it until the upload lands.") } return nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index b008097..480f064 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -211,3 +211,23 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) { t.Errorf("public should no longer be deferred:\n%s", p.Render()) } } + +// The plan must not promise an action apply will silently skip. The avatar is +// the live case: /setuserpic needs an upload that is not implemented, so the +// plan defers it with an explanation instead of claiming a create. +func TestAvatarIsDeferredNotPromised(t *testing.T) { + s := sp() + s.Bot.Avatar = "presence/assets/avatar.png" + p, _ := Compute(s, "sha256:x", &state.Resolved{}, fake{}) + + if find(p, Create, "bot.avatar") != nil { + t.Fatal("plan promised an avatar create that apply does not perform") + } + a := find(p, Defer, "bot.avatar") + if a == nil { + t.Fatalf("expected the avatar to be deferred:\n%s", p.Render()) + } + if !strings.Contains(a.Why, "setuserpic") { + t.Errorf("deferral should say how to do it by hand: %q", a.Why) + } +}