@startsimpli/integrations 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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",
@@ -204,6 +204,7 @@ function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
204
204
  }),
205
205
  connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
206
206
  updateConnection: vi.fn().mockResolvedValue(CONNECTED.results[0]),
207
+ rotateCredential: vi.fn().mockResolvedValue(CONNECTED.results[0]),
207
208
  ...overrides,
208
209
  } as ConnectionsApi;
209
210
  }
@@ -383,6 +384,181 @@ describe('capabilities are the admin’s choice, and the page has to ask', () =>
383
384
  });
384
385
  });
385
386
 
387
+ // --------------------------------------------------------------------------
388
+ // a card an admin can actually maintain (bd startsim-768w.22.24)
389
+ // --------------------------------------------------------------------------
390
+
391
+ /**
392
+ * WHAT A CONNECTED CARD COULD DO BEFORE THIS: test it, discover its workflows,
393
+ * change its capabilities, wire and fire a target. That is the whole list. It
394
+ * could not have its KEY REPLACED and it could not be REMOVED — the two things
395
+ * an admin holding a freshly-minted API key actually needs.
396
+ *
397
+ * `POST /connections/<id>/credential/` has done a checked, non-destructive
398
+ * rotation since the endpoint was designed, and `DELETE /connections/<id>/` has
399
+ * done a transactional disconnect for just as long. `disconnect` was even
400
+ * declared on this page's own api contract. NOTHING RENDERED EITHER ONE. So an
401
+ * admin with a new key had exactly one control that looked like it would take
402
+ * one — the connect form — and using it hit a duplicate-credential collision
403
+ * that came back as a Django HTML 500 (the server half is bd
404
+ * startsim-768w.22.24 too). The missing button is the actual bug; the 500 is
405
+ * only where it surfaced.
406
+ */
407
+ describe('replacing the key on a connection that already exists', () => {
408
+ async function openReplaceForm(api = stubApi()) {
409
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
410
+ await screen.findByText('Ops n8n');
411
+ await userEvent.click(screen.getByRole('button', { name: /replace key|new key/i }));
412
+ return api;
413
+ }
414
+
415
+ it('offers a way to replace the key on a connected card', async () => {
416
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
417
+ await screen.findByText('Ops n8n');
418
+ expect(
419
+ screen.getByRole('button', { name: /replace key|new key/i }),
420
+ ).toBeInTheDocument();
421
+ });
422
+
423
+ it('renders the connector’s OWN credential fields, from the catalog row', async () => {
424
+ // Same schema-driven renderer as the connect form. A bespoke "paste your
425
+ // key" box would be the per-vendor branch this layer exists to refuse: the
426
+ // fixture connector takes a url + a secret + a select, and the replace form
427
+ // has to show the ones that are credentials.
428
+ await openReplaceForm();
429
+ expect(await screen.findByLabelText(/Public API key/i)).toBeInTheDocument();
430
+ });
431
+
432
+ it('says the current key survives a bad paste, where the admin can read it', async () => {
433
+ // The reassurance someone pasting a production key wants, and it is TRUE:
434
+ // rotation runs check() against the vendor and writes only on success, so a
435
+ // wrong key leaves the working one live. Saying nothing here is what makes
436
+ // people go looking for a "safer" path — which is the connect form.
437
+ await openReplaceForm();
438
+ expect(
439
+ screen.getByText(/before it replaces|until the new one|only if it works|nothing changes/i),
440
+ ).toBeInTheDocument();
441
+ });
442
+
443
+ it('sends the pasted key to the ROTATE endpoint, not to connect', async () => {
444
+ const api = await openReplaceForm();
445
+ await userEvent.type(screen.getByLabelText(/Public API key/i), 'n_new_key_123');
446
+ await userEvent.click(screen.getByRole('button', { name: /^replace$|test and replace/i }));
447
+
448
+ await waitFor(() => expect(api.rotateCredential).toHaveBeenCalledWith('acme', 'c1', {
449
+ api_key: 'n_new_key_123',
450
+ }));
451
+ expect(api.connect).not.toHaveBeenCalled();
452
+ });
453
+
454
+ it('reports a rejected key instead of claiming the rotation landed', async () => {
455
+ const rejected = Object.assign(new Error('Invalid API key format'), { status: 401 });
456
+ const api = stubApi({ rotateCredential: vi.fn().mockRejectedValue(rejected) });
457
+ await openReplaceForm(api);
458
+ await userEvent.type(screen.getByLabelText(/Public API key/i), 'bad');
459
+ await userEvent.click(screen.getByRole('button', { name: /^replace$|test and replace/i }));
460
+
461
+ expect(await screen.findByText(/Invalid API key format/)).toBeInTheDocument();
462
+ });
463
+
464
+ it('does not offer to replace a key when manage is false', async () => {
465
+ const api = stubApi({
466
+ connections: vi.fn().mockResolvedValue({
467
+ ...CONNECTED,
468
+ permissions: { view: true, manage: false, issue_tokens: false },
469
+ }),
470
+ });
471
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
472
+ await screen.findByText('Ops n8n');
473
+ expect(
474
+ screen.queryByRole('button', { name: /replace key|new key/i }),
475
+ ).not.toBeInTheDocument();
476
+ });
477
+ });
478
+
479
+ describe('removing a connection', () => {
480
+ it('offers disconnect on a connected card', async () => {
481
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
482
+ await screen.findByText('Ops n8n');
483
+ expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument();
484
+ });
485
+
486
+ it('CONFIRMS first, and says what stops', async () => {
487
+ // Disconnect is not an edit. It stops the scheduled fetches, stops the
488
+ // outbound sends and revokes every token issued for this account, in one
489
+ // transaction. A single misclick doing all of that silently is not a
490
+ // control an admin can use with confidence.
491
+ const api = stubApi();
492
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
493
+ await screen.findByText('Ops n8n');
494
+ await userEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
495
+
496
+ expect(api.disconnect).not.toHaveBeenCalled();
497
+ expect(screen.getByText(/stop|revoke/i)).toBeInTheDocument();
498
+ });
499
+
500
+ it('disconnects once confirmed, and refreshes the list', async () => {
501
+ const api = stubApi();
502
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
503
+ await screen.findByText('Ops n8n');
504
+ await userEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
505
+ await userEvent.click(screen.getByRole('button', { name: /yes, disconnect|confirm disconnect/i }));
506
+
507
+ await waitFor(() => expect(api.disconnect).toHaveBeenCalledWith('acme', 'c1'));
508
+ // Two loads: the mount, and the one after the card changed.
509
+ await waitFor(() => expect(
510
+ (api.connections as ReturnType<typeof vi.fn>).mock.calls.length,
511
+ ).toBeGreaterThan(1));
512
+ });
513
+
514
+ it('lets an admin back out of the confirmation', async () => {
515
+ const api = stubApi();
516
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
517
+ await screen.findByText('Ops n8n');
518
+ await userEvent.click(screen.getByRole('button', { name: /^disconnect$/i }));
519
+ await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
520
+
521
+ expect(api.disconnect).not.toHaveBeenCalled();
522
+ expect(
523
+ screen.queryByRole('button', { name: /yes, disconnect|confirm disconnect/i }),
524
+ ).not.toBeInTheDocument();
525
+ });
526
+
527
+ it('does not offer disconnect when manage is false', async () => {
528
+ const api = stubApi({
529
+ connections: vi.fn().mockResolvedValue({
530
+ ...CONNECTED,
531
+ permissions: { view: true, manage: false, issue_tokens: false },
532
+ }),
533
+ });
534
+ render(<ConnectionsPage api={api} foundrySlug="acme" />);
535
+ await screen.findByText('Ops n8n');
536
+ expect(screen.queryByRole('button', { name: /disconnect/i })).not.toBeInTheDocument();
537
+ });
538
+ });
539
+
540
+ describe('the connect form knows what is already connected', () => {
541
+ it('says so, and names the card, before an admin fills the form in again', async () => {
542
+ // The server now answers a duplicate connect with a 409 that names the card
543
+ // (bd startsim-768w.22.24) instead of an IntegrityError. Saying it HERE
544
+ // costs a round trip less and, more to the point, catches the admin before
545
+ // they have pasted a production key into the wrong form.
546
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
547
+ await userEvent.click(await screen.findByRole('button', { name: /^connect$/i }));
548
+ expect(screen.getByText(/already connected/i)).toBeInTheDocument();
549
+ expect(screen.getAllByText(/Ops n8n/).length).toBeGreaterThan(1);
550
+ });
551
+
552
+ it('still allows a SECOND account on the same vendor', async () => {
553
+ // Two workspaces on one vendor are two accounts and two cards — the server
554
+ // says so in `Connection`'s own constraint. The warning is a warning, never
555
+ // a lock.
556
+ render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
557
+ await userEvent.click(await screen.findByRole('button', { name: /^connect$/i }));
558
+ expect(screen.getByRole('button', { name: /test and save/i })).toBeInTheDocument();
559
+ });
560
+ });
561
+
386
562
  describe('the catalog envelope, as the server actually ships it', () => {
387
563
  // REGRESSION. The first browser drive of this page died on
388
564
  // `(entry.credential_schema ?? []) is not iterable`, because these fixtures
@@ -431,6 +431,28 @@ describe('extractCommitments', () => {
431
431
  expect(result.created).toBeGreaterThan(0);
432
432
  });
433
433
 
434
+ it('slices a sweep so one call fits the gateway, and says where to resume', async () => {
435
+ // A deployed tenant closes a request at 60s and one call over the whole
436
+ // corpus does not finish inside it (measured on tandem — 504). The sweep is
437
+ // therefore BATCHED INTO CALLS THAT FIT, never one call per document.
438
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, documents: FIXTURE_TRACKED_DOCUMENTS });
439
+ const extractor = createFixtureExtractor();
440
+ const spy = vi.spyOn(extractor, 'extract');
441
+
442
+ const first = await extractCommitments({ scope: TANDEM, limit: 2 }, { corpus, extractor });
443
+ expect(first.documentsProcessed).toBe(2);
444
+ expect(first.totalDocuments).toBe(FIXTURE_TRACKED_DOCUMENTS.length);
445
+ expect(first.nextOffset).toBe(2);
446
+ expect(spy.mock.calls[0][0].documents).toHaveLength(2); // still N per call
447
+
448
+ const second = await extractCommitments(
449
+ { scope: TANDEM, offset: first.nextOffset!, limit: 2 },
450
+ { corpus, extractor },
451
+ );
452
+ expect(second.documentsProcessed).toBe(1);
453
+ expect(second.nextOffset).toBeNull();
454
+ });
455
+
434
456
  it('refuses outright when the session carries no workspace claim', async () => {
435
457
  const corpus = createFixtureCorpusApi({ leakScope: true, documents: FIXTURE_TRACKED_DOCUMENTS });
436
458
  const listDocuments = vi.spyOn(corpus, 'listDocuments');
@@ -196,6 +196,8 @@ describe('TrackerPage — what is outstanding', () => {
196
196
  describe('TrackerPage — running the extraction', () => {
197
197
  const RESULT = {
198
198
  documentsProcessed: 16,
199
+ totalDocuments: 16,
200
+ nextOffset: null,
199
201
  fromRules: 24,
200
202
  fromModel: 11,
201
203
  created: 30,
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * WHAT IT RENDERS, in the order the job actually happens:
11
11
  *
12
- * 1. connected accounts, each saying what it is and whether it is healthy
12
+ * 1. connected accounts, each saying what it is and whether it is healthy —
13
+ * and each MAINTAINABLE: test it, replace its key, disconnect it
13
14
  * 2. the catalog — everything connectable, including rows that cannot load
14
15
  * 3. per connection: DISCOVER what the credential reaches -> WIRE a choice ->
15
16
  * CONFIRM the derived config -> FIRE it, and the run history of both legs
@@ -18,6 +19,14 @@
18
19
  * and the CONFIRM step exists because a derived URL is a guess about the
19
20
  * vendor's own settings until a human agrees with it.
20
21
  *
22
+ * THE MAINTENANCE CONTROLS IN STEP 1 ARE NOT A DETAIL (bd startsim-768w.22.24).
23
+ * Without them a card is a dead end in both directions: an admin with a rotated
24
+ * vendor key cannot install it, and an admin who is done with a vendor cannot
25
+ * remove it. Both endpoints existed and worked; nothing rendered them, so the
26
+ * only control that looked like it would take a new key was the connect form —
27
+ * which makes a second card, not a replacement, and collided on a unique
28
+ * constraint that reached a live foundry as a Django HTML 500.
29
+ *
21
30
  * NOTHING HERE NAMES A VENDOR. Every difference between two vendors arrives as
22
31
  * data — the field schema, the capability list, the choice rows and their
23
32
  * reasons. A `if (connector === ...)` in this file would undo the DB-driven
@@ -135,12 +144,13 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
135
144
  api={api}
136
145
  foundrySlug={foundrySlug}
137
146
  connection={connection}
138
- // What the CONNECTOR declares, so the card can offer a capability
139
- // this connection does not have yet — which is the only way an
140
- // account connected before the picker existed ever gets one.
141
- declared={
142
- catalog.find((entry) => entry.key === connection.connector_key)?.capabilities
143
- }
147
+ // The CONNECTOR's own catalog row. It carries the capability list,
148
+ // so the card can offer a capability this connection does not have
149
+ // yet — the only way an account connected before the picker existed
150
+ // ever gets one — and the credential schema, so replacing the key
151
+ // renders the same schema-driven form the connect path does rather
152
+ // than a bespoke "paste your key" box per vendor.
153
+ entry={catalog.find((row) => row.key === connection.connector_key)}
144
154
  canManage={permissions.manage}
145
155
  classNames={cn}
146
156
  onChanged={reload}
@@ -156,6 +166,11 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
156
166
  api={api}
157
167
  foundrySlug={foundrySlug}
158
168
  entry={entry}
169
+ // What this foundry ALREADY has on this connector. A second vendor
170
+ // account is legitimate and stays possible; an admin who is really
171
+ // trying to install a new key is not, and they are the ones this
172
+ // form used to swallow.
173
+ existing={connections.filter((c) => c.connector_key === entry.key)}
159
174
  canManage={permissions.manage}
160
175
  classNames={cn}
161
176
  onConnected={reload}
@@ -240,11 +255,13 @@ function orderCapabilities(declared: string[], selected: Iterable<string>): stri
240
255
  // --------------------------------------------------------------------------
241
256
 
242
257
  function CatalogCard({
243
- api, foundrySlug, entry, canManage, classNames: cn, onConnected,
258
+ api, foundrySlug, entry, existing, canManage, classNames: cn, onConnected,
244
259
  }: {
245
260
  api: ConnectionsApi;
246
261
  foundrySlug: string;
247
262
  entry: ConnectorCatalogEntry;
263
+ /** Cards this foundry already holds on this connector. */
264
+ existing: Connection[];
248
265
  canManage: boolean;
249
266
  classNames: typeof DEFAULTS;
250
267
  onConnected: () => void;
@@ -325,6 +342,23 @@ function CatalogCard({
325
342
 
326
343
  {open ? (
327
344
  <div className="mt-4 space-y-3">
345
+ {/* SAID BEFORE THE KEY IS PASTED, not after the server refuses.
346
+ Connecting again makes a SECOND account, which is legitimate and
347
+ stays available — but it is almost never what an admin holding a
348
+ rotated key wants, and it was the only thing this page offered
349
+ them. The server now answers the indistinguishable case with a 409
350
+ naming the card; this catches it a round trip earlier. */}
351
+ {existing.length > 0 ? (
352
+ <p className={cn.muted} data-already-connected={entry.key}>
353
+ Already connected as{' '}
354
+ <strong className="text-gray-900">
355
+ {existing.map((c) => c.name).join(', ')}
356
+ </strong>
357
+ . To install a new key, use <em>Replace key</em> on that
358
+ connection — it is tested before it replaces the working one.
359
+ Connecting here adds a SECOND account.
360
+ </p>
361
+ ) : null}
328
362
  <CredentialSchemaForm
329
363
  fields={fields}
330
364
  values={values}
@@ -375,17 +409,119 @@ function CatalogCard({
375
409
  );
376
410
  }
377
411
 
412
+ // --------------------------------------------------------------------------
413
+ // replacing the key on a connection that already exists
414
+ // --------------------------------------------------------------------------
415
+
416
+ /**
417
+ * ROTATION, rendered from the same schema as the connect form.
418
+ *
419
+ * Two things make this safe to put in front of a non-engineer, and both are
420
+ * properties of the endpoint rather than of this component:
421
+ *
422
+ * * the pasted credential is run through `check()` against the vendor BEFORE
423
+ * anything is written, so a wrong key changes nothing and the working
424
+ * connection stays up. That is said in the copy, because someone about to
425
+ * paste a production key is entitled to know it;
426
+ * * a PARTIAL map is legal. A connector with a url + a key + a select takes
427
+ * just the key here, and the stored values fill in the rest — so replacing
428
+ * a secret never means re-typing the settings around it.
429
+ *
430
+ * Only the CREDENTIAL fields render. `config` is not a credential and is edited
431
+ * through the connection's own settings; mixing them here would make a
432
+ * rotation quietly a config change.
433
+ */
434
+ function ReplaceKeyForm({
435
+ api, foundrySlug, connection, entry, classNames: cn, onDone,
436
+ }: {
437
+ api: ConnectionsApi;
438
+ foundrySlug: string;
439
+ connection: Connection;
440
+ entry?: ConnectorCatalogEntry;
441
+ classNames: typeof DEFAULTS;
442
+ onDone: () => void;
443
+ }) {
444
+ const [values, setValues] = React.useState<Record<string, string>>({});
445
+ const [busy, setBusy] = React.useState(false);
446
+ const [error, setError] = React.useState('');
447
+
448
+ const fields = schemaFields(entry?.credential_schema);
449
+ const warnings = patternWarnings(fields, values);
450
+ // NOT `missingRequired`. A rotation is partial by design — the whole point is
451
+ // to replace one secret without re-typing the fields around it — so the gate
452
+ // is "something was actually typed", and the server decides the rest.
453
+ const submitted = React.useMemo(() => {
454
+ const out: Record<string, unknown> = {};
455
+ for (const spec of fields) {
456
+ const value = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
457
+ if (value) out[spec.key] = value;
458
+ }
459
+ return out;
460
+ }, [fields, values]);
461
+ const empty = Object.keys(submitted).length === 0;
462
+
463
+ return (
464
+ <div className="mt-3 space-y-3 border-t pt-3" data-replace-key={connection.id}>
465
+ <p className={cn.muted}>
466
+ The new key is tested against the vendor before it replaces the current
467
+ one. If it is rejected, nothing changes and this connection keeps
468
+ working on the key it has.
469
+ </p>
470
+ {fields.length === 0 ? (
471
+ <p className={cn.muted}>
472
+ This connector holds no credential of ours, so there is nothing to
473
+ replace.
474
+ </p>
475
+ ) : (
476
+ <CredentialSchemaForm
477
+ fields={fields}
478
+ values={values}
479
+ warnings={warnings}
480
+ idPrefix={`replace-${connection.id}`}
481
+ onChange={(key, value) => {
482
+ const spec = fields.find((f) => f.key === key);
483
+ setValues((prev) => ({
484
+ ...prev, [spec && isSecret(spec) ? `${key}__draft` : key]: value,
485
+ }));
486
+ }}
487
+ />
488
+ )}
489
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
490
+ <button
491
+ type="button"
492
+ className={cn.button}
493
+ disabled={busy || empty}
494
+ onClick={() => {
495
+ setBusy(true);
496
+ setError('');
497
+ api.rotateCredential(foundrySlug, connection.id, submitted)
498
+ .then(() => { setValues({}); onDone(); })
499
+ // The server's typed answer, verbatim: it already scrubbed itself of
500
+ // the values just submitted, and "the vendor rejected this key" is
501
+ // not something the browser could work out on its own.
502
+ .catch((err: unknown) => setError(
503
+ (err as { message?: string })?.message || 'Could not replace the key.'))
504
+ .finally(() => setBusy(false));
505
+ }}
506
+ >
507
+ {busy ? 'Testing…' : 'Test and replace'}
508
+ </button>
509
+ </div>
510
+ );
511
+ }
512
+
378
513
  // --------------------------------------------------------------------------
379
514
  // a connected account: discover -> wire -> confirm -> fire
380
515
  // --------------------------------------------------------------------------
381
516
 
382
517
  function ConnectionCard({
383
- api, foundrySlug, connection, declared, canManage, classNames: cn, onChanged,
518
+ api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
384
519
  }: {
385
520
  api: ConnectionsApi;
386
521
  foundrySlug: string;
387
522
  connection: Connection;
388
- declared?: string[];
523
+ /** This connector's catalog row: its capabilities AND its field schema. */
524
+ entry?: ConnectorCatalogEntry;
389
525
  canManage: boolean;
390
526
  classNames: typeof DEFAULTS;
391
527
  onChanged: () => void;
@@ -396,7 +532,10 @@ function ConnectionCard({
396
532
  const [busy, setBusy] = React.useState('');
397
533
  const [error, setError] = React.useState('');
398
534
  const [notice, setNotice] = React.useState('');
535
+ const [replacing, setReplacing] = React.useState(false);
536
+ const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
399
537
 
538
+ const declared = entry?.capabilities;
400
539
  const enabled = React.useMemo(
401
540
  () => connection.capabilities_enabled ?? [], [connection.capabilities_enabled]);
402
541
  // The catalog's declaration first, then anything this connection holds that
@@ -444,7 +583,7 @@ function ConnectionCard({
444
583
  ) : null}
445
584
  </div>
446
585
  {canManage ? (
447
- <div className="flex gap-2">
586
+ <div className="flex flex-wrap justify-end gap-2">
448
587
  <button
449
588
  type="button"
450
589
  className={cn.button}
@@ -457,6 +596,21 @@ function ConnectionCard({
457
596
  >
458
597
  Test
459
598
  </button>
599
+ {/* THE CONTROL THIS PAGE WAS MISSING. A vendor key gets rotated —
600
+ that is normal operations, not an exception — and the only place
601
+ to put the new one was a form that creates a second account. */}
602
+ <button
603
+ type="button"
604
+ className={cn.button}
605
+ disabled={Boolean(busy)}
606
+ onClick={() => {
607
+ setConfirmingDisconnect(false);
608
+ setError('');
609
+ setReplacing((v) => !v);
610
+ }}
611
+ >
612
+ {replacing ? 'Cancel' : 'Replace key'}
613
+ </button>
460
614
  <button
461
615
  type="button"
462
616
  className={cn.button}
@@ -470,10 +624,74 @@ function ConnectionCard({
470
624
  >
471
625
  {busy === 'discover' ? 'Loading…' : 'Workflows'}
472
626
  </button>
627
+ <button
628
+ type="button"
629
+ className={cn.button}
630
+ disabled={Boolean(busy) || confirmingDisconnect}
631
+ onClick={() => {
632
+ setReplacing(false);
633
+ setError('');
634
+ setConfirmingDisconnect(true);
635
+ }}
636
+ >
637
+ Disconnect
638
+ </button>
473
639
  </div>
474
640
  ) : null}
475
641
  </div>
476
642
 
643
+ {replacing ? (
644
+ <ReplaceKeyForm
645
+ api={api}
646
+ foundrySlug={foundrySlug}
647
+ connection={connection}
648
+ entry={entry}
649
+ classNames={cn}
650
+ onDone={() => { setReplacing(false); setNotice('New key installed.'); onChanged(); }}
651
+ />
652
+ ) : null}
653
+
654
+ {confirmingDisconnect ? (
655
+ <div className="mt-3 space-y-2 border-t pt-3" role="group"
656
+ aria-label="Confirm disconnect">
657
+ {/* Disconnect is one sentence to an admin and four writes to the
658
+ server, three of which cannot be undone by reconnecting: the
659
+ tokens this vendor holds against us are revoked and the stored
660
+ credential is deleted. Naming them is the difference between a
661
+ control someone uses deliberately and one they regret. */}
662
+ <p className={cn.muted}>
663
+ Disconnecting <strong className="text-gray-900">{connection.name}</strong>{' '}
664
+ will stop its scheduled fetches, stop anything it sends out, revoke
665
+ every token issued for it and delete the stored key. The card stays
666
+ here, marked disabled, and you can connect it again later with a new
667
+ key.
668
+ </p>
669
+ <div className="flex gap-2">
670
+ <button
671
+ type="button"
672
+ className={cn.button}
673
+ disabled={Boolean(busy)}
674
+ onClick={() => void guard('disconnect', async () => {
675
+ await api.disconnect(foundrySlug, connection.id);
676
+ setConfirmingDisconnect(false);
677
+ setNotice('Disconnected.');
678
+ onChanged();
679
+ })}
680
+ >
681
+ {busy === 'disconnect' ? 'Disconnecting…' : 'Yes, disconnect'}
682
+ </button>
683
+ <button
684
+ type="button"
685
+ className={cn.button}
686
+ disabled={Boolean(busy)}
687
+ onClick={() => setConfirmingDisconnect(false)}
688
+ >
689
+ Cancel
690
+ </button>
691
+ </div>
692
+ </div>
693
+ ) : null}
694
+
477
695
  {/* THE FIX FOR THE CARDS THAT ALREADY EXIST. A connection written before
478
696
  the connect form asked holds an empty list and can do nothing; the
479
697
  connect form can never reach it. Turning `poll` on here is what makes
@@ -181,6 +181,31 @@ export interface ConnectionsApi {
181
181
  checkConnection(slug: string, id: string): Promise<{
182
182
  ok: boolean; account_label: string; detail: string;
183
183
  }>;
184
+
185
+ /**
186
+ * REPLACE THE KEY on a connection that already exists.
187
+ *
188
+ * The endpoint has always done this well — the new credential is run through
189
+ * `check()` against the vendor FIRST and written only if it passes, so a bad
190
+ * paste leaves the working key live and the card untouched. It was simply not
191
+ * on this interface, so the page could not reach it and an admin holding a
192
+ * fresh key had only the connect form to try (bd startsim-768w.22.24). That
193
+ * form creates a SECOND card, which is a different thing entirely and, until
194
+ * the same bead, a 500.
195
+ *
196
+ * A PARTIAL map is legal and is the normal case: send the one field being
197
+ * replaced, and the stored values fill in the rest.
198
+ */
199
+ rotateCredential(slug: string, id: string, credentials: Record<string, unknown>):
200
+ Promise<Connection>;
201
+
202
+ /**
203
+ * DISCONNECT, not delete. One transaction stops the scheduled fetches, stops
204
+ * the outbound sends, revokes every token issued for this account and deletes
205
+ * the stored credential. The card survives, marked `disabled`, so the record
206
+ * that this vendor was ever connected is not thrown away — and it can be
207
+ * connected again later.
208
+ */
184
209
  disconnect(slug: string, id: string): Promise<void>;
185
210
  connectionChoices(slug: string, id: string, params?: {
186
211
  kind?: string; cursor?: string;
@@ -235,6 +235,17 @@ export interface ExtractRequest {
235
235
  scope: CorpusScope;
236
236
  /** Narrow the run to specific records. Omit to sweep the corpus. */
237
237
  documentIds?: string[];
238
+ /**
239
+ * Where this call starts in the in-scope corpus, and how many documents it
240
+ * may take. Omit both to do the whole thing in one model call.
241
+ *
242
+ * This exists for a deployment fact, not a design preference: the tandem ALB
243
+ * closes a request at 60 seconds, and one batched call over all sixteen real
244
+ * documents does not finish inside it (measured — 504). Slicing keeps every
245
+ * call batched over N documents while letting the caller walk the corpus.
246
+ */
247
+ offset?: number;
248
+ limit?: number;
238
249
  }
239
250
 
240
251
  export interface ExtractDeps {
@@ -258,12 +269,24 @@ export async function extractCommitments(
258
269
 
259
270
  const { documents: candidates, matched } = await readDocuments(corpus, documentLimit, maxPages);
260
271
  const { kept, dropped } = enforceScope(candidates, request.scope);
261
- const documents = request.documentIds?.length
272
+ const inScope = request.documentIds?.length
262
273
  ? kept.filter((document) => request.documentIds!.includes(document.id))
263
274
  : kept;
264
275
 
276
+ // The slice this call handles. `listDocuments` orders stably (the tenant's
277
+ // (org_id, entity_type, -created_at) index), so an offset means the same thing
278
+ // between calls of one sweep.
279
+ const offset = Math.max(0, request.offset ?? 0);
280
+ const documents = request.limit ? inScope.slice(offset, offset + request.limit) : inScope.slice(offset);
281
+ const consumed = offset + documents.length;
282
+ const nextOffset = consumed < inScope.length ? consumed : null;
283
+
265
284
  if (documents.length === 0) {
266
- return { ...empty(extractor, 'nothing in scope'), droppedOutOfScope: dropped.length };
285
+ return {
286
+ ...empty(extractor, 'nothing in scope'),
287
+ totalDocuments: inScope.length,
288
+ droppedOutOfScope: dropped.length,
289
+ };
267
290
  }
268
291
 
269
292
  // 3. rules first, so the model can be told what not to repeat.
@@ -317,6 +340,8 @@ export async function extractCommitments(
317
340
 
318
341
  return {
319
342
  documentsProcessed: documents.length,
343
+ totalDocuments: inScope.length,
344
+ nextOffset,
320
345
  fromRules: ruleDrafts.length,
321
346
  fromModel: modelDrafts.length,
322
347
  created: reconciled.created,
@@ -325,8 +350,8 @@ export async function extractCommitments(
325
350
  needsReview: resolutions.needsReview,
326
351
  droppedOutOfScope: dropped.length,
327
352
  provider: extractor.describe(),
328
- ...(matched > candidates.length ? { } : {}),
329
353
  };
354
+ void matched;
330
355
  }
331
356
 
332
357
  /**
@@ -424,6 +449,8 @@ function empty(extractor: CorpusExtractorLlm, _why: string): ExtractionResult {
424
449
  void _why;
425
450
  return {
426
451
  documentsProcessed: 0,
452
+ totalDocuments: 0,
453
+ nextOffset: null,
427
454
  fromRules: 0,
428
455
  fromModel: 0,
429
456
  created: 0,
@@ -565,12 +565,30 @@ export interface CommitmentTrackerApi extends CommitmentApi {
565
565
  * Run extraction over documents that have not been processed. Optional: a
566
566
  * read-only deployment omits it and the control does not render.
567
567
  */
568
- extract?(input: { scope: CorpusScope; documentIds?: string[] }): Promise<ExtractionResult>;
568
+ extract?(input: {
569
+ scope: CorpusScope;
570
+ documentIds?: string[];
571
+ /** Resume point for a batched sweep. See `ExtractionResult.nextOffset`. */
572
+ offset?: number;
573
+ limit?: number;
574
+ }): Promise<ExtractionResult>;
569
575
  }
570
576
 
571
577
  export interface ExtractionResult {
572
- /** Documents read and sent to the extractor. */
578
+ /** Documents read and sent to the extractor IN THIS CALL. */
573
579
  documentsProcessed: number;
580
+ /** Documents in scope altogether, so a partial sweep can say what it is of. */
581
+ totalDocuments: number;
582
+ /**
583
+ * Where a resumed sweep should start, or `null` when the corpus is done.
584
+ *
585
+ * A run is RESUMABLE because it has to be: a deployed tenant sits behind a
586
+ * gateway with a request timeout (60s on the tandem ALB, measured — a single
587
+ * call over sixteen documents 504s), and one model call per document is
588
+ * forbidden. So the sweep is batched into calls that fit, and the caller walks
589
+ * the offsets. Still N documents per model call; just not always all of them.
590
+ */
591
+ nextOffset: number | null;
574
592
  /** Items the vendor's own structure produced, with no model involved. */
575
593
  fromRules: number;
576
594
  /** Items only the model found. */