diff --git a/CHANGELOG.json b/CHANGELOG.json index a97b3da..95bc6a4 100644 --- a/CHANGELOG.json +++ b/CHANGELOG.json @@ -8,6 +8,57 @@ "plexusone" ], "releases": [ + { + "version": "v0.7.0", + "date": "2026-11-06", + "highlights": [ + { + "description": "A connection shows what it reaches: validations can return details, such as the sites an account can read, kept shortened in `Health.Details`", + "commit": "2352bfa" + } + ], + "added": [ + { + "description": "`provider.Result.Details` and `provider.Detail`, what a validation found the credential reaches", + "commit": "2352bfa" + }, + { + "description": "`Health.Details` and `cf.Detail`: the last successful validation's details, at most 8 lists of 25 values with `More` counting the rest; a failed validation keeps them", + "commit": "2352bfa" + } + ], + "security": [ + { + "description": "Validation details drop directional formatting characters, such as U+202E, as well as control characters, so a provider's text cannot display reordered", + "commit": "24f45a8" + } + ], + "dependencies": [ + { + "description": "`modernc.org/sqlite` 1.61.1", + "commit": "e06e5c5" + }, + { + "description": "`github.com/jackc/pgx/v5` 5.12.0", + "commit": "a4d99e0" + } + ], + "documentation": [ + { + "description": "The connections and providers guides describe validation details" + } + ], + "tests": [ + { + "description": "Validation details: their limits, storage in every store, and kept after a failed check", + "commit": "9eb2d40" + }, + { + "description": "Directional characters removed from details, and a reconnect's details replacing the old ones", + "commit": "feffe81" + } + ] + }, { "version": "v0.6.0", "date": "2026-10-25", diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ad669..ac5cd9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro ## [Unreleased] +## [v0.7.0] - 2026-11-06 + +### Highlights + +- A connection shows what it reaches: validations can return details, such as the sites an account can read, kept shortened in `Health.Details` + +### Security + +- Validation details drop directional formatting characters, such as U+202E, as well as control characters, so a provider's text cannot display reordered ([`24f45a8`](https://github.com/plexusone/connectforge/commit/24f45a8)) + +### Added + +- `provider.Result.Details` and `provider.Detail`, what a validation found the credential reaches ([`2352bfa`](https://github.com/plexusone/connectforge/commit/2352bfa)) +- `Health.Details` and `cf.Detail`: the last successful validation's details, at most 8 lists of 25 values with `More` counting the rest; a failed validation keeps them ([`2352bfa`](https://github.com/plexusone/connectforge/commit/2352bfa)) + +### Dependencies + +- _2 dependency updates_ + +### Documentation + +- The connections and providers guides describe validation details + +### Tests + +- Validation details: their limits, storage in every store, and kept after a failed check ([`9eb2d40`](https://github.com/plexusone/connectforge/commit/9eb2d40)) +- Directional characters removed from details, and a reconnect's details replacing the old ones ([`feffe81`](https://github.com/plexusone/connectforge/commit/feffe81)) + ## [v0.6.0] - 2026-10-25 ### Highlights @@ -180,7 +208,8 @@ and this changelog is generated by [Structured Changelog](https://github.com/gro - MkDocs site with a getting-started page and a user guide covering connections and their lifecycle, using a connection, writing a provider, integrating with a host, storage and security - Release notes for v0.1.0, and PRD, TRD, plan and roadmap under `docs/specs/initiatives/INIT-CONNECTFORGE-001`, with agent guidance in `AGENTS.md` -[unreleased]: https://github.com/plexusone/connectforge/compare/v0.6.0...HEAD +[unreleased]: https://github.com/plexusone/connectforge/compare/v0.7.0...HEAD +[v0.7.0]: https://github.com/plexusone/connectforge/compare/v0.6.0...v0.7.0 [v0.6.0]: https://github.com/plexusone/connectforge/compare/v0.5.0...v0.6.0 [v0.5.0]: https://github.com/plexusone/connectforge/compare/v0.4.0...v0.5.0 [v0.4.0]: https://github.com/plexusone/connectforge/compare/v0.3.0...v0.4.0 diff --git a/docs/guides/connections.md b/docs/guides/connections.md index 9a6b91a..2c8661c 100644 --- a/docs/guides/connections.md +++ b/docs/guides/connections.md @@ -49,11 +49,13 @@ Every store enforces the transition table; a change it forbids returns | `LastError` | A classified message: the provider's refusal with its status and request ID (at most 128 printable characters), or only the failure's class, such as `connectforge: transient failure`, for a network or egress failure. Never a response body, a URL or a resolved address; the full error is in the audit event | | `ConsecutiveFails` | Failed checks in a row; reset by a success | | `CredentialExpiresAt` | When the current credential stops working, as the provider reported at the last successful check; empty when it does not expire or the provider does not say | +| `Details` | What the last successful check found the credential reaches, such as the sites an account can read: up to 8 labelled lists of up to 25 values each, with `More` counting the values left out, and control and directional formatting characters removed. A failed check keeps them | The expiry is refreshed by `Create`, a successful `Test` and `Reconnect`, and is also recorded on the sealed credential. Show it next to the connection so a person replaces the credential before it lapses rather than after a feature -fails. +fails. Show the details too, so a person can see what the connection reaches +without opening the provider, and notice when it reaches less than expected. ## Failures are classified diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 3390300..be7f135 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -112,6 +112,11 @@ When a provider reports an expiry, the service records it on the credential and in the connection's `Health.CredentialExpiresAt`, so an interface can say when the credential must be replaced. +A provider can also return `Details`, what its check found the credential +reaches, such as the properties or projects an account can read. The service +shortens them and keeps them in `Health.Details`. They are shown to people as +they are, so they must never hold a secret. + ## Writing your own Implement `Provider` when a service needs more than a header token: a signed diff --git a/docs/releases/v0.7.0.md b/docs/releases/v0.7.0.md new file mode 100644 index 0000000..b4eb737 --- /dev/null +++ b/docs/releases/v0.7.0.md @@ -0,0 +1,25 @@ +# v0.7.0 + +**Release Date:** 2026-11-06 + +## Highlights + +- **A connection shows what it reaches.** A provider's validation can + return `Details`, such as the sites or projects the credential can read. + The service shortens them (up to 8 labelled lists of up to 25 values, + with `More` counting the rest, control and directional formatting + characters such as U+202E removed, and long values cut) and keeps them in `Health.Details` after each successful check. A + failed check keeps the last ones, so an interface can show what the + connection reached when it last worked. + +The release adds no breaking change. Stores keep health as one value, so +existing records read as before, with no details until their next check. + +## Upgrading + +Nothing is required. Providers that want to show what a credential reaches +set `provider.Result.Details`; they must never put a secret in them. + +## Dependencies + +- `modernc.org/sqlite` 1.61.1 and `github.com/jackc/pgx/v5` 5.12.0. diff --git a/mkdocs.yml b/mkdocs.yml index b38a00a..783703c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ nav: - Plan: specs/initiatives/INIT-CONNECTFORGE-001/PLAN.md - Roadmap: specs/initiatives/INIT-CONNECTFORGE-001/ROADMAP.md - Releases: + - v0.7.0: releases/v0.7.0.md - v0.6.0: releases/v0.6.0.md - v0.5.0: releases/v0.5.0.md - v0.4.0: releases/v0.4.0.md diff --git a/provider/provider.go b/provider/provider.go index e57f0c3..5ec320d 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -37,6 +37,18 @@ type Result struct { // ExpiresAt is when the credential stops working, if the provider // reports it; nil when it does not expire or the provider does not say. ExpiresAt *time.Time + // Details are what the check found that the credential reaches, for + // people to see, such as the sites or projects the account can read. + // They are shown as they are, so they must never hold a secret, and the + // service shortens long lists. + Details []Detail +} + +// Detail is one thing validation found, such as "Properties" and the +// properties the account can read. +type Detail struct { + Label string + Values []string } // Manifest describes a provider so a user interface can render a connection diff --git a/service/details.go b/service/details.go new file mode 100644 index 0000000..99f68e1 --- /dev/null +++ b/service/details.go @@ -0,0 +1,73 @@ +package service + +import ( + "strings" + "unicode" + "unicode/utf8" + + cf "github.com/plexusone/connectforge" + "github.com/plexusone/connectforge/provider" +) + +// Limits on what a validation's details keep, so a provider that lists +// thousands of sites still stores and shows a short summary. +const ( + maxDetails = 8 + maxDetailValues = 25 + maxLabelLen = 64 + maxValueLen = 200 +) + +// details returns a provider's details shortened for storage and display: +// at most maxDetails, each with at most maxDetailValues values, labels and +// values cut to length, and control and directional formatting characters +// removed. A detail with no +// label is dropped. More counts the values left out. +func details(in []provider.Detail) []cf.Detail { + var out []cf.Detail + for _, d := range in { + label := clip(d.Label, maxLabelLen) + if label == "" { + continue + } + if len(out) == maxDetails { + break + } + cd := cf.Detail{Label: label} + for _, v := range d.Values { + v = clip(v, maxValueLen) + if v == "" { + continue + } + if len(cd.Values) == maxDetailValues { + cd.More++ + continue + } + cd.Values = append(cd.Values, v) + } + out = append(out, cd) + } + return out +} + +// clip removes control characters, directional formatting characters and +// surrounding space from s, and cuts it to at most n bytes on a rune +// boundary, marking a cut with an ellipsis. Directional overrides and +// isolates, such as U+202E, would let a provider's text display reordered +// and mislead the person reading it. +func clip(s string, n int) string { + s = strings.TrimSpace(strings.Map(func(r rune) rune { + if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { + return -1 + } + return r + }, s)) + if len(s) <= n { + return s + } + cut := n - len("…") + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] + "…" +} diff --git a/service/details_internal_test.go b/service/details_internal_test.go new file mode 100644 index 0000000..abbdd76 --- /dev/null +++ b/service/details_internal_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "fmt" + "strings" + "testing" + + "github.com/plexusone/connectforge/provider" +) + +func TestDetailsAreShortened(t *testing.T) { + var many []string + for i := range 30 { + many = append(many, fmt.Sprintf("site-%d.example.com", i)) + } + in := []provider.Detail{ + {Label: " Sites\n", Values: many}, + {Label: "", Values: []string{"dropped: no label"}}, + {Label: "Long", Values: []string{strings.Repeat("é", 300), " ", "tab\there"}}, + } + for i := range 10 { + in = append(in, provider.Detail{Label: fmt.Sprintf("extra %d", i)}) + } + got := details(in) + if len(got) != maxDetails { + t.Fatalf("details = %d, want %d", len(got), maxDetails) + } + if got[0].Label != "Sites" || len(got[0].Values) != maxDetailValues || got[0].More != 5 { + t.Errorf("sites = %q, %d values, more %d", got[0].Label, len(got[0].Values), got[0].More) + } + long := got[1] + if long.Label != "Long" || len(long.Values) != 2 { + t.Fatalf("long = %+v", long) + } + if v := long.Values[0]; len(v) > maxValueLen || !strings.HasSuffix(v, "…") || !strings.HasPrefix(v, "é") { + t.Errorf("a long value was not cut on a rune boundary: %d bytes", len(v)) + } + if long.Values[1] != "tabhere" { + t.Errorf("control characters kept: %q", long.Values[1]) + } + // Directional overrides and isolates are removed, so a value cannot + // display reordered. + bidi := details([]provider.Detail{{Label: "Sites\u202e", Values: []string{"evil\u202egnp.exe", "\u2066a\u2069\u200fb\u061c"}}}) + if bidi[0].Label != "Sites" || bidi[0].Values[0] != "evilgnp.exe" || bidi[0].Values[1] != "ab" { + t.Errorf("directional characters kept: %+v", bidi) + } + if details(nil) != nil { + t.Error("no details gave a non-nil slice") + } +} diff --git a/service/service.go b/service/service.go index c16cb72..08a3962 100644 --- a/service/service.go +++ b/service/service.go @@ -122,7 +122,7 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (cf.Connection, er } c.ExternalAccountID, c.ExternalLabel, c.Scopes = res.AccountID, res.AccountLabel, res.Scopes c.State = cf.StateConnected - c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt} + c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt, Details: details(res.Details)} created, err := s.o.Store.CreateConnection(ctx, c) if err != nil { @@ -246,7 +246,7 @@ func (s *Service) Test(ctx context.Context, owner cf.OwnerID, actor cf.Principal } return nil } - c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt} + c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt, Details: details(res.Details)} c.State = cf.StateConnected if res.AccountLabel != "" { c.ExternalLabel = res.AccountLabel @@ -373,7 +373,7 @@ func (s *Service) Reconnect(ctx context.Context, in ReconnectInput) (cf.Connecti out, err := s.update(ctx, in.Owner, in.Actor, in.ID, cf.ActionManage, func(c *cf.Connection) error { c.State = cf.StateConnected - c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt} + c.Health = cf.Health{LastValidatedAt: now, LastValidation: cf.OutcomeOK, CredentialExpiresAt: res.ExpiresAt, Details: details(res.Details)} if res.AccountID != "" { c.ExternalAccountID = res.AccountID } diff --git a/service/service_test.go b/service/service_test.go index 52dc7dc..be73650 100644 --- a/service/service_test.go +++ b/service/service_test.go @@ -75,7 +75,22 @@ func (r *recorder) types() []cf.EventType { // loopback lets the tests reach their local fake API. var loopback = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} +// detailed adds details to a provider's successful validations. +type detailed struct { + provider.Provider + found *[]provider.Detail +} + +func (d detailed) Validate(ctx context.Context, hc *http.Client, m provider.Material) (provider.Result, error) { + res, err := d.Provider.Validate(ctx, hc, m) + if err == nil { + res.Details = *d.found + } + return res, err +} + type env struct { + found []provider.Detail api *fakeapi.API store *memstore.Store broker *broker.Broker @@ -87,10 +102,10 @@ func setup(t *testing.T) *env { t.Helper() api := fakeapi.Start(t) reg := provider.NewRegistry() - if err := reg.Register(api.Provider(t)); err != nil { + e := &env{api: api, store: memstore.New(), audit: &recorder{}} + if err := reg.Register(detailed{Provider: api.Provider(t), found: &e.found}); err != nil { t.Fatal(err) } - e := &env{api: api, store: memstore.New(), audit: &recorder{}} b, err := broker.New(broker.Options{ Store: e.store, Crypto: cryptotest.New(), Providers: reg, Authorizer: authz{}, Auditor: e.audit, Egress: egress.Policy{AllowPrefixes: loopback, Transport: api.Client().Transport.(*http.Transport)}, //nolint:forcetypeassert // httptest's client uses *http.Transport @@ -407,10 +422,14 @@ func (e *env) seen(tok string) bool { func TestReconnectReplacesTheCredential(t *testing.T) { ctx := context.Background() e := setup(t) + e.found = []provider.Detail{{Label: "Zones", Values: []string{"example.com"}}} c, err := e.create(t, fakeapi.Good) if err != nil { t.Fatal(err) } + if len(c.Health.Details) != 1 || c.Health.Details[0].Values[0] != "example.com" { + t.Fatalf("details after create: %+v", c.Health.Details) + } use := cf.Use{Consumer: cf.Consumer{Kind: "application", ID: "dash"}, Purpose: "traffic"} if err := e.svc.Bind(ctx, owner, agent, c.ID, use.Consumer, use.Purpose); err != nil { t.Fatal(err) @@ -423,11 +442,16 @@ func TestReconnectReplacesTheCredential(t *testing.T) { } // A new token for the same account puts it right without a new - // connection, so the binding is untouched. + // connection, so the binding is untouched, and its check's details + // replace the old ones. + e.found = []provider.Detail{{Label: "Zones", Values: []string{"example.net"}}} r, err := e.reconnect(t, admin, c.ID, fakeapi.Rotated) if err != nil { t.Fatalf("reconnect: %v", err) } + if d := r.Health.Details; len(d) != 1 || d[0].Label != "Zones" || len(d[0].Values) != 1 || d[0].Values[0] != "example.net" { + t.Fatalf("details after reconnect: %+v", d) + } if r.ID != c.ID || r.State != cf.StateConnected || r.Health.LastValidation != cf.OutcomeOK || r.Health.ConsecutiveFails != 0 { t.Fatalf("reconnected: %+v", r) } @@ -656,3 +680,32 @@ func TestExpiryIsRecorded(t *testing.T) { t.Fatalf("replaced credential: version %d, expiry %v, %v", cr.Version, cr.ExpiresAt, err) } } + +// TestTestKeepsWhatValidationFound checks that a successful validation's +// details are stored, shortened, and kept when a later validation fails. +func TestTestKeepsWhatValidationFound(t *testing.T) { + ctx := context.Background() + e := setup(t) + e.found = []provider.Detail{{Label: "Zones", Values: []string{"example.com", "example.org"}}} + c, err := e.create(t, fakeapi.Good) + if err != nil { + t.Fatal(err) + } + if d := c.Health.Details; len(d) != 1 || d[0].Label != "Zones" || len(d[0].Values) != 2 { + t.Fatalf("details after create: %+v", d) + } + + e.found = []provider.Detail{{Label: "Zones", Values: []string{"example.net"}}} + if c, err = e.svc.Test(ctx, owner, agent, c.ID); err != nil || len(c.Health.Details) != 1 || c.Health.Details[0].Values[0] != "example.net" { + t.Fatalf("details after a test: %+v, %v", c.Health.Details, err) + } + + e.api.Revoke(fakeapi.Good) + c, err = e.svc.Test(ctx, owner, agent, c.ID) + if err != nil || c.Health.LastValidation != cf.OutcomeFailed { + t.Fatalf("failed test: %+v, %v", c.Health, err) + } + if d := c.Health.Details; len(d) != 1 || d[0].Values[0] != "example.net" { + t.Errorf("a failed test dropped the last details: %+v", d) + } +} diff --git a/store/storetest/storetest.go b/store/storetest/storetest.go index bcfb952..3c8443b 100644 --- a/store/storetest/storetest.go +++ b/store/storetest/storetest.go @@ -253,8 +253,9 @@ func testReturnsCopies(t *testing.T, s cf.Store) { } // testHealthRoundTrip checks that a connection's health, including the -// credential's expiry, is stored and read back, is not shared with callers, -// and is cleared when a replaced credential reports none. +// credential's expiry and the validation's details, is stored and read +// back, is not shared with callers, and is cleared when a replaced +// credential reports none. func testHealthRoundTrip(t *testing.T, s cf.Store) { ctx := context.Background() c := mustCreate(t, s, conn(ownerA, "c1", "cloudflare")) @@ -262,7 +263,8 @@ func testHealthRoundTrip(t *testing.T, s cf.Store) { exp := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC) c.State = cf.StateConnected stored := exp // the store gets its own copy, so exp stays the expected value - c.Health = cf.Health{LastValidatedAt: checked, LastValidation: cf.OutcomeOK, CredentialExpiresAt: &stored} + found := []cf.Detail{{Label: "Zones", Values: []string{"example.com", "example.org"}, More: 3}} + c.Health = cf.Health{LastValidatedAt: checked, LastValidation: cf.OutcomeOK, CredentialExpiresAt: &stored, Details: found} c, err := s.UpdateConnection(ctx, c) if err != nil { t.Fatal(err) @@ -276,6 +278,14 @@ func testHealthRoundTrip(t *testing.T, s cf.Store) { h.CredentialExpiresAt == nil || !h.CredentialExpiresAt.Equal(exp) { t.Fatalf("health: %+v", h) } + if len(h.Details) != 1 || h.Details[0].Label != "Zones" || len(h.Details[0].Values) != 2 || + h.Details[0].Values[1] != "example.org" || h.Details[0].More != 3 { + t.Fatalf("details: %+v", h.Details) + } + got.Health.Details[0].Values[0] = "changed" + if again, err := s.GetConnection(ctx, ownerA, "c1"); err != nil || again.Health.Details[0].Values[0] != "example.com" { + t.Fatalf("details share memory with callers: %+v, %v", again.Health.Details, err) + } *got.Health.CredentialExpiresAt = checked again, err := s.GetConnection(ctx, ownerA, "c1") if err != nil || again.Health.CredentialExpiresAt == nil || !again.Health.CredentialExpiresAt.Equal(exp) { diff --git a/types.go b/types.go index ee15a93..668d6d5 100644 --- a/types.go +++ b/types.go @@ -80,6 +80,19 @@ type Health struct { // the provider reported at the last successful validation; nil when it // does not expire or the provider does not say. CredentialExpiresAt *time.Time `json:"credentialExpiresAt,omitempty"` + // Details are what the last successful validation found the credential + // reaches, such as the sites an account can read, shortened for display. + // A failed validation keeps them. They never hold a secret. + Details []Detail `json:"details,omitempty"` +} + +// Detail is one thing a validation found, such as "Properties" and the +// properties an account can read. +type Detail struct { + Label string `json:"label"` + Values []string `json:"values,omitempty"` + // More is how many values were left out to keep the detail short. + More int `json:"more,omitempty"` } // Connection is an owner's relationship with one account at a provider. It @@ -111,6 +124,14 @@ func (c Connection) Clone() Connection { t := *c.Health.CredentialExpiresAt c.Health.CredentialExpiresAt = &t } + if c.Health.Details != nil { + ds := make([]Detail, len(c.Health.Details)) + for i, d := range c.Health.Details { + d.Values = slices.Clone(d.Values) + ds[i] = d + } + c.Health.Details = ds + } return c }