@startsimpli/integrations 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,20 +44,105 @@ import {
44
44
  import type {
45
45
  Connection,
46
46
  ConnectionChoice,
47
+ ConnectionChoicePage,
48
+ ConnectionInvokeResult,
47
49
  ConnectionPermissions,
48
50
  ConnectionTarget,
49
51
  ConnectorAction,
50
52
  ConnectorCatalogEntry,
51
53
  ConnectorFieldSpec,
52
54
  ConnectionsApi,
55
+ MemberConnectionsApi,
53
56
  } from './types';
54
57
 
55
- export interface ConnectionsPageProps {
56
- api: ConnectionsApi;
57
- foundrySlug: string;
58
- classNames?: Partial<Record<
59
- 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
60
- >>;
58
+ type ClassNames = Partial<Record<
59
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error', string
60
+ >>;
61
+
62
+ /**
63
+ * TWO SURFACES, ONE COMPOSER (bd startsim-768w.22.25.6).
64
+ *
65
+ * A discriminated union rather than an optional slug, because the two modes do
66
+ * not merely differ in their arguments — `personal` has no slug ANYWHERE, which
67
+ * is the member surface's whole security property (see `MemberConnectionsApi`).
68
+ * Making `foundrySlug` optional would let a personal mount pass one and read as
69
+ * though it meant something.
70
+ */
71
+ export type ConnectionsPageProps =
72
+ | {
73
+ mode?: 'foundry';
74
+ api: ConnectionsApi;
75
+ foundrySlug: string;
76
+ classNames?: ClassNames;
77
+ }
78
+ | {
79
+ mode: 'personal';
80
+ api: MemberConnectionsApi;
81
+ /** There is no slug on this surface. The token's audience names the foundry. */
82
+ foundrySlug?: never;
83
+ classNames?: ClassNames;
84
+ };
85
+
86
+ /**
87
+ * The api as the components below use it: SLUGLESS, because the slug is a
88
+ * property of the mount and not of any control on the page.
89
+ *
90
+ * The foundry-level verbs are optional, and that is what gates the UI. A control
91
+ * renders when the verb behind it exists, so personal mode cannot render a
92
+ * button whose handler would be `undefined` — the type checker, not a
93
+ * `mode === 'personal'` sprinkled through nine call sites, is what keeps the
94
+ * two surfaces honest.
95
+ */
96
+ interface BoundApi {
97
+ connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
98
+ connections(): Promise<{ results: Connection[]; permissions: ConnectionPermissions }>;
99
+ connect(input: {
100
+ connector_key: string;
101
+ name?: string;
102
+ credentials?: Record<string, unknown>;
103
+ config?: Record<string, unknown>;
104
+ capabilities_enabled?: string[];
105
+ }): Promise<Connection>;
106
+ checkConnection(id: string): Promise<{ ok: boolean; account_label: string; detail: string }>;
107
+ rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
108
+ disconnect(id: string): Promise<void>;
109
+ updateConnection?(id: string, input: {
110
+ name?: string; capabilities_enabled?: string[]; config?: Record<string, unknown>;
111
+ }): Promise<Connection>;
112
+ connectionChoices?(id: string, params?: { kind?: string; cursor?: string }):
113
+ Promise<ConnectionChoicePage>;
114
+ wireTarget?(id: string, input: {
115
+ choice_id: string; name?: string; options?: Record<string, unknown>;
116
+ }): Promise<ConnectionTarget>;
117
+ confirmTarget?(id: string, destinationId: string): Promise<ConnectionTarget>;
118
+ connectionActions?(id: string): Promise<{ results: ConnectorAction[] }>;
119
+ invokeConnectionAction?(id: string, action: string, input: {
120
+ destination_id: string; record_type?: string; records?: unknown[];
121
+ }): Promise<ConnectionInvokeResult>;
122
+ }
123
+
124
+ function bindApi(props: ConnectionsPageProps): BoundApi {
125
+ if (props.mode === 'personal') {
126
+ // Already slugless. Passed through rather than wrapped, so nothing here can
127
+ // grow a slug argument by accident.
128
+ return props.api;
129
+ }
130
+ const { api, foundrySlug: slug } = props;
131
+ return {
132
+ connectorCatalog: () => api.connectorCatalog(slug),
133
+ connections: () => api.connections(slug),
134
+ connect: (input) => api.connect(slug, input),
135
+ checkConnection: (id) => api.checkConnection(slug, id),
136
+ rotateCredential: (id, credentials) => api.rotateCredential(slug, id, credentials),
137
+ disconnect: (id) => api.disconnect(slug, id),
138
+ updateConnection: (id, input) => api.updateConnection(slug, id, input),
139
+ connectionChoices: (id, params) => api.connectionChoices(slug, id, params),
140
+ wireTarget: (id, input) => api.wireTarget(slug, id, input),
141
+ confirmTarget: (id, destinationId) => api.confirmTarget(slug, id, destinationId),
142
+ connectionActions: (id) => api.connectionActions(slug, id),
143
+ invokeConnectionAction: (id, action, input) =>
144
+ api.invokeConnectionAction(slug, id, action, input),
145
+ };
61
146
  }
62
147
 
63
148
  const DEFAULTS = {
@@ -85,19 +170,49 @@ const CAPABILITY_COPY: Record<string, string> = {
85
170
  writeback_credential: 'Issue a token this vendor uses to call us',
86
171
  };
87
172
 
88
- /** A refusal must read as a refusal, not as an empty page. */
89
- function readFailureMessage(err: unknown): string {
173
+ /**
174
+ * A refusal must read as a refusal, not as an empty page — and it must name the
175
+ * refusal that actually happened. This runs in ONE place: the catch around the
176
+ * two GETs `reload` makes. So trace which 403 can reach here on each surface.
177
+ *
178
+ * Control plane: the caller lacks the connections permission on this foundry.
179
+ *
180
+ * Member surface: `IsSeatedMember` denied — no ACTIVE `CompanyMembership` and
181
+ * not the org's `owner`. That is the ONLY 403 either GET produces;
182
+ * `MyConnectorCatalogView` answers a foundry that has allowed nothing with
183
+ * `{results: []}` and a 200, and `MyConnectionsView.get` runs no per-connector
184
+ * policy at all. The allowlist refusal (`_NOT_ALLOWED`) is raised by the POST,
185
+ * lands in `CatalogCard`'s own catch, and renders the server's wording verbatim.
186
+ * Blaming the allowlist here would send someone whose SEAT is gone to ask an
187
+ * admin to allow a connector — the admin could allow every one of them and this
188
+ * page would still refuse.
189
+ */
190
+ function readFailureMessage(err: unknown, personal: boolean): string {
90
191
  const status = (err as { status?: number })?.status;
91
192
  if (status === 403) {
92
- return 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
193
+ return personal
194
+ ? 'You are not a member of this foundry, or your access to it has ended. Ask an admin to add you.'
195
+ : 'You do not have access to this foundry’s connections. Ask an admin for the connections permission.';
93
196
  }
94
197
  if (status === 401) return 'Your session has expired. Sign in again.';
95
198
  const message = (err as { message?: string })?.message;
96
199
  return message || 'Could not load connections.';
97
200
  }
98
201
 
99
- export function ConnectionsPage({ api, foundrySlug, classNames = {} }: ConnectionsPageProps) {
202
+ export function ConnectionsPage(props: ConnectionsPageProps) {
203
+ const { classNames = {} } = props;
204
+ const personal = props.mode === 'personal';
100
205
  const cn = { ...DEFAULTS, ...classNames };
206
+ // Keyed on the PARTS, never on `props` — a props object is a new object every
207
+ // render, so memoising on it would rebuild the binding each time, change
208
+ // `reload`'s identity each time, and turn the load effect into a loop.
209
+ const injected = props.api;
210
+ const slug = props.mode === 'personal' ? undefined : props.foundrySlug;
211
+ const api = React.useMemo(
212
+ () => bindApi(props),
213
+ // eslint-disable-next-line react-hooks/exhaustive-deps
214
+ [personal, injected, slug],
215
+ );
101
216
 
102
217
  const [catalog, setCatalog] = React.useState<ConnectorCatalogEntry[]>([]);
103
218
  const [connections, setConnections] = React.useState<Connection[]>([]);
@@ -111,19 +226,22 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
111
226
  setLoading(true);
112
227
  try {
113
228
  const [cat, conns] = await Promise.all([
114
- api.connectorCatalog(foundrySlug),
115
- api.connections(foundrySlug),
229
+ // In personal mode this is the ALLOWED catalog, already narrowed by the
230
+ // foundry's policy server-side. Rendered as handed: filtering here would
231
+ // put the withheld connectors in the browser to be filtered out of.
232
+ api.connectorCatalog(),
233
+ api.connections(),
116
234
  ]);
117
235
  setCatalog(cat.results ?? []);
118
236
  setConnections(conns.results ?? []);
119
237
  setPermissions(conns.permissions ?? { view: true, manage: false, issue_tokens: false });
120
238
  setLoadError('');
121
239
  } catch (err) {
122
- setLoadError(readFailureMessage(err));
240
+ setLoadError(readFailureMessage(err, personal));
123
241
  } finally {
124
242
  setLoading(false);
125
243
  }
126
- }, [api, foundrySlug]);
244
+ }, [api, personal]);
127
245
 
128
246
  React.useEffect(() => { void reload(); }, [reload]);
129
247
 
@@ -134,16 +252,26 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
134
252
  return (
135
253
  <div className={cn.root}>
136
254
  <section className={cn.section}>
137
- <h2 className={cn.heading}>Connected accounts</h2>
255
+ {/* WHOSE ACCOUNTS THESE ARE. On the member surface the list is already
256
+ owner-scoped server-side (`for_owner`), and the heading has to say so
257
+ — a member looking at "Connected accounts" inside their foundry's app
258
+ has no way to tell whether they are seeing the foundry's keys. */}
259
+ <h2 className={cn.heading}>
260
+ {personal ? 'Your connected accounts' : 'Connected accounts'}
261
+ </h2>
138
262
  {loading && connections.length === 0 ? <p className={cn.muted}>Loading…</p> : null}
139
263
  {!loading && connections.length === 0 ? (
140
- <p className={cn.muted}>Nothing connected yet.</p>
264
+ <p className={cn.muted}>
265
+ {personal
266
+ ? 'You have not connected an account yet.'
267
+ : 'Nothing connected yet.'}
268
+ </p>
141
269
  ) : null}
142
270
  {connections.map((connection) => (
143
271
  <ConnectionCard
144
272
  key={connection.id}
145
273
  api={api}
146
- foundrySlug={foundrySlug}
274
+ personal={personal}
147
275
  connection={connection}
148
276
  // The CONNECTOR's own catalog row. It carries the capability list,
149
277
  // so the card can offer a capability this connection does not have
@@ -160,17 +288,26 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
160
288
  </section>
161
289
 
162
290
  <section className={cn.section}>
163
- <h2 className={cn.heading}>Available connectors</h2>
291
+ <h2 className={cn.heading}>
292
+ {personal ? 'Connect your own account' : 'Available connectors'}
293
+ </h2>
294
+ {personal && !loading && catalog.length === 0 ? (
295
+ <p className={cn.muted}>
296
+ Your foundry has not opened any connector to members yet. An admin
297
+ decides which ones you may connect your own account on.
298
+ </p>
299
+ ) : null}
164
300
  {catalog.map((entry) => (
165
301
  <CatalogCard
166
302
  key={entry.key}
167
303
  api={api}
168
- foundrySlug={foundrySlug}
304
+ personal={personal}
169
305
  entry={entry}
170
- // What this foundry ALREADY has on this connector. A second vendor
171
- // account is legitimate and stays possible; an admin who is really
172
- // trying to install a new key is not, and they are the ones this
173
- // form used to swallow.
306
+ // What ALREADY exists on this connector — in personal mode that is
307
+ // the member's own cards and nobody else's, because `connections`
308
+ // came back owner-scoped. A second vendor account is legitimate and
309
+ // stays possible; someone who is really trying to install a rotated
310
+ // key is not, and they are the ones this form used to swallow.
174
311
  existing={connections.filter((c) => c.connector_key === entry.key)}
175
312
  canManage={permissions.manage}
176
313
  classNames={cn}
@@ -256,12 +393,12 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
256
393
  // --------------------------------------------------------------------------
257
394
 
258
395
  function CatalogCard({
259
- api, foundrySlug, entry, existing, canManage, classNames: cn, onConnected,
396
+ api, personal, entry, existing, canManage, classNames: cn, onConnected,
260
397
  }: {
261
- api: ConnectionsApi;
262
- foundrySlug: string;
398
+ api: BoundApi;
399
+ personal: boolean;
263
400
  entry: ConnectorCatalogEntry;
264
- /** Cards this foundry already holds on this connector. */
401
+ /** Cards already held on this connector — the foundry's, or the member's own. */
265
402
  existing: Connection[];
266
403
  canManage: boolean;
267
404
  classNames: typeof DEFAULTS;
@@ -303,7 +440,7 @@ function CatalogCard({
303
440
  // recordings are stored.
304
441
  if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
305
442
  }
306
- await api.connect(foundrySlug, {
443
+ await api.connect({
307
444
  connector_key: entry.key, name: entry.name, credentials, config,
308
445
  // Always sent. Leaving it off is what wrote the inert cards.
309
446
  capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
@@ -437,10 +574,9 @@ function CatalogCard({
437
574
  * rotation quietly a config change.
438
575
  */
439
576
  function ReplaceKeyForm({
440
- api, foundrySlug, connection, entry, classNames: cn, onDone,
577
+ api, connection, entry, classNames: cn, onDone,
441
578
  }: {
442
- api: ConnectionsApi;
443
- foundrySlug: string;
579
+ api: BoundApi;
444
580
  connection: Connection;
445
581
  entry?: ConnectorCatalogEntry;
446
582
  classNames: typeof DEFAULTS;
@@ -499,7 +635,7 @@ function ReplaceKeyForm({
499
635
  onClick={() => {
500
636
  setBusy(true);
501
637
  setError('');
502
- api.rotateCredential(foundrySlug, connection.id, submitted)
638
+ api.rotateCredential(connection.id, submitted)
503
639
  .then(() => { setValues({}); onDone(); })
504
640
  // The server's typed answer, verbatim: it already scrubbed itself of
505
641
  // the values just submitted, and "the vendor rejected this key" is
@@ -541,10 +677,9 @@ function ReplaceKeyForm({
541
677
  * `ingest_transcripts` the off position is the whole privacy posture.
542
678
  */
543
679
  function ConnectionSettingsForm({
544
- api, foundrySlug, connection, fields, classNames: cn, onDone,
680
+ api, connection, fields, classNames: cn, onDone,
545
681
  }: {
546
- api: ConnectionsApi;
547
- foundrySlug: string;
682
+ api: BoundApi;
548
683
  connection: Connection;
549
684
  fields: ConnectorFieldSpec[];
550
685
  classNames: typeof DEFAULTS;
@@ -578,7 +713,7 @@ function ConnectionSettingsForm({
578
713
  ? Number(value)
579
714
  : value;
580
715
  }
581
- await api.updateConnection(foundrySlug, connection.id, { config });
716
+ await api.updateConnection!(connection.id, { config });
582
717
  onDone();
583
718
  } catch (err) {
584
719
  setError((err as { message?: string })?.message || 'Those settings were not saved.');
@@ -615,10 +750,10 @@ function ConnectionSettingsForm({
615
750
  }
616
751
 
617
752
  function ConnectionCard({
618
- api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
753
+ api, personal, connection, entry, canManage, classNames: cn, onChanged,
619
754
  }: {
620
- api: ConnectionsApi;
621
- foundrySlug: string;
755
+ api: BoundApi;
756
+ personal: boolean;
622
757
  connection: Connection;
623
758
  /** This connector's catalog row: its capabilities AND its field schema. */
624
759
  entry?: ConnectorCatalogEntry;
@@ -692,7 +827,7 @@ function ConnectionCard({
692
827
  className={cn.button}
693
828
  disabled={Boolean(busy)}
694
829
  onClick={() => void guard('check', async () => {
695
- const res = await api.checkConnection(foundrySlug, connection.id);
830
+ const res = await api.checkConnection(connection.id);
696
831
  setNotice(res.detail || (res.ok ? 'Connection is healthy.' : ''));
697
832
  onChanged();
698
833
  })}
@@ -714,19 +849,26 @@ function ConnectionCard({
714
849
  >
715
850
  {replacing ? 'Cancel' : 'Replace key'}
716
851
  </button>
717
- <button
718
- type="button"
719
- className={cn.button}
720
- disabled={Boolean(busy)}
721
- onClick={() => void guard('discover', async () => {
722
- const page = await api.connectionChoices(foundrySlug, connection.id);
723
- setChoices(page.results ?? []);
724
- const available = await api.connectionActions(foundrySlug, connection.id);
725
- setActions(available.results ?? []);
726
- })}
727
- >
728
- {busy === 'discover' ? 'Loading…' : 'Workflows'}
729
- </button>
852
+ {/* DISCOVER -> WIRE -> FIRE is foundry-level and stays there. The
853
+ member router serves no choices, targets or actions at all, so
854
+ the control is gated on the verb EXISTING rather than on a mode
855
+ flag — there is no way to render a button here whose handler is
856
+ undefined. */}
857
+ {api.connectionChoices && api.connectionActions ? (
858
+ <button
859
+ type="button"
860
+ className={cn.button}
861
+ disabled={Boolean(busy)}
862
+ onClick={() => void guard('discover', async () => {
863
+ const page = await api.connectionChoices!(connection.id);
864
+ setChoices(page.results ?? []);
865
+ const available = await api.connectionActions!(connection.id);
866
+ setActions(available.results ?? []);
867
+ })}
868
+ >
869
+ {busy === 'discover' ? 'Loading…' : 'Workflows'}
870
+ </button>
871
+ ) : null}
730
872
  {/* SETTINGS, which this card has never had. A connector's
731
873
  `config_schema` ships on every catalog row and was rendered
732
874
  exactly once — on the connect form, before the account existed.
@@ -735,7 +877,7 @@ function ConnectionCard({
735
877
  recordings of people's conversations are stored was reachable
736
878
  only by editing the database. `ReplaceKeyForm` already pointed at
737
879
  "the connection's own settings" as if this existed. */}
738
- {configFields.length ? (
880
+ {configFields.length && api.updateConnection ? (
739
881
  <button
740
882
  type="button"
741
883
  className={cn.button}
@@ -769,7 +911,6 @@ function ConnectionCard({
769
911
  {settingsOpen ? (
770
912
  <ConnectionSettingsForm
771
913
  api={api}
772
- foundrySlug={foundrySlug}
773
914
  connection={connection}
774
915
  fields={configFields}
775
916
  classNames={cn}
@@ -780,7 +921,6 @@ function ConnectionCard({
780
921
  {replacing ? (
781
922
  <ReplaceKeyForm
782
923
  api={api}
783
- foundrySlug={foundrySlug}
784
924
  connection={connection}
785
925
  entry={entry}
786
926
  classNames={cn}
@@ -809,7 +949,7 @@ function ConnectionCard({
809
949
  className={cn.button}
810
950
  disabled={Boolean(busy)}
811
951
  onClick={() => void guard('disconnect', async () => {
812
- await api.disconnect(foundrySlug, connection.id);
952
+ await api.disconnect(connection.id);
813
953
  setConfirmingDisconnect(false);
814
954
  setNotice('Disconnected.');
815
955
  onChanged();
@@ -837,21 +977,26 @@ function ConnectionCard({
837
977
  scope="connection"
838
978
  declared={offered}
839
979
  selected={selected}
840
- disabled={!canManage || Boolean(busy)}
980
+ // On the member surface there is no PATCH, so the picker is
981
+ // INFORMATIONAL rather than absent. A member chose these at connect time
982
+ // and `POST` accepted them; hiding the list afterwards would make that
983
+ // choice look like it never happened, and leave them unable to see what
984
+ // their own account is doing.
985
+ disabled={!canManage || !api.updateConnection || Boolean(busy)}
841
986
  idPrefix={`connection-${connection.id}`}
842
987
  onToggle={(capability, on) => setSelected((prev) => (
843
988
  on ? [...prev, capability] : prev.filter((c) => c !== capability)
844
989
  ))}
845
990
  classNames={cn}
846
991
  >
847
- {canManage ? (
992
+ {canManage && api.updateConnection ? (
848
993
  <div className="mt-2 flex items-center gap-3">
849
994
  <button
850
995
  type="button"
851
996
  className={cn.button}
852
997
  disabled={!dirty || pending.length === 0 || Boolean(busy)}
853
998
  onClick={() => void guard('capabilities', async () => {
854
- await api.updateConnection(foundrySlug, connection.id, {
999
+ await api.updateConnection!(connection.id, {
855
1000
  capabilities_enabled: pending,
856
1001
  });
857
1002
  setNotice('Saved.');
@@ -866,6 +1011,11 @@ function ConnectionCard({
866
1011
  </span>
867
1012
  ) : null}
868
1013
  </div>
1014
+ ) : personal ? (
1015
+ <p className={cn.muted}>
1016
+ Chosen when you connected. Ask an admin to change what this account
1017
+ may do, or disconnect it and connect again.
1018
+ </p>
869
1019
  ) : null}
870
1020
  </CapabilityPicker>
871
1021
 
@@ -897,7 +1047,7 @@ function ConnectionCard({
897
1047
  className={cn.button}
898
1048
  disabled={Boolean(busy)}
899
1049
  onClick={() => void guard('wire', async () => {
900
- setTarget(await api.wireTarget(foundrySlug, connection.id, {
1050
+ setTarget(await api.wireTarget!(connection.id, {
901
1051
  choice_id: choice.id,
902
1052
  }));
903
1053
  })}
@@ -925,7 +1075,7 @@ function ConnectionCard({
925
1075
  className={cn.button}
926
1076
  disabled={Boolean(busy)}
927
1077
  onClick={() => void guard('confirm', async () => {
928
- setTarget(await api.confirmTarget(foundrySlug, connection.id, target.id));
1078
+ setTarget(await api.confirmTarget!(connection.id, target.id));
929
1079
  })}
930
1080
  >
931
1081
  Confirm this target
@@ -940,8 +1090,8 @@ function ConnectionCard({
940
1090
  className={cn.button}
941
1091
  disabled={Boolean(busy)}
942
1092
  onClick={() => void guard('invoke', async () => {
943
- const result = await api.invokeConnectionAction(
944
- foundrySlug, connection.id, available.name,
1093
+ const result = await api.invokeConnectionAction!(
1094
+ connection.id, available.name,
945
1095
  { destination_id: target.id, records: [] },
946
1096
  );
947
1097
  // `accepted` is not `completed`. Saying "done" here would
@@ -220,3 +220,61 @@ export interface ConnectionsApi {
220
220
  }): Promise<ConnectionInvokeResult>;
221
221
  connectionRuns(slug: string, id: string): Promise<{ results: ConnectionRun[] }>;
222
222
  }
223
+
224
+ /**
225
+ * THE SAME PAGE, FOR A PERSON RATHER THAN A FOUNDRY (bd startsim-768w.22.25.6).
226
+ *
227
+ * A seated member of a foundry connects THEIR OWN account — their Granola key,
228
+ * not the foundry's — from inside their tenant app. The endpoints are
229
+ * `connections/me/...` and the differences from the interface above are not
230
+ * stylistic; each one is a shape the member router deliberately does not serve
231
+ * (`backend/apps/connectors/member_urls.py`, `api/member_views.py`):
232
+ *
233
+ * NO SLUG. Every method here is slugless, and that is the whole security
234
+ * property rather than a convenience. The member surface names the foundry
235
+ * through the TOKEN'S AUDIENCE, so — in the router's own words — "there is no id
236
+ * here to substitute, no slug to swap, and therefore nothing in the URL space
237
+ * that names anybody else." A client whose signatures take a slug hands that
238
+ * affordance back at the type level, and reads to the next person as a client
239
+ * that scopes by slug. It must not exist.
240
+ *
241
+ * NO `updateConnection`. `MyConnectionDetailView` serves GET and DELETE only.
242
+ * Renaming a card or changing its config or enabled capabilities stays on the
243
+ * control-plane router; a member who needs one asks an admin.
244
+ *
245
+ * NO choices, targets, actions or tokens. Discovering what a credential reaches,
246
+ * wiring it to a destination, invoking it and minting `ConnectionToken`s are
247
+ * foundry-level and stay owner- or admin-exclusive. That is a design decision,
248
+ * not a backlog item — a member's key is theirs to install and revoke, not a
249
+ * lever on the foundry's pipelines.
250
+ *
251
+ * `connectorCatalog` here returns the ALLOWED catalog — `member_connectable`
252
+ * for this foundry, filtered server-side, default-deny per connector. The
253
+ * composer renders exactly what it is handed: a client-side filter over the
254
+ * full catalog would ship the withheld vendors in the bundle.
255
+ */
256
+ export interface MemberConnectionsApi {
257
+ connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }>;
258
+ connections(): Promise<{
259
+ results: Connection[];
260
+ permissions: ConnectionPermissions;
261
+ }>;
262
+ connect(input: {
263
+ connector_key: string;
264
+ name?: string;
265
+ credentials?: Record<string, unknown>;
266
+ config?: Record<string, unknown>;
267
+ /**
268
+ * Chosen once, at connect time, and not editable afterwards on this
269
+ * surface — there is no PATCH. The picker on an existing card is therefore
270
+ * informational, and saying so is the honest rendering of both halves.
271
+ */
272
+ capabilities_enabled?: string[];
273
+ }): Promise<Connection>;
274
+ checkConnection(id: string): Promise<{
275
+ ok: boolean; account_label: string; detail: string;
276
+ }>;
277
+ rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection>;
278
+ disconnect(id: string): Promise<void>;
279
+ connectionRuns(id: string): Promise<{ results: ConnectionRun[] }>;
280
+ }