| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "net/url" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | |
| 9 | "amadan.net/oficina/suite" |
| 10 | |
| 11 | "amadan.net/oficina/memoria/internal/memoria/sibling" |
| 12 | ) |
| 13 | |
| 14 | // SiblingConfig is the roster of apps of record memoria may ask about |
| 15 | // passages, read from platform config. |
| 16 | // |
| 17 | // **The token is a separate variable from the address**, per app. Two |
| 18 | // reasons, and neither is style. A roster is the sort of thing that gets |
| 19 | // logged, printed by an operator, or pasted into a ticket while somebody |
| 20 | // works out why a page is short; if the token travels in the same string |
| 21 | // it goes with it. And rotation (§6.2's three phases) changes one |
| 22 | // variable rather than rewriting a list, which is what makes a rotation |
| 23 | // interruptible and resumable rather than an edit that is half-applied. |
| 24 | type SiblingConfig struct { |
| 25 | // Team is the label the transport binds to, so a call cannot leave |
| 26 | // the team's own hosts (§6.4). Required as soon as one sibling is |
| 27 | // configured, and meaningless without one. |
| 28 | Team string |
| 29 | Apps []sibling.Sibling |
| 30 | } |
| 31 | |
| 32 | // LoadSiblings reads the roster. |
| 33 | // |
| 34 | // MEMORIA_TEAM=acme |
| 35 | // MEMORIA_SIBLINGS=Docs=https://acme.docs.oficina.works,Sheets=https://acme.sheets.oficina.works |
| 36 | // MEMORIA_SIBLING_TOKEN_DOCS=... |
| 37 | // MEMORIA_SIBLING_TOKEN_SHEETS=... |
| 38 | // |
| 39 | // **The host is `<team>.<app>`, team first** (§6.3). This comment had the |
| 40 | // labels the other way round, and so did every fixture — which meant the |
| 41 | // roster accepted an address the transport refuses on every single call. |
| 42 | // See the host check below for what that costs. |
| 43 | // |
| 44 | // **No siblings is a valid configuration and not a warning.** Slice 0 |
| 45 | // holds only memoria-owned content (§4.2), which has no app of record to |
| 46 | // ask, so an empty roster is the ordinary state of an instance that has |
| 47 | // not adopted a source yet. |
| 48 | // |
| 49 | // **A half-configured one is not.** Every refusal below is a case where |
| 50 | // the alternative is silence: a sibling with no token, or no team to bind |
| 51 | // the transport to, cannot be asked — and a sibling that cannot be asked |
| 52 | // withholds every passage it owns. The reader sees a page that is |
| 53 | // permanently short with a notice blaming an app that is, in fact, fine. |
| 54 | // So it is refused at boot, where it is a startup error somebody reads, |
| 55 | // rather than at query time, where it is a mystery. |
| 56 | func LoadSiblings(getenv func(string) string) (SiblingConfig, error) { |
| 57 | var c SiblingConfig |
| 58 | c.Team = strings.TrimSpace(getenv("MEMORIA_TEAM")) |
| 59 | |
| 60 | raw := strings.TrimSpace(getenv("MEMORIA_SIBLINGS")) |
| 61 | if raw == "" { |
| 62 | // An instance with no siblings needs no team. Saying so here |
| 63 | // rather than requiring MEMORIA_TEAM unconditionally keeps slice |
| 64 | // 0's configuration exactly as small as slice 0 needs. |
| 65 | return c, nil |
| 66 | } |
| 67 | |
| 68 | // Checked before the loop, not after it. The per-sibling host check |
| 69 | // below needs a team to compare against, so without this an instance |
| 70 | // missing MEMORIA_TEAM fails with the module's "no team configured" |
| 71 | // — accurate, and not the message that tells an operator which |
| 72 | // variable to set. |
| 73 | if c.Team == "" { |
| 74 | return c, fmt.Errorf("%w: MEMORIA_TEAM, which binds the sibling transport to one team's hosts (§6.4). Without it there is nothing for the host guard to compare against", ErrMissing) |
| 75 | } |
| 76 | |
| 77 | seen := map[string]bool{} |
| 78 | for i, entry := range strings.Split(raw, ",") { |
| 79 | entry = strings.TrimSpace(entry) |
| 80 | if entry == "" { |
| 81 | continue |
| 82 | } |
| 83 | name, base, ok := strings.Cut(entry, "=") |
| 84 | name, base = strings.TrimSpace(name), strings.TrimSpace(base) |
| 85 | if !ok || name == "" || base == "" { |
| 86 | // **The entry is counted, not echoed.** main logs this error, |
| 87 | // and MEMORIA_SIBLINGS sits beside the token variables: one |
| 88 | // transposed assignment and a malformed "entry" is a bearer |
| 89 | // token in `carlos logs`, which is storage we cannot delete. |
| 90 | // The position is enough to find it in the variable. |
| 91 | return c, fmt.Errorf("%w: MEMORIA_SIBLINGS entry %d is not name=url", ErrMissing, i+1) |
| 92 | } |
| 93 | // **Keyed on the token variable, not on the name**, because that |
| 94 | // is the collision that matters: "Docs" and "docs" are different |
| 95 | // map keys and different Document.Source values, but they read |
| 96 | // the same MEMORIA_SIBLING_TOKEN_DOCS — so one app silently |
| 97 | // receives the other's credential. Keying on the name alone |
| 98 | // missed it, and a review found it. |
| 99 | if seen[tokenVar(name)] { |
| 100 | return c, fmt.Errorf("memoria: MEMORIA_SIBLINGS lists two apps that resolve to %s; the name is the key a document's source resolves through, and the token variable is derived from it", tokenVar(name)) |
| 101 | } |
| 102 | seen[tokenVar(name)] = true |
| 103 | |
| 104 | // https only, and said here rather than left to the transport. |
| 105 | // A consumer token on a cleartext hop is a credential handed to |
| 106 | // anyone on the path, and the roster is where the address is |
| 107 | // chosen. |
| 108 | if !strings.HasPrefix(base, "https://") { |
| 109 | return c, fmt.Errorf("memoria: sibling %q is not https", name) |
| 110 | } |
| 111 | |
| 112 | // **The address must be one the transport will actually dial**, |
| 113 | // checked here with the module's own predicate rather than with a |
| 114 | // second implementation of it. |
| 115 | // |
| 116 | // This was missing, and the cost was the exact failure this |
| 117 | // function exists to prevent. `CheckSiblingHost` requires |
| 118 | // `<team>.<app>.oficina.works` — **team first** — and this file's |
| 119 | // own documented example had the labels reversed. An instance |
| 120 | // configured from it boots, logs "siblings configured", and then |
| 121 | // has every S1, S2 and S3 refused before the transport dials. |
| 122 | // What a reader sees is "Docs did not answer; N results withheld" |
| 123 | // on every query forever, naming an app that is working |
| 124 | // perfectly, and there is nothing in the logs but "sibling poll |
| 125 | // failed". A boot failure is the whole point of the paragraph |
| 126 | // above; without this check it was a boot failure that did not |
| 127 | // happen. |
| 128 | u, err := url.Parse(base) |
| 129 | if err != nil { |
| 130 | return c, fmt.Errorf("memoria: sibling %q has an unparseable address", name) |
| 131 | } |
| 132 | if err := suite.CheckSiblingHost(c.Team, u.Hostname()); err != nil { |
| 133 | return c, fmt.Errorf("memoria: sibling %q: %w", name, err) |
| 134 | } |
| 135 | |
| 136 | token := strings.TrimSpace(getenv(tokenVar(name))) |
| 137 | if token == "" { |
| 138 | return c, fmt.Errorf("%w: %s for sibling %q. A sibling with no token cannot be asked, and one that cannot be asked withholds every passage it owns — silently short pages rather than a boot failure", ErrMissing, tokenVar(name), name) |
| 139 | } |
| 140 | c.Apps = append(c.Apps, sibling.Sibling{Name: name, BaseURL: strings.TrimRight(base, "/"), Token: token}) |
| 141 | } |
| 142 | |
| 143 | sort.Slice(c.Apps, func(i, j int) bool { return c.Apps[i].Name < c.Apps[j].Name }) |
| 144 | return c, nil |
| 145 | } |
| 146 | |
| 147 | // tokenVar is the per-app token variable. Upper-cased with non-alphanumerics |
| 148 | // folded to underscores, so a display name like "Team Docs" has one |
| 149 | // spelling here and cannot be configured two ways. |
| 150 | func tokenVar(name string) string { |
| 151 | var b strings.Builder |
| 152 | b.WriteString("MEMORIA_SIBLING_TOKEN_") |
| 153 | for _, r := range strings.ToUpper(name) { |
| 154 | switch { |
| 155 | case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': |
| 156 | b.WriteRune(r) |
| 157 | default: |
| 158 | b.WriteByte('_') |
| 159 | } |
| 160 | } |
| 161 | return b.String() |
| 162 | } |
| 163 | |
| 164 | // Names lists the configured apps, for a log line that must never carry a |
| 165 | // token. The tokens are in the same structs; this is the accessor that |
| 166 | // exists so nobody reaches for %+v. |
| 167 | func (c SiblingConfig) Names() []string { |
| 168 | out := make([]string, 0, len(c.Apps)) |
| 169 | for _, s := range c.Apps { |
| 170 | out = append(out, s.Name) |
| 171 | } |
| 172 | return out |
| 173 | } |
| 174 | |