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}
473
474/// A repository as observed on the forge.
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477#[non_exhaustive]
478pub struct RepoState {
479 /// Where it is now (after any rename or transfer).
480 pub resource: Resource,
481 /// The forge's numeric repo id — rights are keyed on this (§9).
482 pub forge_id: u64,
483 /// Visibility.
484 pub visibility: Visibility,
485 /// Archived (read-only).
486 pub archived: bool,
487 /// The default branch, if the repository has any commits.
488 pub default_branch: Option<String>,
489 /// Direct collaborators and pending invitations.
490 pub collaborators: Vec<Collaborator>,
491 /// The managed default-branch protection.
492 pub protection: ProtectionState,
493}
494
495impl RepoState {
496 /// A state with no collaborators and no protection.
497 pub fn new(resource: Resource, forge_id: u64) -> Self {
498 RepoState {
499 resource,
500 forge_id,
501 visibility: Visibility::Public,
502 archived: false,
503 default_branch: None,
504 collaborators: Vec::new(),
505 protection: ProtectionState::default(),
506 }
507 }
508}
509
510/// What the VTC says a repository should look like on the forge: the
511/// enforced projection of §2.
512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514#[non_exhaustive]
515pub struct Projection {
516 /// The resource the VTC holds for the repository.
517 pub resource: Resource,
518 /// The forge id the VTC recorded, if it has one. When set, a state with
519 /// the same id at a different resource is a rename, not a new repo.
520 pub forge_id: Option<u64>,
521 /// Desired roles for people with linked accounts. Nobody else should
522 /// hold a direct role.
523 pub roles: Vec<RoleAssignment>,
524 /// The check that must be required on the default branch
525 /// (`Verify commit trust`). `None` for a repo not yet bootstrapped.
526 pub required_check: Option<String>,
527 /// Whether the repository should be archived.
528 pub archived: bool,
529 /// The visibility the VTC recorded, if it tracks one.
530 pub visibility: Option<Visibility>,
531 /// The repository's owners with linked forge accounts. Where the forge
532 /// guards workflows with owner review, two or more owners must all be
533 /// reviewers; one owner is a solo repository with no review guard.
534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub owners: Vec<ForgeAccount>,
536}
537
538impl Projection {
539 /// A projection with no roles and no required check.
540 pub fn new(resource: Resource) -> Self {
541 Projection {
542 resource,
543 forge_id: None,
544 roles: Vec::new(),
545 required_check: None,
546 archived: false,
547 visibility: None,
548 owners: Vec::new(),
549 }
550 }
551}
552
553/// What happened to one person in [`crate::Forge::apply_roles`].
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555#[serde(rename_all = "camelCase")]
556#[non_exhaustive]
557pub struct RoleChange {
558 /// Who.
559 pub account: ForgeAccount,
560 /// Role before.
561 pub from: ForgeRole,
562 /// Role requested.
563 pub to: ForgeRole,
564 /// What the forge did.
565 pub outcome: RoleOutcome,
566}
567
568impl RoleChange {
569 /// A change of `account` from `from` to `to`, with its outcome.
570 pub fn new(
571 account: ForgeAccount,
572 from: ForgeRole,
573 to: ForgeRole,
574 outcome: RoleOutcome,
575 ) -> Self {
576 RoleChange {
577 account,
578 from,
579 to,
580 outcome,
581 }
582 }
583}
584
585/// Outcome of one role change.
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
588#[non_exhaustive]
589pub enum RoleOutcome {
590 /// Applied directly.
591 Applied,
592 /// An invitation was sent (or updated); the person must accept.
593 Invited,
594 /// The forge refused; the message says why.
595 Failed(String),
596}
597
598/// Result of converging a repository's roles.
599#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase")]
601#[non_exhaustive]
602pub struct ApplyReport {
603 /// Every change attempted, in order.
604 pub changes: Vec<RoleChange>,
605 /// People already at their desired role.
606 pub unchanged: Vec<ForgeAccount>,
607 /// Direct collaborators not in the desired set that were left alone
608 /// because the call said [`Unlisted::Keep`].
609 pub kept_unlisted: Vec<Collaborator>,
610}
611
612impl ApplyReport {
613 /// Whether every attempted change went through.
614 pub fn is_complete(&self) -> bool {
615 !self
616 .changes
617 .iter()
618 .any(|c| matches!(c.outcome, RoleOutcome::Failed(_)))
619 }
620}
621
622/// Start binding a namespace (§4.1 step 1).
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "camelCase")]
625#[non_exhaustive]
626pub struct BindRequest {
627 /// The namespace to bind, `host/owner`.
628 pub namespace: Resource,
629 /// Single-use nonce the caller generated and stored (with its 15-minute
630 /// expiry); the forge will hand it back on the callback.
631 pub state: String,
632}
633
634impl BindRequest {
635 /// Bind `namespace`, with `state` as the nonce.
636 pub fn new(namespace: Resource, state: impl Into<String>) -> Self {
637 BindRequest {
638 namespace,
639 state: state.into(),
640 }
641 }
642}
643
644/// Where to send the admin next.
645#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646#[serde(rename_all = "camelCase", tag = "type")]
647#[non_exhaustive]
648pub enum BindStep {
649 /// Open this URL in the admin's browser (App install, OAuth consent).
650 Redirect {
651 /// The URL.
652 url: String,
653 },
654}
655
656/// The forge's redirect back to the bridge after a bind.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658#[serde(rename_all = "camelCase")]
659#[non_exhaustive]
660pub struct BindCallback {
661 /// The callback's query parameters, as received.
662 pub params: BTreeMap<String, String>,
663 /// The nonce the caller issued for this bind, looked up from its store.
664 /// The adapter compares it in constant time; the caller consumes it
665 /// whatever the outcome, so it is single-use.
666 pub expected_state: String,
667 /// The namespace the bind was started for. An install on any other owner
668 /// is refused.
669 pub expected_namespace: Resource,
670}
671
672impl BindCallback {
673 /// A callback for `expected_namespace`, with the issued nonce.
674 pub fn new(
675 params: BTreeMap<String, String>,
676 expected_state: impl Into<String>,
677 expected_namespace: Resource,
678 ) -> Self {
679 BindCallback {
680 params,
681 expected_state: expected_state.into(),
682 expected_namespace,
683 }
684 }
685}
686
687/// A completed bind.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(rename_all = "camelCase")]
690#[non_exhaustive]
691pub struct NamespaceBinding {
692 /// The namespace, with owner id, kind and installation filled in.
693 pub namespace: Namespace,
694 /// Permissions the adapter needs that the installation does not grant
695 /// (an owner who declined an upgrade). Empty when fully capable.
696 pub missing_permissions: Vec<String>,
697 /// What the namespace can do, as found while binding (a probe of the
698 /// forge's plan, say). Persist it: the adapter's copy is in memory.
699 #[serde(default, skip_serializing_if = "Option::is_none")]
700 pub capabilities: Option<Capabilities>,
701}
702
703impl NamespaceBinding {
704 /// A binding, with the permissions the installation lacks.
705 pub fn new(namespace: Namespace, missing_permissions: Vec<String>) -> Self {
706 NamespaceBinding {
707 namespace,
708 missing_permissions,
709 capabilities: None,
710 }
711 }
712
713 /// Attach the capabilities found while binding.
714 pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
715 self.capabilities = Some(capabilities);
716 self
717 }
718}
719
720/// Where a member goes to link their account.
721///
722/// `Debug` is hand-written: `device_code` redeems the member's authorisation
723/// once they approve, so it must not reach a log.
724#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
725#[serde(rename_all = "camelCase", tag = "type")]
726#[non_exhaustive]
727pub enum LinkStep {
728 /// OAuth device flow: show `user_code` and `verification_uri`; the
729 /// bridge polls with `device_code`.
730 DeviceCode {
731 /// Opaque handle the bridge polls with. Keep it server-side: it is
732 /// what redeems the member's authorisation.
733 device_code: String,
734 /// Short code the member types in.
735 user_code: String,
736 /// Where they type it.
737 verification_uri: String,
738 /// Seconds until the codes expire.
739 expires_in: u64,
740 /// Minimum seconds between polls.
741 interval: u64,
742 },
743 /// Open this URL (authorisation-code flows).
744 Redirect {
745 /// The URL.
746 url: String,
747 },
748}
749
750impl fmt::Debug for LinkStep {
751 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
752 match self {
753 LinkStep::DeviceCode {
754 user_code,
755 verification_uri,
756 expires_in,
757 interval,
758 ..
759 } => f
760 .debug_struct("DeviceCode")
761 .field("device_code", &"<redacted>")
762 .field("user_code", user_code)
763 .field("verification_uri", verification_uri)
764 .field("expires_in", expires_in)
765 .field("interval", interval)
766 .finish(),
767 LinkStep::Redirect { url } => f.debug_struct("Redirect").field("url", url).finish(),
768 }
769 }
770}
771
772/// Completion input for a link. `Debug` redacts the device code, as for
773/// [`LinkStep`].
774#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
775#[serde(rename_all = "camelCase", tag = "type")]
776#[non_exhaustive]
777pub enum LinkCallback {
778 /// Poll a device flow to completion.
779 DeviceCode {
780 /// From [`LinkStep::DeviceCode`].
781 device_code: String,
782 /// From [`LinkStep::DeviceCode`].
783 interval: u64,
784 /// From [`LinkStep::DeviceCode`]; polling stops at this deadline.
785 expires_in: u64,
786 },
787 /// An authorisation-code redirect. Build it with
788 /// [`LinkCallback::redirect`].
789 #[non_exhaustive]
790 Redirect {
791 /// Query parameters received.
792 params: BTreeMap<String, String>,
793 /// The member (DID) the caller started this link for, from its own
794 /// session — never from the redirect. An adapter whose `state` is
795 /// bound to the member checks it, so a link started by one person
796 /// cannot be completed into another's session (login CSRF).
797 #[serde(default, skip_serializing_if = "Option::is_none")]
798 member: Option<String>,
799 },
800}
801
802impl fmt::Debug for LinkCallback {
803 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
804 match self {
805 LinkCallback::DeviceCode {
806 interval,
807 expires_in,
808 ..
809 } => f
810 .debug_struct("DeviceCode")
811 .field("device_code", &"<redacted>")
812 .field("interval", interval)
813 .field("expires_in", expires_in)
814 .finish(),
815 LinkCallback::Redirect { params, member } => f
816 .debug_struct("Redirect")
817 .field("params", ¶ms.keys().collect::<Vec<_>>())
818 .field("member", member)
819 .finish(),
820 }
821 }
822}
823
824impl LinkCallback {
825 /// An authorisation-code redirect for `member`, the DID the caller
826 /// passed to [`crate::Forge::begin_account_link`].
827 pub fn redirect(params: BTreeMap<String, String>, member: impl Into<String>) -> LinkCallback {
828 LinkCallback::Redirect {
829 params,
830 member: Some(member.into()),
831 }
832 }
833
834 /// The callback that polls the device flow `step` started. `None` for a
835 /// step that is not a device flow.
836 pub fn from_device_step(step: &LinkStep) -> Option<LinkCallback> {
837 match step {
838 LinkStep::DeviceCode {
839 device_code,
840 interval,
841 expires_in,
842 ..
843 } => Some(LinkCallback::DeviceCode {
844 device_code: device_code.clone(),
845 interval: *interval,
846 expires_in: *expires_in,
847 }),
848 LinkStep::Redirect { .. } => None,
849 }
850 }
851}