@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 +1 -1
- package/src/__tests__/connections.test.tsx +176 -0
- package/src/__tests__/corpus-extract.test.ts +22 -0
- package/src/__tests__/corpus-tracker.test.tsx +2 -0
- package/src/components/pages/ConnectionsPage.tsx +229 -11
- package/src/components/pages/types.ts +25 -0
- package/src/corpus/extract.ts +30 -3
- package/src/corpus/types.ts +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// account connected before the picker existed
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
518
|
+
api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
|
|
384
519
|
}: {
|
|
385
520
|
api: ConnectionsApi;
|
|
386
521
|
foundrySlug: string;
|
|
387
522
|
connection: Connection;
|
|
388
|
-
|
|
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;
|
package/src/corpus/extract.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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,
|
package/src/corpus/types.ts
CHANGED
|
@@ -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: {
|
|
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. */
|