@startsimpli/integrations 0.2.20 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The member connector client (bd startsim-768w.22.25.6).
3
+ *
4
+ * WHAT THIS SUITE IS FOR, and it is one thing: the URLs. Every method on
5
+ * `connectionsMe` must land under `/connections/me/`, and NOTHING it sends may
6
+ * name a foundry, a slug, or a user. The member router's guarantee — "there is
7
+ * no id here to substitute, no slug to swap" — is a property of the URL space,
8
+ * and a client is exactly where that guarantee gets quietly given back.
9
+ *
10
+ * So each assertion below reads the URL that actually reached `fetch`, not the
11
+ * arguments the caller passed.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ import { connectionsMe } from '../api/connections';
16
+ import { setApiBase, setAuthTokenProvider } from '../api/_fetch';
17
+
18
+ interface Call { url: string; method: string; body: unknown }
19
+
20
+ let calls: Call[] = [];
21
+
22
+ function jsonOnce(payload: unknown, status = 200) {
23
+ return new Response(JSON.stringify(payload), {
24
+ status, headers: { 'Content-Type': 'application/json' },
25
+ });
26
+ }
27
+
28
+ beforeEach(() => {
29
+ calls = [];
30
+ setApiBase('/api/v1');
31
+ setAuthTokenProvider(null);
32
+ vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit = {}) => {
33
+ calls.push({
34
+ url: String(url),
35
+ method: init.method ?? 'GET',
36
+ body: init.body ? JSON.parse(String(init.body)) : undefined,
37
+ });
38
+ return jsonOnce({ results: [], permissions: {} });
39
+ }));
40
+ });
41
+
42
+ afterEach(() => { vi.unstubAllGlobals(); });
43
+
44
+ describe('connectionsMe — every route is /connections/me/', () => {
45
+ it('reads the ALLOWED catalog, not the global one', async () => {
46
+ await connectionsMe.connectorCatalog();
47
+ expect(calls[0].url).toBe('/api/v1/connections/me/catalog/');
48
+ expect(calls[0].method).toBe('GET');
49
+ });
50
+
51
+ it('lists my connections with no query string at all', async () => {
52
+ await connectionsMe.connections();
53
+ // No `?owner=`, no `?foundry=`. A parameter that scopes the list is a
54
+ // parameter someone can change.
55
+ expect(calls[0].url).toBe('/api/v1/connections/me/');
56
+ });
57
+
58
+ it('connects by POSTing the input as the body, unwrapped', async () => {
59
+ await connectionsMe.connect({
60
+ connector_key: 'granola', name: 'Mine',
61
+ credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
62
+ });
63
+ expect(calls[0].url).toBe('/api/v1/connections/me/');
64
+ expect(calls[0].method).toBe('POST');
65
+ expect(calls[0].body).toEqual({
66
+ connector_key: 'granola', name: 'Mine',
67
+ credentials: { api_key: 'grn_x' }, capabilities_enabled: ['poll'],
68
+ });
69
+ });
70
+
71
+ it('tests against the STORED credential, sending no key', async () => {
72
+ await connectionsMe.checkConnection('abc');
73
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/check/');
74
+ expect(calls[0].method).toBe('POST');
75
+ // Deliberately empty: a check that accepts a pasted key is a second connect
76
+ // path, and the router does not read this body.
77
+ expect(calls[0].body).toEqual({});
78
+ });
79
+
80
+ it('rotates through the credential route, wrapping the map the way the API expects', async () => {
81
+ await connectionsMe.rotateCredential('abc', { api_key: 'grn_new' });
82
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/credential/');
83
+ expect(calls[0].body).toEqual({ credentials: { api_key: 'grn_new' } });
84
+ });
85
+
86
+ it('disconnects with DELETE on the detail route', async () => {
87
+ await connectionsMe.disconnect('abc');
88
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/');
89
+ expect(calls[0].method).toBe('DELETE');
90
+ });
91
+
92
+ it('reads runs off the connection it belongs to', async () => {
93
+ await connectionsMe.connectionRuns('abc');
94
+ expect(calls[0].url).toBe('/api/v1/connections/me/abc/runs/');
95
+ });
96
+
97
+ it('carries no slug, tenant or user anywhere in any request it makes', async () => {
98
+ await connectionsMe.connectorCatalog();
99
+ await connectionsMe.connections();
100
+ await connectionsMe.connect({ connector_key: 'granola' });
101
+ await connectionsMe.checkConnection('abc');
102
+ await connectionsMe.rotateCredential('abc', { api_key: 'x' });
103
+ await connectionsMe.disconnect('abc');
104
+ await connectionsMe.connectionRuns('abc');
105
+
106
+ for (const call of calls) {
107
+ expect(call.url.startsWith('/api/v1/connections/me/')).toBe(true);
108
+ expect(call.url).not.toMatch(/[?&]/);
109
+ const serialised = JSON.stringify(call.body ?? {});
110
+ expect(serialised).not.toMatch(/slug|foundry|tenant|company|owner|user_id/i);
111
+ }
112
+ });
113
+
114
+ it('follows setApiBase, which is how a tenant app proxies same-origin', async () => {
115
+ // bd startsim-768w.22.25.7: the tenant serves `/central-api` from its own
116
+ // origin. If this client ignored the base, the browser would talk to central
117
+ // cross-origin with a bearer token attached.
118
+ setApiBase('/central-api/v1');
119
+ await connectionsMe.connections();
120
+ expect(calls[0].url).toBe('/central-api/v1/connections/me/');
121
+ });
122
+ });
@@ -20,7 +20,7 @@ import userEvent from '@testing-library/user-event';
20
20
 
21
21
  import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
22
22
  import { ConnectionsPage } from '../components/pages/ConnectionsPage';
23
- import type { ConnectionsApi } from '../components/pages/types';
23
+ import type { ConnectionsApi, MemberConnectionsApi } from '../components/pages/types';
24
24
 
25
25
  // --------------------------------------------------------------------------
26
26
  // two schemas, one component
@@ -792,3 +792,208 @@ describe('ConnectionCard — a connected account has settings', () => {
792
792
  expect(await within(section).findByText(/keeping verbatim meeting speech forever/i)).toBeInTheDocument();
793
793
  });
794
794
  });
795
+
796
+ // --------------------------------------------------------------------------
797
+ // personal mode (bd startsim-768w.22.25.6)
798
+ // --------------------------------------------------------------------------
799
+
800
+ /**
801
+ * THE SAME COMPOSER, POINTED AT THE MEMBER SURFACE.
802
+ *
803
+ * A member of a foundry sets THEIR OWN key on a connector, from inside their
804
+ * tenant app, and the page they use is this one — `mode="personal"`. There is no
805
+ * second component, because a second component is a second place for the
806
+ * capability picker, the rotation form and the disconnect confirmation to drift.
807
+ *
808
+ * What differs is not cosmetic, and every difference below is forced by
809
+ * `backend/apps/connectors/member_urls.py`:
810
+ *
811
+ * * NO SLUG anywhere in the member client's signatures. The member endpoints
812
+ * are `connections/me/...` and the foundry is named by the token's audience
813
+ * — "there is no id here to substitute, no slug to swap". A client that
814
+ * takes a slug re-introduces, in the types, the exact affordance that URL
815
+ * space was designed not to have.
816
+ * * NO PATCH. `MyConnectionDetailView` serves GET and DELETE only, so the
817
+ * capability picker is informational and there is no Save beside it, and no
818
+ * settings form.
819
+ * * NO targets, choices, actions or tokens. Those are foundry-level and stay
820
+ * on the control-plane router, so the Workflows control is not rendered.
821
+ * * THE CATALOG IS THE SERVER'S ANSWER. `me/catalog/` returns only what the
822
+ * foundry has allowed members to connect. Filtering client-side would ship
823
+ * the disallowed vendors in the bundle.
824
+ */
825
+ const MEMBER_CATALOG = {
826
+ // Only Granola. The foundry allowed one connector to its members; n8n is in
827
+ // the foundry-wide catalog and must not appear on this surface at all.
828
+ results: [
829
+ {
830
+ ...CATALOG.results[1],
831
+ availability: { available: true, reason: '' },
832
+ },
833
+ ],
834
+ };
835
+
836
+ const MY_CONNECTIONS = {
837
+ results: [
838
+ {
839
+ id: 'm1', public_id: 'mpub1', name: 'My Granola', connector_key: 'granola',
840
+ connector_name: 'Granola', external_account_id: '', account_label: 'me@example.com',
841
+ capabilities_enabled: ['poll'], config: {}, status: 'connected',
842
+ health: 'ok', auth_mode: 'api_key',
843
+ },
844
+ ],
845
+ permissions: { view: true, manage: true, issue_tokens: false },
846
+ };
847
+
848
+ function stubMemberApi(overrides: Partial<MemberConnectionsApi> = {}): MemberConnectionsApi {
849
+ return {
850
+ connectorCatalog: vi.fn().mockResolvedValue(MEMBER_CATALOG),
851
+ connections: vi.fn().mockResolvedValue(MY_CONNECTIONS),
852
+ connect: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
853
+ checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
854
+ rotateCredential: vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]),
855
+ disconnect: vi.fn().mockResolvedValue(undefined),
856
+ connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
857
+ ...overrides,
858
+ } as MemberConnectionsApi;
859
+ }
860
+
861
+ async function myCard(api: MemberConnectionsApi = stubMemberApi()) {
862
+ render(<ConnectionsPage mode="personal" api={api} />);
863
+ const card = await screen.findByText('My Granola');
864
+ return (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
865
+ }
866
+
867
+ describe('ConnectionsPage — personal mode speaks to the member surface', () => {
868
+ it('calls the member endpoints with NO slug argument', async () => {
869
+ const api = stubMemberApi();
870
+ render(<ConnectionsPage mode="personal" api={api} />);
871
+ expect(await screen.findByText('My Granola')).toBeInTheDocument();
872
+ // Not `toHaveBeenCalledWith(undefined)` — called with nothing. A slug
873
+ // parameter that happens to be undefined is still a slug parameter.
874
+ expect((api.connectorCatalog as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
875
+ expect((api.connections as ReturnType<typeof vi.fn>).mock.calls[0]).toEqual([]);
876
+ });
877
+
878
+ it('renders EXACTLY the catalog the server returned — no client-side filter', async () => {
879
+ // The foundry-wide catalog holds n8n. The member catalog does not. If this
880
+ // page ever merges the two, or filters a full list in the browser, the
881
+ // vendor the foundry deliberately withheld ships in the bundle anyway.
882
+ render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
883
+ expect(await screen.findByText('Granola')).toBeInTheDocument();
884
+ expect(screen.queryByText('n8n')).toBeNull();
885
+ });
886
+
887
+ it('offers the controls a member actually has: Test, Replace key, Disconnect', async () => {
888
+ const section = await myCard();
889
+ expect(within(section).getByRole('button', { name: /^Test$/i })).toBeInTheDocument();
890
+ expect(within(section).getByRole('button', { name: /Replace key/i })).toBeInTheDocument();
891
+ expect(within(section).getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
892
+ });
893
+
894
+ it('does NOT offer Workflows — choices, targets and actions are foundry-level', async () => {
895
+ const section = await myCard();
896
+ expect(within(section).queryByRole('button', { name: /Workflows/i })).toBeNull();
897
+ });
898
+
899
+ it('shows the capabilities but offers no Save, because there is no PATCH', async () => {
900
+ const section = await myCard();
901
+ // Informational, not hidden: a member chose these at connect time and is
902
+ // entitled to see what their account is doing.
903
+ expect(within(section).getByText(/Fetch new records on a schedule/i)).toBeInTheDocument();
904
+ expect(within(section).queryByRole('button', { name: /^Save$/i })).toBeNull();
905
+ expect(within(section).queryByRole('button', { name: /Settings/i })).toBeNull();
906
+ });
907
+
908
+ it('connects with no slug, sending credentials and capabilities', async () => {
909
+ const connect = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
910
+ const api = stubMemberApi({ connect, connections: vi.fn().mockResolvedValue({
911
+ ...MY_CONNECTIONS, results: [],
912
+ }) });
913
+ render(<ConnectionsPage mode="personal" api={api} />);
914
+ await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
915
+ await userEvent.type(screen.getByLabelText(/API key/i), 'grn_mine');
916
+ await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
917
+
918
+ await waitFor(() => expect(connect).toHaveBeenCalled());
919
+ const [payload, ...rest] = connect.mock.calls[0];
920
+ expect(rest).toEqual([]);
921
+ expect(payload.connector_key).toBe('granola');
922
+ expect(payload.credentials).toEqual({ api_key: 'grn_mine' });
923
+ expect(payload.capabilities_enabled).toEqual(['poll']);
924
+ });
925
+
926
+ it('rotates and disconnects through the slugless member client', async () => {
927
+ const rotateCredential = vi.fn().mockResolvedValue(MY_CONNECTIONS.results[0]);
928
+ const disconnect = vi.fn().mockResolvedValue(undefined);
929
+ const section = await myCard(stubMemberApi({ rotateCredential, disconnect }));
930
+
931
+ await userEvent.click(within(section).getByRole('button', { name: /Replace key/i }));
932
+ await userEvent.type(within(section).getByLabelText(/API key/i), 'grn_rotated');
933
+ await userEvent.click(within(section).getByRole('button', { name: /Test and replace/i }));
934
+ await waitFor(() => expect(rotateCredential).toHaveBeenCalled());
935
+ expect(rotateCredential.mock.calls[0]).toEqual(['m1', { api_key: 'grn_rotated' }]);
936
+
937
+ await userEvent.click(within(section).getByRole('button', { name: /^Disconnect$/i }));
938
+ await userEvent.click(within(section).getByRole('button', { name: /Yes, disconnect/i }));
939
+ await waitFor(() => expect(disconnect).toHaveBeenCalled());
940
+ expect(disconnect.mock.calls[0]).toEqual(['m1']);
941
+ });
942
+
943
+ it('says the account is YOURS, never the foundry’s', async () => {
944
+ render(<ConnectionsPage mode="personal" api={stubMemberApi()} />);
945
+ expect(await screen.findByText(/Your connected accounts/i)).toBeInTheDocument();
946
+ });
947
+
948
+ it('reads a 403 on load as “you have no seat here”, not “ask for permission”', async () => {
949
+ // The ONLY 403 either load GET can produce is `IsSeatedMember` denying — the
950
+ // catalog answers a foundry that has allowed nothing with an empty 200, and
951
+ // the list runs no per-connector policy. So this copy must name the seat.
952
+ // Blaming the allowlist would send someone whose membership has ended to ask
953
+ // an admin to allow a connector, which cannot fix them.
954
+ const api = stubMemberApi({
955
+ connections: vi.fn().mockRejectedValue(Object.assign(new Error('nope'), { status: 403 })),
956
+ });
957
+ render(<ConnectionsPage mode="personal" api={api} />);
958
+ const alert = await screen.findByRole('alert');
959
+ expect(alert.textContent).toMatch(/not a member of this foundry/i);
960
+ expect(alert.textContent).not.toMatch(/connections permission/i);
961
+ });
962
+
963
+ it('renders the SERVER’s allowlist refusal when connecting is what was refused', async () => {
964
+ // `_NOT_ALLOWED` is raised by the POST, not the load, and it lands in the
965
+ // catalog card's own catch. That is the one place the allowlist wording
966
+ // belongs, and it comes from the server verbatim rather than being guessed
967
+ // at here — the server knows which connector was refused.
968
+ const refusal = 'your foundry has not allowed members to connect their own account on this connector.';
969
+ const api = stubMemberApi({
970
+ connect: vi.fn().mockRejectedValue(Object.assign(new Error(refusal), { status: 403 })),
971
+ connections: vi.fn().mockResolvedValue({ ...MY_CONNECTIONS, results: [] }),
972
+ });
973
+ render(<ConnectionsPage mode="personal" api={api} />);
974
+
975
+ await userEvent.click(await screen.findByRole('button', { name: /^Connect$/i }));
976
+ await userEvent.type(screen.getByLabelText(/API key/i), 'grn_live');
977
+ await userEvent.click(screen.getByRole('button', { name: /Test and save/i }));
978
+
979
+ await waitFor(() => expect(screen.getByText(refusal)).toBeInTheDocument());
980
+ });
981
+ });
982
+
983
+ describe('ConnectionsPage — foundry mode is untouched by personal mode', () => {
984
+ it('still defaults to foundry mode and still passes the slug', async () => {
985
+ const api = stubApi();
986
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
987
+ expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
988
+ expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
989
+ expect(screen.getByText('Connected accounts')).toBeInTheDocument();
990
+ });
991
+
992
+ it('still offers Workflows and the capability Save in foundry mode', async () => {
993
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
994
+ const card = await screen.findByText('Ops n8n');
995
+ const section = (card.closest('div[data-connection]') ?? card.parentElement!) as HTMLElement;
996
+ expect(within(section).getByRole('button', { name: /Workflows/i })).toBeInTheDocument();
997
+ expect(within(section).getByRole('button', { name: /^Save$/i })).toBeInTheDocument();
998
+ });
999
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `/api/v1/connections/me/` — a member's OWN vendor accounts
3
+ * (bd startsim-768w.22.25.6).
4
+ *
5
+ * The client half of the member connector surface. An app inside a foundry's
6
+ * tenant mounts `<ConnectionsPage mode="personal" api={connectionsMe} />` and
7
+ * owns nothing else: this object satisfies `MemberConnectionsApi`, so the same
8
+ * composer the command center uses renders a member's own keys.
9
+ *
10
+ * NO SLUG. Not on any method, not as an ignored parameter, not as an optional
11
+ * one — and that is the point rather than a convenience. The member router
12
+ * (`backend/apps/connectors/member_urls.py`) names the foundry through the
13
+ * TOKEN'S AUDIENCE, and says why in its own words: "there is no id here to
14
+ * substitute, no slug to swap, and therefore nothing in the URL space that names
15
+ * anybody else." A client whose signatures take a slug hands that affordance
16
+ * straight back at the type level and reads, to whoever maintains it next, as a
17
+ * client that scopes by slug. A member of two foundries holds two tokens; the
18
+ * token in flight decides which foundry answers, and nothing here can override
19
+ * that.
20
+ *
21
+ * WHAT IS ABSENT IS ALSO THE CONTRACT. There is no `updateConnection` because
22
+ * `MyConnectionDetailView` serves GET and DELETE only; no choices, targets,
23
+ * actions or `ConnectionToken`s because those are foundry-level and stay
24
+ * owner-exclusive on the control-plane router. Adding one here would be a client
25
+ * for a route that does not exist.
26
+ *
27
+ * WHERE IT POINTS. Through `request()`, so `setApiBase` and
28
+ * `setAuthTokenProvider` apply: a tenant app points the base at its own
29
+ * same-origin proxy (bd startsim-768w.22.25.7) and the browser never makes a
30
+ * cross-origin call to central with a bearer token on it.
31
+ */
32
+ import type {
33
+ Connection,
34
+ ConnectionPermissions,
35
+ ConnectionRun,
36
+ ConnectorCatalogEntry,
37
+ MemberConnectionsApi,
38
+ } from '../components/pages/types';
39
+ import { request } from './_fetch';
40
+
41
+ /** Mounted at `/api/v1/connections/` (`config/urls.py`), so this rides the base. */
42
+ const BASE = '/connections/me';
43
+
44
+ export const connectionsMe: MemberConnectionsApi = {
45
+ /**
46
+ * The connectors THIS foundry has opened to its members — not the global
47
+ * catalog. The narrowing is `policy_service.member_connectable`, server-side,
48
+ * default-deny per connector per foundry. Rendered as handed: filtering in the
49
+ * browser would mean shipping the withheld vendors to be filtered out of.
50
+ */
51
+ connectorCatalog(): Promise<{ results: ConnectorCatalogEntry[] }> {
52
+ return request<{ results: ConnectorCatalogEntry[] }>(`${BASE}/catalog/`);
53
+ },
54
+
55
+ /**
56
+ * MY cards. Already owner-scoped by `Connection.objects.for_owner(foundry,
57
+ * user)` — there is no query parameter that widens this and no id to swap.
58
+ *
59
+ * `permissions` comes back with `issue_tokens: false` because the router has
60
+ * no such route, not because of anything about the caller.
61
+ */
62
+ connections(): Promise<{ results: Connection[]; permissions: ConnectionPermissions }> {
63
+ return request<{ results: Connection[]; permissions: ConnectionPermissions }>(`${BASE}/`);
64
+ },
65
+
66
+ /**
67
+ * Connect my own account. BLOCKING: the server runs `check()` against the
68
+ * vendor before storing anything, so a key that has not been proven to work is
69
+ * never saved as connected.
70
+ *
71
+ * `capabilities_enabled` is chosen HERE and only here — there is no PATCH on
72
+ * this surface, so this request is the one place a member says what their
73
+ * account may do.
74
+ *
75
+ * A 403 back from this is not "you lack a permission": it is the foundry not
76
+ * having allowed members to connect their own account on this connector. The
77
+ * server re-checks that here rather than trusting that the catalog it rendered
78
+ * earlier is what the browser actually sent.
79
+ */
80
+ connect(input: {
81
+ connector_key: string;
82
+ name?: string;
83
+ credentials?: Record<string, unknown>;
84
+ config?: Record<string, unknown>;
85
+ capabilities_enabled?: string[];
86
+ }): Promise<Connection> {
87
+ return request<Connection>(`${BASE}/`, { method: 'POST', body: input });
88
+ },
89
+
90
+ /**
91
+ * Test it now, against the STORED credential. The body is deliberately empty:
92
+ * a "test connection" button that accepts a pasted key is a second connect
93
+ * path, and the router does not read this body at all.
94
+ */
95
+ checkConnection(id: string): Promise<{ ok: boolean; account_label: string; detail: string }> {
96
+ return request<{ ok: boolean; account_label: string; detail: string }>(
97
+ `${BASE}/${id}/check/`, { method: 'POST', body: {} },
98
+ );
99
+ },
100
+
101
+ /**
102
+ * Rotate MY key. The old one stays live until the new one passes `check()`,
103
+ * so a bad paste changes nothing. A PARTIAL map is legal and normal: send the
104
+ * one field being replaced and the stored values fill in the rest.
105
+ */
106
+ rotateCredential(id: string, credentials: Record<string, unknown>): Promise<Connection> {
107
+ return request<Connection>(`${BASE}/${id}/credential/`, {
108
+ method: 'POST', body: { credentials },
109
+ });
110
+ },
111
+
112
+ /**
113
+ * DISCONNECT, not delete. One transaction stops the scheduled fetches, revokes
114
+ * every token issued for the account and deletes the stored credential; the
115
+ * card survives as `disabled`, so the record that this vendor was connected is
116
+ * not thrown away.
117
+ *
118
+ * A card that is not mine answers 404 rather than 403 — on this surface
119
+ * existence is itself the payload.
120
+ */
121
+ disconnect(id: string): Promise<void> {
122
+ return request<void>(`${BASE}/${id}/`, { method: 'DELETE' });
123
+ },
124
+
125
+ /** Recent activity on my card: a merge over the run tables it owns. */
126
+ connectionRuns(id: string): Promise<{ results: ConnectionRun[] }> {
127
+ return request<{ results: ConnectionRun[] }>(`${BASE}/${id}/runs/`);
128
+ },
129
+ };
package/src/api/index.ts CHANGED
@@ -2,4 +2,5 @@ export * from './oauth';
2
2
  export * from './calendar';
3
3
  export * from './email';
4
4
  export * from './accounts';
5
+ export * from './connections';
5
6
  export { setApiBase, getApiBase, setAuthTokenProvider } from './_fetch';
@@ -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
+ }