Skip to main content

vgi_forge/
model.rs

1//! The data an adapter is handed and hands back.
2//!
3//! Everything here is forge-neutral and serialisable: these are the payloads
4//! of the VTC ↔ bridge jobs (`git-ns/bridge/*`), so a third-party bridge in
5//! another language sees the same shapes. Types that are expected to grow are
6//! `#[non_exhaustive]`; construct them with their constructors or
7//! `Default` and field assignment.
8
9use std::collections::BTreeMap;
10use std::fmt;
11
12use serde::{Deserialize, Serialize};
13
14use crate::bootstrap::MergeMethod;
15use crate::event::ProtectionGap;
16use crate::resource::Resource;
17use crate::rights::ForgeRole;
18
19/// Which forge software an adapter speaks.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22#[non_exhaustive]
23pub enum ForgeKind {
24    /// github.com or GitHub Enterprise Server.
25    GitHub,
26    /// Forgejo (and Gitea, best effort).
27    Forgejo,
28}
29
30impl ForgeKind {
31    /// Stable lowercase name: `github`, `forgejo`.
32    pub fn as_str(self) -> &'static str {
33        match self {
34            ForgeKind::GitHub => "github",
35            ForgeKind::Forgejo => "forgejo",
36        }
37    }
38}
39
40impl fmt::Display for ForgeKind {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(self.as_str())
43    }
44}
45
46/// Whether a namespace is an organisation or a personal account (§3, §8).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "lowercase")]
49#[non_exhaustive]
50pub enum NamespaceKind {
51    /// An organisation: real roles, bot repo creation.
52    Organization,
53    /// A personal account: the reduced capability set of §8.
54    User,
55}
56
57/// A bound namespace, as the adapter needs it (§4.1). The VTC's record has
58/// more (`id`, `boundBy`, `boundAt`); the adapter needs only what locates the
59/// owner on the forge and the credential that acts on it.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62#[non_exhaustive]
63pub struct Namespace {
64    /// `host/owner`, e.g. `github.com/acme`.
65    pub resource: Resource,
66    /// The forge's numeric id for the owner — survives renames.
67    pub owner_id: Option<u64>,
68    /// Organisation or personal account.
69    pub kind: NamespaceKind,
70    /// The automation credential's handle on this namespace (a GitHub App
71    /// installation id). `None` is manual mode: the VTC governs rights and
72    /// the registry, but nothing acts on the forge.
73    pub installation_id: Option<u64>,
74}
75
76impl Namespace {
77    /// A namespace with no owner id and no installation (manual mode).
78    pub fn new(resource: Resource, kind: NamespaceKind) -> Self {
79        Namespace {
80            resource,
81            owner_id: None,
82            kind,
83            installation_id: None,
84        }
85    }
86
87    /// Set the owner's numeric id.
88    pub fn with_owner_id(mut self, id: u64) -> Self {
89        self.owner_id = Some(id);
90        self
91    }
92
93    /// Set the installation id.
94    pub fn with_installation(mut self, id: u64) -> Self {
95        self.installation_id = Some(id);
96        self
97    }
98}
99
100/// How a forge makes a status check required (§5.8 table).
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103#[non_exhaustive]
104pub enum RequiredCheckKind {
105    /// The forge cannot require a check: repos are flagged *unprotected*.
106    #[default]
107    None,
108    /// A repository ruleset with a required status check (GitHub).
109    Ruleset,
110    /// Branch protection `status_check_contexts` (Forgejo).
111    BranchProtection,
112}
113
114/// How a member links their forge account (§4.4).
115#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117#[non_exhaustive]
118pub enum LinkMethod {
119    /// No automated link; the core records a binding some other way.
120    #[default]
121    None,
122    /// OAuth device flow — works from a terminal (GitHub).
123    DeviceFlow,
124    /// OAuth2 authorisation code with PKCE, through a browser (Forgejo).
125    AuthorizationCodePkce,
126}
127
128/// What a forge — and one namespace on it — can do (§5.8).
129///
130/// The core and the UX branch on this, never on [`ForgeKind`]. A personal
131/// GitHub account is not a special case: it is the GitHub adapter returning
132/// a smaller set here.
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135#[non_exhaustive]
136pub struct Capabilities {
137    /// The adapter holds a credential for this namespace and can act on it
138    /// at all. `false` is manual mode: every forge-side step is a human's.
139    pub automation: bool,
140    /// The bridge can create repositories here. Without it, `repo/create`
141    /// reserves the name and returns manual steps.
142    pub bot_can_create_repos: bool,
143    /// The forge roles available on repositories here, lowest first.
144    /// [`crate::collapse_to_ladder`] fits a requested role onto it.
145    pub role_levels: Vec<ForgeRole>,
146    /// How the verify-trust check is made required.
147    pub required_checks: RequiredCheckKind,
148    /// Whether the forge pushes change events. Without them, drift is found
149    /// by a scheduled `inspect` sweep.
150    pub webhooks: bool,
151    /// How members link their forge accounts.
152    pub account_link: LinkMethod,
153    /// Whether the credential can be narrowed to one repository per job.
154    pub per_repo_tokens: bool,
155    /// The check runs from a namespace-level workflow pinned to a revision
156    /// (a GitHub org ruleset's required workflow), so a pull request cannot
157    /// change what checks it. Without it the repository's own workflow is
158    /// guarded by owner review instead (§9).
159    #[serde(default)]
160    pub required_workflow: bool,
161    /// Without a namespace workflow, a repository with a single owner gets
162    /// no review requirement on its workflow (there is nobody else to
163    /// review), so its owner could weaken their own check. The UI shows
164    /// "solo: workflow edits not review-protected" for such repositories;
165    /// [`ProtectionState::check_source_guard`] says which applies to each.
166    #[serde(default)]
167    pub single_owner_repos_unreviewed: bool,
168    /// The bridge itself runs verify-trust against each pull request and
169    /// posts the check under its own forge identity, and the protection
170    /// requires the check *from that identity* (§9, "forged check runs").
171    /// Nothing in the repository decides what runs, and no CI workflow can
172    /// post a check that counts. The bridge becomes a merge dependency, as
173    /// the registry already is.
174    #[serde(default)]
175    pub bridge_posted_check: bool,
176}
177
178/// What keeps a repository's check out of reach of the pull request it
179/// checks (§9), as observed.
180#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", tag = "type")]
182#[non_exhaustive]
183pub enum CheckSourceGuard {
184    /// Not observed (manual mode, or not inspected).
185    #[default]
186    Unknown,
187    /// A namespace-level workflow pinned to a commit. Its shortfalls are in
188    /// [`ProtectionState::other_gaps`].
189    RequiredWorkflow,
190    /// Workflow changes need an owner's approving review.
191    OwnerReview {
192        /// The accounts the managed owner rule names (ids resolved from the
193        /// forge's current logins).
194        reviewers: Vec<ForgeAccount>,
195        /// What is wrong with it; empty when it holds.
196        issues: Vec<String>,
197    },
198    /// No review guard: the repository's own workflow can be changed by a
199    /// pull request its owner merges. Accepted for a single-owner
200    /// repository.
201    Unreviewed,
202    /// The bridge runs the check itself and the protection accepts it only
203    /// from the bridge's own forge identity
204    /// ([`Capabilities::bridge_posted_check`]): nothing in the repository
205    /// is on the check's path.
206    BridgePosted,
207}
208
209/// A person's account on a forge. The numeric id is authoritative; the login
210/// is for display and can be renamed and re-registered (§4.4).
211#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ForgeAccount {
214    /// Numeric account id.
215    pub id: u64,
216    /// Login at the time it was read. Display only.
217    pub login: String,
218}
219
220impl ForgeAccount {
221    /// An account from its id and current login.
222    pub fn new(id: u64, login: impl Into<String>) -> Self {
223        ForgeAccount {
224            id,
225            login: login.into(),
226        }
227    }
228}
229
230/// One person's desired role on one repository.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct RoleAssignment {
234    /// Who.
235    pub account: ForgeAccount,
236    /// The role they should hold.
237    pub role: ForgeRole,
238}
239
240impl RoleAssignment {
241    /// Assign `role` to `account`.
242    pub fn new(account: ForgeAccount, role: ForgeRole) -> Self {
243        RoleAssignment { account, role }
244    }
245}
246
247/// What to do with direct collaborators the desired set does not mention.
248///
249/// Removing one *named* account whatever its role (a `git-ns/bridge/job`
250/// 0.2 `removeAccounts` entry) needs no mode of its own: the caller lists it
251/// in `desired` with [`ForgeRole::None`](crate::ForgeRole::None), which an
252/// adapter converges by taking the account's direct role away — matched by
253/// id, with the login read fresh from the forge — and treats as already
254/// converged when the account holds none.
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257#[non_exhaustive]
258pub enum Unlisted {
259    /// Leave them. Drift mode `report` (the default for roles, §5.6): the
260    /// core reports them and a human adopts or reverts.
261    #[default]
262    Keep,
263    /// Remove them. Drift mode `enforce`.
264    Remove,
265}
266
267/// Repository visibility.
268#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270#[non_exhaustive]
271pub enum Visibility {
272    /// Anyone can read.
273    #[default]
274    Public,
275    /// Only collaborators and org members with access.
276    Private,
277    /// Enterprise members (GitHub Enterprise).
278    Internal,
279}
280
281/// A repository to create (§5.2 `git-ns/repo/create`).
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284#[non_exhaustive]
285pub struct RepoSpec {
286    /// The full resource, `host/owner/name`.
287    pub resource: Resource,
288    /// Visibility.
289    pub visibility: Visibility,
290    /// Optional description.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub description: Option<String>,
293    /// The repository's owners (`git.repo.own` holders) with linked forge
294    /// accounts — who may approve changes to its workflows where the forge
295    /// guards them with owner review (§9).
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub owners: Vec<ForgeAccount>,
298}
299
300impl RepoSpec {
301    /// A public repository with no description.
302    pub fn new(resource: Resource) -> Self {
303        RepoSpec {
304            resource,
305            visibility: Visibility::Public,
306            description: None,
307            owners: Vec::new(),
308        }
309    }
310
311    /// Add an owner.
312    pub fn with_owner(mut self, owner: ForgeAccount) -> Self {
313        self.owners.push(owner);
314        self
315    }
316
317    /// Set the visibility.
318    pub fn with_visibility(mut self, visibility: Visibility) -> Self {
319        self.visibility = visibility;
320        self
321    }
322
323    /// Set the description.
324    pub fn with_description(mut self, description: impl Into<String>) -> Self {
325        self.description = Some(description.into());
326        self
327    }
328}
329
330/// Access an account has to a repository that is not a direct role on it —
331/// what is left after its direct role was taken away (`git-ns/bridge/job`
332/// 0.2 `removeAccounts`), and which the bridge reports rather than changes.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335#[non_exhaustive]
336pub struct IndirectAccess {
337    /// The account's effective role on the repository, as the forge reports
338    /// it.
339    pub role: ForgeRole,
340    /// Where it comes from, as far as the forge says. Empty when it does not
341    /// say.
342    pub via: Vec<AccessSource>,
343}
344
345impl IndirectAccess {
346    /// `role`, coming from `via`.
347    pub fn new(role: ForgeRole, via: Vec<AccessSource>) -> Self {
348        IndirectAccess { role, via }
349    }
350}
351
352impl fmt::Display for IndirectAccess {
353    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
354        write!(f, "`{}` access", self.role)?;
355        if self.via.is_empty() {
356            return f.write_str(" from something other than a direct role");
357        }
358        for (i, v) in self.via.iter().enumerate() {
359            f.write_str(match i {
360                0 => " ",
361                _ if i + 1 == self.via.len() => " and ",
362                _ => ", ",
363            })?;
364            write!(f, "{v}")?;
365        }
366        Ok(())
367    }
368}
369
370/// Where access that is not a direct role comes from.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase", tag = "kind", content = "name")]
373#[non_exhaustive]
374pub enum AccessSource {
375    /// Membership of a team with access to the repository (the team's
376    /// name).
377    Team(String),
378    /// Being an owner of the organisation (its login).
379    OrgOwner(String),
380    /// The permission every member of the organisation has on its
381    /// repositories (the organisation's login).
382    OrgMember(String),
383}
384
385impl fmt::Display for AccessSource {
386    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
387        match self {
388            AccessSource::Team(t) => write!(f, "through team `{t}`"),
389            AccessSource::OrgOwner(o) => write!(f, "as an owner of `{o}`"),
390            AccessSource::OrgMember(o) => write!(f, "as a member of `{o}` (its base permission)"),
391        }
392    }
393}
394
395/// A collaborator as observed on the forge.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "camelCase")]
398#[non_exhaustive]
399pub struct Collaborator {
400    /// Who.
401    pub account: ForgeAccount,
402    /// Their role (the base role, for a forge with custom roles).
403    pub role: ForgeRole,
404    /// An invitation not yet accepted. Counts as present for drift: the
405    /// adapter has done its part.
406    pub pending: bool,
407}
408
409impl Collaborator {
410    /// An accepted collaborator.
411    pub fn new(account: ForgeAccount, role: ForgeRole) -> Self {
412        Collaborator {
413            account,
414            role,
415            pending: false,
416        }
417    }
418
419    /// A pending invitation.
420    pub fn invited(account: ForgeAccount, role: ForgeRole) -> Self {
421        Collaborator {
422            account,
423            role,
424            pending: true,
425        }
426    }
427}
428
429/// The default-branch protection that makes the check mean something, as
430/// observed. Each flag is the *protective* state, so `Default` is "nothing
431/// protected".
432#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434#[non_exhaustive]
435pub struct ProtectionState {
436    /// The managed rule (ruleset, branch protection) exists.
437    pub present: bool,
438    /// It is enforced, not disabled or in evaluate-only mode.
439    pub enforced: bool,
440    /// It covers the default branch.
441    pub covers_default_branch: bool,
442    /// Changes must come through a pull request.
443    pub requires_pull_request: bool,
444    /// Status checks the rule requires (by context name).
445    pub required_checks: Vec<String>,
446    /// Force-pushes are blocked.
447    pub blocks_force_push: bool,
448    /// Branch deletion is blocked.
449    pub blocks_deletion: bool,
450    /// Actors allowed to bypass it. Must be empty (§5.3).
451    pub bypass_actors: Vec<String>,
452    /// Shortfalls in what keeps the check's own workflow out of the change
453    /// under test's reach (a namespace required workflow, owner review),
454    /// which the fields above cannot express. The adapter fills this in.
455    #[serde(default, skip_serializing_if = "Vec::is_empty")]
456    pub other_gaps: Vec<ProtectionGap>,
457    /// Which guard keeps the check out of the pull request's reach.
458    #[serde(default)]
459    pub check_source_guard: CheckSourceGuard,
460    /// Paths a pull request may not change (forge glob syntax), for a forge
461    /// that protects the workflow this way. Empty when not read.
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub protected_paths: Vec<String>,
464    /// The merge methods the repository allows, for an adapter that reads
465    /// them. `None`: not observed.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub merge_methods: Option<Vec<MergeMethod>>,
468    /// Whether the forge's CI is enabled on the repository, for an adapter
469    /// that reads it. `None`: not observed.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub ci_enabled: Option<bool>,
472    /// Approving reviews the rule requires before a pull request merges.
473    #[serde(default, skip_serializing_if = "is_zero")]
474    pub required_approvals: u8,
475}
476
477fn is_zero(n: &u8) -> bool {
478    *n == 0
479}
480
481/// A repository as observed on the forge.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483#[serde(rename_all = "camelCase")]
484#[non_exhaustive]
485pub struct RepoState {
486    /// Where it is now (after any rename or transfer).
487    pub resource: Resource,
488    /// The forge's numeric repo id — rights are keyed on this (§9).
489    pub forge_id: u64,
490    /// Visibility.
491    pub visibility: Visibility,
492    /// Archived (read-only).
493    pub archived: bool,
494    /// The default branch, if the repository has any commits.
495    pub default_branch: Option<String>,
496    /// Direct collaborators and pending invitations.
497    pub collaborators: Vec<Collaborator>,
498    /// The managed default-branch protection.
499    pub protection: ProtectionState,
500}
501
502impl RepoState {
503    /// A state with no collaborators and no protection.
504    pub fn new(resource: Resource, forge_id: u64) -> Self {
505        RepoState {
506            resource,
507            forge_id,
508            visibility: Visibility::Public,
509            archived: false,
510            default_branch: None,
511            collaborators: Vec::new(),
512            protection: ProtectionState::default(),
513        }
514    }
515}
516
517/// What the VTC says a repository should look like on the forge: the
518/// enforced projection of §2.
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "camelCase")]
521#[non_exhaustive]
522pub struct Projection {
523    /// The resource the VTC holds for the repository.
524    pub resource: Resource,
525    /// The forge id the VTC recorded, if it has one. When set, a state with
526    /// the same id at a different resource is a rename, not a new repo.
527    pub forge_id: Option<u64>,
528    /// Desired roles for people with linked accounts. Nobody else should
529    /// hold a direct role.
530    pub roles: Vec<RoleAssignment>,
531    /// The check that must be required on the default branch
532    /// (`Verify commit trust`). `None` for a repo not yet bootstrapped.
533    pub required_check: Option<String>,
534    /// Whether the repository should be archived.
535    pub archived: bool,
536    /// The visibility the VTC recorded, if it tracks one.
537    pub visibility: Option<Visibility>,
538    /// The repository's owners with linked forge accounts. Where the forge
539    /// guards workflows with owner review, two or more owners must all be
540    /// reviewers; one owner is a solo repository with no review guard.
541    #[serde(default, skip_serializing_if = "Vec::is_empty")]
542    pub owners: Vec<ForgeAccount>,
543    /// Approving reviews a pull request must have before it merges (the
544    /// bridge's `required_approvals`). Fewer on the forge is drift; `0` asks
545    /// for none.
546    #[serde(default, skip_serializing_if = "is_zero")]
547    pub required_approvals: u8,
548}
549
550impl Projection {
551    /// A projection with no roles and no required check.
552    pub fn new(resource: Resource) -> Self {
553        Projection {
554            resource,
555            forge_id: None,
556            roles: Vec::new(),
557            required_check: None,
558            archived: false,
559            visibility: None,
560            owners: Vec::new(),
561            required_approvals: 0,
562        }
563    }
564}
565
566/// What happened to one person in [`crate::Forge::apply_roles`].
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568#[serde(rename_all = "camelCase")]
569#[non_exhaustive]
570pub struct RoleChange {
571    /// Who.
572    pub account: ForgeAccount,
573    /// Role before.
574    pub from: ForgeRole,
575    /// Role requested.
576    pub to: ForgeRole,
577    /// What the forge did.
578    pub outcome: RoleOutcome,
579}
580
581impl RoleChange {
582    /// A change of `account` from `from` to `to`, with its outcome.
583    pub fn new(
584        account: ForgeAccount,
585        from: ForgeRole,
586        to: ForgeRole,
587        outcome: RoleOutcome,
588    ) -> Self {
589        RoleChange {
590            account,
591            from,
592            to,
593            outcome,
594        }
595    }
596}
597
598/// Outcome of one role change.
599#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
601#[non_exhaustive]
602pub enum RoleOutcome {
603    /// Applied directly.
604    Applied,
605    /// An invitation was sent (or updated); the person must accept.
606    Invited,
607    /// The forge refused; the message says why.
608    Failed(String),
609}
610
611/// Result of converging a repository's roles.
612#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614#[non_exhaustive]
615pub struct ApplyReport {
616    /// Every change attempted, in order.
617    pub changes: Vec<RoleChange>,
618    /// People already at their desired role.
619    pub unchanged: Vec<ForgeAccount>,
620    /// Direct collaborators not in the desired set that were left alone
621    /// because the call said [`Unlisted::Keep`].
622    pub kept_unlisted: Vec<Collaborator>,
623}
624
625impl ApplyReport {
626    /// Whether every attempted change went through.
627    pub fn is_complete(&self) -> bool {
628        !self
629            .changes
630            .iter()
631            .any(|c| matches!(c.outcome, RoleOutcome::Failed(_)))
632    }
633}
634
635/// Start binding a namespace (§4.1 step 1).
636#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "camelCase")]
638#[non_exhaustive]
639pub struct BindRequest {
640    /// The namespace to bind, `host/owner`.
641    pub namespace: Resource,
642    /// Single-use nonce the caller generated and stored (with its 15-minute
643    /// expiry); the forge will hand it back on the callback.
644    pub state: String,
645}
646
647impl BindRequest {
648    /// Bind `namespace`, with `state` as the nonce.
649    pub fn new(namespace: Resource, state: impl Into<String>) -> Self {
650        BindRequest {
651            namespace,
652            state: state.into(),
653        }
654    }
655}
656
657/// Where to send the admin next.
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(rename_all = "camelCase", tag = "type")]
660#[non_exhaustive]
661pub enum BindStep {
662    /// Open this URL in the admin's browser (App install, OAuth consent).
663    Redirect {
664        /// The URL.
665        url: String,
666    },
667}
668
669/// The forge's redirect back to the bridge after a bind.
670#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671#[serde(rename_all = "camelCase")]
672#[non_exhaustive]
673pub struct BindCallback {
674    /// The callback's query parameters, as received.
675    pub params: BTreeMap<String, String>,
676    /// The nonce the caller issued for this bind, looked up from its store.
677    /// The adapter compares it in constant time; the caller consumes it
678    /// whatever the outcome, so it is single-use.
679    pub expected_state: String,
680    /// The namespace the bind was started for. An install on any other owner
681    /// is refused.
682    pub expected_namespace: Resource,
683}
684
685impl BindCallback {
686    /// A callback for `expected_namespace`, with the issued nonce.
687    pub fn new(
688        params: BTreeMap<String, String>,
689        expected_state: impl Into<String>,
690        expected_namespace: Resource,
691    ) -> Self {
692        BindCallback {
693            params,
694            expected_state: expected_state.into(),
695            expected_namespace,
696        }
697    }
698}
699
700/// A completed bind.
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703#[non_exhaustive]
704pub struct NamespaceBinding {
705    /// The namespace, with owner id, kind and installation filled in.
706    pub namespace: Namespace,
707    /// Permissions the adapter needs that the installation does not grant
708    /// (an owner who declined an upgrade). Empty when fully capable.
709    pub missing_permissions: Vec<String>,
710    /// What the namespace can do, as found while binding (a probe of the
711    /// forge's plan, say). Persist it: the adapter's copy is in memory.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub capabilities: Option<Capabilities>,
714}
715
716impl NamespaceBinding {
717    /// A binding, with the permissions the installation lacks.
718    pub fn new(namespace: Namespace, missing_permissions: Vec<String>) -> Self {
719        NamespaceBinding {
720            namespace,
721            missing_permissions,
722            capabilities: None,
723        }
724    }
725
726    /// Attach the capabilities found while binding.
727    pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
728        self.capabilities = Some(capabilities);
729        self
730    }
731}
732
733/// Where a member goes to link their account.
734///
735/// `Debug` is hand-written: `device_code` redeems the member's authorisation
736/// once they approve, so it must not reach a log.
737#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
738#[serde(rename_all = "camelCase", tag = "type")]
739#[non_exhaustive]
740pub enum LinkStep {
741    /// OAuth device flow: show `user_code` and `verification_uri`; the
742    /// bridge polls with `device_code`.
743    DeviceCode {
744        /// Opaque handle the bridge polls with. Keep it server-side: it is
745        /// what redeems the member's authorisation.
746        device_code: String,
747        /// Short code the member types in.
748        user_code: String,
749        /// Where they type it.
750        verification_uri: String,
751        /// Seconds until the codes expire.
752        expires_in: u64,
753        /// Minimum seconds between polls.
754        interval: u64,
755    },
756    /// Open this URL (authorisation-code flows).
757    Redirect {
758        /// The URL.
759        url: String,
760    },
761}
762
763impl fmt::Debug for LinkStep {
764    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765        match self {
766            LinkStep::DeviceCode {
767                user_code,
768                verification_uri,
769                expires_in,
770                interval,
771                ..
772            } => f
773                .debug_struct("DeviceCode")
774                .field("device_code", &"<redacted>")
775                .field("user_code", user_code)
776                .field("verification_uri", verification_uri)
777                .field("expires_in", expires_in)
778                .field("interval", interval)
779                .finish(),
780            LinkStep::Redirect { url } => f.debug_struct("Redirect").field("url", url).finish(),
781        }
782    }
783}
784
785/// Completion input for a link. `Debug` redacts the device code, as for
786/// [`LinkStep`].
787#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
788#[serde(rename_all = "camelCase", tag = "type")]
789#[non_exhaustive]
790pub enum LinkCallback {
791    /// Poll a device flow to completion.
792    DeviceCode {
793        /// From [`LinkStep::DeviceCode`].
794        device_code: String,
795        /// From [`LinkStep::DeviceCode`].
796        interval: u64,
797        /// From [`LinkStep::DeviceCode`]; polling stops at this deadline.
798        expires_in: u64,
799    },
800    /// An authorisation-code redirect. Build it with
801    /// [`LinkCallback::redirect`].
802    #[non_exhaustive]
803    Redirect {
804        /// Query parameters received.
805        params: BTreeMap<String, String>,
806        /// The member (DID) the caller started this link for, from its own
807        /// session — never from the redirect. An adapter whose `state` is
808        /// bound to the member checks it, so a link started by one person
809        /// cannot be completed into another's session (login CSRF).
810        #[serde(default, skip_serializing_if = "Option::is_none")]
811        member: Option<String>,
812    },
813}
814
815impl fmt::Debug for LinkCallback {
816    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
817        match self {
818            LinkCallback::DeviceCode {
819                interval,
820                expires_in,
821                ..
822            } => f
823                .debug_struct("DeviceCode")
824                .field("device_code", &"<redacted>")
825                .field("interval", interval)
826                .field("expires_in", expires_in)
827                .finish(),
828            LinkCallback::Redirect { params, member } => f
829                .debug_struct("Redirect")
830                .field("params", &params.keys().collect::<Vec<_>>())
831                .field("member", member)
832                .finish(),
833        }
834    }
835}
836
837impl LinkCallback {
838    /// An authorisation-code redirect for `member`, the DID the caller
839    /// passed to [`crate::Forge::begin_account_link`].
840    pub fn redirect(params: BTreeMap<String, String>, member: impl Into<String>) -> LinkCallback {
841        LinkCallback::Redirect {
842            params,
843            member: Some(member.into()),
844        }
845    }
846
847    /// The callback that polls the device flow `step` started. `None` for a
848    /// step that is not a device flow.
849    pub fn from_device_step(step: &LinkStep) -> Option<LinkCallback> {
850        match step {
851            LinkStep::DeviceCode {
852                device_code,
853                interval,
854                expires_in,
855                ..
856            } => Some(LinkCallback::DeviceCode {
857                device_code: device_code.clone(),
858                interval: *interval,
859                expires_in: *expires_in,
860            }),
861            LinkStep::Redirect { .. } => None,
862        }
863    }
864}