@startsimpli/integrations 0.2.20 → 0.2.22

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.22",
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';
@@ -17,6 +17,7 @@ export type {
17
17
  ConnectorCatalogEntry,
18
18
  ConnectorFieldSpec,
19
19
  Connection as FoundryConnection,
20
+ MemberConnectionRow,
20
21
  ConnectionPermissions,
21
22
  ConnectionChoice,
22
23
  ConnectionChoicePage,