@startsimpli/integrations 0.1.3 → 0.1.4

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.1.3",
3
+ "version": "0.1.4",
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",
@@ -15,7 +15,7 @@
15
15
  * theme classNames.
16
16
  */
17
17
  import { describe, expect, it, vi } from 'vitest';
18
- import { render, screen, waitFor } from '@testing-library/react';
18
+ import { render, screen, waitFor, within } from '@testing-library/react';
19
19
  import userEvent from '@testing-library/user-event';
20
20
 
21
21
  import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
@@ -203,6 +203,7 @@ function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
203
203
  run_status: 'running', external_ref: null, output: {}, error: '',
204
204
  }),
205
205
  connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
206
+ updateConnection: vi.fn().mockResolvedValue(CONNECTED.results[0]),
206
207
  ...overrides,
207
208
  } as ConnectionsApi;
208
209
  }
@@ -268,6 +269,120 @@ describe('ConnectionsPage — injected, not contextual', () => {
268
269
  });
269
270
  });
270
271
 
272
+ // --------------------------------------------------------------------------
273
+ // the capability choice (bd startsim-768w.22.22)
274
+ // --------------------------------------------------------------------------
275
+
276
+ /**
277
+ * WHAT WENT WRONG ON PRODUCTION. An admin connected Granola on the `tandem`
278
+ * foundry through this very page. The card went green — `check()` really
279
+ * reached the vendor — and `capabilities_enabled` was stored as `[]`. With an
280
+ * empty list NO capability is enabled: not `poll`, not `action`, not
281
+ * `webhook_in`. So `poll_is_enabled()` answered false, no scheduled ingest leg
282
+ * was ever created, and the card sat there connected and completely inert.
283
+ *
284
+ * The server was not the liar. `POST /connections/` has accepted
285
+ * `capabilities_enabled[]` since the endpoint was designed, and
286
+ * `PATCH /connections/<id>/` has accepted edits to it and created the poll leg
287
+ * on the spot. THIS PAGE never sent either one — there was no control for it
288
+ * anywhere, at connect time or after — so the server received nothing, wrote
289
+ * nothing, and the admin had no way to say what they wanted.
290
+ *
291
+ * Both halves are asserted here: the choice is offered when connecting, and it
292
+ * stays changeable afterwards, because the connections that already exist
293
+ * cannot be fixed by anything the connect form does.
294
+ */
295
+ describe('capabilities are the admin’s choice, and the page has to ask', () => {
296
+ /** The capability picker inside ONE card. Both surfaces render one. */
297
+ function picker(scope: HTMLElement, which: 'catalog' | 'connection') {
298
+ const group = scope.querySelector<HTMLElement>(`[data-capabilities="${which}"]`);
299
+ if (!group) throw new Error(`no ${which} capability picker rendered`);
300
+ return within(group);
301
+ }
302
+
303
+ async function openConnectForm() {
304
+ const api = stubApi();
305
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
306
+ // The catalog's available row. The unavailable one offers no button at all.
307
+ await userEvent.click(await screen.findByRole('button', { name: /^connect$/i }));
308
+ return { api, container };
309
+ }
310
+
311
+ it('offers every capability the connector declares, from the catalog row', async () => {
312
+ const { container } = await openConnectForm();
313
+ // The n8n fixture declares action + poll. Both are offered; nothing else is.
314
+ const boxes = picker(container, 'catalog').getAllByRole('checkbox');
315
+ expect(boxes.map((b) => b.getAttribute('value')).sort()).toEqual(['action', 'poll']);
316
+ for (const box of boxes) expect(box).toBeChecked();
317
+ });
318
+
319
+ it('SENDS the capability list on connect instead of leaving the card inert', async () => {
320
+ const { api } = await openConnectForm();
321
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
322
+ await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
323
+
324
+ await waitFor(() => expect(api.connect).toHaveBeenCalled());
325
+ const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
326
+ expect(sent.capabilities_enabled).toEqual(['action', 'poll']);
327
+ });
328
+
329
+ it('honours an admin who unchecks one before saving', async () => {
330
+ const { api, container } = await openConnectForm();
331
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
332
+ await userEvent.click(picker(container, 'catalog').getByRole('checkbox', { name: /action/i }));
333
+ await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
334
+
335
+ await waitFor(() => expect(api.connect).toHaveBeenCalled());
336
+ const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
337
+ expect(sent.capabilities_enabled).toEqual(['poll']);
338
+ });
339
+
340
+ it('will not let an admin save a connection that can do nothing', async () => {
341
+ const { container } = await openConnectForm();
342
+ await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
343
+ for (const box of picker(container, 'catalog').getAllByRole('checkbox')) {
344
+ await userEvent.click(box);
345
+ }
346
+ // The server refuses an empty list with a 400 (bd startsim-768w.22.22);
347
+ // refusing it here means the admin finds out before a round trip.
348
+ expect(screen.getByRole('button', { name: /test and save/i })).toBeDisabled();
349
+ });
350
+
351
+ it('lets an admin turn a capability ON for a connection that already exists', async () => {
352
+ // THE UNBLOCK. The `tandem` Granola card is already there with an empty
353
+ // list; nothing the connect form does can reach it. `Ops n8n` here has only
354
+ // `action` enabled, and the connector declares `poll` too.
355
+ const api = stubApi();
356
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
357
+ await screen.findByText('Ops n8n');
358
+
359
+ const on = picker(container, 'connection');
360
+ const poll = on.getByRole('checkbox', { name: /poll/i });
361
+ expect(poll).not.toBeChecked();
362
+ await userEvent.click(poll);
363
+ await userEvent.click(on.getByRole('button', { name: /save/i }));
364
+
365
+ await waitFor(() => expect(api.updateConnection).toHaveBeenCalledWith('acme', 'c1', {
366
+ capabilities_enabled: ['action', 'poll'],
367
+ }));
368
+ });
369
+
370
+ it('shows the capability state read-only when manage is false', async () => {
371
+ const api = stubApi({
372
+ connections: vi.fn().mockResolvedValue({
373
+ ...CONNECTED,
374
+ permissions: { view: true, manage: false, issue_tokens: false },
375
+ }),
376
+ });
377
+ const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
378
+ await screen.findByText('Ops n8n');
379
+
380
+ const on = picker(container, 'connection');
381
+ expect(on.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
382
+ for (const box of on.getAllByRole('checkbox')) expect(box).toBeDisabled();
383
+ });
384
+ });
385
+
271
386
  describe('the catalog envelope, as the server actually ships it', () => {
272
387
  // REGRESSION. The first browser drive of this page died on
273
388
  // `(entry.credential_schema ?? []) is not iterable`, because these fixtures
@@ -60,6 +60,21 @@ const DEFAULTS = {
60
60
  error: 'text-sm text-red-600',
61
61
  };
62
62
 
63
+ /**
64
+ * WHAT AN ADMIN IS AGREEING TO, in their words rather than the wire's.
65
+ *
66
+ * Data, not a branch: an unknown key falls through to the key itself, because
67
+ * the capability vocabulary lives in the connector package and will outrun this
68
+ * bundle exactly the way the credential schema does. A capability this build has
69
+ * never heard of must render as a choice, not vanish from the list.
70
+ */
71
+ const CAPABILITY_COPY: Record<string, string> = {
72
+ poll: 'Fetch new records on a schedule',
73
+ webhook_in: 'Receive events pushed to us',
74
+ action: 'Let us run actions in this account',
75
+ writeback_credential: 'Issue a token this vendor uses to call us',
76
+ };
77
+
63
78
  /** A refusal must read as a refusal, not as an empty page. */
64
79
  function readFailureMessage(err: unknown): string {
65
80
  const status = (err as { status?: number })?.status;
@@ -120,6 +135,12 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
120
135
  api={api}
121
136
  foundrySlug={foundrySlug}
122
137
  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
+ }
123
144
  canManage={permissions.manage}
124
145
  classNames={cn}
125
146
  onChanged={reload}
@@ -145,6 +166,75 @@ export function ConnectionsPage({ api, foundrySlug, classNames = {} }: Connectio
145
166
  );
146
167
  }
147
168
 
169
+ // --------------------------------------------------------------------------
170
+ // the capability choice (bd startsim-768w.22.22)
171
+ // --------------------------------------------------------------------------
172
+
173
+ /**
174
+ * WHICH VERBS THIS ACCOUNT IS ALLOWED — the control this page did not have.
175
+ *
176
+ * `POST /connections/` and `PATCH /connections/<id>/` have both accepted
177
+ * `capabilities_enabled[]` since the endpoints were designed. Nothing here ever
178
+ * sent one, so every connection made through the product was stored with an
179
+ * EMPTY list, and an empty list enables nothing: the card goes green on a real
180
+ * `check()` and can then do none of the things it advertises. That is how a
181
+ * connected Granola account on a live foundry ingested nothing forever.
182
+ *
183
+ * Rendered from `entry.capabilities` — the connector's own declaration, off the
184
+ * catalog row — so a new vendor's verbs appear here with no code change, and a
185
+ * verb this build has never heard of still renders.
186
+ */
187
+ function CapabilityPicker({
188
+ scope, declared, selected, disabled, idPrefix, onToggle, classNames: cn, children,
189
+ }: {
190
+ scope: 'catalog' | 'connection';
191
+ declared: string[];
192
+ selected: string[];
193
+ disabled: boolean;
194
+ idPrefix: string;
195
+ onToggle: (capability: string, on: boolean) => void;
196
+ classNames: typeof DEFAULTS;
197
+ /** Whatever COMMITS the choice — inline, so the control and the thing that
198
+ saves it are one region for a screen reader and for a test. */
199
+ children?: React.ReactNode;
200
+ }) {
201
+ if (declared.length === 0) return null;
202
+ return (
203
+ <fieldset data-capabilities={scope} className="mt-3 space-y-1 border-0 p-0">
204
+ <legend className="text-sm font-medium text-gray-900">What this account may do</legend>
205
+ {declared.map((capability) => {
206
+ const id = `${idPrefix}-cap-${capability}`;
207
+ return (
208
+ <label key={capability} htmlFor={id} className="flex items-start gap-2 text-sm">
209
+ <input
210
+ id={id}
211
+ type="checkbox"
212
+ value={capability}
213
+ className="mt-0.5"
214
+ checked={selected.includes(capability)}
215
+ disabled={disabled}
216
+ onChange={(event) => onToggle(capability, event.target.checked)}
217
+ />
218
+ <span>
219
+ <span className="text-gray-900">{capability}</span>
220
+ <span className={`ml-2 ${cn.muted}`}>
221
+ {CAPABILITY_COPY[capability] ?? ''}
222
+ </span>
223
+ </span>
224
+ </label>
225
+ );
226
+ })}
227
+ {children}
228
+ </fieldset>
229
+ );
230
+ }
231
+
232
+ /** Declaration order is the render order, and the wire order. */
233
+ function orderCapabilities(declared: string[], selected: Iterable<string>): string[] {
234
+ const set = new Set(selected);
235
+ return declared.filter((capability) => set.has(capability));
236
+ }
237
+
148
238
  // --------------------------------------------------------------------------
149
239
  // catalog
150
240
  // --------------------------------------------------------------------------
@@ -163,6 +253,13 @@ function CatalogCard({
163
253
  const [values, setValues] = React.useState<Record<string, string>>({});
164
254
  const [busy, setBusy] = React.useState(false);
165
255
  const [error, setError] = React.useState('');
256
+ // Every declared capability starts ON. Not because "on" is a safe default in
257
+ // general, but because the alternative here is a connection that can do
258
+ // nothing — and the list is right there, unchecked with one click, before
259
+ // anything is saved. The privacy control that is genuinely opt-in lives one
260
+ // level down (a connector's own config, e.g. whether a poll fetches meeting
261
+ // transcript bytes at all) and is unaffected by this box.
262
+ const [capabilities, setCapabilities] = React.useState<string[]>(entry.capabilities);
166
263
 
167
264
  const credentialFields = schemaFields(entry.credential_schema);
168
265
  const configFields = schemaFields(entry.config_schema);
@@ -186,9 +283,12 @@ function CatalogCard({
186
283
  }
187
284
  await api.connect(foundrySlug, {
188
285
  connector_key: entry.key, name: entry.name, credentials, config,
286
+ // Always sent. Leaving it off is what wrote the inert cards.
287
+ capabilities_enabled: orderCapabilities(entry.capabilities, capabilities),
189
288
  });
190
289
  setOpen(false);
191
290
  setValues({});
291
+ setCapabilities(entry.capabilities);
192
292
  onConnected();
193
293
  } catch (err) {
194
294
  // The server's typed answer, verbatim. It already scrubbed itself of the
@@ -237,11 +337,34 @@ function CatalogCard({
237
337
  }));
238
338
  }}
239
339
  />
340
+ <CapabilityPicker
341
+ scope="catalog"
342
+ declared={entry.capabilities}
343
+ selected={capabilities}
344
+ disabled={busy}
345
+ idPrefix={`connect-${entry.key}`}
346
+ onToggle={(capability, on) => setCapabilities((prev) => (
347
+ on ? [...prev, capability] : prev.filter((c) => c !== capability)
348
+ ))}
349
+ classNames={cn}
350
+ />
351
+ {/* The server refuses an empty list with a 400 rather than storing a
352
+ card that can do nothing. Saying so here costs the admin a round
353
+ trip instead of a confusing error. */}
354
+ {entry.capabilities.length > 0 && capabilities.length === 0 ? (
355
+ <p className={cn.error}>
356
+ Enable at least one — a connection with nothing enabled can do nothing.
357
+ </p>
358
+ ) : null}
240
359
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
241
360
  <button
242
361
  type="button"
243
362
  className={cn.button}
244
- disabled={busy || missing.length > 0}
363
+ disabled={
364
+ busy
365
+ || missing.length > 0
366
+ || (entry.capabilities.length > 0 && capabilities.length === 0)
367
+ }
245
368
  onClick={() => void submit()}
246
369
  >
247
370
  {busy ? 'Testing…' : 'Test and save'}
@@ -257,11 +380,12 @@ function CatalogCard({
257
380
  // --------------------------------------------------------------------------
258
381
 
259
382
  function ConnectionCard({
260
- api, foundrySlug, connection, canManage, classNames: cn, onChanged,
383
+ api, foundrySlug, connection, declared, canManage, classNames: cn, onChanged,
261
384
  }: {
262
385
  api: ConnectionsApi;
263
386
  foundrySlug: string;
264
387
  connection: Connection;
388
+ declared?: string[];
265
389
  canManage: boolean;
266
390
  classNames: typeof DEFAULTS;
267
391
  onChanged: () => void;
@@ -273,6 +397,26 @@ function ConnectionCard({
273
397
  const [error, setError] = React.useState('');
274
398
  const [notice, setNotice] = React.useState('');
275
399
 
400
+ const enabled = React.useMemo(
401
+ () => connection.capabilities_enabled ?? [], [connection.capabilities_enabled]);
402
+ // The catalog's declaration first, then anything this connection holds that
403
+ // the catalog no longer offers. A capability an admin cannot SEE is one they
404
+ // cannot turn off, and a catalog row that will not load must not blank the
405
+ // card's own state.
406
+ const offered = React.useMemo(() => {
407
+ const seen = new Set<string>();
408
+ return [...(declared ?? []), ...enabled].filter((capability) => {
409
+ if (seen.has(capability)) return false;
410
+ seen.add(capability);
411
+ return true;
412
+ });
413
+ }, [declared, enabled]);
414
+ const [selected, setSelected] = React.useState<string[]>(enabled);
415
+ React.useEffect(() => { setSelected(enabled); }, [enabled]);
416
+
417
+ const pending = orderCapabilities(offered, selected);
418
+ const dirty = pending.join(',') !== orderCapabilities(offered, enabled).join(',');
419
+
276
420
  async function guard(label: string, run: () => Promise<void>) {
277
421
  setBusy(label);
278
422
  setError('');
@@ -330,6 +474,46 @@ function ConnectionCard({
330
474
  ) : null}
331
475
  </div>
332
476
 
477
+ {/* THE FIX FOR THE CARDS THAT ALREADY EXIST. A connection written before
478
+ the connect form asked holds an empty list and can do nothing; the
479
+ connect form can never reach it. Turning `poll` on here is what makes
480
+ the server create the scheduled ingest leg. */}
481
+ <CapabilityPicker
482
+ scope="connection"
483
+ declared={offered}
484
+ selected={selected}
485
+ disabled={!canManage || Boolean(busy)}
486
+ idPrefix={`connection-${connection.id}`}
487
+ onToggle={(capability, on) => setSelected((prev) => (
488
+ on ? [...prev, capability] : prev.filter((c) => c !== capability)
489
+ ))}
490
+ classNames={cn}
491
+ >
492
+ {canManage ? (
493
+ <div className="mt-2 flex items-center gap-3">
494
+ <button
495
+ type="button"
496
+ className={cn.button}
497
+ disabled={!dirty || pending.length === 0 || Boolean(busy)}
498
+ onClick={() => void guard('capabilities', async () => {
499
+ await api.updateConnection(foundrySlug, connection.id, {
500
+ capabilities_enabled: pending,
501
+ });
502
+ setNotice('Saved.');
503
+ onChanged();
504
+ })}
505
+ >
506
+ {busy === 'capabilities' ? 'Saving…' : 'Save'}
507
+ </button>
508
+ {pending.length === 0 ? (
509
+ <span className={cn.error}>
510
+ Enable at least one, or disconnect the account.
511
+ </span>
512
+ ) : null}
513
+ </div>
514
+ ) : null}
515
+ </CapabilityPicker>
516
+
333
517
  {notice ? <p className={cn.muted}>{notice}</p> : null}
334
518
  {error ? <p className={cn.error} role="alert">{error}</p> : null}
335
519
 
@@ -156,6 +156,27 @@ export interface ConnectionsApi {
156
156
  name?: string;
157
157
  credentials?: Record<string, unknown>;
158
158
  config?: Record<string, unknown>;
159
+ /**
160
+ * WHICH VERBS THIS ACCOUNT IS ALLOWED. Omitting it used to leave the server
161
+ * to write `[]`, which enables nothing at all — a green card that cannot
162
+ * poll, cannot be invoked and cannot receive a webhook (bd
163
+ * startsim-768w.22.22). The composer always sends it now, and the server
164
+ * refuses an empty list rather than storing the inert card.
165
+ */
166
+ capabilities_enabled?: string[];
167
+ }): Promise<Connection>;
168
+
169
+ /**
170
+ * Edit a connection in place. The only field this page changes today is
171
+ * `capabilities_enabled`, and it must be changeable AFTER connect: every
172
+ * connection that already exists was written before the connect form asked,
173
+ * so nothing the connect form does can reach them. Turning `poll` on here is
174
+ * what creates the scheduled ingest leg, server-side, in the same request.
175
+ */
176
+ updateConnection(slug: string, id: string, input: {
177
+ name?: string;
178
+ capabilities_enabled?: string[];
179
+ config?: Record<string, unknown>;
159
180
  }): Promise<Connection>;
160
181
  checkConnection(slug: string, id: string): Promise<{
161
182
  ok: boolean; account_label: string; detail: string;