@startsimpli/integrations 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * THE CLIENT CORPUS — ingestion, term extraction, memory, isolation
3
+ * (bd startsim-ppfl, RED).
4
+ *
5
+ * The bead asks for four things and this file is all four:
6
+ *
7
+ * - client documents ingest, and terminology is extracted with the right
8
+ * candidates surfaced FOR HUMAN CONFIRMATION — "an unconfirmed
9
+ * auto-extracted term must not silently become authoritative";
10
+ * - an approved translation pair becomes a memory entry and is hit on a
11
+ * matching later segment;
12
+ * - an accepted human edit becomes a new entry;
13
+ * - one client's corpus is unreachable from another client's tenant.
14
+ *
15
+ * THE ISOLATION CASES ARE THE POINT OF THE LANE. `startsim-hopl` sells "one
16
+ * client's material never appears against another's", and a glossary term is
17
+ * the sharpest possible violation of it: short, memorable, and it would surface
18
+ * INSIDE ANOTHER CLIENT'S TRANSLATED DOCUMENT, in their own language, looking
19
+ * entirely legitimate.
20
+ *
21
+ * Every case here runs against records deliberately mixed across two orgs —
22
+ * the same posture `corpus-scope.test.ts` already takes, which assumes the query
23
+ * layer DOES NOT hold the line and proves the output is safe anyway.
24
+ */
25
+
26
+ import { describe, expect, it } from 'vitest';
27
+
28
+ import { FIXTURE_ORG, FIXTURE_OTHER_ORG } from '../corpus/fixtures';
29
+ import {
30
+ TRANSLATION_TYPE_KEYS,
31
+ applyTermVerdict,
32
+ exactMemoryHit,
33
+ extractTermCandidates,
34
+ fuzzyMemoryMatches,
35
+ glossaryForSegment,
36
+ ingestForTranslation,
37
+ memoryUnitFromReviewerEdit,
38
+ normalizeForMemory,
39
+ readAllGlossaryTerms,
40
+ scopedMemory,
41
+ scopedTerms,
42
+ type GlossaryTermRecord,
43
+ type MemoryUnitRecord,
44
+ type TranslationCorpusApi,
45
+ } from '../corpus/translation';
46
+ import type { CorpusPage, CorpusScope, SourceDocument } from '../corpus/types';
47
+
48
+ const TANDEM: CorpusScope = { orgId: FIXTURE_ORG };
49
+ const NORTHWIND: CorpusScope = { orgId: FIXTURE_OTHER_ORG };
50
+
51
+ const EN_AR = { sourceLocale: 'en', targetLocale: 'ar' };
52
+
53
+ function doc(
54
+ id: string,
55
+ body: string,
56
+ scope: CorpusScope = TANDEM,
57
+ title = 'Client document',
58
+ ): SourceDocument {
59
+ return {
60
+ id,
61
+ title,
62
+ body,
63
+ sections: [],
64
+ occurredAt: '2026-03-01T00:00:00.000Z',
65
+ source: 'upload',
66
+ sourceLabel: 'Upload',
67
+ sourceRef: id,
68
+ participants: [],
69
+ scope,
70
+ };
71
+ }
72
+
73
+ function term(
74
+ over: Partial<GlossaryTermRecord> & Pick<GlossaryTermRecord, 'sourceTerm'>,
75
+ ): GlossaryTermRecord {
76
+ return {
77
+ id: `term-${over.sourceTerm}`,
78
+ sourceLocale: 'en',
79
+ targetLocale: 'ar',
80
+ targetTerm: 'اتفاقية التسوية',
81
+ status: 'approved',
82
+ occurrences: 3,
83
+ documentIds: ['doc-1'],
84
+ scope: TANDEM,
85
+ ...over,
86
+ };
87
+ }
88
+
89
+ function unit(over: Partial<MemoryUnitRecord> & Pick<MemoryUnitRecord, 'sourceText'>): MemoryUnitRecord {
90
+ return {
91
+ id: `unit-${over.sourceText.slice(0, 12)}`,
92
+ sourceLocale: 'en',
93
+ targetLocale: 'ar',
94
+ targetText: 'نص تجريبي',
95
+ sourceHash: over.sourceText.trim().toLowerCase(),
96
+ origin: 'reviewer_edit',
97
+ approved: true,
98
+ scope: TANDEM,
99
+ ...over,
100
+ };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe('ingestion', () => {
106
+ it('writes rows of the translation types and NOTHING else', () => {
107
+ // A connector that quietly invents a sixth entity_type is a schema change
108
+ // nobody reviewed. Generic envelope, declared types, no surprises.
109
+ const writes = ingestForTranslation(doc('doc-1', 'The settlement agreement is enclosed.'), {
110
+ scope: TANDEM,
111
+ });
112
+
113
+ const types = new Set(writes.map((w) => w.entity_type));
114
+ for (const type of types) {
115
+ expect(Object.values(TRANSLATION_TYPE_KEYS)).toContain(type);
116
+ }
117
+ expect(writes.length).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('carries an external_id on every row, so a re-ingest converges instead of duplicating', () => {
121
+ const writes = ingestForTranslation(doc('doc-1', 'The settlement agreement is enclosed.'), {
122
+ scope: TANDEM,
123
+ });
124
+
125
+ for (const write of writes) expect(write.external_id).toBeTruthy();
126
+ expect(new Set(writes.map((w) => w.external_id)).size).toBe(writes.length);
127
+ });
128
+ });
129
+
130
+ describe('term extraction proposes, it does not decide', () => {
131
+ // Planted terms repeat; the noise does not. That is the whole signal, and it
132
+ // is why `minOccurrences` is a parameter rather than a constant.
133
+ const documents = [
134
+ doc(
135
+ 'doc-1',
136
+ [
137
+ 'The settlement agreement was signed on Tuesday.',
138
+ 'A settlement agreement binds both parties.',
139
+ 'Northwind Logistics accepted the settlement agreement.',
140
+ 'Force majeure applies to Northwind Logistics only.',
141
+ 'The force majeure clause was invoked.',
142
+ 'Somebody left a blue umbrella in the lobby.',
143
+ ].join(' '),
144
+ ),
145
+ doc('doc-2', 'Northwind Logistics confirmed the force majeure position.'),
146
+ ];
147
+
148
+ const options = { scope: TANDEM, ...EN_AR, minOccurrences: 2 };
149
+
150
+ it('yields the planted terms', () => {
151
+ const candidates = extractTermCandidates(documents, options);
152
+ const sources = candidates.map((c) => c.sourceTerm.toLowerCase());
153
+
154
+ expect(sources).toContain('settlement agreement');
155
+ expect(sources).toContain('force majeure');
156
+ expect(sources).toContain('northwind logistics');
157
+ });
158
+
159
+ it('does NOT yield the planted noise', () => {
160
+ const candidates = extractTermCandidates(documents, options);
161
+ const sources = candidates.map((c) => c.sourceTerm.toLowerCase());
162
+
163
+ // Occurs once, and is not terminology however memorable it is.
164
+ expect(sources).not.toContain('blue umbrella');
165
+ });
166
+
167
+ it('marks every extracted term a CANDIDATE, never approved, never binding', () => {
168
+ // The rule the bead puts in capitals. A glossary that fills itself is a
169
+ // glossary the client never agreed to — and adherence is measured against it.
170
+ const candidates = extractTermCandidates(documents, options);
171
+
172
+ expect(candidates.length).toBeGreaterThan(0);
173
+ for (const candidate of candidates) {
174
+ expect(candidate.status).toBe('candidate');
175
+ expect(candidate.targetTerm).toBe('');
176
+ }
177
+ });
178
+
179
+ it('never proposes a term a human already rejected', () => {
180
+ const rejected = term({ sourceTerm: 'force majeure', status: 'rejected' });
181
+
182
+ const candidates = extractTermCandidates(documents, { ...options, known: [rejected] });
183
+
184
+ expect(candidates.map((c) => c.sourceTerm.toLowerCase())).not.toContain('force majeure');
185
+ });
186
+
187
+ it('promotes a term only through an explicit verdict', () => {
188
+ const candidate = term({ sourceTerm: 'settlement agreement', status: 'candidate', targetTerm: '' });
189
+
190
+ const approved = applyTermVerdict(candidate, {
191
+ status: 'approved',
192
+ targetTerm: 'اتفاقية التسوية',
193
+ });
194
+
195
+ expect(approved.status).toBe('approved');
196
+ expect(approved.targetTerm).toBe('اتفاقية التسوية');
197
+ });
198
+ });
199
+
200
+ describe('glossary lookup', () => {
201
+ const present = term({ sourceTerm: 'settlement agreement' });
202
+ const absent = term({ sourceTerm: 'force majeure', id: 'term-fm' });
203
+ const candidate = term({
204
+ sourceTerm: 'settlement agreement',
205
+ id: 'term-candidate',
206
+ status: 'candidate',
207
+ targetTerm: '',
208
+ });
209
+
210
+ it('returns only the terms whose source form OCCURS in the segment', () => {
211
+ // The prompt scales with the text, not with the glossary.
212
+ const got = glossaryForSegment('Please review the settlement agreement.', [present, absent]);
213
+
214
+ expect(got.map((t) => t.id)).toEqual([present.id]);
215
+ });
216
+
217
+ it('never returns an unconfirmed candidate, even when it occurs', () => {
218
+ const got = glossaryForSegment('Please review the settlement agreement.', [candidate]);
219
+
220
+ expect(got).toEqual([]);
221
+ });
222
+
223
+ it('matches case-insensitively without matching a different word', () => {
224
+ expect(
225
+ glossaryForSegment('The SETTLEMENT AGREEMENT is enclosed.', [present]).map((t) => t.id),
226
+ ).toEqual([present.id]);
227
+ expect(glossaryForSegment('The settlement is enclosed.', [present])).toEqual([]);
228
+ });
229
+ });
230
+
231
+ describe('translation memory', () => {
232
+ const stored = unit({
233
+ sourceText: 'The quarterly report is ready for your review.',
234
+ targetText: 'نص تجريبي: التقرير ربع السنوي جاهز للمراجعة.',
235
+ });
236
+
237
+ it('hits exactly after normalising whitespace and case', () => {
238
+ const hit = exactMemoryHit(
239
+ ' the QUARTERLY report is ready for your review. ',
240
+ [stored],
241
+ { scope: TANDEM, ...EN_AR },
242
+ );
243
+
244
+ expect(hit?.id).toBe(stored.id);
245
+ expect(hit?.targetText).toBe(stored.targetText);
246
+ });
247
+
248
+ it('does NOT hit after a material change', () => {
249
+ // A different number is a different sentence. Normalisation must not eat it.
250
+ const hit = exactMemoryHit(
251
+ 'The monthly report is ready for your review.',
252
+ [stored],
253
+ { scope: TANDEM, ...EN_AR },
254
+ );
255
+
256
+ expect(hit).toBeNull();
257
+ });
258
+
259
+ it('normalises whitespace and case but preserves the material content', () => {
260
+ expect(normalizeForMemory(' The Report ')).toBe(normalizeForMemory('the report'));
261
+ expect(normalizeForMemory('12 seats')).not.toBe(normalizeForMemory('13 seats'));
262
+ });
263
+
264
+ it('never serves an unapproved unit', () => {
265
+ const draft = unit({ sourceText: stored.sourceText, id: 'draft', approved: false });
266
+
267
+ expect(exactMemoryHit(stored.sourceText, [draft], { scope: TANDEM, ...EN_AR })).toBeNull();
268
+ });
269
+
270
+ it('does not hit across a language pair', () => {
271
+ const zh = unit({ sourceText: stored.sourceText, id: 'zh', targetLocale: 'zh' });
272
+
273
+ expect(
274
+ exactMemoryHit(stored.sourceText, [zh], { scope: TANDEM, sourceLocale: 'en', targetLocale: 'ar' }),
275
+ ).toBeNull();
276
+ });
277
+
278
+ it('returns fuzzy neighbours ordered by score and honours the limit', () => {
279
+ const near = unit({ sourceText: 'The quarterly report is ready for review.', id: 'near' });
280
+ const mid = unit({ sourceText: 'The quarterly report will be ready soon.', id: 'mid' });
281
+ const far = unit({ sourceText: 'Parking allocation for the London office.', id: 'far' });
282
+
283
+ const matches = fuzzyMemoryMatches(stored.sourceText, [far, mid, near], {
284
+ scope: TANDEM,
285
+ ...EN_AR,
286
+ limit: 2,
287
+ });
288
+
289
+ expect(matches).toHaveLength(2);
290
+ expect(matches[0]!.unit.id).toBe('near');
291
+ expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score);
292
+ expect(matches.map((m) => m.unit.id)).not.toContain('far');
293
+ });
294
+ });
295
+
296
+ describe('a reviewer edit becomes memory', () => {
297
+ it('creates an approved unit that a later identical segment hits exactly', () => {
298
+ // THE SINGLE TEST THAT PROVES "it learns from your material". Without it the
299
+ // reviewer corrects the same term forever, which is the upkeep trap that
300
+ // makes people abandon tools.
301
+ const source = 'Please review the settlement agreement before Friday.';
302
+ const edited = 'نص تجريبي مصحَّح: اتفاقية التسوية.';
303
+
304
+ const created = memoryUnitFromReviewerEdit(
305
+ { sourceText: source, editedTargetText: edited, ...EN_AR },
306
+ TANDEM,
307
+ );
308
+
309
+ expect(created.origin).toBe('reviewer_edit');
310
+ expect(created.approved).toBe(true);
311
+
312
+ const hit = exactMemoryHit(source, [created], { scope: TANDEM, ...EN_AR });
313
+ // BYTE-IDENTICAL. A reviewer who fixed it once must never see it wrong again.
314
+ expect(hit?.targetText).toBe(edited);
315
+ });
316
+ });
317
+
318
+ describe('isolation between clients', () => {
319
+ const mine = term({ sourceTerm: 'settlement agreement', id: 'mine', scope: TANDEM });
320
+ const theirs = term({ sourceTerm: 'settlement agreement', id: 'theirs', scope: NORTHWIND });
321
+ const myUnit = unit({ sourceText: 'Shared sentence.', id: 'my-unit', scope: TANDEM });
322
+ const theirUnit = unit({ sourceText: 'Shared sentence.', id: 'their-unit', scope: NORTHWIND });
323
+
324
+ it('does not return another tenant’s glossary term, and SAYS it dropped one', () => {
325
+ const read = scopedTerms([mine, theirs], TANDEM);
326
+
327
+ expect(read.kept.map((t) => t.id)).toEqual(['mine']);
328
+ // Non-zero and surfaced — a silent zero here is how a leak stays invisible.
329
+ expect(read.droppedOutOfScope).toBe(1);
330
+ });
331
+
332
+ it('does not return another tenant’s memory unit, and SAYS it dropped one', () => {
333
+ const read = scopedMemory([myUnit, theirUnit], TANDEM);
334
+
335
+ expect(read.kept.map((u) => u.id)).toEqual(['my-unit']);
336
+ expect(read.droppedOutOfScope).toBe(1);
337
+ });
338
+
339
+ it('never serves a foreign memory unit as an exact hit', () => {
340
+ // The worst case in the lane: a fluent, confident, correctly-formatted
341
+ // translation sourced from another customer's approved wording.
342
+ const hit = exactMemoryHit('Shared sentence.', [theirUnit], { scope: TANDEM, ...EN_AR });
343
+
344
+ expect(hit).toBeNull();
345
+ });
346
+
347
+ it('never offers a foreign unit as a fuzzy example', () => {
348
+ const matches = fuzzyMemoryMatches('Shared sentence.', [theirUnit], {
349
+ scope: TANDEM,
350
+ ...EN_AR,
351
+ limit: 5,
352
+ });
353
+
354
+ expect(matches).toEqual([]);
355
+ });
356
+
357
+ it('returns NOTHING for a principal carrying no org claim', () => {
358
+ // A missing claim must read as "nothing", never as "unscoped, so everything".
359
+ const read = scopedTerms([mine, theirs], { orgId: '' });
360
+
361
+ expect(read.kept).toEqual([]);
362
+ expect(read.droppedOutOfScope).toBe(2);
363
+ });
364
+
365
+ /**
366
+ * The RLS half of bd startsim-ppfl item 7 is NOT in this repository.
367
+ *
368
+ * It belongs to `tenant-starter`, a separate Django project with its own
369
+ * runner (`DJANGO_DEBUG=true .venv/bin/python manage.py test`, sqlite, its own
370
+ * venv — NOT the main-backend Docker stack). There is no `tenant-starter`
371
+ * checkout in this worktree, so the test cannot be written here, and writing
372
+ * a TypeScript imitation of it would be worse than leaving it undone: it would
373
+ * look like coverage of a database guarantee it does not touch.
374
+ *
375
+ * `startsim-bfem` already records this as UNVERIFIED, owner Quinn.
376
+ */
377
+ it.todo(
378
+ 'reading outside org_scope() returns nothing rather than everything — tenant-starter, not this repo',
379
+ );
380
+ });
381
+
382
+ describe('paging', () => {
383
+ /** Three pages of two. A reader that stops at the first sees a third of the glossary. */
384
+ function pagedApi(): TranslationCorpusApi {
385
+ const all = [
386
+ term({ sourceTerm: 'alpha', id: 't1' }),
387
+ term({ sourceTerm: 'bravo', id: 't2' }),
388
+ term({ sourceTerm: 'charlie', id: 't3' }),
389
+ term({ sourceTerm: 'delta', id: 't4' }),
390
+ term({ sourceTerm: 'echo', id: 't5' }),
391
+ term({ sourceTerm: 'foxtrot', id: 't6' }),
392
+ ];
393
+ const PAGE = 2;
394
+ return {
395
+ async listGlossaryTerms({ page = 1 }): Promise<CorpusPage<GlossaryTermRecord>> {
396
+ const start = (page - 1) * PAGE;
397
+ const results = all.slice(start, start + PAGE);
398
+ return {
399
+ count: all.length,
400
+ // The server ignores `pageSize`, exactly as the live tenant does.
401
+ next: start + PAGE < all.length ? `?page=${page + 1}` : null,
402
+ previous: page > 1 ? `?page=${page - 1}` : null,
403
+ results,
404
+ };
405
+ },
406
+ async listMemoryUnits(): Promise<CorpusPage<MemoryUnitRecord>> {
407
+ return { count: 0, next: null, previous: null, results: [] };
408
+ },
409
+ };
410
+ }
411
+
412
+ it('reads the glossary PAST the first page', async () => {
413
+ const read = await readAllGlossaryTerms(pagedApi(), TANDEM);
414
+
415
+ expect(read.kept).toHaveLength(6);
416
+ expect(read.kept.map((t) => t.id)).toEqual(['t1', 't2', 't3', 't4', 't5', 't6']);
417
+ });
418
+
419
+ it('scope-filters every page, not just the first', async () => {
420
+ const read = await readAllGlossaryTerms(pagedApi(), NORTHWIND);
421
+
422
+ // Every fixture term belongs to tandem, so a northwind principal gets none
423
+ // — and is TOLD that six rows were dropped rather than shown an empty
424
+ // glossary that looks like a clean read.
425
+ expect(read.kept).toEqual([]);
426
+ expect(read.droppedOutOfScope).toBe(6);
427
+ });
428
+ });
@@ -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';