@startsimpli/integrations 0.2.26 → 0.2.28

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.26",
3
+ "version": "0.2.28",
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,319 @@
1
+ /**
2
+ * OPENING ONE RECORD (bd startsim-4s4um RED / startsim-2lt8y GREEN).
3
+ *
4
+ * REPORTED, with a live URL:
5
+ * `https://tandem.ai.startsimpli.com/library?doc=5ab48b58-8573-4114-b408-215dd0eaf5d7`
6
+ * renders no document.
7
+ *
8
+ * WHAT THE TENANT ACTUALLY DOES, read out of
9
+ * `start-simpli-api/tenant-starter/apps/api/{views,filters}.py` @ origin/main
10
+ * rather than assumed:
11
+ *
12
+ * - `/api/v1/entities/<id>/` is a real DRF detail route. It answers the ONE
13
+ * row, or 404 when the row is absent for this reader — the read gate makes a
14
+ * forbidden row ABSENT rather than refused, deliberately.
15
+ * - `/api/v1/entities/` applies `?type=` plus a fixed vocabulary
16
+ * (`search`, `source`, `party`, `occurred_after`, `occurred_before`,
17
+ * `related_*`, `order`) and `attr.*`. `_note_unknown` sweeps EVERY OTHER
18
+ * KEY into `ignored_filters` and returns the page unnarrowed. `id` is not in
19
+ * that vocabulary, which is why `?id=<uuid>` comes back with count 180.
20
+ *
21
+ * SO THE FAKE BELOW IGNORES UNKNOWN PARAMETERS RATHER THAN ERRORING ON THEM,
22
+ * and that is the whole point of writing a new one instead of reaching for
23
+ * `createFixtureCorpusHttp`: that fixture applies `?search=` when a backend is
24
+ * named and never reports `ignored_filters`, so a lookup expressed as a filter
25
+ * would come back plausibly narrowed there. A fake that answers an unsupported
26
+ * filter with `[]` — or with a filtered page — makes this suite green while the
27
+ * deployed Library stays blank, which is the exact trap this bead names.
28
+ *
29
+ * WHAT IS NOT THE BUG, established before any of this was written. The DEPLOYED
30
+ * bundle on tandem.ai.startsimpli.com already asks the detail route:
31
+ *
32
+ * getDocument(e){let[a,r]=await Promise.all([
33
+ * t.get(`${m}/${encodeURIComponent(e)}`),f()]);...}
34
+ *
35
+ * and that slash-less path resolves — `curl` returns 401 with
36
+ * `allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS`, the detail route's own verb
37
+ * set, not a redirect and not the list's. The request shape is right. What is
38
+ * missing is everything that happens when it does not come back: the client
39
+ * accepts whatever it is handed without checking it is the record it asked for,
40
+ * and the composer turns every failure into an empty screen.
41
+ */
42
+ import { describe, expect, it } from 'vitest';
43
+ import { render, screen, within } from '@testing-library/react';
44
+
45
+ import { LibraryPage } from '../components/pages/LibraryPage';
46
+ import { createCorpusApi } from '../corpus/client';
47
+ import type { CorpusHttp } from '../corpus/client';
48
+ import type { TenantEntity } from '../corpus/entities';
49
+ import type { CorpusScope } from '../corpus/types';
50
+
51
+ const SCOPE: CorpusScope = { orgId: 'org_tandem' };
52
+
53
+ /** The record the user deep-linked, live, when they reported this. */
54
+ const OPENED = '5ab48b58-8573-4114-b408-215dd0eaf5d7';
55
+ /** A well-formed id for a row that is simply not there. */
56
+ const ABSENT = '00000000-0000-4000-8000-000000000000';
57
+
58
+ function document(id: string, title: string, index: number): TenantEntity {
59
+ return {
60
+ id,
61
+ entity_type: 'source_document',
62
+ external_id: `granola:${id}`,
63
+ name: title,
64
+ org_id: SCOPE.orgId,
65
+ created_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T10:00:00Z`,
66
+ data: {
67
+ source: 'granola',
68
+ title,
69
+ summary: `Summary of ${title}.`,
70
+ occurred_at: `2026-08-${String((index % 27) + 1).padStart(2, '0')}T09:00:00Z`,
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A corpus the size of the reported one, with the deep-linked record buried
77
+ * where the composer's on-page shortcut cannot reach it.
78
+ *
79
+ * THE POSITION MATTERS. The record is 100 rows in, so page one does not contain
80
+ * it and `documents.find(...)` misses — which is precisely the state the
81
+ * reported URL lands in and the only state where the lookup is exercised at all.
82
+ */
83
+ const CORPUS: TenantEntity[] = Array.from({ length: 180 }, (_, index) =>
84
+ index === 100
85
+ ? document(OPENED, 'Tandem <> Obwb quarterly review', index)
86
+ : document(`doc-${index}`, `Record ${index}`, index),
87
+ );
88
+
89
+ /** Parameters this tenant recognises on `/entities/`. Everything else is ignored. */
90
+ const HONOURED = new Set([
91
+ 'search', 'source', 'party', 'occurred_after', 'occurred_before',
92
+ 'related_to', 'related_via', 'related_direction', 'related_depth', 'order',
93
+ ]);
94
+ const RESERVED = new Set(['page', 'page_size', 'format', 'type']);
95
+
96
+ interface Recorded {
97
+ path: string;
98
+ params: Record<string, unknown>;
99
+ }
100
+
101
+ /**
102
+ * The tenant, as the tenant is.
103
+ *
104
+ * `detailStatus` stands in for the read gate and the session: the tenant answers
105
+ * an absent row with 404 and an expired bearer with 401, and those are different
106
+ * facts that must not collapse into the same screen.
107
+ */
108
+ function tenantHttp(options: {
109
+ entities?: TenantEntity[];
110
+ pageSize?: number;
111
+ detailStatus?: number;
112
+ } = {}) {
113
+ const rows = options.entities ?? CORPUS;
114
+ const pageSize = options.pageSize ?? 20;
115
+ const requests: Recorded[] = [];
116
+
117
+ const http: CorpusHttp = {
118
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
119
+ const params = init?.params ?? {};
120
+ requests.push({ path, params });
121
+
122
+ if (path.includes('relationships')) {
123
+ return { count: 0, next: null, previous: null, results: [] } as T;
124
+ }
125
+ if (path.includes('connections')) {
126
+ return { count: 0, next: null, previous: null, results: [] } as T;
127
+ }
128
+ if (!path.includes('entities')) {
129
+ throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
130
+ }
131
+
132
+ // BOTH SPELLINGS OF THE DETAIL ROUTE, because both were measured to
133
+ // resolve on the live tenant. A fake that insisted on the trailing slash
134
+ // would manufacture evidence for a path change the wire does not ask for.
135
+ const detail = /entities\/([^/?]+)\/?$/.exec(path)?.[1];
136
+ if (detail) {
137
+ if (options.detailStatus) {
138
+ throw Object.assign(new Error(`${path} responded ${options.detailStatus}`), {
139
+ status: options.detailStatus,
140
+ });
141
+ }
142
+ const found = rows.find((row) => String(row.id) === decodeURIComponent(detail));
143
+ // ABSENT, NOT REFUSED — `readable_types_q` / `readable_rows_q` narrow the
144
+ // queryset, so a row this reader may not see 404s exactly like one that
145
+ // was never written.
146
+ if (!found) throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
147
+ return found as T;
148
+ }
149
+
150
+ // The LIST. `?type=` narrows; every other key is swept into
151
+ // `ignored_filters` and the page comes back exactly as wide as it was.
152
+ const type = params.type ? String(params.type) : '';
153
+ const matching = type ? rows.filter((row) => row.entity_type === type) : rows;
154
+ const size = Number(params.page_size ?? pageSize) || pageSize;
155
+ const page = Number(params.page ?? 1) || 1;
156
+ const start = (page - 1) * size;
157
+ const applied: string[] = [];
158
+ const ignored: string[] = [];
159
+ for (const key of Object.keys(params)) {
160
+ if (RESERVED.has(key)) continue;
161
+ (HONOURED.has(key) ? applied : ignored).push(key);
162
+ }
163
+ return {
164
+ count: matching.length,
165
+ next: start + size < matching.length ? `?page=${page + 1}` : null,
166
+ previous: page > 1 ? `?page=${page - 1}` : null,
167
+ results: matching.slice(start, start + size),
168
+ applied_filters: applied,
169
+ ignored_filters: ignored,
170
+ } as T;
171
+ },
172
+ };
173
+
174
+ return { http, requests };
175
+ }
176
+
177
+ /**
178
+ * The tenant as it answers a lookup that was expressed as a FILTER.
179
+ *
180
+ * `GET /api/v1/entities/?id=5ab48b58-…` was checked against prod for this epic
181
+ * and returned 200 with count 180 — the whole corpus, unnarrowed, because `id`
182
+ * is not a filter this API has. Any client that reaches the entity list instead
183
+ * of the detail route gets THIS: a page envelope where it expected a row.
184
+ *
185
+ * The contract under test is that such a response is a FAILURE, not a document.
186
+ * Mapping it produces a record with no id and no title that renders as a blank
187
+ * panel — an arbitrary answer wearing a real record's clothes, which is worse
188
+ * than an error because nobody can tell it is wrong.
189
+ */
190
+ function pageEnvelopeHttp() {
191
+ const { requests } = tenantHttp();
192
+ const http: CorpusHttp = {
193
+ async get<T>(path: string, init?: { params?: Record<string, unknown> }): Promise<T> {
194
+ requests.push({ path, params: init?.params ?? {} });
195
+ if (path.includes('entities')) {
196
+ return {
197
+ count: CORPUS.length,
198
+ next: '?page=2',
199
+ previous: null,
200
+ results: CORPUS.slice(0, 20),
201
+ ignored_filters: ['id'],
202
+ } as T;
203
+ }
204
+ return { count: 0, next: null, previous: null, results: [] } as T;
205
+ },
206
+ };
207
+ return { http, requests };
208
+ }
209
+
210
+ function corpusOver(http: CorpusHttp) {
211
+ return createCorpusApi({ http, scope: SCOPE, supportedFilters: [] });
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // the client
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe('a document lookup returns THAT document', () => {
219
+ it('answers with the record that was asked for, not the first of many', async () => {
220
+ const api = corpusOver(tenantHttp().http);
221
+ const found = await api.getDocument(OPENED);
222
+ expect(found.id).toBe(OPENED);
223
+ expect(found.title).toBe('Tandem <> Obwb quarterly review');
224
+ });
225
+
226
+ it('asks the DETAIL route, and never expresses the id as a filter', async () => {
227
+ // The distinction the whole bead turns on. `?id=` is swept into
228
+ // `ignored_filters` by this API, so a lookup spelled that way is handed the
229
+ // unfiltered corpus and has to guess.
230
+ const { http, requests } = tenantHttp();
231
+ await corpusOver(http).getDocument(OPENED);
232
+
233
+ const lookup = requests.find((request) => request.path.includes(OPENED));
234
+ expect(lookup, 'the id belongs in the PATH').toBeTruthy();
235
+ expect(
236
+ requests.every((request) => !('id' in request.params)),
237
+ 'no request may carry the id as a query parameter',
238
+ ).toBe(true);
239
+ });
240
+
241
+ it('refuses a page envelope instead of fabricating a record from it', async () => {
242
+ // A response that is a LIST is not the record, whatever produced it — an
243
+ // ignored `?id=`, a proxy that dropped the path segment, a deployment whose
244
+ // detail route is a list. Mapping it yields id `undefined` and the title
245
+ // "Untitled unknown record": a blank panel that claims to be the document.
246
+ const api = corpusOver(pageEnvelopeHttp().http);
247
+ await expect(api.getDocument(OPENED)).rejects.toMatchObject({ status: 404 });
248
+ });
249
+
250
+ it('surfaces a record that is genuinely absent as not-found', async () => {
251
+ const api = corpusOver(tenantHttp().http);
252
+ await expect(api.getDocument(ABSENT)).rejects.toMatchObject({ status: 404 });
253
+ });
254
+
255
+ it('never answers one id with a different record', async () => {
256
+ const api = corpusOver(tenantHttp().http);
257
+ const found = await api.getDocument('doc-3');
258
+ expect(found.id).toBe('doc-3');
259
+ await expect(api.getDocument(ABSENT)).rejects.toBeTruthy();
260
+ });
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // the screen
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('the Library opens the record it was deep-linked to', () => {
268
+ it('renders a record that is not on the first page', async () => {
269
+ const api = corpusOver(tenantHttp().http);
270
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
271
+
272
+ expect(
273
+ await screen.findByRole('region', { name: 'Record detail' }),
274
+ ).toHaveTextContent('Tandem <> Obwb quarterly review');
275
+ });
276
+
277
+ it('SAYS SO when the record cannot be found, instead of rendering nothing', async () => {
278
+ // THE REPORTED SYMPTOM. `?doc=<id>` in the address bar, a list on screen,
279
+ // and no record and no explanation — the composer swallowed the failure into
280
+ // `setSelected(null)`. A screen that asked for a record and shows neither the
281
+ // record nor a reason is the one outcome the user cannot act on.
282
+ const api = corpusOver(tenantHttp().http);
283
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
284
+
285
+ const notice = await screen.findByRole('region', { name: 'Record' });
286
+ expect(notice).toHaveTextContent(/could not be found/i);
287
+ expect(notice).toHaveTextContent(new RegExp(ABSENT));
288
+ });
289
+
290
+ it('calls an expired session an expired session, not a missing record', async () => {
291
+ // "Sign in again" and "that record is gone" send the reader to different
292
+ // places, and only one of them is true at a time.
293
+ const api = corpusOver(tenantHttp({ detailStatus: 401 }).http);
294
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
295
+
296
+ const notice = await screen.findByRole('region', { name: 'Record' });
297
+ expect(notice).toHaveTextContent(/session has expired/i);
298
+ expect(notice).not.toHaveTextContent(/could not be found/i);
299
+ });
300
+
301
+ it('calls a refusal a refusal', async () => {
302
+ const api = corpusOver(tenantHttp({ detailStatus: 403 }).http);
303
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={OPENED} />);
304
+
305
+ expect(await screen.findByRole('region', { name: 'Record' })).toHaveTextContent(
306
+ /do not have access/i,
307
+ );
308
+ });
309
+
310
+ it('lets the reader dismiss the notice and get back to the list', async () => {
311
+ // Without this the `?doc=` in the URL is a dead end: the panel cannot be
312
+ // closed because there is no panel, only a message.
313
+ const api = corpusOver(tenantHttp().http);
314
+ render(<LibraryPage api={api} scope={SCOPE} initialDocumentId={ABSENT} />);
315
+
316
+ const notice = await screen.findByRole('region', { name: 'Record' });
317
+ expect(within(notice).getByRole('button', { name: /close/i })).toBeInTheDocument();
318
+ });
319
+ });
@@ -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
+ });
@@ -85,6 +85,29 @@ function readFailureMessage(err: unknown): string {
85
85
  return (err as { message?: string })?.message || 'Could not load the library.';
86
86
  }
87
87
 
88
+ /**
89
+ * Why the record the screen was asked to open is not on the screen
90
+ * (bd startsim-2lt8y).
91
+ *
92
+ * FOUR ANSWERS, NOT ONE, and the branch is the point: "sign in again", "you may
93
+ * not see this", "that record is not here" and "something went wrong" send the
94
+ * reader to four different places, and only one of them is true at a time.
95
+ * Calling an expired session a missing record is a different lie from the one
96
+ * this bead is fixing, not a smaller version of it.
97
+ *
98
+ * The id is NAMED because it came out of the address bar. A citation or a
99
+ * bookmark that no longer resolves is worth being able to quote back to
100
+ * somebody, and "a record" is not quotable.
101
+ */
102
+ function openFailureMessage(err: unknown, id: string): string {
103
+ const status = (err as { status?: number })?.status;
104
+ if (status === 404) {
105
+ return `That record could not be found in this workspace (${id}). It may have been removed, or it may not be one you can see.`;
106
+ }
107
+ if (status === 401 || status === 403) return readFailureMessage(err);
108
+ return (err as { message?: string })?.message || `That record could not be opened (${id}).`;
109
+ }
110
+
88
111
  function formatDate(value: string | null | undefined): string {
89
112
  if (!value) return 'No date';
90
113
  const at = Date.parse(value);
@@ -160,6 +183,13 @@ export function LibraryPage({
160
183
  const [parties, setParties] = React.useState<Party[]>([]);
161
184
  const [selectedId, setSelectedId] = React.useState<string | null>(initialDocumentId ?? null);
162
185
  const [selected, setSelected] = React.useState<SourceDocument | null>(null);
186
+ /**
187
+ * Why the open record is not open. Held separately from `loadError`, which is
188
+ * about the LIST: a Library that loaded fine and a citation that no longer
189
+ * resolves are different facts, and blanking the list for the second one would
190
+ * be a worse answer than the silence this replaces.
191
+ */
192
+ const [selectError, setSelectError] = React.useState('');
163
193
  const [loading, setLoading] = React.useState(true);
164
194
  const [loadError, setLoadError] = React.useState('');
165
195
  /**
@@ -216,18 +246,22 @@ export function LibraryPage({
216
246
  }, [api, filters, scope]);
217
247
 
218
248
  React.useEffect(() => {
219
- if (!selectedId) { setSelected(null); return; }
249
+ if (!selectedId) { setSelected(null); setSelectError(''); return; }
220
250
  let live = true;
221
251
  void (async () => {
222
252
  // Prefer the row already on screen; fall back to a fetch so a citation
223
253
  // deep-link opens a record that is not on the current page.
224
254
  const onPage = documents.find((document) => document.id === selectedId);
225
- if (onPage) { setSelected(onPage); return; }
255
+ if (onPage) { setSelected(onPage); setSelectError(''); return; }
226
256
  try {
227
257
  const fetched = await api.getDocument(selectedId);
228
- if (live) setSelected(fetched);
229
- } catch {
230
- if (live) setSelected(null);
258
+ if (live) { setSelected(fetched); setSelectError(''); }
259
+ } catch (err) {
260
+ // THE REPORTED BUG (bd startsim-4s4um). This was `setSelected(null)` and
261
+ // nothing else, so `?doc=<id>` in the address bar produced a Library
262
+ // with no record and no explanation — the one outcome a reader cannot
263
+ // act on, and indistinguishable from a page that simply ignored them.
264
+ if (live) { setSelected(null); setSelectError(openFailureMessage(err, selectedId)); }
231
265
  }
232
266
  })();
233
267
  return () => { live = false; };
@@ -235,6 +269,7 @@ export function LibraryPage({
235
269
 
236
270
  const select = React.useCallback((id: string | null) => {
237
271
  setSelectedId(id);
272
+ if (!id) setSelectError('');
238
273
  onSelectDocument?.(id);
239
274
  }, [onSelectDocument]);
240
275
 
@@ -459,6 +494,16 @@ export function LibraryPage({
459
494
  onFilterByParty={(partyId) => update({ partyId })}
460
495
  />
461
496
  ) : null}
497
+
498
+ {/* Asked for, and not shown — said out loud rather than left blank. */}
499
+ {!selected && selectError ? (
500
+ <section className={cn.card} aria-label="Record">
501
+ <div className="flex items-start justify-between gap-4">
502
+ <p className={cn.warning} role="alert">{selectError}</p>
503
+ <button type="button" className={cn.button} onClick={() => select(null)}>Close</button>
504
+ </div>
505
+ </section>
506
+ ) : null}
462
507
  </div>
463
508
  );
464
509
  }
@@ -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
 
@@ -320,6 +356,38 @@ function appliedFilters(
320
356
  return requested.filter((key) => supported.includes(key));
321
357
  }
322
358
 
359
+ /**
360
+ * Is this response the ROW that was asked for, or something else wearing a 200?
361
+ *
362
+ * Three things are refused, and each of them has been seen answering a
363
+ * single-record request somewhere: a DRF page (what an ignored `?id=` returns —
364
+ * verified on the live tenant, 200 with count 180), a body with no id at all,
365
+ * and a row whose id is somebody else's.
366
+ */
367
+ function isRecord(value: unknown, id: string): value is TenantEntity {
368
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
369
+ // A page is the shape a LIST answers with. It is never one record, whatever
370
+ // its count happens to be.
371
+ if (Array.isArray((value as { results?: unknown }).results)) return false;
372
+ const own = (value as TenantEntity).id;
373
+ if (own === undefined || own === null || String(own).trim() === '') return false;
374
+ // Case-insensitively, because a UUID has two legal spellings and the server
375
+ // answers in its own. Anything else different is a DIFFERENT RECORD, which is
376
+ // the entire reason this function exists.
377
+ return String(own).trim().toLowerCase() === String(id).trim().toLowerCase();
378
+ }
379
+
380
+ /**
381
+ * The tenant's own word for a row that is not there.
382
+ *
383
+ * 404 and not a bespoke error, because the tenant's read gate makes a row this
384
+ * principal may not see ABSENT rather than refused — so callers get one status
385
+ * to branch on, and "not found" is already the honest word for both.
386
+ */
387
+ function notFound(id: string): Error & { status: number } {
388
+ return Object.assign(new Error(`No record ${id} in this workspace.`), { status: 404 });
389
+ }
390
+
323
391
  export function createCorpusApi(
324
392
  options: CreateCorpusApiOptions,
325
393
  ): CorpusApi & CommitmentApi & ExtractionRunStore {
@@ -423,11 +491,34 @@ export function createCorpusApi(
423
491
  };
424
492
  },
425
493
 
494
+ /**
495
+ * ONE record, by the DETAIL ROUTE — never by a filter (bd startsim-2lt8y).
496
+ *
497
+ * `/api/v1/entities/<id>/` is a real route on the tenant and it answers the
498
+ * one row. The entity LIST has no `id` filter: `EntityQuery._note_unknown`
499
+ * sweeps every unrecognised parameter into `ignored_filters` and returns the
500
+ * page exactly as wide as it was, so `?id=<uuid>` comes back 200 with the
501
+ * whole corpus. A lookup spelled that way cannot fail — it just answers with
502
+ * somebody else's record.
503
+ *
504
+ * WHICH IS WHY THE RESPONSE IS CHECKED. Asking the right route is not the
505
+ * same as being answered by it: a proxy that drops the path segment, a
506
+ * deployment whose detail route lists, an `?id=` that got ignored upstream —
507
+ * all hand back a DRF page where a row was expected, and `toSourceDocument`
508
+ * will map it without complaint into a document with the id `undefined` and
509
+ * the title "Untitled unknown record". That renders as a blank panel that
510
+ * claims to be the record, which is worse than an error because nobody
511
+ * downstream can tell it is wrong. So a response that is not the row that
512
+ * was asked for is a 404, reported the same way the tenant reports an absent
513
+ * row — the read gate makes a forbidden row ABSENT rather than refused, so
514
+ * "not found" is already the honest word for both.
515
+ */
426
516
  async getDocument(id) {
427
517
  const [entity, byRecord] = await Promise.all([
428
518
  http.get<TenantEntity>(`${entities}/${encodeURIComponent(id)}`),
429
519
  participantGraph(),
430
520
  ]);
521
+ if (!isRecord(entity, id)) throw notFound(id);
431
522
  return toSourceDocument(entity, scope, sourceLabels, byRecord.get(String(entity.id)) ?? []);
432
523
  },
433
524
 
@@ -446,14 +537,6 @@ export function createCorpusApi(
446
537
  };
447
538
  },
448
539
 
449
- /**
450
- * Freshness per SOURCE, assembled from the connections the tenant has.
451
- *
452
- * It reads the connections API rather than the documents, because "Dropbox
453
- * has not synced since Tuesday" is a true and useful statement even when
454
- * Dropbox has contributed zero documents — and a count derived from the
455
- * corpus alone can never say it.
456
- */
457
540
  /**
458
541
  * The tracking surface's read (bd startsim-768w.38.25).
459
542
  *
@@ -557,6 +640,41 @@ export function createCorpusApi(
557
640
  return durable as ExtractionRun;
558
641
  },
559
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
+ */
560
678
  async listSources(): Promise<SourceFreshness[]> {
561
679
  const [connectionPage, documentPage] = await Promise.all([
562
680
  http
@@ -570,7 +688,7 @@ export function createCorpusApi(
570
688
  void documentPage; // total count is per-source below; kept for the empty-corpus case
571
689
 
572
690
  const now = Date.now();
573
- return (connectionPage.results ?? []).map((connection) => {
691
+ return (connectionPage.results ?? []).filter(isALiveSource).map((connection) => {
574
692
  const key = String(connection.connector_key ?? connection.connectorKey ?? 'unknown');
575
693
  // THREE FACTS, READ SEPARATELY (bd startsim-1cow). This was one `??`
576
694
  // chain ending in `last_checked_at`, and central's own view says of that