@startsimpli/integrations 0.2.27 → 0.2.29

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.27",
3
+ "version": "0.2.29",
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,143 @@
1
+ /**
2
+ * The "connect an MCP client" composer (bd startsim-62dca).
3
+ *
4
+ * THE ASSERTION THIS FILE EXISTS FOR is `never mints without a click`. Every
5
+ * other surface in this package reads; this one ISSUES A CREDENTIAL that reads a
6
+ * workspace as a human, lasts weeks, and cannot be withdrawn before it expires
7
+ * (startsim-ja8mt). A mint fired from an effect, a retry, or a re-render would
8
+ * scatter live bearers nobody asked for and nobody can cancel — and it would do
9
+ * it silently, because each one succeeds. So the number of POSTs is asserted,
10
+ * not assumed.
11
+ *
12
+ * The rest pins the four disclosures the bead requires. They are not decoration:
13
+ * a screen that renders a copy button without them is lying by omission about
14
+ * what the user is about to hand over.
15
+ */
16
+ import { describe, expect, it, vi } from 'vitest';
17
+ import { render, screen, waitFor } from '@testing-library/react';
18
+ import userEvent from '@testing-library/user-event';
19
+
20
+ import { ConnectClaudePage } from '../components/pages/ConnectClaudePage';
21
+ import type { McpTokensApi } from '../api/mcp-tokens';
22
+
23
+ const MINTED = {
24
+ token: 'eyJhbGciOiJSUzI1NiJ9.PAYLOAD.SIG',
25
+ token_type: 'Bearer',
26
+ foundry: 'tandem',
27
+ audience: 'tenant:tandem',
28
+ acting_as: { org_id: 'tandem', role: 'owner', email: 'qa-tandem@startsimpli.com' },
29
+ jti: 'abc123',
30
+ issued_at: '2026-08-27T00:00:00Z',
31
+ expires_at: '2026-09-26T00:00:00Z',
32
+ mcp_url: 'https://tandem.ai.startsimpli.com/api/v1/mcp/',
33
+ claude_code_command:
34
+ 'claude mcp add --transport http tandem https://tandem.ai.startsimpli.com/api/v1/mcp/ --header "Authorization: Bearer eyJ…"',
35
+ notice: 'Shown once — store it like a password. It cannot be revoked before it expires.',
36
+ };
37
+
38
+ function stubApi(overrides: Partial<McpTokensApi> = {}): McpTokensApi {
39
+ return {
40
+ list: vi.fn().mockResolvedValue({
41
+ foundries: [
42
+ { slug: 'tandem', name: 'Tandem', audience: 'tenant:tandem', mcp_url: MINTED.mcp_url, state: 'live' },
43
+ ],
44
+ default_ttl_days: 30,
45
+ max_ttl_days: 90,
46
+ }),
47
+ mint: vi.fn().mockResolvedValue(MINTED),
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ describe('ConnectClaudePage', () => {
53
+ it('NEVER mints without a click', async () => {
54
+ const api = stubApi();
55
+ render(<ConnectClaudePage api={api} />);
56
+ await waitFor(() => expect(api.list).toHaveBeenCalled());
57
+ // Give any stray effect a chance to fire before asserting the absence.
58
+ await new Promise((r) => setTimeout(r, 25));
59
+ expect(api.mint).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('mints exactly once per click, never once per render', async () => {
63
+ const api = stubApi();
64
+ const { rerender } = render(<ConnectClaudePage api={api} />);
65
+ await screen.findByRole('button', { name: /create connection token/i });
66
+ await userEvent.click(screen.getByRole('button', { name: /create connection token/i }));
67
+ await screen.findByText(/copy it now/i);
68
+ rerender(<ConnectClaudePage api={api} />);
69
+ expect(api.mint).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it('warns about the consequences BEFORE the token exists', async () => {
73
+ // A warning that only appears next to the credential is one nobody can act
74
+ // on — by then it has been issued and cannot be taken back.
75
+ render(<ConnectClaudePage api={stubApi()} />);
76
+ const note = await screen.findByRole('note');
77
+ expect(note.textContent).toMatch(/as\s+you/i);
78
+ expect(note.textContent).toMatch(/cannot be cancelled before it expires/i);
79
+ expect(note.textContent).toMatch(/shown once/i);
80
+ // ...and specifically that losing the seat does not switch it off.
81
+ expect(note.textContent).toMatch(/does not switch this one off/i);
82
+ });
83
+
84
+ it('shows WHOSE data the token reads, from the server, not the browser', async () => {
85
+ render(<ConnectClaudePage api={stubApi()} />);
86
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
87
+ // Scoped to the identity card, because the slug legitimately appears in
88
+ // several places on this page (the command, the URL) and a bare text query
89
+ // would pass on any of them rather than on the one that states WHOSE.
90
+ const identity = (await screen.findByText(/This connection reads/i))
91
+ .closest('div') as HTMLElement;
92
+ expect(identity.textContent).toMatch(/qa-tandem@startsimpli\.com/);
93
+ expect(identity.textContent).toMatch(/owner/);
94
+ expect(identity.textContent).toMatch(/tandem/);
95
+ });
96
+
97
+ it('shows when it expires as a date, not an ISO string', async () => {
98
+ render(<ConnectClaudePage api={stubApi()} />);
99
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
100
+ const expiry = await screen.findByText(/Expires/);
101
+ expect(expiry.textContent).not.toMatch(/2026-09-26T00:00:00Z/);
102
+ expect(expiry.textContent).toMatch(/2026/);
103
+ });
104
+
105
+ it('renders the command the SERVER composed, not one it builds itself', async () => {
106
+ // The tenant's own record knows its host; a vanity domain would break a
107
+ // pattern assembled in the browser.
108
+ render(<ConnectClaudePage api={stubApi()} />);
109
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
110
+ expect(await screen.findByText(/claude mcp add --transport http tandem/)).toBeTruthy();
111
+ });
112
+
113
+ it('surfaces a refused mint as the reason, not as an outage', async () => {
114
+ // The listing is advisory; central decides membership independently, so a
115
+ // 403 here is an ordinary answer the user has to be able to read.
116
+ const api = stubApi({ mint: vi.fn().mockRejectedValue(new Error('You do not have an active seat in `tandem`')) });
117
+ render(<ConnectClaudePage api={api} />);
118
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
119
+ const alert = await screen.findByRole('alert');
120
+ expect(alert.textContent).toMatch(/active seat/i);
121
+ expect(screen.queryByText(/copy it now/i)).toBeNull();
122
+ });
123
+
124
+ it('says so plainly when the account is in no workspace', async () => {
125
+ const api = stubApi({
126
+ list: vi.fn().mockResolvedValue({ foundries: [], default_ttl_days: 30, max_ttl_days: 90 }),
127
+ });
128
+ render(<ConnectClaudePage api={api} />);
129
+ expect(await screen.findByText(/not a member of any workspace/i)).toBeTruthy();
130
+ expect(screen.queryByRole('button', { name: /create connection token/i })).toBeNull();
131
+ });
132
+
133
+ it('names no foundry, vendor or product of its own', async () => {
134
+ // Everything specific must arrive as DATA. Rendering an empty list must not
135
+ // leak a hardcoded example workspace into the page.
136
+ const api = stubApi({
137
+ list: vi.fn().mockResolvedValue({ foundries: [], default_ttl_days: 30, max_ttl_days: 90 }),
138
+ });
139
+ const { container } = render(<ConnectClaudePage api={api} />);
140
+ await screen.findByText(/not a member of any workspace/i);
141
+ expect(container.textContent).not.toMatch(/tandem|mcr|marketing-agents/i);
142
+ });
143
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * THE LIBRARY'S SOURCE STRIP MUST LIST THE SOURCES A MEMBER ACTUALLY HAS
3
+ * (bd startsim-pg8na RED / startsim-bu04n GREEN).
4
+ *
5
+ * REPORTED ON THE LIVE TANDEM TENANT, 2026-08-26: the strip showed Granola
6
+ * ("Failing", 20 syncs in a row failed) and n8n, while the records listed below
7
+ * it were Fathom and Granola documents, and the member's own Fathom and Dropbox
8
+ * connections never appeared at all.
9
+ *
10
+ * WHERE THE ROWS COME FROM, which the original diagnosis got wrong and which
11
+ * decides what may be tested here. There is no tenant-local connections table.
12
+ * `tenant-starter/config/urls.py` routes `api/v1/connections/` to
13
+ * `ConnectionsView`, whose only job is to forward the caller's already-verified
14
+ * bearer to CENTRAL's `/api/v1/subtree/connections/` and return the page
15
+ * envelope untouched ("proxied from central so the call stays same-origin",
16
+ * bd startsim-768w.38.19). So `listSources()` is already reading central — it is
17
+ * reading central's SHARED-only surface, and the two facts line up exactly:
18
+ *
19
+ * TENANT GET /api/v1/connections/ -> granola, n8n
20
+ * CENTRAL Connection rows for tandem -> dropbox(owner qosha),
21
+ * fathom(owner qosha),
22
+ * granola(SHARED), n8n(SHARED)
23
+ *
24
+ * `SubtreeConnectionsView` resolves through `Connection.objects.for_scope(...)`,
25
+ * which is `owner IS NULL`. Granola and n8n are not stale junk: they are the
26
+ * foundry's real shared connections, and Granola really is failing. The two
27
+ * missing cards are missing because the server was never asked for them.
28
+ *
29
+ * THE DIVISION OF LABOUR THIS FILE ASSUMES.
30
+ *
31
+ * Server: a member's OWN rows reach this endpoint. That is a union of
32
+ * `for_scope(orgs) | for_owner(orgs, request.user)` in central — the
33
+ * remedy `SubtreeConnectionsView` names in its own source, bounded to
34
+ * the requesting user so the disclosure bd startsim-768w.22.25.11
35
+ * closed stays closed. It cannot be done from this package, and it
36
+ * must not be done by merging in `/api/v1/connections/me/`: that
37
+ * surface's serializer carries `last_checked_at` and NOTHING ELSE of
38
+ * the three freshness facts (no `last_synced_at`, no
39
+ * `last_attempt_at`, no `consecutive_failures` — see
40
+ * `ConnectionSerializer.Meta.fields`), so every personal card built
41
+ * from it would report a verified credential as delivered data. That
42
+ * is precisely the lie bd startsim-1cow removed.
43
+ *
44
+ * Client: what arrives is rendered honestly. A connection carries an owner or
45
+ * it does not and the strip treats both alike; a connection the member
46
+ * DISCONNECTED is not a source of the corpus and must stop appearing
47
+ * as one.
48
+ *
49
+ * The second half is the part that fails today, and it is the "legacy ones
50
+ * masquerading as sources" the report describes: `disconnect()` deliberately
51
+ * keeps the card as `status: 'disabled'` ("DISCONNECT, not delete … the card
52
+ * survives as `disabled`, so the record that this vendor was connected is not
53
+ * thrown away"), and `listSources()` has no case for that status, so a vendor
54
+ * nobody is connected to any more falls through and renders next to the live
55
+ * ones. Not a denylist of connector keys — `status` is data the server writes.
56
+ */
57
+ import { describe, expect, it } from 'vitest';
58
+ import { render, screen, within } from '@testing-library/react';
59
+
60
+ import { LibraryPage } from '../components/pages/LibraryPage';
61
+ import { createCorpusApi } from '../corpus/client';
62
+ import type { CorpusHttp } from '../corpus/client';
63
+ import { FIXTURE_ORG } from '../corpus/fixtures';
64
+
65
+ const SCOPE = { orgId: FIXTURE_ORG };
66
+ const EMPTY_PAGE = { count: 0, next: null, previous: null, results: [] };
67
+
68
+ const NOW = Date.now();
69
+ const ago = (ms: number) => new Date(NOW - ms).toISOString();
70
+ const AN_HOUR_AGO = ago(60 * 60_000);
71
+ const TEN_MINUTES_AGO = ago(10 * 60_000);
72
+
73
+ /** Every path this adapter asked for, so a widening read cannot pass unnoticed. */
74
+ interface Recorder {
75
+ http: CorpusHttp;
76
+ calls: Array<{ path: string; params?: Record<string, unknown> }>;
77
+ }
78
+
79
+ /**
80
+ * The WIRE, not the contract — rows go in exactly as central serialises them.
81
+ * Same convention as `corpus-freshness.test.tsx`, for the same reason: a fixture
82
+ * must never be more generous than the server that feeds it.
83
+ */
84
+ function recorderOver(rows: Array<Record<string, unknown>>): Recorder {
85
+ const calls: Array<{ path: string; params?: Record<string, unknown> }> = [];
86
+ return {
87
+ calls,
88
+ http: {
89
+ async get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T> {
90
+ calls.push({ path, params: options?.params });
91
+ if (path.includes('connections')) {
92
+ return { count: rows.length, next: null, previous: null, results: rows } as T;
93
+ }
94
+ return EMPTY_PAGE as T;
95
+ },
96
+ },
97
+ };
98
+ }
99
+
100
+ function sourcesFrom(rows: Array<Record<string, unknown>>) {
101
+ const { http } = recorderOver(rows);
102
+ return createCorpusApi({ http, scope: SCOPE }).listSources();
103
+ }
104
+
105
+ /** The foundry's shared Granola: real, connected, and genuinely failing. */
106
+ const SHARED_GRANOLA = {
107
+ id: 'shared-granola',
108
+ connector_key: 'granola',
109
+ connector_name: 'Granola',
110
+ status: 'connected',
111
+ check_error: '',
112
+ last_synced_at: null,
113
+ last_attempt_at: TEN_MINUTES_AGO,
114
+ last_checked_at: null,
115
+ consecutive_failures: 20,
116
+ };
117
+
118
+ /**
119
+ * The member's OWN Fathom, as central serialises an owned row once the subtree
120
+ * read includes them. `owner` is the only structural difference, and the strip
121
+ * must not care: whose account it is decides who may SEE the row, server-side.
122
+ */
123
+ const MY_FATHOM = {
124
+ id: 'my-fathom',
125
+ connector_key: 'fathom',
126
+ connector_name: 'Fathom',
127
+ owner: 'qosha@debugg.ai',
128
+ status: 'connected',
129
+ check_error: '',
130
+ last_synced_at: AN_HOUR_AGO,
131
+ last_attempt_at: AN_HOUR_AGO,
132
+ last_checked_at: AN_HOUR_AGO,
133
+ consecutive_failures: 0,
134
+ };
135
+
136
+ /** A vendor the member has DISCONNECTED. The card survives; the source does not. */
137
+ const DISCONNECTED_DROPBOX = {
138
+ id: 'old-dropbox',
139
+ connector_key: 'dropbox',
140
+ connector_name: 'Dropbox',
141
+ owner: 'qosha@debugg.ai',
142
+ status: 'disabled',
143
+ check_error: '',
144
+ last_synced_at: null,
145
+ last_attempt_at: null,
146
+ last_checked_at: null,
147
+ consecutive_failures: 0,
148
+ };
149
+
150
+ describe('listSources — a personal connection is a source like any other', () => {
151
+ it('lists the member’s own connection alongside the foundry’s shared ones', async () => {
152
+ const sources = await sourcesFrom([SHARED_GRANOLA, MY_FATHOM]);
153
+
154
+ expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
155
+ });
156
+
157
+ it('keeps a personal connection’s freshness intact rather than flattening it', async () => {
158
+ const [fathom] = await sourcesFrom([MY_FATHOM]);
159
+
160
+ // The whole reason a merge from `/api/v1/connections/me/` is refused: that
161
+ // payload has no `last_synced_at` at all, so a card built from it could only
162
+ // ever say "nothing yet" over data that arrived an hour ago.
163
+ expect(fathom.lastSyncedAt).toBe(AN_HOUR_AGO);
164
+ expect(fathom.status).toBe('ok');
165
+ expect(fathom.label).toBe('Fathom');
166
+ });
167
+
168
+ it('reads one connections endpoint and does not widen the read to reach them', async () => {
169
+ // A single source of truth, stated as a test so a future "just also fetch
170
+ // everybody's" cannot be added quietly. The bound that actually keeps
171
+ // bd startsim-768w.22.25.11 closed is server-side; this pins that the client
172
+ // never asks for more than the member-scoped page it is handed.
173
+ const recorder = recorderOver([SHARED_GRANOLA, MY_FATHOM]);
174
+ await createCorpusApi({ http: recorder.http, scope: SCOPE }).listSources();
175
+
176
+ const connectionReads = recorder.calls.filter((call) => call.path.includes('connections'));
177
+ expect(connectionReads).toHaveLength(1);
178
+ expect(connectionReads[0].path).toBe('api/v1/connections');
179
+ // No owner, no user, no "all" — nothing that names a principal other than
180
+ // the bearer the proxy already forwarded.
181
+ expect(connectionReads[0].params ?? {}).toEqual({});
182
+ });
183
+ });
184
+
185
+ describe('listSources — a disconnected vendor is not a source', () => {
186
+ it('drops a connection the member has disconnected', async () => {
187
+ const sources = await sourcesFrom([SHARED_GRANOLA, MY_FATHOM, DISCONNECTED_DROPBOX]);
188
+
189
+ expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
190
+ expect(sources.some((source) => source.source === 'dropbox')).toBe(false);
191
+ });
192
+
193
+ it('never renders a disconnected vendor as healthy', async () => {
194
+ const [source] = await sourcesFrom([DISCONNECTED_DROPBOX]);
195
+
196
+ // If it is listed at all it must not read as a working source. Today it
197
+ // falls through every branch and comes back `never` — "Connected, but
198
+ // nothing has arrived yet" about an account that is not connected.
199
+ expect(source).toBeUndefined();
200
+ });
201
+
202
+ it('keeps a broken-but-live connection, which is a different thing entirely', async () => {
203
+ // `needs_reauth` and `error` are still connected accounts with a credential
204
+ // on file and a member who can fix them. Only the lifecycle-ended row goes.
205
+ const sources = await sourcesFrom([
206
+ { ...MY_FATHOM, status: 'needs_reauth', check_error: 'The vendor rejected this key (401).' },
207
+ SHARED_GRANOLA,
208
+ ]);
209
+
210
+ expect(sources.map((source) => source.source).sort()).toEqual(['fathom', 'granola']);
211
+ expect(sources.find((source) => source.source === 'fathom')?.status).toBe('error');
212
+ });
213
+ });
214
+
215
+ describe('the Library strip, rendered', () => {
216
+ it('draws a card for the member’s own source and none for the disconnected one', async () => {
217
+ const { http } = recorderOver([SHARED_GRANOLA, MY_FATHOM, DISCONNECTED_DROPBOX]);
218
+ const api = createCorpusApi({ http, scope: SCOPE });
219
+
220
+ render(<LibraryPage api={api} scope={SCOPE} />);
221
+
222
+ // Scoped to the strip itself: `listSources` also feeds the source filter's
223
+ // options, so an unscoped query would match the same vendor twice and say
224
+ // nothing about which surface it appeared on.
225
+ const strip = within(await screen.findByRole('region', { name: 'Source freshness' }));
226
+ expect(strip.getByText('Fathom')).toBeInTheDocument();
227
+ expect(strip.getByText('Granola')).toBeInTheDocument();
228
+ expect(strip.queryByText('Dropbox')).not.toBeInTheDocument();
229
+ });
230
+
231
+ it('does not blame a disconnected vendor in the empty state', async () => {
232
+ // `EmptyState` names every source whose status is `error`. A vendor nobody
233
+ // is connected to must not be reported as "failing to sync" — the action
234
+ // that sentence asks for (go fix the connection) is the wrong action.
235
+ const { http } = recorderOver([DISCONNECTED_DROPBOX]);
236
+ const api = createCorpusApi({ http, scope: SCOPE });
237
+
238
+ render(<LibraryPage api={api} scope={SCOPE} />);
239
+
240
+ expect(await screen.findByText(/Nothing has been pulled in yet/i)).toBeInTheDocument();
241
+ expect(screen.queryByText(/Dropbox/)).not.toBeInTheDocument();
242
+ });
243
+ });
package/src/api/index.ts CHANGED
@@ -3,4 +3,7 @@ export * from './calendar';
3
3
  export * from './email';
4
4
  export * from './accounts';
5
5
  export * from './connections';
6
+ // The MCP bearer mint (bd startsim-hj5zh / startsim-62dca). Central, not the
7
+ // tenant — it rides the tenant's own /api/v1/auth/ proxy, see the module.
8
+ export * from './mcp-tokens';
6
9
  export { setApiBase, getApiBase, setAuthTokenProvider } from './_fetch';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `/api/v1/auth/mcp-token/` — the long-lived bearer an MCP client holds for ONE
3
+ * foundry (bd startsim-hj5zh, surfaced by startsim-62dca).
4
+ *
5
+ * WHY THIS IS NOT A CONNECTION CLIENT even though it lives beside them. Every
6
+ * other client in this directory talks to the tenant or to the connectors API
7
+ * about a vendor account WE hold. This one talks to CENTRAL about a credential
8
+ * the user is about to hand to software we do not run. That inversion is the
9
+ * whole reason the composer over it is mostly warnings.
10
+ *
11
+ * IT MUST REACH CENTRAL, NOT THE TENANT. `request()` prefixes the configured
12
+ * base, and inside a tenant app that base points at the tenant's own same-origin
13
+ * proxy — which forwards `/api/v1/auth/*` to central (see the tenant nginx: the
14
+ * `/api/v1/auth/` location proxies to api.startsimpli.com, above the `/api/`
15
+ * location that would otherwise send it to the tenant's Django). So the ordinary
16
+ * base is correct here and no cross-origin call is made with a bearer on it.
17
+ * A client that hardcoded central's host would break that property.
18
+ *
19
+ * TWO VERBS, and the asymmetry is deliberate: `list` is safe to call on render,
20
+ * `mint` is not. Minting ISSUES A CREDENTIAL — it is not idempotent in any way a
21
+ * user cares about, because every call produces another bearer that stays valid
22
+ * until it expires and cannot be withdrawn (startsim-ja8mt). Nothing in this
23
+ * package may call `mint` from an effect.
24
+ */
25
+ import { request } from './_fetch';
26
+
27
+ /** A foundry the signed-in account may connect a client to. */
28
+ export interface McpFoundry {
29
+ slug: string;
30
+ name: string;
31
+ audience: string;
32
+ mcp_url: string;
33
+ state: string;
34
+ }
35
+
36
+ export interface McpFoundryList {
37
+ foundries: McpFoundry[];
38
+ default_ttl_days: number;
39
+ max_ttl_days: number;
40
+ }
41
+
42
+ /**
43
+ * The mint's answer. `token` is present exactly once, in this response, and is
44
+ * never retrievable again — the server stores no copy it could hand back.
45
+ */
46
+ export interface McpMintedToken {
47
+ token: string;
48
+ token_type: string;
49
+ foundry: string;
50
+ audience: string;
51
+ /**
52
+ * WHOSE data this reads. All three are DB-derived by central, never taken from
53
+ * the request, so this is the identity the tenant will actually act as — the
54
+ * thing a user has to see before pasting the token anywhere.
55
+ */
56
+ acting_as: { org_id: string | null; role: string | null; email: string | null };
57
+ jti: string | null;
58
+ issued_at: string;
59
+ expires_at: string;
60
+ mcp_url: string;
61
+ claude_code_command: string | null;
62
+ notice: string;
63
+ }
64
+
65
+ export interface McpTokensApi {
66
+ list(): Promise<McpFoundryList>;
67
+ mint(input: { foundry: string; ttl_days?: number }): Promise<McpMintedToken>;
68
+ }
69
+
70
+ const BASE = '/auth/mcp-token';
71
+
72
+ export const mcpTokens: McpTokensApi = {
73
+ /**
74
+ * The foundries this account holds an active seat in. Advisory — central
75
+ * refuses the mint independently — so a slug that appears here can still be
76
+ * denied, and the composer has to render that denial rather than assume the
77
+ * list was authoritative.
78
+ */
79
+ list(): Promise<McpFoundryList> {
80
+ return request<McpFoundryList>(`${BASE}/`);
81
+ },
82
+
83
+ mint(input: { foundry: string; ttl_days?: number }): Promise<McpMintedToken> {
84
+ return request<McpMintedToken>(`${BASE}/`, { method: 'POST', body: input });
85
+ },
86
+ };
@@ -12,6 +12,10 @@ export * from './ReconsentPromptCard';
12
12
  // one level into each app.
13
13
  export * from './CredentialSchemaForm';
14
14
  export * from './pages/ConnectionsPage';
15
+ // "Connect an MCP client" — a sibling composer, deliberately in the same place
16
+ // as ConnectionsPage: from a user's point of view both are "connect a thing to
17
+ // this workspace", even though this one hands a credential OUT (startsim-62dca).
18
+ export * from './pages/ConnectClaudePage';
15
19
  export type {
16
20
  ConnectionsApi,
17
21
  ConnectorCatalogEntry,
@@ -32,20 +36,3 @@ export type {
32
36
  export * from './pages/LibraryPage';
33
37
  export * from './pages/AskPage';
34
38
  export * from './pages/TrackerPage';
35
-
36
- // The planning toolset (bd startsim-8l6ef): focuses, and the items filed under
37
- // them. `work-items` carries the pieces the tracker and the planner SHARE — the
38
- // row, the honesty banner, the overdue/changed predicates — so two views over
39
- // one list cannot drift into disagreeing about what they mean.
40
- export * from './pages/PlanningPage';
41
- export {
42
- PRIORITY_LABELS,
43
- UnfilteredNotice,
44
- WORK_ITEM_DEFAULTS,
45
- WorkItemRow,
46
- changedSince,
47
- compareForScreen,
48
- isOverdue,
49
- priorityLabel,
50
- } from './pages/work-items';
51
- export type { WorkItemSlot } from './pages/work-items';
@@ -0,0 +1,270 @@
1
+ /**
2
+ * CONNECT AN MCP CLIENT to this workspace, as one composer (bd startsim-62dca).
3
+ *
4
+ * `<ConnectClaudePage api={mcpTokens} />` is the whole integration. An app owns
5
+ * theme classNames and a ten-line wrapper — same shape as `ConnectionsPage`, and
6
+ * for the same reason: shipping primitives without the page-level composer just
7
+ * moves the duplication up one level.
8
+ *
9
+ * THIS SCREEN IS MOSTLY WARNINGS, AND THAT IS THE DESIGN. Every other surface in
10
+ * this package helps somebody connect a vendor account WE hold. This one issues
11
+ * a credential the user then pastes into software we do not run, which reads
12
+ * their workspace as them, lasts weeks, and — until bd startsim-ja8mt lands —
13
+ * CANNOT BE WITHDRAWN before it expires. Deactivating the member's seat stops
14
+ * the next mint, not this token. A screen that rendered a copy button and no
15
+ * consequences would be lying by omission, so the consequences are not tucked
16
+ * into a tooltip: they are on the page, next to the token, in the four places
17
+ * the bead names.
18
+ *
19
+ * NOTHING HERE NAMES A FOUNDRY, a vendor, or a client product beyond the command
20
+ * the server itself composed. The foundry list arrives as data from central, and
21
+ * `claude_code_command` is built server-side from the tenant's own record — so a
22
+ * foundry on a vanity domain gets its own host rather than a pattern this file
23
+ * guessed. An `if (slug === ...)` here would undo that.
24
+ *
25
+ * MINTING IS NEVER AUTOMATIC. `list()` runs on mount; `mint()` runs only from a
26
+ * click. Every call to it issues another unrevokable bearer, so a re-render, a
27
+ * retry, or a `useEffect` that "just refreshes" would quietly scatter live
28
+ * credentials. That is why the mint state machine below is explicit rather than
29
+ * a query hook.
30
+ */
31
+ import * as React from 'react';
32
+
33
+ import type { McpFoundry, McpMintedToken, McpTokensApi } from '../../api/mcp-tokens';
34
+
35
+ type ClassNames = Partial<Record<
36
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error' | 'warning' | 'code', string
37
+ >>;
38
+
39
+ export interface ConnectClaudePageProps {
40
+ api: McpTokensApi;
41
+ classNames?: ClassNames;
42
+ /** Pre-select a foundry. Optional — with one seat the list is the answer. */
43
+ foundrySlug?: string;
44
+ }
45
+
46
+ const FALLBACK: Required<Pick<ClassNames,
47
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error' | 'warning' | 'code'>> = {
48
+ root: 'space-y-6',
49
+ section: 'space-y-3',
50
+ heading: 'text-lg font-semibold',
51
+ card: 'rounded-lg border p-4',
52
+ button: 'rounded-md border px-3 py-2 text-sm font-medium',
53
+ muted: 'text-sm opacity-70',
54
+ error: 'rounded-md border p-3 text-sm',
55
+ warning: 'rounded-md border p-3 text-sm',
56
+ code: 'block w-full overflow-x-auto rounded p-3 font-mono text-xs',
57
+ };
58
+
59
+ function cx(theme: ClassNames | undefined, key: keyof typeof FALLBACK): string {
60
+ return theme?.[key] ?? FALLBACK[key];
61
+ }
62
+
63
+ /** Local-date rendering, so "expires" is not an ISO string a person has to parse. */
64
+ function readableDate(iso: string): string {
65
+ const d = new Date(iso);
66
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, {
67
+ year: 'numeric', month: 'long', day: 'numeric',
68
+ });
69
+ }
70
+
71
+ function daysUntil(iso: string): number | null {
72
+ const d = new Date(iso);
73
+ if (Number.isNaN(d.getTime())) return null;
74
+ return Math.max(0, Math.round((d.getTime() - Date.now()) / 86_400_000));
75
+ }
76
+
77
+ export function ConnectClaudePage({ api, classNames, foundrySlug }: ConnectClaudePageProps) {
78
+ const [list, setList] = React.useState<{ foundries: McpFoundry[]; maxTtl: number; defaultTtl: number } | null>(null);
79
+ const [selected, setSelected] = React.useState<string>(foundrySlug ?? '');
80
+ const [ttlDays, setTtlDays] = React.useState<number | ''>('');
81
+ const [minted, setMinted] = React.useState<McpMintedToken | null>(null);
82
+ const [minting, setMinting] = React.useState(false);
83
+ const [error, setError] = React.useState<string>('');
84
+ const [copied, setCopied] = React.useState<'' | 'token' | 'command'>('');
85
+
86
+ React.useEffect(() => {
87
+ let live = true;
88
+ api.list().then(
89
+ (r) => {
90
+ if (!live) return;
91
+ setList({ foundries: r.foundries, maxTtl: r.max_ttl_days, defaultTtl: r.default_ttl_days });
92
+ setSelected((cur) => cur || (r.foundries.length === 1 ? r.foundries[0].slug : cur));
93
+ },
94
+ (e: unknown) => { if (live) setError(e instanceof Error ? e.message : String(e)); },
95
+ );
96
+ return () => { live = false; };
97
+ }, [api]);
98
+
99
+ async function doMint() {
100
+ if (!selected || minting) return;
101
+ setMinting(true);
102
+ setError('');
103
+ try {
104
+ // The ONLY call site. See the module docstring: nothing may mint from an
105
+ // effect, a retry, or a render.
106
+ const r = await api.mint({ foundry: selected, ...(ttlDays === '' ? {} : { ttl_days: ttlDays }) });
107
+ setMinted(r);
108
+ } catch (e: unknown) {
109
+ // A 403 here is the ordinary case, not an outage: the listing is advisory
110
+ // and central decides membership independently. Show what it said.
111
+ setError(e instanceof Error ? e.message : String(e));
112
+ } finally {
113
+ setMinting(false);
114
+ }
115
+ }
116
+
117
+ async function copy(text: string, which: 'token' | 'command') {
118
+ try {
119
+ await navigator.clipboard.writeText(text);
120
+ setCopied(which);
121
+ window.setTimeout(() => setCopied(''), 2000);
122
+ } catch {
123
+ // A denied clipboard permission must not read as a failed mint — the token
124
+ // is on screen and selectable either way.
125
+ setCopied('');
126
+ }
127
+ }
128
+
129
+ if (error && !list) {
130
+ return <div className={cx(classNames, 'error')} role="alert">{error}</div>;
131
+ }
132
+ if (!list) {
133
+ return <p className={cx(classNames, 'muted')}>Loading…</p>;
134
+ }
135
+ if (list.foundries.length === 0) {
136
+ return (
137
+ <div className={cx(classNames, 'card')}>
138
+ <p className={cx(classNames, 'muted')}>
139
+ You are not a member of any workspace that can be connected yet. Ask an
140
+ owner or admin to invite you, then come back.
141
+ </p>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ const chosen = list.foundries.find((f) => f.slug === selected);
147
+
148
+ return (
149
+ <div className={cx(classNames, 'root')}>
150
+ {!minted && (
151
+ <section className={cx(classNames, 'section')}>
152
+ {list.foundries.length > 1 && (
153
+ <label className="block">
154
+ <span className={cx(classNames, 'heading')}>Which workspace?</span>
155
+ <select
156
+ className="mt-1 block w-full rounded-md border px-3 py-2"
157
+ value={selected}
158
+ onChange={(e) => setSelected(e.target.value)}
159
+ >
160
+ <option value="">Choose…</option>
161
+ {list.foundries.map((f) => (
162
+ <option key={f.slug} value={f.slug}>{f.name}</option>
163
+ ))}
164
+ </select>
165
+ </label>
166
+ )}
167
+
168
+ <label className="block">
169
+ <span className={cx(classNames, 'heading')}>How long should it last?</span>
170
+ <input
171
+ type="number"
172
+ min={1}
173
+ max={list.maxTtl}
174
+ placeholder={`${list.defaultTtl} days`}
175
+ className="mt-1 block w-full rounded-md border px-3 py-2"
176
+ value={ttlDays}
177
+ onChange={(e) => setTtlDays(e.target.value === '' ? '' : Number(e.target.value))}
178
+ />
179
+ <span className={cx(classNames, 'muted')}>
180
+ Up to {list.maxTtl} days. Shorter is safer — this cannot be
181
+ cancelled early, so the expiry date is the only way it stops working.
182
+ </span>
183
+ </label>
184
+
185
+ {/* The consequences, BEFORE the button rather than after the token.
186
+ A warning that appears only once the credential exists is a warning
187
+ nobody can act on. */}
188
+ <div className={cx(classNames, 'warning')} role="note">
189
+ <strong>Before you create this:</strong> it reads this workspace <em>as
190
+ you</em> — whatever you can see, it can see. It is shown once and
191
+ cannot be retrieved again, and it <strong>cannot be cancelled before
192
+ it expires</strong>. Turning off your access to the workspace stops
193
+ new ones being made; it does not switch this one off.
194
+ </div>
195
+
196
+ <button
197
+ type="button"
198
+ className={cx(classNames, 'button')}
199
+ disabled={!selected || minting}
200
+ onClick={doMint}
201
+ >
202
+ {minting ? 'Creating…' : 'Create connection token'}
203
+ </button>
204
+
205
+ {error && <div className={cx(classNames, 'error')} role="alert">{error}</div>}
206
+ </section>
207
+ )}
208
+
209
+ {minted && (
210
+ <section className={cx(classNames, 'section')}>
211
+ <h2 className={cx(classNames, 'heading')}>Your token — copy it now</h2>
212
+
213
+ {/* WHOSE DATA, stated first. The mint derives these server-side from
214
+ the DB, so this is the identity the workspace will actually act as
215
+ — not what the browser asked for. */}
216
+ <div className={cx(classNames, 'card')}>
217
+ <p className={cx(classNames, 'muted')}>This connection reads</p>
218
+ <p>
219
+ <strong>{minted.foundry}</strong>
220
+ {minted.acting_as.email ? <> as <strong>{minted.acting_as.email}</strong></> : null}
221
+ {minted.acting_as.role ? <> ({minted.acting_as.role})</> : null}
222
+ </p>
223
+ <p className={cx(classNames, 'muted')}>
224
+ Expires {readableDate(minted.expires_at)}
225
+ {daysUntil(minted.expires_at) !== null ? ` — about ${daysUntil(minted.expires_at)} days` : ''}
226
+ </p>
227
+ </div>
228
+
229
+ <div className={cx(classNames, 'warning')} role="alert">
230
+ {minted.notice}
231
+ </div>
232
+
233
+ {minted.claude_code_command && (
234
+ <div className={cx(classNames, 'section')}>
235
+ <p className={cx(classNames, 'muted')}>Run this in your terminal:</p>
236
+ <code className={cx(classNames, 'code')}>{minted.claude_code_command}</code>
237
+ <button
238
+ type="button"
239
+ className={cx(classNames, 'button')}
240
+ onClick={() => copy(minted.claude_code_command as string, 'command')}
241
+ >
242
+ {copied === 'command' ? 'Copied' : 'Copy command'}
243
+ </button>
244
+ </div>
245
+ )}
246
+
247
+ <details>
248
+ <summary className={cx(classNames, 'muted')}>Just the token</summary>
249
+ <code className={cx(classNames, 'code')}>{minted.token}</code>
250
+ <button
251
+ type="button"
252
+ className={cx(classNames, 'button')}
253
+ onClick={() => copy(minted.token, 'token')}
254
+ >
255
+ {copied === 'token' ? 'Copied' : 'Copy token'}
256
+ </button>
257
+ </details>
258
+
259
+ <button
260
+ type="button"
261
+ className={cx(classNames, 'button')}
262
+ onClick={() => { setMinted(null); setCopied(''); }}
263
+ >
264
+ Done
265
+ </button>
266
+ </section>
267
+ )}
268
+ </div>
269
+ );
270
+ }
@@ -292,6 +292,42 @@ function at(value: unknown): string | null {
292
292
  return typeof value === 'string' && value.trim() ? value : null;
293
293
  }
294
294
 
295
+ /**
296
+ * Connection lifecycle states in which the account is NO LONGER CONNECTED
297
+ * (bd startsim-bu04n).
298
+ *
299
+ * `Connection.Status` is `connected | needs_reauth | error | disabled`, and only
300
+ * the last one has ended: the member disconnected it. Central's member surface
301
+ * documents what that leaves behind — "DISCONNECT, not delete. One transaction
302
+ * stops the scheduled fetches, revokes every token issued for the account and
303
+ * deletes the stored credential; the card survives as `disabled`, so the record
304
+ * that this vendor was connected is not thrown away."
305
+ *
306
+ * The card surviving is right for the connections screen, where a member manages
307
+ * it and may reconnect. It is wrong for the Library's source strip, whose whole
308
+ * question is "where is my corpus coming from, and is it current". A vendor with
309
+ * no credential and no schedule is not coming from anywhere, and it used to fall
310
+ * through every branch below to `never` — "Connected, but nothing has arrived
311
+ * yet" printed about an account that is not connected.
312
+ *
313
+ * A STATUS, NOT A VENDOR. This reads the lifecycle field central writes, so a
314
+ * connector added tomorrow is handled with no code change here. A list of
315
+ * connector keys would be exactly the per-vendor branch the DB-driven catalog
316
+ * exists to prevent, and the reported symptom ("it shows old, disconnected
317
+ * ones") is precisely the symptom such a list would paper over.
318
+ *
319
+ * AN UNRECOGNISED STATUS IS KEPT, deliberately. `needs_reauth` and `error` are
320
+ * live accounts with a credential on file and a member who can fix them; the
321
+ * failure mode of hiding one of those — a real source silently absent from the
322
+ * strip — is worse than the failure mode of showing a status this build has not
323
+ * heard of yet. A new terminal state is added here on purpose, not by default.
324
+ */
325
+ const RETIRED_CONNECTION_STATUSES: ReadonlySet<string> = new Set(['disabled']);
326
+
327
+ function isALiveSource(connection: Record<string, unknown>): boolean {
328
+ return !RETIRED_CONNECTION_STATUSES.has(String(connection.status ?? ''));
329
+ }
330
+
295
331
  /** Rows written in parallel. Small enough not to look like a burst to the tenant. */
296
332
  const WRITE_CONCURRENCY = 6;
297
333
 
@@ -501,14 +537,6 @@ export function createCorpusApi(
501
537
  };
502
538
  },
503
539
 
504
- /**
505
- * Freshness per SOURCE, assembled from the connections the tenant has.
506
- *
507
- * It reads the connections API rather than the documents, because "Dropbox
508
- * has not synced since Tuesday" is a true and useful statement even when
509
- * Dropbox has contributed zero documents — and a count derived from the
510
- * corpus alone can never say it.
511
- */
512
540
  /**
513
541
  * The tracking surface's read (bd startsim-768w.38.25).
514
542
  *
@@ -612,6 +640,41 @@ export function createCorpusApi(
612
640
  return durable as ExtractionRun;
613
641
  },
614
642
 
643
+ /**
644
+ * Freshness per SOURCE, assembled from the connections behind this workspace.
645
+ *
646
+ * It reads the connections API rather than the documents, because "Dropbox
647
+ * has not synced since Tuesday" is a true and useful statement even when
648
+ * Dropbox has contributed zero documents — and a count derived from the
649
+ * corpus alone can never say it.
650
+ *
651
+ * WHERE THE ROWS ACTUALLY COME FROM (bd startsim-bu04n), because the name of
652
+ * the default path invites the wrong guess. `api/v1/connections` on a tenant
653
+ * fork is NOT a tenant table: `tenant-starter`'s `ConnectionsView` forwards
654
+ * the caller's already-verified bearer to central's
655
+ * `/api/v1/subtree/connections/` and returns the page envelope untouched
656
+ * (bd startsim-768w.38.19). This read is a central read wearing a
657
+ * same-origin path, which is why every field below is central's.
658
+ *
659
+ * WHICH CONNECTIONS THAT SURFACE HANDS BACK is central's decision and not
660
+ * this adapter's. `SubtreeConnectionsView` resolves through
661
+ * `Connection.objects.for_scope(...)` — the foundry's SHARED rows — and a
662
+ * member's own vendor accounts join the page only when central unions
663
+ * `for_owner(orgs, request.user)` in, bounded to the requesting user. That
664
+ * bound is what keeps a colleague's personal connection off this strip
665
+ * (bd startsim-768w.22.25.11), and it is the reason this adapter must not
666
+ * reach for `/api/v1/connections/me/` to fill the gap itself: besides
667
+ * splitting the answer across two servers, that serializer carries
668
+ * `last_checked_at` and NONE of the other freshness facts, so every personal
669
+ * card built from it would report a verified credential as delivered data —
670
+ * exactly the lie bd startsim-1cow removed. Rows are read from one endpoint,
671
+ * as handed.
672
+ *
673
+ * `documentCount` is whatever the connections payload says and is therefore
674
+ * `0` today: no connections endpoint sends `document_count`, and a per-source
675
+ * count from the corpus needs the `?source=` filter bd startsim-768w.38.12
676
+ * is still open on. Tracked as bd startsim-o0o6o.1.
677
+ */
615
678
  async listSources(): Promise<SourceFreshness[]> {
616
679
  const [connectionPage, documentPage] = await Promise.all([
617
680
  http
@@ -625,7 +688,7 @@ export function createCorpusApi(
625
688
  void documentPage; // total count is per-source below; kept for the empty-corpus case
626
689
 
627
690
  const now = Date.now();
628
- return (connectionPage.results ?? []).map((connection) => {
691
+ return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
629
692
  const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
630
693
  // THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
631
694
  // chain ending in `last_checked_at`, and central's own view says of that