1import * as Effect from "effect/Effect";
2import * as Predicate from "effect/Predicate";
3import * as Result from "effect/Result";
4import {
5 type AuthError,
6 reconfigureHint,
7 type AuthProviders,
8 type ProviderDetailLine,
9} from "./AuthProvider.ts";
10import type { ProviderConfig } from "./Profile.ts";
11
12/**
13 * What a connected provider looks like right now. Establishing this means
14 * decoding the stored config and asking the provider to describe itself,
15 * either of which can fail — which is the point: a profile listing has to
16 * show a broken connection rather than fail because of one.
17 */
18export interface ProviderConnection {
19 readonly name: string;
20 /** How credentials were supplied (`oauth`, `api-token`, …). */
21 readonly method: string;
22 readonly status:
23 | "connected"
24 | "needs-reauth"
25 | "needs-reconfigure"
26 | "invalid"
27 | "unavailable";
28 readonly details: ReadonlyArray<ProviderDetailLine>;
29 /** Present whenever `status` is anything but `connected`. */
30 readonly diagnostic?: {
31 readonly severity: "warning" | "error";
32 readonly code: string;
33 readonly message: string;
34 };
35}
36
37const broken = (
38 name: string,
39 method: string,
40 severity: "warning" | "error",
41 code: string,
42 message: string,
43): ProviderConnection => ({
44 name,
45 method,
46 status: severity === "warning" ? "needs-reauth" : "invalid",
47 details: [],
48 diagnostic: { severity, code, message },
49});
50
51/**
52 * Resolve one stored provider's live connection status. Never fails: every
53 * way this can go wrong is reported as a `status` + `diagnostic` so a listing
54 * can show the whole profile.
55 */
56export const inspectProvider = Effect.fn("inspectProvider")(function* (
57 profile: string,
58 name: string,
59 config: ProviderConfig,
60 registered: AuthProviders["Service"],
61 updateConfig?: (config: ProviderConfig) => Effect.Effect<void, AuthError>,
62) {
63 const method = config.method ?? "unknown";
64 const provider = registered[name];
65 if (provider === undefined) {
66 return {
67 name,
68 method,
69 status: "unavailable",
70 details: [],
71 diagnostic: {
72 severity: "warning",
73 code: "provider.unregistered",
74 message: `Provider '${name}' is not registered.`,
75 },
76 } satisfies ProviderConnection;
77 }
78
79 // A deliberately empty migrated document records that the provider was
80 // connected in v0 while making no claim that its obsolete credentials are
81 // usable. Keep that distinct from malformed provider-owned values.
82 if (config.method === undefined) {
83 return {
84 name,
85 method,
86 status: "needs-reconfigure",
87 details: [],
88 diagnostic: {
89 severity: "warning",
90 code: "provider.needs-reconfigure",
91 message: `Provider '${name}' needs to be reconfigured. ${reconfigureHint(name, profile)}`,
92 },
93 } satisfies ProviderConnection;
94 }
95
96 const decoded = yield* Effect.result(provider.decodeConfig(profile, config));
97 if (Result.isFailure(decoded)) {
98 return broken(
99 name,
100 method,
101 "error",
102 "provider.invalid-config",
103 decoded.failure.message,
104 );
105 }
106
107 const details = yield* Effect.result(
108 provider.details(profile, decoded.success, updateConfig),
109 );
110 if (Result.isSuccess(details)) {
111 return {
112 name,
113 method,
114 status: "connected",
115 details: details.success.lines,
116 } satisfies ProviderConnection;
117 }
118
119 // A provider that can no longer prove who it is has expired credentials,
120 // which the user can fix by re-authenticating — not a broken config.
121 const needsReauth = Predicate.isTagged("NeedsReauth")(details.failure);
122 return broken(
123 name,
124 method,
125 needsReauth ? "warning" : "error",
126 needsReauth ? "provider.needs-reauth" : "provider.details-failed",
127 details.failure.message,
128 );
129});